very rudementary chromecasting

This commit is contained in:
mrjvs 2023-10-20 23:24:37 +02:00
parent 43d4869f7e
commit 18b434c9ac
5 changed files with 254 additions and 10 deletions

View File

@ -176,7 +176,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
} }
function unloadSource() { function unloadSource() {
if (videoElement) videoElement.removeAttribute("src"); if (videoElement) videoElement.src = "";
if (hls) { if (hls) {
hls.destroy(); hls.destroy();
hls = null; hls = null;

View File

@ -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<DisplayInterfaceEvents>();
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();
},
};
}

View File

@ -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 { useChromecastAvailable } from "@/hooks/useChromecastAvailable";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
@ -8,8 +10,49 @@ export function CastingInternal() {
const setController = usePlayerStore((s) => s.casting.setController); const setController = usePlayerStore((s) => s.casting.setController);
const setPlayer = usePlayerStore((s) => s.casting.setPlayer); const setPlayer = usePlayerStore((s) => s.casting.setPlayer);
const setIsCasting = usePlayerStore((s) => s.casting.setIsCasting); 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 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(() => { useEffect(() => {
if (!available) return; if (!available) return;
@ -20,23 +63,23 @@ export function CastingInternal() {
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
}); });
const player = new cast.framework.RemotePlayer(); const newPlayer = new cast.framework.RemotePlayer();
setPlayer(player); setPlayer(newPlayer);
const controller = new cast.framework.RemotePlayerController(player); const newControlller = new cast.framework.RemotePlayerController(newPlayer);
setController(controller); setController(newControlller);
function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) { function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) {
if (e.field === "isConnected") { if (e.field === "isConnected") {
setIsCasting(e.value); setIsCasting(e.value);
} }
} }
controller.addEventListener( newControlller.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
connectionChanged connectionChanged
); );
return () => { return () => {
controller.removeEventListener( newControlller.removeEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
connectionChanged connectionChanged
); );

View File

@ -3,7 +3,6 @@ import { ReactNode } from "react";
import { BrandPill } from "@/components/layout/BrandPill"; import { BrandPill } from "@/components/layout/BrandPill";
import { Player } from "@/components/player"; import { Player } from "@/components/player";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { useChromecastAvailable } from "@/hooks/useChromecastAvailable";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";

View File

@ -63,6 +63,7 @@ export interface SourceSlice {
setCaption(caption: Caption | null): void; setCaption(caption: Caption | null): void;
setSourceId(id: string | null): void; setSourceId(id: string | null): void;
enableAutomaticQuality(): void; enableAutomaticQuality(): void;
redisplaySource(startAt: number): void;
} }
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia { export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
@ -123,7 +124,6 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
setSource(stream: SourceSliceSource, startAt: number) { setSource(stream: SourceSliceSource, startAt: number) {
let qualities: string[] = []; let qualities: string[] = [];
if (stream.type === "file") qualities = Object.keys(stream.qualities); if (stream.type === "file") qualities = Object.keys(stream.qualities);
const store = get();
const qualityPreferences = useQualityStore.getState(); const qualityPreferences = useQualityStore.getState();
const loadableStream = selectQuality(stream, qualityPreferences.quality); const loadableStream = selectQuality(stream, qualityPreferences.quality);
@ -132,6 +132,18 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.qualities = qualities as SourceQuality[]; s.qualities = qualities as SourceQuality[];
s.currentQuality = loadableStream.quality; 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({ store.display?.load({
source: loadableStream.stream, source: loadableStream.stream,