Files
yattee/Yattee/Services/Navigation/NavigationDestination.swift
Arkadiusz Fal 8a7218ed1b Show several opened links as mixed results
Load All in Open Link opens an Opened Links page that resolves each link
into a video, playlist or channel row like search results, in input
order. Video rows keep the usual context menu and swipe actions, and the
page can play, queue, bookmark or download all resolved videos.
2026-10-08 22:36:57 +02:00

213 lines
7.8 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)
/// Several links opened at once, resolved into mixed results.
case openedLinks([URL])
}
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)
case .openedLinks(let urls):
OpenedLinksView(urls: urls)
}
}
}
// 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())
}
}