feat(stream): add stream quality selection and update stream URL handling

This commit is contained in:
2026-03-14 04:03:54 +01:00
parent 8f42164bce
commit cf92cc49c2
11 changed files with 346 additions and 107 deletions

View File

@@ -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(),

View File

@@ -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())

View File

@@ -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};

View File

@@ -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.
/// ///

View File

@@ -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.

View File

@@ -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",

View File

@@ -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,
)
} }
} }

View File

@@ -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,

View File

@@ -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>
)} )}

View File

@@ -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",
}); });

View File

@@ -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",
}); });