Reconcile the ~50 frontend files that consumed the old hand-written types against
the stricter generated bindings (141 -> 0 svelte-check errors):
- Nullable strictness: null-coalesce/guard at consumer sites; widen shared helpers
(CachedImage.tag, ticksToSeconds, formatDuration, getSessionIcon, session store
send*/transferToRemote) to accept null.
- Field-name fixes exposed by codegen: userData.played -> isPlayed; remote
NowPlayingItem uses album (not albumName); ArtistItem id/name.
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia.
- ArtistItem now serializes camelCase (PascalCase alias retained for Jellyfin
deserialize); update its serialize test.
- Update downloads.test.ts to the bundled { request } shape; complete the
sessions.test.ts NowPlayingItem mock.
Frontend: svelte-check 0 errors, vitest 448 passing. Backend: 387 passing.
104 lines
3.6 KiB
Svelte
104 lines
3.6 KiB
Svelte
<!-- TRACES: UR-010 | DR-037 -->
|
|
<script lang="ts">
|
|
import type { Session } from "$lib/api/types";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
|
|
interface Props {
|
|
session: Session;
|
|
selected?: boolean;
|
|
onclick?: () => void;
|
|
}
|
|
|
|
let { session, selected = false, onclick }: Props = $props();
|
|
|
|
function formatTime(ticks: number): string {
|
|
const seconds = Math.floor(ticks / 10000000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
}
|
|
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
const playState = $derived(session.playState);
|
|
const nowPlaying = $derived(session.nowPlayingItem);
|
|
</script>
|
|
|
|
<button
|
|
type="button"
|
|
onclick={onclick}
|
|
class="w-full p-4 rounded-lg border-2 transition-all text-left
|
|
{selected
|
|
? 'border-[var(--color-jellyfin)] bg-[var(--color-jellyfin)]/10'
|
|
: 'border-[var(--color-surface)] bg-[var(--color-surface)] hover:border-[var(--color-jellyfin)]/50'}"
|
|
>
|
|
<!-- Session header -->
|
|
<div class="flex items-start gap-3 mb-2">
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-semibold text-white truncate">{session.deviceName}</h3>
|
|
<p class="text-sm text-gray-400 truncate">{session.client} • {session.userName}</p>
|
|
</div>
|
|
|
|
{#if selected}
|
|
<div class="flex-shrink-0 w-2 h-2 rounded-full bg-[var(--color-jellyfin)]"></div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Now playing -->
|
|
{#if nowPlaying && playState}
|
|
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
|
|
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
|
|
<CachedImage
|
|
itemId={nowPlaying.id ?? ""}
|
|
imageType="Primary"
|
|
tag={nowPlaying.primaryImageTag}
|
|
maxWidth={80}
|
|
alt={nowPlaying.name ?? undefined}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
|
|
{#if nowPlaying.artists && nowPlaying.artists.length > 0}
|
|
<p class="text-xs text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
|
|
{:else if nowPlaying.album}
|
|
<p class="text-xs text-gray-400 truncate">{nowPlaying.album}</p>
|
|
{/if}
|
|
|
|
<div class="flex items-center gap-2 mt-1">
|
|
<div class="flex items-center gap-1">
|
|
{#if playState.isPaused}
|
|
<svg class="w-3 h-3 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
</svg>
|
|
<span class="text-xs text-gray-400">Paused</span>
|
|
{:else}
|
|
<svg class="w-3 h-3 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
<span class="text-xs text-[var(--color-jellyfin)]">Playing</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if playState.positionTicks}
|
|
<span class="text-xs text-gray-500">•</span>
|
|
<span class="text-xs text-gray-400">{formatTime(playState.positionTicks)}</span>
|
|
{/if}
|
|
|
|
{#if playState.volumeLevel !== undefined}
|
|
<span class="text-xs text-gray-500">•</span>
|
|
<span class="text-xs text-gray-400">Vol {playState.volumeLevel}%</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="mt-3 pt-3 border-t border-white/10">
|
|
<p class="text-sm text-gray-500 italic">No media playing</p>
|
|
</div>
|
|
{/if}
|
|
</button>
|