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
+9 -9
View File
@@ -29,7 +29,7 @@ vi.mock("$lib/stores/auth", () => ({
getItem: vi.fn(async (id: string) => ({
id,
name: "Test Item",
runTimeTicks: 100000000,
durationMs: 10000,
})),
})),
},
@@ -61,7 +61,7 @@ describe("playback reporting service", () => {
(c) => c[0] === "storage_update_playback_context"
);
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 () => {
@@ -121,7 +121,7 @@ describe("playback reporting service", () => {
const call = invokeSpy.mock.calls.find(
(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();
});
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 authModule = vi.mocked(auth);
const mockRepo = {
@@ -167,7 +167,7 @@ describe("playback reporting service", () => {
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"item-123",
900000000 // 90 seconds in ticks
90000 // 90 seconds in ms
);
});
@@ -207,7 +207,7 @@ describe("playback reporting service", () => {
getItem: vi.fn(async () => ({
id: "item-123",
name: "Item",
runTimeTicks: 100000000,
durationMs: 10000,
})),
};
authModule.getRepository = vi.fn(() => mockRepo as any);
@@ -217,11 +217,11 @@ describe("playback reporting service", () => {
expect(mockRepo.getItem).toHaveBeenCalledWith("item-123");
expect(mockRepo.reportPlaybackStopped).toHaveBeenCalledWith(
"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 authModule = vi.mocked(auth);
const mockRepo = {
@@ -229,7 +229,7 @@ describe("playback reporting service", () => {
getItem: vi.fn(async () => ({
id: "item-123",
name: "Item",
runTimeTicks: null,
durationMs: null,
})),
};
authModule.getRepository = vi.fn(() => mockRepo as any);
+9 -9
View File
@@ -26,7 +26,7 @@ export async function reportPlaybackStart(
contextType: "container" | "single" = "single",
contextId: string | null = null
): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000);
const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId();
console.log(
@@ -42,7 +42,7 @@ export async function reportPlaybackStart(
// Update local DB with context (always works, even offline)
if (userId) {
try {
await commands.storageUpdatePlaybackContext(userId, itemId, positionTicks, contextType, contextId);
await commands.storageUpdatePlaybackContext(userId, itemId, positionMs, contextType, contextId);
} catch (e) {
console.error("[PlaybackReporting] Failed to update playback context:", e);
}
@@ -62,7 +62,7 @@ export async function reportPlaybackProgress(
positionSeconds: number,
_isPaused = false
): Promise<void> {
const positionTicks = Math.floor(positionSeconds * 10000000);
const positionMs = Math.floor(positionSeconds * 1000);
const userId = auth.getUserId();
// 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)
if (userId) {
try {
await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks);
await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
} catch (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
*/
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();
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)
if (userId) {
try {
await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks);
await commands.storageUpdatePlaybackProgress(userId, itemId, positionMs);
} catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e);
}
@@ -108,7 +108,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
try {
// Get the repository to check if we should queue
const repo = auth.getRepository();
await repo.reportPlaybackStopped(itemId, positionTicks);
await repo.reportPlaybackStopped(itemId, positionMs);
} catch (e) {
console.error("[PlaybackReporting] Failed to report to server:", e);
// 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 item = await repo.getItem(itemId);
if (item.runTimeTicks) {
await repo.reportPlaybackStopped(itemId, item.runTimeTicks);
if (item.durationMs) {
await repo.reportPlaybackStopped(itemId, item.durationMs);
}
} catch (e) {
console.error("[PlaybackReporting] Failed to report as played:", e);
@@ -73,8 +73,8 @@ function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "track-1",
name: "Test Track",
type: "Audio",
runTimeTicks: null,
kind: "track",
durationMs: null,
...overrides,
} 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 () => {
// 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 });
const item = makeItem({ durationMs: null });
currentQueueItemStore.set(item);
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 () => {
// 70s in ticks (1 tick = 100ns) → 700_000_000.
const item = makeItem({ runTimeTicks: 700_000_000 });
// 70 seconds = 70_000 ms.
const item = makeItem({ durationMs: 70_000 });
currentQueueItemStore.set(item);
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).
*/
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) {
const live = get(playbackDuration);
if (live > 0) {
+3 -3
View File
@@ -104,12 +104,12 @@ class SyncService {
*/
async queuePlaybackProgress(
itemId: string,
positionTicks: number
positionMs: number
): Promise<number> {
// 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 });
}
/**