iOS 14/macOS Big Sur Support

This commit is contained in:
Arkadiusz Fal
2021-11-28 15:37:55 +01:00
parent f47d8ed752
commit 37a315e75a
57 changed files with 1147 additions and 813 deletions

View File

@@ -51,7 +51,7 @@ struct FavoritesView: View {
.navigationTitle("Favorites")
#endif
#if os(macOS)
.background()
.background(Color.tertiaryBackground)
.frame(minWidth: 360)
#endif
}

View File

@@ -10,7 +10,7 @@ struct MenuCommands: Commands {
}
private var navigationMenu: some Commands {
CommandMenu("Navigation") {
CommandGroup(before: .windowSize) {
Button("Favorites") {
model.navigation?.tabSelection = .favorites
}
@@ -19,7 +19,7 @@ struct MenuCommands: Commands {
Button("Subscriptions") {
model.navigation?.tabSelection = .subscriptions
}
.disabled(!(model.accounts?.app.supportsSubscriptions ?? true))
.disabled(subscriptionsDisabled)
.keyboardShortcut("2")
Button("Popular") {
@@ -37,9 +37,17 @@ struct MenuCommands: Commands {
model.navigation?.tabSelection = .search
}
.keyboardShortcut("f")
Divider()
}
}
private var subscriptionsDisabled: Bool {
!(
(model.accounts?.app.supportsSubscriptions ?? false) && model.accounts?.signedIn ?? false
)
}
private var playbackMenu: some Commands {
CommandMenu("Playback") {
Button((model.player?.isPlaying ?? true) ? "Pause" : "Play") {

View File

@@ -1,26 +0,0 @@
import Foundation
import SwiftUI
struct UnsubscribeAlertModifier: ViewModifier {
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<SubscriptionsModel> private var subscriptions
func body(content: Content) -> some View {
content
.alert(unsubscribeAlertTitle, isPresented: $navigation.presentingUnsubscribeAlert) {
if let channel = navigation.channelToUnsubscribe {
Button("Unsubscribe", role: .destructive) {
subscriptions.unsubscribe(channel.id)
}
}
}
}
var unsubscribeAlertTitle: String {
if let channel = navigation.channelToUnsubscribe {
return "Unsubscribe from \(channel.name)"
}
return "Unknown channel"
}
}

View File

@@ -15,13 +15,25 @@ struct AccountsMenuView: View {
}
}
} label: {
Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle")
.labelStyle(.titleAndIcon)
if #available(iOS 15.0, macOS 12.0, *) {
label
.labelStyle(.titleAndIcon)
} else {
HStack {
Image(systemName: "person.crop.circle")
label
.labelStyle(.titleOnly)
}
}
}
.disabled(instances.isEmpty)
.transaction { t in t.animation = .none }
}
private var label: some View {
Label(model.current?.description ?? "Select Account", systemImage: "person.crop.circle")
}
private var allAccounts: [Account] {
accounts + instances.map(\.anonymousAccount)
}

View File

@@ -12,6 +12,7 @@ struct AppSidebarPlaylists: View {
LazyView(PlaylistVideosView(playlist))
} label: {
Label(playlist.title, systemImage: AppSidebarNavigation.symbolSystemImage(playlist.title))
.backport
.badge(Text("\(playlist.videos.count)"))
}
.id(playlist.id)

View File

@@ -18,7 +18,6 @@ struct AppSidebarSubscriptions: View {
navigation.presentUnsubscribeAlert(channel)
}
}
.modifier(UnsubscribeAlertModifier())
.id("channel\(channel.id)")
}
}

View File

