mirror of
https://github.com/FluxaMedia/fluxa-desktop.git
synced 2026-08-17 12:44:09 +00:00
fix(player): wait for rendered video before handoff
This commit is contained in:
parent
91a68c899e
commit
2c5b159605
9 changed files with 129 additions and 35 deletions
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
19
src/App.tsx
19
src/App.tsx
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))}
|
||||
|
|
|
|||
Loading…
Reference in a new issue