Experimental video player

This commit is contained in:
Aleksi Lassila
2023-06-19 20:44:36 +03:00
parent 3429d2c5ee
commit b39bf97b8a
21 changed files with 21264 additions and 146 deletions

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import type { TmdbMovie, TmdbMovieFull } from '$lib/tmdb-api';
import { formatGenres, formatMinutes } from '$lib/utils';
import classNames from 'classnames';
import { TMDB_IMAGES } from '$lib/constants';
import { onMount } from 'svelte';
import { fetchTmdbMovie, fetchTmdbMovieImages, TmdbApi } from '$lib/tmdb-api';
import CardPlaceholder from './CardPlaceholder.svelte';
export let tmdbId: string;
export let available = true;
export let progress = 0;
export let progressType: 'watched' | 'downloading' = 'watched';
export let randomProgress = false;
if (randomProgress) {
progress = Math.random() > 0.3 ? Math.random() * 100 : 0;
}
let tmdbMovie: TmdbMovie;
let backdropUrl;
onMount(async () => {
if (!tmdbId) return;
fetchTmdbMovieImages(String(tmdbId))
.then(
(r) =>
(backdropUrl = TMDB_IMAGES + r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path)
)
.catch((err) => (backdropUrl = null));
fetchTmdbMovie(tmdbId).then((movie) => (tmdbMovie = movie));
});
</script>
{#if !tmdbMovie || !backdropUrl}
<CardPlaceholder />
{:else}
<div
style={"background-image: url('" + backdropUrl + "')"}
class="bg-center bg-cover h-40 w-72 rounded overflow-hidden relative drop-shadow-2xl"
>
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
<div
on:click={() => window.open('/movie/' + tmdbMovie.id, '_self')}
class="h-full w-full opacity-0 hover:opacity-100 transition-opacity flex flex-col justify-between cursor-pointer p-2 px-3 relative z-[1] peer"
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
>
<div>
<h1 class="font-bold tracking-wider">{tmdbMovie.original_title}</h1>
<div class="text-xs text-zinc-300 tracking-wider font-medium">
{formatGenres(tmdbMovie.genres)}
</div>
</div>
<div class="flex justify-between items-end">
{#if progressType === 'watched'}
<div class="text-xs font-medium text-zinc-200">
{progress
? formatMinutes(tmdbMovie.runtime - tmdbMovie.runtime * (progress / 100)) + ' left'
: formatMinutes(tmdbMovie.runtime)}
</div>
{:else if progressType === 'downloading'}
<div class="text-xs font-medium text-zinc-200">
{Math.floor(progress) + '% Downloaded'}
</div>
{/if}
</div>
</div>
<div
class={classNames('absolute inset-0', {
'bg-darken opacity-0 peer-hover:opacity-100': available,
'bg-[#00000055] peer-hover:bg-darken': !available
})}
/>
</div>
{/if}

View File

@@ -0,0 +1,8 @@
<script lang="ts">
export let index = 0;
</script>
<div
class="h-40 w-72 rounded overflow-hidden drop-shadow-2xl bg-darken animate-pulse"
style={'animation-delay: ' + ((index * 100) % 2000) + 'ms;'}
/>

View File

@@ -16,7 +16,7 @@
}
$: {
transparent = y === 0;
transparent = y <= 0;
baseStyle = classNames(
'fixed px-8 inset-x-0 grid grid-cols-[min-content_1fr_min-content] items-center z-10',
'transition-all',

View File

@@ -6,18 +6,25 @@
import RequestModal from '../RequestModal/RequestModal.svelte';
import { addRadarrMovie, getQueuedById, getRadarrMovie } from '$lib/radarr/radarr';
import IconButton from '../IconButton.svelte';
import { formatMinutes } from '$lib/utils.js';
import classNames from 'classnames';
import VideoPlayer from '../VideoPlayer/VideoPlayer.svelte';
import { getJellyfinItemByTmdbId } from '$lib/jellyfin/jellyfin';
let isRequestModalVisible = false;
export let tmdbId: string;
let videoPlayerVisible;
const { data: localResource, load, didLoad } = getRadarrMovie();
const { data: queueResponse, load: loadQueued } = getQueuedById();
const { data: addMovieResponse, loading: addMovieLoading, load: addToRadarr } = addRadarrMovie();
const { data: jellyfinData, load: loadJellyfinData } = getJellyfinItemByTmdbId();
function refreshRadarrMovie() {
if (tmdbId) load(tmdbId);
if (tmdbId) {
load(tmdbId);
loadJellyfinData(tmdbId);
}
}
onMount(() => {
@@ -101,7 +108,7 @@
{movieFile.mediaInfo.videoCodec}
</h2>
</div>
<Button size="sm">Stream</Button>
<Button size="sm" on:click={() => (videoPlayerVisible = true)}>Stream</Button>
</div>
{/each}
</div>
@@ -122,3 +129,7 @@
on:download={() => refreshRadarrMovie()}
/>
{/if}
{#if $jellyfinData?.Id}
player
<VideoPlayer bind:visible={videoPlayerVisible} jellyfinVideoId={$jellyfinData.Id} />
{/if}

View File

@@ -1,57 +0,0 @@
<script lang="ts">
import type { TmdbMovieFull } from '$lib/tmdb-api';
import { formatGenres, formatMinutes } from '$lib/utils';
import classNames from 'classnames';
import { TMDB_IMAGES } from '$lib/constants';
export let tmdbMovie: TmdbMovieFull;
export let available = true;
export let progress = 0;
export let progressType: 'watched' | 'downloading' = 'watched';
export let randomProgress = false;
if (randomProgress) {
progress = Math.random() > 0.3 ? Math.random() * 100 : 0;
}
const backdropUrl =
TMDB_IMAGES + tmdbMovie.images.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path;
</script>
<div
style={"background-image: url('" + backdropUrl + "')"}
class="bg-center bg-cover h-40 w-72 rounded overflow-hidden relative drop-shadow-2xl"
>
<div style={'width: ' + progress + '%'} class="h-[2px] bg-zinc-200 bottom-0 absolute z-[1]" />
<div
on:click={() => window.open('/movie/' + tmdbMovie.id, '_self')}
class="h-full w-full opacity-0 hover:opacity-100 transition-opacity flex flex-col justify-between cursor-pointer p-2 px-3 relative z-[1] peer"
style={progress > 0 ? 'padding-bottom: 0.6rem;' : ''}
>
<div>
<h1 class="font-bold tracking-wider">{tmdbMovie.original_title}</h1>
<div class="text-xs text-zinc-300 tracking-wider font-medium">
{formatGenres(tmdbMovie.genres)}
</div>
</div>
<div class="flex justify-between items-end">
{#if progressType === 'watched'}
<div class="text-xs font-medium text-zinc-200">
{progress
? formatMinutes(tmdbMovie.runtime - tmdbMovie.runtime * (progress / 100)) + ' left'
: formatMinutes(tmdbMovie.runtime)}
</div>
{:else if progressType === 'downloading'}
<div class="text-xs font-medium text-zinc-200">
{Math.floor(progress) + '% Downloaded'}
</div>
{/if}
</div>
</div>
<div
class={classNames('absolute inset-0', {
'bg-darken opacity-0 peer-hover:opacity-100': available,
'bg-[#00000055] peer-hover:bg-darken': !available
})}
/>
</div>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getJellyfinPlaybackInfo } from '$lib/jellyfin/jellyfin';
import Hls from 'hls.js';
import Modal from '../Modal/Modal.svelte';
import { JELLYFIN_BASE_URL } from '$lib/jellyfin/jellyfin.js';
export let visible = false;
export let jellyfinVideoId: string;
let video: HTMLVideoElement;
const { data: playbackInfo, load: loadPlaybackInfo } = getJellyfinPlaybackInfo();
onMount(() => {
if (!Hls.isSupported()) {
throw new Error('HLS is not supported');
}
if (!jellyfinVideoId) {
throw new Error('No video id provided');
}
loadPlaybackInfo(jellyfinVideoId);
});
playbackInfo.subscribe((info) => {
console.log('Subscribe info', info);
if (!info) return;
console.log(video.src);
const hls = new Hls();
hls.loadSource(JELLYFIN_BASE_URL + info);
hls.attachMedia(video);
});
function handleClose() {
visible = false;
video?.pause();
}
</script>
<Modal {visible} close={handleClose}>
<video controls bind:this={video} />
</Modal>

View File

@@ -0,0 +1,56 @@
import type { PageServerLoad } from './$types';
import { RadarrApi } from '$lib/radarr/radarr';
export const load = (() => {
const radarrMovies = RadarrApi.get('/api/v3/movie', {
params: {}
}).then((r) => r.data);
const downloadingRadarrMovies = RadarrApi.get('/api/v3/queue', {
params: {
query: {
includeMovie: true
}
}
}).then((r) => r.data?.records?.filter((record) => record.movie));
const downloading = downloadingRadarrMovies.then(async (movies) => {
return movies?.map((m) => ({
tmdbId: m.movie?.tmdbId,
size: m.size,
sizeleft: m.sizeleft
}));
});
const unavailable = radarrMovies.then(async (movies) => {
const downloadingMovies = await downloading;
return movies?.filter(
(m) =>
(!m.movieFile || !m.hasFile || !m.isAvailable) &&
!downloadingMovies?.find((d) => d.tmdbId === m.tmdbId)
);
});
const available = radarrMovies.then(async (movies) => {
const downloadingMovies = await downloading;
const unavailableMovies = await unavailable;
if (!downloadingMovies || !movies) return [];
return movies
.filter((movie) => {
return !downloadingMovies.find((downloadingMovie) => downloadingMovie.tmdbId === movie.id);
})
.filter(
(movie) => !unavailableMovies?.find((unavailableMovie) => unavailableMovie.id === movie.id)
);
});
return {
streamed: {
available,
downloading,
unavailable
}
};
}) satisfies PageServerLoad;

View File

@@ -1,74 +1,91 @@
<script lang="ts">
import type { PageData } from './$types';
import SmallHorizontalPoster from '../components/SmallHorizontalPoster/SmallHorizontalPoster.svelte';
import SmallHorizontalPoster from '../components/Card/Card.svelte';
import type { TmdbMovieFull } from '$lib/tmdb-api';
import { TMDB_IMAGES } from '$lib/constants.js';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import CardPlaceholder from '../components/Card/CardPlaceholder.svelte';
export let data: PageData;
console.log(data);
const allMovies: Record<string, TmdbMovieFull> = {};
data.tmdbMovies.forEach((m) => (allMovies[m.id] = m));
const tmdbIdToDownloading = {};
(data.downloading as any).forEach((d) => (tmdbIdToDownloading[d.movie.tmdbId] = d));
const tmdbIdToRadarrMovie = {};
(data.radarrMovies as any).forEach((r) => (tmdbIdToRadarrMovie[r.tmdbId] = r));
const downloading = data.tmdbMovies.filter((m) => tmdbIdToDownloading[m.id] !== undefined);
const available = data.tmdbMovies.filter((m) => tmdbIdToDownloading[m.id] === undefined);
const unavailable = data.tmdbMovies.filter(
(m) => !tmdbIdToRadarrMovie[m.id]?.hasFile && !tmdbIdToDownloading[m.id]
);
//
// const allMovies: Record<string, TmdbMovieFull> = {};
// await data.tmdbMovies.forEach((m) => (allMovies[m.id] = m));
//
// const tmdbIdToDownloading = {};
// (data.downloading as any).forEach((d) => (tmdbIdToDownloading[d.movie.tmdbId] = d));
//
// const tmdbIdToRadarrMovie = {};
// (data.radarrMovies as any).forEach((r) => (tmdbIdToRadarrMovie[r.tmdbId] = r));
//
// const downloading = data.tmdbMovies.filter((m) => tmdbIdToDownloading[m.id] !== undefined);
// const available = data.tmdbMovies.filter((m) => tmdbIdToDownloading[m.id] === undefined);
// const unavailable = data.tmdbMovies.filter(
// (m) => !tmdbIdToRadarrMovie[m.id]?.hasFile && !tmdbIdToDownloading[m.id]
// );
const watched = [];
const posterGridStyle = 'flex flex-wrap justify-center gap-x-4 gap-y-8';
const headerStyle = 'uppercase tracking-widest font-bold text-center mt-2';
let loading = false;
beforeNavigate(() => (loading = true));
afterNavigate(() => (loading = false));
console.log(data);
</script>
<div style={"background-image: url('" + TMDB_IMAGES + "/vvjYv7bSWerbsi0LsMjLnTVOX7c.jpg')"}>
<div
style={"background-image: url('" + TMDB_IMAGES + "/vvjYv7bSWerbsi0LsMjLnTVOX7c.jpg')"}
class="transition-all"
>
<div class="py-24 backdrop-blur-2xl bg-darken px-8 flex flex-col gap-4">
<!-- Contains all the titles available locally, the ones already watched previously (greyed out at the-->
<!-- bottom), and the ones that are in some sort of watchlist and not available via any source.-->
<!-- <div>Library</div>-->
{#if downloading.length > 0}
<h1 class={headerStyle}>Downloading</h1>
{#await Promise.all( [data.streamed.available, data.streamed.unavailable, data.streamed.downloading] )}
<div class={posterGridStyle}>
{#each downloading as movie (movie.id)}
<SmallHorizontalPoster
progress={(tmdbIdToDownloading[movie.id].sizeleft /
tmdbIdToDownloading[movie.id].size) *
100}
progressType="downloading"
available={false}
tmdbMovie={movie}
/>
{#each [...Array(20).keys()] as index (index)}
<CardPlaceholder {index} />
{/each}
</div>
{/if}
{:then [available, unavailable, downloading]}
{#if downloading.length > 0}
<h1 class={headerStyle}>Downloading</h1>
<div class={posterGridStyle}>
{#each downloading as movie (movie.tmdbId)}
<SmallHorizontalPoster
tmdbId={movie.tmdbId}
progress={(movie.sizeleft / movie.size) * 100}
progressType="downloading"
available={false}
/>
{/each}
</div>
{/if}
{#if available.length > 0}
<h1 class={headerStyle}>Available</h1>
<div class={posterGridStyle}>
{#each available as movie (movie.id)}
<SmallHorizontalPoster randomProgress={true} tmdbMovie={movie} />
{/each}
</div>
{/if}
{#if available.length > 0}
<h1 class={headerStyle}>Available</h1>
<div class={posterGridStyle}>
{#each available as movie (movie.tmdbId)}
<SmallHorizontalPoster randomProgress={true} tmdbId={movie.tmdbId} />
{/each}
</div>
{/if}
{#if unavailable.length > 0}
<h1 class={headerStyle}>Unavailable</h1>
<div class={posterGridStyle}>
{#each unavailable as movie (movie.id)}
<SmallHorizontalPoster available={false} tmdbMovie={movie} />
{/each}
</div>
{/if}
{#if unavailable.length > 0}
<h1 class={headerStyle}>Unavailable</h1>
<div class={posterGridStyle}>
{#each unavailable as movie (movie.tmdbId)}
<SmallHorizontalPoster available={false} tmdbId={movie.tmdbId} />
{/each}
</div>
{/if}
{#if watched.length > 0}
<h1 class={headerStyle}>Watched</h1>
{/if}
{#if watched.length > 0}
<h1 class={headerStyle}>Watched</h1>
{/if}
{/await}
</div>
</div>

View File

@@ -1,30 +0,0 @@
import type { PageLoad } from './$types';
import { fetchFullMovieDetails } from '$lib/tmdb-api';
import { RadarrApi } from '$lib/radarr/radarr';
export const load = (async () => {
const radarrMovies = await RadarrApi.get('/api/v3/movie', {
params: {}
}).then((r) => r.data);
let tmdbMovies;
if (radarrMovies) {
tmdbMovies = await Promise.all(
radarrMovies.filter((m) => m.tmdbId).map((m) => fetchFullMovieDetails(String(m.tmdbId)))
);
}
console.log('radarrMovies', radarrMovies);
return {
radarrMovies,
tmdbMovies,
downloading: await RadarrApi.get('/api/v3/queue', {
params: {
query: {
includeMovie: true
}
}
}).then((r) => r.data?.records)
};
}) satisfies PageLoad;

View File