Area 1 (tv/page.tsx 964→423 lines): - hooks: use-fullscreen, use-idle, use-volume, use-quality, use-subtitles, use-channel-input, use-channel-passwords, use-tv-keyboard - components: SubtitlePicker, VolumeControl, QualityPicker, TopControlBar, LogoWatermark, AutoplayPrompt, ChannelNumberOverlay, TvBaseLayer Area 2 (edit-channel-sheet.tsx 1244→678 lines): - hooks: use-channel-form (all form state + reset logic) - lib/schemas.ts: extracted Zod schemas + extractErrors - components: AlgorithmicFilterEditor, RecyclePolicyEditor, WebhookEditor, AccessSettingsEditor, LogoEditor Area 3 (dashboard/page.tsx 406→261 lines): - hooks: use-channel-order, use-import-channel, use-regenerate-all - lib/channel-export.ts: pure export utility - components: DashboardHeader
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ChannelInfo,
|
|
ChannelControls,
|
|
ScheduleOverlay,
|
|
UpNextBanner,
|
|
ChannelPasswordModal,
|
|
TopControlBar,
|
|
LogoWatermark,
|
|
AutoplayPrompt,
|
|
ChannelNumberOverlay,
|
|
TvBaseLayer,
|
|
} from "./components";
|
|
import { useAuthContext } from "@/context/auth-context";
|
|
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
|
import { useCast } from "@/hooks/use-cast";
|
|
import {
|
|
useStreamUrl,
|
|
fmtTime,
|
|
calcProgress,
|
|
minutesUntil,
|
|
toScheduleSlots,
|
|
findNextSlot,
|
|
} from "@/hooks/use-tv";
|
|
import { useFullscreen } from "@/hooks/use-fullscreen";
|
|
import { useIdle } from "@/hooks/use-idle";
|
|
import { useVolume } from "@/hooks/use-volume";
|
|
import { useQuality, QUALITY_OPTIONS } from "@/hooks/use-quality";
|
|
import { useSubtitlePicker } from "@/hooks/use-subtitles";
|
|
import { useChannelInput } from "@/hooks/use-channel-input";
|
|
import { useChannelPasswords } from "@/hooks/use-channel-passwords";
|
|
import { useTvKeyboard } from "@/hooks/use-tv-keyboard";
|
|
|
|
const IDLE_TIMEOUT_MS = 3500;
|
|
const BANNER_THRESHOLD = 80;
|
|
|
|
export default function TvPage() {
|
|
return (
|
|
<Suspense>
|
|
<TvPageContent />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function TvPageContent() {
|
|
const { token } = useAuthContext();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: channels, isLoading: isLoadingChannels } = useChannels();
|
|
|
|
const channelId = searchParams.get("channel");
|
|
const channelIdx =
|
|
channels && channelId
|
|
? Math.max(0, channels.findIndex((c) => c.id === channelId))
|
|
: 0;
|
|
const channel = channels?.[channelIdx];
|
|
|
|
const switchChannel = useCallback(
|
|
(idx: number, list = channels) => {
|
|
const target = list?.[idx];
|
|
if (!target) return;
|
|
router.replace(`/tv?channel=${target.id}`, { scroll: false });
|
|
},
|
|
[channels, router],
|
|
);
|
|
|
|
const [showSchedule, setShowSchedule] = useState(false);
|
|
const [showStats, setShowStats] = useState(false);
|
|
const [streamError, setStreamError] = useState(false);
|
|
const [videoEnded, setVideoEnded] = useState(false);
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const touchStartY = useRef<number | null>(null);
|
|
|
|
const { castAvailable, isCasting, castDeviceName, requestCast, stopCasting } = useCast();
|
|
|
|
// passwords: channelPassword depends only on channelId (from localStorage), safe to call before broadcast
|
|
const passwords = useChannelPasswords(channel?.id);
|
|
|
|
const {
|
|
data: broadcast,
|
|
isLoading: isLoadingBroadcast,
|
|
error: broadcastError,
|
|
} = useCurrentBroadcast(channel?.id ?? "", passwords.channelPassword);
|
|
|
|
// blockPassword derived from broadcast.slot.id after broadcast loads
|
|
const blockPassword = passwords.getBlockPassword(broadcast?.slot.id);
|
|
|
|
const { data: epgSlots } = useEpg(
|
|
channel?.id ?? "",
|
|
undefined,
|
|
undefined,
|
|
passwords.channelPassword,
|
|
);
|
|
|
|
const quality = useQuality(channel?.id, broadcast?.slot.id);
|
|
const volume = useVolume(videoRef, isCasting);
|
|
const subtitles = useSubtitlePicker(channelIdx, broadcast?.slot.id);
|
|
|
|
const { data: streamUrl, error: streamUrlError } = useStreamUrl(
|
|
channel?.id,
|
|
token,
|
|
broadcast?.slot.id,
|
|
passwords.channelPassword,
|
|
blockPassword,
|
|
quality.quality,
|
|
);
|
|
|
|
const channelCount = channels?.length ?? 0;
|
|
|
|
// useIdle after volume/subtitles/quality so onIdle can reference their stable setters
|
|
const { showOverlays, needsInteraction, setNeedsInteraction, resetIdle } = useIdle(
|
|
IDLE_TIMEOUT_MS,
|
|
videoRef,
|
|
useCallback(() => {
|
|
volume.setShowSlider(false);
|
|
subtitles.setShowSubtitlePicker(false);
|
|
quality.setShowQualityPicker(false);
|
|
// setters from useState are stable — empty deps is intentional
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []),
|
|
);
|
|
|
|
// useFullscreen after useIdle so we have showOverlays
|
|
const { isFullscreen, toggleFullscreen } = useFullscreen(
|
|
videoRef,
|
|
showOverlays,
|
|
streamUrl,
|
|
);
|
|
|
|
const prevChannel = useCallback(() => {
|
|
switchChannel(
|
|
(channelIdx - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1),
|
|
);
|
|
resetIdle();
|
|
}, [channelIdx, channelCount, switchChannel, resetIdle]);
|
|
|
|
const nextChannel = useCallback(() => {
|
|
switchChannel((channelIdx + 1) % Math.max(channelCount, 1));
|
|
resetIdle();
|
|
}, [channelIdx, channelCount, switchChannel, resetIdle]);
|
|
|
|
const toggleSchedule = useCallback(() => {
|
|
setShowSchedule((s) => !s);
|
|
resetIdle();
|
|
}, [resetIdle]);
|
|
|
|
const { channelInput, handleDigit } = useChannelInput(
|
|
channelCount,
|
|
switchChannel,
|
|
resetIdle,
|
|
);
|
|
|
|
useTvKeyboard({
|
|
nextChannel,
|
|
prevChannel,
|
|
toggleSchedule,
|
|
toggleFullscreen,
|
|
toggleMute: volume.toggleMute,
|
|
setShowStats,
|
|
subtitleTracks: subtitles.subtitleTracks,
|
|
setActiveSubtitleTrack: subtitles.setActiveSubtitleTrack,
|
|
handleDigit,
|
|
});
|
|
|
|
// Password modal triggers
|
|
useEffect(() => {
|
|
if ((broadcastError as Error)?.message === "password_required") {
|
|
passwords.setShowChannelPasswordModal(true);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [broadcastError]);
|
|
|
|
useEffect(() => {
|
|
if ((streamUrlError as Error)?.message === "password_required") {
|
|
passwords.setShowBlockPasswordModal(true);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [streamUrlError]);
|
|
|
|
// Clear transient states when slot changes
|
|
useEffect(() => {
|
|
setStreamError(false);
|
|
setVideoEnded(false);
|
|
}, [broadcast?.slot.id]);
|
|
|
|
// Tick for progress bar (every 30 s)
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const id = setInterval(() => setTick((n) => n + 1), 30_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
// Derived values
|
|
const hasBroadcast = !!broadcast;
|
|
const progress = hasBroadcast
|
|
? calcProgress(broadcast.slot.start_at, broadcast.slot.item.duration_secs)
|
|
: 0;
|
|
const scheduleSlots = toScheduleSlots(epgSlots ?? [], broadcast?.slot.id);
|
|
const nextSlot = findNextSlot(epgSlots ?? [], broadcast?.slot.id);
|
|
const showBanner = hasBroadcast && progress >= BANNER_THRESHOLD && !!nextSlot;
|
|
|
|
const handleStreamError = useCallback(() => setStreamError(true), []);
|
|
|
|
const handleVideoEnded = useCallback(() => {
|
|
setVideoEnded(true);
|
|
queryClient.invalidateQueries({ queryKey: ["broadcast", channel?.id] });
|
|
}, [queryClient, channel?.id]);
|
|
|
|
const handleRetry = useCallback(() => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["stream-url", channel?.id, broadcast?.slot.id],
|
|
});
|
|
setStreamError(false);
|
|
}, [queryClient, channel?.id, broadcast?.slot.id]);
|
|
|
|
const handleTouchStart = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
touchStartY.current = e.touches[0].clientY;
|
|
resetIdle();
|
|
},
|
|
[resetIdle],
|
|
);
|
|
|
|
const handleTouchEnd = useCallback(
|
|
(e: React.TouchEvent) => {
|
|
if (touchStartY.current === null) return;
|
|
const dy = touchStartY.current - e.changedTouches[0].clientY;
|
|
touchStartY.current = null;
|
|
if (Math.abs(dy) > 60) {
|
|
if (dy > 0) nextChannel();
|
|
else prevChannel();
|
|
}
|
|
},
|
|
[nextChannel, prevChannel],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="relative flex flex-1 overflow-hidden bg-black"
|
|
style={{ cursor: showOverlays ? "default" : "none" }}
|
|
onMouseMove={resetIdle}
|
|
onClick={resetIdle}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{/* Base layer */}
|
|
<div className="absolute inset-0">
|
|
<TvBaseLayer
|
|
isLoadingChannels={isLoadingChannels}
|
|
hasChannels={!!channels && channels.length > 0}
|
|
broadcastError={broadcastError}
|
|
isLoadingBroadcast={isLoadingBroadcast}
|
|
hasBroadcast={hasBroadcast}
|
|
streamUrlError={streamUrlError}
|
|
streamError={streamError}
|
|
videoEnded={videoEnded}
|
|
streamUrl={streamUrl}
|
|
broadcast={broadcast}
|
|
activeSubtitleTrack={subtitles.activeSubtitleTrack}
|
|
isMuted={volume.isMuted}
|
|
showStats={showStats}
|
|
videoRef={videoRef}
|
|
onSubtitleTracksChange={subtitles.setSubtitleTracks}
|
|
onStreamError={handleStreamError}
|
|
onEnded={handleVideoEnded}
|
|
onNeedsInteraction={() => setNeedsInteraction(true)}
|
|
onRetry={handleRetry}
|
|
/>
|
|
</div>
|
|
|
|
{/* Logo watermark */}
|
|
{channel?.logo && (
|
|
<LogoWatermark
|
|
logo={channel.logo}
|
|
position={channel.logo_position}
|
|
opacity={channel.logo_opacity ?? 1}
|
|
/>
|
|
)}
|
|
|
|
{/* Password modals */}
|
|
{passwords.showChannelPasswordModal && (
|
|
<ChannelPasswordModal
|
|
label="Channel password required"
|
|
onSubmit={passwords.submitChannelPassword}
|
|
onCancel={() => passwords.setShowChannelPasswordModal(false)}
|
|
/>
|
|
)}
|
|
{passwords.showBlockPasswordModal && (
|
|
<ChannelPasswordModal
|
|
label="Block password required"
|
|
onSubmit={(pw) =>
|
|
passwords.submitBlockPassword(
|
|
broadcast?.slot.id ?? "",
|
|
channel?.id,
|
|
pw,
|
|
)
|
|
}
|
|
onCancel={() => passwords.setShowBlockPasswordModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Autoplay blocked prompt */}
|
|
{needsInteraction && <AutoplayPrompt />}
|
|
|
|
{/* Overlays */}
|
|
{channel && (
|
|
<>
|
|
<div
|
|
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between transition-opacity duration-300"
|
|
style={{ opacity: showOverlays ? 1 : 0 }}
|
|
>
|
|
<TopControlBar
|
|
subtitleTracks={subtitles.subtitleTracks}
|
|
activeSubtitleTrack={subtitles.activeSubtitleTrack}
|
|
showSubtitlePicker={subtitles.showSubtitlePicker}
|
|
onToggleSubtitlePicker={() =>
|
|
subtitles.setShowSubtitlePicker((s) => !s)
|
|
}
|
|
onChangeSubtitleTrack={(id) => {
|
|
subtitles.setActiveSubtitleTrack(id);
|
|
subtitles.setShowSubtitlePicker(false);
|
|
}}
|
|
volume={volume.volume}
|
|
isMuted={volume.isMuted}
|
|
VolumeIcon={volume.VolumeIcon}
|
|
showVolumeSlider={volume.showSlider}
|
|
onToggleVolumeSlider={() => volume.setShowSlider((s) => !s)}
|
|
onToggleMute={volume.toggleMute}
|
|
onVolumeChange={(v) => {
|
|
volume.setVolume(v);
|
|
volume.setIsMuted(v === 0);
|
|
}}
|
|
isFullscreen={isFullscreen}
|
|
onToggleFullscreen={toggleFullscreen}
|
|
castAvailable={castAvailable}
|
|
isCasting={isCasting}
|
|
castDeviceName={castDeviceName}
|
|
streamUrl={streamUrl}
|
|
onRequestCast={requestCast}
|
|
onStopCasting={stopCasting}
|
|
quality={quality.quality}
|
|
qualityOptions={QUALITY_OPTIONS}
|
|
showQualityPicker={quality.showQualityPicker}
|
|
onToggleQualityPicker={() =>
|
|
quality.setShowQualityPicker((s) => !s)
|
|
}
|
|
onChangeQuality={quality.changeQuality}
|
|
showStats={showStats}
|
|
onToggleStats={() => setShowStats((v) => !v)}
|
|
showSchedule={showSchedule}
|
|
onToggleSchedule={toggleSchedule}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-3 bg-linear-to-t from-black/80 via-black/40 to-transparent p-5 pt-20">
|
|
{showBanner && nextSlot && (
|
|
<UpNextBanner
|
|
nextShowTitle={nextSlot.item.title}
|
|
minutesUntil={minutesUntil(nextSlot.start_at)}
|
|
nextShowStartTime={fmtTime(nextSlot.start_at)}
|
|
/>
|
|
)}
|
|
<div className="flex items-end justify-between gap-4">
|
|
{hasBroadcast ? (
|
|
<ChannelInfo
|
|
channelNumber={channelIdx + 1}
|
|
channelName={channel.name}
|
|
item={broadcast.slot.item}
|
|
showStartTime={fmtTime(broadcast.slot.start_at)}
|
|
showEndTime={fmtTime(broadcast.slot.end_at)}
|
|
progress={progress}
|
|
/>
|
|
) : (
|
|
<div className="rounded-lg bg-black/60 px-4 py-3 backdrop-blur-md">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex h-7 min-w-9 items-center justify-center rounded bg-white px-1.5 font-mono text-xs font-bold text-black">
|
|
{channelIdx + 1}
|
|
</span>
|
|
<span className="text-sm font-medium text-zinc-300">
|
|
{channel.name}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="pointer-events-auto">
|
|
<ChannelControls
|
|
channelNumber={channelIdx + 1}
|
|
channelName={channel.name}
|
|
onPrevChannel={prevChannel}
|
|
onNextChannel={nextChannel}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{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>
|
|
)}
|
|
|
|
<ChannelNumberOverlay channelInput={channelInput} />
|
|
|
|
{showOverlays && showSchedule && (
|
|
<div className="absolute bottom-4 right-4 top-14 z-20 w-80">
|
|
<ScheduleOverlay
|
|
channelName={channel.name}
|
|
slots={scheduleSlots}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|