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