domain: player/reporting ticks -> milliseconds (phase 4c)

Playback position now crosses the IPC boundary in milliseconds. Ticks
survive only inside Rust (DB storage, Jellyfin API) and at the genuine
remote-session boundary (session seek / transfer / RemoteControls).

Rust command signatures (ms in, converted to ticks internally):
- storage_update_playback_progress / _context: position_ms
- repository_report_playback_start / _progress / _stopped: position_ms
- PlaybackProgress.position_ticks -> position_ms (converted in the query)

Frontend:
- playbackReporting, playerEvents, VideoPlayer, Queue, player/[id] resume:
  seconds*1000 / durationMs/1000 instead of tick math.
- repository-client + syncService param names -> positionMs.
- Tests updated to ms fixtures/assertions.

Out of scope (legitimately ticks): NowPlayingItem, PlayState.positionTicks,
sessionSeek, playbackModeTransferToLocal, RemoteControls, SessionCard — the
remote Jellyfin session API.

Rust 456, frontend 644, check clean.
This commit is contained in:
2026-07-23 22:02:29 +02:00
parent 93d198ce21
commit ec8a7610f5
14 changed files with 88 additions and 71 deletions
+7 -3
View File
@@ -579,8 +579,9 @@ pub async fn repository_report_playback_start(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
) -> Result<(), String> { ) -> Result<(), String> {
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref() repo.as_ref()
.report_playback_start(&item_id, position_ticks) .report_playback_start(&item_id, position_ticks)
@@ -595,8 +596,9 @@ pub async fn repository_report_playback_progress(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
) -> Result<(), String> { ) -> Result<(), String> {
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref() repo.as_ref()
.report_playback_progress(&item_id, position_ticks) .report_playback_progress(&item_id, position_ticks)
@@ -611,8 +613,10 @@ pub async fn repository_report_playback_stopped(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
) -> Result<(), String> { ) -> Result<(), String> {
// Milliseconds across the boundary; the Jellyfin API wants ticks.
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref() repo.as_ref()
.report_playback_stopped(&item_id, position_ticks) .report_playback_stopped(&item_id, position_ticks)
+17 -9
View File
@@ -583,7 +583,9 @@ pub async fn storage_delete_user(
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PlaybackProgress { pub struct PlaybackProgress {
pub item_id: String, pub item_id: String,
pub position_ticks: i64, /// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
/// converted here so the frontend never sees ticks.
pub position_ms: i64,
pub is_played: bool, pub is_played: bool,
pub is_favorite: bool, pub is_favorite: bool,
pub play_count: i32, pub play_count: i32,
@@ -597,8 +599,11 @@ pub async fn storage_update_playback_progress(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
) -> Result<(), String> { ) -> Result<(), String> {
// The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
// stays on this side of the boundary. 10_000 ticks = 1 ms.
let position_ticks = position_ms * 10_000;
let db_service = { let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service()) Arc::new(database.service())
@@ -649,12 +654,14 @@ pub async fn storage_update_playback_context(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
item_id: String, item_id: String,
position_ticks: i64, position_ms: i64,
context_type: Option<String>, context_type: Option<String>,
context_id: Option<String>, context_id: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::storage::db_service::{Query, QueryParam}; use crate::storage::db_service::{Query, QueryParam};
// Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
let position_ticks = position_ms * 10_000;
let db_service = { let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service()) Arc::new(database.service())
@@ -857,9 +864,10 @@ pub async fn storage_get_playback_progress(
db_service db_service
.query_optional(query, |row| { .query_optional(query, |row| {
let position_ticks: i64 = row.get(1)?;
Ok(PlaybackProgress { Ok(PlaybackProgress {
item_id: row.get(0)?, item_id: row.get(0)?,
position_ticks: row.get(1)?, position_ms: position_ticks / 10_000,
is_played: row.get::<_, i32>(2)? != 0, is_played: row.get::<_, i32>(2)? != 0,
is_favorite: row.get::<_, i32>(3)? != 0, is_favorite: row.get::<_, i32>(3)? != 0,
play_count: row.get(4)?, play_count: row.get(4)?,
@@ -1495,7 +1503,7 @@ mod tests {
fn test_playback_progress_serialization() { fn test_playback_progress_serialization() {
let progress = PlaybackProgress { let progress = PlaybackProgress {
item_id: "item-123".to_string(), item_id: "item-123".to_string(),
position_ticks: 150_000_000, position_ms: 150_000_000,
is_played: true, is_played: true,
is_favorite: false, is_favorite: false,
play_count: 3, play_count: 3,
@@ -1512,7 +1520,7 @@ mod tests {
fn test_playback_progress_played_status() { fn test_playback_progress_played_status() {
let progress = PlaybackProgress { let progress = PlaybackProgress {
item_id: "item-456".to_string(), item_id: "item-456".to_string(),
position_ticks: 0, position_ms: 0,
is_played: true, is_played: true,
is_favorite: true, is_favorite: true,
play_count: 1, play_count: 1,
@@ -1530,7 +1538,7 @@ mod tests {
fn test_playback_progress_not_played() { fn test_playback_progress_not_played() {
let progress = PlaybackProgress { let progress = PlaybackProgress {
item_id: "item-789".to_string(), item_id: "item-789".to_string(),
position_ticks: 30_000_000, position_ms: 30_000_000,
is_played: false, is_played: false,
is_favorite: false, is_favorite: false,
play_count: 0, play_count: 0,
@@ -1600,7 +1608,7 @@ mod tests {
fn test_playback_progress_camel_case() { fn test_playback_progress_camel_case() {
let progress = PlaybackProgress { let progress = PlaybackProgress {
item_id: "i1".to_string(), item_id: "i1".to_string(),
position_ticks: 100, position_ms: 100,
is_played: true, is_played: true,
is_favorite: false, is_favorite: false,
play_count: 1, play_count: 1,
@@ -1609,7 +1617,7 @@ mod tests {
let json = serde_json::to_string(&progress).unwrap(); let json = serde_json::to_string(&progress).unwrap();
// Verify camelCase serialization // Verify camelCase serialization
assert!(json.contains("itemId")); assert!(json.contains("itemId"));
assert!(json.contains("positionTicks")); assert!(json.contains("positionMs"));
assert!(json.contains("isPlayed")); assert!(json.contains("isPlayed"));
assert!(json.contains("isFavorite")); assert!(json.contains("isFavorite"));
assert!(json.contains("playCount")); assert!(json.contains("playCount"));
+16 -11
View File
@@ -670,15 +670,15 @@ async storageDeleteUser(userId: string) : Promise<null> {
* Update playback progress in local database * Update playback progress in local database
* This stores the progress locally for offline access and "continue watching" * This stores the progress locally for offline access and "continue watching"
*/ */
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionTicks: number) : Promise<null> { async storageUpdatePlaybackProgress(userId: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionTicks }); return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionMs });
}, },
/** /**
* Update playback progress with context in local database * Update playback progress with context in local database
* This stores the progress along with playback context (container vs single) * This stores the progress along with playback context (container vs single)
*/ */
async storageUpdatePlaybackContext(userId: string, itemId: string, positionTicks: number, contextType: string | null, contextId: string | null) : Promise<null> { async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: number, contextType: string | null, contextId: string | null) : Promise<null> {
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionTicks, contextType, contextId }); return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionMs, contextType, contextId });
}, },
/** /**
* Mark item as played in local database * Mark item as played in local database
@@ -1332,20 +1332,20 @@ async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStr
/** /**
* Report playback start * Report playback start
*/ */
async repositoryReportPlaybackStart(handle: string, itemId: string, positionTicks: number) : Promise<null> { async repositoryReportPlaybackStart(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionTicks }); return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionMs });
}, },
/** /**
* Report playback progress * Report playback progress
*/ */
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionTicks: number) : Promise<null> { async repositoryReportPlaybackProgress(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionTicks }); return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionMs });
}, },
/** /**
* Report playback stopped * Report playback stopped
*/ */
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionTicks: number) : Promise<null> { async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionTicks }); return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
}, },
/** /**
* Get image URL for an item * Get image URL for an item
@@ -2070,7 +2070,12 @@ export type PlaybackMode = { type: "local" } | { type: "remote"; session_id: str
/** /**
* Playback progress info * Playback progress info
*/ */
export type PlaybackProgress = { itemId: string; positionTicks: number; isPlayed: boolean; isFavorite: boolean; playCount: number } export type PlaybackProgress = { itemId: string;
/**
* Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
* converted here so the frontend never sees ticks.
*/
positionMs: number; isPlayed: boolean; isFavorite: boolean; playCount: number }
/** /**
* Represents a media item that can be played * Represents a media item that can be played
* *
+1 -1
View File
@@ -470,7 +470,7 @@ describe("RepositoryClient", () => {
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", { expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
handle: "test-handle-123", handle: "test-handle-123",
itemId: "item123", itemId: "item123",
positionTicks: 5000000, positionMs: 5000000,
}); });
}); });
}); });
+6 -6
View File
@@ -164,16 +164,16 @@ export class RepositoryClient {
return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId); return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
} }
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackStart(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks); await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs);
} }
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackProgress(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks); await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs);
} }
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackStopped(itemId: string, positionMs: number): Promise<void> {
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks); await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs);
} }
// ===== Stream URL Methods (via Rust) ===== // ===== Stream URL Methods (via Rust) =====
+3 -3
View File
@@ -36,9 +36,9 @@
let dragDisabled = $state(true); let dragDisabled = $state(true);
const flipDurationMs = 200; const flipDurationMs = 200;
function formatDuration(ticks?: number | null): string { function formatDuration(ms?: number | null): string {
if (!ticks) return ""; if (!ms) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ms / 1000);
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
const secs = seconds % 60; const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`; return `${mins}:${secs.toString().padStart(2, "0")}`;
@@ -97,8 +97,8 @@ function makeEpisode(): MediaItem {
return { return {
id: "ep1", id: "ep1",
name: "Episode 1", name: "Episode 1",
type: "Episode", kind: "episode",
runTimeTicks: 24 * 60 * 10_000_000, // 24 min durationMs: 24 * 60 * 1000, // 24 min
} as MediaItem; } as MediaItem;
} }
+4 -4
View File
@@ -165,9 +165,9 @@
// Use known duration from media item (runTimeTicks is in 10M ticks/second) // Use known duration from media item (runTimeTicks is in 10M ticks/second)
// Fallback to video element duration for direct streams // Fallback to video element duration for direct streams
const duration = $derived.by(() => { const duration = $derived.by(() => {
// Explicitly check if runTimeTicks exists and is a valid number // Explicitly check if durationMs exists and is a valid number
if (media && media.runTimeTicks && media.runTimeTicks > 0) { if (media && media.durationMs && media.durationMs > 0) {
return media.runTimeTicks / 10_000_000; return media.durationMs / 1000;
} }
// Otherwise use the video element's duration // Otherwise use the video element's duration
return videoDuration; return videoDuration;
@@ -386,7 +386,7 @@
// Check if we're near the end of the video - if so, this is likely // Check if we're near the end of the video - if so, this is likely
// end-of-stream rather than a real error. Jellyfin transcoded HLS // end-of-stream rather than a real error. Jellyfin transcoded HLS
// streams may not always terminate cleanly with #EXT-X-ENDLIST. // streams may not always terminate cleanly with #EXT-X-ENDLIST.
const knownDuration = media?.runTimeTicks ? media.runTimeTicks / 10_000_000 : videoDuration; const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
const effectiveTime = currentTime + seekOffset; const effectiveTime = currentTime + seekOffset;
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9; const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
+9 -9
View File
@@ -29,7 +29,7 @@ vi.mock("$lib/stores/auth", () => ({
getItem: vi.fn(async (id: string) => ({ getItem: vi.fn(async (id: string) => ({
id, id,
name: "Test Item", name: "Test Item",
runTimeTicks: 100000000, durationMs: 10000,
})), })),
})), })),
}, },
@@ -61,7 +61,7 @@ describe("playback reporting service", () => {
(c) => c[0] === "storage_update_playback_context" (c) => c[0] === "storage_update_playback_context"
); );
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![1]).toHaveProperty("positionTicks", 600000000); // 60 seconds expect(call![1]).toHaveProperty("positionMs", 60000); // 60 seconds
}); });
it("should use single context by default", async () => { it("should use single context by default", async () => {
@@ -121,7 +121,7 @@ describe("playback reporting service", () => {
const call = invokeSpy.mock.calls.find( const call = invokeSpy.mock.calls.find(
(c) => c[0] === "storage_update_playback_progress" (c) => c[0] === "storage_update_playback_progress"
); );
expect(call![1]).toHaveProperty("positionTicks", 450000000); // 45 seconds expect(call![1]).toHaveProperty("positionMs", 45000); // 45 seconds
}); });
}); });
@@ -155,7 +155,7 @@ describe("playback reporting service", () => {
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalled(); expect(mockRepo.reportPlaybackStopped).toHaveBeenCalled();
}); });
it("should convert seconds to ticks for server report", async () => { it("should convert seconds to milliseconds for server report", async () => {
const { auth } = await import("$lib/stores/auth"); const { auth } = await import("$lib/stores/auth");
const authModule = vi.mocked(auth); const authModule = vi.mocked(auth);
const mockRepo = { const mockRepo = {
@@ -167,7 +167,7 @@ describe("playback reporting service", () => {
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123", "item-123",
900000000 // 90 seconds in ticks 90000 // 90 seconds in ms
); );
}); });
@@ -207,7 +207,7 @@ describe("playback reporting service", () => {
getItem: vi.fn(async () => ({ getItem: vi.fn(async () => ({
id: "item-123", id: "item-123",
name: "Item", name: "Item",
runTimeTicks: 100000000, durationMs: 10000,
})), })),
}; };
authModule.getRepository = vi.fn(() => mockRepo as any); authModule.getRepository = vi.fn(() => mockRepo as any);
@@ -217,11 +217,11 @@ describe("playback reporting service", () => {
expect(mockRepo.getItem).toHaveBeenCalledWith("item-123"); expect(mockRepo.getItem).toHaveBeenCalledWith("item-123");
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith( expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123", "item-123",
100000000 10000
); );
}); });
it("should handle items without runTimeTicks", async () => { it("should handle items without durationMs", async () => {
const { auth } = await import("$lib/stores/auth"); const { auth } = await import("$lib/stores/auth");
const authModule = vi.mocked(auth); const authModule = vi.mocked(auth);
const mockRepo = { const mockRepo = {
@@ -229,7 +229,7 @@ describe("playback reporting service", () => {
getItem: vi.fn(async () => ({ getItem: vi.fn(async () => ({
id: "item-123", id: "item-123",
name: "Item", name: "Item",
runTimeTicks: null, durationMs: null,
})), })),
}; };
authModule.getRepository = vi.fn(() => mockRepo as any); authModule.getRepository = vi.fn(() => mockRepo as any);
+9 -9
View File
@@ -26,7 +26,7 @@ export async function reportPlaybackStart(
contextType: "container" | "single" = "single", contextType: "container" | "single" = "single",
contextId: string | null = null contextId: string | null = null
): Promise<void> { ): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000); const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId(); const userId = auth.getUserId();
console.log( console.log(
@@ -42,7 +42,7 @@ export async function reportPlaybackStart(
// Update local DB with context (always works, even offline) // Update local DB with context (always works, even offline)
if (userId) { if (userId) {
try { try {
await commands.storageUpdatePlaybackContext(userId, itemId, positionTicks, contextType, contextId); await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update playback context:", e); console.error("[PlaybackReporting] Failed to update playback context:", e);
} }
@@ -62,7 +62,7 @@ export async function reportPlaybackProgress(
positionSeconds: number, positionSeconds: number,
_isPaused = false _isPaused = false
): Promise<void> { ): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000); const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId(); const userId = auth.getUserId();
// Reduce logging for frequent progress updates // Reduce logging for frequent progress updates
@@ -73,7 +73,7 @@ export async function reportPlaybackProgress(
// Update local DB only (progress updates are frequent, don't report to server) // Update local DB only (progress updates are frequent, don't report to server)
if (userId) { if (userId) {
try { try {
await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks); await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e); console.error("[PlaybackReporting] Failed to update local progress:", e);
} }
@@ -89,7 +89,7 @@ export async function reportPlaybackProgress(
* TRACES: UR-005, UR-025 | DR-028 * TRACES: UR-005, UR-025 | DR-028
*/ */
export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> { export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000); const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId(); const userId = auth.getUserId();
console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds); console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds);
@@ -97,7 +97,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
// Update local DB first (always works, even offline) // Update local DB first (always works, even offline)
if (userId) { if (userId) {
try { try {
await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks); await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e); console.error("[PlaybackReporting] Failed to update local progress:", e);
} }
@@ -108,7 +108,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
try { try {
// Get the repository to check if we should queue // Get the repository to check if we should queue
const repo = auth.getRepository(); const repo = auth.getRepository();
await repo.reportPlaybackStopped(itemId, positionTicks); await repo.reportPlaybackStopped(itemId, positionMs);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to report to server:", e); console.error("[PlaybackReporting] Failed to report to server:", e);
// Server error - could queue, but for now just log // Server error - could queue, but for now just log
@@ -140,8 +140,8 @@ export async function markAsPlayed(itemId: string): Promise<void> {
const repo = auth.getRepository(); const repo = auth.getRepository();
const item = await repo.getItem(itemId); const item = await repo.getItem(itemId);
if (item.runTimeTicks) { if (item.durationMs) {
await repo.reportPlaybackStopped(itemId, item.runTimeTicks); await repo.reportPlaybackStopped(itemId, item.durationMs);
} }
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to report as played:", e); console.error("[PlaybackReporting] Failed to report as played:", e);
@@ -73,8 +73,8 @@ function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
return { return {
id: "track-1", id: "track-1",
name: "Test Track", name: "Test Track",
type: "Audio", kind: "track",
runTimeTicks: null, durationMs: null,
...overrides, ...overrides,
} as MediaItem; } as MediaItem;
} }
@@ -102,7 +102,7 @@ describe("Player Events — pause must not zero the slider duration", () => {
it("preserves the live duration across pause when runTimeTicks is missing", async () => { it("preserves the live duration across pause when runTimeTicks is missing", async () => {
// runTimeTicks is null — the previous code recomputed duration as 0 here, // runTimeTicks is null — the previous code recomputed duration as 0 here,
// which collapsed the slider's max and snapped the thumb to the start. // which collapsed the slider's max and snapped the thumb to the start.
const item = makeItem({ runTimeTicks: null }); const item = makeItem({ durationMs: null });
currentQueueItemStore.set(item); currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents"); const { initPlayerEvents } = await import("./playerEvents");
@@ -130,8 +130,8 @@ describe("Player Events — pause must not zero the slider duration", () => {
}); });
it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => { it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
// 70s in ticks (1 tick = 100ns) → 700_000_000. // 70 seconds = 70_000 ms.
const item = makeItem({ runTimeTicks: 700_000_000 }); const item = makeItem({ durationMs: 70_000 });
currentQueueItemStore.set(item); currentQueueItemStore.set(item);
const { initPlayerEvents } = await import("./playerEvents"); const { initPlayerEvents } = await import("./playerEvents");
+1 -1
View File
@@ -170,7 +170,7 @@ function handlePositionUpdate(position: number, duration: number): void {
* with 0 when runTimeTicks is missing (which would zero the slider's max). * with 0 when runTimeTicks is missing (which would zero the slider's max).
*/ */
function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number { function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number {
const estimate = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0; const estimate = currentItem.durationMs ? currentItem.durationMs / 1000 : 0;
if (isSameTrack) { if (isSameTrack) {
const live = get(playbackDuration); const live = get(playbackDuration);
if (live > 0) { if (live > 0) {
+3 -3
View File
@@ -104,12 +104,12 @@ class SyncService {
*/ */
async queuePlaybackProgress( async queuePlaybackProgress(
itemId: string, itemId: string,
positionTicks: number positionMs: number
): Promise<number> { ): Promise<number> {
// Update local state first // Update local state first
await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionTicks); await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionMs);
return this.queueMutation("update_progress", itemId, { positionTicks }); return this.queueMutation("update_progress", itemId, { positionMs });
} }
/** /**
+5 -5
View File
@@ -185,9 +185,9 @@
const progress = await commands.storageGetPlaybackProgress(userId, id); const progress = await commands.storageGetPlaybackProgress(userId, id);
console.log("Resume check - retrieved progress:", progress); console.log("Resume check - retrieved progress:", progress);
if (progress && progress.positionTicks > 0 && item.runTimeTicks) { if (progress && progress.positionMs > 0 && item.durationMs) {
const positionSeconds = progress.positionTicks / 10_000_000; const positionSeconds = progress.positionMs / 1000;
const totalSeconds = item.runTimeTicks / 10_000_000; const totalSeconds = item.durationMs / 1000;
const progressPercent = (positionSeconds / totalSeconds) * 100; const progressPercent = (positionSeconds / totalSeconds) * 100;
console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent); console.log("Resume check - positionSeconds:", positionSeconds, "totalSeconds:", totalSeconds, "progressPercent:", progressPercent);
@@ -206,7 +206,7 @@
console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90); console.log("Resume check - NOT showing dialog. Position > 30?", positionSeconds > 30, "Progress < 90?", progressPercent < 90);
} }
} else { } else {
console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionTicks, "Has runtime?", !!item.runTimeTicks); console.log("Resume check - No valid progress found. Has progress?", !!progress, "Has position?", progress?.positionMs, "Has runtime?", !!item.durationMs);
} }
} catch (e) { } catch (e) {
console.error("Failed to check saved progress:", e); console.error("Failed to check saved progress:", e);
@@ -354,7 +354,7 @@
title: t.name, title: t.name,
artist: t.artists?.join(", ") || null, artist: t.artists?.join(", ") || null,
album: t.albumName || null, album: t.albumName || null,
duration: t.runTimeTicks ? t.runTimeTicks / 10000000 : null, duration: t.durationMs ? t.durationMs / 1000 : null,
artworkUrl: t.imageId artworkUrl: t.imageId
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId }) ? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId })
: null, : null,