diff --git a/Yattee/Services/Navigation/URLRouter.swift b/Yattee/Services/Navigation/URLRouter.swift index 38c2a689..5c91e88c 100644 --- a/Yattee/Services/Navigation/URLRouter.swift +++ b/Yattee/Services/Navigation/URLRouter.swift @@ -96,6 +96,63 @@ struct URLRouter: Sendable { return true } + // MARK: - Timestamp Parsing + + /// Extract a timestamp (seconds) from a URL's query, supporting `t`, `time`, and `start`. + /// Accepts plain seconds (`90`, `90.5`), YouTube-style `90s`, and `1h2m3s` / `2m30s` forms. + func parseTimestamp(_ url: URL) -> TimeInterval? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let items = components.queryItems else { + return nil + } + for name in ["t", "time", "start"] { + if let raw = items.first(where: { $0.name == name })?.value, + let parsed = Self.parseTimestampValue(raw) { + return parsed + } + } + return nil + } + + /// Parse a single timestamp string. Returns nil if unparseable or zero-length. + static func parseTimestampValue(_ raw: String) -> TimeInterval? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + + // Plain numeric value (e.g. "90", "90.5") + if let value = TimeInterval(trimmed) { + return value >= 0 ? value : nil + } + + // Compound form like "1h2m3s", "2m30s", "90s" + var total: TimeInterval = 0 + var current = "" + var matched = false + for ch in trimmed { + if ch.isNumber || ch == "." { + current.append(ch) + } else { + guard let value = TimeInterval(current) else { return nil } + let unit: TimeInterval + switch ch { + case "h", "H": unit = 3600 + case "m", "M": unit = 60 + case "s", "S": unit = 1 + default: return nil + } + total += value * unit + current = "" + matched = true + } + } + // Trailing digits without a unit suffix (e.g. "1m30") — treat as seconds. + if !current.isEmpty, let value = TimeInterval(current) { + total += value + matched = true + } + return matched ? total : nil + } + // MARK: - Custom Scheme /// Parse yattee:// scheme URLs. diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index ecaedd3d..14e62d01 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -916,12 +916,23 @@ final class PlayerService { let mpvPiPActive = false #endif - // If this video is already playing, just expand the player (unless PiP is active) + // If this video is already loaded, optionally seek/resume instead of reloading. if isCurrentlyPlaying(video: video) { - LoggingService.shared.logPlayer("Video already playing, just expanding") + LoggingService.shared.logPlayer("Video already loaded, applying seek/resume instead of reload (startTime=\(startTime ?? -1), state=\(state.playbackState))") if !mpvPiPActive { navigationCoordinator?.expandPlayer() } + let wasPaused = state.playbackState == .paused + if let startTime { + Task { @MainActor in + await seek(to: startTime) + if wasPaused || state.playbackState == .paused { + resume() + } + } + } else if wasPaused { + resume() + } return } @@ -958,11 +969,14 @@ final class PlayerService { let mpvPiPActive = false #endif - // If this video is already playing, just expand the player (unless PiP is active) + // If this video is already loaded, optionally resume instead of reloading. if isCurrentlyPlaying(video: video) { if !mpvPiPActive { navigationCoordinator?.expandPlayer() } + if state.playbackState == .paused { + resume() + } return } diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index 15388485..213bf74b 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -484,11 +484,12 @@ struct YatteeApp: App { /// Handle video deep link based on default link action setting. private func handleVideoDeepLink(videoID: VideoID, originalURL: URL, action: DefaultLinkAction) { + let startTime = URLRouter().parseTimestamp(originalURL) switch action { case .open: // Play directly (existing behavior) Task { - await playVideoFromDeepLink(videoID: videoID) + await playVideoFromDeepLink(videoID: videoID, startTime: startTime) } case .download: @@ -500,7 +501,7 @@ struct YatteeApp: App { #else // tvOS doesn't support downloads, fall back to open Task { - await playVideoFromDeepLink(videoID: videoID) + await playVideoFromDeepLink(videoID: videoID, startTime: startTime) } #endif @@ -540,9 +541,9 @@ struct YatteeApp: App { } /// Play a video from a deep link. - private func playVideoFromDeepLink(videoID: VideoID) async { + private func playVideoFromDeepLink(videoID: VideoID, startTime: TimeInterval? = nil) async { LoggingService.shared.info( - "Deep link play: videoID=\(videoID.videoID) source=\(videoID.source)", + "Deep link play: videoID=\(videoID.videoID) source=\(videoID.source) startTime=\(startTime ?? -1)", category: .general ) guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else { @@ -579,7 +580,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) + appEnvironment.playerService.openVideo(video, startTime: startTime) } catch { LoggingService.shared.error( "Deep link play: video fetch failed (\(error.localizedDescription)), falling back to info view",