fix: various youtube trailers related issues

This commit is contained in:
Aleksi Lassila
2025-02-17 17:15:35 +02:00
parent c64d46f6a8
commit 455cc2f672
13 changed files with 406 additions and 307 deletions

View File

@@ -3,11 +3,14 @@
import classNames from 'classnames';
import { onDestroy } from 'svelte';
import { isFirefox } from '../../utils/browser-detection';
import YouTubeBackground from '../YouTubeBackground.svelte';
import YouTubeVideo from '../YoutubeVideo.svelte';
import { fade } from 'svelte/transition';
import { localSettings } from '$lib/stores/localstorage.store';
export let urls: Promise<string[]>;
export let items: Promise<{ backdropUrl: string; videoUrl?: string }[]>;
export let index: number;
export let hasFocus = true;
export let heroHasFocus = false;
export let hideInterface = false;
let visibleIndex = -2;
let visibleIndexTimeout: ReturnType<typeof setTimeout>;
@@ -37,47 +40,52 @@
</script>
<div class="fixed inset-0" style="-webkit-transform: translate3d(0,0,0);">
{#await urls then urlArray}
{#if !isFirefox()}
{#each urlArray as { trailerUrl, backdropUrl }, i}
{#if i === index}
{#if trailerUrl}
<YouTubeBackground videoId={trailerUrl} backgroundUrl={backdropUrl} />
{:else}
<div
class={classNames('absolute inset-0 bg-center bg-cover', {
'opacity-100': visibleIndex === i,
'opacity-0': visibleIndex !== i,
'scale-110': !hasFocus
})}
style={`background-image: url('${backdropUrl}'); transition: opacity 500ms, transform 500ms;`}
/>
{/if}
{/if}
{/each}
{:else}
<div class={classNames('flex overflow-hidden h-full w-full transition-transform duration-500', { 'scale-110': !hasFocus })}
style="perspective: 1px; -webkit-perspective: 1px;">
{#each urlArray as { backdropUrl }, i}
<div class="w-full h-full flex-shrink-0 relative"
style="transform-style: preserve-3d; -webkit-transform-style: preserve-3d; overflow: hidden;"
bind:this={htmlElements[i]}>
<div
{#if true}
{#await items then items}
{#each items as { videoUrl, backdropUrl }, i}
<div
class={classNames('absolute inset-0 bg-center bg-cover', {
'opacity-100': visibleIndex === i,
'opacity-0': visibleIndex !== i,
'scale-110': !hasFocus
})}
style={`background-image: url('${backdropUrl}'); ${
!PLATFORM_TV &&
'transform: translateZ(-5px) scale(6); -webkit-transform: translateZ(-5px) scale(6);'
}`}
/>
style={`background-image: url('${backdropUrl}'); transition: opacity 500ms, transform 500ms;`}
>
{#if videoUrl && i === visibleIndex && $localSettings.enableTrailers && $localSettings.autoplayTrailers}
<YouTubeVideo videoId={videoUrl} play={heroHasFocus} />
{/if}
</div>
{/each}
{/await}
{:else}
<div
class={classNames('flex overflow-hidden h-full w-full transition-transform duration-500', {
'scale-110': !hasFocus
})}
style="perspective: 1px; -webkit-perspective: 1px;"
>
{#await items then items}
{#each items as { backdropUrl, videoUrl }, i}
<div
class="w-full h-full flex-shrink-0 basis-auto relative"
style="transform-style: preserve-3d; -webkit-transform-style: preserve-3d; overflow: hidden;"
bind:this={htmlElements[i]}
>
<div
class="w-full h-full flex-shrink-0 basis-auto bg-center bg-cover absolute inset-0"
style={`background-image: url('${backdropUrl}'); ${
!PLATFORM_TV &&
'transform: translateZ(-5px) scale(6); -webkit-transform: translateZ(-5px) scale(6);'
}`}
/>
<!-- {#if videoUrl && mountVideo}
<YouTubeBackground videoId={videoUrl} backgroundUrl={backdropUrl} />
{/if} -->
</div>
{/each}
{/await}
</div>
{/if}
{/await}
</div>
<div
class={classNames('absolute inset-0 flex flex-col transition-opacity', {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Container from '../Container.svelte';
import HeroShowcaseBackground from './HeroBackground.svelte';
import HeroBackground from './HeroBackground.svelte';
import IconButton from '../FloatingIconButton.svelte';
import { ChevronRight } from 'radix-icons-svelte';
import PageDots from '../HeroShowcase/PageDots.svelte';
@@ -10,13 +10,13 @@
const dispatch = createEventDispatcher();
export let urls: Promise<{ trailerUrl: string; backdropUrl: string }[]>;
export let items: Promise<{ backdropUrl: string; videoUrl?: string }[]>;
export let index = 0;
export let hideInterface = false;
let length = 0;
$: urls.then((urls) => (length = urls.length));
$: items.then((urls) => (length = urls.length));
function onNext() {
if (index === length - 1) {
@@ -42,9 +42,9 @@
return true;
}
let hasFocusWithin: Readable<boolean>;
let heroHasFocusWithin: Readable<boolean>;
let focusIndex: Writable<number>;
$: backgroundHasFocus = $hasFocusWithin && $focusIndex === 0;
$: backgroundHasFocus = $heroHasFocusWithin && $focusIndex === 0;
</script>
<Container
@@ -68,10 +68,10 @@
dispatch('navigate', detail);
}
}}
bind:hasFocusWithin
bind:hasFocusWithin={heroHasFocusWithin}
bind:focusIndex
>
<HeroShowcaseBackground {urls} {index} hasFocus={backgroundHasFocus} {hideInterface} />
<HeroBackground {items} {index} hasFocus={backgroundHasFocus} heroHasFocus={$heroHasFocusWithin} {hideInterface} />
<div
class={classNames('flex flex-1 z-10 transition-opacity', {
'opacity-0': hideInterface

View File

@@ -11,7 +11,7 @@
type: 'movie' | 'tv';
posterUri: string;
backdropUri: string;
trailerUrl?: string;
videoUrl?: string;
title: string;
overview: string;
infoProperties: { label: string; href?: string }[];
@@ -35,10 +35,12 @@
</script>
<HeroCarousel
urls={items.then((items) => items.map((i) => ({
backdropUrl: `${TMDB_IMAGES_ORIGINAL}${i.backdropUri}`,
trailerUrl: i.trailerUrl || ''
})))}
items={items.then((items) =>
items.map((i) => ({
backdropUrl: `${TMDB_IMAGES_ORIGINAL}${i.backdropUri}`,
videoUrl: i.videoUrl
}))
)}
bind:index={showcaseIndex}
on:enter
on:navigate={({ detail }) => {

View File

@@ -7,14 +7,13 @@
export let movies: Promise<TmdbMovie2[]>;
$: items = movies.then(async (movies) => {
return Promise.all(
$: items = movies
.then(async (movies) =>
movies.map(async (movie) => {
const trailerUrl = movie.id
? await tmdbApi.getMovieVideos(movie.id).then((videos) =>
videos.find((video) => video.type === 'Trailer' && video.site === 'YouTube')?.key || ''
)
: '';
const movieFull = await tmdbApi.getTmdbMovie(movie.id ?? 0);
const videoUrl = movieFull?.videos?.results?.find(
(video) => video.type === 'Trailer' && video.site === 'YouTube'
)?.key;
return {
id: movie.id ?? 0,
@@ -23,7 +22,7 @@
backdropUri: movie.backdrop_path ?? '',
title: movie.title ?? '',
overview: movie.overview ?? '',
trailerUrl: trailerUrl ?? '',
videoUrl,
infoProperties: [
...(movie.release_date
? [{ label: new Date(movie.release_date).getFullYear().toString() }]
@@ -43,8 +42,8 @@
]
};
})
);
});
)
.then((i) => Promise.all(i));
</script>
<HeroShowcase on:select={({ detail }) => navigate(`/movie/${detail?.id}`)} {items} />

View File

@@ -10,20 +10,19 @@
$: items = series.then(async (series) => {
return Promise.all(
series.map(async (series) => {
const trailerUrl = series.id
? await tmdbApi.getSeriesVideos(series.id).then((videos) =>
videos.find((video) => video.type === 'Trailer' && video.site === 'YouTube')?.key || ''
)
: '';
const seriesFull = await tmdbApi.getTmdbSeries(series.id ?? 0);
const videoUrl = seriesFull?.videos?.results?.find(
(video) => video.type === 'Trailer' && video.site === 'YouTube'
)?.key;
return {
id: series.id ?? 0,
type: 'series' as const,
type: 'tv' as const,
posterUri: series.poster_path ?? '',
backdropUri: series.backdrop_path ?? '',
title: series.name ?? '',
overview: series.overview ?? '',
trailerUrl: trailerUrl ?? '',
videoUrl,
infoProperties: [
...(series.status !== 'Ended'
? [{ label: `Since ${new Date(series.first_air_date ?? 0).getFullYear()}` }]

View File

@@ -1,225 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { fade } from 'svelte/transition';
import { PLATFORM_WEB } from '../constants';
export let videoId: string | null = null;
export let backgroundUrl: string;
let player: any;
let showBackgroundImage = true;
let showBackgroundImageError = false;
let isDestroyed = false;
let isPlayerReady = false;
let lastRequestedVideoId: string | null = null;
const stopBeforeEnd = 12;
let checkStopInterval: any = null;
let isLoadingPlayer = false;
function loadYouTubeAPI() {
if (!PLATFORM_WEB) return;
if (!window.YT) {
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
window.onYouTubeIframeAPIReady = () => {
setupPlayer();
};
} else {
setupPlayer();
}
}
function destroyPlayer() {
if (player) {
try {
player.destroy();
} catch (e) {
console.warn("Error destroying player.", e);
}
player = null;
isPlayerReady = false;
clearInterval(checkStopInterval);
}
}
function setupPlayer() {
if (!PLATFORM_WEB || !window.YT || isDestroyed || !videoId || isLoadingPlayer) return;
isLoadingPlayer = true;
if (videoId === lastRequestedVideoId) {
isLoadingPlayer = false;
return;
}
lastRequestedVideoId = videoId;
destroyPlayer();
setTimeout(() => {
if (!window.YT || isDestroyed) {
isLoadingPlayer = false;
return;
}
player = new window.YT.Player('youtube-player', {
videoId: videoId,
playerVars: {
autoplay: 1,
controls: 0,
modestbranding: 1,
rel: 0,
iv_load_policy: 3,
start: 3,
fs: 0,
disablekb: 1,
cc_load_policy: 0,
mute: 1
},
events: {
onReady: () => {
isLoadingPlayer = false;
if (isDestroyed || videoId !== lastRequestedVideoId) {
destroyPlayer();
return;
}
isPlayerReady = true;
setTimeout(() => player?.playVideo(), 1500);
},
onStateChange: handlePlayerStateChange,
onError: handlePlayerError
}
});
}, 200);
}
function handlePlayerError(event: any) {
if (isDestroyed) return;
const errorMessages: Record<number, string> = {
2: "Invalid video ID.",
5: "Playback error.",
100: "Video not found.",
101: "Embedding restricted by the owner.",
150: "Embedding restricted by the owner."
};
console.warn("YouTube Player Error:", errorMessages[event.data] || "Unknown error.");
showBackgroundImageError = true;
showBackgroundImage = true;
destroyPlayer();
}
function handlePlayerStateChange(event: any) {
if (isDestroyed || !isPlayerReady) return;
if (event.data === YT.PlayerState.PLAYING) {
setTimeout(() => (showBackgroundImage = false), 1000);
if (checkStopInterval) {
clearInterval(checkStopInterval);
}
checkStopInterval = setInterval(() => {
if (!player || showBackgroundImageError || isDestroyed) {
clearInterval(checkStopInterval);
return;
}
const remainingTime = player.getDuration() - player.getCurrentTime();
if (remainingTime <= stopBeforeEnd) {
try {
player.pauseVideo();
player.seekTo(0);
player.playVideo();
} catch (e) {
console.warn("Error looping video.", e);
}
}
}, 1000);
} else if (event.data === YT.PlayerState.ENDED) {
if (!isDestroyed) {
try {
player.seekTo(0);
player.playVideo();
} catch (e) {
console.warn("Error restarting video.", e);
}
}
}
}
onMount(() => {
isDestroyed = false;
loadYouTubeAPI();
});
onDestroy(() => {
isDestroyed = true;
destroyPlayer();
});
$: if (videoId && window.YT && !isDestroyed) {
setupPlayer();
}
</script>
<div id="youtube-player" class="video-background"></div>
{#if showBackgroundImage || showBackgroundImageError}
<div
class="background-image"
style={`background-image: url('${backgroundUrl}');`}
in:fade={{ duration: 200 }}
out:fade={{ duration: 400 }}
></div>
{/if}
<style>
.video-background {
position: fixed;
top: 50%;
left: 50%;
width: 100vw;
height: 100vh;
transform: translate(-50%, -50%) scale(1.6);
transition: transform 0.5s ease-in-out;
z-index: 0;
}
@media (max-width: 1200px) {
.video-background {
transform: translate(-50%, -50%) scale(2);
}
}
@media (max-width: 800px) {
.video-background {
transform: translate(-50%, -50%) scale(2.5);
}
}
@media (max-width: 500px) {
.video-background {
transform: translate(-50%, -50%) scale(3);
}
}
.background-image {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 1;
background-size: cover;
background-position: center;
opacity: 1;
}
</style>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import { PLATFORM_TV } from '$lib/constants';
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
const STOP_WHEN_REMAINING = 12;
export let videoId: string | null = null;
export let play = false;
export let autoplay = true;
export let autoplayDelay = 2000;
export let loadTime = PLATFORM_TV ? 2500 : 1000;
const playerId = `youtube-player-${videoId}-${Math.random().toString(36).substr(2, 9)}`;
let didMount = false;
let isInitialized = false;
let player: YT['Player'];
let isPlayerReady = false;
let checkStopInterval: ReturnType<typeof setInterval>;
let autoplayTimeout: ReturnType<typeof setTimeout>;
let loadTimeout: ReturnType<typeof setTimeout>;
$: if (isInitialized && player?.playVideo && play) {
player.playVideo();
} else if (isInitialized && player?.pauseVideo && !play) {
player.pauseVideo();
}
$: if (didMount && !isInitialized && play) loadYouTubeAPI();
function loadYouTubeAPI() {
isInitialized = true;
console.log('Loading YouTube API for ' + videoId);
if (!window.YT) {
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
(window as any).onYouTubeIframeAPIReady = () => {
setupPlayer();
};
} else {
setupPlayer();
}
}
function destroyPlayer() {
console.log('Destroying player');
clearInterval(checkStopInterval);
clearTimeout(autoplayTimeout);
clearTimeout(loadTimeout);
isPlayerReady = false;
if (!player) return;
try {
player.destroy();
} catch (e) {
console.warn('Error destroying player.', e);
}
}
function setupPlayer() {
if (!window.YT || !videoId) return;
setTimeout(() => {
if (!window.YT) return;
player = new window.YT.Player(playerId, {
videoId: videoId,
playerVars: {
autoplay: 0,
controls: 0,
modestbranding: 1,
rel: 0,
iv_load_policy: 3,
start: 3,
fs: 0,
disablekb: 1,
cc_load_policy: 0,
mute: 1
},
events: {
onReady: () => {
player?.playVideo();
play = true;
if (loadTime) {
loadTimeout = setTimeout(() => {
isPlayerReady = true;
console.log('Playing video');
}, loadTime);
} else {
isPlayerReady = true;
console.log('Playing video');
}
},
onStateChange: handlePlayerStateChange,
onError: handlePlayerError
}
});
}, 200);
}
function handlePlayerError(event: any) {
const errorMessages: Record<number, string> = {
2: 'Invalid video ID.',
5: 'Playback error.',
100: 'Video not found.',
101: 'Embedding restricted by the owner.',
150: 'Embedding restricted by the owner.'
};
console.error('YouTube Player Error:', errorMessages[event.data] || 'Unknown error.');
destroyPlayer();
}
function handlePlayerStateChange(event: any) {
if (!isPlayerReady) return;
if (event.data === window.YT.PlayerState.PLAYING) {
// setTimeout(() => (showBackgroundImage = false), 1000);
clearInterval(checkStopInterval);
checkStopInterval = setInterval(() => {
if (
!player
// showBackgroundImageError ||
) {
clearInterval(checkStopInterval);
return;
}
const remainingTime = player.getDuration() - player.getCurrentTime();
if (remainingTime <= STOP_WHEN_REMAINING) {
try {
player.pauseVideo();
player.seekTo(0);
player.playVideo();
} catch (e) {
console.warn('Error looping video.', e);
}
}
}, 1000);
} else if (event.data === window.YT.PlayerState.ENDED) {
try {
player.seekTo(0);
player.playVideo();
} catch (e) {
console.warn('Error restarting video.', e);
}
}
}
onMount(() => {
if (autoplay) {
autoplayTimeout = setTimeout(() => {
play = true;
didMount = true;
}, autoplayDelay);
} else {
didMount = true;
}
});
onDestroy(() => {
destroyPlayer();
});
$: {
const el = document.getElementById(playerId);
if (el) el.style.opacity = isPlayerReady && play ? '1' : '0';
}
</script>
<div out:fade={{ delay: isPlayerReady && play ? 2000 : 0 }}>
<div id={playerId} class="video-background" style="opacity: 0;" />
</div>
<style>
.video-background {
position: absolute;
top: 50%;
left: 50%;
width: 100vw;
height: 100vh;
transform: translate(-50%, -50%) scale(1.6);
transition: transform 0.5s ease-in-out, opacity 1s ease-in-out;
z-index: 0;
}
@media (max-width: 1200px) {
.video-background {
transform: translate(-50%, -50%) scale(2);
}
}
@media (max-width: 800px) {
.video-background {
transform: translate(-50%, -50%) scale(2.5);
}
}
@media (max-width: 500px) {
.video-background {
transform: translate(-50%, -50%) scale(3);
}
}
.background-image {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 1;
background-size: cover;
background-position: center;
opacity: 1;
}
</style>

View File

@@ -332,6 +332,22 @@
localSettings.update((p) => ({ ...p, checkForUpdates: detail }))}
/>
</div>
<div class="flex items-center justify-between text-lg font-medium text-secondary-100 py-2">
<label class="mr-2">Enable Trailers</label>
<Toggle
checked={$localSettings.enableTrailers}
on:change={({ detail }) =>
localSettings.update((p) => ({ ...p, enableTrailers: detail }))}
/>
</div>
<div class="flex items-center justify-between text-lg font-medium text-secondary-100 py-2">
<label class="mr-2">Autoplay Trailers</label>
<Toggle
checked={$localSettings.autoplayTrailers}
on:change={({ detail }) =>
localSettings.update((p) => ({ ...p, autoplayTrailers: detail }))}
/>
</div>
</Tab>
<Tab {...tab} direction="vertical" tab={Tabs.About}>

View File

@@ -37,6 +37,23 @@
$: recommendations = tmdbApi.getMovieRecommendations(tmdbId);
$: images = $tmdbMovie.then((movie) => {
const trailer = movie?.videos?.results?.find(
(video) => video.type === 'Trailer' && video.site === 'YouTube'
)?.key;
return (
movie?.images.backdrops
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
?.map((bd, i) => ({
backdropUrl: TMDB_IMAGES_ORIGINAL + bd.file_path || '',
videoUrl: trailer && i === 0 ? trailer : undefined
}))
.slice(0, 5) || []
);
});
let titleProperties: { href?: string; label: string }[] = [];
$tmdbMovie.then((movie) => {
if (movie?.runtime) {
@@ -71,15 +88,7 @@
class="h-[calc(100vh-4rem)] flex flex-col py-16 px-32"
on:enter={scrollIntoView({ top: 999 })}
>
<HeroCarousel
urls={$tmdbMovie.then(
(movie) =>
movie?.images.backdrops
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
?.map((bd) => ({backdropUrl: TMDB_IMAGES_ORIGINAL + bd.file_path || ''}))
.slice(0, 5) || []
)}
>
<HeroCarousel items={images}>
<Container />
<div class="h-full flex-1 flex flex-col justify-end">
{#await $tmdbMovie then movie}

View File

@@ -40,6 +40,22 @@
const episodeCards = useRegistrar();
let scrollTop: number;
$: images = $tmdbSeries.then((series) => {
const trailer = series?.videos?.results?.find(
(video) => video.type === 'Trailer' && video.site === 'YouTube'
)?.key;
return (
series?.images.backdrops
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
?.map((bd, i) => ({
backdropUrl: TMDB_IMAGES_ORIGINAL + bd.file_path || '',
videoUrl: trailer && i === 0 ? trailer : undefined
}))
.slice(0, 5) || []
);
});
let titleProperties: { href?: string; label: string }[] = [];
$tmdbSeries.then((series) => {
if (series && series.status !== 'Ended') {
@@ -86,15 +102,7 @@
}
}}
>
<HeroCarousel
urls={$tmdbSeries.then(
(series) =>
series?.images.backdrops
?.sort((a, b) => (b.vote_count || 0) - (a.vote_count || 0))
?.map((bd) => ({backdropUrl: TMDB_IMAGES_ORIGINAL + bd.file_path || ''}))
.slice(0, 5) || []
)}
>
<HeroCarousel items={images}>
<Container />
<div class="h-full flex-1 flex flex-col justify-end">
{#await $tmdbSeries then series}

View File

@@ -44,11 +44,15 @@ export const localSettings = createLocalStorageStore<{
useCssTransitions: boolean;
checkForUpdates: boolean;
skippedVersion: string;
enableTrailers: boolean;
autoplayTrailers: boolean;
}>('settings', {
animateScrolling: true,
useCssTransitions: true,
checkForUpdates: true,
skippedVersion: ''
skippedVersion: '',
enableTrailers: true,
autoplayTrailers: true
});
export type LibraryViewSettings = {

View File

@@ -9,4 +9,59 @@ export type MediaType = 'Movie' | 'Series';
declare global {
const REIVERR_VERSION: string;
type YTPlayerOptions = {
videoId: string;
playerVars: Record<string, string | number | boolean>;
// playerVars: {
// autoplay: 0 | 1;
// controls: 0 | 1;
// disablekb: 0 | 1;
// enablejsapi: 0 | 1;
// iv_load_policy: 1 | 3;
// loop: 0 | 1;
// modestbranding: 0 | 1;
// playsinline: 0 | 1;
// rel: 0 | 1;
// showinfo: 0 | 1;
// start: number;
// fs: 0 | 1;
// cc_load_policy: 0 | 1;
// mute: 0 | 1;
// };
events: {
onReady: (event: any) => void;
onStateChange: (event: any) => void;
onError: (event: any) => void;
};
};
type YTPlayer = {
new (id: string, options: YTPlayerOptions): YTPlayer;
destroy(): void;
getDuration(): number;
getCurrentTime(): number;
pauseVideo(): void;
playVideo(): void;
stopVideo(): void;
seekTo(seconds: number, allowSeekAhead?: boolean): void;
};
// Youtube API
interface YT {
Player: YTPlayer;
PlayerState: {
ENDED: number;
PLAYING: number;
PAUSED: number;
BUFFERING: number;
CUED: number;
};
}
const YT: YT;
interface Window {
YT: YT;
}
}

View File

@@ -9,4 +9,6 @@
<name>Reiverr</name>
<tizen:profile name="tv-samsung"/>
<tizen:setting screen-orientation="landscape" context-menu="enable" background-support="disable" encryption="disable" install-location="auto" hwkey-event="enable"/>
<tizen:allow-navigation>*</tizen:allow-navigation>
<access origin="http://youtube.com" subdomains="true"/>
</widget>