feat: enhance schedule slot handling with episode details and duration calculation

This commit is contained in:
2026-03-11 22:30:05 +01:00
parent 0f1b9c11fe
commit ee64fc0b8a
3 changed files with 101 additions and 24 deletions

View File

@@ -3,7 +3,12 @@ import { cn } from "@/lib/utils";
export interface ScheduleSlot { export interface ScheduleSlot {
id: string; id: string;
/** Headline: series name for episodes, film title for everything else. */
title: string; title: string;
/** Secondary line: "S1 · E3 · Episode Title" for episodes, year for movies. */
subtitle?: string | null;
/** Rounded slot duration in minutes. */
durationMins: number;
startTime: string; // "HH:MM" startTime: string; // "HH:MM"
endTime: string; // "HH:MM" endTime: string; // "HH:MM"
isCurrent?: boolean; isCurrent?: boolean;
@@ -50,8 +55,17 @@ export function ScheduleOverlay({ channelName, slots }: ScheduleOverlayProps) {
> >
{slot.title} {slot.title}
</p> </p>
{slot.subtitle && (
<p className={cn(
"truncate text-xs leading-snug",
slot.isCurrent ? "text-zinc-400" : "text-zinc-600"
)}>
{slot.subtitle}
</p>
)}
<p className="mt-0.5 font-mono text-[10px] text-zinc-600"> <p className="mt-0.5 font-mono text-[10px] text-zinc-600">
{slot.startTime} {slot.endTime} {slot.startTime} {slot.endTime}
{" · "}{slot.durationMins}m
</p> </p>
</div> </div>
</li> </li>

View File

@@ -11,7 +11,7 @@ import {
NoSignal, NoSignal,
} from "./components"; } from "./components";
import type { SubtitleTrack } from "./components/video-player"; import type { SubtitleTrack } from "./components/video-player";
import { Maximize2, Minimize2, Volume2, VolumeX } from "lucide-react"; import { 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 { import {
@@ -76,12 +76,17 @@ export default function TvPage() {
} }
}, []); }, []);
// Volume / mute // Volume control
const [volume, setVolume] = useState(1); // 0.0 1.0
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
useEffect(() => { useEffect(() => {
if (videoRef.current) videoRef.current.muted = isMuted; if (!videoRef.current) return;
}, [isMuted]); videoRef.current.muted = isMuted;
videoRef.current.volume = volume;
}, [isMuted, volume]);
const toggleMute = useCallback(() => setIsMuted((m) => !m), []); const toggleMute = useCallback(() => setIsMuted((m) => !m), []);
const VolumeIcon = isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
// 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("");
@@ -137,10 +142,11 @@ export default function TvPage() {
const resetIdle = useCallback(() => { const resetIdle = useCallback(() => {
setShowOverlays(true); setShowOverlays(true);
if (idleTimer.current) clearTimeout(idleTimer.current); if (idleTimer.current) clearTimeout(idleTimer.current);
idleTimer.current = setTimeout( idleTimer.current = setTimeout(() => {
() => setShowOverlays(false), setShowOverlays(false);
IDLE_TIMEOUT_MS, setShowVolumeSlider(false);
); setShowSubtitlePicker(false);
}, IDLE_TIMEOUT_MS);
// Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction) // Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction)
videoRef.current?.play().catch(() => {}); videoRef.current?.play().catch(() => {});
}, []); }, []);
@@ -395,16 +401,45 @@ export default function TvPage() {
</div> </div>
)} )}
{/* Volume control */}
<div className="pointer-events-auto relative">
<button <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" className="rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleMute} onClick={() => setShowVolumeSlider((s) => !s)}
title={isMuted ? "Unmute [M]" : "Mute [M]"} title="Volume"
> >
{isMuted <VolumeIcon className="h-4 w-4" />
? <VolumeX className="h-4 w-4" />
: <Volume2 className="h-4 w-4" />}
</button> </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 <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" 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} onClick={toggleFullscreen}

View File

@@ -47,13 +47,41 @@ export function toScheduleSlots(
slots: ScheduledSlotResponse[], slots: ScheduledSlotResponse[],
currentSlotId?: string, currentSlotId?: string,
): ScheduleSlot[] { ): ScheduleSlot[] {
return slots.map((slot) => ({ return slots.map((slot) => {
const item = slot.item;
const isEpisode = item.content_type === "episode";
// Headline: series name for episodes (fall back to episode title), film title otherwise
const title = isEpisode && item.series_name ? item.series_name : item.title;
// Subtitle: episode identifier + title, or year for films
let subtitle: string | null = null;
if (isEpisode) {
const epParts: string[] = [];
if (item.season_number != null) epParts.push(`S${item.season_number}`);
if (item.episode_number != null) epParts.push(`E${item.episode_number}`);
const epLabel = epParts.join(" · ");
subtitle = item.series_name
? [epLabel, item.title].filter(Boolean).join(" · ")
: epLabel || null;
} else if (item.year) {
subtitle = String(item.year);
}
const durationMins = Math.round(
(new Date(slot.end_at).getTime() - new Date(slot.start_at).getTime()) / 60_000,
);
return {
id: slot.id, id: slot.id,
title: slot.item.title, title,
subtitle,
durationMins,
startTime: fmtTime(slot.start_at), startTime: fmtTime(slot.start_at),
endTime: fmtTime(slot.end_at), endTime: fmtTime(slot.end_at),
isCurrent: slot.id === currentSlotId, isCurrent: slot.id === currentSlotId,
})); };
});
} }
/** /**