965 lines
34 KiB
TypeScript
965 lines
34 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 {
|
||
VideoPlayer,
|
||
ChannelInfo,
|
||
ChannelControls,
|
||
ScheduleOverlay,
|
||
UpNextBanner,
|
||
NoSignal,
|
||
ChannelPasswordModal,
|
||
} from "./components";
|
||
import type { SubtitleTrack } from "./components/video-player";
|
||
import type { LogoPosition } from "@/lib/types";
|
||
import {
|
||
Cast,
|
||
Info,
|
||
Maximize2,
|
||
Minimize2,
|
||
Volume1,
|
||
Volume2,
|
||
VolumeX,
|
||
} from "lucide-react";
|
||
import { useAuthContext } from "@/context/auth-context";
|
||
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
||
import { useCast } from "@/hooks/use-cast";
|
||
import {
|
||
useStreamUrl,
|
||
fmtTime,
|
||
calcProgress,
|
||
calcOffsetSecs,
|
||
minutesUntil,
|
||
toScheduleSlots,
|
||
findNextSlot,
|
||
} from "@/hooks/use-tv";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const IDLE_TIMEOUT_MS = 3500;
|
||
const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
|
||
|
||
function logoPositionClass(pos?: LogoPosition) {
|
||
switch (pos) {
|
||
case "top_left":
|
||
return "top-0 left-0";
|
||
case "bottom_left":
|
||
return "bottom-0 left-0";
|
||
case "bottom_right":
|
||
return "bottom-0 right-0";
|
||
default:
|
||
return "top-0 right-0";
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export default function TvPage() {
|
||
return (
|
||
<Suspense>
|
||
<TvPageContent />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
function TvPageContent() {
|
||
const { token } = useAuthContext();
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
|
||
// Channel list
|
||
const { data: channels, isLoading: isLoadingChannels } = useChannels();
|
||
|
||
// URL is the single source of truth for the active channel.
|
||
// channelIdx is derived — never stored in state.
|
||
const channelId = searchParams.get("channel");
|
||
const channelIdx =
|
||
channels && channelId
|
||
? Math.max(
|
||
0,
|
||
channels.findIndex((c) => c.id === channelId),
|
||
)
|
||
: 0;
|
||
const channel = channels?.[channelIdx];
|
||
|
||
// Write a channel switch back to the URL so keyboard, buttons, and
|
||
// guide links all stay in sync and the page is bookmarkable/refreshable.
|
||
const switchChannel = useCallback(
|
||
(idx: number, list = channels) => {
|
||
const target = list?.[idx];
|
||
if (!target) return;
|
||
router.replace(`/tv?channel=${target.id}`, { scroll: false });
|
||
},
|
||
[channels, router],
|
||
);
|
||
|
||
// Overlay / idle state
|
||
const [showOverlays, setShowOverlays] = useState(true);
|
||
const [showSchedule, setShowSchedule] = useState(false);
|
||
const [showStats, setShowStats] = useState(false);
|
||
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
// Video ref — used to resume playback if autoplay was blocked on load
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
|
||
// Access control — persisted per channel in localStorage
|
||
const [channelPasswords, setChannelPasswords] = useState<
|
||
Record<string, string>
|
||
>(() => {
|
||
try {
|
||
return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}");
|
||
} catch {
|
||
return {};
|
||
}
|
||
});
|
||
const [blockPasswords, setBlockPasswords] = useState<Record<string, string>>(
|
||
() => {
|
||
try {
|
||
return JSON.parse(localStorage.getItem("block_passwords") ?? "{}");
|
||
} catch {
|
||
return {};
|
||
}
|
||
},
|
||
);
|
||
const [showChannelPasswordModal, setShowChannelPasswordModal] =
|
||
useState(false);
|
||
const [showBlockPasswordModal, setShowBlockPasswordModal] = useState(false);
|
||
|
||
const channelPassword = channel ? channelPasswords[channel.id] : undefined;
|
||
|
||
// Stream error recovery
|
||
const [streamError, setStreamError] = useState(false);
|
||
|
||
// When the video ends, show no-signal until the next broadcast is detected.
|
||
const [videoEnded, setVideoEnded] = useState(false);
|
||
|
||
// Autoplay blocked by browser — cleared on first interaction via resetIdle
|
||
const [needsInteraction, setNeedsInteraction] = useState(false);
|
||
|
||
// Subtitles
|
||
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
|
||
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
|
||
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false);
|
||
|
||
// Quality
|
||
const [quality, setQuality] = useState<string>(() => {
|
||
try {
|
||
return localStorage.getItem("quality") ?? "direct";
|
||
} catch {
|
||
return "direct";
|
||
}
|
||
});
|
||
const [showQualityPicker, setShowQualityPicker] = useState(false);
|
||
|
||
const QUALITY_OPTIONS = [
|
||
{ value: "direct", label: "Auto" },
|
||
{ value: "40000000", label: "40 Mbps" },
|
||
{ value: "8000000", label: "8 Mbps" },
|
||
{ value: "2000000", label: "2 Mbps" },
|
||
];
|
||
|
||
// Fullscreen
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
useEffect(() => {
|
||
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||
document.addEventListener("fullscreenchange", handler);
|
||
return () => document.removeEventListener("fullscreenchange", handler);
|
||
}, []);
|
||
|
||
// Hide the shared nav bar in fullscreen; reveal it when overlays are shown
|
||
// (mouse move / key press). Classes are cleaned up on unmount.
|
||
useEffect(() => {
|
||
document.body.classList.toggle("tv-fullscreen", isFullscreen);
|
||
document.body.classList.toggle("tv-overlays", isFullscreen && showOverlays);
|
||
return () => document.body.classList.remove("tv-fullscreen", "tv-overlays");
|
||
}, [isFullscreen, showOverlays]);
|
||
|
||
const toggleFullscreen = useCallback(() => {
|
||
// Standard Fullscreen API (Chrome, Firefox, Safari desktop)
|
||
if (document.fullscreenEnabled) {
|
||
if (!document.fullscreenElement) {
|
||
document.documentElement.requestFullscreen().catch(() => {});
|
||
} else {
|
||
document.exitFullscreen().catch(() => {});
|
||
}
|
||
return;
|
||
}
|
||
// iOS Safari: requestFullscreen is not supported. Fall back to the video
|
||
// element's proprietary webkit API instead.
|
||
const video = videoRef.current as HTMLVideoElement & {
|
||
webkitEnterFullscreen?: () => void;
|
||
webkitExitFullscreen?: () => void;
|
||
webkitDisplayingFullscreen?: boolean;
|
||
};
|
||
if (!video?.webkitEnterFullscreen) return;
|
||
if (video.webkitDisplayingFullscreen) {
|
||
video.webkitExitFullscreen?.();
|
||
} else {
|
||
video.webkitEnterFullscreen();
|
||
}
|
||
}, []);
|
||
|
||
// Volume control
|
||
const [volume, setVolume] = useState(1); // 0.0 – 1.0
|
||
const [isMuted, setIsMuted] = useState(false);
|
||
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
|
||
useEffect(() => {
|
||
if (!videoRef.current) return;
|
||
videoRef.current.muted = isMuted;
|
||
videoRef.current.volume = volume;
|
||
}, [isMuted, volume]);
|
||
const toggleMute = useCallback(() => setIsMuted((m) => !m), []);
|
||
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)
|
||
const [channelInput, setChannelInput] = useState("");
|
||
const channelInputTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
// Touch-swipe state
|
||
const touchStartY = useRef<number | null>(null);
|
||
|
||
const queryClient = useQueryClient();
|
||
|
||
// Tick for live progress calculation (every 30 s is fine for the progress bar)
|
||
const [, setTick] = useState(0);
|
||
useEffect(() => {
|
||
const id = setInterval(() => setTick((n) => n + 1), 30_000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
// Per-channel data
|
||
const {
|
||
data: broadcast,
|
||
isLoading: isLoadingBroadcast,
|
||
error: broadcastError,
|
||
} = useCurrentBroadcast(channel?.id ?? "", channelPassword);
|
||
const blockPassword = broadcast?.slot.id
|
||
? blockPasswords[broadcast.slot.id]
|
||
: undefined;
|
||
const { data: epgSlots } = useEpg(
|
||
channel?.id ?? "",
|
||
undefined,
|
||
undefined,
|
||
channelPassword,
|
||
);
|
||
const { data: streamUrl, error: streamUrlError } = useStreamUrl(
|
||
channel?.id,
|
||
token,
|
||
broadcast?.slot.id,
|
||
channelPassword,
|
||
blockPassword,
|
||
quality,
|
||
);
|
||
|
||
const changeQuality = useCallback(
|
||
(q: string) => {
|
||
setQuality(q);
|
||
try {
|
||
localStorage.setItem("quality", q);
|
||
} catch {}
|
||
setShowQualityPicker(false);
|
||
queryClient.invalidateQueries({
|
||
queryKey: ["stream-url", channel?.id, broadcast?.slot.id],
|
||
});
|
||
},
|
||
[queryClient, channel?.id, broadcast?.slot.id],
|
||
);
|
||
|
||
// iOS Safari: track fullscreen state via webkit video element events.
|
||
// Re-run when streamUrl changes so we catch the video element after it mounts.
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
const onBegin = () => setIsFullscreen(true);
|
||
const onEnd = () => setIsFullscreen(false);
|
||
video.addEventListener("webkitbeginfullscreen", onBegin);
|
||
video.addEventListener("webkitendfullscreen", onEnd);
|
||
return () => {
|
||
video.removeEventListener("webkitbeginfullscreen", onBegin);
|
||
video.removeEventListener("webkitendfullscreen", onEnd);
|
||
};
|
||
}, [streamUrl]);
|
||
|
||
// Show channel password modal when broadcast returns password_required
|
||
useEffect(() => {
|
||
if ((broadcastError as Error)?.message === "password_required") {
|
||
setShowChannelPasswordModal(true);
|
||
}
|
||
}, [broadcastError]);
|
||
|
||
// Show block password modal when stream URL fetch returns password_required
|
||
useEffect(() => {
|
||
if ((streamUrlError as Error)?.message === "password_required") {
|
||
setShowBlockPasswordModal(true);
|
||
}
|
||
}, [streamUrlError]);
|
||
|
||
// Clear transient states when a new slot is detected
|
||
useEffect(() => {
|
||
setStreamError(false);
|
||
setVideoEnded(false);
|
||
}, [broadcast?.slot.id]);
|
||
|
||
// Reset subtitle state when channel or slot changes
|
||
useEffect(() => {
|
||
setSubtitleTracks([]);
|
||
setActiveSubtitleTrack(-1);
|
||
setShowSubtitlePicker(false);
|
||
}, [channelIdx, broadcast?.slot.id]);
|
||
|
||
// ------------------------------------------------------------------
|
||
// Derived display 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;
|
||
|
||
// ------------------------------------------------------------------
|
||
// Idle detection
|
||
// ------------------------------------------------------------------
|
||
|
||
const resetIdle = useCallback(() => {
|
||
setShowOverlays(true);
|
||
setNeedsInteraction(false);
|
||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||
idleTimer.current = setTimeout(() => {
|
||
setShowOverlays(false);
|
||
setShowVolumeSlider(false);
|
||
setShowSubtitlePicker(false);
|
||
setShowQualityPicker(false);
|
||
}, IDLE_TIMEOUT_MS);
|
||
// Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction)
|
||
videoRef.current?.play().catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
resetIdle();
|
||
return () => {
|
||
if (idleTimer.current) clearTimeout(idleTimer.current);
|
||
};
|
||
}, [resetIdle]);
|
||
|
||
// ------------------------------------------------------------------
|
||
// Channel switching
|
||
// ------------------------------------------------------------------
|
||
|
||
const channelCount = channels?.length ?? 0;
|
||
|
||
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]);
|
||
|
||
// ------------------------------------------------------------------
|
||
// Keyboard shortcuts
|
||
// ------------------------------------------------------------------
|
||
|
||
useEffect(() => {
|
||
const handleKey = (e: KeyboardEvent) => {
|
||
if (
|
||
e.target instanceof HTMLInputElement ||
|
||
e.target instanceof HTMLTextAreaElement
|
||
)
|
||
return;
|
||
|
||
switch (e.key) {
|
||
case "ArrowUp":
|
||
case "PageUp":
|
||
e.preventDefault();
|
||
nextChannel();
|
||
break;
|
||
case "ArrowDown":
|
||
case "PageDown":
|
||
e.preventDefault();
|
||
prevChannel();
|
||
break;
|
||
case "s":
|
||
case "S":
|
||
setShowStats((v) => !v);
|
||
break;
|
||
case "g":
|
||
case "G":
|
||
toggleSchedule();
|
||
break;
|
||
case "f":
|
||
case "F":
|
||
toggleFullscreen();
|
||
break;
|
||
case "m":
|
||
case "M":
|
||
toggleMute();
|
||
break;
|
||
case "c":
|
||
case "C":
|
||
if (subtitleTracks.length > 0) {
|
||
setActiveSubtitleTrack((prev) =>
|
||
prev === -1 ? subtitleTracks[0].id : -1,
|
||
);
|
||
}
|
||
break;
|
||
default: {
|
||
if (e.key >= "0" && e.key <= "9") {
|
||
setChannelInput((prev) => {
|
||
const next = prev + e.key;
|
||
if (channelInputTimer.current)
|
||
clearTimeout(channelInputTimer.current);
|
||
channelInputTimer.current = setTimeout(() => {
|
||
const num = parseInt(next, 10);
|
||
if (num >= 1 && num <= Math.max(channelCount, 1)) {
|
||
switchChannel(num - 1);
|
||
resetIdle();
|
||
}
|
||
setChannelInput("");
|
||
}, 1500);
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKey);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKey);
|
||
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
||
};
|
||
}, [
|
||
nextChannel,
|
||
prevChannel,
|
||
toggleSchedule,
|
||
toggleFullscreen,
|
||
toggleMute,
|
||
channelCount,
|
||
switchChannel,
|
||
resetIdle,
|
||
subtitleTracks,
|
||
]);
|
||
|
||
// ------------------------------------------------------------------
|
||
// Touch swipe (swipe up = next channel, swipe down = prev channel)
|
||
// ------------------------------------------------------------------
|
||
|
||
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],
|
||
);
|
||
|
||
// ------------------------------------------------------------------
|
||
// Stream error recovery
|
||
// ------------------------------------------------------------------
|
||
|
||
const handleStreamError = useCallback(() => {
|
||
setStreamError(true);
|
||
}, []);
|
||
|
||
const handleVideoEnded = useCallback(() => {
|
||
setVideoEnded(true);
|
||
// Immediately poll for the next broadcast instead of waiting up to 30 s.
|
||
queryClient.invalidateQueries({ queryKey: ["broadcast", channel?.id] });
|
||
}, [queryClient, channel?.id]);
|
||
|
||
const handleRetry = useCallback(() => {
|
||
// Bust the cached stream URL so it refetches with the current offset
|
||
queryClient.invalidateQueries({
|
||
queryKey: ["stream-url", channel?.id, broadcast?.slot.id],
|
||
});
|
||
setStreamError(false);
|
||
}, [queryClient, channel?.id, broadcast?.slot.id]);
|
||
|
||
const submitChannelPassword = useCallback(
|
||
(password: string) => {
|
||
if (!channel) return;
|
||
const next = { ...channelPasswords, [channel.id]: password };
|
||
setChannelPasswords(next);
|
||
try {
|
||
localStorage.setItem("channel_passwords", JSON.stringify(next));
|
||
} catch {}
|
||
setShowChannelPasswordModal(false);
|
||
queryClient.invalidateQueries({ queryKey: ["broadcast", channel.id] });
|
||
},
|
||
[channel, channelPasswords, queryClient],
|
||
);
|
||
|
||
const submitBlockPassword = useCallback(
|
||
(password: string) => {
|
||
if (!broadcast?.slot.id) return;
|
||
const next = { ...blockPasswords, [broadcast.slot.id]: password };
|
||
setBlockPasswords(next);
|
||
try {
|
||
localStorage.setItem("block_passwords", JSON.stringify(next));
|
||
} catch {}
|
||
setShowBlockPasswordModal(false);
|
||
queryClient.invalidateQueries({
|
||
queryKey: ["stream-url", channel?.id, broadcast.slot.id],
|
||
});
|
||
},
|
||
[broadcast?.slot.id, blockPasswords, channel?.id, queryClient],
|
||
);
|
||
|
||
// ------------------------------------------------------------------
|
||
// Render helpers
|
||
// ------------------------------------------------------------------
|
||
|
||
const renderBase = () => {
|
||
if (isLoadingChannels) {
|
||
return <NoSignal variant="loading" message="Tuning in…" />;
|
||
}
|
||
if (!channels || channels.length === 0) {
|
||
return (
|
||
<NoSignal
|
||
variant="no-signal"
|
||
message="No channels configured. Visit the Dashboard to create one."
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Channel-level access errors (not password — those show a modal)
|
||
const broadcastErrMsg = (broadcastError as Error)?.message;
|
||
if (broadcastErrMsg === "auth_required") {
|
||
return (
|
||
<NoSignal variant="locked" message="Sign in to watch this channel." />
|
||
);
|
||
}
|
||
if (
|
||
broadcastErrMsg &&
|
||
broadcastError &&
|
||
(broadcastError as { status?: number }).status === 403
|
||
) {
|
||
return (
|
||
<NoSignal variant="locked" message="This channel is owner-only." />
|
||
);
|
||
}
|
||
|
||
if (isLoadingBroadcast) {
|
||
return <NoSignal variant="loading" message="Tuning in…" />;
|
||
}
|
||
if (!hasBroadcast) {
|
||
return (
|
||
<NoSignal
|
||
variant="no-signal"
|
||
message="Nothing is scheduled right now. Check back later."
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Block-level access errors (not password — those show a modal overlay)
|
||
const streamErrMsg = (streamUrlError as Error)?.message;
|
||
if (streamErrMsg === "auth_required") {
|
||
return (
|
||
<NoSignal variant="locked" message="Sign in to watch this block." />
|
||
);
|
||
}
|
||
if (
|
||
streamUrlError &&
|
||
(streamUrlError as { status?: number }).status === 403
|
||
) {
|
||
return <NoSignal variant="locked" message="This block is owner-only." />;
|
||
}
|
||
|
||
if (streamError) {
|
||
return (
|
||
<NoSignal variant="error" message="Stream failed to load.">
|
||
<button
|
||
onClick={handleRetry}
|
||
className="mt-2 rounded-md border border-zinc-700 bg-zinc-800/80 px-4 py-2 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100"
|
||
>
|
||
Retry
|
||
</button>
|
||
</NoSignal>
|
||
);
|
||
}
|
||
if (videoEnded) {
|
||
return <NoSignal variant="loading" message="Up next, stay tuned…" />;
|
||
}
|
||
if (streamUrl) {
|
||
return (
|
||
<VideoPlayer
|
||
ref={videoRef}
|
||
src={streamUrl}
|
||
className="absolute inset-0 h-full w-full"
|
||
initialOffset={
|
||
broadcast ? calcOffsetSecs(broadcast.slot.start_at) : 0
|
||
}
|
||
subtitleTrack={activeSubtitleTrack}
|
||
muted={isMuted}
|
||
showStats={showStats}
|
||
broadcast={broadcast}
|
||
onSubtitleTracksChange={setSubtitleTracks}
|
||
onStreamError={handleStreamError}
|
||
onEnded={handleVideoEnded}
|
||
onNeedsInteraction={() => setNeedsInteraction(true)}
|
||
/>
|
||
);
|
||
}
|
||
// Broadcast exists but stream URL resolving — show loading until ready
|
||
return <NoSignal variant="loading" message="Loading stream…" />;
|
||
};
|
||
|
||
// ------------------------------------------------------------------
|
||
// Render
|
||
// ------------------------------------------------------------------
|
||
|
||
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">{renderBase()}</div>
|
||
|
||
{/* ── Logo watermark — always visible, not tied to idle state ── */}
|
||
{channel?.logo && (
|
||
<div
|
||
className={`pointer-events-none absolute z-10 p-3 ${logoPositionClass(channel.logo_position)}`}
|
||
style={{ opacity: channel.logo_opacity ?? 1 }}
|
||
>
|
||
{channel.logo.trimStart().startsWith("<") ? (
|
||
<div
|
||
dangerouslySetInnerHTML={{ __html: channel.logo }}
|
||
className="h-12 w-auto"
|
||
/>
|
||
) : (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={channel.logo}
|
||
alt=""
|
||
className="h-12 w-auto object-contain"
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Channel password modal ──────────────────────────────────── */}
|
||
{showChannelPasswordModal && (
|
||
<ChannelPasswordModal
|
||
label="Channel password required"
|
||
onSubmit={submitChannelPassword}
|
||
onCancel={() => setShowChannelPasswordModal(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Block password modal ────────────────────────────────────── */}
|
||
{showBlockPasswordModal && (
|
||
<ChannelPasswordModal
|
||
label="Block password required"
|
||
onSubmit={submitBlockPassword}
|
||
onCancel={() => setShowBlockPasswordModal(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Autoplay blocked prompt ─────────────────────────────────── */}
|
||
{needsInteraction && (
|
||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||
<div className="rounded-xl bg-black/70 px-8 py-5 text-center backdrop-blur-sm">
|
||
<p className="text-sm font-medium text-zinc-200">
|
||
Click or move the mouse to play
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Overlays — only when channels available ─────────────────── */}
|
||
{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 }}
|
||
>
|
||
{/* Top-right: subtitle picker + guide toggle */}
|
||
<div className="flex justify-end gap-2 p-4">
|
||
{subtitleTracks.length > 0 && (
|
||
<div className="pointer-events-auto relative">
|
||
<button
|
||
className="rounded-md bg-black/50 px-3 py-1.5 text-xs backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||
style={{
|
||
color: activeSubtitleTrack !== -1 ? "white" : undefined,
|
||
borderBottom:
|
||
activeSubtitleTrack !== -1
|
||
? "2px solid white"
|
||
: "2px solid transparent",
|
||
}}
|
||
onClick={() => setShowSubtitlePicker((s) => !s)}
|
||
>
|
||
CC
|
||
</button>
|
||
|
||
{showSubtitlePicker && (
|
||
<div className="absolute right-0 top-9 z-30 min-w-[10rem] overflow-hidden rounded-md border border-zinc-700 bg-zinc-900/95 py-1 shadow-xl backdrop-blur">
|
||
<button
|
||
className={`w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-700 ${activeSubtitleTrack === -1 ? "text-white" : "text-zinc-400"}`}
|
||
onClick={() => {
|
||
setActiveSubtitleTrack(-1);
|
||
setShowSubtitlePicker(false);
|
||
}}
|
||
>
|
||
Off
|
||
</button>
|
||
{subtitleTracks.map((track) => (
|
||
<button
|
||
key={track.id}
|
||
className={`w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-700 ${activeSubtitleTrack === track.id ? "text-white" : "text-zinc-400"}`}
|
||
onClick={() => {
|
||
setActiveSubtitleTrack(track.id);
|
||
setShowSubtitlePicker(false);
|
||
}}
|
||
>
|
||
{track.name || track.lang || `Track ${track.id + 1}`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Volume control */}
|
||
<div className="pointer-events-auto relative">
|
||
<button
|
||
className="rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||
onClick={() => setShowVolumeSlider((s) => !s)}
|
||
title="Volume"
|
||
>
|
||
<VolumeIcon className="h-4 w-4" />
|
||
</button>
|
||
|
||
{showVolumeSlider && (
|
||
<div className="absolute right-0 top-9 z-30 w-36 rounded-lg border border-zinc-700 bg-zinc-900/95 p-3 shadow-xl backdrop-blur">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={100}
|
||
value={isMuted ? 0 : Math.round(volume * 100)}
|
||
onChange={(e) => {
|
||
const v = Number(e.target.value) / 100;
|
||
setVolume(v);
|
||
setIsMuted(v === 0);
|
||
}}
|
||
className="w-full accent-white"
|
||
/>
|
||
<div className="mt-1.5 flex items-center justify-between">
|
||
<button
|
||
onClick={toggleMute}
|
||
className="text-[10px] text-zinc-500 hover:text-zinc-300"
|
||
>
|
||
{isMuted ? "Unmute [M]" : "Mute [M]"}
|
||
</button>
|
||
<span className="font-mono text-[10px] text-zinc-500">
|
||
{isMuted ? "0" : Math.round(volume * 100)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
className="pointer-events-auto rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||
onClick={toggleFullscreen}
|
||
title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"}
|
||
>
|
||
{isFullscreen ? (
|
||
<Minimize2 className="h-4 w-4" />
|
||
) : (
|
||
<Maximize2 className="h-4 w-4" />
|
||
)}
|
||
</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>
|
||
)}
|
||
|
||
{/* Quality picker */}
|
||
<div className="pointer-events-auto relative">
|
||
<button
|
||
className="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={() => setShowQualityPicker((s) => !s)}
|
||
title="Stream quality"
|
||
>
|
||
{QUALITY_OPTIONS.find((o) => o.value === quality)?.label ??
|
||
quality}
|
||
</button>
|
||
{showQualityPicker && (
|
||
<div className="absolute right-0 top-9 z-30 min-w-[8rem] overflow-hidden rounded-md border border-zinc-700 bg-zinc-900/95 py-1 shadow-xl backdrop-blur">
|
||
{QUALITY_OPTIONS.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
className={`w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-700 ${quality === opt.value ? "text-white" : "text-zinc-400"}`}
|
||
onClick={() => changeQuality(opt.value)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
className={`pointer-events-auto rounded-md bg-black/50 p-1.5 backdrop-blur transition-colors hover:bg-black/70 hover:text-white ${showStats ? "text-violet-400" : "text-zinc-400"}`}
|
||
onClick={() => setShowStats((v) => !v)}
|
||
title="Stats for nerds [S]"
|
||
>
|
||
<Info className="h-4 w-4" />
|
||
</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"
|
||
onClick={toggleSchedule}
|
||
>
|
||
{showSchedule ? "Hide guide" : "Guide [G]"}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Bottom: banner + info row */}
|
||
<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}
|
||
/>
|
||
) : (
|
||
/* Minimal channel badge when no broadcast */
|
||
<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>
|
||
|
||
{/* 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 */}
|
||
{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">
|
||
<p className="mb-1 text-[10px] uppercase tracking-widest text-zinc-500">
|
||
Channel
|
||
</p>
|
||
<p className="font-mono text-5xl font-bold text-white">
|
||
{channelInput}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Schedule overlay — outside the fading div so it has its own visibility */}
|
||
{showOverlays && showSchedule && (
|
||
<div className="absolute bottom-4 right-4 top-14 z-20 w-80">
|
||
<ScheduleOverlay
|
||
channelName={channel.name}
|
||
slots={scheduleSlots}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|