feat: trailer icons/links, improved trailer fade animations

This commit is contained in:
Aleksi Lassila
2025-02-17 19:24:59 +02:00
parent 0d7f63283f
commit 36e6caa1b7
10 changed files with 107 additions and 25 deletions

View File

@@ -51,8 +51,12 @@
})}
style={`background-image: url('${backdropUrl}'); transition: opacity 500ms, transform 500ms;`}
>
{#if videoUrl && i === visibleIndex && $localSettings.enableTrailers && $localSettings.autoplayTrailers}
<YouTubeVideo videoId={videoUrl} play={heroHasFocus} />
{#if videoUrl && i === visibleIndex && $localSettings.enableTrailers}
<YouTubeVideo
videoId={videoUrl}
autoplay={$localSettings.autoplayTrailers}
visible={$localSettings.autoplayTrailers ? heroHasFocus : hasFocus}
/>
{/if}
</div>
{/each}

View File

@@ -2,7 +2,7 @@
import Container from '../Container.svelte';
import HeroBackground from './HeroBackground.svelte';
import IconButton from '../FloatingIconButton.svelte';
import { ChevronRight } from 'radix-icons-svelte';
import { ChevronRight, ChevronUp } from 'radix-icons-svelte';
import PageDots from '../HeroShowcase/PageDots.svelte';
import type { Readable, Writable } from 'svelte/store';
import { createEventDispatcher } from 'svelte';
@@ -71,13 +71,25 @@
bind:hasFocusWithin={heroHasFocusWithin}
bind:focusIndex
>
<HeroBackground {items} {index} hasFocus={backgroundHasFocus} heroHasFocus={$heroHasFocusWithin} {hideInterface} />
<HeroBackground
{items}
{index}
hasFocus={backgroundHasFocus}
heroHasFocus={$heroHasFocusWithin}
{hideInterface}
/>
<div
class={classNames('flex flex-1 z-10 transition-opacity', {
'opacity-0': hideInterface
})}
>
<slot />
<!-- <div
class="absolute inset-x-1/2 -translate-x-1/2 top-16 min-w-fit flex flex-col items-center justify-center"
>
<ChevronUp size={38} />
<div class="whitespace-nowrap">View Trailer</div>
</div> -->
<div class="flex flex-col justify-end ml-4">
<div class="flex flex-1 justify-end items-center">
<IconButton on:click={onNext}>

View File

@@ -5,6 +5,8 @@
import { TMDB_IMAGES_ORIGINAL, TMDB_POSTER_SMALL } from '../../constants';
import { registrars } from '../../selectable.js';
import HeroCarousel from '../HeroCarousel/HeroCarousel.svelte';
import { localSettings } from '$lib/stores/localstorage.store';
import type { TitleInfoProperty } from '$lib/pages/TitlePages/HeroTitleInfo';
type ShowcaseItem = {
id: number;
@@ -14,7 +16,7 @@
videoUrl?: string;
title: string;
overview: string;
infoProperties: { label: string; href?: string }[];
infoProperties: TitleInfoProperty[];
url?: string;
};
@@ -38,7 +40,7 @@
items={items.then((items) =>
items.map((i) => ({
backdropUrl: `${TMDB_IMAGES_ORIGINAL}${i.backdropUri}`,
videoUrl: i.videoUrl
videoUrl: $localSettings.autoplayTrailers ? i.videoUrl : undefined
}))
)}
bind:index={showcaseIndex}

View File

@@ -4,6 +4,7 @@
import { navigate } from '../StackRouter/StackRouter';
import HeroShowcase from './HeroShowcase.svelte';
import { tmdbApi } from '$lib/apis/tmdb/tmdb-api';
import { Video } from 'radix-icons-svelte';
export let movies: Promise<TmdbMovie2[]>;
@@ -38,7 +39,12 @@
}
]
: []),
...(movie.genres ? [{ label: movie.genres.map((genre) => genre.name).join(', ') }] : [])
...(movie.genres
? [{ label: movie.genres.map((genre) => genre.name).join(', ') }]
: []),
...(videoUrl
? [{ icon: Video, href: `https://www.youtube.com/watch?v=${videoUrl}` }]
: [])
]
};
})

