321 lines
14 KiB
Svelte
321 lines
14 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 type { Session } from "$lib/api/types";
|
|
|
|
interface Props {
|
|
isOpen?: boolean;
|
|
onClose?: () => void;
|
|
onSelectSession?: (session: Session) => void;
|
|
}
|
|
|
|
let { isOpen = false, onClose, onSelectSession }: Props = $props();
|
|
|
|
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) {
|
|
console.error("Failed to select session:", error);
|
|
// Error is already stored in playbackMode store
|
|
}
|
|
}
|
|
|
|
async function handleTransferToLocal() {
|
|
try {
|
|
await playbackMode.transferToLocal();
|
|
if (onClose) {
|
|
onClose();
|
|
}
|
|
} catch (error) {
|
|
console.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) {
|
|
console.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();
|
|
}
|
|
});
|
|
</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}
|
|
<button
|
|
onclick={() => handleSessionSelect(session)}
|
|
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left"
|
|
>
|
|
<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 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">{session.client}</p>
|
|
{#if session.nowPlayingItem}
|
|
<p class="text-xs text-gray-500 truncate mt-1">
|
|
Playing: {session.nowPlayingItem.name}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{#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}
|
|
|
|
<!-- 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}
|