- {#if getSessionIcon(session.client) === "tv"}
+ {#if lmsZone}
+
+
+ {:else if getSessionIcon(session.client) === "tv"}
{:else if getSessionIcon(session.client) === "web"}
@@ -187,14 +239,33 @@
{session.deviceName}
-
{session.client}
+
+ {#if lmsZone}
+ {fused ? "In sync group — tap to remove" : masterMac ? "Tap to add to group" : "Speaker zone"}
+ {:else}
+ {session.client}
+ {/if}
+
{#if session.nowPlayingItem}
Playing: {session.nowPlayingItem.name}
{/if}
- {#if session.playState}
+ {#if lmsZone}
+
+
+ {#if fused}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+ {:else if session.playState}
{#if session.playState.isPaused}
@@ -295,6 +366,27 @@
{/if}
+
+ {#if $lmsSync.error}
+
+
+
+
+
+
{$lmsSync.error}
+
+
lmsSync.clearError()}
+ class="text-white hover:text-gray-200 transition-colors"
+ aria-label="Dismiss error"
+ >
+
+
+
+
+
+ {/if}
+
{#if $transferError}
diff --git a/src/lib/stores/library.ts b/src/lib/stores/library.ts
index 08daacb..391bb40 100644
--- a/src/lib/stores/library.ts
+++ b/src/lib/stores/library.ts
@@ -152,6 +152,47 @@ function createLibraryStore() {
}
}
+ // Load Live TV channels (broadcast / IPTV) into the items list. Live TV uses a
+ // dedicated Jellyfin endpoint rather than the generic /Items browse.
+ async function loadLiveTvChannels() {
+ update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
+ try {
+ const repo = auth.getRepository();
+ const channels = await repo.getLiveTvChannels();
+ update((s) => ({
+ ...s,
+ items: channels,
+ totalItems: channels.length,
+ loadingCount: Math.max(0, s.loadingCount - 1),
+ }));
+ return channels;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to load Live TV channels";
+ update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
+ throw error;
+ }
+ }
+
+ // Load the root list of plugin "Channels" into the items list.
+ async function loadChannels() {
+ update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
+ try {
+ const repo = auth.getRepository();
+ const result = await repo.getChannels();
+ update((s) => ({
+ ...s,
+ items: result.items,
+ totalItems: result.totalRecordCount,
+ loadingCount: Math.max(0, s.loadingCount - 1),
+ }));
+ return result;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to load channels";
+ update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message }));
+ throw error;
+ }
+ }
+
async function loadItem(itemId: string) {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -297,6 +338,8 @@ function createLibraryStore() {
subscribe,
loadLibraries,
loadItems,
+ loadLiveTvChannels,
+ loadChannels,
loadItem,
search,
setCurrentLibrary,
diff --git a/src/lib/stores/lmsSync.test.ts b/src/lib/stores/lmsSync.test.ts
new file mode 100644
index 0000000..508a5ea
--- /dev/null
+++ b/src/lib/stores/lmsSync.test.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { get } from "svelte/store";
+import type { Session } from "$lib/api/types";
+
+const mockInvoke = vi.fn();
+vi.mock("$lib/api/bindings", () => ({
+ commands: {
+ lmsGetSyncGroups: () => mockInvoke("lms_get_sync_groups"),
+ lmsCreateSyncGroup: (masterMac: string, slaveMacs: string[]) =>
+ mockInvoke("lms_create_sync_group", { masterMac, slaveMacs }),
+ lmsUnsyncPlayer: (mac: string) => mockInvoke("lms_unsync_player", { mac }),
+ lmsDissolveSyncGroup: (masterMac: string) =>
+ mockInvoke("lms_dissolve_sync_group", { masterMac }),
+ },
+}));
+
+import { lmsSync, isLmsSession, macForSession } from "./lmsSync";
+
+function session(deviceId: string | null): Session {
+ return { id: "s", deviceId } as unknown as Session;
+}
+
+describe("LMS session detection", () => {
+ it("detects LMS zones by the lms- device-id prefix", () => {
+ expect(isLmsSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe(true);
+ expect(isLmsSession(session("chromecast-123"))).toBe(false);
+ expect(isLmsSession(session(null))).toBe(false);
+ expect(isLmsSession(null)).toBe(false);
+ });
+
+ it("recovers the MAC from an LMS session, null otherwise", () => {
+ expect(macForSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe("aa:bb:cc:dd:ee:ff");
+ expect(macForSession(session("web-xyz"))).toBeNull();
+ expect(macForSession(null)).toBeNull();
+ });
+});
+
+describe("lmsSync store", () => {
+ beforeEach(() => {
+ mockInvoke.mockReset();
+ lmsSync.reset();
+ });
+
+ it("fusing preserves existing slaves and adds the new zone", async () => {
+ // Initial group: master M with slave A.
+ mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
+ await lmsSync.refresh();
+
+ // create returns void; refresh after returns the updated group.
+ mockInvoke.mockResolvedValueOnce(undefined);
+ mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }]);
+
+ await lmsSync.fuseZone("M", "B");
+
+ expect(mockInvoke).toHaveBeenCalledWith("lms_create_sync_group", {
+ masterMac: "M",
+ slaveMacs: ["A", "B"],
+ });
+ expect(get(lmsSync).groups[0].slaveMacs).toEqual(["A", "B"]);
+ });
+
+ it("decoupling a slave unsyncs just that player", async () => {
+ mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
+ await lmsSync.refresh();
+
+ mockInvoke.mockResolvedValueOnce(undefined); // unsync
+ mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]);
+
+ await lmsSync.decoupleZone("A");
+
+ expect(mockInvoke).toHaveBeenCalledWith("lms_unsync_player", { mac: "A" });
+ });
+
+ it("decoupling the master dissolves the whole group", async () => {
+ mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]);
+ await lmsSync.refresh();
+
+ mockInvoke.mockResolvedValueOnce(undefined); // dissolve
+ mockInvoke.mockResolvedValueOnce([]);
+
+ await lmsSync.decoupleZone("M");
+
+ expect(mockInvoke).toHaveBeenCalledWith("lms_dissolve_sync_group", { masterMac: "M" });
+ });
+
+ it("treats a missing plugin (refresh error) as no groups, not fatal", async () => {
+ mockInvoke.mockRejectedValueOnce(new Error("404"));
+ await lmsSync.refresh();
+ expect(get(lmsSync).groups).toEqual([]);
+ });
+});
diff --git a/src/lib/stores/lmsSync.ts b/src/lib/stores/lmsSync.ts
new file mode 100644
index 0000000..8c8b103
--- /dev/null
+++ b/src/lib/stores/lmsSync.ts
@@ -0,0 +1,133 @@
+/**
+ * LMS multi-room sync ("fuse zones") store.
+ *
+ * The JellyLMS plugin registers each LMS player as a Jellyfin session whose
+ * device id is `lms-{MacAddress}`. We use that prefix both to detect which cast
+ * targets are fuseable LMS zones and to recover the MAC the SyncGroups API needs.
+ *
+ * Fusing model (per product decision): the currently-controlled zone is the sync
+ * master; other selected zones join it. Toggling an already-grouped zone off
+ * decouples just that zone; toggling the master off dissolves the group.
+ *
+ * TRACES: UR-010 | JA-021, JA-025 | DR-037
+ */
+
+import { writable, get } from "svelte/store";
+import { commands } from "$lib/api/bindings";
+import type { Session } from "$lib/api/types";
+import type { LmsSyncGroup } from "$lib/api/bindings";
+
+const LMS_DEVICE_PREFIX = "lms-";
+
+/** Is this cast target an LMS zone (and therefore fuseable)? */
+export function isLmsSession(session: Session | null | undefined): boolean {
+ return !!session?.deviceId?.startsWith(LMS_DEVICE_PREFIX);
+}
+
+/** Recover the LMS player MAC from a session, or null if it isn't an LMS zone. */
+export function macForSession(session: Session | null | undefined): string | null {
+ const deviceId = session?.deviceId;
+ if (!deviceId?.startsWith(LMS_DEVICE_PREFIX)) return null;
+ return deviceId.slice(LMS_DEVICE_PREFIX.length);
+}
+
+interface LmsSyncState {
+ groups: LmsSyncGroup[];
+ isBusy: boolean;
+ error: string | null;
+}
+
+function createLmsSyncStore() {
+ const { subscribe, update, set } = writable({
+ groups: [],
+ isBusy: false,
+ error: null,
+ });
+
+ /** Refresh the list of current sync groups from the server. */
+ async function refresh(): Promise {
+ try {
+ const groups = await commands.lmsGetSyncGroups();
+ update((s) => ({ ...s, groups, error: null }));
+ } catch (error) {
+ // The plugin may not be installed; treat as "no groups" rather than fatal.
+ console.warn("[LmsSync] Failed to load sync groups:", error);
+ update((s) => ({ ...s, groups: [] }));
+ }
+ }
+
+ /** All MACs that are part of any sync group (master or slave). */
+ function groupedMacs(): Set {
+ const macs = new Set();
+ for (const g of get({ subscribe }).groups) {
+ macs.add(g.masterMac);
+ for (const slave of g.slaveMacs ?? []) macs.add(slave);
+ }
+ return macs;
+ }
+
+ /**
+ * Add an LMS zone to the master's sync group (fuse it in).
+ * Preserves any zones already grouped with the master.
+ */
+ async function fuseZone(masterMac: string, zoneMac: string): Promise {
+ if (masterMac === zoneMac) return;
+ update((s) => ({ ...s, isBusy: true, error: null }));
+ try {
+ const existing = get({ subscribe }).groups.find((g) => g.masterMac === masterMac);
+ const slaves = new Set(existing?.slaveMacs ?? []);
+ slaves.add(zoneMac);
+ await commands.lmsCreateSyncGroup(masterMac, [...slaves]);
+ await refresh();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to fuse zone";
+ update((s) => ({ ...s, error: message }));
+ throw error;
+ } finally {
+ update((s) => ({ ...s, isBusy: false }));
+ }
+ }
+
+ /**
+ * Remove a single zone from its group. If the zone is a group's master, the
+ * whole group is dissolved (a group can't outlive its master).
+ */
+ async function decoupleZone(zoneMac: string): Promise {
+ update((s) => ({ ...s, isBusy: true, error: null }));
+ try {
+ const asMaster = get({ subscribe }).groups.find((g) => g.masterMac === zoneMac);
+ if (asMaster) {
+ await commands.lmsDissolveSyncGroup(zoneMac);
+ } else {
+ await commands.lmsUnsyncPlayer(zoneMac);
+ }
+ await refresh();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to decouple zone";
+ update((s) => ({ ...s, error: message }));
+ throw error;
+ } finally {
+ update((s) => ({ ...s, isBusy: false }));
+ }
+ }
+
+ function clearError(): void {
+ update((s) => ({ ...s, error: null }));
+ }
+
+ function reset(): void {
+ set({ groups: [], isBusy: false, error: null });
+ }
+
+ return {
+ subscribe,
+ refresh,
+ groupedMacs,
+ fuseZone,
+ decoupleZone,
+ clearError,
+ reset,
+ };
+}
+
+export const lmsSync = createLmsSyncStore();
diff --git a/src/lib/stores/playbackMode.ts b/src/lib/stores/playbackMode.ts
index 43873b4..6b7bb6c 100644
--- a/src/lib/stores/playbackMode.ts
+++ b/src/lib/stores/playbackMode.ts
@@ -198,6 +198,12 @@ function createPlaybackModeStore() {
// Get repository for handle (backend will fetch playback info via player_play_tracks)
const repository = auth.getRepository();
+ // Mark the whole sequence as a transfer in the *Rust* manager too. Without
+ // this, player_play_tracks sees mode=Remote and casts the track back to the
+ // remote session instead of playing it locally (the frontend's own
+ // isTransferring flag is invisible to Rust). Cleared in `finally`.
+ await commands.playbackModeSetTransferring(true);
+
// Start local playback (events allowed through because isTransferring=true)
// Use player_play_tracks - backend fetches all metadata from single ID
const repositoryHandle = repository.getHandle();
@@ -248,6 +254,14 @@ function createPlaybackModeStore() {
console.error("Transfer to local failed:", error);
throw error;
} finally {
+ // Always lower the Rust transferring flag so it can't stick on if any step
+ // above threw (transfer_to_local lowers it on success, but not if we never
+ // reached it). Safe to call unconditionally.
+ try {
+ await commands.playbackModeSetTransferring(false);
+ } catch (e) {
+ console.warn("[PlaybackMode] Failed to clear transferring flag:", e);
+ }
currentTransferAbort = null;
}
}
diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte
index 3f22de5..d89a819 100644
--- a/src/routes/library/+page.svelte
+++ b/src/routes/library/+page.svelte
@@ -76,6 +76,22 @@
return;
}
+ // Live TV channels use a dedicated endpoint
+ if (lib.collectionType === "livetv") {
+ library.setCurrentLibrary(lib);
+ library.clearGenres();
+ await library.loadLiveTvChannels();
+ return;
+ }
+
+ // Plugin "Channels" root list uses a dedicated endpoint
+ if (lib.collectionType === "channels") {
+ library.setCurrentLibrary(lib);
+ library.clearGenres();
+ await library.loadChannels();
+ return;
+ }
+
// For other library types, load items normally
library.setCurrentLibrary(lib);
library.clearGenres();
@@ -97,6 +113,11 @@
if ("type" in item) {
// It's a MediaItem
const mediaItem = item as MediaItem;
+ // A ChannelFolderItem can be a folder (drill in) or a playable leaf.
+ if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) {
+ goto(`/player/${mediaItem.id}`);
+ return;
+ }
switch (mediaItem.type) {
case "Series":
case "Movie":
@@ -111,7 +132,8 @@
goto(`/library/${mediaItem.id}`);
break;
case "Episode":
- // Episodes play directly
+ case "TvChannel":
+ // Episodes and live TV channels play directly
goto(`/player/${mediaItem.id}`);
break;
default:
diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte
index 26226b9..f34ec5c 100644
--- a/src/routes/library/[id]/+page.svelte
+++ b/src/routes/library/[id]/+page.svelte
@@ -187,6 +187,12 @@
goto(`/library/${clickedItem.id}`);
return;
}
+ // A ChannelFolderItem can be either a folder (drill in) or a playable leaf.
+ // Route non-folder channel items straight to the player.
+ if (clickedItem.type === "ChannelFolderItem" && !clickedItem.isFolder) {
+ goto(`/player/${clickedItem.id}`);
+ return;
+ }
switch (clickedItem.type) {
case "Series":
case "Season":
diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte
index d54c954..d70dc57 100644
--- a/src/routes/player/[id]/+page.svelte
+++ b/src/routes/player/[id]/+page.svelte
@@ -57,6 +57,7 @@
let streamUrl = $state(null);
let mediaSourceId = $state(null);
let isVideo = $state(false);
+ let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume
let videoInitialPosition = $state(0); // Position in seconds to seek to after video loads
let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit)
let isOfflinePlayback = $state(false); // Whether playing from local file
@@ -104,6 +105,15 @@
}
});
+ // A playable channel leaf (ChannelFolderItem) has no dedicated item type, so
+ // treat it as video when it carries a video media stream.
+ function isVideoChannelItem(item: MediaItem): boolean {
+ return (
+ item.type === "ChannelFolderItem" &&
+ (item.mediaStreams?.some((s) => s.type === "Video") ?? false)
+ );
+ }
+
async function loadAndPlay(id: string, startPosition?: number, forceRestart = false) {
loading = true;
error = null;
@@ -118,8 +128,9 @@
currentMedia = item;
// Check if this is a non-playable collection type that should be viewed in library instead
- const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist"];
- if (collectionTypes.includes(item.type)) {
+ const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist", "Channel"];
+ // A ChannelFolderItem that is itself a folder is a container, not playable.
+ if (collectionTypes.includes(item.type) || (item.type === "ChannelFolderItem" && item.isFolder)) {
console.log("loadAndPlay: Redirecting collection type to library:", item.type);
goto(`/library/${id}`);
return;
@@ -132,7 +143,8 @@
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
console.log("loadAndPlay: Track already playing, showing UI without restarting");
- isVideo = item.type === "Movie" || item.type === "Episode";
+ isLive = item.type === "TvChannel";
+ isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item);
isPlaying = true;
loading = false;
// hasNext/hasPrevious come from the event-driven queue store.
@@ -143,8 +155,10 @@
return;
}
- // Determine if this is video content (Movie and Episode are video types)
- isVideo = item.type === "Movie" || item.type === "Episode";
+ // Determine if this is video content (Movie, Episode, live TV channels, and
+ // channel leaf items that carry a video stream).
+ isLive = item.type === "TvChannel";
+ isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item);
// When switching to video, stop audio playback and clear the queue
// This prevents audio from continuing in the background and clears stale state
@@ -164,7 +178,8 @@
const userId = auth.getUserId();
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
- if (!startPosition && !forceRestart && userId) {
+ // Live streams have no fixed position - never resume.
+ if (!startPosition && !forceRestart && userId && !isLive) {
try {
const progress = await commands.storageGetPlaybackProgress(userId, id);
console.log("Resume check - retrieved progress:", progress);
@@ -251,6 +266,22 @@
// Online playback - get playback info from server
isOfflinePlayback = false;
const repo = auth.getRepository();
+
+ if (isLive) {
+ // Live TV channels must be "opened" before streaming; the server returns
+ // a ready-to-play HLS transcoding URL. No resume, no seek, no progress.
+ console.log("loadAndPlay: Opening live stream for channel:", id);
+ const liveInfo = await repo.openLiveStream(id);
+ console.log("loadAndPlay: Live stream URL:", liveInfo.streamUrl);
+ mediaSourceId = liveInfo.mediaSourceId;
+ streamUrl = liveInfo.streamUrl;
+ videoNeedsTranscoding = true;
+ videoInitialPosition = 0;
+ isPlaying = true;
+ loading = false;
+ return;
+ }
+
console.log("loadAndPlay: Getting playback info");
const playbackInfo = await repo.getPlaybackInfo(id);
console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId);
@@ -613,6 +644,7 @@
mediaSourceId={mediaSourceId ?? undefined}
initialPosition={videoInitialPosition}
needsTranscoding={videoNeedsTranscoding}
+ {isLive}
onClose={handleClose}
onSeek={handleVideoSeek}
onReportStart={handleReportStart}