feat(stream): add stream quality selection and update stream URL handling
This commit is contained in:
@@ -232,6 +232,7 @@ impl IMediaProvider for NoopMediaProvider {
|
|||||||
async fn get_stream_url(
|
async fn get_stream_url(
|
||||||
&self,
|
&self,
|
||||||
_: &domain::MediaItemId,
|
_: &domain::MediaItemId,
|
||||||
|
_: &domain::StreamQuality,
|
||||||
) -> domain::DomainResult<String> {
|
) -> domain::DomainResult<String> {
|
||||||
Err(domain::DomainError::InfrastructureError(
|
Err(domain::DomainError::InfrastructureError(
|
||||||
"No media provider configured.".into(),
|
"No media provider configured.".into(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use chrono::Utc;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use domain::{DomainError, ScheduleEngineService};
|
use domain::{DomainError, ScheduleEngineService, StreamQuality};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
dto::{CurrentBroadcastResponse, ScheduledSlotResponse},
|
dto::{CurrentBroadcastResponse, ScheduledSlotResponse},
|
||||||
@@ -130,11 +130,18 @@ pub(super) async fn get_epg(
|
|||||||
/// Redirect to the stream URL for whatever is currently playing.
|
/// Redirect to the stream URL for whatever is currently playing.
|
||||||
/// Returns 307 Temporary Redirect so the client fetches from the media provider directly.
|
/// Returns 307 Temporary Redirect so the client fetches from the media provider directly.
|
||||||
/// Returns 204 No Content when the channel is in a gap (no-signal).
|
/// Returns 204 No Content when the channel is in a gap (no-signal).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct StreamQuery {
|
||||||
|
/// "direct" | bitrate in bps as string (e.g. "8000000"). Defaults to "direct".
|
||||||
|
quality: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn get_stream(
|
pub(super) async fn get_stream(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(channel_id): Path<Uuid>,
|
Path(channel_id): Path<Uuid>,
|
||||||
OptionalCurrentUser(user): OptionalCurrentUser,
|
OptionalCurrentUser(user): OptionalCurrentUser,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<StreamQuery>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||||
|
|
||||||
@@ -174,9 +181,13 @@ pub(super) async fn get_stream(
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stream_quality = match query.quality.as_deref() {
|
||||||
|
Some("direct") | None => StreamQuality::Direct,
|
||||||
|
Some(bps_str) => StreamQuality::Transcode(bps_str.parse::<u32>().unwrap_or(40_000_000)),
|
||||||
|
};
|
||||||
let url = state
|
let url = state
|
||||||
.schedule_engine
|
.schedule_engine
|
||||||
.get_stream_url(&broadcast.slot.item.id)
|
.get_stream_url(&broadcast.slot.item.id, &stream_quality)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Redirect::temporary(&url).into_response())
|
Ok(Redirect::temporary(&url).into_response())
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub mod value_objects;
|
|||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use entities::*;
|
pub use entities::*;
|
||||||
pub use errors::{DomainError, DomainResult};
|
pub use errors::{DomainError, DomainResult};
|
||||||
pub use ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamingProtocol};
|
pub use ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamingProtocol, StreamQuality};
|
||||||
pub use repositories::*;
|
pub use repositories::*;
|
||||||
pub use iptv::{generate_m3u, generate_xmltv};
|
pub use iptv::{generate_m3u, generate_xmltv};
|
||||||
pub use services::{ChannelService, ScheduleEngineService, UserService};
|
pub use services::{ChannelService, ScheduleEngineService, UserService};
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ use crate::entities::MediaItem;
|
|||||||
use crate::errors::{DomainError, DomainResult};
|
use crate::errors::{DomainError, DomainResult};
|
||||||
use crate::value_objects::{ContentType, MediaFilter, MediaItemId};
|
use crate::value_objects::{ContentType, MediaFilter, MediaItemId};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stream quality
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Requested stream quality for `get_stream_url`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum StreamQuality {
|
||||||
|
/// Try direct stream via PlaybackInfo; fall back to HLS at 8 Mbps.
|
||||||
|
Direct,
|
||||||
|
/// Force HLS transcode at this bitrate (bits per second).
|
||||||
|
Transcode(u32),
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Provider capabilities
|
// Provider capabilities
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -113,7 +126,7 @@ pub trait IMediaProvider: Send + Sync {
|
|||||||
///
|
///
|
||||||
/// URLs are intentionally *not* stored in the schedule because they may be
|
/// URLs are intentionally *not* stored in the schedule because they may be
|
||||||
/// short-lived (signed URLs, session tokens) or depend on client context.
|
/// short-lived (signed URLs, session tokens) or depend on client context.
|
||||||
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String>;
|
async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String>;
|
||||||
|
|
||||||
/// List top-level collections (libraries/sections) available in this provider.
|
/// List top-level collections (libraries/sections) available in this provider.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::entities::{
|
|||||||
ScheduledSlot,
|
ScheduledSlot,
|
||||||
};
|
};
|
||||||
use crate::errors::{DomainError, DomainResult};
|
use crate::errors::{DomainError, DomainResult};
|
||||||
use crate::ports::IMediaProvider;
|
use crate::ports::{IMediaProvider, StreamQuality};
|
||||||
use crate::repositories::{ChannelRepository, ScheduleRepository};
|
use crate::repositories::{ChannelRepository, ScheduleRepository};
|
||||||
use crate::value_objects::{
|
use crate::value_objects::{
|
||||||
BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy,
|
BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy,
|
||||||
@@ -224,8 +224,8 @@ impl ScheduleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delegate stream URL resolution to the configured media provider.
|
/// Delegate stream URL resolution to the configured media provider.
|
||||||
pub async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
|
pub async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String> {
|
||||||
self.media_provider.get_stream_url(item_id).await
|
self.media_provider.get_stream_url(item_id, quality).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all slots that overlap the given time window — the EPG data.
|
/// Return all slots that overlap the given time window — the EPG data.
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ pub(super) struct JellyfinItem {
|
|||||||
pub recursive_item_count: Option<u32>,
|
pub recursive_item_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct JellyfinPlaybackInfoResponse {
|
||||||
|
#[serde(rename = "MediaSources")]
|
||||||
|
pub media_sources: Vec<JellyfinMediaSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct JellyfinMediaSource {
|
||||||
|
#[serde(rename = "SupportsDirectStream")]
|
||||||
|
pub supports_direct_stream: bool,
|
||||||
|
#[serde(rename = "DirectStreamUrl")]
|
||||||
|
pub direct_stream_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
|
pub(super) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
|
||||||
match ct {
|
match ct {
|
||||||
ContentType::Movie => "Movie",
|
ContentType::Movie => "Movie",
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem,
|
Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem,
|
||||||
MediaItemId, ProviderCapabilities, SeriesSummary, StreamingProtocol,
|
MediaItemId, ProviderCapabilities, SeriesSummary, StreamQuality, StreamingProtocol,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::config::JellyfinConfig;
|
use super::config::JellyfinConfig;
|
||||||
use super::mapping::{map_jellyfin_item, TICKS_PER_SEC};
|
use super::mapping::{map_jellyfin_item, TICKS_PER_SEC};
|
||||||
use super::models::{jellyfin_item_type, JellyfinItemsResponse};
|
use super::models::{jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse};
|
||||||
|
|
||||||
pub struct JellyfinMediaProvider {
|
pub struct JellyfinMediaProvider {
|
||||||
pub(super) client: reqwest::Client,
|
pub(super) client: reqwest::Client,
|
||||||
@@ -361,24 +361,45 @@ impl IMediaProvider for JellyfinMediaProvider {
|
|||||||
Ok(body.items.into_iter().map(|item| item.name).collect())
|
Ok(body.items.into_iter().map(|item| item.name).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an HLS stream URL for a Jellyfin item.
|
async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String> {
|
||||||
///
|
match quality {
|
||||||
/// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC
|
StreamQuality::Direct => {
|
||||||
/// segments on the fly. HLS is preferred over a single MP4 stream because
|
let url = format!("{}/Items/{}/PlaybackInfo", self.config.base_url, item_id.as_ref());
|
||||||
/// `StartTimeTicks` works reliably with HLS — each segment is independent,
|
let resp = self.client.post(&url)
|
||||||
/// so Jellyfin can begin the playlist at the correct broadcast offset
|
.header("X-Emby-Token", &self.config.api_key)
|
||||||
/// without needing to byte-range seek into an in-progress transcode.
|
.query(&[("userId", &self.config.user_id), ("mediaSourceId", &item_id.as_ref().to_string())])
|
||||||
///
|
.json(&serde_json::json!({}))
|
||||||
/// The API key is embedded so the player needs no separate auth header.
|
.send().await
|
||||||
/// The caller (stream proxy route) appends `StartTimeTicks` when there is
|
.map_err(|e| DomainError::InfrastructureError(format!("PlaybackInfo failed: {e}")))?;
|
||||||
/// a non-zero broadcast offset.
|
|
||||||
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
|
if resp.status().is_success() {
|
||||||
Ok(format!(
|
let info: JellyfinPlaybackInfoResponse = resp.json().await
|
||||||
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate=40000000&mediaSourceId={}&api_key={}",
|
.map_err(|e| DomainError::InfrastructureError(format!("PlaybackInfo parse failed: {e}")))?;
|
||||||
self.config.base_url,
|
if let Some(src) = info.media_sources.first() {
|
||||||
item_id.as_ref(),
|
if src.supports_direct_stream {
|
||||||
item_id.as_ref(),
|
if let Some(rel_url) = &src.direct_stream_url {
|
||||||
self.config.api_key,
|
return Ok(format!("{}{}&api_key={}", self.config.base_url, rel_url, self.config.api_key));
|
||||||
))
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: HLS at 8 Mbps
|
||||||
|
Ok(self.hls_url(item_id, 8_000_000))
|
||||||
|
}
|
||||||
|
StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JellyfinMediaProvider {
|
||||||
|
fn hls_url(&self, item_id: &MediaItemId, bitrate: u32) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate={}&mediaSourceId={}&api_key={}",
|
||||||
|
self.config.base_url,
|
||||||
|
item_id.as_ref(),
|
||||||
|
bitrate,
|
||||||
|
item_id.as_ref(),
|
||||||
|
self.config.api_key,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem,
|
Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem,
|
||||||
MediaItemId, ProviderCapabilities, StreamingProtocol,
|
MediaItemId, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::config::LocalFilesConfig;
|
use super::config::LocalFilesConfig;
|
||||||
@@ -138,7 +138,7 @@ impl IMediaProvider for LocalFilesProvider {
|
|||||||
.map(|item| to_media_item(item_id.clone(), &item)))
|
.map(|item| to_media_item(item_id.clone(), &item)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
|
async fn get_stream_url(&self, item_id: &MediaItemId, _quality: &StreamQuality) -> DomainResult<String> {
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{}/api/v1/files/stream/{}",
|
"{}/api/v1/files/stream/{}",
|
||||||
self.base_url,
|
self.base_url,
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ import {
|
|||||||
} from "./components";
|
} from "./components";
|
||||||
import type { SubtitleTrack } from "./components/video-player";
|
import type { SubtitleTrack } from "./components/video-player";
|
||||||
import type { LogoPosition } from "@/lib/types";
|
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 { useAuthContext } from "@/context/auth-context";
|
||||||
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels";
|
||||||
import { useCast } from "@/hooks/use-cast";
|
import { useCast } from "@/hooks/use-cast";
|
||||||
@@ -37,10 +44,14 @@ const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
|
|||||||
|
|
||||||
function logoPositionClass(pos?: LogoPosition) {
|
function logoPositionClass(pos?: LogoPosition) {
|
||||||
switch (pos) {
|
switch (pos) {
|
||||||
case "top_left": return "top-0 left-0";
|
case "top_left":
|
||||||
case "bottom_left": return "bottom-0 left-0";
|
return "top-0 left-0";
|
||||||
case "bottom_right":return "bottom-0 right-0";
|
case "bottom_left":
|
||||||
default: return "top-0 right-0";
|
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.
|
// URL is the single source of truth for the active channel.
|
||||||
// channelIdx is derived — never stored in state.
|
// channelIdx is derived — never stored in state.
|
||||||
const channelId = searchParams.get("channel");
|
const channelId = searchParams.get("channel");
|
||||||
const channelIdx = channels && channelId
|
const channelIdx =
|
||||||
? Math.max(0, channels.findIndex((c) => c.id === channelId))
|
channels && channelId
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
channels.findIndex((c) => c.id === channelId),
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const channel = channels?.[channelIdx];
|
const channel = channels?.[channelIdx];
|
||||||
|
|
||||||
// Write a channel switch back to the URL so keyboard, buttons, and
|
// Write a channel switch back to the URL so keyboard, buttons, and
|
||||||
// guide links all stay in sync and the page is bookmarkable/refreshable.
|
// guide links all stay in sync and the page is bookmarkable/refreshable.
|
||||||
const switchChannel = useCallback((idx: number, list = channels) => {
|
const switchChannel = useCallback(
|
||||||
|
(idx: number, list = channels) => {
|
||||||
const target = list?.[idx];
|
const target = list?.[idx];
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
router.replace(`/tv?channel=${target.id}`, { scroll: false });
|
router.replace(`/tv?channel=${target.id}`, { scroll: false });
|
||||||
}, [channels, router]);
|
},
|
||||||
|
[channels, router],
|
||||||
|
);
|
||||||
|
|
||||||
// Overlay / idle state
|
// Overlay / idle state
|
||||||
const [showOverlays, setShowOverlays] = useState(true);
|
const [showOverlays, setShowOverlays] = useState(true);
|
||||||
@@ -89,13 +107,26 @@ function TvPageContent() {
|
|||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
// Access control — persisted per channel in localStorage
|
// Access control — persisted per channel in localStorage
|
||||||
const [channelPasswords, setChannelPasswords] = useState<Record<string, string>>(() => {
|
const [channelPasswords, setChannelPasswords] = useState<
|
||||||
try { return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}"); } catch { return {}; }
|
Record<string, string>
|
||||||
|
>(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const [blockPasswords, setBlockPasswords] = useState<Record<string, string>>(() => {
|
const [blockPasswords, setBlockPasswords] = useState<Record<string, string>>(
|
||||||
try { return JSON.parse(localStorage.getItem("block_passwords") ?? "{}"); } catch { return {}; }
|
() => {
|
||||||
});
|
try {
|
||||||
const [showChannelPasswordModal, setShowChannelPasswordModal] = useState(false);
|
return JSON.parse(localStorage.getItem("block_passwords") ?? "{}");
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [showChannelPasswordModal, setShowChannelPasswordModal] =
|
||||||
|
useState(false);
|
||||||
const [showBlockPasswordModal, setShowBlockPasswordModal] = useState(false);
|
const [showBlockPasswordModal, setShowBlockPasswordModal] = useState(false);
|
||||||
|
|
||||||
const channelPassword = channel ? channelPasswords[channel.id] : undefined;
|
const channelPassword = channel ? channelPasswords[channel.id] : undefined;
|
||||||
@@ -114,6 +145,23 @@ function TvPageContent() {
|
|||||||
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
|
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
|
||||||
const [showSubtitlePicker, setShowSubtitlePicker] = useState(false);
|
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
|
// Fullscreen
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -165,9 +213,11 @@ function TvPageContent() {
|
|||||||
videoRef.current.volume = volume;
|
videoRef.current.volume = volume;
|
||||||
}, [isMuted, 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;
|
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
|
// Auto-mute local video while casting, restore on cast end
|
||||||
const prevMutedRef = useRef(false);
|
const prevMutedRef = useRef(false);
|
||||||
@@ -198,12 +248,41 @@ function TvPageContent() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Per-channel data
|
// Per-channel data
|
||||||
const { data: broadcast, isLoading: isLoadingBroadcast, error: broadcastError } =
|
const {
|
||||||
useCurrentBroadcast(channel?.id ?? "", channelPassword);
|
data: broadcast,
|
||||||
const blockPassword = broadcast?.slot.id ? blockPasswords[broadcast.slot.id] : undefined;
|
isLoading: isLoadingBroadcast,
|
||||||
const { data: epgSlots } = useEpg(channel?.id ?? "", undefined, undefined, channelPassword);
|
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(
|
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.
|
// iOS Safari: track fullscreen state via webkit video element events.
|
||||||
@@ -273,6 +352,7 @@ function TvPageContent() {
|
|||||||
setShowOverlays(false);
|
setShowOverlays(false);
|
||||||
setShowVolumeSlider(false);
|
setShowVolumeSlider(false);
|
||||||
setShowSubtitlePicker(false);
|
setShowSubtitlePicker(false);
|
||||||
|
setShowQualityPicker(false);
|
||||||
}, IDLE_TIMEOUT_MS);
|
}, 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(() => {});
|
||||||
@@ -292,7 +372,9 @@ function TvPageContent() {
|
|||||||
const channelCount = channels?.length ?? 0;
|
const channelCount = channels?.length ?? 0;
|
||||||
|
|
||||||
const prevChannel = useCallback(() => {
|
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();
|
resetIdle();
|
||||||
}, [channelIdx, channelCount, switchChannel, resetIdle]);
|
}, [channelIdx, channelCount, switchChannel, resetIdle]);
|
||||||
|
|
||||||
@@ -345,7 +427,8 @@ function TvPageContent() {
|
|||||||
if (e.key >= "0" && e.key <= "9") {
|
if (e.key >= "0" && e.key <= "9") {
|
||||||
setChannelInput((prev) => {
|
setChannelInput((prev) => {
|
||||||
const next = prev + e.key;
|
const next = prev + e.key;
|
||||||
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
if (channelInputTimer.current)
|
||||||
|
clearTimeout(channelInputTimer.current);
|
||||||
channelInputTimer.current = setTimeout(() => {
|
channelInputTimer.current = setTimeout(() => {
|
||||||
const num = parseInt(next, 10);
|
const num = parseInt(next, 10);
|
||||||
if (num >= 1 && num <= Math.max(channelCount, 1)) {
|
if (num >= 1 && num <= Math.max(channelCount, 1)) {
|
||||||
@@ -366,18 +449,31 @@ function TvPageContent() {
|
|||||||
window.removeEventListener("keydown", handleKey);
|
window.removeEventListener("keydown", handleKey);
|
||||||
if (channelInputTimer.current) clearTimeout(channelInputTimer.current);
|
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)
|
// Touch swipe (swipe up = next channel, swipe down = prev channel)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
const handleTouchStart = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
touchStartY.current = e.touches[0].clientY;
|
touchStartY.current = e.touches[0].clientY;
|
||||||
resetIdle();
|
resetIdle();
|
||||||
}, [resetIdle]);
|
},
|
||||||
|
[resetIdle],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
const handleTouchEnd = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
if (touchStartY.current === null) return;
|
if (touchStartY.current === null) return;
|
||||||
const dy = touchStartY.current - e.changedTouches[0].clientY;
|
const dy = touchStartY.current - e.changedTouches[0].clientY;
|
||||||
touchStartY.current = null;
|
touchStartY.current = null;
|
||||||
@@ -385,7 +481,9 @@ function TvPageContent() {
|
|||||||
if (dy > 0) nextChannel();
|
if (dy > 0) nextChannel();
|
||||||
else prevChannel();
|
else prevChannel();
|
||||||
}
|
}
|
||||||
}, [nextChannel, prevChannel]);
|
},
|
||||||
|
[nextChannel, prevChannel],
|
||||||
|
);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Stream error recovery
|
// Stream error recovery
|
||||||
@@ -409,23 +507,35 @@ function TvPageContent() {
|
|||||||
setStreamError(false);
|
setStreamError(false);
|
||||||
}, [queryClient, channel?.id, broadcast?.slot.id]);
|
}, [queryClient, channel?.id, broadcast?.slot.id]);
|
||||||
|
|
||||||
const submitChannelPassword = useCallback((password: string) => {
|
const submitChannelPassword = useCallback(
|
||||||
|
(password: string) => {
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
const next = { ...channelPasswords, [channel.id]: password };
|
const next = { ...channelPasswords, [channel.id]: password };
|
||||||
setChannelPasswords(next);
|
setChannelPasswords(next);
|
||||||
try { localStorage.setItem("channel_passwords", JSON.stringify(next)); } catch {}
|
try {
|
||||||
|
localStorage.setItem("channel_passwords", JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
setShowChannelPasswordModal(false);
|
setShowChannelPasswordModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["broadcast", channel.id] });
|
queryClient.invalidateQueries({ queryKey: ["broadcast", channel.id] });
|
||||||
}, [channel, channelPasswords, queryClient]);
|
},
|
||||||
|
[channel, channelPasswords, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
const submitBlockPassword = useCallback((password: string) => {
|
const submitBlockPassword = useCallback(
|
||||||
|
(password: string) => {
|
||||||
if (!broadcast?.slot.id) return;
|
if (!broadcast?.slot.id) return;
|
||||||
const next = { ...blockPasswords, [broadcast.slot.id]: password };
|
const next = { ...blockPasswords, [broadcast.slot.id]: password };
|
||||||
setBlockPasswords(next);
|
setBlockPasswords(next);
|
||||||
try { localStorage.setItem("block_passwords", JSON.stringify(next)); } catch {}
|
try {
|
||||||
|
localStorage.setItem("block_passwords", JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
setShowBlockPasswordModal(false);
|
setShowBlockPasswordModal(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["stream-url", channel?.id, broadcast.slot.id] });
|
queryClient.invalidateQueries({
|
||||||
}, [broadcast?.slot.id, blockPasswords, channel?.id, queryClient]);
|
queryKey: ["stream-url", channel?.id, broadcast.slot.id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[broadcast?.slot.id, blockPasswords, channel?.id, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Render helpers
|
// Render helpers
|
||||||
@@ -447,10 +557,18 @@ function TvPageContent() {
|
|||||||
// Channel-level access errors (not password — those show a modal)
|
// Channel-level access errors (not password — those show a modal)
|
||||||
const broadcastErrMsg = (broadcastError as Error)?.message;
|
const broadcastErrMsg = (broadcastError as Error)?.message;
|
||||||
if (broadcastErrMsg === "auth_required") {
|
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) {
|
if (
|
||||||
return <NoSignal variant="locked" message="This channel is owner-only." />;
|
broadcastErrMsg &&
|
||||||
|
broadcastError &&
|
||||||
|
(broadcastError as { status?: number }).status === 403
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<NoSignal variant="locked" message="This channel is owner-only." />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoadingBroadcast) {
|
if (isLoadingBroadcast) {
|
||||||
@@ -468,9 +586,14 @@ function TvPageContent() {
|
|||||||
// Block-level access errors (not password — those show a modal overlay)
|
// Block-level access errors (not password — those show a modal overlay)
|
||||||
const streamErrMsg = (streamUrlError as Error)?.message;
|
const streamErrMsg = (streamUrlError as Error)?.message;
|
||||||
if (streamErrMsg === "auth_required") {
|
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." />;
|
return <NoSignal variant="locked" message="This block is owner-only." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +618,9 @@ function TvPageContent() {
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={streamUrl}
|
src={streamUrl}
|
||||||
className="absolute inset-0 h-full w-full"
|
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}
|
subtitleTrack={activeSubtitleTrack}
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
onSubtitleTracksChange={setSubtitleTracks}
|
onSubtitleTracksChange={setSubtitleTracks}
|
||||||
@@ -532,10 +657,17 @@ function TvPageContent() {
|
|||||||
style={{ opacity: channel.logo_opacity ?? 1 }}
|
style={{ opacity: channel.logo_opacity ?? 1 }}
|
||||||
>
|
>
|
||||||
{channel.logo.trimStart().startsWith("<") ? (
|
{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
|
// 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -562,7 +694,9 @@ function TvPageContent() {
|
|||||||
{needsInteraction && (
|
{needsInteraction && (
|
||||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -664,9 +798,11 @@ function TvPageContent() {
|
|||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"}
|
title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"}
|
||||||
>
|
>
|
||||||
{isFullscreen
|
{isFullscreen ? (
|
||||||
? <Minimize2 className="h-4 w-4" />
|
<Minimize2 className="h-4 w-4" />
|
||||||
: <Maximize2 className="h-4 w-4" />}
|
) : (
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{castAvailable && (
|
{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 ${
|
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"
|
isCasting ? "text-blue-400" : "text-zinc-400"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => isCasting ? stopCasting() : streamUrl && requestCast(streamUrl)}
|
onClick={() =>
|
||||||
title={isCasting ? `Stop casting to ${castDeviceName ?? "TV"}` : "Cast to TV"}
|
isCasting
|
||||||
|
? stopCasting()
|
||||||
|
: streamUrl && requestCast(streamUrl)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
isCasting
|
||||||
|
? `Stop casting to ${castDeviceName ?? "TV"}`
|
||||||
|
: "Cast to TV"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Cast className="h-4 w-4" />
|
<Cast className="h-4 w-4" />
|
||||||
</button>
|
</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
|
<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"
|
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}
|
onClick={toggleSchedule}
|
||||||
@@ -745,8 +914,12 @@ function TvPageContent() {
|
|||||||
{/* Channel number input overlay */}
|
{/* Channel number input overlay */}
|
||||||
{channelInput && (
|
{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">
|
<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="mb-1 text-[10px] uppercase tracking-widest text-zinc-500">
|
||||||
<p className="font-mono text-5xl font-bold text-white">{channelInput}</p>
|
Channel
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-5xl font-bold text-white">
|
||||||
|
{channelInput}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export async function GET(
|
|||||||
const token = request.nextUrl.searchParams.get("token");
|
const token = request.nextUrl.searchParams.get("token");
|
||||||
const channelPassword = request.nextUrl.searchParams.get("channel_password");
|
const channelPassword = request.nextUrl.searchParams.get("channel_password");
|
||||||
const blockPassword = request.nextUrl.searchParams.get("block_password");
|
const blockPassword = request.nextUrl.searchParams.get("block_password");
|
||||||
|
const quality = request.nextUrl.searchParams.get("quality");
|
||||||
|
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
@@ -35,7 +36,10 @@ export async function GET(
|
|||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
|
if (channelPassword) headers["X-Channel-Password"] = channelPassword;
|
||||||
if (blockPassword) headers["X-Block-Password"] = blockPassword;
|
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,
|
headers,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -132,14 +132,16 @@ export function useStreamUrl(
|
|||||||
slotId: string | undefined,
|
slotId: string | undefined,
|
||||||
channelPassword?: string,
|
channelPassword?: string,
|
||||||
blockPassword?: string,
|
blockPassword?: string,
|
||||||
|
quality?: string,
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword],
|
queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword, quality],
|
||||||
queryFn: async (): Promise<string | null> => {
|
queryFn: async (): Promise<string | null> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (token) params.set("token", token);
|
if (token) params.set("token", token);
|
||||||
if (channelPassword) params.set("channel_password", channelPassword);
|
if (channelPassword) params.set("channel_password", channelPassword);
|
||||||
if (blockPassword) params.set("block_password", blockPassword);
|
if (blockPassword) params.set("block_password", blockPassword);
|
||||||
|
if (quality) params.set("quality", quality);
|
||||||
const res = await fetch(`/api/stream/${channelId}?${params}`, {
|
const res = await fetch(`/api/stream/${channelId}?${params}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user