Work on components, Video player and more
This commit is contained in:
2
.idea/.gitignore
generated
vendored
2
.idea/.gitignore
generated
vendored
@@ -6,3 +6,5 @@
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# GitHub Copilot persisted chat sessions
|
||||
/copilot/chatSessions
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
import { Bookmark, CardStack, Gear, Laptop, MagnifyingGlass } from 'radix-icons-svelte';
|
||||
import classNames from 'classnames';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import SeriesPage from './lib/pages/SeriesPage.svelte';
|
||||
import BrowseSeriesPage from './lib/pages/BrowseSeriesPage.svelte';
|
||||
import MoviesPage from './lib/pages/MoviesPage.svelte';
|
||||
import LibraryPage from './lib/pages/LibraryPage.svelte';
|
||||
import ManagePage from './lib/pages/ManagePage.svelte';
|
||||
import SearchPage from './lib/pages/SearchPage.svelte';
|
||||
import SeriesPage from './lib/pages/SeriesPage.svelte';
|
||||
|
||||
Selectable.focusedObject.subscribe((s) => console.log('FocusedObject', s));
|
||||
let mainContent: Selectable;
|
||||
|
||||
onMount(() => {
|
||||
@@ -68,8 +68,8 @@
|
||||
</Container>
|
||||
|
||||
<Container bind:container={mainContent} class="flex-1 flex flex-col min-w-0">
|
||||
<Route>
|
||||
<SeriesPage />
|
||||
<Route path="/">
|
||||
<BrowseSeriesPage />
|
||||
</Route>
|
||||
<Route path="movies">
|
||||
<MoviesPage />
|
||||
@@ -83,6 +83,10 @@
|
||||
<Route path="search">
|
||||
<SearchPage />
|
||||
</Route>
|
||||
<Route path="series/:id" component={SeriesPage} />
|
||||
<Route path="*">
|
||||
<div>404</div>
|
||||
</Route>
|
||||
</Container>
|
||||
</Router>
|
||||
</Container>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Selectable } from './lib/selectable';
|
||||
import type { Readable } from 'svelte/store';
|
||||
|
||||
export let name: string = '';
|
||||
export let horizontal = false;
|
||||
|
||||
@@ -19,7 +19,11 @@ a {
|
||||
}
|
||||
|
||||
.selectable {
|
||||
@apply focus-visible:outline outline-2 outline-[#f0cd6dc2] outline-offset-2;
|
||||
@apply outline-none outline-0 focus-visible:border-2 border-[#f0cd6dc2];
|
||||
}
|
||||
|
||||
.selectable:focus-visible, .selectable:focus {
|
||||
outline-style: solid;
|
||||
}
|
||||
|
||||
.peer-selectable {
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { contextMenu } from '../ContextMenu/ContextMenu';
|
||||
import ContextMenu from '../ContextMenu/ContextMenu.svelte';
|
||||
import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<Container horizontal>
|
||||
<div
|
||||
class={classNames(
|
||||
'flex overflow-x-scroll items-center overflow-y-visible gap-4 relative scrollbar-hide p-1',
|
||||
'flex overflow-x-scroll items-center overflow-y-visible relative scrollbar-hide p-1',
|
||||
scrollClass
|
||||
)}
|
||||
bind:this={carousel}
|
||||
|
||||
113
src/lib/components/ContextMenu/ContextMenu.svelte
Normal file
113
src/lib/components/ContextMenu/ContextMenu.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { contextMenu } from './ContextMenu';
|
||||
|
||||
export let heading = '';
|
||||
export let disabled = false;
|
||||
export let position: 'absolute' | 'fixed' = 'fixed';
|
||||
let anchored = position === 'absolute';
|
||||
|
||||
export let id = Symbol();
|
||||
|
||||
let menu: HTMLDivElement;
|
||||
let windowWidth: number;
|
||||
let windowHeight: number;
|
||||
|
||||
let fixedPosition = { x: 0, y: 0 };
|
||||
|
||||
function close() {
|
||||
contextMenu.hide();
|
||||
}
|
||||
|
||||
export function handleOpen(event: MouseEvent) {
|
||||
if (disabled || (anchored && $contextMenu === id)) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
fixedPosition = { x: event.clientX, y: event.clientY };
|
||||
contextMenu.show(id);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!menu?.contains(event.target as Node) && $contextMenu === id) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleShortcuts(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && $contextMenu === id) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={handleShortcuts}
|
||||
on:click={handleClickOutside}
|
||||
bind:innerWidth={windowWidth}
|
||||
bind:innerHeight={windowHeight}
|
||||
/>
|
||||
<svelte:head>
|
||||
{#if $contextMenu === id}
|
||||
<style>
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
<!-- <svelte:body bind:this={body} /> -->
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
on:contextmenu|preventDefault={handleOpen}
|
||||
on:click={(e) => {
|
||||
if (anchored) {
|
||||
e.stopPropagation();
|
||||
handleOpen(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{#if $contextMenu === id}
|
||||
{#key fixedPosition}
|
||||
<div
|
||||
class={`${position} z-50 my-2 px-1 py-1 bg-zinc-800 bg-opacity-50 rounded-lg backdrop-blur-xl flex flex-col w-max`}
|
||||
style={position === 'fixed'
|
||||
? `left: ${
|
||||
fixedPosition.x - (fixedPosition.x > windowWidth / 2 ? menu?.clientWidth : 0)
|
||||
}px; top: ${
|
||||
fixedPosition.y - (fixedPosition.y > windowHeight / 2 ? menu?.clientHeight : 0)
|
||||
}px;`
|
||||
: menu?.getBoundingClientRect()?.left > windowWidth / 2
|
||||
? `right: 0;${fixedPosition.y > windowHeight / 2 ? 'bottom: 100%;' : ''}`
|
||||
: `left: 0;${fixedPosition.y > windowHeight / 2 ? 'bottom: 100%;' : ''}`}
|
||||
bind:this={menu}
|
||||
in:fly|global={{ y: 5, duration: 100, delay: anchored ? 0 : 100 }}
|
||||
out:fly|global={{ y: 5, duration: 100 }}
|
||||
>
|
||||
<slot name="title">
|
||||
{#if heading}
|
||||
<h2
|
||||
class="text-xs text-zinc-200 opacity-60 tracking-wide font-semibold px-3 py-1 line-clamp-1 text-left"
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
{/if}
|
||||
</slot>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="flex flex-col gap-0.5" on:click={() => close()}>
|
||||
<slot name="menu" />
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
17
src/lib/components/ContextMenu/ContextMenu.ts
Normal file
17
src/lib/components/ContextMenu/ContextMenu.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
function createContextMenu() {
|
||||
const visibleItem = writable<symbol | null>(null);
|
||||
|
||||
return {
|
||||
subscribe: visibleItem.subscribe,
|
||||
show: (item: symbol) => {
|
||||
visibleItem.set(item);
|
||||
},
|
||||
hide: () => {
|
||||
visibleItem.set(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const contextMenu = createContextMenu();
|
||||
12
src/lib/components/ContextMenu/ContextMenuButton.svelte
Normal file
12
src/lib/components/ContextMenu/ContextMenuButton.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ContextMenu from './ContextMenu.svelte';
|
||||
|
||||
export let heading = '';
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<ContextMenu position="absolute" {heading}>
|
||||
<slot name="menu" slot="menu" />
|
||||
<slot />
|
||||
</ContextMenu>
|
||||
</div>
|
||||
1
src/lib/components/ContextMenu/ContextMenuDivider.svelte
Normal file
1
src/lib/components/ContextMenu/ContextMenuDivider.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<div class="bg-zinc-200 bg-opacity-20 h-[1.5px] mx-3 my-1 rounded-full" />
|
||||
17
src/lib/components/ContextMenu/ContextMenuItem.svelte
Normal file
17
src/lib/components/ContextMenu/ContextMenuItem.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
|
||||
export let disabled = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click
|
||||
class={classNames(
|
||||
'text-sm font-medium tracking-wide px-3 py-1 hover:bg-zinc-200 hover:bg-opacity-10 rounded-md text-left cursor-default',
|
||||
{
|
||||
'opacity-75 pointer-events-none': disabled
|
||||
}
|
||||
)}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
setJellyfinItemUnwatched,
|
||||
setJellyfinItemWatched,
|
||||
type JellyfinItem
|
||||
} from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import type { RadarrMovie } from '../../apis/radarr/radarrApi';
|
||||
import type { SonarrSeries } from '../../apis/sonarr/sonarrApi';
|
||||
import { jellyfinItemsStore } from '../../stores/data.store';
|
||||
import { settings } from '../../stores/settings.store';
|
||||
import type { TitleType } from '../../types';
|
||||
import ContextMenuDivider from './ContextMenuDivider.svelte';
|
||||
import ContextMenuItem from './ContextMenuItem.svelte';
|
||||
|
||||
export let jellyfinItem: JellyfinItem | undefined = undefined;
|
||||
export let sonarrSeries: SonarrSeries | undefined = undefined;
|
||||
export let radarrMovie: RadarrMovie | undefined = undefined;
|
||||
|
||||
export let type: TitleType;
|
||||
export let tmdbId: number;
|
||||
|
||||
let watched = false;
|
||||
$: watched = jellyfinItem?.UserData?.Played !== undefined ? jellyfinItem.UserData?.Played : false;
|
||||
|
||||
function handleSetWatched() {
|
||||
if (jellyfinItem?.Id) {
|
||||
watched = true;
|
||||
setJellyfinItemWatched(jellyfinItem.Id).finally(() => jellyfinItemsStore.refreshIn(3000));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSetUnwatched() {
|
||||
if (jellyfinItem?.Id) {
|
||||
watched = false;
|
||||
setJellyfinItemUnwatched(jellyfinItem.Id).finally(() => jellyfinItemsStore.refreshIn(3000));
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenInJellyfin() {
|
||||
window.open($settings.jellyfin.baseUrl + '/web/index.html#!/details?id=' + jellyfinItem?.Id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ContextMenuItem on:click={handleSetWatched} disabled={!jellyfinItem?.Id || watched}>
|
||||
Mark as watched
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem on:click={handleSetUnwatched} disabled={!jellyfinItem?.Id || !watched}>
|
||||
Mark as unwatched
|
||||
</ContextMenuItem>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuItem disabled={!jellyfinItem?.Id} on:click={handleOpenInJellyfin}>
|
||||
Open in Jellyfin
|
||||
</ContextMenuItem>
|
||||
{#if type === 'movie'}
|
||||
<ContextMenuItem
|
||||
disabled={!radarrMovie}
|
||||
on:click={() => window.open($settings.radarr.baseUrl + '/movie/' + radarrMovie?.tmdbId)}
|
||||
>
|
||||
Open in Radarr
|
||||
</ContextMenuItem>
|
||||
{:else}
|
||||
<ContextMenuItem
|
||||
disabled={!sonarrSeries}
|
||||
on:click={() => window.open($settings.sonarr.baseUrl + '/series/' + sonarrSeries?.titleSlug)}
|
||||
>
|
||||
Open in Sonarr
|
||||
</ContextMenuItem>
|
||||
{/if}
|
||||
<ContextMenuItem on:click={() => window.open(`https://www.themoviedb.org/${type}/${tmdbId}`)}>
|
||||
Open in TMDB
|
||||
</ContextMenuItem>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import classNames from 'classnames';
|
||||
import { Check } from 'radix-icons-svelte';
|
||||
import ContextMenuItem from './ContextMenuItem.svelte';
|
||||
|
||||
export let selected = false;
|
||||
</script>
|
||||
|
||||
<ContextMenuItem on:click>
|
||||
<div class="flex items-center gap-2 justify-between cursor-pointer">
|
||||
<Check
|
||||
size={20}
|
||||
class={classNames({
|
||||
'opacity-0': !selected,
|
||||
'opacity-100': selected
|
||||
})}
|
||||
/>
|
||||
<div class="flex items-center text-left w-32">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
@@ -6,6 +6,8 @@
|
||||
import LazyImg from './LazyImg.svelte';
|
||||
import { Star } from 'radix-icons-svelte';
|
||||
import type { TitleType } from '../types';
|
||||
import Container from '../../Container.svelte';
|
||||
import { useNavigate } from 'svelte-navigator';
|
||||
|
||||
export let tmdbId: number | undefined = undefined;
|
||||
export let tvdbId: number | undefined = undefined;
|
||||
@@ -22,9 +24,11 @@
|
||||
export let shadow = false;
|
||||
export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md';
|
||||
export let orientation: 'portrait' | 'landscape' = 'landscape';
|
||||
|
||||
const navigate = useNavigate();
|
||||
</script>
|
||||
|
||||
<button
|
||||
<Container
|
||||
on:click={() => {
|
||||
if (openInModal) {
|
||||
if (tmdbId) {
|
||||
@@ -32,12 +36,12 @@
|
||||
} else if (tvdbId) {
|
||||
//openTitleModal({ type, id: tvdbId, provider: 'tvdb' });
|
||||
}
|
||||
} else {
|
||||
window.location.href = tmdbId || tvdbId ? `/${type}/${tmdbId || tvdbId}` : '#';
|
||||
} else if (tmdbId || tvdbId) {
|
||||
navigate(`/${type}/${tmdbId || tvdbId}`);
|
||||
}
|
||||
}}
|
||||
class={classNames(
|
||||
'relative flex rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left',
|
||||
'relative flex rounded-xl selectable group hover:text-inherit flex-shrink-0 overflow-hidden text-left cursor-pointer',
|
||||
{
|
||||
'aspect-video': orientation === 'landscape',
|
||||
'aspect-[2/3]': orientation === 'portrait',
|
||||
@@ -118,4 +122,4 @@
|
||||
<ProgressBar {progress} />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</Container>
|
||||
|
||||
47
src/lib/components/VideoPlayer/Slider.svelte
Normal file
47
src/lib/components/VideoPlayer/Slider.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
export let min = 0;
|
||||
export let max = 100;
|
||||
export let step = 0.01;
|
||||
export let primaryValue = 0;
|
||||
export let secondaryValue = 0;
|
||||
|
||||
let progressBarOffset = 0;
|
||||
</script>
|
||||
|
||||
<div class="h-1 relative group">
|
||||
<div class="h-full relative px-[0.5rem]">
|
||||
<div class="h-full bg-zinc-200 bg-opacity-20 rounded-full overflow-hidden relative">
|
||||
<!-- Secondary progress -->
|
||||
<div
|
||||
class="h-full bg-zinc-200 bg-opacity-20 absolute top-0"
|
||||
style="width: {(secondaryValue / max) * 100}%;"
|
||||
/>
|
||||
|
||||
<!-- Primary progress -->
|
||||
<div
|
||||
class="h-full bg-amber-300 absolute top-0"
|
||||
style="width: {(primaryValue / max) * 100}%;"
|
||||
bind:offsetWidth={progressBarOffset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute w-3 h-3 bg-amber-200 rounded-full transform mx-2 -translate-x-1/2 -translate-y-1/2 top-1/2 cursor-pointer
|
||||
drop-shadow-md opacity-0 group-hover:opacity-100 transition-opacity duration-100"
|
||||
style="left: {progressBarOffset}px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
class="w-full absolute -top-0.5 cursor-pointer h-2 opacity-0"
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
bind:value={primaryValue}
|
||||
on:mouseup
|
||||
on:mousedown
|
||||
on:touchstart
|
||||
on:touchend
|
||||
/>
|
||||
</div>
|
||||
500
src/lib/components/VideoPlayer/VideoPlayer.svelte
Normal file
500
src/lib/components/VideoPlayer/VideoPlayer.svelte
Normal file
@@ -0,0 +1,500 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
delteActiveEncoding as deleteActiveEncoding,
|
||||
getJellyfinItem,
|
||||
getJellyfinPlaybackInfo,
|
||||
reportJellyfinPlaybackProgress,
|
||||
reportJellyfinPlaybackStarted,
|
||||
reportJellyfinPlaybackStopped
|
||||
} from '../../apis/jellyfin/jellyfinApi';
|
||||
import getDeviceProfile from '../../apis/jellyfin/playback-profiles';
|
||||
import { getQualities } from '../../apis/jellyfin/qualities';
|
||||
import { settings } from '../../stores/settings.store';
|
||||
import classNames from 'classnames';
|
||||
import Hls from 'hls.js';
|
||||
import {
|
||||
Cross2,
|
||||
EnterFullScreen,
|
||||
ExitFullScreen,
|
||||
Gear,
|
||||
Pause,
|
||||
Play,
|
||||
SpeakerLoud,
|
||||
SpeakerModerate,
|
||||
SpeakerOff,
|
||||
SpeakerQuiet
|
||||
} from 'radix-icons-svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { contextMenu } from '../ContextMenu/ContextMenu';
|
||||
import SelectableContextMenuItem from '../ContextMenu/SelectableContextMenuItem.svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import Slider from './Slider.svelte';
|
||||
import { linear } from 'svelte/easing';
|
||||
import ContextMenuButton from '../ContextMenu/ContextMenuButton.svelte';
|
||||
import { isTizen } from '../../utils/browser-detection';
|
||||
|
||||
export let jellyfinId: string;
|
||||
|
||||
let qualityContextMenuId = Symbol();
|
||||
|
||||
let video: HTMLVideoElement;
|
||||
let videoWrapper: HTMLDivElement;
|
||||
let mouseMovementTimeout: NodeJS.Timeout;
|
||||
let stopCallback: () => void;
|
||||
let deleteEncoding: () => void;
|
||||
let reportProgress: () => void;
|
||||
let progressInterval: NodeJS.Timeout;
|
||||
|
||||
// These functions are different in every browser
|
||||
let reqFullscreenFunc: ((elem: HTMLElement) => void) | undefined = undefined;
|
||||
let exitFullscreen: (() => void) | undefined = undefined;
|
||||
let fullscreenChangeEvent: string | undefined = undefined;
|
||||
let getFullscreenElement: (() => HTMLElement) | undefined = undefined;
|
||||
|
||||
// Find the correct functions
|
||||
let elem = document.createElement('div');
|
||||
// @ts-ignore
|
||||
if (elem.requestFullscreen) {
|
||||
reqFullscreenFunc = (elem) => {
|
||||
elem.requestFullscreen();
|
||||
};
|
||||
fullscreenChangeEvent = 'fullscreenchange';
|
||||
getFullscreenElement = () => <HTMLElement>document.fullscreenElement;
|
||||
if (document.exitFullscreen) exitFullscreen = () => document.exitFullscreen();
|
||||
// @ts-ignore
|
||||
} else if (elem.webkitRequestFullscreen) {
|
||||
reqFullscreenFunc = (elem) => {
|
||||
// @ts-ignore
|
||||
elem.webkitRequestFullscreen();
|
||||
};
|
||||
fullscreenChangeEvent = 'webkitfullscreenchange';
|
||||
// @ts-ignore
|
||||
getFullscreenElement = () => <HTMLElement>document.webkitFullscreenElement;
|
||||
// @ts-ignore
|
||||
if (document.webkitExitFullscreen) exitFullscreen = () => document.webkitExitFullscreen();
|
||||
// @ts-ignore
|
||||
} else if (elem.msRequestFullscreen) {
|
||||
reqFullscreenFunc = (elem) => {
|
||||
// @ts-ignore
|
||||
elem.msRequestFullscreen();
|
||||
};
|
||||
fullscreenChangeEvent = 'MSFullscreenChange';
|
||||
// @ts-ignore
|
||||
getFullscreenElement = () => <HTMLElement>document.msFullscreenElement;
|
||||
// @ts-ignore
|
||||
if (document.msExitFullscreen) exitFullscreen = () => document.msExitFullscreen();
|
||||
// @ts-ignore
|
||||
} else if (elem.mozRequestFullScreen) {
|
||||
reqFullscreenFunc = (elem) => {
|
||||
// @ts-ignore
|
||||
elem.mozRequestFullScreen();
|
||||
};
|
||||
fullscreenChangeEvent = 'mozfullscreenchange';
|
||||
// @ts-ignore
|
||||
getFullscreenElement = () => <HTMLElement>document.mozFullScreenElement;
|
||||
// @ts-ignore
|
||||
if (document.mozCancelFullScreen) exitFullscreen = () => document.mozCancelFullScreen();
|
||||
}
|
||||
|
||||
let paused: boolean = false;
|
||||
let duration: number = 0;
|
||||
let displayedTime: number = 0;
|
||||
let bufferedTime: number = 0;
|
||||
|
||||
let videoLoaded: boolean = false;
|
||||
let seeking: boolean = false;
|
||||
let playerStateBeforeSeek: boolean;
|
||||
|
||||
let fullscreen: boolean = false;
|
||||
let volume: number = 1;
|
||||
let mute: boolean = false;
|
||||
|
||||
let resolution: number = 1080;
|
||||
let currentBitrate: number = 0;
|
||||
|
||||
let shouldCloseUi = false;
|
||||
let uiVisible = true;
|
||||
$: uiVisible = !shouldCloseUi || seeking || paused || $contextMenu === qualityContextMenuId;
|
||||
|
||||
const fetchPlaybackInfo = (
|
||||
itemId: string,
|
||||
maxBitrate: number | undefined = undefined,
|
||||
starting: boolean = true
|
||||
) =>
|
||||
getJellyfinItem(itemId).then((item) =>
|
||||
getJellyfinPlaybackInfo(
|
||||
itemId,
|
||||
getDeviceProfile(),
|
||||
item?.UserData?.PlaybackPositionTicks || Math.floor(displayedTime * 10_000_000),
|
||||
maxBitrate || getQualities(item?.Height || 1080)[0].maxBitrate
|
||||
).then(async (playbackInfo) => {
|
||||
if (!playbackInfo) return;
|
||||
const { playbackUri, playSessionId: sessionId, mediaSourceId, directPlay } = playbackInfo;
|
||||
|
||||
if (!playbackUri || !sessionId) {
|
||||
console.log('No playback URL or session ID', playbackUri, sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
video.poster = item?.BackdropImageTags?.length
|
||||
? `${$settings.jellyfin.baseUrl}/Items/${item?.Id}/Images/Backdrop?quality=100&tag=${item?.BackdropImageTags?.[0]}`
|
||||
: '';
|
||||
|
||||
videoLoaded = false;
|
||||
if (!directPlay) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
|
||||
hls.loadSource($settings.jellyfin.baseUrl + playbackUri);
|
||||
hls.attachMedia(video);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl') || isTizen()) {
|
||||
/*
|
||||
* HLS.js does NOT work on iOS on iPhone because Safari on iPhone does not support MSE.
|
||||
* This is not a problem, since HLS is natively supported on iOS. But any other browser
|
||||
* that does not support MSE will not be able to play the video.
|
||||
*/
|
||||
video.src = $settings.jellyfin.baseUrl + playbackUri;
|
||||
} else {
|
||||
throw new Error('HLS is not supported');
|
||||
}
|
||||
} else {
|
||||
video.src = $settings.jellyfin.baseUrl + playbackUri;
|
||||
}
|
||||
|
||||
resolution = item?.Height || 1080;
|
||||
currentBitrate = maxBitrate || getQualities(resolution)[0].maxBitrate;
|
||||
|
||||
if (item?.UserData?.PlaybackPositionTicks) {
|
||||
displayedTime = item?.UserData?.PlaybackPositionTicks / 10_000_000;
|
||||
}
|
||||
|
||||
// We should not requestFullscreen automatically, as it's not what
|
||||
// the user expects. Moreover, most browsers will deny the request
|
||||
// if the video takes a while to load.
|
||||
// video.play().then(() => videoWrapper.requestFullscreen());
|
||||
|
||||
// A start report should only be sent when the video starts playing,
|
||||
// not every time a playback info request is made
|
||||
if (mediaSourceId && starting)
|
||||
await reportJellyfinPlaybackStarted(itemId, sessionId, mediaSourceId);
|
||||
|
||||
reportProgress = async () => {
|
||||
await reportJellyfinPlaybackProgress(
|
||||
itemId,
|
||||
sessionId,
|
||||
video?.paused == true,
|
||||
video?.currentTime * 10_000_000
|
||||
);
|
||||
};
|
||||
|
||||
if (progressInterval) clearInterval(progressInterval);
|
||||
progressInterval = setInterval(() => {
|
||||
video && video.readyState === 4 && video?.currentTime > 0 && sessionId && itemId;
|
||||
reportProgress();
|
||||
}, 5000);
|
||||
|
||||
deleteEncoding = () => {
|
||||
deleteActiveEncoding(sessionId);
|
||||
};
|
||||
|
||||
stopCallback = () => {
|
||||
reportJellyfinPlaybackStopped(itemId, sessionId, video?.currentTime * 10_000_000);
|
||||
deleteEncoding();
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function onSeekStart() {
|
||||
if (seeking) return;
|
||||
|
||||
playerStateBeforeSeek = paused;
|
||||
seeking = true;
|
||||
paused = true;
|
||||
}
|
||||
|
||||
function onSeekEnd() {
|
||||
if (!seeking) return;
|
||||
|
||||
paused = playerStateBeforeSeek;
|
||||
seeking = false;
|
||||
|
||||
video.currentTime = displayedTime;
|
||||
}
|
||||
|
||||
function handleBuffer() {
|
||||
let timeRanges = video.buffered;
|
||||
// Find the first one whose end time is after the current time
|
||||
// (the time ranges given by the browser are normalized, which means
|
||||
// that they are sorted and non-overlapping)
|
||||
for (let i = 0; i < timeRanges.length; i++) {
|
||||
if (timeRanges.end(i) > video.currentTime) {
|
||||
bufferedTime = timeRanges.end(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// function handleClose() {
|
||||
// playerState.close();
|
||||
// video?.pause();
|
||||
// clearInterval(progressInterval);
|
||||
// stopCallback?.();
|
||||
// modalStack.close(modalId);
|
||||
// }
|
||||
|
||||
function handleUserInteraction(touch: boolean = false) {
|
||||
if (touch) shouldCloseUi = !shouldCloseUi;
|
||||
else shouldCloseUi = false;
|
||||
|
||||
if (!shouldCloseUi) {
|
||||
if (mouseMovementTimeout) clearTimeout(mouseMovementTimeout);
|
||||
mouseMovementTimeout = setTimeout(() => {
|
||||
shouldCloseUi = true;
|
||||
}, 3000);
|
||||
} else {
|
||||
if (mouseMovementTimeout) clearTimeout(mouseMovementTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectQuality(bitrate: number) {
|
||||
if (!jellyfinId || !video || seeking) return;
|
||||
if (bitrate === currentBitrate) return;
|
||||
|
||||
currentBitrate = bitrate;
|
||||
video.pause();
|
||||
let timeBeforeLoad = video.currentTime;
|
||||
let stateBeforeLoad = paused;
|
||||
await reportProgress?.();
|
||||
await deleteEncoding?.();
|
||||
await fetchPlaybackInfo?.(jellyfinId, bitrate, false);
|
||||
displayedTime = timeBeforeLoad;
|
||||
paused = stateBeforeLoad;
|
||||
}
|
||||
|
||||
function secondsToTime(seconds: number, forceHours = false) {
|
||||
if (isNaN(seconds)) return '00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds - hours * 3600) / 60);
|
||||
const secondsLeft = Math.floor(seconds - hours * 3600 - minutes * 60);
|
||||
|
||||
let str = '';
|
||||
if (hours > 0 || forceHours) str += `${hours}:`;
|
||||
|
||||
if (minutes >= 10) str += `${minutes}:`;
|
||||
else str += `0${minutes}:`;
|
||||
|
||||
if (secondsLeft >= 10) str += `${secondsLeft}`;
|
||||
else str += `0${secondsLeft}`;
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (video && jellyfinId) {
|
||||
if (video.src === '') fetchPlaybackInfo(jellyfinId);
|
||||
paused = false;
|
||||
console.log('Paused', paused);
|
||||
video.play();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Workaround because the paused state does not sync
|
||||
// with the video element until a change is made
|
||||
paused = false;
|
||||
|
||||
// if (video && $playerState.jellyfinId) {
|
||||
// if (video.src === '') fetchPlaybackInfo($playerState.jellyfinId);
|
||||
// }
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(progressInterval);
|
||||
if (fullscreen) exitFullscreen?.();
|
||||
});
|
||||
|
||||
$: {
|
||||
if (fullscreen && !getFullscreenElement?.()) {
|
||||
if (reqFullscreenFunc) reqFullscreenFunc(videoWrapper);
|
||||
} else if (getFullscreenElement?.()) {
|
||||
if (exitFullscreen) exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
// We add a listener to the fullscreen change event to update the fullscreen variable
|
||||
// since it can be changed by the user by other means than the button
|
||||
if (fullscreenChangeEvent) {
|
||||
document.addEventListener(fullscreenChangeEvent, () => {
|
||||
fullscreen = !!getFullscreenElement?.();
|
||||
});
|
||||
}
|
||||
|
||||
function handleRequestFullscreen() {
|
||||
if (reqFullscreenFunc) {
|
||||
fullscreen = !fullscreen;
|
||||
// @ts-ignore
|
||||
} else if (video?.webkitEnterFullScreen) {
|
||||
// Edge case to allow fullscreen on iPhone
|
||||
// @ts-ignore
|
||||
video.webkitEnterFullScreen();
|
||||
}
|
||||
}
|
||||
|
||||
function handleShortcuts(event: KeyboardEvent) {
|
||||
if (event.key === 'f') {
|
||||
handleRequestFullscreen();
|
||||
} else if (event.key === ' ') {
|
||||
paused = !paused;
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
video.currentTime -= 10;
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
video.currentTime += 10;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
volume = Math.min(volume + 0.1, 1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
volume = Math.max(volume - 0.1, 0);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleShortcuts} />
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!--<div-->
|
||||
<!-- class={classNames(-->
|
||||
<!-- 'bg-black w-screen h-[100dvh] sm:h-screen relative flex items-center justify-center',-->
|
||||
<!-- {-->
|
||||
<!-- 'cursor-none': !uiVisible-->
|
||||
<!-- }-->
|
||||
<!-- )}-->
|
||||
<!-- in:fade|global={{ duration: 300, easing: linear }}-->
|
||||
<!-- out:fade|global={{ duration: 200, easing: linear }}-->
|
||||
<!-->-->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="w-screen h-screen flex items-center justify-center"
|
||||
bind:this={videoWrapper}
|
||||
on:mousemove={() => handleUserInteraction(false)}
|
||||
on:touchend|preventDefault={() => handleUserInteraction(true)}
|
||||
in:fade|global={{ duration: 500, delay: 1200, easing: linear }}
|
||||
>
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
bind:this={video}
|
||||
bind:paused
|
||||
bind:duration
|
||||
on:timeupdate={() =>
|
||||
(displayedTime = !seeking && videoLoaded ? video.currentTime : displayedTime)}
|
||||
on:progress={() => handleBuffer()}
|
||||
on:play={() => {
|
||||
if (seeking) video?.pause();
|
||||
}}
|
||||
on:loadeddata={() => {
|
||||
video.currentTime = displayedTime;
|
||||
videoLoaded = true;
|
||||
}}
|
||||
bind:volume
|
||||
bind:muted={mute}
|
||||
class="sm:w-full sm:h-full"
|
||||
playsinline={true}
|
||||
on:dblclick|preventDefault={() => (fullscreen = !fullscreen)}
|
||||
on:click={() => (paused = !paused)}
|
||||
autoplay
|
||||
/>
|
||||
|
||||
{#if uiVisible}
|
||||
<!-- Video controls -->
|
||||
<div
|
||||
class="absolute bottom-0 w-screen bg-gradient-to-t from-black/[.8] via-60% via-black-opacity-80 to-transparent"
|
||||
on:touchend|stopPropagation
|
||||
transition:fade={{ duration: 100 }}
|
||||
>
|
||||
<div class="flex flex-col items-center p-4 gap-2 w-full">
|
||||
<div class="flex items-center text-sm w-full">
|
||||
<span class="whitespace-nowrap tabular-nums"
|
||||
>{secondsToTime(displayedTime, duration > 3600)}</span
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<Slider
|
||||
bind:primaryValue={displayedTime}
|
||||
secondaryValue={bufferedTime}
|
||||
max={duration}
|
||||
on:mousedown={onSeekStart}
|
||||
on:mouseup={onSeekEnd}
|
||||
on:touchstart={onSeekStart}
|
||||
on:touchend={onSeekEnd}
|
||||
/>
|
||||
</div>
|
||||
<span class="whitespace-nowrap tabular-nums">{secondsToTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-2 w-full">
|
||||
<IconButton on:click={() => (paused = !paused)}>
|
||||
{#if (!seeking && paused) || (seeking && playerStateBeforeSeek)}
|
||||
<Play size={20} />
|
||||
{:else}
|
||||
<Pause size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<ContextMenuButton heading="Quality">
|
||||
<svelte:fragment slot="menu">
|
||||
{#each getQualities(resolution) as quality}
|
||||
<SelectableContextMenuItem
|
||||
selected={quality.maxBitrate === currentBitrate}
|
||||
on:click={() => handleSelectQuality(quality.maxBitrate)}
|
||||
>
|
||||
{quality.name}
|
||||
</SelectableContextMenuItem>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
|
||||
<IconButton>
|
||||
<Gear size={20} />
|
||||
</IconButton>
|
||||
</ContextMenuButton>
|
||||
<IconButton
|
||||
on:click={() => {
|
||||
mute = !mute;
|
||||
}}
|
||||
>
|
||||
{#if volume == 0 || mute}
|
||||
<SpeakerOff size={20} />
|
||||
{:else if volume < 0.25}
|
||||
<SpeakerQuiet size={20} />
|
||||
{:else if volume < 0.9}
|
||||
<SpeakerModerate size={20} />
|
||||
{:else}
|
||||
<SpeakerLoud size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
|
||||
<div class="w-32">
|
||||
<Slider bind:primaryValue={volume} secondaryValue={0} max={1} />
|
||||
</div>
|
||||
|
||||
<IconButton on:click={handleRequestFullscreen}>
|
||||
{#if fullscreen}
|
||||
<ExitFullScreen size={20} />
|
||||
{:else if !fullscreen && exitFullscreen}
|
||||
<EnterFullScreen size={20} />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--{#if uiVisible}-->
|
||||
<!-- <div class="absolute top-4 right-8 z-50" transition:fade={{ duration: 100 }}>-->
|
||||
<!-- <IconButton on:click={handleClose}>-->
|
||||
<!-- <Cross2 size={25} />-->
|
||||
<!-- </IconButton>-->
|
||||
<!-- </div>-->
|
||||
<!--{/if}-->
|
||||
<!--</div>-->
|
||||
25
src/lib/components/VideoPlayer/VideoPlayer.ts
Normal file
25
src/lib/components/VideoPlayer/VideoPlayer.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { modalStack } from '../../stores/modal.store';
|
||||
import VideoPlayer from './VideoPlayer.svelte';
|
||||
import { jellyfinItemsStore } from '../../stores/data.store';
|
||||
|
||||
const initialValue = { visible: false, jellyfinId: '' };
|
||||
export type PlayerStateValue = typeof initialValue;
|
||||
|
||||
function createPlayerState() {
|
||||
const store = writable<PlayerStateValue>(initialValue);
|
||||
|
||||
return {
|
||||
...store,
|
||||
streamJellyfinId: (id: string) => {
|
||||
store.set({ visible: true, jellyfinId: id });
|
||||
modalStack.create(VideoPlayer, {}); // FIXME
|
||||
},
|
||||
close: () => {
|
||||
store.set({ visible: false, jellyfinId: '' });
|
||||
jellyfinItemsStore.refresh();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const playerState = createPlayerState();
|
||||
107
src/lib/pages/BrowseSeriesPage.svelte
Normal file
107
src/lib/pages/BrowseSeriesPage.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../Container.svelte';
|
||||
import { getPosterProps, TmdbApiOpen } from '../apis/tmdb/tmdbApi';
|
||||
import { formatDateToYearMonthDay } from '../utils';
|
||||
import { settings } from '../stores/settings.store';
|
||||
import type { TitleType } from '../types';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Poster from '../components/Poster.svelte';
|
||||
import { getJellyfinItems, type JellyfinItem } from '../apis/jellyfin/jellyfinApi';
|
||||
import { jellyfinItemsStore } from '../stores/data.store';
|
||||
import Carousel from '../components/Carousel/Carousel.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import CarouselPlaceholderItems from '../components/Carousel/CarouselPlaceholderItems.svelte';
|
||||
import VideoPlayer from '../components/VideoPlayer/VideoPlayer.svelte';
|
||||
|
||||
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
|
||||
jellyfinItemsStore.subscribe((data) => {
|
||||
if (data.loading) return;
|
||||
resolve(data.data || []);
|
||||
});
|
||||
});
|
||||
|
||||
const fetchCardProps = async (
|
||||
items: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
id?: number;
|
||||
vote_average?: number;
|
||||
number_of_seasons?: number;
|
||||
first_air_date?: string;
|
||||
poster_path?: string;
|
||||
}[],
|
||||
type: TitleType | undefined = undefined
|
||||
): Promise<ComponentProps<Poster>[]> => {
|
||||
const filtered = $settings.discover.excludeLibraryItems
|
||||
? items.filter(
|
||||
async (item) =>
|
||||
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
|
||||
)
|
||||
: items;
|
||||
|
||||
return Promise.all(filtered.map(async (item) => getPosterProps(item, type))).then((props) =>
|
||||
props.filter((p) => p.backdropUrl).map((i) => ({ ...i, openInModal: false }))
|
||||
);
|
||||
};
|
||||
|
||||
const fetchNowStreaming = () =>
|
||||
TmdbApiOpen.GET('/3/discover/tv', {
|
||||
params: {
|
||||
query: {
|
||||
'air_date.gte': formatDateToYearMonthDay(new Date()),
|
||||
'first_air_date.lte': formatDateToYearMonthDay(new Date()),
|
||||
sort_by: 'popularity.desc',
|
||||
language: $settings.language,
|
||||
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.results || [])
|
||||
.then((i) => fetchCardProps(i, 'series'));
|
||||
|
||||
const fetchLibraryItems = async () => {
|
||||
const items = await getJellyfinItems();
|
||||
const props = await fetchCardProps(items, 'series');
|
||||
console.log('JellyfinItems', items, props);
|
||||
return props;
|
||||
};
|
||||
|
||||
function parseIncludedLanguages(includedLanguages: string) {
|
||||
return includedLanguages.replace(' ', '').split(',').join('|');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container focusOnMount>
|
||||
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
|
||||
<div slot="title" class="text-lg font-semibold text-zinc-300">
|
||||
{$_('discover.streamingNow')}
|
||||
</div>
|
||||
{#await fetchNowStreaming()}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop (prop.tmdbId)}
|
||||
<Container class="m-2">
|
||||
<Poster {...prop} />
|
||||
</Container>
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
<!-- <Carousel scrollClass="px-2 sm:px-8 2xl:px-16">-->
|
||||
<!-- <div slot="title" class="text-lg font-semibold text-zinc-300">-->
|
||||
<!-- {$_('discover.library')}-->
|
||||
<!-- </div>-->
|
||||
<!-- {#await fetchLibraryItems()}-->
|
||||
<!-- <CarouselPlaceholderItems />-->
|
||||
<!-- {:then props}-->
|
||||
<!-- {#each props as prop (prop.tmdbId)}-->
|
||||
<!-- <Container>-->
|
||||
<!-- <Poster {...prop} />-->
|
||||
<!-- </Container>-->
|
||||
<!-- {/each}-->
|
||||
<!-- {/await}-->
|
||||
<!-- </Carousel>-->
|
||||
<!-- <Poster-->
|
||||
<!-- backdropUrl="http://192.168.0.129:8096/Items/8cc44d55dba1495a2ffcda104286d611/Images/Primary?quality=80&fillWidth=432&tag=d026e7eb1d9ba9934c8769695e396dc4"-->
|
||||
<!-- />-->
|
||||
<!-- <VideoPlayer jellyfinId="8cc44d55dba1495a2ffcda104286d611" />-->
|
||||
</Container>
|
||||
@@ -1,81 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Container from '../../Container.svelte';
|
||||
import { getPosterProps, TmdbApiOpen } from '../apis/tmdb/tmdbApi';
|
||||
import { formatDateToYearMonthDay } from '../utils';
|
||||
import { settings } from '../stores/settings.store';
|
||||
import type { TitleType } from '../types';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Poster from '../components/Poster.svelte';
|
||||
import type { JellyfinItem } from '../apis/jellyfin/jellyfinApi';
|
||||
import { jellyfinItemsStore } from '../stores/data.store';
|
||||
import Carousel from '../components/Carousel/Carousel.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import CarouselPlaceholderItems from '../components/Carousel/CarouselPlaceholderItems.svelte';
|
||||
|
||||
const jellyfinItemsPromise = new Promise<JellyfinItem[]>((resolve) => {
|
||||
jellyfinItemsStore.subscribe((data) => {
|
||||
if (data.loading) return;
|
||||
resolve(data.data || []);
|
||||
});
|
||||
});
|
||||
|
||||
const fetchCardProps = async (
|
||||
items: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
id?: number;
|
||||
vote_average?: number;
|
||||
number_of_seasons?: number;
|
||||
first_air_date?: string;
|
||||
poster_path?: string;
|
||||
}[],
|
||||
type: TitleType | undefined = undefined
|
||||
): Promise<ComponentProps<Poster>[]> => {
|
||||
const filtered = $settings.discover.excludeLibraryItems
|
||||
? items.filter(
|
||||
async (item) =>
|
||||
!(await jellyfinItemsPromise).find((i) => i.ProviderIds?.Tmdb === String(item.id))
|
||||
)
|
||||
: items;
|
||||
|
||||
return Promise.all(filtered.map(async (item) => getPosterProps(item, type))).then((props) =>
|
||||
props.filter((p) => p.backdropUrl)
|
||||
);
|
||||
};
|
||||
|
||||
const fetchNowStreaming = () =>
|
||||
TmdbApiOpen.GET('/3/discover/tv', {
|
||||
params: {
|
||||
query: {
|
||||
'air_date.gte': formatDateToYearMonthDay(new Date()),
|
||||
'first_air_date.lte': formatDateToYearMonthDay(new Date()),
|
||||
sort_by: 'popularity.desc',
|
||||
language: $settings.language,
|
||||
with_original_language: parseIncludedLanguages($settings.discover.includedLanguages)
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.results || [])
|
||||
.then((i) => fetchCardProps(i, 'series'));
|
||||
|
||||
function parseIncludedLanguages(includedLanguages: string) {
|
||||
return includedLanguages.replace(' ', '').split(',').join('|');
|
||||
}
|
||||
export let id: string;
|
||||
</script>
|
||||
|
||||
<Container focusOnMount>
|
||||
<Carousel scrollClass="px-2 sm:px-8 2xl:px-16">
|
||||
<div slot="title" class="text-lg font-semibold text-zinc-300">
|
||||
{$_('discover.streamingNow')}
|
||||
<Container>
|
||||
<div>
|
||||
Series page for id: {id}
|
||||
</div>
|
||||
{#await fetchNowStreaming()}
|
||||
<CarouselPlaceholderItems />
|
||||
{:then props}
|
||||
{#each props as prop (prop.tmdbId)}
|
||||
<Container>
|
||||
<Poster {...prop} />
|
||||
</Container>
|
||||
{/each}
|
||||
{/await}
|
||||
</Carousel>
|
||||
</Container>
|
||||
<Container>This is something</Container>
|
||||
<Container on:click={() => history.back()}>Go back</Container>
|
||||
</Container>
|
||||
|
||||
@@ -66,12 +66,31 @@ export class Selectable {
|
||||
this.children[get(this.focusIndex)]?.focus();
|
||||
} else if (this.htmlElement) {
|
||||
this.htmlElement.focus({ preventScroll: true });
|
||||
this.htmlElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||
// this.htmlElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||
this.scrollIntoView(50);
|
||||
Selectable.focusedObject.set(this);
|
||||
this.updateFocusIndex();
|
||||
}
|
||||
}
|
||||
|
||||
scrollIntoView(offset = 0, direction: Direction = 'left') {
|
||||
if (this.htmlElement) {
|
||||
const boundingRect = this.htmlElement.getBoundingClientRect();
|
||||
const offsetParent = this.htmlElement.offsetParent as HTMLElement;
|
||||
|
||||
if (offsetParent) {
|
||||
const left = this.htmlElement.offsetLeft - offset;
|
||||
|
||||
console.log(boundingRect);
|
||||
console.log('Scrolling to left: ', left);
|
||||
offsetParent.scrollTo({
|
||||
left,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateFocusIndex(selectable?: Selectable) {
|
||||
if (selectable) {
|
||||
const index = this.children.indexOf(selectable);
|
||||
@@ -191,7 +210,7 @@ export class Selectable {
|
||||
}
|
||||
|
||||
_unmountContainer() {
|
||||
console.log('Unmounting selectable', this);
|
||||
// console.log('Unmounting selectable', this);
|
||||
const isFocusedWithin = get(this.hasFocusWithin);
|
||||
|
||||
if (this.htmlElement) {
|
||||
@@ -214,7 +233,7 @@ export class Selectable {
|
||||
const selectable = _selectable || new Selectable().setDirection(flowDirection);
|
||||
|
||||
return (htmlElement: HTMLElement) => {
|
||||
console.log('Registering', htmlElement, selectable);
|
||||
// console.log('Registering', htmlElement, selectable);
|
||||
selectable.setHtmlElement(htmlElement);
|
||||
|
||||
return {
|
||||
@@ -274,6 +293,12 @@ export class Selectable {
|
||||
private shouldFocusByDefault(): boolean {
|
||||
return this.focusByDefault || this.parent?.shouldFocusByDefault() || false;
|
||||
}
|
||||
|
||||
click() {
|
||||
if (this.htmlElement) {
|
||||
this.htmlElement.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
@@ -300,5 +325,7 @@ export function handleKeyboardNavigation(event: KeyboardEvent) {
|
||||
if (Selectable.focusLeft()) event.preventDefault();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
if (Selectable.focusRight()) event.preventDefault();
|
||||
} else if (event.key === 'Enter') {
|
||||
currentlyFocusedObject.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ export const defaultSettings: SettingsValues = {
|
||||
rootFolderPath: ''
|
||||
},
|
||||
jellyfin: {
|
||||
apiKey: null,
|
||||
baseUrl: null,
|
||||
userId: null
|
||||
apiKey: 'ff526980723144a095f560fc2975657b',
|
||||
baseUrl: 'http://192.168.0.129:8096',
|
||||
userId: '75dcb061c9404115a7acdc893ea6bbbc'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
3
tizen/.gitignore
vendored
3
tizen/.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.sign/*
|
||||
*.wgt
|
||||
*.wgt
|
||||
.buildResult/
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"$lib/*": ["src/lib/*"],
|
||||
}
|
||||
// "paths": {
|
||||
// "$lib/*": ["src/lib/*"],
|
||||
// }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
||||
Reference in New Issue
Block a user