Implement Safari video startup fixes and range streaming support

This commit is contained in:
2026-05-21 04:06:44 +00:00
parent 2e9928a411
commit eeed9f3ef7
20 changed files with 1015 additions and 334 deletions
+8 -1
View File
@@ -517,6 +517,7 @@ function movieToMediaItem(movie: Movie): MediaItem {
year: movie.year,
cover_path: movie.cover_path,
showreel_images: movie.showreel_images,
showreel_source_sets: movie.showreel_source_sets,
type: 'movies',
resolution: resolution,
data: movie,
@@ -526,10 +527,15 @@ function movieToMediaItem(movie: Movie): MediaItem {
function seriesToMediaItem(series: Series): MediaItem {
// For series, collect reel images from all episodes
const reelImages: string[] = [];
const reelSourceSets: string[][] = [];
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_image) {
if (episode.reel_sources && episode.reel_sources.length > 0) {
reelImages.push(episode.reel_sources[0]);
reelSourceSets.push(episode.reel_sources);
} else if (episode.reel_image) {
reelImages.push(episode.reel_image);
reelSourceSets.push([episode.reel_image]);
}
}
}
@@ -540,6 +546,7 @@ function seriesToMediaItem(series: Series): MediaItem {
year: null,
cover_path: series.cover_path,
showreel_images: reelImages.length > 0 ? reelImages : null,
showreel_source_sets: reelSourceSets.length > 0 ? reelSourceSets : null,
type: 'series',
data: series,
};
+75 -1
View File
@@ -1,5 +1,9 @@
import type { MediaIndex } from './types';
export interface PlayerStatus {
remote: boolean;
}
export function normalizeMediaPath(input: string): string {
return input
.replace(/\\/g, '/')
@@ -7,6 +11,65 @@ export function normalizeMediaPath(input: string): string {
.replace(/^\/+/, '');
}
export function isVideoPath(path: string | null | undefined): boolean {
return Boolean(path && /\.(webm|mp4|mkv|avi|mov)$/i.test(path));
}
export interface VideoSourceAttributes {
type: string;
codecs: string;
}
export function isSafariBrowser(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
const ua = navigator.userAgent;
return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|FxiOS/i.test(ua);
}
export function getVideoPreviewUrl(url: string): string {
if (!url || !isSafariBrowser()) {
return url;
}
if (url.includes('#')) {
return url;
}
// Safari often needs a tiny time offset to paint the first frame before playback.
return `${url}#t=0.001`;
}
export function getVideoSourceAttributes(path: string | null | undefined): VideoSourceAttributes {
if (!path) {
return { type: 'video/mp4; codecs="hvc1"', codecs: 'hvc1' };
}
if (/\.webm$/i.test(path)) {
return { type: 'video/webm; codecs="av01"', codecs: 'av01' };
}
if (/\.mp4$/i.test(path) || /\.m4v$/i.test(path)) {
return { type: 'video/mp4; codecs="hvc1"', codecs: 'hvc1' };
}
if (/\.mov$/i.test(path)) {
return { type: 'video/quicktime; codecs="hvc1"', codecs: 'hvc1' };
}
if (/\.avi$/i.test(path)) {
return { type: 'video/x-msvideo', codecs: '' };
}
if (/\.mkv$/i.test(path)) {
return { type: 'video/x-matroska', codecs: '' };
}
return { type: 'video/mp4; codecs="hvc1"', codecs: 'hvc1' };
}
/**
* Load the media index from the server
*/
@@ -40,7 +103,7 @@ export async function playMedia(filePath: string): Promise<void> {
}
/**
* Open a folder in Windows Explorer
* Open a folder in the system file manager
*/
export async function openFolder(folderPath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath);
@@ -60,6 +123,17 @@ export async function openFolder(folderPath: string): Promise<void> {
}
}
/**
* Return player integration capabilities for the current OS.
*/
export async function getPlayerStatus(): Promise<PlayerStatus> {
const response = await fetch('/api/player/status');
if (!response.ok) {
throw new Error(`Failed to load player status: ${response.statusText}`);
}
return response.json();
}
/**
* Return whether MPC-BE local web control is currently reachable.
*/
+34 -20
View File
@@ -32,13 +32,21 @@
class="collage-media"
/>
<video
v-if="index !== 0 && !getImageUrl(item) && getVideoUrl(item)"
:src="getVideoUrl(item)!"
v-if="index !== 0 && !getImageUrl(item) && getVideoSources(item).length > 0"
class="collage-media"
loop
muted
playsinline
/>
<div v-if="index !== 0 && !getImageUrl(item) && !getVideoUrl(item)" class="collage-placeholder">
>
<source
v-for="source in getVideoSources(item)"
:key="source.src"
:src="source.src"
:type="source.type"
:codecs="source.codecs"
>
</video>
<div v-if="index !== 0 && !getImageUrl(item) && getVideoSources(item).length === 0" class="collage-placeholder">
<span class="placeholder-title">{{ item.title }}</span>
</div>
<div class="collage-item-overlay"></div>
@@ -82,7 +90,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import type { MediaItem, Movie, Series } from '../types';
import { getCoverUrl } from '../api';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isVideoPath } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation';
const focusedIndex = ref<number | null>(null);
@@ -467,20 +475,26 @@ function getImageUrl(item: MediaItem): string | undefined {
return getCoverUrl(imagePath);
}
function getVideoUrl(item: MediaItem): string | undefined {
// Check showreel_images for video files
if (item.type === 'movies') {
const movie = item.data as Movie;
const showreel = movie.showreel_images;
if (showreel && showreel.length > 0) {
// Find first video file in showreel
const video = showreel.find(path => /\.(webm|mp4)$/i.test(path));
if (video) {
return getCoverUrl(video);
}
}
function getVideoSources(item: MediaItem): Array<{ src: string; type: string; codecs: string }> {
if (isVideoPath(item.cover_path)) {
return [{ src: getVideoPreviewUrl(getCoverUrl(item.cover_path)), ...getVideoSourceAttributes(item.cover_path) }];
}
return undefined;
const showreelSourceSets = item.showreel_source_sets;
if (showreelSourceSets && showreelSourceSets.length > 0) {
return showreelSourceSets[0]
.filter(path => isVideoPath(path))
.map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path)), ...getVideoSourceAttributes(path) }));
}
const showreel = item.showreel_images;
if (showreel && showreel.length > 0) {
return showreel
.filter(path => isVideoPath(path))
.map(path => ({ src: getVideoPreviewUrl(getCoverUrl(path)), ...getVideoSourceAttributes(path) }));
}
return [];
}
function getRating(item: MediaItem): number | null {
@@ -687,7 +701,7 @@ function handleItemClick(item: MediaItem, index: number) {
justify-content: center;
}
.hex-showcase img.hex-clip {
.hex-showcase .hex-clip {
height: 100%;
aspect-ratio: 1.3 / 1;
object-fit: cover;
@@ -1071,7 +1085,7 @@ function handleItemClick(item: MediaItem, index: number) {
transform: translateX(-50%);
}
.hex-showcase img.hex-clip {
.hex-showcase .hex-clip {
clip-path: none;
aspect-ratio: auto;
height: 100%;
+130 -48
View File
@@ -18,26 +18,39 @@
<div class="collage-header">
<!-- Background collage of showreel videos -->
<div class="collage-grid" v-if="showreelVideos && showreelVideos.length > 0">
<div class="collage-grid">
<div
v-for="(video, index) in collageVideos"
:key="index"
v-for="slot in collageSlots"
:key="slot.index"
class="collage-item"
@mouseenter="handleVideoHover(index, true)"
@mouseleave="handleVideoHover(index, false)"
@mouseenter="handleVideoHover(slot.index, true)"
@mouseleave="handleVideoHover(slot.index, false)"
>
<div
class="collage-fallback-tile"
:class="`collage-fallback-${slot.index + 1}`"
></div>
<video
:ref="el => setVideoRef(el as HTMLVideoElement, index)"
:src="getShowreelUrl(video)"
v-if="slot.sourcePaths.length > 0"
:ref="el => setVideoRef(el as HTMLVideoElement, slot.index)"
:class="{ 'is-ready': isVideoReady(slot.index) }"
:autoplay="safariAutoplay"
loop
muted
playsinline
></video>
@loadeddata="handleVideoLoaded(slot.index)"
@error="handleVideoError(slot.index)"
>
<source
v-for="sourcePath in slot.sourcePaths"
:key="sourcePath"
:src="getShowreelUrl(sourcePath)"
:type="getShowreelSourceAttributes(sourcePath).type"
:codecs="getShowreelSourceAttributes(sourcePath).codecs"
>
</video>
</div>
</div>
<div class="collage-grid collage-fallback" v-else>
<div class="collage-item" :style="headerStyle"></div>
</div>
<!-- Diagonal overlay -->
<div class="collage-overlay"></div>
@@ -159,9 +172,9 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { computed, ref, watch, onMounted, nextTick } from 'vue';
import type { CastMember, MediaItem, Movie, Series, Torrent } from '../types';
import { getCoverUrl } from '../api';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser, type VideoSourceAttributes } from '../api';
import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg';
import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg';
import SeriesFullView from './SeriesFullView.vue';
@@ -181,23 +194,59 @@ const emit = defineEmits<{
// Track expanded episode for showing multiple releases
const videoRefs = ref<(HTMLVideoElement | null)[]>([]);
const videoStates = ref<string[]>([]);
const COLLAGE_SLOT_COUNT = 5;
const safariAutoplay = isSafariBrowser();
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2];
function setVideoRef(el: HTMLVideoElement | null, index: number) {
videoRefs.value[index] = el;
}
function handleVideoLoaded(index: number) {
videoStates.value[index] = 'ready';
}
function handleVideoError(index: number) {
videoStates.value[index] = 'error';
}
function isVideoReady(index: number): boolean {
return videoStates.value[index] === 'ready';
}
// Start staggered video playback
function startStaggeredPlayback() {
const videos = videoRefs.value.filter(v => v !== null) as HTMLVideoElement[];
if (videos.length === 0) return;
if (safariAutoplay) {
videos.forEach((video, index) => {
const offset = COLLAGE_START_OFFSETS_SECONDS[index] ?? 0;
const startVideo = () => {
video.currentTime = offset;
video.play().catch(() => {});
};
if (video.readyState >= 1) {
startVideo();
} else {
video.addEventListener('loadedmetadata', startVideo, { once: true });
}
});
return;
}
// Start first video immediately
videos[0].play();
// Non-Safari keeps legacy behavior: start without explicit seek offset.
videos[0].play().catch(() => {});
// Set up staggered start for remaining videos
for (let i = 1; i < videos.length; i++) {
setTimeout(() => {
videos[i]?.play();
const video = videos[i];
if (!video) return;
video.play().catch(() => {});
}, i * 2000);
}
}
@@ -261,35 +310,56 @@ onMounted(() => {
}, 100);
});
// Showreel videos - for movies it's the showreel_images array (now .webm), for series it's reel_image from each episode
const showreelVideos = computed((): string[] | null => {
const showreelSourceSets = computed((): string[][] | null => {
if (props.item.type === 'movies') {
const movie = props.item.data as Movie;
return movie.showreel_images;
if (movie.showreel_source_sets && movie.showreel_source_sets.length > 0) {
return movie.showreel_source_sets;
}
return movie.showreel_images?.map((path) => [path]) ?? null;
} else {
// For series, collect reel images from all episodes in selected season
const series = props.item.data as Series;
const videos: string[] = [];
const sourceSets: string[][] = [];
for (const season of series.seasons || []) {
for (const episode of season.episodes || []) {
if (episode.reel_image) {
videos.push(episode.reel_image);
if (episode.reel_sources && episode.reel_sources.length > 0) {
sourceSets.push(episode.reel_sources);
} else if (episode.reel_image) {
sourceSets.push([episode.reel_image]);
}
}
}
return videos.length > 0 ? videos : null;
return sourceSets.length > 0 ? sourceSets : null;
}
});
// Select videos for collage display (up to 5 for visual balance)
const collageVideos = computed((): string[] => {
if (!showreelVideos.value || showreelVideos.value.length === 0) return [];
// Take up to 5 videos for the collage
return showreelVideos.value.slice(0, 5);
const collageSourceSets = computed((): string[][] => {
if (!showreelSourceSets.value || showreelSourceSets.value.length === 0) return [];
return showreelSourceSets.value.slice(0, 5);
});
const collageSlots = computed(() => {
return Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => ({
index,
sourcePaths: collageSourceSets.value[index] ?? [],
}));
});
watch(collageSlots, async (slots) => {
videoRefs.value = Array.from({ length: COLLAGE_SLOT_COUNT }, (_, index) => videoRefs.value[index] ?? null);
videoStates.value = slots.map((slot) => slot.sourcePaths.length > 0 ? 'loading' : 'missing');
await nextTick();
setTimeout(() => {
startStaggeredPlayback();
}, 100);
}, { immediate: true });
function getShowreelUrl(path: string): string {
return getCoverUrl(path);
return getVideoPreviewUrl(getCoverUrl(path));
}
function getShowreelSourceAttributes(path: string): VideoSourceAttributes {
return getVideoSourceAttributes(path);
}
// Movie versions
const movieVersions = computed((): Torrent[] => {
@@ -298,16 +368,6 @@ const movieVersions = computed((): Torrent[] => {
return Object.values(movie.torrents || {});
});
const headerStyle = computed(() => {
const movie = props.item.data as Movie;
const imagePath = movie.backdrop_path || props.item.cover_path;
const imageUrl = getCoverUrl(imagePath);
if (imageUrl) {
return { backgroundImage: `url("${imageUrl}")` };
}
return { background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)' };
});
// Page backdrop background
const backdropStyle = computed(() => {
if (props.item.type !== 'movies') return {};
@@ -1199,9 +1259,17 @@ function handleOpenFolder(folderPath: string) {
}
.collage-header .collage-item video {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.25s ease;
}
.collage-header .collage-item video.is-ready {
opacity: 1;
}
/* First item - no slant, straight left edge */
@@ -1232,19 +1300,33 @@ function handleOpenFolder(folderPath: string) {
rgba(0, 0, 0, 0.3) 100%
);
pointer-events: none;
z-index: 2;
}
.collage-header .collage-fallback {
display: flex;
width: 100%;
margin-left: 0;
.collage-fallback-tile {
position: absolute;
inset: 0;
z-index: 0;
}
.collage-header .collage-fallback .collage-item {
margin-left: 0;
clip-path: none;
background-size: cover;
background-position: center;
.collage-fallback-1 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-2 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-3 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-4 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-fallback-5 {
background: linear-gradient(135deg, #1b2738 0%, #0f1724 100%);
}
.collage-overlay {
+28 -15
View File
@@ -79,14 +79,21 @@
<!-- Episode background video -->
<div class="tile-bg">
<video
v-if="getEpisodeImage(episode)"
v-if="getEpisodeVideoSources(episode).length > 0"
:ref="el => setVideoRef(el as HTMLVideoElement, `${sIndex}-${eIndex}`)"
:src="getEpisodeImage(episode)"
:alt="`Episode ${episode.episode_number}`"
:autoplay="safariAutoplay"
loop
muted
playsinline
></video>
>
<source
v-for="source in getEpisodeVideoSources(episode)"
:key="source.src"
:src="source.src"
:type="source.type"
:codecs="source.codecs"
>
</video>
<div v-else class="tile-placeholder"></div>
</div>
@@ -158,7 +165,7 @@
<script setup lang="ts">
import { computed, ref, nextTick, watch } from 'vue';
import type { Series, Season, Episode, Torrent } from '../types';
import { getCoverUrl } from '../api';
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from '../api';
import { navAttrs } from '../composables/useKeyboardNavigation';
const props = defineProps<{
@@ -326,6 +333,7 @@ function getVersionLabel(torrent: Torrent): string {
// Video refs for hover effects
const videoRefs = ref<Map<string, HTMLVideoElement>>(new Map());
let videoIndex = 0;
const safariAutoplay = isSafariBrowser();
// Set video ref with staggered playback
function setVideoRef(el: HTMLVideoElement | null, key: string) {
@@ -334,8 +342,11 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) {
// Staggered start times with 0.2 second offset
const index = videoIndex++;
setTimeout(() => {
if (safariAutoplay && el.readyState >= 1) {
el.currentTime = 0.001 + ((index % 6) * 0.03);
}
el.play().catch(() => {}); // Ignore autoplay policy errors
}, index * 200);
}, safariAutoplay ? 0 : index * 200);
} else {
videoRefs.value.delete(key);
}
@@ -422,15 +433,17 @@ function getSeasonPoster(season: Season): string | undefined {
return undefined;
}
// Get episode image
function getEpisodeImage(episode: Episode): string | undefined {
if (episode.reel_image) {
return getCoverUrl(episode.reel_image);
}
if (episode.still_path && !episode.still_path.startsWith('/')) {
return getCoverUrl(episode.still_path);
}
return undefined;
function getEpisodeVideoSources(episode: Episode): Array<{ src: string; type: string; codecs: string }> {
const sources = episode.reel_sources && episode.reel_sources.length > 0
? episode.reel_sources
: episode.reel_image
? [episode.reel_image]
: [];
return sources.map((path) => ({
src: getVideoPreviewUrl(getCoverUrl(path)),
...getVideoSourceAttributes(path),
}));
}
// Collage slice style for season posters
+3
View File
@@ -61,6 +61,7 @@ export interface Movie {
cover_path: string | null;
backdrop_path: string | null;
showreel_images: string[] | null;
showreel_source_sets: string[][] | null;
torrents: { [key: string]: Torrent };
}
@@ -74,6 +75,7 @@ export interface Episode {
rating: number | null;
director: string | null;
reel_image: string | null;
reel_sources: string[] | null;
torrents: { [key: string]: Torrent };
}
@@ -144,6 +146,7 @@ export interface MediaItem {
year?: number | null;
cover_path: string | null;
showreel_images?: string[] | null;
showreel_source_sets?: string[][] | null;
type: MediaType;
resolution?: string | null;
data: Movie | Series | EpisodeWithSeries;