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.
This commit is contained in:
Arkadiusz Fal
2026-10-08 18:25:32 +02:00
parent b9e7d88a12
commit 03be964c66
8 changed files with 245 additions and 68 deletions

View File

@@ -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"
}
}
}

View File

@@ -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)
}
}

View File

@@ -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.
///

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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.