diff --git a/src/components/media/WatchedMediaCard.tsx b/src/components/media/WatchedMediaCard.tsx index fa68a071..0e66e235 100644 --- a/src/components/media/WatchedMediaCard.tsx +++ b/src/components/media/WatchedMediaCard.tsx @@ -14,8 +14,9 @@ export interface WatchedMediaCardProps { function formatSeries(obj: ProgressMediaItem | undefined) { if (!obj) return undefined; if (obj.type !== "show") return; - // TODO only show latest episode watched - const ep = Object.values(obj.episodes)[0]; + const ep = Object.values(obj.episodes).sort( + (a, b) => b.updatedAt - a.updatedAt + )[0]; const season = obj.seasons[ep?.seasonId]; if (!ep || !season) return; return { diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 731f9d03..051e2ea3 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -6,6 +6,7 @@ import { DisplayInterfaceEvents, } from "@/components/player/display/displayInterface"; import { handleBuffered } from "@/components/player/utils/handleBuffered"; +import { getMediaErrorDetails } from "@/components/player/utils/mediaErrorDetails"; import { LoadableSource, SourceQuality, @@ -119,9 +120,12 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { hls.on(Hls.Events.ERROR, (event, data) => { console.error("HLS error", data); if (data.fatal) { - throw new Error( - `HLS ERROR:${data.error?.message ?? "Something went wrong"}` - ); + emit("error", { + message: data.error.message, + stackTrace: data.error.stack, + errorName: data.error.name, + type: "hls", + }); } }); hls.on(Hls.Events.MANIFEST_LOADED, () => { @@ -154,6 +158,15 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { emit("play", undefined); emit("loading", false); }); + videoElement.addEventListener("error", () => { + const err = videoElement?.error ?? null; + const errorDetails = getMediaErrorDetails(err); + emit("error", { + errorName: errorDetails.name, + message: errorDetails.message, + type: "htmlvideo", + }); + }); videoElement.addEventListener("playing", () => emit("play", undefined)); videoElement.addEventListener("pause", () => emit("pause", undefined)); videoElement.addEventListener("canplay", () => emit("loading", false)); diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts index 67284d73..1139c32a 100644 --- a/src/components/player/display/displayInterface.ts +++ b/src/components/player/display/displayInterface.ts @@ -1,6 +1,14 @@ import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities"; import { Listener } from "@/utils/events"; +export type DisplayErrorType = "hls" | "htmlvideo"; +export type DisplayError = { + stackTrace?: string; + message: string; + errorName: string; + type: DisplayErrorType; +}; + export type DisplayInterfaceEvents = { play: void; pause: void; @@ -15,6 +23,7 @@ export type DisplayInterfaceEvents = { needstrack: boolean; canairplay: boolean; playbackrate: number; + error: DisplayError; }; export interface qualityChangeOptions { diff --git a/src/components/player/utils/mediaErrorDetails.ts b/src/components/player/utils/mediaErrorDetails.ts new file mode 100644 index 00000000..646585f7 --- /dev/null +++ b/src/components/player/utils/mediaErrorDetails.ts @@ -0,0 +1,36 @@ +const mediaErrorMap: Record = { + 1: { + name: "MEDIA_ERR_ABORTED", + message: + "The fetching of the associated resource was aborted by the user's request.", + }, + 2: { + name: "MEDIA_ERR_NETWORK", + message: + "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.", + }, + 3: { + name: "MEDIA_ERR_DECODE", + message: + "Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error.", + }, + 4: { + name: "MEDIA_ERR_SRC_NOT_SUPPORTED", + message: + "The associated resource or media provider object has been found to be unsuitable.", + }, +}; + +export function getMediaErrorDetails(err: MediaError | null): { + name: string; + message: string; +} { + const item = mediaErrorMap[err?.code ?? -1]; + if (!item) { + return { + name: "MediaError", + message: "Unknown media error occured", + }; + } + return item; +} diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index a1cd3a7c..c4b76e34 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -10,6 +10,7 @@ import { convertRunoutputToSource } from "@/components/player/utils/convertRunou import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape"; import { useQueryParam } from "@/hooks/useQueryParams"; import { MetaPart } from "@/pages/parts/player/MetaPart"; +import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { ScrapeErrorPart } from "@/pages/parts/player/ScrapeErrorPart"; import { ScrapingPart } from "@/pages/parts/player/ScrapingPart"; @@ -108,6 +109,7 @@ export function PlayerView() { {status === playerStatus.SCRAPE_NOT_FOUND && errorData ? ( ) : null} + {status === playerStatus.PLAYBACK_ERROR ? : null} ); } diff --git a/src/pages/developer/VideoTesterView.tsx b/src/pages/developer/VideoTesterView.tsx index b840cb52..258b4615 100644 --- a/src/pages/developer/VideoTesterView.tsx +++ b/src/pages/developer/VideoTesterView.tsx @@ -5,6 +5,7 @@ import { Dropdown } from "@/components/Dropdown"; import { usePlayer } from "@/components/player/hooks/usePlayer"; import { Title } from "@/components/text/Title"; import { TextInputControl } from "@/components/text-inputs/TextInputControl"; +import { PlaybackErrorPart } from "@/pages/parts/player/PlaybackErrorPart"; import { PlayerPart } from "@/pages/parts/player/PlayerPart"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { SourceSliceSource, StreamType } from "@/stores/player/utils/qualities"; @@ -105,6 +106,7 @@ export default function VideoTesterView() { ) : null} + {status === playerStatus.PLAYBACK_ERROR ? : null} ); } diff --git a/src/pages/parts/player/PlaybackErrorPart.tsx b/src/pages/parts/player/PlaybackErrorPart.tsx new file mode 100644 index 00000000..b5bf9573 --- /dev/null +++ b/src/pages/parts/player/PlaybackErrorPart.tsx @@ -0,0 +1,56 @@ +import { Button } from "@/components/Button"; +import { Icon, Icons } from "@/components/Icon"; +import { IconPill } from "@/components/layout/IconPill"; +import { Paragraph } from "@/components/text/Paragraph"; +import { Title } from "@/components/text/Title"; +import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout"; +import { usePlayerStore } from "@/stores/player/store"; + +export function PlaybackErrorPart() { + const playbackError = usePlayerStore((s) => s.interface.error); + + return ( + + + Not found + Goo goo gaa gaa + + Oh, my apowogies, sweetie! The itty-bitty movie-web did its utmost + bestest, but alas, no wucky videos to be spotted anywhere (ยดโŠ™ฯ‰โŠ™`) + Please don't be angwy, wittle movie-web ish twying so hard. Can + you find it in your heart to forgive? UwU ๐Ÿ’– + + + + + {/* Error */} + {playbackError ? ( +
+
+ Error details +
+ + +
+
+
+ {playbackError.message} +
+
+ ) : null} +
+
+ ); +} diff --git a/src/state/bookmark/store.ts b/src/state/bookmark/store.ts index b2020020..3d68afec 100644 --- a/src/state/bookmark/store.ts +++ b/src/state/bookmark/store.ts @@ -1,9 +1,17 @@ +import { MWMediaType } from "@/backend/metadata/types/mw"; +import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks"; import { createVersionedStore } from "@/utils/storage"; import { BookmarkStoreData } from "./types"; import { OldBookmarks, migrateV1Bookmarks } from "../watched/migrations/v2"; import { migrateV2Bookmarks } from "../watched/migrations/v3"; +const typeMap: Record = { + [MWMediaType.ANIME]: null, + [MWMediaType.MOVIE]: "movie", + [MWMediaType.SERIES]: "show", +}; + export const BookmarkStore = createVersionedStore() .setKey("mw-bookmarks") .addVersion({ @@ -20,6 +28,28 @@ export const BookmarkStore = createVersionedStore() }) .addVersion({ version: 2, + migrate(old: BookmarkStoreData): BookmarkStoreData { + const newItems: Record = {}; + + for (const oldBookmark of old.bookmarks) { + const type = typeMap[oldBookmark.type]; + if (!type) continue; + newItems[oldBookmark.id] = { + title: oldBookmark.title, + year: oldBookmark.year ? Number(oldBookmark.year) : undefined, + poster: oldBookmark.poster, + type, + updatedAt: Date.now(), + }; + } + + useBookmarkStore.getState().replaceBookmarks(newItems); + + return { bookmarks: [] }; + }, + }) + .addVersion({ + version: 3, create() { return { bookmarks: [], diff --git a/src/state/watched/store.ts b/src/state/watched/store.ts index c11e3f59..3d45f879 100644 --- a/src/state/watched/store.ts +++ b/src/state/watched/store.ts @@ -1,3 +1,5 @@ +import { MWMediaType } from "@/backend/metadata/types/mw"; +import { ProgressMediaItem, useProgressStore } from "@/stores/progress"; import { createVersionedStore } from "@/utils/storage"; import { OldData, migrateV2Videos } from "./migrations/v2"; @@ -28,6 +30,93 @@ export const VideoProgressStore = createVersionedStore() }) .addVersion({ version: 3, + migrate(old: WatchedStoreData): WatchedStoreData { + console.log(old); + + // Convert items + const newItems: Record = {}; + + for (const oldItem of old.items) { + if (oldItem.item.meta.type === MWMediaType.SERIES) { + // Upsert + if (!newItems[oldItem.item.meta.id]) { + newItems[oldItem.item.meta.id] = { + type: "show", + episodes: {}, + seasons: {}, + title: oldItem.item.meta.title, + updatedAt: oldItem.watchedAt, + poster: oldItem.item.meta.poster, + year: Number(oldItem.item.meta.year), + }; + } + + // Add episodes + if ( + oldItem.item.series && + !newItems[oldItem.item.meta.id].episodes[ + oldItem.item.series.episodeId + ] + ) { + // Find episode ID (barely ever works) + const episodeTitle = oldItem.item.meta.seasonData.episodes.find( + (ep) => ep.id === oldItem.item.series?.episodeId + )?.title; + + // Add season to season data + newItems[oldItem.item.meta.id].seasons[ + oldItem.item.series.seasonId + ] = { + id: oldItem.item.series.seasonId, + number: oldItem.item.series.season, + title: + oldItem.item.meta.seasons.find( + (s) => s.number === oldItem.item.series?.season + )?.title || "Unknown season", + }; + + // Populate episode data + newItems[oldItem.item.meta.id].episodes[ + oldItem.item.series.episodeId + ] = { + title: episodeTitle || "Unknown", + id: oldItem.item.series.episodeId, + number: oldItem.item.series.episode, + seasonId: oldItem.item.series.seasonId, + updatedAt: oldItem.watchedAt, + progress: { + duration: (100 / oldItem.percentage) * oldItem.progress, + watched: oldItem.progress, + }, + }; + } + } else { + newItems[oldItem.item.meta.id] = { + type: "movie", + episodes: {}, + seasons: {}, + title: oldItem.item.meta.title, + updatedAt: oldItem.watchedAt, + year: Number(oldItem.item.meta.year), + poster: oldItem.item.meta.poster, + progress: { + duration: (100 / oldItem.percentage) * oldItem.progress, + watched: oldItem.progress, + }, + }; + } + } + + console.log(newItems); + useProgressStore.getState().replaceItems(newItems); + + return { + items: [], + }; + }, + }) + .addVersion({ + version: 4, create() { return { items: [], diff --git a/src/stores/bookmarks/index.ts b/src/stores/bookmarks/index.ts index 96b1931c..d45bfcf9 100644 --- a/src/stores/bookmarks/index.ts +++ b/src/stores/bookmarks/index.ts @@ -6,7 +6,7 @@ import { PlayerMeta } from "@/stores/player/slices/source"; export interface BookmarkMediaItem { title: string; - year: number; + year?: number; poster?: string; type: "show" | "movie"; updatedAt: number; @@ -16,9 +16,9 @@ export interface ProgressStore { bookmarks: Record; addBookmark(meta: PlayerMeta): void; removeBookmark(id: string): void; + replaceBookmarks(items: Record): void; } -// TODO add migration from previous bookmark store export const useBookmarkStore = create( persist( immer((set) => ({ @@ -39,6 +39,11 @@ export const useBookmarkStore = create( }; }); }, + replaceBookmarks(items: Record) { + set((s) => { + s.bookmarks = items; + }); + }, })), { name: "__MW::bookmarks", diff --git a/src/stores/player/slices/display.ts b/src/stores/player/slices/display.ts index 06885589..ffaeb5af 100644 --- a/src/stores/player/slices/display.ts +++ b/src/stores/player/slices/display.ts @@ -90,6 +90,12 @@ export const createDisplaySlice: MakeSlice = (set, get) => ({ s.mediaPlaying.playbackRate = rate; }); }); + newDisplay.on("error", (err) => { + set((s) => { + s.status = playerStatus.PLAYBACK_ERROR; + s.interface.error = err; + }); + }); set((s) => { s.display = newDisplay; diff --git a/src/stores/player/slices/interface.ts b/src/stores/player/slices/interface.ts index e3302f9b..0787d0fe 100644 --- a/src/stores/player/slices/interface.ts +++ b/src/stores/player/slices/interface.ts @@ -1,3 +1,4 @@ +import { DisplayError } from "@/components/player/display/displayInterface"; import { MakeSlice } from "@/stores/player/slices/types"; export enum VideoPlayerTimeFormat { @@ -23,6 +24,7 @@ export interface InterfaceSlice { isCasting: boolean; hideNextEpisodeBtn: boolean; shouldStartFromBeginning: boolean; + error?: DisplayError; volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently? volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig" diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 40ad3800..9cc5298c 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -14,6 +14,7 @@ export const playerStatus = { SCRAPING: "scraping", PLAYING: "playing", SCRAPE_NOT_FOUND: "scrapeNotFound", + PLAYBACK_ERROR: "playbackError", } as const; export type PlayerStatus = ValuesOf; diff --git a/src/stores/progress/index.ts b/src/stores/progress/index.ts index a52cd978..5f3e85b5 100644 --- a/src/stores/progress/index.ts +++ b/src/stores/progress/index.ts @@ -20,12 +20,13 @@ export interface ProgressEpisodeItem { number: number; id: string; seasonId: string; + updatedAt: number; progress: ProgressItem; } export interface ProgressMediaItem { title: string; - year: number; + year?: number; poster?: string; type: "show" | "movie"; progress?: ProgressItem; @@ -43,9 +44,9 @@ export interface ProgressStore { items: Record; updateItem(ops: UpdateItemOptions): void; removeItem(id: string): void; + replaceItems(items: Record): void; } -// TODO add migration from previous progress store export const useProgressStore = create( persist( immer((set) => ({ @@ -55,6 +56,11 @@ export const useProgressStore = create( delete s.items[id]; }); }, + replaceItems(items: Record) { + set((s) => { + s.items = items; + }); + }, updateItem({ meta, progress }) { set((s) => { if (!s.items[meta.tmdbId]) @@ -95,6 +101,7 @@ export const useProgressStore = create( number: meta.episode.number, title: meta.episode.title, seasonId: meta.season.tmdbId, + updatedAt: Date.now(), progress: { duration: 0, watched: 0, diff --git a/src/utils/mediaTypes.ts b/src/utils/mediaTypes.ts index 6165f016..f577ca5f 100644 --- a/src/utils/mediaTypes.ts +++ b/src/utils/mediaTypes.ts @@ -1,7 +1,7 @@ export interface MediaItem { id: string; title: string; - year: number; + year?: number; poster?: string; type: "show" | "movie"; }