feat(casting): implement casting functionality with auto-mute and UI controls
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
65
k-tv-frontend/hooks/use-cast.ts
Normal file
65
k-tv-frontend/hooks/use-cast.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user