Compare commits

..

2 Commits

3 changed files with 27 additions and 12 deletions

View File

@@ -158,20 +158,18 @@ impl IMediaProvider for JellyfinMediaProvider {
/// Build an HLS stream URL for a Jellyfin item. /// Build an HLS stream URL for a Jellyfin item.
/// ///
/// Returns a `master.m3u8` playlist URL. HLS is preferred over a single /// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC
/// MP4 stream because hls.js `startPosition` can seek to the broadcast /// segments on the fly. HLS is preferred over a single MP4 stream because
/// offset before the first segment fetch, without byte-range seeking into /// `StartTimeTicks` works reliably with HLS — each segment is independent,
/// an in-progress transcode. /// so Jellyfin can begin the playlist at the correct broadcast offset
/// /// without needing to byte-range seek into an in-progress transcode.
/// `allowVideoStreamCopy=true` and `allowAudioStreamCopy=true` tell
/// Jellyfin to remux (not re-encode) content that is already H.264/AAC,
/// avoiding transcode cache file buildup. Content in other codecs will
/// still be transcoded to H.264/AAC for browser compatibility.
/// ///
/// The API key is embedded so the player needs no separate auth header. /// 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<String> { async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
Ok(format!( Ok(format!(
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&allowVideoStreamCopy=true&allowAudioStreamCopy=true&mediaSourceId={}&api_key={}", "{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate=40000000&mediaSourceId={}&api_key={}",
self.config.base_url, self.config.base_url,
item_id.as_ref(), item_id.as_ref(),
item_id.as_ref(), item_id.as_ref(),

View File

@@ -18,6 +18,8 @@ interface VideoPlayerProps {
subtitleTrack?: number; subtitleTrack?: number;
onStreamError?: () => void; onStreamError?: () => void;
onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void; onSubtitleTracksChange?: (tracks: SubtitleTrack[]) => void;
/** Called when the browser blocks autoplay and user interaction is required. */
onNeedsInteraction?: () => void;
} }
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>( const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
@@ -30,6 +32,7 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
subtitleTrack = -1, subtitleTrack = -1,
onStreamError, onStreamError,
onSubtitleTracksChange, onSubtitleTracksChange,
onNeedsInteraction,
}, },
ref, ref,
) => { ) => {
@@ -69,7 +72,7 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
hlsRef.current = hls; hlsRef.current = hls;
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {}); video.play().catch(() => onNeedsInteraction?.());
}); });
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
@@ -95,7 +98,7 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
"loadedmetadata", "loadedmetadata",
() => { () => {
if (initialOffset > 0) video.currentTime = initialOffset; if (initialOffset > 0) video.currentTime = initialOffset;
video.play().catch(() => {}); video.play().catch(() => onNeedsInteraction?.());
}, },
{ once: true }, { once: true },
); );

View File

@@ -56,6 +56,9 @@ export default function TvPage() {
// Stream error recovery // Stream error recovery
const [streamError, setStreamError] = useState(false); const [streamError, setStreamError] = useState(false);
// Autoplay blocked by browser — cleared on first interaction via resetIdle
const [needsInteraction, setNeedsInteraction] = useState(false);
// Subtitles // Subtitles
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]); const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1); const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1);
@@ -141,6 +144,7 @@ export default function TvPage() {
const resetIdle = useCallback(() => { const resetIdle = useCallback(() => {
setShowOverlays(true); setShowOverlays(true);
setNeedsInteraction(false);
if (idleTimer.current) clearTimeout(idleTimer.current); if (idleTimer.current) clearTimeout(idleTimer.current);
idleTimer.current = setTimeout(() => { idleTimer.current = setTimeout(() => {
setShowOverlays(false); setShowOverlays(false);
@@ -325,6 +329,7 @@ export default function TvPage() {
subtitleTrack={activeSubtitleTrack} subtitleTrack={activeSubtitleTrack}
onSubtitleTracksChange={setSubtitleTracks} onSubtitleTracksChange={setSubtitleTracks}
onStreamError={handleStreamError} onStreamError={handleStreamError}
onNeedsInteraction={() => setNeedsInteraction(true)}
/> />
); );
} }
@@ -348,6 +353,15 @@ export default function TvPage() {
{/* ── Base layer ─────────────────────────────────────────────── */} {/* ── Base layer ─────────────────────────────────────────────── */}
<div className="absolute inset-0">{renderBase()}</div> <div className="absolute inset-0">{renderBase()}</div>
{/* ── Autoplay blocked prompt ─────────────────────────────────── */}
{needsInteraction && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className="rounded-xl bg-black/70 px-8 py-5 text-center backdrop-blur-sm">
<p className="text-sm font-medium text-zinc-200">Click or move the mouse to play</p>
</div>
</div>
)}
{/* ── Overlays — only when channels available ─────────────────── */} {/* ── Overlays — only when channels available ─────────────────── */}
{channel && ( {channel && (
<> <>