zustand stores

This commit is contained in:
2026-03-27 03:32:48 +02:00
parent df17c7ab95
commit b0c2e48224
20 changed files with 1279 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { sensitiveStorage } from '../lib/sensitive-storage'
/**
* Connection details for a Sonarr / Radarr instance. The user can have
* up to two of each: a "default" tier (1080p, regular library) and a
* "4K" tier (separate root folder + quality profile). Many users keep
* 4K and 1080p in separate libraries to avoid Jellyfin transcoding 4K
* down to 1080p on weak clients.
*/
export interface ArrInstance {
id: string
kind: 'sonarr' | 'radarr'
/** Display name shown in the request modal and Settings. */
name: string
baseUrl: string
apiKey: string
/** Distinguishes the regular tier from the 4K tier of the same kind. */
tier: 'default' | '4k'
/** Defaults the request modal pre-selects unless an override rule
* fires or the user picks something else. */
defaultQualityProfileId?: number
defaultRootFolder?: string
defaultLanguageProfileId?: number
/** Sonarr-only: monitor flag + season-folder layout the requester gets. */
defaultMonitored?: boolean
defaultSeasonFolder?: boolean
/** Optional connection-test verdict, refreshed when settings page tests it. */
lastTestedAt?: string
lastTestVersion?: string
lastTestError?: string
}
interface State {
instances: ArrInstance[]
upsert: (instance: ArrInstance) => void
remove: (id: string) => void
/** Convenience: find the instance matching kind + tier, or null. */
pick: (kind: 'sonarr' | 'radarr', tier: 'default' | '4k') => ArrInstance | null
}
function uid() {
return 'arr_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
}
export const useArrInstances = create<State>()(
persist(
(set, get) => ({
instances: [],
upsert: instance =>
set(s => {
const exists = s.instances.some(x => x.id === instance.id)
if (exists) {
return {
instances: s.instances.map(x => (x.id === instance.id ? instance : x)),
}
}
return { instances: [...s.instances, instance] }
}),
remove: id => set(s => ({ instances: s.instances.filter(x => x.id !== id) })),
pick: (kind, tier) =>
get().instances.find(x => x.kind === kind && x.tier === tier) || null,
}),
{ name: 'arr-instances-v1', storage: createJSONStorage(() => sensitiveStorage) },
),
)
export function newArrInstance(kind: 'sonarr' | 'radarr', tier: 'default' | '4k'): ArrInstance {
return {
id: uid(),
kind,
tier,
name: `${kind === 'sonarr' ? 'Sonarr' : 'Radarr'}${tier === '4k' ? ' 4K' : ''}`,
baseUrl: '',
apiKey: '',
defaultMonitored: true,
defaultSeasonFolder: true,
}
}
+76
View File
@@ -0,0 +1,76 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Letterboxd-flavoured watch diary. Each entry is a dated record of a
* single watch session with optional rating + note + emoji. Entries
* are append-only; older ones aren't mutated when newer ones arrive,
* so a "watched it again" entry sits beside the original without
* overwriting context.
*
* Storage shape: a single `entries` array, sorted newest-first when
* read via the convenience selectors.
*/
export interface DiaryEntry {
id: string
itemId: string
itemName: string
/** ISO timestamp when the watch happened (defaults to now on creation). */
watchedAt: string
/** Optional 1-10 rating attached to this specific watch. */
rating?: number
/** Free-text note. */
note?: string
/** Single emoji glyph. We don't validate; the picker constrains it. */
emoji?: string
/** Whether this watch was logged as a rewatch. Mirrors personal-data
* but kept here too so diary stats can sum cleanly without joining. */
rewatch?: boolean
}
interface State {
entries: DiaryEntry[]
add: (entry: Omit<DiaryEntry, 'id' | 'watchedAt'> & { watchedAt?: string }) => DiaryEntry
update: (id: string, patch: Partial<Omit<DiaryEntry, 'id'>>) => void
remove: (id: string) => void
/** Convenience: chronological for an item, newest-first. */
byItem: (itemId: string) => DiaryEntry[]
}
function uid() {
return 'd_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
}
export const useDiary = create<State>()(
persist(
(set, get) => ({
entries: [],
add: input => {
const entry: DiaryEntry = {
id: uid(),
watchedAt: input.watchedAt || new Date().toISOString(),
itemId: input.itemId,
itemName: input.itemName,
rating: input.rating,
note: input.note,
emoji: input.emoji,
rewatch: input.rewatch,
}
set(s => ({ entries: [...s.entries, entry] }))
return entry
},
update: (id, patch) =>
set(s => ({
entries: s.entries.map(e => (e.id === id ? { ...e, ...patch } : e)),
})),
remove: id =>
set(s => ({ entries: s.entries.filter(e => e.id !== id) })),
byItem: itemId =>
get().entries
.filter(e => e.itemId === itemId)
.sort((a, b) => b.watchedAt.localeCompare(a.watchedAt)),
}),
{ name: 'diary-v1' },
),
)
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type DownloadStatus = 'queued' | 'downloading' | 'done' | 'error'
export interface DownloadItem {
id: string
itemId: string
name: string
posterUrl?: string | null
streamUrl: string
subtitleUrl?: string | null
status: DownloadStatus
progress: number
sizeBytes?: number
localPath?: string
error?: string
createdAt: string
}
interface State {
items: DownloadItem[]
add: (item: Omit<DownloadItem, 'id' | 'status' | 'progress' | 'createdAt'>) => void
update: (id: string, patch: Partial<DownloadItem>) => void
remove: (id: string) => void
clearCompleted: () => void
getByItemId: (itemId: string) => DownloadItem | undefined
}
function uid() {
return 'dl_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
}
export const useDownloads = create<State>()(
persist(
(set, get) => ({
items: [],
add: item =>
set(s => {
if (s.items.some(i => i.itemId === item.itemId && i.status !== 'error')) return s
const next: DownloadItem = {
...item,
id: uid(),
status: 'queued',
progress: 0,
createdAt: new Date().toISOString(),
}
return { items: [...s.items, next] }
}),
update: (id, patch) =>
set(s => ({
items: s.items.map(i => (i.id === id ? { ...i, ...patch } : i)),
})),
remove: id =>
set(s => ({
items: s.items.filter(i => i.id !== id),
})),
clearCompleted: () =>
set(s => ({
items: s.items.filter(i => i.status !== 'done'),
})),
getByItemId: itemId => get().items.find(i => i.itemId === itemId),
}),
{ name: 'downloads-v1' },
),
)
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface SavedLetterboxdList {
/** Canonical URL: `https://letterboxd.com/{user}/list/{slug}/` */
url: string
addedAt: string
/** Optional override - the user can rename a list locally. */
customTitle?: string
}
interface State {
lists: SavedLetterboxdList[]
add: (url: string) => void
remove: (url: string) => void
rename: (url: string, customTitle: string | undefined) => void
}
export const useLetterboxdLists = create<State>()(
persist(
set => ({
lists: [],
add: url =>
set(s => {
if (s.lists.some(l => l.url === url)) return s
return {
lists: [...s.lists, { url, addedAt: new Date().toISOString() }],
}
}),
remove: url =>
set(s => ({ lists: s.lists.filter(l => l.url !== url) })),
rename: (url, customTitle) =>
set(s => ({
lists: s.lists.map(l => (l.url === url ? { ...l, customTitle } : l)),
})),
}),
{ name: 'letterboxd-lists-v1' },
),
)
+44
View File
@@ -0,0 +1,44 @@
import { create } from 'zustand'
/**
* Multi-select state for the library grid. Lives outside React Query so
* the selection can survive remounts / pagination scrolls but is reset
* when the user explicitly clears it (Esc, click outside, batch action).
*
* Page state is intentionally *not* persisted - selection is ephemeral.
*/
interface State {
selected: Set<string>
toggle: (itemId: string) => void
add: (itemId: string) => void
remove: (itemId: string) => void
setMany: (itemIds: string[]) => void
clear: () => void
}
export const useLibrarySelection = create<State>(set => ({
selected: new Set(),
toggle: itemId =>
set(s => {
const next = new Set(s.selected)
if (next.has(itemId)) next.delete(itemId)
else next.add(itemId)
return { selected: next }
}),
add: itemId =>
set(s => {
if (s.selected.has(itemId)) return s
const next = new Set(s.selected)
next.add(itemId)
return { selected: next }
}),
remove: itemId =>
set(s => {
if (!s.selected.has(itemId)) return s
const next = new Set(s.selected)
next.delete(itemId)
return { selected: next }
}),
setMany: itemIds => set({ selected: new Set(itemIds) }),
clear: () => set({ selected: new Set() }),
}))
+144
View File
@@ -0,0 +1,144 @@
import { create } from 'zustand'
import type { BaseItemDto } from '../api/types'
/**
* Music playback queue + transport state. Drives the MiniPlayer and the
* NowPlaying expanded view. Separate from the video player (PlayerPage),
* which has its own runtime store for session-only state like A-B loops
* and subtitle offsets.
*/
interface MusicState {
currentTrack: BaseItemDto | null
isPlaying: boolean
currentTime: number
duration: number
volume: number
isMuted: boolean
queue: BaseItemDto[]
queueIndex: number
shuffle: boolean
repeat: 'off' | 'all' | 'one'
playTrack: (track: BaseItemDto, queue?: BaseItemDto[]) => void
pause: () => void
resume: () => void
stop: () => void
seekTo: (time: number) => void
setVolume: (vol: number) => void
toggleMute: () => void
nextTrack: () => void
prevTrack: () => void
toggleShuffle: () => void
cycleRepeat: () => void
addToQueue: (items: BaseItemDto | BaseItemDto[]) => void
removeFromQueue: (index: number) => void
clearQueue: () => void
setCurrentTime: (time: number) => void
setDuration: (duration: number) => void
setPlaying: (playing: boolean) => void
}
function shuffleArray<T>(arr: T[]): T[] {
const shuffled = [...arr]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
export const useMusicStore = create<MusicState>((set, get) => ({
currentTrack: null,
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
isMuted: false,
queue: [],
queueIndex: -1,
shuffle: false,
repeat: 'off',
playTrack: (track, queue) => {
const state = get()
const newQueue = queue || [track]
const idx = newQueue.findIndex(t => t.Id === track.Id)
set({
currentTrack: track,
isPlaying: true,
queue: state.shuffle ? shuffleArray(newQueue) : newQueue,
queueIndex: state.shuffle ? 0 : Math.max(0, idx),
currentTime: 0,
duration: 0,
})
},
pause: () => set({ isPlaying: false }),
resume: () => set({ isPlaying: true }),
stop: () => set({ currentTrack: null, isPlaying: false, currentTime: 0 }),
seekTo: (time) => set({ currentTime: time }),
setVolume: (vol) => set({ volume: Math.max(0, Math.min(1, vol)) }),
toggleMute: () => set(s => ({ isMuted: !s.isMuted })),
nextTrack: () => {
const { queue, queueIndex, repeat } = get()
if (queue.length === 0) return
let next = queueIndex + 1
if (next >= queue.length) {
if (repeat === 'all') next = 0
else if (repeat === 'one') next = queueIndex
else { set({ isPlaying: false }); return }
}
set({ queueIndex: next, currentTrack: queue[next], currentTime: 0, isPlaying: true })
},
prevTrack: () => {
const { queue, queueIndex, currentTime } = get()
if (queue.length === 0) return
if (currentTime > 3) {
set({ currentTime: 0 })
return
}
const prev = Math.max(0, queueIndex - 1)
set({ queueIndex: prev, currentTrack: queue[prev], currentTime: 0, isPlaying: true })
},
toggleShuffle: () => {
const { shuffle, queue, currentTrack } = get()
if (!shuffle) {
const shuffled = shuffleArray(queue)
if (currentTrack) {
const idx = shuffled.findIndex(t => t.Id === currentTrack.Id)
if (idx > 0) {
;[shuffled[0], shuffled[idx]] = [shuffled[idx], shuffled[0]]
}
}
set({ shuffle: true, queue: shuffled, queueIndex: 0 })
} else {
const current = currentTrack
const original = [...queue].sort((a, b) =>
(a.SortName || a.Name || '').localeCompare(b.SortName || b.Name || '')
)
const idx = current ? original.findIndex(t => t.Id === current.Id) : 0
set({ shuffle: false, queue: original, queueIndex: Math.max(0, idx) })
}
},
cycleRepeat: () => set(s => ({
repeat: s.repeat === 'off' ? 'all' : s.repeat === 'all' ? 'one' : 'off'
})),
addToQueue: (items) => set(s => ({
queue: [...s.queue, ...(Array.isArray(items) ? items : [items])]
})),
removeFromQueue: (index) => set(s => ({
queue: s.queue.filter((_, i) => i !== index)
})),
clearQueue: () => set({ queue: [], queueIndex: -1 }),
setCurrentTime: (time) => set({ currentTime: time }),
setDuration: (duration) => set({ duration }),
setPlaying: (playing) => set({ isPlaying: playing }),
}))
+136
View File
@@ -0,0 +1,136 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Override rules let the user automate request routing. Example: if
* the genre includes "Anime", route to the Anime tier of Sonarr with a
* specific quality profile and root folder.
*
* Rules are evaluated at request time in declaration order; the first
* one whose conditions match wins. Anything unset in the matched rule
* falls back to the picked instance's defaults, so a rule can override
* a single field without specifying everything.
*/
export interface OverrideRule {
id: string
name: string
/** Restrict to which kind of media this rule applies to. */
kind: 'movie' | 'tv' | 'any'
/** Condition: any of these genre names triggers (case-insensitive).
* Empty array means "any genre". */
genres: string[]
/** Condition: original-language ISO codes (e.g. "ja", "ko"). Empty = any. */
languages: string[]
/** Condition: year range (inclusive). null bound = open-ended. */
yearMin?: number
yearMax?: number
/** Condition: TMDB keyword ids. Empty = any. */
keywordIds: number[]
/** Action: pick this tier (regular vs 4K). */
tier?: 'default' | '4k'
/** Action: override quality profile id within the picked instance. */
qualityProfileId?: number
/** Action: override root folder path. */
rootFolderPath?: string
/** Action: override Sonarr language profile id. */
languageProfileId?: number
/** Action: append tag ids to the request. */
tagIds?: number[]
enabled: boolean
createdAt: string
}
interface State {
rules: OverrideRule[]
add: (rule: Omit<OverrideRule, 'id' | 'createdAt'>) => OverrideRule
update: (id: string, patch: Partial<OverrideRule>) => void
remove: (id: string) => void
reorder: (id: string, direction: -1 | 1) => void
}
function uid() {
return 'rule_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
}
export const useOverrideRules = create<State>()(
persist(
set => ({
rules: [],
add: input => {
const next: OverrideRule = {
...input,
id: uid(),
createdAt: new Date().toISOString(),
}
set(s => ({ rules: [...s.rules, next] }))
return next
},
update: (id, patch) =>
set(s => ({
rules: s.rules.map(r => (r.id === id ? { ...r, ...patch } : r)),
})),
remove: id => set(s => ({ rules: s.rules.filter(r => r.id !== id) })),
reorder: (id, direction) =>
set(s => {
const idx = s.rules.findIndex(r => r.id === id)
const target = idx + direction
if (idx < 0 || target < 0 || target >= s.rules.length) return s
const next = [...s.rules]
const [item] = next.splice(idx, 1)
next.splice(target, 0, item)
return { rules: next }
}),
}),
{ name: 'override-rules-v1' },
),
)
export interface OverrideContext {
kind: 'movie' | 'tv'
genres: string[]
originalLanguage: string | null
year: number | null
keywordIds: number[]
}
/**
* Evaluate active rules against an item and return the resulting
* override patch (or null when nothing matches). The first matching
* enabled rule wins.
*/
export function applyOverrideRules(
rules: OverrideRule[],
ctx: OverrideContext,
): Partial<OverrideRule> | null {
for (const rule of rules) {
if (!rule.enabled) continue
if (rule.kind !== 'any' && rule.kind !== ctx.kind) continue
if (rule.genres.length > 0) {
const want = new Set(rule.genres.map(g => g.toLowerCase()))
const have = new Set((ctx.genres || []).map(g => g.toLowerCase()))
const overlaps = [...want].some(g => have.has(g))
if (!overlaps) continue
}
if (rule.languages.length > 0) {
const want = new Set(rule.languages.map(l => l.toLowerCase()))
if (!ctx.originalLanguage || !want.has(ctx.originalLanguage.toLowerCase())) continue
}
if (rule.yearMin != null && (ctx.year == null || ctx.year < rule.yearMin)) continue
if (rule.yearMax != null && (ctx.year == null || ctx.year > rule.yearMax)) continue
if (rule.keywordIds.length > 0) {
const have = new Set(ctx.keywordIds || [])
if (!rule.keywordIds.some(k => have.has(k))) continue
}
return {
tier: rule.tier,
qualityProfileId: rule.qualityProfileId,
rootFolderPath: rule.rootFolderPath,
languageProfileId: rule.languageProfileId,
tagIds: rule.tagIds,
}
}
return null
}
+126
View File
@@ -0,0 +1,126 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Per-item personal annotations the user makes outside of Jellyfin's
* binary watched/favorite flags: a freeform note, a 1-10 rating, and a
* rewatch counter (with a flag for whether the most-recent watch was a
* first-time vs rewatch). Persisted locally - none of this data
* round-trips to the Jellyfin server today.
*
* Keys are Jellyfin item ids. We never write entries for synthetic
* `tmdb-...` ids since those don't have stable identity across user
* libraries; the UI guards against that at the call site.
*/
export interface PersonalEntry {
/** 0 = unrated; 1-10 otherwise. */
rating: number
note: string
/** Total times the user has played this item to completion or near-it. */
rewatchCount: number
/** Tracks whether the most-recent watch was logged as a rewatch. Used by
* the diary stats and surface UIs to avoid double-counting. */
lastWasRewatch: boolean
/** Updated every time we touch any field, for sort-by-recent. */
updatedAt: string
}
const EMPTY: PersonalEntry = {
rating: 0,
note: '',
rewatchCount: 0,
lastWasRewatch: false,
updatedAt: '',
}
interface State {
entries: Record<string, PersonalEntry>
setRating: (itemId: string, rating: number) => void
setNote: (itemId: string, note: string) => void
recordWatch: (itemId: string, asRewatch: boolean) => void
/** Increment rewatch count silently - used by the auto-tracker when an
* already-watched item starts playback again. */
incrementRewatch: (itemId: string) => void
clear: (itemId: string) => void
}
function ensure(entries: Record<string, PersonalEntry>, id: string): PersonalEntry {
return entries[id] || { ...EMPTY }
}
export const usePersonalData = create<State>()(
persist(
(set, get) => ({
entries: {},
setRating: (itemId, rating) =>
set(s => ({
entries: {
...s.entries,
[itemId]: {
...ensure(s.entries, itemId),
rating: Math.max(0, Math.min(10, Math.round(rating))),
updatedAt: new Date().toISOString(),
},
},
})),
setNote: (itemId, note) =>
set(s => ({
entries: {
...s.entries,
[itemId]: {
...ensure(s.entries, itemId),
note,
updatedAt: new Date().toISOString(),
},
},
})),
recordWatch: (itemId, asRewatch) =>
set(s => {
const cur = ensure(s.entries, itemId)
return {
entries: {
...s.entries,
[itemId]: {
...cur,
rewatchCount: asRewatch ? cur.rewatchCount + 1 : cur.rewatchCount,
lastWasRewatch: asRewatch,
updatedAt: new Date().toISOString(),
},
},
}
}),
incrementRewatch: itemId => {
const s = get()
const cur = ensure(s.entries, itemId)
// Per-mount dedup is the caller's responsibility (PlayerPage
// tracks the last item it incremented in a ref). The store
// unconditionally bumps so a user who closes and reopens the
// app to rewatch the same title actually sees the count rise.
set({
entries: {
...s.entries,
[itemId]: {
...cur,
rewatchCount: cur.rewatchCount + 1,
lastWasRewatch: true,
updatedAt: new Date().toISOString(),
},
},
})
},
clear: itemId =>
set(s => {
const next = { ...s.entries }
delete next[itemId]
return { entries: next }
}),
}),
{ name: 'personal-data-v1' },
),
)
export function getPersonalEntry(itemId: string | null | undefined): PersonalEntry {
if (!itemId) return EMPTY
return usePersonalData.getState().entries[itemId] || EMPTY
}
+94
View File
@@ -0,0 +1,94 @@
import { create } from 'zustand'
/**
* Per-session player state - things that should reset on a new playback
* session and shouldn't pollute the persisted preferences store.
*
* Examples:
* - A-B loop markers (per item)
* - Theater / zen mode toggle (transient)
* - Per-session subtitle / audio delay overrides on top of the persisted
* defaults from `usePreferencesStore`
* - Secondary subtitle index
* - Picture filters during this session (overrides persisted defaults)
*
* Anything you'd want to persist across episodes / app restarts belongs
* in `usePreferencesStore`, not here.
*/
export interface PlayerRuntimeState {
// A-B loop
loopA: number | null
loopB: number | null
// Subtitle + audio offsets in ms. These add to the persisted default
// from settings; combined effective offset = settings + runtime.
subtitleOffsetMs: number
audioOffsetMs: number
// Secondary subtitle stream index (Jellyfin stream Index, not array index)
secondarySubtitleIndex: number | null
// Theater / zen mode
theaterMode: boolean
// Picture filters - if a value is null we fall back to the persisted pref.
// Overrides for *this* session only.
brightnessOverride: number | null
contrastOverride: number | null
saturationOverride: number | null
// Auto-recap dismissal flag - true once the user has dismissed (or
// skipped) the recap card for the current item, so it doesn't reappear
// if playback restarts.
recapDismissed: boolean
setLoopA(t: number | null): void
setLoopB(t: number | null): void
clearLoop(): void
setSubtitleOffsetMs(ms: number): void
bumpSubtitleOffsetMs(deltaMs: number): void
setAudioOffsetMs(ms: number): void
bumpAudioOffsetMs(deltaMs: number): void
setSecondarySubtitleIndex(i: number | null): void
setTheaterMode(on: boolean): void
setBrightnessOverride(v: number | null): void
setContrastOverride(v: number | null): void
setSaturationOverride(v: number | null): void
setRecapDismissed(v: boolean): void
/** Reset everything except theaterMode (which is window-scoped, not item-scoped). */
resetForNewItem(): void
}
export const usePlayerRuntimeStore = create<PlayerRuntimeState>(set => ({
loopA: null,
loopB: null,
subtitleOffsetMs: 0,
audioOffsetMs: 0,
secondarySubtitleIndex: null,
theaterMode: false,
brightnessOverride: null,
contrastOverride: null,
saturationOverride: null,
recapDismissed: false,
setLoopA: t => set({ loopA: t }),
setLoopB: t => set({ loopB: t }),
clearLoop: () => set({ loopA: null, loopB: null }),
setSubtitleOffsetMs: ms => set({ subtitleOffsetMs: ms }),
bumpSubtitleOffsetMs: deltaMs => set(s => ({ subtitleOffsetMs: s.subtitleOffsetMs + deltaMs })),
setAudioOffsetMs: ms => set({ audioOffsetMs: ms }),
bumpAudioOffsetMs: deltaMs => set(s => ({ audioOffsetMs: s.audioOffsetMs + deltaMs })),
setSecondarySubtitleIndex: i => set({ secondarySubtitleIndex: i }),
setTheaterMode: on => set({ theaterMode: on }),
setBrightnessOverride: v => set({ brightnessOverride: v }),
setContrastOverride: v => set({ contrastOverride: v }),
setSaturationOverride: v => set({ saturationOverride: v }),
setRecapDismissed: v => set({ recapDismissed: v }),
resetForNewItem: () =>
set({
loopA: null,
loopB: null,
subtitleOffsetMs: 0,
audioOffsetMs: 0,
secondarySubtitleIndex: null,
brightnessOverride: null,
contrastOverride: null,
saturationOverride: null,
recapDismissed: false,
}),
}))
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import type {
AppSettings,
HomeShowSettings,
DetailShowSettings,
EpisodeShowSettings,
EpisodeBehaviorSettings,
EpisodeRecapSettings,
EndVideoShowSettings,
} from '../api/types'
import { defaultSettings, migrateV1Preferences, migrateV2Preferences } from '../api/types'
import { sensitiveStorage } from '../lib/sensitive-storage'
interface PreferencesState extends AppSettings {
setPreference: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => void
setHomeShow: <K extends keyof HomeShowSettings>(key: K, value: boolean) => void
setDetailShow: <K extends keyof DetailShowSettings>(key: K, value: boolean) => void
setEpisodeShow: <K extends keyof EpisodeShowSettings>(key: K, value: boolean) => void
setEpisodeBehavior: <K extends keyof EpisodeBehaviorSettings>(key: K, value: EpisodeBehaviorSettings[K]) => void
setEpisodeRecap: <K extends keyof EpisodeRecapSettings>(key: K, value: EpisodeRecapSettings[K]) => void
setEndVideoShow: <K extends keyof EndVideoShowSettings>(key: K, value: boolean) => void
resetToDefaults: () => void
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
set => ({
...defaultSettings,
setPreference: (key, value) => set({ [key]: value } as Partial<AppSettings>),
setHomeShow: (key, value) =>
set(s => ({ home: { show: { ...s.home.show, [key]: value } } })),
setDetailShow: (key, value) =>
set(s => ({ detail: { show: { ...s.detail.show, [key]: value } } })),
setEpisodeShow: (key, value) =>
set(s => ({ episode: { ...s.episode, show: { ...s.episode.show, [key]: value } } })),
setEpisodeBehavior: (key, value) =>
set(s => ({ episode: { ...s.episode, behavior: { ...s.episode.behavior, [key]: value } } })),
setEpisodeRecap: (key, value) =>
set(s => ({ episode: { ...s.episode, recap: { ...s.episode.recap, [key]: value } } })),
setEndVideoShow: (key, value) =>
set(s => ({ endVideo: { show: { ...s.endVideo.show, [key]: value } } })),
resetToDefaults: () => set({ ...defaultSettings }),
}),
{
name: 'jf_prefs',
version: 3,
storage: createJSONStorage(() => sensitiveStorage),
migrate: (persistedState, fromVersion) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as AppSettings
}
if (fromVersion < 2) {
return migrateV2Preferences(
migrateV1Preferences(persistedState as Record<string, unknown>) as unknown as Record<string, unknown>,
)
}
if (fromVersion < 3) {
return migrateV2Preferences(persistedState as Record<string, unknown>)
}
return persistedState as AppSettings
},
},
),
)
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand'
import type { BaseItemDto } from '../api/types'
/**
* Active playback queue. PlaylistView populates it when the user hits Play
* or Shuffle; PlayerPage reads it to drive the prev / next chrome buttons
* and to auto-advance on `ended`. Lives in memory only - we don't persist
* the queue across reloads.
*
* `items` is already in play-order (shuffled if applicable). `originalOrder`
* holds the un-shuffled list so toggling shuffle off mid-queue can restore
* the natural sequence without re-fetching.
*/
export interface QueueSource {
type: 'playlist'
id: string
}
export interface QueueState {
items: BaseItemDto[]
originalOrder: BaseItemDto[]
index: number
shuffled: boolean
source: QueueSource | null
setQueue(args: {
items: BaseItemDto[]
index: number
source: QueueSource | null
shuffled: boolean
originalOrder?: BaseItemDto[]
}): void
setIndex(index: number): void
clear(): void
}
export const useQueueStore = create<QueueState>(set => ({
items: [],
originalOrder: [],
index: 0,
shuffled: false,
source: null,
setQueue: ({ items, index, source, shuffled, originalOrder }) =>
set({
items,
originalOrder: originalOrder ?? items,
index,
shuffled,
source,
}),
setIndex: index => set({ index }),
clear: () => set({ items: [], originalOrder: [], index: 0, shuffled: false, source: null }),
}))
+20
View File
@@ -0,0 +1,20 @@
import { create } from 'zustand'
import type { BaseItemDto } from '../api/types'
/**
* Tiny global store for the quick-look modal. Any PosterCard can call
* `open(item)` on right-click / long-press, and a single QuickLookModal
* mounted at the app root reads the current target. Avoids prop-drilling
* a modal handler through every grid and row.
*/
interface QuickLookState {
item: BaseItemDto | null
open: (item: BaseItemDto) => void
close: () => void
}
export const useQuickLookStore = create<QuickLookState>(set => ({
item: null,
open: (item) => set({ item }),
close: () => set({ item: null }),
}))
+20
View File
@@ -0,0 +1,20 @@
import { create } from 'zustand'
import type { TmdbMovie, TmdbTvShow } from '../api/tmdb'
interface State {
open: boolean
tmdbId: number | null
kind: 'movie' | 'tv'
tmdbData: TmdbMovie | TmdbTvShow | null
show: (args: { tmdbId: number; kind: 'movie' | 'tv'; tmdbData: TmdbMovie | TmdbTvShow | null }) => void
close: () => void
}
export const useRequestModal = create<State>(set => ({
open: false,
tmdbId: null,
kind: 'movie',
tmdbData: null,
show: ({ tmdbId, kind, tmdbData }) => set({ open: true, tmdbId, kind, tmdbData }),
close: () => set({ open: false }),
}))
+59
View File
@@ -0,0 +1,59 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Persisted snapshots of library filter state. The user picks a combo
* they want to revisit (e.g. "Unwatched 4K HDR sci-fi") and we save it
* by name; loading restores all the filter knobs in one click.
*
* Scope is per library type ('movies' / 'shows') so a "noir from the
* 90s" search on Movies doesn't pollute the TV shows page.
*/
export interface SavedSearch {
id: string
name: string
scope: 'movies' | 'shows'
filters: SavedSearchFilters
createdAt: string
}
export interface SavedSearchFilters {
sortBy: string
watched: 'any' | 'played' | 'unplayed'
genre: string | null
decade: number | null
only4K: boolean
onlyHdr: boolean
}
interface State {
searches: SavedSearch[]
add: (scope: 'movies' | 'shows', name: string, filters: SavedSearchFilters) => SavedSearch
remove: (id: string) => void
}
function uid() {
return 'srch_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
}
export const useSavedSearches = create<State>()(
persist(
set => ({
searches: [],
add: (scope, name, filters) => {
const next: SavedSearch = {
id: uid(),
name: name.trim(),
scope,
filters,
createdAt: new Date().toISOString(),
}
set(s => ({ searches: [...s.searches, next] }))
return next
},
remove: id => set(s => ({ searches: s.searches.filter(x => x.id !== id) })),
}),
{ name: 'saved-searches-v1' },
),
)
+33
View File
@@ -0,0 +1,33 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const SIDEBAR_COLLAPSED_W = 68
export const SIDEBAR_DEFAULT_W = 240
export const SIDEBAR_MIN_W = 200
export const SIDEBAR_MAX_W = 380
interface SidebarState {
pinned: boolean
pinnedWidth: number
setPinned: (pinned: boolean) => void
togglePinned: () => void
setPinnedWidth: (w: number) => void
resetPinnedWidth: () => void
}
export const useSidebarStore = create<SidebarState>()(
persist(
set => ({
pinned: false,
pinnedWidth: SIDEBAR_DEFAULT_W,
setPinned: pinned => set({ pinned }),
togglePinned: () => set(s => ({ pinned: !s.pinned })),
setPinnedWidth: w =>
set({
pinnedWidth: Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, w)),
}),
resetPinnedWidth: () => set({ pinnedWidth: SIDEBAR_DEFAULT_W }),
}),
{ name: 'jf_sidebar' },
),
)
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export interface SmartShelfRule {
/** Stable id - uuid-ish, generated client-side. */
id: string
name: string
/** "Movie" / "Series" / both ('any'). Mapped to includeItemTypes. */
type: 'movie' | 'series' | 'any'
genres: string[]
/** Inclusive year range [min, max]. Either bound optional. */
yearMin?: number
yearMax?: number
/** Watched / unwatched / either. */
watched: 'any' | 'played' | 'unplayed'
/** Minimum community rating (TMDB / Jellyfin). */
minRating?: number
sortBy: 'random' | 'recent' | 'rating' | 'name'
/** Up to 100; we cap on the API side too. */
limit: number
createdAt: string
}
interface SmartShelvesState {
shelves: SmartShelfRule[]
add: (shelf: Omit<SmartShelfRule, 'id' | 'createdAt'>) => SmartShelfRule
update: (id: string, patch: Partial<SmartShelfRule>) => void
remove: (id: string) => void
}
function uid() {
return 'shelf_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
}
export const useSmartShelves = create<SmartShelvesState>()(
persist(
set => ({
shelves: [],
add: shelf => {
const next: SmartShelfRule = { ...shelf, id: uid(), createdAt: new Date().toISOString() }
set(s => ({ shelves: [...s.shelves, next] }))
return next
},
update: (id, patch) =>
set(s => ({
shelves: s.shelves.map(x => (x.id === id ? { ...x, ...patch } : x)),
})),
remove: id =>
set(s => ({
shelves: s.shelves.filter(x => x.id !== id),
})),
}),
{ name: 'smart-shelves-v1' },
),
)
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand'
/**
* In-memory state for the active SyncPlay group. Not persisted - rejoining
* after a reload is an explicit user action since the server may have
* pruned an idle group.
*/
export interface SyncPlayGroupState {
groupId: string
groupName: string
memberCount: number
}
export interface RemoteQueueItem {
itemId: string
positionTicks: number
isPlaying: boolean
}
interface State {
active: SyncPlayGroupState | null
/** Bumped any time the server pushes a remote command we should apply. */
lastRemoteSeq: number
lastRemoteCommand: { type: 'pause' | 'play' | 'seek'; positionTicks?: number } | null
/** Last queue update from the server. PlayerPage watches this so a
* joiner can catch up to whatever the host is currently playing. */
remoteQueueItem: RemoteQueueItem | null
setActive: (next: SyncPlayGroupState | null) => void
setMemberCount: (count: number) => void
applyRemote: (cmd: { type: 'pause' | 'play' | 'seek'; positionTicks?: number }) => void
setRemoteQueueItem: (q: RemoteQueueItem | null) => void
}
export const useSyncPlay = create<State>(set => ({
active: null,
lastRemoteSeq: 0,
lastRemoteCommand: null,
remoteQueueItem: null,
setActive: next => set({ active: next, remoteQueueItem: null }),
setMemberCount: count =>
set(s => (s.active ? { active: { ...s.active, memberCount: count } } : s)),
applyRemote: cmd => set(s => ({ lastRemoteSeq: s.lastRemoteSeq + 1, lastRemoteCommand: cmd })),
setRemoteQueueItem: q => set({ remoteQueueItem: q }),
}))
+32
View File
@@ -0,0 +1,32 @@
import { create } from 'zustand'
interface Toast {
id: number
message: string
tone: 'info' | 'success' | 'error'
}
interface ToastState {
toasts: Toast[]
show: (message: string, tone?: Toast['tone']) => void
dismiss: (id: number) => void
}
let counter = 0
export const useToastStore = create<ToastState>(set => ({
toasts: [],
show: (message, tone = 'info') => {
const id = ++counter
set(s => ({ toasts: [...s.toasts, { id, message, tone }] }))
setTimeout(() => {
set(s => ({ toasts: s.toasts.filter(t => t.id !== id) }))
}, 2400)
},
dismiss: id => set(s => ({ toasts: s.toasts.filter(t => t.id !== id) })),
}))
/** Convenience that doesn't require subscribing - safe to call anywhere. */
export function toast(message: string, tone: Toast['tone'] = 'info') {
useToastStore.getState().show(message, tone)
}
+56
View File
@@ -0,0 +1,56 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Trakt.tv connection state. Tokens persist via Zustand's localStorage
* adapter - they're per-device and not strictly "sensitive" in the same
* way the Jellyfin server token is, since they can be revoked on
* trakt.tv at any time.
*
* If you bundled client credentials at build time (env vars), they
* override anything stored here.
*/
export interface TraktTokens {
accessToken: string
refreshToken: string
expiresAt: string
}
interface State {
/** OAuth client id - required for all calls. Get one from trakt.tv. */
clientId: string
clientSecret: string
tokens: TraktTokens | null
enabled: boolean
setCredentials: (clientId: string, clientSecret: string) => void
setTokens: (tokens: TraktTokens | null) => void
setEnabled: (on: boolean) => void
disconnect: () => void
}
export const useTrakt = create<State>()(
persist(
set => ({
clientId: '',
clientSecret: '',
tokens: null,
enabled: true,
setCredentials: (clientId, clientSecret) => set({ clientId, clientSecret }),
setTokens: tokens => set({ tokens }),
setEnabled: enabled => set({ enabled }),
disconnect: () => set({ tokens: null }),
}),
{ name: 'trakt-v1' },
),
)
export function getTraktClientId(): string {
const env = (import.meta.env.VITE_TRAKT_CLIENT_ID || '').trim()
return env || useTrakt.getState().clientId
}
export function getTraktClientSecret(): string {
const env = (import.meta.env.VITE_TRAKT_CLIENT_SECRET || '').trim()
return env || useTrakt.getState().clientSecret
}
+33
View File
@@ -0,0 +1,33 @@
import { create } from 'zustand'
interface State {
open: boolean
videoKey: string | null
title: string
/** Optional caption shown under the title (e.g. "Trailer · Official"). */
subtitle?: string
onClose?: (() => void) | null
show: (args: { videoKey: string; title: string; subtitle?: string; onClose?: () => void }) => void
close: () => void
}
/**
* Tiny global store for the in-app YouTube viewer modal. Any component
* (VideosSection, the hero Trailer button, etc.) can call `show({...})`
* to open the player without dragging the user out to youtube.com.
*/
export const useYoutubeViewer = create<State>(set => ({
open: false,
videoKey: null,
title: '',
subtitle: undefined,
onClose: null,
show: ({ videoKey, title, subtitle, onClose }) =>
set({ open: true, videoKey, title, subtitle, onClose: onClose || null }),
close: () =>
set(s => {
const cb = s.onClose
setTimeout(() => cb?.(), 0)
return { open: false, onClose: null }
}),
}))