@axium/kino 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +157 -0
- package/db.json +106 -0
- package/dist/client/api.d.ts +49 -0
- package/dist/client/api.js +136 -0
- package/dist/client/frontend.d.ts +3 -0
- package/dist/client/frontend.js +33 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/web_hook.d.ts +8 -0
- package/dist/client/web_hook.js +4 -0
- package/dist/common.d.ts +749 -0
- package/dist/common.js +212 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server/api/delete.d.ts +1 -0
- package/dist/server/api/delete.js +57 -0
- package/dist/server/api/metadata.d.ts +4 -0
- package/dist/server/api/metadata.js +153 -0
- package/dist/server/api/raw.d.ts +1 -0
- package/dist/server/api/raw.js +86 -0
- package/dist/server/api/search.d.ts +1 -0
- package/dist/server/api/search.js +108 -0
- package/dist/server/api/upload.d.ts +1 -0
- package/dist/server/api/upload.js +122 -0
- package/dist/server/api/views.d.ts +1 -0
- package/dist/server/api/views.js +128 -0
- package/dist/server/db.d.ts +11 -0
- package/dist/server/db.js +100 -0
- package/dist/server/hooks.d.ts +9 -0
- package/dist/server/hooks.js +19 -0
- package/dist/server/images.d.ts +12 -0
- package/dist/server/images.js +152 -0
- package/dist/server/media.d.ts +47 -0
- package/dist/server/media.js +136 -0
- package/dist/server/tmdb.d.ts +2 -0
- package/dist/server/tmdb.js +7 -0
- package/lib/EpisodeList.svelte +89 -0
- package/lib/MediaActions.svelte +110 -0
- package/lib/MediaCard.svelte +41 -0
- package/lib/MediaDetail.svelte +140 -0
- package/lib/MediaGrid.svelte +27 -0
- package/lib/Poster.svelte +49 -0
- package/lib/ProgressBar.svelte +22 -0
- package/lib/RecentGrid.svelte +82 -0
- package/lib/SearchBar.svelte +174 -0
- package/lib/SeasonList.svelte +50 -0
- package/lib/index.ts +10 -0
- package/lib/tsconfig.json +12 -0
- package/lib/watch.svelte.ts +93 -0
- package/locales/en.json +103 -0
- package/package.json +102 -0
- package/routes/+layout.svelte +22 -0
- package/routes/+layout.ts +21 -0
- package/routes/kino/+page.svelte +57 -0
- package/routes/kino/+page.ts +19 -0
- package/routes/movies/+page.svelte +22 -0
- package/routes/movies/+page.ts +12 -0
- package/routes/movies/[id]/+page.svelte +36 -0
- package/routes/movies/[id]/+page.ts +7 -0
- package/routes/movies/[id]/watch/+page.svelte +34 -0
- package/routes/movies/[id]/watch/+page.ts +18 -0
- package/routes/tsconfig.json +12 -0
- package/routes/tv/+page.svelte +22 -0
- package/routes/tv/+page.ts +12 -0
- package/routes/tv/[id]/+page.svelte +39 -0
- package/routes/tv/[id]/+page.ts +7 -0
- package/routes/tv/[id]/[season]/+page.svelte +43 -0
- package/routes/tv/[id]/[season]/+page.ts +11 -0
- package/routes/tv/[id]/[season]/[episode]/+page.svelte +46 -0
- package/routes/tv/[id]/[season]/[episode]/+page.ts +12 -0
- package/routes/tv/[id]/[season]/[episode]/watch/+page.svelte +46 -0
- package/routes/tv/[id]/[season]/[episode]/watch/+page.ts +22 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { getConfig } from '@axium/core';
|
|
2
|
+
import * as io from 'ioium/node';
|
|
3
|
+
import * as kt from 'kinotool';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, renameSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { mediaExtensions, mediaTypes } from '../common.js';
|
|
8
|
+
export { extensionForType, mediaExtensions, mediaTypes } from '../common.js';
|
|
9
|
+
/** Path of a movie's files, without an extension */
|
|
10
|
+
export function moviePath(id) {
|
|
11
|
+
return join(getConfig('@axium/kino').data_dir, 'movie', id.toString());
|
|
12
|
+
}
|
|
13
|
+
/** Path of an episode's files, without an extension */
|
|
14
|
+
export function episodePath(id, season, episode) {
|
|
15
|
+
return join(getConfig('@axium/kino').data_dir, 'tv', id.toString(), season.toString(), episode.toString());
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Pick which of an item's files to serve.
|
|
19
|
+
*
|
|
20
|
+
* This deliberately ignores the `remux` config: it can be changed at any time, and files written under
|
|
21
|
+
* an older setting still have to be served. Only what is actually on disk decides.
|
|
22
|
+
*
|
|
23
|
+
* Playback prefers the MP4, since browsers can seek it without reading the whole file.
|
|
24
|
+
* Downloads prefer the original upload, which still has every audio track and subtitle.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveMedia(base, download = false) {
|
|
27
|
+
const order = download ? ['.mkv', '.mp4'] : ['.mp4', '.mkv'];
|
|
28
|
+
for (const ext of order) {
|
|
29
|
+
if (existsSync(base + ext))
|
|
30
|
+
return { path: base + ext, ext, type: mediaTypes[ext] };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/** Every file belonging to an item, including a partial remux left behind by a crash */
|
|
35
|
+
export function mediaFiles(base) {
|
|
36
|
+
return [...mediaExtensions.map(ext => base + ext), base + '.mp4.part'].filter(existsSync);
|
|
37
|
+
}
|
|
38
|
+
/** Remove all of an item's files. Returns the paths that were removed. */
|
|
39
|
+
export function removeMedia(base) {
|
|
40
|
+
const files = mediaFiles(base);
|
|
41
|
+
for (const file of files)
|
|
42
|
+
rmSync(file, { force: true });
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
/** Bases with a remux already running, so a retry can't start a second ffmpeg on the same file */
|
|
46
|
+
const remuxing = new Set();
|
|
47
|
+
/**
|
|
48
|
+
* Start remuxing a freshly uploaded MKV to MP4, if the config asks for it.
|
|
49
|
+
*
|
|
50
|
+
* Remuxing multi-GB files takes minutes, so ffmpeg is spawned rather than run through
|
|
51
|
+
* `kt.mkv.remuxToMp4`, whose synchronous `trackCommand` would block the event loop for the duration.
|
|
52
|
+
* Until it finishes, `resolveMedia` keeps serving the MKV.
|
|
53
|
+
*/
|
|
54
|
+
export function remuxUpload(base) {
|
|
55
|
+
const { remux } = getConfig('@axium/kino');
|
|
56
|
+
if (remux == 'original')
|
|
57
|
+
return;
|
|
58
|
+
const input = base + '.mkv', output = base + '.mp4';
|
|
59
|
+
// Nothing to do for files that were uploaded as MP4 or have already been remuxed
|
|
60
|
+
if (!existsSync(input) || existsSync(output))
|
|
61
|
+
return;
|
|
62
|
+
if (remuxing.has(base))
|
|
63
|
+
return;
|
|
64
|
+
let plan;
|
|
65
|
+
try {
|
|
66
|
+
plan = kt.mkv.planRemuxToMp4(input, output);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
// e.g. HEVC video, which would need a real transcode
|
|
70
|
+
io.warn(`Kino: cannot remux ${input}: ` + io.errorText(e));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
remuxing.add(base);
|
|
74
|
+
io.info(`Kino: remuxing ${input}`);
|
|
75
|
+
const child = spawn('ffmpeg', plan.args, { stdio: 'ignore' });
|
|
76
|
+
child.on('error', e => {
|
|
77
|
+
remuxing.delete(base);
|
|
78
|
+
rmSync(plan.temp, { force: true });
|
|
79
|
+
io.warn(`Kino: could not run ffmpeg: ` + io.errorText(e));
|
|
80
|
+
});
|
|
81
|
+
child.on('exit', code => {
|
|
82
|
+
remuxing.delete(base);
|
|
83
|
+
if (code !== 0) {
|
|
84
|
+
rmSync(plan.temp, { force: true });
|
|
85
|
+
io.warn(`Kino: remux of ${input} failed with code ${code}`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
renameSync(plan.temp, output);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
rmSync(plan.temp, { force: true });
|
|
93
|
+
io.warn(`Kino: could not finish remux of ${input}: ` + io.errorText(e));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
io.info(`Kino: remuxed ${input}`);
|
|
97
|
+
/**
|
|
98
|
+
* Deleting the original is safe while it is being read: the inode survives until every
|
|
99
|
+
* open handle is closed, and new requests resolve to the MP4.
|
|
100
|
+
*/
|
|
101
|
+
if (getConfig('@axium/kino').remux == 'replace')
|
|
102
|
+
rmSync(input, { force: true });
|
|
103
|
+
});
|
|
104
|
+
// Don't hold the process open for a remux
|
|
105
|
+
child.unref();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Apply TMDB metadata to a freshly written file.
|
|
109
|
+
*
|
|
110
|
+
* Only Matroska is handled: `mkvpropedit` edits the header in place, so it finishes in milliseconds
|
|
111
|
+
* regardless of file size. `kt.mp4.setFromMovie` would rewrite the entire container, and it runs
|
|
112
|
+
* synchronously, so calling it here would block the event loop for as long as the copy takes.
|
|
113
|
+
*
|
|
114
|
+
* A remuxed MP4 still picks up the title through the remux's `-map_metadata`.
|
|
115
|
+
*
|
|
116
|
+
* @todo Tag directly-uploaded MP4s once kinotool can plan the rewrite the way `planRemuxToMp4` does,
|
|
117
|
+
* so ffmpeg can be spawned instead of run inline.
|
|
118
|
+
*/
|
|
119
|
+
export async function applyMetadata(path, ext, data) {
|
|
120
|
+
if (ext != '.mkv') {
|
|
121
|
+
io.debug('Kino: skipping metadata for ' + path + ' (only Matroska can be tagged in place)');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
if ('title' in data)
|
|
126
|
+
await kt.mkv.setFromMovie(path, data);
|
|
127
|
+
else
|
|
128
|
+
await kt.mkv.setFromEpisode(path, data);
|
|
129
|
+
// Browsers take the first audio track, which is often something they can't decode
|
|
130
|
+
kt.mkv.setAacDefaultAudio(path, kt.mkv.getInfo(path));
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
// A file we can't tag is still playable, so don't fail the upload over it
|
|
134
|
+
io.warn('Kino: could not write metadata to ' + path + ': ' + io.errorText(e));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
3
|
+
import { Icon } from '@axium/client/components';
|
|
4
|
+
import { viewProgress, type KinoEpisode } from '@axium/kino/common';
|
|
5
|
+
import Poster from './Poster.svelte';
|
|
6
|
+
import ProgressBar from './ProgressBar.svelte';
|
|
7
|
+
|
|
8
|
+
const { id, season, episodes }: { id: number; season: number; episodes?: KinoEpisode[] } = $props();
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<div class="EpisodeList">
|
|
12
|
+
{#each episodes || [] as episode (episode.episode_number)}
|
|
13
|
+
{@const progress = viewProgress(episode.progress)}
|
|
14
|
+
<a class="episode" href="/tv/{id}/{season}/{episode.episode_number}">
|
|
15
|
+
<div class="art">
|
|
16
|
+
<Poster path={episode.still_path} type="still" size="w185" alt={episode.name} />
|
|
17
|
+
{#if progress !== null}
|
|
18
|
+
<ProgressBar value={progress} />
|
|
19
|
+
{/if}
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="info">
|
|
23
|
+
<span class="number subtle">{text('kino.episode_number', { number: episode.episode_number })}</span>
|
|
24
|
+
<span class="name">{episode.name}</span>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<Icon i={episode.upload ? 'circle-play' : 'upload'} class="status" />
|
|
28
|
+
</a>
|
|
29
|
+
{:else}
|
|
30
|
+
<p class="subtle">{text('kino.no_episodes')}</p>
|
|
31
|
+
{/each}
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
.EpisodeList {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
gap: 0.5em;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Explicit columns: artwork, then the label, then the status icon pinned to the right */
|
|
42
|
+
.episode {
|
|
43
|
+
display: grid;
|
|
44
|
+
grid-template-columns: 8em 1fr auto;
|
|
45
|
+
gap: 1em;
|
|
46
|
+
align-items: center;
|
|
47
|
+
padding: 0.5em;
|
|
48
|
+
border-radius: 0.5em;
|
|
49
|
+
text-decoration: none;
|
|
50
|
+
color: inherit;
|
|
51
|
+
|
|
52
|
+
&:hover {
|
|
53
|
+
background-color: var(--bg-strong);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
:global(.status) {
|
|
57
|
+
--fill: var(--fg-accent);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.art {
|
|
62
|
+
display: flex;
|
|
63
|
+
flex-direction: column;
|
|
64
|
+
gap: 0.25em;
|
|
65
|
+
min-width: 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.info {
|
|
69
|
+
display: flex;
|
|
70
|
+
flex-direction: column;
|
|
71
|
+
min-width: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.number {
|
|
75
|
+
font-size: 0.85em;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.name {
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
text-overflow: ellipsis;
|
|
81
|
+
white-space: nowrap;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@media (width < 700px) {
|
|
85
|
+
.episode {
|
|
86
|
+
grid-template-columns: 5em 1fr auto;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
</style>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
3
|
+
import { FormDialog, Icon } from '@axium/client/components';
|
|
4
|
+
import { toast } from '@axium/client/toast';
|
|
5
|
+
import { formatBytes } from '@axium/core/format';
|
|
6
|
+
import { mediaAccept, type KinoUpload } from '@axium/kino/common';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** The upload backing this movie/episode, or nullish when nothing has been uploaded */
|
|
10
|
+
upload?: KinoUpload | null;
|
|
11
|
+
/** Where the "watch" button links to */
|
|
12
|
+
watchHref: string;
|
|
13
|
+
/** The `/raw` URL the file is downloaded from */
|
|
14
|
+
dataURL: string;
|
|
15
|
+
/** Label for the upload button */
|
|
16
|
+
uploadText: string;
|
|
17
|
+
/** Whether the current user may delete the upload */
|
|
18
|
+
canDelete?: boolean;
|
|
19
|
+
/** Performs the upload, resolving with the new upload once it finishes */
|
|
20
|
+
uploadFile(file: File): Promise<KinoUpload | undefined>;
|
|
21
|
+
/** Deletes the upload and its files */
|
|
22
|
+
deleteUpload?(): Promise<unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let { upload = $bindable(), watchHref, dataURL, uploadText, canDelete, uploadFile, deleteUpload }: Props = $props();
|
|
26
|
+
|
|
27
|
+
let input = $state<HTMLInputElement>();
|
|
28
|
+
let deleteDialog = $state<HTMLDialogElement>();
|
|
29
|
+
|
|
30
|
+
function onPicked(event: Event & { currentTarget: HTMLInputElement }) {
|
|
31
|
+
const file = event.currentTarget.files?.[0];
|
|
32
|
+
|
|
33
|
+
// Clear it so picking the same file again still fires a change event
|
|
34
|
+
event.currentTarget.value = '';
|
|
35
|
+
|
|
36
|
+
if (!file) return;
|
|
37
|
+
|
|
38
|
+
// Progress is reported by a toast, so there is nothing to wait on here
|
|
39
|
+
void uploadFile(file).then(result => {
|
|
40
|
+
if (result) upload = result;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function confirmDelete() {
|
|
45
|
+
try {
|
|
46
|
+
await deleteUpload!();
|
|
47
|
+
upload = null;
|
|
48
|
+
void toast('success', text('kino.delete_success'));
|
|
49
|
+
} catch (e) {
|
|
50
|
+
void toast('error', e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<div class="MediaActions">
|
|
56
|
+
{#if upload}
|
|
57
|
+
<a class="action icon-text" href={watchHref}>
|
|
58
|
+
<Icon i="play" />
|
|
59
|
+
{text('kino.watch')}
|
|
60
|
+
</a>
|
|
61
|
+
<a class="action icon-text" href={dataURL} download={upload.name}>
|
|
62
|
+
<Icon i="download" />
|
|
63
|
+
{text('kino.download')}
|
|
64
|
+
</a>
|
|
65
|
+
{#if canDelete && deleteUpload}
|
|
66
|
+
<button class="icon-text danger" onclick={() => deleteDialog?.showModal()}>
|
|
67
|
+
<Icon i="trash" />
|
|
68
|
+
{text('kino.delete')}
|
|
69
|
+
</button>
|
|
70
|
+
{/if}
|
|
71
|
+
<span class="subtle">
|
|
72
|
+
{text('kino.uploaded_detail', { date: upload.uploadedAt.toLocaleDateString(), size: formatBytes(upload.size) })}
|
|
73
|
+
</span>
|
|
74
|
+
{:else}
|
|
75
|
+
<button class="icon-text" onclick={() => input?.click()}>
|
|
76
|
+
<Icon i="upload" />
|
|
77
|
+
{uploadText}
|
|
78
|
+
</button>
|
|
79
|
+
<span class="subtle">{text('kino.not_uploaded')}</span>
|
|
80
|
+
{/if}
|
|
81
|
+
|
|
82
|
+
<input bind:this={input} type="file" accept={mediaAccept} onchange={onPicked} hidden />
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<FormDialog bind:dialog={deleteDialog} submitText={text('kino.delete')} submitDanger submit={confirmDelete}>
|
|
86
|
+
<span>{text('kino.delete_confirm')}</span>
|
|
87
|
+
</FormDialog>
|
|
88
|
+
|
|
89
|
+
<style>
|
|
90
|
+
.MediaActions {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-wrap: wrap;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 1em;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.action {
|
|
98
|
+
border: var(--border-accent);
|
|
99
|
+
background-color: var(--bg-normal);
|
|
100
|
+
border-radius: 0.5em;
|
|
101
|
+
padding: 0.4em 0.75em;
|
|
102
|
+
text-decoration: none;
|
|
103
|
+
color: inherit;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
|
|
106
|
+
&:hover {
|
|
107
|
+
background-color: var(--bg-accent);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { KinoSearchResults } from '@axium/kino/common';
|
|
3
|
+
import Poster from './Poster.svelte';
|
|
4
|
+
|
|
5
|
+
const { item }: { item: KinoSearchResults[number] } = $props();
|
|
6
|
+
|
|
7
|
+
const href = $derived(item.type == 'movie' ? `/movies/${item.id}` : `/tv/${item.id}`);
|
|
8
|
+
const title = $derived(item.type == 'movie' ? item.title : item.name);
|
|
9
|
+
const date = $derived(item.type == 'movie' ? item.release_date : item.first_air_date);
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<a class="MediaCard" {href}>
|
|
13
|
+
<Poster path={item.poster_path} type="poster" size="w342" alt={title} />
|
|
14
|
+
<span class="title">{title}</span>
|
|
15
|
+
{#if date}
|
|
16
|
+
<span class="subtle year">{date.getFullYear()}</span>
|
|
17
|
+
{/if}
|
|
18
|
+
</a>
|
|
19
|
+
|
|
20
|
+
<style>
|
|
21
|
+
.MediaCard {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
gap: 0.25em;
|
|
25
|
+
text-decoration: none;
|
|
26
|
+
color: inherit;
|
|
27
|
+
|
|
28
|
+
&:hover .title {
|
|
29
|
+
color: var(--fg-strong);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.title {
|
|
34
|
+
text-wrap: balance;
|
|
35
|
+
line-height: 1.2;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.year {
|
|
39
|
+
font-size: 0.85em;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
3
|
+
import { imageURL } from '@axium/kino/client';
|
|
4
|
+
import type { ImageType } from '@axium/kino/common';
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
import Poster from './Poster.svelte';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
title: string;
|
|
10
|
+
/** Shown above the title, e.g. the show a season belongs to */
|
|
11
|
+
context?: Snippet;
|
|
12
|
+
/** The artwork shown beside the title */
|
|
13
|
+
imagePath?: string | null;
|
|
14
|
+
imageType?: ImageType;
|
|
15
|
+
backdropPath?: string | null;
|
|
16
|
+
date?: Date | null;
|
|
17
|
+
/** Whether the date is a release date (movies) or an air date (TV) */
|
|
18
|
+
dateKind?: 'released' | 'aired';
|
|
19
|
+
overview?: string | null;
|
|
20
|
+
/** Watch/download/upload buttons */
|
|
21
|
+
actions?: Snippet;
|
|
22
|
+
/** Anything below the header, e.g. a season or episode list */
|
|
23
|
+
children?: Snippet;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
title,
|
|
28
|
+
context,
|
|
29
|
+
imagePath,
|
|
30
|
+
imageType = 'poster',
|
|
31
|
+
backdropPath,
|
|
32
|
+
date,
|
|
33
|
+
dateKind = 'released',
|
|
34
|
+
overview,
|
|
35
|
+
actions,
|
|
36
|
+
children,
|
|
37
|
+
}: Props = $props();
|
|
38
|
+
|
|
39
|
+
const backdrop = $derived(imageURL(backdropPath, 'backdrop', 'w1280'));
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<div class="MediaDetail">
|
|
43
|
+
<div class="header">
|
|
44
|
+
{#if backdrop}
|
|
45
|
+
<div class="backdrop" style:background-image="url('{backdrop}')"></div>
|
|
46
|
+
{/if}
|
|
47
|
+
|
|
48
|
+
<div class="art"><Poster path={imagePath} type={imageType} size={imageType == 'poster' ? 'w342' : 'w300'} alt={title} /></div>
|
|
49
|
+
|
|
50
|
+
<div class="info">
|
|
51
|
+
{#if context}<span class="subtle">{@render context()}</span>{/if}
|
|
52
|
+
<h1>{title}</h1>
|
|
53
|
+
<span class="subtle">
|
|
54
|
+
{date
|
|
55
|
+
? text(dateKind == 'aired' ? 'kino.aired' : 'kino.released', { date: date.toLocaleDateString() })
|
|
56
|
+
: text('kino.unreleased')}
|
|
57
|
+
</span>
|
|
58
|
+
|
|
59
|
+
{#if actions}<div class="actions">{@render actions()}</div>{/if}
|
|
60
|
+
|
|
61
|
+
<h2>{text('kino.overview')}</h2>
|
|
62
|
+
<p class="overview">{overview || text('kino.no_overview')}</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{#if children}{@render children()}{/if}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
.MediaDetail {
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-direction: column;
|
|
73
|
+
gap: 2em;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.header {
|
|
77
|
+
position: relative;
|
|
78
|
+
display: grid;
|
|
79
|
+
grid-template-columns: 14em 1fr;
|
|
80
|
+
gap: 2em;
|
|
81
|
+
padding: 1.5em;
|
|
82
|
+
border-radius: 1em;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
isolation: isolate;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.backdrop {
|
|
88
|
+
position: absolute;
|
|
89
|
+
inset: 0;
|
|
90
|
+
z-index: -1;
|
|
91
|
+
background-size: cover;
|
|
92
|
+
background-position: center;
|
|
93
|
+
opacity: 0.25;
|
|
94
|
+
/* Keep the text readable over busy artwork */
|
|
95
|
+
mask-image: linear-gradient(to right, transparent, black 40%);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.info {
|
|
99
|
+
display: flex;
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
gap: 0.5em;
|
|
102
|
+
min-width: 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
h1 {
|
|
106
|
+
margin: 0;
|
|
107
|
+
text-wrap: balance;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
h2 {
|
|
111
|
+
margin: 0.5em 0 0;
|
|
112
|
+
font-size: 1.05em;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.overview {
|
|
116
|
+
margin: 0;
|
|
117
|
+
max-width: 60ch;
|
|
118
|
+
text-wrap: pretty;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.actions {
|
|
122
|
+
margin-top: 0.5em;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@media (width < 700px) {
|
|
126
|
+
.header {
|
|
127
|
+
grid-template-columns: 1fr;
|
|
128
|
+
gap: 1em;
|
|
129
|
+
padding: 1em;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.art {
|
|
133
|
+
width: 10em;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.backdrop {
|
|
137
|
+
mask-image: linear-gradient(to bottom, transparent, black 60%);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { KinoSearchResults } from '@axium/kino/common';
|
|
3
|
+
import MediaCard from './MediaCard.svelte';
|
|
4
|
+
|
|
5
|
+
const { items, empty }: { items: KinoSearchResults; empty: string } = $props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<div class="MediaGrid">
|
|
9
|
+
{#each items as item (item.type + item.id)}
|
|
10
|
+
<MediaCard {item} />
|
|
11
|
+
{:else}
|
|
12
|
+
<p class="subtle">{empty}</p>
|
|
13
|
+
{/each}
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
.MediaGrid {
|
|
18
|
+
display: grid;
|
|
19
|
+
grid-template-columns: repeat(auto-fill, minmax(9em, 1fr));
|
|
20
|
+
gap: 1em;
|
|
21
|
+
align-items: start;
|
|
22
|
+
|
|
23
|
+
&:has(> p) {
|
|
24
|
+
display: block;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Icon } from '@axium/client/components';
|
|
3
|
+
import { imageURL } from '@axium/kino/client';
|
|
4
|
+
import type { ImageSize, ImageType } from '@axium/kino/common';
|
|
5
|
+
|
|
6
|
+
const { path, type = 'poster', size, alt = '' }: { path?: string | null; type?: ImageType; size?: ImageSize; alt?: string } = $props();
|
|
7
|
+
|
|
8
|
+
const src = $derived(imageURL(path, type, size));
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
{#if src}
|
|
12
|
+
<img class="Poster {type}" {src} {alt} loading="lazy" />
|
|
13
|
+
{:else}
|
|
14
|
+
<div class="Poster placeholder {type}">
|
|
15
|
+
<Icon i={type == 'poster' ? 'film' : 'photo-film'} --size="2em" />
|
|
16
|
+
</div>
|
|
17
|
+
{/if}
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
.Poster {
|
|
21
|
+
display: block;
|
|
22
|
+
width: 100%;
|
|
23
|
+
border-radius: 0.5em;
|
|
24
|
+
background-color: var(--bg-alt);
|
|
25
|
+
object-fit: cover;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.poster {
|
|
29
|
+
aspect-ratio: 2 / 3;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.still,
|
|
33
|
+
.backdrop {
|
|
34
|
+
aspect-ratio: 16 / 9;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.logo,
|
|
38
|
+
.profile {
|
|
39
|
+
aspect-ratio: 1;
|
|
40
|
+
object-fit: contain;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.placeholder {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
--fill: var(--fg-disabled);
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/** How far through the item the viewer is, from 0 to 1 */
|
|
3
|
+
const { value }: { value: number } = $props();
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div class="ProgressBar" role="progressbar" aria-valuenow={Math.round(value * 100)} aria-valuemin={0} aria-valuemax={100}>
|
|
7
|
+
<div class="fill" style:width="{value * 100}%"></div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<style>
|
|
11
|
+
.ProgressBar {
|
|
12
|
+
height: 3px;
|
|
13
|
+
border-radius: 2px;
|
|
14
|
+
background-color: var(--bg-strong);
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
|
|
17
|
+
.fill {
|
|
18
|
+
height: 100%;
|
|
19
|
+
background-color: var(--fg-accent);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
</style>
|