From 271aff86b5184112c8a72a7a1fae615d3ee0a8a1 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 6 Jun 2026 22:59:36 +0200 Subject: [PATCH] Present view options and search filters as popovers on macOS Plain-Form sheets rendered as columns-style content floating inside oversized fixed frames on macOS. Anchor these panels to their toolbar buttons as popovers instead, sized to content; iOS/tvOS keep sheets. - Search and PeerTube explore filters apply live (Cancel never reverted anything anyway) and dismiss on outside click - Subscriptions/Manage Channels "Subscriptions Data" opens as a sheet from the popover's onDisappear via a pending flag, since presenting while the popover dismisses gets swallowed - Media browser view options drop navigation chrome on macOS --- Yattee/Views/Channel/ChannelView.swift | 44 ++-- .../Views/Components/ViewOptionsSheet.swift | 3 +- Yattee/Views/Home/BookmarksListView.swift | 27 ++- Yattee/Views/Home/ContinueWatchingView.swift | 25 ++- Yattee/Views/Home/HistoryListView.swift | 27 ++- .../Views/Instances/InstanceBrowseView.swift | 27 ++- .../Views/MediaBrowser/MediaBrowserView.swift | 25 ++- .../MediaBrowserViewOptionsSheet.swift | 30 ++- Yattee/Views/Search/SearchView.swift | 171 ++++++++------ .../Views/Settings/PeerTubeFiltersSheet.swift | 94 ++++---- .../PeerTubeInstancesExploreView.swift | 29 ++- .../Subscriptions/ManageChannelsView.swift | 176 +++++++++------ .../Subscriptions/SubscriptionsView.swift | 209 +++++++++++------- 13 files changed, 554 insertions(+), 333 deletions(-) diff --git a/Yattee/Views/Channel/ChannelView.swift b/Yattee/Views/Channel/ChannelView.swift index 18602067..a0f481ff 100644 --- a/Yattee/Views/Channel/ChannelView.swift +++ b/Yattee/Views/Channel/ChannelView.swift @@ -144,6 +144,16 @@ struct ChannelView: View { showInsetBackground ? ListBackgroundStyle.grouped.color : ListBackgroundStyle.plain.color } + private var viewOptionsSheetContent: some View { + ViewOptionsSheet( + layout: $layout, + rowStyle: $rowStyle, + gridColumns: $gridColumns, + hideWatched: $hideWatched, + maxGridColumns: gridConfig.maxColumns + ) + } + var body: some View { Group { if let channel { @@ -328,6 +338,11 @@ struct ChannelView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif } #if !os(tvOS) @@ -340,16 +355,12 @@ struct ChannelView: View { channelMenu } } + #if !os(macOS) .sheet(isPresented: $showViewOptions) { - ViewOptionsSheet( - layout: $layout, - rowStyle: $rowStyle, - gridColumns: $gridColumns, - hideWatched: $hideWatched, - maxGridColumns: gridConfig.maxColumns - ) - .liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition) + viewOptionsSheetContent + .liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition) } + #endif #if os(iOS) .toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar) .navigationBarTitleDisplayMode(.inline) @@ -487,6 +498,11 @@ struct ChannelView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "channelViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif } #if !os(tvOS) @@ -499,16 +515,12 @@ struct ChannelView: View { channelMenu } } + #if !os(macOS) .sheet(isPresented: $showViewOptions) { - ViewOptionsSheet( - layout: $layout, - rowStyle: $rowStyle, - gridColumns: $gridColumns, - hideWatched: $hideWatched, - maxGridColumns: gridConfig.maxColumns - ) - .liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition) + viewOptionsSheetContent + .liquidGlassSheetContent(sourceID: "channelViewOptions", in: sheetTransition) } + #endif #if os(iOS) .toolbarBackground(collapseProgress > 0.8 ? .visible : .hidden, for: .navigationBar) .navigationBarTitleDisplayMode(.inline) diff --git a/Yattee/Views/Components/ViewOptionsSheet.swift b/Yattee/Views/Components/ViewOptionsSheet.swift index e5b98236..82578d78 100644 --- a/Yattee/Views/Components/ViewOptionsSheet.swift +++ b/Yattee/Views/Components/ViewOptionsSheet.swift @@ -156,7 +156,8 @@ struct ViewOptionsSheet: View { .presentationDragIndicator(.visible) #endif #if os(macOS) - .frame(minWidth: 420, minHeight: 320) + .padding() + .frame(width: 300) #endif } } diff --git a/Yattee/Views/Home/BookmarksListView.swift b/Yattee/Views/Home/BookmarksListView.swift index 24471e5e..2d1b088a 100644 --- a/Yattee/Views/Home/BookmarksListView.swift +++ b/Yattee/Views/Home/BookmarksListView.swift @@ -82,6 +82,16 @@ struct BookmarksListView: View { return ([], nil) } + private var viewOptionsSheetContent: some View { + ViewOptionsSheet( + layout: $layout, + rowStyle: $rowStyle, + gridColumns: $gridColumns, + hideWatched: $hideWatched, + maxGridColumns: gridConfig.maxColumns + ) + } + var body: some View { GeometryReader { geometry in #if os(tvOS) @@ -150,19 +160,20 @@ struct BookmarksListView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "bookmarksViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif } } #endif + #if !os(macOS) .sheet(isPresented: $showViewOptions) { - ViewOptionsSheet( - layout: $layout, - rowStyle: $rowStyle, - gridColumns: $gridColumns, - hideWatched: $hideWatched, - maxGridColumns: gridConfig.maxColumns - ) - .liquidGlassSheetContent(sourceID: "bookmarksViewOptions", in: sheetTransition) + viewOptionsSheetContent + .liquidGlassSheetContent(sourceID: "bookmarksViewOptions", in: sheetTransition) } + #endif .onAppear { loadBookmarks() loadWatchEntries() diff --git a/Yattee/Views/Home/ContinueWatchingView.swift b/Yattee/Views/Home/ContinueWatchingView.swift index deffb169..111ed4ee 100644 --- a/Yattee/Views/Home/ContinueWatchingView.swift +++ b/Yattee/Views/Home/ContinueWatchingView.swift @@ -41,6 +41,15 @@ struct ContinueWatchingView: View { GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns) } + private var viewOptionsSheetContent: some View { + ViewOptionsSheet( + layout: $layout, + rowStyle: $rowStyle, + gridColumns: $gridColumns, + maxGridColumns: gridConfig.maxColumns + ) + } + var body: some View { GeometryReader { geometry in #if os(tvOS) @@ -122,6 +131,11 @@ struct ContinueWatchingView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "continueWatchingViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif } ToolbarItem(placement: .primaryAction) { Menu { @@ -137,15 +151,12 @@ struct ContinueWatchingView: View { } } #endif + #if !os(macOS) .sheet(isPresented: $showViewOptions) { - ViewOptionsSheet( - layout: $layout, - rowStyle: $rowStyle, - gridColumns: $gridColumns, - maxGridColumns: gridConfig.maxColumns - ) - .liquidGlassSheetContent(sourceID: "continueWatchingViewOptions", in: sheetTransition) + viewOptionsSheetContent + .liquidGlassSheetContent(sourceID: "continueWatchingViewOptions", in: sheetTransition) } + #endif .onAppear { loadHistory() } diff --git a/Yattee/Views/Home/HistoryListView.swift b/Yattee/Views/Home/HistoryListView.swift index 734537a3..68da5e76 100644 --- a/Yattee/Views/Home/HistoryListView.swift +++ b/Yattee/Views/Home/HistoryListView.swift @@ -79,6 +79,16 @@ struct HistoryListView: View { return ([], nil) } + private var viewOptionsSheetContent: some View { + ViewOptionsSheet( + layout: $layout, + rowStyle: $rowStyle, + gridColumns: $gridColumns, + hideWatched: nil, // No hide watched for history view + maxGridColumns: gridConfig.maxColumns + ) + } + var body: some View { GeometryReader { geometry in #if os(tvOS) @@ -165,6 +175,11 @@ struct HistoryListView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "historyViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif } // Clear history menu (only when not empty) @@ -186,16 +201,12 @@ struct HistoryListView: View { } } #endif + #if !os(macOS) .sheet(isPresented: $showViewOptions) { - ViewOptionsSheet( - layout: $layout, - rowStyle: $rowStyle, - gridColumns: $gridColumns, - hideWatched: nil, // No hide watched for history view - maxGridColumns: gridConfig.maxColumns - ) - .liquidGlassSheetContent(sourceID: "historyViewOptions", in: sheetTransition) + viewOptionsSheetContent + .liquidGlassSheetContent(sourceID: "historyViewOptions", in: sheetTransition) } + #endif .confirmationDialog( confirmationTitle, isPresented: $showingClearConfirmation, diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index eb46d0c9..8c8e8333 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -112,6 +112,16 @@ struct InstanceBrowseView: View { } } + private var viewOptionsSheetContent: some View { + ViewOptionsSheet( + layout: $layout, + rowStyle: $rowStyle, + gridColumns: $gridColumnCount, + hideWatched: $hideWatched, + maxGridColumns: gridConfig.maxColumns + ) + } + var body: some View { let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain GeometryReader { geometry in @@ -337,19 +347,20 @@ struct InstanceBrowseView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "instanceBrowseViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif } } #endif + #if !os(macOS) .sheet(isPresented: $showViewOptions) { - ViewOptionsSheet( - layout: $layout, - rowStyle: $rowStyle, - gridColumns: $gridColumnCount, - hideWatched: $hideWatched, - maxGridColumns: gridConfig.maxColumns - ) - .liquidGlassSheetContent(sourceID: "instanceBrowseViewOptions", in: sheetTransition) + viewOptionsSheetContent + .liquidGlassSheetContent(sourceID: "instanceBrowseViewOptions", in: sheetTransition) } + #endif .task { // Initialize search view model if let appEnvironment { diff --git a/Yattee/Views/MediaBrowser/MediaBrowserView.swift b/Yattee/Views/MediaBrowser/MediaBrowserView.swift index 32b9e3e2..f1e1ec7c 100644 --- a/Yattee/Views/MediaBrowser/MediaBrowserView.swift +++ b/Yattee/Views/MediaBrowser/MediaBrowserView.swift @@ -59,6 +59,15 @@ struct MediaBrowserView: View { return sortedFiles(result) } + private var viewOptionsSheetContent: some View { + MediaBrowserViewOptionsSheet( + sourceType: source.type, + sortOrder: $sortOrder, + sortAscending: $sortAscending, + showOnlyPlayable: $showOnlyPlayable + ) + } + var body: some View { content #if !os(tvOS) @@ -91,18 +100,20 @@ struct MediaBrowserView: View { ) } .liquidGlassTransitionSource(id: "mediaBrowserViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif } #endif } + #if !os(macOS) .sheet(isPresented: $showViewOptions) { - MediaBrowserViewOptionsSheet( - sourceType: source.type, - sortOrder: $sortOrder, - sortAscending: $sortAscending, - showOnlyPlayable: $showOnlyPlayable - ) - .liquidGlassSheetContent(sourceID: "mediaBrowserViewOptions", in: sheetTransition) + viewOptionsSheetContent + .liquidGlassSheetContent(sourceID: "mediaBrowserViewOptions", in: sheetTransition) } + #endif .task { await loadFiles() } diff --git a/Yattee/Views/MediaBrowser/MediaBrowserViewOptionsSheet.swift b/Yattee/Views/MediaBrowser/MediaBrowserViewOptionsSheet.swift index f2ff2ed9..bf031f8e 100644 --- a/Yattee/Views/MediaBrowser/MediaBrowserViewOptionsSheet.swift +++ b/Yattee/Views/MediaBrowser/MediaBrowserViewOptionsSheet.swift @@ -21,26 +21,43 @@ struct MediaBrowserViewOptionsSheet: View { } var body: some View { + #if os(macOS) + // Popover content: no navigation chrome, click-outside dismisses. + formContent + .padding() + .frame(width: 300) + .onAppear { + // Reset sort order if current selection is not available for this source type + if !availableSortOptions.contains(sortOrder) { + sortOrder = .name + } + } + #else NavigationStack { #if os(tvOS) listContent #else formContent + .navigationTitle("mediaBrowser.viewOptions.title") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + sheetCloseToolbarItem { dismiss() } + } #endif } #if os(iOS) .presentationDetents([.height(280)]) .presentationDragIndicator(.visible) #endif - #if os(macOS) - .frame(minWidth: 420, minHeight: 320) - #endif .onAppear { // Reset sort order if current selection is not available for this source type if !availableSortOptions.contains(sortOrder) { sortOrder = .name } } + #endif } @ViewBuilder @@ -83,13 +100,6 @@ struct MediaBrowserViewOptionsSheet: View { sharedOptions } } - .navigationTitle("mediaBrowser.viewOptions.title") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - sheetCloseToolbarItem { dismiss() } - } } } diff --git a/Yattee/Views/Search/SearchView.swift b/Yattee/Views/Search/SearchView.swift index 593b29d3..82385ea2 100644 --- a/Yattee/Views/Search/SearchView.swift +++ b/Yattee/Views/Search/SearchView.swift @@ -96,34 +96,20 @@ struct SearchView: View { var body: some View { tvOSOrDefaultContent + #if !os(macOS) .sheet(isPresented: $showFilterSheet) { - SearchFiltersSheet(onApply: { - if hasResults { - Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) } - } - }, filters: Binding( - get: { searchViewModel?.filters ?? .defaults }, - set: { newFilters in - searchViewModel?.filters = newFilters - saveFilters(newFilters) - } - )) + searchFiltersSheetContent #if !os(tvOS) .presentationDetents([.medium, .large]) #endif } .sheet(isPresented: $showViewOptions) { - ViewOptionsSheet( - layout: $layout, - rowStyle: $rowStyle, - gridColumns: $gridColumns, - hideWatched: $hideWatched, - maxGridColumns: gridConfig.maxColumns - ) + viewOptionsSheetContent #if !os(tvOS) .liquidGlassSheetContent(sourceID: "searchViewOptions", in: sheetTransition) #endif } + #endif .onChange(of: searchTextBinding.wrappedValue) { _, newValue in if newValue.isEmpty { searchViewModel?.clearResults() // Clear everything when empty @@ -182,6 +168,43 @@ struct SearchView: View { } } + private var searchFiltersSheetContent: some View { + SearchFiltersSheet(onApply: { + if hasResults { + Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) } + } + }, filters: Binding( + get: { searchViewModel?.filters ?? .defaults }, + set: { newFilters in + searchViewModel?.filters = newFilters + saveFilters(newFilters) + } + )) + } + + private var viewOptionsSheetContent: some View { + ViewOptionsSheet( + layout: $layout, + rowStyle: $rowStyle, + gridColumns: $gridColumns, + hideWatched: $hideWatched, + maxGridColumns: gridConfig.maxColumns + ) + } + + #if os(macOS) + private var searchFiltersToolbarButton: some View { + Button { + showFilterSheet = true + } label: { + Label(String(localized: "search.filters"), systemImage: filtersIconName) + } + .popover(isPresented: $showFilterSheet, arrowEdge: .bottom) { + searchFiltersSheetContent + } + } + #endif + @ViewBuilder private var tvOSOrDefaultContent: some View { #if os(tvOS) @@ -239,14 +262,10 @@ struct SearchView: View { // Items stay in the toolbar even before a search is submitted // (invisible and inert) so the search field keeps a stable size. ToolbarItem(placement: .navigation) { - Button { - showFilterSheet = true - } label: { - Label(String(localized: "search.filters"), systemImage: filtersIconName) - } - .opacity(hasSearched ? 1 : 0) - .disabled(!hasSearched) - .accessibilityHidden(!hasSearched) + searchFiltersToolbarButton + .opacity(hasSearched ? 1 : 0) + .disabled(!hasSearched) + .accessibilityHidden(!hasSearched) } .sharedBackgroundVisibility(hasSearched ? .automatic : .hidden) ToolbarItem(placement: .principal) { @@ -259,11 +278,7 @@ struct SearchView: View { .sharedBackgroundVisibility(hasSearched ? .automatic : .hidden) } else if hasSearched { ToolbarItem(placement: .navigation) { - Button { - showFilterSheet = true - } label: { - Label(String(localized: "search.filters"), systemImage: filtersIconName) - } + searchFiltersToolbarButton } ToolbarItem(placement: .principal) { contentTypePicker @@ -286,6 +301,11 @@ struct SearchView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "searchViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif } } .searchable(text: searchTextBinding, prompt: Text(String(localized: "search.placeholder"))) @@ -1493,45 +1513,15 @@ struct SearchFiltersSheet: View { @Binding var filters: SearchFilters var body: some View { + #if os(macOS) + // Popover content: filters apply live, click-outside dismisses. + filtersForm + .onChange(of: filters) { _, _ in onApply() } + .padding() + .frame(width: 300) + #else NavigationStack { - Form { - // Sort, Upload Date, Duration in one section - Section { - Picker(String(localized: "search.sort"), selection: $filters.sort) { - ForEach(SearchSortOption.allCases) { option in - Text(option.title).tag(option) - } - } - - Picker(String(localized: "search.uploadDate"), selection: $filters.date) { - ForEach(SearchDateFilter.allCases) { option in - Text(option.title).tag(option) - } - } - - Picker(String(localized: "search.duration"), selection: $filters.duration) { - ForEach(SearchDurationFilter.allCases) { option in - Text(option.title).tag(option) - } - } - } - - // Reset Button - Section { - Button(role: .destructive) { - let currentType = filters.type - filters = .defaults - filters.type = currentType - } label: { - HStack { - Spacer() - Text(String(localized: "search.filters.reset")) - Spacer() - } - } - .disabled(filters.isDefault) - } - } + filtersForm .navigationTitle(String(localized: "search.filters")) #if os(iOS) .navigationBarTitleDisplayMode(.inline) @@ -1550,10 +1540,49 @@ struct SearchFiltersSheet: View { } } } - #if os(macOS) - .frame(minWidth: 500, minHeight: 400) #endif } + + private var filtersForm: some View { + Form { + // Sort, Upload Date, Duration in one section + Section { + Picker(String(localized: "search.sort"), selection: $filters.sort) { + ForEach(SearchSortOption.allCases) { option in + Text(option.title).tag(option) + } + } + + Picker(String(localized: "search.uploadDate"), selection: $filters.date) { + ForEach(SearchDateFilter.allCases) { option in + Text(option.title).tag(option) + } + } + + Picker(String(localized: "search.duration"), selection: $filters.duration) { + ForEach(SearchDurationFilter.allCases) { option in + Text(option.title).tag(option) + } + } + } + + // Reset Button + Section { + Button(role: .destructive) { + let currentType = filters.type + filters = .defaults + filters.type = currentType + } label: { + HStack { + Spacer() + Text(String(localized: "search.filters.reset")) + Spacer() + } + } + .disabled(filters.isDefault) + } + } + } } diff --git a/Yattee/Views/Settings/PeerTubeFiltersSheet.swift b/Yattee/Views/Settings/PeerTubeFiltersSheet.swift index c3545c21..d90fdf15 100644 --- a/Yattee/Views/Settings/PeerTubeFiltersSheet.swift +++ b/Yattee/Views/Settings/PeerTubeFiltersSheet.swift @@ -15,46 +15,15 @@ struct PeerTubeFiltersSheet: View { let onApply: () -> Void var body: some View { + #if os(macOS) + // Popover content: filters apply live, click-outside dismisses. + filtersForm + .onChange(of: filters) { _, _ in onApply() } + .padding() + .frame(width: 300) + #else NavigationStack { - Form { - // Language filter - if !languages.isEmpty { - Section { - Picker(String(localized: "peertube.filter.language"), selection: $filters.language) { - Text(String(localized: "common.any")).tag(nil as String?) - ForEach(languages, id: \.self) { lang in - Text(languageDisplayName(lang)).tag(lang as String?) - } - } - } - } - - // Country filter - if !countries.isEmpty { - Section { - Picker(String(localized: "peertube.filter.country"), selection: $filters.country) { - Text(String(localized: "common.any")).tag(nil as String?) - ForEach(countries, id: \.self) { country in - Text(countryDisplayName(country)).tag(country as String?) - } - } - } - } - - // Reset button - Section { - Button(role: .destructive) { - filters = PeerTubeDirectoryFilters() - } label: { - HStack { - Spacer() - Text(String(localized: "common.reset")) - Spacer() - } - } - .disabled(filters.isDefault) - } - } + filtersForm #if os(tvOS) .scrollClipDisabled() .padding(.horizontal, 40) @@ -63,9 +32,7 @@ struct PeerTubeFiltersSheet: View { .onChange(of: filters.country) { _, _ in onApply() } #else .navigationTitle(String(localized: "peertube.explore.filters")) - #if os(iOS) .navigationBarTitleDisplayMode(.inline) - #endif .toolbar { ToolbarItem(placement: .cancellationAction) { Button(String(localized: "common.cancel")) { @@ -83,9 +50,50 @@ struct PeerTubeFiltersSheet: View { } #if os(iOS) .presentationDetents([.medium]) - #elseif os(macOS) - .frame(minWidth: 350, minHeight: 250) #endif + #endif + } + + private var filtersForm: some View { + Form { + // Language filter + if !languages.isEmpty { + Section { + Picker(String(localized: "peertube.filter.language"), selection: $filters.language) { + Text(String(localized: "common.any")).tag(nil as String?) + ForEach(languages, id: \.self) { lang in + Text(languageDisplayName(lang)).tag(lang as String?) + } + } + } + } + + // Country filter + if !countries.isEmpty { + Section { + Picker(String(localized: "peertube.filter.country"), selection: $filters.country) { + Text(String(localized: "common.any")).tag(nil as String?) + ForEach(countries, id: \.self) { country in + Text(countryDisplayName(country)).tag(country as String?) + } + } + } + } + + // Reset button + Section { + Button(role: .destructive) { + filters = PeerTubeDirectoryFilters() + } label: { + HStack { + Spacer() + Text(String(localized: "common.reset")) + Spacer() + } + } + .disabled(filters.isDefault) + } + } } private func languageDisplayName(_ code: String) -> String { diff --git a/Yattee/Views/Settings/PeerTubeInstancesExploreView.swift b/Yattee/Views/Settings/PeerTubeInstancesExploreView.swift index b4352052..12cca896 100644 --- a/Yattee/Views/Settings/PeerTubeInstancesExploreView.swift +++ b/Yattee/Views/Settings/PeerTubeInstancesExploreView.swift @@ -93,17 +93,23 @@ struct PeerTubeInstancesExploreView: View { .task { await loadAllInstances() } + #if !os(macOS) .sheet(isPresented: $showFiltersSheet) { - PeerTubeFiltersSheet( - filters: $filters, - languages: availableLanguages, - countries: availableCountries, - onApply: { - // Reset display limit when filters change - displayLimit = pageSize - } - ) + filtersSheetContent } + #endif + } + + private var filtersSheetContent: some View { + PeerTubeFiltersSheet( + filters: $filters, + languages: availableLanguages, + countries: availableCountries, + onApply: { + // Reset display limit when filters change + displayLimit = pageSize + } + ) } // MARK: - Content @@ -227,6 +233,11 @@ struct PeerTubeInstancesExploreView: View { systemImage: filters.isDefault ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill" ) } + #if os(macOS) + .popover(isPresented: $showFiltersSheet, arrowEdge: .bottom) { + filtersSheetContent + } + #endif } } diff --git a/Yattee/Views/Subscriptions/ManageChannelsView.swift b/Yattee/Views/Subscriptions/ManageChannelsView.swift index 93efdb33..35b4df01 100644 --- a/Yattee/Views/Subscriptions/ManageChannelsView.swift +++ b/Yattee/Views/Subscriptions/ManageChannelsView.swift @@ -12,6 +12,10 @@ struct ManageChannelsView: View { @Namespace private var sheetTransition @State private var channels: [Channel] = [] @State private var showViewOptions = false + #if os(macOS) + @State private var showSubscriptionsData = false + @State private var pendingSubscriptionsData = false + #endif @State private var searchText = "" @State private var isLoading = false @State private var notificationStates: [String: Bool] = [:] @@ -29,6 +33,92 @@ struct ManageChannelsView: View { appEnvironment?.settingsManager.listStyle ?? .inset } + private var viewOptionsForm: some View { + Form { + // View options section + Section { + // Layout picker (segmented) + Picker(selection: $layout) { + ForEach(VideoListLayout.allCases, id: \.self) { option in + Label(option.displayName, systemImage: option.systemImage) + .tag(option) + } + } label: { + Text("viewOptions.layout") + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) + + // List-specific options + if layout == .list { + PlatformMenuPicker(String(localized: "viewOptions.rowSize"), selection: $rowStyle) { + Text("viewOptions.rowSize.compact").tag(VideoRowStyle.compact) + Text("viewOptions.rowSize.regular").tag(VideoRowStyle.regular) + Text("viewOptions.rowSize.large").tag(VideoRowStyle.large) + } + } + + // Grid-specific options + if layout == .grid { + #if os(tvOS) + Picker("viewOptions.columns.header", selection: $gridColumns) { + ForEach(GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns), id: \.self) { count in + Text("\(count)").tag(count) + } + } + .pickerStyle(.segmented) + #else + Stepper( + "viewOptions.columns \(min(max(GridConstants.minAllowedColumns, gridColumns), gridConfig.maxColumns))", + value: $gridColumns, + in: GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns) + ) + #endif + } + + PlatformMenuPicker(String(localized: "manageChannels.sortBy"), selection: $sortOrder) { + Text("manageChannels.sortBy.name").tag(SidebarChannelSort.alphabetical) + Text("manageChannels.sortBy.recentlySubscribed").tag(SidebarChannelSort.recentlySubscribed) + Text("manageChannels.sortBy.lastUploaded").tag(SidebarChannelSort.lastUploaded) + } + } + + #if os(macOS) + // Subscriptions Data (opens as a sheet; popovers can't push) + Section { + Button { + pendingSubscriptionsData = true + showViewOptions = false + } label: { + Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape") + } + } + #elseif os(iOS) + // Subscriptions Data navigation link + Section { + NavigationLink { + SubscriptionsSettingsView() + } label: { + Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape") + } + } + #endif + } + } + + #if os(macOS) + private var subscriptionsDataSheet: some View { + NavigationStack { + SubscriptionsSettingsView() + .toolbar { + sheetCloseToolbarItem { showSubscriptionsData = false } + } + } + .frame(minWidth: 500, minHeight: 450) + } + #endif + // Grid layout configuration @State private var viewWidth: CGFloat = 0 private var gridConfig: GridLayoutConfiguration { @@ -108,72 +198,32 @@ struct ManageChannelsView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "manageChannelsViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsForm + .padding() + .frame(width: 300) + .onDisappear { + // Present the sheet only after the popover is gone, + // otherwise the presentation is swallowed. + if pendingSubscriptionsData { + pendingSubscriptionsData = false + showSubscriptionsData = true + } + } + } + #endif } } #endif + #if os(macOS) + .sheet(isPresented: $showSubscriptionsData) { + subscriptionsDataSheet + } + #else .sheet(isPresented: $showViewOptions) { NavigationStack { - Form { - // View options section - Section { - // Layout picker (segmented) - Picker(selection: $layout) { - ForEach(VideoListLayout.allCases, id: \.self) { option in - Label(option.displayName, systemImage: option.systemImage) - .tag(option) - } - } label: { - Text("viewOptions.layout") - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) - - // List-specific options - if layout == .list { - PlatformMenuPicker(String(localized: "viewOptions.rowSize"), selection: $rowStyle) { - Text("viewOptions.rowSize.compact").tag(VideoRowStyle.compact) - Text("viewOptions.rowSize.regular").tag(VideoRowStyle.regular) - Text("viewOptions.rowSize.large").tag(VideoRowStyle.large) - } - } - - // Grid-specific options - if layout == .grid { - #if os(tvOS) - Picker("viewOptions.columns.header", selection: $gridColumns) { - ForEach(GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns), id: \.self) { count in - Text("\(count)").tag(count) - } - } - .pickerStyle(.segmented) - #else - Stepper( - "viewOptions.columns \(min(max(GridConstants.minAllowedColumns, gridColumns), gridConfig.maxColumns))", - value: $gridColumns, - in: GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns) - ) - #endif - } - - PlatformMenuPicker(String(localized: "manageChannels.sortBy"), selection: $sortOrder) { - Text("manageChannels.sortBy.name").tag(SidebarChannelSort.alphabetical) - Text("manageChannels.sortBy.recentlySubscribed").tag(SidebarChannelSort.recentlySubscribed) - Text("manageChannels.sortBy.lastUploaded").tag(SidebarChannelSort.lastUploaded) - } - } - - #if !os(tvOS) - // Subscriptions Data navigation link - Section { - NavigationLink { - SubscriptionsSettingsView() - } label: { - Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape") - } - } - #endif - } + viewOptionsForm #if os(tvOS) .scrollClipDisabled() .padding(.horizontal, 40) @@ -185,13 +235,11 @@ struct ManageChannelsView: View { #endif #endif } - #if os(macOS) - .frame(minWidth: 500, minHeight: 400) - #endif .presentationDetents([.height(360), .large]) .presentationDragIndicator(.visible) .liquidGlassSheetContent(sourceID: "manageChannelsViewOptions", in: sheetTransition) } + #endif .onAppear { if let syncChannels = subscriptionService?.fetchSubscriptionsSync() { channels = syncChannels diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index f4867581..9f0b65a8 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -26,6 +26,10 @@ struct SubscriptionsView: View { @State private var errorMessage: String? @State private var watchEntriesMap: [String: WatchEntry] = [:] @State private var showViewOptions = false + #if os(macOS) + @State private var showSubscriptionsData = false + @State private var pendingSubscriptionsData = false + #endif // View options (persisted) @AppStorage("subscriptionsLayout") private var layout: VideoListLayout = .list @@ -53,6 +57,108 @@ struct SubscriptionsView: View { GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns) } + private var viewOptionsForm: some View { + Form { + Section { + #if os(macOS) + Toggle("viewOptions.showSidebar", isOn: $showSidebar) + #elseif os(iOS) + if isIPadRegular { + Toggle("viewOptions.showSidebar", isOn: $showSidebar) + } + #elseif os(tvOS) + PlatformMenuPicker(String(localized: "viewOptions.showSidebar"), selection: $showSidebar) { + Text("common.on").tag(true) + Text("common.off").tag(false) + } + #endif + + // Layout picker (inline menu) + PlatformMenuPicker(String(localized: "viewOptions.layout"), selection: $layout) { + ForEach(VideoListLayout.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + + // List-specific options + if layout == .list { + PlatformMenuPicker(String(localized: "viewOptions.rowSize"), selection: $rowStyle) { + Text("viewOptions.rowSize.compact").tag(VideoRowStyle.compact) + Text("viewOptions.rowSize.regular").tag(VideoRowStyle.regular) + Text("viewOptions.rowSize.large").tag(VideoRowStyle.large) + } + } + + // Grid-specific options + if layout == .grid { + #if os(tvOS) + Picker("viewOptions.columns.header", selection: $gridColumns) { + ForEach(GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns), id: \.self) { count in + Text("\(count)").tag(count) + } + } + .pickerStyle(.segmented) + #else + Stepper( + "viewOptions.columns \(min(max(GridConstants.minAllowedColumns, gridColumns), gridConfig.maxColumns))", + value: $gridColumns, + in: GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns) + ) + #endif + } + + #if os(tvOS) + PlatformMenuPicker(String(localized: "viewOptions.hideWatched"), selection: $hideWatched) { + Text("common.on").tag(true) + Text("common.off").tag(false) + } + #else + Toggle("viewOptions.hideWatched", isOn: $hideWatched) + #endif + + #if os(iOS) + Picker("viewOptions.channelStrip", selection: $channelStripSize) { + ForEach(ChannelStripSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + .disabled(isIPadRegular && showSidebar) + #endif + } + + #if os(macOS) + Section { + Button { + pendingSubscriptionsData = true + showViewOptions = false + } label: { + Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape") + } + } + #elseif os(iOS) + Section { + NavigationLink { + SubscriptionsSettingsView() + } label: { + Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape") + } + } + #endif + } + } + + #if os(macOS) + private var subscriptionsDataSheet: some View { + NavigationStack { + SubscriptionsSettingsView() + .toolbar { + sheetCloseToolbarItem { showSubscriptionsData = false } + } + } + .frame(minWidth: 500, minHeight: 450) + } + #endif + private var isShowingFullScreenError: Bool { if case .error = feedCache.feedLoadState, feedCache.videos.isEmpty { return true @@ -319,89 +425,32 @@ struct SubscriptionsView: View { Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") } .liquidGlassTransitionSource(id: "subscriptionsViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsForm + .padding() + .frame(width: 300) + .onDisappear { + // Present the sheet only after the popover is gone, + // otherwise the presentation is swallowed. + if pendingSubscriptionsData { + pendingSubscriptionsData = false + showSubscriptionsData = true + } + } + } + #endif } } #endif + #if os(macOS) + .sheet(isPresented: $showSubscriptionsData) { + subscriptionsDataSheet + } + #else .sheet(isPresented: $showViewOptions) { NavigationStack { - Form { - Section { - #if os(macOS) - Toggle("viewOptions.showSidebar", isOn: $showSidebar) - #elseif os(iOS) - if isIPadRegular { - Toggle("viewOptions.showSidebar", isOn: $showSidebar) - } - #elseif os(tvOS) - PlatformMenuPicker(String(localized: "viewOptions.showSidebar"), selection: $showSidebar) { - Text("common.on").tag(true) - Text("common.off").tag(false) - } - #endif - - // Layout picker (inline menu) - PlatformMenuPicker(String(localized: "viewOptions.layout"), selection: $layout) { - ForEach(VideoListLayout.allCases, id: \.self) { option in - Text(option.displayName).tag(option) - } - } - - // List-specific options - if layout == .list { - PlatformMenuPicker(String(localized: "viewOptions.rowSize"), selection: $rowStyle) { - Text("viewOptions.rowSize.compact").tag(VideoRowStyle.compact) - Text("viewOptions.rowSize.regular").tag(VideoRowStyle.regular) - Text("viewOptions.rowSize.large").tag(VideoRowStyle.large) - } - } - - // Grid-specific options - if layout == .grid { - #if os(tvOS) - Picker("viewOptions.columns.header", selection: $gridColumns) { - ForEach(GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns), id: \.self) { count in - Text("\(count)").tag(count) - } - } - .pickerStyle(.segmented) - #else - Stepper( - "viewOptions.columns \(min(max(GridConstants.minAllowedColumns, gridColumns), gridConfig.maxColumns))", - value: $gridColumns, - in: GridConstants.minAllowedColumns...max(GridConstants.minAllowedColumns, gridConfig.maxColumns) - ) - #endif - } - - #if os(tvOS) - PlatformMenuPicker(String(localized: "viewOptions.hideWatched"), selection: $hideWatched) { - Text("common.on").tag(true) - Text("common.off").tag(false) - } - #else - Toggle("viewOptions.hideWatched", isOn: $hideWatched) - #endif - - #if os(iOS) - Picker("viewOptions.channelStrip", selection: $channelStripSize) { - ForEach(ChannelStripSize.allCases, id: \.self) { size in - Text(size.displayName).tag(size) - } - } - .disabled(isIPadRegular && showSidebar) - #endif - } - - #if !os(tvOS) - Section { - NavigationLink { - SubscriptionsSettingsView() - } label: { - Label(String(localized: "manageChannels.subscriptionsData"), systemImage: "person.2.badge.gearshape") - } - } - #endif - } + viewOptionsForm #if os(tvOS) .scrollClipDisabled() .padding(.horizontal, 40) @@ -413,13 +462,11 @@ struct SubscriptionsView: View { #endif #endif } - #if os(macOS) - .frame(minWidth: 500, minHeight: 450) - #endif .presentationDetents([.height(520), .large]) .presentationDragIndicator(.visible) .liquidGlassSheetContent(sourceID: "subscriptionsViewOptions", in: sheetTransition) } + #endif .task { await loadSubscriptionsAsync() loadWatchEntries()