From b0c2e48224c9fa3a1ce39d37e40b3070a6cb0a52 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Mar 2026 03:32:48 +0200 Subject: [PATCH] zustand stores --- src/stores/arr-instances-store.ts | 80 ++++++++++++++ src/stores/diary-store.ts | 76 ++++++++++++++ src/stores/downloads-store.ts | 66 ++++++++++++ src/stores/letterboxd-lists-store.ts | 39 +++++++ src/stores/library-selection-store.ts | 44 ++++++++ src/stores/music-store.ts | 144 ++++++++++++++++++++++++++ src/stores/override-rules-store.ts | 136 ++++++++++++++++++++++++ src/stores/personal-data-store.ts | 126 ++++++++++++++++++++++ src/stores/player-runtime-store.ts | 94 +++++++++++++++++ src/stores/preferences-store.ts | 66 ++++++++++++ src/stores/queue-store.ts | 55 ++++++++++ src/stores/quick-look-store.ts | 20 ++++ src/stores/request-modal-store.ts | 20 ++++ src/stores/saved-searches-store.ts | 59 +++++++++++ src/stores/sidebar-store.ts | 33 ++++++ src/stores/smart-shelves-store.ts | 55 ++++++++++ src/stores/syncplay-store.ts | 45 ++++++++ src/stores/toast-store.ts | 32 ++++++ src/stores/trakt-store.ts | 56 ++++++++++ src/stores/youtube-viewer-store.ts | 33 ++++++ 20 files changed, 1279 insertions(+) create mode 100644 src/stores/arr-instances-store.ts create mode 100644 src/stores/diary-store.ts create mode 100644 src/stores/downloads-store.ts create mode 100644 src/stores/letterboxd-lists-store.ts create mode 100644 src/stores/library-selection-store.ts create mode 100644 src/stores/music-store.ts create mode 100644 src/stores/override-rules-store.ts create mode 100644 src/stores/personal-data-store.ts create mode 100644 src/stores/player-runtime-store.ts create mode 100644 src/stores/preferences-store.ts create mode 100644 src/stores/queue-store.ts create mode 100644 src/stores/quick-look-store.ts create mode 100644 src/stores/request-modal-store.ts create mode 100644 src/stores/saved-searches-store.ts create mode 100644 src/stores/sidebar-store.ts create mode 100644 src/stores/smart-shelves-store.ts create mode 100644 src/stores/syncplay-store.ts create mode 100644 src/stores/toast-store.ts create mode 100644 src/stores/trakt-store.ts create mode 100644 src/stores/youtube-viewer-store.ts diff --git a/src/stores/arr-instances-store.ts b/src/stores/arr-instances-store.ts new file mode 100644 index 0000000..cd14683 --- /dev/null +++ b/src/stores/arr-instances-store.ts @@ -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()( + 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, + } +} diff --git a/src/stores/diary-store.ts b/src/stores/diary-store.ts new file mode 100644 index 0000000..f577adc --- /dev/null +++ b/src/stores/diary-store.ts @@ -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 & { watchedAt?: string }) => DiaryEntry + update: (id: string, patch: Partial>) => 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()( + 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' }, + ), +) diff --git a/src/stores/downloads-store.ts b/src/stores/downloads-store.ts new file mode 100644 index 0000000..661af39 --- /dev/null +++ b/src/stores/downloads-store.ts @@ -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) => void + update: (id: string, patch: Partial) => 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()( + 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' }, + ), +) diff --git a/src/stores/letterboxd-lists-store.ts b/src/stores/letterboxd-lists-store.ts new file mode 100644 index 0000000..8477510 --- /dev/null +++ b/src/stores/letterboxd-lists-store.ts @@ -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()( + 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' }, + ), +) diff --git a/src/stores/library-selection-store.ts b/src/stores/library-selection-store.ts new file mode 100644 index 0000000..4280b4d --- /dev/null +++ b/src/stores/library-selection-store.ts @@ -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 + toggle: (itemId: string) => void + add: (itemId: string) => void + remove: (itemId: string) => void + setMany: (itemIds: string[]) => void + clear: () => void +} + +export const useLibrarySelection = create(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() }), +})) diff --git a/src/stores/music-store.ts b/src/stores/music-store.ts new file mode 100644 index 0000000..c45a940 --- /dev/null +++ b/src/stores/music-store.ts @@ -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(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((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 }), +})) diff --git a/src/stores/override-rules-store.ts b/src/stores/override-rules-store.ts new file mode 100644 index 0000000..a370cd6 --- /dev/null +++ b/src/stores/override-rules-store.ts @@ -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 + update: (id: string, patch: Partial) => 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()( + 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 | 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 +} diff --git a/src/stores/personal-data-store.ts b/src/stores/personal-data-store.ts new file mode 100644 index 0000000..f592ac2 --- /dev/null +++ b/src/stores/personal-data-store.ts @@ -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 + 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, id: string): PersonalEntry { + return entries[id] || { ...EMPTY } +} + +export const usePersonalData = create()( + 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 +} diff --git a/src/stores/player-runtime-store.ts b/src/stores/player-runtime-store.ts new file mode 100644 index 0000000..02c352d --- /dev/null +++ b/src/stores/player-runtime-store.ts @@ -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(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, + }), +})) diff --git a/src/stores/preferences-store.ts b/src/stores/preferences-store.ts new file mode 100644 index 0000000..831bb2b --- /dev/null +++ b/src/stores/preferences-store.ts @@ -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: (key: K, value: AppSettings[K]) => void + setHomeShow: (key: K, value: boolean) => void + setDetailShow: (key: K, value: boolean) => void + setEpisodeShow: (key: K, value: boolean) => void + setEpisodeBehavior: (key: K, value: EpisodeBehaviorSettings[K]) => void + setEpisodeRecap: (key: K, value: EpisodeRecapSettings[K]) => void + setEndVideoShow: (key: K, value: boolean) => void + resetToDefaults: () => void +} + +export const usePreferencesStore = create()( + persist( + set => ({ + ...defaultSettings, + + setPreference: (key, value) => set({ [key]: value } as Partial), + 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) as unknown as Record, + ) + } + if (fromVersion < 3) { + return migrateV2Preferences(persistedState as Record) + } + return persistedState as AppSettings + }, + }, + ), +) diff --git a/src/stores/queue-store.ts b/src/stores/queue-store.ts new file mode 100644 index 0000000..60e6257 --- /dev/null +++ b/src/stores/queue-store.ts @@ -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(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 }), +})) diff --git a/src/stores/quick-look-store.ts b/src/stores/quick-look-store.ts new file mode 100644 index 0000000..bcfe457 --- /dev/null +++ b/src/stores/quick-look-store.ts @@ -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(set => ({ + item: null, + open: (item) => set({ item }), + close: () => set({ item: null }), +})) diff --git a/src/stores/request-modal-store.ts b/src/stores/request-modal-store.ts new file mode 100644 index 0000000..2c1c536 --- /dev/null +++ b/src/stores/request-modal-store.ts @@ -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(set => ({ + open: false, + tmdbId: null, + kind: 'movie', + tmdbData: null, + show: ({ tmdbId, kind, tmdbData }) => set({ open: true, tmdbId, kind, tmdbData }), + close: () => set({ open: false }), +})) diff --git a/src/stores/saved-searches-store.ts b/src/stores/saved-searches-store.ts new file mode 100644 index 0000000..afa9e80 --- /dev/null +++ b/src/stores/saved-searches-store.ts @@ -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()( + 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' }, + ), +) diff --git a/src/stores/sidebar-store.ts b/src/stores/sidebar-store.ts new file mode 100644 index 0000000..c421a3c --- /dev/null +++ b/src/stores/sidebar-store.ts @@ -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()( + 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' }, + ), +) diff --git a/src/stores/smart-shelves-store.ts b/src/stores/smart-shelves-store.ts new file mode 100644 index 0000000..85a7a3c --- /dev/null +++ b/src/stores/smart-shelves-store.ts @@ -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 + update: (id: string, patch: Partial) => void + remove: (id: string) => void +} + +function uid() { + return 'shelf_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36) +} + +export const useSmartShelves = create()( + 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' }, + ), +) diff --git a/src/stores/syncplay-store.ts b/src/stores/syncplay-store.ts new file mode 100644 index 0000000..0e190bb --- /dev/null +++ b/src/stores/syncplay-store.ts @@ -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(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 }), +})) diff --git a/src/stores/toast-store.ts b/src/stores/toast-store.ts new file mode 100644 index 0000000..7f905ce --- /dev/null +++ b/src/stores/toast-store.ts @@ -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(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) +} diff --git a/src/stores/trakt-store.ts b/src/stores/trakt-store.ts new file mode 100644 index 0000000..9d60548 --- /dev/null +++ b/src/stores/trakt-store.ts @@ -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()( + 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 +} diff --git a/src/stores/youtube-viewer-store.ts b/src/stores/youtube-viewer-store.ts new file mode 100644 index 0000000..3559c06 --- /dev/null +++ b/src/stores/youtube-viewer-store.ts @@ -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(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 } + }), +}))