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

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