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.
242 lines
8.7 KiB
Svelte
242 lines
8.7 KiB
Svelte
<!-- TRACES: UR-010 | JA-022, JA-023, JA-024, JA-026 | DR-037 -->
|
|
<script lang="ts">
|
|
import type { Session } from "$lib/api/types";
|
|
import { sessions } from "$lib/stores";
|
|
|
|
interface Props {
|
|
session: Session;
|
|
}
|
|
|
|
let { session }: Props = $props();
|
|
|
|
let isCommandPending = $state(false);
|
|
|
|
const playState = $derived(session.playState);
|
|
const nowPlaying = $derived(session.nowPlayingItem);
|
|
const supportsSeek = $derived(playState?.canSeek ?? false);
|
|
const supportsNextPrevious = $derived(
|
|
(session.supportedCommands ?? []).includes("NextTrack") &&
|
|
(session.supportedCommands ?? []).includes("PreviousTrack")
|
|
);
|
|
|
|
async function handlePlayPause() {
|
|
if (isCommandPending) return;
|
|
isCommandPending = true;
|
|
try {
|
|
await sessions.sendPlayPause(session.id);
|
|
} finally {
|
|
isCommandPending = false;
|
|
}
|
|
}
|
|
|
|
async function handleStop() {
|
|
if (isCommandPending) return;
|
|
isCommandPending = true;
|
|
try {
|
|
await sessions.sendStop(session.id);
|
|
} finally {
|
|
isCommandPending = false;
|
|
}
|
|
}
|
|
|
|
async function handleNext() {
|
|
if (isCommandPending || !supportsNextPrevious) return;
|
|
isCommandPending = true;
|
|
try {
|
|
await sessions.sendNext(session.id);
|
|
} finally {
|
|
isCommandPending = false;
|
|
}
|
|
}
|
|
|
|
async function handlePrevious() {
|
|
if (isCommandPending || !supportsNextPrevious) return;
|
|
isCommandPending = true;
|
|
try {
|
|
await sessions.sendPrevious(session.id);
|
|
} finally {
|
|
isCommandPending = false;
|
|
}
|
|
}
|
|
|
|
function handleVolumeChange(event: Event) {
|
|
const target = event.target as HTMLInputElement;
|
|
const volume = parseInt(target.value);
|
|
sessions.sendVolume(session.id, volume);
|
|
}
|
|
|
|
function handleSeek(event: Event) {
|
|
const target = event.target as HTMLInputElement;
|
|
const positionPercent = parseFloat(target.value);
|
|
|
|
if (nowPlaying?.runTimeTicks) {
|
|
const positionTicks = (positionPercent / 100) * nowPlaying.runTimeTicks;
|
|
sessions.sendSeek(session.id, positionTicks);
|
|
}
|
|
}
|
|
|
|
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 positionPercent = $derived(() => {
|
|
if (!playState?.positionTicks || !nowPlaying?.runTimeTicks) return 0;
|
|
return (playState.positionTicks / nowPlaying.runTimeTicks) * 100;
|
|
});
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-6 p-6 rounded-lg bg-[var(--color-surface)]">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-white">Remote Control</h3>
|
|
<p class="text-sm text-gray-400">Controlling: {session.deviceName}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{#if nowPlaying && playState}
|
|
<!-- Now Playing Info -->
|
|
<div class="flex flex-col gap-2">
|
|
<h4 class="text-base font-medium text-white truncate">{nowPlaying.name}</h4>
|
|
{#if nowPlaying.artists && nowPlaying.artists.length > 0}
|
|
<p class="text-sm text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
|
|
{:else if nowPlaying?.album}
|
|
<p class="text-sm text-gray-400 truncate">{nowPlaying?.album}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Seek Bar -->
|
|
{#if supportsSeek && nowPlaying.runTimeTicks}
|
|
<div class="flex flex-col gap-2">
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
step="0.1"
|
|
value={positionPercent()}
|
|
oninput={handleSeek}
|
|
class="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer
|
|
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
|
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
|
|
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3
|
|
[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0"
|
|
/>
|
|
<div class="flex justify-between text-xs text-gray-400">
|
|
<span>{playState.positionTicks ? formatTime(playState.positionTicks) : "0:00"}</span>
|
|
<span>{formatTime(nowPlaying.runTimeTicks)}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Playback Controls -->
|
|
<div class="flex items-center justify-center gap-4">
|
|
<!-- Previous -->
|
|
<button
|
|
onclick={handlePrevious}
|
|
disabled={!supportsNextPrevious || isCommandPending}
|
|
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
title="Previous"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Play/Pause -->
|
|
<button
|
|
onclick={handlePlayPause}
|
|
disabled={isCommandPending}
|
|
class="p-3 rounded-full bg-white text-black hover:scale-105 transition-transform disabled:opacity-50"
|
|
title={playState.isPaused ? "Play" : "Pause"}
|
|
>
|
|
{#if playState.isPaused}
|
|
<svg class="w-6 h-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
{:else}
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Next -->
|
|
<button
|
|
onclick={handleNext}
|
|
disabled={!supportsNextPrevious || isCommandPending}
|
|
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
|
title="Next"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Stop -->
|
|
<button
|
|
onclick={handleStop}
|
|
disabled={isCommandPending}
|
|
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-50 transition-colors"
|
|
title="Stop"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 6h12v12H6z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Volume Control -->
|
|
{#if playState.volumeLevel !== undefined}
|
|
<div class="flex items-center gap-3">
|
|
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
{#if playState.isMuted || (playState.volumeLevel ?? 0) === 0}
|
|
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
|
{:else if (playState.volumeLevel ?? 0) < 50}
|
|
<path d="M7 9v6h4l5 5V4l-5 5H7z" />
|
|
{:else}
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
|
|
{/if}
|
|
</svg>
|
|
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={playState.volumeLevel}
|
|
oninput={handleVolumeChange}
|
|
class="flex-1 h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer
|
|
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
|
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
|
|
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3
|
|
[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0"
|
|
/>
|
|
|
|
<span class="text-sm text-gray-400 w-12 text-right">{playState.volumeLevel}%</span>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<!-- No media playing -->
|
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
|
<svg class="w-16 h-16 text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
/>
|
|
</svg>
|
|
<h4 class="text-base font-medium text-gray-400 mb-2">No Media Playing</h4>
|
|
<p class="text-sm text-gray-500">
|
|
Start playing media on {session.deviceName} to control it from here
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|