mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-29 20:28:42 +00:00
rework chromecast implementation
This commit is contained in:
parent
4f52cd2ba2
commit
d8368e6d40
4 changed files with 152 additions and 70 deletions
|
|
@ -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<HTMLButtonElement>(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 (
|
||||
<VideoPlayerButton
|
||||
ref={ref}
|
||||
|
|
@ -44,13 +83,25 @@ export function Chromecast(props: ChromecastProps) {
|
|||
props.className ?? "",
|
||||
"google-cast-button",
|
||||
isCasting ? "casting" : "",
|
||||
hidden ? "hidden" : "",
|
||||
hidden || castHidden ? "hidden" : "",
|
||||
].join(" ")}
|
||||
icon={Icons.CASTING}
|
||||
onClick={(el) => {
|
||||
const castButton = el.querySelector("google-cast-launcher");
|
||||
if (castButton) (castButton as HTMLDivElement).click();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{/* Render a hidden launcher so programmatic click always works */}
|
||||
<google-cast-launcher
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</VideoPlayerButton>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
/// <reference types="chromecast-caf-sender"/>
|
||||
|
||||
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 [];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue