feat: enhance schedule slot handling with episode details and duration calculation
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user