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
This commit is contained in:
Arkadiusz Fal
2026-06-06 22:59:36 +02:00
parent dbe494f18a
commit 271aff86b5
13 changed files with 554 additions and 333 deletions

View File

@@ -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)

View File

@@ -156,7 +156,8 @@ struct ViewOptionsSheet: View {
.presentationDragIndicator(.visible)
#endif
#if os(macOS)
.frame(minWidth: 420, minHeight: 320)
.padding()
.frame(width: 300)
#endif
}
}

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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() }
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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()