mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
The tab bar and sidebar layouts each kept their own navigation paths and selection, so swapping between them (resizing an iPad Stage Manager window across the size-class breakpoint) lost every pushed screen and re-applied the startup tab. NavigationRouter now holds the selected section, a typed stack per section keyed by SidebarItem.id, the search text and the sidebar manager. Both layouts select with SidebarItem and render the router's state; navigate(to:) pushes onto it directly instead of going through an observed pendingNavigation, so a request made before a layout appears is not lost, and a destination pushed before startup keeps its section. When a layout can't show the selection (a sidebar channel in the tab bar, a section hidden by customization) it lands on the section that can host it with that screen pushed first. This also fixes keyboard section commands in the tab bar layout and the coordinator's stale tab on iPad. AppTab, pendingNavigation, selectedSidebarItem and the unused SheetDestination/path API are gone, and the iOS 18 and 26.1 root views are merged into AdaptiveTabView.
142 lines
4.7 KiB
Swift
142 lines
4.7 KiB
Swift
//
|
|
// CompactTabView.swift
|
|
// Yattee
|
|
//
|
|
// Custom tab bar for compact size class (iPhone, iPad Stage Manager small window).
|
|
// Uses settings-based tab customization since Apple's TabViewCustomization only works in sidebar mode.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS)
|
|
struct CompactTabView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
let router: NavigationRouter
|
|
|
|
// Zoom transition namespace (local to this tab view)
|
|
@Namespace private var zoomTransition
|
|
|
|
private var settingsManager: SettingsManager? {
|
|
appEnvironment?.settingsManager
|
|
}
|
|
|
|
/// Returns the visible custom tabs from settings
|
|
private var visibleTabItems: [TabBarItem] {
|
|
settingsManager?.visibleTabBarItems() ?? []
|
|
}
|
|
|
|
/// Whether to show the mini player accessory (iOS 26.1+)
|
|
private var shouldShowAccessory: Bool {
|
|
guard let state = appEnvironment?.playerService.state else { return false }
|
|
return state.currentVideo != nil && !state.isClosingVideo
|
|
}
|
|
|
|
private var zoomTransitionsEnabled: Bool {
|
|
appEnvironment?.settingsManager.zoomTransitionsEnabled ?? true
|
|
}
|
|
|
|
var body: some View {
|
|
@Bindable var router = router
|
|
|
|
TabView(selection: $router.selection) {
|
|
// Fixed: Home (first)
|
|
Tab(value: SidebarItem.home) {
|
|
NavigationStack(path: router.path(for: .home)) {
|
|
HomeView()
|
|
.withNavigationDestinations()
|
|
}
|
|
} label: {
|
|
Label(String(localized: "tabs.home"), systemImage: "house.fill")
|
|
}
|
|
.accessibilityIdentifier("tab.home")
|
|
|
|
// Dynamic tabs from settings (in the middle, SwiftUI auto-collapses overflow into More)
|
|
ForEach(visibleTabItems) { item in
|
|
Tab(value: Self.sidebarItem(for: item)) {
|
|
NavigationStack(path: router.path(for: Self.sidebarItem(for: item))) {
|
|
tabContent(for: item)
|
|
.withNavigationDestinations()
|
|
}
|
|
} label: {
|
|
Label(item.localizedTitle, systemImage: item.icon)
|
|
}
|
|
}
|
|
|
|
// Fixed: Search (last) - with role: .search
|
|
Tab(value: SidebarItem.search, role: .search) {
|
|
NavigationStack(path: router.path(for: .search)) {
|
|
SearchView(searchText: $router.searchText)
|
|
.withNavigationDestinations()
|
|
}
|
|
} label: {
|
|
Label(String(localized: "tabs.search"), systemImage: "magnifyingglass")
|
|
}
|
|
.accessibilityIdentifier("tab.search")
|
|
}
|
|
.zoomTransitionNamespace(zoomTransition)
|
|
.zoomTransitionsEnabled(zoomTransitionsEnabled)
|
|
.iOS26TabFeatures(shouldShowAccessory: shouldShowAccessory, settingsManager: settingsManager)
|
|
.onAppear {
|
|
router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForTabBar() ?? .home)
|
|
ensureSelectionIsAvailable()
|
|
}
|
|
.onChange(of: router.selection) { _, _ in
|
|
ensureSelectionIsAvailable()
|
|
}
|
|
.onChange(of: visibleTabItems) { _, _ in
|
|
ensureSelectionIsAvailable()
|
|
}
|
|
}
|
|
|
|
// MARK: - Layout Mapping
|
|
|
|
private static func sidebarItem(for item: TabBarItem) -> SidebarItem {
|
|
SidebarMainItem(tabBarItem: item)?.sidebarItem ?? .home
|
|
}
|
|
|
|
/// Sidebar-only selections (channels, playlists, sources) and tabs hidden
|
|
/// by customization are remapped onto a tab that can host them.
|
|
private func ensureSelectionIsAvailable() {
|
|
let available = Set([SidebarItem.home, .search] + visibleTabItems.map(Self.sidebarItem(for:)))
|
|
router.ensureSelectionIsAvailable(
|
|
isAvailable: { available.contains($0) },
|
|
instance: { id in appEnvironment?.instancesManager.enabledInstances.first { $0.id == id } }
|
|
)
|
|
}
|
|
|
|
// MARK: - Tab Content
|
|
|
|
@ViewBuilder
|
|
private func tabContent(for item: TabBarItem) -> some View {
|
|
switch item {
|
|
case .subscriptions:
|
|
SubscriptionsView()
|
|
case .channels:
|
|
ManageChannelsView()
|
|
case .bookmarks:
|
|
BookmarksListView()
|
|
case .playlists:
|
|
PlaylistsListView()
|
|
case .history:
|
|
HistoryListView()
|
|
case .downloads:
|
|
DownloadsView()
|
|
case .sources:
|
|
MediaSourcesView()
|
|
case .settings:
|
|
SettingsView(showCloseButton: false)
|
|
case .continueWatching:
|
|
ContinueWatchingView()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
CompactTabView(router: NavigationRouter())
|
|
.appEnvironment(.preview)
|
|
}
|
|
#endif
|