From cf92cc49c2fdc343a42e75a8d209fb056e46cbe8 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 14 Mar 2026 04:03:54 +0100 Subject: [PATCH] feat(stream): add stream quality selection and update stream URL handling --- k-tv-backend/api/src/main.rs | 1 + .../api/src/routes/channels/broadcast.rs | 15 +- k-tv-backend/domain/src/lib.rs | 2 +- k-tv-backend/domain/src/ports.rs | 15 +- .../domain/src/services/schedule/mod.rs | 6 +- k-tv-backend/infra/src/jellyfin/models.rs | 14 + k-tv-backend/infra/src/jellyfin/provider.rs | 63 ++-- .../infra/src/local_files/provider.rs | 4 +- k-tv-frontend/app/(main)/tv/page.tsx | 323 ++++++++++++++---- .../app/api/stream/[channelId]/route.ts | 6 +- k-tv-frontend/hooks/use-tv.ts | 4 +- 11 files changed, 346 insertions(+), 107 deletions(-) diff --git a/k-tv-backend/api/src/main.rs b/k-tv-backend/api/src/main.rs index db599c0..f0cf5bb 100644 --- a/k-tv-backend/api/src/main.rs +++ b/k-tv-backend/api/src/main.rs @@ -232,6 +232,7 @@ impl IMediaProvider for NoopMediaProvider { async fn get_stream_url( &self, _: &domain::MediaItemId, + _: &domain::StreamQuality, ) -> domain::DomainResult { Err(domain::DomainError::InfrastructureError( "No media provider configured.".into(), diff --git a/k-tv-backend/api/src/routes/channels/broadcast.rs b/k-tv-backend/api/src/routes/channels/broadcast.rs index 9fccd7f..8e9f90e 100644 --- a/k-tv-backend/api/src/routes/channels/broadcast.rs +++ b/k-tv-backend/api/src/routes/channels/broadcast.rs @@ -8,7 +8,7 @@ use chrono::Utc; use serde::Deserialize; use uuid::Uuid; -use domain::{DomainError, ScheduleEngineService}; +use domain::{DomainError, ScheduleEngineService, StreamQuality}; use crate::{ dto::{CurrentBroadcastResponse, ScheduledSlotResponse}, @@ -130,11 +130,18 @@ pub(super) async fn get_epg( /// Redirect to the stream URL for whatever is currently playing. /// 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). +#[derive(Debug, Deserialize)] +pub(super) struct StreamQuery { + /// "direct" | bitrate in bps as string (e.g. "8000000"). Defaults to "direct". + quality: Option, +} + pub(super) async fn get_stream( State(state): State, Path(channel_id): Path, OptionalCurrentUser(user): OptionalCurrentUser, headers: HeaderMap, + Query(query): Query, ) -> Result { 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::().unwrap_or(40_000_000)), + }; let url = state .schedule_engine - .get_stream_url(&broadcast.slot.item.id) + .get_stream_url(&broadcast.slot.item.id, &stream_quality) .await?; Ok(Redirect::temporary(&url).into_response()) diff --git a/k-tv-backend/domain/src/lib.rs b/k-tv-backend/domain/src/lib.rs index da6b1a3..4ef7e41 100644 --- a/k-tv-backend/domain/src/lib.rs +++ b/k-tv-backend/domain/src/lib.rs @@ -14,7 +14,7 @@ pub mod value_objects; // Re-export commonly used types pub use entities::*; 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 iptv::{generate_m3u, generate_xmltv}; pub use services::{ChannelService, ScheduleEngineService, UserService}; diff --git a/k-tv-backend/domain/src/ports.rs b/k-tv-backend/domain/src/ports.rs index aee1d82..7ac6e09 100644 --- a/k-tv-backend/domain/src/ports.rs +++ b/k-tv-backend/domain/src/ports.rs @@ -12,6 +12,19 @@ use crate::entities::MediaItem; use crate::errors::{DomainError, DomainResult}; 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 // ============================================================================ @@ -113,7 +126,7 @@ pub trait IMediaProvider: Send + Sync { /// /// URLs are intentionally *not* stored in the schedule because they may be /// short-lived (signed URLs, session tokens) or depend on client context. - async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult; + async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult; /// List top-level collections (libraries/sections) available in this provider. /// diff --git a/k-tv-backend/domain/src/services/schedule/mod.rs b/k-tv-backend/domain/src/services/schedule/mod.rs index e224b38..7ad9936 100644 --- a/k-tv-backend/domain/src/services/schedule/mod.rs +++ b/k-tv-backend/domain/src/services/schedule/mod.rs @@ -10,7 +10,7 @@ use crate::entities::{ ScheduledSlot, }; use crate::errors::{DomainError, DomainResult}; -use crate::ports::IMediaProvider; +use crate::ports::{IMediaProvider, StreamQuality}; use crate::repositories::{ChannelRepository, ScheduleRepository}; use crate::value_objects::{ BlockId, ChannelId, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy, @@ -224,8 +224,8 @@ impl ScheduleEngineService { } /// Delegate stream URL resolution to the configured media provider. - pub async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult { - self.media_provider.get_stream_url(item_id).await + pub async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult { + self.media_provider.get_stream_url(item_id, quality).await } /// Return all slots that overlap the given time window — the EPG data. diff --git a/k-tv-backend/infra/src/jellyfin/models.rs b/k-tv-backend/infra/src/jellyfin/models.rs index c11c304..15d3974 100644 --- a/k-tv-backend/infra/src/jellyfin/models.rs +++ b/k-tv-backend/infra/src/jellyfin/models.rs @@ -47,6 +47,20 @@ pub(super) struct JellyfinItem { pub recursive_item_count: Option, } +#[derive(Debug, Deserialize)] +pub(super) struct JellyfinPlaybackInfoResponse { + #[serde(rename = "MediaSources")] + pub media_sources: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct JellyfinMediaSource { + #[serde(rename = "SupportsDirectStream")] + pub supports_direct_stream: bool, + #[serde(rename = "DirectStreamUrl")] + pub direct_stream_url: Option, +} + pub(super) fn jellyfin_item_type(ct: &ContentType) -> &'static str { match ct { ContentType::Movie => "Movie", diff --git a/k-tv-backend/infra/src/jellyfin/provider.rs b/k-tv-backend/infra/src/jellyfin/provider.rs index 4b059d8..ec20a7f 100644 --- a/k-tv-backend/infra/src/jellyfin/provider.rs +++ b/k-tv-backend/infra/src/jellyfin/provider.rs @@ -2,12 +2,12 @@ use async_trait::async_trait; use domain::{ Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem, - MediaItemId, ProviderCapabilities, SeriesSummary, StreamingProtocol, + MediaItemId, ProviderCapabilities, SeriesSummary, StreamQuality, StreamingProtocol, }; use super::config::JellyfinConfig; 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(super) client: reqwest::Client, @@ -361,24 +361,45 @@ impl IMediaProvider for JellyfinMediaProvider { Ok(body.items.into_iter().map(|item| item.name).collect()) } - /// Build an HLS stream URL for a Jellyfin item. - /// - /// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC - /// segments on the fly. HLS is preferred over a single MP4 stream because - /// `StartTimeTicks` works reliably with HLS — each segment is independent, - /// so Jellyfin can begin the playlist at the correct broadcast offset - /// without needing to byte-range seek into an in-progress transcode. - /// - /// The API key is embedded so the player needs no separate auth header. - /// The caller (stream proxy route) appends `StartTimeTicks` when there is - /// a non-zero broadcast offset. - async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult { - Ok(format!( - "{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate=40000000&mediaSourceId={}&api_key={}", - self.config.base_url, - item_id.as_ref(), - item_id.as_ref(), - self.config.api_key, - )) + async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult { + match quality { + StreamQuality::Direct => { + let url = format!("{}/Items/{}/PlaybackInfo", self.config.base_url, item_id.as_ref()); + let resp = self.client.post(&url) + .header("X-Emby-Token", &self.config.api_key) + .query(&[("userId", &self.config.user_id), ("mediaSourceId", &item_id.as_ref().to_string())]) + .json(&serde_json::json!({})) + .send().await + .map_err(|e| DomainError::InfrastructureError(format!("PlaybackInfo failed: {e}")))?; + + if resp.status().is_success() { + let info: JellyfinPlaybackInfoResponse = resp.json().await + .map_err(|e| DomainError::InfrastructureError(format!("PlaybackInfo parse failed: {e}")))?; + if let Some(src) = info.media_sources.first() { + if src.supports_direct_stream { + if let Some(rel_url) = &src.direct_stream_url { + 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, + ) } } diff --git a/k-tv-backend/infra/src/local_files/provider.rs b/k-tv-backend/infra/src/local_files/provider.rs index 1e0e7c5..df85f87 100644 --- a/k-tv-backend/infra/src/local_files/provider.rs +++ b/k-tv-backend/infra/src/local_files/provider.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use domain::{ Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem, - MediaItemId, ProviderCapabilities, StreamingProtocol, + MediaItemId, ProviderCapabilities, StreamQuality, StreamingProtocol, }; use super::config::LocalFilesConfig; @@ -138,7 +138,7 @@ impl IMediaProvider for LocalFilesProvider { .map(|item| to_media_item(item_id.clone(), &item))) } - async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult { + async fn get_stream_url(&self, item_id: &MediaItemId, _quality: &StreamQuality) -> DomainResult { Ok(format!( "{}/api/v1/files/stream/{}", self.base_url, diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx index dd7f097..893eb51 100644 --- a/k-tv-frontend/app/(main)/tv/page.tsx +++ b/k-tv-frontend/app/(main)/tv/page.tsx @@ -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(null); // Access control — persisted per channel in localStorage - const [channelPasswords, setChannelPasswords] = useState>(() => { - try { return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}"); } catch { return {}; } + const [channelPasswords, setChannelPasswords] = useState< + Record + >(() => { + try { + return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}"); + } catch { + return {}; + } }); - const [blockPasswords, setBlockPasswords] = useState>(() => { - try { return JSON.parse(localStorage.getItem("block_passwords") ?? "{}"); } catch { return {}; } - }); - const [showChannelPasswordModal, setShowChannelPasswordModal] = useState(false); + const [blockPasswords, setBlockPasswords] = useState>( + () => { + 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(() => { + 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 ; + return ( + + ); } - if (broadcastErrMsg && broadcastError && (broadcastError as { status?: number }).status === 403) { - return ; + if ( + broadcastErrMsg && + broadcastError && + (broadcastError as { status?: number }).status === 403 + ) { + return ( + + ); } 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 ; + return ( + + ); } - if (streamUrlError && (streamUrlError as { status?: number }).status === 403) { + if ( + streamUrlError && + (streamUrlError as { status?: number }).status === 403 + ) { return ; } @@ -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("<") ? ( -
+
) : ( // eslint-disable-next-line @next/next/no-img-element - + )}
)} @@ -562,7 +694,9 @@ function TvPageContent() { {needsInteraction && (
-

Click or move the mouse to play

+

+ Click or move the mouse to play +

)} @@ -664,9 +798,11 @@ function TvPageContent() { onClick={toggleFullscreen} title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"} > - {isFullscreen - ? - : } + {isFullscreen ? ( + + ) : ( + + )} {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" + } > )} + {/* Quality picker */} +
+ + {showQualityPicker && ( +
+ {QUALITY_OPTIONS.map((opt) => ( + + ))} +
+ )} +
+
)} diff --git a/k-tv-frontend/app/api/stream/[channelId]/route.ts b/k-tv-frontend/app/api/stream/[channelId]/route.ts index d7eeed3..cd918ee 100644 --- a/k-tv-frontend/app/api/stream/[channelId]/route.ts +++ b/k-tv-frontend/app/api/stream/[channelId]/route.ts @@ -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", }); diff --git a/k-tv-frontend/hooks/use-tv.ts b/k-tv-frontend/hooks/use-tv.ts index c4ea700..0475ddf 100644 --- a/k-tv-frontend/hooks/use-tv.ts +++ b/k-tv-frontend/hooks/use-tv.ts @@ -132,14 +132,16 @@ export function useStreamUrl( slotId: string | undefined, channelPassword?: string, blockPassword?: string, + quality?: string, ) { return useQuery({ - queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword], + queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword, quality], queryFn: async (): Promise => { const params = new URLSearchParams(); if (token) params.set("token", token); if (channelPassword) params.set("channel_password", channelPassword); if (blockPassword) params.set("block_password", blockPassword); + if (quality) params.set("quality", quality); const res = await fetch(`/api/stream/${channelId}?${params}`, { cache: "no-store", });