Compare commits
2 Commits
fa695da81b
...
f069376136
| Author | SHA1 | Date | |
|---|---|---|---|
| f069376136 | |||
| 4d2eeaa8c6 |
@@ -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(),
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user