alright, got it to the point where it'll automatically download new entries. now to fix the UX of it all...
This commit is contained in:
parent
6a209f8698
commit
b5306d1d11
10 changed files with 195 additions and 61 deletions
85
docs/design/playing_media.md
Normal file
85
docs/design/playing_media.md
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
# Playing media
|
||||||
|
|
||||||
|
This document will describe the application UX flow.
|
||||||
|
|
||||||
|
## Types of media & Expected behavior
|
||||||
|
|
||||||
|
- movie
|
||||||
|
- actual movie -> show the video player and start/continue playing the movie
|
||||||
|
- collection -> Show a picker for which movie to play (also allow play-all)
|
||||||
|
- audio/music
|
||||||
|
- artist -> Show top songs, albums, and a play-all button
|
||||||
|
- album -> Show songs in album and a play-all button
|
||||||
|
- song -> Show audio player and start playing the song
|
||||||
|
- show/tv
|
||||||
|
- series -> Show a picker for the seasons and include a play button that will start/continue the first non-completed episode
|
||||||
|
- season -> Show a picker for the episodes and include a play button that will start/continue the first non-completed episode
|
||||||
|
- episode -> Show the video player and start/continue the episode (include the skip to previous&next episode buttons)
|
||||||
|
- playlist -> play the selected entry according to the above listed definitions
|
||||||
|
|
||||||
|
## UX flow
|
||||||
|
|
||||||
|
```txt
|
||||||
|
WHEN type of media IS {
|
||||||
|
audio -> {
|
||||||
|
WHEN entry does not exist in lidarr {
|
||||||
|
add entry to lidarr
|
||||||
|
}
|
||||||
|
|
||||||
|
WHEN queue record status IS {
|
||||||
|
paused -> ???
|
||||||
|
|
||||||
|
downloading -> {
|
||||||
|
display estimated time remaining
|
||||||
|
wait for download to complete
|
||||||
|
}
|
||||||
|
|
||||||
|
_ -> ???
|
||||||
|
}
|
||||||
|
|
||||||
|
play audio
|
||||||
|
}
|
||||||
|
|
||||||
|
show/tv -> {
|
||||||
|
WHEN entry is not an episode {
|
||||||
|
redirect to earliest non-completed episode
|
||||||
|
}
|
||||||
|
|
||||||
|
WHEN entry does not exist in sonarr {
|
||||||
|
add entry to sonarr
|
||||||
|
}
|
||||||
|
|
||||||
|
WHEN queue record status IS {
|
||||||
|
paused -> ???
|
||||||
|
|
||||||
|
downloading -> {
|
||||||
|
display estimated time remaining
|
||||||
|
wait for download to complete
|
||||||
|
}
|
||||||
|
|
||||||
|
_ -> ???
|
||||||
|
}
|
||||||
|
|
||||||
|
play episode
|
||||||
|
}
|
||||||
|
|
||||||
|
movie -> {
|
||||||
|
WHEN entry does not exist in radarr {
|
||||||
|
add entry to radarr
|
||||||
|
}
|
||||||
|
|
||||||
|
WHEN queue record status IS {
|
||||||
|
paused -> ???
|
||||||
|
|
||||||
|
downloading -> {
|
||||||
|
display estimated time remaining
|
||||||
|
wait for download to complete
|
||||||
|
}
|
||||||
|
|
||||||
|
_ -> ???
|
||||||
|
}
|
||||||
|
|
||||||
|
play movie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
|
@ -25,7 +25,7 @@ const Page: Component<{ entry: Entry }> = (props) => {
|
||||||
>
|
>
|
||||||
<h2 class={css.title}>{props.entry.title}</h2>
|
<h2 class={css.title}>{props.entry.title}</h2>
|
||||||
|
|
||||||
<a class={css.cta} href={`/watch/${slug()}`}>
|
<a class={css.cta} href={`/play/${slug()}`}>
|
||||||
Continue
|
Continue
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
@ -213,15 +213,13 @@ export const getItemStream = query(
|
||||||
|
|
||||||
const userData = await getItemUserData(userId, itemId);
|
const userData = await getItemUserData(userId, itemId);
|
||||||
|
|
||||||
console.log(userData);
|
|
||||||
|
|
||||||
const { response } = await getClient().GET("/Videos/{itemId}/stream", {
|
const { response } = await getClient().GET("/Videos/{itemId}/stream", {
|
||||||
params: {
|
params: {
|
||||||
path: {
|
path: {
|
||||||
itemId,
|
itemId,
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
// startTimeTicks: userData?.playbackPositionTicks,
|
startTimeTicks: userData?.playbackPositionTicks,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
parseAs: 'stream',
|
parseAs: 'stream',
|
||||||
|
|
|
@ -26,10 +26,52 @@ export const getClient = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const get = query(async () => {
|
export const TEST = query(async (id: number) => {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
const { data, error } = await getClient().GET('/api/v3/movie');
|
const { data, error } = await getClient().GET('/api/v3/queue/details', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
movieId: id,
|
||||||
|
// includeMovie: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = data?.[0];
|
||||||
|
|
||||||
|
if (!item?.status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(item.status) {
|
||||||
|
case 'paused': {
|
||||||
|
console.log('not sure what to do now. there\'s a reason it is paused after all. just report it to the client perhaps?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'downloading': {
|
||||||
|
console.log(`download is esitmated to complete at ${item.estimatedCompletionTime}`);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 'radarr.TEST');
|
||||||
|
|
||||||
|
export const get = query(async (id: number) => {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
const { data, error } = await getClient().GET('/api/v3/movie/{id}', {
|
||||||
|
params: {
|
||||||
|
path: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}, 'radarr.get');
|
}, 'radarr.get');
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
searchMulti,
|
searchMulti,
|
||||||
} from "./apis/tmdb";
|
} from "./apis/tmdb";
|
||||||
import { listIds as listSerieIds, addSeries } from "./apis/sonarr";
|
import { listIds as listSerieIds, addSeries } from "./apis/sonarr";
|
||||||
import { listIds as listMovieIds, addMovie } from "./apis/radarr";
|
import { listIds as listMovieIds, addMovie, TEST } from "./apis/radarr";
|
||||||
import { merge } from "~/utilities";
|
import { merge } from "~/utilities";
|
||||||
|
|
||||||
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
|
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
|
||||||
|
@ -34,8 +34,14 @@ export const getStream = query(async (id: string, range: string) => {
|
||||||
return getItemStream(jellyfinUserId, ids.jellyfin, range);
|
return getItemStream(jellyfinUserId, ids.jellyfin, range);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ids?.[manager]) {
|
if (ids?.radarr) {
|
||||||
console.log('id is known, but jellyfin does not (yet) have the file');
|
console.log(`radarr has the entry '${ids.radarr}', but jellyfin does not (yet) have the file`);
|
||||||
|
console.log(await TEST(ids.radarr))
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids?.sonarr) {
|
||||||
|
console.log('sonarr has the entry, but jellyfin does not (yet) have the file');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,13 +8,12 @@ export const ListItem: Component<{ entry: Entry }> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure class={css.listItem} data-id={props.entry.id}>
|
<figure class={css.listItem} data-id={props.entry.id}>
|
||||||
<img src={props.entry.thumbnail ?? ''} alt={props.entry.title} />
|
<img src={props.entry.thumbnail ?? ""} alt={props.entry.title} />
|
||||||
|
|
||||||
|
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<strong>{props.entry.title}</strong>
|
<strong>{props.entry.title}</strong>
|
||||||
|
|
||||||
<a href={`/watch/${slug()}`}>Watch now</a>
|
<a href={`/play/${slug()}`}>Watch now</a>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {
|
||||||
ContextProviderProps,
|
ContextProviderProps,
|
||||||
createContextProvider,
|
createContextProvider,
|
||||||
} from "@solid-primitives/context";
|
} from "@solid-primitives/context";
|
||||||
import { Accessor, createEffect, onMount, Setter } from "solid-js";
|
import { Accessor, createEffect, on, onMount, Setter } from "solid-js";
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
import { createEventListenerMap } from "@solid-primitives/event-listener";
|
import { createEventListenerMap } from "@solid-primitives/event-listener";
|
||||||
import { createFullscreen } from "@solid-primitives/fullscreen";
|
import { createFullscreen } from "@solid-primitives/fullscreen";
|
||||||
|
@ -124,13 +124,20 @@ export const [VideoProvider, useVideo] = createContextProvider<
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log(props.root, props.video);
|
||||||
|
|
||||||
if (isServer || video === undefined) {
|
if (isServer || video === undefined) {
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
video[store.state === "playing" ? "play" : "pause"]();
|
on(
|
||||||
});
|
() => store.state,
|
||||||
|
(state) => {
|
||||||
|
video[state === "playing" ? "play" : "pause"]();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
video.muted = store.volume.muted;
|
video.muted = store.volume.muted;
|
||||||
|
@ -174,9 +181,11 @@ export const [VideoProvider, useVideo] = createContextProvider<
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
canplay() {
|
canplay() {
|
||||||
|
console.log("can play!");
|
||||||
// setStore("loading", false);
|
// setStore("loading", false);
|
||||||
},
|
},
|
||||||
canplaythrough() {
|
canplaythrough() {
|
||||||
|
console.log("can play through!");
|
||||||
setStore("loading", false);
|
setStore("loading", false);
|
||||||
},
|
},
|
||||||
waiting() {
|
waiting() {
|
||||||
|
|
|
@ -33,15 +33,15 @@ const metadata = query(async (id: string) => {
|
||||||
// 3. create sprite from images
|
// 3. create sprite from images
|
||||||
// 4. remove thumbs
|
// 4. remove thumbs
|
||||||
|
|
||||||
const path = `${import.meta.dirname}/SampleVideo_1280x720_10mb`;
|
// const path = `${import.meta.dirname}/SampleVideo_1280x720_10mb`;
|
||||||
|
|
||||||
return json({
|
// return json({
|
||||||
captions: await Bun.file(`${path}.captions.vtt`).bytes(),
|
// captions: await Bun.file(`${path}.captions.vtt`).bytes(),
|
||||||
thumbnails: {
|
// thumbnails: {
|
||||||
track: await Bun.file(`${path}.thumbnails.vtt`).text(),
|
// track: await Bun.file(`${path}.thumbnails.vtt`).text(),
|
||||||
image: await Bun.file(`${import.meta.dirname}/overview.jpg`).bytes(),
|
// image: await Bun.file(`${import.meta.dirname}/overview.jpg`).bytes(),
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
}, "player.metadata");
|
}, "player.metadata");
|
||||||
|
|
||||||
interface PlayerProps {
|
interface PlayerProps {
|
||||||
|
@ -56,32 +56,30 @@ export const Player: Component<PlayerProps> = (props) => {
|
||||||
undefined as unknown as HTMLVideoElement
|
undefined as unknown as HTMLVideoElement
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = createAsync(() => metadata(props.entry.id), {
|
// const data = createAsync(() => metadata(props.entry.id), {
|
||||||
deferStream: true,
|
// deferStream: true,
|
||||||
initialValue: {} as any,
|
// initialValue: {} as any,
|
||||||
});
|
// });
|
||||||
const captionUrl = createMemo(() => {
|
// const captionUrl = createMemo(() => {
|
||||||
const { captions } = data();
|
// const { captions } = data();
|
||||||
|
|
||||||
return captions !== undefined
|
// return captions !== undefined
|
||||||
? URL.createObjectURL(new Blob([captions], { type: "text/vtt" }))
|
// ? URL.createObjectURL(new Blob([captions], { type: "text/vtt" }))
|
||||||
: "";
|
// : "";
|
||||||
});
|
// });
|
||||||
const thumbnails = createMemo(() => {
|
// const thumbnails = createMemo(() => {
|
||||||
const { thumbnails } = data();
|
// const { thumbnails } = data();
|
||||||
|
|
||||||
return thumbnails !== undefined
|
// return thumbnails !== undefined
|
||||||
? URL.createObjectURL(new Blob([thumbnails.track], { type: "text/vtt" }))
|
// ? URL.createObjectURL(new Blob([thumbnails.track], { type: "text/vtt" }))
|
||||||
: "";
|
// : "";
|
||||||
});
|
// });
|
||||||
|
|
||||||
createEffect(on(thumbnails, (thumbnails) => {}));
|
// createEffect(on(thumbnails, (thumbnails) => {}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<figure ref={setPlayer} class={css.player}>
|
<figure ref={setPlayer} class={css.player}>
|
||||||
{/* <h1>{props.entry.title}</h1> */}
|
|
||||||
|
|
||||||
<video
|
<video
|
||||||
ref={setVideo}
|
ref={setVideo}
|
||||||
src={`/api/content/${props.entry.id}/stream${
|
src={`/api/content/${props.entry.id}/stream${
|
||||||
|
@ -91,7 +89,7 @@ export const Player: Component<PlayerProps> = (props) => {
|
||||||
lang="en"
|
lang="en"
|
||||||
autoplay
|
autoplay
|
||||||
>
|
>
|
||||||
<track
|
{/* <track
|
||||||
default
|
default
|
||||||
kind="captions"
|
kind="captions"
|
||||||
label="English"
|
label="English"
|
||||||
|
@ -99,7 +97,7 @@ export const Player: Component<PlayerProps> = (props) => {
|
||||||
src={captionUrl()}
|
src={captionUrl()}
|
||||||
/>
|
/>
|
||||||
<track default kind="chapters" src={thumbnails()} id="thumbnails" />
|
<track default kind="chapters" src={thumbnails()} id="thumbnails" />
|
||||||
{/* <track kind="captions" />
|
<track kind="captions" />
|
||||||
<track kind="chapters" />
|
<track kind="chapters" />
|
||||||
<track kind="descriptions" />
|
<track kind="descriptions" />
|
||||||
<track kind="metadata" />
|
<track kind="metadata" />
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
RouteDefinition,
|
RouteDefinition,
|
||||||
useParams,
|
useParams,
|
||||||
} from "@solidjs/router";
|
} from "@solidjs/router";
|
||||||
import { createEffect, createMemo, Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { createSlug, getEntryFromSlug } from "~/features/content";
|
import { createSlug, getEntryFromSlug } from "~/features/content";
|
||||||
import { Player } from "~/features/player";
|
import { Player } from "~/features/player";
|
||||||
import { Title } from "@solidjs/meta";
|
import { Title } from "@solidjs/meta";
|
||||||
|
@ -27,8 +27,8 @@ const healUrl = query(async (slug: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not entirely sure a permanent redirect is what we want in this case
|
// Not entirely sure a permanent redirect is what we want in this case
|
||||||
throw redirect(`/watch/${actualSlug}`, { status: 308 });
|
throw redirect(`/play/${actualSlug}`, { status: 308 });
|
||||||
}, "watch.heal");
|
}, "play.heal");
|
||||||
|
|
||||||
interface ItemParams extends Params {
|
interface ItemParams extends Params {
|
||||||
slug: string;
|
slug: string;
|
||||||
|
@ -51,19 +51,16 @@ export const route = {
|
||||||
export default function Item() {
|
export default function Item() {
|
||||||
const { slug } = useParams<ItemParams>();
|
const { slug } = useParams<ItemParams>();
|
||||||
const entry = createAsync(() => getEntryFromSlug(slug));
|
const entry = createAsync(() => getEntryFromSlug(slug));
|
||||||
const title = createMemo(() => entry()?.title);
|
|
||||||
|
|
||||||
console.log(entry());
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
console.log(entry());
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={css.page}>
|
<div class={css.page}>
|
||||||
<Title>{title()}</Title>
|
<Title>{entry()?.title}</Title>
|
||||||
<Show when={entry()} fallback="Some kind of pretty 404 page I guess">
|
<Show when={entry()} fallback="Some kind of pretty 404 page I guess">
|
||||||
{(entry) => <Player entry={entry()} />}
|
{(entry) => (
|
||||||
|
<>
|
||||||
|
<Player entry={entry()} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
Loading…
Add table
Add a link
Reference in a new issue