mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
Merge branch 'dev' of https://github.com/frost768/movie-web into feat/subtitle-rendering
This commit is contained in:
commit
31cd4d3c75
39 changed files with 1224 additions and 430 deletions
|
@ -6,6 +6,8 @@
|
|||
"dependencies": {
|
||||
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@react-spring/web": "^9.7.1",
|
||||
"@use-gesture/react": "^10.2.24",
|
||||
"crypto-js": "^4.1.1",
|
||||
"dompurify": "^3.0.1",
|
||||
"fscreen": "^1.2.0",
|
||||
|
@ -63,7 +65,6 @@
|
|||
"@types/pako": "^2.0.0",
|
||||
"@types/react": "^17.0.39",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@types/react-router": "^5.1.18",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-stickynode": "^4.0.0",
|
||||
|
|
|
@ -10,6 +10,7 @@ export enum MWCaptionType {
|
|||
|
||||
export enum MWStreamQuality {
|
||||
Q360P = "360p",
|
||||
Q540P = "540p",
|
||||
Q480P = "480p",
|
||||
Q720P = "720p",
|
||||
Q1080P = "1080p",
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { compareTitle } from "@/utils/titleMatch";
|
||||
import { proxiedFetch } from "../helpers/fetch";
|
||||
import { registerProvider } from "../helpers/register";
|
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
|
||||
import {
|
||||
MWCaptionType,
|
||||
MWStreamQuality,
|
||||
MWStreamType,
|
||||
} from "../helpers/streams";
|
||||
import { MWMediaType } from "../metadata/types";
|
||||
|
||||
// const flixHqBase = "https://api.consumet.org/movies/flixhq";
|
||||
|
@ -9,13 +13,52 @@ import { MWMediaType } from "../metadata/types";
|
|||
// SEE ISSUE: https://github.com/consumet/api.consumet.org/issues/326
|
||||
const flixHqBase = "https://c.delusionz.xyz/movies/flixhq";
|
||||
|
||||
interface FLIXMediaBase {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface FLIXTVSerie extends FLIXMediaBase {
|
||||
type: "TV Series";
|
||||
seasons: number | null;
|
||||
}
|
||||
|
||||
interface FLIXMovie extends FLIXMediaBase {
|
||||
type: "Movie";
|
||||
releaseDate: string;
|
||||
}
|
||||
|
||||
function castSubtitles({ url, lang }: { url: string; lang: string }) {
|
||||
return {
|
||||
url,
|
||||
langIso: lang,
|
||||
type:
|
||||
url.substring(url.length - 3) === "vtt"
|
||||
? MWCaptionType.VTT
|
||||
: MWCaptionType.SRT,
|
||||
};
|
||||
}
|
||||
|
||||
const qualityMap: Record<string, MWStreamQuality> = {
|
||||
"360": MWStreamQuality.Q360P,
|
||||
"540": MWStreamQuality.Q540P,
|
||||
"480": MWStreamQuality.Q480P,
|
||||
"720": MWStreamQuality.Q720P,
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
};
|
||||
|
||||
registerProvider({
|
||||
id: "flixhq",
|
||||
displayName: "FlixHQ",
|
||||
rank: 100,
|
||||
type: [MWMediaType.MOVIE],
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
// search for relevant item
|
||||
const searchResults = await proxiedFetch<any>(
|
||||
`/${encodeURIComponent(media.meta.title)}`,
|
||||
|
@ -23,11 +66,22 @@ registerProvider({
|
|||
baseURL: flixHqBase,
|
||||
}
|
||||
);
|
||||
const foundItem = searchResults.results.find((v: any) => {
|
||||
return (
|
||||
compareTitle(v.title, media.meta.title) &&
|
||||
v.releaseDate === media.meta.year
|
||||
);
|
||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
|
||||
if (media.meta.type === MWMediaType.MOVIE) {
|
||||
const movie = v as FLIXMovie;
|
||||
return (
|
||||
compareTitle(movie.title, media.meta.title) &&
|
||||
movie.releaseDate === media.meta.year
|
||||
);
|
||||
}
|
||||
const serie = v as FLIXTVSerie;
|
||||
if (serie.seasons && media.meta.seasons) {
|
||||
return (
|
||||
compareTitle(serie.title, media.meta.title) &&
|
||||
serie.seasons === media.meta.seasons.length
|
||||
);
|
||||
}
|
||||
return compareTitle(serie.title, media.meta.title);
|
||||
});
|
||||
if (!foundItem) throw new Error("No watchable item found");
|
||||
const flixId = foundItem.id;
|
||||
|
@ -40,7 +94,7 @@ registerProvider({
|
|||
id: flixId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!mediaInfo.episodes) throw new Error("No watchable item found");
|
||||
// get stream info from media
|
||||
progress(75);
|
||||
const watchInfo = await proxiedFetch<any>("/watch", {
|
||||
|
@ -51,18 +105,22 @@ registerProvider({
|
|||
},
|
||||
});
|
||||
|
||||
// get best quality source
|
||||
const source = watchInfo.sources.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
if (!watchInfo.sources) throw new Error("No watchable item found");
|
||||
|
||||
// get best quality source
|
||||
// comes sorted by quality in descending order
|
||||
const source = watchInfo.sources[0];
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
quality: MWStreamQuality.QUNKNOWN,
|
||||
quality: qualityMap[source.quality],
|
||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
|
||||
captions: [],
|
||||
captions: watchInfo.subtitles
|
||||
.filter(
|
||||
(x: { url: string; lang: string }) => !x.lang.includes("(maybe)")
|
||||
)
|
||||
.map(castSubtitles),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
@ -9,13 +9,13 @@ import { MWMediaType } from "../metadata/types";
|
|||
|
||||
const netfilmBase = "https://net-film.vercel.app";
|
||||
|
||||
const qualityMap = {
|
||||
"360": MWStreamQuality.Q360P,
|
||||
"480": MWStreamQuality.Q480P,
|
||||
"720": MWStreamQuality.Q720P,
|
||||
"1080": MWStreamQuality.Q1080P,
|
||||
const qualityMap: Record<number, MWStreamQuality> = {
|
||||
360: MWStreamQuality.Q360P,
|
||||
540: MWStreamQuality.Q540P,
|
||||
480: MWStreamQuality.Q480P,
|
||||
720: MWStreamQuality.Q720P,
|
||||
1080: MWStreamQuality.Q1080P,
|
||||
};
|
||||
type QualityInMap = keyof typeof qualityMap;
|
||||
|
||||
registerProvider({
|
||||
id: "netfilm",
|
||||
|
@ -24,6 +24,9 @@ registerProvider({
|
|||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
if (!this.type.includes(media.meta.type)) {
|
||||
throw new Error("Unsupported type");
|
||||
}
|
||||
// search for relevant item
|
||||
const searchResponse = await proxiedFetch<any>(
|
||||
`/api/search?keyword=${encodeURIComponent(media.meta.title)}`,
|
||||
|
@ -54,8 +57,8 @@ registerProvider({
|
|||
const data = watchInfo.data;
|
||||
|
||||
// get best quality source
|
||||
const source = data.qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
const source: { url: string; quality: number } = data.qualities.reduce(
|
||||
(p: any, c: any) => (c.quality > p.quality ? c : p)
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
|
@ -71,7 +74,7 @@ registerProvider({
|
|||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
quality: qualityMap[source.quality],
|
||||
type: MWStreamType.HLS,
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
|
@ -124,8 +127,8 @@ registerProvider({
|
|||
const data = episodeStream.data;
|
||||
|
||||
// get best quality source
|
||||
const source = data.qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
const source: { url: string; quality: number } = data.qualities.reduce(
|
||||
(p: any, c: any) => (c.quality > p.quality ? c : p)
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
|
@ -141,7 +144,7 @@ registerProvider({
|
|||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
quality: qualityMap[source.quality],
|
||||
type: MWStreamType.HLS,
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
|
|
|
@ -36,7 +36,8 @@ export enum Icons {
|
|||
CASTING = "casting",
|
||||
CIRCLE_EXCLAMATION = "circle_exclamation",
|
||||
DOWNLOAD = "download",
|
||||
SETTINGS = "settings",
|
||||
GEAR = "gear",
|
||||
WATCH_PARTY = "watch_party",
|
||||
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||
}
|
||||
|
||||
|
@ -76,12 +77,13 @@ const iconList: Record<Icons, string> = {
|
|||
skip_forward: `<svg width="1em" height="1em" viewBox="0 0 26 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3333 12.3333L16 7.66667M16 7.66667L11.3333 3M16 7.66667H6.66667C5.42899 7.66667 4.242 8.15833 3.36684 9.0335C2.49167 9.90867 2 11.0957 2 12.3333C2 13.571 2.49167 14.758 3.36684 15.6332C4.242 16.5083 5.42899 17 6.66667 17H9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16.5043 14.2727V23H14.6591V16.0241H14.608L12.6094 17.277V15.6406L14.7699 14.2727H16.5043ZM22.0004 23.1918C21.2674 23.1889 20.6367 23.0085 20.1083 22.6506C19.5827 22.2926 19.1779 21.7741 18.8938 21.0952C18.6126 20.4162 18.4734 19.5994 18.4762 18.6449C18.4762 17.6932 18.6168 16.8821 18.8981 16.2116C19.1822 15.5412 19.587 15.0312 20.1126 14.6818C20.641 14.3295 21.2702 14.1534 22.0004 14.1534C22.7305 14.1534 23.3583 14.3295 23.8839 14.6818C24.4123 15.0341 24.8185 15.5455 25.1026 16.2159C25.3867 16.8835 25.5273 17.6932 25.5245 18.6449C25.5245 19.6023 25.3825 20.4205 25.0984 21.0994C24.8171 21.7784 24.4137 22.2969 23.8881 22.6548C23.3626 23.0128 22.7333 23.1918 22.0004 23.1918ZM22.0004 21.6619C22.5004 21.6619 22.8995 21.4105 23.1978 20.9077C23.4961 20.4048 23.6438 19.6506 23.641 18.6449C23.641 17.983 23.5728 17.4318 23.4364 16.9915C23.3029 16.5511 23.1126 16.2202 22.8654 15.9986C22.6211 15.777 22.3327 15.6662 22.0004 15.6662C21.5032 15.6662 21.1055 15.9148 20.8072 16.4119C20.5089 16.9091 20.3583 17.6534 20.3555 18.6449C20.3555 19.3153 20.4222 19.875 20.5558 20.3239C20.6921 20.7699 20.8839 21.1051 21.131 21.3295C21.3782 21.5511 21.668 21.6619 22.0004 21.6619Z" fill="currentColor" /></svg>`,
|
||||
skip_backward: `<svg width="1em" height="1em" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.6667 12.3333L9 7.66667M9 7.66667L13.6667 3M9 7.66667H18.3333C19.571 7.66667 20.758 8.15833 21.6332 9.0335C22.5083 9.90867 23 11.0957 23 12.3333C23 13.571 22.5083 14.758 21.6332 15.6332C20.758 16.5083 19.571 17 18.3333 17H16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.50426 14.2727V23H2.65909V16.0241H2.60795L0.609375 17.277V15.6406L2.76989 14.2727H4.50426ZM10.0004 23.1918C9.2674 23.1889 8.63672 23.0085 8.10831 22.6506C7.58274 22.2926 7.17791 21.7741 6.89382 21.0952C6.61257 20.4162 6.47337 19.5994 6.47621 18.6449C6.47621 17.6932 6.61683 16.8821 6.89808 16.2116C7.18217 15.5412 7.587 15.0312 8.11257 14.6818C8.64098 14.3295 9.27024 14.1534 10.0004 14.1534C10.7305 14.1534 11.3583 14.3295 11.8839 14.6818C12.4123 15.0341 12.8185 15.5455 13.1026 16.2159C13.3867 16.8835 13.5273 17.6932 13.5245 18.6449C13.5245 19.6023 13.3825 20.4205 13.0984 21.0994C12.8171 21.7784 12.4137 22.2969 11.8881 22.6548C11.3626 23.0128 10.7333 23.1918 10.0004 23.1918ZM10.0004 21.6619C10.5004 21.6619 10.8995 21.4105 11.1978 20.9077C11.4961 20.4048 11.6438 19.6506 11.641 18.6449C11.641 17.983 11.5728 17.4318 11.4364 16.9915C11.3029 16.5511 11.1126 16.2202 10.8654 15.9986C10.6211 15.777 10.3327 15.6662 10.0004 15.6662C9.5032 15.6662 9.10547 15.9148 8.80717 16.4119C8.50888 16.9091 8.35831 17.6534 8.35547 18.6449C8.35547 19.3153 8.42223 19.875 8.55575 20.3239C8.69212 20.7699 8.88388 21.1051 9.13104 21.3295C9.3782 21.5511 9.66797 21.6619 10.0004 21.6619Z" fill="currentColor"/></svg>`,
|
||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
|
||||
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 25 20"><path transform="translate(-3 -6)" d="M25.5,6H5.5A2.507,2.507,0,0,0,3,8.5v15A2.507,2.507,0,0,0,5.5,26h20A2.507,2.507,0,0,0,28,23.5V8.5A2.507,2.507,0,0,0,25.5,6ZM5.5,16h5v2.5h-5ZM18,23.5H5.5V21H18Zm7.5,0h-5V21h5Zm0-5H13V16H25.5Z" fill="currentColor"/></svg>`,
|
||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
||||
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
|
||||
casting: "",
|
||||
download: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
|
||||
settings: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512" fill="white" stroke="currentColor"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
|
||||
gear: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M481.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-30.9 28.1c-7.7 7.1-11.4 17.5-10.9 27.9c.1 2.9 .2 5.8 .2 8.8s-.1 5.9-.2 8.8c-.5 10.5 3.1 20.9 10.9 27.9l30.9 28.1c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-39.7-12.6c-10-3.2-20.8-1.1-29.7 4.6c-4.9 3.1-9.9 6.1-15.1 8.7c-9.3 4.8-16.5 13.2-18.8 23.4l-8.9 40.7c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-8.9-40.7c-2.2-10.2-9.5-18.6-18.8-23.4c-5.2-2.7-10.2-5.6-15.1-8.7c-8.8-5.7-19.7-7.8-29.7-4.6L69.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l30.9-28.1c7.7-7.1 11.4-17.5 10.9-27.9c-.1-2.9-.2-5.8-.2-8.8s.1-5.9 .2-8.8c.5-10.5-3.1-20.9-10.9-27.9L8.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l39.7 12.6c10 3.2 20.8 1.1 29.7-4.6c4.9-3.1 9.9-6.1 15.1-8.7c9.3-4.8 16.5-13.2 18.8-23.4l8.9-40.7c2-9.1 9-16.3 18.2-17.8C213.3 1.2 227.5 0 242 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l8.9 40.7c2.2 10.2 9.4 18.6 18.8 23.4c5.2 2.7 10.2 5.6 15.1 8.7c8.8 5.7 19.7 7.7 29.7 4.6l39.7-12.6c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM242 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
|
||||
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
|
||||
pictureInPicture: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"/></svg>`,
|
||||
};
|
||||
|
||||
|
|
|
@ -4,7 +4,13 @@ import {
|
|||
TransitionClasses,
|
||||
} from "@headlessui/react";
|
||||
|
||||
type TransitionAnimations = "slide-down" | "slide-up" | "fade" | "none";
|
||||
type TransitionAnimations =
|
||||
| "slide-down"
|
||||
| "slide-full-left"
|
||||
| "slide-full-right"
|
||||
| "slide-up"
|
||||
| "fade"
|
||||
| "none";
|
||||
|
||||
interface Props {
|
||||
show?: boolean;
|
||||
|
@ -41,6 +47,28 @@ function getClasses(
|
|||
};
|
||||
}
|
||||
|
||||
if (animation === "slide-full-left") {
|
||||
return {
|
||||
leave: `transition-[transform] ${duration}`,
|
||||
leaveFrom: "translate-x-0",
|
||||
leaveTo: "-translate-x-full",
|
||||
enter: `transition-[transform] ${duration}`,
|
||||
enterFrom: "-translate-x-full",
|
||||
enterTo: "translate-x-0",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "slide-full-right") {
|
||||
return {
|
||||
leave: `transition-[transform] ${duration}`,
|
||||
leaveFrom: "translate-x-0",
|
||||
leaveTo: "translate-x-full",
|
||||
enter: `transition-[transform] ${duration}`,
|
||||
enterFrom: "translate-x-full",
|
||||
enterTo: "translate-x-0",
|
||||
};
|
||||
}
|
||||
|
||||
if (animation === "fade") {
|
||||
return {
|
||||
leave: `transition-[transform,opacity] ${duration}`,
|
||||
|
|
47
src/components/popout/FloatingAnchor.tsx
Normal file
47
src/components/popout/FloatingAnchor.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
export function createFloatingAnchorEvent(id: string): string {
|
||||
return `__floating::anchor::${id}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function FloatingAnchor(props: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const old = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
function render() {
|
||||
if (cancelled) return;
|
||||
|
||||
if (ref.current) {
|
||||
const current = old.current;
|
||||
const newer = ref.current.getBoundingClientRect();
|
||||
const newerStr = JSON.stringify(newer);
|
||||
if (current !== newerStr) {
|
||||
old.current = newerStr;
|
||||
const evtStr = createFloatingAnchorEvent(props.id);
|
||||
(window as any)[evtStr] = newer;
|
||||
const evObj = new CustomEvent(createFloatingAnchorEvent(props.id), {
|
||||
detail: newer,
|
||||
});
|
||||
document.dispatchEvent(evObj);
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(render);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props]);
|
||||
|
||||
return <div ref={ref}>{props.children}</div>;
|
||||
}
|
189
src/components/popout/FloatingCard.tsx
Normal file
189
src/components/popout/FloatingCard.tsx
Normal file
|
@ -0,0 +1,189 @@
|
|||
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
|
||||
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { PopoutSection } from "@/video/components/popouts/PopoutUtils";
|
||||
import { useSpringValue, animated, easings } from "@react-spring/web";
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Icon, Icons } from "../Icon";
|
||||
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle";
|
||||
|
||||
interface FloatingCardProps {
|
||||
children?: ReactNode;
|
||||
onClose?: () => void;
|
||||
for: string;
|
||||
}
|
||||
|
||||
interface RootFloatingCardProps extends FloatingCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function CardBase(props: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { isMobile } = useIsMobile();
|
||||
const height = useSpringValue(0, {
|
||||
config: { easing: easings.easeInOutSine, duration: 300 },
|
||||
});
|
||||
const width = useSpringValue(0, {
|
||||
config: { easing: easings.easeInOutSine, duration: 300 },
|
||||
});
|
||||
const [pages, setPages] = useState<NodeListOf<Element> | null>(null);
|
||||
|
||||
const getNewHeight = useCallback(
|
||||
(updateList = true) => {
|
||||
if (!ref.current) return;
|
||||
const children = ref.current.querySelectorAll(
|
||||
":scope *[data-floating-page='true']"
|
||||
);
|
||||
if (updateList) setPages(children);
|
||||
if (children.length === 0) {
|
||||
height.start(0);
|
||||
width.start(0);
|
||||
return;
|
||||
}
|
||||
const lastChild = children[children.length - 1];
|
||||
const rect = lastChild.getBoundingClientRect();
|
||||
const rectHeight = lastChild.scrollHeight;
|
||||
if (height.get() === 0) {
|
||||
height.set(rectHeight);
|
||||
width.set(rect.width);
|
||||
} else {
|
||||
height.start(rectHeight);
|
||||
width.start(rect.width);
|
||||
}
|
||||
},
|
||||
[height, width]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
getNewHeight();
|
||||
const observer = new MutationObserver(() => {
|
||||
getNewHeight();
|
||||
});
|
||||
observer.observe(ref.current, {
|
||||
attributes: false,
|
||||
childList: true,
|
||||
subtree: false,
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [getNewHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
getNewHeight(false);
|
||||
});
|
||||
pages?.forEach((el) => observer.observe(el));
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [pages, getNewHeight]);
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
style={{
|
||||
height,
|
||||
width: isMobile ? "100%" : width,
|
||||
}}
|
||||
className="relative flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
{props.children}
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingCard(props: RootFloatingCardProps) {
|
||||
const { isMobile } = useIsMobile();
|
||||
const content = <CardBase>{props.children}</CardBase>;
|
||||
|
||||
if (isMobile)
|
||||
return (
|
||||
<FloatingCardMobilePosition
|
||||
className={props.className}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
{content}
|
||||
</FloatingCardMobilePosition>
|
||||
);
|
||||
|
||||
return (
|
||||
<FloatingCardAnchorPosition id={props.for} className={props.className}>
|
||||
{content}
|
||||
</FloatingCardAnchorPosition>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoutFloatingCard(props: FloatingCardProps) {
|
||||
return (
|
||||
<FloatingCard
|
||||
className="overflow-hidden rounded-md bg-ash-300"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const FloatingCardView = {
|
||||
Header(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
close?: boolean;
|
||||
goBack: () => any;
|
||||
action?: React.ReactNode;
|
||||
backText?: string;
|
||||
}) {
|
||||
let left = (
|
||||
<div
|
||||
onClick={props.goBack}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<Icon icon={Icons.ARROW_LEFT} />
|
||||
<span>{props.backText || "Go back"}</span>
|
||||
</div>
|
||||
);
|
||||
if (props.close)
|
||||
left = (
|
||||
<div
|
||||
onClick={props.goBack}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<Icon icon={Icons.X} />
|
||||
<span>Close</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-[#1C161B]">
|
||||
<FloatingDragHandle />
|
||||
<PopoutSection>
|
||||
<div className="flex justify-between">
|
||||
<div>{left}</div>
|
||||
<div>{props.action ?? null}</div>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 mb-2 text-3xl font-bold text-white">
|
||||
{props.title}
|
||||
</h2>
|
||||
<p>{props.description}</p>
|
||||
</PopoutSection>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Content(props: { children: React.ReactNode; noSection?: boolean }) {
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[1fr]">
|
||||
{props.noSection ? (
|
||||
<div className="relative h-full overflow-y-auto bg-ash-300">
|
||||
{props.children}
|
||||
</div>
|
||||
) : (
|
||||
<PopoutSection className="relative h-full overflow-y-auto bg-ash-300">
|
||||
{props.children}
|
||||
</PopoutSection>
|
||||
)}
|
||||
<MobilePopoutSpacer />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
56
src/components/popout/FloatingContainer.tsx
Normal file
56
src/components/popout/FloatingContainer.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { Transition } from "@/components/Transition";
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
onClose?: () => void;
|
||||
show?: boolean;
|
||||
darken?: boolean;
|
||||
}
|
||||
|
||||
export function FloatingContainer(props: Props) {
|
||||
const target = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function listen(e: MouseEvent) {
|
||||
target.current = e.target as Element;
|
||||
}
|
||||
document.addEventListener("mousedown", listen);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", listen);
|
||||
};
|
||||
});
|
||||
|
||||
const click = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const startedTarget = target.current;
|
||||
target.current = null;
|
||||
if (e.currentTarget !== e.target) return;
|
||||
if (!startedTarget) return;
|
||||
if (!startedTarget.isEqualNode(e.currentTarget as Element)) return;
|
||||
if (props.onClose) props.onClose();
|
||||
},
|
||||
[props]
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<Transition show={props.show} animation="none">
|
||||
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none">
|
||||
<Transition animation="fade" isChild>
|
||||
<div
|
||||
onClick={click}
|
||||
className={[
|
||||
"absolute inset-0",
|
||||
props.darken ? "bg-black opacity-90" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
</Transition>
|
||||
<Transition animation="slide-up" className="h-0" isChild>
|
||||
{props.children}
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>,
|
||||
document.body
|
||||
);
|
||||
}
|
19
src/components/popout/FloatingDragHandle.tsx
Normal file
19
src/components/popout/FloatingDragHandle.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
|
||||
export function FloatingDragHandle() {
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
if (!isMobile) return null;
|
||||
|
||||
return (
|
||||
<div className="relative z-50 mx-auto my-3 -mb-3 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" />
|
||||
);
|
||||
}
|
||||
|
||||
export function MobilePopoutSpacer() {
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
if (!isMobile) return null;
|
||||
|
||||
return <div className="h-[200px]" />;
|
||||
}
|
39
src/components/popout/FloatingView.tsx
Normal file
39
src/components/popout/FloatingView.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { Transition } from "@/components/Transition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
show?: boolean;
|
||||
className?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
active?: boolean; // true if a child view is loaded
|
||||
}
|
||||
|
||||
export function FloatingView(props: Props) {
|
||||
const { isMobile } = useIsMobile();
|
||||
const width = !isMobile ? `${props.width}px` : "100%";
|
||||
return (
|
||||
<Transition
|
||||
animation={props.active ? "slide-full-left" : "slide-full-right"}
|
||||
className="absolute inset-0"
|
||||
durationClass="duration-[400ms]"
|
||||
show={props.show}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
props.className ?? "",
|
||||
"grid grid-rows-[auto,minmax(0,1fr)]",
|
||||
].join(" ")}
|
||||
data-floating-page={props.show ? "true" : undefined}
|
||||
style={{
|
||||
height: props.height ? `${props.height}px` : undefined,
|
||||
width: props.width ? width : undefined,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface AnchorPositionProps {
|
||||
children?: ReactNode;
|
||||
id: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FloatingCardAnchorPosition(props: AnchorPositionProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [left, setLeft] = useState<number>(0);
|
||||
const [top, setTop] = useState<number>(0);
|
||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
|
||||
const calculateAndSetCoords = useCallback(
|
||||
(anchor: DOMRect, card: DOMRect) => {
|
||||
const buttonCenter = anchor.left + anchor.width / 2;
|
||||
const bottomReal = window.innerHeight - anchor.bottom;
|
||||
|
||||
setTop(
|
||||
window.innerHeight - bottomReal - anchor.height - card.height - 30
|
||||
);
|
||||
setLeft(
|
||||
Math.min(
|
||||
buttonCenter - card.width / 2,
|
||||
window.innerWidth - card.width - 30
|
||||
)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchorRect || !cardRect) return;
|
||||
calculateAndSetCoords(anchorRect, cardRect);
|
||||
}, [anchorRect, calculateAndSetCoords, cardRect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
function checkBox() {
|
||||
const divRect = ref.current?.getBoundingClientRect();
|
||||
setCardRect(divRect ?? null);
|
||||
}
|
||||
checkBox();
|
||||
const observer = new ResizeObserver(checkBox);
|
||||
observer.observe(ref.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const evtStr = createFloatingAnchorEvent(props.id);
|
||||
if ((window as any)[evtStr]) setAnchorRect((window as any)[evtStr]);
|
||||
function listen(ev: CustomEvent<DOMRect>) {
|
||||
setAnchorRect(ev.detail);
|
||||
}
|
||||
document.addEventListener(evtStr, listen as any);
|
||||
return () => {
|
||||
document.removeEventListener(evtStr, listen as any);
|
||||
};
|
||||
}, [props.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
transform: `translateX(${left}px) translateY(${top}px)`,
|
||||
}}
|
||||
className={[
|
||||
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden",
|
||||
props.className ?? "",
|
||||
].join(" ")}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
import { useSpring, animated, config } from "@react-spring/web";
|
||||
import { useDrag } from "@use-gesture/react";
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface MobilePositionProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const closing = useRef<boolean>(false);
|
||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
|
||||
const [{ y }, api] = useSpring(() => ({
|
||||
y: 0,
|
||||
onRest() {
|
||||
if (!closing.current) return;
|
||||
if (props.onClose) props.onClose();
|
||||
},
|
||||
}));
|
||||
|
||||
const bind = useDrag(
|
||||
({ last, velocity: [, vy], direction: [, dy], movement: [, my] }) => {
|
||||
if (closing.current) return;
|
||||
const height = cardRect?.height ?? 0;
|
||||
if (last) {
|
||||
// if past half height downwards
|
||||
// OR Y velocity is past 0.5 AND going down AND 20 pixels below start position
|
||||
if (my > height * 0.5 || (vy > 0.5 && dy > 0 && my > 20)) {
|
||||
api.start({
|
||||
y: height * 1.2,
|
||||
immediate: false,
|
||||
config: { ...config.wobbly, velocity: vy, clamp: true },
|
||||
});
|
||||
closing.current = true;
|
||||
} else {
|
||||
api.start({
|
||||
y: 0,
|
||||
immediate: false,
|
||||
config: config.wobbly,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
api.start({ y: my, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
from: () => [0, y.get()],
|
||||
filterTaps: true,
|
||||
bounds: { top: 0 },
|
||||
rubberband: true,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
function checkBox() {
|
||||
const divRect = ref.current?.getBoundingClientRect();
|
||||
setCardRect(divRect ?? null);
|
||||
}
|
||||
checkBox();
|
||||
const observer = new ResizeObserver(checkBox);
|
||||
observer.observe(ref.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
window.innerHeight - (cardRect?.height ?? 0) + 200
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={[props.className ?? "", "touch-none"].join(" ")}
|
||||
style={{
|
||||
y,
|
||||
}}
|
||||
{...bind()}
|
||||
>
|
||||
{props.children}
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
}
|
60
src/hooks/useFloatingRouter.ts
Normal file
60
src/hooks/useFloatingRouter.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
export function useFloatingRouter(initial = "/") {
|
||||
const [route, setRoute] = useState<string[]>(
|
||||
initial.split("/").filter((v) => v.length > 0)
|
||||
);
|
||||
const [previousRoute, setPreviousRoute] = useState(route);
|
||||
const currentPage = route[route.length - 1] ?? "/";
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (previousRoute.length === route.length) return;
|
||||
// when navigating backwards, we delay the updating by a bit so transitions can be applied correctly
|
||||
setTimeout(() => {
|
||||
setPreviousRoute(route);
|
||||
}, 20);
|
||||
}, [route, previousRoute]);
|
||||
|
||||
function navigate(path: string) {
|
||||
const newRoute = path.split("/").filter((v) => v.length > 0);
|
||||
if (newRoute.length > previousRoute.length) setPreviousRoute(newRoute);
|
||||
setRoute(newRoute);
|
||||
}
|
||||
|
||||
function isActive(page: string) {
|
||||
if (page === "/") return true;
|
||||
const index = previousRoute.indexOf(page);
|
||||
if (index === -1) return false; // not active
|
||||
if (index === previousRoute.length - 1) return false; // active but latest route so shouldnt be counted as active
|
||||
return true;
|
||||
}
|
||||
|
||||
function isCurrentPage(page: string) {
|
||||
return page === currentPage;
|
||||
}
|
||||
|
||||
function isLoaded(page: string) {
|
||||
if (page === "/") return true;
|
||||
return route.includes(page);
|
||||
}
|
||||
|
||||
function pageProps(page: string) {
|
||||
return {
|
||||
show: isCurrentPage(page),
|
||||
active: isActive(page),
|
||||
};
|
||||
}
|
||||
|
||||
function reset() {
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
return {
|
||||
navigate,
|
||||
reset,
|
||||
isLoaded,
|
||||
isCurrentPage,
|
||||
pageProps,
|
||||
isActive,
|
||||
};
|
||||
}
|
|
@ -15,6 +15,7 @@ import { EmbedTesterView } from "@/views/developer/EmbedTesterView";
|
|||
import { SettingsView } from "@/views/settings/SettingsView";
|
||||
import { BannerContextProvider } from "@/hooks/useBanner";
|
||||
import { Layout } from "@/setup/Layout";
|
||||
import { TestView } from "@/views/developer/TestView";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
@ -46,6 +47,7 @@ function App() {
|
|||
|
||||
{/* other */}
|
||||
<Route exact path="/dev" component={DeveloperView} />
|
||||
<Route exact path="/dev/test" component={TestView} />
|
||||
<Route exact path="/dev/video" component={VideoTesterView} />
|
||||
<Route
|
||||
exact
|
||||
|
|
|
@ -62,6 +62,7 @@
|
|||
"source": "Source",
|
||||
"captions": "Captions",
|
||||
"download": "Download",
|
||||
"settings": "Settings",
|
||||
"pictureInPicture": "Picture in Picture"
|
||||
},
|
||||
"popouts": {
|
||||
|
@ -77,6 +78,13 @@
|
|||
"errors": {
|
||||
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
||||
"embedsError": "Something went wrong loading the embeds for this thing that you like"
|
||||
},
|
||||
"descriptions": {
|
||||
"sources": "What provider do you want to use?",
|
||||
"embeds": "Choose which video to view",
|
||||
"seasons": "Choose which season you want to watch",
|
||||
"episode": "Pick an episode",
|
||||
"captions": "Choose a subtitle language"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
|
|
|
@ -10,10 +10,7 @@ import { MobileCenterAction } from "@/video/components/actions/MobileCenterActio
|
|||
import { PageTitleAction } from "@/video/components/actions/PageTitleAction";
|
||||
import { PauseAction } from "@/video/components/actions/PauseAction";
|
||||
import { ProgressAction } from "@/video/components/actions/ProgressAction";
|
||||
import { QualityDisplayAction } from "@/video/components/actions/QualityDisplayAction";
|
||||
import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction";
|
||||
import { SourceSelectionAction } from "@/video/components/actions/SourceSelectionAction";
|
||||
import { CaptionsSelectionAction } from "@/video/components/actions/CaptionsSelectionAction";
|
||||
import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
|
||||
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
|
||||
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
|
||||
|
@ -30,9 +27,10 @@ import { ReactNode, useCallback, useState } from "react";
|
|||
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
|
||||
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
|
||||
import { DownloadAction } from "@/video/components/actions/DownloadAction";
|
||||
import { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
|
||||
import { CaptionRenderer } from "./CaptionRenderer";
|
||||
import { SettingsAction } from "./actions/SettingsAction";
|
||||
import { DividerAction } from "./actions/DividerAction";
|
||||
|
||||
type Props = VideoPlayerBaseProps;
|
||||
|
||||
|
@ -145,11 +143,9 @@ export function VideoPlayer(props: Props) {
|
|||
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center">
|
||||
<div />
|
||||
<div className="flex items-center justify-center">
|
||||
<DownloadAction />
|
||||
<PictureInPictureAction />
|
||||
<CaptionsSelectionAction />
|
||||
<SeriesSelectionAction />
|
||||
<SourceSelectionAction />
|
||||
<PictureInPictureAction />
|
||||
<SettingsAction />
|
||||
</div>
|
||||
<FullscreenAction />
|
||||
</div>
|
||||
|
@ -157,15 +153,12 @@ export function VideoPlayer(props: Props) {
|
|||
<>
|
||||
<LeftSideControls />
|
||||
<div className="flex-1" />
|
||||
<QualityDisplayAction />
|
||||
<SeriesSelectionAction />
|
||||
<SourceSelectionAction />
|
||||
<div className="mx-2 h-6 w-px bg-white opacity-50" />
|
||||
<DividerAction />
|
||||
<SettingsAction />
|
||||
<ChromecastAction />
|
||||
<AirplayAction />
|
||||
<DownloadAction />
|
||||
<PictureInPictureAction />
|
||||
<CaptionsSelectionAction />
|
||||
<FullscreenAction />
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -27,7 +27,9 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
|
|||
|
||||
const children =
|
||||
typeof props.children === "function"
|
||||
? props.children({ isFullscreen: videoInterface.isFullscreen })
|
||||
? props.children({
|
||||
isFullscreen: videoInterface.isFullscreen,
|
||||
})
|
||||
: props.children;
|
||||
|
||||
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
|
||||
|
|
|
@ -19,8 +19,20 @@ export function BackdropAction(props: BackdropActionProps) {
|
|||
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clickareaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const lastTouchEnd = useRef<number>(0);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
if (!moved) setMoved(true);
|
||||
if (!moved) {
|
||||
setTimeout(() => {
|
||||
const isTouch = Date.now() - lastTouchEnd.current < 200;
|
||||
if (!isTouch) {
|
||||
setMoved(true);
|
||||
}
|
||||
}, 20);
|
||||
return;
|
||||
}
|
||||
|
||||
// remove after all
|
||||
if (timeout.current) clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => {
|
||||
if (moved) setMoved(false);
|
||||
|
@ -32,8 +44,6 @@ export function BackdropAction(props: BackdropActionProps) {
|
|||
setMoved(false);
|
||||
}, [setMoved]);
|
||||
|
||||
const [lastTouchEnd, setLastTouchEnd] = useState(0);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
||||
|
@ -43,13 +53,17 @@ export function BackdropAction(props: BackdropActionProps) {
|
|||
if (videoInterface.popout !== null) return;
|
||||
|
||||
if ((e as React.TouchEvent).type === "touchend") {
|
||||
setLastTouchEnd(Date.now());
|
||||
lastTouchEnd.current = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e as React.MouseEvent<HTMLDivElement>).button !== 0) {
|
||||
return; // not main button (left click), exit event
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (Date.now() - lastTouchEnd < 200) {
|
||||
setMoved(!moved);
|
||||
if (Date.now() - lastTouchEnd.current < 200) {
|
||||
setMoved((v) => !v);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -57,7 +71,7 @@ export function BackdropAction(props: BackdropActionProps) {
|
|||
else controls.play();
|
||||
}, 20);
|
||||
},
|
||||
[controls, mediaPlaying, videoInterface, lastTouchEnd, moved]
|
||||
[controls, mediaPlaying, videoInterface]
|
||||
);
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import { Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CaptionsSelectionAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="relative">
|
||||
<PopoutAnchor for="captions">
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
text={isMobile ? (t("videoPlayer.buttons.captions") as string) : ""}
|
||||
wide={isMobile}
|
||||
onClick={() => controls.openPopout("captions")}
|
||||
icon={Icons.CAPTIONS}
|
||||
/>
|
||||
</PopoutAnchor>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
12
src/video/components/actions/DividerAction.tsx
Normal file
12
src/video/components/actions/DividerAction.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { MWMediaType } from "@/backend/metadata/types";
|
||||
|
||||
export function DividerAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
|
||||
if (meta?.meta.meta.type !== MWMediaType.SERIES) return null;
|
||||
|
||||
return <div className="mx-2 h-6 w-px bg-white opacity-50" />;
|
||||
}
|
|
@ -4,9 +4,9 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
|||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
|
@ -24,7 +24,7 @@ export function SeriesSelectionAction(props: Props) {
|
|||
return (
|
||||
<div className={props.className}>
|
||||
<div className="relative">
|
||||
<PopoutAnchor for="episodes">
|
||||
<FloatingAnchor id="episodes">
|
||||
<VideoPlayerIconButton
|
||||
active={videoInterface.popout === "episodes"}
|
||||
icon={Icons.EPISODES}
|
||||
|
@ -32,7 +32,7 @@ export function SeriesSelectionAction(props: Props) {
|
|||
wide
|
||||
onClick={() => controls.openPopout("episodes")}
|
||||
/>
|
||||
</PopoutAnchor>
|
||||
</FloatingAnchor>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -2,33 +2,38 @@ import { Icons } from "@/components/Icon";
|
|||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { PopoutAnchor } from "@/video/components/popouts/PopoutAnchor";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourceSelectionAction(props: Props) {
|
||||
export function SettingsAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const controls = useControls(descriptor);
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const { isMobile } = useIsMobile(false);
|
||||
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="relative">
|
||||
<PopoutAnchor for="source">
|
||||
<FloatingAnchor id="settings">
|
||||
<VideoPlayerIconButton
|
||||
active={videoInterface.popout === "source"}
|
||||
icon={Icons.CLAPPER_BOARD}
|
||||
iconSize="text-xl"
|
||||
text={t("videoPlayer.buttons.source") as string}
|
||||
wide
|
||||
onClick={() => controls.openPopout("source")}
|
||||
active={videoInterface.popout === "settings"}
|
||||
className={props.className}
|
||||
onClick={() => controls.openPopout("settings")}
|
||||
text={
|
||||
isMobile
|
||||
? (t("videoPlayer.buttons.settings") as string)
|
||||
: undefined
|
||||
}
|
||||
icon={Icons.GEAR}
|
||||
/>
|
||||
</PopoutAnchor>
|
||||
</FloatingAnchor>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,17 @@
|
|||
import { Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
|
||||
interface Props {
|
||||
onClick: () => any;
|
||||
}
|
||||
|
||||
export function CaptionsSelectionAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PopoutListAction icon={Icons.CAPTIONS} onClick={props.onClick}>
|
||||
{t("videoPlayer.buttons.captions")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
|
@ -3,39 +3,29 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
|||
import { useSource } from "@/video/state/logic/source";
|
||||
import { MWStreamType } from "@/backend/helpers/streams";
|
||||
import { normalizeTitle } from "@/utils/normalizeTitle";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DownloadAction(props: Props) {
|
||||
export function DownloadAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const sourceInterface = useSource(descriptor);
|
||||
const { isMobile } = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
const meta = useMeta(descriptor);
|
||||
|
||||
const isHLS = sourceInterface.source?.type === MWStreamType.HLS;
|
||||
|
||||
if (isHLS) return null;
|
||||
|
||||
const title = meta?.meta.meta.title;
|
||||
|
||||
return (
|
||||
<a
|
||||
<PopoutListAction
|
||||
href={isHLS ? undefined : sourceInterface.source?.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
download={title ? `${normalizeTitle(title)}.mp4` : undefined}
|
||||
icon={Icons.DOWNLOAD}
|
||||
>
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
icon={Icons.DOWNLOAD}
|
||||
disabled={isHLS}
|
||||
text={isMobile ? (t("videoPlayer.buttons.download") as string) : ""}
|
||||
/>
|
||||
</a>
|
||||
{t("videoPlayer.buttons.download")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
||||
import { QualityDisplayAction } from "./QualityDisplayAction";
|
||||
|
||||
interface Props {
|
||||
onClick?: () => any;
|
||||
}
|
||||
|
||||
export function SourceSelectionAction(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PopoutListAction
|
||||
icon={Icons.CLAPPER_BOARD}
|
||||
onClick={props.onClick}
|
||||
right={<QualityDisplayAction />}
|
||||
noChevron
|
||||
>
|
||||
{t("videoPlayer.buttons.source")}
|
||||
</PopoutListAction>
|
||||
);
|
||||
}
|
|
@ -33,7 +33,7 @@ export const VideoPlayerIconButton = forwardRef<
|
|||
className={[
|
||||
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 transition-colors duration-100",
|
||||
props.active ? "!bg-denim-500 !bg-opacity-100" : "",
|
||||
!props.noPadding ? (props.wide ? "py-2 px-4" : "p-2") : "",
|
||||
!props.noPadding ? (props.wide ? "p-2 sm:px-4" : "p-2") : "",
|
||||
!props.disabled
|
||||
? "group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100"
|
||||
: "",
|
||||
|
|
|
@ -6,6 +6,9 @@ import {
|
|||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import { IconButton } from "@/components/buttons/IconButton";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
import { FloatingView } from "@/components/popout/FloatingView";
|
||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
|
@ -20,7 +23,10 @@ function makeCaptionId(caption: MWCaption, isLinked: boolean): string {
|
|||
return isLinked ? `linked-${caption.langIso}` : `external-${caption.langIso}`;
|
||||
}
|
||||
|
||||
export function CaptionSelectionPopout() {
|
||||
export function CaptionSelectionPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
|
@ -69,77 +75,73 @@ export function CaptionSelectionPopout() {
|
|||
const [showCaptionSettings, setShowCaptionSettings] =
|
||||
useState<boolean>(false);
|
||||
return (
|
||||
<>
|
||||
<PopoutSection className="flex flex-row justify-between bg-ash-100 font-bold text-white">
|
||||
<div>{t("videoPlayer.popouts.captions")}</div>
|
||||
<IconButton
|
||||
icon={Icons.SETTINGS}
|
||||
onClick={() => {
|
||||
setShowCaptionSettings((old) => !old);
|
||||
}}
|
||||
/>
|
||||
</PopoutSection>
|
||||
{showCaptionSettings ? (
|
||||
<CaptionSettingsPopout />
|
||||
) : (
|
||||
<div className="relative overflow-y-auto">
|
||||
<PopoutSection>
|
||||
<PopoutListEntry
|
||||
active={!currentCaption}
|
||||
onClick={() => {
|
||||
controls.clearCaption();
|
||||
controls.closePopout();
|
||||
}}
|
||||
>
|
||||
{t("videoPlayer.popouts.noCaptions")}
|
||||
</PopoutListEntry>
|
||||
<PopoutListEntry
|
||||
key={CUSTOM_CAPTION_ID}
|
||||
active={currentCaption === CUSTOM_CAPTION_ID}
|
||||
loading={loadingCustomCaption}
|
||||
errored={!!errorCustomCaption}
|
||||
onClick={() => {
|
||||
customCaptionUploadElement.current?.click();
|
||||
}}
|
||||
>
|
||||
{currentCaption === CUSTOM_CAPTION_ID
|
||||
? t("videoPlayer.popouts.customCaption")
|
||||
: t("videoPlayer.popouts.uploadCustomCaption")}
|
||||
<input
|
||||
ref={customCaptionUploadElement}
|
||||
type="file"
|
||||
onChange={handleUploadCaption}
|
||||
className="hidden"
|
||||
accept=".vtt, .srt"
|
||||
/>
|
||||
</PopoutListEntry>
|
||||
</PopoutSection>
|
||||
<FloatingView
|
||||
{...props.router.pageProps(props.prefix)}
|
||||
width={320}
|
||||
height={500}
|
||||
>
|
||||
<FloatingCardView.Header
|
||||
title={t("videoPlayer.popouts.captions")}
|
||||
description={t("videoPlayer.popouts.descriptions.captions")}
|
||||
goBack={() => props.router.navigate("/")}
|
||||
/>
|
||||
<FloatingCardView.Content noSection>
|
||||
<PopoutSection>
|
||||
<PopoutListEntry
|
||||
active={!currentCaption}
|
||||
onClick={() => {
|
||||
controls.clearCaption();
|
||||
controls.closePopout();
|
||||
}}
|
||||
>
|
||||
{t("videoPlayer.popouts.noCaptions")}
|
||||
</PopoutListEntry>
|
||||
<PopoutListEntry
|
||||
key={CUSTOM_CAPTION_ID}
|
||||
active={currentCaption === CUSTOM_CAPTION_ID}
|
||||
loading={loadingCustomCaption}
|
||||
errored={!!errorCustomCaption}
|
||||
onClick={() => {
|
||||
customCaptionUploadElement.current?.click();
|
||||
}}
|
||||
>
|
||||
{currentCaption === CUSTOM_CAPTION_ID
|
||||
? t("videoPlayer.popouts.customCaption")
|
||||
: t("videoPlayer.popouts.uploadCustomCaption")}
|
||||
<input
|
||||
ref={customCaptionUploadElement}
|
||||
type="file"
|
||||
onChange={handleUploadCaption}
|
||||
className="hidden"
|
||||
accept=".vtt, .srt"
|
||||
/>
|
||||
</PopoutListEntry>
|
||||
</PopoutSection>
|
||||
|
||||
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-200 px-5 py-3 text-sm font-bold uppercase">
|
||||
<Icon className="text-base" icon={Icons.LINK} />
|
||||
<span>{t("videoPlayer.popouts.linkedCaptions")}</span>
|
||||
</p>
|
||||
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-300 px-5 py-3 text-xs font-bold uppercase">
|
||||
<Icon className="text-base" icon={Icons.LINK} />
|
||||
<span>{t("videoPlayer.popouts.linkedCaptions")}</span>
|
||||
</p>
|
||||
|
||||
<PopoutSection className="pt-0">
|
||||
<div>
|
||||
{linkedCaptions.map((link) => (
|
||||
<PopoutListEntry
|
||||
key={link.langIso}
|
||||
active={link.id === currentCaption}
|
||||
loading={loading && link.id === loadingId.current}
|
||||
errored={error && link.id === loadingId.current}
|
||||
onClick={() => {
|
||||
loadingId.current = link.id;
|
||||
setCaption(link, true);
|
||||
}}
|
||||
>
|
||||
{link.langIso}
|
||||
</PopoutListEntry>
|
||||
))}
|
||||
</div>
|
||||
</PopoutSection>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<PopoutSection className="pt-0">
|
||||
<div>
|
||||
{linkedCaptions.map((link) => (
|
||||
<PopoutListEntry
|
||||
key={link.langIso}
|
||||
active={link.id === currentCaption}
|
||||
loading={loading && link.id === loadingId.current}
|
||||
errored={error && link.id === loadingId.current}
|
||||
onClick={() => {
|
||||
loadingId.current = link.id;
|
||||
setCaption(link, true);
|
||||
}}
|
||||
>
|
||||
{link.langIso}
|
||||
</PopoutListEntry>
|
||||
))}
|
||||
</div>
|
||||
</PopoutSection>
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,19 +12,22 @@ import { useMeta } from "@/video/state/logic/meta";
|
|||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useWatchedContext } from "@/state/watched";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
import { FloatingView } from "@/components/popout/FloatingView";
|
||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
import { PopoutListEntry } from "./PopoutUtils";
|
||||
|
||||
export function EpisodeSelectionPopout() {
|
||||
const params = useParams<{
|
||||
media: string;
|
||||
}>();
|
||||
const { t } = useTranslation();
|
||||
const { pageProps, navigate } = useFloatingRouter("/episodes");
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
const controls = useControls(descriptor);
|
||||
|
||||
const [isPickingSeason, setIsPickingSeason] = useState<boolean>(false);
|
||||
const [currentVisibleSeason, setCurrentVisibleSeason] = useState<{
|
||||
seasonId: string;
|
||||
season?: MWSeasonWithEpisodeMeta;
|
||||
|
@ -40,7 +43,6 @@ export function EpisodeSelectionPopout() {
|
|||
seasonId: sId,
|
||||
season: undefined,
|
||||
});
|
||||
setIsPickingSeason(false);
|
||||
reqSeasonMeta(decodeJWId(params.media)?.id as string, sId).then((v) => {
|
||||
if (v?.meta.type !== MWMediaType.SERIES) return;
|
||||
setCurrentVisibleSeason({
|
||||
|
@ -79,80 +81,59 @@ export function EpisodeSelectionPopout() {
|
|||
)?.episodes;
|
||||
}, [meta, currentSeasonId, currentVisibleSeason]);
|
||||
|
||||
const toggleIsPickingSeason = () => {
|
||||
setIsPickingSeason(!isPickingSeason);
|
||||
};
|
||||
|
||||
const setSeason = (id: string) => {
|
||||
requestSeason(id);
|
||||
setCurrentVisibleSeason({ seasonId: id });
|
||||
navigate("/episodes");
|
||||
};
|
||||
|
||||
const { watched } = useWatchedContext();
|
||||
|
||||
const titlePositionClass = useMemo(() => {
|
||||
const offset = isPickingSeason ? "left-0" : "left-10";
|
||||
return [
|
||||
"absolute w-full transition-[left,opacity] duration-200",
|
||||
offset,
|
||||
].join(" ");
|
||||
}, [isPickingSeason]);
|
||||
const closePopout = () => {
|
||||
controls.closePopout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopoutSection className="bg-ash-100 font-bold text-white">
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
className={[
|
||||
"-m-1.5 rounded-lg p-1.5 transition-opacity duration-100 hover:bg-ash-200",
|
||||
isPickingSeason ? "pointer-events-none opacity-0" : "opacity-1",
|
||||
].join(" ")}
|
||||
onClick={toggleIsPickingSeason}
|
||||
type="button"
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} />
|
||||
</button>
|
||||
<span
|
||||
className={[
|
||||
titlePositionClass,
|
||||
!isPickingSeason ? "opacity-1" : "opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
{currentSeasonInfo?.title || ""}
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
titlePositionClass,
|
||||
isPickingSeason ? "opacity-1" : "opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("videoPlayer.popouts.seasons")}
|
||||
</span>
|
||||
</div>
|
||||
</PopoutSection>
|
||||
<div className="relative grid h-full grid-rows-[minmax(1px,1fr)]">
|
||||
<PopoutSection
|
||||
className={[
|
||||
"absolute inset-0 z-30 overflow-y-auto border-ash-400 bg-ash-100 transition-[max-height,padding] duration-200",
|
||||
isPickingSeason
|
||||
? "max-h-full border-t"
|
||||
: "max-h-0 overflow-hidden py-0",
|
||||
].join(" ")}
|
||||
>
|
||||
<FloatingView {...pageProps("seasons")} height={600} width={375}>
|
||||
<FloatingCardView.Header
|
||||
title={t("videoPlayer.popouts.seasons")}
|
||||
description={t("videoPlayer.popouts.descriptions.seasons")}
|
||||
goBack={() => navigate("/episodes")}
|
||||
backText={`To ${currentSeasonInfo?.title.toLowerCase()}`}
|
||||
/>
|
||||
<FloatingCardView.Content>
|
||||
{currentSeasonInfo
|
||||
? meta?.seasons?.map?.((season) => (
|
||||
<PopoutListEntry
|
||||
key={season.id}
|
||||
active={meta?.episode?.seasonId === season.id}
|
||||
onClick={() => setSeason(season.id)}
|
||||
isOnDarkBackground
|
||||
>
|
||||
{season.title}
|
||||
</PopoutListEntry>
|
||||
))
|
||||
: "No season"}
|
||||
</PopoutSection>
|
||||
<PopoutSection className="relative h-full overflow-y-auto">
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
<FloatingView {...pageProps("episodes")} height={600} width={375}>
|
||||
<FloatingCardView.Header
|
||||
title={currentSeasonInfo?.title ?? "Unknown season"}
|
||||
description={t("videoPlayer.popouts.descriptions.episode")}
|
||||
goBack={closePopout}
|
||||
close
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/episodes/seasons")}
|
||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
||||
>
|
||||
<span>Other seasons</span>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<FloatingCardView.Content>
|
||||
{loading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading />
|
||||
|
@ -165,7 +146,7 @@ export function EpisodeSelectionPopout() {
|
|||
className="text-xl text-bink-600"
|
||||
/>
|
||||
<p className="mt-6 w-full text-center">
|
||||
{t("videoPLayer.popouts.errors.loadingWentWrong", {
|
||||
{t("videoPlayer.popouts.errors.loadingWentWrong", {
|
||||
seasonTitle: currentSeasonInfo?.title?.toLowerCase(),
|
||||
})}
|
||||
</p>
|
||||
|
@ -201,8 +182,8 @@ export function EpisodeSelectionPopout() {
|
|||
: "No episodes"}
|
||||
</div>
|
||||
)}
|
||||
</PopoutSection>
|
||||
</div>
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,76 +1,35 @@
|
|||
import { Transition } from "@/components/Transition";
|
||||
import { useSyncPopouts } from "@/video/components/hooks/useSyncPopouts";
|
||||
import { EpisodeSelectionPopout } from "@/video/components/popouts/EpisodeSelectionPopout";
|
||||
import { SourceSelectionPopout } from "@/video/components/popouts/SourceSelectionPopout";
|
||||
import { CaptionSelectionPopout } from "@/video/components/popouts/CaptionSelectionPopout";
|
||||
import { SettingsPopout } from "@/video/components/popouts/SettingsPopout";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import {
|
||||
useInterface,
|
||||
VideoInterfaceEvent,
|
||||
} from "@/video/state/logic/interface";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useCallback } from "react";
|
||||
import { PopoutFloatingCard } from "@/components/popout/FloatingCard";
|
||||
import { FloatingContainer } from "@/components/popout/FloatingContainer";
|
||||
|
||||
import "./Popouts.css";
|
||||
|
||||
function ShowPopout(props: { popoutId: string | null }) {
|
||||
// only updates popout id when a new one is set, so transitions look good
|
||||
const [popoutId, setPopoutId] = useState<string | null>(props.popoutId);
|
||||
useEffect(() => {
|
||||
if (!props.popoutId) return;
|
||||
setPopoutId(props.popoutId);
|
||||
}, [props]);
|
||||
|
||||
if (popoutId === "episodes") return <EpisodeSelectionPopout />;
|
||||
if (popoutId === "source") return <SourceSelectionPopout />;
|
||||
if (popoutId === "captions") return <CaptionSelectionPopout />;
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10">
|
||||
Unknown popout
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoutContainer(props: { videoInterface: VideoInterfaceEvent }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [right, setRight] = useState<number>(0);
|
||||
const [bottom, setBottom] = useState<number>(0);
|
||||
const [width, setWidth] = useState<number>(0);
|
||||
|
||||
const { isMobile } = useIsMobile(true);
|
||||
|
||||
const calculateAndSetCoords = useCallback((rect: DOMRect, w: number) => {
|
||||
const buttonCenter = rect.left + rect.width / 2;
|
||||
|
||||
setBottom(rect ? rect.height + 30 : 30);
|
||||
setRight(Math.max(window.innerWidth - buttonCenter - w / 2, 30));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.videoInterface.popoutBounds) return;
|
||||
calculateAndSetCoords(props.videoInterface.popoutBounds, width);
|
||||
}, [props.videoInterface.popoutBounds, calculateAndSetCoords, width]);
|
||||
|
||||
useEffect(() => {
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
setWidth(rect?.width ?? 0);
|
||||
}, []);
|
||||
function ShowPopout(props: { popoutId: string | null; onClose: () => void }) {
|
||||
const popoutMap = {
|
||||
settings: <SettingsPopout />,
|
||||
episodes: <EpisodeSelectionPopout />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
"absolute z-10 grid w-80 grid-rows-[auto,minmax(0,1fr)] overflow-hidden rounded-lg bg-ash-200",
|
||||
isMobile ? "h-[230px]" : " h-[500px]",
|
||||
].join(" ")}
|
||||
style={{
|
||||
right: `${right}px`,
|
||||
bottom: `${bottom}px`,
|
||||
}}
|
||||
>
|
||||
<ShowPopout popoutId={props.videoInterface.popout} />
|
||||
</div>
|
||||
<>
|
||||
{Object.entries(popoutMap).map(([id, el]) => (
|
||||
<FloatingContainer
|
||||
key={id}
|
||||
show={props.popoutId === id}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<PopoutFloatingCard for={id} onClose={props.onClose}>
|
||||
{el}
|
||||
</PopoutFloatingCard>
|
||||
</FloatingContainer>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -80,20 +39,9 @@ export function PopoutProviderAction() {
|
|||
const controls = useControls(descriptor);
|
||||
useSyncPopouts(descriptor);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const onClose = useCallback(() => {
|
||||
controls.closePopout();
|
||||
}, [controls]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
show={!!videoInterface.popout}
|
||||
animation="slide-up"
|
||||
className="h-full"
|
||||
>
|
||||
<div className="popout-wrapper pointer-events-auto absolute inset-0">
|
||||
<div onClick={handleClick} className="absolute inset-0" />
|
||||
<PopoutContainer videoInterface={videoInterface} />
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
return <ShowPopout popoutId={videoInterface.popout} onClose={onClose} />;
|
||||
}
|
||||
|
|
|
@ -3,16 +3,32 @@ import { Spinner } from "@/components/layout/Spinner";
|
|||
import { ProgressRing } from "@/components/layout/ProgressRing";
|
||||
import { createRef, useEffect, useRef } from "react";
|
||||
|
||||
interface PopoutListEntryTypes {
|
||||
interface PopoutListEntryBaseTypes {
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
isOnDarkBackground?: boolean;
|
||||
}
|
||||
|
||||
interface PopoutListEntryTypes extends PopoutListEntryBaseTypes {
|
||||
percentageCompleted?: number;
|
||||
loading?: boolean;
|
||||
errored?: boolean;
|
||||
}
|
||||
|
||||
interface PopoutListEntryRootTypes extends PopoutListEntryBaseTypes {
|
||||
right?: React.ReactNode;
|
||||
noChevron?: boolean;
|
||||
}
|
||||
|
||||
interface PopoutListActionTypes extends PopoutListEntryBaseTypes {
|
||||
icon?: Icons;
|
||||
right?: React.ReactNode;
|
||||
download?: string;
|
||||
href?: string;
|
||||
noChevron?: boolean;
|
||||
}
|
||||
|
||||
interface ScrollToActiveProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
|
@ -87,7 +103,7 @@ export function PopoutSection(props: PopoutSectionProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function PopoutListEntry(props: PopoutListEntryTypes) {
|
||||
export function PopoutListEntryBase(props: PopoutListEntryRootTypes) {
|
||||
const bg = props.isOnDarkBackground ? "bg-ash-200" : "bg-ash-400";
|
||||
const hover = props.isOnDarkBackground
|
||||
? "hover:bg-ash-200"
|
||||
|
@ -96,7 +112,7 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
|
|||
return (
|
||||
<div
|
||||
className={[
|
||||
"group my-2 -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
|
||||
"group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
|
||||
hover,
|
||||
props.active
|
||||
? `${bg} active text-white outline-denim-700`
|
||||
|
@ -108,34 +124,83 @@ export function PopoutListEntry(props: PopoutListEntryTypes) {
|
|||
<div className="absolute left-0 h-8 w-0.5 bg-bink-500" />
|
||||
)}
|
||||
<span className="truncate">{props.children}</span>
|
||||
<div className="relative h-4 w-4 min-w-[1rem]">
|
||||
{props.errored && (
|
||||
<Icon
|
||||
icon={Icons.WARNING}
|
||||
className="absolute inset-0 text-rose-400"
|
||||
/>
|
||||
)}
|
||||
{props.loading && !props.errored && (
|
||||
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" />
|
||||
)}
|
||||
{!props.loading && !props.errored && (
|
||||
<div className="relative min-h-[1rem] min-w-[1rem]">
|
||||
{!props.noChevron && (
|
||||
<Icon
|
||||
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100"
|
||||
icon={Icons.CHEVRON_RIGHT}
|
||||
/>
|
||||
)}
|
||||
{props.percentageCompleted && !props.loading && !props.errored ? (
|
||||
<ProgressRing
|
||||
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
|
||||
backingRingClassname="stroke-ash-500"
|
||||
percentage={
|
||||
props.percentageCompleted > 90 ? 100 : props.percentageCompleted
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{props.right}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoutListEntry(props: PopoutListEntryTypes) {
|
||||
return (
|
||||
<PopoutListEntryBase
|
||||
isOnDarkBackground={props.isOnDarkBackground}
|
||||
active={props.active}
|
||||
onClick={props.onClick}
|
||||
noChevron={props.loading || props.errored}
|
||||
right={
|
||||
<>
|
||||
{props.errored && (
|
||||
<Icon
|
||||
icon={Icons.WARNING}
|
||||
className="absolute inset-0 text-rose-400"
|
||||
/>
|
||||
)}
|
||||
{props.loading && !props.errored && (
|
||||
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" />
|
||||
)}
|
||||
{props.percentageCompleted && !props.loading && !props.errored ? (
|
||||
<ProgressRing
|
||||
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
|
||||
backingRingClassname="stroke-ash-500"
|
||||
percentage={
|
||||
props.percentageCompleted > 90 ? 100 : props.percentageCompleted
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</PopoutListEntryBase>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoutListAction(props: PopoutListActionTypes) {
|
||||
const entry = (
|
||||
<PopoutListEntryBase
|
||||
active={props.active}
|
||||
isOnDarkBackground={props.isOnDarkBackground}
|
||||
right={props.right}
|
||||
onClick={props.href ? undefined : props.onClick}
|
||||
noChevron={props.noChevron}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{props.icon ? <Icon className="text-xl" icon={props.icon} /> : null}
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
</PopoutListEntryBase>
|
||||
);
|
||||
|
||||
return props.href ? (
|
||||
<a
|
||||
href={props.href ? props.href : undefined}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
download={props.download ? props.download : undefined}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{entry}
|
||||
</a>
|
||||
) : (
|
||||
entry
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,4 +12,4 @@
|
|||
.popout-wrapper ::-webkit-scrollbar {
|
||||
/* For some reason the styles don't get applied without the width */
|
||||
width: 13px;
|
||||
}
|
||||
}
|
29
src/video/components/popouts/SettingsPopout.tsx
Normal file
29
src/video/components/popouts/SettingsPopout.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
import { FloatingDragHandle } from "@/components/popout/FloatingDragHandle";
|
||||
import { FloatingView } from "@/components/popout/FloatingView";
|
||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { DownloadAction } from "@/video/components/actions/list-entries/DownloadAction";
|
||||
import { CaptionsSelectionAction } from "@/video/components/actions/list-entries/CaptionsSelectionAction";
|
||||
import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction";
|
||||
import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
|
||||
import { SourceSelectionPopout } from "./SourceSelectionPopout";
|
||||
|
||||
export function SettingsPopout() {
|
||||
const floatingRouter = useFloatingRouter();
|
||||
const { pageProps, navigate } = floatingRouter;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingView {...pageProps("/")} width={320}>
|
||||
<FloatingDragHandle />
|
||||
<FloatingCardView.Content>
|
||||
<DownloadAction />
|
||||
<SourceSelectionAction onClick={() => navigate("/source")} />
|
||||
<CaptionsSelectionAction onClick={() => navigate("/captions")} />
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
<SourceSelectionPopout router={floatingRouter} prefix="source" />
|
||||
<CaptionSelectionPopout router={floatingRouter} prefix="captions" />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo, useRef, useState } from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
|
@ -15,7 +15,10 @@ import { runEmbedScraper, runProvider } from "@/backend/helpers/run";
|
|||
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
import { FloatingView } from "@/components/popout/FloatingView";
|
||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
||||
import { PopoutListEntry } from "./PopoutUtils";
|
||||
|
||||
interface EmbedEntryProps {
|
||||
name: string;
|
||||
|
@ -49,7 +52,10 @@ export function EmbedEntry(props: EmbedEntryProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function SourceSelectionPopout() {
|
||||
export function SourceSelectionPopout(props: {
|
||||
router: ReturnType<typeof useFloatingRouter>;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
|
@ -66,7 +72,6 @@ export function SourceSelectionPopout() {
|
|||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [scrapeResult, setScrapeResult] =
|
||||
useState<MWProviderScrapeResult | null>(null);
|
||||
const showingProvider = !!selectedProvider;
|
||||
const selectedProviderPopulated = useMemo(
|
||||
() => providers.find((v) => v.id === selectedProvider) ?? null,
|
||||
[providers, selectedProvider]
|
||||
|
@ -106,6 +111,7 @@ export function SourceSelectionPopout() {
|
|||
if (!providerId) {
|
||||
providerRef.current = null;
|
||||
setSelectedProvider(null);
|
||||
props.router.navigate(`/${props.prefix}/source`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -135,16 +141,9 @@ export function SourceSelectionPopout() {
|
|||
});
|
||||
providerRef.current = providerId;
|
||||
setSelectedProvider(providerId);
|
||||
props.router.navigate(`/${props.prefix}/source/embeds`);
|
||||
};
|
||||
|
||||
const titlePositionClass = useMemo(() => {
|
||||
const offset = !showingProvider ? "left-0" : "left-10";
|
||||
return [
|
||||
"absolute w-full transition-[left,opacity] duration-200",
|
||||
offset,
|
||||
].join(" ");
|
||||
}, [showingProvider]);
|
||||
|
||||
const visibleEmbeds = useMemo(() => {
|
||||
const embeds = scrapeResult?.embeds || [];
|
||||
|
||||
|
@ -174,45 +173,43 @@ export function SourceSelectionPopout() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<PopoutSection className="bg-ash-100 font-bold text-white">
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
className={[
|
||||
"-m-1.5 rounded-lg p-1.5 transition-opacity duration-100 hover:bg-ash-200",
|
||||
!showingProvider ? "pointer-events-none opacity-0" : "opacity-1",
|
||||
].join(" ")}
|
||||
onClick={() => selectProvider()}
|
||||
type="button"
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} />
|
||||
</button>
|
||||
<span
|
||||
className={[
|
||||
titlePositionClass,
|
||||
showingProvider ? "opacity-1" : "opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
{selectedProviderPopulated?.displayName ?? ""}
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
titlePositionClass,
|
||||
!showingProvider ? "opacity-1" : "opacity-0",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("videoPlayer.popouts.sources")}
|
||||
</span>
|
||||
</div>
|
||||
</PopoutSection>
|
||||
<div className="relative grid h-full grid-rows-[minmax(1px,1fr)]">
|
||||
<PopoutSection
|
||||
className={[
|
||||
"absolute inset-0 z-30 overflow-y-auto border-ash-400 bg-ash-100 transition-[max-height,padding] duration-200",
|
||||
showingProvider
|
||||
? "max-h-full border-t"
|
||||
: "max-h-0 overflow-hidden py-0",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* List providers */}
|
||||
<FloatingView
|
||||
{...props.router.pageProps(props.prefix)}
|
||||
width={320}
|
||||
height={500}
|
||||
>
|
||||
<FloatingCardView.Header
|
||||
title={t("videoPlayer.popouts.sources")}
|
||||
description={t("videoPlayer.popouts.descriptions.sources")}
|
||||
goBack={() => props.router.navigate("/")}
|
||||
/>
|
||||
<FloatingCardView.Content>
|
||||
{providers.map((v) => (
|
||||
<PopoutListEntry
|
||||
key={v.id}
|
||||
onClick={() => {
|
||||
selectProvider(v.id);
|
||||
}}
|
||||
>
|
||||
{v.displayName}
|
||||
</PopoutListEntry>
|
||||
))}
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
|
||||
{/* List embeds */}
|
||||
<FloatingView
|
||||
{...props.router.pageProps(`embeds`)}
|
||||
width={320}
|
||||
height={500}
|
||||
>
|
||||
<FloatingCardView.Header
|
||||
title={selectedProviderPopulated?.displayName ?? ""}
|
||||
description={t("videoPlayer.popouts.descriptions.embeds")}
|
||||
goBack={() => props.router.navigate(`/${props.prefix}`)}
|
||||
/>
|
||||
<FloatingCardView.Content>
|
||||
{loading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loading />
|
||||
|
@ -268,22 +265,8 @@ export function SourceSelectionPopout() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</PopoutSection>
|
||||
<PopoutSection className="relative h-full overflow-y-auto">
|
||||
<div>
|
||||
{providers.map((v) => (
|
||||
<PopoutListEntry
|
||||
key={v.id}
|
||||
onClick={() => {
|
||||
selectProvider(v.id);
|
||||
}}
|
||||
>
|
||||
{v.displayName}
|
||||
</PopoutListEntry>
|
||||
))}
|
||||
</div>
|
||||
</PopoutSection>
|
||||
</div>
|
||||
</FloatingCardView.Content>
|
||||
</FloatingView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ export function DeveloperView() {
|
|||
linkText="Embed scraper tester"
|
||||
/>
|
||||
<ArrowLink to="/dev/video" direction="right" linkText="Video tester" />
|
||||
<ArrowLink to="/dev/test" direction="right" linkText="Test page" />
|
||||
</ThinContainer>
|
||||
</div>
|
||||
);
|
||||
|
|
4
src/views/developer/TestView.tsx
Normal file
4
src/views/developer/TestView.tsx
Normal file
|
@ -0,0 +1,4 @@
|
|||
// simple empty view, perfect for putting in tests
|
||||
export function TestView() {
|
||||
return <div />;
|
||||
}
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { useWatchedContext } from "@/state/watched";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Modal, ModalCard } from "@/components/layout/Modal";
|
||||
|
@ -22,6 +22,22 @@ function Bookmarks() {
|
|||
const bookmarks = getFilteredBookmarks();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const { watched } = useWatchedContext();
|
||||
|
||||
const bookmarksSorted = useMemo(() => {
|
||||
return bookmarks
|
||||
.map((v) => {
|
||||
return {
|
||||
...v,
|
||||
watched: watched.items
|
||||
.sort((a, b) => b.watchedAt - a.watchedAt)
|
||||
.find((watchedItem) => watchedItem.item.meta.id === v.id),
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) => (b.watched?.watchedAt || 0) - (a.watched?.watchedAt || 0)
|
||||
);
|
||||
}, [watched.items, bookmarks]);
|
||||
|
||||
if (bookmarks.length === 0) return null;
|
||||
|
||||
|
@ -34,7 +50,7 @@ function Bookmarks() {
|
|||
<EditButton editing={editing} onEdit={setEditing} />
|
||||
</SectionHeading>
|
||||
<MediaGrid ref={gridRef}>
|
||||
{bookmarks.map((v) => (
|
||||
{bookmarksSorted.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={v.id}
|
||||
media={v}
|
||||
|
@ -85,15 +101,23 @@ function Watched() {
|
|||
|
||||
function NewDomainModal() {
|
||||
const [show, setShow] = useState(
|
||||
new URLSearchParams(window.location.search).get("migrated") === "1"
|
||||
new URLSearchParams(window.location.search).get("migrated") === "1" ||
|
||||
localStorage.getItem("mw-show-domain-modal") === "true"
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
localStorage.setItem("mw-show-domain-modal", "false");
|
||||
setShow(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const newParams = new URLSearchParams(history.location.search);
|
||||
newParams.delete("migrated");
|
||||
if (newParams.get("migrated") === "1")
|
||||
localStorage.setItem("mw-show-domain-modal", "true");
|
||||
history.replace({
|
||||
search: newParams.toString(),
|
||||
});
|
||||
|
@ -161,7 +185,7 @@ function NewDomainModal() {
|
|||
<p>{t("v3.tireless")}</p>
|
||||
</div>
|
||||
<div className="mt-16 mb-6 flex items-center justify-center">
|
||||
<Button icon={Icons.PLAY} onClick={() => setShow(false)}>
|
||||
<Button icon={Icons.PLAY} onClick={() => closeModal()}>
|
||||
{t("v3.leaveAnnouncement")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
65
yarn.lock
65
yarn.lock
|
@ -1152,6 +1152,52 @@
|
|||
"@nodelib/fs.scandir" "2.1.5"
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@react-spring/animated@~9.7.1":
|
||||
version "9.7.1"
|
||||
resolved "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.1.tgz"
|
||||
integrity sha512-EX5KAD9y7sD43TnLeTNG1MgUVpuRO1YaSJRPawHNRgUWYfILge3s85anny4S4eTJGpdp5OoFV2kx9fsfeo0qsw==
|
||||
dependencies:
|
||||
"@react-spring/shared" "~9.7.1"
|
||||
"@react-spring/types" "~9.7.1"
|
||||
|
||||
"@react-spring/core@~9.7.1":
|
||||
version "9.7.1"
|
||||
resolved "https://registry.npmjs.org/@react-spring/core/-/core-9.7.1.tgz"
|
||||
integrity sha512-8K9/FaRn5VvMa24mbwYxwkALnAAyMRdmQXrARZLcBW2vxLJ6uw9Cy3d06Z8M12kEqF2bDlccaCSDsn2bSz+Q4A==
|
||||
dependencies:
|
||||
"@react-spring/animated" "~9.7.1"
|
||||
"@react-spring/rafz" "~9.7.1"
|
||||
"@react-spring/shared" "~9.7.1"
|
||||
"@react-spring/types" "~9.7.1"
|
||||
|
||||
"@react-spring/rafz@~9.7.1":
|
||||
version "9.7.1"
|
||||
resolved "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.1.tgz"
|
||||
integrity sha512-JSsrRfbEJvuE3w/uvU3mCTuWwpQcBXkwoW14lBgzK9XJhuxmscGo59AgJUpFkGOiGAVXFBGB+nEXtSinFsopgw==
|
||||
|
||||
"@react-spring/shared@~9.7.1":
|
||||
version "9.7.1"
|
||||
resolved "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.1.tgz"
|
||||
integrity sha512-R2kZ+VOO6IBeIAYTIA3C1XZ0ZVg/dDP5FKtWaY8k5akMer9iqf5H9BU0jyt3Qtxn0qQY7whQdf6MTcWtKeaawg==
|
||||
dependencies:
|
||||
"@react-spring/rafz" "~9.7.1"
|
||||
"@react-spring/types" "~9.7.1"
|
||||
|
||||
"@react-spring/types@~9.7.1":
|
||||
version "9.7.1"
|
||||
resolved "https://registry.npmjs.org/@react-spring/types/-/types-9.7.1.tgz"
|
||||
integrity sha512-yBcyfKUeZv9wf/ZFrQszvhSPuDx6Py6yMJzpMnS+zxcZmhXPeOCKZSHwqrUz1WxvuRckUhlgb7eNI/x5e1e8CA==
|
||||
|
||||
"@react-spring/web@^9.7.1":
|
||||
version "9.7.1"
|
||||
resolved "https://registry.npmjs.org/@react-spring/web/-/web-9.7.1.tgz"
|
||||
integrity sha512-6uUE5MyKqdrJnIJqlDN/AXf3i8PjOQzUuT26nkpsYxUGOk7c+vZVPcfrExLSoKzTb9kF0i66DcqzO5fXz/Z1AA==
|
||||
dependencies:
|
||||
"@react-spring/animated" "~9.7.1"
|
||||
"@react-spring/core" "~9.7.1"
|
||||
"@react-spring/shared" "~9.7.1"
|
||||
"@react-spring/types" "~9.7.1"
|
||||
|
||||
"@rollup/plugin-babel@^5.2.0":
|
||||
version "5.3.1"
|
||||
resolved "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz"
|
||||
|
@ -1417,13 +1463,6 @@
|
|||
dependencies:
|
||||
"@types/react" "^17"
|
||||
|
||||
"@types/react-helmet@^6.1.6":
|
||||
version "6.1.6"
|
||||
resolved "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.6.tgz"
|
||||
integrity sha512-ZKcoOdW/Tg+kiUbkFCBtvDw0k3nD4HJ/h/B9yWxN4uDO8OkRksWTO+EL+z/Qu3aHTeTll3Ro0Cc/8UhwBCMG5A==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-router-dom@^5.3.3":
|
||||
version "5.3.3"
|
||||
resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz"
|
||||
|
@ -1569,6 +1608,18 @@
|
|||
"@typescript-eslint/types" "5.46.1"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
|
||||
"@use-gesture/core@10.2.24":
|
||||
version "10.2.24"
|
||||
resolved "https://registry.npmjs.org/@use-gesture/core/-/core-10.2.24.tgz"
|
||||
integrity sha512-ZL7F9mgOn3Qlnp6QLI9jaOfcvqrx6JPE/BkdVSd8imveaFTm/a3udoO6f5Us/1XtqnL4347PsIiK6AtCvMHk2Q==
|
||||
|
||||
"@use-gesture/react@^10.2.24":
|
||||
version "10.2.24"
|
||||
resolved "https://registry.npmjs.org/@use-gesture/react/-/react-10.2.24.tgz"
|
||||
integrity sha512-rAZ8Nnpu1g4eFzqCPlaq+TppJpMy0dTpYOQx5KpfoBF4P3aWnCqwj7eKxcmdIb1NJKpIJj50DPugUH4mq5cpBg==
|
||||
dependencies:
|
||||
"@use-gesture/core" "10.2.24"
|
||||
|
||||
"@vitejs/plugin-react-swc@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.0.0.tgz"
|
||||
|
|
Loading…
Reference in a new issue