Files
yattee/Yattee/Services/Navigation/NavigationRouter.swift
Arkadiusz Fal fd6a97ce5c Keep the video info carousel aligned across window resizes
The carousel's lazy stack kept the old widths of cards it had laid out
off-screen, so after a resize (tab bar to split view sidebar on iPad,
rotation) every later card sat at a stale offset and the current card
landed beside the viewport. The stack now takes a new identity per
card width, and the existing re-snap aligns the current card.

Swapping between the tab bar and the sidebar also rebuilds the page,
which reset the carousel to the video it was opened on. The page's
position and continuation-loaded videos now live on its queue context,
fresh for every push, so a rebuilt page resumes where it was.
2026-10-14 08:22:58 +02:00

191 lines
7.2 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()
/// Set when Settings was requested in a layout without a Settings section
/// (the iPhone tab bar, unless the user added the tab). Home presents its
/// Settings sheet and clears it.
var pendingSettingsSheet = false
@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: {
// Only the selected section's stack is on screen, so only it
// edits its path (Back, swipe back). A NavigationSplitView
// detail stack that is being replaced on a selection change
// writes an empty path on its way out, which would throw
// away what the section had pushed.
guard key == self.selection.id else { return }
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) {
var destination = destination
// A row hands out the same context on every tap until it re-renders,
// so a page pushed again must not inherit the earlier page's position.
if case .video(let source, var queueContext?) = destination {
queueContext.pageState = VideoInfoPageState()
destination = .video(source, queueContext: queueContext)
}
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
case .settings:
host = .home
root = nil
pendingSettingsSheet = true
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. The `.tabBar` style opts out.
static func usesSidebar(
horizontalSizeClass: UserInterfaceSizeClass?,
verticalSizeClass: UserInterfaceSizeClass?,
style: NavigationStyle = .automatic
) -> Bool {
guard style == .automatic, horizontalSizeClass == .regular else { return false }
return verticalSizeClass == .regular || UIDevice.current.userInterfaceIdiom == .pad
}
}
#endif