Merge branch 'feat/Trailerv2' into dev

This commit is contained in:
Aleksi Lassila
2025-02-17 17:15:48 +02:00
22 changed files with 581 additions and 197 deletions

View File

@@ -0,0 +1,10 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ClearMetadataCache1739757875787 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Drop all rows in SERIES table
await queryRunner.query('DELETE FROM "series"');
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View File

@@ -26,7 +26,7 @@
"typeorm": "ts-node ./node_modules/typeorm/cli",
"typeorm:run-migrations": "npm run typeorm migration:run -- -d ./dist/data-source.js",
"typeorm:generate-migration": "ts-node ./node_modules/typeorm/cli -d ./dist/data-source.js migration:generate",
"typeorm:create-migration": "ts-node ./node_modules/typeorm/cli migration:create ./migrations/$npm_config_name",
"typeorm:create-migration": "ts-node ./node_modules/typeorm/cli migration:create",
"typeorm:revert-migration": "ts-node ./node_modules/typeorm/cli -d ./dist/data-source.js migration:revert"
},
"dependencies": {

View File

@@ -24,7 +24,14 @@ async function createAdminUser(userService: UsersService) {
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, {
logger: [
'error',
'warn',
'log',
...(ENV === 'development' ? (['debug'] as const) : []),
],
});
app.setGlobalPrefix('api');
app.enableCors();
app.use(json({ limit: '50mb' }));

View File

@@ -1,12 +1,25 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { DatabaseModule } from 'src/database/database.module';
import { TMDB_CACHE_TTL } from 'src/consts';
import { UsersModule } from 'src/users/users.module';
import { metadataProviders } from './metadata.providers';
import { MetadataService } from './metadata.service';
import { TmdbModule } from './tmdb/tmdb.module';
import { TmdbController } from './tmdb/tmdb.controller';
import { tmdbProviders } from './tmdb/tmdb.providers';
import { TmdbService } from './tmdb/tmdb.service';
@Module({
imports: [TmdbModule],
providers: [...metadataProviders, MetadataService],
imports: [
UsersModule,
CacheModule.register({ ttl: TMDB_CACHE_TTL, max: 10_000 }),
],
providers: [
...metadataProviders,
MetadataService,
...tmdbProviders,
TmdbService,
],
controllers: [TmdbController],
exports: [MetadataService],
})
export class MetadataModule {}

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Movie, Series } from './metadata.entity';
import { MOVIE_REPOSITORY, SERIES_REPOSITORY } from './metadata.providers';
@@ -9,6 +9,8 @@ import { TmdbService } from './tmdb/tmdb.service';
@Injectable()
export class MetadataService {
private logger = new Logger(MetadataService.name);
constructor(
@Inject(TMDB_API)
private tmdbApi: TmdbApi,
@@ -39,11 +41,7 @@ export class MetadataService {
!movie.updatedAt ||
new Date().getTime() - movie.updatedAt.getTime() > TMDB_CACHE_TTL
) {
const tmdbMovie = await this.tmdbApi.v3
.movieDetails(Number(tmdbId), {
append_to_response: 'videos,credits,external_ids,images',
})
.then((r) => r.data as TmdbMovieFull);
const tmdbMovie = await this.tmdbService.getFullMovie(Number(tmdbId));
movie.tmdbMovie = tmdbMovie;
}
@@ -65,7 +63,7 @@ export class MetadataService {
}
if (series.isStale()) {
console.log('getting metadata for series', tmdbId);
this.logger.debug(`Caching series ${tmdbId}`);
const tmdbSeries = await this.tmdbService.getFullSeries(Number(tmdbId));
if (tmdbSeries) series.tmdbSeries = tmdbSeries;
}

View File

@@ -1,55 +1,28 @@
import {
Cache,
CACHE_MANAGER
} from '@nestjs/cache-manager';
import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager';
import {
All,
Controller,
Inject,
Logger,
Param,
Req,
Res,
UseGuards
UseGuards,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { GetAuthUser, UserAccessControl } from 'src/auth/auth.guard';
import { TMDB_API_KEY, TMDB_CACHE_TTL } from 'src/consts';
import { User } from 'src/users/user.entity';
import { MetadataService } from '../metadata.service';
@UseGuards(UserAccessControl)
@Controller('tmdb')
export class TmdbController {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
// constructor(private metadataService: MetadataService) {}
// @Get('v3/proxy/3/movie/:tmdbId')
// async getMovieDetails(
// @Param('tmdbId') tmdbId: string,
// @Next() next: NextFunction,
// @GetAuthUser() user: User,
// // @Res({ passthrough: true }) res: Response, // Passthrough required
// ) {
// if (!parseInt(tmdbId)) {
// console.log('Invalid TMDB ID', tmdbId);
// next();
// return;
// }
// console.log('getting cached movie', tmdbId);
// const movie = await this.metadataService
// .getMovieByTmdbId(tmdbId)
// .catch((e) => {
// console.error('Error getting movie by TMDB ID', tmdbId, e);
// return null;
// });
// if (!movie?.tmdbMovie) {
// console.error('No movie found for TMDB ID', tmdbId);
// }
// console.log('returning cached movie', tmdbId);
// return movie?.tmdbMovie;
// }
private logger = new Logger(TmdbController.name);
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private metadataService: MetadataService,
) {}
// @UseInterceptors(CacheInterceptor)
// @CacheTTL(METADATA_CACHE_TTL)
@@ -62,7 +35,7 @@ export class TmdbController {
) {
const uri = params[0] + '?' + req.url.split('?')[1];
const cached = await this.cacheManager.get(uri).catch((e) => {
console.error('Error getting cache', e);
this.logger.error('Error getting cache', e);
return null;
});
@@ -72,44 +45,34 @@ export class TmdbController {
return cached;
}
// console.log('TMDB PROXY', req.url);
// 3/tv/87739?append_to_response=videos%2Caggregate_credits%2Cexternal_ids%2Cimages&include_image_language=en%2Cen%2Cnull
const first = uri.split('?')?.[0];
if (req.method === 'GET' && first.match(/3\/tv\/\d+$/)) {
const tmdbId = first.split('/').pop();
this.logger.debug(`Getting series from cache: ${tmdbId}`);
const metadata = await this.metadataService.getSeriesByTmdbId(tmdbId);
res.json(metadata.tmdbSeries);
return metadata;
} else if (req.method === 'GET' && first.match(/3\/movie\/\d+$/)) {
const tmdbId = first.split('/').pop();
this.logger.debug(`Getting movie from cache: ${tmdbId}`);
const metadata = await this.metadataService.getMovieByTmdbId(tmdbId);
res.json(metadata.tmdbMovie);
return metadata;
}
// if (params[0].match(/^3\/movie\/\d+\/?$/)) {
// // console.log('req.params', req.params);
// const movie = await this.metadataService.getMovieByTmdbId(
// req.params[0].split('/')[2],
// );
// // console.log('movie', movie);
// if (movie?.tmdbMovie) {
// // console.log('returning cached movie');
// res.json(movie.tmdbMovie);
// return;
// }
// }
this.logger.debug(`TMDB proxy cache miss: ${req.method} ${uri}`);
const proxyRes = await fetch(`https://api.themoviedb.org/${uri}`, {
method: req.method || 'GET',
headers: {
Authorization: `Bearer ${TMDB_API_KEY}`,
// ...headers,
// Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${settings.apiKey}"`,
},
})
// .then((r) => {
// // r.text().then((text) =>
// // console.log('TMDB Proxy response', uri, r.status, text),
// // );
// return r;
// })
.catch((e) => {
console.error('TMDB Proxy error', e);
// res.status(500).send('Proxy error');
throw e;
});
}).catch((e) => {
this.logger.error('TMDB Proxy error', e);
throw e;
});
// Readable.from(proxyRes.body).pipe(res);
const json = await proxyRes.json();
res.status(proxyRes.status);
res.json(json);

View File

@@ -12,11 +12,22 @@ export type MovieExternalIds = Awaited<
export type MovieImages = Awaited<
ReturnType<TmdbApi['v3']['movieImages']>
>['data'];
export type TmdbMovie = Awaited<
ReturnType<TmdbApi['v3']['movieDetails']>
>['data'];
export type SeriesVideos = Awaited<
ReturnType<TmdbApi['v3']['tvSeriesVideos']>
>['data'];
export type SeriesCredits = Awaited<
ReturnType<TmdbApi['v3']['tvSeriesAggregateCredits']>
>['data'];
export type SeriesExternalIds = Awaited<
ReturnType<TmdbApi['v3']['tvSeriesExternalIds']>
>['data'];
export type SeriesImages = Awaited<
ReturnType<TmdbApi['v3']['tvSeriesImages']>
>['data'];
export type TmdbSeries = Awaited<
ReturnType<TmdbApi['v3']['tvSeriesDetails']>
>['data'];
@@ -28,4 +39,9 @@ export type TmdbMovieFull = TmdbMovie & {
images: MovieImages;
};
export type TmdbSeriesFull = TmdbSeries;
export type TmdbSeriesFull = TmdbSeries & {
videos: SeriesVideos;
aggregate_credits: SeriesCredits;
external_ids: SeriesExternalIds;
images: SeriesImages;
};

View File

@@ -1,18 +0,0 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { TMDB_CACHE_TTL } from 'src/consts';
import { UsersModule } from 'src/users/users.module';
import { TmdbController } from './tmdb.controller';
import { tmdbProviders } from './tmdb.providers';
import { TmdbService } from './tmdb.service';
@Module({
imports: [
UsersModule,
CacheModule.register({ ttl: TMDB_CACHE_TTL, max: 10_000 }),
],
providers: [...tmdbProviders, TmdbService],
exports: [...tmdbProviders, TmdbService],
controllers: [TmdbController],
})
export class TmdbModule {}

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { TmdbSeriesFull } from './tmdb.dto';
import { TmdbMovieFull, TmdbSeriesFull } from './tmdb.dto';
import { TMDB_API, TmdbApi } from './tmdb.providers';
@Injectable()
@@ -11,8 +11,10 @@ export class TmdbService {
async getFullSeries(tmdbId: number): Promise<TmdbSeriesFull> {
const tmdbSeries = await this.tmdbApi.v3
.tvSeriesDetails(Number(tmdbId))
.then((r) => r.data);
.tvSeriesDetails(Number(tmdbId), {
append_to_response: 'videos,aggregate_credits,external_ids,images',
})
.then((r) => r.data as TmdbSeriesFull);
// .catch((e) => {
// console.error('could not get metadata for series', tmdbId, e);
// return e;
@@ -20,4 +22,12 @@ export class TmdbService {
return tmdbSeries;
}
async getFullMovie(tmdbId: number) {
return this.tmdbApi.v3
.movieDetails(Number(tmdbId), {
append_to_response: 'videos,credits,external_ids,images',
})
.then((r) => r.data as TmdbMovieFull);
}
}

View File

@@ -305,6 +305,35 @@ export class TmdbApi implements Api<paths> {
getPersonBackdrops = async (person_id: number) =>
this.getPersonTaggedImages(person_id).then((r) => r.filter((i) => (i.aspect_ratio || 0) > 1.5));
getMovieVideos = async (tmdbId: number) => {
return this.getClient()
.GET('/3/movie/{movie_id}/videos', {
params: {
path: {
movie_id: tmdbId
},
query: {
language: get(settings)?.language || 'en',
}
}
})
.then((res) => res.data?.results || []);
};
getSeriesVideos = async (tmdbId: number) => {
return this.getClient()
?.GET('/3/tv/{series_id}/videos', {
params: {
path: {
series_id: tmdbId
},
query: {
language: get(settings)?.language || 'en',
}
}
})
.then((res) => res.data?.results || []);
};
// OTHER
// USER

View File

@@ -3,10 +3,14 @@
import classNames from 'classnames';
import { onDestroy } from 'svelte';
import { isFirefox } from '../../utils/browser-detection';
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>;
@@ -36,17 +40,21 @@
</script>
<div class="fixed inset-0" style="-webkit-transform: translate3d(0,0,0);">
{#if !isFirefox()}
{#await urls then urls}
{#each urls as url, i}
{#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('${url}'); transition: opacity 500ms, transform 500ms;`}
/>
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}
@@ -56,8 +64,8 @@
})}
style="perspective: 1px; -webkit-perspective: 1px;"
>
{#await urls then urls}
{#each urls as url, i}
{#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;"
@@ -65,11 +73,14 @@
>
<div
class="w-full h-full flex-shrink-0 basis-auto bg-center bg-cover absolute inset-0"
style={`background-image: url('${url}'); ${
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}

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<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,7 +35,12 @@
</script>
<HeroCarousel
urls={items.then((items) => items.map((i) => `${TMDB_IMAGES_ORIGINAL}${i.backdropUri}`))}
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

@@ -3,35 +3,47 @@
import { formatMinutesToTime, formatThousands } from '$lib/utils';
import { navigate } from '../StackRouter/StackRouter';
import HeroShowcase from './HeroShowcase.svelte';
import { tmdbApi } from '$lib/apis/tmdb/tmdb-api';
export let movies: Promise<TmdbMovie2[]>;
$: items = movies.then((movies) =>
movies.map((movie) => ({
id: movie.id ?? 0,
type: 'movie' as const,
posterUri: movie.poster_path ?? '',
backdropUri: movie.backdrop_path ?? '',
title: `${movie.title}`,
overview: movie.overview ?? '',
infoProperties: [
...(movie.release_date
? [{ label: new Date(movie.release_date).getFullYear().toString() }]
: []),
...(movie.runtime ? [{ label: formatMinutesToTime(movie.runtime) }] : []),
...(movie.vote_average
? [
{
label: `${movie.vote_average.toFixed(1)} TMDB (${formatThousands(
movie.vote_count ?? 0
)})`,
href: `https://www.themoviedb.org/movie/${movie.id}`
}
]
: []),
...(movie.genres ? [{ label: movie.genres.map((genre) => genre.name).join(', ') }] : [])
]
}))
);
$: items = movies
.then(async (movies) =>
movies.map(async (movie) => {
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,
type: 'movie' as const,
posterUri: movie.poster_path ?? '',
backdropUri: movie.backdrop_path ?? '',
title: movie.title ?? '',
overview: movie.overview ?? '',
videoUrl,
infoProperties: [
...(movie.release_date
? [{ label: new Date(movie.release_date).getFullYear().toString() }]
: []),
...(movie.runtime ? [{ label: formatMinutesToTime(movie.runtime) }] : []),
...(movie.vote_average
? [
{
label: `${movie.vote_average.toFixed(1)} TMDB (${formatThousands(
movie.vote_count ?? 0
)})`,
href: `https://www.themoviedb.org/movie/${movie.id}`
}
]
: []),
...(movie.genres ? [{ label: movie.genres.map((genre) => genre.name).join(', ') }] : [])
]
};
})
)
.then((i) => Promise.all(i));
</script>
<HeroShowcase on:select={({ detail }) => navigate(`/movie/${detail?.id}`)} {items} />

