mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
very rudementary chromecasting
This commit is contained in:
parent
43d4869f7e
commit
18b434c9ac
5 changed files with 254 additions and 10 deletions
|
@ -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;
|
||||||
|
|
190
src/components/player/display/chromecast.ts
Normal file
190
src/components/player/display/chromecast.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue