mirror of
https://github.com/yattee/yattee.git
synced 2026-08-06 07:11:28 +00:00
Fix timed links resuming at watch position instead of URL timestamp
Timed links arrive wrapped as yattee://open?url=... from the share extension, but the timestamp was parsed from the wrapper URL, where a single-? form like youtu.be/ID?t=N hides t inside the url query item. With no startTime the player fell back to saved watch progress. - Add URLRouter.unwrapped(_:) resolving the wrapper to the inner URL (raw remainder after ?url=, since the share extension does not encode & and URLComponents would drop &t= parts) and use it in handleDeepLink - Thread forceStartTime through openVideo/playPreferringDownloaded/play so explicit link timestamps beat the 90%-watched restart threshold, clamped to just before the end; resume flows keep the old behavior - Carry the parsed timestamp through OpenLinkSheet's play action, which dropped it entirely - Cover timestamp parsing and wrapper unwrapping in NavigationTests
This commit is contained in:
@@ -96,6 +96,32 @@ struct URLRouter: Sendable {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Wrapper Unwrapping
|
||||||
|
|
||||||
|
/// Resolve a `yattee://open?url={encoded_url}` wrapper (share extension) to the inner URL.
|
||||||
|
/// Returns the input unchanged for any other URL. The wrapper's query holds the full
|
||||||
|
/// original link, so timestamp parsing must run against the unwrapped URL.
|
||||||
|
///
|
||||||
|
/// The share extension encodes with `.urlQueryAllowed`, which leaves `?`, `&`, and `=`
|
||||||
|
/// intact - URLComponents would split the inner URL's own query into separate wrapper
|
||||||
|
/// items (losing e.g. `&t=120`), so take the raw remainder after `?url=` instead.
|
||||||
|
func unwrapped(_ url: URL) -> URL {
|
||||||
|
guard url.scheme?.lowercased() == "yattee", url.host == "open",
|
||||||
|
let range = url.absoluteString.range(of: "?url=") else {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
let raw = String(url.absoluteString[range.upperBound...])
|
||||||
|
guard let decoded = raw.removingPercentEncoding,
|
||||||
|
let innerURL = URL(string: decoded) else {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
// Unwrap once more in case the wrapper was itself wrapped
|
||||||
|
if innerURL.scheme?.lowercased() == "yattee", innerURL.host == "open", innerURL != url {
|
||||||
|
return unwrapped(innerURL)
|
||||||
|
}
|
||||||
|
return innerURL
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Timestamp Parsing
|
// MARK: - Timestamp Parsing
|
||||||
|
|
||||||
/// Extract a timestamp (seconds) from a URL's query, supporting `t`, `time`, and `start`.
|
/// Extract a timestamp (seconds) from a URL's query, supporting `t`, `time`, and `start`.
|
||||||
|
|||||||
@@ -240,7 +240,9 @@ final class PlayerService {
|
|||||||
/// - stream: Optional specific stream to use (if provided, skips fetching streams from API)
|
/// - stream: Optional specific stream to use (if provided, skips fetching streams from API)
|
||||||
/// - audioStream: Optional separate audio stream (for video-only streams)
|
/// - audioStream: Optional separate audio stream (for video-only streams)
|
||||||
/// - startTime: Optional start time in seconds
|
/// - startTime: Optional start time in seconds
|
||||||
func play(video: Video, stream: Stream? = nil, audioStream: Stream? = nil, startTime: TimeInterval? = nil) async {
|
/// - forceStartTime: When true, startTime is an explicit user request (timed link,
|
||||||
|
/// chapter tap) and is honored even past the completion threshold
|
||||||
|
func play(video: Video, stream: Stream? = nil, audioStream: Stream? = nil, startTime: TimeInterval? = nil, forceStartTime: Bool = false) async {
|
||||||
// Downloaded/local files bypass stream selection (they arrive here as
|
// Downloaded/local files bypass stream selection (they arrive here as
|
||||||
// ready-made file:// streams), so audio mode is applied at this choke
|
// ready-made file:// streams), so audio mode is applied at this choke
|
||||||
// point instead of in selectStreams.
|
// point instead of in selectStreams.
|
||||||
@@ -427,7 +429,11 @@ final class PlayerService {
|
|||||||
} else if let startTime {
|
} else if let startTime {
|
||||||
// Explicit startTime provided - use it (0 means play from beginning, >0 means resume)
|
// Explicit startTime provided - use it (0 means play from beginning, >0 means resume)
|
||||||
// For quality switching with startTime > 0, honor the time unless video was completed
|
// For quality switching with startTime > 0, honor the time unless video was completed
|
||||||
if startTime > 0 && completionThreshold > 0 && startTime >= completionThreshold {
|
if forceStartTime {
|
||||||
|
// User-requested timestamp (timed link, chapter tap) - always honor it,
|
||||||
|
// clamped so an out-of-range value can't seek past the end.
|
||||||
|
seekTime = effectiveDuration > 0 ? min(startTime, max(0, effectiveDuration - 1)) : startTime
|
||||||
|
} else if startTime > 0 && completionThreshold > 0 && startTime >= completionThreshold {
|
||||||
seekTime = 0 // Video was completed, start over
|
seekTime = 0 // Video was completed, start over
|
||||||
} else {
|
} else {
|
||||||
seekTime = startTime
|
seekTime = startTime
|
||||||
@@ -951,7 +957,8 @@ final class PlayerService {
|
|||||||
video: Video,
|
video: Video,
|
||||||
fallbackStream: Stream? = nil,
|
fallbackStream: Stream? = nil,
|
||||||
fallbackAudioStream: Stream? = nil,
|
fallbackAudioStream: Stream? = nil,
|
||||||
startTime: TimeInterval? = nil
|
startTime: TimeInterval? = nil,
|
||||||
|
forceStartTime: Bool = false
|
||||||
) async {
|
) async {
|
||||||
// Check if this is a media source video needing on-demand resolution
|
// Check if this is a media source video needing on-demand resolution
|
||||||
// Uses unified method that fetches folder contents dynamically - works from any playback source
|
// Uses unified method that fetches folder contents dynamically - works from any playback source
|
||||||
@@ -959,7 +966,7 @@ final class PlayerService {
|
|||||||
do {
|
do {
|
||||||
let (stream, captions) = try await resolveMediaSourceStream(for: video)
|
let (stream, captions) = try await resolveMediaSourceStream(for: video)
|
||||||
currentDownload = nil
|
currentDownload = nil
|
||||||
await play(video: video, stream: stream, audioStream: nil, startTime: startTime)
|
await play(video: video, stream: stream, audioStream: nil, startTime: startTime, forceStartTime: forceStartTime)
|
||||||
|
|
||||||
// Set available captions and auto-select preferred
|
// Set available captions and auto-select preferred
|
||||||
if !captions.isEmpty {
|
if !captions.isEmpty {
|
||||||
@@ -983,7 +990,7 @@ final class PlayerService {
|
|||||||
if let (downloadedVideo, localStream, audioStream, captionURL, dislikeCount) = downloadManager.videoAndStream(for: download) {
|
if let (downloadedVideo, localStream, audioStream, captionURL, dislikeCount) = downloadManager.videoAndStream(for: download) {
|
||||||
// Store the download info for later reference
|
// Store the download info for later reference
|
||||||
currentDownload = download
|
currentDownload = download
|
||||||
await play(video: downloadedVideo, stream: localStream, audioStream: audioStream, startTime: startTime)
|
await play(video: downloadedVideo, stream: localStream, audioStream: audioStream, startTime: startTime, forceStartTime: forceStartTime)
|
||||||
// Restore dislike count from download (for offline playback)
|
// Restore dislike count from download (for offline playback)
|
||||||
if let dislikeCount {
|
if let dislikeCount {
|
||||||
state.dislikeCount = dislikeCount
|
state.dislikeCount = dislikeCount
|
||||||
@@ -1014,11 +1021,11 @@ final class PlayerService {
|
|||||||
autoDismissDelay: 4.0
|
autoDismissDelay: 4.0
|
||||||
)
|
)
|
||||||
currentDownload = nil
|
currentDownload = nil
|
||||||
await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime)
|
await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime, forceStartTime: forceStartTime)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
currentDownload = nil
|
currentDownload = nil
|
||||||
await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime)
|
await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime, forceStartTime: forceStartTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,7 +1051,9 @@ final class PlayerService {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - video: The video to open
|
/// - video: The video to open
|
||||||
/// - startTime: Optional start time in seconds (used for continue watching)
|
/// - startTime: Optional start time in seconds (used for continue watching)
|
||||||
func openVideo(_ video: Video, startTime: TimeInterval? = nil) {
|
/// - forceStartTime: When true, startTime is an explicit user request (timed link,
|
||||||
|
/// chapter tap) and is honored even past the completion threshold
|
||||||
|
func openVideo(_ video: Video, startTime: TimeInterval? = nil, forceStartTime: Bool = false) {
|
||||||
// Live streams have no meaningful resume position - drop any passed one
|
// Live streams have no meaningful resume position - drop any passed one
|
||||||
// so callers with a stale watch entry cannot seek into the live window.
|
// so callers with a stale watch entry cannot seek into the live window.
|
||||||
let startTime = video.isLive ? nil : startTime
|
let startTime = video.isLive ? nil : startTime
|
||||||
@@ -1089,7 +1098,7 @@ final class PlayerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPlayTask = Task {
|
currentPlayTask = Task {
|
||||||
await playPreferringDownloaded(video: video, startTime: startTime)
|
await playPreferringDownloaded(video: video, startTime: startTime, forceStartTime: forceStartTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -682,7 +682,7 @@ struct OpenLinkFormView: View {
|
|||||||
|
|
||||||
if !firstVideoPlayed {
|
if !firstVideoPlayed {
|
||||||
// Play first video - this expands player
|
// Play first video - this expands player
|
||||||
playVideo(video, appEnvironment: appEnvironment)
|
playVideo(video, sourceURL: url, appEnvironment: appEnvironment)
|
||||||
firstVideoPlayed = true
|
firstVideoPlayed = true
|
||||||
} else {
|
} else {
|
||||||
// Add to queue
|
// Add to queue
|
||||||
@@ -767,11 +767,13 @@ struct OpenLinkFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playVideo(_ video: Video, appEnvironment: AppEnvironment) {
|
private func playVideo(_ video: Video, sourceURL: URL, appEnvironment: AppEnvironment) {
|
||||||
// Don't pass a specific stream - let the player's selectStreamAndBackend
|
// Don't pass a specific stream - let the player's selectStreamAndBackend
|
||||||
// choose the best video+audio combination. Using streams.first would
|
// choose the best video+audio combination. Using streams.first would
|
||||||
// incorrectly select audio-only streams for sites like Bilibili.
|
// incorrectly select audio-only streams for sites like Bilibili.
|
||||||
appEnvironment.playerService.openVideo(video)
|
let router = URLRouter()
|
||||||
|
let startTime = router.parseTimestamp(router.unwrapped(sourceURL))
|
||||||
|
appEnvironment.playerService.openVideo(video, startTime: startTime, forceStartTime: startTime != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Download Action
|
// MARK: - Download Action
|
||||||
|
|||||||
@@ -500,6 +500,9 @@ struct YatteeApp: App {
|
|||||||
/// Handle incoming deep link URLs.
|
/// Handle incoming deep link URLs.
|
||||||
private func handleDeepLink(_ url: URL) {
|
private func handleDeepLink(_ url: URL) {
|
||||||
let router = URLRouter()
|
let router = URLRouter()
|
||||||
|
// Resolve yattee://open?url=… wrappers first so timestamp parsing and
|
||||||
|
// sheet prefill below see the real link, not the wrapper.
|
||||||
|
let url = router.unwrapped(url)
|
||||||
guard let destination = router.route(url) else { return }
|
guard let destination = router.route(url) else { return }
|
||||||
|
|
||||||
let action = appEnvironment.settingsManager.defaultLinkAction
|
let action = appEnvironment.settingsManager.defaultLinkAction
|
||||||
@@ -671,7 +674,7 @@ struct YatteeApp: App {
|
|||||||
)
|
)
|
||||||
LoggingService.shared.info("Deep link play: fetched video, opening player", category: .general)
|
LoggingService.shared.info("Deep link play: fetched video, opening player", category: .general)
|
||||||
appEnvironment.toastManager.dismiss(id: toastID)
|
appEnvironment.toastManager.dismiss(id: toastID)
|
||||||
appEnvironment.playerService.openVideo(video, startTime: startTime)
|
appEnvironment.playerService.openVideo(video, startTime: startTime, forceStartTime: startTime != nil)
|
||||||
} catch {
|
} catch {
|
||||||
LoggingService.shared.error(
|
LoggingService.shared.error(
|
||||||
"Deep link play: video fetch failed (\(error.localizedDescription)), falling back to info view",
|
"Deep link play: video fetch failed (\(error.localizedDescription)), falling back to info view",
|
||||||
|
|||||||
@@ -107,6 +107,63 @@ struct URLRouterTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Timestamp Parsing Tests
|
||||||
|
|
||||||
|
@Test("Parse timestamp from youtu.be short URL")
|
||||||
|
func timestampFromShortURL() {
|
||||||
|
let url = URL(string: "https://youtu.be/GBimVR2VBQU?t=17097")!
|
||||||
|
#expect(router.parseTimestamp(url) == 17097)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Parse compound timestamp value")
|
||||||
|
func compoundTimestampValue() {
|
||||||
|
#expect(URLRouter.parseTimestampValue("1h2m3s") == 3723)
|
||||||
|
#expect(URLRouter.parseTimestampValue("2m30s") == 150)
|
||||||
|
#expect(URLRouter.parseTimestampValue("90s") == 90)
|
||||||
|
#expect(URLRouter.parseTimestampValue("90.5") == 90.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Share Extension Wrapper Tests
|
||||||
|
|
||||||
|
@Test("Unwrap yattee://open wrapper to inner URL with timestamp")
|
||||||
|
func unwrapOpenWrapper() {
|
||||||
|
// Share extension percent-encoding leaves ?, /, : intact - this is the literal form delivered
|
||||||
|
let wrapper = URL(string: "yattee://open?url=https://youtu.be/GBimVR2VBQU?t=17097")!
|
||||||
|
let inner = router.unwrapped(wrapper)
|
||||||
|
|
||||||
|
#expect(inner.absoluteString == "https://youtu.be/GBimVR2VBQU?t=17097")
|
||||||
|
#expect(router.parseTimestamp(inner) == 17097)
|
||||||
|
|
||||||
|
// Unwrapped URL still routes to the right video
|
||||||
|
if case .video(let source, _) = router.route(inner), case .id(let videoID) = source {
|
||||||
|
#expect(videoID.videoID == "GBimVR2VBQU")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected video destination")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unwrap wrapper with ampersand timestamp form")
|
||||||
|
func unwrapOpenWrapperAmpersandForm() {
|
||||||
|
let wrapper = URL(string: "yattee://open?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120")!
|
||||||
|
let inner = router.unwrapped(wrapper)
|
||||||
|
|
||||||
|
#expect(router.parseTimestamp(inner) == 120)
|
||||||
|
if case .video(let source, _) = router.route(inner), case .id(let videoID) = source {
|
||||||
|
#expect(videoID.videoID == "dQw4w9WgXcQ")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected video destination")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unwrapped returns non-wrapper URLs unchanged")
|
||||||
|
func unwrapPassthrough() {
|
||||||
|
let plain = URL(string: "https://youtu.be/dQw4w9WgXcQ?t=42")!
|
||||||
|
#expect(router.unwrapped(plain) == plain)
|
||||||
|
|
||||||
|
let scheme = URL(string: "yattee://subscriptions")!
|
||||||
|
#expect(router.unwrapped(scheme) == scheme)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - PeerTube URL Tests
|
// MARK: - PeerTube URL Tests
|
||||||
|
|
||||||
@Test("Parse PeerTube /w/ video URL")
|
@Test("Parse PeerTube /w/ video URL")
|
||||||
|
|||||||
Reference in New Issue
Block a user