🏗️ Build and Test JellyTau / Run Tests (push) Successful in 11m49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 10m18s
Traceability Validation / Check Requirement Traces (push) Successful in 1m2s
Build & Release / Run Tests (push) Successful in 10m46s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m16s
Build & Release / Build Linux (push) Successful in 24m59s
Build & Release / Build Android (push) Successful in 33m5s
Build & Release / Create Release (push) Successful in 17s
The audio-only (background-audio) button was gated on the AndroidBackgroundAudio JS-bridge probe, resolved once as a const at mount. The bridge is injected into the WebView asynchronously and races component mount, so on some loads the probe returned false and never recovered, hiding the button on 'some videos' at random. Gate on platform() === 'android' instead (synchronous, stable), matching the convention in VolumeControl. toggleBackgroundAudio() already no-ops if the bridge is momentarily absent. Bump version to 0.0.18.
2021 lines
83 KiB
Svelte
2021 lines
83 KiB
Svelte
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
|
|
<script lang="ts">
|
|
import { onMount, onDestroy, untrack } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { commands } from "$lib/api/bindings";
|
|
import type { JRayActor } from "$lib/api/bindings";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import Hls from "hls.js";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import { auth } from "$lib/stores/auth";
|
|
import VolumeControl from "./VolumeControl.svelte";
|
|
import SleepTimerModal from "./SleepTimerModal.svelte";
|
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
|
import CachedImage from "../common/CachedImage.svelte";
|
|
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
|
import { playbackPosition } from "$lib/stores/player";
|
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
|
import { playerController } from "$lib/player";
|
|
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
|
|
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
|
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
|
import {
|
|
setBackgroundAudioEnabled,
|
|
subscribeAppBackgrounded,
|
|
subscribeAppForegrounded,
|
|
} from "$lib/utils/backgroundAudio";
|
|
import { platform } from "@tauri-apps/plugin-os";
|
|
import {
|
|
computeHandoffPosition,
|
|
initialHandoffState,
|
|
shouldEnterBackgroundAudio,
|
|
shouldExitBackgroundAudio,
|
|
type BackgroundAudioState,
|
|
} from "./backgroundAudioHandoff";
|
|
|
|
interface Props {
|
|
media: MediaItem | null;
|
|
streamUrl: string;
|
|
mediaSourceId?: string; // Media source ID for subtitle URLs
|
|
initialPosition?: number; // Position in seconds to seek to after load (for resume)
|
|
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
|
|
onClose: () => void;
|
|
onSeek?: (positionSeconds: number, audioStreamIndex?: number) => Promise<string>; // Returns new stream URL for transcoded seeking
|
|
// Reporting callbacks pass the played media's id explicitly so a late
|
|
// reportStop (fired from onDestroy during autoplay navigation) is attributed
|
|
// to the episode this player actually played, not the next episode whose URL
|
|
// is already active on the page.
|
|
onReportProgress?: (positionSeconds: number, isPaused: boolean, reportId?: string) => void;
|
|
onReportStart?: (positionSeconds: number, reportId?: string) => void;
|
|
onReportStop?: (positionSeconds: number, reportId?: string) => void;
|
|
onEnded?: () => void; // Called when video playback ends naturally
|
|
onNext?: () => void; // Called when user clicks next episode button
|
|
hasNext?: boolean; // Whether there is a next episode available
|
|
isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
|
|
}
|
|
|
|
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props();
|
|
|
|
// The id this player instance reports progress against. Snapshotted from the
|
|
// media prop so a late reportStop (e.g. from onDestroy during autoplay
|
|
// navigation) is always attributed to the episode this player played.
|
|
// untrack() makes the intent explicit: capture the initial value only.
|
|
const reportMediaId = untrack(() => media?.id);
|
|
|
|
let videoElement: HTMLVideoElement | null = $state(null);
|
|
let isPlaying = $state(false);
|
|
let currentTime = $state(0);
|
|
// Guards against onEnded() firing more than once per loaded stream. For
|
|
// transcoded HLS, both the native `ended` event and the "fatal network error
|
|
// near end of stream" recovery path can fire for the same playback, which
|
|
// would otherwise call playerOnPlaybackEnded twice (e.g. decrementing the
|
|
// sleep-timer episode counter twice). Reset when the stream URL changes.
|
|
let endedFired = $state(false);
|
|
|
|
function notifyEnded() {
|
|
if (endedFired) return;
|
|
endedFired = true;
|
|
onEnded?.();
|
|
}
|
|
let isFullscreen = $state(false);
|
|
let showControls = $state(true);
|
|
let showSleepTimerModal = $state(false);
|
|
let isBuffering = $state(false);
|
|
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
|
let isSeeking = $state(false);
|
|
// Capture only the initial streamUrl prop; later prop changes are applied via
|
|
// the $effect below (untrack keeps this a one-time snapshot, matching
|
|
// reportMediaId above and silencing state_referenced_locally).
|
|
let currentStreamUrl = $state(untrack(() => streamUrl));
|
|
let hasReportedStart = $state(false);
|
|
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
|
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
|
|
let hasPerformedInitialSeek = $state(false); // Track if we've seeked to initialPosition
|
|
let canplayFallbackTimeout: ReturnType<typeof setTimeout> | null = null; // Fallback timeout for canplay event
|
|
let isDraggingSeekBar = $state(false); // Track if user is dragging the seek bar
|
|
let debugLogInterval: ReturnType<typeof setInterval> | null = null; // Debug logging interval
|
|
let rafId: number | null = null; // RequestAnimationFrame ID for smooth time updates
|
|
|
|
// Touch gesture state
|
|
let touchStartX = $state(0);
|
|
let touchStartY = $state(0);
|
|
let touchStartTime = $state(0);
|
|
let lastTapTime = $state(0);
|
|
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let brightness = $state(1); // 0-2, default 1
|
|
let showDoubleTapFeedback = $state<"left" | "right" | null>(null);
|
|
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let swipeGestureActive = $state(false);
|
|
|
|
// Backend info from Rust (Rust decides which backend to use based on platform)
|
|
let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend
|
|
let backendChosen = false; // playerPlayItem succeeded and told us which backend to use
|
|
let nativeUnlisteners: Array<() => void> = []; // raw-channel listeners for native backend mode
|
|
// Position updates captured before a native seek can land after it and snap
|
|
// the bar back; suppress backend position feeds briefly after each seek
|
|
// (same idea as the MPV backend's last_seek_time suppression).
|
|
let lastNativeSeekAt = 0;
|
|
const NATIVE_SEEK_SETTLE_MS = 1500;
|
|
function nativeSeekSettling(): boolean {
|
|
return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS;
|
|
}
|
|
let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount)
|
|
let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
|
|
let swipeType = $state<"brightness" | null>(null);
|
|
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
|
|
let hlsFatalRecoveryAttempts = 0; // Track recovery attempts to prevent infinite restarts
|
|
|
|
// ===== Player adapter (control boundary) =====
|
|
// The adapter owns the high-level control contract (play/pause/seek/track).
|
|
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
|
|
// registers the adapter with the facade so control intents — from UI OR from a
|
|
// backend control event (lockscreen/remote/sleep) — reach this element.
|
|
let playerAdapter: Html5PlayerAdapter | null = null;
|
|
|
|
function tearDownHls() {
|
|
if (hls) {
|
|
hls.detachMedia();
|
|
hls.stopLoad();
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
}
|
|
|
|
const adapterBridge: Html5ElementBridge = {
|
|
getElement: () => videoElement,
|
|
getSeekOffset: () => seekOffset,
|
|
setSeekOffset: (o) => { seekOffset = o; },
|
|
setStreamUrl: (u) => { currentStreamUrl = u; },
|
|
destroyHls: tearDownHls,
|
|
getMediaSourceId: () => mediaSourceId ?? null,
|
|
};
|
|
|
|
// Audio track selection
|
|
let showAudioTrackMenu = $state(false);
|
|
let selectedAudioTrackIndex = $state<number | null>(null);
|
|
|
|
// Subtitle track selection
|
|
let showSubtitleMenu = $state(false);
|
|
let selectedSubtitleIndex = $state<number | null>(null);
|
|
|
|
// Track duration from video element (for when media item doesn't have runTimeTicks)
|
|
let videoDuration = $state(0);
|
|
|
|
// Use known duration from media item (runTimeTicks is in 10M ticks/second)
|
|
// Fallback to video element duration for direct streams
|
|
const duration = $derived.by(() => {
|
|
// Explicitly check if durationMs exists and is a valid number
|
|
if (media && media.durationMs && media.durationMs > 0) {
|
|
return media.durationMs / 1000;
|
|
}
|
|
// Otherwise use the video element's duration
|
|
return videoDuration;
|
|
});
|
|
|
|
|
|
// Get available audio tracks from media streams
|
|
const audioTracks = $derived(() => {
|
|
if (!media || !media.mediaStreams) {
|
|
console.log("[VideoPlayer] No media or mediaStreams available");
|
|
return [];
|
|
}
|
|
const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
|
|
console.log("[VideoPlayer] Found audio tracks:", tracks.length, tracks);
|
|
return tracks;
|
|
});
|
|
|
|
// Function to find best matching audio track based on preference
|
|
function findBestAudioTrack(preference: { audioTrackDisplayTitle?: string | null, audioTrackLanguage?: string | null }) {
|
|
const tracks = audioTracks();
|
|
if (tracks.length === 0) return null;
|
|
|
|
// Try to match by display title first
|
|
if (preference.audioTrackDisplayTitle) {
|
|
const match = tracks.find(t => t.displayTitle === preference.audioTrackDisplayTitle);
|
|
if (match) {
|
|
console.log("[VideoPlayer] Matched audio track by display title:", match.displayTitle);
|
|
return match.index;
|
|
}
|
|
}
|
|
|
|
// Try to match by language
|
|
if (preference.audioTrackLanguage) {
|
|
const match = tracks.find(t => t.language === preference.audioTrackLanguage);
|
|
if (match) {
|
|
console.log("[VideoPlayer] Matched audio track by language:", match.language);
|
|
return match.index;
|
|
}
|
|
}
|
|
|
|
// Fall back to default track
|
|
const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
|
|
console.log("[VideoPlayer] Using default/first audio track:", defaultTrack.displayTitle || defaultTrack.language);
|
|
return defaultTrack.index;
|
|
}
|
|
|
|
// Load series audio preference on mount
|
|
async function loadSeriesAudioPreference() {
|
|
if (!media || !media.seriesId) return;
|
|
|
|
try {
|
|
const userId = auth.getUserId();
|
|
if (!userId) return;
|
|
|
|
const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);
|
|
|
|
if (preference) {
|
|
console.log("[VideoPlayer] Loaded series audio preference:", preference);
|
|
const matchedIndex = findBestAudioTrack(preference);
|
|
if (matchedIndex !== null) {
|
|
selectedAudioTrackIndex = matchedIndex;
|
|
console.log("[VideoPlayer] Applied series audio preference, track index:", matchedIndex);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("[VideoPlayer] Failed to load series audio preference:", err);
|
|
}
|
|
}
|
|
|
|
// Get available subtitle tracks from media streams
|
|
const subtitleTracks = $derived(() => {
|
|
if (!media || !media.mediaStreams) {
|
|
console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
|
|
return [];
|
|
}
|
|
const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
|
|
console.log("[VideoPlayer] Found subtitle tracks:", tracks.length, tracks);
|
|
return tracks;
|
|
});
|
|
|
|
// Track the last prop value to detect when parent changes the URL (vs internal seeks)
|
|
let lastStreamUrlProp = $state("");
|
|
|
|
// Update stream URL when prop changes (from parent component, not from internal seeks)
|
|
$effect(() => {
|
|
// Only reset when the streamUrl prop actually changes from parent
|
|
if (streamUrl !== lastStreamUrlProp) {
|
|
lastStreamUrlProp = streamUrl;
|
|
currentStreamUrl = streamUrl;
|
|
seekOffset = 0;
|
|
isMediaReady = false; // Reset to loading state when stream URL changes
|
|
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
|
lastAppliedInitialPosition = undefined; // New stream - forget the previously-applied resume point
|
|
endedFired = false; // New stream loaded - allow onEnded to fire again
|
|
html5Adapter.resetReporting(); // New stream - clear position-report throttle
|
|
}
|
|
});
|
|
|
|
|
|
// Sleep-timer expiry pause is now driven by the backend through the player
|
|
// adapter: playerEvents.ts routes `sleep_timer_expired` to the active adapter's
|
|
// pause() (see handleControlCommand / the sleep_timer_expired case). This
|
|
// removes the component's direct videoElement.pause() reach-in — the backend
|
|
// has control authority over the webview element via the adapter boundary.
|
|
|
|
// Native backend (Android ExoPlayer): drive the seek bar from the player
|
|
// store, which is fed by the backend's PositionUpdate events. The legacy
|
|
// "player://position-update" raw channel was never emitted by the backend,
|
|
// so without this the bar only moves when the user scrubs.
|
|
$effect(() => {
|
|
const position = $playbackPosition;
|
|
if (!useHtml5Element && !isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
|
|
currentTime = position;
|
|
}
|
|
});
|
|
|
|
// Set up HLS.js for HLS streams
|
|
$effect(() => {
|
|
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
|
|
return;
|
|
}
|
|
|
|
const isHlsStream = currentStreamUrl.includes('.m3u8');
|
|
|
|
if (isHlsStream && Hls.isSupported()) {
|
|
// Clean up existing HLS instance if any - CRITICAL for preventing dual audio
|
|
if (hls) {
|
|
console.log('[VideoPlayer] Cleaning up existing HLS instance');
|
|
// Detach from media element first to stop all audio/video
|
|
hls.detachMedia();
|
|
// Stop loading and flush buffers
|
|
hls.stopLoad();
|
|
// Destroy the instance
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
|
|
// Clear video element completely to stop any residual playback
|
|
// This is critical to prevent dual audio streams
|
|
if (videoElement.src) {
|
|
videoElement.pause(); // Ensure playback is stopped
|
|
videoElement.removeAttribute('src');
|
|
videoElement.load(); // Reset the media element and clear all buffers
|
|
videoElement.currentTime = 0;
|
|
}
|
|
|
|
// Small delay to ensure cleanup completes before creating new instance
|
|
// This prevents race conditions with dual audio
|
|
setTimeout(() => {
|
|
if (!videoElement) return;
|
|
|
|
console.log('[VideoPlayer] Creating new HLS instance for:', currentStreamUrl);
|
|
|
|
// Create new HLS instance
|
|
hls = new Hls({
|
|
debug: true, // Enable debug logging to diagnose loading issues
|
|
enableWorker: true,
|
|
lowLatencyMode: false,
|
|
// Buffer configuration for smooth playback without gaps
|
|
maxBufferLength: 60, // Maximum buffer length in seconds (increased for smoother playback)
|
|
maxMaxBufferLength: 120, // Maximum max buffer length in seconds
|
|
backBufferLength: 60, // Keep 60 seconds of back buffer to prevent gaps
|
|
maxBufferSize: 100 * 1000 * 1000, // 100MB max buffer size
|
|
maxBufferHole: 0.5, // Maximum buffer hole tolerance before seeking over it
|
|
maxFragLookUpTolerance: 0.25, // Fragment lookup tolerance
|
|
// Improve stall recovery
|
|
abrEwmaDefaultEstimate: 500000, // Initial bandwidth estimate
|
|
abrBandWidthFactor: 0.95, // Conservative bandwidth estimation
|
|
abrBandWidthUpFactor: 0.7, // Slower quality upgrades to reduce buffering
|
|
// Prevent aggressive buffer eviction
|
|
liveSyncDurationCount: 3, // Only for live streams
|
|
liveMaxLatencyDurationCount: 10, // Only for live streams
|
|
});
|
|
|
|
// Attach media element
|
|
hls.attachMedia(videoElement);
|
|
|
|
// Listen for media attached event
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
console.log('[VideoPlayer] HLS.js attached to video element');
|
|
// Load the HLS stream
|
|
hls!.loadSource(currentStreamUrl);
|
|
});
|
|
|
|
// Listen for manifest parsed event
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
|
|
});
|
|
|
|
// On the Android WebView the element's own `canplay` may not fire for
|
|
// MSE-fed HLS, so treat the first buffered fragment as "ready" too.
|
|
// This reveals the <video> element (otherwise it stays invisible behind
|
|
// the black poster card while audio plays).
|
|
hls.on(Hls.Events.FRAG_BUFFERED, () => {
|
|
markMediaReady();
|
|
});
|
|
|
|
// The canplay-fallback timeout is normally armed from the element's
|
|
// `loadstart` event, but with hls.js the element's `src` is "" and
|
|
// `loadstart` may not fire, so arm a backstop here directly.
|
|
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
|
|
canplayFallbackTimeout = setTimeout(() => {
|
|
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
|
|
console.warn('[VideoPlayer] HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
|
markMediaReady();
|
|
}
|
|
}, 5000);
|
|
|
|
// Reset recovery attempts for new HLS instance
|
|
hlsFatalRecoveryAttempts = 0;
|
|
|
|
// Handle errors
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
console.error('[VideoPlayer] HLS error:', data);
|
|
if (data.fatal) {
|
|
// Check if we're near the end of the video - if so, this is likely
|
|
// end-of-stream rather than a real error. Jellyfin transcoded HLS
|
|
// streams may not always terminate cleanly with #EXT-X-ENDLIST.
|
|
const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
|
|
const effectiveTime = currentTime + seekOffset;
|
|
const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;
|
|
|
|
switch (data.type) {
|
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
hlsFatalRecoveryAttempts++;
|
|
if (isNearEnd) {
|
|
// Near end of stream - treat as natural end, don't restart
|
|
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
|
|
notifyEnded();
|
|
} else if (hlsFatalRecoveryAttempts <= 3) {
|
|
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
|
|
hls!.startLoad();
|
|
} else {
|
|
console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
|
|
hls!.destroy();
|
|
}
|
|
break;
|
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
console.error('[VideoPlayer] Fatal media error, trying to recover');
|
|
hls!.recoverMediaError();
|
|
break;
|
|
default:
|
|
console.error('[VideoPlayer] Unrecoverable HLS error');
|
|
hls!.destroy();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}, 50); // 50ms delay to ensure cleanup completes
|
|
|
|
// Cleanup on effect re-run
|
|
return () => {
|
|
console.log('[VideoPlayer] Effect cleanup: destroying HLS instance');
|
|
if (hls) {
|
|
hls.detachMedia();
|
|
hls.stopLoad();
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
if (videoElement) {
|
|
videoElement.pause();
|
|
}
|
|
};
|
|
} else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
|
// Native HLS support (Safari)
|
|
console.log('[VideoPlayer] Using native HLS support');
|
|
videoElement.src = currentStreamUrl;
|
|
} else {
|
|
// Not an HLS stream, use regular video element
|
|
console.log('[VideoPlayer] Using regular video element for non-HLS stream');
|
|
}
|
|
});
|
|
|
|
// Ensure video element is unmuted and has max volume when it's bound (critical for Android)
|
|
$effect(() => {
|
|
if (videoElement) {
|
|
videoElement.muted = false;
|
|
videoElement.volume = 1.0;
|
|
console.log("[VideoPlayer] Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);
|
|
|
|
// DIAGNOSTIC: Check if video has audio tracks
|
|
if ((videoElement as any).audioTracks) {
|
|
console.log("[VideoPlayer] Audio tracks count:", (videoElement as any).audioTracks.length);
|
|
|
|
// Set initial audio track (prefer default track)
|
|
if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
|
|
const defaultTrack = audioTracks().find(t => t.isDefault);
|
|
selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
|
|
console.log("[VideoPlayer] Selected default audio track:", selectedAudioTrackIndex);
|
|
}
|
|
}
|
|
if ((videoElement as any).mozHasAudio !== undefined) {
|
|
console.log("[VideoPlayer] mozHasAudio:", (videoElement as any).mozHasAudio);
|
|
}
|
|
if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
|
|
console.log("[VideoPlayer] webkitAudioDecodedByteCount:", (videoElement as any).webkitAudioDecodedByteCount);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle initial position changes (for resuming the *same* loaded video from a
|
|
// different position, e.g. the resume point is re-chosen without a reload).
|
|
//
|
|
// This must only react to a genuine change of the `initialPosition` prop to a
|
|
// value we haven't already applied. The previous version re-fired on the very
|
|
// first seek (it also depended on, and wrote, `hasPerformedInitialSeek`), and
|
|
// since seeking a transcoded/HLS stream re-buffers and fires `canplay` again,
|
|
// the two seek paths ping-ponged forever — the player appeared to pause/resume
|
|
// in a loop. We now snapshot the last-applied position and only seek when the
|
|
// prop actually moves to a new value, untracking the writes so this effect
|
|
// can't re-trigger itself.
|
|
let lastAppliedInitialPosition = $state<number | undefined>(undefined);
|
|
$effect(() => {
|
|
const pos = initialPosition;
|
|
if (!pos || pos <= 0 || !isMediaReady) return;
|
|
// Only act on a real change to a not-yet-applied position.
|
|
if (pos === untrack(() => lastAppliedInitialPosition)) return;
|
|
// Skip the very first application; handleCanPlay owns the initial seek.
|
|
if (!untrack(() => hasPerformedInitialSeek)) {
|
|
lastAppliedInitialPosition = pos;
|
|
return;
|
|
}
|
|
untrack(() => {
|
|
console.log("[VideoPlayer] Initial position changed, seeking to:", pos);
|
|
lastAppliedInitialPosition = pos;
|
|
if (videoElement) {
|
|
videoElement.currentTime = pos;
|
|
currentTime = pos;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Set up progress reporting interval
|
|
onMount(async () => {
|
|
// Background-audio lifecycle listeners MUST be registered synchronously —
|
|
// before any await below — per the native-mode pitfall (an await here can
|
|
// flip the component into HTML5 mode). Unsubscribers go into nativeUnlisteners
|
|
// so onDestroy tears them down.
|
|
if (backgroundAudioSupported) {
|
|
nativeUnlisteners.push(subscribeAppBackgrounded(enterBackgroundAudioHandoff));
|
|
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
|
|
}
|
|
|
|
// Initialize player via Rust - Rust will decide which backend to use based on platform
|
|
if (media && currentStreamUrl) {
|
|
try {
|
|
console.log("[VideoPlayer] Initializing player for:", media.name);
|
|
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
|
|
|
// Build subtitle tracks for native player
|
|
const subtitleTracks = [];
|
|
if (media.mediaStreams && mediaSourceId) {
|
|
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
|
|
for (const sub of subtitles) {
|
|
try {
|
|
const url = await getSubtitleUrl(sub.index);
|
|
if (url) {
|
|
subtitleTracks.push({
|
|
index: sub.index,
|
|
url: url,
|
|
language: sub.language || null,
|
|
label: sub.displayTitle || sub.language || `Track ${sub.index}`,
|
|
mime_type: "text/vtt" // Jellyfin converts to WebVTT
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.warn(`[VideoPlayer] Failed to build subtitle URL for track ${sub.index}:`, err);
|
|
}
|
|
}
|
|
console.log(`[VideoPlayer] Built ${subtitleTracks.length} subtitle tracks for native player`);
|
|
}
|
|
|
|
// Call Rust backend to start playback
|
|
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
|
// Send minimal video data - no complex serialization to avoid Tauri Android issues
|
|
const response: any = await commands.playerPlayItem({
|
|
streamUrl: currentStreamUrl,
|
|
title: media.name,
|
|
id: media.id,
|
|
videoCodec: needsTranscoding ? "hevc" : "h264",
|
|
needsTranscoding: needsTranscoding,
|
|
});
|
|
|
|
// Rust tells us which backend it's using
|
|
useHtml5Element = response.useHtml5Element;
|
|
backendChosen = true;
|
|
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
|
|
|
// INTERIM (until the video-player API refactor lands): always render
|
|
// through the webview HTML5 element, including Android. The native
|
|
// ExoPlayer SurfaceView sits behind an opaque webview and has never
|
|
// actually been visible (an init bug kept the app on the HTML5 path
|
|
// since the POC), so true native mode plays audio behind a frozen
|
|
// picture. Stop the native backend and let the webview own playback,
|
|
// matching Linux behavior and avoiding dual audio.
|
|
if (!useHtml5Element) {
|
|
console.warn("[VideoPlayer] Native video backend reported - overriding to HTML5 rendering (native surface not visible through webview)");
|
|
useHtml5Element = true;
|
|
try {
|
|
await commands.playerStop();
|
|
didStopBackendEarly = true;
|
|
} catch (err) {
|
|
console.warn("[VideoPlayer] Failed to stop native backend:", err);
|
|
}
|
|
}
|
|
|
|
// If using HTML5 element for non-transcoded content, stop the backend player
|
|
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
|
|
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
|
|
try {
|
|
console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
|
await commands.playerStop();
|
|
didStopBackendEarly = true; // Track that we stopped the backend
|
|
} catch (err) {
|
|
console.warn("[VideoPlayer] Failed to stop backend player:", err);
|
|
}
|
|
} else if (useHtml5Element && needsTranscoding) {
|
|
console.log("[VideoPlayer] Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions");
|
|
// Backend is kept running but should not play audio since HTML5 element handles playback
|
|
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
|
}
|
|
|
|
// Register the HTML5 player adapter with the facade so control intents
|
|
// (UI or backend lockscreen/remote/sleep events) route to this element.
|
|
if (useHtml5Element) {
|
|
const host = createRustReportHost(media.id, {
|
|
onEnded: () => notifyEnded(),
|
|
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
|
|
});
|
|
playerAdapter = new Html5PlayerAdapter(host, adapterBridge);
|
|
playerAdapter.attach(videoElement);
|
|
playerController.setActiveAdapter(playerAdapter);
|
|
}
|
|
|
|
if (!useHtml5Element) {
|
|
// Using native backend, subscribe to player events
|
|
didStartNativePlayback = true; // Track that we started native playback
|
|
isPlaying = (response.state?.kind ?? response.state) === "playing";
|
|
// Cleanup happens in the component's top-level onDestroy. Calling
|
|
// onDestroy() here — after an await — throws lifecycle_outside_component,
|
|
// which the catch below used to misread as an init failure: it flipped
|
|
// useHtml5Element to true, so every seek went down the HTML5 path and
|
|
// never reached ExoPlayer (the video "seeked" then snapped back).
|
|
nativeUnlisteners.push(
|
|
await listen("player://position-update", (event: any) => {
|
|
if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
|
|
currentTime = event.payload.position;
|
|
}
|
|
})
|
|
);
|
|
nativeUnlisteners.push(
|
|
await listen("player://state-changed", (event: any) => {
|
|
isPlaying = event.payload.state === "playing";
|
|
})
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Failed to initialize player:", err);
|
|
if (backendChosen) {
|
|
// The backend already accepted the item; a later error (e.g. event
|
|
// subscription) must not silently switch the seek/controls path to
|
|
// HTML5 while the native backend keeps playing.
|
|
console.warn("[VideoPlayer] Backend already initialized - keeping native mode despite error");
|
|
} else {
|
|
// Fallback to HTML5 on error
|
|
useHtml5Element = true;
|
|
|
|
// For non-transcoded content, try to stop any backend player that might have started
|
|
if (!needsTranscoding) {
|
|
try {
|
|
await commands.playerStop();
|
|
didStopBackendEarly = true;
|
|
} catch (stopErr) {
|
|
// Ignore errors when stopping
|
|
}
|
|
} else {
|
|
// For transcoded content, keep backend for seeking
|
|
didStartNativePlayback = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load series audio preference (for TV shows)
|
|
await loadSeriesAudioPreference();
|
|
|
|
// Report progress every 10 seconds while playing. Live streams have no
|
|
// meaningful position to report, so skip progress reporting entirely.
|
|
if (!isLive) {
|
|
progressInterval = setInterval(() => {
|
|
if (isPlaying && !isSeeking && onReportProgress) {
|
|
onReportProgress(currentTime, false, reportMediaId);
|
|
}
|
|
}, 10000);
|
|
}
|
|
|
|
// Debug logging every second
|
|
debugLogInterval = setInterval(() => {
|
|
if (videoElement && isPlaying && !isSeeking) {
|
|
const buffered = videoElement.buffered;
|
|
const bufferedRanges = [];
|
|
for (let i = 0; i < buffered.length; i++) {
|
|
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
|
|
}
|
|
|
|
console.log("[VideoPlayer Debug]", {
|
|
currentTime: videoElement.currentTime.toFixed(2),
|
|
displayTime: currentTime.toFixed(2),
|
|
buffered: bufferedRanges.join(", "),
|
|
readyState: videoElement.readyState,
|
|
paused: videoElement.paused,
|
|
seeking: videoElement.seeking,
|
|
playbackRate: videoElement.playbackRate,
|
|
});
|
|
}
|
|
}, 1000);
|
|
});
|
|
|
|
onDestroy(async () => {
|
|
// Stop RAF loop
|
|
stopTimeUpdates();
|
|
|
|
// Unregister the adapter from the facade (guarded so we only clear our own).
|
|
if (playerAdapter) {
|
|
playerController.clearActiveAdapter(playerAdapter);
|
|
playerAdapter = null;
|
|
}
|
|
|
|
if (progressInterval) {
|
|
clearInterval(progressInterval);
|
|
}
|
|
if (debugLogInterval) {
|
|
clearInterval(debugLogInterval);
|
|
}
|
|
|
|
// Remove native backend event listeners (incl. background-audio lifecycle subs)
|
|
for (const unlisten of nativeUnlisteners) {
|
|
unlisten();
|
|
}
|
|
nativeUnlisteners = [];
|
|
|
|
// Re-assert defaults so this player's background-audio choice can't leak into
|
|
// the next one: disarm background audio and restore auto-PiP.
|
|
if (backgroundAudioSupported) {
|
|
setBackgroundAudioEnabled(false);
|
|
setAutoEnterEnabled(true);
|
|
}
|
|
|
|
// Clean up HLS.js instance - prevent dual audio on unmount
|
|
if (hls) {
|
|
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
|
|
hls.detachMedia(); // Detach from video element first
|
|
hls.stopLoad(); // Stop loading and flush buffers
|
|
hls.destroy();
|
|
hls = null;
|
|
}
|
|
|
|
// Stop video element playback
|
|
if (videoElement) {
|
|
videoElement.pause();
|
|
videoElement.src = '';
|
|
videoElement.load();
|
|
}
|
|
|
|
// Stop the player when component is destroyed
|
|
// Skip if we already stopped the backend early (non-transcoded + HTML5)
|
|
if (didStartNativePlayback && !didStopBackendEarly) {
|
|
try {
|
|
console.log("[VideoPlayer] Stopping backend player on component unmount");
|
|
await commands.playerStop();
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Failed to stop backend player:", err);
|
|
}
|
|
}
|
|
|
|
// Report stop when component is destroyed (skip for live - no resume tracking)
|
|
if (!isLive && onReportStop && currentTime > 0) {
|
|
onReportStop(currentTime, reportMediaId);
|
|
}
|
|
});
|
|
|
|
// Smooth time updates using requestAnimationFrame (60fps)
|
|
function updateTimeLoop() {
|
|
if (videoElement && !isSeeking && !isDraggingSeekBar && isPlaying) {
|
|
const newCurrentTime = seekOffset + videoElement.currentTime;
|
|
if (videoElement.readyState >= 2) {
|
|
currentTime = newCurrentTime;
|
|
// Feed the Rust controller a throttled position tick (~250ms) so it
|
|
// stays the source of truth for HTML5 video without flooding IPC.
|
|
html5Adapter.reportPosition(currentTime, duration);
|
|
}
|
|
}
|
|
|
|
// Keep the loop alive while playing — stopped by handlePause/handleEnded
|
|
if (isPlaying) {
|
|
rafId = requestAnimationFrame(updateTimeLoop);
|
|
} else {
|
|
rafId = null;
|
|
}
|
|
}
|
|
|
|
// Start/stop RAF loop based on playback state
|
|
function startTimeUpdates() {
|
|
if (rafId === null) {
|
|
rafId = requestAnimationFrame(updateTimeLoop);
|
|
}
|
|
}
|
|
|
|
function stopTimeUpdates() {
|
|
if (rafId !== null) {
|
|
cancelAnimationFrame(rafId);
|
|
rafId = null;
|
|
}
|
|
}
|
|
|
|
// Fallback: Update time on timeupdate event (for when RAF isn't running)
|
|
function handleTimeUpdate() {
|
|
if (videoElement && !isSeeking && !isDraggingSeekBar && !isPlaying) {
|
|
const newCurrentTime = seekOffset + videoElement.currentTime;
|
|
if (videoElement.readyState >= 2) {
|
|
currentTime = newCurrentTime;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleLoadedMetadata() {
|
|
console.log("[VideoPlayer] loadedmetadata event");
|
|
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
|
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
|
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
|
|
|
// For direct streams without runTimeTicks, use video element's duration
|
|
if (videoElement && videoElement.duration && !isNaN(videoElement.duration) && videoElement.duration !== Infinity) {
|
|
const newDuration = videoElement.duration;
|
|
console.log("[VideoPlayer] Setting videoDuration to:", newDuration);
|
|
videoDuration = newDuration;
|
|
console.log("[VideoPlayer] videoDuration state is now:", videoDuration);
|
|
}
|
|
|
|
// Tell the Rust controller the media is loaded and its duration (mirrors the
|
|
// native MediaLoaded event so the backend has a duration for HTML5 video).
|
|
html5Adapter.reportMediaLoaded(duration);
|
|
|
|
// Use setTimeout to log the derived value after reactive updates
|
|
setTimeout(() => {
|
|
console.log("[VideoPlayer] Derived duration value:", duration);
|
|
console.log("[VideoPlayer] Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
|
|
}, 0);
|
|
}
|
|
|
|
// Flip out of the Loading state and reveal the <video> element (which is
|
|
// `invisible` and covered by the black poster card until then). Multiple
|
|
// signals can legitimately mean "ready": the native `canplay` event, hls.js
|
|
// buffering its first fragment, or the element actually reaching `playing`.
|
|
// On the Android system WebView the HLS path feeds the element through MSE
|
|
// with `src=""`, so `loadstart`/`canplay` don't fire reliably and the
|
|
// canplay-fallback timeout was never armed — audio played while the video
|
|
// stayed invisible. Any of these callers now reveals it.
|
|
// Apply the pending background-audio foreground seek, if any. This MUST run
|
|
// no matter which readiness signal fired — on the Android WebView HLS/MSE path
|
|
// `canplay` is unreliable and the video is revealed via markMediaReady()
|
|
// instead, so gating this on handleCanPlay alone meant the seek was silently
|
|
// dropped and the reloaded stream played from its start (resume "started from
|
|
// the beginning"). Returns true if a pending seek was consumed.
|
|
async function applyPendingForegroundSeek(): Promise<boolean> {
|
|
if (pendingForegroundSeek === null || !videoElement) return false;
|
|
const seekTo = pendingForegroundSeek;
|
|
const shouldPlay = pendingForegroundPlay;
|
|
pendingForegroundSeek = null;
|
|
pendingForegroundPlay = false;
|
|
hasPerformedInitialSeek = true;
|
|
|
|
const el = videoElement;
|
|
// currentTime is only honored once the element has metadata (duration/seekable).
|
|
// If it isn't there yet, defer to loadedmetadata rather than seeking into a
|
|
// still-empty timeline (which the element clamps back to 0).
|
|
const doSeek = async () => {
|
|
try {
|
|
el.currentTime = seekTo;
|
|
// Displayed position is absolute: element time + transcode seekOffset.
|
|
// (Direct stream: seekOffset=0, seekTo=pos. Transcoded: seekOffset=pos,
|
|
// seekTo=0.) Both yield the correct absolute position.
|
|
currentTime = seekOffset + seekTo;
|
|
el.muted = false;
|
|
el.volume = 1.0;
|
|
if (shouldPlay) await el.play();
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Failed to resume after background audio:", err);
|
|
}
|
|
};
|
|
|
|
if (el.readyState >= 1 /* HAVE_METADATA */) {
|
|
console.log("[VideoPlayer] Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
|
|
await doSeek();
|
|
} else {
|
|
console.log("[VideoPlayer] Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
|
|
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function markMediaReady() {
|
|
if (isMediaReady) return;
|
|
console.log("[VideoPlayer] Marking media ready");
|
|
isMediaReady = true;
|
|
// A handoff return can be revealed here (not via canplay) — apply its seek.
|
|
void applyPendingForegroundSeek();
|
|
}
|
|
|
|
async function handleCanPlay() {
|
|
// Media is ready to play - transition from Loading to Playing state (DR-001)
|
|
console.log("[VideoPlayer] canplay event fired - media is ready");
|
|
isMediaReady = true;
|
|
|
|
// Ensure video is unmuted and at max volume (critical for Android)
|
|
if (videoElement) {
|
|
videoElement.muted = false;
|
|
videoElement.volume = 1.0;
|
|
console.log("[VideoPlayer] Video unmuted on canplay, volume: 1.0");
|
|
}
|
|
|
|
// Returning from background audio: resume the <video> at the position native
|
|
// audio reached, restoring the prior play/pause state. Takes precedence over
|
|
// the resume-point seek below (which is for a fresh load, not a handoff).
|
|
if (await applyPendingForegroundSeek()) {
|
|
return;
|
|
}
|
|
|
|
// Seek to initial position if resuming playback
|
|
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
|
|
console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
|
|
hasPerformedInitialSeek = true;
|
|
lastAppliedInitialPosition = initialPosition; // mark this value as applied so the change-effect ignores it
|
|
|
|
// Pause video to prevent autoplay from starting at position 0
|
|
const wasPlaying = !videoElement.paused;
|
|
videoElement.pause();
|
|
|
|
try {
|
|
videoElement.currentTime = initialPosition;
|
|
currentTime = initialPosition;
|
|
|
|
// Wait for the seek to complete before resuming playback
|
|
await new Promise<void>((resolve) => {
|
|
const onSeeked = () => {
|
|
videoElement?.removeEventListener("seeked", onSeeked);
|
|
resolve();
|
|
};
|
|
videoElement!.addEventListener("seeked", onSeeked);
|
|
// Fallback timeout in case seeked event doesn't fire
|
|
setTimeout(() => {
|
|
videoElement?.removeEventListener("seeked", onSeeked);
|
|
resolve();
|
|
}, 2000);
|
|
});
|
|
|
|
// Resume playback after seek completes
|
|
if (wasPlaying || videoElement.autoplay) {
|
|
await videoElement.play();
|
|
}
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Failed to seek to initial position:", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleError(e: Event) {
|
|
const video = e.target as HTMLVideoElement;
|
|
const error = video.error;
|
|
|
|
// Log comprehensive error details
|
|
console.error("[VideoPlayer] Video error event:", {
|
|
code: error?.code,
|
|
message: error?.message,
|
|
networkState: video.networkState,
|
|
readyState: video.readyState,
|
|
currentSrc: video.currentSrc,
|
|
src: video.src,
|
|
});
|
|
|
|
// MediaError codes: 1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED
|
|
const errorMessages: Record<number, string> = {
|
|
1: "Playback aborted",
|
|
2: "Network error while loading video - check server connectivity and CORS headers",
|
|
3: "Video decoding failed - codec may not be supported by browser",
|
|
4: "Video format not supported - may need transcoding",
|
|
};
|
|
|
|
const errorCode = error?.code || 0;
|
|
const msg = errorMessages[errorCode] || `Unknown error (code ${errorCode})`;
|
|
console.error("[VideoPlayer] Error interpretation:", msg);
|
|
|
|
// Log additional debugging info
|
|
console.error("[VideoPlayer] Stream URL:", currentStreamUrl);
|
|
console.error("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
|
|
|
// Network state meanings: 0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE
|
|
const networkStates = ["NETWORK_EMPTY", "NETWORK_IDLE", "NETWORK_LOADING", "NETWORK_NO_SOURCE"];
|
|
console.error("[VideoPlayer] Network state:", networkStates[video.networkState] || video.networkState);
|
|
|
|
// Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
|
|
const readyStates = ["HAVE_NOTHING", "HAVE_METADATA", "HAVE_CURRENT_DATA", "HAVE_FUTURE_DATA", "HAVE_ENOUGH_DATA"];
|
|
console.error("[VideoPlayer] Ready state:", readyStates[video.readyState] || video.readyState);
|
|
}
|
|
|
|
function handleWaiting() {
|
|
console.log("[VideoPlayer] waiting event - buffering");
|
|
isBuffering = true;
|
|
}
|
|
|
|
function handlePlaying() {
|
|
console.log("[VideoPlayer] playing event - playback resumed");
|
|
isBuffering = false;
|
|
// Safety net: if we reached `playing` we are definitely renderable, even if
|
|
// `canplay`/hls FRAG_BUFFERED were missed on this WebView.
|
|
markMediaReady();
|
|
}
|
|
|
|
function handleLoadStart() {
|
|
console.log("[VideoPlayer] loadstart event - starting to load:", currentStreamUrl);
|
|
console.log("[VideoPlayer] Video element readyState:", videoElement?.readyState);
|
|
console.log("[VideoPlayer] Video element networkState:", videoElement?.networkState);
|
|
|
|
// Clear any existing fallback timeout
|
|
if (canplayFallbackTimeout) {
|
|
clearTimeout(canplayFallbackTimeout);
|
|
}
|
|
|
|
// Set up a fallback timeout in case canplay event never fires
|
|
canplayFallbackTimeout = setTimeout(() => {
|
|
if (!isMediaReady && videoElement) {
|
|
console.warn("[VideoPlayer] canplay event did not fire within 5 seconds");
|
|
console.log("[VideoPlayer] Fallback check - readyState:", videoElement.readyState, "networkState:", videoElement.networkState);
|
|
|
|
// Check if video is actually ready despite event not firing
|
|
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
|
|
console.log("[VideoPlayer] Video appears ready (readyState >= 3), forcing media ready state");
|
|
markMediaReady();
|
|
}
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
// ===== JRay "who's on screen" pause overlay (optional plugin) =====
|
|
// On pause, ask the JRay Jellyfin plugin which actors are on screen at the
|
|
// current timestamp and show them as a tappable overlay. If the plugin isn't
|
|
// installed (or has no data for this item), the call resolves to [] and the
|
|
// overlay simply doesn't render. Tapping an actor with a resolved Jellyfin
|
|
// Person id navigates to that person's library page.
|
|
let jrayActors = $state<JRayActor[]>([]);
|
|
// Monotonic token so a slow in-flight request can't overwrite a newer pause
|
|
// (or a resume that cleared the list).
|
|
let jrayRequestId = 0;
|
|
|
|
async function fetchJrayActors() {
|
|
const itemId = media?.id;
|
|
if (!itemId) return;
|
|
const token = ++jrayRequestId;
|
|
const t = currentTime;
|
|
try {
|
|
const actors = await auth.getRepository().jrayActorsAt(itemId, t);
|
|
// Discard if a newer pause/resume happened while we were waiting.
|
|
if (token === jrayRequestId) {
|
|
jrayActors = actors;
|
|
}
|
|
} catch (err) {
|
|
console.warn("[VideoPlayer] JRay lookup failed:", err);
|
|
if (token === jrayRequestId) jrayActors = [];
|
|
}
|
|
}
|
|
|
|
function clearJrayActors() {
|
|
jrayRequestId++; // invalidate any in-flight request
|
|
jrayActors = [];
|
|
}
|
|
|
|
function openJrayActor(actor: JRayActor) {
|
|
if (actor.jellyfin_id) {
|
|
goto(`/library/${actor.jellyfin_id}`);
|
|
}
|
|
}
|
|
|
|
// Drive the JRay overlay off the single `isPlaying` flag so it works for both
|
|
// the HTML5 <video> (desktop) and the native ExoPlayer path (Android, which
|
|
// updates isPlaying via the player://state-changed event). Fetch when we go
|
|
// paused, clear when we resume. untrack() keeps this from re-running on every
|
|
// currentTime tick — only isPlaying transitions matter.
|
|
$effect(() => {
|
|
if (isPlaying) {
|
|
untrack(clearJrayActors);
|
|
} else if (isMediaReady) {
|
|
untrack(fetchJrayActors);
|
|
}
|
|
});
|
|
|
|
function handlePlay() {
|
|
isPlaying = true;
|
|
startTimeUpdates(); // Start RAF loop for smooth time updates
|
|
// Mirror the DOM state into the Rust PlayerController so it is the single
|
|
// source of truth for HTML5 video (the <video> lives in the webview, which
|
|
// Rust cannot observe directly). See html5Adapter.ts.
|
|
html5Adapter.reportState("playing", reportMediaId ?? null);
|
|
// Report playback start on first play (skip for live - no resume tracking)
|
|
if (!isLive && !hasReportedStart && onReportStart) {
|
|
onReportStart(currentTime, reportMediaId);
|
|
hasReportedStart = true;
|
|
}
|
|
}
|
|
|
|
function handlePause() {
|
|
isPlaying = false;
|
|
stopTimeUpdates(); // Stop RAF loop when paused
|
|
html5Adapter.reportState("paused", reportMediaId ?? null);
|
|
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
|
// Report progress when paused
|
|
if (onReportProgress) {
|
|
onReportProgress(currentTime, true, reportMediaId);
|
|
}
|
|
}
|
|
|
|
function handleEnded() {
|
|
isPlaying = false;
|
|
stopTimeUpdates(); // Stop RAF loop when ended
|
|
// NOTE: do NOT report a "stopped" player state here. Natural end-of-video is
|
|
// an autoplay handoff, not a stop: the backend's on_video_playback_ended
|
|
// decides whether to advance to the next episode (incl. sleep-timer episode
|
|
// counting). Emitting StateChanged{stopped} would flip the player/mode to
|
|
// idle mid-handoff and suppress the next-episode auto-advance (pauses at the
|
|
// end of an episode instead of continuing). onReportStop below still reports
|
|
// progress to Jellyfin; notifyEnded() drives the autoplay decision.
|
|
if (!isLive && onReportStop) {
|
|
onReportStop(currentTime, reportMediaId);
|
|
}
|
|
// Notify parent that video has ended (for next episode popup)
|
|
notifyEnded();
|
|
}
|
|
|
|
async function togglePlayPause() {
|
|
// Route through the facade → active adapter so the toggle goes through the
|
|
// one control boundary (and the adapter reports the resulting element state
|
|
// back into Rust). The element's own play/pause handlers update isPlaying.
|
|
try {
|
|
await playerController.toggle();
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Failed to toggle playback:", err);
|
|
}
|
|
}
|
|
|
|
function handleSeekBarInput(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const targetTime = parseFloat(input.value);
|
|
// Update the displayed time immediately for smooth visual feedback
|
|
currentTime = targetTime;
|
|
}
|
|
|
|
async function handleSeekBarChange(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const targetTime = parseFloat(input.value);
|
|
|
|
// Set isSeeking immediately to prevent timeupdate from interfering
|
|
isSeeking = true;
|
|
isDraggingSeekBar = false;
|
|
|
|
try {
|
|
console.log("[VideoPlayer] Seeking to:", targetTime.toFixed(2));
|
|
|
|
// Optimistic display; the primitive updates currentTime/seekOffset as it
|
|
// completes (reloadSource drives the stream URL via the adapter bridge).
|
|
currentTime = targetTime;
|
|
stopTimeUpdates(); // pause RAF while the seek settles
|
|
|
|
// The BACKEND decides the strategy (in-place vs transcode reload); the
|
|
// facade dispatches the matching adapter PRIMITIVE. This is the shared
|
|
// decision-in-Rust design — no strategy branch lives here anymore.
|
|
lastNativeSeekAt = Date.now();
|
|
await playerController.seekVideo(
|
|
targetTime,
|
|
mediaSourceId ?? null,
|
|
selectedAudioTrackIndex ?? null
|
|
);
|
|
|
|
// Resume smooth updates if still playing after the seek settled.
|
|
if (videoElement && !videoElement.paused) {
|
|
startTimeUpdates();
|
|
}
|
|
|
|
console.log("[VideoPlayer] Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Seek failed:", err);
|
|
} finally {
|
|
isSeeking = false;
|
|
isDraggingSeekBar = false;
|
|
}
|
|
}
|
|
|
|
// Resolved once at component setup: the PiP bridge is installed by
|
|
// MainActivity before the page loads and never changes for the session.
|
|
// Synchronous by design - no await in onMount (see VideoPlayer native-mode
|
|
// pitfalls: awaiting there flips the component into HTML5 mode).
|
|
const pipSupported = isPipSupported();
|
|
|
|
function handlePictureInPicture() {
|
|
enterPip();
|
|
}
|
|
|
|
// ===== Background audio (UR-040, Android) =====
|
|
// Keep the video's audio playing when the app is backgrounded/locked by handing
|
|
// playback off to the native ExoPlayer audio service; the WebView <video> is
|
|
// torn down so no video is decoded. Mutually exclusive with auto-PiP.
|
|
//
|
|
// Gate on the platform, NOT on the AndroidBackgroundAudio JS-bridge probe.
|
|
// The native isSupported() is unconditionally true on Android, but the bridge
|
|
// is injected into the WebView asynchronously and races component mount — a
|
|
// one-shot bridge probe here comes out false on some loads and, being a const,
|
|
// never recovers, so the button vanished on "some videos". platform() is
|
|
// available synchronously and is stable. toggleBackgroundAudio() no-ops safely
|
|
// if the bridge is momentarily absent.
|
|
const backgroundAudioSupported = platform() === "android";
|
|
let backgroundAudioOn = $state(false); // v1: default OFF each session
|
|
let handoffState: BackgroundAudioState = { ...initialHandoffState };
|
|
|
|
function toggleBackgroundAudio() {
|
|
backgroundAudioOn = !backgroundAudioOn;
|
|
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
|
|
// so exactly one background behavior is active.
|
|
setBackgroundAudioEnabled(backgroundAudioOn);
|
|
setAutoEnterEnabled(!backgroundAudioOn);
|
|
}
|
|
|
|
// App went to background/locked while background-audio is armed: hand off to
|
|
// native audio and stop the WebView video decode.
|
|
async function enterBackgroundAudioHandoff() {
|
|
if (!shouldEnterBackgroundAudio(backgroundAudioOn, handoffState)) return;
|
|
// `currentTime` is the component's authoritative ABSOLUTE position (the RAF
|
|
// loop keeps it at seekOffset + element.currentTime, and it survives HLS
|
|
// transcode segment resets). Reading videoElement.currentTime directly is
|
|
// wrong for transcoded streams (it's the in-segment offset) and can read 0
|
|
// if the element is mid-teardown — which shipped audio starting from 0:00.
|
|
const pos = computeHandoffPosition(currentTime, 0);
|
|
const wasPlaying = isPlaying;
|
|
console.log("[VideoPlayer] Background-audio handoff at position:", pos.toFixed(1));
|
|
handoffState = { active: true, wasPlaying };
|
|
try {
|
|
if (!media) return;
|
|
// Ask the server for an audio-only stream of this video item (no video
|
|
// decode), carrying the selected audio track and resume position.
|
|
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo(
|
|
media.id,
|
|
mediaSourceId ?? undefined,
|
|
pos,
|
|
selectedAudioTrackIndex ?? undefined,
|
|
);
|
|
await commands.playerEnterBackgroundAudio(
|
|
{
|
|
id: media.id,
|
|
title: media.name,
|
|
streamUrl: audioUrl,
|
|
videoCodec: "aac",
|
|
needsTranscoding: false,
|
|
// Now-playing metadata so the lockscreen/miniplayer show the item.
|
|
artist: media.seriesName ?? null,
|
|
primaryImageTag: media.imageId ?? null,
|
|
serverId: media.serverId ?? null,
|
|
// Real duration so the lockscreen scrubber has a range to draw.
|
|
durationSeconds: duration > 0 ? duration : null,
|
|
},
|
|
pos,
|
|
);
|
|
// Tear down the WebView <video>/HLS decode AFTER native audio has started,
|
|
// so there is never a gap — and exactly one audio source is ever live.
|
|
tearDownHls();
|
|
if (videoElement) {
|
|
videoElement.pause();
|
|
videoElement.removeAttribute("src");
|
|
videoElement.load();
|
|
}
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Background-audio handoff failed:", err);
|
|
handoffState = { ...initialHandoffState };
|
|
}
|
|
}
|
|
|
|
// App returned to foreground: stop native audio, reload the WebView <video> at
|
|
// the position native reached, and restore play/pause.
|
|
async function exitBackgroundAudioHandoff() {
|
|
if (!shouldExitBackgroundAudio(handoffState)) return;
|
|
const wasPlaying = handoffState.wasPlaying;
|
|
handoffState = { ...initialHandoffState };
|
|
try {
|
|
// Absolute position the native audio reached (base offset applied in Rust).
|
|
const pos = await commands.playerExitBackgroundAudio();
|
|
console.log("[VideoPlayer] Returning from background audio at:", pos.toFixed(1));
|
|
|
|
isMediaReady = false;
|
|
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
|
|
// post-handoff position. Keep the initial-position change-effect quiescent:
|
|
// leaving hasPerformedInitialSeek=true and pinning lastAppliedInitialPosition
|
|
// to the current prop means the effect sees no "change" and won't fire a
|
|
// stale seek back to the original resume point (clobbering the handoff pos).
|
|
hasPerformedInitialSeek = true;
|
|
lastAppliedInitialPosition = initialPosition;
|
|
|
|
pendingForegroundPlay = wasPlaying;
|
|
|
|
// Determine the target URL + how the element/offset should be positioned.
|
|
let targetUrl: string;
|
|
if (needsTranscoding && onSeek) {
|
|
// Transcoded HLS can't seek by setting currentTime — the stream must be
|
|
// rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
|
|
// The reloaded segment's timeline starts at 0, so seekOffset carries the
|
|
// absolute base and the element seeks to 0 (handled on canplay).
|
|
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
|
|
seekOffset = pos;
|
|
currentTime = pos;
|
|
pendingForegroundSeek = 0;
|
|
} else {
|
|
// Direct stream: reload the original URL and seek the element to pos.
|
|
targetUrl = streamUrl;
|
|
seekOffset = 0;
|
|
pendingForegroundSeek = pos;
|
|
}
|
|
|
|
// Force the HLS-init $effect to re-run even if the URL string is unchanged:
|
|
// blank it first, then set it on the next microtask so Svelte sees a real
|
|
// transition. Without this, assigning the same value is a no-op and the
|
|
// player stays stuck on the loading spinner (HLS never re-initialises).
|
|
currentStreamUrl = "";
|
|
await Promise.resolve();
|
|
currentStreamUrl = targetUrl;
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Background-audio return failed:", err);
|
|
}
|
|
}
|
|
|
|
// Consumed by handleCanPlay after the <video> reloads on foreground.
|
|
let pendingForegroundSeek: number | null = null;
|
|
let pendingForegroundPlay = false;
|
|
|
|
function toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen();
|
|
isFullscreen = true;
|
|
} else {
|
|
document.exitFullscreen();
|
|
isFullscreen = false;
|
|
}
|
|
}
|
|
|
|
function formatTime(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function handleMouseMove() {
|
|
showControls = true;
|
|
if (controlsTimeout) {
|
|
clearTimeout(controlsTimeout);
|
|
}
|
|
controlsTimeout = setTimeout(() => {
|
|
if (isPlaying) {
|
|
showControls = false;
|
|
}
|
|
}, 3000);
|
|
}
|
|
|
|
async function seekRelative(seconds: number) {
|
|
isSeeking = true;
|
|
|
|
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
|
|
|
console.log("[VideoPlayer] Relative seek:", {
|
|
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
|
|
from: currentTime.toFixed(2),
|
|
to: newTime.toFixed(2),
|
|
});
|
|
|
|
// Call the unified handleSeekBarChange logic with the new time
|
|
// Create a synthetic event to reuse the existing logic
|
|
const syntheticEvent = {
|
|
target: {
|
|
value: newTime.toString()
|
|
}
|
|
} as unknown as Event;
|
|
|
|
await handleSeekBarChange(syntheticEvent);
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === " " || e.key === "k") {
|
|
e.preventDefault();
|
|
togglePlayPause();
|
|
} else if (e.key === "f") {
|
|
toggleFullscreen();
|
|
} else if (e.key === "Escape") {
|
|
if (isFullscreen) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
onClose();
|
|
}
|
|
} else if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
seekRelative(-10);
|
|
} else if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
seekRelative(10);
|
|
}
|
|
}
|
|
|
|
// Touch gesture handlers
|
|
function handleTouchStart(e: TouchEvent) {
|
|
const touch = e.touches[0];
|
|
touchStartX = touch.clientX;
|
|
touchStartY = touch.clientY;
|
|
touchStartTime = Date.now();
|
|
|
|
const now = Date.now();
|
|
const timeSinceLastTap = now - lastTapTime;
|
|
|
|
// Double tap detection (within 300ms)
|
|
if (timeSinceLastTap < 300 && timeSinceLastTap > 0) {
|
|
e.preventDefault();
|
|
handleDoubleTap(touch.clientX);
|
|
lastTapTime = 0; // Reset to prevent triple-tap
|
|
if (tapTimeout) {
|
|
clearTimeout(tapTimeout);
|
|
tapTimeout = null;
|
|
}
|
|
} else {
|
|
lastTapTime = now;
|
|
// Set timeout to clear if no second tap
|
|
tapTimeout = setTimeout(() => {
|
|
lastTapTime = 0;
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
function handleTouchMove(e: TouchEvent) {
|
|
if (!e.touches[0]) return;
|
|
|
|
const touch = e.touches[0];
|
|
const deltaX = touch.clientX - touchStartX;
|
|
const deltaY = touch.clientY - touchStartY;
|
|
const timeDelta = Date.now() - touchStartTime;
|
|
|
|
// Minimum movement to register as swipe (50px)
|
|
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
|
swipeGestureActive = true;
|
|
|
|
// Brightness control on vertical swipe
|
|
swipeType = "brightness";
|
|
// Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
|
|
const brightnessChange = -deltaY / 300; // Swipe up = brighter
|
|
brightness = Math.max(0.3, Math.min(1.7, 1 + brightnessChange));
|
|
|
|
// Reset touch start for continuous adjustment
|
|
touchStartY = touch.clientY;
|
|
}
|
|
}
|
|
|
|
function handleTouchEnd(e: TouchEvent) {
|
|
swipeGestureActive = false;
|
|
swipeType = null;
|
|
}
|
|
|
|
function handleDoubleTap(x: number) {
|
|
const screenWidth = window.innerWidth;
|
|
const isLeftSide = x < screenWidth / 2;
|
|
|
|
if (isLeftSide) {
|
|
// Double tap left: rewind 10 seconds
|
|
seekRelative(-10);
|
|
showDoubleTapFeedback = "left";
|
|
} else {
|
|
// Double tap right: forward 10 seconds
|
|
seekRelative(10);
|
|
showDoubleTapFeedback = "right";
|
|
}
|
|
|
|
// Hide feedback after animation
|
|
if (doubleTapFeedbackTimeout) {
|
|
clearTimeout(doubleTapFeedbackTimeout);
|
|
}
|
|
doubleTapFeedbackTimeout = setTimeout(() => {
|
|
showDoubleTapFeedback = null;
|
|
}, 800);
|
|
}
|
|
|
|
function toggleAudioTrackMenu() {
|
|
showAudioTrackMenu = !showAudioTrackMenu;
|
|
}
|
|
|
|
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
|
|
console.log("[VideoPlayer] Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
|
const previousTrackIndex = selectedAudioTrackIndex;
|
|
selectedAudioTrackIndex = streamIndex;
|
|
showAudioTrackMenu = false;
|
|
|
|
try {
|
|
// The BACKEND decides whether the audio-track switch needs a transcode
|
|
// reload; the facade dispatches the resulting adapter PRIMITIVE
|
|
// (reloadSource) which runs the invariant dual-audio teardown sequence.
|
|
// No strategy branch lives here anymore.
|
|
stopTimeUpdates();
|
|
await playerController.switchAudioTrack(
|
|
streamIndex,
|
|
arrayIndex,
|
|
videoElement ? videoElement.currentTime + seekOffset : null,
|
|
mediaSourceId ?? null
|
|
);
|
|
if (videoElement && !videoElement.paused) {
|
|
startTimeUpdates();
|
|
}
|
|
|
|
console.log("[VideoPlayer] Successfully changed audio track");
|
|
|
|
// Save series audio preference for future episodes
|
|
if (media && media.seriesId) {
|
|
try {
|
|
const userId = auth.getUserId();
|
|
if (!userId) return;
|
|
|
|
// Find the selected track info
|
|
const selectedTrack = audioTracks().find(t => t.index === streamIndex);
|
|
if (selectedTrack) {
|
|
await commands.storageSaveSeriesAudioPreference(
|
|
userId,
|
|
media.seriesId,
|
|
media.serverId ?? "",
|
|
selectedTrack.displayTitle || null,
|
|
selectedTrack.language || null,
|
|
streamIndex
|
|
);
|
|
console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
|
|
}
|
|
} catch (err) {
|
|
console.warn("[VideoPlayer] Failed to save series audio preference:", err);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("[VideoPlayer] Failed to change audio track:", err);
|
|
// Revert to previous track on error
|
|
selectedAudioTrackIndex = previousTrackIndex;
|
|
}
|
|
}
|
|
|
|
function toggleSubtitleMenu() {
|
|
showSubtitleMenu = !showSubtitleMenu;
|
|
}
|
|
|
|
async function selectSubtitle(streamIndex: number | null, arrayIndex?: number) {
|
|
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
|
selectedSubtitleIndex = streamIndex;
|
|
showSubtitleMenu = false;
|
|
|
|
// For HTML5 video element, update the text tracks
|
|
if (useHtml5Element && videoElement && videoElement.textTracks) {
|
|
// Disable all text tracks first
|
|
for (let i = 0; i < videoElement.textTracks.length; i++) {
|
|
videoElement.textTracks[i].mode = "disabled";
|
|
}
|
|
|
|
// Enable the selected track if not null
|
|
if (streamIndex !== null) {
|
|
// Find the corresponding track element by stream index
|
|
const tracks = videoElement.querySelectorAll("track");
|
|
tracks.forEach((track) => {
|
|
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
|
if (trackStreamIndex === streamIndex) {
|
|
const textTrack = track.track;
|
|
if (textTrack) {
|
|
textTrack.mode = "showing";
|
|
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} else if (!useHtml5Element) {
|
|
// For native backend (Android), send command to change subtitle track
|
|
try {
|
|
// Use array index for ExoPlayer (0-based position in subtitle tracks array)
|
|
// If streamIndex is null (disable), pass null; otherwise use arrayIndex
|
|
const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex);
|
|
await commands.playerSetSubtitleTrack(indexToUse);
|
|
console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse);
|
|
} catch (error) {
|
|
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get subtitle URL for a given stream index
|
|
async function getSubtitleUrl(streamIndex: number): Promise<string> {
|
|
if (!media || !mediaSourceId) return "";
|
|
try {
|
|
const repo = auth.getRepository();
|
|
return await repo.getSubtitleUrl(media.id, mediaSourceId ?? "", streamIndex, "vtt");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
<div
|
|
class="fixed inset-0 flex flex-col z-50"
|
|
class:bg-black={useHtml5Element}
|
|
style:background-color={!useHtml5Element ? 'transparent' : ''}
|
|
onmousemove={handleMouseMove}
|
|
ontouchstart={handleTouchStart}
|
|
ontouchmove={handleTouchMove}
|
|
ontouchend={handleTouchEnd}
|
|
role="application"
|
|
aria-label="Video player"
|
|
>
|
|
<!-- Video -->
|
|
<div class="flex-1 flex items-center justify-center relative">
|
|
{#if !!useHtml5Element}
|
|
<!-- HTML5 video for desktop/non-Android platforms -->
|
|
<video
|
|
bind:this={videoElement}
|
|
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
|
class="max-w-full max-h-full"
|
|
class:invisible={!isMediaReady}
|
|
style="filter: brightness({brightness})"
|
|
playsinline
|
|
autoplay
|
|
muted={false}
|
|
ontimeupdate={handleTimeUpdate}
|
|
onloadedmetadata={handleLoadedMetadata}
|
|
oncanplay={handleCanPlay}
|
|
onplay={handlePlay}
|
|
onpause={handlePause}
|
|
onended={handleEnded}
|
|
onerror={handleError}
|
|
onwaiting={handleWaiting}
|
|
onplaying={handlePlaying}
|
|
onloadstart={handleLoadStart}
|
|
onclick={togglePlayPause}
|
|
>
|
|
<!-- Temporarily disabled to debug playback issues
|
|
{#each subtitleTracks() as track}
|
|
<track
|
|
kind="subtitles"
|
|
src={getSubtitleUrl(track.index)}
|
|
srclang={track.language || "unknown"}
|
|
label={track.displayTitle || track.language || `Track ${track.index}`}
|
|
data-stream-index={track.index}
|
|
default={track.isDefault}
|
|
/>
|
|
{/each}
|
|
-->
|
|
</video>
|
|
{:else}
|
|
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
|
|
<!-- Leave this area transparent so video shows through -->
|
|
<div class="flex-1"></div>
|
|
{/if}
|
|
|
|
<!-- Title card with loading spinner (Loading state from DR-001) -->
|
|
{#if !isMediaReady}
|
|
<div class="absolute inset-0 flex items-center justify-center bg-black">
|
|
<!-- Poster/Title Card -->
|
|
{#if media?.imageId}
|
|
<CachedImage
|
|
itemId={media.id}
|
|
imageType="Primary"
|
|
tag={media.imageId}
|
|
maxHeight={1080}
|
|
alt={media?.name || "Video"}
|
|
class="max-w-full max-h-full object-contain"
|
|
/>
|
|
{:else}
|
|
<div class="text-white text-2xl font-semibold px-8 text-center">
|
|
{media?.name || "Loading..."}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Loading spinner overlay -->
|
|
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
|
|
<div class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Double-tap feedback overlays -->
|
|
{#if showDoubleTapFeedback === "left"}
|
|
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
|
|
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
|
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
|
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">-10</text>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showDoubleTapFeedback === "right"}
|
|
<div class="absolute right-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
|
|
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
|
|
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
|
|
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+10</text>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Swipe gesture feedback -->
|
|
{#if swipeGestureActive && swipeType === "brightness"}
|
|
<div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none">
|
|
<div class="bg-black/60 rounded-lg px-4 py-3 backdrop-blur-sm flex items-center gap-3">
|
|
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" />
|
|
</svg>
|
|
<div class="flex flex-col">
|
|
<span class="text-white text-xs font-medium">Brightness</span>
|
|
<div class="w-24 h-1 bg-white/30 rounded-full mt-1">
|
|
<div class="h-full bg-white rounded-full" style="width: {((brightness - 0.3) / 1.4) * 100}%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Loading overlay for seeking -->
|
|
{#if isSeeking}
|
|
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
|
|
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
{:else if !isPlaying}
|
|
<!-- Play/Pause overlay -->
|
|
<button
|
|
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
|
onclick={togglePlayPause}
|
|
aria-label="Play"
|
|
>
|
|
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- JRay "who's on screen" overlay: shown while paused when the JRay plugin
|
|
returned actors for the current timestamp. Tapping an actor with a
|
|
resolved Jellyfin Person id opens their library page. -->
|
|
{#if !isPlaying && !isSeeking && jrayActors.length > 0}
|
|
<div class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto">
|
|
<div class="text-white/60 text-xs font-medium uppercase tracking-wide mb-2">On screen</div>
|
|
<div class="flex flex-col gap-2">
|
|
{#each jrayActors as actor (actor.name + actor.jellyfin_id)}
|
|
{#if actor.jellyfin_id}
|
|
<button
|
|
class="flex items-center gap-2 text-left text-white text-sm hover:text-blue-300 transition-colors group"
|
|
onclick={() => openJrayActor(actor)}
|
|
>
|
|
<!-- Headshot from the actor's Jellyfin Person item. Falls back to
|
|
a placeholder inside CachedImage when no image exists. -->
|
|
<CachedImage
|
|
itemId={actor.jellyfin_id}
|
|
imageType="Primary"
|
|
maxWidth={80}
|
|
alt={actor.name}
|
|
class="w-8 h-8 rounded-full object-cover flex-shrink-0 ring-1 ring-white/20 group-hover:ring-blue-300/60"
|
|
/>
|
|
<span>{actor.name}</span>
|
|
</button>
|
|
{:else}
|
|
<span class="flex items-center gap-2 text-white/80 text-sm">
|
|
<!-- No resolved Jellyfin id → no headshot available. -->
|
|
<span class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400">
|
|
{actor.name.slice(0, 1)}
|
|
</span>
|
|
<span>{actor.name}</span>
|
|
</span>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
<div
|
|
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
|
|
class:opacity-0={!showControls}
|
|
class:pointer-events-none={!showControls}
|
|
>
|
|
<!-- Title -->
|
|
<div class="mb-2">
|
|
<h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
|
|
</div>
|
|
|
|
<!-- Progress bar (hidden for live streams - no fixed timeline) -->
|
|
{#if isLive}
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="flex items-center gap-1.5 text-white text-sm font-semibold">
|
|
<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
|
|
LIVE
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max={duration || 100}
|
|
value={currentTime}
|
|
oninput={handleSeekBarInput}
|
|
onchange={handleSeekBarChange}
|
|
onmousedown={() => isDraggingSeekBar = true}
|
|
onmouseup={() => isDraggingSeekBar = false}
|
|
ontouchstart={() => isDraggingSeekBar = true}
|
|
ontouchend={() => isDraggingSeekBar = false}
|
|
class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
|
|
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
|
[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
|
/>
|
|
<span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Control buttons -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<!-- Play/Pause -->
|
|
<button onclick={togglePlayPause} class="text-white hover:text-gray-300" aria-label={isPlaying ? "Pause" : "Play"}>
|
|
{#if isPlaying}
|
|
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
</svg>
|
|
{:else}
|
|
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Next Episode -->
|
|
{#if hasNext}
|
|
<button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
|
|
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<!-- Audio Track Selection -->
|
|
{#if audioTracks().length > 1}
|
|
<div class="relative">
|
|
<button
|
|
onclick={toggleAudioTrackMenu}
|
|
class="text-white hover:text-gray-300"
|
|
aria-label="Select audio track"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Audio Track Menu -->
|
|
{#if showAudioTrackMenu}
|
|
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
|
|
<div class="p-2">
|
|
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
|
Audio Track
|
|
</div>
|
|
{#each audioTracks() as track, i}
|
|
<button
|
|
onclick={() => selectAudioTrack(track.index, i)}
|
|
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex === track.index ? 'bg-white/20' : ''}"
|
|
>
|
|
<span class="text-sm">
|
|
{track.displayTitle || track.language || `Track ${i + 1}`}
|
|
{#if track.isDefault}
|
|
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
|
{/if}
|
|
</span>
|
|
{#if selectedAudioTrackIndex === track.index}
|
|
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Subtitle Selection -->
|
|
{#if subtitleTracks().length > 0}
|
|
<div class="relative">
|
|
<button
|
|
onclick={toggleSubtitleMenu}
|
|
class="text-white hover:text-gray-300"
|
|
aria-label="Select subtitles"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Subtitle Menu -->
|
|
{#if showSubtitleMenu}
|
|
<div class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto">
|
|
<div class="p-2">
|
|
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
|
Subtitles
|
|
</div>
|
|
<!-- Off option -->
|
|
<button
|
|
onclick={() => selectSubtitle(null)}
|
|
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === null ? 'bg-white/20' : ''}"
|
|
>
|
|
<span class="text-sm">Off</span>
|
|
{#if selectedSubtitleIndex === null}
|
|
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
<!-- Subtitle tracks -->
|
|
{#each subtitleTracks() as track, i}
|
|
<button
|
|
onclick={() => selectSubtitle(track.index, i)}
|
|
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
|
|
>
|
|
<div class="flex flex-col">
|
|
<span class="text-sm">
|
|
{track.displayTitle || track.language || `Track ${track.index}`}
|
|
{#if track.isDefault}
|
|
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
|
{/if}
|
|
{#if track.isForced}
|
|
<span class="text-xs text-gray-400 ml-1">(Forced)</span>
|
|
{/if}
|
|
</span>
|
|
{#if track.codec}
|
|
<span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
|
|
{/if}
|
|
</div>
|
|
{#if selectedSubtitleIndex === track.index}
|
|
<svg class="w-4 h-4 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Sleep Timer -->
|
|
{#if $sleepTimerActive}
|
|
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} />
|
|
{:else}
|
|
<button
|
|
onclick={() => { showSleepTimerModal = true; }}
|
|
class="text-white hover:text-gray-300"
|
|
aria-label="Sleep timer"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Volume Control -->
|
|
<VolumeControl size="md" />
|
|
|
|
<!-- Picture-in-picture (Android only) -->
|
|
{#if pipSupported}
|
|
<button
|
|
onclick={handlePictureInPicture}
|
|
class="text-white hover:text-gray-300"
|
|
aria-label="Picture in picture"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Background audio (Android only) — keep audio playing when the app is
|
|
backgrounded/locked; video decode stops. Suppresses auto-PiP while on. -->
|
|
{#if backgroundAudioSupported}
|
|
<button
|
|
onclick={toggleBackgroundAudio}
|
|
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
|
|
aria-label="Background audio"
|
|
aria-pressed={backgroundAudioOn}
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Fullscreen -->
|
|
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
{#if isFullscreen}
|
|
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
|
|
{:else}
|
|
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
|
{/if}
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Close -->
|
|
<button onclick={onClose} class="text-white hover:text-gray-300" aria-label="Close">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} />
|
|
|
|
<style>
|
|
@keyframes fade-out {
|
|
0% {
|
|
opacity: 1;
|
|
transform: translateY(-50%) scale(1);
|
|
}
|
|
50% {
|
|
opacity: 1;
|
|
transform: translateY(-50%) scale(1.1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translateY(-50%) scale(0.9);
|
|
}
|
|
}
|
|
|
|
.animate-fade-out {
|
|
animation: fade-out 0.8s ease-out forwards;
|
|
}
|
|
</style>
|