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.