first commit

This commit is contained in:
2026-04-26 21:27:00 +07:00
commit 3ce6f0510b
48 changed files with 9700 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useMemo, useState } from "react";
import { apiJson } from "@/utils/api";
import type { MediaMtxPathsListResponse } from "@/types/api";
export function usePathsStatus(pollMs: number) {
const [data, setData] = useState<MediaMtxPathsListResponse | null>(null);
useEffect(() => {
let isMounted = true;
let t: number | undefined;
const tick = async () => {
try {
const res = await apiJson<MediaMtxPathsListResponse>("/paths", { method: "GET" });
if (isMounted) setData(res);
} catch {
if (isMounted) setData(null);
} finally {
t = window.setTimeout(tick, pollMs);
}
};
tick();
return () => {
isMounted = false;
if (t) window.clearTimeout(t);
};
}, [pollMs]);
const readyByName = useMemo(() => {
const map = new Map<string, boolean>();
for (const it of data?.items ?? []) {
map.set(it.name, Boolean(it.ready));
}
return map;
}, [data]);
return { raw: data, readyByName };
}
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
return savedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
return {
theme,
toggleTheme,
isDark: theme === 'dark'
};
}
+177
View File
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type PlayerStatus = "idle" | "connecting" | "playing" | "error";
function withNoTrailingSlash(url: string) {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
async function waitForIceGatheringComplete(pc: RTCPeerConnection) {
if (pc.iceGatheringState === "complete") return;
await new Promise<void>((resolve) => {
const onState = () => {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", onState);
resolve();
}
};
pc.addEventListener("icegatheringstatechange", onState);
});
}
export function useWhepPlayer({
enabled,
webrtcBaseUrl,
streamName,
}: {
enabled: boolean;
webrtcBaseUrl: string;
streamName: string;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const sessionUrlRef = useRef<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [status, setStatus] = useState<PlayerStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [restartNonce, setRestartNonce] = useState(0);
const whepUrl = useMemo(() => {
return `${withNoTrailingSlash(webrtcBaseUrl)}/${encodeURIComponent(streamName)}/whep`;
}, [webrtcBaseUrl, streamName]);
const stop = useCallback(async () => {
abortRef.current?.abort();
abortRef.current = null;
const sessionUrl = sessionUrlRef.current;
sessionUrlRef.current = null;
const pc = pcRef.current;
pcRef.current = null;
if (pc) {
try {
pc.ontrack = null;
pc.onconnectionstatechange = null;
pc.oniceconnectionstatechange = null;
pc.close();
} catch {
}
}
const v = videoRef.current;
if (v) {
try {
v.srcObject = null;
v.removeAttribute("src");
} catch {
}
}
if (sessionUrl) {
try {
await fetch(sessionUrl, { method: "DELETE" });
} catch {
}
}
}, []);
const restart = useCallback(() => {
setRestartNonce((n) => n + 1);
}, []);
useEffect(() => {
let disposed = false;
const run = async () => {
await stop();
if (!enabled) {
setStatus("idle");
setError(null);
return;
}
setStatus("connecting");
setError(null);
const abort = new AbortController();
abortRef.current = abort;
try {
const videoEl = videoRef.current;
if (!videoEl) throw new Error("no_video");
const pc = new RTCPeerConnection();
pcRef.current = pc;
pc.addTransceiver("video", { direction: "recvonly" });
pc.addTransceiver("audio", { direction: "recvonly" });
pc.ontrack = (ev) => {
const stream = ev.streams?.[0];
if (!stream) return;
if (videoEl.srcObject !== stream) {
videoEl.srcObject = stream;
void videoEl.play().catch(() => undefined);
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === "failed") {
restart();
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
const sdpOffer = pc.localDescription?.sdp;
if (!sdpOffer) throw new Error("no_offer");
const res = await fetch(whepUrl, {
method: "POST",
headers: {
"Content-Type": "application/sdp",
Accept: "application/sdp",
},
body: sdpOffer,
signal: abort.signal,
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`whep_http_${res.status}${t ? `: ${t}` : ""}`);
}
const answerSdp = await res.text();
const location = res.headers.get("location") || res.headers.get("Location");
if (location) sessionUrlRef.current = location;
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
if (!disposed) setStatus("playing");
} catch (e) {
if (!disposed) {
setStatus("error");
setError(e instanceof Error ? e.message : "unknown_error");
}
}
};
run();
return () => {
disposed = true;
void stop();
};
}, [enabled, restartNonce, stop, whepUrl, restart]);
return {
videoRef,
status,
error,
restart,
stop,
};
}