Plex Integration + Music Source Integration Improvements (#37)
* plex integration * The big one - Full Music Source page rework + Playlist importing + Full Plex Integration + Discovery Options + More Like This/Surprise Me/Instant Mix + More... * Music source track page - Play all / shuffle fixes * lint * format * fix type checks * format
This commit is contained in:
@@ -7,6 +7,10 @@ import { updateDiscoveryCacheTTL } from '$lib/stores/discoveryCache';
|
||||
import { updateDiscoverQueueCacheTTL } from '$lib/utils/discoverQueueCache';
|
||||
import { updateSearchCacheTTL } from '$lib/stores/search';
|
||||
import { updateJellyfinSidebarCacheTTL } from '$lib/utils/jellyfinLibraryCache';
|
||||
import {
|
||||
updatePlexSidebarCacheTTL,
|
||||
updatePlexAlbumsListCacheTTL
|
||||
} from '$lib/utils/plexLibraryCache';
|
||||
import { updateLocalFilesSidebarCacheTTL } from '$lib/utils/localFilesCache';
|
||||
import { libraryStore } from '$lib/stores/library';
|
||||
import { recentlyAddedStore } from '$lib/stores/recentlyAdded';
|
||||
@@ -20,6 +24,7 @@ export interface CacheTTLs {
|
||||
search: number;
|
||||
localFilesSidebar: number;
|
||||
jellyfinSidebar: number;
|
||||
plexSidebar: number;
|
||||
playlistSources: number;
|
||||
discoverQueuePollingInterval: number;
|
||||
discoverQueueAutoGenerate: boolean;
|
||||
@@ -34,6 +39,7 @@ const DEFAULTS: CacheTTLs = {
|
||||
search: CACHE_TTL.SEARCH,
|
||||
localFilesSidebar: CACHE_TTL.LOCAL_FILES_SIDEBAR,
|
||||
jellyfinSidebar: CACHE_TTL.JELLYFIN_SIDEBAR,
|
||||
plexSidebar: CACHE_TTL.PLEX_SIDEBAR,
|
||||
playlistSources: CACHE_TTL.PLAYLIST_SOURCES,
|
||||
discoverQueuePollingInterval: 4000,
|
||||
discoverQueueAutoGenerate: true
|
||||
@@ -52,6 +58,8 @@ function applyTTLs(ttls: CacheTTLs): void {
|
||||
updateSearchCacheTTL(ttls.search);
|
||||
updateLocalFilesSidebarCacheTTL(ttls.localFilesSidebar);
|
||||
updateJellyfinSidebarCacheTTL(ttls.jellyfinSidebar);
|
||||
updatePlexSidebarCacheTTL(ttls.plexSidebar);
|
||||
updatePlexAlbumsListCacheTTL(ttls.plexSidebar);
|
||||
}
|
||||
|
||||
export async function initCacheTTLs(): Promise<void> {
|
||||
@@ -69,6 +77,7 @@ export async function initCacheTTLs(): Promise<void> {
|
||||
search: (data.search as number) ?? DEFAULTS.search,
|
||||
localFilesSidebar: (data.local_files_sidebar as number) ?? DEFAULTS.localFilesSidebar,
|
||||
jellyfinSidebar: (data.jellyfin_sidebar as number) ?? DEFAULTS.jellyfinSidebar,
|
||||
plexSidebar: (data.plex_sidebar as number) ?? DEFAULTS.plexSidebar,
|
||||
playlistSources: (data.playlist_sources as number) ?? DEFAULTS.playlistSources,
|
||||
discoverQueuePollingInterval:
|
||||
(data.discover_queue_polling_interval as number) ?? DEFAULTS.discoverQueuePollingInterval,
|
||||
@@ -76,8 +85,9 @@ export async function initCacheTTLs(): Promise<void> {
|
||||
(data.discover_queue_auto_generate as boolean) ?? DEFAULTS.discoverQueueAutoGenerate
|
||||
};
|
||||
applyTTLs(resolved);
|
||||
} catch (e) {
|
||||
console.warn('[cacheTtl] Failed to load cache TTL settings, using defaults', e);
|
||||
} catch {
|
||||
resolved = { ...DEFAULTS };
|
||||
applyTTLs(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ interface IntegrationStatus {
|
||||
lidarr: boolean;
|
||||
jellyfin: boolean;
|
||||
navidrome: boolean;
|
||||
plex: boolean;
|
||||
listenbrainz: boolean;
|
||||
youtube: boolean;
|
||||
youtube_api: boolean;
|
||||
@@ -19,6 +20,7 @@ function createIntegrationStore() {
|
||||
lidarr: false,
|
||||
jellyfin: false,
|
||||
navidrome: false,
|
||||
plex: false,
|
||||
listenbrainz: false,
|
||||
youtube: false,
|
||||
youtube_api: false,
|
||||
@@ -41,6 +43,7 @@ function createIntegrationStore() {
|
||||
lidarr: false,
|
||||
jellyfin: false,
|
||||
navidrome: false,
|
||||
plex: false,
|
||||
listenbrainz: false,
|
||||
youtube: false,
|
||||
youtube_api: false,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
|
||||
import { playerStore } from '$lib/stores/player.svelte';
|
||||
import type { NowPlayingSession } from '$lib/types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
type SourceKey = 'jellyfin' | 'navidrome' | 'plex';
|
||||
|
||||
const GRACE_MS: Record<SourceKey, number> = {
|
||||
jellyfin: 30_000,
|
||||
navidrome: 180_000,
|
||||
plex: 30_000
|
||||
};
|
||||
|
||||
type OwnedEntry = { track: string; idleSince: number };
|
||||
|
||||
function buildLocalSession(): NowPlayingSession | null {
|
||||
const state = playerStore.playbackState;
|
||||
if (state === 'idle' || state === 'error') return null;
|
||||
const np = playerStore.nowPlaying;
|
||||
if (!np) return null;
|
||||
|
||||
const src = np.sourceType;
|
||||
if (src !== 'jellyfin' && src !== 'navidrome' && src !== 'plex') return null;
|
||||
|
||||
return {
|
||||
id: `local-${src}-${np.trackSourceId ?? np.albumId}`,
|
||||
user_name: '',
|
||||
track_name: np.trackName ?? '',
|
||||
artist_name: np.artistName,
|
||||
album_name: np.albumName,
|
||||
cover_url: np.coverUrl ?? '',
|
||||
device_name: 'MusicSeerr',
|
||||
is_paused: state === 'paused' || state === 'buffering' || state === 'loading',
|
||||
source: src,
|
||||
progress_ms: playerStore.progress * 1000,
|
||||
duration_ms: playerStore.duration * 1000,
|
||||
_isLocal: true
|
||||
};
|
||||
}
|
||||
|
||||
const ownedSessions = new SvelteMap<SourceKey, OwnedEntry>();
|
||||
let currentOwnedSource: SourceKey | null = null;
|
||||
|
||||
function createMergedStore() {
|
||||
const mergedSessions = $derived.by(() => {
|
||||
const local = buildLocalSession();
|
||||
const server = nowPlayingStore.sessions;
|
||||
|
||||
if (local) {
|
||||
const src = local.source as SourceKey;
|
||||
if (currentOwnedSource && currentOwnedSource !== src) {
|
||||
const prev = ownedSessions.get(currentOwnedSource);
|
||||
if (prev && !prev.idleSince) {
|
||||
ownedSessions.set(currentOwnedSource, { ...prev, idleSince: Date.now() });
|
||||
}
|
||||
}
|
||||
currentOwnedSource = src;
|
||||
ownedSessions.set(src, { track: local.track_name, idleSince: 0 });
|
||||
} else if (currentOwnedSource) {
|
||||
const entry = ownedSessions.get(currentOwnedSource);
|
||||
if (entry && !entry.idleSince) {
|
||||
ownedSessions.set(currentOwnedSource, { ...entry, idleSince: Date.now() });
|
||||
}
|
||||
currentOwnedSource = null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
for (const [src, entry] of ownedSessions) {
|
||||
if (entry.idleSince && now - entry.idleSince >= GRACE_MS[src]) {
|
||||
ownedSessions.delete(src);
|
||||
}
|
||||
}
|
||||
|
||||
if (!local) {
|
||||
const hasGraceEntries = ownedSessions.size > 0;
|
||||
if (hasGraceEntries) {
|
||||
return server.filter((s) => {
|
||||
const src = s.source as SourceKey;
|
||||
const owned = ownedSessions.get(src);
|
||||
if (owned && owned.idleSince && s.track_name === owned.track) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
const localSource = local.source as SourceKey;
|
||||
const filtered = server.filter((s) => {
|
||||
if (s.source === localSource && s.track_name === local.track_name) return false;
|
||||
const src = s.source as SourceKey;
|
||||
const owned = ownedSessions.get(src);
|
||||
if (owned && owned.idleSince && s.track_name === owned.track) return false;
|
||||
return true;
|
||||
});
|
||||
return [local, ...filtered];
|
||||
});
|
||||
|
||||
const activeSessions = $derived(mergedSessions.filter((s) => !s.is_paused));
|
||||
const primarySession = $derived(activeSessions[0] ?? mergedSessions[0] ?? null);
|
||||
|
||||
function isSourcePlaying(source: SourceKey): boolean {
|
||||
return mergedSessions.some((s) => s.source === source && !s.is_paused);
|
||||
}
|
||||
|
||||
function sourceHasSessions(source: SourceKey): boolean {
|
||||
return mergedSessions.some((s) => s.source === source);
|
||||
}
|
||||
|
||||
function sessionsForSource(source: SourceKey): NowPlayingSession[] {
|
||||
return mergedSessions.filter((s) => s.source === source);
|
||||
}
|
||||
|
||||
return {
|
||||
get sessions() {
|
||||
return mergedSessions;
|
||||
},
|
||||
get activeSessions() {
|
||||
return activeSessions;
|
||||
},
|
||||
get primarySession() {
|
||||
return primarySession;
|
||||
},
|
||||
isSourcePlaying,
|
||||
sourceHasSessions,
|
||||
sessionsForSource,
|
||||
start: nowPlayingStore.start,
|
||||
stop: nowPlayingStore.stop,
|
||||
refresh: nowPlayingStore.refresh
|
||||
};
|
||||
}
|
||||
|
||||
export const nowPlayingMerged = createMergedStore();
|
||||
@@ -0,0 +1,281 @@
|
||||
import { API } from '$lib/constants';
|
||||
import { integrationStore } from '$lib/stores/integration';
|
||||
import { get } from 'svelte/store';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type {
|
||||
NowPlayingSession,
|
||||
JellyfinSessionInfo,
|
||||
JellyfinSessionsResponse,
|
||||
NavidromeNowPlayingEntry,
|
||||
NavidromeNowPlayingResponse,
|
||||
PlexSessionInfo,
|
||||
PlexSessionsResponse
|
||||
} from '$lib/types';
|
||||
|
||||
const POLL_INTERVAL_MS = 3_000;
|
||||
const STALE_SESSION_EXPIRY_MS = 30_000;
|
||||
const STALE_PROGRESS_THRESHOLD_MS = POLL_INTERVAL_MS * 2.5;
|
||||
const MAX_INTERPOLATION_ADVANCE_MS = POLL_INTERVAL_MS * 3;
|
||||
const FROZEN_BASIS_MS = 15_000;
|
||||
|
||||
type SourceKey = 'jellyfin' | 'navidrome' | 'plex';
|
||||
type InterpolationBasis = { serverProgress: number; updatedAt: number };
|
||||
|
||||
function jellyfinToSession(s: JellyfinSessionInfo): NowPlayingSession {
|
||||
return {
|
||||
id: s.session_id,
|
||||
user_name: s.user_name,
|
||||
track_name: s.track_name,
|
||||
artist_name: s.artist_name,
|
||||
album_name: s.album_name,
|
||||
cover_url: s.cover_url,
|
||||
device_name: s.device_name,
|
||||
is_paused: s.is_paused,
|
||||
source: 'jellyfin',
|
||||
progress_ms: s.position_seconds * 1000,
|
||||
duration_ms: s.duration_seconds * 1000,
|
||||
audio_codec: s.audio_codec,
|
||||
bitrate: s.bitrate
|
||||
};
|
||||
}
|
||||
|
||||
function navidromeToSession(e: NavidromeNowPlayingEntry): NowPlayingSession {
|
||||
const progressMs =
|
||||
e.estimated_position_seconds != null && e.estimated_position_seconds > 0
|
||||
? e.estimated_position_seconds * 1000
|
||||
: e.minutes_ago > 0
|
||||
? Math.max(0, e.duration_seconds * 1000 - e.minutes_ago * 60_000)
|
||||
: 0;
|
||||
return {
|
||||
id: `${e.user_name}-${e.player_name}-${e.album_id}-${e.track_name}`,
|
||||
user_name: e.user_name,
|
||||
track_name: e.track_name,
|
||||
artist_name: e.artist_name,
|
||||
album_name: e.album_name,
|
||||
cover_url: e.cover_art_id ? `/api/v1/navidrome/cover/${e.cover_art_id}` : '',
|
||||
device_name: e.player_name,
|
||||
is_paused: false,
|
||||
source: 'navidrome',
|
||||
progress_ms: progressMs,
|
||||
duration_ms: e.duration_seconds * 1000
|
||||
};
|
||||
}
|
||||
|
||||
function plexToSession(s: PlexSessionInfo): NowPlayingSession {
|
||||
return {
|
||||
id: s.session_id,
|
||||
user_name: s.user_name,
|
||||
track_name: s.track_title,
|
||||
artist_name: s.artist_name,
|
||||
album_name: s.album_name,
|
||||
cover_url: s.cover_url,
|
||||
device_name: s.player_device,
|
||||
is_paused: s.player_state === 'paused',
|
||||
source: 'plex',
|
||||
progress_ms: s.progress_ms,
|
||||
duration_ms: s.duration_ms,
|
||||
audio_codec: s.audio_codec,
|
||||
bitrate: s.bitrate
|
||||
};
|
||||
}
|
||||
|
||||
const FETCH_FAILED = Symbol('fetch_failed');
|
||||
|
||||
function createNowPlayingStore() {
|
||||
let sessions = $state<NowPlayingSession[]>([]);
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let tickTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let running = false;
|
||||
|
||||
const lastGoodSessions = new SvelteMap<SourceKey, NowPlayingSession[]>();
|
||||
const interpBasis = new SvelteMap<string, InterpolationBasis>();
|
||||
|
||||
const activeSessions = $derived(sessions.filter((s) => !s.is_paused));
|
||||
const primarySession = $derived(activeSessions[0] ?? sessions[0] ?? null);
|
||||
|
||||
async function fetchSource<T>(
|
||||
url: string,
|
||||
mapper: (data: T) => NowPlayingSession[],
|
||||
source: SourceKey
|
||||
): Promise<NowPlayingSession[] | typeof FETCH_FAILED> {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) return FETCH_FAILED;
|
||||
const data: T = await r.json();
|
||||
const mapped = mapper(data);
|
||||
lastGoodSessions.set(source, mapped);
|
||||
return mapped;
|
||||
} catch {
|
||||
return FETCH_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
|
||||
const integrations = get(integrationStore);
|
||||
const fetches: Promise<{
|
||||
source: SourceKey;
|
||||
result: NowPlayingSession[] | typeof FETCH_FAILED;
|
||||
}>[] = [];
|
||||
|
||||
if (integrations.jellyfin) {
|
||||
fetches.push(
|
||||
fetchSource<JellyfinSessionsResponse>(
|
||||
API.jellyfinLibrary.sessions(),
|
||||
(d) => (d?.sessions ?? []).map(jellyfinToSession),
|
||||
'jellyfin'
|
||||
).then((result) => ({ source: 'jellyfin' as SourceKey, result }))
|
||||
);
|
||||
}
|
||||
if (integrations.navidrome) {
|
||||
fetches.push(
|
||||
fetchSource<NavidromeNowPlayingResponse>(
|
||||
API.navidromeLibrary.nowPlaying(),
|
||||
(d) => (d?.entries ?? []).map(navidromeToSession),
|
||||
'navidrome'
|
||||
).then((result) => ({ source: 'navidrome' as SourceKey, result }))
|
||||
);
|
||||
}
|
||||
if (integrations.plex) {
|
||||
fetches.push(
|
||||
fetchSource<PlexSessionsResponse>(
|
||||
API.plexLibrary.sessions(),
|
||||
(d) => (d?.sessions ?? []).map(plexToSession),
|
||||
'plex'
|
||||
).then((result) => ({ source: 'plex' as SourceKey, result }))
|
||||
);
|
||||
}
|
||||
|
||||
if (fetches.length === 0) {
|
||||
sessions = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.all(fetches);
|
||||
const now = Date.now();
|
||||
const incoming: NowPlayingSession[] = [];
|
||||
|
||||
for (const { source, result } of results) {
|
||||
if (result === FETCH_FAILED) {
|
||||
const stale = lastGoodSessions.get(source);
|
||||
if (stale && stale.length > 0) {
|
||||
const basis = interpBasis.get(stale[0].id);
|
||||
if (!basis || now - basis.updatedAt < STALE_SESSION_EXPIRY_MS) {
|
||||
incoming.push(...stale);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
incoming.push(...result);
|
||||
}
|
||||
}
|
||||
|
||||
const newIds = new SvelteSet<string>();
|
||||
for (const s of incoming) {
|
||||
newIds.add(s.id);
|
||||
const prev = interpBasis.get(s.id);
|
||||
if (s.progress_ms != null) {
|
||||
if (!prev || prev.serverProgress !== s.progress_ms) {
|
||||
interpBasis.set(s.id, { serverProgress: s.progress_ms, updatedAt: now });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of interpBasis.keys()) {
|
||||
if (!newIds.has(key)) interpBasis.delete(key);
|
||||
}
|
||||
|
||||
for (const s of incoming) {
|
||||
if (s.is_paused) continue;
|
||||
const basis = interpBasis.get(s.id);
|
||||
if (basis && now - basis.updatedAt > STALE_PROGRESS_THRESHOLD_MS) {
|
||||
s.is_paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
sessions = incoming;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
const now = Date.now();
|
||||
const updated = sessions.map((s) => {
|
||||
if (s.is_paused || !s.duration_ms || s.progress_ms == null) return s;
|
||||
const basis = interpBasis.get(s.id);
|
||||
if (!basis) {
|
||||
const next = Math.min(s.progress_ms + 1000, s.duration_ms);
|
||||
return next === s.progress_ms ? s : { ...s, progress_ms: next };
|
||||
}
|
||||
const basisAge = now - basis.updatedAt;
|
||||
if (basisAge > FROZEN_BASIS_MS) return s;
|
||||
const elapsed = Math.min(basisAge, MAX_INTERPOLATION_ADVANCE_MS);
|
||||
const interpolated = Math.min(basis.serverProgress + elapsed, s.duration_ms);
|
||||
if (interpolated === s.progress_ms) return s;
|
||||
return { ...s, progress_ms: interpolated };
|
||||
});
|
||||
sessions = updated;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
fetchAll();
|
||||
pollTimer = setInterval(fetchAll, POLL_INTERVAL_MS);
|
||||
tickTimer = setInterval(tick, 1000);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
running = false;
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = undefined;
|
||||
}
|
||||
if (tickTimer) {
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = undefined;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
if (!document.hidden && running) {
|
||||
fetchAll();
|
||||
}
|
||||
}
|
||||
|
||||
function isSourcePlaying(source: SourceKey): boolean {
|
||||
return sessions.some((s) => s.source === source && !s.is_paused);
|
||||
}
|
||||
|
||||
function sourceHasSessions(source: SourceKey): boolean {
|
||||
return sessions.some((s) => s.source === source);
|
||||
}
|
||||
|
||||
function sessionsForSource(source: SourceKey): NowPlayingSession[] {
|
||||
return sessions.filter((s) => s.source === source);
|
||||
}
|
||||
|
||||
return {
|
||||
get sessions() {
|
||||
return sessions;
|
||||
},
|
||||
get activeSessions() {
|
||||
return activeSessions;
|
||||
},
|
||||
get primarySession() {
|
||||
return primarySession;
|
||||
},
|
||||
start,
|
||||
stop,
|
||||
refresh: fetchAll,
|
||||
isSourcePlaying,
|
||||
sourceHasSessions,
|
||||
sessionsForSource
|
||||
};
|
||||
}
|
||||
|
||||
export const nowPlayingStore = createNowPlayingStore();
|
||||
@@ -15,8 +15,14 @@ import {
|
||||
} from '$lib/player/jellyfinPlaybackApi';
|
||||
import {
|
||||
reportNavidromeScrobble,
|
||||
reportNavidromeNowPlaying
|
||||
reportNavidromeNowPlaying,
|
||||
reportNavidromeStopped
|
||||
} from '$lib/player/navidromePlaybackApi';
|
||||
import {
|
||||
reportPlexScrobble,
|
||||
reportPlexNowPlaying,
|
||||
reportPlexStopped
|
||||
} from '$lib/player/plexPlaybackApi';
|
||||
import { playbackToast } from '$lib/stores/playbackToast.svelte';
|
||||
import {
|
||||
getStoredVolume,
|
||||
@@ -118,7 +124,8 @@ function createPlayerStore() {
|
||||
const handleBeforeUnload = createBeforeUnloadHandler(
|
||||
() => ({ jellyfinItem: getJellyfinItem(), currentItem: queue[currentIndex] ?? null, progress }),
|
||||
API.stream.jellyfinStop,
|
||||
API.stream.navidromeScrobble
|
||||
API.stream.navidromeScrobble,
|
||||
API.stream.plexScrobble
|
||||
);
|
||||
|
||||
function getNextIndex(): number | null {
|
||||
@@ -131,6 +138,9 @@ function createPlayerStore() {
|
||||
const item = queue[currentIndex];
|
||||
return item?.sourceType === 'jellyfin' ? item : null;
|
||||
}
|
||||
function getCurrentItem(): QueueItem | null {
|
||||
return queue[currentIndex] ?? null;
|
||||
}
|
||||
function persist(): void {
|
||||
doPersistSession(nowPlaying, queue, currentIndex, progress, shuffleEnabled, shuffleOrder);
|
||||
}
|
||||
@@ -145,11 +155,17 @@ function createPlayerStore() {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
beforeUnloadRegistered = false;
|
||||
}
|
||||
async function stopJellyfinSession(item: QueueItem | null, posSeconds: number): Promise<void> {
|
||||
async function stopPreviousSession(item: QueueItem | null, posSeconds: number): Promise<void> {
|
||||
progressReporter.stop();
|
||||
unregisterBeforeUnload();
|
||||
if (!item || item.sourceType !== 'jellyfin' || !item.playSessionId) return;
|
||||
await reportJellyfinStop(item.trackSourceId, item.playSessionId, posSeconds);
|
||||
if (!item) return;
|
||||
if (item.sourceType === 'jellyfin' && item.playSessionId) {
|
||||
await reportJellyfinStop(item.trackSourceId, item.playSessionId, posSeconds);
|
||||
} else if (item.sourceType === 'navidrome') {
|
||||
void reportNavidromeStopped(item.trackSourceId);
|
||||
} else if (item.sourceType === 'plex' && item.plexRatingKey) {
|
||||
void reportPlexStopped(item.plexRatingKey);
|
||||
}
|
||||
}
|
||||
|
||||
function applyResetState(): void {
|
||||
@@ -191,6 +207,14 @@ function createPlayerStore() {
|
||||
loadUrl: url
|
||||
};
|
||||
}
|
||||
if (item.sourceType === 'plex') {
|
||||
isSeekable = true;
|
||||
if (item.plexRatingKey) void reportPlexNowPlaying(item.plexRatingKey);
|
||||
return {
|
||||
source: createPlaybackSource('plex', { url: url!, seekable: true }),
|
||||
loadUrl: url
|
||||
};
|
||||
}
|
||||
isSeekable = true;
|
||||
return {
|
||||
source: createPlaybackSource('jellyfin', { url: url!, seekable: true }),
|
||||
@@ -227,7 +251,7 @@ function createPlayerStore() {
|
||||
playbackState = 'loading';
|
||||
progress = 0;
|
||||
duration = 0;
|
||||
await stopJellyfinSession(prevItem, prevProgress);
|
||||
await stopPreviousSession(prevItem, prevProgress);
|
||||
currentSource?.destroy();
|
||||
const gen = ++loadGeneration;
|
||||
let source: PlaybackSource,
|
||||
@@ -264,13 +288,13 @@ function createPlayerStore() {
|
||||
playbackState = 'error';
|
||||
const trackName = nowPlaying?.trackName ?? 'Unknown track';
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
playbackToast.show('Multiple tracks failed — playback stopped', 'error');
|
||||
playbackToast.show('Several tracks failed, so playback stopped.', 'error');
|
||||
applyResetState();
|
||||
return;
|
||||
}
|
||||
const nextIdx = getNextIndex();
|
||||
if (nextIdx !== null) {
|
||||
playbackToast.show(`"${trackName}" unavailable — skipping…`, 'warning');
|
||||
playbackToast.show(`"${trackName}" is unavailable, skipping...`, 'warning');
|
||||
errorSkipTimeout = setTimeout(() => {
|
||||
errorSkipTimeout = null;
|
||||
if (gen === loadGeneration) void loadQueueItem(nextIdx);
|
||||
@@ -309,9 +333,12 @@ function createPlayerStore() {
|
||||
void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true);
|
||||
}
|
||||
if (state === 'ended') {
|
||||
const ci = queue[currentIndex] ?? null;
|
||||
void stopJellyfinSession(getJellyfinItem(), progress);
|
||||
if (ci?.sourceType === 'navidrome') void reportNavidromeScrobble(ci.trackSourceId);
|
||||
const endedItem = getCurrentItem();
|
||||
void stopPreviousSession(endedItem, progress);
|
||||
if (endedItem?.sourceType === 'plex' && endedItem.plexRatingKey)
|
||||
void reportPlexScrobble(endedItem.plexRatingKey);
|
||||
else if (endedItem?.sourceType === 'navidrome')
|
||||
void reportNavidromeScrobble(endedItem.trackSourceId);
|
||||
const nextIdx = getNextIndex();
|
||||
if (nextIdx !== null) {
|
||||
void loadQueueItem(nextIdx).then(() => {
|
||||
@@ -414,7 +441,7 @@ function createPlayerStore() {
|
||||
},
|
||||
|
||||
playAlbum(source: PlaybackSource, metadata: NowPlaying): void {
|
||||
void stopJellyfinSession(getJellyfinItem(), progress);
|
||||
void stopPreviousSession(getCurrentItem(), progress);
|
||||
currentSource?.destroy();
|
||||
const gen = ++loadGeneration;
|
||||
currentSource = source;
|
||||
@@ -502,6 +529,27 @@ function createPlayerStore() {
|
||||
showQueueMutationToast('queue', items.length);
|
||||
},
|
||||
|
||||
appendQueueSilent(items: QueueItem[]): void {
|
||||
if (items.length === 0) return;
|
||||
const r = addMultipleItems(queue, items, shuffleEnabled, shuffleOrder);
|
||||
queue = r.newQueue;
|
||||
shuffleOrder = r.newShuffleOrder;
|
||||
persist();
|
||||
},
|
||||
|
||||
regenerateShuffleOrder(): void {
|
||||
if (!shuffleEnabled || queue.length === 0) return;
|
||||
const allIndices = Array.from({ length: queue.length }, (_, i) => i);
|
||||
const upcoming = allIndices.filter((i) => i !== currentIndex && i > currentIndex);
|
||||
const played = allIndices.filter((i) => i < currentIndex);
|
||||
for (let i = upcoming.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
|
||||
}
|
||||
shuffleOrder = [...played, currentIndex, ...upcoming];
|
||||
persist();
|
||||
},
|
||||
|
||||
playNext(item: QueueItem): void {
|
||||
if (queue.length === 0) {
|
||||
this.playQueue([stampSingleOrigin(item, 'manual')], 0, false);
|
||||
@@ -590,7 +638,8 @@ function createPlayerStore() {
|
||||
playlistTrackId: string,
|
||||
newSourceType: SourceType,
|
||||
newTrackSourceId: string,
|
||||
newFormat?: string
|
||||
newFormat?: string,
|
||||
plexRatingKey?: string
|
||||
): void {
|
||||
const r = updateItemByPlaylistTrackId(
|
||||
queue,
|
||||
@@ -598,7 +647,8 @@ function createPlayerStore() {
|
||||
currentIndex,
|
||||
newSourceType,
|
||||
newTrackSourceId,
|
||||
newFormat
|
||||
newFormat,
|
||||
plexRatingKey
|
||||
);
|
||||
if (r) {
|
||||
queue = r;
|
||||
@@ -615,12 +665,27 @@ function createPlayerStore() {
|
||||
const jf = getJellyfinItem();
|
||||
if (jf?.playSessionId)
|
||||
void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true);
|
||||
const item = getCurrentItem();
|
||||
if (item?.sourceType === 'plex' && item.plexRatingKey)
|
||||
void reportPlexStopped(item.plexRatingKey);
|
||||
if (item?.sourceType === 'navidrome') void reportNavidromeStopped(item.trackSourceId);
|
||||
persist();
|
||||
},
|
||||
|
||||
togglePlay(): void {
|
||||
if (isPlaying) currentSource?.pause();
|
||||
else currentSource?.play();
|
||||
if (isPlaying) {
|
||||
currentSource?.pause();
|
||||
const jf = getJellyfinItem();
|
||||
if (jf?.playSessionId)
|
||||
void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true);
|
||||
const item = getCurrentItem();
|
||||
if (item?.sourceType === 'plex' && item.plexRatingKey)
|
||||
void reportPlexStopped(item.plexRatingKey);
|
||||
if (item?.sourceType === 'navidrome') void reportNavidromeStopped(item.trackSourceId);
|
||||
persist();
|
||||
} else {
|
||||
currentSource?.play();
|
||||
}
|
||||
},
|
||||
seekTo(seconds: number): void {
|
||||
currentSource?.seekTo(seconds);
|
||||
@@ -636,7 +701,7 @@ function createPlayerStore() {
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
void stopJellyfinSession(getJellyfinItem(), progress);
|
||||
void stopPreviousSession(getCurrentItem(), progress);
|
||||
if (errorSkipTimeout) {
|
||||
clearTimeout(errorSkipTimeout);
|
||||
errorSkipTimeout = null;
|
||||
@@ -660,7 +725,7 @@ function createPlayerStore() {
|
||||
shuffleOrder = resume.shuffleOrder;
|
||||
isPlayerVisible = true;
|
||||
consecutiveErrors = 0;
|
||||
void stopJellyfinSession(getJellyfinItem(), progress);
|
||||
void stopPreviousSession(getCurrentItem(), progress);
|
||||
currentSource?.destroy();
|
||||
currentIndex = resume.currentIndex;
|
||||
playbackState = 'loading';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { QueueItem } from '$lib/player/types';
|
||||
import { isPlexScrobbleEnabled } from '$lib/player/plexPlaybackApi';
|
||||
|
||||
export interface ProgressReporterState {
|
||||
jellyfinItem: QueueItem | null;
|
||||
@@ -76,7 +77,8 @@ export function createBeforeUnloadHandler(
|
||||
progress: number;
|
||||
},
|
||||
jellyfinStopUrl: (trackSourceId: string) => string,
|
||||
navidromeScrobbleUrl: (trackSourceId: string) => string
|
||||
navidromeScrobbleUrl: (trackSourceId: string) => string,
|
||||
plexScrobbleUrl: (ratingKey: string) => string
|
||||
): () => void {
|
||||
return () => {
|
||||
if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return;
|
||||
@@ -96,5 +98,17 @@ export function createBeforeUnloadHandler(
|
||||
new Blob([], { type: 'application/json' })
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
currentItem?.sourceType === 'plex' &&
|
||||
currentItem.plexRatingKey &&
|
||||
progress > 30 &&
|
||||
isPlexScrobbleEnabled()
|
||||
) {
|
||||
navigator.sendBeacon(
|
||||
plexScrobbleUrl(currentItem.plexRatingKey),
|
||||
new Blob([], { type: 'application/json' })
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,7 +119,8 @@ export function updateItemByPlaylistTrackId(
|
||||
currentIndex: number,
|
||||
newSourceType: SourceType,
|
||||
newTrackSourceId: string,
|
||||
newFormat?: string
|
||||
newFormat?: string,
|
||||
plexRatingKey?: string
|
||||
): QueueItem[] | null {
|
||||
const index = queue.findIndex((item) => item.playlistTrackId === playlistTrackId);
|
||||
if (index < 0 || index === currentIndex) return null;
|
||||
@@ -132,7 +133,8 @@ export function updateItemByPlaylistTrackId(
|
||||
trackSourceId: newTrackSourceId,
|
||||
streamUrl,
|
||||
format: newFormat,
|
||||
playSessionId: undefined
|
||||
playSessionId: undefined,
|
||||
plexRatingKey: newSourceType === 'plex' ? plexRatingKey : undefined
|
||||
};
|
||||
return newQueue;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export function resolveSourceUrl(item: QueueItem): string | undefined {
|
||||
return item.streamUrl ?? API.stream.navidrome(item.trackSourceId);
|
||||
case 'jellyfin':
|
||||
return API.stream.jellyfin(item.trackSourceId);
|
||||
case 'plex':
|
||||
return item.streamUrl ?? API.stream.plex(item.trackSourceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +24,8 @@ export function buildPrefetchUrl(item: QueueItem): string | null {
|
||||
return API.stream.jellyfin(item.trackSourceId);
|
||||
case 'navidrome':
|
||||
return API.stream.navidrome(item.trackSourceId);
|
||||
case 'plex':
|
||||
return API.stream.plex(item.trackSourceId);
|
||||
case 'local':
|
||||
return API.stream.local(item.trackSourceId);
|
||||
default:
|
||||
@@ -40,6 +44,8 @@ export function buildStreamUrlForSource(
|
||||
return API.stream.navidrome(trackSourceId);
|
||||
case 'jellyfin':
|
||||
return API.stream.jellyfin(trackSourceId);
|
||||
case 'plex':
|
||||
return API.stream.plex(trackSourceId);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user