From 942a6cc9c072fb1a1e2bb2de5cb3d1a9b6e8fdf0 Mon Sep 17 00:00:00 2001 From: Jip Fr Date: Sun, 12 Feb 2023 00:41:55 +0100 Subject: [PATCH] Made type-safe versioned store, migrated to it Co-authored-by: mrjvs --- src/index.tsx | 10 +- src/state/bookmark/context.tsx | 35 +-- src/state/bookmark/store.ts | 15 +- src/state/bookmark/types.ts | 5 + src/state/watched/context.tsx | 57 +--- src/state/watched/migrations/v2.ts | 170 ++++++++++ src/state/watched/store.ts | 204 +----------- src/state/watched/types.ts | 22 ++ src/utils/storage.ts | 364 ++++++++++------------ src/video/components/hooks/volumeStore.ts | 11 +- 10 files changed, 408 insertions(+), 485 deletions(-) create mode 100644 src/state/bookmark/types.ts create mode 100644 src/state/watched/migrations/v2.ts create mode 100644 src/state/watched/types.ts diff --git a/src/index.tsx b/src/index.tsx index 202b4840..467a898b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ import "@/setup/i18n"; import "@/setup/index.css"; import "@/backend"; import { initializeChromecast } from "./setup/chromecast"; +import { initializeStores } from "./utils/storage"; // initialize const key = @@ -42,12 +43,19 @@ initializeChromecast(); // TODO general todos: // - localize everything (fix loading screen text (series vs movies)) +const LazyLoadedApp = React.lazy(async () => { + await initializeStores(); + return { + default: App, + }; +}); + ReactDOM.render( - + diff --git a/src/state/bookmark/context.tsx b/src/state/bookmark/context.tsx index 65485a7b..6252ceac 100644 --- a/src/state/bookmark/context.tsx +++ b/src/state/bookmark/context.tsx @@ -1,17 +1,8 @@ import { MWMediaMeta } from "@/backend/metadata/types"; -import { - createContext, - ReactNode, - useCallback, - useContext, - useMemo, - useState, -} from "react"; +import { useStore } from "@/utils/storage"; +import { createContext, ReactNode, useContext, useMemo } from "react"; import { BookmarkStore } from "./store"; - -interface BookmarkStoreData { - bookmarks: MWMediaMeta[]; -} +import { BookmarkStoreData } from "./types"; interface BookmarkStoreDataWrapper { setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void; @@ -36,25 +27,7 @@ function getBookmarkIndexFromMedia( } export function BookmarkContextProvider(props: { children: ReactNode }) { - const bookmarkLocalstorage = BookmarkStore.get(); - const [bookmarkStorage, setBookmarkStore] = useState( - bookmarkLocalstorage as BookmarkStoreData - ); - - const setBookmarked = useCallback( - (data: any) => { - setBookmarkStore((old) => { - const old2 = JSON.parse(JSON.stringify(old)); - let newData = data; - if (data.constructor === Function) { - newData = data(old2); - } - bookmarkLocalstorage.save(newData); - return newData; - }); - }, - [bookmarkLocalstorage, setBookmarkStore] - ); + const [bookmarkStorage, setBookmarked] = useStore(BookmarkStore); const contextValue = useMemo( () => ({ diff --git a/src/state/bookmark/store.ts b/src/state/bookmark/store.ts index 089a6693..3b7a3a92 100644 --- a/src/state/bookmark/store.ts +++ b/src/state/bookmark/store.ts @@ -1,17 +1,18 @@ -import { versionedStoreBuilder } from "@/utils/storage"; +import { createVersionedStore } from "@/utils/storage"; +import { BookmarkStoreData } from "./types"; -export const BookmarkStore = versionedStoreBuilder() +export const BookmarkStore = createVersionedStore() .setKey("mw-bookmarks") .addVersion({ version: 0, + migrate() { + return { + bookmarks: [], // TODO migrate bookmarks + }; + }, }) .addVersion({ version: 1, - migrate() { - return { - bookmarks: [], - }; - }, create() { return { bookmarks: [], diff --git a/src/state/bookmark/types.ts b/src/state/bookmark/types.ts new file mode 100644 index 00000000..05cb3641 --- /dev/null +++ b/src/state/bookmark/types.ts @@ -0,0 +1,5 @@ +import { MWMediaMeta } from "@/backend/metadata/types"; + +export interface BookmarkStoreData { + bookmarks: MWMediaMeta[]; +} diff --git a/src/state/watched/context.tsx b/src/state/watched/context.tsx index 62da316d..f45baf71 100644 --- a/src/state/watched/context.tsx +++ b/src/state/watched/context.tsx @@ -1,5 +1,6 @@ import { DetailedMeta } from "@/backend/metadata/getmeta"; -import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types"; +import { MWMediaType } from "@/backend/metadata/types"; +import { useStore } from "@/utils/storage"; import { createContext, ReactNode, @@ -7,9 +8,9 @@ import { useContext, useMemo, useRef, - useState, } from "react"; import { VideoProgressStore } from "./store"; +import { StoreMediaItem, WatchedStoreItem, WatchedStoreData } from "./types"; const FIVETEEN_MINUTES = 15 * 60; const FIVE_MINUTES = 5 * 60; @@ -34,29 +35,8 @@ function shouldSave( return true; } -interface MediaItem { - meta: MWMediaMeta; - series?: { - episodeId: string; - seasonId: string; - episode: number; - season: number; - }; -} - -export interface WatchedStoreItem { - item: MediaItem; - progress: number; - percentage: number; - watchedAt: number; -} - -export interface WatchedStoreData { - items: WatchedStoreItem[]; -} - interface WatchedStoreDataWrapper { - updateProgress(media: MediaItem, progress: number, total: number): void; + updateProgress(media: StoreMediaItem, progress: number, total: number): void; getFilteredWatched(): WatchedStoreItem[]; removeProgress(id: string): void; watched: WatchedStoreData; @@ -72,7 +52,7 @@ const WatchedContext = createContext({ }); WatchedContext.displayName = "WatchedContext"; -function isSameEpisode(media: MediaItem, v: MediaItem) { +function isSameEpisode(media: StoreMediaItem, v: StoreMediaItem) { return ( media.meta.id === v.meta.id && (!media.series || @@ -82,24 +62,7 @@ function isSameEpisode(media: MediaItem, v: MediaItem) { } export function WatchedContextProvider(props: { children: ReactNode }) { - const watchedLocalstorage = VideoProgressStore.get(); - const [watched, setWatchedReal] = useState( - watchedLocalstorage as WatchedStoreData - ); - - const setWatched = useCallback( - (data: any) => { - setWatchedReal((old) => { - let newData = data; - if (data.constructor === Function) { - newData = data(old); - } - watchedLocalstorage.save(newData); - return newData; - }); - }, - [setWatchedReal, watchedLocalstorage] - ); + const [watched, setWatched] = useStore(VideoProgressStore); const contextValue = useMemo( () => ({ @@ -110,7 +73,11 @@ export function WatchedContextProvider(props: { children: ReactNode }) { return newData; }); }, - updateProgress(media: MediaItem, progress: number, total: number): void { + updateProgress( + media: StoreMediaItem, + progress: number, + total: number + ): void { setWatched((data: WatchedStoreData) => { const newData = { ...data }; let item = newData.items.find((v) => isSameEpisode(media, v.item)); @@ -176,7 +143,7 @@ export function useWatchedContext() { } function isSameEpisodeMeta( - media: MediaItem, + media: StoreMediaItem, mediaTwo: DetailedMeta | null, episodeId?: string ) { diff --git a/src/state/watched/migrations/v2.ts b/src/state/watched/migrations/v2.ts new file mode 100644 index 00000000..ff0115e6 --- /dev/null +++ b/src/state/watched/migrations/v2.ts @@ -0,0 +1,170 @@ +import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; +import { searchForMedia } from "@/backend/metadata/search"; +import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types"; +import { WatchedStoreData, WatchedStoreItem } from "../types"; + +interface OldMediaBase { + mediaId: number; + mediaType: MWMediaType; + percentage: number; + progress: number; + providerId: string; + title: string; + year: number; +} + +interface OldMovie extends OldMediaBase { + mediaType: MWMediaType.MOVIE; +} + +interface OldSeries extends OldMediaBase { + mediaType: MWMediaType.SERIES; + episodeId: number; + seasonId: number; +} + +export interface OldData { + items: (OldMovie | OldSeries)[]; +} + +export async function migrateV2(old: OldData) { + const oldData = old; + if (!oldData) return; + + const uniqueMedias: Record = {}; + oldData.items.forEach((item: any) => { + if (uniqueMedias[item.mediaId]) return; + uniqueMedias[item.mediaId] = item; + }); + + const yearsAreClose = (a: number, b: number) => { + return Math.abs(a - b) <= 1; + }; + + const mediaMetas: Record> = {}; + + const relevantItems = await Promise.all( + Object.values(uniqueMedias).map(async (item) => { + const year = Number(item.year.toString().split("-")[0]); + const data = await searchForMedia({ + searchQuery: `${item.title} ${year}`, + type: item.mediaType, + }); + const relevantItem = data.find((res) => + yearsAreClose(Number(res.year), year) + ); + if (!relevantItem) { + console.error("No item"); + return; + } + return { + id: item.mediaId, + data: relevantItem, + }; + }) + ); + + for (const item of relevantItems.filter(Boolean)) { + if (!item) continue; + + let keys: (string | null)[][] = [["0", "0"]]; + if (item.data.type === "series") { + // TODO sort episodes by season & episode so it shows the "highest" episode as last + const meta = await getMetaFromId(item.data.type, item.data.id); + if (!meta || !meta?.meta.seasons) return; + const seasonNumbers = [ + ...new Set( + oldData.items + .filter((watchedEntry: any) => watchedEntry.mediaId === item.id) + .map((watchedEntry: any) => watchedEntry.seasonId) + ), + ]; + const seasons = seasonNumbers.map((num) => ({ + num, + season: meta.meta?.seasons?.[(num as number) - 1], + })); + keys = seasons + .map((season) => (season ? [season.num, season?.season?.id] : [])) + .filter((entry) => entry.length > 0); + } + + if (!mediaMetas[item.id]) mediaMetas[item.id] = {}; + await Promise.all( + keys.map(async ([key, id]) => { + if (!key) return; + mediaMetas[item.id][key] = await getMetaFromId( + item.data.type, + item.data.id, + id === "0" || id === null ? undefined : id + ); + }) + ); + } + + // We've got all the metadata you can dream of now + // Now let's convert stuff into the new format. + const newData: WatchedStoreData = { + ...oldData, + items: [], + }; + + for (const oldWatched of oldData.items) { + if (oldWatched.mediaType === "movie") { + if (!mediaMetas[oldWatched.mediaId]["0"]?.meta) continue; + + const newItem: WatchedStoreItem = { + item: { + meta: mediaMetas[oldWatched.mediaId]["0"]?.meta as MWMediaMeta, + }, + progress: oldWatched.progress, + percentage: oldWatched.percentage, + watchedAt: Date.now(), // There was no watchedAt in V2 + }; + + oldData.items = oldData.items.filter( + (item) => JSON.stringify(item) !== JSON.stringify(oldWatched) + ); + newData.items.push(newItem); + } else if (oldWatched.mediaType === "series") { + if (!mediaMetas[oldWatched.mediaId][oldWatched.seasonId]?.meta) continue; + + const meta = mediaMetas[oldWatched.mediaId][oldWatched.seasonId] + ?.meta as MWMediaMeta; + + if (meta.type !== "series") return; + + const newItem: WatchedStoreItem = { + item: { + meta, + series: { + episode: Number(oldWatched.episodeId), + season: Number(oldWatched.seasonId), + seasonId: meta.seasonData.id, + episodeId: + meta.seasonData.episodes[Number(oldWatched.episodeId) - 1].id, + }, + }, + progress: oldWatched.progress, + percentage: oldWatched.percentage, + watchedAt: Date.now(), // There was no watchedAt in V2 + // Put watchedAt in the future to show last episode as most recently + }; + + if ( + newData.items.find( + (item) => + item.item.meta.id === newItem.item.meta.id && + item.item.series?.episodeId === newItem.item.series?.episodeId + ) + ) + continue; + + oldData.items = oldData.items.filter( + (item) => JSON.stringify(item) !== JSON.stringify(oldWatched) + ); + newData.items.push(newItem); + } + } + + return newData; +} diff --git a/src/state/watched/store.ts b/src/state/watched/store.ts index 4bab3c78..56fac9ae 100644 --- a/src/state/watched/store.ts +++ b/src/state/watched/store.ts @@ -1,57 +1,25 @@ -import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta"; -import { searchForMedia } from "@/backend/metadata/search"; -import { MWMediaMeta, MWMediaType } from "@/backend/metadata/types"; -import { versionedStoreBuilder } from "@/utils/storage"; -import { WatchedStoreData, WatchedStoreItem } from "./context"; +import { createVersionedStore } from "@/utils/storage"; +import { migrateV2, OldData } from "./migrations/v2"; +import { WatchedStoreData } from "./types"; -interface OldMediaBase { - mediaId: number; - mediaType: MWMediaType; - percentage: number; - progress: number; - providerId: string; - title: string; - year: number; -} - -interface OldMovie extends OldMediaBase { - mediaType: MWMediaType.MOVIE; -} - -interface OldSeries extends OldMediaBase { - mediaType: MWMediaType.SERIES; - episodeId: number; - seasonId: number; -} - -interface OldData { - items: (OldMovie | OldSeries)[]; -} - -export const VideoProgressStore = versionedStoreBuilder() +export const VideoProgressStore = createVersionedStore() .setKey("video-progress") .addVersion({ version: 0, + migrate() { + return { + items: [], // dont migrate from version 0 to version 1, unmigratable + }; + }, }) .addVersion({ version: 1, - migrate() { - return { - items: [], - }; + async migrate(old: OldData) { + return migrateV2(old); }, }) .addVersion({ version: 2, - migrate(old: OldData) { - requestAnimationFrame(() => { - // eslint-disable-next-line no-use-before-define - migrateV2(old); - }); - return { - items: [], - }; - }, create() { return { items: [], @@ -59,153 +27,3 @@ export const VideoProgressStore = versionedStoreBuilder() }, }) .build(); - -async function migrateV2(old: OldData) { - const oldData = old; - if (!oldData) return; - - const uniqueMedias: Record = {}; - oldData.items.forEach((item: any) => { - if (uniqueMedias[item.mediaId]) return; - uniqueMedias[item.mediaId] = item; - }); - - const yearsAreClose = (a: number, b: number) => { - return Math.abs(a - b) <= 1; - }; - - const mediaMetas: Record> = {}; - - const relevantItems = await Promise.all( - Object.values(uniqueMedias).map(async (item) => { - const year = Number(item.year.toString().split("-")[0]); - const data = await searchForMedia({ - searchQuery: `${item.title} ${year}`, - type: item.mediaType, - }); - const relevantItem = data.find((res) => - yearsAreClose(Number(res.year), year) - ); - if (!relevantItem) { - console.error("No item"); - return; - } - return { - id: item.mediaId, - data: relevantItem, - }; - }) - ); - - for (const item of relevantItems.filter(Boolean)) { - if (!item) continue; - - let keys: (string | null)[][] = [["0", "0"]]; - if (item.data.type === "series") { - const meta = await getMetaFromId(item.data.type, item.data.id); - if (!meta || !meta?.meta.seasons) return; - const seasonNumbers = [ - ...new Set( - oldData.items - .filter((watchedEntry: any) => watchedEntry.mediaId === item.id) - .map((watchedEntry: any) => watchedEntry.seasonId) - ), - ]; - const seasons = seasonNumbers - .map((num) => ({ - num, - season: meta.meta?.seasons?.[(num as number) - 1], - })) - .filter(Boolean); - keys = seasons - .map((season) => (season ? [season.num, season?.season?.id] : [])) - .filter((entry) => entry.length > 0); // Stupid TypeScript - } - - if (!mediaMetas[item.id]) mediaMetas[item.id] = {}; - await Promise.all( - keys.map(async ([key, id]) => { - if (!key) return; - mediaMetas[item.id][key] = await getMetaFromId( - item.data.type, - item.data.id, - id === "0" || id === null ? undefined : id - ); - }) - ); - } - - // We've got all the metadata you can dream of now - // Now let's convert stuff into the new format. - interface WatchedStoreDataWithVersion extends WatchedStoreData { - "--version": number; - } - const newData: WatchedStoreDataWithVersion = { - ...oldData, - items: [], - "--version": 2, - }; - - for (const oldWatched of oldData.items) { - if (oldWatched.mediaType === "movie") { - if (!mediaMetas[oldWatched.mediaId]["0"]?.meta) continue; - - const newItem: WatchedStoreItem = { - item: { - meta: mediaMetas[oldWatched.mediaId]["0"]?.meta as MWMediaMeta, - }, - progress: oldWatched.progress, - percentage: oldWatched.percentage, - watchedAt: Date.now(), // There was no watchedAt in V2 - }; - - oldData.items = oldData.items.filter( - (item) => JSON.stringify(item) !== JSON.stringify(oldWatched) - ); - newData.items.push(newItem); - } else if (oldWatched.mediaType === "series") { - if (!mediaMetas[oldWatched.mediaId][oldWatched.seasonId]?.meta) continue; - - const meta = mediaMetas[oldWatched.mediaId][oldWatched.seasonId] - ?.meta as MWMediaMeta; - - if (meta.type !== "series") return; - - const newItem: WatchedStoreItem = { - item: { - meta, - series: { - episode: Number(oldWatched.episodeId), - season: Number(oldWatched.seasonId), - seasonId: meta.seasonData.id, - episodeId: - meta.seasonData.episodes[Number(oldWatched.episodeId) - 1].id, - }, - }, - progress: oldWatched.progress, - percentage: oldWatched.percentage, - watchedAt: Date.now(), // There was no watchedAt in V2 - }; - - if ( - newData.items.find( - (item) => - item.item.meta.id === newItem.item.meta.id && - item.item.series?.episodeId === newItem.item.series?.episodeId - ) - ) - continue; - - oldData.items = oldData.items.filter( - (item) => JSON.stringify(item) !== JSON.stringify(oldWatched) - ); - newData.items.push(newItem); - } - } - - console.log(JSON.stringify(old), JSON.stringify(newData)); - if (JSON.stringify(old.items) !== JSON.stringify(newData.items)) { - console.log(newData); - VideoProgressStore.get().save(newData); - } -} diff --git a/src/state/watched/types.ts b/src/state/watched/types.ts new file mode 100644 index 00000000..a3246c38 --- /dev/null +++ b/src/state/watched/types.ts @@ -0,0 +1,22 @@ +import { MWMediaMeta } from "@/backend/metadata/types"; + +export interface StoreMediaItem { + meta: MWMediaMeta; + series?: { + episodeId: string; + seasonId: string; + episode: number; + season: number; + }; +} + +export interface WatchedStoreItem { + item: StoreMediaItem; + progress: number; + percentage: number; + watchedAt: number; +} + +export interface WatchedStoreData { + items: WatchedStoreItem[]; +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 9e56e651..b7bed37f 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,232 +1,188 @@ -// TODO make type and react safe!! -/* - it needs to be react-ified by having a save function not on the instance itself. - also type safety is important, this is all spaghetti with "any" everywhere -*/ +import { useEffect, useState } from "react"; -function buildStoreObject(d: any) { - const data: any = { - versions: d.versions, - currentVersion: d.maxVersion, - id: d.storageString, +interface StoreVersion { + version: number; + migrate?(data: A): any; + create?: () => A; +} +interface StoreRet { + save: (data: T) => void; + get: () => T; + _raw: () => any; + onChange: (cb: (data: T) => void) => { + destroy: () => void; }; +} - function update(this: any, obj2: any) { - let obj = obj2; - if (!obj) throw new Error("object to update is not an object"); +export interface StoreBuilder { + setKey: (key: string) => StoreBuilder; + addVersion: (ver: StoreVersion) => StoreBuilder; + build: () => StoreRet; +} - // repeat until object fully updated - if (obj["--version"] === undefined) obj["--version"] = 0; - while (obj["--version"] !== this.currentVersion) { - // get version - let version: any = obj["--version"] || 0; - if (version.constructor !== Number || version < 0) version = -42; - // invalid on purpose so it will reset - else { - version = ((version as number) + 1).toString(); - } +interface InternalStoreData { + versions: StoreVersion[]; + key: string | null; +} - // check if version exists - if (!this.versions[version]) { - console.error( - `Version not found for storage item in store ${this.id}, resetting` - ); - obj = null; - break; - } +const storeCallbacks: Record void)[]> = {}; +const stores: Record, InternalStoreData]> = {}; - // update object - obj = this.versions[version].update(obj); +export async function initializeStores() { + // migrate all stores + for (const [store, internal] of Object.values(stores)) { + const versions = internal.versions.sort((a, b) => a.version - b.version); + + const data = store._raw(); + const dataVersion = + data["--version"] && typeof data["--version"] === "number" + ? data["--version"] + : 0; + + // Find which versions need to be used for migrations + const relevantVersions = versions.filter((v) => v.version >= dataVersion); + + // Migrate over each version + let mostRecentData = data; + for (const version of relevantVersions) { + if (version.migrate) + mostRecentData = await version.migrate(mostRecentData); } - // if resulting obj is null, use latest version as init object - if (obj === null) { - console.error( - `Storage item for store ${this.id} has been reset due to faulty updates` - ); - return this.versions[this.currentVersion.toString()].init(); - } - - // updates succesful, return - return obj; + store.save(mostRecentData); } +} - function get(this: any) { - // get from storage api - const store = this; - let gottenData: any = localStorage.getItem(this.id); +function buildStorageObject(store: InternalStoreData): StoreRet { + const key = store.key ?? ""; + const latestVersion = store.versions.sort((a, b) => b.version - a.version)[0]; - // parse json if item exists - if (gottenData) { - try { - gottenData = JSON.parse(gottenData); - if (!gottenData.constructor) { - console.error( - `Storage item for store ${this.id} has not constructor` - ); - throw new Error("storage item has no constructor"); - } - if (gottenData.constructor !== Object) { - console.error(`Storage item for store ${this.id} is not an object`); - throw new Error("storage item is not an object"); - } - } catch (_) { - // if errored, set to null so it generates new one, see below - console.error(`Failed to parse storage item for store ${this.id}`); - gottenData = null; - } - } - - // if item doesnt exist, generate from version init - if (!gottenData) { - gottenData = this.versions[this.currentVersion.toString()].init(); - } - - // update the data if needed - gottenData = this.update(gottenData); - - // add a save object to return value - gottenData.save = function save(newData: any) { - const dataToStore = newData || gottenData; - localStorage.setItem(store.id, JSON.stringify(dataToStore)); + function onChange(cb: (data: T) => void) { + if (!storeCallbacks[key]) storeCallbacks[key] = []; + storeCallbacks[key].push(cb); + return { + destroy() { + // remove function pointer from callbacks + storeCallbacks[key] = storeCallbacks[key].filter((v) => v === cb); + }, }; - - // add instance helpers - Object.entries(d.instanceHelpers).forEach(([name, helper]: any) => { - if (gottenData[name] !== undefined) - throw new Error( - `helper name: ${name} on instance of store ${this.id} is reserved` - ); - gottenData[name] = helper.bind(gottenData); - }); - - // return data - return gottenData; } - // add functions to store - data.get = get.bind(data); - data.update = update.bind(data); + function makeRaw() { + const data = latestVersion.create?.() ?? {}; + data["--version"] = latestVersion.version; + return data; + } - // add static helpers - Object.entries(d.staticHelpers).forEach(([name, helper]: any) => { - if (data[name] !== undefined) - throw new Error(`helper name: ${name} on store ${data.id} is reserved`); - data[name] = helper.bind({}); + function getRaw() { + const item = localStorage.getItem(key); + if (!item) return makeRaw(); + try { + return JSON.parse(item); + } catch (err) { + // we assume user has fucked with the data, give them a fresh store + console.error(`FAILED TO PARSE LOCALSTORAGE FOR KEY ${key}`, err); + return makeRaw(); + } + } + + function save(data: T) { + const withVersion: any = { ...data }; + withVersion["--version"] = latestVersion.version; + localStorage.setItem(key, JSON.stringify(withVersion)); + + if (!storeCallbacks[key]) storeCallbacks[key] = []; + storeCallbacks[key].forEach((v) => v(structuredClone(data))); + } + + return { + get() { + const data = getRaw(); + delete data["--version"]; + return data as T; + }, + _raw() { + return getRaw(); + }, + onChange, + save, + }; +} + +function assertStore(store: InternalStoreData) { + const versionListSorted = store.versions.sort( + (a, b) => a.version - b.version + ); + versionListSorted.forEach((v, i, arr) => { + if (i === 0) return; + if (v.version !== arr[i - 1].version + 1) + throw new Error("Version list of store is not incremental"); + }); + versionListSorted.forEach((v) => { + if (v.version < 0) throw new Error("Versions cannot be negative"); }); - return data; + // version zero must exist + if (versionListSorted[0]?.version !== 0) + throw new Error("Version 0 doesn't exist in version list of store"); + + // max version must have create function + if (!store.versions[store.versions.length - 1].create) + throw new Error(`Missing create function on latest version of store`); + + // check storage string + if (!store.key) throw new Error("storage key not set in store"); + + // check if all parts have migratio + const migrations = [...versionListSorted]; + migrations.pop(); + migrations.forEach((v) => { + if (!v.migrate) + throw new Error(`Migration missing on version ${v.version}`); + }); } -/* - * Builds a versioned store - * - * manages versioning of localstorage items - */ -export function versionedStoreBuilder(): any { +export function createVersionedStore(): StoreBuilder { + const _data: InternalStoreData = { + versions: [], + key: null, + }; + return { - _data: { - versionList: [], - maxVersion: 0, - versions: {}, - storageString: undefined, - instanceHelpers: {}, - staticHelpers: {}, - }, - - setKey(str: string) { - this._data.storageString = str; + setKey(key) { + _data.key = key; return this; }, - - addVersion({ version, migrate, create }: any) { - // input checking - if (version < 0) throw new Error("Cannot add version below 0 in store"); - if (version > 0 && !migrate) - throw new Error( - `Missing migration on version ${version} (needed for any version above 0)` - ); - - // update max version list - if (version > this._data.maxVersion) this._data.maxVersion = version; - // add to version list - this._data.versionList.push(version); - - // register version - this._data.versions[version.toString()] = { - version, // version number - update: migrate - ? (data: any) => { - // update function, and increment version - const newData = migrate(data); - newData["--version"] = version; // eslint-disable-line no-param-reassign - return newData; - } - : undefined, - init: create - ? () => { - // return an initial object - const data = create(); - data["--version"] = version; - return data; - } - : undefined, - }; + addVersion(ver) { + _data.versions.push(ver); return this; }, - - registerHelper({ name, helper, type }: any) { - // type - let helperType: string = type; - if (!helperType) helperType = "instance"; - - // input checking - if (!name || name.constructor !== String) { - throw new Error("helper name is not a string"); - } - if (!helper || helper.constructor !== Function) { - throw new Error("helper function is not a function"); - } - if (!["instance", "static"].includes(helperType)) { - throw new Error("helper type must be either 'instance' or 'static'"); - } - - // register helper - if (helperType === "instance") - this._data.instanceHelpers[name as string] = helper; - else if (helperType === "static") - this._data.staticHelpers[name as string] = helper; - - return this; - }, - build() { - // check if version list doesnt skip versions - const versionListSorted = this._data.versionList.sort( - (a: number, b: number) => a - b - ); - versionListSorted.forEach((v: any, i: number, arr: any[]) => { - if (i === 0) return; - if (v !== arr[i - 1] + 1) - throw new Error("Version list of store is not incremental"); - }); - - // version zero must exist - if (versionListSorted[0] !== 0) - throw new Error("Version 0 doesn't exist in version list of store"); - - // max version must have init function - if (!this._data.versions[this._data.maxVersion.toString()].init) - throw new Error( - `Missing create function on version ${this._data.maxVersion} (needed for latest version of store)` - ); - - // check storage string - if (!this._data.storageString) - throw new Error("storage key not set in store"); - - // build versioned store - return buildStoreObject(this._data); + assertStore(_data); + const storageObject = buildStorageObject(_data); + stores[_data.key ?? ""] = [storageObject, _data]; + return storageObject; }, }; } + +export function useStore( + store: StoreRet +): [T, (cb: (old: T) => T) => void] { + const [data, setData] = useState(store.get()); + useEffect(() => { + const { destroy } = store.onChange((newData) => { + setData(newData); + }); + return () => { + destroy(); + }; + }, [store]); + + function setNewData(cb: (old: T) => T) { + const newData = cb(data); + store.save(newData); + } + + return [data, setNewData]; +} diff --git a/src/video/components/hooks/volumeStore.ts b/src/video/components/hooks/volumeStore.ts index 3b328810..e577c09d 100644 --- a/src/video/components/hooks/volumeStore.ts +++ b/src/video/components/hooks/volumeStore.ts @@ -1,6 +1,10 @@ -import { versionedStoreBuilder } from "@/utils/storage"; +import { createVersionedStore } from "@/utils/storage"; -export const volumeStore = versionedStoreBuilder() +interface VolumeStoreData { + volume: number; +} + +export const volumeStore = createVersionedStore() .setKey("mw-volume") .addVersion({ version: 0, @@ -18,8 +22,7 @@ export function getStoredVolume(): number { } export function setStoredVolume(volume: number) { - const store = volumeStore.get(); - store.save({ + volumeStore.save({ volume, }); }