View File

@@ -1,38 +1,50 @@
<script lang="ts">
import type { TmdbMovie2, TmdbSeries2 } from '$lib/apis/tmdb/tmdb-api';
import { formatMinutesToTime, formatThousands } from '$lib/utils';
import type { TmdbSeries2 } from '$lib/apis/tmdb/tmdb-api';
import { formatThousands } from '$lib/utils';
import { navigate } from '../StackRouter/StackRouter';
import HeroShowcase from './HeroShowcase.svelte';
import { tmdbApi } from '$lib/apis/tmdb/tmdb-api';
export let series: Promise<TmdbSeries2[]>;
$: items = series.then((series) =>
series.map((series) => ({
id: series.id ?? 0,
type: 'movie' as const,
posterUri: series.poster_path ?? '',
backdropUri: series.backdrop_path ?? '',
title: `${series.name}`,
overview: series.overview ?? '',
infoProperties: [
...(series.status !== 'Ended'
? [{ label: `Since ${new Date(series.first_air_date ?? 0).getFullYear()}` }]
: series.last_air_date
? [{ label: `Ended ${new Date(series.last_air_date).getFullYear()}` }]
: []),
...(series.vote_average
? [
{
label: `${series.vote_average.toFixed(1)} TMDB (${formatThousands(
series.vote_count ?? 0
)})`,
href: `https://www.themoviedb.org/tv/${series.id}`
}
]
: []),
...(series.genres ? [{ label: series.genres.map((genre) => genre.name).join(', ') }] : [])
]
}))
);
$: items = series.then(async (series) => {
return Promise.all(
series.map(async (series) => {
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: 'tv' as const,
posterUri: series.poster_path ?? '',
backdropUri: series.backdrop_path ?? '',
title: series.name ?? '',
overview: series.overview ?? '',
videoUrl,
infoProperties: [
...(series.status !== 'Ended'
? [{ label: `Since ${new Date(series.first_air_date ?? 0).getFullYear()}` }]
: series.last_air_date
? [{ label: `Ended ${new Date(series.last_air_date).getFullYear()}` }]
: []),
...(series.vote_average
? [
{
label: `${series.vote_average.toFixed(1)} TMDB (${formatThousands(
series.vote_count ?? 0
)})`,
href: `https://www.themoviedb.org/tv/${series.id}`
}
]
: []),
...(series.genres ? [{ label: series.genres.map((genre) => genre.name).join(', ') }] : [])
]
};
})
);
});
</script>
<HeroShowcase on:select={({ detail }) => navigate(`/series/${detail?.id}`)} {items} />

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) => 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((i) => TMDB_IMAGES_ORIGINAL + i.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>