View File

@@ -4,6 +4,7 @@
import { navigate } from '../StackRouter/StackRouter';
import HeroShowcase from './HeroShowcase.svelte';
import { tmdbApi } from '$lib/apis/tmdb/tmdb-api';
import { Video } from 'radix-icons-svelte';
export let series: Promise<TmdbSeries2[]>;
@@ -39,7 +40,12 @@
}
]
: []),
...(series.genres ? [{ label: series.genres.map((genre) => genre.name).join(', ') }] : [])
...(series.genres
? [{ label: series.genres.map((genre) => genre.name).join(', ') }]
: []),
...(videoUrl
? [{ icon: Video, href: `https://www.youtube.com/watch?v=${videoUrl}` }]
: [])
]
};
})

View File

@@ -6,7 +6,11 @@
const STOP_WHEN_REMAINING = 12;
export let videoId: string | null = null;
export let play = false;
// Load video only if visible, pause with delay when not visible
export let visible = true;
// Play/pause video
export let play = true;
// Autoplay after load
export let autoplay = true;
export let autoplayDelay = 2000;
export let loadTime = PLATFORM_TV ? 2500 : 1000;
@@ -20,14 +24,20 @@
let checkStopInterval: ReturnType<typeof setInterval>;
let autoplayTimeout: ReturnType<typeof setTimeout>;
let loadTimeout: ReturnType<typeof setTimeout>;
let pauseTimeout: ReturnType<typeof setTimeout>;
$: if (isInitialized && player?.playVideo && play) {
$: if (isInitialized && player?.playVideo && visible && play) {
clearTimeout(pauseTimeout);
player.playVideo();
} else if (isInitialized && player?.pauseVideo && !play) {
player.pauseVideo();
} else if (isInitialized && player?.pauseVideo && !visible) {
pauseTimeout = setTimeout(() => {
player.pauseVideo();
}, 1000);
}
$: if (didMount && !isInitialized && play) loadYouTubeAPI();
$: if (didMount && !isInitialized && visible && play) loadYouTubeAPI();
function loadYouTubeAPI() {
isInitialized = true;
console.log('Loading YouTube API for ' + videoId);
@@ -49,6 +59,7 @@
clearInterval(checkStopInterval);
clearTimeout(autoplayTimeout);
clearTimeout(loadTimeout);
clearTimeout(pauseTimeout);
isPlayerReady = false;
if (!player) return;
@@ -169,11 +180,11 @@
});
$: {
const el = document.getElementById(playerId);
if (el) el.style.opacity = isPlayerReady && play ? '1' : '0';
if (el) el.style.opacity = isPlayerReady && visible ? '1' : '0';
}
</script>
<div out:fade={{ delay: isPlayerReady && play ? 2000 : 0 }}>
<div out:fade={{ delay: isPlayerReady && visible ? 1000 : 0 }}>
<div id={playerId} class="video-background" style="opacity: 0;" />
</div>
@@ -185,7 +196,7 @@
width: 100vw;
height: 100vh;
transform: translate(-50%, -50%) scale(1.6);
transition: transform 0.5s ease-in-out, opacity 1s ease-in-out;
transition: transform 0.5s ease-in-out, opacity 0.5s ease-in-out;
z-index: 0;
}

View File

