Add support for fusing/unfusing JellyLMS zones into synchronized
multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -14,6 +15,20 @@
|
||||
|
||||
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
|
||||
@@ -31,6 +46,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.error("Failed to toggle LMS zone:", error);
|
||||
// Error is surfaced via the lmsSync store
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransferToLocal() {
|
||||
try {
|
||||
await playbackMode.transferToLocal();
|
||||
@@ -80,6 +121,7 @@
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
sessions.refresh();
|
||||
lmsSync.refresh();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -167,14 +209,24 @@
|
||||
<!-- Available sessions -->
|
||||
{#each $controllableSessions as session (session.id)}
|
||||
{#if session.id !== $selectedSession?.id}
|
||||
{@const lmsZone = isLmsSession(session)}
|
||||
{@const fused = lmsZone && isZoneFused(session)}
|
||||
<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"
|
||||
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 getSessionIcon(session.client) === "tv"}
|
||||
{#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" />
|
||||
@@ -187,14 +239,33 @@
|
||||
</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>
|
||||
<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 session.playState}
|
||||
{#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">
|
||||
@@ -295,6 +366,27 @@
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user