From ef9eaf074ec541c25d0f5a50b26c689002d1e2d6 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 10 Dec 2023 12:58:45 +0100 Subject: [PATCH 1/4] Handle image rendering error for thumbnails --- src/components/player/internals/ThumbnailScraper.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/player/internals/ThumbnailScraper.tsx b/src/components/player/internals/ThumbnailScraper.tsx index 768ba181..4be50eac 100644 --- a/src/components/player/internals/ThumbnailScraper.tsx +++ b/src/components/player/internals/ThumbnailScraper.tsx @@ -92,6 +92,7 @@ class ThumnbnailWorker { ); const imgUrl = this.canvasEl.toDataURL(); if (this.interrupted) return; + if (imgUrl === "data:," || !imgUrl) return; // failed image rendering this.cb({ at, From 09da337362627494ae1677a4bfaa4e12f153c94a Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 10 Dec 2023 13:12:51 +0100 Subject: [PATCH 2/4] Focustrap workaround, pain --- src/components/overlays/OverlayDisplay.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/overlays/OverlayDisplay.tsx b/src/components/overlays/OverlayDisplay.tsx index 42ce9f61..bf6152fa 100644 --- a/src/components/overlays/OverlayDisplay.tsx +++ b/src/components/overlays/OverlayDisplay.tsx @@ -70,6 +70,11 @@ export function OverlayPortal(props: { className="absolute inset-0 pointer-events-none" isChild > + {/* a tabable index that does nothing - used so focus trap doesn't error when nothing is rendered yet */} +
{props.children}
From fac61d26da8539ae7edc91185f036b9c0736dacc Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 10 Dec 2023 17:12:05 +0100 Subject: [PATCH 3/4] Add chromecasting display interface Co-authored-by: William Oldham --- src/components/player/display/base.ts | 2 + src/components/player/display/chromecast.ts | 129 ++++++++++++++---- .../player/display/displayInterface.ts | 14 ++ .../player/internals/CastingInternal.tsx | 31 ++++- src/stores/player/slices/source.ts | 2 + 5 files changed, 152 insertions(+), 26 deletions(-) diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 3f44bf21..5cbf1045 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -282,6 +282,8 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { processContainerElement(container) { containerElement = container; }, + setMeta() {}, + setCaption() {}, pause() { videoElement?.pause(); diff --git a/src/components/player/display/chromecast.ts b/src/components/player/display/chromecast.ts index d823e468..c8bfc3c7 100644 --- a/src/components/player/display/chromecast.ts +++ b/src/components/player/display/chromecast.ts @@ -1,8 +1,11 @@ import fscreen from "fscreen"; +import { MWMediaType } from "@/backend/metadata/types/mw"; import { + DisplayCaption, DisplayInterface, DisplayInterfaceEvents, + DisplayMeta, } from "@/components/player/display/displayInterface"; import { LoadableSource } from "@/stores/player/utils/qualities"; import { @@ -18,13 +21,17 @@ export interface ChromeCastDisplayInterfaceOptions { instance: cast.framework.CastContext; } -// TODO check all functionality -// TODO listen for events to update the state +/* + ** Chromecasting is unfinished, here is its limitations: + ** 1. Captions - chromecast requires only VTT, but needs it from a URL. we only have SRT urls + ** 2. HLS - we've having some issues with content types. sometimes it loads, sometimes it doesn't + */ + export function makeChromecastDisplayInterface( ops: ChromeCastDisplayInterfaceOptions ): DisplayInterface { const { emit, on, off } = makeEmitter(); - const isPaused = false; + let isPaused = false; let playbackRate = 1; let source: LoadableSource | null = null; let videoElement: HTMLVideoElement | null = null; @@ -33,8 +40,64 @@ export function makeChromecastDisplayInterface( let isPausedBeforeSeeking = false; let isSeeking = false; let startAt = 0; - // let automaticQuality = false; - // let preferenceQuality: SourceQuality | null = null; + let meta: DisplayMeta = { + title: "", + type: MWMediaType.MOVIE, + }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let caption: DisplayCaption | null = null; + + function listenForEvents() { + const listen = async (e: cast.framework.RemotePlayerChangedEvent) => { + switch (e.field) { + case "volumeLevel": + if (await canChangeVolume()) emit("volumechange", e.value); + break; + case "currentTime": + emit("time", e.value); + break; + case "duration": + emit("duration", e.value ?? 0); + break; + case "mediaInfo": + if (e.value) emit("duration", e.value.duration ?? 0); + break; + case "playerState": + emit("loading", e.value === "BUFFERING"); + if (e.value === "PLAYING") emit("play", undefined); + else if (e.value === "PAUSED") emit("pause", undefined); + isPaused = e.value === "PAUSED"; + break; + case "isMuted": + emit("volumechange", e.value ? 1 : 0); + break; + case "displayStatus": + case "canSeek": + case "title": + case "isPaused": + case "canPause": + case "isMediaLoaded": + case "statusText": + case "isConnected": + case "displayName": + case "canControlVolume": + case "savedPlayerState": + break; + default: + break; + } + }; + ops.controller?.addEventListener( + cast.framework.RemotePlayerEventType.ANY_CHANGE, + listen + ); + return () => { + ops.controller?.removeEventListener( + cast.framework.RemotePlayerEventType.ANY_CHANGE, + listen + ); + }; + } function setupSource() { if (!source) { @@ -42,30 +105,33 @@ export function makeChromecastDisplayInterface( return; } - if (source.type === "hls") { - // TODO hls support - return; - } + let type = "video/mp4"; + if (source.type === "hls") type = "application/x-mpegurl"; - // TODO movie meta - const movieMeta = new chrome.cast.media.MovieMediaMetadata(); - movieMeta.title = ""; + const metaData = new chrome.cast.media.GenericMediaMetadata(); + metaData.title = meta.title; - const mediaInfo = new chrome.cast.media.MediaInfo("video", "video/mp4"); + const mediaInfo = new chrome.cast.media.MediaInfo("video", type); (mediaInfo as any).contentUrl = source.url; mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; - mediaInfo.metadata = movieMeta; + mediaInfo.metadata = metaData; mediaInfo.customData = { playbackRate, }; const request = new chrome.cast.media.LoadRequest(mediaInfo); request.autoplay = true; + request.currentTime = startAt; + + if (source.type === "hls") { + const staticMedia = chrome.cast.media as any; + const media = request.media as any; + media.hlsSegmentFormat = staticMedia.HlsSegmentFormat.FMP4; + media.hlsVideoSegmentFormat = staticMedia.HlsVideoSegmentFormat.FMP4; + } - ops.player.currentTime = startAt; const session = ops.instance.getCurrentSession(); session?.loadMedia(request); - ops.controller.seek(); } function setSource() { @@ -86,25 +152,29 @@ export function makeChromecastDisplayInterface( } fscreen.addEventListener("fullscreenchange", fullscreenChange); + // start listening immediately + const stopListening = listenForEvents(); + return { on, off, destroy: () => { + stopListening(); destroyVideoElement(); fscreen.removeEventListener("fullscreenchange", fullscreenChange); }, load(loadOps) { - // automaticQuality = loadOps.automaticQuality; - // preferenceQuality = loadOps.preferredQuality; source = loadOps.source; emit("loading", true); startAt = loadOps.startAt; setSource(); }, - changeQuality(_newAutomaticQuality, _newPreferredQuality) { - // if (source?.type !== "hls") return; - // automaticQuality = newAutomaticQuality; - // preferenceQuality = newPreferredQuality; + changeQuality() { + // cant control qualities + }, + setCaption(newCaption) { + caption = newCaption; + setSource(); }, processVideoElement(video) { @@ -115,12 +185,22 @@ export function makeChromecastDisplayInterface( processContainerElement(container) { containerElement = container; }, + setMeta(data) { + meta = data; + setSource(); + }, pause() { - if (!isPaused) ops.controller.playOrPause(); + if (!isPaused) { + ops.controller.playOrPause(); + isPaused = true; + } }, play() { - if (isPaused) ops.controller.playOrPause(); + if (isPaused) { + ops.controller.playOrPause(); + isPaused = false; + } }, setSeeking(active) { if (active === isSeeking) return; @@ -156,6 +236,7 @@ export function makeChromecastDisplayInterface( if (isChangeable) { ops.player.volumeLevel = volume; ops.controller.setVolumeLevel(); + emit("volumechange", volume); } else { // For browsers where it can't be changed emit("volumechange", volume === 0 ? 0 : 1); diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts index c4c33d0d..0b76fd79 100644 --- a/src/components/player/display/displayInterface.ts +++ b/src/components/player/display/displayInterface.ts @@ -1,3 +1,4 @@ +import { MWMediaType } from "@/backend/metadata/types/mw"; import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities"; import { Listener } from "@/utils/events"; @@ -34,6 +35,17 @@ export interface qualityChangeOptions { startAt: number; } +export interface DisplayMeta { + title: string; + type: MWMediaType; +} + +export interface DisplayCaption { + srtData: string; + language: string; + url?: string; +} + export interface DisplayInterface extends Listener { play(): void; pause(): void; @@ -52,4 +64,6 @@ export interface DisplayInterface extends Listener { destroy(): void; startAirplay(): void; setPlaybackRate(rate: number): void; + setMeta(meta: DisplayMeta): void; + setCaption(caption: DisplayCaption | null): void; } diff --git a/src/components/player/internals/CastingInternal.tsx b/src/components/player/internals/CastingInternal.tsx index 49d6d363..a46a1968 100644 --- a/src/components/player/internals/CastingInternal.tsx +++ b/src/components/player/internals/CastingInternal.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; +import { mediaItemTypeToMediaType } from "@/backend/metadata/tmdb"; import { makeVideoElementDisplayInterface } from "@/components/player/display/base"; import { makeChromecastDisplayInterface } from "@/components/player/display/chromecast"; import { useChromecastAvailable } from "@/hooks/useChromecastAvailable"; @@ -11,26 +12,39 @@ export function CastingInternal() { const setPlayer = usePlayerStore((s) => s.casting.setPlayer); const setIsCasting = usePlayerStore((s) => s.casting.setIsCasting); const isCasting = usePlayerStore((s) => s.interface.isCasting); + const caption = usePlayerStore((s) => s.caption?.selected); const setDisplay = usePlayerStore((s) => s.setDisplay); const redisplaySource = usePlayerStore((s) => s.redisplaySource); const available = useChromecastAvailable(); + const display = usePlayerStore((s) => s.display); const controller = usePlayerStore((s) => s.casting.controller); const player = usePlayerStore((s) => s.casting.player); const instance = usePlayerStore((s) => s.casting.instance); + const time = usePlayerStore((s) => s.progress.time); + const metaTitle = usePlayerStore((s) => s.meta?.title); + const metaType = usePlayerStore((s) => s.meta?.type); const dataRef = useRef({ controller, player, instance, + time, + metaTitle, + metaType, + caption, }); useEffect(() => { dataRef.current = { controller, player, instance, + time, + metaTitle, + metaType, + caption, }; - }, [controller, player, instance]); + }, [controller, player, instance, time, metaTitle, metaType, caption]); useEffect(() => { if (isCasting) { @@ -44,15 +58,28 @@ export function CastingInternal() { instance: dataRef.current.instance, player: dataRef.current.player, }); + newDisplay.setMeta({ + title: dataRef.current.metaTitle ?? "", + type: mediaItemTypeToMediaType(dataRef.current.metaType ?? "movie"), + }); + newDisplay.setCaption(dataRef.current.caption); setDisplay(newDisplay); - redisplaySource(0); // TODO right start time + redisplaySource(dataRef.current.time ?? 0); } } else { const newDisplay = makeVideoElementDisplayInterface(); setDisplay(newDisplay); + redisplaySource(dataRef.current.time ?? 0); } }, [isCasting, setDisplay, redisplaySource]); + useEffect(() => { + display?.setMeta({ + title: dataRef.current.metaTitle ?? "", + type: mediaItemTypeToMediaType(dataRef.current.metaType ?? "movie"), + }); + }, [metaTitle, metaType, display]); + useEffect(() => { if (!available) return; diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 9f1ea9a3..c210cbef 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -132,6 +132,8 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ }); }, setCaption(caption) { + const store = get(); + store.display?.setCaption(caption); set((s) => { s.caption.selected = caption; }); From 436a75d3f22c4bedf9d28ae4ba2e23d6a60ef4db Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sun, 10 Dec 2023 17:47:38 +0100 Subject: [PATCH 4/4] Add chromecasting UI --- src/assets/locales/en.json | 3 +++ .../player/atoms/CastingNotification.tsx | 22 +++++++++++++++++++ src/components/player/atoms/index.ts | 1 + src/components/player/base/SubtitleView.tsx | 4 +++- src/components/player/display/base.ts | 3 +++ src/components/player/display/chromecast.ts | 3 +++ .../player/display/displayInterface.ts | 3 +++ src/pages/parts/player/PlayerPart.tsx | 13 +++++++---- 8 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 src/components/player/atoms/CastingNotification.tsx diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 9e923ee0..71fb1d78 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -94,6 +94,9 @@ "failure": "Error occurred" } }, + "casting": { + "enabled": "Casting to device..." + }, "playbackError": { "badge": "Playback error", "title": "Failed to play video!", diff --git a/src/components/player/atoms/CastingNotification.tsx b/src/components/player/atoms/CastingNotification.tsx new file mode 100644 index 00000000..9c4e423b --- /dev/null +++ b/src/components/player/atoms/CastingNotification.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next"; + +import { Icon, Icons } from "@/components/Icon"; +import { usePlayerStore } from "@/stores/player/store"; + +export function CastingNotification() { + const { t } = useTranslation(); + const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); + const display = usePlayerStore((s) => s.display); + const isCasting = display?.getType() === "casting"; + + if (isLoading || !isCasting) return null; + + return ( +
+
+ +
+

{t("player.casting.enabled")}

+
+ ); +} diff --git a/src/components/player/atoms/index.ts b/src/components/player/atoms/index.ts index 111703d5..3caf8154 100644 --- a/src/components/player/atoms/index.ts +++ b/src/components/player/atoms/index.ts @@ -15,3 +15,4 @@ export * from "./Airplay"; export * from "./VolumeChangedPopout"; export * from "./NextEpisodeButton"; export * from "./Chromecast"; +export * from "./CastingNotification"; diff --git a/src/components/player/base/SubtitleView.tsx b/src/components/player/base/SubtitleView.tsx index c3f16a78..38520cac 100644 --- a/src/components/player/base/SubtitleView.tsx +++ b/src/components/player/base/SubtitleView.tsx @@ -107,8 +107,10 @@ export function SubtitleRenderer() { export function SubtitleView(props: { controlsShown: boolean }) { const caption = usePlayerStore((s) => s.caption.selected); const captionAsTrack = usePlayerStore((s) => s.caption.asTrack); + const display = usePlayerStore((s) => s.display); + const isCasting = display?.getType() === "casting"; - if (captionAsTrack || !caption) return null; + if (captionAsTrack || !caption || isCasting) return null; return ( { destroyVideoElement(); fscreen.removeEventListener("fullscreenchange", fullscreenChange); diff --git a/src/components/player/display/chromecast.ts b/src/components/player/display/chromecast.ts index c8bfc3c7..401fc6f8 100644 --- a/src/components/player/display/chromecast.ts +++ b/src/components/player/display/chromecast.ts @@ -158,6 +158,9 @@ export function makeChromecastDisplayInterface( return { on, off, + getType() { + return "casting"; + }, destroy: () => { stopListening(); destroyVideoElement(); diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts index 0b76fd79..b7a3e09d 100644 --- a/src/components/player/display/displayInterface.ts +++ b/src/components/player/display/displayInterface.ts @@ -46,6 +46,8 @@ export interface DisplayCaption { url?: string; } +export type DisplayType = "web" | "casting"; + export interface DisplayInterface extends Listener { play(): void; pause(): void; @@ -66,4 +68,5 @@ export interface DisplayInterface extends Listener { setPlaybackRate(rate: number): void; setMeta(meta: DisplayMeta): void; setCaption(caption: DisplayCaption | null): void; + getType(): DisplayType; } diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 14df6f31..48a25094 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -31,10 +31,15 @@ export function PlayerPart(props: PlayerPartProps) { {status === playerStatus.PLAYING ? ( - - - - + <> + + + + + + + + ) : null}