mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user