mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
The sidebar layout was gated on iPad idiom. It now appears wherever there is room for it: regular width and regular height, or an iPad with regular width (a short Stage Manager window keeps it). That covers the Duo inner screen in both orientations, while large iPhones in landscape and the Duo outer screen keep the tab bar. The subscriptions feed's channel sidebar follows the same rule, and Sidebar settings are offered on devices with a hinge. SwiftUI's sidebarAdaptable TabView never shows a sidebar on iPhone idiom, so the iOS sidebar layout now uses the NavigationSplitView with a list sidebar that macOS already used, on iPad as well. The sidebar layout shows the floating mini player instead of the tab bar accessory.
166 lines
5.9 KiB
Swift
166 lines
5.9 KiB
Swift
//
|
|
// NavigationRouter.swift
|
|
// Yattee
|
|
//
|
|
// Layout-independent navigation state: which section is selected and what is
|
|
// pushed in each section's stack. The tab bar and the sidebar layouts both
|
|
// render this state, so swapping between them (iPad Stage Manager resize,
|
|
// iPhone Duo fold/unfold) keeps the user where they were.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class NavigationRouter {
|
|
/// The selected section. Both the tab bar and the sidebar bind to this.
|
|
var selection: SidebarItem = .home {
|
|
didSet {
|
|
guard selection != oldValue else { return }
|
|
onSelectionChange?(selection)
|
|
}
|
|
}
|
|
|
|
/// Pushed destinations per section, keyed by `SidebarItem.id` so that a
|
|
/// renamed channel or playlist keeps its stack.
|
|
private(set) var stacks: [String: [NavigationDestination]] = [:]
|
|
|
|
/// Search field text, shared so it survives a layout swap.
|
|
var searchText = ""
|
|
|
|
/// Sidebar channels, playlists and sources. Owned here rather than by the
|
|
/// sidebar layout so it is created and loaded once.
|
|
let sidebarManager = SidebarManager()
|
|
|
|
@ObservationIgnored private var hasAppliedStartupTab = false
|
|
@ObservationIgnored private var isSidebarManagerConfigured = false
|
|
|
|
/// Called after every selection change (Handoff updates).
|
|
@ObservationIgnored var onSelectionChange: ((SidebarItem) -> Void)?
|
|
|
|
// MARK: - Stacks
|
|
|
|
func path(for item: SidebarItem) -> Binding<[NavigationDestination]> {
|
|
let key = item.id
|
|
return Binding(
|
|
get: { self.stacks[key] ?? [] },
|
|
set: { self.stacks[key] = $0 }
|
|
)
|
|
}
|
|
|
|
func stack(for item: SidebarItem) -> [NavigationDestination] {
|
|
stacks[item.id] ?? []
|
|
}
|
|
|
|
/// Pushes a destination onto the selected section's stack.
|
|
func push(_ destination: NavigationDestination) {
|
|
stacks[selection.id, default: []].append(destination)
|
|
}
|
|
|
|
func popToRoot(_ item: SidebarItem? = nil) {
|
|
stacks[(item ?? selection).id] = []
|
|
}
|
|
|
|
func select(_ item: SidebarItem) {
|
|
selection = item
|
|
}
|
|
|
|
// MARK: - Startup
|
|
|
|
/// Applies the configured startup section once per app run. Later layout
|
|
/// swaps keep the current selection instead of jumping back to it.
|
|
///
|
|
/// A destination pushed before the first layout appeared (a cold-launch
|
|
/// deep link or Handoff) wins over the startup preference.
|
|
func applyStartupTabIfNeeded(_ startupTab: SidebarMainItem) {
|
|
guard !hasAppliedStartupTab else { return }
|
|
hasAppliedStartupTab = true
|
|
guard stack(for: selection).isEmpty else { return }
|
|
selection = startupTab.sidebarItem
|
|
}
|
|
|
|
func configureSidebarManagerIfNeeded(_ appEnvironment: AppEnvironment) {
|
|
guard !isSidebarManagerConfigured else { return }
|
|
isSidebarManagerConfigured = true
|
|
sidebarManager.configure(
|
|
dataManager: appEnvironment.dataManager,
|
|
settingsManager: appEnvironment.settingsManager,
|
|
mediaSourcesManager: appEnvironment.mediaSourcesManager,
|
|
instancesManager: appEnvironment.instancesManager,
|
|
accountsManager: appEnvironment.accountsManager,
|
|
subscriptionService: appEnvironment.subscriptionService,
|
|
remotePlaylistsCache: appEnvironment.remotePlaylistsCache,
|
|
contextValidator: appEnvironment.dataContextValidator
|
|
)
|
|
}
|
|
|
|
// MARK: - Layout Mapping
|
|
|
|
/// Makes the selection one the current layout can show.
|
|
///
|
|
/// When the selected section has no tab or row of its own in this layout
|
|
/// (a sidebar channel shown in the tab bar, a tab hidden by customization),
|
|
/// the user lands on the closest section that can host it, with the
|
|
/// section's own screen pushed first and its stack kept after it.
|
|
///
|
|
/// - Parameters:
|
|
/// - isAvailable: whether the layout shows `item` as a tab or row.
|
|
/// - instance: resolves an instance ID for `.instance` items.
|
|
func ensureSelectionIsAvailable(
|
|
isAvailable: (SidebarItem) -> Bool,
|
|
instance: (UUID) -> Instance? = { _ in nil }
|
|
) {
|
|
let current = selection
|
|
guard !isAvailable(current) else { return }
|
|
|
|
let host: SidebarItem
|
|
let root: NavigationDestination?
|
|
switch current {
|
|
case .channel(let channelID, _, let source):
|
|
host = isAvailable(.subscriptionsFeed) ? .subscriptionsFeed : .home
|
|
root = .channel(channelID, source)
|
|
case .playlist, .remotePlaylist:
|
|
host = isAvailable(.playlistsList) ? .playlistsList : .home
|
|
root = current.navigationDestination()
|
|
case .mediaSource(let id, _, _):
|
|
host = isAvailable(.sources) ? .sources : .home
|
|
root = .mediaSource(id)
|
|
case .instance(let id, _, _):
|
|
host = isAvailable(.sources) ? .sources : .home
|
|
root = instance(id).map { .instanceBrowse($0) }
|
|
case .sources:
|
|
host = .home
|
|
root = .mediaSources
|
|
default:
|
|
host = .home
|
|
root = current.navigationDestination()
|
|
}
|
|
|
|
let carried = stack(for: current)
|
|
if let root {
|
|
stacks[host.id] = [root] + carried
|
|
}
|
|
stacks[current.id] = []
|
|
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
|