diff --git a/src/components/player/atoms/Chromecast.tsx b/src/components/player/atoms/Chromecast.tsx index a3d278e8..e73fdb8a 100644 --- a/src/components/player/atoms/Chromecast.tsx +++ b/src/components/player/atoms/Chromecast.tsx @@ -4,12 +4,24 @@ import { Icons } from "@/components/Icon"; import { VideoPlayerButton } from "@/components/player/internals/Button"; import { usePlayerStore } from "@/stores/player/store"; +// Allow the custom element in TSX without adding a global d.ts file +/* eslint-disable @typescript-eslint/no-namespace */ +declare global { + namespace JSX { + interface IntrinsicElements { + "google-cast-launcher": any; + } + } +} +/* eslint-enable @typescript-eslint/no-namespace */ + export interface ChromecastProps { className?: string; } export function Chromecast(props: ChromecastProps) { const [hidden, setHidden] = useState(false); + const [castHidden, setCastHidden] = useState(false); const isCasting = usePlayerStore((s) => s.interface.isCasting); const ref = useRef(null); @@ -37,6 +49,33 @@ export function Chromecast(props: ChromecastProps) { }; }, [setButtonVisibility]); + // Hide the button when there are no cast devices available according to CAF + useEffect(() => { + const w = window as any; + const cast = w?.cast; + if (!cast?.framework) return; + + const context = cast.framework.CastContext.getInstance(); + const update = () => { + const state = context.getCastState(); + setCastHidden(state === cast.framework.CastState.NO_DEVICES_AVAILABLE); + }; + const handler = () => update(); + + context.addEventListener( + cast.framework.CastContextEventType.CAST_STATE_CHANGED, + handler, + ); + update(); + + return () => { + context.removeEventListener( + cast.framework.CastContextEventType.CAST_STATE_CHANGED, + handler, + ); + }; + }, []); + return ( ); } diff --git a/src/components/player/display/chromecast.ts b/src/components/player/display/chromecast.ts index 48f8b2ab..9ea243f4 100644 --- a/src/components/player/display/chromecast.ts +++ b/src/components/player/display/chromecast.ts @@ -1,3 +1,5 @@ +/// + import fscreen from "fscreen"; import { MWMediaType } from "@/backend/metadata/types/mw"; @@ -9,11 +11,7 @@ import { } 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 { canFullscreen, canFullscreenAnyElement } from "@/utils/detectFeatures"; import { makeEmitter } from "@/utils/events"; export interface ChromeCastDisplayInterfaceOptions { @@ -49,10 +47,10 @@ export function makeChromecastDisplayInterface( let caption: DisplayCaption | null = null; function listenForEvents() { - const listen = async (e: cast.framework.RemotePlayerChangedEvent) => { + const listen = (e: cast.framework.RemotePlayerChangedEvent) => { switch (e.field) { case "volumeLevel": - if (await canChangeVolume()) emit("volumechange", e.value); + emit("volumechange", e.value); break; case "currentTime": emit("time", e.value); @@ -70,7 +68,7 @@ export function makeChromecastDisplayInterface( isPaused = e.value === "PAUSED"; break; case "isMuted": - emit("volumechange", e.value ? 1 : 0); + emit("volumechange", e.value ? 0 : ops.player.volumeLevel); break; case "displayStatus": case "canSeek": @@ -112,31 +110,62 @@ export function makeChromecastDisplayInterface( 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); + const contentUrl = processCdnLink(source.url); + const mediaInfo = new chrome.cast.media.MediaInfo(contentUrl, type); mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; mediaInfo.metadata = metaData; mediaInfo.customData = { playbackRate, }; + // Add basic VTT captions support if a caption URL is provided + if (caption?.url) { + try { + const textTrack = new chrome.cast.media.Track( + 1, + chrome.cast.media.TrackType.TEXT, + ); + textTrack.trackContentType = "text/vtt"; + textTrack.trackContentId = caption.url; + textTrack.language = caption.language; + textTrack.name = caption.language || "Subtitles"; + textTrack.subtype = chrome.cast.media.TextTrackType.SUBTITLES; + mediaInfo.tracks = [textTrack]; + } catch { + // ignore track creation errors + } + } + const request = new chrome.cast.media.LoadRequest(mediaInfo); request.autoplay = true; request.currentTime = startAt; + if (caption?.url) request.activeTrackIds = [1]; 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; + (mediaInfo as any).hlsSegmentFormat = staticMedia.HlsSegmentFormat.FMP4; + (mediaInfo as any).hlsVideoSegmentFormat = + staticMedia.HlsVideoSegmentFormat.FMP4; } const session = ops.instance.getCurrentSession(); - session?.loadMedia(request); + session + ?.loadMedia(request) + .then(() => { + emit("loading", false); + }) + .catch((err: unknown) => { + emit("loading", false); + emit("error", { + type: "global", + errorName: "chromecast_load_failure", + message: (err as any)?.message ?? String(err), + }); + }); } function setSource() { - if (!videoElement || !source) return; + if (!source) return; setupSource(); } @@ -178,6 +207,19 @@ export function makeChromecastDisplayInterface( }, setCaption(newCaption) { caption = newCaption; + // If a session and media exist, toggle active track IDs without reloading + const session = ops.instance.getCurrentSession(); + const media = session?.getMediaSession(); + try { + if (media) { + const ids = newCaption?.url ? [1] : []; + const req = new chrome.cast.media.EditTracksInfoRequest(ids); + (media as any).editTracksInfo(req); + return; + } + } catch { + // Fallback to reload if needed + } setSource(); }, @@ -195,15 +237,13 @@ export function makeChromecastDisplayInterface( }, pause() { - if (!isPaused) { + if (!ops.player.isPaused) { ops.controller.playOrPause(); - isPaused = true; } }, play() { - if (isPaused) { + if (ops.player.isPaused) { ops.controller.playOrPause(); - isPaused = false; } }, setSeeking(active) { @@ -220,10 +260,12 @@ export function makeChromecastDisplayInterface( 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); + // clamp time between 0 and max duration if duration is known + let time = t; + if (!Number.isNaN(ops.player.duration)) { + time = Math.min(t, ops.player.duration); + time = Math.max(0, time); + } if (Number.isNaN(time)) return; emit("time", time); @@ -231,20 +273,14 @@ export function makeChromecastDisplayInterface( ops.controller.seek(); }, async setVolume(v) { - // clamp time between 0 and 1 + // clamp volume 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); - } + // Always control remote cast volume regardless of local platform restrictions + ops.player.volumeLevel = volume; + ops.controller.setVolumeLevel(); + emit("volumechange", volume); }, toggleFullscreen() { if (isFullscreen) { @@ -271,8 +307,10 @@ export function makeChromecastDisplayInterface( // cant airplay while chromecasting }, setPlaybackRate(rate) { + // Default Media Receiver does not support changing playback rate dynamically. + // Store locally and notify UI without reloading media. playbackRate = rate; - setSource(); + emit("playbackrate", rate); }, getCaptionList() { return []; diff --git a/src/setup/chromecast.ts b/src/setup/chromecast.ts index 9c288629..2f72b8e4 100644 --- a/src/setup/chromecast.ts +++ b/src/setup/chromecast.ts @@ -3,10 +3,13 @@ const CHROMECAST_SENDER_SDK = const callbacks: ((available: boolean) => void)[] = []; let _available: boolean | null = null; +let _initialized = false; function init(available: boolean) { _available = available; callbacks.forEach((cb) => cb(available)); + // Clear callbacks after first resolution to avoid leaks/repeated calls + callbacks.length = 0; } export function isChromecastAvailable(cb: (available: boolean) => void) { @@ -15,9 +18,29 @@ export function isChromecastAvailable(cb: (available: boolean) => void) { } export function initializeChromecast() { - window.__onGCastApiAvailable = (isAvailable) => { - init(isAvailable); - }; + if (_initialized) return; + _initialized = true; + + const w = window as any; + // Only set the global callback if not already present + if (!w.__onGCastApiAvailable) { + w.__onGCastApiAvailable = (isAvailable: boolean) => { + try { + if (isAvailable && w.cast?.framework) { + const context = w.cast.framework.CastContext.getInstance(); + context.setOptions({ + receiverApplicationId: + w.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID, + autoJoinPolicy: w.cast.framework.AutoJoinPolicy.ORIGIN_SCOPED, + }); + } + } catch { + // Swallow errors; availability will still be reported below + } finally { + init(!!isAvailable); + } + }; + } // add script if doesnt exist yet const exists = !!document.getElementById("chromecast-script"); diff --git a/src/utils/setup/chromecast.ts b/src/utils/setup/chromecast.ts deleted file mode 100644 index 9c288629..00000000 --- a/src/utils/setup/chromecast.ts +++ /dev/null @@ -1,30 +0,0 @@ -const CHROMECAST_SENDER_SDK = - "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; - -const callbacks: ((available: boolean) => void)[] = []; -let _available: boolean | null = null; - -function init(available: boolean) { - _available = available; - callbacks.forEach((cb) => cb(available)); -} - -export function isChromecastAvailable(cb: (available: boolean) => void) { - if (_available !== null) return cb(_available); - callbacks.push(cb); -} - -export function initializeChromecast() { - window.__onGCastApiAvailable = (isAvailable) => { - init(isAvailable); - }; - - // add script if doesnt exist yet - const exists = !!document.getElementById("chromecast-script"); - if (!exists) { - const script = document.createElement("script"); - script.setAttribute("src", CHROMECAST_SENDER_SDK); - script.setAttribute("id", "chromecast-script"); - document.body.appendChild(script); - } -}