fix resuming video playback after background audio only mode.
This commit is contained in:
+26
-3
@@ -187,6 +187,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
override fun onSeekTo(position: Long) {
|
override fun onSeekTo(position: Long) {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||||
|
// The scrubber is absolute; Rust owns the seek in absolute terms
|
||||||
|
// (in a background-audio handoff it rebuilds the stream at this
|
||||||
|
// StartTimeTicks). Send the absolute position as-is.
|
||||||
val positionSeconds = position / 1000.0
|
val positionSeconds = position / 1000.0
|
||||||
nativeOnMediaCommand("seek:$positionSeconds")
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
}
|
}
|
||||||
@@ -259,6 +262,25 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
private var lastArtist: String = ""
|
private var lastArtist: String = ""
|
||||||
private var lastIsPlaying: Boolean = false
|
private var lastIsPlaying: Boolean = false
|
||||||
|
|
||||||
|
// Base offset (ms) added to every position reported to the lockscreen
|
||||||
|
// MediaSession. During a background-audio handoff the audio stream is
|
||||||
|
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
|
||||||
|
// position RELATIVE to that point (starting at 0). The metadata duration,
|
||||||
|
// however, is the full absolute length — so without this base the scrubber
|
||||||
|
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
|
||||||
|
// position via setPositionOffset(); 0 for normal playback.
|
||||||
|
private var positionOffsetMs: Long = 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the base position offset (seconds) applied to lockscreen positions.
|
||||||
|
* Called by the native layer when entering/exiting a background-audio handoff.
|
||||||
|
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
|
||||||
|
*/
|
||||||
|
fun setPositionOffset(offsetSeconds: Double) {
|
||||||
|
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the MediaSession metadata and playback state, plus the notification.
|
* Update the MediaSession metadata and playback state, plus the notification.
|
||||||
*
|
*
|
||||||
@@ -292,8 +314,8 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
session.setMetadata(metadataBuilder.build())
|
session.setMetadata(metadataBuilder.build())
|
||||||
|
|
||||||
// Update MediaSession playback state
|
// Update MediaSession playback state (position made absolute via the base offset).
|
||||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||||
|
|
||||||
// While casting, re-assert the remote volume provider. Metadata pushes
|
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||||
// arrive on the session poller thread and can race with (or arrive
|
// arrive on the session poller thread and can race with (or arrive
|
||||||
@@ -322,7 +344,8 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
val session = mediaSessionCompat ?: return
|
val session = mediaSessionCompat ?: return
|
||||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||||
lastIsPlaying = isPlaying
|
lastIsPlaying = isPlaying
|
||||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
|
||||||
|
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||||
// Only rebuild the notification when the play/pause icon actually flips.
|
// Only rebuild the notification when the play/pause icon actually flips.
|
||||||
if (notificationStateChanged) {
|
if (notificationStateChanged) {
|
||||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
|
|||||||
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
|
||||||
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
||||||
|
|
||||||
|
/// Base offset (seconds) for the active background-audio handoff.
|
||||||
|
///
|
||||||
|
/// The audio-only stream is requested with `StartTimeTicks` = the handoff
|
||||||
|
/// position, so the server makes that point the stream's zero. ExoPlayer then
|
||||||
|
/// reports position RELATIVE to that zero. To convert back to an absolute
|
||||||
|
/// position on exit (so the video resumes where the audio actually reached), we
|
||||||
|
/// add this stored base to the native player's reported position.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-040 | DR-052
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct BackgroundAudioOffset(pub Mutex<f64>);
|
||||||
|
|
||||||
/// Response for player state queries
|
/// Response for player state queries
|
||||||
#[derive(specta::Type, Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -184,6 +196,11 @@ pub struct PlayItemRequest {
|
|||||||
pub primary_image_tag: Option<String>,
|
pub primary_image_tag: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub server_id: Option<String>,
|
pub server_id: Option<String>,
|
||||||
|
/// Total media duration (seconds). Threaded through the background-audio
|
||||||
|
/// handoff so the lockscreen MediaSession advertises a real duration — a
|
||||||
|
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration_seconds: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue context for remote transfer - what type of queue is this?
|
/// Queue context for remote transfer - what type of queue is this?
|
||||||
@@ -555,6 +572,7 @@ pub async fn player_play_item(
|
|||||||
pub async fn player_enter_background_audio(
|
pub async fn player_enter_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
|
bg_offset: State<'_, BackgroundAudioOffset>,
|
||||||
item: PlayItemRequest,
|
item: PlayItemRequest,
|
||||||
position_seconds: f64,
|
position_seconds: f64,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
@@ -579,7 +597,8 @@ pub async fn player_enter_background_audio(
|
|||||||
primary_image_tag: item.primary_image_tag.clone(),
|
primary_image_tag: item.primary_image_tag.clone(),
|
||||||
item_type: None,
|
item_type: None,
|
||||||
playlist_id: None,
|
playlist_id: None,
|
||||||
duration: None,
|
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
|
||||||
|
duration: item.duration_seconds,
|
||||||
artwork_url: None,
|
artwork_url: None,
|
||||||
media_type: MediaType::Audio,
|
media_type: MediaType::Audio,
|
||||||
source: MediaSource::Remote {
|
source: MediaSource::Remote {
|
||||||
@@ -600,16 +619,23 @@ pub async fn player_enter_background_audio(
|
|||||||
session_mgr.start_audio_session(media_item.clone());
|
session_mgr.start_audio_session(media_item.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember where the video was: the audio stream's zero == this position
|
||||||
|
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
|
||||||
|
// this base to the native player's relative position to get the absolute one.
|
||||||
|
*bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0);
|
||||||
|
|
||||||
|
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
|
||||||
|
// relative to the stream's StartTimeTicks zero, but the metadata duration is
|
||||||
|
// absolute, so shift the reported position back to absolute for the scrubber.
|
||||||
|
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller
|
controller
|
||||||
.play_item(media_item)
|
.play_item(media_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
// Resume at the handoff position.
|
// NOTE: do NOT seek here. The audio-only URL already starts at the handoff
|
||||||
if position_seconds > 0.0 {
|
// position via StartTimeTicks; the stream's timeline begins at 0 == that
|
||||||
controller
|
// point, so an extra seek(position_seconds) would jump PAST the content.
|
||||||
.seek(position_seconds)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.emit_queue_changed();
|
controller.emit_queue_changed();
|
||||||
if let Some(emitter) = controller.event_emitter() {
|
if let Some(emitter) = controller.event_emitter() {
|
||||||
@@ -634,17 +660,31 @@ pub async fn player_enter_background_audio(
|
|||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_exit_background_audio(
|
pub async fn player_exit_background_audio(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
bg_offset: State<'_, BackgroundAudioOffset>,
|
||||||
) -> Result<f64, String> {
|
) -> Result<f64, String> {
|
||||||
|
// The base offset (handoff position) + native player's relative position =
|
||||||
|
// the absolute position to resume the video at. Read/reset the base first.
|
||||||
|
let base = {
|
||||||
|
let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let b = *off;
|
||||||
|
*off = 0.0;
|
||||||
|
b
|
||||||
|
};
|
||||||
|
|
||||||
|
// Back to foreground playback: the lockscreen scrubber is absolute again.
|
||||||
|
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
// Capture position into a `let` BEFORE stop() — never hold work across a lock
|
||||||
// re-entrant call (deadlock discipline, CLAUDE.md).
|
// re-entrant call (deadlock discipline, CLAUDE.md).
|
||||||
let position = controller.position();
|
let relative = controller.position();
|
||||||
controller.stop().map_err(|e| e.to_string())?;
|
controller.stop().map_err(|e| e.to_string())?;
|
||||||
|
let absolute = base + relative;
|
||||||
info!(
|
info!(
|
||||||
"player_exit_background_audio: returning position {:.1}s",
|
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
|
||||||
position
|
base, relative, absolute
|
||||||
);
|
);
|
||||||
Ok(position)
|
Ok(absolute)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play a queue of media items
|
/// Play a queue of media items
|
||||||
|
|||||||
@@ -1166,6 +1166,9 @@ pub fn run() {
|
|||||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||||
app.manage(video_settings);
|
app.manage(video_settings);
|
||||||
|
|
||||||
|
// Background-audio handoff base offset (UR-040).
|
||||||
|
app.manage(commands::player::BackgroundAudioOffset::default());
|
||||||
|
|
||||||
// Initialize thumbnail cache
|
// Initialize thumbnail cache
|
||||||
info!("[INIT] Initializing thumbnail cache...");
|
info!("[INIT] Initializing thumbnail cache...");
|
||||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||||
|
|||||||
@@ -1315,6 +1315,66 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the base position offset (seconds) on the lockscreen MediaSession.
|
||||||
|
///
|
||||||
|
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
|
||||||
|
/// service isn't running yet, so it's safe to call unconditionally.
|
||||||
|
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||||
|
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||||
|
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
|
||||||
|
|
||||||
|
let class_loader = env
|
||||||
|
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
|
||||||
|
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||||
|
|
||||||
|
let service_class_name = env
|
||||||
|
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||||
|
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||||
|
|
||||||
|
let service_class_obj = env
|
||||||
|
.call_method(
|
||||||
|
&class_loader,
|
||||||
|
"loadClass",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||||
|
&[JValue::Object(&service_class_name.into())],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
|
||||||
|
|
||||||
|
let service_class = JClass::from(service_class_obj);
|
||||||
|
|
||||||
|
let service_obj = env
|
||||||
|
.call_static_method(
|
||||||
|
&service_class,
|
||||||
|
"getInstance",
|
||||||
|
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to get service instance: {}", e))?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| format!("Failed to convert to object: {}", e))?;
|
||||||
|
|
||||||
|
// Service not running yet - nothing to offset.
|
||||||
|
if service_obj.is_null() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
env.call_method(
|
||||||
|
&service_obj,
|
||||||
|
"setPositionOffset",
|
||||||
|
"(D)V",
|
||||||
|
&[JValue::Double(offset_seconds)],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to set position offset: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Stub implementations for non-Android platforms
|
/// Stub implementations for non-Android platforms
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
|
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
|
||||||
|
|||||||
@@ -80,6 +80,22 @@ pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the base offset (seconds) added to positions reported to the Android
|
||||||
|
/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
|
||||||
|
/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
|
||||||
|
/// relative and must be shifted back to absolute to match the full duration.
|
||||||
|
/// Pass 0.0 to clear on exit. No-op off Android.
|
||||||
|
pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
return android::set_position_offset(_offset_seconds);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
use log::{debug, error, warn};
|
use log::{debug, error, warn};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|||||||
@@ -1808,7 +1808,13 @@ needsTranscoding: boolean;
|
|||||||
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
|
||||||
* existing video-only callers need not send them.
|
* existing video-only callers need not send them.
|
||||||
*/
|
*/
|
||||||
artist?: string | null; primaryImageTag?: string | null; serverId?: string | null }
|
artist?: string | null; primaryImageTag?: string | null; serverId?: string | null;
|
||||||
|
/**
|
||||||
|
* Total media duration (seconds). Threaded through the background-audio
|
||||||
|
* handoff so the lockscreen MediaSession advertises a real duration — a
|
||||||
|
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
||||||
|
*/
|
||||||
|
durationSeconds?: number | null }
|
||||||
/**
|
/**
|
||||||
* Queue context for remote transfer - what type of queue is this?
|
* Queue context for remote transfer - what type of queue is this?
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -826,10 +826,55 @@
|
|||||||
// with `src=""`, so `loadstart`/`canplay` don't fire reliably and the
|
// with `src=""`, so `loadstart`/`canplay` don't fire reliably and the
|
||||||
// canplay-fallback timeout was never armed — audio played while the video
|
// canplay-fallback timeout was never armed — audio played while the video
|
||||||
// stayed invisible. Any of these callers now reveals it.
|
// stayed invisible. Any of these callers now reveals it.
|
||||||
|
// Apply the pending background-audio foreground seek, if any. This MUST run
|
||||||
|
// no matter which readiness signal fired — on the Android WebView HLS/MSE path
|
||||||
|
// `canplay` is unreliable and the video is revealed via markMediaReady()
|
||||||
|
// instead, so gating this on handleCanPlay alone meant the seek was silently
|
||||||
|
// dropped and the reloaded stream played from its start (resume "started from
|
||||||
|
// the beginning"). Returns true if a pending seek was consumed.
|
||||||
|
async function applyPendingForegroundSeek(): Promise<boolean> {
|
||||||
|
if (pendingForegroundSeek === null || !videoElement) return false;
|
||||||
|
const seekTo = pendingForegroundSeek;
|
||||||
|
const shouldPlay = pendingForegroundPlay;
|
||||||
|
pendingForegroundSeek = null;
|
||||||
|
pendingForegroundPlay = false;
|
||||||
|
hasPerformedInitialSeek = true;
|
||||||
|
|
||||||
|
const el = videoElement;
|
||||||
|
// currentTime is only honored once the element has metadata (duration/seekable).
|
||||||
|
// If it isn't there yet, defer to loadedmetadata rather than seeking into a
|
||||||
|
// still-empty timeline (which the element clamps back to 0).
|
||||||
|
const doSeek = async () => {
|
||||||
|
try {
|
||||||
|
el.currentTime = seekTo;
|
||||||
|
// Displayed position is absolute: element time + transcode seekOffset.
|
||||||
|
// (Direct stream: seekOffset=0, seekTo=pos. Transcoded: seekOffset=pos,
|
||||||
|
// seekTo=0.) Both yield the correct absolute position.
|
||||||
|
currentTime = seekOffset + seekTo;
|
||||||
|
el.muted = false;
|
||||||
|
el.volume = 1.0;
|
||||||
|
if (shouldPlay) await el.play();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[VideoPlayer] Failed to resume after background audio:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (el.readyState >= 1 /* HAVE_METADATA */) {
|
||||||
|
console.log("[VideoPlayer] Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
||||||
|
await doSeek();
|
||||||
|
} else {
|
||||||
|
console.log("[VideoPlayer] Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
||||||
|
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function markMediaReady() {
|
function markMediaReady() {
|
||||||
if (isMediaReady) return;
|
if (isMediaReady) return;
|
||||||
console.log("[VideoPlayer] Marking media ready");
|
console.log("[VideoPlayer] Marking media ready");
|
||||||
isMediaReady = true;
|
isMediaReady = true;
|
||||||
|
// A handoff return can be revealed here (not via canplay) — apply its seek.
|
||||||
|
void applyPendingForegroundSeek();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCanPlay() {
|
async function handleCanPlay() {
|
||||||
@@ -847,19 +892,7 @@
|
|||||||
// Returning from background audio: resume the <video> at the position native
|
// Returning from background audio: resume the <video> at the position native
|
||||||
// audio reached, restoring the prior play/pause state. Takes precedence over
|
// audio reached, restoring the prior play/pause state. Takes precedence over
|
||||||
// the resume-point seek below (which is for a fresh load, not a handoff).
|
// the resume-point seek below (which is for a fresh load, not a handoff).
|
||||||
if (pendingForegroundSeek !== null && videoElement) {
|
if (await applyPendingForegroundSeek()) {
|
||||||
const seekTo = pendingForegroundSeek;
|
|
||||||
const shouldPlay = pendingForegroundPlay;
|
|
||||||
pendingForegroundSeek = null;
|
|
||||||
pendingForegroundPlay = false;
|
|
||||||
hasPerformedInitialSeek = true;
|
|
||||||
try {
|
|
||||||
videoElement.currentTime = seekTo;
|
|
||||||
currentTime = seekTo;
|
|
||||||
if (shouldPlay) await videoElement.play();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[VideoPlayer] Failed to resume after background audio:", err);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,7 +1005,7 @@
|
|||||||
// Check if video is actually ready despite event not firing
|
// Check if video is actually ready despite event not firing
|
||||||
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
||||||
console.log("[VideoPlayer] Video appears ready (readyState >= 3), forcing media ready state");
|
console.log("[VideoPlayer] Video appears ready (readyState >= 3), forcing media ready state");
|
||||||
isMediaReady = true;
|
markMediaReady();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@@ -1192,6 +1225,8 @@
|
|||||||
artist: media.seriesName ?? null,
|
artist: media.seriesName ?? null,
|
||||||
primaryImageTag: media.primaryImageTag ?? null,
|
primaryImageTag: media.primaryImageTag ?? null,
|
||||||
serverId: media.serverId ?? null,
|
serverId: media.serverId ?? null,
|
||||||
|
// Real duration so the lockscreen scrubber has a range to draw.
|
||||||
|
durationSeconds: duration > 0 ? duration : null,
|
||||||
},
|
},
|
||||||
pos,
|
pos,
|
||||||
);
|
);
|
||||||
@@ -1216,19 +1251,46 @@
|
|||||||
const wasPlaying = handoffState.wasPlaying;
|
const wasPlaying = handoffState.wasPlaying;
|
||||||
handoffState = { ...initialHandoffState };
|
handoffState = { ...initialHandoffState };
|
||||||
try {
|
try {
|
||||||
|
// Absolute position the native audio reached (base offset applied in Rust).
|
||||||
const pos = await commands.playerExitBackgroundAudio();
|
const pos = await commands.playerExitBackgroundAudio();
|
||||||
// Reload the video at the returned position. Resetting these re-runs the
|
console.log("[VideoPlayer] Returning from background audio at:", pos.toFixed(1));
|
||||||
// HLS init $effect and reveals/seeks the element as on a fresh load.
|
|
||||||
hasPerformedInitialSeek = false;
|
|
||||||
lastAppliedInitialPosition = undefined;
|
|
||||||
seekOffset = 0;
|
|
||||||
isMediaReady = false;
|
isMediaReady = false;
|
||||||
// Re-point the element at the (unchanged) video stream URL; assigning a new
|
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
||||||
// reference restarts the HLS effect even if the string is identical.
|
// post-handoff position. Keep the initial-position change-effect quiescent:
|
||||||
currentStreamUrl = streamUrl;
|
// leaving hasPerformedInitialSeek=true and pinning lastAppliedInitialPosition
|
||||||
// Seek to where native audio left off once the element is ready again.
|
// to the current prop means the effect sees no "change" and won't fire a
|
||||||
pendingForegroundSeek = pos;
|
// stale seek back to the original resume point (clobbering the handoff pos).
|
||||||
|
hasPerformedInitialSeek = true;
|
||||||
|
lastAppliedInitialPosition = initialPosition;
|
||||||
|
|
||||||
pendingForegroundPlay = wasPlaying;
|
pendingForegroundPlay = wasPlaying;
|
||||||
|
|
||||||
|
// Determine the target URL + how the element/offset should be positioned.
|
||||||
|
let targetUrl: string;
|
||||||
|
if (needsTranscoding && onSeek) {
|
||||||
|
// Transcoded HLS can't seek by setting currentTime — the stream must be
|
||||||
|
// rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
|
||||||
|
// The reloaded segment's timeline starts at 0, so seekOffset carries the
|
||||||
|
// absolute base and the element seeks to 0 (handled on canplay).
|
||||||
|
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
|
||||||
|
seekOffset = pos;
|
||||||
|
currentTime = pos;
|
||||||
|
pendingForegroundSeek = 0;
|
||||||
|
} else {
|
||||||
|
// Direct stream: reload the original URL and seek the element to pos.
|
||||||
|
targetUrl = streamUrl;
|
||||||
|
seekOffset = 0;
|
||||||
|
pendingForegroundSeek = pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force the HLS-init $effect to re-run even if the URL string is unchanged:
|
||||||
|
// blank it first, then set it on the next microtask so Svelte sees a real
|
||||||
|
// transition. Without this, assigning the same value is a no-op and the
|
||||||
|
// player stays stuck on the loading spinner (HLS never re-initialises).
|
||||||
|
currentStreamUrl = "";
|
||||||
|
await Promise.resolve();
|
||||||
|
currentStreamUrl = targetUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Background-audio return failed:", err);
|
console.error("[VideoPlayer] Background-audio return failed:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user