Files
k-tv/k-tv-frontend/app/(main)/tv/components/stats-panel.tsx
Gabriel Kaszewski e805028d46 feat: add server-sent events for logging and activity tracking
- Implemented a custom tracing layer (`AppLogLayer`) to capture log events and broadcast them to SSE clients.
- Created admin routes for streaming server logs and listing recent activity logs.
- Added an activity log repository interface and SQLite implementation for persisting activity events.
- Integrated activity logging into user authentication and channel CRUD operations.
- Developed frontend components for displaying server logs and activity logs in the admin panel.
- Enhanced the video player with a stats overlay for monitoring streaming metrics.
2026-03-16 02:21:40 +01:00

151 lines
4.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import type Hls from "hls.js";
import type { CurrentBroadcastResponse } from "@/lib/types";
interface StatsPanelProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
hlsRef: React.RefObject<Hls | null>;
streamingProtocol?: "hls" | "direct_file";
broadcast?: CurrentBroadcastResponse | null;
}
interface Stats {
protocol: string;
resolution: string;
bitrate: string;
bandwidth: string;
buffer: string;
offset: string;
slotEnds: string;
}
function fmtSecs(s: number) {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = Math.floor(s % 60);
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
function fmtKbps(bps: number) {
if (!bps) return "--";
return `${Math.round(bps / 1000).toLocaleString()} kbps`;
}
function getBufferAhead(video: HTMLVideoElement) {
const ct = video.currentTime;
for (let i = 0; i < video.buffered.length; i++) {
if (video.buffered.start(i) <= ct && ct <= video.buffered.end(i)) {
return video.buffered.end(i) - ct;
}
}
return 0;
}
export function StatsPanel({ videoRef, hlsRef, streamingProtocol, broadcast }: StatsPanelProps) {
const [stats, setStats] = useState<Stats>({
protocol: "--",
resolution: "--",
bitrate: "--",
bandwidth: "--",
buffer: "--",
offset: "--",
slotEnds: "--",
});
useEffect(() => {
const update = () => {
const video = videoRef.current;
const hls = hlsRef.current;
const protocol =
streamingProtocol === "direct_file"
? "Direct file"
: hls
? "HLS (hls.js)"
: "HLS (native)";
let resolution = "--";
let bitrate = "--";
let bandwidth = "--";
if (hls) {
const level = hls.currentLevel >= 0 ? hls.levels[hls.currentLevel] : null;
if (level) {
resolution = `${level.width}×${level.height}`;
bitrate = fmtKbps(level.bitrate);
}
if (hls.bandwidthEstimate > 0) {
bandwidth = fmtKbps(hls.bandwidthEstimate);
}
}
const buffer = video ? `${getBufferAhead(video).toFixed(1)} s` : "--";
const offset = video ? fmtSecs(video.currentTime) : "--";
let slotEnds = "--";
if (broadcast?.slot.end_at) {
const secsLeft = (new Date(broadcast.slot.end_at).getTime() - Date.now()) / 1000;
if (secsLeft > 0) {
slotEnds = `in ${fmtSecs(secsLeft)}`;
} else {
slotEnds = "ending…";
}
}
setStats({ protocol, resolution, bitrate, bandwidth, buffer, offset, slotEnds });
};
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [videoRef, hlsRef, streamingProtocol, broadcast]);
return (
<div className="pointer-events-none absolute bottom-20 left-4 z-30 min-w-56 rounded-lg border border-white/10 bg-black/75 px-4 py-3 backdrop-blur-sm">
<div className="mb-2.5 flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-violet-400">
Stats for nerds
</span>
<span className="rounded bg-green-900/50 px-1.5 py-0.5 text-[9px] text-green-400">
LIVE
</span>
</div>
<table className="w-full border-collapse font-mono text-[11px]">
<tbody>
<tr>
<td className="pr-4 text-zinc-500">Protocol</td>
<td className="text-zinc-200">{stats.protocol}</td>
</tr>
<tr>
<td className="pr-4 text-zinc-500">Resolution</td>
<td className="text-zinc-200">{stats.resolution}</td>
</tr>
<tr>
<td className="pr-4 text-zinc-500">Bitrate</td>
<td className="text-zinc-200">{stats.bitrate}</td>
</tr>
<tr>
<td className="pr-4 text-zinc-500">Bandwidth est.</td>
<td className="text-green-400">{stats.bandwidth}</td>
</tr>
<tr>
<td className="pr-4 text-zinc-500">Buffer</td>
<td className="text-zinc-200">{stats.buffer}</td>
</tr>
<tr className="border-t border-white/10">
<td className="pr-4 pt-2 text-zinc-500">Offset</td>
<td className="pt-2 text-zinc-200">{stats.offset}</td>
</tr>
<tr>
<td className="pr-4 text-zinc-500">Slot ends</td>
<td className="text-zinc-200">{stats.slotEnds}</td>
</tr>
</tbody>
</table>
</div>
);
}