Files
yattee/Yattee/Services/Navigation/NavigationDestination.swift
Arkadiusz Fal 8d4f472348 Unify macOS content view backgrounds with opaque window background
Views without an explicit background sat on the translucent window
material, which picks up a wallpaper tint (bluish in dark mode) and
clashed with views drawing windowBackgroundColor, making the video
info header gradient fade visibly mismatch. Add opaqueWindowBackground()
and apply it centrally in NavigationDestinationHandlerModifier to both
stack roots and pushed destinations, plus VideoInfoView for
presentations outside navigation stacks.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-06 09:36:49 +02:00

209 lines
7.6 KiB
Swift

//
// NavigationDestination.swift
// Yattee
//
// Navigation destinations for the app.
//
import Foundation
import SwiftUI
/// Source for video navigation - either a loaded video or just an ID to fetch.
enum VideoSource: Hashable, Sendable {
case id(VideoID)
case loaded(Video)
}
/// Navigation destinations for the app.
enum NavigationDestination: Hashable {
/// Video info page - can be initialized with either a loaded video or just an ID.
case video(VideoSource, queueContext: VideoQueueContext? = nil)
case channel(String, ContentSource)
case playlist(PlaylistSource)
case continueWatching
case downloads
case downloadsStorage
case subscriptionsFeed
case settings
case playlists
case bookmarks
case history
case manageChannels
case search(String)
/// External video URL to be extracted via Yattee Server.
case externalVideo(URL)
/// External channel URL to be extracted via Yattee Server.
case externalChannel(URL)
/// Direct media URL (mp4, m3u8, etc.) to play without extraction.
case directMedia(URL)
/// Media sources list.
case mediaSources
/// Browse a specific media source by ID (for sidebar navigation).
case mediaSource(UUID)
/// Browse a specific media source at a path.
case mediaBrowser(MediaSource, path: String, showOnlyPlayable: Bool = false)
/// Browse a specific instance (Popular/Trending).
case instanceBrowse(Instance, initialTab: InstanceBrowseView.BrowseTab? = nil)
/// Import subscriptions from an instance.
case importSubscriptions(instance: Instance)
/// Import playlists from an instance.
case importPlaylists(instance: Instance)
}
extension NavigationDestination {
/// Returns the transition ID for zoom navigation animations, if applicable.
///
/// Used to connect source views (NavigationLinks) with destination views
/// for smooth zoom transitions. Returns nil for destinations that don't
/// support zoom transitions.
var transitionID: AnyHashable? {
switch self {
case .video(let source, _):
switch source {
case .id(let videoID): return videoID
case .loaded(let video): return video.id
}
case .channel(let channelID, _):
return channelID
case .playlist(let source):
return source.transitionID
default:
return nil
}
}
@ViewBuilder
func view() -> some View {
switch self {
case .video(let source, let queueContext):
switch source {
case .id(let videoID):
VideoInfoView(videoID: videoID)
.videoQueueContext(queueContext)
case .loaded(let video):
VideoInfoView(video: video)
.videoQueueContext(queueContext)
}
case .channel(let channelID, let source):
ChannelView(channelID: channelID, source: source)
case .playlist(let source):
UnifiedPlaylistDetailView(source: source)
case .continueWatching:
ContinueWatchingView()
case .downloads:
#if os(tvOS)
ContentUnavailableView {
Label(String(localized: "home.downloads.title"), systemImage: "arrow.down.circle")
} description: {
Text(String(localized: "home.downloads.notAvailable"))
}
#else
DownloadsView()
#endif
case .downloadsStorage:
#if os(tvOS)
ContentUnavailableView {
Label(String(localized: "settings.downloads.storage.title"), systemImage: "arrow.down.circle")
} description: {
Text(String(localized: "home.downloads.notAvailable"))
}
#else
DownloadsStorageView()
#endif
case .subscriptionsFeed:
SubscriptionsView()
case .settings:
SettingsView()
case .playlists:
PlaylistsListView()
case .bookmarks:
BookmarksListView()
case .history:
HistoryListView()
case .manageChannels:
ManageChannelsView()
case .search(let query):
SearchView(initialQuery: query)
case .externalVideo(let url):
ExternalVideoView(url: url)
case .directMedia(let url):
// Direct media URLs are typically handled inline by OpenLinkSheet,
// but if navigated to directly, show video info with the created video
VideoInfoView(video: DirectMediaHelper.createVideo(from: url))
case .externalChannel(let url):
// Use unified ChannelView with external channel URL
ChannelView(
channelID: url.absoluteString,
source: .extracted(extractor: "external", originalURL: url),
channelURL: url
)
case .mediaSources:
MediaSourcesView()
case .mediaSource(let id):
MediaSourceByIDView(sourceID: id)
case .mediaBrowser(let source, let path, let showOnlyPlayable):
MediaBrowserView(source: source, path: path, showOnlyPlayable: showOnlyPlayable)
case .instanceBrowse(let instance, let initialTab):
InstanceBrowseView(instance: instance, initialTab: initialTab)
case .importSubscriptions(let instance):
ImportSubscriptionsView(instance: instance)
case .importPlaylists(let instance):
ImportPlaylistsView(instance: instance)
}
}
}
// MARK: - Media Source by ID View
/// Helper view that looks up a MediaSource by ID and shows MediaBrowserView.
private struct MediaSourceByIDView: View {
let sourceID: UUID
@Environment(\.appEnvironment) private var appEnvironment
var body: some View {
if let source = appEnvironment?.mediaSourcesManager.source(byID: sourceID) {
MediaBrowserView(source: source, path: "/")
} else {
ContentUnavailableView {
Label(String(localized: "navigation.sourceNotFound"), systemImage: "externaldrive.badge.exclamationmark")
} description: {
Text(String(localized: "navigation.sourceNotFound.description"))
}
}
}
}
// MARK: - Navigation Destination Modifier
/// A view modifier that registers navigation destination handlers for all app destinations.
/// Apply this to views that contain NavigationLink(value: NavigationDestination) to ensure
/// the navigation stack can resolve all destination types.
///
/// Also applies zoom navigation transitions for supported destinations (video, channel, playlist)
/// when a zoom transition namespace is available in the environment.
struct NavigationDestinationHandlerModifier: ViewModifier {
func body(content: Content) -> some View {
content
.opaqueWindowBackground()
.navigationDestination(for: NavigationDestination.self) { destination in
Group {
if let transitionID = destination.transitionID {
destination.view()
.zoomTransitionDestination(id: transitionID)
} else {
destination.view()
}
}
.opaqueWindowBackground()
}
}
}
extension View {
/// Adds navigation destination handlers for all app navigation destinations.
/// Use this on views within a NavigationStack that contain NavigationLink(value:).
func withNavigationDestinations() -> some View {
modifier(NavigationDestinationHandlerModifier())
}
}