First working POC
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
||||
import SessionPickerModal from "./SessionPickerModal.svelte";
|
||||
|
||||
interface Props {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { size = "md", className = "" }: Props = $props();
|
||||
|
||||
let showPicker = $state(false);
|
||||
|
||||
// Size classes
|
||||
const sizeClasses = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-5 h-5",
|
||||
lg: "w-6 h-6",
|
||||
};
|
||||
|
||||
const buttonSizeClasses = {
|
||||
sm: "p-1.5",
|
||||
md: "p-2",
|
||||
lg: "p-2.5",
|
||||
};
|
||||
|
||||
function handleClick() {
|
||||
showPicker = true;
|
||||
}
|
||||
|
||||
function closePicker() {
|
||||
showPicker = false;
|
||||
}
|
||||
|
||||
// Set polling hints when component mounts/unmounts
|
||||
onMount(async () => {
|
||||
// Initial manual refresh to get sessions
|
||||
sessions.refresh();
|
||||
|
||||
// Set initial hint based on connection state
|
||||
const hint = isConnected ? "cast_active" : "cast_discovery";
|
||||
await invoke("sessions_set_polling_hint", { hint });
|
||||
});
|
||||
|
||||
onDestroy(async () => {
|
||||
// Reset to normal polling when component unmounts
|
||||
await invoke("sessions_set_polling_hint", { hint: "normal" });
|
||||
});
|
||||
|
||||
// Update polling hint when connection state changes
|
||||
$effect(() => {
|
||||
const hint = isConnected ? "cast_active" : "cast_discovery";
|
||||
invoke("sessions_set_polling_hint", { hint });
|
||||
});
|
||||
|
||||
const isConnected = $derived($selectedSession !== null);
|
||||
const sessionCount = $derived($controllableSessions.length);
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={handleClick}
|
||||
class="{buttonSizeClasses[size]} {className} rounded-lg transition-colors relative {isConnected
|
||||
? 'text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/10'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
|
||||
title={isConnected
|
||||
? `Casting to ${$selectedSession?.deviceName}`
|
||||
: sessionCount > 0
|
||||
? `Cast to ${sessionCount} available device${sessionCount !== 1 ? 's' : ''}`
|
||||
: 'No devices available'}
|
||||
aria-label="Cast"
|
||||
>
|
||||
<!-- Cast Icon -->
|
||||
<svg class={sizeClasses[size]} fill="currentColor" viewBox="0 0 24 24">
|
||||
{#if isConnected}
|
||||
<!-- Connected cast icon -->
|
||||
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11zM21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||
{:else}
|
||||
<!-- Standard cast icon -->
|
||||
<path d="M21 3H3c-1.1 0-2 .9-2 2v3h2V5h18v14h-7v2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.93-11-11-11z" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<!-- Badge for available sessions count -->
|
||||
{#if !isConnected && sessionCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-4 h-4 bg-[var(--color-jellyfin)] text-white text-[10px] font-bold rounded-full flex items-center justify-center"
|
||||
>
|
||||
{sessionCount}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Connected indicator -->
|
||||
{#if isConnected}
|
||||
<span class="absolute bottom-0 right-0 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full border-2 border-[var(--color-surface)]"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<SessionPickerModal isOpen={showPicker} onClose={closePicker} />
|
||||
@@ -0,0 +1,240 @@
|
||||
<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.albumName}
|
||||
<p class="text-sm text-gray-400 truncate">{nowPlaying.albumName}</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}
|
||||
<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 < 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>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import type { Session } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
selected?: boolean;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { session, selected = false, onclick }: Props = $props();
|
||||
|
||||
function getImageUrl(): string {
|
||||
if (!session.nowPlayingItem) return "";
|
||||
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
return repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
|
||||
maxWidth: 80,
|
||||
tag: session.nowPlayingItem.primaryImageTag,
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
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 imageUrl = $derived(getImageUrl());
|
||||
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">
|
||||
{#if imageUrl}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={nowPlaying.name}
|
||||
class="w-12 h-12 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<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.albumName}
|
||||
<p class="text-xs text-gray-400 truncate">{nowPlaying.albumName}</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>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
||||
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
||||
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
|
||||
await playbackMode.transferToRemote(session.id);
|
||||
|
||||
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): 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') handleBackdropClick(); }}
|
||||
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}
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { sessions, controllableSessions } from "$lib/stores";
|
||||
import SessionCard from "./SessionCard.svelte";
|
||||
|
||||
interface Props {
|
||||
onSelectSession?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
let { onSelectSession }: Props = $props();
|
||||
|
||||
function handleSessionClick(sessionId: string) {
|
||||
if (onSelectSession) {
|
||||
onSelectSession(sessionId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
Active Sessions
|
||||
{#if $controllableSessions.length > 0}
|
||||
<span class="text-sm text-gray-400 font-normal">
|
||||
({$controllableSessions.length})
|
||||
</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onclick={() => sessions.refresh()}
|
||||
class="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="Refresh sessions"
|
||||
>
|
||||
<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="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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if $sessions.isLoading && $sessions.sessions.length === 0}
|
||||
<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">Loading sessions...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error state -->
|
||||
{#if $sessions.error}
|
||||
<div class="p-4 rounded-lg bg-red-500/10 border border-red-500/50">
|
||||
<p class="text-sm text-red-400">{$sessions.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sessions list -->
|
||||
{#if $controllableSessions.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each $controllableSessions as session (session.id)}
|
||||
<SessionCard
|
||||
{session}
|
||||
selected={$sessions.selectedSessionId === session.id}
|
||||
onclick={() => handleSessionClick(session.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !$sessions.isLoading}
|
||||
<!-- Empty state -->
|
||||
<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.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-lg font-medium text-gray-400 mb-2">No Active Sessions</h3>
|
||||
<p class="text-sm text-gray-500 max-w-sm">
|
||||
No controllable Jellyfin sessions found. Start playing media on another device to control it from here.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Last updated -->
|
||||
{#if $sessions.lastUpdated && $controllableSessions.length > 0}
|
||||
<p class="text-xs text-gray-500 text-center">
|
||||
Last updated: {$sessions.lastUpdated.toLocaleTimeString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user