Files
k-tv/k-tv-frontend/app/(main)/tv/page.tsx
Gabriel Kaszewski 8ed8da2d90 refactor(frontend): extract logic to hooks, split inline components
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
2026-03-17 02:25:02 +01:00

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>
);
}