Experimental video player
This commit is contained in:
6
package-lock.json
generated
6
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@apollo/client": "^3.7.15",
|
||||
"axios": "^1.4.0",
|
||||
"graphql": "^16.6.0",
|
||||
"hls.js": "^1.4.6",
|
||||
"openapi-fetch": "^0.2.1",
|
||||
"radix-icons-svelte": "^1.2.1",
|
||||
"svelte-apollo": "^0.5.0"
|
||||
@@ -5275,6 +5276,11 @@
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.4.6.tgz",
|
||||
"integrity": "sha512-lGv9QfjfjfuGQfLa/28vDFlYWb9Myq5QuvM9qWp5DyElp8jTGMNodTdeAjOLzaA/fN4XHeG+HhTkRGzntwuDZw=="
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@apollo/client": "^3.7.15",
|
||||
"axios": "^1.4.0",
|
||||
"graphql": "^16.6.0",
|
||||
"hls.js": "^1.4.6",
|
||||
"openapi-fetch": "^0.2.1",
|
||||
"radix-icons-svelte": "^1.2.1",
|
||||
"svelte-apollo": "^0.5.0"
|
||||
|
||||
20607
src/lib/jellyfin/jellyfin-types.d.ts
vendored
Normal file
20607
src/lib/jellyfin/jellyfin-types.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
375
src/lib/jellyfin/jellyfin.ts
Normal file
375
src/lib/jellyfin/jellyfin.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import type { paths } from '$lib/jellyfin/jellyfin-types';
|
||||
import { PUBLIC_JELLYFIN_API_KEY } from '$env/static/public';
|
||||
import { request } from '$lib/utils';
|
||||
|
||||
export const JELLYFIN_DEVICE_ID = 'Reiverr Client';
|
||||
export const JELLYFIN_BASE_URL = 'http://jellyfin.home';
|
||||
export const JELLYFIN_USER_ID = '75dcb061c9404115a7acdc893ea6bbbc';
|
||||
|
||||
export const JellyfinApi = createClient<paths>({
|
||||
baseUrl: JELLYFIN_BASE_URL,
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${JELLYFIN_DEVICE_ID}", Token="${PUBLIC_JELLYFIN_API_KEY}"`
|
||||
}
|
||||
});
|
||||
|
||||
export const getJellyfinContinueWatching = () =>
|
||||
request(() =>
|
||||
JellyfinApi.get('/Users/{userId}/Items/Resume', {
|
||||
params: {
|
||||
path: {
|
||||
userId: JELLYFIN_USER_ID
|
||||
},
|
||||
query: {
|
||||
limit: 8,
|
||||
mediaTypes: ['Video']
|
||||
}
|
||||
}
|
||||
}).then((r) => r.data?.Items)
|
||||
);
|
||||
|
||||
export const getJellyfinItemByTmdbId = () =>
|
||||
request((tmdbId: string) =>
|
||||
JellyfinApi.get('/Users/{userId}/Items', {
|
||||
params: {
|
||||
path: {
|
||||
userId: JELLYFIN_USER_ID
|
||||
},
|
||||
query: {
|
||||
hasTmdbId: true,
|
||||
recursive: true,
|
||||
isMovie: true,
|
||||
fields: ['ProviderIds']
|
||||
}
|
||||
}
|
||||
}).then((r) => r.data?.Items?.find((i) => i.ProviderIds?.Tmdb == tmdbId))
|
||||
);
|
||||
|
||||
export const getJellyfinPlaybackInfo = () =>
|
||||
request((id: string) =>
|
||||
JellyfinApi.post('/Items/{itemId}/PlaybackInfo', {
|
||||
params: {
|
||||
path: {
|
||||
itemId: id
|
||||
},
|
||||
query: {
|
||||
userId: JELLYFIN_USER_ID,
|
||||
startTimeTicks: 0,
|
||||
autoOpenLiveStream: true,
|
||||
maxStreamingBitrate: 140000000
|
||||
}
|
||||
},
|
||||
body: {
|
||||
DeviceProfile: {
|
||||
CodecProfiles: [
|
||||
{
|
||||
Codec: 'aac',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'Equals',
|
||||
IsRequired: false,
|
||||
Property: 'IsSecondaryAudio',
|
||||
Value: 'false'
|
||||
}
|
||||
],
|
||||
Type: 'VideoAudio'
|
||||
},
|
||||
{
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'Equals',
|
||||
IsRequired: false,
|
||||
Property: 'IsSecondaryAudio',
|
||||
Value: 'false'
|
||||
}
|
||||
],
|
||||
Type: 'VideoAudio'
|
||||
},
|
||||
{
|
||||
Codec: 'h264',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
IsRequired: false,
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoProfile',
|
||||
Value: 'high|main|baseline|constrained baseline'
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoRangeType',
|
||||
Value: 'SDR'
|
||||
},
|
||||
{
|
||||
Condition: 'LessThanEqual',
|
||||
IsRequired: false,
|
||||
Property: 'VideoLevel',
|
||||
Value: '52'
|
||||
},
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
IsRequired: false,
|
||||
Property: 'IsInterlaced',
|
||||
Value: 'true'
|
||||
}
|
||||
],
|
||||
Type: 'Video'
|
||||
},
|
||||
{
|
||||
Codec: 'hevc',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
IsRequired: false,
|
||||
Property: 'IsAnamorphic',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoProfile',
|
||||
Value: 'main'
|
||||
},
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoRangeType',
|
||||
Value: 'SDR'
|
||||
},
|
||||
{
|
||||
Condition: 'LessThanEqual',
|
||||
IsRequired: false,
|
||||
Property: 'VideoLevel',
|
||||
Value: '120'
|
||||
},
|
||||
{
|
||||
Condition: 'NotEquals',
|
||||
IsRequired: false,
|
||||
Property: 'IsInterlaced',
|
||||
Value: 'true'
|
||||
}
|
||||
],
|
||||
Type: 'Video'
|
||||
},
|
||||
{
|
||||
Codec: 'vp9',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoRangeType',
|
||||
Value: 'SDR|HDR10|HLG'
|
||||
}
|
||||
],
|
||||
Type: 'Video'
|
||||
},
|
||||
{
|
||||
Codec: 'av1',
|
||||
Conditions: [
|
||||
{
|
||||
Condition: 'EqualsAny',
|
||||
IsRequired: false,
|
||||
Property: 'VideoRangeType',
|
||||
Value: 'SDR|HDR10|HLG'
|
||||
}
|
||||
],
|
||||
Type: 'Video'
|
||||
}
|
||||
],
|
||||
ContainerProfiles: [],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
AudioCodec: 'vorbis,opus',
|
||||
Container: 'webm',
|
||||
Type: 'Video',
|
||||
VideoCodec: 'vp8,vp9,av1'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac,mp3,opus,flac,alac,vorbis',
|
||||
Container: 'mp4,m4v',
|
||||
Type: 'Video',
|
||||
VideoCodec: 'h264,vp9,av1'
|
||||
},
|
||||
{
|
||||
Container: 'opus',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'opus',
|
||||
Container: 'webm',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'mp3',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'aac',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
Container: 'm4a',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
Container: 'm4b',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'flac',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'alac',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'alac',
|
||||
Container: 'm4a',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'alac',
|
||||
Container: 'm4b',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'webma',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'webma',
|
||||
Container: 'webm',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'wav',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
Container: 'ogg',
|
||||
Type: 'Audio'
|
||||
}
|
||||
],
|
||||
MaxStaticBitrate: 100000000,
|
||||
MaxStreamingBitrate: 120000000,
|
||||
MusicStreamingTranscodingBitrate: 384000,
|
||||
ResponseProfiles: [
|
||||
{
|
||||
Container: 'm4v',
|
||||
MimeType: 'video/mp4',
|
||||
Type: 'Video'
|
||||
}
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
{
|
||||
Format: 'vtt',
|
||||
Method: 'External'
|
||||
},
|
||||
{
|
||||
Format: 'ass',
|
||||
Method: 'External'
|
||||
},
|
||||
{
|
||||
Format: 'ssa',
|
||||
Method: 'External'
|
||||
}
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
BreakOnNonKeyFrames: true,
|
||||
Container: 'ts',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'hls',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
Container: 'aac',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'mp3',
|
||||
Container: 'mp3',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'opus',
|
||||
Container: 'opus',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'wav',
|
||||
Container: 'wav',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'opus',
|
||||
Container: 'opus',
|
||||
Context: 'Static',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'mp3',
|
||||
Container: 'mp3',
|
||||
Context: 'Static',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac',
|
||||
Container: 'aac',
|
||||
Context: 'Static',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'wav',
|
||||
Container: 'wav',
|
||||
Context: 'Static',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'http',
|
||||
Type: 'Audio'
|
||||
},
|
||||
{
|
||||
AudioCodec: 'aac,mp3',
|
||||
BreakOnNonKeyFrames: true,
|
||||
Container: 'ts',
|
||||
Context: 'Streaming',
|
||||
MaxAudioChannels: '2',
|
||||
Protocol: 'hls',
|
||||
Type: 'Video',
|
||||
VideoCodec: 'h264'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}).then((r) => r.data?.MediaSources?.[0]?.TranscodingUrl)
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import { PUBLIC_RADARR_API_KEY } from '$env/static/public';
|
||||
import { request } from '$lib/utils';
|
||||
import type { paths } from '$lib/radarr/radarr-api';
|
||||
import type { components } from '$lib/radarr/radarr-api';
|
||||
import type { paths } from '$lib/radarr/radarr-types';
|
||||
import type { components } from '$lib/radarr/radarr-types';
|
||||
import type { TmdbMovie, TmdbMovieFull } from '$lib/tmdb-api';
|
||||
import { fetchTmdbMovie, TmdbApi } from '$lib/tmdb-api';
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ export async function fetchFullMovieDetails(tmdbId: string): Promise<TmdbMovieFu
|
||||
};
|
||||
}
|
||||
|
||||
export const fetchTmdbMovie = async (tmdbId: string) =>
|
||||
export const fetchTmdbMovie = async (tmdbId: string): Promise<TmdbMovie> =>
|
||||
await TmdbApi.get<TmdbMovie>('/movie/' + tmdbId).then((r) => r.data);
|
||||
|
||||
export const fetchTmdbMovieVideos = async (tmdbId: string) =>
|
||||
export const fetchTmdbMovieVideos = async (tmdbId: string): Promise<Video[]> =>
|
||||
await TmdbApi.get<VideosResponse>('/movie/' + tmdbId + '/videos').then((res) => res.data.results);
|
||||
|
||||
export const fetchTmdbMovieImages = async (tmdbId: string) =>
|
||||
export const fetchTmdbMovieImages = async (tmdbId: string): Promise<ImagesResponse> =>
|
||||
await TmdbApi.get<ImagesResponse>('/movie/' + tmdbId + '/images').then((res) => res.data);
|
||||
|
||||
export interface TmdbMovieFull extends TmdbMovie {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import type { components as sonarrComponents } from '$lib/sonarr/sonarr-api';
|
||||
import type { components as sonarrComponents } from '$lib/sonarr/sonarr-types';
|
||||
|
||||
export type SeriesResource = sonarrComponents['schemas']['SeriesResource'];
|
||||
|
||||
@@ -35,7 +35,7 @@ export function request<T, A>(fetcher: (arg: A) => Promise<T>, args: A | undefin
|
||||
|
||||
fetcher(arg)
|
||||
.then((d) => {
|
||||
console.log('got data', d);
|
||||
console.log('request data', d);
|
||||
data.set(d);
|
||||
})
|
||||
.catch((e) => error.set(e))
|
||||
|
||||
76
src/routes/components/Card/Card.svelte
Normal file
76
src/routes/components/Card/Card.svelte
Normal 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}
|
||||
8
src/routes/components/Card/CardPlaceholder.svelte
Normal file
8
src/routes/components/Card/CardPlaceholder.svelte
Normal 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;'}
|
||||
/>
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
48
src/routes/components/VideoPlayer/VideoPlayer.svelte
Normal file
48
src/routes/components/VideoPlayer/VideoPlayer.svelte
Normal 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>
|
||||
56
src/routes/library/+page.server.ts
Normal file
56
src/routes/library/+page.server.ts
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
0
src/routes/library/+server.ts
Normal file
0
src/routes/library/+server.ts
Normal file
Reference in New Issue
Block a user