feat(library and playback): Support for serverside channel plugins and hls streaming

This commit is contained in:
2026-06-27 17:25:57 +02:00
parent f1d25c4f4d
commit 7d7f27aa10
16 changed files with 495 additions and 61 deletions
+23 -1
View File
@@ -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:
+6
View File
@@ -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":
+38 -6
View File
@@ -57,6 +57,7 @@
let streamUrl = $state<string | null>(null);
let mediaSourceId = $state<string | null>(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}