Implement Safari video startup fixes and range streaming support
This commit is contained in:
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user