Use NavigationSplitView on macOS with persistent sidebar Settings button

Replaces the macOS TabView(.sidebarAdaptable) root with NavigationSplitView
so the Settings gear can live in the sidebar column's toolbar (next to the
sidebar toggle) instead of only appearing in HomeView's detail toolbar.
This commit is contained in:
Arkadiusz Fal
2026-04-19 14:01:06 +02:00
parent cedefb5c97
commit cee2793399
3 changed files with 125 additions and 151 deletions

View File

@@ -10,7 +10,9 @@ import SwiftUI
struct HomeView: View { struct HomeView: View {
@Environment(\.appEnvironment) private var appEnvironment @Environment(\.appEnvironment) private var appEnvironment
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
#if !os(tvOS) && !os(macOS)
@Namespace private var sheetTransition @Namespace private var sheetTransition
#endif
@State private var playlists: [LocalPlaylist] = [] @State private var playlists: [LocalPlaylist] = []
@State private var bookmarksCount: Int = 0 @State private var bookmarksCount: Int = 0
@State private var recentBookmarks: [Bookmark] = [] @State private var recentBookmarks: [Bookmark] = []
@@ -18,7 +20,9 @@ struct HomeView: View {
@State private var recentContinueWatching: [WatchEntry] = [] @State private var recentContinueWatching: [WatchEntry] = []
@State private var historyCount: Int = 0 @State private var historyCount: Int = 0
@State private var recentHistory: [WatchEntry] = [] @State private var recentHistory: [WatchEntry] = []
#if !os(tvOS) && !os(macOS)
@State private var showingSettings = false @State private var showingSettings = false
#endif
@State private var showingOpenLink = false @State private var showingOpenLink = false
@State private var showingRemoteControl = false @State private var showingRemoteControl = false
@State private var showingCustomizeHome = false @State private var showingCustomizeHome = false
@@ -78,7 +82,7 @@ struct HomeView: View {
.toolbarTitleDisplayMode(.inlineLarge) .toolbarTitleDisplayMode(.inlineLarge)
#endif #endif
.toolbar { .toolbar {
#if !os(tvOS) #if !os(tvOS) && !os(macOS)
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { Button {
showingSettings = true showingSettings = true
@@ -91,7 +95,7 @@ struct HomeView: View {
} }
#endif #endif
} }
#if !os(tvOS) #if !os(tvOS) && !os(macOS)
.sheet(isPresented: $showingSettings) { .sheet(isPresented: $showingSettings) {
SettingsView() SettingsView()
.liquidGlassSheetContent(sourceID: "homeSettings", in: sheetTransition) .liquidGlassSheetContent(sourceID: "homeSettings", in: sheetTransition)

View File

@@ -376,17 +376,41 @@ struct UnifiedTabView: View {
// Zoom transition namespace (local to this tab view) // Zoom transition namespace (local to this tab view)
@Namespace private var zoomTransition @Namespace private var zoomTransition
// Settings sheet (rendered from the NavigationSplitView toolbar so the gear sits
// immediately after the system-vended sidebar-toggle in the sidebar column).
@State private var showingSettings = false
@Namespace private var sheetTransition
private var yatteeServerAuthHeader: String? { private var yatteeServerAuthHeader: String? {
guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil } guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
} }
var body: some View { var body: some View {
TabView(selection: $selection) { NavigationSplitView {
mainTabs sidebar
sidebarSections .toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingSettings = true
} label: {
Image(systemName: "gear")
}
.accessibilityIdentifier("app.settingsButton")
.accessibilityLabel(String(localized: "settings.title"))
.liquidGlassTransitionSource(id: "appSettings", in: sheetTransition)
}
}
} detail: {
detailForSelection
}
.sheet(isPresented: $showingSettings) {
SettingsView()
.liquidGlassSheetContent(sourceID: "appSettings", in: sheetTransition)
}
.onChange(of: appEnvironment?.navigationCoordinator.dismissSettingsTrigger) {
showingSettings = false
} }
.tabViewStyle(.sidebarAdaptable)
.zoomTransitionNamespace(zoomTransition) .zoomTransitionNamespace(zoomTransition)
.onAppear { .onAppear {
configureSidebarManager() configureSidebarManager()
@@ -420,149 +444,18 @@ struct UnifiedTabView: View {
settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder
} }
// MARK: - Tab Builders // MARK: - Sidebar
@TabContentBuilder<SidebarItem> private var sidebar: some View {
private var mainTabs: some TabContent<SidebarItem> { List(selection: $selection) {
ForEach(visibleMainItems) { item in ForEach(visibleMainItems) { item in
mainTab(for: item) sidebarRow(for: item)
}
} }
@TabContentBuilder<SidebarItem>
private func mainTab(for item: SidebarMainItem) -> some TabContent<SidebarItem> {
switch item {
case .search:
Tab(value: SidebarItem.search, role: .search) {
NavigationStack(path: $searchPath) {
SearchView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.search.title, systemImage: SidebarItem.search.systemImage)
}
case .home:
Tab(value: SidebarItem.home) {
NavigationStack(path: $homePath) {
HomeView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.home.title, systemImage: SidebarItem.home.systemImage)
}
case .subscriptions:
Tab(value: SidebarItem.subscriptionsFeed) {
NavigationStack(path: $subscriptionsFeedPath) {
SubscriptionsView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.subscriptionsFeed.title, systemImage: SidebarItem.subscriptionsFeed.systemImage)
}
case .bookmarks:
Tab(value: SidebarItem.bookmarks) {
NavigationStack(path: $bookmarksPath) {
BookmarksListView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.bookmarks.title, systemImage: SidebarItem.bookmarks.systemImage)
}
case .history:
Tab(value: SidebarItem.history) {
NavigationStack(path: $historyPath) {
HistoryListView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.history.title, systemImage: SidebarItem.history.systemImage)
}
case .downloads:
Tab(value: SidebarItem.downloads) {
NavigationStack(path: $downloadsPath) {
DownloadsView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.downloads.title, systemImage: SidebarItem.downloads.systemImage)
}
case .channels:
Tab(value: SidebarItem.manageChannels) {
NavigationStack(path: $manageChannelsPath) {
ManageChannelsView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.manageChannels.title, systemImage: SidebarItem.manageChannels.systemImage)
}
case .playlists:
Tab(value: SidebarItem.playlistsList) {
NavigationStack(path: $playlistsListPath) {
PlaylistsListView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.playlistsList.title, systemImage: SidebarItem.playlistsList.systemImage)
}
case .sources:
Tab(value: SidebarItem.sources) {
NavigationStack(path: $sourcesPath) {
MediaSourcesView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.sources.title, systemImage: SidebarItem.sources.systemImage)
}
case .settings:
Tab(value: SidebarItem.settings) {
NavigationStack(path: $settingsPath) {
SettingsView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage)
}
case .openURL:
Tab(value: SidebarItem.openURL) {
NavigationStack(path: $openURLPath) {
OpenLinkView().withNavigationDestinations()
}
} label: {
Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage)
}
case .remoteControl:
Tab(value: SidebarItem.remoteControl) {
NavigationStack(path: $remoteControlPath) {
RemoteControlContentView(navigationStyle: .link)
.navigationTitle(String(localized: "remoteControl.title"))
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.remoteControl.title, systemImage: SidebarItem.remoteControl.systemImage)
}
case .continueWatching:
Tab(value: SidebarItem.continueWatching) {
NavigationStack(path: $continueWatchingPath) {
ContinueWatchingView()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.continueWatching.title, systemImage: SidebarItem.continueWatching.systemImage)
}
}
}
@TabContentBuilder<SidebarItem>
private var sidebarSections: some TabContent<SidebarItem> {
// Unified Sources Section (instances + media sources)
if !sidebarManager.hasNoSources && (settingsManager?.sidebarSourcesEnabled ?? true) { if !sidebarManager.hasNoSources && (settingsManager?.sidebarSourcesEnabled ?? true) {
TabSection(String(localized: "sidebar.section.sources")) { Section(String(localized: "sidebar.section.sources")) {
ForEach(sidebarManager.sortedSourceItems) { item in ForEach(sidebarManager.sortedSourceItems) { item in
Tab(value: item) { NavigationLink(value: item) {
sourceContent(for: item)
} label: {
Label(item.title, systemImage: item.systemImage) Label(item.title, systemImage: item.systemImage)
} }
} }
@@ -570,11 +463,9 @@ struct UnifiedTabView: View {
} }
if !sidebarManager.channelItems.isEmpty && (settingsManager?.sidebarChannelsEnabled ?? true) { if !sidebarManager.channelItems.isEmpty && (settingsManager?.sidebarChannelsEnabled ?? true) {
TabSection(String(localized: "sidebar.section.channels")) { Section(String(localized: "sidebar.section.channels")) {
ForEach(sidebarManager.channelItems) { item in ForEach(sidebarManager.channelItems) { item in
Tab(value: item) { NavigationLink(value: item) {
channelContent(for: item)
} label: {
channelLabel(for: item) channelLabel(for: item)
} }
} }
@@ -582,17 +473,95 @@ struct UnifiedTabView: View {
} }
if !sidebarManager.playlistItems.isEmpty && (settingsManager?.sidebarPlaylistsEnabled ?? true) { if !sidebarManager.playlistItems.isEmpty && (settingsManager?.sidebarPlaylistsEnabled ?? true) {
TabSection(String(localized: "sidebar.section.playlists")) { Section(String(localized: "sidebar.section.playlists")) {
ForEach(sidebarManager.playlistItems) { item in ForEach(sidebarManager.playlistItems) { item in
Tab(value: item) { NavigationLink(value: item) {
playlistContent(for: item)
} label: {
playlistLabel(for: item) playlistLabel(for: item)
} }
} }
} }
} }
} }
}
@ViewBuilder
private func sidebarRow(for item: SidebarMainItem) -> some View {
let sidebarItem = item.sidebarItem
NavigationLink(value: sidebarItem) {
Label(sidebarItem.title, systemImage: sidebarItem.systemImage)
}
}
// MARK: - Detail
@ViewBuilder
private var detailForSelection: some View {
switch selection {
case .home:
NavigationStack(path: $homePath) {
HomeView().withNavigationDestinations()
}
case .search:
NavigationStack(path: $searchPath) {
SearchView().withNavigationDestinations()
}
case .subscriptionsFeed:
NavigationStack(path: $subscriptionsFeedPath) {
SubscriptionsView().withNavigationDestinations()
}
case .bookmarks:
NavigationStack(path: $bookmarksPath) {
BookmarksListView().withNavigationDestinations()
}
case .history:
NavigationStack(path: $historyPath) {
HistoryListView().withNavigationDestinations()
}
case .downloads:
NavigationStack(path: $downloadsPath) {
DownloadsView().withNavigationDestinations()
}
case .manageChannels:
NavigationStack(path: $manageChannelsPath) {
ManageChannelsView().withNavigationDestinations()
}
case .playlistsList:
NavigationStack(path: $playlistsListPath) {
PlaylistsListView().withNavigationDestinations()
}
case .sources:
NavigationStack(path: $sourcesPath) {
MediaSourcesView().withNavigationDestinations()
}
case .settings:
NavigationStack(path: $settingsPath) {
SettingsView().withNavigationDestinations()
}
case .openURL:
NavigationStack(path: $openURLPath) {
OpenLinkView().withNavigationDestinations()
}
case .remoteControl:
NavigationStack(path: $remoteControlPath) {
RemoteControlContentView(navigationStyle: .link)
.navigationTitle(String(localized: "remoteControl.title"))
.withNavigationDestinations()
}
case .continueWatching:
NavigationStack(path: $continueWatchingPath) {
ContinueWatchingView()
.withNavigationDestinations()
}
case .channel:
channelContent(for: selection)
case .playlist:
playlistContent(for: selection)
case .mediaSource, .instance:
sourceContent(for: selection)
case .nowPlaying:
EmptyView()
}
}
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
} }

View File

@@ -167,6 +167,7 @@ struct YatteeApp: App {
} }
#if os(macOS) #if os(macOS)
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.defaultSize(width: 1200, height: 800) .defaultSize(width: 1200, height: 800)
// Handle URLs in the existing window instead of opening a new one // Handle URLs in the existing window instead of opening a new one
.handlesExternalEvents(matching: Set(["*"])) .handlesExternalEvents(matching: Set(["*"]))