zustand stores
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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' },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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' },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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() }),
|
||||||
|
}))
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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' },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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' },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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' },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user