hooks for jellyfin data, playback, tmdb, player chrome
This commit is contained in:
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user