From 03be964c66522a05b2d5dda464a6f77f4d9391f4 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 8 Oct 2026 18:25:32 +0200 Subject: [PATCH] Load a single link from Open Link into its details screen Open Link gets a Load action that resolves one link and shows its video details page, playlist or channel screen instead of only playing or downloading it. Play and Download stay as shortcuts for video links. Timed links start at their timestamp from the details page, the Show Details link action opens the details page, and a short extraction cache avoids re-extracting the same link for details and playback. --- Yattee/Localizable.xcstrings | 17 +- Yattee/Models/Player/VideoQueueContext.swift | 8 +- Yattee/Services/API/ContentService.swift | 18 +- .../Navigation/LinkVideoResolver.swift | 35 ++++ .../Navigation/NavigationCoordinator.swift | 10 + Yattee/Views/Home/OpenLinkSheet.swift | 171 ++++++++++++------ Yattee/Views/Video/VideoInfoView.swift | 15 +- Yattee/YatteeApp.swift | 39 +++- 8 files changed, 245 insertions(+), 68 deletions(-) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 08fa1c62..6e612521 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -7363,6 +7363,17 @@ } } }, + "openLink.load" : { + "comment" : "Button label: show the link's video, playlist or channel screen", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Load" + } + } + } + }, "openLink.noInstance" : { "comment" : "Error message", "localizations" : { @@ -7402,7 +7413,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open" + "value" : "Play" } } } @@ -7413,7 +7424,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open All" + "value" : "Play All" } } } @@ -12230,7 +12241,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ask Every Time" + "value" : "Show Details" } } } diff --git a/Yattee/Models/Player/VideoQueueContext.swift b/Yattee/Models/Player/VideoQueueContext.swift index aef3b38a..f52c2648 100644 --- a/Yattee/Models/Player/VideoQueueContext.swift +++ b/Yattee/Models/Player/VideoQueueContext.swift @@ -47,6 +47,10 @@ struct VideoQueueContext { /// When set, playback from VideoInfoView must route through /// `QueueManager.playFromMediaBrowser` using this payload. var mediaBrowserPlayback: MediaBrowserPlaybackInfo? = nil + + /// Position requested by an opened timed link (`?t=`). Unlike `startTime` + /// (a resume position), it is honored as-is and skips the resume prompt. + var linkStartTime: TimeInterval? = nil /// Whether this context has valid queue information var hasQueueInfo: Bool { @@ -91,7 +95,8 @@ extension VideoQueueContext: Equatable { lhs.videoList == rhs.videoList && lhs.videoIndex == rhs.videoIndex && lhs.startTime == rhs.startTime && - lhs.mediaBrowserPlayback == rhs.mediaBrowserPlayback + lhs.mediaBrowserPlayback == rhs.mediaBrowserPlayback && + lhs.linkStartTime == rhs.linkStartTime } } @@ -104,5 +109,6 @@ extension VideoQueueContext: Hashable { hasher.combine(videoIndex) hasher.combine(startTime) hasher.combine(mediaBrowserPlayback) + hasher.combine(linkStartTime) } } diff --git a/Yattee/Services/API/ContentService.swift b/Yattee/Services/API/ContentService.swift index 29ea234b..fbbb1f4e 100644 --- a/Yattee/Services/API/ContentService.swift +++ b/Yattee/Services/API/ContentService.swift @@ -676,9 +676,25 @@ actor ContentService: ContentServiceProtocol { guard instance.type == .yatteeServer else { throw APIError.notSupported } - return try await yatteeServerAPI(for: instance).extractURL(url, instance: instance) + // Keyed by link only: callers pick the Yattee Server differently, and + // any server returns equivalent streams within the TTL. + let key = url.absoluteString + if let cached = recentExtractions[key], Date().timeIntervalSince(cached.date) < Self.extractionCacheTTL { + return cached.result + } + let result = try await yatteeServerAPI(for: instance).extractURL(url, instance: instance) + recentExtractions = recentExtractions.filter { Date().timeIntervalSince($0.value.date) < Self.extractionCacheTTL } + recentExtractions[key] = (result, Date()) + return result } + /// How long an extraction is reused. A link opened from Open Link is + /// extracted to show its details page, which re-extracts for full details, + /// and Play extracts again; all three share one Yattee Server request. + /// Short, so a retry after a failed stream soon fetches fresh URLs. + private static let extractionCacheTTL: TimeInterval = 60 + private var recentExtractions: [String: (result: (video: Video, streams: [Stream], captions: [Caption]), date: Date)] = [:] + /// Extracts channel/user videos from any URL that yt-dlp supports. /// Requires a Yattee Server instance. /// diff --git a/Yattee/Services/Navigation/LinkVideoResolver.swift b/Yattee/Services/Navigation/LinkVideoResolver.swift index 2004ec93..95d14fbc 100644 --- a/Yattee/Services/Navigation/LinkVideoResolver.swift +++ b/Yattee/Services/Navigation/LinkVideoResolver.swift @@ -61,6 +61,41 @@ struct LinkVideoResolver { } } + /// Resolves a link to the screen that shows it: a video's details page + /// (loaded here so a bad link fails before navigating), or the playlist / + /// channel screen, which loads its own content. A `?t=` timestamp is + /// carried to the details page's Play. + func resolveDestination(_ url: URL) async throws -> NavigationDestination { + let router = URLRouter() + let url = router.unwrapped(url) + let startTime = router.parseTimestamp(url) + + func details(_ video: Video) -> NavigationDestination { + var context = VideoQueueContext.single(video) + context.linkStartTime = startTime + return .video(.loaded(video), queueContext: context) + } + + switch router.route(url) { + case .video(.id(let videoID), _): + guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else { + throw LinkResolveError.noInstanceAvailable + } + let video = try await appEnvironment.contentService.video(id: videoID.videoID, instance: instance) + return details(video) + + case .video(.loaded(let video), _): + return details(video) + + case .directMedia, .externalVideo, nil: + let (video, _, _, _) = try await resolve(url) + return details(video) + + case .some(let destination): + return destination + } + } + #if !os(tvOS) /// Enqueues a single video for download with already-fetched streams. func enqueueDownload( diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index c36b9ce8..d8d1f3e6 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -312,6 +312,16 @@ final class NavigationCoordinator { router.select(.subscriptionsFeed) } + /// Navigate to an opened link's screen, collapsing the full-screen player + /// so the pushed screen is visible instead of landing underneath it. + func navigateToOpenedLink(_ destination: NavigationDestination) { + if isPlayerExpanded { + isPlayerCollapsing = true + isPlayerExpanded = false + } + navigate(to: destination) + } + /// Navigate to a video by its ID string. /// Creates a VideoID assuming YouTube source. func navigateToVideo(videoID: String) { diff --git a/Yattee/Views/Home/OpenLinkSheet.swift b/Yattee/Views/Home/OpenLinkSheet.swift index f0f30de5..0128b7e6 100644 --- a/Yattee/Views/Home/OpenLinkSheet.swift +++ b/Yattee/Views/Home/OpenLinkSheet.swift @@ -2,7 +2,9 @@ // OpenLinkSheet.swift // Yattee // -// Sheet for entering URLs to play or download via Yattee Server's yt-dlp extraction. +// Sheet for entering links: Load shows a single link's video, playlist or +// channel screen; Play and Download act on video links directly (YouTube, +// PeerTube, direct media, or anything Yattee Server's yt-dlp extracts). // Supports multiple URLs (one per line) with batch processing. // @@ -264,33 +266,45 @@ struct OpenLinkFormView: View { .foregroundStyle(.secondary) .font(.subheadline) } else { - Button { - isTextEditorFocused = false - Task { await downloadAllURLs() } - } label: { - Label( - isMultipleURLs - ? String(localized: "openLink.downloadAll") - : String(localized: "openLink.download"), - systemImage: "arrow.down.circle" - ) - } - .disabled(!isValidInput) + if allLinksAreVideos { + Button { + isTextEditorFocused = false + Task { await downloadAllURLs() } + } label: { + Label( + isMultipleURLs + ? String(localized: "openLink.downloadAll") + : String(localized: "openLink.download"), + systemImage: "arrow.down.circle" + ) + } + .disabled(!isValidInput) - Button { - isTextEditorFocused = false - Task { await openAllURLs() } - } label: { - Label( - isMultipleURLs - ? String(localized: "openLink.openAll") - : String(localized: "openLink.open"), - systemImage: "play.fill" - ) + Button { + isTextEditorFocused = false + Task { await openAllURLs() } + } label: { + Label( + isMultipleURLs + ? String(localized: "openLink.openAll") + : String(localized: "openLink.open"), + systemImage: "play.fill" + ) + } + .disabled(!isValidInput) + } + + if !isMultipleURLs { + Button { + isTextEditorFocused = false + Task { await loadURL() } + } label: { + Label(String(localized: "openLink.load"), systemImage: "arrow.right.circle") + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: .command) + .disabled(!isValidInput) } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.return, modifiers: .command) - .disabled(!isValidInput) } } } @@ -530,35 +544,49 @@ struct OpenLinkFormView: View { .foregroundStyle(.secondary) } } else { - // Open/Play button - Button { - isTextEditorFocused = false - Task { await openAllURLs() } - } label: { - Label( - isMultipleURLs - ? String(localized: "openLink.openAll") - : String(localized: "openLink.open"), - systemImage: "play.fill" - ) + // Load: show the link's video, playlist or channel screen + if !isMultipleURLs { + Button { + isTextEditorFocused = false + Task { await loadURL() } + } label: { + Label(String(localized: "openLink.load"), systemImage: "arrow.right.circle") + .fontWeight(.semibold) + } + .disabled(!isValidInput) } - .disabled(!isValidInput) - #if !os(tvOS) - // Download button - Button { - isTextEditorFocused = false - Task { await downloadAllURLs() } - } label: { - Label( - isMultipleURLs - ? String(localized: "openLink.downloadAll") - : String(localized: "openLink.download"), - systemImage: "arrow.down.circle" - ) + if allLinksAreVideos { + // Play shortcut + Button { + isTextEditorFocused = false + Task { await openAllURLs() } + } label: { + Label( + isMultipleURLs + ? String(localized: "openLink.openAll") + : String(localized: "openLink.open"), + systemImage: "play.fill" + ) + } + .disabled(!isValidInput) + + #if !os(tvOS) + // Download shortcut + Button { + isTextEditorFocused = false + Task { await downloadAllURLs() } + } label: { + Label( + isMultipleURLs + ? String(localized: "openLink.downloadAll") + : String(localized: "openLink.download"), + systemImage: "arrow.down.circle" + ) + } + .disabled(!isValidInput) + #endif } - .disabled(!isValidInput) - #endif } } } @@ -626,6 +654,18 @@ struct OpenLinkFormView: View { } } + /// Whether every parsed link is a video, so Play and Download apply. + /// Playlists and channels can only be loaded. + private var allLinksAreVideos: Bool { + let router = URLRouter() + return parsedURLs.allSatisfy { url in + switch router.route(url) { + case .video, .externalVideo, .directMedia, nil: return true + default: return false + } + } + } + // MARK: - Clipboard private func checkClipboard() { @@ -656,6 +696,33 @@ struct OpenLinkFormView: View { .map { $0 } } + // MARK: - Load Action + + /// Resolves the single link and shows its screen: a video's details page, + /// or the playlist / channel screen. Errors stay in the form. + private func loadURL() async { + guard let url = parsedURLs.first, !isMultipleURLs, let appEnvironment else { return } + + isExtracting = true + hasErrors = false + extractedItems = [ExtractedItem(url: url, status: .extracting)] + + do { + let destination = try await LinkVideoResolver(appEnvironment: appEnvironment).resolveDestination(url) + if case .video(.loaded(let video), _) = destination { + extractedItems[0].video = video + } + extractedItems[0].status = .success + isExtracting = false + dismissIfRequested() + appEnvironment.navigationCoordinator.navigateToOpenedLink(destination) + } catch { + extractedItems[0].status = .failed(error.localizedDescription) + hasErrors = true + isExtracting = false + } + } + // MARK: - Open (Play) Action private func openAllURLs() async { diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index d2a3a439..dc94b9bb 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -2400,7 +2400,8 @@ struct VideoInfoView: View { // Skip API fetch for local media sources (SMB, WebDAV, local files) if extractor == MediaFile.smbProvider || extractor == MediaFile.webdavProvider - || extractor == MediaFile.localFolderProvider { + || extractor == MediaFile.localFolderProvider + || extractor == DirectMediaHelper.provider { isLoadingVideoDetails = false return } @@ -2535,6 +2536,14 @@ struct VideoInfoView: View { return } + // An opened timed link asks for an exact position - honor it over any + // saved progress, without the resume prompt. + if let linkStartTime = videoQueueContext?.linkStartTime, + videoQueueContext?.video.id == video.id { + playVideoWithStartTime(linkStartTime, forceStartTime: true) + return + } + // Get saved watch progress from database let savedProgress = env.dataManager.watchProgress(for: video.id.videoID) let videoDuration = video.duration @@ -2570,7 +2579,7 @@ struct VideoInfoView: View { } /// Plays the video with the specified start time. - private func playVideoWithStartTime(_ time: TimeInterval) { + private func playVideoWithStartTime(_ time: TimeInterval, forceStartTime: Bool = false) { guard let video = displayedVideo else { return } // Media-browser playback must go through `playFromMediaBrowser` so the @@ -2599,7 +2608,7 @@ struct VideoInfoView: View { let list = context.videoList else { // No queue context - play single video if time > 0 { - playerService?.openVideo(video, startTime: time) + playerService?.openVideo(video, startTime: time, forceStartTime: forceStartTime) } else { playerService?.openVideo(video) } diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 9f475f38..06380515 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -619,8 +619,10 @@ struct YatteeApp: App { #endif case .ask: - // Show OpenLinkSheet with URL pre-filled - showOpenLinkSheetWithURL(originalURL) + // Show the video's details page, carrying the link's timestamp + Task { + await showLinkDetails(originalURL, fallback: .video(.id(videoID))) + } } } @@ -643,15 +645,36 @@ struct YatteeApp: App { #endif case .ask: - // Show OpenLinkSheet with URL pre-filled - showOpenLinkSheetWithURL(externalURL) + // Extract, then show the video's details page + Task { + await showLinkDetails(externalURL, fallback: .externalVideo(externalURL)) + } } } - /// Show OpenLinkSheet with a pre-filled URL. - private func showOpenLinkSheetWithURL(_ url: URL) { - // The top-most sheet host (the player when expanded) presents it - appEnvironment.navigationCoordinator.presenter.present(.openLink(url)) + /// Load a link's video and show its details page. On failure, `fallback` + /// (a screen that loads the link itself) shows the error. + private func showLinkDetails(_ url: URL, fallback: NavigationDestination) async { + let toastID = appEnvironment.toastManager.show( + scopes: [.main, .player], + category: .loading, + title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"), + subtitle: url.host, + autoDismissDelay: 60.0 + ) + + let destination: NavigationDestination + do { + destination = try await LinkVideoResolver(appEnvironment: appEnvironment).resolveDestination(url) + } catch { + LoggingService.shared.warning( + "Deep link details: failed to load \(url.absoluteString): \(error.localizedDescription)", + category: .general + ) + destination = fallback + } + appEnvironment.toastManager.dismiss(id: toastID) + appEnvironment.navigationCoordinator.navigateToOpenedLink(destination) } /// Play a video from a deep link.