From 18b434c9ac88824b7e77b05f5f8738afab6f4f8d Mon Sep 17 00:00:00 2001 From: mrjvs Date: Fri, 20 Oct 2023 23:24:37 +0200 Subject: [PATCH] very rudementary chromecasting --- src/components/player/display/base.ts | 2 +- src/components/player/display/chromecast.ts | 190 ++++++++++++++++++ .../player/internals/CastingInternal.tsx | 57 +++++- src/pages/parts/player/PlayerPart.tsx | 1 - src/stores/player/slices/source.ts | 14 +- 5 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 src/components/player/display/chromecast.ts diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index 1fe5f9c1..2b30a0f9 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -176,7 +176,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface { } function unloadSource() { - if (videoElement) videoElement.removeAttribute("src"); + if (videoElement) videoElement.src = ""; if (hls) { hls.destroy(); hls = null; diff --git a/src/components/player/display/chromecast.ts b/src/components/player/display/chromecast.ts new file mode 100644 index 00000000..3c9ed562 --- /dev/null +++ b/src/components/player/display/chromecast.ts @@ -0,0 +1,190 @@ +import fscreen from "fscreen"; + +import { + DisplayInterface, + DisplayInterfaceEvents, +} from "@/components/player/display/displayInterface"; +import { LoadableSource } from "@/stores/player/utils/qualities"; +import { + canChangeVolume, + canFullscreen, + canFullscreenAnyElement, +} from "@/utils/detectFeatures"; +import { makeEmitter } from "@/utils/events"; + +export interface ChromeCastDisplayInterfaceOptions { + controller: cast.framework.RemotePlayerController; + player: cast.framework.RemotePlayer; + instance: cast.framework.CastContext; +} + +// TODO check all functionality +// TODO listen for events to update the state +export function makeChromecastDisplayInterface( + ops: ChromeCastDisplayInterfaceOptions +): DisplayInterface { + const { emit, on, off } = makeEmitter(); + const isPaused = false; + let playbackRate = 1; + let source: LoadableSource | null = null; + let videoElement: HTMLVideoElement | null = null; + let containerElement: HTMLElement | null = null; + let isFullscreen = false; + let isPausedBeforeSeeking = false; + let isSeeking = false; + let startAt = 0; + // let automaticQuality = false; + // let preferenceQuality: SourceQuality | null = null; + + function setupSource() { + if (!source) { + ops.controller?.stop(); + return; + } + + if (source.type === "hls") { + // TODO hls support + return; + } + + // TODO movie meta + const movieMeta = new chrome.cast.media.MovieMediaMetadata(); + movieMeta.title = ""; + + const mediaInfo = new chrome.cast.media.MediaInfo("video", "video/mp4"); + (mediaInfo as any).contentUrl = source.url; + mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; + mediaInfo.metadata = movieMeta; + mediaInfo.customData = { + playbackRate, + }; + + const request = new chrome.cast.media.LoadRequest(mediaInfo); + request.autoplay = true; + + ops.player.currentTime = startAt; + const session = ops.instance.getCurrentSession(); + session?.loadMedia(request); + ops.controller.seek(); + } + + function setSource() { + if (!videoElement || !source) return; + setupSource(); + } + + function destroyVideoElement() { + if (videoElement) videoElement = null; + } + + function fullscreenChange() { + isFullscreen = + !!document.fullscreenElement || // other browsers + !!(document as any).webkitFullscreenElement; // safari + emit("fullscreen", isFullscreen); + if (!isFullscreen) emit("needstrack", false); + } + fscreen.addEventListener("fullscreenchange", fullscreenChange); + + return { + on, + off, + destroy: () => { + 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; + }, + + processVideoElement(video) { + destroyVideoElement(); + videoElement = video; + setSource(); + }, + processContainerElement(container) { + containerElement = container; + }, + + pause() { + if (!isPaused) ops.controller.playOrPause(); + }, + play() { + if (isPaused) ops.controller.playOrPause(); + }, + setSeeking(active) { + if (active === isSeeking) return; + isSeeking = active; + + // if it was playing when starting to seek, play again + if (!active) { + if (!isPausedBeforeSeeking) this.play(); + return; + } + + isPausedBeforeSeeking = isPaused ?? true; + this.pause(); + }, + setTime(t) { + if (!videoElement) return; + // clamp time between 0 and max duration + let time = Math.min(t, ops.player.duration); + time = Math.max(0, time); + + if (Number.isNaN(time)) return; + emit("time", time); + ops.player.currentTime = time; + ops.controller.seek(); + }, + async setVolume(v) { + // clamp time between 0 and 1 + let volume = Math.min(v, 1); + volume = Math.max(0, volume); + + // update state + const isChangeable = await canChangeVolume(); + if (isChangeable) { + ops.player.volumeLevel = volume; + ops.controller.setVolumeLevel(); + } else { + // For browsers where it can't be changed + emit("volumechange", volume === 0 ? 0 : 1); + } + }, + toggleFullscreen() { + if (isFullscreen) { + isFullscreen = false; + emit("fullscreen", isFullscreen); + emit("needstrack", false); + if (!fscreen.fullscreenElement) return; + fscreen.exitFullscreen(); + return; + } + + // enter fullscreen + isFullscreen = true; + emit("fullscreen", isFullscreen); + if (!canFullscreen() || fscreen.fullscreenElement) return; + if (canFullscreenAnyElement()) { + if (containerElement) fscreen.requestFullscreen(containerElement); + } + }, + startAirplay() { + // cant airplay while chromecasting + }, + setPlaybackRate(rate) { + playbackRate = rate; + setSource(); + }, + }; +} diff --git a/src/components/player/internals/CastingInternal.tsx b/src/components/player/internals/CastingInternal.tsx index 5afa1497..49d6d363 100644 --- a/src/components/player/internals/CastingInternal.tsx +++ b/src/components/player/internals/CastingInternal.tsx @@ -1,5 +1,7 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; +import { makeVideoElementDisplayInterface } from "@/components/player/display/base"; +import { makeChromecastDisplayInterface } from "@/components/player/display/chromecast"; import { useChromecastAvailable } from "@/hooks/useChromecastAvailable"; import { usePlayerStore } from "@/stores/player/store"; @@ -8,8 +10,49 @@ export function CastingInternal() { const setController = usePlayerStore((s) => s.casting.setController); const setPlayer = usePlayerStore((s) => s.casting.setPlayer); const setIsCasting = usePlayerStore((s) => s.casting.setIsCasting); + const isCasting = usePlayerStore((s) => s.interface.isCasting); + const setDisplay = usePlayerStore((s) => s.setDisplay); + const redisplaySource = usePlayerStore((s) => s.redisplaySource); const available = useChromecastAvailable(); + const controller = usePlayerStore((s) => s.casting.controller); + const player = usePlayerStore((s) => s.casting.player); + const instance = usePlayerStore((s) => s.casting.instance); + + const dataRef = useRef({ + controller, + player, + instance, + }); + useEffect(() => { + dataRef.current = { + controller, + player, + instance, + }; + }, [controller, player, instance]); + + useEffect(() => { + if (isCasting) { + if ( + dataRef.current.controller && + dataRef.current.instance && + dataRef.current.player + ) { + const newDisplay = makeChromecastDisplayInterface({ + controller: dataRef.current.controller, + instance: dataRef.current.instance, + player: dataRef.current.player, + }); + setDisplay(newDisplay); + redisplaySource(0); // TODO right start time + } + } else { + const newDisplay = makeVideoElementDisplayInterface(); + setDisplay(newDisplay); + } + }, [isCasting, setDisplay, redisplaySource]); + useEffect(() => { if (!available) return; @@ -20,23 +63,23 @@ export function CastingInternal() { autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, }); - const player = new cast.framework.RemotePlayer(); - setPlayer(player); - const controller = new cast.framework.RemotePlayerController(player); - setController(controller); + const newPlayer = new cast.framework.RemotePlayer(); + setPlayer(newPlayer); + const newControlller = new cast.framework.RemotePlayerController(newPlayer); + setController(newControlller); function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) { if (e.field === "isConnected") { setIsCasting(e.value); } } - controller.addEventListener( + newControlller.addEventListener( cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, connectionChanged ); return () => { - controller.removeEventListener( + newControlller.removeEventListener( cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, connectionChanged ); diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 4c8aae82..67664948 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -3,7 +3,6 @@ import { ReactNode } from "react"; import { BrandPill } from "@/components/layout/BrandPill"; import { Player } from "@/components/player"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; -import { useChromecastAvailable } from "@/hooks/useChromecastAvailable"; import { useIsMobile } from "@/hooks/useIsMobile"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 7d1b0e61..1d95795d 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -63,6 +63,7 @@ export interface SourceSlice { setCaption(caption: Caption | null): void; setSourceId(id: string | null): void; enableAutomaticQuality(): void; + redisplaySource(startAt: number): void; } export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia { @@ -123,7 +124,6 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ setSource(stream: SourceSliceSource, startAt: number) { let qualities: string[] = []; if (stream.type === "file") qualities = Object.keys(stream.qualities); - const store = get(); const qualityPreferences = useQualityStore.getState(); const loadableStream = selectQuality(stream, qualityPreferences.quality); @@ -132,6 +132,18 @@ export const createSourceSlice: MakeSlice = (set, get) => ({ s.qualities = qualities as SourceQuality[]; s.currentQuality = loadableStream.quality; }); + const store = get(); + store.redisplaySource(startAt); + }, + redisplaySource(startAt: number) { + const store = get(); + const quality = store.currentQuality; + if (!store.source) return; + const qualityPreferences = useQualityStore.getState(); + const loadableStream = selectQuality(store.source, { + automaticQuality: qualityPreferences.quality.automaticQuality, + lastChosenQuality: quality, + }); store.display?.load({ source: loadableStream.stream,