feat(stream): add stream quality selection and update stream URL handling
This commit is contained in:
@@ -14,7 +14,14 @@ import {
|
||||
} from "./components";
|
||||
import type { SubtitleTrack } from "./components/video-player";
|
||||
import type { LogoPosition } from "@/lib/types";
|
||||
import { Cast, Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react";
|
||||
import {
|
||||
Cast,
|
||||
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";
|
||||
@@ -37,10 +44,14 @@ 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";
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,18 +78,25 @@ function TvPageContent() {
|
||||
// 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 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]);
|
||||
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);
|
||||
@@ -89,13 +107,26 @@ function TvPageContent() {
|
||||
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 [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 [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;
|
||||
@@ -114,6 +145,23 @@ function TvPageContent() {
|
||||
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(() => {
|
||||
@@ -156,7 +204,7 @@ function TvPageContent() {
|
||||
}, []);
|
||||
|
||||
// Volume control
|
||||
const [volume, setVolume] = useState(1); // 0.0 – 1.0
|
||||
const [volume, setVolume] = useState(1); // 0.0 – 1.0
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -165,9 +213,11 @@ function TvPageContent() {
|
||||
videoRef.current.volume = volume;
|
||||
}, [isMuted, volume]);
|
||||
const toggleMute = useCallback(() => setIsMuted((m) => !m), []);
|
||||
const VolumeIcon = isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
|
||||
const VolumeIcon =
|
||||
isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
|
||||
|
||||
const { castAvailable, isCasting, castDeviceName, requestCast, stopCasting } = useCast();
|
||||
const { castAvailable, isCasting, castDeviceName, requestCast, stopCasting } =
|
||||
useCast();
|
||||
|
||||
// Auto-mute local video while casting, restore on cast end
|
||||
const prevMutedRef = useRef(false);
|
||||
@@ -198,12 +248,41 @@ function TvPageContent() {
|
||||
}, []);
|
||||
|
||||
// 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: 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,
|
||||
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.
|
||||
@@ -273,6 +352,7 @@ function TvPageContent() {
|
||||
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(() => {});
|
||||
@@ -292,7 +372,9 @@ function TvPageContent() {
|
||||
const channelCount = channels?.length ?? 0;
|
||||
|
||||
const prevChannel = useCallback(() => {
|
||||
switchChannel((channelIdx - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1));
|
||||
switchChannel(
|
||||
(channelIdx - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1),
|
||||
);
|
||||
resetIdle();
|
||||
}, [channelIdx, channelCount, switchChannel, resetIdle]);
|
||||
|
||||
@@ -345,7 +427,8 @@ function TvPageContent() {
|
||||
if (e.key >= "0" && e.key <= "9") {
|
||||
setChannelInput((prev) => {
|
||||
const next = prev + e.key;
|
||||
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
||||
if (channelInputTimer.current)
|
||||
clearTimeout(channelInputTimer.current);
|
||||
channelInputTimer.current = setTimeout(() => {
|
||||
const num = parseInt(next, 10);
|
||||
if (num >= 1 && num <= Math.max(channelCount, 1)) {
|
||||
@@ -366,26 +449,41 @@ function TvPageContent() {
|
||||
window.removeEventListener("keydown", handleKey);
|
||||
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
||||
};
|
||||
}, [nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute, channelCount, switchChannel, resetIdle]);
|
||||
}, [
|
||||
nextChannel,
|
||||
prevChannel,
|
||||
toggleSchedule,
|
||||
toggleFullscreen,
|
||||
toggleMute,
|
||||
channelCount,
|
||||
switchChannel,
|
||||
resetIdle,
|
||||
]);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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 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]);
|
||||
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
|
||||
@@ -409,23 +507,35 @@ function TvPageContent() {
|
||||
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 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]);
|
||||
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
|
||||
@@ -447,10 +557,18 @@ function TvPageContent() {
|
||||
// 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." />;
|
||||
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 (
|
||||
broadcastErrMsg &&
|
||||
broadcastError &&
|
||||
(broadcastError as { status?: number }).status === 403
|
||||
) {
|
||||
return (
|
||||
<NoSignal variant="locked" message="This channel is owner-only." />
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingBroadcast) {
|
||||
@@ -468,9 +586,14 @@ function TvPageContent() {
|
||||
// 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." />;
|
||||
return (
|
||||
<NoSignal variant="locked" message="Sign in to watch this block." />
|
||||
);
|
||||
}
|
||||
if (streamUrlError && (streamUrlError as { status?: number }).status === 403) {
|
||||
if (
|
||||
streamUrlError &&
|
||||
(streamUrlError as { status?: number }).status === 403
|
||||
) {
|
||||
return <NoSignal variant="locked" message="This block is owner-only." />;
|
||||
}
|
||||
|
||||
@@ -495,7 +618,9 @@ function TvPageContent() {
|
||||
ref={videoRef}
|
||||
src={streamUrl}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
initialOffset={broadcast ? calcOffsetSecs(broadcast.slot.start_at) : 0}
|
||||
initialOffset={
|
||||
broadcast ? calcOffsetSecs(broadcast.slot.start_at) : 0
|
||||
}
|
||||
subtitleTrack={activeSubtitleTrack}
|
||||
muted={isMuted}
|
||||
onSubtitleTracksChange={setSubtitleTracks}
|
||||
@@ -532,10 +657,17 @@ function TvPageContent() {
|
||||
style={{ opacity: channel.logo_opacity ?? 1 }}
|
||||
>
|
||||
{channel.logo.trimStart().startsWith("<") ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: channel.logo }} className="h-12 w-auto" />
|
||||
<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" />
|
||||
<img
|
||||
src={channel.logo}
|
||||
alt=""
|
||||
className="h-12 w-auto object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -562,7 +694,9 @@ function TvPageContent() {
|
||||
{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>
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
Click or move the mouse to play
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -664,9 +798,11 @@ function TvPageContent() {
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"}
|
||||
>
|
||||
{isFullscreen
|
||||
? <Minimize2 className="h-4 w-4" />
|
||||
: <Maximize2 className="h-4 w-4" />}
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{castAvailable && (
|
||||
@@ -674,13 +810,46 @@ function TvPageContent() {
|
||||
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"}
|
||||
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 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
|
||||
onClick={toggleSchedule}
|
||||
@@ -745,8 +914,12 @@ function TvPageContent() {
|
||||
{/* 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export async function GET(
|
||||
const token = request.nextUrl.searchParams.get("token");
|
||||
const channelPassword = request.nextUrl.searchParams.get("channel_password");
|
||||
const blockPassword = request.nextUrl.searchParams.get("block_password");
|
||||
const quality = request.nextUrl.searchParams.get("quality");
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
@@ -35,7 +36,10 @@ export async function GET(
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
|
||||
if (blockPassword) headers["X-Block-Password"] = blockPassword;
|
||||
res = await fetch(`${API_URL}/channels/${channelId}/stream`, {
|
||||
const backendParams = new URLSearchParams();
|
||||
if (quality) backendParams.set("quality", quality);
|
||||
const backendQuery = backendParams.toString() ? `?${backendParams}` : "";
|
||||
res = await fetch(`${API_URL}/channels/${channelId}/stream${backendQuery}`, {
|
||||
headers,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user