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:
+16
-11
@@ -670,15 +670,15 @@ async storageDeleteUser(userId: string) : Promise<null> {
|
||||
* Update playback progress in local database
|
||||
* This stores the progress locally for offline access and "continue watching"
|
||||
*/
|
||||
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionTicks });
|
||||
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Update playback progress with context in local database
|
||||
* 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> {
|
||||
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionTicks, contextType, contextId });
|
||||
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, positionMs, contextType, contextId });
|
||||
},
|
||||
/**
|
||||
* Mark item as played in local database
|
||||
@@ -1332,20 +1332,20 @@ async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStr
|
||||
/**
|
||||
* Report playback start
|
||||
*/
|
||||
async repositoryReportPlaybackStart(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackStart(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Report playback progress
|
||||
*/
|
||||
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Report playback stopped
|
||||
*/
|
||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Get image URL for an item
|
||||
@@ -2070,7 +2070,12 @@ export type PlaybackMode = { type: "local" } | { type: "remote"; session_id: str
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
|
||||
@@ -470,7 +470,7 @@ describe("RepositoryClient", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
positionTicks: 5000000,
|
||||
positionMs: 5000000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,16 +164,16 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackStart(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackProgress(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackStopped(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
// ===== Stream URL Methods (via Rust) =====
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
let dragDisabled = $state(true);
|
||||
const flipDurationMs = 200;
|
||||
|
||||
function formatDuration(ticks?: number | null): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms) return "";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
|
||||
@@ -97,8 +97,8 @@ function makeEpisode(): MediaItem {
|
||||
return {
|
||||
id: "ep1",
|
||||
name: "Episode 1",
|
||||
type: "Episode",
|
||||
runTimeTicks: 24 * 60 * 10_000_000, // 24 min
|
||||
kind: "episode",
|
||||
durationMs: 24 * 60 * 1000, // 24 min
|
||||
} as MediaItem;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,9 +165,9 @@
|
||||
// Use known duration from media item (runTimeTicks is in 10M ticks/second)
|
||||
// Fallback to video element duration for direct streams
|
||||
const duration = $derived.by(() => {
|
||||
// Explicitly check if runTimeTicks exists and is a valid number
|
||||
if (media && media.runTimeTicks && media.runTimeTicks > 0) {
|
||||
return media.runTimeTicks / 10_000_000;
|
||||
// Explicitly check if durationMs exists and is a valid number
|
||||
if (media && media.durationMs && media.durationMs > 0) {
|
||||
return media.durationMs / 1000;
|
||||
}
|
||||
// Otherwise use the video element's duration
|
||||
return videoDuration;
|
||||
@@ -386,7 +386,7 @@
|
||||
// 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
|
||||
// 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 isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -185,9 +185,9 @@
|
||||
const progress = await commands.storageGetPlaybackProgress(userId, id);
|
||||
console.log("Resume check - retrieved progress:", progress);
|
||||
|
||||
if (progress && progress.positionTicks > 0 && item.runTimeTicks) {
|
||||
const positionSeconds = progress.positionTicks / 10_000_000;
|
||||
const totalSeconds = item.runTimeTicks / 10_000_000;
|
||||
if (progress && progress.positionMs > 0 && item.durationMs) {
|
||||
const positionSeconds = progress.positionMs / 1000;
|
||||
const totalSeconds = item.durationMs / 1000;
|
||||
const progressPercent = (positionSeconds / totalSeconds) * 100;
|
||||
|
||||
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);
|
||||
}
|
||||
} 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) {
|
||||
console.error("Failed to check saved progress:", e);
|
||||
@@ -354,7 +354,7 @@
|
||||
title: t.name,
|
||||
artist: t.artists?.join(", ") || null,
|
||||
album: t.albumName || null,
|
||||
duration: t.runTimeTicks ? t.runTimeTicks / 10000000 : null,
|
||||
duration: t.durationMs ? t.durationMs / 1000 : null,
|
||||
artworkUrl: t.imageId
|
||||
? repo.getImageUrl(t.albumId || t.id, "Primary", { maxWidth: 300, tag: t.imageId })
|
||||
: null,
|
||||
|
||||
Reference in New Issue
Block a user