Settings and volume migrations + add language setting + move all old store data to /stores/__old

This commit is contained in:
mrjvs 2023-10-31 19:07:47 +01:00
parent 97c42eeb49
commit f8bba7b27b
22 changed files with 195 additions and 525 deletions

View file

@ -10,12 +10,12 @@ import { ErrorBoundary } from "@/pages/errors/ErrorBoundary";
import App from "@/setup/App";
import { conf } from "@/setup/config";
import i18n from "@/setup/i18n";
import "@/setup/ga";
import "@/setup/index.css";
import { useLanguageStore } from "@/stores/language";
import { initializeChromecast } from "./setup/chromecast";
import { SettingsStore } from "./state/settings/store";
import { initializeStores } from "./utils/storage";
import { initializeOldStores } from "./stores/__old/migrations";
// initialize
const key =
@ -29,8 +29,8 @@ registerSW({
});
const LazyLoadedApp = React.lazy(async () => {
await initializeStores();
i18n.changeLanguage(SettingsStore.get().language ?? "en");
await initializeOldStores();
i18n.changeLanguage(useLanguageStore.getState().language);
return {
default: App,
};

View file

@ -19,10 +19,8 @@ import { HomePage } from "@/pages/HomePage";
import { PlayerView } from "@/pages/PlayerView";
import { SettingsPage } from "@/pages/Settings";
import { Layout } from "@/setup/Layout";
import { BookmarkContextProvider } from "@/state/bookmark";
import { SettingsProvider } from "@/state/settings";
import { WatchedContextProvider } from "@/state/watched";
import { useHistoryListener } from "@/stores/history";
import { useLanguageListener } from "@/stores/language";
function LegacyUrlView({ children }: { children: ReactElement }) {
const location = useLocation();
@ -60,83 +58,66 @@ function QuickSearch() {
function App() {
useHistoryListener();
useOnlineListener();
useLanguageListener();
return (
<SettingsProvider>
<WatchedContextProvider>
<BookmarkContextProvider>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/s/:query">
<QuickSearch />
</Route>
<Route exact path="/search/:type">
<Redirect to="/browse" push={false} />
</Route>
<Route exact path="/search/:type/:query?">
{({ match }) => {
if (match?.params.query)
return (
<Redirect
to={`/browse/${match?.params.query}`}
push={false}
/>
);
return <Redirect to="/browse" push={false} />;
}}
</Route>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/s/:query">
<QuickSearch />
</Route>
<Route exact path="/search/:type">
<Redirect to="/browse" push={false} />
</Route>
<Route exact path="/search/:type/:query?">
{({ match }) => {
if (match?.params.query)
return (
<Redirect to={`/browse/${match?.params.query}`} push={false} />
);
return <Redirect to="/browse" push={false} />;
}}
</Route>
{/* pages */}
<Route
exact
path={["/media/:media", "/media/:media/:season/:episode"]}
>
<LegacyUrlView>
<PlayerView />
</LegacyUrlView>
</Route>
<Route
exact
path={["/browse/:query?", "/"]}
component={HomePage}
/>
<Route exact path="/faq" component={AboutPage} />
<Route exact path="/dmca" component={DmcaPage} />
{/* pages */}
<Route exact path={["/media/:media", "/media/:media/:season/:episode"]}>
<LegacyUrlView>
<PlayerView />
</LegacyUrlView>
</Route>
<Route exact path={["/browse/:query?", "/"]} component={HomePage} />
<Route exact path="/faq" component={AboutPage} />
<Route exact path="/dmca" component={DmcaPage} />
{/* Settings page */}
<Route exact path="/settings" component={SettingsPage} />
{/* Settings page */}
<Route exact path="/settings" component={SettingsPage} />
{/* admin routes */}
<Route exact path="/admin" component={AdminPage} />
{/* admin routes */}
<Route exact path="/admin" component={AdminPage} />
{/* other */}
<Route
exact
path="/dev"
component={lazy(() => import("@/pages/DeveloperPage"))}
/>
<Route
exact
path="/dev/video"
component={lazy(
() => import("@/pages/developer/VideoTesterView")
)}
/>
{/* developer routes that can abuse workers are disabled in production */}
{process.env.NODE_ENV === "development" ? (
<Route
exact
path="/dev/test"
component={lazy(() => import("@/pages/developer/TestView"))}
/>
) : null}
<Route path="*" component={NotFoundPage} />
</Switch>
</Layout>
</BookmarkContextProvider>
</WatchedContextProvider>
</SettingsProvider>
{/* other */}
<Route
exact
path="/dev"
component={lazy(() => import("@/pages/DeveloperPage"))}
/>
<Route
exact
path="/dev/video"
component={lazy(() => import("@/pages/developer/VideoTesterView"))}
/>
{/* developer routes that can abuse workers are disabled in production */}
{process.env.NODE_ENV === "development" ? (
<Route
exact
path="/dev/test"
component={lazy(() => import("@/pages/developer/TestView"))}
/>
) : null}
<Route path="*" component={NotFoundPage} />
</Switch>
</Layout>
);
}

View file

@ -1,71 +0,0 @@
import { ReactNode, createContext, useContext, useMemo } from "react";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { useStore } from "@/utils/storage";
import { BookmarkStore } from "./store";
import { BookmarkStoreData } from "./types";
interface BookmarkStoreDataWrapper {
setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void;
getFilteredBookmarks(): MWMediaMeta[];
bookmarkStore: BookmarkStoreData;
}
const BookmarkedContext = createContext<BookmarkStoreDataWrapper>({
setItemBookmark: () => {},
getFilteredBookmarks: () => [],
bookmarkStore: {
bookmarks: [],
},
});
function getBookmarkIndexFromMedia(
bookmarks: MWMediaMeta[],
media: MWMediaMeta
): number {
const a = bookmarks.findIndex((v) => v.id === media.id);
return a;
}
export function BookmarkContextProvider(props: { children: ReactNode }) {
const [bookmarkStorage, setBookmarked] = useStore(BookmarkStore);
const contextValue = useMemo(
() => ({
setItemBookmark(media: MWMediaMeta, bookmarked: boolean) {
setBookmarked((data: BookmarkStoreData): BookmarkStoreData => {
let bookmarks = [...data.bookmarks];
bookmarks = bookmarks.filter((v) => v.id !== media.id);
if (bookmarked) bookmarks.push({ ...media });
return {
bookmarks,
};
});
},
getFilteredBookmarks() {
return [...bookmarkStorage.bookmarks];
},
bookmarkStore: bookmarkStorage,
}),
[bookmarkStorage, setBookmarked]
);
return (
<BookmarkedContext.Provider value={contextValue}>
{props.children}
</BookmarkedContext.Provider>
);
}
export function useBookmarkContext() {
return useContext(BookmarkedContext);
}
export function getIfBookmarkedFromPortable(
bookmarks: MWMediaMeta[],
media: MWMediaMeta
): boolean {
const bookmarked = getBookmarkIndexFromMedia(bookmarks, media);
return bookmarked !== -1;
}

View file

@ -1 +0,0 @@
export * from "./context";

View file

@ -1,92 +0,0 @@
import { ReactNode, createContext, useContext, useMemo } from "react";
import { LangCode } from "@/setup/iso6391";
import { useStore } from "@/utils/storage";
import { SettingsStore } from "./store";
import { MWSettingsData } from "./types";
interface MWSettingsDataSetters {
setLanguage(language: LangCode): void;
setCaptionLanguage(language: LangCode): void;
setCaptionDelay(delay: number): void;
setCaptionColor(color: string): void;
setCaptionFontSize(size: number): void;
setCaptionBackgroundColor(backgroundColor: number): void;
}
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
export function SettingsProvider(props: { children: ReactNode }) {
function enforceRange(min: number, value: number, max: number) {
return Math.max(min, Math.min(value, max));
}
const [settings, setSettings] = useStore(SettingsStore);
const context: MWSettingsDataWrapper = useMemo(() => {
const settingsContext: MWSettingsDataWrapper = {
...settings,
setLanguage(language) {
setSettings((oldSettings) => {
return {
...oldSettings,
language,
};
});
},
setCaptionLanguage(language) {
setSettings((oldSettings) => {
const captionSettings = oldSettings.captionSettings;
captionSettings.language = language;
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionDelay(delay: number) {
setSettings((oldSettings) => {
const captionSettings = oldSettings.captionSettings;
captionSettings.delay = enforceRange(-10, delay, 10);
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionColor(color) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.color = color;
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionFontSize(size) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.fontSize = enforceRange(10, size, 60);
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionBackgroundColor(backgroundColor) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.backgroundColor = `${style.backgroundColor.substring(
0,
7
)}${backgroundColor.toString(16).padStart(2, "0")}`;
const newSettings = oldSettings;
return newSettings;
});
},
};
return settingsContext;
}, [settings, setSettings]);
return (
<SettingsContext.Provider value={context}>
{props.children}
</SettingsContext.Provider>
);
}
export function useSettings() {
return useContext(SettingsContext);
}
export default SettingsContext;

View file

@ -1 +0,0 @@
export * from "./context";

View file

@ -1,49 +0,0 @@
import { createVersionedStore } from "@/utils/storage";
import { MWSettingsData, MWSettingsDataV1 } from "./types";
export const SettingsStore = createVersionedStore<MWSettingsData>()
.setKey("mw-settings")
.addVersion({
version: 0,
create(): MWSettingsDataV1 {
return {
language: "en",
captionSettings: {
delay: 0,
style: {
color: "#ffffff",
fontSize: 25,
backgroundColor: "#00000096",
},
},
};
},
migrate(data: MWSettingsDataV1): MWSettingsData {
return {
language: data.language,
captionSettings: {
language: "none",
...data.captionSettings,
},
};
},
})
.addVersion({
version: 1,
create(): MWSettingsData {
return {
language: "en",
captionSettings: {
delay: 0,
language: "none",
style: {
color: "#ffffff",
fontSize: 25,
backgroundColor: "#00000096",
},
},
};
},
})
.build();

View file

@ -1,204 +0,0 @@
import {
ReactNode,
createContext,
useCallback,
useContext,
useMemo,
useRef,
} from "react";
import { DetailedMeta } from "@/backend/metadata/getmeta";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { useStore } from "@/utils/storage";
import { VideoProgressStore } from "./store";
import { StoreMediaItem, WatchedStoreData, WatchedStoreItem } from "./types";
const FIVETEEN_MINUTES = 15 * 60;
const FIVE_MINUTES = 5 * 60;
function shouldSave(
time: number,
duration: number,
isSeries: boolean
): boolean {
const timeFromEnd = Math.max(0, duration - time);
// short movie
if (duration < FIVETEEN_MINUTES) {
if (time < 5) return false;
if (timeFromEnd < 60) return false;
return true;
}
// long movie
if (time < 30) return false;
if (timeFromEnd < FIVE_MINUTES && !isSeries) return false;
return true;
}
interface WatchedStoreDataWrapper {
updateProgress(media: StoreMediaItem, progress: number, total: number): void;
getFilteredWatched(): WatchedStoreItem[];
removeProgress(id: string): void;
watched: WatchedStoreData;
}
const WatchedContext = createContext<WatchedStoreDataWrapper>({
updateProgress: () => {},
getFilteredWatched: () => [],
removeProgress: () => {},
watched: {
items: [],
},
});
WatchedContext.displayName = "WatchedContext";
function isSameEpisode(media: StoreMediaItem, v: StoreMediaItem) {
return (
media.meta.id === v.meta.id &&
(!media.series ||
(media.series.seasonId === v.series?.seasonId &&
media.series.episodeId === v.series?.episodeId))
);
}
export function WatchedContextProvider(props: { children: ReactNode }) {
const [watched, setWatched] = useStore(VideoProgressStore);
const contextValue = useMemo(
() => ({
removeProgress(id: string) {
setWatched((data: WatchedStoreData) => {
const newData = { ...data };
newData.items = newData.items.filter((v) => v.item.meta.id !== id);
return newData;
});
},
updateProgress(
media: StoreMediaItem,
progress: number,
total: number
): void {
setWatched((data: WatchedStoreData) => {
const newData = { ...data };
let item = newData.items.find((v) => isSameEpisode(media, v.item));
if (!item) {
item = {
item: {
...media,
meta: { ...media.meta },
series: media.series ? { ...media.series } : undefined,
},
progress: 0,
percentage: 0,
watchedAt: Date.now(),
};
newData.items.push(item);
}
// update actual item
item.progress = progress;
item.percentage = Math.round((progress / total) * 100);
item.watchedAt = Date.now();
// remove item if shouldnt save
if (!shouldSave(progress, total, !!media.series)) {
newData.items = data.items.filter(
(v) => !isSameEpisode(v.item, media)
);
}
return newData;
});
},
getFilteredWatched() {
let filtered = watched.items;
// get most recently watched for every single item
const alreadyFoundMedia: string[] = [];
filtered = filtered
.sort((a, b) => {
return b.watchedAt - a.watchedAt;
})
.filter((item) => {
const mediaId = item.item.meta.id;
if (alreadyFoundMedia.includes(mediaId)) return false;
alreadyFoundMedia.push(mediaId);
return true;
});
return filtered;
},
watched,
}),
[watched, setWatched]
);
return (
<WatchedContext.Provider value={contextValue as any}>
{props.children}
</WatchedContext.Provider>
);
}
export function useWatchedContext() {
return useContext(WatchedContext);
}
function isSameEpisodeMeta(
media: StoreMediaItem,
mediaTwo: DetailedMeta | null,
episodeId?: string
) {
if (mediaTwo?.meta.type === MWMediaType.SERIES && episodeId) {
return isSameEpisode(media, {
meta: mediaTwo.meta,
series: {
season: 0,
episode: 0,
episodeId,
seasonId: mediaTwo.meta.seasonData.id,
},
});
}
if (!mediaTwo) return () => false;
return isSameEpisode(media, { meta: mediaTwo.meta });
}
export function useWatchedItem(meta: DetailedMeta | null, episodeId?: string) {
const { watched, updateProgress } = useContext(WatchedContext);
const item = useMemo(
() => watched.items.find((v) => isSameEpisodeMeta(v.item, meta, episodeId)),
[watched, meta, episodeId]
);
const lastCommitedTime = useRef([0, 0]);
const callback = useCallback(
(progress: number, total: number) => {
const hasChanged =
lastCommitedTime.current[0] !== progress ||
lastCommitedTime.current[1] !== total;
if (meta && hasChanged) {
lastCommitedTime.current = [progress, total];
const obj = {
meta: meta.meta,
series:
meta.meta.type === MWMediaType.SERIES && episodeId
? {
seasonId: meta.meta.seasonData.id,
episodeId,
season: meta.meta.seasonData.number,
episode:
meta.meta.seasonData.episodes.find(
(ep) => ep.id === episodeId
)?.number || 0,
}
: undefined,
};
updateProgress(obj, progress, total);
}
},
[meta, updateProgress, episodeId]
);
return { updateProgress: callback, watchedItem: item };
}

View file

@ -1 +0,0 @@
export * from "./context";

View file

@ -1,8 +1,8 @@
import { MWMediaType } from "@/backend/metadata/types/mw";
import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks";
import { createVersionedStore } from "@/utils/storage";
import { BookmarkStoreData } from "./types";
import { createVersionedStore } from "../migrations";
import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2";
import { migrateV2Bookmarks } from "../watched/migrations/v3";

View file

@ -1,5 +1,3 @@
import { useEffect, useState } from "react";
interface StoreVersion<A> {
version: number;
migrate?(data: A): any;
@ -28,7 +26,7 @@ interface InternalStoreData {
const storeCallbacks: Record<string, ((data: any) => void)[]> = {};
const stores: Record<string, [StoreRet<any>, InternalStoreData]> = {};
export async function initializeStores() {
export async function initializeOldStores() {
// migrate all stores
for (const [store, internal] of Object.values(stores)) {
const versions = internal.versions.sort((a, b) => a.version - b.version);
@ -177,24 +175,3 @@ export function createVersionedStore<T>(): StoreBuilder<T> {
},
};
}
export function useStore<T>(
store: StoreRet<T>
): [T, (cb: (old: T) => T) => void] {
const [data, setData] = useState<T>(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];
}

View file

@ -0,0 +1,68 @@
import { useLanguageStore } from "@/stores/language";
import { useSubtitleStore } from "@/stores/subtitles";
import { MWSettingsData, MWSettingsDataV1 } from "./types";
import { createVersionedStore } from "../migrations";
export const SettingsStore = createVersionedStore<Record<never, never>>()
.setKey("mw-settings")
.addVersion({
version: 0,
create(): MWSettingsDataV1 {
return {
language: "en",
captionSettings: {
delay: 0,
style: {
color: "#ffffff",
fontSize: 25,
backgroundColor: "#00000096",
},
},
};
},
migrate(data: MWSettingsDataV1): MWSettingsData {
return {
language: data.language,
captionSettings: {
language: "none",
...data.captionSettings,
},
};
},
})
.addVersion({
version: 1,
migrate(old: MWSettingsData): Record<never, never> {
const langStore = useLanguageStore.getState();
const subtitleStore = useSubtitleStore.getState();
const backgroundColor = old.captionSettings.style.backgroundColor;
let backgroundOpacity = 0.5;
if (backgroundColor.length === 9) {
const opacitySplit = backgroundColor.slice(7); // '#' + 6 digits
backgroundOpacity = parseInt(opacitySplit, 16) / 255; // read as hex;
}
langStore.setLanguage(old.language);
subtitleStore.updateStyling({
backgroundOpacity,
color: old.captionSettings.style.color,
size: old.captionSettings.style.fontSize / 25,
});
subtitleStore.importSubtitleLanguage(
old.captionSettings.language === "none"
? null
: old.captionSettings.language
);
return {};
},
})
.addVersion({
version: 2,
create(): Record<never, never> {
return {};
},
})
.build();

View file

@ -0,0 +1,29 @@
import { useVolumeStore } from "@/stores/volume";
import { createVersionedStore } from "../migrations";
interface VolumeStoreData {
volume: number;
}
export const volumeStore = createVersionedStore<Record<never, never>>()
.setKey("mw-volume")
.addVersion({
version: 0,
create() {
return {
volume: 1,
};
},
migrate(data: VolumeStoreData): Record<never, never> {
useVolumeStore.getState().setVolume(data.volume);
return {};
},
})
.addVersion({
version: 1,
create() {
return {};
},
})
.build();

View file

@ -1,10 +1,10 @@
import { useProgressStore } from "@/stores/progress";
import { createVersionedStore } from "@/utils/storage";
import { OldData, migrateV2Videos } from "./migrations/v2";
import { migrateV3Videos } from "./migrations/v3";
import { migrateV4Videos } from "./migrations/v4";
import { WatchedStoreData } from "./types";
import { createVersionedStore } from "../migrations";
export const VideoProgressStore = createVersionedStore<WatchedStoreData>()
.setKey("video-progress")

View file

@ -0,0 +1,29 @@
import { useEffect } from "react";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import i18n from "@/setup/i18n";
export interface LanguageStore {
language: string;
setLanguage(v: string): void;
}
export const useLanguageStore = create(
immer<LanguageStore>((set) => ({
language: "en",
setLanguage(v) {
set((s) => {
s.language = v;
});
},
}))
);
export function useLanguageListener() {
const language = useLanguageStore((s) => s.language);
useEffect(() => {
i18n.changeLanguage(language);
}, [language]);
}

View file

@ -30,9 +30,9 @@ export interface SubtitleStore {
setCustomSubs(): void;
setOverrideCasing(enabled: boolean): void;
setDelay(delay: number): void;
importSubtitleLanguage(lang: string | null): void;
}
// TODO add migration from previous stored settings
export const useSubtitleStore = create(
persist(
immer<SubtitleStore>((set) => ({
@ -77,6 +77,11 @@ export const useSubtitleStore = create(
s.delay = Math.max(Math.min(500, delay), -500);
});
},
importSubtitleLanguage(lang) {
set((s) => {
s.lastSelectedLanguage = lang;
});
},
})),
{
name: "__MW::subtitles",