@@ -41,6 +41,8 @@ struct AppTabNavigation: View {
} else {
trendingNavigationView
}
} else {
trendingNavigationView
}
} else {
if accounts.app.supportsPopular {
@@ -62,26 +64,7 @@ struct AppTabNavigation: View {
}
NavigationView {
LazyView(
SearchView()
.toolbar { toolbarContent }
.searchable(text: $search.queryText, placement: .navigationBarDrawer(displayMode: .always)) {
ForEach(search.querySuggestions.collection, id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
}
}
.onChange(of: search.queryText) { query in
search.loadSuggestions(query)
}
.onSubmit(of: .search) {
search.changeQuery { query in
query.query = search.queryText
}
recents.addQuery(search.queryText)
}
)
LazyView(SearchView())
}
.tabItem {
Label("Search", systemImage: "magnifyingglass")
@@ -129,7 +112,7 @@ struct AppTabNavigation: View {
.toolbar { toolbarContent }
}
.tabItem {
Label("Popular", systemImage: "chart.bar")
Label("Popular", systemImage: "arrow.up.right.circle")
.accessibility(label: Text("Popular"))
}
.tag(TabSelection.popular)
@@ -141,7 +124,7 @@ struct AppTabNavigation: View {
.toolbar { toolbarContent }
}
.tabItem {
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
Label("Trending", systemImage: "chart.bar")
.accessibility(label: Text("Trending"))
}
.tag(TabSelection.trending)

View File

@@ -46,7 +46,7 @@ struct Sidebar: View {
}
var mainNavigationLinks: some View {
Section("Videos") {
Section(header: Text("Videos")) {
NavigationLink(destination: LazyView(FavoritesView()), tag: TabSelection.favorites, selection: $navigation.tabSelection) {
Label("Favorites", systemImage: "heart")
.accessibility(label: Text("Favorites"))
@@ -60,13 +60,13 @@ struct Sidebar: View {
if accounts.app.supportsPopular {
NavigationLink(destination: LazyView(PopularView()), tag: TabSelection.popular, selection: $navigation.tabSelection) {
Label("Popular", systemImage: "chart.bar")
Label("Popular", systemImage: "arrow.up.right.circle")
.accessibility(label: Text("Popular"))
}
}
NavigationLink(destination: LazyView(TrendingView()), tag: TabSelection.trending, selection: $navigation.tabSelection) {
Label("Trending", systemImage: "chart.line.uptrend.xyaxis")
Label("Trending", systemImage: "chart.bar")
.accessibility(label: Text("Trending"))
}

View File

@@ -3,7 +3,8 @@ import Foundation
import SwiftUI
struct PlaybackBar: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Environment(\.presentationMode) private var presentationMode
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<PlayerModel> private var player
@@ -64,18 +65,20 @@ struct PlaybackBar: View {
Spacer()
}
}
.alert(player.playerError?.localizedDescription ?? "", isPresented: $player.presentingErrorDetails) {
Button("OK") {}
.alert(isPresented: $player.presentingErrorDetails) {
Alert(
title: Text("Error"),
message: Text(player.playerError?.localizedDescription ?? "")
)
}
.environment(\.colorScheme, .dark)
.frame(minWidth: 0, maxWidth: .infinity)
.padding(4)
.background(.black)
.background(colorScheme == .dark ? Color.black : Color.white)
}
private var closeButton: some View {
Button {
dismiss()
presentationMode.wrappedValue.dismiss()
} label: {
Label(
"Close",
@@ -105,10 +108,18 @@ struct PlaybackBar: View {
return "less than a minute"
}
let timeFinishAt = Date.now.addingTimeInterval(remainingSeconds)
let timeFinishAtString = timeFinishAt.formatted(date: .omitted, time: .shortened)
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
return "ends at \(timeFinishAtString)"
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
}
private func formattedTimeFinishAt(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .none
dateFormatter.timeStyle = .short
return dateFormatter.string(from: date)
}
private var rateMenu: some View {

View File

@@ -44,16 +44,18 @@ struct PlayerQueueView: View {
}
ForEach(player.queue) { item in
PlayerQueueRow(item: item, fullScreen: $fullScreen)
let row = PlayerQueueRow(item: item, fullScreen: $fullScreen)
.contextMenu {
removeButton(item, history: false)
removeAllButton(history: false)
}
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
row.swipeActions(edge: .trailing, allowsFullSwipe: true) {
removeButton(item, history: false)
}
#endif
} else {
row
}
}
}
}
@@ -63,14 +65,18 @@ struct PlayerQueueView: View {
if !player.history.isEmpty {
Section(header: Text("Played Previously")) {
ForEach(player.history) { item in
PlayerQueueRow(item: item, history: true, fullScreen: $fullScreen)
let row = PlayerQueueRow(item: item, history: true, fullScreen: $fullScreen)
.contextMenu {
removeButton(item, history: true)
removeAllButton(history: true)
}
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
removeButton(item, history: true)
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
row.swipeActions(edge: .trailing, allowsFullSwipe: true) {
removeButton(item, history: true)
}
} else {
row
}
#endif
}
@@ -100,28 +106,44 @@ struct PlayerQueueView: View {
}
private func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View {
Button(role: .destructive) {
if history {
player.removeHistory(item)
} else {
player.remove(item)
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
return Button(role: .destructive) {
removeButtonAction(item, history: history)
} label: {
Label("Remove", systemImage: "trash")
}
} else {
return Button {
removeButtonAction(item, history: history)
} label: {
Label("Remove", systemImage: "trash")
}
} label: {
Label("Remove", systemImage: "trash")
}
}
private func removeButtonAction(_ item: PlayerQueueItem, history: Bool) {
_ = history ? player.removeHistory(item) : player.remove(item)
}
private func removeAllButton(history: Bool) -> some View {
Button(role: .destructive) {
if history {
player.removeHistoryItems()
} else {
player.removeQueueItems()
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
return Button(role: .destructive) {
removeAllButtonAction(history: history)
} label: {
Label("Remove All", systemImage: "trash.fill")
}
} else {
return Button {
removeAllButtonAction(history: history)
} label: {
Label("Remove All", systemImage: "trash.fill")
}
} label: {
Label("Remove All", systemImage: "trash.fill")
}
}
private func removeAllButtonAction(history: Bool) {
_ = history ? player.removeHistoryItems() : player.removeQueueItems()
}
}
struct PlayerQueueView_Previews: PreviewProvider {

View File

@@ -11,14 +11,14 @@ struct VideoDetails: View {
@Binding var fullScreen: Bool
@State private var subscribed = false
@State private var confirmationShown = false
@State private var presentingUnsubscribeAlert = false
@State private var presentingAddToPlaylist = false
@State private var presentingShareSheet = false
@State private var shareURL: URL?
@State private var currentPage = Page.details
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
@Environment(\.inNavigationView) private var inNavigationView
@EnvironmentObject<AccountsModel> private var accounts
@@ -82,7 +82,7 @@ struct VideoDetails: View {
if fullScreen {
fullScreen = false
} else {
self.dismiss()
self.presentationMode.wrappedValue.dismiss()
}
}
}
@@ -184,19 +184,26 @@ struct VideoDetails: View {
Section {
if subscribed {
Button("Unsubscribe") {
confirmationShown = true
presentingUnsubscribeAlert = true
}
#if os(iOS)
.backport
.tint(.gray)
#endif
.confirmationDialog("Are you you want to unsubscribe from \(video!.channel.name)?", isPresented: $confirmationShown) {
Button("Unsubscribe") {
subscriptions.unsubscribe(video!.channel.id)
.alert(isPresented: $presentingUnsubscribeAlert) {
Alert(
title: Text(
"Are you you want to unsubscribe from \(video!.channel.name)?"
),
primaryButton: .destructive(Text("Unsubscribe")) {
subscriptions.unsubscribe(video!.channel.id)
withAnimation {
subscribed.toggle()
}
}
withAnimation {
subscribed.toggle()
}
},
secondaryButton: .cancel()
)
}
} else {
Button("Subscribe") {
@@ -206,12 +213,12 @@ struct VideoDetails: View {
subscribed.toggle()
}
}
.backport
.tint(.blue)
}
}
.font(.system(size: 13))
.buttonStyle(.borderless)
.buttonBorderShape(.roundedRectangle)
}
}
}
@@ -241,13 +248,13 @@ struct VideoDetails: View {
Text(published)
}
if let publishedAt = video.publishedAt {
if let date = video.publishedAt {
if video.publishedDate != nil {
Text("")
.foregroundColor(.secondary)
.opacity(0.3)
}
Text(publishedAt.formatted(date: .abbreviated, time: .omitted))
Text(formattedPublishedAt(date))
}
}
.font(.system(size: 12))
@@ -257,6 +264,15 @@ struct VideoDetails: View {
}
}
func formattedPublishedAt(_ date: Date) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .none
return dateFormatter.string(from: date)
}
var countsSection: some View {
Group {
if let video = player.currentVideo {
@@ -338,11 +354,17 @@ struct VideoDetails: View {
VStack(alignment: .leading, spacing: 10) {
if let description = video.description {
Text(description)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption)
.padding(.bottom, 4)
Group {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
Text(description)
.textSelection(.enabled)
} else {
Text(description)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption)
.padding(.bottom, 4)
} else {
Text("No description")
.foregroundColor(.secondary)

View File

@@ -16,8 +16,10 @@ struct VideoPlayerView: View {
@State private var playerSize: CGSize = .zero
@State private var fullScreen = false
@Environment(\.colorScheme) private var colorScheme
#if os(iOS)
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
#endif
@@ -82,11 +84,11 @@ struct VideoPlayerView: View {
fullScreen = true
}
},
down: { dismiss() }
down: { presentationMode.wrappedValue.dismiss() }
)
#endif
.background(.black)
.background(Color.black)
Group {
#if os(iOS)
@@ -98,7 +100,7 @@ struct VideoPlayerView: View {
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreen)
#endif
}
.background()
.background(colorScheme == .dark ? Color.black : Color.white)
.modifier(VideoDetailsPaddingModifier(geometry: geometry, aspectRatio: player.controller?.aspectRatio, fullScreen: fullScreen))
}
#endif

View File

@@ -7,7 +7,7 @@ struct AddToPlaylistView: View {
@State private var selectedPlaylistID: Playlist.ID = ""
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<PlaylistsModel> private var model
var body: some View {
@@ -37,7 +37,7 @@ struct AddToPlaylistView: View {
.padding(.vertical)
#elseif os(tvOS)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(.thickMaterial)
.background(Color.tertiaryBackground)
#else
.padding(.vertical)
#endif
@@ -70,7 +70,7 @@ struct AddToPlaylistView: View {
#if !os(tvOS)
Button("Cancel") {
dismiss()
presentationMode.wrappedValue.dismiss()
}
.keyboardShortcut(.cancelAction)
#endif
@@ -155,7 +155,7 @@ struct AddToPlaylistView: View {
Defaults[.lastUsedPlaylistID] = id
model.addVideo(playlistID: id, videoID: video.videoID) {
dismiss()
presentationMode.wrappedValue.dismiss()
}
}

View File

@@ -10,9 +10,7 @@ struct PlaylistFormView: View {
@State private var valid = false
@State private var showingDeleteConfirmation = false
@FocusState private var focused: Bool
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlaylistsModel> private var playlists
@@ -22,77 +20,68 @@ struct PlaylistFormView: View {
}
var body: some View {
#if os(macOS) || os(iOS)
VStack(alignment: .leading) {
HStack(alignment: .center) {
Text(editing ? "Edit Playlist" : "Create Playlist")
.font(.title2.bold())
Group {
#if os(macOS) || os(iOS)
VStack(alignment: .leading) {
HStack(alignment: .center) {
Text(editing ? "Edit Playlist" : "Create Playlist")
.font(.title2.bold())
Spacer()
Spacer()
Button("Cancel") {
dismiss()
}.keyboardShortcut(.cancelAction)
Button("Cancel") {
presentationMode.wrappedValue.dismiss()
}.keyboardShortcut(.cancelAction)
}
.padding(.horizontal)
Form {
TextField("Name", text: $name, onCommit: validate)
.frame(maxWidth: 450)
.padding(.leading, 10)
visibilityFormItem
.pickerStyle(.segmented)
}
#if os(macOS)
.padding(.horizontal)
#endif
HStack {
if editing {
deletePlaylistButton
}
Spacer()
Button("Save", action: submitForm)
.disabled(!valid)
.keyboardShortcut(.defaultAction)
}
.frame(minHeight: 35)
.padding(.horizontal)
}
.padding(.horizontal)
Form {
TextField("Name", text: $name, onCommit: validate)
.frame(maxWidth: 450)
.padding(.leading, 10)
.focused($focused)
visibilityFormItem
.pickerStyle(.segmented)
}
#if os(macOS)
.padding(.horizontal)
#if os(iOS)
.padding(.vertical)
#else
.frame(width: 400, height: 150)
#endif
HStack {
if editing {
deletePlaylistButton
}
Spacer()
Button("Save", action: submitForm)
.disabled(!valid)
.keyboardShortcut(.defaultAction)
}
.frame(minHeight: 35)
.padding(.horizontal)
}
.onChange(of: name) { _ in validate() }
.onAppear(perform: initializeForm)
#if os(iOS)
.padding(.vertical)
#else
.frame(width: 400, height: 150)
VStack {
Group {
header
form
}
.frame(maxWidth: 1000)
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(Color.tertiaryBackground)
#endif
#else
VStack {
Group {
header
form
}
.frame(maxWidth: 1000)
}
.onAppear {
guard editing else {
return
}
self.name = self.playlist.title
self.visibility = self.playlist.visibility
validate()
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(.thickMaterial)
#endif
}
.onChange(of: name) { _ in validate() }
.onAppear(perform: initializeForm)
}
#if os(tvOS)
@@ -152,16 +141,16 @@ struct PlaylistFormView: View {
#endif
func initializeForm() {
focused = true
guard editing else {
return
}
name = playlist.title
visibility = playlist.visibility
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
name = playlist.title
visibility = playlist.visibility
validate()
validate()
}
}
func validate() {
@@ -182,7 +171,7 @@ struct PlaylistFormView: View {
playlists.load(force: true)
dismiss()
presentationMode.wrappedValue.dismiss()
}
}
@@ -194,7 +183,7 @@ struct PlaylistFormView: View {
#if os(macOS)
Picker("Visibility", selection: $visibility) {
ForEach(Playlist.Visibility.allCases) { visibility in
Text(visibility.name)
Text(visibility.name).tag(visibility)
}
}
#else
@@ -216,9 +205,10 @@ struct PlaylistFormView: View {
}
var deletePlaylistButton: some View {
Button("Delete", role: .destructive) {
Button("Delete") {
showingDeleteConfirmation = true
}.alert(isPresented: $showingDeleteConfirmation) {
}
.alert(isPresented: $showingDeleteConfirmation) {
Alert(
title: Text("Are you sure you want to delete playlist?"),
message: Text("Playlist \"\(playlist.title)\" will be deleted.\nIt cannot be undone."),
@@ -226,16 +216,14 @@ struct PlaylistFormView: View {
secondaryButton: .cancel()
)
}
#if os(macOS)
.foregroundColor(.red)
#endif
}
func deletePlaylistAndDismiss() {
accounts.api.playlist(playlist.id)?.request(.delete).onSuccess { _ in
playlist = nil
playlists.load(force: true)
dismiss()
presentationMode.wrappedValue.dismiss()
}
}
}

View File

@@ -71,17 +71,20 @@ struct PlaylistsView: View {
ToolbarItemGroup {
#if !os(iOS)
if !model.isEmpty {
selectPlaylistButton
.prefersDefaultFocus(in: focusNamespace)
if #available(macOS 12.0, *) {
selectPlaylistButton
.prefersDefaultFocus(in: focusNamespace)
} else {
selectPlaylistButton
}
}
if currentPlaylist != nil {
editPlaylistButton
}
#endif
FavoriteButton(item: FavoriteItem(section: .playlist(selectedPlaylistID)))
newPlaylistButton
FavoriteButton(item: FavoriteItem(section: .playlist(selectedPlaylistID)))
}
#if os(iOS)
@@ -99,6 +102,8 @@ struct PlaylistsView: View {
Spacer()
newPlaylistButton
if currentPlaylist != nil {
editPlaylistButton
}
@@ -168,7 +173,7 @@ struct PlaylistsView: View {
}
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
#if os(macOS)
.background()
.background(Color.tertiaryBackground)
#endif
}

View File

@@ -0,0 +1,80 @@
import SwiftUI
struct SearchTextField: View {
@Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var state
var body: some View {
ZStack {
#if os(macOS)
fieldBorder
#endif
HStack(spacing: 0) {
#if os(macOS)
Image(systemName: "magnifyingglass")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 12, height: 12)
.padding(.horizontal, 8)
.opacity(0.8)
#endif
TextField("Search...", text: $state.queryText) {
state.changeQuery { query in query.query = state.queryText }
recents.addQuery(state.queryText)
}
.onChange(of: state.queryText) { _ in
if state.query.query.compare(state.queryText, options: .caseInsensitive) == .orderedSame {
state.fieldIsFocused = true
}
}
#if os(macOS)
.textFieldStyle(.plain)
#else
.textFieldStyle(.roundedBorder)
.padding(.leading)
.padding(.trailing, 15)
#endif
if !self.state.queryText.isEmpty {
clearButton
}
}
}
.padding(.top, navigationStyle == .tab ? 10 : 0)
}
private var fieldBorder: some View {
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(Color.background)
.frame(width: 250, height: 32)
.overlay(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.stroke(
state.fieldIsFocused ? Color.blue.opacity(0.7) : Color.gray.opacity(0.4),
lineWidth: state.fieldIsFocused ? 3 : 1
)
.frame(width: 250, height: 31)
)
}
private var clearButton: some View {
Button(action: {
self.state.queryText = ""
}) {
Image(systemName: "xmark.circle.fill")
.resizable()
.aspectRatio(contentMode: .fit)
#if os(macOS)
.frame(width: 14, height: 14)
#else
.frame(width: 18, height: 18)
#endif
.padding(.trailing, 3)
}
.buttonStyle(PlainButtonStyle())
.padding(.trailing, 10)
.opacity(0.7)
}
}

View File

@@ -0,0 +1,77 @@
import SwiftUI
struct SearchSuggestions: View {
@EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var state
var body: some View {
List {
Button {
state.changeQuery { query in
query.query = state.queryText
state.fieldIsFocused = false
}
recents.addQuery(state.queryText)
} label: {
HStack(spacing: 5) {
Label(state.queryText, systemImage: "magnifyingglass")
.lineLimit(1)
}
}
#if os(macOS)
.onHover(perform: onHover(_:))
#endif
ForEach(visibleSuggestions, id: \.self) { suggestion in
Button {
state.queryText = suggestion
} label: {
HStack(spacing: 0) {
Label(state.queryText, systemImage: "arrow.up.left.circle")
.lineLimit(1)
.layoutPriority(2)
.foregroundColor(.secondary)
Text(querySuffix(suggestion))
.lineLimit(1)
.layoutPriority(1)
}
}
#if os(macOS)
.onHover(perform: onHover(_:))
#endif
}
}
#if os(macOS)
.buttonStyle(.link)
#endif
}
private var visibleSuggestions: [String] {
state.querySuggestions.collection.filter {
$0.compare(state.queryText, options: .caseInsensitive) != .orderedSame
}
}
private func querySuffix(_ suggestion: String) -> String {
suggestion.replacingFirstOccurrence(of: state.queryText.lowercased(), with: "")
}
#if os(macOS)
private func onHover(_ inside: Bool) {
if inside {
NSCursor.pointingHand.push()
} else {
NSCursor.pop()
}
}
#endif
}
struct SearchSuggestions_Previews: PreviewProvider {
static var previews: some View {
SearchSuggestions()
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -9,7 +9,6 @@ struct SearchView: View {
@State private var searchDate = SearchQuery.Date.any
@State private var searchDuration = SearchQuery.Duration.any
@State private var presentingClearConfirmation = false
@State private var recentsChanged = false
#if os(tvOS)
@@ -31,50 +30,37 @@ struct SearchView: View {
state.store.collection.sorted { $0 < $1 }
}
init(_ query: SearchQuery? = nil, videos: [Video] = [Video]()) {
init(_ query: SearchQuery? = nil, videos: [Video] = []) {
self.query = query
self.videos = videos
}
var body: some View {
PlayerControlsView {
VStack {
if showRecentQueries {
recentQueries
} else {
#if os(tvOS)
ScrollView(.vertical, showsIndicators: false) {
HStack(spacing: 0) {
if accounts.app.supportsSearchFilters {
filtersHorizontalStack
}
#if os(iOS)
VStack {
SearchTextField()
if let favoriteItem = favoriteItem {
FavoriteButton(item: favoriteItem)
.id(favoriteItem.id)
.labelStyle(.iconOnly)
.font(.system(size: 25))
}
}
HorizontalCells(items: items)
}
.edgesIgnoringSafeArea(.horizontal)
#else
VerticalCells(items: items)
#endif
if noResults {
Text("No results")
if searchFiltersActive {
Button("Reset search filters", action: resetFilters)
}
Spacer()
if state.query.query != state.queryText, !state.queryText.isEmpty, !state.querySuggestions.collection.isEmpty {
SearchSuggestions()
} else {
results
}
}
}
#else
ZStack {
results
if state.query.query != state.queryText, !state.queryText.isEmpty, !state.querySuggestions.collection.isEmpty {
HStack {
Spacer()
SearchSuggestions()
.borderLeading(width: 1, color: Color("ControlsBorderColor"))
.frame(maxWidth: 280)
}
}
}
#endif
}
.toolbar {
#if !os(tvOS)
@@ -118,6 +104,10 @@ struct SearchView: View {
if accounts.app.supportsSearchFilters {
filtersMenu
}
#if os(macOS)
SearchTextField()
#endif
}
#endif
}
@@ -132,10 +122,11 @@ struct SearchView: View {
state.store.replace(ContentItem.array(of: videos))
}
}
.searchable(text: $state.queryText, placement: searchFieldPlacement) {
ForEach(state.querySuggestions.collection, id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
.onChange(of: state.query.query) { newQuery in
if newQuery.isEmpty {
favoriteItem = nil
} else {
updateFavoriteItem()
}
}
.onChange(of: state.queryText) { newQuery in
@@ -161,11 +152,7 @@ struct SearchView: View {
}
#endif
}
.onSubmit(of: .search) {
state.changeQuery { query in query.query = state.queryText }
recents.addQuery(state.queryText)
updateFavoriteItem()
}
.onChange(of: searchSortOrder) { order in
state.changeQuery { query in
query.sortBy = order
@@ -185,19 +172,55 @@ struct SearchView: View {
}
}
#if !os(tvOS)
.ignoresSafeArea(.keyboard, edges: .bottom)
.navigationTitle("Search")
#endif
}
var searchFieldPlacement: SearchFieldPlacement {
#if os(iOS)
.navigationBarDrawer(displayMode: .always)
#else
.automatic
.navigationBarHidden(true)
#endif
}
var toolbarPlacement: ToolbarItemPlacement {
private var results: some View {
VStack {
if showRecentQueries {
recentQueries
} else {
#if os(tvOS)
ScrollView(.vertical, showsIndicators: false) {
HStack(spacing: 0) {
if accounts.app.supportsSearchFilters {
filtersHorizontalStack
}
if let favoriteItem = favoriteItem {
FavoriteButton(item: favoriteItem)
.id(favoriteItem.id)
.labelStyle(.iconOnly)
.font(.system(size: 25))
}
}
HorizontalCells(items: items)
}
.edgesIgnoringSafeArea(.horizontal)
#else
VerticalCells(items: items)
#endif
if noResults {
Text("No results")
if searchFiltersActive {
Button("Reset search filters", action: resetFilters)
}
Spacer()
}
}
}
}
private var toolbarPlacement: ToolbarItemPlacement {
#if os(iOS)
.bottomBar
#else
@@ -205,25 +228,25 @@ struct SearchView: View {
#endif
}
fileprivate var showRecentQueries: Bool {
private var showRecentQueries: Bool {
navigationStyle == .tab && state.queryText.isEmpty
}
fileprivate var filtersActive: Bool {
private var filtersActive: Bool {
searchDuration != .any || searchDate != .any
}
fileprivate func resetFilters() {
private func resetFilters() {
searchSortOrder = .relevance
searchDate = .any
searchDuration = .any
}
fileprivate var noResults: Bool {
private var noResults: Bool {
items.isEmpty && !state.isLoading && !state.query.isEmpty
}
var recentQueries: some View {
private var recentQueries: some View {
VStack {
List {
Section(header: Text("Recents")) {
@@ -237,22 +260,13 @@ struct SearchView: View {
state.changeQuery { query in query.query = item.title }
updateFavoriteItem()
}
#if os(iOS)
.swipeActions(edge: .trailing) {
deleteButton(item)
}
#elseif os(tvOS)
.contextMenu {
deleteButton(item)
deleteAllButton
}
#endif
}
}
.redrawOn(change: recentsChanged)
if !recentItems.isEmpty {
clearAllButton
}
}
}
#if os(iOS)
@@ -260,37 +274,33 @@ struct SearchView: View {
#endif
}
#if !os(macOS)
func deleteButton(_ item: RecentItem) -> some View {
Button(role: .destructive) {
recents.close(item)
recentsChanged.toggle()
} label: {
Label("Delete", systemImage: "trash")
}
}
#endif
var clearAllButton: some View {
Button("Clear All", role: .destructive) {
presentingClearConfirmation = true
}
.confirmationDialog("Clear All", isPresented: $presentingClearConfirmation) {
Button("Clear All", role: .destructive) {
recents.clearQueries()
}
private func deleteButton(_ item: RecentItem) -> some View {
Button {
recents.close(item)
recentsChanged.toggle()
} label: {
Label("Delete", systemImage: "trash")
}
}
var searchFiltersActive: Bool {
private var deleteAllButton: some View {
Button {
recents.clearQueries()
recentsChanged.toggle()
} label: {
Label("Delete All", systemImage: "trash.fill")
}
}
private var searchFiltersActive: Bool {
searchDate != .any || searchDuration != .any
}
var recentItems: [RecentItem] {
private var recentItems: [RecentItem] {
Defaults[.recentlyOpened].filter { $0.type == .query }.reversed()
}
var searchSortOrderPicker: some View {
private var searchSortOrderPicker: some View {
Picker("Sort", selection: $searchSortOrder) {
ForEach(SearchQuery.SortOrder.allCases) { sortOrder in
Text(sortOrder.name).tag(sortOrder)
@@ -299,7 +309,7 @@ struct SearchView: View {
}
#if os(tvOS)
var searchSortOrderButton: some View {
private var searchSortOrderButton: some View {
Button(action: { self.searchSortOrder = self.searchSortOrder.next() }) { Text(self.searchSortOrder.name)
.font(.system(size: 30))
.padding(.horizontal)
@@ -315,7 +325,7 @@ struct SearchView: View {
}
}
var searchDateButton: some View {
private var searchDateButton: some View {
Button(action: { self.searchDate = self.searchDate.next() }) {
Text(self.searchDate.name)
.font(.system(size: 30))
@@ -332,7 +342,7 @@ struct SearchView: View {
}
}
var searchDurationButton: some View {
private var searchDurationButton: some View {
Button(action: { self.searchDuration = self.searchDuration.next() }) {
Text(self.searchDuration.name)
.font(.system(size: 30))
@@ -349,7 +359,7 @@ struct SearchView: View {
}
}
var filtersHorizontalStack: some View {
private var filtersHorizontalStack: some View {
HStack {
HStack(spacing: 30) {
Text("Sort")
@@ -375,7 +385,7 @@ struct SearchView: View {
.font(.system(size: 30))
}
#else
var filtersMenu: some View {
private var filtersMenu: some View {
Menu(filtersActive ? "Filter: active" : "Filter") {
Picker(selection: $searchDuration, label: Text("Duration")) {
ForEach(SearchQuery.Duration.allCases) { duration in

View File

@@ -15,9 +15,7 @@ struct AccountForm: View {
@State private var validationError: String?
@State private var validationDebounce = Debounce()
@FocusState private var focused: Bool
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
var body: some View {
VStack {
@@ -32,7 +30,7 @@ struct AccountForm: View {
.padding(.vertical)
#elseif os(tvOS)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(.thickMaterial)
.background(Color.tertiaryBackground)
#else
.frame(width: 400, height: 145)
#endif
@@ -46,7 +44,7 @@ struct AccountForm: View {
Spacer()
Button("Cancel") {
dismiss()
presentationMode.wrappedValue.dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
@@ -68,7 +66,6 @@ struct AccountForm: View {
formFields
#endif
}
.onAppear(perform: initializeForm)
.onChange(of: username) { _ in validate() }
.onChange(of: password) { _ in validate() }
}
@@ -76,24 +73,23 @@ struct AccountForm: View {
var formFields: some View {
Group {
if !instance.app.accountsUsePassword {
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
.focused($focused)
TextField("Name", text: $name)
}
TextField("Username", text: $username, prompt: usernamePrompt)
TextField(usernamePrompt, text: $username)
if instance.app.accountsUsePassword {
SecureField("Password", text: $password, prompt: Text("Password"))
SecureField("Password", text: $password)
}
}
}
var usernamePrompt: Text {
var usernamePrompt: String {
switch instance.app {
case .invidious:
return Text("SID Cookie")
return "SID Cookie"
default:
return Text("Username")
return "Username"
}
}
@@ -121,10 +117,6 @@ struct AccountForm: View {
.padding(.horizontal)
}
private func initializeForm() {
focused = true
}
private func validate() {
isValid = false
validationDebounce.invalidate()
@@ -151,7 +143,7 @@ struct AccountForm: View {
let account = AccountsModel.add(instance: instance, name: name, username: username, password: password)
selectedAccount?.wrappedValue = account
dismiss()
presentationMode.wrappedValue.dismiss()
}
private var validator: AccountValidator {

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct AccountsNavigationLink: View {
@EnvironmentObject<AccountsModel> private var accounts
var instance: Instance
var body: some View {
NavigationLink(instance.longDescription) {
InstanceSettings(instanceID: instance.id)
}
.buttonStyle(.plain)
.contextMenu {
removeInstanceButton(instance)
}
}
private func removeInstanceButton(_ instance: Instance) -> some View {
if #available(iOS 15.0, *) {
return Button("Remove", role: .destructive) { removeAction(instance) }
} else {
return Button("Remove") { removeAction(instance) }
}
}
private func removeAction(_ instance: Instance) {
if accounts.current?.instance == instance {
accounts.setCurrent(nil)
}
InstancesModel.remove(instance)
}
}

View File

@@ -1,102 +0,0 @@
import SwiftUI
struct AccountsSettings: View {
let instanceID: Instance.ID?
@State private var accountsChanged = false
@State private var presentingAccountForm = false
@State private var frontendURL = ""
@EnvironmentObject<AccountsModel> private var model
@EnvironmentObject<InstancesModel> private var instances
var instance: Instance! {
InstancesModel.find(instanceID)
}
var body: some View {
List {
if instance.app.hasFrontendURL {
Section(header: Text("Frontend URL")) {
TextField(
"Frontend URL",
text: $frontendURL,
prompt: Text("To enable videos, channels and playlists sharing")
)
.onAppear {
frontendURL = instance.frontendURL ?? ""
}
.onChange(of: frontendURL) { newValue in
InstancesModel.setFrontendURL(instance, newValue)
}
.labelsHidden()
.autocapitalization(.none)
.keyboardType(.URL)
}
}
Section(header: Text("Accounts"), footer: sectionFooter) {
if instance.app.supportsAccounts {
accounts
} else {
Text("Accounts are not supported for the application of this instance")
.foregroundColor(.secondary)
}
}
}
#if os(tvOS)
.frame(maxWidth: 1000)
#endif
.navigationTitle(instance.description)
}
var accounts: some View {
Group {
ForEach(InstancesModel.accounts(instanceID), id: \.self) { account in
#if os(tvOS)
Button(account.description) {}
.contextMenu {
Button("Remove", role: .destructive) { removeAccount(account) }
Button("Cancel", role: .cancel) {}
}
#else
Text(account.description)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button("Remove", role: .destructive) { removeAccount(account) }
}
#endif
}
.redrawOn(change: accountsChanged)
Button("Add account...") {
presentingAccountForm = true
}
}
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
AccountForm(instance: instance)
}
#if !os(tvOS)
.listStyle(.insetGrouped)
#endif
}
private var sectionFooter: some View {
if !instance.app.supportsAccounts {
return Text("")
}
#if os(iOS)
return Text("Swipe to remove account")
#else
return Text("Tap and hold to remove account")
.foregroundColor(.secondary)
#endif
}
private func removeAccount(_ account: Account) {
AccountsModel.remove(account)
accountsChanged.toggle()
}
}

View File

@@ -13,9 +13,7 @@ struct InstanceForm: View {
@State private var validationError: String?
@State private var validationDebounce = Debounce()
@FocusState private var nameFieldFocused: Bool
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
var body: some View {
VStack(alignment: .leading) {
@@ -30,12 +28,11 @@ struct InstanceForm: View {
}
.onChange(of: app) { _ in validate() }
.onChange(of: url) { _ in validate() }
.onAppear(perform: initializeForm)
#if os(iOS)
.padding(.vertical)
#elseif os(tvOS)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.background(.thickMaterial)
.background(Color.tertiaryBackground)
#else
.frame(width: 400, height: 190)
#endif
@@ -49,7 +46,7 @@ struct InstanceForm: View {
Spacer()
Button("Cancel") {
dismiss()
presentationMode.wrappedValue.dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
@@ -80,10 +77,9 @@ struct InstanceForm: View {
}
.pickerStyle(.segmented)
TextField("Name", text: $name, prompt: Text("Instance Name (optional)"))
.focused($nameFieldFocused)
TextField("Name", text: $name)
TextField("API URL", text: $url, prompt: Text("https://invidious.home.net"))
TextField("API URL", text: $url)
#if !os(macOS)
.autocapitalization(.none)
@@ -138,10 +134,6 @@ struct InstanceForm: View {
}
}
func initializeForm() {
nameFieldFocused = true
}
func submitForm() {
guard isValid else {
return
@@ -149,7 +141,7 @@ struct InstanceForm: View {
savedInstanceID = InstancesModel.add(app: app, name: name, url: url).id
dismiss()
presentationMode.wrappedValue.dismiss()
}
}

View File

@@ -0,0 +1,104 @@
import SwiftUI
struct InstanceSettings: View {
let instanceID: Instance.ID?
@State private var accountsChanged = false
@State private var presentingAccountForm = false
@State private var frontendURL = ""
@EnvironmentObject<AccountsModel> private var model
@EnvironmentObject<InstancesModel> private var instances
var instance: Instance! {
InstancesModel.find(instanceID)
}
var body: some View {
List {
if instance.app.hasFrontendURL {
Section(header: Text("Frontend URL")) {
TextField(
"Frontend URL",
text: $frontendURL
)
.onAppear {
frontendURL = instance.frontendURL ?? ""
}
.onChange(of: frontendURL) { newValue in
InstancesModel.setFrontendURL(instance, newValue)
}
.labelsHidden()
.autocapitalization(.none)
.keyboardType(.URL)
}
}
Section(header: Text("Accounts"), footer: sectionFooter) {
if instance.app.supportsAccounts {
ForEach(InstancesModel.accounts(instanceID), id: \.self) { account in
#if os(tvOS)
Button(account.description) {}
.contextMenu {
Button("Remove") { removeAccount(account) }
Button("Cancel", role: .cancel) {}
}
#else
ZStack {
NavigationLink(destination: EmptyView()) {
EmptyView()
}
.disabled(true)
.hidden()
HStack {
Text(account.description)
Spacer()
}
.contextMenu {
Button("Remove") { removeAccount(account) }
}
}
#endif
}
.redrawOn(change: accountsChanged)
Button("Add account...") {
presentingAccountForm = true
}
.sheet(isPresented: $presentingAccountForm, onDismiss: { accountsChanged.toggle() }) {
AccountForm(instance: instance)
}
#if !os(tvOS)
.listStyle(.insetGrouped)
#endif
} else {
Text("Accounts are not supported for the application of this instance")
.foregroundColor(.secondary)
}
}
}
#if os(tvOS)
.frame(maxWidth: 1000)
#elseif os(iOS)
.listStyle(.insetGrouped)
#endif
.navigationTitle(instance.description)
}
private var sectionFooter: some View {
if !instance.app.supportsAccounts {
return Text("")
}
return Text("Tap and hold to remove account")
.foregroundColor(.secondary)
}
private func removeAccount(_ account: Account) {
AccountsModel.remove(account)
accountsChanged.toggle()
}
}

View File

@@ -1,70 +0,0 @@
import Defaults
import SwiftUI
struct InstancesSettings: View {
@Default(.instances) private var instances
@EnvironmentObject<AccountsModel> private var accounts
@State private var selectedInstanceID: Instance.ID?
@State private var selectedAccount: Account?
@State private var presentingInstanceForm = false
@State private var savedFormInstanceID: Instance.ID?
var body: some View {
Group {
Section(header: SettingsHeader(text: "Instances")) {
ForEach(instances) { instance in
Group {
NavigationLink(instance.longDescription) {
AccountsSettings(instanceID: instance.id)
}
}
#if os(iOS)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
removeInstanceButton(instance)
}
.buttonStyle(.plain)
#else
.contextMenu {
removeInstanceButton(instance)
}
#endif
}
addInstanceButton
}
#if os(iOS)
.listStyle(.insetGrouped)
#endif
}
.sheet(isPresented: $presentingInstanceForm) {
InstanceForm(savedInstanceID: $savedFormInstanceID)
}
}
private var addInstanceButton: some View {
Button("Add Instance...") {
presentingInstanceForm = true
}
}
private func removeInstanceButton(_ instance: Instance) -> some View {
Button("Remove", role: .destructive) {
if accounts.current?.instance == instance {
accounts.setCurrent(nil)
}
InstancesModel.remove(instance)
}
}
}
struct InstancesSettingsView_Previews: PreviewProvider {
static var previews: some View {
VStack {
InstancesSettings()
}
.frame(width: 400, height: 270)
}
}

View File

@@ -9,8 +9,7 @@ struct ServicesSettings: View {
Section(header: SettingsHeader(text: "SponsorBlock API")) {
TextField(
"SponsorBlock API Instance",
text: $sponsorBlockInstance,
prompt: Text("SponsorBlock API URL, leave blank to disable")
text: $sponsorBlockInstance
)
.labelsHidden()
#if !os(macOS)
@@ -21,7 +20,7 @@ struct ServicesSettings: View {
Section(header: SettingsHeader(text: "Categories to Skip")) {
#if os(macOS)
List(SponsorBlockAPI.categories, id: \.self) { category in
let list = List(SponsorBlockAPI.categories, id: \.self) { category in
SponsorBlockCategorySelectionRow(
title: SponsorBlockAPI.categoryDescription(category) ?? "Unknown",
selected: sponsorBlockCategories.contains(category)
@@ -29,7 +28,16 @@ struct ServicesSettings: View {
toggleCategory(category, value: value)
}
}
.listStyle(.inset(alternatesRowBackgrounds: true))
Group {
if #available(macOS 12.0, *) {
list
.listStyle(.inset(alternatesRowBackgrounds: true))
} else {
list
.listStyle(.inset)
}
}
Spacer()
#else
ForEach(SponsorBlockAPI.categories, id: \.self) { category in

View File

@@ -10,11 +10,16 @@ struct SettingsView: View {
#endif
#if os(iOS)
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
#endif
@EnvironmentObject<AccountsModel> private var accounts
@State private var presentingInstanceForm = false
@State private var savedFormInstanceID: Instance.ID?
@Default(.instances) private var instances
var body: some View {
#if os(macOS)
TabView {
@@ -65,8 +70,14 @@ struct SettingsView: View {
}
}
#endif
InstancesSettings()
.environmentObject(accounts)
Section(header: Text("Instances")) {
ForEach(instances) { instance in
AccountsNavigationLink(instance: instance)
}
addInstanceButton
}
BrowsingSettings()
PlaybackSettings()
ServicesSettings()
@@ -76,7 +87,7 @@ struct SettingsView: View {
ToolbarItem(placement: .navigationBarTrailing) {
#if !os(tvOS)
Button("Done") {
dismiss()
presentationMode.wrappedValue.dismiss()
}
.keyboardShortcut(.cancelAction)
#endif
@@ -87,11 +98,20 @@ struct SettingsView: View {
.listStyle(.insetGrouped)
#endif
}
.sheet(isPresented: $presentingInstanceForm) {
InstanceForm(savedInstanceID: $savedFormInstanceID)
}
#if os(tvOS)
.background(.black)
.background(Color.black)
#endif
#endif
}
private var addInstanceButton: some View {
Button("Add Instance...") {
presentingInstanceForm = true
}
}
}
struct SettingsView_Previews: PreviewProvider {

View File

@@ -9,51 +9,34 @@ struct TrendingCountry: View {
@State private var query: String = ""
@State private var selection: Country?
@FocusState var countryIsFocused
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
var body: some View {
VStack {
#if os(macOS)
#if !os(tvOS)
HStack {
TextField("Country", text: $query, prompt: Text(TrendingCountry.prompt))
.focused($countryIsFocused)
if #available(iOS 15.0, macOS 12.0, *) {
TextField("Country", text: $query, prompt: Text(TrendingCountry.prompt))
} else {
TextField(TrendingCountry.prompt, text: $query)
}
Button("Done") { selectCountryAndDismiss() }
.keyboardShortcut(.defaultAction)
.keyboardShortcut(.cancelAction)
}
.padding([.horizontal, .top])
countriesList
#else
NavigationView {
countriesList
.toolbar {
ToolbarItemGroup(placement: .navigationBarLeading) {
Button("Done") { selectCountryAndDismiss() }
}
}
#if os(iOS)
.navigationBarTitle("Trending Country", displayMode: .automatic)
#endif
}
#endif
countriesList
}
.onAppear {
countryIsFocused = true
}
.onSubmit { selectCountryAndDismiss() }
#if !os(macOS)
.searchable(text: $query, placement: searchPlacement, prompt: Text(TrendingCountry.prompt))
#endif
#if os(tvOS)
.background(.thinMaterial)
.searchable(text: $query, placement: .automatic, prompt: Text(TrendingCountry.prompt))
.background(Color.black)
#endif
}
var countriesList: some View {
ScrollViewReader { _ in
let list = ScrollViewReader { _ in
List(store.collection, selection: $selection) { country in
#if os(macOS)
Text(country.name)
@@ -71,29 +54,29 @@ struct TrendingCountry: View {
}
}
#if os(macOS)
.listStyle(.inset(alternatesRowBackgrounds: true))
.padding(.bottom, 5)
#endif
}
#if !os(macOS)
var searchPlacement: SearchFieldPlacement {
#if os(iOS)
.navigationBarDrawer(displayMode: .always)
return Group {
#if os(macOS)
if #available(macOS 12.0, *) {
list
.listStyle(.inset(alternatesRowBackgrounds: true))
} else {
list
}
#else
.automatic
list
#endif
}
#endif
#if os(macOS)
.padding(.bottom, 5)
#endif
}
func selectCountryAndDismiss(_ country: Country? = nil) {
if let selected = country ?? selection {
selectedCountry = selected
}
dismiss()
presentationMode.wrappedValue.dismiss()
}
}

View File

@@ -172,11 +172,12 @@ struct TrendingView: View {
}
#else
Picker("Category", selection: $category) {
Picker(category.controlLabel, selection: $category) {
ForEach(TrendingCategory.allCases) { category in
Text(category.controlLabel).tag(category)
}
}
.pickerStyle(.menu)
#endif
}

View File

@@ -19,7 +19,7 @@ struct VerticalCells: View {
}
.edgesIgnoringSafeArea(.horizontal)
#if os(macOS)
.background()
.background(Color.tertiaryBackground)
.frame(minWidth: 360)
#endif
}

View File

@@ -12,6 +12,7 @@ struct VideoCell: View {
@Environment(\.horizontalCells) private var horizontalCells
#endif
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<ThumbnailsModel> private var thumbnails
@@ -38,7 +39,13 @@ struct VideoCell: View {
}
.buttonStyle(.plain)
.contentShape(RoundedRectangle(cornerRadius: 12))
.contextMenu { VideoContextMenuView(video: video, playerNavigationLinkActive: $player.playerNavigationLinkActive) }
.contextMenu {
VideoContextMenuView(
video: video,
playerNavigationLinkActive: $player.playerNavigationLinkActive
)
.environmentObject(accounts)
}
}
var content: some View {
@@ -55,7 +62,7 @@ struct VideoCell: View {
#endif
}
#if os(macOS)
.background()
.background(Color.tertiaryBackground)
#endif
}

View File

@@ -83,7 +83,7 @@ struct ChannelPlaylistView: View {
.navigationTitle(playlist.title)
#else
.background(.thickMaterial)
.background(Color.tertiaryBackground)
#endif
}

View File

@@ -9,7 +9,7 @@ struct ChannelVideosView: View {
@StateObject private var store = Store<Channel>()
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
@Environment(\.inNavigationView) private var inNavigationView
#if os(iOS)
@@ -43,7 +43,7 @@ struct ChannelVideosView: View {
}
var content: some View {
VStack {
let content = VStack {
#if os(tvOS)
HStack {
Text(navigationTitle)
@@ -65,40 +65,43 @@ struct ChannelVideosView: View {
.frame(maxWidth: .infinity)
#endif
VerticalCells(items: videos)
#if !os(iOS)
.prefersDefaultFocus(in: focusNamespace)
#if os(iOS)
VerticalCells(items: videos)
#else
if #available(macOS 12.0, *) {
VerticalCells(items: videos)
.prefersDefaultFocus(in: focusNamespace)
} else {
VerticalCells(items: videos)
}
#endif
}
.environment(\.inChannelView, true)
#if !os(iOS)
.focusScope(focusNamespace)
#endif
#if !os(tvOS)
.toolbar {
ToolbarItem(placement: .navigation) {
ShareButton(
contentItem: contentItem,
presentingShareSheet: $presentingShareSheet,
shareURL: $shareURL
)
}
.toolbar {
ToolbarItem(placement: .navigation) {
ShareButton(
contentItem: contentItem,
presentingShareSheet: $presentingShareSheet,
shareURL: $shareURL
)
}
ToolbarItem {
HStack {
Text("**\(store.item?.subscriptionsString ?? "loading")** subscribers")
.foregroundColor(.secondary)
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
ToolbarItem {
HStack {
Text("**\(store.item?.subscriptionsString ?? "loading")** subscribers")
.foregroundColor(.secondary)
.opacity(store.item?.subscriptionsString != nil ? 1 : 0)
subscriptionToggleButton
subscriptionToggleButton
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
FavoriteButton(item: FavoriteItem(section: .channel(channel.id, channel.name)))
}
}
}
}
#else
.background(.thickMaterial)
.background(Color.tertiaryBackground)
#endif
#if os(iOS)
.sheet(isPresented: $presentingShareSheet) {
@@ -107,7 +110,6 @@ struct ChannelVideosView: View {
}
}
#endif
.modifier(UnsubscribeAlertModifier())
.onAppear {
if store.item.isNil {
resource.addObserver(store)
@@ -115,6 +117,17 @@ struct ChannelVideosView: View {
}
}
.navigationTitle(navigationTitle)
return Group {
if #available(macOS 12.0, *) {
content
#if !os(iOS)
.focusScope(focusNamespace)
#endif
} else {
content
}
}
}
private var resource: Resource {

View File

@@ -26,8 +26,13 @@ struct DetailBadge: View {
struct DefaultStyleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.background(.thinMaterial)
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
content
.background(.thinMaterial)
} else {
content
.background(Color.background)
}
}
}

View File

@@ -1,15 +1,15 @@
import SwiftUI
struct OpenSettingsButton: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
#if !os(macOS)
@EnvironmentObject<NavigationModel> private var navigation
#endif
var body: some View {
Button {
dismiss()
let button = Button {
presentationMode.wrappedValue.dismiss()
#if os(macOS)
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
@@ -19,7 +19,13 @@ struct OpenSettingsButton: View {
} label: {
Label("Open Settings", systemImage: "gearshape.2")
}
.buttonStyle(.borderedProminent)
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
button
.buttonStyle(.borderedProminent)
} else {
button
}
}
}

View File

@@ -25,7 +25,7 @@ struct PlayerControlsView<Content: View>: View {
}
private var controls: some View {
HStack {
let controls = HStack {
Button(action: {
model.presentingPlayer.toggle()
}) {
@@ -92,14 +92,23 @@ struct PlayerControlsView<Content: View>: View {
.padding(.horizontal)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 55)
.padding(.vertical, 0)
.background(.ultraThinMaterial)
.borderTop(height: 0.4, color: Color("PlayerControlsBorderColor"))
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("PlayerControlsBorderColor"))
.borderTop(height: 0.4, color: Color("ControlsBorderColor"))
.borderBottom(height: navigationStyle == .sidebar ? 0 : 0.4, color: Color("ControlsBorderColor"))
#if !os(tvOS)
.onSwipeGesture(up: {
model.presentingPlayer = true
})
#endif
return Group {
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
controls
.background(Material.ultraThinMaterial)
} else {
controls
.background(Color.tertiaryBackground)
}
}
}
private var appVersion: String {

View File

@@ -31,9 +31,6 @@ struct SubscriptionsView: View {
FavoriteButton(item: FavoriteItem(section: .subscriptions))
}
}
.refreshable {
loadResources(force: true)
}
}
fileprivate func loadResources(force: Bool = false) {

View File

@@ -113,7 +113,7 @@ struct VideoContextMenuView: View {
private var subscriptionButton: some View {
Group {
if subscriptions.isSubscribing(video.channel.id) {
Button(role: .destructive) {
Button {
#if os(tvOS)
subscriptions.unsubscribe(video.channel.id)
#else
@@ -143,7 +143,7 @@ struct VideoContextMenuView: View {
}
func removeFromPlaylistButton(playlistID: String) -> some View {
Button(role: .destructive) {
Button {
playlists.removeVideo(videoIndexID: video.indexID!, playlistID: playlistID)
} label: {
Label("Remove from playlist", systemImage: "text.badge.minus")

View File

@@ -2,14 +2,14 @@ import Defaults
import SwiftUI
struct WelcomeScreen: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.presentationMode) private var presentationMode
@EnvironmentObject<AccountsModel> private var accounts
@Default(.accounts) private var allAccounts
var body: some View {
VStack {
let welcomeScreen = VStack {
Spacer()
Text("Welcome")
@@ -26,7 +26,7 @@ struct WelcomeScreen: View {
AccountSelectionView(showHeader: false)
Button {
dismiss()
presentationMode.wrappedValue.dismiss()
} label: {
Text("Start")
}
@@ -36,7 +36,7 @@ struct WelcomeScreen: View {
#else
AccountsMenuView()
.onChange(of: accounts.current) { _ in
dismiss()
presentationMode.wrappedValue.dismiss()
}
#if os(macOS)
.frame(maxWidth: 280)
@@ -50,10 +50,16 @@ struct WelcomeScreen: View {
Spacer()
}
.interactiveDismissDisabled()
#if os(macOS)
.frame(minWidth: 400, minHeight: 400)
.frame(minWidth: 400, minHeight: 400)
#endif
if #available(iOS 15.0, macOS 12.0, tvOS 15.0, *) {
welcomeScreen
.interactiveDismissDisabled()
} else {
welcomeScreen
}
}
}