Wire up playback reporting, fix duration flash, hide video from audio mini player
Playback reporting (position sync / resume-on-another-device): - player_configure_jellyfin now builds a PlaybackReporter sharing the player controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every auth path (login/restore/reauth); previously they never did. - The PlaybackReporterWrapper now shares the same Arc the controller and MPV progress loop report through, instead of a dead parallel Option. - Android position callbacks now emit throttled progress reports (30s/item), mirroring the MPV backend. Duration flash on pause: - resolveDuration() prefers the live store duration for the already-loaded track over the runTimeTicks estimate, so pausing no longer clobbers the slider's max to 0 when runTimeTicks is missing. Video leaking into audio mini player: - isVideoItem() also checks the backend PlayerMediaItem mediaType discriminator, so a video started via player_play_item (no Jellyfin `type`, mediaType "video") no longer surfaces in the audio mini player. Middle-truncation of long media names: - New truncateMiddle util applied to track/episode/card/mini-player titles so distinguishing tails (episode numbers, suffixes) stay visible. Adds regression tests for the duration and mini-player fixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dcee342c47
commit
342f95cac1
@ -1826,9 +1826,10 @@ pub async fn player_get_cache_config(
|
||||
#[specta::specta]
|
||||
pub async fn player_configure_jellyfin(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
server_url: String,
|
||||
access_token: String,
|
||||
_user_id: String,
|
||||
user_id: String,
|
||||
device_id: String,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
|
||||
@ -1839,12 +1840,31 @@ pub async fn player_configure_jellyfin(
|
||||
device_id,
|
||||
};
|
||||
|
||||
let client = JellyfinClient::new(config)?;
|
||||
// Legacy client (used for remote session control / casting).
|
||||
let client = JellyfinClient::new(config.clone())?;
|
||||
|
||||
// Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
|
||||
// actually report through. Without this, Start/Progress/Stopped never reach
|
||||
// Jellyfin, so playback position never syncs and you can't resume on another
|
||||
// device. The reporter shares the player controller's Arc, so populating it
|
||||
// here lights up reporting on both desktop and Android, on every auth path
|
||||
// that configures the player (login / restore / reauth).
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
let reporter_client = JellyfinClient::new(config)?;
|
||||
let reporter = crate::playback_reporting::PlaybackReporter::new(
|
||||
db_service,
|
||||
Arc::new(TokioMutex::new(Some(reporter_client))),
|
||||
user_id,
|
||||
);
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
controller.set_jellyfin_client(Some(client));
|
||||
controller.set_playback_reporter(Some(reporter)).await;
|
||||
|
||||
log::info!("[PlayerCommand] Jellyfin client configured successfully");
|
||||
log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1858,8 +1878,9 @@ pub async fn player_disable_jellyfin(
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
controller.set_jellyfin_client(None);
|
||||
controller.set_playback_reporter(None).await;
|
||||
|
||||
log::info!("[PlayerCommand] Jellyfin client disabled");
|
||||
log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -1018,9 +1018,13 @@ pub fn run() {
|
||||
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
|
||||
app.manage(repository_manager_wrapper);
|
||||
|
||||
// Initialize playback reporter wrapper (initially empty, set on login)
|
||||
// Initialize playback reporter wrapper. This MUST share the same Arc
|
||||
// the player controller and MPV progress loop report through (created
|
||||
// above at `playback_reporter`), otherwise `playback_reporter_init`
|
||||
// would populate a dead, parallel Option and no Start/Progress/Stopped
|
||||
// would ever reach Jellyfin.
|
||||
info!("[INIT] Initializing playback reporter wrapper...");
|
||||
let playback_reporter_wrapper = PlaybackReporterWrapper(Arc::new(tokio::sync::Mutex::new(None)));
|
||||
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
|
||||
app.manage(playback_reporter_wrapper);
|
||||
|
||||
info!("[INIT] Application setup completed successfully");
|
||||
|
||||
@ -17,7 +17,8 @@ use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||
use super::media::{MediaItem, MediaType};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler};
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Global reference to the JavaVM for JNI callbacks
|
||||
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
|
||||
@ -584,6 +585,75 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
} else {
|
||||
log::error!("[Android] WARNING: No event emitter for position update!");
|
||||
}
|
||||
|
||||
// Throttled progress reporting to Jellyfin so playback position syncs and can
|
||||
// be resumed on another device. ExoPlayer only fires position updates while
|
||||
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
|
||||
// progress loop; both share the same EventThrottler (every 30s per item).
|
||||
report_android_progress(position);
|
||||
}
|
||||
|
||||
/// Report throttled playback progress to Jellyfin from the Android position
|
||||
/// callback. No-op until the reporter/throttler are wired (post-login) or when
|
||||
/// not actively playing.
|
||||
fn report_android_progress(position: f64) {
|
||||
let item_id = match SHARED_STATE.get() {
|
||||
Some(state) => {
|
||||
let state = state.lock_safe();
|
||||
if !state.state.is_playing() {
|
||||
return;
|
||||
}
|
||||
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
|
||||
let throttler = match POSITION_THROTTLER.get() {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
if !throttler.should_report(&item_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let reporter_arc = match PLAYBACK_REPORTER.get() {
|
||||
Some(r) => r.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let position_ticks = seconds_to_ticks(position);
|
||||
let item_id_for_task = item_id.clone();
|
||||
|
||||
// The reporter is async; the JNI callback is sync. Spawn onto the Tokio
|
||||
// runtime when present, otherwise a throwaway runtime on a new thread.
|
||||
let spawn_report = move || async move {
|
||||
let reporter_guard = reporter_arc.lock().await;
|
||||
if let Some(reporter) = reporter_guard.as_ref() {
|
||||
let operation = PlaybackOperation::Progress {
|
||||
item_id: item_id_for_task.clone(),
|
||||
position_ticks,
|
||||
is_paused: false,
|
||||
};
|
||||
match reporter.report(operation, true).await {
|
||||
Ok(_) => debug!("[Android] Reported progress for {}", item_id_for_task),
|
||||
Err(e) => log::warn!("[Android] Failed to report progress: {}", e),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(spawn_report());
|
||||
} else {
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(spawn_report());
|
||||
});
|
||||
}
|
||||
|
||||
throttler.mark_reported(&item_id);
|
||||
}
|
||||
|
||||
/// Called when player state changes.
|
||||
|
||||
@ -165,9 +165,8 @@ impl PlayerController {
|
||||
self.jellyfin_client.clone()
|
||||
}
|
||||
|
||||
/// Configure the playback reporter for dual sync (local DB + server)
|
||||
/// Will be called from initialization commands after login
|
||||
#[allow(dead_code)]
|
||||
/// Configure the playback reporter for dual sync (local DB + server).
|
||||
/// Called from `player_configure_jellyfin` on login/restore/reauth.
|
||||
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
|
||||
let mut reporter_guard = self.playback_reporter.lock().await;
|
||||
*reporter_guard = reporter;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { goto } from "$app/navigation";
|
||||
@ -152,7 +153,7 @@
|
||||
class="flex-1 min-w-0 text-left"
|
||||
>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{album.album_name}
|
||||
{truncateMiddle(album.album_name, 40)}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{album.artist_name || "Unknown Artist"} • {album.track_count} {album.track_count === 1 ? "track" : "tracks"}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import LibraryGrid from "./LibraryGrid.svelte";
|
||||
@ -171,7 +172,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{album.name}
|
||||
{truncateMiddle(album.name, 40)}
|
||||
</p>
|
||||
{#if album.productionYear}
|
||||
<p class="text-xs text-gray-400">{album.productionYear}</p>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
@ -159,7 +160,7 @@
|
||||
|
||||
<!-- Episode title -->
|
||||
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
|
||||
{episode.name}
|
||||
{truncateMiddle(episode.name, 64)}
|
||||
</h1>
|
||||
|
||||
<!-- Metadata -->
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
@ -134,7 +135,7 @@
|
||||
{episodeNumber}.
|
||||
</span>
|
||||
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
|
||||
{episode.name}
|
||||
{truncateMiddle(episode.name, 56)}
|
||||
</h3>
|
||||
<!-- Played indicator -->
|
||||
{#if episode.userData?.isPlayed}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigateBack } from "$lib/utils/navigation";
|
||||
import { currentLibrary } from "$lib/stores/library";
|
||||
@ -229,7 +230,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
{truncateMiddle(item.name, 40)}
|
||||
</p>
|
||||
{#if item.productionYear}
|
||||
<p class="text-sm text-gray-400">
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
@ -106,7 +107,7 @@
|
||||
<!-- Title & Subtitle -->
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
{truncateMiddle(item.name, 56)}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
@ -166,7 +167,7 @@
|
||||
|
||||
<div class="mt-2 space-y-0.5">
|
||||
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||
{item.name}
|
||||
{truncateMiddle(item.name, 40)}
|
||||
</p>
|
||||
{#if subtitle()}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import type { PlayTracksContext } from "$lib/api/bindings";
|
||||
import { goto } from "$app/navigation";
|
||||
import { queue } from "$lib/stores/queue";
|
||||
@ -242,7 +243,7 @@
|
||||
<span
|
||||
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
|
||||
>
|
||||
{track.name}
|
||||
{truncateMiddle(track.name, 48)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -356,7 +357,7 @@
|
||||
{#if currentlyPlayingId === track.id}
|
||||
<span class="inline-block mr-1">▶</span>
|
||||
{/if}
|
||||
{track.name}
|
||||
{truncateMiddle(track.name, 48)}
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1">
|
||||
{#if showArtist && showAlbum}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
@ -242,7 +243,7 @@
|
||||
<div class="p-6 space-y-6 flex-shrink-0">
|
||||
<!-- Title & Artist -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-white truncate">{displayMedia?.name}</h1>
|
||||
<h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1>
|
||||
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
{#each displayMedia?.artistItems as artist, i}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
@ -322,7 +323,7 @@
|
||||
<div
|
||||
class="text-sm font-medium text-white truncate block w-full text-left"
|
||||
>
|
||||
{displayMedia?.name}
|
||||
{truncateMiddle(displayMedia?.name, 40)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
|
||||
{#if displayMedia?.artistItems?.length}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
@ -198,7 +199,7 @@
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
|
||||
{item.name}
|
||||
{truncateMiddle(item.name, 48)}
|
||||
</p>
|
||||
{#if item.artists?.length}
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
|
||||
147
src/lib/services/playerEvents.regression.test.ts
Normal file
147
src/lib/services/playerEvents.regression.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Player Events Service — regression tests
|
||||
*
|
||||
* These tests intentionally use the REAL player store (and its real derived
|
||||
* stores), unlike playerEvents.test.ts which mocks the store away. The bugs
|
||||
* covered here live in the interaction between the event handler and the
|
||||
* store's position/duration fields, so a mocked store cannot catch them.
|
||||
*
|
||||
* TRACES: UR-005 | DR-001
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get, writable } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import type { PlayerStatusEvent } from "$lib/api/bindings";
|
||||
|
||||
// Capture the handler that initPlayerEvents registers so we can drive events
|
||||
// directly, exactly as the Tauri event bridge would.
|
||||
let registeredHandler: ((event: { payload: PlayerStatusEvent }) => void) | null = null;
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (_event: string, handler: any) => {
|
||||
registeredHandler = handler;
|
||||
return () => {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
// The current queue item is what handleStateChanged seeds playing/paused state
|
||||
// from. Back it with a writable so each test can install its own item.
|
||||
const currentQueueItemStore = writable<MediaItem | null>(null);
|
||||
vi.mock("$lib/stores/queue", () => ({
|
||||
queue: { subscribe: vi.fn() },
|
||||
currentQueueItem: { subscribe: (run: any) => currentQueueItemStore.subscribe(run) },
|
||||
}));
|
||||
|
||||
// Keep the player in local mode so events aren't skipped as remote.
|
||||
// playerEvents.ts reads `get(playbackMode)` (.mode/.isTransferring) and player.ts
|
||||
// imports `isRemoteMode` from the same module — provide both.
|
||||
vi.mock("$lib/stores/playbackMode", () => ({
|
||||
playbackMode: {
|
||||
setMode: vi.fn(),
|
||||
initializeSessionMonitoring: vi.fn(),
|
||||
subscribe: (run: any) => {
|
||||
run({ mode: "local", isTransferring: false });
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
isRemoteMode: { subscribe: (run: any) => (run(false), () => {}) },
|
||||
}));
|
||||
|
||||
// Remote-mode merged stores in player.ts read this; stub as no remote session.
|
||||
vi.mock("$lib/stores/sessions", () => ({
|
||||
selectedSession: { subscribe: (run: any) => (run(null), () => {}) },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/sleepTimer", () => ({
|
||||
sleepTimer: { set: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/nextEpisode", () => ({
|
||||
nextEpisode: { showPopup: vi.fn(), updateCountdown: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/services/preload", () => ({
|
||||
preloadUpcomingTracks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
return {
|
||||
id: "track-1",
|
||||
name: "Test Track",
|
||||
type: "Audio",
|
||||
runTimeTicks: null,
|
||||
...overrides,
|
||||
} as MediaItem;
|
||||
}
|
||||
|
||||
async function fire(event: PlayerStatusEvent): Promise<void> {
|
||||
if (!registeredHandler) throw new Error("handler not registered");
|
||||
await registeredHandler({ payload: event });
|
||||
// Let any async work inside the handler settle.
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("Player Events — pause must not zero the slider duration", () => {
|
||||
beforeEach(async () => {
|
||||
// initPlayerEvents is a singleton; reset it so each test re-registers its
|
||||
// own handler and starts from idle player state.
|
||||
const { cleanupPlayerEvents } = await import("./playerEvents");
|
||||
const { player } = await import("$lib/stores/player");
|
||||
cleanupPlayerEvents();
|
||||
player.setIdle();
|
||||
vi.clearAllMocks();
|
||||
registeredHandler = null;
|
||||
currentQueueItemStore.set(null);
|
||||
});
|
||||
|
||||
it("preserves the live duration across pause when runTimeTicks is missing", async () => {
|
||||
// runTimeTicks is null — the previous code recomputed duration as 0 here,
|
||||
// which collapsed the slider's max and snapped the thumb to the start.
|
||||
const item = makeItem({ runTimeTicks: null });
|
||||
currentQueueItemStore.set(item);
|
||||
|
||||
const { initPlayerEvents } = await import("./playerEvents");
|
||||
const { player, playbackDuration, playbackPosition } = await import("$lib/stores/player");
|
||||
|
||||
await initPlayerEvents();
|
||||
|
||||
// 1. Track starts playing.
|
||||
await fire({ type: "state_changed", state: "playing", media_id: item.id });
|
||||
// 2. Backend reports the real duration once media is loaded / position ticks.
|
||||
await fire({ type: "position_update", position: 42, duration: 180 });
|
||||
|
||||
expect(get(playbackPosition)).toBe(42);
|
||||
expect(get(playbackDuration)).toBe(180);
|
||||
|
||||
// 3. User pauses. Position AND duration must survive — duration is what
|
||||
// drives the slider's max, so a 0 here is what caused the regression.
|
||||
await fire({ type: "state_changed", state: "paused", media_id: item.id });
|
||||
|
||||
expect(get(playbackPosition)).toBe(42);
|
||||
expect(get(playbackDuration)).toBe(180);
|
||||
|
||||
// Sanity: the store is genuinely paused, not reset to idle/loading.
|
||||
expect(get(player).state.kind).toBe("paused");
|
||||
});
|
||||
|
||||
it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
|
||||
// 70s in ticks (1 tick = 100ns) → 700_000_000.
|
||||
const item = makeItem({ runTimeTicks: 700_000_000 });
|
||||
currentQueueItemStore.set(item);
|
||||
|
||||
const { initPlayerEvents } = await import("./playerEvents");
|
||||
const { playbackDuration } = await import("$lib/stores/player");
|
||||
|
||||
await initPlayerEvents();
|
||||
|
||||
// No position_update yet, so there is no live duration to prefer.
|
||||
await fire({ type: "state_changed", state: "paused", media_id: item.id });
|
||||
|
||||
expect(get(playbackDuration)).toBe(70);
|
||||
});
|
||||
});
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
import { type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
|
||||
import { player, playbackPosition, currentMedia } from "$lib/stores/player";
|
||||
import { player, playbackPosition, playbackDuration, currentMedia } from "$lib/stores/player";
|
||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||
import { playbackMode } from "$lib/stores/playbackMode";
|
||||
import { sleepTimer } from "$lib/stores/sleepTimer";
|
||||
@ -142,6 +142,26 @@ function handlePositionUpdate(position: number, duration: number): void {
|
||||
// Note: Sleep timer logic is now handled entirely in the Rust backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the duration to seed playing/paused state with.
|
||||
*
|
||||
* For the same track that is already loaded, the live store duration (kept
|
||||
* fresh by media_loaded / position_update events) is authoritative and is
|
||||
* preferred — falling back to the runTimeTicks estimate only when the store
|
||||
* has no usable duration yet. This avoids clobbering a known-good duration
|
||||
* with 0 when runTimeTicks is missing (which would zero the slider's max).
|
||||
*/
|
||||
function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number {
|
||||
const estimate = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||
if (isSameTrack) {
|
||||
const live = get(playbackDuration);
|
||||
if (live > 0) {
|
||||
return live;
|
||||
}
|
||||
}
|
||||
return estimate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state change events.
|
||||
*
|
||||
@ -171,7 +191,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
||||
const previous = get(currentMedia);
|
||||
const isSameTrack = previous?.id === currentItem.id;
|
||||
const startPosition = isSameTrack ? get(playbackPosition) : 0;
|
||||
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||
const initialDuration = resolveDuration(currentItem, isSameTrack);
|
||||
player.setPlaying(currentItem, startPosition, initialDuration);
|
||||
|
||||
// Trigger preloading of upcoming tracks in the background
|
||||
@ -180,9 +200,15 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
||||
console.debug("[playerEvents] Preload failed (non-critical):", e);
|
||||
});
|
||||
} else if (state === "paused" && currentItem) {
|
||||
// Keep current position from store
|
||||
// Keep current position and duration from store. The same track is
|
||||
// already loaded on pause, so its live duration (from media_loaded /
|
||||
// position_update) is authoritative — recomputing from runTimeTicks
|
||||
// would clobber it with 0 when runTimeTicks is missing, forcing the
|
||||
// slider's max to 0 and flashing the thumb to the start.
|
||||
const previous = get(currentMedia);
|
||||
const isSameTrack = previous?.id === currentItem.id;
|
||||
const currentPosition = get(playbackPosition);
|
||||
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||
const initialDuration = resolveDuration(currentItem, isSameTrack);
|
||||
player.setPaused(currentItem, currentPosition, initialDuration);
|
||||
} else if (state === "loading" && currentItem) {
|
||||
player.setLoading(currentItem);
|
||||
|
||||
@ -250,6 +250,29 @@ export const mergedVolume = derived(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether an item is video content and therefore must NOT appear in the audio
|
||||
* mini player. Checks the Jellyfin item type (Movie/Episode/live TV) AND the
|
||||
* backend queue item's `mediaType` discriminator.
|
||||
*
|
||||
* The `mediaType` check is essential: a video started via `player_play_item`
|
||||
* (every Movie/Episode goes through VideoPlayer) is pushed onto the backend
|
||||
* queue as a PlayerMediaItem with NO `type` field but `mediaType: "video"`.
|
||||
* Without this, that queue item's absent `type` slips past the Movie/Episode
|
||||
* check and the video surfaces in the audio mini player after leaving /player.
|
||||
*/
|
||||
function isVideoItem(item: MediaItem | null): boolean {
|
||||
if (!item) return false;
|
||||
const type = item.type;
|
||||
if (type === "Movie" || type === "Episode" || type === "TvChannel") {
|
||||
return true;
|
||||
}
|
||||
// Backend PlayerMediaItem carries a lowercase mediaType discriminator that is
|
||||
// present even when the Jellyfin `type` is absent (video-only play requests).
|
||||
const mediaType = (item as { mediaType?: string }).mediaType;
|
||||
return mediaType === "video";
|
||||
}
|
||||
|
||||
/**
|
||||
* Should show audio miniplayer - state machine gated
|
||||
* Only true when:
|
||||
@ -268,8 +291,8 @@ export const shouldShowAudioMiniPlayer = derived(
|
||||
|
||||
// Determine media type from the player state, falling back to the queue
|
||||
// item (the player store can momentarily lack media during transitions).
|
||||
const mediaType = $media?.type ?? $queueItem?.type;
|
||||
if (mediaType === "Movie" || mediaType === "Episode") {
|
||||
const item = $media ?? $queueItem;
|
||||
if (isVideoItem(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -76,6 +76,25 @@ describe("shouldShowAudioMiniPlayer", () => {
|
||||
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
|
||||
});
|
||||
|
||||
it("hides a video queue item that has no Jellyfin `type` but mediaType 'video'", () => {
|
||||
// Regression: player_play_item (every Movie/Episode via VideoPlayer) pushes
|
||||
// a backend PlayerMediaItem onto the queue with item_type: None but
|
||||
// media_type: Video → serialized with no `type` and mediaType: "video".
|
||||
// Without the mediaType check this slipped past the Movie/Episode guard and
|
||||
// surfaced the last-played video in the audio mini player after leaving
|
||||
// /player.
|
||||
const videoQueueItem = { id: "v2", mediaType: "video" } as unknown as MediaItem;
|
||||
currentQueueItem.set(videoQueueItem);
|
||||
player.setIdle();
|
||||
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
|
||||
});
|
||||
|
||||
it("hides for a live TV channel", () => {
|
||||
const channelItem = { id: "c1", type: "TvChannel" } as unknown as MediaItem;
|
||||
player.setPlaying(channelItem, 0, 0);
|
||||
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows in remote mode when the session has a now-playing item", () => {
|
||||
isRemoteMode.set(true);
|
||||
selectedSession.set({ nowPlayingItem: { id: "r1" } });
|
||||
|
||||
39
src/lib/utils/truncateMiddle.test.ts
Normal file
39
src/lib/utils/truncateMiddle.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { truncateMiddle } from "./truncateMiddle";
|
||||
|
||||
describe("truncateMiddle", () => {
|
||||
it("returns short strings unchanged", () => {
|
||||
expect(truncateMiddle("short", 32)).toBe("short");
|
||||
expect(truncateMiddle("exactly-len", 11)).toBe("exactly-len");
|
||||
});
|
||||
|
||||
it("abbreviates in the middle keeping head and tail", () => {
|
||||
const result = truncateMiddle("long_media_name_like_this", 16);
|
||||
expect(result).toContain("…");
|
||||
expect(result.length).toBe(16);
|
||||
expect(result.startsWith("long")).toBe(true);
|
||||
expect(result.endsWith("this")).toBe(true);
|
||||
});
|
||||
|
||||
it("never exceeds maxLength", () => {
|
||||
for (const len of [1, 2, 3, 5, 10, 25]) {
|
||||
expect(truncateMiddle("a".repeat(100), len).length).toBeLessThanOrEqual(len);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles null and undefined", () => {
|
||||
expect(truncateMiddle(null)).toBe("");
|
||||
expect(truncateMiddle(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to head-truncation when there is no room for content", () => {
|
||||
expect(truncateMiddle("abcdef", 1)).toBe("a");
|
||||
expect(truncateMiddle("abcdef", 0)).toBe("");
|
||||
});
|
||||
|
||||
it("supports a custom ellipsis", () => {
|
||||
const result = truncateMiddle("long_media_name_like_this", 16, "...");
|
||||
expect(result).toContain("...");
|
||||
expect(result.length).toBe(16);
|
||||
});
|
||||
});
|
||||
33
src/lib/utils/truncateMiddle.ts
Normal file
33
src/lib/utils/truncateMiddle.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Abbreviate a long string in the middle so both the start and the end stay
|
||||
* visible, e.g. `long_media_name_like_this` -> `long_med…ike_this`.
|
||||
*
|
||||
* Unlike CSS `text-overflow: ellipsis` (which only clips the end), this keeps
|
||||
* the tail of a media name — often the most distinguishing part (episode
|
||||
* number, disambiguating suffix, etc.).
|
||||
*
|
||||
* @param value The string to abbreviate.
|
||||
* @param maxLength Maximum length of the returned string, including the ellipsis.
|
||||
* @param ellipsis The separator inserted in the middle (default `…`).
|
||||
*/
|
||||
export function truncateMiddle(
|
||||
value: string | null | undefined,
|
||||
maxLength = 32,
|
||||
ellipsis = "…",
|
||||
): string {
|
||||
if (value == null) return "";
|
||||
if (maxLength <= 0) return "";
|
||||
if (value.length <= maxLength) return value;
|
||||
|
||||
// Not enough room for any real content around the ellipsis: fall back to a
|
||||
// plain head-truncation so we never return more than maxLength characters.
|
||||
if (maxLength <= ellipsis.length) {
|
||||
return value.slice(0, maxLength);
|
||||
}
|
||||
|
||||
const keep = maxLength - ellipsis.length;
|
||||
const head = Math.ceil(keep / 2);
|
||||
const tail = Math.floor(keep / 2);
|
||||
|
||||
return value.slice(0, head) + ellipsis + (tail > 0 ? value.slice(value.length - tail) : "");
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user