fix(player): wait for rendered video before handoff

This commit is contained in:
KhooLy 2026-07-13 02:24:24 +03:00
parent 91a68c899e
commit 2c5b159605
9 changed files with 129 additions and 35 deletions

View file

@ -30,6 +30,7 @@ const MPV_RENDER_PARAM_SW_SIZE: c_int = 17;
const MPV_RENDER_PARAM_SW_FORMAT: c_int = 18;
const MPV_RENDER_PARAM_SW_STRIDE: c_int = 19;
const MPV_RENDER_PARAM_SW_POINTER: c_int = 20;
const MPV_RENDER_UPDATE_FRAME: u64 = 1 << 0;
const MPV_EVENT_NONE: c_int = 0;
const MPV_EVENT_LOG_MESSAGE: c_int = 2;
@ -405,6 +406,8 @@ impl MpvRenderer {
renderer.set_option("terminal", "no")?;
renderer.set_option("config", "no")?;
renderer.set_option("vo", "libmpv")?;
#[cfg(target_os = "windows")]
renderer.set_option("gpu-api", "opengl")?;
renderer.set_option("idle", "yes")?;
renderer.set_option("keep-open", "yes")?;
if let Err(error) = renderer.set_option("osc", "no") {
@ -433,7 +436,6 @@ impl MpvRenderer {
renderer.set_option("cache-secs", "30")?;
renderer.set_option("demuxer-max-bytes", "150MiB")?;
renderer.set_option("demuxer-readahead-secs", "10")?;
renderer.set_option("ytdl", "no")?;
// Lower audio latency and proper app name for PulseAudio/PipeWire
renderer.set_option("audio-buffer", "0.2")?;
@ -652,7 +654,7 @@ impl MpvRenderer {
let height = height.clamp(2, 1080);
self.ensure_buffer(width, height);
unsafe { (self.api.mpv_render_context_update)(self.render_context) };
let update_flags = unsafe { (self.api.mpv_render_context_update)(self.render_context) };
let mut size = [width, height];
let format = CString::new("rgb0").unwrap();
@ -694,7 +696,9 @@ impl MpvRenderer {
*alpha = 255;
}
self.frames_rendered = self.frames_rendered.saturating_add(1);
if update_flags & MPV_RENDER_UPDATE_FRAME != 0 {
self.frames_rendered = self.frames_rendered.saturating_add(1);
}
Ok(PlayerFrame {
width,
@ -719,7 +723,7 @@ impl MpvRenderer {
self.create_opengl_context()?;
}
unsafe { (self.api.mpv_render_context_update)(self.render_context) };
let update_flags = unsafe { (self.api.mpv_render_context_update)(self.render_context) };
// Linux/GTK: query the offscreen FBO that GTK's GLArea binds.
// Windows/macOS: render into the default framebuffer (FBO 0).
@ -759,7 +763,9 @@ impl MpvRenderer {
self.api.error_string(result)
));
}
self.frames_rendered = self.frames_rendered.saturating_add(1);
if update_flags & MPV_RENDER_UPDATE_FRAME != 0 {
self.frames_rendered = self.frames_rendered.saturating_add(1);
}
Ok(())
}
@ -909,7 +915,7 @@ impl MpvRenderer {
}
}
MPV_EVENT_COMMAND_REPLY if event.error < 0 => {
log::warn!(
log::debug!(
"mpv async command failed: {}",
self.api.error_string(event.error)
);

View file

@ -102,6 +102,7 @@ export default function App() {
const [detailInitialEpisode, setDetailInitialEpisode] = useState<Video | null>(null);
const [detailAutoShowStreams, setDetailAutoShowStreams] = useState(false);
const [detailResumeAt, setDetailResumeAt] = useState<number | undefined>(undefined);
const [detailPlaybackError, setDetailPlaybackError] = useState<string | null>(null);
const [discoverInitialGenre, setDiscoverInitialGenre] = useState<string | null>(null);
const [globalSearchQuery, setGlobalSearchQuery] = useState('');
const [searchFocusSignal, setSearchFocusSignal] = useState(0);
@ -113,8 +114,10 @@ export default function App() {
const stateRef = useRef<AppState>(DEFAULT_STATE);
const lastNonSettingsRouteRef = useRef<NavRoute>('home');
const lastNonSearchRouteRef = useRef<NavRoute>('home');
const episodePlaybackFailureRef = useRef<(meta: Meta, episode: Video, message: string) => Promise<void>>(async () => {});
const artworkPrefetchRef = useRef<Promise<unknown> | null>(null);
const windowFullscreenRef = useRef(false);
const handleEpisodePlaybackFailed = useCallback((meta: Meta, episode: Video, message: string) => episodePlaybackFailureRef.current(meta, episode, message), []);
const overlayPrefs = useCallback((merged: AppState): AppState => {
const prefs = storedPrefsRef.current;
@ -195,8 +198,22 @@ export default function App() {
activeProfile,
updateState,
onProfileUpdated: setActiveProfile,
onEpisodePlaybackFailed: handleEpisodePlaybackFailed,
});
const openEpisodeSourcePicker = useCallback(async (meta: Meta, episode: Video, message: string) => {
await closePlayer();
setDetailInitialEpisode(episode);
setDetailAutoShowStreams(true);
setDetailResumeAt(undefined);
setDetailPlaybackError(message);
setDetailMeta(meta);
}, [closePlayer]);
useEffect(() => {
episodePlaybackFailureRef.current = openEpisodeSourcePicker;
}, [openEpisodeSourcePicker]);
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | null = null;
@ -236,6 +253,7 @@ export default function App() {
totalDuration?: number,
sourceCandidates?: Stream[],
) => {
setDetailPlaybackError(null);
const isP2P = !!(stream.isTorrent || stream.infoHash);
if (!isP2P) {
await handlePlay(stream, meta, episode, resumeAt, totalDuration, sourceCandidates);
@ -699,6 +717,7 @@ export default function App() {
onBack={() => { void closePlayer(); setDetailMeta(null); setDetailInitialEpisode(null); setDetailAutoShowStreams(false); setDetailResumeAt(undefined); }}
initialEpisode={detailInitialEpisode}
autoShowStreams={detailAutoShowStreams}
playbackFailure={detailPlaybackError}
/>
)}
<div style={{ display: !showDetail && activeRoute === 'home' ? 'contents' : 'none' }}>

View file

@ -359,7 +359,16 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
const pixels = new Uint8ClampedArray(binary.length);
for (let i = 0; i < binary.length; i++) pixels[i] = binary.charCodeAt(i);
ctx.putImageData(new ImageData(pixels, frame.width, frame.height), 0, 0);
if (!firstFrameFiredRef.current && onFirstFrame) {
const status = liveStatusRef.current;
const position = parseFloat(status?.timePos ?? '0');
const hasRenderedVideo =
status?.loaded &&
status.hasVideoTrack &&
status.voConfigured === 'yes' &&
status.framesRendered >= 2 &&
status.pausedForCache !== 'yes' &&
position > 0.15;
if (!firstFrameFiredRef.current && onFirstFrame && hasRenderedVideo) {
firstFrameFiredRef.current = true;
sendCmd('set pause no');
onFirstFrame();
@ -560,7 +569,6 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
}
if (!firstFrameFiredRef.current && onFirstFrame) {
const noVideoTrack = status.trackListReady && !status.hasVideoTrack;
const hasVideoDimensions =
(parseFloat(status.width ?? '0') || 0) > 0 &&
(parseFloat(status.height ?? '0') || 0) > 0;
@ -569,9 +577,9 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in
status.pause !== 'yes' &&
status.pausedForCache !== 'yes' &&
pos > 0.15;
const voReady = !noVideoTrack && status.voConfigured === 'yes' && status.framesRendered >= 2 && status.pausedForCache !== 'yes';
const activeVideoPlayback = !noVideoTrack && status.hasVideoTrack && hasVideoDimensions && playbackAdvancing;
if (playbackUrl && status.path === playbackUrl && !status.resuming && (voReady || activeVideoPlayback || noVideoTrack)) {
const renderedVideo = status.hasVideoTrack && hasVideoDimensions && status.voConfigured === 'yes' && status.framesRendered >= 2 && playbackAdvancing;
const activeAudioOnlyPlayback = status.trackListReady && !status.hasVideoTrack && playbackAdvancing;
if (playbackUrl && status.path === playbackUrl && !status.resuming && (renderedVideo || activeAudioOnlyPlayback)) {
firstFrameFiredRef.current = true;
sendCmd('set pause no');
onFirstFrame();

View file

@ -208,6 +208,7 @@ export function EpisodePanel({
isLoadingStreams,
isLoadingEpisodes,
availableAddons,
playbackFailure,
streamAddonCount,
onBackToEpisodes,
onEpisodeClick,
@ -234,6 +235,7 @@ export function EpisodePanel({
isLoadingStreams: boolean;
isLoadingEpisodes?: boolean;
availableAddons: string[];
playbackFailure?: string | null;
streamAddonCount: number;
onBackToEpisodes: () => void;
onEpisodeClick: (ep: Video) => void;
@ -309,6 +311,7 @@ export function EpisodePanel({
streams={streams}
isLoading={isLoadingStreams}
availableAddons={availableAddons}
playbackFailure={playbackFailure}
streamAddonCount={streamAddonCount}
onBack={onBackToEpisodes}
onPlay={onPlaySource}

View file

@ -31,6 +31,7 @@ export type ModernDetailProps = {
selectedSeason: number;
selectedEpisode: Video | null;
showSources: boolean;
playbackFailure?: string | null;
streams: Stream[];
episodePlan: { seasonNumbers?: number[]; selectedSeason?: number; episodes?: Video[]; selectedEpisode?: Video | null } | null;
similarItems: Meta[];
@ -95,7 +96,7 @@ function GenreTag({ label, onClick }: { label: string; onClick?: () => void }) {
export function ModernDetailLayout({
displayMeta, bgUrl, isSeries, detail, meta, episodes, filteredEps, seasonNumbers,
selectedSeason, selectedEpisode, showSources, streams, episodePlan, similarItems,
selectedSeason, selectedEpisode, showSources, playbackFailure, streams, episodePlan, similarItems,
displayTrailers, trailerMetadata, castMembers, directorLinks, peopleImages,
watchedMap, progressMap, continueWatchingEntry, isInWatchlist, isDropped, isCompleted,
omdbRatings, fanartArtwork, availableAddons, streamAddonCount, poster,
@ -679,7 +680,7 @@ export function ModernDetailLayout({
{showSources && selectedEpisode && isSeries && (
<div style={MS.overlayBackdrop} onClick={onBackToEpisodes}>
<div style={MS.overlaySheet} onClick={(e) => e.stopPropagation()}>
<InlineSourceList episode={selectedEpisode} meta={displayMeta} streams={streams} isLoading={!!detail.isLoadingStreams} availableAddons={availableAddons} failedAddons={detail.failedAddons ?? []} streamAddonCount={streamAddonCount} onBack={onBackToEpisodes} onPlay={onPlaySource} onAddonChange={(addon) => onDispatch(JSON.stringify({ type: 'detailSelectedAddonChanged', addon }))} onRetryFailed={onRetryFailed} />
<InlineSourceList episode={selectedEpisode} meta={displayMeta} streams={streams} isLoading={!!detail.isLoadingStreams} availableAddons={availableAddons} failedAddons={detail.failedAddons ?? []} playbackFailure={playbackFailure} streamAddonCount={streamAddonCount} onBack={onBackToEpisodes} onPlay={onPlaySource} onAddonChange={(addon) => onDispatch(JSON.stringify({ type: 'detailSelectedAddonChanged', addon }))} onRetryFailed={onRetryFailed} />
</div>
</div>
)}
@ -687,7 +688,7 @@ export function ModernDetailLayout({
{showSources && !isSeries && (
<div style={MS.overlayBackdrop} onClick={onBackToEpisodes}>
<div style={MS.overlaySheet} onClick={(e) => e.stopPropagation()}>
<MovieSourcePanel meta={displayMeta} streams={streams} isLoading={!!detail.isLoadingStreams} availableAddons={availableAddons} failedAddons={detail.failedAddons ?? []} streamAddonCount={streamAddonCount} onPlay={(stream) => onPlay(stream, displayMeta, null, undefined, streams)} onAddonChange={(addon) => onDispatch(JSON.stringify({ type: 'detailSelectedAddonChanged', addon }))} onClose={onBackToEpisodes} onRetryFailed={onRetryFailed} />
<MovieSourcePanel meta={displayMeta} streams={streams} isLoading={!!detail.isLoadingStreams} availableAddons={availableAddons} failedAddons={detail.failedAddons ?? []} playbackFailure={playbackFailure} streamAddonCount={streamAddonCount} onPlay={(stream) => onPlay(stream, displayMeta, null, undefined, streams)} onAddonChange={(addon) => onDispatch(JSON.stringify({ type: 'detailSelectedAddonChanged', addon }))} onClose={onBackToEpisodes} onRetryFailed={onRetryFailed} />
</div>
</div>
)}

View file

@ -127,6 +127,7 @@ export function MovieSourcePanel({
isLoading,
availableAddons,
failedAddons,
playbackFailure,
streamAddonCount,
onPlay,
onAddonChange,
@ -138,6 +139,7 @@ export function MovieSourcePanel({
isLoading: boolean;
availableAddons: string[];
failedAddons?: string[];
playbackFailure?: string | null;
streamAddonCount: number;
onPlay: (stream: Stream) => void;
onAddonChange?: (addon: string | null) => void;
@ -199,6 +201,7 @@ export function MovieSourcePanel({
{!isLoading && !!failedAddons?.length && (
<FailedAddonsNotice count={failedAddons.length} onRetry={onRetryFailed} />
)}
{playbackFailure && <PlaybackFailureNotice message={playbackFailure} />}
<div key={selectedAddon ?? 'all'} style={EP.inlineSources}>
{isLoading && visibleStreams.length === 0 && <div style={SS.center}><div style={spinnerStyle} /></div>}
@ -236,6 +239,15 @@ function FailedAddonsNotice({ count, onRetry }: { count: number; onRetry?: () =>
);
}
function PlaybackFailureNotice({ message }: { message: string }) {
return (
<div style={{ padding: '0.625rem 1rem', margin: '0.375rem 1rem 0', background: 'rgba(255,94,94,0.12)', border: '1px solid rgba(255,94,94,0.32)', borderRadius: '0.375rem' }}>
<div style={{ color: '#FF9A9A', fontSize: '0.75rem', fontWeight: 700 }}>{t('player.playback_error_title')}</div>
<div style={{ color: 'rgba(255,255,255,0.72)', fontSize: '0.75rem', lineHeight: 1.4, marginTop: '0.1875rem', whiteSpace: 'pre-wrap' }}>{message}</div>
</div>
);
}
export function InlineSourceList({
episode,
meta,
@ -243,6 +255,7 @@ export function InlineSourceList({
isLoading,
availableAddons,
failedAddons,
playbackFailure,
streamAddonCount,
onBack,
onPlay,
@ -255,6 +268,7 @@ export function InlineSourceList({
isLoading: boolean;
availableAddons: string[];
failedAddons?: string[];
playbackFailure?: string | null;
streamAddonCount: number;
onBack: () => void;
onPlay: (stream: Stream) => void;
@ -324,6 +338,7 @@ export function InlineSourceList({
{!isLoading && !!failedAddons?.length && (
<FailedAddonsNotice count={failedAddons.length} onRetry={onRetryFailed} />
)}
{playbackFailure && <PlaybackFailureNotice message={playbackFailure} />}
<div key={selectedAddon ?? 'all'} style={EP.inlineSources}>
{isLoading && visibleStreams.length === 0 && <div style={SS.center}><div style={spinnerStyle} /></div>}

View file

@ -7,6 +7,7 @@ function debugLog(msg: string) {
void invoke('debug_log', { msg }).catch(() => {});
}
import {
type EmbeddedMpvStatus,
embeddedMpvAddSubtitle,
embeddedMpvApplyPreferences,
embeddedMpvSetHttpHeaders,
@ -71,6 +72,7 @@ interface UsePlayerOptions {
activeProfile: UserProfile | null;
updateState: (s: Partial<AppState>) => void;
onProfileUpdated?: (profile: UserProfile) => void;
onEpisodePlaybackFailed?: (meta: Meta, episode: Video, message: string) => Promise<void> | void;
}
interface UsePlayerResult {
@ -88,7 +90,7 @@ interface UsePlayerResult {
playerPlaybackError: string | null;
playerSubtitleWarning: string[] | null;
dismissSubtitleWarning: () => void;
handlePlay: (stream: Stream, meta?: Meta, episode?: Video | null, resumeAtSeconds?: number, totalDurationSeconds?: number, sourceCandidates?: Stream[]) => Promise<void>;
handlePlay: (stream: Stream, meta?: Meta, episode?: Video | null, resumeAtSeconds?: number, totalDurationSeconds?: number, sourceCandidates?: Stream[], openSourcePickerOnFailure?: boolean) => Promise<void>;
closePlayer: () => Promise<void>;
notifyFirstFrame: () => void;
flushProgressOnQuit: () => Promise<void>;
@ -116,7 +118,7 @@ function streamRequestHeaders(stream: Stream): Record<string, string> | undefine
return headers;
}
export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdated }: UsePlayerOptions): UsePlayerResult {
export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdated, onEpisodePlaybackFailed }: UsePlayerOptions): UsePlayerResult {
const [playerUrl, setPlayerUrl] = useState<string | null>(null);
const [playerTitle, setPlayerTitle] = useState<string | undefined>();
const [playerEpisodeTitle, setPlayerEpisodeTitle] = useState<string | undefined>();
@ -148,6 +150,9 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
const playingNextEpisodeRef = useRef<Video | null>(null);
const prefetchedNextEpRef = useRef<{ episodeId: string; stream: Stream } | null>(null);
const playerUsesTorrentRef = useRef(false);
const lastPlaybackStatusRef = useRef<EmbeddedMpvStatus | null>(null);
const openSourcePickerOnFailureRef = useRef(false);
const firstFrameHandoffPendingRef = useRef(false);
const playerLoadingOverlayRef = useRef<PlayerLoadingOverlayState | null>(null);
@ -362,13 +367,9 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
void playerClearChapters();
void playerClearEpisodes();
try {
const status = await withCloseTimeout(embeddedMpvStatus(), 700).catch(() => null);
const status = await withCloseTimeout(embeddedMpvStatus(), 700).catch(() => null) ?? lastPlaybackStatusRef.current;
if (!status && captureMeta) {
debugLog('closePlayer: embeddedMpvStatus timed out, final progress save skipped');
Sentry.captureMessage('closePlayer: final progress save skipped (status timeout)', {
level: 'warning',
extra: { metaId: captureMeta.id },
});
debugLog('closePlayer: embeddedMpvStatus timed out and no cached playback status is available');
}
if (captureMeta && captureStream) {
await persistLastPlaybackSource(captureMeta, captureStream).catch(() => undefined);
@ -505,6 +506,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
attemptedSourceKeysRef.current = new Set();
lastResumeAtSecondsRef.current = undefined;
lastTotalDurationSecondsRef.current = undefined;
lastPlaybackStatusRef.current = null;
}
}, [stateRef, updateState]);
@ -516,6 +518,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
const captureStream = playingStreamRef.current;
const status = await embeddedMpvStatus().catch(() => null);
if (!status) return;
lastPlaybackStatusRef.current = status;
const timePos = parseFloat(status.timePos ?? '0');
const duration = parseFloat(status.duration ?? '0');
if (!(timePos > 30 && duration > 0)) return;
@ -557,6 +560,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
resumeAtSeconds?: number,
totalDurationSeconds?: number,
sourceCandidates?: Stream[],
openSourcePickerOnFailure = false,
) => {
debugLog('handlePlay:start');
setPlayerPlaybackError(null);
@ -564,6 +568,7 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
try {
const generation = ++playGenerationRef.current;
const isCancelled = () => generation !== playGenerationRef.current;
openSourcePickerOnFailureRef.current = openSourcePickerOnFailure;
setPlayerUrl(null);
setPlayerUsesTorrent(streamIsP2P(stream));
const currentStreamKey = streamKey(stream);
@ -615,7 +620,11 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
const nextSource = nextRetrySource(stream, message === t('player.torrent_no_peers') || message === t('player.torrent_too_slow'));
if (nextSource && meta && !isCancelled()) {
setLoadingStatus(t('player.status_trying_next_source'));
await handlePlay(nextSource, meta, episode, resumeAtSeconds, effectiveTotalDuration);
await handlePlay(nextSource, meta, episode, resumeAtSeconds, effectiveTotalDuration, undefined, openSourcePickerOnFailure);
return;
}
if (openSourcePickerOnFailure && meta && episode && onEpisodePlaybackFailed) {
await onEpisodePlaybackFailed(meta, episode, message);
return;
}
if (!isCancelled()) await failPlayerLoading(message);
@ -730,6 +739,14 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
} else if (status.pausedForCache === 'yes') {
const pct = Math.round(parseFloat(status.cacheBufferingState ?? '') || 0);
setLoadingStatus(pct > 0 ? t('player.status_buffering_percent', pct) : t('player.status_buffering'));
} else if (
!status.hasVideoTrack ||
status.voConfigured !== 'yes' ||
status.framesRendered < 2 ||
(parseFloat(status.width ?? '0') || 0) <= 0 ||
(parseFloat(status.height ?? '0') || 0) <= 0
) {
setLoadingStatus(t('player.status_connecting_source'));
} else {
setLoadingStatus(t('player.status_starting_playback'));
}
@ -885,9 +902,13 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
})();
} catch (err) {
debugLog(`handlePlay:FATAL ${err instanceof Error ? `${err.message}\n${err.stack}` : String(err)}`);
if (openSourcePickerOnFailure && meta && episode && onEpisodePlaybackFailed) {
await onEpisodePlaybackFailed(meta, episode, err instanceof Error && err.message ? err.message : t('player.playback_error'));
return;
}
await failPlayerLoading(err instanceof Error && err.message ? err.message : (t('player.playback_error') || 'Playback failed'));
}
}, [stateRef, showPlayerLoading, failPlayerLoading, playInEmbeddedMpv, nextRetrySource, setLoadingStatus]);
}, [stateRef, showPlayerLoading, failPlayerLoading, playInEmbeddedMpv, nextRetrySource, setLoadingStatus, onEpisodePlaybackFailed]);
const handleNativePlayerError = useCallback(async (message: string) => {
const nextSource = nextRetrySource(playingStreamRef.current);
@ -903,8 +924,12 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
);
return;
}
if (openSourcePickerOnFailureRef.current && playingMetaRef.current && playingEpisodeRef.current && onEpisodePlaybackFailed) {
await onEpisodePlaybackFailed(playingMetaRef.current, playingEpisodeRef.current, message);
return;
}
if (!playerLoadingOverlayRef.current?.error) await failPlayerLoading(message);
}, [failPlayerLoading, handlePlay, nextRetrySource]);
}, [failPlayerLoading, handlePlay, nextRetrySource, onEpisodePlaybackFailed]);
const showEpisodeTransitionLoading = useCallback((meta: Meta, episode: Video, stream: Stream) => {
const title = playerDisplayTitle(meta, episode, stream);
@ -944,11 +969,19 @@ export function usePlayer({ stateRef, activeProfile, updateState, onProfileUpdat
closePlayer,
handlePlay,
onPlayerError: handleNativePlayerError,
onEpisodePlaybackFailed,
showEpisodeTransitionLoading,
});
const notifyFirstFrame = useCallback(() => {
setPlayerLoadingOverlay((prev) => (prev?.error ? prev : null));
if (firstFrameHandoffPendingRef.current) return;
firstFrameHandoffPendingRef.current = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
firstFrameHandoffPendingRef.current = false;
setPlayerLoadingOverlay((prev) => (prev?.error ? prev : null));
});
});
}, []);
const dismissSubtitleWarning = useCallback(() => {

View file

@ -20,6 +20,7 @@ export function usePlayerNativeEvents({
closePlayer,
handlePlay,
onPlayerError,
onEpisodePlaybackFailed,
showEpisodeTransitionLoading,
}: {
stateRef: React.MutableRefObject<AppState>;
@ -30,8 +31,9 @@ export function usePlayerNativeEvents({
playingNextEpisodeRef: React.MutableRefObject<Video | null>;
prefetchedNextEpRef: React.MutableRefObject<{ episodeId: string; stream: Stream } | null>;
closePlayer: () => Promise<void>;
handlePlay: (stream: Stream, meta?: Meta, episode?: Video | null, resumeAtSeconds?: number, totalDurationSeconds?: number, sourceCandidates?: Stream[]) => Promise<void>;
handlePlay: (stream: Stream, meta?: Meta, episode?: Video | null, resumeAtSeconds?: number, totalDurationSeconds?: number, sourceCandidates?: Stream[], openSourcePickerOnFailure?: boolean) => Promise<void>;
onPlayerError: (message: string) => Promise<void>;
onEpisodePlaybackFailed?: (meta: Meta, episode: Video, message: string) => Promise<void> | void;
showEpisodeTransitionLoading: (meta: Meta, episode: Video, stream: Stream) => void;
}) {
const episodeTransitionActiveRef = useRef(false);
@ -111,10 +113,11 @@ export function usePlayerNativeEvents({
} catch {}
}
if (!chosenStream) {
if (!closingPlayerRef.current) await onPlayerError(t('player.no_playable_url'));
if (!closingPlayerRef.current && onEpisodePlaybackFailed) await onEpisodePlaybackFailed(meta, nextEp, t('player.no_playable_url'));
else if (!closingPlayerRef.current) await onPlayerError(t('player.no_playable_url'));
return;
}
try { await handlePlay(chosenStream, meta, nextEp, undefined, undefined, sourceCandidates); } catch {}
try { await handlePlay(chosenStream, meta, nextEp, undefined, undefined, sourceCandidates, true); } catch {}
} finally {
episodeTransitionActiveRef.current = false;
}
@ -165,10 +168,11 @@ export function usePlayerNativeEvents({
}
} catch {}
if (!chosenStream) {
if (!closingPlayerRef.current) await onPlayerError(t('player.no_playable_url'));
if (!closingPlayerRef.current && onEpisodePlaybackFailed) await onEpisodePlaybackFailed(meta, ep, t('player.no_playable_url'));
else if (!closingPlayerRef.current) await onPlayerError(t('player.no_playable_url'));
return;
}
try { await handlePlay(chosenStream, meta, ep, undefined, undefined, sourceCandidates); } catch {}
try { await handlePlay(chosenStream, meta, ep, undefined, undefined, sourceCandidates, true); } catch {}
} finally {
episodeTransitionActiveRef.current = false;
}
@ -178,5 +182,5 @@ export function usePlayerNativeEvents({
.catch(() => undefined);
return () => { cancelled = true; unlisteners.forEach((fn) => fn()); };
}, [handlePlay, stateRef, closingPlayerRef, playingMetaRef, playingStreamRef, playingEpisodeRef, playingNextEpisodeRef, prefetchedNextEpRef, episodeTransitionActiveRef, showEpisodeTransitionLoading]);
}, [handlePlay, stateRef, closingPlayerRef, playingMetaRef, playingStreamRef, playingEpisodeRef, playingNextEpisodeRef, prefetchedNextEpRef, episodeTransitionActiveRef, showEpisodeTransitionLoading, onEpisodePlaybackFailed]);
}

View file

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, Play } from 'lucide-react';
import { coreDetailEpisodePlan, prewarmYoutubeTrailerConfig } from '../core/engine';
import { coreDetailEpisodePlan } from '../core/engine';
import { prewarmYoutubeTrailerConfig } from '../core/effectRunner';
import { coreSupportsResource } from '../core/addonManifest';
import { loadAddons } from '../core/libraryOps';
import { appPrefs, prefBool, prefString } from '../core/appPrefs';
@ -49,6 +50,7 @@ interface Props {
onBack: () => void;
initialEpisode?: Video | null;
autoShowStreams?: boolean;
playbackFailure?: string | null;
}
function orderStreamsByPrefs(streams: Stream[], prefs: Record<string, unknown>): Stream[] {
@ -71,7 +73,7 @@ function streamText(stream: Stream): string {
}
export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail, onNavigateGenre, onBack, initialEpisode, autoShowStreams }: Props) {
export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail, onNavigateGenre, onBack, initialEpisode, autoShowStreams, playbackFailure }: Props) {
const detail = state.detail;
const [bgError, setBgError] = useState(false);
const [selectedSeason, setSelectedSeason] = useState(initialEpisode?.season ?? 1);
@ -326,7 +328,7 @@ export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail
const castMembers = useMemo(() => buildCastMembers(displayMeta).slice(0, 12), [displayMeta]);
const directorLinks = useMemo(
() => (displayMeta.links ?? []).filter((l) => l.category.toLowerCase().includes('director')).slice(0, 2),
() => (displayMeta.links ?? []).filter((l) => String(l.category ?? '').toLowerCase().includes('director')).slice(0, 2),
[displayMeta.links],
);
@ -384,6 +386,7 @@ export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail
selectedSeason={selectedSeason}
selectedEpisode={selectedEpisode}
showSources={showSources}
playbackFailure={playbackFailure}
streams={streams}
episodePlan={episodePlan}
similarItems={similarItems}
@ -591,6 +594,7 @@ export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail
isLoadingStreams={!!detail.isLoadingStreams}
isLoadingEpisodes={detail.isLoading && filteredEps.length === 0}
availableAddons={detail.availableAddons ?? []}
playbackFailure={playbackFailure}
streamAddonCount={streamAddonCount}
onBackToEpisodes={() => setShowSources(false)}
onEpisodeClick={handleEpisodeClick}
@ -612,6 +616,7 @@ export function DetailScreen({ meta, state, onDispatch, onPlay, onNavigateDetail
isLoading={!!detail.isLoadingStreams}
availableAddons={detail.availableAddons ?? []}
failedAddons={detail.failedAddons ?? []}
playbackFailure={playbackFailure}
streamAddonCount={streamAddonCount}
onPlay={(stream) => onPlay(stream, displayMeta, null, undefined, streams)}
onAddonChange={(addon) => onDispatch(JSON.stringify({ type: 'detailSelectedAddonChanged', addon }))}