feat(casting): implement casting functionality with auto-mute and UI controls

This commit is contained in:
2026-03-14 02:38:54 +01:00
parent 953366ed63
commit 0bdf7104a9
4 changed files with 112 additions and 1 deletions

View File

@@ -14,9 +14,10 @@ import {
} from "./components"; } from "./components";
import type { SubtitleTrack } from "./components/video-player"; import type { SubtitleTrack } from "./components/video-player";
import type { LogoPosition } from "@/lib/types"; import type { LogoPosition } from "@/lib/types";
import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react"; import { Cast, Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react";
import { useAuthContext } from "@/context/auth-context"; import { useAuthContext } from "@/context/auth-context";
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels"; import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
import { useCast } from "@/hooks/use-cast";
import { import {
useStreamUrl, useStreamUrl,
fmtTime, fmtTime,
@@ -166,6 +167,20 @@ function TvPageContent() {
const toggleMute = useCallback(() => setIsMuted((m) => !m), []); const toggleMute = useCallback(() => setIsMuted((m) => !m), []);
const VolumeIcon = isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2; const VolumeIcon = isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
const { castAvailable, isCasting, castDeviceName, requestCast, stopCasting } = useCast();
// Auto-mute local video while casting, restore on cast end
const prevMutedRef = useRef(false);
useEffect(() => {
if (isCasting) {
prevMutedRef.current = isMuted;
setIsMuted(true);
} else {
setIsMuted(prevMutedRef.current);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCasting]);
// Channel jump by number (e.g. press "1","4" → jump to ch 14 after 1.5 s) // Channel jump by number (e.g. press "1","4" → jump to ch 14 after 1.5 s)
const [channelInput, setChannelInput] = useState(""); const [channelInput, setChannelInput] = useState("");
const channelInputTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const channelInputTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -654,6 +669,18 @@ function TvPageContent() {
: <Maximize2 className="h-4 w-4" />} : <Maximize2 className="h-4 w-4" />}
</button> </button>
{castAvailable && (
<button
className={`pointer-events-auto rounded-md bg-black/50 p-1.5 backdrop-blur transition-colors hover:bg-black/70 hover:text-white ${
isCasting ? "text-blue-400" : "text-zinc-400"
}`}
onClick={() => isCasting ? stopCasting() : streamUrl && requestCast(streamUrl)}
title={isCasting ? `Stop casting to ${castDeviceName ?? "TV"}` : "Cast to TV"}
>
<Cast className="h-4 w-4" />
</button>
)}
<button <button
className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white" className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleSchedule} onClick={toggleSchedule}
@@ -708,6 +735,13 @@ function TvPageContent() {
</div> </div>
</div> </div>
{/* Casting indicator */}
{isCasting && castDeviceName && (
<div className="pointer-events-none absolute left-4 top-4 z-10 rounded-md bg-black/60 px-3 py-1.5 text-xs text-blue-300 backdrop-blur">
Casting to {castDeviceName}
</div>
)}
{/* Channel number input overlay */} {/* Channel number input overlay */}
{channelInput && ( {channelInput && (
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-black/80 px-8 py-5 text-center backdrop-blur"> <div className="pointer-events-none absolute left-1/2 top-1/2 z-30 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-black/80 px-8 py-5 text-center backdrop-blur">

View File

@@ -32,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/chromecast-caf-sender": "^1.0.11",
"@types/hls.js": "^1.0.0", "@types/hls.js": "^1.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
@@ -450,6 +451,10 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/chrome": ["@types/chrome@0.1.37", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-IJE4ceuDO7lrEuua7Pow47zwNcI8E6qqkowRP7aFPaZ0lrjxh6y836OPqqkIZeTX64FTogbw+4RNH0+QrweCTQ=="],
"@types/chromecast-caf-sender": ["@types/chromecast-caf-sender@1.0.11", "", { "dependencies": { "@types/chrome": "*" } }, "sha512-Pv3xvNYtxD/cTM/tKfuZRlLasvpxAm+CFni0GJd6Cp8XgiZS9g9tMZkR1uymsi5fIFv057SZKKAWVFFgy7fJtw=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@@ -470,6 +475,12 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="],
"@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="],
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
"@types/hls.js": ["@types/hls.js@1.0.0", "", { "dependencies": { "hls.js": "*" } }, "sha512-EGY2QJefX+Z9XH4PAxI7RFoNqBlQEk16UpYR3kbr82CIgMX5SlMe0PjFdFV0JytRhyVPQCiwSyONuI6S1KdSag=="], "@types/hls.js": ["@types/hls.js@1.0.0", "", { "dependencies": { "hls.js": "*" } }, "sha512-EGY2QJefX+Z9XH4PAxI7RFoNqBlQEk16UpYR3kbr82CIgMX5SlMe0PjFdFV0JytRhyVPQCiwSyONuI6S1KdSag=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],

View File

@@ -0,0 +1,65 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
export function useCast() {
const [castAvailable, setCastAvailable] = useState(false);
const [isCasting, setIsCasting] = useState(false);
const [castDeviceName, setCastDeviceName] = useState<string | null>(null);
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
const init = () => {
const ctx = cast.framework.CastContext.getInstance();
ctx.setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
ctx.addEventListener(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
(e) => {
const casting = e.castState === cast.framework.CastState.CONNECTED;
setIsCasting(casting);
setCastDeviceName(
casting ? (ctx.getCurrentSession()?.getCastDevice().friendlyName ?? null) : null
);
setCastAvailable(e.castState !== cast.framework.CastState.NO_DEVICES_AVAILABLE);
},
);
const state = ctx.getCastState();
setCastAvailable(state !== cast.framework.CastState.NO_DEVICES_AVAILABLE);
setIsCasting(state === cast.framework.CastState.CONNECTED);
};
if (window.cast?.framework) {
init();
} else {
window.__onGCastApiAvailable = (isAvailable: boolean) => {
if (isAvailable) init();
};
}
}, []);
const requestCast = useCallback(async (streamUrl: string) => {
if (!window.cast?.framework) return;
const ctx = cast.framework.CastContext.getInstance();
let session = ctx.getCurrentSession();
if (!session) {
const err = await ctx.requestSession();
if (err) return;
session = ctx.getCurrentSession();
}
if (!session) return;
const mediaInfo = new chrome.cast.media.MediaInfo(streamUrl, "application/x-mpegURL");
await session.loadMedia(new chrome.cast.media.LoadRequest(mediaInfo));
}, []);
const stopCasting = useCallback(() => {
window.cast?.framework.CastContext.getInstance().endCurrentSession(true);
}, []);
return { castAvailable, isCasting, castDeviceName, requestCast, stopCasting };
}

View File

@@ -37,6 +37,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/chromecast-caf-sender": "^1.0.11",
"@types/hls.js": "^1.0.0", "@types/hls.js": "^1.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",