From d624a09335a242d0b8b201529b16b5f82d780433 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 2 Aug 2026 16:32:20 +0200 Subject: [PATCH] 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 --- Yattee/Services/Navigation/URLRouter.swift | 26 ++++++++++ Yattee/Services/Player/PlayerService.swift | 27 ++++++---- Yattee/Views/Home/OpenLinkSheet.swift | 8 +-- Yattee/YatteeApp.swift | 5 +- YatteeTests/NavigationTests.swift | 57 ++++++++++++++++++++++ 5 files changed, 110 insertions(+), 13 deletions(-) diff --git a/Yattee/Services/Navigation/URLRouter.swift b/Yattee/Services/Navigation/URLRouter.swift index 5c91e88c..67042e4f 100644 --- a/Yattee/Services/Navigation/URLRouter.swift +++ b/Yattee/Services/Navigation/URLRouter.swift @@ -96,6 +96,32 @@ struct URLRouter: Sendable { 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 /// Extract a timestamp (seconds) from a URL's query, supporting `t`, `time`, and `start`. diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 2f5c2996..a47997a3 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -240,7 +240,9 @@ final class PlayerService { /// - stream: Optional specific stream to use (if provided, skips fetching streams from API) /// - audioStream: Optional separate audio stream (for video-only streams) /// - 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 // ready-made file:// streams), so audio mode is applied at this choke // point instead of in selectStreams. @@ -427,7 +429,11 @@ final class PlayerService { } else if let startTime { // 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 - 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 } else { seekTime = startTime @@ -951,7 +957,8 @@ final class PlayerService { video: Video, fallbackStream: Stream? = nil, fallbackAudioStream: Stream? = nil, - startTime: TimeInterval? = nil + startTime: TimeInterval? = nil, + forceStartTime: Bool = false ) async { // 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 @@ -959,7 +966,7 @@ final class PlayerService { do { let (stream, captions) = try await resolveMediaSourceStream(for: video) 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 if !captions.isEmpty { @@ -983,7 +990,7 @@ final class PlayerService { if let (downloadedVideo, localStream, audioStream, captionURL, dislikeCount) = downloadManager.videoAndStream(for: download) { // Store the download info for later reference 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) if let dislikeCount { state.dislikeCount = dislikeCount @@ -1014,11 +1021,11 @@ final class PlayerService { autoDismissDelay: 4.0 ) 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 { 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: /// - video: The video to open /// - 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 // so callers with a stale watch entry cannot seek into the live window. let startTime = video.isLive ? nil : startTime @@ -1089,7 +1098,7 @@ final class PlayerService { } currentPlayTask = Task { - await playPreferringDownloaded(video: video, startTime: startTime) + await playPreferringDownloaded(video: video, startTime: startTime, forceStartTime: forceStartTime) } } diff --git a/Yattee/Views/Home/OpenLinkSheet.swift b/Yattee/Views/Home/OpenLinkSheet.swift index 7928eda7..5f749cc6 100644 --- a/Yattee/Views/Home/OpenLinkSheet.swift +++ b/Yattee/Views/Home/OpenLinkSheet.swift @@ -682,7 +682,7 @@ struct OpenLinkFormView: View { if !firstVideoPlayed { // Play first video - this expands player - playVideo(video, appEnvironment: appEnvironment) + playVideo(video, sourceURL: url, appEnvironment: appEnvironment) firstVideoPlayed = true } else { // 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 // choose the best video+audio combination. Using streams.first would // 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 diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index f1138423..bc7846c8 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -500,6 +500,9 @@ struct YatteeApp: App { /// Handle incoming deep link URLs. private func handleDeepLink(_ url: URL) { 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 } let action = appEnvironment.settingsManager.defaultLinkAction @@ -671,7 +674,7 @@ struct YatteeApp: App { ) LoggingService.shared.info("Deep link play: fetched video, opening player", category: .general) appEnvironment.toastManager.dismiss(id: toastID) - appEnvironment.playerService.openVideo(video, startTime: startTime) + appEnvironment.playerService.openVideo(video, startTime: startTime, forceStartTime: startTime != nil) } catch { LoggingService.shared.error( "Deep link play: video fetch failed (\(error.localizedDescription)), falling back to info view", diff --git a/YatteeTests/NavigationTests.swift b/YatteeTests/NavigationTests.swift index 0949ab9c..56639136 100644 --- a/YatteeTests/NavigationTests.swift +++ b/YatteeTests/NavigationTests.swift @@ -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 @Test("Parse PeerTube /w/ video URL")