First working POC
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user