diff --git a/Yattee/ContentView.swift b/Yattee/ContentView.swift index fb082182..0255a678 100644 --- a/Yattee/ContentView.swift +++ b/Yattee/ContentView.swift @@ -174,6 +174,7 @@ struct ContentView: View { /// selection and every stack. struct AdaptiveTabView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass let appEnvironment: AppEnvironment @@ -199,7 +200,7 @@ struct AdaptiveTabView: View { var body: some View { ZStack(alignment: .bottom) { - if horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .phone { + if !usesSidebar { CompactTabView(router: appEnvironment.navigationCoordinator.router) .environment(appEnvironment.settingsManager) } else { @@ -207,8 +208,9 @@ struct AdaptiveTabView: View { .environment(appEnvironment.settingsManager) } - // iOS 26.1+ shows the mini player as the tab bar's bottom accessory - if !Self.usesTabBottomAccessory { + // The tab bar layout on iOS 26.1+ shows the mini player as the tab + // bar's bottom accessory; the split-view sidebar layout has no tab bar. + if usesSidebar || !Self.usesTabBottomAccessory { miniPlayerOverlay } @@ -263,6 +265,10 @@ struct AdaptiveTabView: View { } } + private var usesSidebar: Bool { + NavigationLayout.usesSidebar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) + } + private static var usesTabBottomAccessory: Bool { if #available(iOS 26.1, *) { true } else { false } } @@ -279,9 +285,9 @@ struct AdaptiveTabView: View { MiniPlayerView() } .padding(.horizontal, 16) - // Compact width has a bottom tab bar (49pt) to clear; regular width - // (sidebarAdaptable) keeps its tabs at the top, so only float above the edge. - .padding(.bottom, horizontalSizeClass == .compact ? 49 + 8 : 12) + // The tab bar layout has a bottom tab bar (49pt) to clear; the sidebar + // layout (sidebarAdaptable) keeps its tabs at the top, so only float above the edge. + .padding(.bottom, usesSidebar ? 12 : 49 + 8) // Use move-only transition (no opacity) to prevent thumbnail flash during collapse // The opacity transition caused video to appear faded during player sheet dismiss .transition(.move(edge: .bottom)) diff --git a/Yattee/Services/Navigation/NavigationRouter.swift b/Yattee/Services/Navigation/NavigationRouter.swift index d63f55a7..6f97a337 100644 --- a/Yattee/Services/Navigation/NavigationRouter.swift +++ b/Yattee/Services/Navigation/NavigationRouter.swift @@ -144,3 +144,22 @@ final class NavigationRouter { selection = host } } + +// MARK: - Layout Choice + +#if os(iOS) +enum NavigationLayout { + /// Whether the sidebar layout fits: regular width and height (iPad, the + /// iPhone Duo inner screen in either orientation). A large iPhone in + /// landscape is regular width but compact height, so it keeps the tab + /// bar. An iPad keeps the sidebar for as long as its width is regular, + /// even in a short Stage Manager window. + static func usesSidebar( + horizontalSizeClass: UserInterfaceSizeClass?, + verticalSizeClass: UserInterfaceSizeClass? + ) -> Bool { + guard horizontalSizeClass == .regular else { return false } + return verticalSizeClass == .regular || UIDevice.current.userInterfaceIdiom == .pad + } +} +#endif diff --git a/Yattee/Views/Navigation/UnifiedTabView.swift b/Yattee/Views/Navigation/UnifiedTabView.swift index 90e59e3a..b407447b 100644 --- a/Yattee/Views/Navigation/UnifiedTabView.swift +++ b/Yattee/Views/Navigation/UnifiedTabView.swift @@ -9,276 +9,9 @@ import SwiftUI -// MARK: - iOS Implementation +// MARK: - iOS Tab Bar Helpers #if os(iOS) -struct UnifiedTabView: View { - @Environment(\.appEnvironment) private var appEnvironment - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - - let router: NavigationRouter - - // Zoom transition namespace (local to this tab view) - @Namespace private var zoomTransition - - private var shouldShowAccessory: Bool { - guard let state = appEnvironment?.playerService.state else { return false } - return state.currentVideo != nil - } - - private var zoomTransitionsEnabled: Bool { - appEnvironment?.settingsManager.zoomTransitionsEnabled ?? true - } - - private var yatteeServerAuthHeader: String? { - guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil } - return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) - } - - var body: some View { - @Bindable var router = router - - TabView(selection: $router.selection) { - mainTabs - sidebarSections - } - .tabViewStyle(.sidebarAdaptable) - .tabViewSidebarBottomBar { - // Same chrome the macOS sidebar hangs below its list: the sidebar - // is the one surface every tab shares, so the switcher lives here - // rather than on each screen's toolbar. - if DataContextSwitcherVisibility.showsPickers(in: appEnvironment) { - // The bottom bar pins over the list without a background of its - // own, so sidebar rows scrolled to the end show through the - // switcher. A pill of its own keeps what's beneath it blurred. - DataContextSwitcherSidebarRow() - .padding(.horizontal, 14) - .padding(.vertical, 10) - .sidebarBottomBarPill() - .frame(maxWidth: .infinity) - } - } - .iOS26TabFeatures(shouldShowAccessory: shouldShowAccessory, settingsManager: appEnvironment?.settingsManager) - .zoomTransitionNamespace(zoomTransition) - .zoomTransitionsEnabled(zoomTransitionsEnabled) - .onAppear { - configureSidebarManager() - router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home) - ensureSelectionIsAvailable(visibleMainItems: visibleMainItems) - } - .onChange(of: router.selection) { _, _ in - ensureSelectionIsAvailable(visibleMainItems: visibleMainItems) - } - } - - // MARK: - Visible Main Items - - private var visibleMainItems: [SidebarMainItem] { - settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder - } - - // MARK: - Tab Builders - - @TabContentBuilder - private var mainTabs: some TabContent { - ForEach(visibleMainItems) { item in - mainTab(for: item) - } - } - - @TabContentBuilder - private func mainTab(for item: SidebarMainItem) -> some TabContent { - switch item { - case .search: - Tab(value: SidebarItem.search, role: .search) { - NavigationStack(path: router.path(for: .search)) { - SearchView(searchText: Bindable(router).searchText) - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.search.title, systemImage: SidebarItem.search.systemImage) - } - - case .home: - Tab(value: SidebarItem.home) { - NavigationStack(path: router.path(for: .home)) { - HomeView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.home.title, systemImage: SidebarItem.home.systemImage) - } - - case .subscriptions: - Tab(value: SidebarItem.subscriptionsFeed) { - NavigationStack(path: router.path(for: .subscriptionsFeed)) { - SubscriptionsView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.subscriptionsFeed.title, systemImage: SidebarItem.subscriptionsFeed.systemImage) - } - - case .bookmarks: - Tab(value: SidebarItem.bookmarks) { - NavigationStack(path: router.path(for: .bookmarks)) { - BookmarksListView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.bookmarks.title, systemImage: SidebarItem.bookmarks.systemImage) - } - - case .history: - Tab(value: SidebarItem.history) { - NavigationStack(path: router.path(for: .history)) { - HistoryListView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.history.title, systemImage: SidebarItem.history.systemImage) - } - - case .downloads: - Tab(value: SidebarItem.downloads) { - NavigationStack(path: router.path(for: .downloads)) { - DownloadsView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.downloads.title, systemImage: SidebarItem.downloads.systemImage) - } - - case .channels: - Tab(value: SidebarItem.manageChannels) { - NavigationStack(path: router.path(for: .manageChannels)) { - ManageChannelsView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.manageChannels.title, systemImage: SidebarItem.manageChannels.systemImage) - } - - case .playlists: - Tab(value: SidebarItem.playlistsList) { - NavigationStack(path: router.path(for: .playlistsList)) { - PlaylistsListView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.playlistsList.title, systemImage: SidebarItem.playlistsList.systemImage) - } - - case .sources: - Tab(value: SidebarItem.sources) { - NavigationStack(path: router.path(for: .sources)) { - MediaSourcesView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.sources.title, systemImage: SidebarItem.sources.systemImage) - } - - case .settings: - Tab(value: SidebarItem.settings) { - NavigationStack(path: router.path(for: .settings)) { - SettingsView(showCloseButton: false) - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage) - } - - case .openURL: - Tab(value: SidebarItem.openURL) { - NavigationStack(path: router.path(for: .openURL)) { - OpenLinkView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage) - } - - case .remoteControl: - Tab(value: SidebarItem.remoteControl) { - NavigationStack(path: router.path(for: .remoteControl)) { - 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: router.path(for: .continueWatching)) { - ContinueWatchingView() - .withNavigationDestinations() - } - } label: { - Label(SidebarItem.continueWatching.title, systemImage: SidebarItem.continueWatching.systemImage) - } - } - } - - @TabContentBuilder - private var sidebarSections: some TabContent { - // Unified Sources Section (sidebar only - shows instances + media sources) - TabSection(String(localized: "sidebar.section.sources")) { - ForEach(sidebarManager.sortedSourceItems) { item in - Tab(value: item) { - sourceContent(for: item) - } label: { - Label(item.title, systemImage: item.systemImage) - } - } - } - .defaultVisibility(.hidden, for: .tabBar) - .hidden( - horizontalSizeClass == .compact || - sidebarManager.hasNoSources || - !(settingsManager?.sidebarSourcesEnabled ?? true) - ) - - // Channels Section (sidebar only - shows subscribed channels) - TabSection(String(localized: "sidebar.section.channels")) { - ForEach(sidebarManager.channelItems) { item in - Tab(value: item) { - channelContent(for: item) - } label: { - channelLabel(for: item) - } - } - } - .defaultVisibility(.hidden, for: .tabBar) - .hidden( - horizontalSizeClass == .compact || - sidebarManager.channelItems.isEmpty || - !(settingsManager?.sidebarChannelsEnabled ?? true) - ) - - // Playlists Section (sidebar only) - TabSection(String(localized: "sidebar.section.playlists")) { - ForEach(sidebarManager.playlistItems) { item in - Tab(value: item) { - playlistContent(for: item) - } label: { - playlistLabel(for: item) - } - } - } - .defaultVisibility(.hidden, for: .tabBar) - .hidden( - horizontalSizeClass == .compact || - sidebarManager.playlistItems.isEmpty || - !(settingsManager?.sidebarPlaylistsEnabled ?? true) - ) - } - - private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } -} - private extension View { @ViewBuilder func sidebarBottomBarPill() -> some View { @@ -322,15 +55,14 @@ extension MiniPlayerMinimizeBehavior { } } -#Preview("Unified Tab View - iOS") { - UnifiedTabView(router: NavigationRouter()) - .appEnvironment(.preview) -} #endif -// MARK: - macOS Implementation +// MARK: - iOS and macOS Implementation -#if os(macOS) +#if os(iOS) || os(macOS) +/// Sidebar layout for iPad, the iPhone Duo inner screen and macOS: a +/// split view with a list sidebar. SwiftUI's sidebarAdaptable TabView never +/// shows a sidebar on iPhone idiom, so the split view is used on iOS too. struct UnifiedTabView: View { @Environment(\.appEnvironment) private var appEnvironment @@ -344,6 +76,12 @@ struct UnifiedTabView: View { return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server) } + #if os(iOS) + private var zoomTransitionsEnabled: Bool { + appEnvironment?.settingsManager.zoomTransitionsEnabled ?? true + } + #endif + var body: some View { NavigationSplitView { sidebar @@ -351,6 +89,9 @@ struct UnifiedTabView: View { detailForSelection } .zoomTransitionNamespace(zoomTransition) + #if os(iOS) + .zoomTransitionsEnabled(zoomTransitionsEnabled) + #endif .onAppear { configureSidebarManager() router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home) @@ -385,13 +126,29 @@ struct UnifiedTabView: View { } #else sidebarList + .safeAreaInset(edge: .bottom) { + if DataContextSwitcherVisibility.showsPickers(in: appEnvironment) { + // The sidebar is the one surface every section shares, so + // the switcher lives here rather than on each screen's toolbar. + DataContextSwitcherSidebarRow() + .padding(.horizontal, 14) + .padding(.vertical, 10) + .sidebarBottomBarPill() + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + } #endif } private var sidebarList: some View { - @Bindable var router = router + // List selection is optional; deselecting keeps the current section. + let selection = Binding( + get: { router.selection }, + set: { if let item = $0 { router.selection = item } } + ) - return List(selection: $router.selection) { + return List(selection: selection) { ForEach(visibleMainItems) { item in sidebarRow(for: item) } @@ -448,7 +205,7 @@ struct UnifiedTabView: View { } case .search: NavigationStack(path: router.path(for: .search)) { - SearchView().withNavigationDestinations() + SearchView(searchText: Bindable(router).searchText).withNavigationDestinations() } case .subscriptionsFeed: NavigationStack(path: router.path(for: .subscriptionsFeed)) { @@ -480,7 +237,11 @@ struct UnifiedTabView: View { } case .settings: NavigationStack(path: router.path(for: .settings)) { + #if os(iOS) + SettingsView(showCloseButton: false).withNavigationDestinations() + #else SettingsView().withNavigationDestinations() + #endif } case .openURL: NavigationStack(path: router.path(for: .openURL)) { @@ -511,7 +272,7 @@ struct UnifiedTabView: View { private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } } -#Preview("Unified Tab View - macOS") { +#Preview("Unified Tab View") { UnifiedTabView(router: NavigationRouter()) .appEnvironment(.preview) } diff --git a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift index 8d48a4f1..7f897531 100644 --- a/Yattee/Views/Settings/LayoutNavigationSettingsView.swift +++ b/Yattee/Views/Settings/LayoutNavigationSettingsView.swift @@ -78,6 +78,10 @@ private struct HapticsSection: View { // MARK: - Customization Section private struct CustomizationSection: View { + #if os(iOS) + @State private var hasHinge = HingeLayoutObserver.forceTabletop + #endif + var body: some View { SettingsFormSection { #if os(tvOS) @@ -100,8 +104,9 @@ private struct CustomizationSection: View { TabBarSettingsView() } - // Sidebar settings only on iPad (not iPhone) - if UIDevice.current.userInterfaceIdiom == .pad { + // Sidebar settings where the sidebar layout can appear: iPad and + // foldable iPhones (inner screen) + if UIDevice.current.userInterfaceIdiom == .pad || hasHinge { SettingsNavigationRow("settings.sidebar.title", systemImage: "sidebar.leading") { SidebarSettingsView() } @@ -123,6 +128,9 @@ private struct CustomizationSection: View { } #endif } + #if os(iOS) + .observeHinge(hasHinge: $hasHinge) + #endif } } diff --git a/Yattee/Views/Subscriptions/SubscriptionsView.swift b/Yattee/Views/Subscriptions/SubscriptionsView.swift index ff585fc4..c9717345 100644 --- a/Yattee/Views/Subscriptions/SubscriptionsView.swift +++ b/Yattee/Views/Subscriptions/SubscriptionsView.swift @@ -19,6 +19,7 @@ struct SubscriptionsView: View { #endif #if os(iOS) @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass #endif @State private var feedCache = SubscriptionFeedCache.shared @State private var subscriptions: [Subscription] = [] @@ -1138,8 +1139,10 @@ struct SubscriptionsView: View { private static let iPadSidebarMinWidth = 220.0 private static let iPadSidebarMaxWidth = 420.0 + /// Room for the channel sidebar next to the feed: wherever the app itself + /// uses the sidebar layout (iPad, iPhone Duo inner screen). private var isIPadRegular: Bool { - UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .regular + NavigationLayout.usesSidebar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass) } private var clampedIPadSidebarWidth: CGFloat {