mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
Resume and seek when reopening currently-loaded video
When the same video was already loaded (typically paused), opening it again via the URL scheme, a deep link, or a remote-control loadVideo command did nothing — the player just stayed paused. Now the same-video early-return path resumes playback if paused and seeks to the supplied startTime, so timestamps from URLs and remotes are honoured even when the video is already loaded. URLRouter gains a parseTimestamp helper that reads t/time/start query params in plain-seconds and YouTube-style (1h2m3s) forms, and the deep link handler now forwards that timestamp through to openVideo.
This commit is contained in:
@@ -96,6 +96,63 @@ struct URLRouter: Sendable {
|
|||||||
return true
|
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
|
// MARK: - Custom Scheme
|
||||||
|
|
||||||
/// Parse yattee:// scheme URLs.
|
/// Parse yattee:// scheme URLs.
|
||||||
|
|||||||
@@ -916,12 +916,23 @@ final class PlayerService {
|
|||||||
let mpvPiPActive = false
|
let mpvPiPActive = false
|
||||||
#endif
|
#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) {
|
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 {
|
if !mpvPiPActive {
|
||||||
navigationCoordinator?.expandPlayer()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,11 +969,14 @@ final class PlayerService {
|
|||||||
let mpvPiPActive = false
|
let mpvPiPActive = false
|
||||||
#endif
|
#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 isCurrentlyPlaying(video: video) {
|
||||||
if !mpvPiPActive {
|
if !mpvPiPActive {
|
||||||
navigationCoordinator?.expandPlayer()
|
navigationCoordinator?.expandPlayer()
|
||||||
}
|
}
|
||||||
|
if state.playbackState == .paused {
|
||||||
|
resume()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -484,11 +484,12 @@ struct YatteeApp: App {
|
|||||||
|
|
||||||
/// Handle video deep link based on default link action setting.
|
/// Handle video deep link based on default link action setting.
|
||||||
private func handleVideoDeepLink(videoID: VideoID, originalURL: URL, action: DefaultLinkAction) {
|
private func handleVideoDeepLink(videoID: VideoID, originalURL: URL, action: DefaultLinkAction) {
|
||||||
|
let startTime = URLRouter().parseTimestamp(originalURL)
|
||||||
switch action {
|
switch action {
|
||||||
case .open:
|
case .open:
|
||||||
// Play directly (existing behavior)
|
// Play directly (existing behavior)
|
||||||
Task {
|
Task {
|
||||||
await playVideoFromDeepLink(videoID: videoID)
|
await playVideoFromDeepLink(videoID: videoID, startTime: startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .download:
|
case .download:
|
||||||
@@ -500,7 +501,7 @@ struct YatteeApp: App {
|
|||||||
#else
|
#else
|
||||||
// tvOS doesn't support downloads, fall back to open
|
// tvOS doesn't support downloads, fall back to open
|
||||||
Task {
|
Task {
|
||||||
await playVideoFromDeepLink(videoID: videoID)
|
await playVideoFromDeepLink(videoID: videoID, startTime: startTime)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -540,9 +541,9 @@ struct YatteeApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Play a video from a deep link.
|
/// 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(
|
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
|
category: .general
|
||||||
)
|
)
|
||||||
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
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)
|
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)
|
appEnvironment.playerService.openVideo(video, startTime: startTime)
|
||||||
} 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",
|
||||||
|
|||||||
Reference in New Issue
Block a user