@@ -1,12 +1,10 @@
<script lang="ts">
import classNames from 'classnames';
import { DotFilled } from 'radix-icons-svelte';
import type { TitleInfoProperty } from './HeroTitleInfo';
export let title: string;
export let properties: {
href?: string;
label?: string;
}[] = [];
export let properties: TitleInfoProperty[] = [];
export let overview: string;
export let onClickTitle: (() => void) | undefined = undefined;
</script>
@@ -26,18 +24,28 @@
<div
class="flex items-center gap-1 uppercase text-zinc-300 font-semibold tracking-wider mt-2 text-lg"
>
{#each properties.filter((p) => !!p.label) as property, i}
{#each properties.filter((p) => !!p.label || !!p.icon) as property, i}
{#if i !== 0}
<DotFilled />
{/if}
{#if property.href}
<p class="flex-shrink-0">
<a href={property.href} target="_blank">{property.label}</a>
<a href={property.href} target="_blank">
{#if property.label}
{property.label}
{:else if property.icon}
<svelte:component this={property.icon} size={22} />
{/if}
</a>
</p>
{:else}
{:else if property.label}
<p class="flex-shrink-0">
{property.label}
</p>
{:else if property.icon}
<span>
<svelte:component this={property.icon} size={22} />
</span>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,7 @@
import type { ComponentType } from 'svelte';
export type TitleInfoProperty = {
href?: string;
label?: string;
icon?: ComponentType;
};

View File

@@ -12,9 +12,11 @@
import { tmdbMovieDataStore } from '$lib/stores/data.store';
import { useMovieUserData } from '$lib/stores/media-user-data.store';
import { formatMinutesToTime, formatThousands } from '$lib/utils';
import { Bookmark, Check, ExternalLink, Minus, Play } from 'radix-icons-svelte';
import { Bookmark, Check, ExternalLink, Minus, Play, Video } from 'radix-icons-svelte';
import { onDestroy } from 'svelte';
import HeroTitleInfo from '../HeroTitleInfo.svelte';
import { localSettings } from '$lib/stores/localstorage.store';
import type { TitleInfoProperty } from '../HeroTitleInfo';
export let id: string;
const tmdbId = Number(id);
@@ -54,8 +56,12 @@
);
});
let titleProperties: { href?: string; label: string }[] = [];
let titleProperties: TitleInfoProperty[] = [];
$tmdbMovie.then((movie) => {
const trailer = movie?.videos?.results?.find(
(video) => video.type === 'Trailer' && video.site === 'YouTube'
)?.key;
if (movie?.runtime) {
titleProperties.push({
label: formatMinutesToTime(movie.runtime)
@@ -74,6 +80,13 @@
label: movie.genres.map((g) => g.name).join(', ')
});
}
if ($localSettings.enableTrailers && trailer) {
titleProperties.push({
icon: Video,
href: `https://www.youtube.com/watch?v=${trailer}`
});
}
});
onDestroy(() => {

View File

@@ -12,10 +12,12 @@
import { scrollIntoView, useRegistrar } from '$lib/selectable';
import { useSeriesUserData } from '$lib/stores/media-user-data.store';
import { formatThousands } from '$lib/utils';
import { Bookmark, Check, ExternalLink, Minus, Play } from 'radix-icons-svelte';
import { Bookmark, Check, ExternalLink, Minus, Play, Video } from 'radix-icons-svelte';
import { onDestroy } from 'svelte';
import TitleProperties from '../HeroTitleInfo.svelte';
import EpisodeGrid from './EpisodeGrid.svelte';
import { localSettings } from '$lib/stores/localstorage.store';
import type { TitleInfoProperty } from '../HeroTitleInfo';
export let id: string;
const tmdbId = Number(id);
@@ -56,8 +58,12 @@
);
});
let titleProperties: { href?: string; label: string }[] = [];
let titleProperties: TitleInfoProperty[] = [];
$tmdbSeries.then((series) => {
const trailer = series?.videos?.results?.find(
(video) => video.type === 'Trailer' && video.site === 'YouTube'
)?.key;
if (series && series.status !== 'Ended') {
titleProperties.push({
label: `Since ${new Date(series.first_air_date || Date.now())?.getFullYear()}`
@@ -82,6 +88,13 @@
label: series.genres.map((g) => g.name).join(', ')
});
}
if ($localSettings.enableTrailers && trailer) {
titleProperties.push({
icon: Video,
href: `https://www.youtube.com/watch?v=${trailer}`
});
}
});
onDestroy(() => {