Files
yattee/Yattee/Views/Navigation/UnifiedTabView.swift

777 lines
28 KiB
Swift

//
// UnifiedTabView.swift
// Yattee
//
// Unified tab view with sidebar sections.
// Uses TabSection to group navigation items and display user data (channels, playlists).
// iOS 26.1+ gets additional features: bottom accessory mini player and tab bar minimize behavior.
//
import SwiftUI
// MARK: - iOS Tab Bar Helpers
#if os(iOS)
private extension View {
@ViewBuilder
func sidebarBottomBarPill() -> some View {
if #available(iOS 26, *) {
glassEffect(.regular, in: .capsule)
} else {
background(.regularMaterial, in: Capsule())
}
}
}
// iOS 26.1+ Tab Features
extension View {
@ViewBuilder
func iOS26TabFeatures(shouldShowAccessory: Bool, settingsManager: SettingsManager?) -> some View {
if #available(iOS 26.1, *) {
let behavior = settingsManager?.miniPlayerMinimizeBehavior.tabBarMinimizeBehavior ?? .onScrollDown
self
.tabViewBottomAccessory(isEnabled: shouldShowAccessory) {
MiniPlayerView(isTabAccessory: true)
}
.tabBarMinimizeBehavior(behavior)
// iOS 27 stopped focusing the search field when the search tab is
// selected; restore the iOS 26 behavior explicitly.
.tabViewSearchActivation(.searchTabSelection)
} else {
self
}
}
}
@available(iOS 26, *)
extension MiniPlayerMinimizeBehavior {
var tabBarMinimizeBehavior: TabBarMinimizeBehavior {
switch self {
case .onScrollDown:
return .onScrollDown
case .never:
return .never
}
}
}
#endif
// MARK: - iOS and macOS Implementation
#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
let router: NavigationRouter
// Zoom transition namespace (local to this tab view)
@Namespace private var zoomTransition
private var yatteeServerAuthHeader: String? {
guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil }
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
}
#if os(iOS)
private var zoomTransitionsEnabled: Bool {
appEnvironment?.settingsManager.zoomTransitionsEnabled ?? true
}
#endif
#if os(iOS)
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
/// Global trailing edge of the sidebar column and leading edge of the split
/// view, so the mini player can sit over the detail column.
@State private var sidebarTrailingEdge: CGFloat = 0
@State private var splitViewLeadingEdge: CGFloat = 0
@State private var miniPlayerClearance: CGFloat = 0
#endif
var body: some View {
splitView
.zoomTransitionNamespace(zoomTransition)
#if os(iOS)
.zoomTransitionsEnabled(zoomTransitionsEnabled)
.onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).minX } action: {
splitViewLeadingEdge = $0
}
// The detail column absorbs its NavigationStack, so modifiers on the
// stack only reach its root page and vanish on push. The mini player
// floats over the whole split view instead, and every page reserves
// room for it through the environment.
.overlay(alignment: .bottom) { miniPlayerOverlay }
.environment(\.miniPlayerBottomClearance, miniPlayerClearance)
#endif
.onAppear {
configureSidebarManager()
router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home)
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
}
.onChange(of: router.selection) { _, _ in
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
}
.onChange(of: sidebarManager.sortedSourceItems) { _, items in
leaveRemovedSource(remaining: items)
}
}
@ViewBuilder
private var splitView: some View {
#if os(iOS)
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
.onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).maxX } action: {
sidebarTrailingEdge = $0
}
} detail: {
detailForSelection
}
#else
NavigationSplitView {
sidebar
} detail: {
detailForSelection
}
#endif
}
// MARK: - Visible Main Items
private var visibleMainItems: [SidebarMainItem] {
settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder
}
// MARK: - Sidebar
private var sidebar: some View {
#if os(macOS)
VStack(spacing: 0) {
sidebarList
if DataContextSwitcherVisibility.showsPickers(in: appEnvironment) {
Divider()
// The window has no toolbar of its own to hang this off, and the
// sidebar is the one chrome every tab shares.
DataContextSwitcherSettingsRow()
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
#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 {
// List selection is optional; deselecting keeps the current section.
let selection = Binding<SidebarItem?>(
get: { router.selection },
set: { if let item = $0 { router.selection = item } }
)
return List(selection: selection) {
ForEach(visibleMainItems) { item in
sidebarRow(for: item)
}
if !sidebarManager.hasNoSources && (settingsManager?.sidebarSourcesEnabled ?? true) {
Section(String(localized: "sidebar.section.sources")) {
ForEach(sidebarManager.sortedSourceItems) { item in
NavigationLink(value: item) {
Label(item.title, systemImage: item.systemImage)
}
}
}
}
if !sidebarManager.channelItems.isEmpty && (settingsManager?.sidebarChannelsEnabled ?? true) {
Section(String(localized: "sidebar.section.channels")) {
ForEach(sidebarManager.channelItems) { item in
NavigationLink(value: item) {
channelLabel(for: item)
}
}
}
}
if !sidebarManager.playlistItems.isEmpty && (settingsManager?.sidebarPlaylistsEnabled ?? true) {
Section(String(localized: "sidebar.section.playlists")) {
ForEach(sidebarManager.playlistItems) { item in
NavigationLink(value: 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 {
let selection = router.selection
switch selection {
case .home:
NavigationStack(path: router.path(for: .home)) {
HomeView().withNavigationDestinations()
}
case .search:
NavigationStack(path: router.path(for: .search)) {
SearchView(searchText: Bindable(router).searchText).withNavigationDestinations()
}
case .subscriptionsFeed:
NavigationStack(path: router.path(for: .subscriptionsFeed)) {
SubscriptionsView().withNavigationDestinations()
}
case .bookmarks:
NavigationStack(path: router.path(for: .bookmarks)) {
BookmarksListView().withNavigationDestinations()
}
case .history:
NavigationStack(path: router.path(for: .history)) {
HistoryListView().withNavigationDestinations()
}
case .downloads:
NavigationStack(path: router.path(for: .downloads)) {
DownloadsView().withNavigationDestinations()
}
case .manageChannels:
NavigationStack(path: router.path(for: .manageChannels)) {
ManageChannelsView().withNavigationDestinations()
}
case .playlistsList:
NavigationStack(path: router.path(for: .playlistsList)) {
PlaylistsListView().withNavigationDestinations()
}
case .sources:
NavigationStack(path: router.path(for: .sources)) {
MediaSourcesView().withNavigationDestinations()
}
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)) {
OpenLinkView().withNavigationDestinations()
}
case .remoteControl:
NavigationStack(path: router.path(for: .remoteControl)) {
RemoteControlContentView(navigationStyle: .link)
.navigationTitle(String(localized: "remoteControl.title"))
.withNavigationDestinations()
}
case .continueWatching:
NavigationStack(path: router.path(for: .continueWatching)) {
ContinueWatchingView()
.withNavigationDestinations()
}
case .channel:
channelContent(for: selection)
case .playlist, .remotePlaylist:
playlistContent(for: selection)
case .mediaSource, .instance:
sourceContent(for: selection)
case .nowPlaying:
EmptyView()
}
}
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
#if os(iOS)
/// The split view has no tab bar to host the mini player as a bottom
/// accessory, so it floats at the bottom of the detail column and pages
/// reserve its height as bottom safe area: floating content such as the
/// subscriptions channel strip and the last rows of every list then clear
/// it, as they clear the tab accessory.
private var miniPlayerOverlay: some View {
let hasActiveVideo = appEnvironment?.playerService.state.currentVideo != nil
let sidebarWidth = columnVisibility == .detailOnly
? 0
: max(0, sidebarTrailingEdge - splitViewLeadingEdge)
// Stays mounted under the expanded player: the dismiss gesture morphs
// the video into its preview, so it must already be in place
return VStack(spacing: 0) {
if hasActiveVideo {
MiniPlayerView()
.padding(.horizontal, 16)
.padding(.bottom, 12)
// Move-only transition (no opacity) prevents a thumbnail flash during collapse
.transition(.move(edge: .bottom))
}
}
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: {
miniPlayerClearance = $0
}
.padding(.leading, sidebarWidth)
.animation(.spring(response: 0.3), value: hasActiveVideo)
}
#endif
}
#Preview("Unified Tab View") {
UnifiedTabView(router: NavigationRouter())
.appEnvironment(.preview)
}
#endif
// MARK: - tvOS Implementation
#if os(tvOS)
struct UnifiedTabView: View {
@Environment(\.appEnvironment) private var appEnvironment
let router: NavigationRouter
// Zoom transition namespace (local to this tab view)
@Namespace private var zoomTransition
private var shouldShowNowPlaying: Bool {
guard let state = appEnvironment?.playerService.state else { return false }
let isExpanded = appEnvironment?.navigationCoordinator.isPlayerExpanded ?? false
return state.currentVideo != nil && !isExpanded
}
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)
.zoomTransitionNamespace(zoomTransition)
.onAppear {
configureSidebarManager()
router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home)
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
}
.onChange(of: router.selection) { oldValue, _ in
// Pop the previously-selected tab's stack to its root so its
// pushed views don't linger after the user picks another item.
// Before the remap, which may write the stack of `oldValue`.
router.popToRoot(oldValue)
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
// Force any pushed TVSidebarDetailContainer (used by Settings
// sub-pages and similar detail screens) to dismiss. Without this,
// tvOS's sidebarAdaptable TabView leaves the previously-pushed
// detail visible until the user manually presses Menu.
NotificationCenter.default.post(name: .yatteeTVForcePopDetail, object: nil)
}
.onChange(of: sidebarManager.sortedSourceItems) { _, items in
leaveRemovedSource(remaining: items)
}
}
// MARK: - Visible Main Items
private var visibleMainItems: [SidebarMainItem] {
settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder
}
// MARK: - Tab Content Builders
@TabContentBuilder<SidebarItem>
private var mainTabs: some TabContent<SidebarItem> {
// Now Playing (only shown when video is playing and player collapsed)
if shouldShowNowPlaying,
let currentVideo = appEnvironment?.playerService.state.currentVideo {
Tab(value: SidebarItem.nowPlaying) {
Color.clear
.onAppear {
appEnvironment?.navigationCoordinator.expandPlayer()
// Reset selection to prevent immediate re-trigger when player is collapsed
router.select(.home)
}
} label: {
Label("\(currentVideo.title)\n\(currentVideo.author.name)", systemImage: SidebarItem.nowPlaying.systemImage)
}
}
ForEach(visibleMainItems) { item in
mainTab(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: router.path(for: .search)) {
SearchView()
.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:
// Downloads not available on tvOS
// This case won't be reached due to isAvailableOnCurrentPlatform filtering
Tab(value: SidebarItem.home) {
EmptyView()
}
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()
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage)
}
case .openURL:
Tab(value: SidebarItem.openURL) {
NavigationStack(path: router.path(for: .openURL)) {
TVSidebarDetailContainer(
systemImage: SidebarItem.openURL.systemImage,
title: SidebarItem.openURL.title
) {
OpenLinkView()
}
.withNavigationDestinations()
}
} label: {
Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage)
}
case .remoteControl:
Tab(value: SidebarItem.remoteControl) {
NavigationStack(path: router.path(for: .remoteControl)) {
TVSidebarDetailContainer(
systemImage: SidebarItem.remoteControl.systemImage,
title: SidebarItem.remoteControl.title
) {
RemoteControlContentView(navigationStyle: .link)
}
.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<SidebarItem>
private var sidebarSections: some TabContent<SidebarItem> {
// Sources Section (shows configured instances and media sources)
if !sidebarManager.sortedSourceItems.isEmpty && (settingsManager?.sidebarSourcesEnabled ?? true) {
TabSection(String(localized: "sidebar.section.sources")) {
ForEach(sidebarManager.sortedSourceItems) { item in
Tab(value: item) {
sourceContent(for: item)
} label: {
Label(item.title, systemImage: item.systemImage)
}
}
}
}
// Channels Section (shows subscribed channels)
if !sidebarManager.channelItems.isEmpty && (settingsManager?.sidebarChannelsEnabled ?? true) {
TabSection(String(localized: "sidebar.section.channels")) {
ForEach(sidebarManager.channelItems) { item in
Tab(value: item) {
channelContent(for: item)
} label: {
channelLabel(for: item)
}
}
}
}
// Playlists Section
if !sidebarManager.playlistItems.isEmpty && (settingsManager?.sidebarPlaylistsEnabled ?? true) {
TabSection(String(localized: "sidebar.section.playlists")) {
ForEach(sidebarManager.playlistItems) { item in
Tab(value: item) {
playlistContent(for: item)
} label: {
playlistLabel(for: item)
}
}
}
}
}
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
}
#Preview("Unified Tab View - tvOS") {
UnifiedTabView(router: NavigationRouter())
.appEnvironment(.preview)
}
#endif
// MARK: - Shared Helpers
extension UnifiedTabView {
// MARK: - Computed Properties
var sidebarManager: SidebarManager {
router.sidebarManager
}
// MARK: - Configuration
func configureSidebarManager() {
guard let appEnvironment else { return }
router.configureSidebarManagerIfNeeded(appEnvironment)
}
// MARK: - Layout Mapping
/// Main items hidden from the sidebar by customization (or selected in the
/// tab bar layout) are remapped onto a row that can host them.
func ensureSelectionIsAvailable(visibleMainItems: [SidebarMainItem]) {
let available = Set(visibleMainItems.map(\.sidebarItem))
router.ensureSelectionIsAvailable(
isAvailable: { item in
switch item {
case .channel, .playlist, .remotePlaylist, .mediaSource, .instance, .nowPlaying:
return true
default:
return available.contains(item)
}
},
instance: { id in appEnvironment?.instancesManager.enabledInstances.first { $0.id == id } }
)
}
/// A source removed (or disabled) while selected leaves its row with
/// nothing to render, so the user lands on Home instead of a blank page.
func leaveRemovedSource(remaining items: [SidebarItem]) {
let current = router.selection
guard current.isMediaSource || current.isInstance,
!items.contains(where: { $0.id == current.id }) else { return }
router.popToRoot(current)
router.select(.home)
}
// MARK: - Channel Content & Labels
@ViewBuilder
func channelContent(for item: SidebarItem) -> some View {
if case .channel(let channelID, _, let source) = item {
NavigationStack(path: router.path(for: item)) {
ChannelView(channelID: channelID, source: source)
.withNavigationDestinations()
}
.id(item.id)
}
}
@ViewBuilder
func channelLabel(for item: SidebarItem) -> some View {
if case .channel(_, let name, _) = item {
Label {
Text(name)
} icon: {
SidebarChannelIcon(
url: sidebarManager.avatarURL(for: item),
name: name,
authHeader: yatteeServerAuthHeader
)
}
}
}
// MARK: - Playlist Content & Labels
@ViewBuilder
func playlistContent(for item: SidebarItem) -> some View {
switch item {
case .playlist(let id, _):
NavigationStack(path: router.path(for: item)) {
UnifiedPlaylistDetailView(source: .local(id))
.withNavigationDestinations()
}
.id(item.id)
case .remotePlaylist(let id, let title, let accountID):
NavigationStack(path: router.path(for: item)) {
// No instance passed: the detail view resolves it from the
// account, same as SidebarItem.navigationDestination().
UnifiedPlaylistDetailView(source: .remote(id, instance: nil, title: title, accountID: accountID))
.withNavigationDestinations()
}
.id(item.id)
default:
EmptyView()
}
}
@ViewBuilder
func playlistLabel(for item: SidebarItem) -> some View {
if item.isPlaylist {
Label {
Text(item.title)
} icon: {
SidebarPlaylistIcon(url: sidebarManager.thumbnailURL(for: item))
}
}
}
// MARK: - Media Source Content
@ViewBuilder
func mediaSourceContent(for item: SidebarItem) -> some View {
if case .mediaSource(let id, _, _) = item,
let source = appEnvironment?.mediaSourcesManager.source(byID: id) {
NavigationStack(path: router.path(for: item)) {
MediaBrowserView(source: source, path: "/")
.withNavigationDestinations()
}
.id(item.id)
}
}
// MARK: - Instance Content
@ViewBuilder
func instanceContent(for item: SidebarItem) -> some View {
if case .instance(let id, _, _) = item,
let instance = appEnvironment?.instancesManager.enabledInstances.first(where: { $0.id == id }) {
NavigationStack(path: router.path(for: item)) {
InstanceBrowseView(instance: instance)
.withNavigationDestinations()
}
.id(item.id)
}
}
// MARK: - Unified Source Content
/// Renders content for any source item (instance or media source).
@ViewBuilder
func sourceContent(for item: SidebarItem) -> some View {
switch item {
case .instance:
instanceContent(for: item)
case .mediaSource:
mediaSourceContent(for: item)
default:
EmptyView()
}
}
}