hooks for jellyfin data, playback, tmdb, player chrome

This commit is contained in:
2026-03-25 19:11:34 +02:00
parent 996a85de76
commit df17c7ab95
27 changed files with 3066 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import { useQuery } from '@tanstack/react-query'
import { getMediaSegmentsApi, jellyfinClient } from '../../api/jellyfin'
import { debugLog } from '../../lib/log'
export type SegmentLike = {
Type: 'Intro' | 'Outro' | 'Recap' | 'Preview' | 'Commercial' | 'Unknown'
StartTicks: number
EndTicks: number
}
const TICKS_PER_SECOND = 10_000_000
async function tryJsonFetch(url: string, token: string): Promise<any | null> {
try {
const res = await fetch(url, {
headers: { 'X-MediaBrowser-Token': token },
})
if (!res.ok) {
debugLog('[segments] GET', url, '->', res.status)
return null
}
const text = await res.text()
debugLog('[segments] GET', url, '->', res.status, 'body:', text)
if (!text) return null
try {
return JSON.parse(text)
} catch {
return null
}
} catch (err) {
debugLog('[segments] GET', url, 'threw', err)
return null
}
}
function pluginPayloadToSegments(data: any): SegmentLike[] {
if (!data) return []
const out: SegmentLike[] = []
if (data.Valid && Number.isFinite(data.IntroStart) && Number.isFinite(data.IntroEnd) && data.IntroEnd > data.IntroStart) {
out.push({
Type: 'Intro',
StartTicks: Math.floor(Number(data.IntroStart) * TICKS_PER_SECOND),
EndTicks: Math.floor(Number(data.IntroEnd) * TICKS_PER_SECOND),
})
}
if (data.Intro && Number.isFinite(data.Intro.Start) && Number.isFinite(data.Intro.End) && data.Intro.End > data.Intro.Start) {
out.push({
Type: 'Intro',
StartTicks: Math.floor(Number(data.Intro.Start) * TICKS_PER_SECOND),
EndTicks: Math.floor(Number(data.Intro.End) * TICKS_PER_SECOND),
})
}
if (data.Credits && Number.isFinite(data.Credits.Start) && Number.isFinite(data.Credits.End) && data.Credits.End > data.Credits.Start) {
out.push({
Type: 'Outro',
StartTicks: Math.floor(Number(data.Credits.Start) * TICKS_PER_SECOND),
EndTicks: Math.floor(Number(data.Credits.End) * TICKS_PER_SECOND),
})
}
if (Array.isArray(data.Items)) {
for (const it of data.Items) addSegment(out, it)
}
if (Array.isArray(data)) {
for (const it of data) addSegment(out, it)
}
return out
}
function addSegment(out: SegmentLike[], it: any) {
if (!it?.Type) return
const startTicks = Number(it.StartTicks ?? 0)
const endTicks = Number(it.EndTicks ?? 0)
if (endTicks <= startTicks) return
out.push({
Type: it.Type as SegmentLike['Type'],
StartTicks: startTicks,
EndTicks: endTicks,
})
}
export function useMediaSegments(itemId?: string) {
const api = jellyfinClient.getApi()
return useQuery<SegmentLike[]>({
queryKey: ['jellyfin', 'media-segments', itemId],
queryFn: async () => {
if (!api || !itemId) return []
const auth = jellyfinClient.getAuthState()
if (!auth) return []
const { serverUrl, token } = auth
const urls = [
`${serverUrl}/MediaSegments/${itemId}`,
`${serverUrl}/Episode/${itemId}/IntroSkipperSegments`,
`${serverUrl}/Episode/${itemId}/Timestamps`,
`${serverUrl}/Episode/${itemId}/IntroTimestamps/v1`,
`${serverUrl}/Episode/${itemId}/IntroTimestamps`,
]
for (const url of urls) {
const data = await tryJsonFetch(url, token)
if (!data) continue
const segments = pluginPayloadToSegments(data)
if (segments.length > 0) {
debugLog('[segments] resolved from', url, segments)
return segments
}
}
try {
const res = await getMediaSegmentsApi(api).getItemSegments({ itemId })
const items = (res.data.Items || []).map(s => ({
Type: (s.Type || 'Unknown') as SegmentLike['Type'],
StartTicks: Number(s.StartTicks ?? 0),
EndTicks: Number(s.EndTicks ?? 0),
}))
if (items.length > 0) {
debugLog('[segments] resolved via SDK', items)
return items
}
} catch (err) {
debugLog('[segments] SDK call failed', err)
}
debugLog('[segments] no data from any source for', itemId)
return []
},
enabled: !!api && !!itemId,
staleTime: 60 * 60 * 1000,
})
}
+104
View File
@@ -0,0 +1,104 @@
import { useQuery } from '@tanstack/react-query'
import { useArrInstances, type ArrInstance } from '../stores/arr-instances-store'
import { radarrClient } from '../api/radarr'
import { sonarrClient } from '../api/sonarr'
const STALE = 5 * 60 * 1000
/**
* React Query bindings for the Sonarr / Radarr clients. Hooks return
* `null` when no instance is configured for the requested kind+tier so
* callers can render seerr-style "configure to enable" affordances.
*/
export function useArrSystemStatus(instance: ArrInstance | null | undefined) {
return useQuery({
queryKey: ['arr', 'status', instance?.id],
queryFn: () =>
instance
? instance.kind === 'radarr'
? radarrClient(instance).systemStatus()
: sonarrClient(instance).systemStatus()
: Promise.resolve(null),
enabled: !!instance,
staleTime: STALE,
retry: 0,
})
}
export function useRadarrLibrary(tier: 'default' | '4k' = 'default') {
const instance = useArrInstances(s => s.pick('radarr', tier))
return useQuery({
queryKey: ['radarr', 'library', instance?.id],
queryFn: () => (instance ? radarrClient(instance).movies() : Promise.resolve(null)),
enabled: !!instance,
staleTime: STALE,
})
}
export function useSonarrLibrary(tier: 'default' | '4k' = 'default') {
const instance = useArrInstances(s => s.pick('sonarr', tier))
return useQuery({
queryKey: ['sonarr', 'library', instance?.id],
queryFn: () => (instance ? sonarrClient(instance).series() : Promise.resolve(null)),
enabled: !!instance,
staleTime: STALE,
})
}
export function useRadarrQueue(tier: 'default' | '4k' = 'default') {
const instance = useArrInstances(s => s.pick('radarr', tier))
return useQuery({
queryKey: ['radarr', 'queue', instance?.id],
queryFn: () => (instance ? radarrClient(instance).queue() : Promise.resolve(null)),
enabled: !!instance,
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
})
}
export function useSonarrQueue(tier: 'default' | '4k' = 'default') {
const instance = useArrInstances(s => s.pick('sonarr', tier))
return useQuery({
queryKey: ['sonarr', 'queue', instance?.id],
queryFn: () => (instance ? sonarrClient(instance).queue() : Promise.resolve(null)),
enabled: !!instance,
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
})
}
export function useArrProfiles(instance: ArrInstance | null | undefined) {
return useQuery({
queryKey: ['arr', 'profiles', instance?.id],
queryFn: () => {
if (!instance) return Promise.resolve(null)
if (instance.kind === 'radarr') return radarrClient(instance).qualityProfiles()
return sonarrClient(instance).qualityProfiles()
},
enabled: !!instance,
staleTime: 60 * 60 * 1000,
})
}
export function useArrRootFolders(instance: ArrInstance | null | undefined) {
return useQuery({
queryKey: ['arr', 'rootfolders', instance?.id],
queryFn: () => {
if (!instance) return Promise.resolve(null)
if (instance.kind === 'radarr') return radarrClient(instance).rootFolders()
return sonarrClient(instance).rootFolders()
},
enabled: !!instance,
staleTime: 60 * 60 * 1000,
})
}
export function useSonarrLanguageProfiles(instance: ArrInstance | null | undefined) {
return useQuery({
queryKey: ['sonarr', 'language-profiles', instance?.id],
queryFn: () => (instance && instance.kind === 'sonarr' ? sonarrClient(instance).languageProfiles() : Promise.resolve(null)),
enabled: !!instance && instance.kind === 'sonarr',
staleTime: 60 * 60 * 1000,
})
}
+142
View File
@@ -0,0 +1,142 @@
import { useMemo } from 'react'
import { useLibraryByTmdbId } from './use-jellyfin'
import { useRadarrLibrary, useSonarrLibrary, useRadarrQueue, useSonarrQueue } from './use-arr'
/**
* Unified per-item availability state. Combines what Jellyfin already
* has with what Sonarr/Radarr know about so the UI can label every
* recommendation with one of:
* - 'available' - playable in Jellyfin right now
* - 'partial' - in Sonarr with some episodes downloaded but not all
* - 'processing' - being downloaded by *arr at this moment
* - 'pending' - in *arr, monitored, but no file yet (waiting on
* indexer / release)
* - 'requested' - in *arr, monitored, but inactive
* - 'missing' - not in any system
*/
export type Availability = 'available' | 'partial' | 'processing' | 'pending' | 'requested' | 'missing'
export interface AvailabilityRecord {
status: Availability
/** Filled when the item lives in Sonarr/Radarr - useful for cancel /
* search-now actions on the Requests page. */
arrId?: number
arrKind?: 'sonarr' | 'radarr'
arrTier?: 'default' | '4k'
/** Local Jellyfin id when matched. */
jellyfinId?: string
}
/**
* Compute the availability map keyed by TMDB id. Reads all four
* regular+4K *arr libraries plus their queues. The map is stable as
* long as the upstream queries don't change so consumers can pass it
* through `useMemo` cheaply.
*/
export function useAvailability() {
const lib = useLibraryByTmdbId()
const radarrA = useRadarrLibrary('default')
const radarrB = useRadarrLibrary('4k')
const sonarrA = useSonarrLibrary('default')
const sonarrB = useSonarrLibrary('4k')
const radarrQA = useRadarrQueue('default')
const radarrQB = useRadarrQueue('4k')
const sonarrQA = useSonarrQueue('default')
const sonarrQB = useSonarrQueue('4k')
const map = useMemo(() => {
const out = new Map<string, AvailabilityRecord>()
// Jellyfin first - "available" beats everything *arr might say.
if (lib.data) {
for (const [tmdbId, info] of lib.data) {
out.set(tmdbId, { status: 'available', jellyfinId: info.id })
}
}
function applyMovieList(
list: { id?: number; tmdbId: number; hasFile?: boolean; monitored?: boolean }[] | null | undefined,
tier: 'default' | '4k',
queue: { records?: { movieId?: number }[] } | null | undefined,
) {
if (!list) return
const queueIds = new Set((queue?.records || []).map(r => r.movieId).filter((x): x is number => x != null))
for (const m of list) {
if (!m.tmdbId) continue
const key = String(m.tmdbId)
const existing = out.get(key)
if (existing && existing.status === 'available') continue
let status: Availability
if (m.hasFile) status = 'available'
else if (m.id != null && queueIds.has(m.id)) status = 'processing'
else if (m.monitored) status = 'pending'
else status = 'requested'
out.set(key, { status, arrId: m.id, arrKind: 'radarr', arrTier: tier })
}
}
function applySeriesList(
list: { id?: number; tmdbId?: number; tvdbId?: number; imdbId?: string | null; seasons?: { monitored: boolean; statistics?: { episodeFileCount: number; episodeCount: number } }[] }[] | null | undefined,
tier: 'default' | '4k',
queue: { records?: { seriesId?: number }[] } | null | undefined,
) {
if (!list) return
const queueIds = new Set((queue?.records || []).map(r => r.seriesId).filter((x): x is number => x != null))
for (const s of list) {
const tmdb = s.tmdbId ? String(s.tmdbId) : null
if (!tmdb) continue
const existing = out.get(tmdb)
if (existing && existing.status === 'available') continue
let status: Availability
const seasons = s.seasons || []
const totalEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeCount || 0), 0)
const haveEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeFileCount || 0), 0)
if (s.id != null && queueIds.has(s.id)) {
status = 'processing'
} else if (totalEps > 0 && haveEps >= totalEps) {
status = 'available'
} else if (haveEps > 0) {
status = 'partial'
} else if (seasons.some(x => x.monitored)) {
status = 'pending'
} else {
status = 'requested'
}
out.set(tmdb, { status, arrId: s.id, arrKind: 'sonarr', arrTier: tier })
}
}
applyMovieList(radarrA.data, 'default', radarrQA.data)
applyMovieList(radarrB.data, '4k', radarrQB.data)
applySeriesList(sonarrA.data, 'default', sonarrQA.data)
applySeriesList(sonarrB.data, '4k', sonarrQB.data)
return out
}, [
lib.data,
radarrA.data,
radarrB.data,
sonarrA.data,
sonarrB.data,
radarrQA.data,
radarrQB.data,
sonarrQA.data,
sonarrQB.data,
])
return {
map,
isLoading: lib.isLoading || radarrA.isLoading || radarrB.isLoading || sonarrA.isLoading || sonarrB.isLoading,
}
}
/**
* Single-item availability lookup. Convenience over reading from the
* full map directly when a component only cares about one tmdb id.
*/
export function useItemAvailability(tmdbId: string | number | null | undefined): AvailabilityRecord | null {
const { map } = useAvailability()
if (tmdbId == null) return null
return map.get(String(tmdbId)) || null
}
+32
View File
@@ -0,0 +1,32 @@
import { useQueries } from '@tanstack/react-query'
import { getMovieFull } from '../api/tmdb'
const STALE = 7 * 24 * 60 * 60 * 1000
/**
* Resolve a list of TMDB movie ids into their TMDB-shaped objects in
* the original order, dropping entries TMDB doesn't recognise. Used by
* the home page's "Discover canon" rows so the bundled lists in
* `canon-lists.ts` can render through the same TMDB->BaseItemDto
* mapping path as everything else.
*/
export function useCanonListResolved(ids: number[]) {
const queries = useQueries({
queries: ids.map(id => ({
queryKey: ['tmdb', 'movie-full', id],
queryFn: () => getMovieFull(id),
staleTime: STALE,
})),
})
const results: any[] = []
for (let i = 0; i < ids.length; i++) {
const data = queries[i]?.data
if (data) {
results.push({ ...data, media_type: 'movie' })
}
}
return {
items: results,
isLoading: queries.some(q => q.isLoading),
}
}
+59
View File
@@ -0,0 +1,59 @@
import { useQuery } from '@tanstack/react-query'
import { getCollection, getMovieFull } from '../api/tmdb'
import { useLibraryByTmdbId } from './use-jellyfin'
import type { BaseItemDto } from '../api/types'
import { getTmdbId } from '../lib/item-types'
/**
* For a movie, lazy-fetch its TMDB collection (if any) and compute the
* user's progress through it. Returns null when the movie isn't part of
* a collection or until both fetches resolve.
*
* Gated by an `armed` flag (typically the same hover-delay used by the
* trailer hook) so we don't burn N+1 TMDB requests on first paint of a
* library grid.
*/
export function useCollectionMeter(
item: BaseItemDto | null | undefined,
armed: boolean,
) {
const tmdbId = getTmdbId(item)
const numericTmdb = tmdbId ? Number(tmdbId) : null
const isMovie = item?.Type === 'Movie'
const movieQ = useQuery({
queryKey: ['tmdb', 'movie-full', numericTmdb],
queryFn: () => (numericTmdb ? getMovieFull(numericTmdb) : null),
enabled: !!numericTmdb && isMovie && armed,
staleTime: 24 * 60 * 60 * 1000,
})
const collectionId = movieQ.data?.belongs_to_collection?.id ?? null
const colQ = useQuery({
queryKey: ['tmdb', 'collection', collectionId],
queryFn: () => (collectionId ? getCollection(collectionId) : null),
enabled: !!collectionId && armed,
staleTime: 7 * 24 * 60 * 60 * 1000,
})
const libQ = useLibraryByTmdbId()
if (!collectionId || !colQ.data || !libQ.data) return null
const parts = colQ.data.parts || []
if (parts.length < 2) return null
let inLibrary = 0
let watched = 0
for (const p of parts) {
const local = libQ.data.get(String(p.id))
if (local) {
inLibrary++
if (local.played) watched++
}
}
return {
collectionName: movieQ.data?.belongs_to_collection?.name || colQ.data.name,
total: parts.length,
inLibrary,
watched,
}
}
+206
View File
@@ -0,0 +1,206 @@
import { useQuery } from '@tanstack/react-query'
import { usePreferencesStore } from '../stores/preferences-store'
import {
tvmazeLookupByImdbId,
tvmazeLookupByTvdbId,
tvmazeShow,
} from '../api/tvmaze'
import { wikipediaResolve, wikipediaSection, wikipediaSummary } from '../api/wikipedia'
import { cinemetaMovie, cinemetaSeries } from '../api/cinemeta'
import { fanartMovie, fanartTv } from '../api/fanart'
import { getAwards, getAwardWinners, getFilmingLocations } from '../api/wikidata'
import { getMovieRating as rtMovie, getTvRating as rtTv } from '../api/rotten-tomatoes'
/**
* React Query hooks for all the keyless / single-key external sources we
* pull. Each is independent and silently no-ops when the input id is missing
* or the source returns nothing - callers can chain them without guarding.
*/
const DAY = 24 * 60 * 60 * 1000
/* ── TVmaze ─────────────────────────────────────────────────── */
export function useTvmazeByImdbId(imdbId?: string | null) {
return useQuery({
queryKey: ['tvmaze', 'imdb', imdbId],
queryFn: () => (imdbId ? tvmazeLookupByImdbId(imdbId) : null),
enabled: !!imdbId,
staleTime: DAY,
})
}
export function useTvmazeByTvdbId(tvdbId?: string | number | null) {
return useQuery({
queryKey: ['tvmaze', 'tvdb', tvdbId],
queryFn: () => (tvdbId ? tvmazeLookupByTvdbId(tvdbId) : null),
enabled: !!tvdbId,
staleTime: DAY,
})
}
export function useTvmazeShow(id?: number | null) {
return useQuery({
queryKey: ['tvmaze', 'show', id],
queryFn: () => (id ? tvmazeShow(id) : null),
enabled: !!id,
staleTime: DAY,
})
}
/* ── Wikipedia ──────────────────────────────────────────────── */
export function useWikiSummary(title?: string | null) {
return useQuery({
queryKey: ['wiki', 'summary', title],
queryFn: () => (title ? wikipediaSummary(title) : null),
enabled: !!title,
staleTime: 7 * DAY,
})
}
/**
* Resolves a free-text query (movie/show/person name) to a Wikipedia
* article and returns its lead summary. Used when we don't already have
* a known article title.
*/
export function useWikiResolve(query?: string | null, enabled = true) {
return useQuery({
queryKey: ['wiki', 'resolve', query],
queryFn: () => (query ? wikipediaResolve(query) : null),
enabled: !!query && enabled,
staleTime: 7 * DAY,
})
}
/**
* Pull a specific top-level section's plain text from a Wikipedia article.
* Used by the "Production trivia" block on detail pages, which surfaces
* the article's "Production" section when present.
*/
export function useWikiSection(title?: string | null, sectionName?: string | null) {
return useQuery({
queryKey: ['wiki', 'section', title, sectionName],
queryFn: () => (title && sectionName ? wikipediaSection(title, sectionName) : null),
enabled: !!title && !!sectionName,
staleTime: 7 * DAY,
})
}
/* ── Wikidata (SPARQL) ──────────────────────────────────────── */
export function useWikidataAwards(qid?: string | null) {
return useQuery({
queryKey: ['wikidata', 'awards', qid],
queryFn: () => (qid ? getAwards(qid) : []),
enabled: !!qid,
staleTime: 7 * DAY,
})
}
export function useWikidataLocations(qid?: string | null) {
return useQuery({
queryKey: ['wikidata', 'locations', qid],
queryFn: () => (qid ? getFilmingLocations(qid) : []),
enabled: !!qid,
staleTime: 7 * DAY,
})
}
/**
* All winners of the given award (Wikidata Q-id) with TMDB cross-refs
* when available. Used by the "Award winners you're missing" home row.
*/
export function useWikidataAwardWinners(awardQid?: string | null) {
return useQuery({
queryKey: ['wikidata', 'award-winners', awardQid],
queryFn: () => (awardQid ? getAwardWinners(awardQid) : []),
enabled: !!awardQid,
staleTime: 30 * DAY,
})
}
/* ── Cinemeta (IMDB-style fallback) ─────────────────────────── */
export function useCinemeta(imdbId?: string | null, type?: 'movie' | 'series') {
return useQuery({
queryKey: ['cinemeta', type, imdbId],
queryFn: () => {
if (!imdbId || !type) return null
return type === 'movie' ? cinemetaMovie(imdbId) : cinemetaSeries(imdbId)
},
enabled: !!imdbId && !!type,
staleTime: DAY,
})
}
/* ── Key-gated services (TMDB, Fanart.tv) ────────────────────── */
const BUILT_IN_TMDB_KEY_X = (import.meta.env.VITE_TMDB_API_KEY || '').trim()
const BUILT_IN_FANART_KEY = (import.meta.env.VITE_FANART_API_KEY || '').trim()
function effectiveFanartKey(userKey: string): string {
return userKey || BUILT_IN_FANART_KEY
}
/**
* Reactive: returns true when ANY TMDB key is available (user override
* in prefs, mirror entry in localStorage, or build-time default). The
* UI uses this to hide TMDB-dependent rows entirely so the user never
* sees an empty "Trending today" header with no content underneath.
*/
export function useHasTmdbKey(): boolean {
const userKey = usePreferencesStore(s => s.tmdbApiKey)
if (userKey) return true
try {
if (localStorage.getItem('jf_tmdb_key')) return true
} catch { /* ignore */ }
return !!BUILT_IN_TMDB_KEY_X
}
export function useHasFanartKey(): boolean {
const userKey = usePreferencesStore(s => s.fanartApiKey)
return !!userKey || !!BUILT_IN_FANART_KEY
}
export function useFanartMovie(idTmdbOrImdb?: string | null) {
const userKey = usePreferencesStore(s => s.fanartApiKey)
const apiKey = effectiveFanartKey(userKey)
return useQuery({
queryKey: ['fanart', 'movie', idTmdbOrImdb, apiKey],
queryFn: () => (idTmdbOrImdb && apiKey ? fanartMovie(idTmdbOrImdb, apiKey) : null),
enabled: !!idTmdbOrImdb && !!apiKey,
staleTime: 3 * DAY,
})
}
export function useFanartTv(tvdbId?: string | null) {
const userKey = usePreferencesStore(s => s.fanartApiKey)
const apiKey = effectiveFanartKey(userKey)
return useQuery({
queryKey: ['fanart', 'tv', tvdbId, apiKey],
queryFn: () => (tvdbId && apiKey ? fanartTv(tvdbId, apiKey) : null),
enabled: !!tvdbId && !!apiKey,
staleTime: 3 * DAY,
})
}
/* ── Rotten Tomatoes (private Algolia, no key required) ───── */
export function useRottenTomatoes(
name?: string | null,
year?: number | null,
kind?: 'movie' | 'tv' | null,
) {
return useQuery({
queryKey: ['rt', kind, name, year],
queryFn: () =>
name && kind
? kind === 'movie'
? rtMovie(name, year || undefined)
: rtTv(name, year || undefined)
: null,
enabled: !!name && !!kind,
staleTime: 7 * DAY,
})
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useMemo, useState } from 'react'
import Fuse from 'fuse.js'
import { useFullLibraryCatalog, useEpisodeAndTrackSearch } from './use-jellyfin'
import type { BaseItemDto } from '../api/types'
/**
* Real fuzzy search across the user's library. Pre-fetches the full
* Movies + Series + MusicAlbum + MusicArtist catalog once (cached 12h)
* and runs Fuse.js client-side so typos, partial words, and out-of-
* order tokens all resolve. Episodes and tracks fall back to Jellyfin's
* server-side searchTerm because pre-fetching every episode of every
* series would be enormous.
*
* The Fuse threshold is intentionally generous (0.4) so single-letter
* typos still surface the right title; we cap at 80 results so the UI
* stays manageable even for libraries with 5000+ items.
*/
export function useFuzzyLibrarySearch(query: string) {
const catalog = useFullLibraryCatalog()
const epAndTrack = useEpisodeAndTrackSearch(query)
// Local debounce so Fuse doesn't re-run on every keystroke. The
// catalog is stable so this is the only thing changing the result.
const [debounced, setDebounced] = useState(query)
useEffect(() => {
const t = setTimeout(() => setDebounced(query), 120)
return () => clearTimeout(t)
}, [query])
const fuse = useMemo(() => {
const items = catalog.data || []
if (items.length === 0) return null
return new Fuse(items as BaseItemDto[], {
keys: [
{ name: 'Name', weight: 0.7 },
{ name: 'OriginalTitle', weight: 0.25 },
{ name: 'Overview', weight: 0.05 },
],
threshold: 0.4,
ignoreLocation: true,
shouldSort: true,
includeScore: true,
minMatchCharLength: 2,
})
}, [catalog.data])
const results = useMemo<BaseItemDto[]>(() => {
const trimmed = debounced.trim()
if (!trimmed || !fuse) return []
return fuse.search(trimmed, { limit: 80 }).map(r => r.item)
}, [fuse, debounced])
return {
catalogResults: results,
episodesAndTracks: (epAndTrack.data || []) as BaseItemDto[],
isLoading: catalog.isLoading || epAndTrack.isLoading,
isFetching: catalog.isFetching || epAndTrack.isFetching,
catalogReady: !!fuse,
}
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getMovieFull, getTvShowFull } from '../api/tmdb'
import type { BaseItemDto } from '../api/types'
import { getTmdbId } from '../lib/item-types'
/**
* Returns the YouTube key of the first official Trailer/Teaser for the
* given item, lazy-fetched only after the user has hovered the card for
* `delayMs`. Avoids slamming TMDB when users sweep the cursor across a
* grid.
*
* The fetch is gated by an `armed` boolean toggled by the consumer
* (PosterCard's hover effect) so we don't dispatch network requests for
* every card on the screen, only the ones the user lingers on.
*/
export function useHoverTrailer(
item: BaseItemDto | null | undefined,
hovered: boolean,
enabled: boolean,
delayMs = 700,
): { videoKey: string | null; ready: boolean } {
const [armed, setArmed] = useState(false)
// Arm only after the user has stayed on the card for delayMs.
useEffect(() => {
if (!hovered || !enabled) {
setArmed(false)
return
}
const id = setTimeout(() => setArmed(true), delayMs)
return () => clearTimeout(id)
}, [hovered, enabled, delayMs])
const tmdbId = getTmdbId(item)
const numericId = tmdbId ? Number(tmdbId) : null
const isTv = item?.Type === 'Series'
const movieQ = useQuery({
queryKey: ['tmdb', 'movie-full', numericId],
queryFn: () => (numericId ? getMovieFull(numericId) : null),
enabled: !!numericId && !isTv && armed,
staleTime: 24 * 60 * 60 * 1000,
})
const tvQ = useQuery({
queryKey: ['tmdb', 'tv-full', numericId],
queryFn: () => (numericId ? getTvShowFull(numericId) : null),
enabled: !!numericId && isTv && armed,
staleTime: 24 * 60 * 60 * 1000,
})
const videos = (isTv ? tvQ.data?.videos?.results : movieQ.data?.videos?.results) || []
// Prefer official trailers, then teasers; restricted to YouTube since
// we render via the YT iframe player.
const pickPriority = (t: string) => (t === 'Trailer' ? 0 : t === 'Teaser' ? 1 : 2)
const trailer = [...videos]
.filter(v => v.site === 'YouTube' && v.key)
.sort((a, b) => {
if (a.official !== b.official) return a.official ? -1 : 1
return pickPriority(a.type) - pickPriority(b.type)
})[0]
return {
videoKey: trailer?.key || null,
ready: armed && (movieQ.isFetched || tvQ.isFetched),
}
}
+886
View File
@@ -0,0 +1,886 @@
import { useEffect, useState } from 'react'
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'
import { jellyfinClient, getItemsApi, getTvShowsApi, getUserViewsApi, getSearchApi, getLibraryApi, getItemRefreshApi, getMediaInfoApi, getPlaylistsApi, getPlaystateApi, getUserLibraryApi } from '../api/jellyfin'
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api'
import { debugLog } from '../lib/log'
import { browserDeviceProfile } from '../lib/device-profile'
function useApi() {
return jellyfinClient.getApi()
}
export function useHomeData() {
const api = useApi()
const continueWatching = useQuery({
queryKey: ['jellyfin', 'home', 'continueWatching'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
debugLog('[JF] Fetching continueWatching...')
const res = await getItemsApi(api).getResumeItems({
userId: jellyfinClient.getAuthState()!.userId,
limit: 12,
fields: ['PrimaryImageAspectRatio', 'Overview', 'ChildCount', 'Genres'],
enableImageTypes: ['Primary', 'Backdrop', 'Logo', 'Thumb'],
})
debugLog('[JF] continueWatching result:', res.data)
return res.data.Items || []
},
enabled: !!api,
})
const nextUp = useQuery({
queryKey: ['jellyfin', 'home', 'nextUp'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
debugLog('[JF] Fetching nextUp...')
const res = await getTvShowsApi(api).getNextUp({
userId: jellyfinClient.getAuthState()!.userId,
limit: 12,
fields: ['PrimaryImageAspectRatio', 'Overview', 'ChildCount', 'Genres'],
enableImageTypes: ['Primary', 'Backdrop', 'Logo', 'Thumb'],
})
debugLog('[JF] nextUp result:', res.data)
return res.data.Items || []
},
enabled: !!api,
})
const recentlyAdded = useQuery({
queryKey: ['jellyfin', 'home', 'recentlyAdded'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
sortBy: ['DateCreated'],
sortOrder: ['Descending'],
limit: 20,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['PrimaryImageAspectRatio', 'Overview', 'Genres'],
enableImageTypes: ['Primary', 'Backdrop', 'Logo', 'Thumb'],
} as any)
return res.data.Items || []
},
enabled: !!api,
})
return { continueWatching, nextUp, recentlyAdded }
}
export function useLibraries() {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'libraries'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const res = await getUserViewsApi(api).getUserViews({
userId: jellyfinClient.getAuthState()!.userId,
})
return res.data.Items || []
},
enabled: !!api,
})
}
export function useLibraryItems(
parentId?: string,
opts?: {
sortBy?: string[]
sortOrder?: string[]
includeItemTypes?: string[]
filters?: string[]
genres?: string[]
years?: number[]
minCommunityRating?: number
startIndex?: number
limit?: number
enabled?: boolean
includePeople?: boolean
},
) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'library', parentId, opts],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
debugLog('[JF] Fetching library items:', { parentId, opts, userId: jellyfinClient.getAuthState()!.userId })
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
parentId,
sortBy: opts?.sortBy || ['SortName'],
sortOrder: opts?.sortOrder || ['Ascending'],
includeItemTypes: opts?.includeItemTypes,
filters: opts?.filters,
genres: opts?.genres,
years: opts?.years,
minCommunityRating: opts?.minCommunityRating,
startIndex: opts?.startIndex,
limit: opts?.limit || 100,
recursive: true,
fields: [
'PrimaryImageAspectRatio',
'Overview',
'ChildCount',
'RecursiveItemCount',
'MediaSources',
'MediaStreams',
'DateCreated',
'Genres',
'Studios',
'Tags',
'ProviderIds',
'Width',
'Height',
...(opts?.includePeople ? (['People'] as const) : []),
],
} as any)
debugLog('[JF] Library items result:', res.data)
return res.data
},
enabled: !!api && (opts?.enabled ?? true),
})
}
/**
* Bulk-fetch a set of Jellyfin items by their ids in one request. Used
* by the Profile page's top-picks list which needs items keyed off
* personal-rating ids (not necessarily played items).
*/
export function useItemsByIds(itemIds: string[]) {
const api = useApi()
const sortedIds = [...itemIds].sort()
return useQuery({
queryKey: ['jellyfin', 'items-by-ids', sortedIds],
queryFn: async () => {
if (!api || itemIds.length === 0) return []
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
ids: itemIds,
fields: ['PrimaryImageAspectRatio', 'ProviderIds', 'Genres'],
} as any)
return res.data.Items || []
},
enabled: !!api && itemIds.length > 0,
staleTime: 5 * 60 * 1000,
})
}
export function useItemDetails(itemId?: string) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'item', itemId],
queryFn: async () => {
if (!api || !itemId) throw new Error('Missing params')
debugLog('[JF] Fetching item details:', itemId)
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
ids: [itemId],
fields: [
'PrimaryImageAspectRatio',
'Overview',
'ChildCount',
'ProviderIds',
'MediaSources',
'MediaStreams',
'Chapters',
'Trickplay',
'People',
'Studios',
'Tags',
'Taglines',
'Genres',
'RemoteTrailers',
'ExternalUrls',
'DateCreated',
'DateLastMediaAdded',
'Width',
'Height',
'Path',
],
enableImageTypes: ['Primary', 'Backdrop', 'Logo', 'Thumb', 'Banner', 'Disc', 'Art'],
} as any)
debugLog('[JF] Item details result:', res.data)
return res.data.Items?.[0] || null
},
enabled: !!api && !!itemId,
})
}
/**
* Move a playlist entry to a new index. Optimistic - PlaylistView updates
* its local copy immediately, and we just invalidate on settle so the next
* read picks up the server-confirmed order.
*/
export function usePlaylistMove(playlistId?: string) {
const api = useApi()
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ playlistItemId, newIndex }: { playlistItemId: string; newIndex: number }) => {
if (!api || !playlistId) throw new Error('Not ready')
await getPlaylistsApi(api).moveItem({ playlistId, itemId: playlistItemId, newIndex })
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['jellyfin', 'playlist-items', playlistId] })
},
})
}
/**
* Find or lazily create a user-private playlist with the given name.
* Used by the watchlist feature to keep state on the server (so it
* survives device changes) without burning a separate API.
*/
export function useNamedPlaylist(name: string) {
const api = useApi()
const qc = useQueryClient()
return useQuery({
queryKey: ['jellyfin', 'named-playlist', name],
queryFn: async () => {
if (!api) return null
const userId = jellyfinClient.getAuthState()!.userId
// Look for an existing playlist owned by this user with the exact name.
const list = await getItemsApi(api).getItems({
userId,
recursive: true,
includeItemTypes: ['Playlist'],
fields: ['ChildCount'],
} as any)
const found = (list.data.Items || []).find(
p => (p.Name || '').toLowerCase() === name.toLowerCase(),
)
if (found?.Id) return found
// Create a new empty playlist. MediaType "Mixed" is required by
// some Jellyfin server versions; without it the server defaults
// to a music playlist that silently rejects movie/series adds.
const create = await getPlaylistsApi(api).createPlaylist({
createPlaylistDto: { Name: name, UserId: userId, Ids: [], MediaType: 'Mixed' as any },
} as any)
const newId = create.data.Id
if (!newId) return null
qc.invalidateQueries({ queryKey: ['jellyfin', 'named-playlist', name] })
const fresh = await getItemsApi(api).getItems({
userId,
ids: [newId],
fields: ['ChildCount'],
} as any)
return fresh.data.Items?.[0] || null
},
enabled: !!api,
staleTime: 60 * 1000,
})
}
/** Add one or more item ids to a playlist. */
export function usePlaylistAdd(playlistId?: string) {
const api = useApi()
const qc = useQueryClient()
return useMutation({
mutationFn: async (itemIds: string[]) => {
if (!api || !playlistId || itemIds.length === 0) return
await getPlaylistsApi(api).addItemToPlaylist({
playlistId,
ids: itemIds,
userId: jellyfinClient.getAuthState()!.userId,
})
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['jellyfin', 'playlist-items', playlistId] })
},
})
}
/** Remove one or more entries from a playlist by their PlaylistItemId values. */
export function usePlaylistRemove(playlistId?: string) {
const api = useApi()
const qc = useQueryClient()
return useMutation({
mutationFn: async (entryIds: string[]) => {
if (!api || !playlistId || entryIds.length === 0) return
await getPlaylistsApi(api).removeItemFromPlaylist({ playlistId, entryIds })
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['jellyfin', 'playlist-items', playlistId] })
},
})
}
/**
* Bulk mark items as played or unplayed. Used by the playlist selection
* toolbar - hits the playstate endpoint once per item.
*/
export function useBulkMarkPlayed() {
const api = useApi()
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ itemIds, played }: { itemIds: string[]; played: boolean }) => {
if (!api || itemIds.length === 0) return
const userId = jellyfinClient.getAuthState()!.userId
await Promise.all(
itemIds.map(id =>
played
? getPlaystateApi(api).markPlayedItem({ userId, itemId: id })
: getPlaystateApi(api).markUnplayedItem({ userId, itemId: id }),
),
)
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['jellyfin'] })
},
})
}
/**
* Toggle favorite state for one or more items. Used both by the playlist
* heart button (single item) and the selection toolbar (bulk).
*/
export function useBulkToggleFavorite() {
const api = useApi()
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ itemIds, favorite }: { itemIds: string[]; favorite: boolean }) => {
if (!api || itemIds.length === 0) return
const userId = jellyfinClient.getAuthState()!.userId
await Promise.all(
itemIds.map(id =>
favorite
? getUserLibraryApi(api).markFavoriteItem({ userId, itemId: id })
: getUserLibraryApi(api).unmarkFavoriteItem({ userId, itemId: id }),
),
)
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['jellyfin'] })
},
})
}
/**
* Items inside a Playlist. Playlist contents come from a different endpoint
* than regular folders (it preserves user-set order; regular `getItems` with
* a parentId would re-order).
*/
export function usePlaylistItems(playlistId?: string) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'playlist-items', playlistId],
queryFn: async () => {
if (!api || !playlistId) return []
const res = await getPlaylistsApi(api).getPlaylistItems({
playlistId,
userId: jellyfinClient.getAuthState()!.userId,
fields: [
'PrimaryImageAspectRatio',
'Overview',
'MediaSources',
'MediaStreams',
'Genres',
'ProviderIds',
'Width',
'Height',
],
enableImageTypes: ['Primary', 'Backdrop', 'Thumb'],
} as any)
return res.data.Items || []
},
enabled: !!api && !!playlistId,
})
}
export function useSeasons(seriesId?: string) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'seasons', seriesId],
queryFn: async () => {
if (!api || !seriesId) throw new Error('Missing params')
debugLog('[JF] Fetching seasons:', seriesId)
const res = await getTvShowsApi(api).getSeasons({
seriesId,
userId: jellyfinClient.getAuthState()!.userId,
fields: ['PrimaryImageAspectRatio', 'Overview', 'ChildCount'],
})
debugLog('[JF] Seasons result:', res.data)
return res.data.Items || []
},
enabled: !!api && !!seriesId,
})
}
export function useEpisodes(seriesId?: string, seasonId?: string) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'episodes', seriesId, seasonId],
queryFn: async () => {
if (!api || !seriesId) throw new Error('Missing params')
debugLog('[JF] Fetching episodes:', { seriesId, seasonId })
const res = await getTvShowsApi(api).getEpisodes({
seriesId,
userId: jellyfinClient.getAuthState()!.userId,
seasonId,
fields: [
'PrimaryImageAspectRatio',
'Overview',
'MediaSources',
'MediaStreams',
'Chapters',
'ProviderIds',
'Width',
'Height',
],
enableImageTypes: ['Primary', 'Thumb'],
} as any)
debugLog('[JF] Episodes result:', res.data)
return res.data.Items || []
},
enabled: !!api && !!seriesId,
})
}
/**
* Fetch episodes for several seasons in parallel. Used by per-season UI
* (sparklines, recap detection) that needs progress data without waiting
* for the user to click each season.
*/
export function useSeasonsEpisodes(seriesId: string | undefined, seasonIds: string[]) {
const api = useApi()
const queries = useQueries({
queries: seasonIds.map(seasonId => ({
queryKey: ['jellyfin', 'episodes', seriesId, seasonId],
queryFn: async () => {
if (!api || !seriesId) throw new Error('Missing params')
const res = await getTvShowsApi(api).getEpisodes({
seriesId,
userId: jellyfinClient.getAuthState()!.userId,
seasonId,
fields: ['PrimaryImageAspectRatio', 'Overview', 'ProviderIds'],
enableImageTypes: ['Primary', 'Thumb'],
} as any)
return res.data.Items || []
},
enabled: !!api && !!seriesId,
})),
})
return queries
}
export function useSearch(query: string) {
const api = useApi()
// Debounce the query so we don't fire a request on every keystroke. 250ms
// is the sweet spot - feels responsive while collapsing rapid typing.
const [debouncedQuery, setDebouncedQuery] = useState(query)
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 250)
return () => clearTimeout(t)
}, [query])
return useQuery({
queryKey: ['jellyfin', 'search', debouncedQuery],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const res = await getSearchApi(api).getSearchHints({
userId: jellyfinClient.getAuthState()!.userId,
searchTerm: debouncedQuery,
includeItemTypes: ['Movie', 'Series', 'Episode', 'Audio', 'MusicAlbum', 'MusicArtist'],
limit: 20,
})
return res.data.SearchHints || []
},
enabled: !!api && debouncedQuery.length > 0,
})
}
/**
* Pull the entire searchable library catalog once and cache it for 12h.
* Returns Movies + Series + MusicAlbum + MusicArtist with full ImageTags
* and ProviderIds so Fuse.js can rerank and PosterCard can render
* covers. Capped at 5000 items per type to keep the payload bounded on
* very large libraries.
*/
export function useFullLibraryCatalog() {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'full-catalog'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const userId = jellyfinClient.getAuthState()!.userId
const fields = [
'ProviderIds',
'Overview',
'PrimaryImageAspectRatio',
'CommunityRating',
'OriginalTitle',
'PremiereDate',
] as any
const res = await getItemsApi(api).getItems({
userId,
recursive: true,
includeItemTypes: ['Movie', 'Series', 'MusicAlbum', 'MusicArtist'] as any,
sortBy: ['SortName'] as any,
fields,
limit: 5000,
} as any)
return res.data.Items || []
},
enabled: !!api,
staleTime: 12 * 60 * 60 * 1000,
gcTime: 24 * 60 * 60 * 1000,
})
}
/**
* Server-side keyword search for high-cardinality types (Episodes,
* Audio tracks) where pre-fetching everything is impractical. Returns
* full BaseItemDto objects (not lightweight SearchHints) so we get
* ImageTags + ProviderIds straight away.
*/
export function useEpisodeAndTrackSearch(query: string) {
const api = useApi()
const [debounced, setDebounced] = useState(query)
useEffect(() => {
const t = setTimeout(() => setDebounced(query), 220)
return () => clearTimeout(t)
}, [query])
return useQuery({
queryKey: ['jellyfin', 'kw-search', debounced],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const userId = jellyfinClient.getAuthState()!.userId
const fields = [
'ProviderIds',
'Overview',
'PrimaryImageAspectRatio',
'SeriesPrimaryImageTag',
'ParentBackdropImageTags',
'PremiereDate',
'CommunityRating',
] as any
const res = await getItemsApi(api).getItems({
userId,
recursive: true,
searchTerm: debounced,
includeItemTypes: ['Episode', 'Audio'] as any,
fields,
limit: 50,
} as any)
return res.data.Items || []
},
enabled: !!api && debounced.trim().length >= 2,
})
}
export function useNextUpForSeries(seriesId?: string) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'nextUp-series', seriesId],
queryFn: async () => {
if (!api || !seriesId) return null
const res = await getTvShowsApi(api).getNextUp({
userId: jellyfinClient.getAuthState()!.userId,
seriesId,
limit: 1,
fields: ['PrimaryImageAspectRatio', 'Overview', 'MediaSources', 'MediaStreams'],
} as any)
return res.data.Items?.[0] || null
},
enabled: !!api && !!seriesId,
})
}
export function useSimilarItems(itemId?: string, limit = 12) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'similar', itemId, limit],
queryFn: async () => {
if (!api || !itemId) return []
try {
const res = await getLibraryApi(api).getSimilarItems({
itemId,
userId: jellyfinClient.getAuthState()!.userId,
limit,
fields: ['PrimaryImageAspectRatio', 'Overview', 'Genres', 'ProviderIds'],
} as any)
return res.data.Items || []
} catch {
return []
}
},
enabled: !!api && !!itemId,
})
}
/**
* Establishes a server-side playback session for an item and returns the
* resolved MediaSources with proper TranscodingUrl + PlaySessionId. The
* server uses our DeviceProfile to decide direct-play vs HLS transcoding.
* Without this call, the bare /main.m3u8 endpoint generates a playlist with
* runtimeTicks=0, which makes segment fetches fail with 400.
*/
export function usePlaybackInfo(
itemId?: string,
startTimeTicks?: number,
audioStreamIndex?: number,
maxStreamingBitrate?: number,
) {
const api = useApi()
return useQuery({
queryKey: [
'jellyfin',
'playback-info',
itemId,
startTimeTicks,
audioStreamIndex,
maxStreamingBitrate,
],
queryFn: async () => {
if (!api || !itemId) return null
const res = await getMediaInfoApi(api).getPostedPlaybackInfo({
itemId,
playbackInfoDto: {
UserId: jellyfinClient.getAuthState()!.userId,
// 0 = "no cap, prefer Original" - server will pick direct-stream
// when source matches profile. Otherwise honour the user pick.
MaxStreamingBitrate: maxStreamingBitrate || 140_000_000,
StartTimeTicks: startTimeTicks,
// Server-side audio track selection. When undefined the server
// picks the default; when set, the returned TranscodingUrl muxes
// that audio track into the stream.
AudioStreamIndex: audioStreamIndex,
DeviceProfile: browserDeviceProfile() as any,
AutoOpenLiveStream: true,
EnableDirectPlay: true,
EnableDirectStream: true,
EnableTranscoding: true,
AllowVideoStreamCopy: true,
AllowAudioStreamCopy: true,
},
} as any)
return res.data
},
enabled: !!api && !!itemId,
staleTime: 0,
// Override the global 30-minute gcTime: each PlaybackInfoResponse
// includes full MediaSources with encoding params, audio/video
// streams, container-level metadata, etc. Bingeing through 20
// episodes would otherwise keep ~20 of these objects in memory
// for half an hour. 60s is plenty for "user clicked play, the
// stream URL came back" and avoids the buildup.
gcTime: 60 * 1000,
})
}
export { useMediaSegments } from './jellyfin/use-media-segments'
/**
* Returns one representative episode for a series so we can show tech specs
* (resolution / codec / audio) on a series detail page, since series-level
* items have no MediaSources of their own.
*/
export function useSampleEpisode(seriesId?: string, enabled = true) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'sample-episode', seriesId],
queryFn: async () => {
if (!api || !seriesId) return null
const res = await getTvShowsApi(api).getEpisodes({
seriesId,
userId: jellyfinClient.getAuthState()!.userId,
limit: 1,
startIndex: 0,
fields: ['MediaSources', 'MediaStreams', 'Width', 'Height', 'Path'],
} as any)
return res.data.Items?.[0] || null
},
enabled: !!api && !!seriesId && enabled,
staleTime: 5 * 60 * 1000,
})
}
/**
* One-time fetch of every movie + series in the user's library, indexed by TMDB id.
* Used to highlight which TMDB recommendations the user already owns.
*/
/**
* Lightweight genre tally across the user's Movie + Series library.
* Used by the Discover "library gap finder" to spot underrepresented
* categories. Limited to 1500 items - enough to be representative on
* even big libraries without paying for the whole catalog.
*/
export function useLibraryGenreDistribution() {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'lib-genre-distribution'],
queryFn: async () => {
if (!api) return null
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['Genres'],
limit: 1500,
} as any)
const counts = new Map<string, number>()
const items = res.data.Items || []
for (const item of items) {
for (const g of item.Genres || []) {
counts.set(g, (counts.get(g) || 0) + 1)
}
}
return { counts, total: items.length }
},
enabled: !!api,
staleTime: 30 * 60 * 1000,
})
}
export function useLibraryByTmdbId() {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'library-by-tmdb-id'],
queryFn: async () => {
if (!api) return new Map<string, { id: string; name: string; type: string; played: boolean }>()
// Cap at 3000 to bound the size of this single big request - the
// map drives in-library indicators across the whole app, so the
// round-trip cost matters. Libraries beyond this size will see
// some out-of-library cards mis-marked as missing; acceptable
// until we wire pagination.
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['ProviderIds'],
limit: 3000,
} as any)
const map = new Map<string, { id: string; name: string; type: string; played: boolean }>()
for (const item of res.data.Items || []) {
const tmdb = item.ProviderIds?.Tmdb
if (tmdb && item.Id) {
map.set(String(tmdb), {
id: item.Id,
name: item.Name || '',
type: item.Type || 'Movie',
played: !!item.UserData?.Played,
})
}
}
return map
},
enabled: !!api,
staleTime: 5 * 60 * 1000,
})
}
/**
* Triggers a metadata + image refresh on a specific Jellyfin item.
*/
export function useRefreshItem() {
const api = useApi()
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
itemId: string
replaceAllMetadata?: boolean
replaceAllImages?: boolean
}) => {
if (!api) throw new Error('Not authenticated')
const { itemId, replaceAllMetadata = false, replaceAllImages = false } = params
await getItemRefreshApi(api).refreshItem({
itemId,
metadataRefreshMode: 'FullRefresh' as any,
imageRefreshMode: 'FullRefresh' as any,
replaceAllMetadata,
replaceAllImages,
} as any)
// Wait a beat then invalidate so the UI sees fresh data
await new Promise(r => setTimeout(r, 1500))
},
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: ['jellyfin', 'item', vars.itemId] })
qc.invalidateQueries({ queryKey: ['jellyfin', 'episodes'] })
qc.invalidateQueries({ queryKey: ['jellyfin', 'seasons'] })
},
})
}
export function useLiveTvInfo() {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'live-tv', 'info'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const res = await getLiveTvApi(api).getLiveTvInfo()
return res.data
},
enabled: !!api,
})
}
export function useLiveTvChannels() {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'live-tv', 'channels'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const res = await getLiveTvApi(api).getLiveTvChannels({
userId: jellyfinClient.getAuthState()!.userId,
addCurrentProgram: true,
enableImages: true,
enableUserData: true,
limit: 500,
})
return res.data.Items || []
},
enabled: !!api,
})
}
export function useLiveTvRecordings() {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'live-tv', 'recordings'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const res = await getLiveTvApi(api).getRecordings({
userId: jellyfinClient.getAuthState()!.userId,
enableImages: true,
enableUserData: true,
limit: 200,
})
return res.data.Items || []
},
enabled: !!api,
})
}
export function useLiveTvTimers() {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'live-tv', 'timers'],
queryFn: async () => {
if (!api) throw new Error('Not authenticated')
const res = await getLiveTvApi(api).getTimers({ isScheduled: true })
return res.data.Items || []
},
enabled: !!api,
})
}
export function usePersonItems(personName?: string) {
const api = useApi()
return useQuery({
queryKey: ['jellyfin', 'person-items', personName],
queryFn: async () => {
if (!api || !personName) return []
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
person: personName,
sortBy: ['PremiereDate'],
sortOrder: ['Descending'],
recursive: true,
includeItemTypes: ['Movie', 'Series'],
limit: 60,
fields: ['PrimaryImageAspectRatio', 'Overview', 'Genres', 'ProviderIds'],
} as any)
return res.data.Items || []
},
enabled: !!api && !!personName,
})
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { jellyfinClient, getItemsApi } from '../api/jellyfin'
import { toast } from '../stores/toast-store'
/**
* Background poller that checks for newly-added library items and
* surfaces toast notifications. Stores the most recent `DateCreated`
* timestamp in localStorage so restarts don't re-notify about old
* items.
*/
export function useNewReleaseNotifications(enabled: boolean) {
const qc = useQueryClient()
const lastNotifiedRef = useRef<string | null>(() => {
try { return localStorage.getItem('jf_last_notify_date') } catch { return null }
}())
useEffect(() => {
if (!enabled) return
const api = jellyfinClient.getApi()
if (!api) return
async function check() {
try {
const res = await getItemsApi(api).getItems({
userId: jellyfinClient.getAuthState()!.userId,
sortBy: ['DateCreated'],
sortOrder: ['Descending'],
limit: 5,
recursive: true,
includeItemTypes: ['Movie', 'Series'],
fields: ['DateCreated'],
} as any)
const items = res.data.Items || []
const newOnes = items.filter(it => {
const created = it.DateCreated
if (!created) return false
if (!lastNotifiedRef.current) return false
return created > lastNotifiedRef.current
})
if (newOnes.length > 0) {
const first = newOnes[0]
toast(
`${newOnes.length} new ${newOnes.length === 1 ? 'item' : 'items'} added - ${first.Name}`,
'success',
)
}
const newest = items[0]?.DateCreated
if (newest) {
lastNotifiedRef.current = newest
try { localStorage.setItem('jf_last_notify_date', newest) } catch { /* noop */ }
}
// Refresh the home page recently-added cache
qc.invalidateQueries({ queryKey: ['jellyfin', 'home', 'recentlyAdded'] })
} catch { /* ignore */ }
}
// First check after a short delay so the app has finished loading
const first = setTimeout(check, 30_000)
const interval = setInterval(check, 5 * 60 * 1000)
return () => {
clearTimeout(first)
clearInterval(interval)
}
}, [enabled, qc])
}
+59
View File
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from 'react'
/**
* Detects whether a sentinel element has scrolled above the top of the
* nearest scroll container (the AppShell's `main.content-scroll`).
* Returns `true` when the user has moved past the sentinel, `false`
* while the sentinel is still in or below the viewport.
*
* Uses a ref-callback API rather than a `useRef` so the listener
* actually attaches when the sentinel mounts. With a plain useRef the
* setup effect runs once on first commit - which on pages that show a
* skeleton until data loads is BEFORE the real sentinel div renders,
* leaving `ref.current` null and the listener never wired up.
*
* Usage:
* const { sentinelRef, past } = usePastSentinel()
* return <div ref={sentinelRef} />
*/
export function usePastSentinel(): {
sentinelRef: (el: HTMLElement | null) => void
past: boolean
} {
const [el, setEl] = useState<HTMLElement | null>(null)
const [past, setPast] = useState(false)
// Wrap the state setter so React doesn't change the callback identity
// every render - the `ref` prop sees a stable function.
const sentinelRef = useCallback((next: HTMLElement | null) => {
setEl(next)
}, [])
useEffect(() => {
if (!el) return
const root = (document.querySelector('main.content-scroll') as HTMLElement | null) || null
let raf = 0
const update = () => {
raf = 0
const rect = el.getBoundingClientRect()
const rootTop = root ? root.getBoundingClientRect().top : 0
setPast(rect.top <= rootTop + 1)
}
const schedule = () => {
if (raf) return
raf = requestAnimationFrame(update)
}
schedule()
const target: EventTarget = root || window
target.addEventListener('scroll', schedule, { passive: true })
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null
ro?.observe(el)
return () => {
if (raf) cancelAnimationFrame(raf)
target.removeEventListener('scroll', schedule)
ro?.disconnect()
}
}, [el])
return { sentinelRef, past }
}
+124
View File
@@ -0,0 +1,124 @@
import { useMemo } from 'react'
import { useQueries } from '@tanstack/react-query'
import { getMovieFull, getTvShowFull } from '../api/tmdb'
import type { BaseItemDto } from '../api/types'
const STALE = 24 * 60 * 60 * 1000
export interface PersonSpotlight {
id: number
name: string
profile_path: string | null
/** Library item ids that contributed to this person's tally. */
contributingItemIds: string[]
/** Aggregate count - cast/director appearances among watched items. */
count: number
}
/**
* Aggregates directors and lead actors across a user's recently
* watched items, returning the persons who appear at least twice.
*
* Implementation: batch-fetches TMDB full-credits for the top-N most
* recently watched items via React Query's `useQueries`. Both director
* (crew.job === 'Director') and top-billed cast (first 5) are tallied.
*
* Capped at `limit` items to keep the home page request burst small.
*/
export function usePersonSpotlights(
recent: BaseItemDto[] | null | undefined,
limit = 8,
) {
const seeds = useMemo(() => {
if (!recent) return [] as Array<{ tmdbId: number; type: 'movie' | 'tv'; itemId: string }>
const out: Array<{ tmdbId: number; type: 'movie' | 'tv'; itemId: string }> = []
for (const it of recent) {
const t = it.ProviderIds?.Tmdb
if (!t || !it.Id) continue
const tmdbId = Number(t)
if (!Number.isFinite(tmdbId)) continue
const type: 'movie' | 'tv' = it.Type === 'Series' ? 'tv' : 'movie'
out.push({ tmdbId, type, itemId: it.Id })
if (out.length >= limit) break
}
return out
}, [recent, limit])
const queries = useQueries({
queries: seeds.map(s => ({
queryKey: ['tmdb', s.type === 'tv' ? 'tv-full' : 'movie-full', s.tmdbId],
queryFn: () =>
s.type === 'tv' ? getTvShowFull(s.tmdbId) : getMovieFull(s.tmdbId),
staleTime: STALE,
})),
})
return useMemo(() => {
const directorMap = new Map<number, PersonSpotlight & { profile_path: string | null }>()
const actorMap = new Map<number, PersonSpotlight & { profile_path: string | null }>()
seeds.forEach((seed, i) => {
const data: any = queries[i]?.data
if (!data) return
const credits = data.credits
if (!credits) return
const directors = (credits.crew || []).filter((c: any) => c.job === 'Director')
for (const d of directors) {
const cur = directorMap.get(d.id)
if (cur) {
cur.count += 1
if (!cur.contributingItemIds.includes(seed.itemId)) {
cur.contributingItemIds.push(seed.itemId)
}
} else {
directorMap.set(d.id, {
id: d.id,
name: d.name,
profile_path: d.profile_path,
count: 1,
contributingItemIds: [seed.itemId],
})
}
}
// Top 5 billed cast members per item; deeper than that is mostly
// ensemble noise that distorts the recommendation.
const billed = (credits.cast || []).slice(0, 5)
for (const c of billed) {
const cur = actorMap.get(c.id)
if (cur) {
cur.count += 1
if (!cur.contributingItemIds.includes(seed.itemId)) {
cur.contributingItemIds.push(seed.itemId)
}
} else {
actorMap.set(c.id, {
id: c.id,
name: c.name,
profile_path: c.profile_path,
count: 1,
contributingItemIds: [seed.itemId],
})
}
}
})
const topDirector = [...directorMap.values()]
.filter(d => d.count >= 2)
.sort((a, b) => b.count - a.count)[0] || null
const topActor = [...actorMap.values()]
.filter(a => a.count >= 2)
// Avoid actor === director collision so we don't render the same
// person twice on the home page.
.filter(a => a.id !== topDirector?.id)
.sort((a, b) => b.count - a.count)[0] || null
return {
director: topDirector,
actor: topActor,
isLoading: queries.some(q => q.isLoading),
}
}, [seeds, queries])
}
+87
View File
@@ -0,0 +1,87 @@
import { jellyfinClient, getPlaystateApi } from '../api/jellyfin'
/**
* Returns the API client + userId, or null if either is missing. We never
* throw synchronously from this path - the report* functions are fire-and-
* forget from useEffect, and a synchronous throw escapes the .catch() on
* the returned promise, surfacing in the React ErrorBoundary instead of
* being silently dropped.
*/
function tryGetApi() {
const api = jellyfinClient.getApi()
const userId = jellyfinClient.getAuthState()?.userId
if (!api || !userId) return null
return { api, userId }
}
/**
* Common payload bits the server uses to track an active session. Mirrors
* the fields the official jellyfin-web client sends - in particular the
* `PlaySessionId` is what lets the server clean up an orphaned transcode
* when the user closes the window mid-playback, and `PlayMethod` is what
* the Active Devices dashboard groups on.
*/
export interface PlaybackContext {
itemId: string
mediaSourceId?: string
positionTicks?: number
playSessionId?: string
playMethod?: 'DirectPlay' | 'DirectStream' | 'Transcode'
audioStreamIndex?: number
subtitleStreamIndex?: number
isPaused?: boolean
isMuted?: boolean
volumeLevel?: number
}
export async function reportPlaybackStart(ctx: PlaybackContext) {
const conn = tryGetApi()
if (!conn) return
await getPlaystateApi(conn.api).reportPlaybackStart({
playbackStartInfo: {
ItemId: ctx.itemId,
MediaSourceId: ctx.mediaSourceId,
PositionTicks: ctx.positionTicks,
PlaySessionId: ctx.playSessionId,
PlayMethod: ctx.playMethod,
AudioStreamIndex: ctx.audioStreamIndex,
SubtitleStreamIndex: ctx.subtitleStreamIndex,
VolumeLevel: ctx.volumeLevel,
IsMuted: ctx.isMuted,
CanSeek: true,
},
})
}
export async function reportPlaybackProgress(ctx: PlaybackContext) {
const conn = tryGetApi()
if (!conn) return
await getPlaystateApi(conn.api).reportPlaybackProgress({
playbackProgressInfo: {
ItemId: ctx.itemId,
MediaSourceId: ctx.mediaSourceId,
PositionTicks: ctx.positionTicks,
PlaySessionId: ctx.playSessionId,
PlayMethod: ctx.playMethod,
AudioStreamIndex: ctx.audioStreamIndex,
SubtitleStreamIndex: ctx.subtitleStreamIndex,
VolumeLevel: ctx.volumeLevel,
IsMuted: ctx.isMuted,
IsPaused: ctx.isPaused ?? false,
CanSeek: true,
},
})
}
export async function reportPlaybackStop(ctx: PlaybackContext) {
const conn = tryGetApi()
if (!conn) return
await getPlaystateApi(conn.api).reportPlaybackStopped({
playbackStopInfo: {
ItemId: ctx.itemId,
MediaSourceId: ctx.mediaSourceId,
PositionTicks: ctx.positionTicks,
PlaySessionId: ctx.playSessionId,
},
})
}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useRef, type MutableRefObject } from 'react'
import {
reportPlaybackStart,
reportPlaybackProgress,
reportPlaybackStop,
} from './use-playback-report'
import { debugLog } from '../lib/log'
type PlayMethod = 'DirectPlay' | 'DirectStream' | 'Transcode'
interface Args {
itemId: string | undefined
mediaSourceId: string | undefined
startTimeTicks: number | undefined
playSessionId: string | undefined
playMethod: PlayMethod | undefined
audioIndex: number | null
subtitleIndex: number | null
isPaused: boolean
isMuted: boolean
volume: number
/**
* Pinned ref to the latest playback position in ticks. The hook reads
* `.current` on every progress / stop tick so the caller only needs to
* keep it up-to-date - no deps churn.
*/
progressRef: MutableRefObject<number>
}
/** Defensive tick-to-number conversion - returns 0 for NaN / non-finite
* values so the report payload is always serialisable. */
function ticksToNum(n: number): number {
if (!Number.isFinite(n)) return 0
return Math.max(0, Math.floor(n))
}
/**
* Wires the Jellyfin Start / Progress / Stop reporting cycle for the
* current playback. Mirrors what the official jellyfin-web client sends:
* - PlaySessionId so the server can clean up an orphaned transcode if
* the user closes mid-playback
* - PlayMethod so the Active Devices dashboard shows the right mode
* - audio/subtitle index, pause, mute, and volume kept in sync
*
* Progress reports fire on a 5s interval - more granular than the
* previous 10s, not as chatty as jellyfin-web's per-timeupdate.
*
* The hook intentionally only re-runs on itemId. Session state changes
* (mute, pause, audio index) flow through a pinned ref so the interval
* picks them up without re-creating the reporting session.
*/
export function usePlaybackReporting(args: Args) {
const {
itemId,
mediaSourceId,
startTimeTicks,
playSessionId,
playMethod,
audioIndex,
subtitleIndex,
isPaused,
isMuted,
volume,
progressRef,
} = args
const sessionRef = useRef<{
playSessionId?: string
playMethod?: PlayMethod
audioIndex: number | null
subtitleIndex: number | null
isPaused: boolean
isMuted: boolean
volume: number
}>({
playSessionId,
playMethod,
audioIndex,
subtitleIndex,
isPaused,
isMuted,
volume,
})
// Pin the latest values without re-binding the interval. A deps change
// here would tear down + re-create the session, sending duplicate Start
// and Stop reports for the same play.
sessionRef.current = {
playSessionId,
playMethod,
audioIndex,
subtitleIndex,
isPaused,
isMuted,
volume,
}
useEffect(() => {
if (!itemId) return
const safeStart = startTimeTicks && Number.isFinite(startTimeTicks)
? Math.floor(startTimeTicks)
: undefined
function buildContext(positionTicks: number) {
return {
itemId: itemId || '',
mediaSourceId,
positionTicks,
playSessionId: sessionRef.current.playSessionId,
playMethod: sessionRef.current.playMethod,
audioStreamIndex: sessionRef.current.audioIndex ?? undefined,
subtitleStreamIndex: sessionRef.current.subtitleIndex ?? undefined,
isPaused: sessionRef.current.isPaused,
isMuted: sessionRef.current.isMuted,
volumeLevel: Math.round((sessionRef.current.volume ?? 1) * 100),
}
}
reportPlaybackStart({ ...buildContext(safeStart ?? 0), positionTicks: safeStart }).catch(err =>
debugLog('[playback] start report failed', err),
)
const id = setInterval(() => {
reportPlaybackProgress(buildContext(ticksToNum(progressRef.current))).catch(err =>
debugLog('[playback] progress report failed', err),
)
}, 5000)
const currentPosition = () => ticksToNum(progressRef.current)
return () => {
clearInterval(id)
reportPlaybackStop(buildContext(currentPosition())).catch(err =>
debugLog('[playback] stop report failed', err),
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemId])
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, type RefObject } from 'react'
import type { MediaPlayerInstance } from '@vidstack/react'
import { usePreferencesStore } from '../stores/preferences-store'
import { usePlayerRuntimeStore } from '../stores/player-runtime-store'
import { applyAudioGraphState } from '../lib/audio-graph'
/**
* Drives the Web Audio graph that sits between the `<video>` element and
* the device output. Reads the persistent prefs (audio delay default,
* volume boost, night-mode compressor) plus per-session runtime offset,
* and pushes the combined state into `applyAudioGraphState` whenever any
* of them change. The hook also re-applies on streamUrl swap because the
* `<video>` element gets a new MediaSource on each episode change.
*/
export function usePlayerAudioGraph(
playerRef: RefObject<MediaPlayerInstance | null>,
streamUrl: string,
) {
const audioDelayPref = usePreferencesStore(s => s.audioDelayMs)
const volumeBoost = usePreferencesStore(s => s.volumeBoost)
const nightMode = usePreferencesStore(s => s.nightMode)
const audioOffsetMs = usePlayerRuntimeStore(s => s.audioOffsetMs)
useEffect(() => {
const el = (playerRef.current as unknown as { el?: HTMLElement })?.el
const video = el?.querySelector('video') as HTMLVideoElement | null
if (!video) return
const totalDelay = (audioDelayPref ?? 0) + (audioOffsetMs ?? 0)
applyAudioGraphState(video, {
delayMs: totalDelay,
boost: volumeBoost ?? 1,
compressor: !!nightMode,
})
}, [playerRef, audioDelayPref, audioOffsetMs, volumeBoost, nightMode, streamUrl])
return { volumeBoost, nightMode }
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from 'react'
type SeekDirection = 'forward' | 'backward'
export interface SeekIndicatorState {
direction: SeekDirection
key: number
}
export function usePlayerChrome(isPaused: boolean) {
const [controlsVisible, setControlsVisible] = useState(true)
const [seekIndicator, setSeekIndicator] = useState<SeekIndicatorState | null>(null)
const [transientToast, setTransientToast] = useState<string | null>(null)
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const showControls = useCallback(() => {
setControlsVisible(true)
if (hideTimer.current) clearTimeout(hideTimer.current)
hideTimer.current = setTimeout(() => {
if (!isPaused) setControlsVisible(false)
}, 2500)
}, [isPaused])
useEffect(() => {
if (isPaused) {
setControlsVisible(true)
if (hideTimer.current) clearTimeout(hideTimer.current)
} else {
showControls()
}
}, [isPaused, showControls])
useEffect(() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current)
}, [])
function showSeekIndicator(direction: SeekDirection) {
setSeekIndicator({ direction, key: Date.now() })
}
function showToast(msg: string) {
setTransientToast(msg)
setTimeout(() => setTransientToast(null), 1600)
}
return {
controlsVisible,
seekIndicator,
transientToast,
showControls,
showSeekIndicator,
showToast,
}
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect } from 'react'
import type { BaseItemDto } from '../api/types'
import { useEpisodes, useNextUpForSeries } from './use-jellyfin'
import { useQueueStore } from '../stores/queue-store'
type EpisodeItem = BaseItemDto & {
SeriesId?: string | null
SeasonId?: string | null
}
export function usePlayerNavigation(item: BaseItemDto | null | undefined, itemId: string | undefined) {
const episode = item?.Type === 'Episode' ? (item as EpisodeItem) : null
const seriesId = episode?.SeriesId || undefined
const seasonId = episode?.SeasonId || undefined
const { data: nextUpItem } = useNextUpForSeries(seriesId)
const { data: seasonEpisodes = [] } = useEpisodes(seriesId, seasonId)
const currentEpisodeIndex = seasonEpisodes.findIndex(e => e.Id === itemId)
const previousEpisode = currentEpisodeIndex > 0 ? seasonEpisodes[currentEpisodeIndex - 1] : null
const siblingNextEpisode =
currentEpisodeIndex >= 0 && currentEpisodeIndex < seasonEpisodes.length - 1
? seasonEpisodes[currentEpisodeIndex + 1]
: null
const nextEpisode = siblingNextEpisode || nextUpItem || null
const queueItems = useQueueStore(s => s.items)
const queueIndex = useQueueStore(s => s.index)
const setQueueIndex = useQueueStore(s => s.setIndex)
const queuePosition = queueItems.findIndex(it => it.Id === itemId)
const queueActive = queuePosition >= 0
const queuePrev = queueActive && queuePosition > 0 ? queueItems[queuePosition - 1] : null
const queueNext = queueActive && queuePosition < queueItems.length - 1 ? queueItems[queuePosition + 1] : null
useEffect(() => {
if (queueActive && queuePosition !== queueIndex) setQueueIndex(queuePosition)
}, [queueActive, queuePosition, queueIndex, setQueueIndex])
return {
seriesId,
seasonId,
seasonEpisodes,
previousEpisode,
nextEpisode,
nextUpItem,
queueActive,
queueNext,
previousItem: queuePrev || previousEpisode || null,
nextItem: queueNext || nextEpisode || null,
}
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useState } from 'react'
/**
* The six in-player overlay panels are independent toggles (the user can
* have Episodes AND Stream Info open at the same time) so this isn't a
* state machine - just a typed dictionary so we don't carry six
* useState calls + six setters through the page. Reset on navigation
* via `closeAll`.
*/
export type PanelId =
| 'streamInfo'
| 'episodes'
| 'hints'
| 'bookmarks'
| 'chapters'
| 'subSearch'
| 'syncPlay'
type PanelState = Record<PanelId, boolean>
const ALL_CLOSED: PanelState = {
streamInfo: false,
episodes: false,
hints: false,
bookmarks: false,
chapters: false,
subSearch: false,
syncPlay: false,
}
export function usePlayerPanels() {
const [state, setState] = useState<PanelState>(ALL_CLOSED)
const set = useCallback((panel: PanelId, open: boolean) => {
setState(prev => prev[panel] === open ? prev : { ...prev, [panel]: open })
}, [])
const toggle = useCallback((panel: PanelId) => {
setState(prev => ({ ...prev, [panel]: !prev[panel] }))
}, [])
const closeAll = useCallback(() => {
setState(ALL_CLOSED)
}, [])
return { state, set, toggle, closeAll }
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, type RefObject } from 'react'
import type { MediaPlayerInstance } from '@vidstack/react'
import { usePreferencesStore } from '../stores/preferences-store'
/**
* Apply a CSS `filter` chain (brightness/contrast/saturate) to the player's
* underlying `<video>` element, driven by preferences. Updates whenever the
* preference values change or a new stream loads (streamUrl serves as the
* trigger after `<video>` is re-attached for a fresh source).
*/
export function usePlayerPictureFilter(
playerRef: RefObject<MediaPlayerInstance | null>,
streamUrl: string,
) {
const videoBrightness = usePreferencesStore(s => s.videoBrightness)
const videoContrast = usePreferencesStore(s => s.videoContrast)
const videoSaturation = usePreferencesStore(s => s.videoSaturation)
useEffect(() => {
const el = (playerRef.current as unknown as { el?: HTMLElement })?.el
const video = el?.querySelector('video') as HTMLVideoElement | null
if (!video) return
const b = videoBrightness ?? 1
const c = videoContrast ?? 1
const s = videoSaturation ?? 1
const isDefault = b === 1 && c === 1 && s === 1
video.style.filter = isDefault ? '' : `brightness(${b}) contrast(${c}) saturate(${s})`
}, [playerRef, videoBrightness, videoContrast, videoSaturation, streamUrl])
return { videoBrightness, videoContrast, videoSaturation }
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react'
import type { RefObject } from 'react'
import type { MediaPlayerInstance } from '@vidstack/react'
import { usePreferencesStore } from '../stores/preferences-store'
import {
buildBindingMap,
eventToBinding,
findShortcut,
type ShortcutContext,
} from '../lib/player-shortcuts'
/**
* Single window-scoped keydown listener that resolves the canonical binding
* for the event and dispatches to the matching shortcut handler. Reads
* user overrides from the preferences store so rebindings (#20) take
* effect without a reload.
*/
export function usePlayerShortcuts(
playerRef: RefObject<MediaPlayerInstance | null>,
context: ShortcutContext,
) {
// Pin the latest context in a ref so handlers always see fresh state
// setters / refs without us having to re-bind the listener on every
// render.
const ctxRef = useRef(context)
ctxRef.current = context
const overrides = usePreferencesStore(s => s.keyboardShortcuts)
const overridesRef = useRef(overrides)
overridesRef.current = overrides
useEffect(() => {
function onKey(e: KeyboardEvent) {
// Don't intercept while the user is typing into a text field
const tag = (document.activeElement?.tagName || '').toLowerCase()
if (tag === 'input' || tag === 'textarea' || tag === 'select') return
if ((document.activeElement as HTMLElement | null)?.isContentEditable) return
const map = buildBindingMap(overridesRef.current)
const id = map.get(eventToBinding(e))
if (!id) return
const sc = findShortcut(id)
if (!sc) return
const player = playerRef.current
if (!player) return
e.preventDefault()
sc.handler(player, ctxRef.current)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// overrides ref is read inside, no need to depend on the value here
}, [playerRef])
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { jellyfinClient, getMediaInfoApi } from '../api/jellyfin'
import { browserDeviceProfile } from '../lib/device-profile'
import type { BaseItemDto } from '../api/types'
/**
* Pre-fire Jellyfin's PlaybackInfo handshake (and a tiny range request
* against the resolved stream URL) when the user has been hovering a
* card long enough that they're likely to play it. The handshake is
* the longest sync part of opening playback, so cache-warming it makes
* the click→playing transition feel near-instant.
*
* - Skips synthetic TMDB-only items (no real Jellyfin id).
* - Idempotent per item: React Query dedups, so multiple armed cards
* pointing at the same id share one request.
* - Best-effort: any error is swallowed silently; the player will
* re-issue PlaybackInfo as it normally does if the cache is missing.
*/
export function usePrebuffer(item: BaseItemDto | null | undefined, armed: boolean) {
const qc = useQueryClient()
useEffect(() => {
if (!armed) return
if (!item || !item.Id || String(item.Id).startsWith('tmdb-')) return
// Episodes / movies are the only item kinds that go through PlaybackInfo
// for streaming; series / seasons would need a child resolution first.
if (item.Type !== 'Movie' && item.Type !== 'Episode') return
const itemId = item.Id
let cancelled = false
const key = ['jellyfin', 'playback-info', itemId, undefined, undefined, undefined]
qc.fetchQuery({
queryKey: key,
queryFn: async () => {
const api = jellyfinClient.getApi()
if (!api) return null
const res = await getMediaInfoApi(api).getPostedPlaybackInfo({
itemId,
playbackInfoDto: {
UserId: jellyfinClient.getAuthState()!.userId,
MaxStreamingBitrate: 140_000_000,
DeviceProfile: browserDeviceProfile() as any,
AutoOpenLiveStream: true,
EnableDirectPlay: true,
EnableDirectStream: true,
EnableTranscoding: true,
AllowVideoStreamCopy: true,
AllowAudioStreamCopy: true,
},
} as any)
return res.data
},
staleTime: 60_000,
}).then(playbackInfo => {
if (cancelled || !playbackInfo) return
const auth = jellyfinClient.getAuthState()
if (!auth) return
const source: any = (playbackInfo as any).MediaSources?.[0]
if (!source) return
// Build the stream URL the way PlayerPage does and request the
// first kilobyte. For direct-play this primes the HTTP cache; for
// HLS transcodes this triggers Jellyfin to start the transcode
// segment 0 ahead of time.
const url = source.SupportsDirectPlay
? `${auth.serverUrl}/Videos/${itemId}/stream?static=true&MediaSourceId=${source.Id}&api_key=${auth.token}`
: source.TranscodingUrl
? `${auth.serverUrl}${source.TranscodingUrl}`
: null
if (!url) return
fetch(url, {
method: 'GET',
headers: { Range: 'bytes=0-32767' },
}).catch(() => { /* warm-only; ignore */ })
}).catch(() => { /* warm-only; ignore */ })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [armed, item?.Id])
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
import { usePreferencesStore } from '../stores/preferences-store'
/**
* Returns true when motion should be reduced. Combines:
* - The user's `reduceMotion` app preference
* - The OS-level `prefers-reduced-motion: reduce` media query
*
* Either source can opt the user in. Components can use the result to
* skip large entry/exit animations or replace springs with instant
* transitions.
*/
export function useReducedMotion(): boolean {
const userPref = usePreferencesStore(s => s.reduceMotion)
const [osPref, setOsPref] = useState(false)
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setOsPref(mq.matches)
const listener = (e: MediaQueryListEvent) => setOsPref(e.matches)
if (mq.addEventListener) mq.addEventListener('change', listener)
else mq.addListener(listener)
return () => {
if (mq.removeEventListener) mq.removeEventListener('change', listener)
else mq.removeListener(listener)
}
}, [])
return userPref || osPref
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect } from 'react'
import { useSyncPlay } from '../stores/syncplay-store'
import { startSyncPlaySocket, stopSyncPlaySocket, subscribeSyncPlay } from '../lib/syncplay-socket'
/**
* Open the SyncPlay WebSocket only while the user is in a group. Idle
* players don't need a connection - so we gate the socket on the active
* group state and tear it down on leave.
*
* Two message families flow through:
* - SyncPlayCommand: Pause / Unpause / Seek - dispatched as remote
* commands so PlayerPage can mirror them onto the local video.
* - SyncPlayGroupUpdate: state changes, participant joins/leaves,
* queue updates. PlayQueue + GroupJoined carry the current item +
* position so a late joiner can catch up; UserJoined / UserLeft
* update the member count badge.
*/
export function useSyncPlaySocketBridge() {
const applyRemote = useSyncPlay(s => s.applyRemote)
const setRemoteQueueItem = useSyncPlay(s => s.setRemoteQueueItem)
const setMemberCount = useSyncPlay(s => s.setMemberCount)
const setActive = useSyncPlay(s => s.setActive)
const active = useSyncPlay(s => s.active)
useEffect(() => {
if (!active) return
startSyncPlaySocket()
const unsub = subscribeSyncPlay(msg => {
if (msg.MessageType === 'SyncPlayCommand' && msg.Data) {
const data = msg.Data as { Command?: string; PositionTicks?: number }
switch (data.Command) {
case 'Pause':
applyRemote({ type: 'pause' })
break
case 'Unpause':
applyRemote({ type: 'play' })
break
case 'Seek':
if (typeof data.PositionTicks === 'number') {
applyRemote({ type: 'seek', positionTicks: data.PositionTicks })
}
break
}
return
}
if (msg.MessageType === 'SyncPlayGroupUpdate' && msg.Data) {
const data = msg.Data as {
Type?: string
Data?: any
}
switch (data.Type) {
case 'PlayQueue':
case 'GroupJoined': {
// The GroupJoined payload is a GroupInfoDto - it doesn't
// include the queue, so position sync comes from a
// PlayQueue update that follows. PlayQueue carries
// Playlist + PlayingItemIndex + StartPositionTicks.
const queue = data.Data as {
Playlist?: Array<{ ItemId?: string }>
PlayingItemIndex?: number
StartPositionTicks?: number
IsPlaying?: boolean
} | undefined
if (!queue?.Playlist) return
const idx = typeof queue.PlayingItemIndex === 'number' ? queue.PlayingItemIndex : 0
const item = queue.Playlist[idx]
if (!item?.ItemId) return
setRemoteQueueItem({
itemId: item.ItemId,
positionTicks: queue.StartPositionTicks || 0,
isPlaying: queue.IsPlaying !== false,
})
break
}
case 'UserJoined':
case 'UserLeft': {
// The participant list comes in the payload as the group
// info; bump the count from its length if present.
const info = data.Data as { Participants?: string[] } | undefined
if (info?.Participants) setMemberCount(info.Participants.length)
break
}
case 'GroupLeft':
case 'NotInGroup':
case 'GroupDoesNotExist':
// Server says we're no longer in a group - clear local
// state so the player stops mirroring commands.
setActive(null)
break
}
}
})
return () => {
unsub()
stopSyncPlaySocket()
}
}, [active, applyRemote, setRemoteQueueItem, setMemberCount, setActive])
}
+61
View File
@@ -0,0 +1,61 @@
import { useTmdbMovie, useTmdbTvShow, useTmdbCollection } from './use-tmdb'
import { useWikidataAwards, useWikidataLocations, useWikiResolve, useWikiSection } from './use-external'
import type { TmdbMovie, TmdbTvShow } from '../api/tmdb'
/**
* Merged shape that lets call sites access both movie and TV fields without
* rediscriminating the union at every read. The actual loaded data is one
* of TmdbMovie or TmdbTvShow at runtime - this Partial intersection is the
* lowest-friction read shape.
*/
export type TmdbDetailData = Partial<TmdbMovie> & Partial<TmdbTvShow>
interface Args {
kind: 'movie' | 'tv' | null
tmdbId: number | string | null | undefined
}
/**
* Bundles the TMDB-side enrichment used by both DetailPage and
* TmdbDetailPage: the primary movie/tv record, the collection (if the
* movie is part of one), the Wikidata awards + filming locations, and the
* Wikipedia "Production" section. Each consumer can read what it needs;
* unused fields stay idle (React Query gates on `enabled` per hook so
* they're free when not consumed).
*/
export function useTmdbDetailEnrichment({ kind, tmdbId }: Args) {
const numericId = tmdbId != null ? Number(tmdbId) : null
const validId = numericId != null && Number.isFinite(numericId) ? numericId : null
const movie = useTmdbMovie(kind === 'movie' ? validId : null)
const tv = useTmdbTvShow(kind === 'tv' ? validId : null)
const data = (kind === 'movie' ? movie.data : tv.data) as TmdbDetailData | undefined
const isLoading = kind === 'movie' ? movie.isLoading : tv.isLoading
const collectionId = movie.data?.belongs_to_collection?.id
const collection = useTmdbCollection(collectionId)
const wikidataId = data?.external_ids?.wikidata_id || null
const awards = useWikidataAwards(wikidataId)
const locations = useWikidataLocations(wikidataId)
const titleForWiki = data?.title || data?.name
const yearForWiki = (data?.release_date || data?.first_air_date || '').slice(0, 4)
const wikiTitle = useWikiResolve(
titleForWiki && yearForWiki ? `${titleForWiki} ${yearForWiki}` : titleForWiki,
!!titleForWiki,
)
const wikiProduction = useWikiSection(wikiTitle.data?.title || null, 'Production')
return {
movie,
tv,
data,
isLoading,
collection,
awards,
locations,
wikiTitle,
wikiProduction,
}
}
+216
View File
@@ -0,0 +1,216 @@
import { useQueries, useQuery } from '@tanstack/react-query'
import {
getMovieFull,
getTvShowFull,
getSeasonFull,
getEpisodeFull,
getEpisodeGroup,
getPersonFull,
getCollection,
getTrending,
searchMulti,
discoverMovies,
discoverTv,
getUpcomingMovies,
getTopRatedMovies,
getTopRatedTv,
} from '../api/tmdb'
const STALE = 24 * 60 * 60 * 1000
export function useTmdbMovie(tmdbId?: string | number | null) {
const id = tmdbId ? Number(tmdbId) : undefined
return useQuery({
queryKey: ['tmdb', 'movie-full', id],
queryFn: async () => {
if (!id) return null
return getMovieFull(id)
},
enabled: !!id,
staleTime: STALE,
})
}
export function useTmdbTvShow(tmdbId?: string | number | null) {
const id = tmdbId ? Number(tmdbId) : undefined
return useQuery({
queryKey: ['tmdb', 'tv-full', id],
queryFn: async () => {
if (!id) return null
return getTvShowFull(id)
},
enabled: !!id,
staleTime: STALE,
})
}
export function useTmdbSeason(tvId?: string | number | null, seasonNum?: number) {
const id = tvId ? Number(tvId) : undefined
return useQuery({
queryKey: ['tmdb', 'season-full', id, seasonNum],
queryFn: async () => {
if (!id || seasonNum === undefined) return null
return getSeasonFull(id, seasonNum)
},
enabled: !!id && seasonNum !== undefined,
staleTime: STALE,
})
}
export function useTmdbEpisode(
tvId?: string | number | null,
seasonNum?: number,
episodeNum?: number,
) {
const id = tvId ? Number(tvId) : undefined
return useQuery({
queryKey: ['tmdb', 'episode-full', id, seasonNum, episodeNum],
queryFn: async () => {
if (!id || seasonNum === undefined || episodeNum === undefined) return null
return getEpisodeFull(id, seasonNum, episodeNum)
},
enabled: !!id && seasonNum !== undefined && episodeNum !== undefined,
staleTime: STALE,
})
}
/**
* Fetch a TMDB episode-group's ordered list. Used by the episode order
* toggle to reorder the visible episode list to match alternate
* orderings (production, story, DVD).
*/
export function useTmdbEpisodeGroup(groupId?: string | null) {
return useQuery({
queryKey: ['tmdb', 'episode-group', groupId],
queryFn: async () => {
if (!groupId) return null
return getEpisodeGroup(groupId)
},
enabled: !!groupId,
staleTime: STALE,
})
}
/**
* Batch-fetch TMDB episode-full data for many episode numbers in a single
* season. Backed by `useQueries` so React Query handles concurrency,
* caching, and dedup. Returns a Map keyed by episode number for O(1)
* lookup at render time.
*/
export function useTmdbEpisodes(
tvId?: string | number | null,
seasonNum?: number,
episodeNumbers: number[] = [],
) {
const id = tvId ? Number(tvId) : undefined
const numbers = id != null && seasonNum !== undefined ? episodeNumbers : []
const queries = useQueries({
queries: numbers.map(n => ({
queryKey: ['tmdb', 'episode-full', id, seasonNum, n],
queryFn: async () => {
if (!id || seasonNum === undefined) return null
return getEpisodeFull(id, seasonNum, n)
},
enabled: !!id && seasonNum !== undefined,
staleTime: STALE,
})),
})
const map = new Map<number, NonNullable<Awaited<ReturnType<typeof getEpisodeFull>>>>()
numbers.forEach((n, i) => {
const data = queries[i]?.data
if (data) map.set(n, data)
})
return { map, isLoading: queries.some(q => q.isLoading) }
}
export function useTmdbPerson(personId?: string | number | null) {
const id = personId ? Number(personId) : undefined
return useQuery({
queryKey: ['tmdb', 'person-full', id],
queryFn: async () => {
if (!id) return null
return getPersonFull(id)
},
enabled: !!id,
staleTime: STALE,
})
}
export function useTmdbCollection(collectionId?: string | number | null) {
const id = collectionId ? Number(collectionId) : undefined
return useQuery({
queryKey: ['tmdb', 'collection', id],
queryFn: async () => {
if (!id) return null
return getCollection(id)
},
enabled: !!id,
staleTime: STALE,
})
}
export function useTmdbTrending(
mediaType: 'all' | 'movie' | 'tv' | 'person' = 'all',
window: 'day' | 'week' = 'week',
) {
return useQuery({
queryKey: ['tmdb', 'trending', mediaType, window],
queryFn: () => getTrending(mediaType, window),
staleTime: 60 * 60 * 1000,
})
}
export function useTmdbSearch(query: string) {
return useQuery({
queryKey: ['tmdb', 'search', query],
queryFn: () => searchMulti(query),
enabled: query.length > 2,
staleTime: 60 * 60 * 1000,
})
}
export function useTmdbDiscoverMovies(params: Record<string, string>) {
const key = JSON.stringify(params)
const hasParams = Object.keys(params).length > 0
return useQuery({
queryKey: ['tmdb', 'discover-movie', key],
queryFn: () => discoverMovies(params),
enabled: hasParams,
staleTime: STALE,
})
}
export function useTmdbDiscoverTv(params: Record<string, string>) {
const key = JSON.stringify(params)
const hasParams = Object.keys(params).length > 0
return useQuery({
queryKey: ['tmdb', 'discover-tv', key],
queryFn: () => discoverTv(params),
enabled: hasParams,
staleTime: STALE,
})
}
export function useTmdbUpcoming(region?: string) {
return useQuery({
queryKey: ['tmdb', 'upcoming', region],
queryFn: () => getUpcomingMovies(region),
staleTime: 12 * 60 * 60 * 1000,
})
}
export function useTmdbTopRatedMovies() {
return useQuery({
queryKey: ['tmdb', 'top-rated', 'movie'],
queryFn: () => getTopRatedMovies(),
staleTime: STALE,
})
}
export function useTmdbTopRatedTv() {
return useQuery({
queryKey: ['tmdb', 'top-rated', 'tv'],
queryFn: () => getTopRatedTv(),
staleTime: STALE,
})
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react'
import type { BaseItemDto } from '../api/types'
import { useTrakt } from '../stores/trakt-store'
import { scrobbleStart, scrobblePause, scrobbleStop } from '../lib/trakt'
interface Args {
item: BaseItemDto | null | undefined
isPaused: boolean
currentTime: number
duration: number
}
/**
* Fire Trakt scrobble calls in response to local playback transitions.
*
* - start on first play
* - pause on pause
* - resume sends another start
* - stop on unmount (covers navigating away mid-playback)
*
* Progress + duration are tracked through refs so the stop-on-unmount
* effect (which only re-binds when the item changes) sees the latest
* values rather than the ones captured at mount. Scrobble transitions
* key off `isPaused` only - they shouldn't fire on every time-update tick.
*/
export function useTraktScrobble({ item, isPaused, currentTime, duration }: Args) {
const tokens = useTrakt(s => s.tokens)
const enabled = useTrakt(s => s.enabled)
const lastState = useRef<'idle' | 'playing' | 'paused'>('idle')
const lastItemId = useRef<string | null>(null)
const progressRef = useRef({ currentTime: 0, duration: 0 })
// Keep progress in a ref so cleanup callbacks see the latest value.
progressRef.current = { currentTime, duration }
// Reset when item changes
useEffect(() => {
if (item?.Id && item.Id !== lastItemId.current) {
lastState.current = 'idle'
lastItemId.current = item.Id
}
}, [item?.Id])
// State transitions. Only depends on isPaused - currentTime updates
// would otherwise re-run this effect dozens of times a second.
useEffect(() => {
if (!enabled || !tokens || !item) return
const { currentTime: t, duration: d } = progressRef.current
const pct = d > 0 ? (t / d) * 100 : 0
if (isPaused) {
if (lastState.current === 'playing') {
lastState.current = 'paused'
scrobblePause(item, pct).catch(() => {})
}
} else {
if (lastState.current !== 'playing') {
lastState.current = 'playing'
scrobbleStart(item, pct).catch(() => {})
}
}
}, [enabled, tokens, item, isPaused])
// Stop scrobble on unmount or item swap. Reads progress from the ref
// so the percentage reflects where the user actually stopped.
useEffect(() => {
const captured = item
return () => {
if (!useTrakt.getState().enabled) return
if (!useTrakt.getState().tokens) return
if (!captured) return
if (lastState.current === 'idle') return
const { currentTime: t, duration: d } = progressRef.current
const pct = d > 0 ? (t / d) * 100 : 0
scrobbleStop(captured, pct).catch(() => {})
lastState.current = 'idle'
}
}, [item])
}
+69
View File
@@ -0,0 +1,69 @@
import { useMemo } from 'react'
import { useNamedPlaylist, usePlaylistAdd, usePlaylistItems, usePlaylistRemove } from './use-jellyfin'
import { getPlaylistItemId } from '../lib/item-types'
const WATCHLIST_NAME = 'Watchlist'
/**
* Backed by a user-private Jellyfin playlist named "Watchlist". Created
* lazily the first time the user adds something. Server-side state
* means the watchlist follows the user across devices.
*
* Returns the playlist id, the item-id set for fast `isInWatchlist`
* checks, and toggle / add / remove mutations.
*/
export function useWatchlist() {
const playlist = useNamedPlaylist(WATCHLIST_NAME)
const playlistId = playlist.data?.Id || undefined
const items = usePlaylistItems(playlistId)
const add = usePlaylistAdd(playlistId)
const remove = usePlaylistRemove(playlistId)
const idSet = useMemo(() => {
const s = new Set<string>()
for (const it of items.data || []) {
if (it.Id) s.add(it.Id)
}
return s
}, [items.data])
// Map item-id -> PlaylistItemId so we can call removeItemFromPlaylist
// (which needs the entry id, not the item id).
const entryByItemId = useMemo(() => {
const m = new Map<string, string>()
for (const it of items.data || []) {
const itemId = it.Id
const entryId = getPlaylistItemId(it)
if (itemId && entryId) m.set(itemId, entryId)
}
return m
}, [items.data])
function isInWatchlist(itemId: string | null | undefined) {
return !!itemId && idSet.has(itemId)
}
async function toggle(itemId: string | null | undefined) {
if (!itemId) return
if (idSet.has(itemId)) {
const entry = entryByItemId.get(itemId)
if (entry) await remove.mutateAsync([entry])
} else {
await add.mutateAsync([itemId])
}
}
return {
playlistId,
items: items.data || [],
idSet,
isInWatchlist,
toggle,
addToWatchlist: (id: string) => add.mutateAsync([id]),
removeFromWatchlist: (id: string) => {
const entry = entryByItemId.get(id)
if (entry) return remove.mutateAsync([entry])
},
isLoading: playlist.isLoading || items.isLoading,
}
}