Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
520 lines
20 KiB
Svelte
520 lines
20 KiB
Svelte
<!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 -->
|
|
<script lang="ts">
|
|
import { get } from "svelte/store";
|
|
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
|
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
|
import { playbackPosition } from "$lib/stores/player";
|
|
import { lmsSync, isLmsSession, macForSession } from "$lib/stores/lmsSync";
|
|
import type { Session } from "$lib/api/types";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("SessionPicker");
|
|
|
|
interface Props {
|
|
isOpen?: boolean;
|
|
onClose?: () => void;
|
|
onSelectSession?: (session: Session) => void;
|
|
}
|
|
|
|
let { isOpen = false, onClose, onSelectSession }: Props = $props();
|
|
|
|
// MACs currently part of any sync group, for rendering toggle state.
|
|
const groupedMacs = $derived(
|
|
new Set(($lmsSync.groups ?? []).flatMap((g) => [g.masterMac, ...(g.slaveMacs ?? [])])),
|
|
);
|
|
|
|
// The sync master is the LMS zone we're currently controlling. Zones can only
|
|
// be fused once we have a master to fuse them into.
|
|
const masterMac = $derived(
|
|
isLmsSession($selectedSession) ? macForSession($selectedSession) : null,
|
|
);
|
|
|
|
function isZoneFused(session: Session): boolean {
|
|
const mac = macForSession(session);
|
|
return mac !== null && groupedMacs.has(mac);
|
|
}
|
|
|
|
async function handleSessionSelect(session: Session) {
|
|
try {
|
|
// Transfer playback to remote session, resuming at our current position
|
|
await playbackMode.transferToRemote(session.id, get(playbackPosition));
|
|
|
|
if (onSelectSession) {
|
|
onSelectSession(session);
|
|
}
|
|
if (onClose) {
|
|
onClose();
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to select session:", error);
|
|
// Error is already stored in playbackMode store
|
|
}
|
|
}
|
|
|
|
// Toggle an LMS zone in/out of the current master's sync group. If there's no
|
|
// master yet (we're not controlling an LMS zone), the click falls back to a
|
|
// normal transfer so the user can start playing on a zone first.
|
|
async function handleLmsZoneToggle(session: Session) {
|
|
const zoneMac = macForSession(session);
|
|
if (!zoneMac) return;
|
|
|
|
if (!masterMac) {
|
|
// No master yet — start playback on this zone; it becomes the master.
|
|
await handleSessionSelect(session);
|
|
await lmsSync.refresh();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (isZoneFused(session)) {
|
|
await lmsSync.decoupleZone(zoneMac);
|
|
} else {
|
|
await lmsSync.fuseZone(masterMac, zoneMac);
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to toggle LMS zone:", error);
|
|
// Error is surfaced via the lmsSync store
|
|
}
|
|
}
|
|
|
|
async function handleTransferToLocal() {
|
|
try {
|
|
await playbackMode.transferToLocal();
|
|
if (onClose) {
|
|
onClose();
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to transfer to local:", error);
|
|
// Error is already stored in playbackMode store
|
|
}
|
|
}
|
|
|
|
async function handleDisconnect() {
|
|
try {
|
|
await playbackMode.disconnect();
|
|
if (onClose) {
|
|
onClose();
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to disconnect:", error);
|
|
// Error is already stored in playbackMode store
|
|
}
|
|
}
|
|
|
|
function handleClearError() {
|
|
playbackMode.clearError();
|
|
}
|
|
|
|
function handleBackdropClick(event: MouseEvent) {
|
|
if (event.target === event.currentTarget && onClose) {
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
function getSessionIcon(client: string | null | undefined): string {
|
|
const clientLower = (client ?? "").toLowerCase();
|
|
if (
|
|
clientLower.includes("tv") ||
|
|
clientLower.includes("roku") ||
|
|
clientLower.includes("android tv")
|
|
) {
|
|
return "tv";
|
|
} else if (
|
|
clientLower.includes("web") ||
|
|
clientLower.includes("chrome") ||
|
|
clientLower.includes("firefox")
|
|
) {
|
|
return "web";
|
|
} else if (
|
|
clientLower.includes("mobile") ||
|
|
clientLower.includes("ios") ||
|
|
clientLower.includes("android")
|
|
) {
|
|
return "phone";
|
|
}
|
|
return "device";
|
|
}
|
|
|
|
$effect(() => {
|
|
if (isOpen) {
|
|
sessions.refresh();
|
|
lmsSync.refresh();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{#if isOpen}
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="fixed inset-0 bg-black/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4"
|
|
onclick={handleBackdropClick}
|
|
onkeydown={(e) => {
|
|
if (e.key === "Escape" && onClose) onClose();
|
|
}}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="session-picker-title"
|
|
tabindex="-1"
|
|
>
|
|
<!-- Modal -->
|
|
<div
|
|
class="bg-[var(--color-surface)] rounded-t-2xl sm:rounded-2xl w-full sm:max-w-md max-h-[80vh] sm:max-h-[70vh] flex flex-col shadow-2xl"
|
|
onclick={(e) => e.stopPropagation()}
|
|
role="none"
|
|
>
|
|
<!-- Header -->
|
|
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
|
|
<h2 id="session-picker-title" class="text-lg font-semibold text-white">Cast to Device</h2>
|
|
<button
|
|
onclick={onClose}
|
|
class="p-2 -m-2 text-gray-400 hover:text-white transition-colors"
|
|
aria-label="Close"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1 overflow-y-auto">
|
|
{#if $sessions.isLoading && $controllableSessions.length === 0}
|
|
<!-- Loading -->
|
|
<div class="flex items-center justify-center py-12">
|
|
<div class="flex flex-col items-center gap-3">
|
|
<div
|
|
class="w-8 h-8 border-4 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
|
></div>
|
|
<p class="text-sm text-gray-400">Searching for devices...</p>
|
|
</div>
|
|
</div>
|
|
{:else if $controllableSessions.length > 0}
|
|
<!-- Sessions list -->
|
|
<div class="p-4 space-y-2">
|
|
{#if $selectedSession}
|
|
<!-- Currently connected session -->
|
|
<div
|
|
class="mb-4 p-4 rounded-lg bg-[var(--color-jellyfin)]/10 border border-[var(--color-jellyfin)]/30"
|
|
>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-xs font-medium text-[var(--color-jellyfin)] uppercase"
|
|
>Connected</span
|
|
>
|
|
<button
|
|
onclick={handleDisconnect}
|
|
class="text-xs text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="w-10 h-10 rounded-lg bg-[var(--color-jellyfin)]/20 flex items-center justify-center flex-shrink-0"
|
|
>
|
|
<svg
|
|
class="w-5 h-5 text-[var(--color-jellyfin)]"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
{#if getSessionIcon($selectedSession.client) === "tv"}
|
|
<path
|
|
d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"
|
|
/>
|
|
{:else if getSessionIcon($selectedSession.client) === "web"}
|
|
<path
|
|
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z"
|
|
/>
|
|
{:else if getSessionIcon($selectedSession.client) === "phone"}
|
|
<path
|
|
d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"
|
|
/>
|
|
{:else}
|
|
<path
|
|
d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"
|
|
/>
|
|
{/if}
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-medium text-white truncate">{$selectedSession.deviceName}</h3>
|
|
<p class="text-sm text-gray-400 truncate">{$selectedSession.client}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Available sessions -->
|
|
{#each $controllableSessions as session (session.id)}
|
|
{#if session.id !== $selectedSession?.id}
|
|
{@const lmsZone = isLmsSession(session)}
|
|
{@const fused = lmsZone && isZoneFused(session)}
|
|
<button
|
|
onclick={() =>
|
|
lmsZone ? handleLmsZoneToggle(session) : handleSessionSelect(session)}
|
|
disabled={$lmsSync.isBusy}
|
|
aria-pressed={lmsZone ? fused : undefined}
|
|
class="w-full p-4 rounded-lg border transition-all text-left disabled:opacity-60
|
|
{fused
|
|
? 'border-[var(--color-jellyfin)] bg-[var(--color-jellyfin)]/10'
|
|
: 'border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5'}"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0"
|
|
>
|
|
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
|
{#if lmsZone}
|
|
<!-- speaker icon for LMS zones -->
|
|
<path
|
|
d="M12 2a1 1 0 011 1v18a1 1 0 01-1 1H6a2 2 0 01-2-2V4a2 2 0 012-2h6zm0 2H6v16h6V4zm-3 9a2 2 0 110 4 2 2 0 010-4zm0-2a4 4 0 100 8 4 4 0 000-8zm0-4a1 1 0 110 2 1 1 0 010-2zm9 0h2v2h-2V7zm0 4h2v2h-2v-2z"
|
|
/>
|
|
{:else if getSessionIcon(session.client) === "tv"}
|
|
<path
|
|
d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"
|
|
/>
|
|
{:else if getSessionIcon(session.client) === "web"}
|
|
<path
|
|
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z"
|
|
/>
|
|
{:else if getSessionIcon(session.client) === "phone"}
|
|
<path
|
|
d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"
|
|
/>
|
|
{:else}
|
|
<path
|
|
d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"
|
|
/>
|
|
{/if}
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h3 class="font-medium text-white truncate">{session.deviceName}</h3>
|
|
<p class="text-sm text-gray-400 truncate">
|
|
{#if lmsZone}
|
|
{fused
|
|
? "In sync group — tap to remove"
|
|
: masterMac
|
|
? "Tap to add to group"
|
|
: "Speaker zone"}
|
|
{:else}
|
|
{session.client}
|
|
{/if}
|
|
</p>
|
|
{#if session.nowPlayingItem}
|
|
<p class="text-xs text-gray-500 truncate mt-1">
|
|
Playing: {session.nowPlayingItem.name}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{#if lmsZone}
|
|
<!-- Fuse toggle indicator -->
|
|
<div class="flex-shrink-0">
|
|
{#if fused}
|
|
<svg
|
|
class="w-5 h-5 text-[var(--color-jellyfin)]"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<svg
|
|
class="w-5 h-5 text-gray-600"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="12" r="9" stroke-width="2" />
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
{:else if session.playState}
|
|
<div class="flex-shrink-0">
|
|
{#if session.playState.isPaused}
|
|
<svg
|
|
class="w-4 h-4 text-gray-500"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
</svg>
|
|
{:else}
|
|
<svg
|
|
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<!-- Empty state -->
|
|
<div class="flex flex-col items-center justify-center py-12 px-6 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.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
<h3 class="text-base font-medium text-gray-400 mb-2">No Devices Found</h3>
|
|
<p class="text-sm text-gray-500 mb-4">
|
|
Start playing media on another Jellyfin client to cast to it from here.
|
|
</p>
|
|
<button
|
|
onclick={() => sessions.refresh()}
|
|
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white text-sm font-medium hover:bg-[var(--color-jellyfin-hover)] transition-colors"
|
|
>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
{#if $controllableSessions.length > 0}
|
|
<div class="px-6 py-3 border-t border-gray-800">
|
|
<!-- Play Locally Button (shown when remote session is active) -->
|
|
{#if $selectedSession && $playbackMode.mode === "remote"}
|
|
<button
|
|
onclick={handleTransferToLocal}
|
|
class="w-full mb-3 p-3 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium hover:bg-[var(--color-jellyfin-hover)] transition-colors flex items-center justify-center gap-2"
|
|
disabled={$isTransferring}
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z"
|
|
/>
|
|
</svg>
|
|
Play Locally
|
|
</button>
|
|
{/if}
|
|
|
|
<div class="flex items-center justify-between">
|
|
<button
|
|
onclick={() => sessions.refresh()}
|
|
class="text-sm text-gray-400 hover:text-white transition-colors flex items-center gap-2"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
/>
|
|
</svg>
|
|
Refresh
|
|
</button>
|
|
<span class="text-xs text-gray-500">
|
|
{$controllableSessions.length} device{$controllableSessions.length !== 1 ? "s" : ""} available
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Transfer Progress Overlay -->
|
|
{#if $isTransferring}
|
|
<div class="absolute inset-0 bg-black/70 flex items-center justify-center z-10 rounded-2xl">
|
|
<div class="bg-[var(--color-surface)] p-6 rounded-lg flex flex-col items-center gap-4">
|
|
<div
|
|
class="w-10 h-10 border-4 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"
|
|
></div>
|
|
<p class="text-white font-medium">Transferring playback...</p>
|
|
<button
|
|
onclick={() => {
|
|
playbackMode.cancelTransfer();
|
|
if (onClose) onClose();
|
|
}}
|
|
class="px-4 py-2 rounded-lg bg-gray-700 text-white text-sm font-medium hover:bg-gray-600 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- LMS sync error -->
|
|
{#if $lmsSync.error}
|
|
<div
|
|
class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
|
/>
|
|
</svg>
|
|
<span class="text-sm">{$lmsSync.error}</span>
|
|
</div>
|
|
<button
|
|
onclick={() => lmsSync.clearError()}
|
|
class="text-white hover:text-gray-200 transition-colors"
|
|
aria-label="Dismiss error"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Error Display -->
|
|
{#if $transferError}
|
|
<div
|
|
class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"
|
|
/>
|
|
</svg>
|
|
<span class="text-sm">{$transferError}</span>
|
|
</div>
|
|
<button
|
|
onclick={handleClearError}
|
|
class="text-white hover:text-gray-200 transition-colors"
|
|
aria-label="Dismiss error"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|