feat(library and playback): Support for serverside channel plugins and hls streaming
This commit is contained in:
+38
-2
@@ -1134,6 +1134,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
|
||||
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
|
||||
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
|
||||
},
|
||||
/**
|
||||
* Get Live TV channels (broadcast / IPTV) for browsing
|
||||
*/
|
||||
async repositoryGetLiveTvChannels(handle: string) : Promise<MediaItem[]> {
|
||||
return await TAURI_INVOKE("repository_get_live_tv_channels", { handle });
|
||||
},
|
||||
/**
|
||||
* Get the root list of plugin "Channels"
|
||||
*/
|
||||
async repositoryGetChannels(handle: string) : Promise<SearchResult> {
|
||||
return await TAURI_INVOKE("repository_get_channels", { handle });
|
||||
},
|
||||
/**
|
||||
* Open a live stream for a Live TV channel / live item
|
||||
*/
|
||||
async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStreamInfo> {
|
||||
return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId });
|
||||
},
|
||||
/**
|
||||
* Report playback start
|
||||
*/
|
||||
@@ -1539,6 +1557,14 @@ export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"
|
||||
* Library (media collection)
|
||||
*/
|
||||
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
|
||||
/**
|
||||
* Live stream information returned from opening a Live TV / channel stream.
|
||||
*
|
||||
* Unlike on-demand video, a live channel must be "opened" before it can be
|
||||
* streamed; the server returns a transcoding URL (already absolute) plus a
|
||||
* `live_stream_id` that can later be used to close the stream.
|
||||
*/
|
||||
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null }
|
||||
/**
|
||||
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
|
||||
*
|
||||
@@ -1549,7 +1575,12 @@ export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?:
|
||||
/**
|
||||
* Media item
|
||||
*/
|
||||
export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
|
||||
export type MediaItem = { id: string; name: string; type: string;
|
||||
/**
|
||||
* Whether this item is a folder/container (vs a playable leaf). Used to
|
||||
* decide whether a channel item drills into a list or plays directly.
|
||||
*/
|
||||
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
|
||||
/**
|
||||
* Media session type tracking the high-level playback context
|
||||
*/
|
||||
@@ -1927,7 +1958,12 @@ export type PlaylistEntry =
|
||||
/**
|
||||
* The underlying media item
|
||||
*/
|
||||
({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
|
||||
({ id: string; name: string; type: string;
|
||||
/**
|
||||
* Whether this item is a folder/container (vs a playable leaf). Used to
|
||||
* decide whether a channel item drills into a list or plays directly.
|
||||
*/
|
||||
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
|
||||
/**
|
||||
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
GetItemsOptions,
|
||||
SearchOptions,
|
||||
PlaybackInfo,
|
||||
LiveStreamInfo,
|
||||
ImageType,
|
||||
ImageOptions,
|
||||
Genre,
|
||||
@@ -161,6 +162,23 @@ export class RepositoryClient {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Live TV / Channels =====
|
||||
|
||||
/** Browse Live TV channels (broadcast / IPTV). */
|
||||
async getLiveTvChannels(): Promise<MediaItem[]> {
|
||||
return commands.repositoryGetLiveTvChannels(this.ensureHandle());
|
||||
}
|
||||
|
||||
/** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */
|
||||
async getChannels(): Promise<SearchResult> {
|
||||
return commands.repositoryGetChannels(this.ensureHandle());
|
||||
}
|
||||
|
||||
/** Open a live stream for a Live TV channel / live item before HLS playback. */
|
||||
async openLiveStream(itemId: string): Promise<LiveStreamInfo> {
|
||||
return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
// ===== URL Construction Methods (sync, no server call) =====
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
ImageOptions,
|
||||
ImageType,
|
||||
Library,
|
||||
LiveStreamInfo,
|
||||
MediaItem,
|
||||
MediaSource,
|
||||
MediaStream,
|
||||
@@ -51,6 +52,7 @@ export type ItemType =
|
||||
| "CollectionFolder"
|
||||
| "Channel"
|
||||
| "ChannelFolderItem"
|
||||
| "TvChannel"
|
||||
| "Person";
|
||||
|
||||
export type LibraryType =
|
||||
@@ -63,6 +65,7 @@ export type LibraryType =
|
||||
| "boxsets"
|
||||
| "playlists"
|
||||
| "channels"
|
||||
| "livetv"
|
||||
| "unknown";
|
||||
|
||||
export type PersonType =
|
||||
|
||||
@@ -30,9 +30,10 @@
|
||||
onEnded?: () => void; // Called when video playback ends naturally
|
||||
onNext?: () => void; // Called when user clicks next episode button
|
||||
hasNext?: boolean; // Whether there is a next episode available
|
||||
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
|
||||
}
|
||||
|
||||
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props();
|
||||
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
|
||||
|
||||
// The id this player instance reports progress against. Snapshotted from the
|
||||
// media prop so a late reportStop (e.g. from onDestroy during autoplay
|
||||
@@ -488,12 +489,15 @@
|
||||
// Load series audio preference (for TV shows)
|
||||
await loadSeriesAudioPreference();
|
||||
|
||||
// Report progress every 10 seconds while playing
|
||||
progressInterval = setInterval(() => {
|
||||
if (isPlaying && !isSeeking && onReportProgress) {
|
||||
onReportProgress(currentTime, false, reportMediaId);
|
||||
}
|
||||
}, 10000);
|
||||
// Report progress every 10 seconds while playing. Live streams have no
|
||||
// meaningful position to report, so skip progress reporting entirely.
|
||||
if (!isLive) {
|
||||
progressInterval = setInterval(() => {
|
||||
if (isPlaying && !isSeeking && onReportProgress) {
|
||||
onReportProgress(currentTime, false, reportMediaId);
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Debug logging every second
|
||||
debugLogInterval = setInterval(() => {
|
||||
@@ -555,8 +559,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Report stop when component is destroyed
|
||||
if (onReportStop && currentTime > 0) {
|
||||
// Report stop when component is destroyed (skip for live - no resume tracking)
|
||||
if (!isLive && onReportStop && currentTime > 0) {
|
||||
onReportStop(currentTime, reportMediaId);
|
||||
}
|
||||
});
|
||||
@@ -749,8 +753,8 @@
|
||||
function handlePlay() {
|
||||
isPlaying = true;
|
||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||
// Report playback start on first play
|
||||
if (!hasReportedStart && onReportStart) {
|
||||
// Report playback start on first play (skip for live - no resume tracking)
|
||||
if (!isLive && !hasReportedStart && onReportStart) {
|
||||
onReportStart(currentTime, reportMediaId);
|
||||
hasReportedStart = true;
|
||||
}
|
||||
@@ -768,8 +772,8 @@
|
||||
function handleEnded() {
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when ended
|
||||
// Report stop when video ends
|
||||
if (onReportStop) {
|
||||
// Report stop when video ends (skip for live - no resume tracking)
|
||||
if (!isLive && onReportStop) {
|
||||
onReportStop(currentTime, reportMediaId);
|
||||
}
|
||||
// Notify parent that video has ended (for next episode popup)
|
||||
@@ -1413,26 +1417,35 @@
|
||||
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
oninput={handleSeekBarInput}
|
||||
onchange={handleSeekBarChange}
|
||||
onmousedown={() => isDraggingSeekBar = true}
|
||||
onmouseup={() => isDraggingSeekBar = false}
|
||||
ontouchstart={() => isDraggingSeekBar = true}
|
||||
ontouchend={() => isDraggingSeekBar = false}
|
||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
||||
/>
|
||||
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
|
||||
</div>
|
||||
<!-- Progress bar (hidden for live streams - no fixed timeline) -->
|
||||
{#if isLive}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="flex items-center gap-1.5 text-white text-sm font-semibold">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
oninput={handleSeekBarInput}
|
||||
onchange={handleSeekBarChange}
|
||||
onmousedown={() => isDraggingSeekBar = true}
|
||||
onmouseup={() => isDraggingSeekBar = false}
|
||||
ontouchstart={() => isDraggingSeekBar = true}
|
||||
ontouchend={() => isDraggingSeekBar = false}
|
||||
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
||||
/>
|
||||
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Control buttons -->
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user