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 { processCdnLink } from "@/utils/cdn"; 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; } /* ** 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(); let 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 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) { ops.controller?.stop(); return; } let type = "video/mp4"; if (source.type === "hls") type = "application/x-mpegurl"; const metaData = new chrome.cast.media.GenericMediaMetadata(); metaData.title = meta.title; const mediaInfo = new chrome.cast.media.MediaInfo("video", type); (mediaInfo as any).contentUrl = processCdnLink(source.url); mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; 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; } const session = ops.instance.getCurrentSession(); session?.loadMedia(request); } 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); // start listening immediately const stopListening = listenForEvents(); return { on, off, getType() { return "casting"; }, destroy: () => { stopListening(); destroyVideoElement(); fscreen.removeEventListener("fullscreenchange", fullscreenChange); }, load(loadOps) { source = loadOps.source; emit("loading", true); startAt = loadOps.startAt; setSource(); }, changeQuality() { // cant control qualities }, setCaption(newCaption) { caption = newCaption; setSource(); }, processVideoElement(video) { destroyVideoElement(); videoElement = video; setSource(); }, processContainerElement(container) { containerElement = container; }, setMeta(data) { meta = data; setSource(); }, pause() { if (!isPaused) { ops.controller.playOrPause(); isPaused = true; } }, play() { if (isPaused) { ops.controller.playOrPause(); isPaused = false; } }, 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(); emit("volumechange", volume); } 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); } }, togglePictureInPicture() { // Can't PIP while Chromecasting }, startAirplay() { // cant airplay while chromecasting }, setPlaybackRate(rate) { playbackRate = rate; setSource(); }, }; }