domain: catalog frontend off Jellyfin ticks -> milliseconds (phase 3a)
The catalog surface now speaks milliseconds, the app's neutral time unit.
Ticks no longer reach library/home components.
Rust:
- UserData gains playback_position_ms (dual-carry), populated from ticks
at the offline mapping seam via domain::ticks_to_ms.
Frontend:
- formatDuration(duration.ts) and the two local copies now take ms, not
ticks; all callers pass item.durationMs.
- Progress bars (EpisodeRow, EpisodeFocusView, MediaCard, LibraryListView)
compute playbackPositionMs / durationMs — unit-consistent, no tick math.
- PlaylistDetailView totalDuration sums durationMs.
- duration.test.ts + TrackList.test.ts fixtures updated to ms.
Deferred: player/session/reporting tick math (Queue, SessionCard,
RemoteControls, playbackReporting, playerEvents) — those cross the
storage/Jellyfin command boundary in ticks and need command-signature
changes (phase 3b). Display {item.type} badge -> kind label (phase 4).
Rust 456, frontend 644, check + check:boundary clean.
This commit is contained in:
+13
-1
@@ -2538,7 +2538,19 @@ export type User = { id: string; name: string; serverId: string; primaryImageTag
|
||||
/**
|
||||
* User-specific data for an item (playback state, favorites, etc.)
|
||||
*/
|
||||
export type UserData = { playbackPositionTicks?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null }
|
||||
export type UserData = {
|
||||
/**
|
||||
* Legacy Jellyfin resume position in ticks. Being replaced by
|
||||
* `playback_position_ms`; dual-carried while the frontend migrates
|
||||
* (docs/specs/frontend-domain-model.md). New code should read the ms field.
|
||||
*/
|
||||
playbackPositionTicks?: number | null;
|
||||
/**
|
||||
* Resume position in milliseconds — the neutral replacement for
|
||||
* `playback_position_ticks`. Populated from ticks by the mapping; the
|
||||
* frontend never divides ticks itself.
|
||||
*/
|
||||
playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null }
|
||||
/**
|
||||
* User info returned to frontend
|
||||
*/
|
||||
|
||||
@@ -86,9 +86,9 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
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 hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
@@ -99,10 +99,10 @@
|
||||
}
|
||||
|
||||
function getProgress(ep: MediaItem): number {
|
||||
if (!ep.userData || !ep.runTimeTicks) {
|
||||
if (!ep.userData || !ep.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100;
|
||||
return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
@@ -116,7 +116,7 @@
|
||||
const episodeLabel = $derived(
|
||||
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
||||
);
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const duration = $derived(formatDuration(episode.durationMs));
|
||||
const progress = $derived(getProgress(episode));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!episode.userData || !episode.runTimeTicks) {
|
||||
if (!episode.userData || !episode.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100;
|
||||
return ((episode.userData.playbackPositionMs ?? 0) / episode.durationMs) * 100;
|
||||
});
|
||||
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const duration = $derived(formatDuration(episode.durationMs));
|
||||
const episodeNumber = $derived(episode.indexNumber || 0);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -42,10 +42,10 @@
|
||||
|
||||
|
||||
function getProgress(item: MediaItem | Library): number {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
||||
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
|
||||
}
|
||||
|
||||
function getTrackNumber(item: MediaItem | Library): string {
|
||||
@@ -59,7 +59,7 @@
|
||||
<div class="space-y-1">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const subtitle = getSubtitle(item)}
|
||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||
{@const duration = "durationMs" in item ? formatDuration(item.durationMs) : ""}
|
||||
{@const progress = getProgress(item)}
|
||||
{@const trackNum = getTrackNumber(item)}
|
||||
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
|
||||
|
||||
@@ -119,10 +119,10 @@
|
||||
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
||||
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
|
||||
});
|
||||
|
||||
const subtitle = $derived(() => {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
|
||||
|
||||
const totalDuration = $derived(
|
||||
entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0)
|
||||
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="text-gray-400 text-right">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
{formatDuration(track.durationMs)}
|
||||
</div>
|
||||
|
||||
<!-- Download Button Placeholder -->
|
||||
@@ -421,7 +421,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
{formatDuration(track.durationMs)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 1"],
|
||||
albumName: "Album 1",
|
||||
albumId: "album-1",
|
||||
runTimeTicks: 1800000000, // 3 minutes
|
||||
durationMs: 180000, // 3 minutes
|
||||
primaryImageTag: "tag1",
|
||||
indexNumber: 1,
|
||||
},
|
||||
@@ -84,7 +84,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 2"],
|
||||
albumName: "Album 2",
|
||||
albumId: "album-2",
|
||||
runTimeTicks: 2400000000, // 4 minutes
|
||||
durationMs: 240000, // 4 minutes
|
||||
primaryImageTag: "tag2",
|
||||
indexNumber: 2,
|
||||
},
|
||||
@@ -96,7 +96,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 3", "Artist 4"],
|
||||
albumName: "Album 3",
|
||||
albumId: "album-3",
|
||||
runTimeTicks: 3000000000, // 5 minutes
|
||||
durationMs: 300000, // 5 minutes
|
||||
indexNumber: 3,
|
||||
},
|
||||
];
|
||||
@@ -187,7 +187,7 @@ describe("TrackList", () => {
|
||||
const tracksWithoutDuration: MediaItem[] = [
|
||||
{
|
||||
...mockTracks[0],
|
||||
runTimeTicks: undefined,
|
||||
durationMs: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
|
||||
<!-- Duration -->
|
||||
<span class="text-xs text-gray-500 flex-shrink-0">
|
||||
{formatDuration(item.runTimeTicks)}
|
||||
{formatDuration(item.durationMs)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -8,36 +8,35 @@ import { describe, it, expect } from "vitest";
|
||||
import { formatDuration, formatSecondsDuration } from "./duration";
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("should format duration from Jellyfin ticks (mm:ss format)", () => {
|
||||
// 1 second = 10,000,000 ticks
|
||||
expect(formatDuration(10000000)).toBe("0:01");
|
||||
expect(formatDuration(60000000)).toBe("0:06");
|
||||
expect(formatDuration(600000000)).toBe("1:00");
|
||||
expect(formatDuration(6000000000)).toBe("10:00");
|
||||
expect(formatDuration(36610000000)).toBe("61:01");
|
||||
it("should format duration from milliseconds (mm:ss format)", () => {
|
||||
expect(formatDuration(1000)).toBe("0:01");
|
||||
expect(formatDuration(6000)).toBe("0:06");
|
||||
expect(formatDuration(60000)).toBe("1:00");
|
||||
expect(formatDuration(600000)).toBe("10:00");
|
||||
expect(formatDuration(3661000)).toBe("61:01");
|
||||
});
|
||||
|
||||
it("should format duration with hh:mm:ss format", () => {
|
||||
// 1 hour = 3600 seconds = 36,000,000,000 ticks
|
||||
expect(formatDuration(36000000000, "hh:mm:ss")).toBe("1:00:00");
|
||||
expect(formatDuration(36100000000, "hh:mm:ss")).toBe("1:00:10");
|
||||
expect(formatDuration(36610000000, "hh:mm:ss")).toBe("1:01:01");
|
||||
// 1 hour = 3600 seconds = 3,600,000 ms
|
||||
expect(formatDuration(3600000, "hh:mm:ss")).toBe("1:00:00");
|
||||
expect(formatDuration(3610000, "hh:mm:ss")).toBe("1:00:10");
|
||||
expect(formatDuration(3661000, "hh:mm:ss")).toBe("1:01:01");
|
||||
});
|
||||
|
||||
it("should return empty string for undefined or 0 ticks", () => {
|
||||
it("should return empty string for undefined or 0 duration", () => {
|
||||
expect(formatDuration(undefined)).toBe("");
|
||||
expect(formatDuration(0)).toBe("");
|
||||
});
|
||||
|
||||
it("should pad seconds with leading zero", () => {
|
||||
expect(formatDuration(5000000)).toBe("0:00");
|
||||
expect(formatDuration(50000000)).toBe("0:05");
|
||||
expect(formatDuration(150000000)).toBe("0:15");
|
||||
expect(formatDuration(500)).toBe("0:00");
|
||||
expect(formatDuration(5000)).toBe("0:05");
|
||||
expect(formatDuration(15000)).toBe("0:15");
|
||||
});
|
||||
|
||||
it("should handle large durations", () => {
|
||||
// 2 hours 30 minutes 45 seconds = 9045 seconds * 10,000,000 ticks/second
|
||||
expect(formatDuration(90450000000, "hh:mm:ss")).toBe("2:30:45");
|
||||
// 2 hours 30 minutes 45 seconds = 9045 seconds = 9,045,000 ms
|
||||
expect(formatDuration(9045000, "hh:mm:ss")).toBe("2:30:45");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+10
-10
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* Duration formatting utilities
|
||||
* Duration formatting utilities.
|
||||
*
|
||||
* Jellyfin uses "ticks" for duration where 10,000,000 ticks = 1 second
|
||||
* Durations are milliseconds — the app's neutral time unit. The backend has
|
||||
* already converted any provider unit (e.g. Jellyfin ticks) before it reaches
|
||||
* the frontend, so no tick arithmetic lives here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert Jellyfin ticks to formatted duration string
|
||||
* @param ticks Duration in Jellyfin ticks (10M ticks = 1 second)
|
||||
* Convert a millisecond duration to a formatted string.
|
||||
* @param ms Duration in milliseconds
|
||||
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
|
||||
* @returns Formatted duration string or empty string if no ticks
|
||||
* @returns Formatted duration string or empty string if no duration
|
||||
*/
|
||||
export function formatDuration(ticks?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
|
||||
if (!ticks) return "";
|
||||
export function formatDuration(ms?: number | null, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
|
||||
if (!ms) return "";
|
||||
|
||||
// Jellyfin uses 10,000,000 ticks per second
|
||||
const TICKS_PER_SECOND = 10000000;
|
||||
const totalSeconds = Math.floor(ticks / TICKS_PER_SECOND);
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
|
||||
if (format === "hh:mm:ss") {
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
try {
|
||||
item = await library.loadItem(itemId);
|
||||
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.type})`);
|
||||
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
|
||||
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
|
||||
if (item?.people) {
|
||||
item.people.forEach((p, i) => {
|
||||
@@ -170,9 +170,9 @@
|
||||
|
||||
// Images now handled by CachedImage component
|
||||
|
||||
function formatDuration(ticks?: number): 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 hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
}
|
||||
|
||||
function handleItemClick(clickedItem: MediaItem | Library) {
|
||||
if (!("type" in clickedItem)) {
|
||||
if (!("kind" in clickedItem)) {
|
||||
// Library item - navigate to library
|
||||
goto(`/library/${clickedItem.id}`);
|
||||
return;
|
||||
@@ -405,8 +405,8 @@
|
||||
{#if item.type}
|
||||
<span class="px-2 py-1 bg-[var(--color-surface)] rounded">{item.type}</span>
|
||||
{/if}
|
||||
{#if item.runTimeTicks}
|
||||
<span>{formatDuration(item.runTimeTicks)}</span>
|
||||
{#if item.durationMs}
|
||||
<span>{formatDuration(item.durationMs)}</span>
|
||||
{/if}
|
||||
{#if item.communityRating}
|
||||
<span class="flex items-center gap-1">
|
||||
|
||||
Reference in New Issue
Block a user