Merge pull request #221 from movie-web/dev

Version 3.0.8
This commit is contained in:
mrjvs 2023-03-22 22:51:23 +01:00 committed by GitHub
commit 3b0232b3d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2231 additions and 1403 deletions

View File

@ -1,48 +0,0 @@
name: Annotate linting
permissions:
actions: read # download artifact
checks: write # annotate
# this is done as a seperate workflow so
# the annotater has access to write to checks (to annotate)
on:
workflow_run:
workflows: ["Linting and Testing"]
types:
- completed
jobs:
annotate:
name: Annotate linting
runs-on: ubuntu-latest
steps:
- name: Download linting report
uses: actions/github-script@v6
with:
script: |
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{github.event.workflow_run.id }},
});
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "eslint_report.json"
})[0];
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('${{github.workspace}}/eslint_report.zip', Buffer.from(download.data));
- run: unzip eslint_report.zip
- name: Annotate linting
uses: ataylorme/eslint-annotate-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
report-json: "eslint_report.json"

View File

@ -25,15 +25,8 @@ jobs:
- name: Install Yarn packages
run: yarn install
- name: Run ESLint Report
run: yarn lint:report
# continue on error, so it still reports it in the next step
continue-on-error: true
- uses: actions/upload-artifact@v3
with:
name: eslint_report.json
path: eslint_report.json
- name: Run ESLint
run: yarn lint
building:
name: Build project

View File

@ -1,6 +1,6 @@
{
"name": "movie-web",
"version": "3.0.6",
"version": "3.0.8",
"private": true,
"homepage": "https://movie.squeezebox.dev",
"dependencies": {
@ -8,7 +8,9 @@
"@headlessui/react": "^1.5.0",
"@react-spring/web": "^9.7.1",
"@use-gesture/react": "^10.2.24",
"core-js": "^3.29.1",
"crypto-js": "^4.1.1",
"dompurify": "^3.0.1",
"fscreen": "^1.2.0",
"fuse.js": "^6.4.6",
"hls.js": "^1.0.7",
@ -17,6 +19,7 @@
"json5": "^2.2.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0",
"node-webvtt": "^1.9.4",
"ofetch": "^1.0.0",
"pako": "^2.1.0",
"react": "^17.0.2",
@ -42,9 +45,8 @@
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
"defaults",
"chrome > 90"
],
"development": [
"last 1 chrome version",
@ -53,22 +55,27 @@
]
},
"devDependencies": {
"@babel/core": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.21.0",
"@tailwindcss/line-clamp": "^0.4.2",
"@types/chromecast-caf-sender": "^1.0.5",
"@types/crypto-js": "^4.1.1",
"@types/dompurify": "^2.4.0",
"@types/fscreen": "^1.0.1",
"@types/lodash.throttle": "^4.1.7",
"@types/node": "^17.0.15",
"@types/pako": "^2.0.0",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@types/react-router": "^5.1.18",
"@types/react-helmet": "^6.1.6",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"@types/react-stickynode": "^4.0.0",
"@types/react-transition-group": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"@vitejs/plugin-react-swc": "^3.0.0",
"@vitejs/plugin-react": "^3.1.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.10.0",
"eslint-config-airbnb": "19.0.4",
@ -91,7 +98,7 @@
"vite-plugin-package-version": "^1.0.2",
"vite-plugin-pwa": "^0.14.4",
"vitest": "^0.28.5",
"workbox-window": "^6.5.4",
"@types/react-helmet": "^6.1.6"
"workbox-build": "^6.5.4",
"workbox-window": "^6.5.4"
}
}

27
src/@types/node_webtt.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
declare module "node-webvtt" {
interface Cue {
identifier: string;
start: number;
end: number;
text: string;
styles: string;
}
interface Options {
meta?: boolean;
strict?: boolean;
}
type ParserError = Error;
interface ParseResult {
valid: boolean;
strict: boolean;
cues: Cue[];
errors: ParserError[];
meta?: Map<string, string>;
}
interface Segment {
duration: number;
cues: Cue[];
}
function parse(text: string, options: Options): ParseResult;
function segment(input: string, segmentLength?: number): Segment[];
}

View File

@ -1,7 +1,9 @@
import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
import toWebVTT from "srt-webvtt";
import DOMPurify from "dompurify";
export const sanitize = DOMPurify.sanitize;
export const CUSTOM_CAPTION_ID = "customCaption";
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
if (caption.type === MWCaptionType.SRT) {

View File

@ -21,7 +21,7 @@ export type JWMediaResult = {
title: string;
poster?: string;
id: number;
original_release_year: number;
original_release_year?: number;
jw_entity_id: string;
object_type: JWContentTypes;
seasons?: JWSeasonShort[];
@ -67,7 +67,7 @@ export function formatJWMeta(
return {
title: media.title,
id: media.id.toString(),
year: media.original_release_year.toString(),
year: media.original_release_year?.toString(),
poster: media.poster
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
: undefined,

View File

@ -24,7 +24,7 @@ export type MWSeasonWithEpisodeMeta = {
type MWMediaMetaBase = {
title: string;
id: string;
year: string;
year?: string;
poster?: string;
};

View File

@ -18,15 +18,14 @@ interface FLIXMediaBase {
title: string;
url: string;
image: string;
type: "Movie" | "TV Series";
}
interface FLIXTVSerie extends FLIXMediaBase {
type: "TV Series";
seasons: number | null;
}
interface FLIXMovie extends FLIXMediaBase {
type: "Movie";
releaseDate: string;
}
@ -55,7 +54,7 @@ registerProvider({
rank: 100,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, progress }) {
async scrape({ media, episode, progress }) {
if (!this.type.includes(media.meta.type)) {
throw new Error("Unsupported type");
}
@ -66,22 +65,28 @@ registerProvider({
baseURL: flixHqBase,
}
);
const foundItem = searchResults.results.find((v: FLIXMediaBase) => {
if (media.meta.type === MWMediaType.MOVIE) {
if (v.type !== "Movie") return false;
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
);
if (media.meta.type === MWMediaType.SERIES) {
if (v.type !== "TV Series") return false;
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 false;
}
return compareTitle(serie.title, media.meta.title);
return false;
});
if (!foundItem) throw new Error("No watchable item found");
const flixId = foundItem.id;
@ -97,10 +102,24 @@ registerProvider({
if (!mediaInfo.episodes) throw new Error("No watchable item found");
// get stream info from media
progress(75);
// By default we assume it is a movie
let episodeId: string | undefined = mediaInfo.episodes[0].id;
if (media.meta.type === MWMediaType.SERIES) {
const seasonNo = media.meta.seasonData.number;
const episodeNo = media.meta.seasonData.episodes.find(
(e) => e.id === episode
)?.number;
episodeId = mediaInfo.episodes.find(
(e: any) => e.season === seasonNo && e.number === episodeNo
)?.id;
}
if (!episodeId) throw new Error("No watchable item found");
const watchInfo = await proxiedFetch<any>("/watch", {
baseURL: flixHqBase,
params: {
episodeId: mediaInfo.episodes[0].id,
episodeId,
mediaId: flixId,
},
});

View File

@ -39,6 +39,7 @@ export enum Icons {
GEAR = "gear",
WATCH_PARTY = "watch_party",
PICTURE_IN_PICTURE = "pictureInPicture",
CHECKMARK = "checkmark",
}
export interface IconProps {
@ -85,6 +86,7 @@ const iconList: Record<Icons, string> = {
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>`,
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
};
function ChromeCastButton() {

View File

@ -33,6 +33,9 @@ function MediaCardContent({
const canLink = linkable && !closable;
const dotListContent = [t(`media.${media.type}`)];
if (media.year) dotListContent.push(media.year);
return (
<div
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
@ -115,10 +118,7 @@ function MediaCardContent({
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
<span>{media.title}</span>
</h1>
<DotList
className="text-xs"
content={[t(`media.${media.type}`), media.year]}
/>
<DotList className="text-xs" content={dotListContent} />
</article>
</div>
);

View File

@ -1,6 +1,8 @@
import React, { ReactNode, Suspense } from "react";
import "core-js/stable";
import React, { Suspense } from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, HashRouter } from "react-router-dom";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
import { conf } from "@/setup/config";
import { registerSW } from "virtual:pwa-register";
@ -21,9 +23,7 @@ if (key) {
}
initializeChromecast();
registerSW({
onNeedRefresh() {
window.location.reload();
},
immediate: true,
});
const LazyLoadedApp = React.lazy(async () => {

View File

@ -1,6 +1,7 @@
import { Redirect, Route, Switch } from "react-router-dom";
import { BookmarkContextProvider } from "@/state/bookmark";
import { WatchedContextProvider } from "@/state/watched";
import { SettingsProvider } from "@/state/settings";
import { NotFoundPage } from "@/views/notfound/NotFoundView";
import { MediaView } from "@/views/media/MediaView";
@ -17,46 +18,48 @@ import { TestView } from "@/views/developer/TestView";
function App() {
return (
<WatchedContextProvider>
<BookmarkContextProvider>
<BannerContextProvider>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
<SettingsProvider>
<WatchedContextProvider>
<BookmarkContextProvider>
<BannerContextProvider>
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
{/* pages */}
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route
exact
path="/search/:type/:query?"
component={SearchView}
/>
{/* pages */}
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route
exact
path="/search/:type/:query?"
component={SearchView}
/>
{/* other */}
<Route exact path="/dev" component={DeveloperView} />
<Route exact path="/dev/test" component={TestView} />
<Route exact path="/dev/video" component={VideoTesterView} />
<Route
exact
path="/dev/providers"
component={ProviderTesterView}
/>
<Route exact path="/dev/embeds" component={EmbedTesterView} />
<Route path="*" component={NotFoundPage} />
</Switch>
</Layout>
</BannerContextProvider>
</BookmarkContextProvider>
</WatchedContextProvider>
{/* other */}
<Route exact path="/dev" component={DeveloperView} />
<Route exact path="/dev/test" component={TestView} />
<Route exact path="/dev/video" component={VideoTesterView} />
<Route
exact
path="/dev/providers"
component={ProviderTesterView}
/>
<Route exact path="/dev/embeds" component={EmbedTesterView} />
<Route path="*" component={NotFoundPage} />
</Switch>
</Layout>
</BannerContextProvider>
</BookmarkContextProvider>
</WatchedContextProvider>
</SettingsProvider>
);
}

View File

@ -1,4 +1,4 @@
export const APP_VERSION = import.meta.env.PACKAGE_VERSION;
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
export const GITHUB_LINK = "https://github.com/movie-web/movie-web";
export const APP_VERSION = "3.0.6";
export const GA_ID = "G-44YVXRL61C";

View File

@ -54,3 +54,122 @@ body[data-no-select] {
.google-cast-button:not(.casting) google-cast-launcher {
@apply brightness-[500];
}
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
:root {
--slider-height: 0.25rem;
--slider-border-radius: 1em;
--slider-progress-background: #8652bb;
}
input[type=range].styled-slider {
height: var(--slider-height);
-webkit-appearance: none;
appearance: none;
border-radius: var(--slider-border-radius);
background: #1C161B;
}
/*progress support*/
input[type=range].styled-slider.slider-progress {
--range: calc(var(--max) - var(--min));
--ratio: calc((var(--value) - var(--min)) / var(--range));
--sx: calc(0.5 * 1rem + var(--ratio) * (100% - 1rem));
}
/*webkit*/
input[type=range].styled-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
margin-top: calc(0.25em * 0.5 - 1rem * 0.5);
}
input[type=range].styled-slider::-webkit-slider-runnable-track {
height: var(--slider-height);
border: none;
box-shadow: none;
border-radius: var(--slider-border-radius);
}
input[type=range].styled-slider::-webkit-slider-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-webkit-slider-runnable-track {
background: linear-gradient(var(--slider-progress-background),var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
}
/*mozilla*/
input[type=range].styled-slider::-moz-range-thumb {
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
}
input[type=range].styled-slider::-moz-range-track {
height: var(--slider-height);
border: none;
border-radius: var(--slider-border-radius);
background: #1C161B;
box-shadow: none;
}
input[type=range].styled-slider::-moz-range-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-moz-range-track {
background: linear-gradient(var(--slider-progress-background),var(--slider-progress-background)) 0/var(--sx) 100% no-repeat, #1C161B;
}
/*ms*/
input[type=range].styled-slider::-ms-fill-upper {
background: transparent;
border-color: transparent;
}
input[type=range].styled-slider::-ms-fill-lower {
background: transparent;
border-color: transparent;
}
input[type=range].styled-slider::-ms-thumb {
width: 1rem;
height: 1rem;
border-radius: var(--slider-border-radius);
background: #FFFFFF;
border: none;
box-shadow: 0 0 2px #000000;
margin-top: 0;
box-sizing: border-box;
}
input[type=range].styled-slider::-ms-track {
height: var(--slider-height);
border-radius: var(--slider-border-radius);
background: #1C161B;
border: none;
box-shadow: none;
box-sizing: border-box;
}
input[type=range].styled-slider::-ms-thumb:hover {
background: #DCDCDC;
}
input[type=range].styled-slider.slider-progress::-ms-fill-lower {
height: var(--slider-height);
border-radius: var(--slider-border-radius) 0 0 5px;
margin: -undefined 0 -undefined -undefined;
background: var(--slider-progress-background);
border: none;
border-right-width: 0;
}

View File

@ -69,6 +69,13 @@
"sources": "Sources",
"seasons": "Seasons",
"captions": "Captions",
"captionPreferences": {
"title": "Customize",
"delay": "Delay",
"fontSize": "Size",
"opacity": "Opacity",
"color": "Color"
},
"episode": "E{{index}} - {{title}}",
"noCaptions": "No captions",
"linkedCaptions": "Linked captions",
@ -84,7 +91,8 @@
"embeds": "Choose which video to view",
"seasons": "Choose which season you want to watch",
"episode": "Pick an episode",
"captions": "Choose a subtitle language"
"captions": "Choose a subtitle language",
"captionPreferences": "Make subtitles look how you want it"
}
},
"errors": {

View File

@ -0,0 +1,78 @@
import { useStore } from "@/utils/storage";
import { createContext, ReactNode, useContext, useMemo } from "react";
import { SettingsStore } from "./store";
import { MWSettingsData } from "./types";
interface MWSettingsDataSetters {
setLanguage(language: string): void;
setCaptionDelay(delay: number): void;
setCaptionColor(color: string): void;
setCaptionFontSize(size: number): void;
setCaptionBackgroundColor(backgroundColor: string): void;
}
type MWSettingsDataWrapper = MWSettingsData & MWSettingsDataSetters;
const SettingsContext = createContext<MWSettingsDataWrapper>(null as any);
export function SettingsProvider(props: { children: ReactNode }) {
function enforceRange(min: number, value: number, max: number) {
return Math.max(min, Math.min(value, max));
}
const [settings, setSettings] = useStore(SettingsStore);
const context: MWSettingsDataWrapper = useMemo(() => {
const settingsContext: MWSettingsDataWrapper = {
...settings,
setLanguage(language) {
setSettings((oldSettings) => {
return {
...oldSettings,
language,
};
});
},
setCaptionDelay(delay: number) {
setSettings((oldSettings) => {
const captionSettings = oldSettings.captionSettings;
captionSettings.delay = enforceRange(-10, delay, 10);
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionColor(color) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.color = color;
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionFontSize(size) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.fontSize = enforceRange(10, size, 60);
const newSettings = oldSettings;
return newSettings;
});
},
setCaptionBackgroundColor(backgroundColor) {
setSettings((oldSettings) => {
const style = oldSettings.captionSettings.style;
style.backgroundColor = backgroundColor;
const newSettings = oldSettings;
return newSettings;
});
},
};
return settingsContext;
}, [settings, setSettings]);
return (
<SettingsContext.Provider value={context}>
{props.children}
</SettingsContext.Provider>
);
}
export function useSettings() {
return useContext(SettingsContext);
}
export default SettingsContext;

View File

@ -0,0 +1 @@
export * from "./context";

View File

@ -0,0 +1,22 @@
import { createVersionedStore } from "@/utils/storage";
import { MWSettingsData } from "./types";
export const SettingsStore = createVersionedStore<MWSettingsData>()
.setKey("mw-settings")
.addVersion({
version: 0,
create(): MWSettingsData {
return {
language: "en",
captionSettings: {
delay: 0,
style: {
color: "#ffffff",
fontSize: 25,
backgroundColor: "#00000096",
},
},
};
},
})
.build();

View File

@ -0,0 +1,21 @@
export interface CaptionStyleSettings {
color: string;
/**
* Range is [10, 30]
*/
fontSize: number;
backgroundColor: string;
}
export interface CaptionSettings {
/**
* Range is [-10, 10]s
*/
delay: number;
style: CaptionStyleSettings;
}
export interface MWSettingsData {
language: string;
captionSettings: CaptionSettings;
}

View File

@ -99,7 +99,7 @@ function buildStorageObject<T>(store: InternalStoreData): StoreRet<T> {
localStorage.setItem(key, JSON.stringify(withVersion));
if (!storeCallbacks[key]) storeCallbacks[key] = [];
storeCallbacks[key].forEach((v) => v(structuredClone(data)));
storeCallbacks[key].forEach((v) => v(window.structuredClone(data)));
}
return {

View File

@ -27,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 { PictureInPictureAction } from "@/video/components/actions/PictureInPictureAction";
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
import { SettingsAction } from "./actions/SettingsAction";
import { DividerAction } from "./actions/DividerAction";
import { PictureInPictureAction } from "./actions/PictureInPictureAction";
type Props = VideoPlayerBaseProps;
@ -165,6 +166,7 @@ export function VideoPlayer(props: Props) {
</Transition>
{show ? <PopoutProviderAction /> : null}
</BackdropAction>
<CaptionRendererAction isControlsShown={show} />
{props.children}
</VideoPlayerError>
</>

View File

@ -0,0 +1,92 @@
import { Transition } from "@/components/Transition";
import { useSettings } from "@/state/settings";
import { sanitize } from "@/backend/helpers/captions";
import { parse, Cue } from "node-webvtt";
import { useRef } from "react";
import { useAsync } from "react-use";
import { useVideoPlayerDescriptor } from "../../state/hooks";
import { useProgress } from "../../state/logic/progress";
import { useSource } from "../../state/logic/source";
function CaptionCue({ text }: { text?: string }) {
const { captionSettings } = useSettings();
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
// added a <br /> for newlines
const html = sanitize(textWithNewlines, {
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
ADD_TAGS: ["v", "lang"],
ALLOWED_ATTR: ["title", "lang"],
});
return (
<p
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
style={{
...captionSettings.style,
}}
>
<span
// its sanitised a few lines up
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: html,
}}
dir="auto"
/>
</p>
);
}
export function CaptionRendererAction({
isControlsShown,
}: {
isControlsShown: boolean;
}) {
const descriptor = useVideoPlayerDescriptor();
const source = useSource(descriptor).source;
const videoTime = useProgress(descriptor).time;
const { captionSettings } = useSettings();
const captions = useRef<Cue[]>([]);
useAsync(async () => {
const url = source?.caption?.url;
if (url) {
// Is there a better way?
const result = await fetch(url);
// Uses UTF-8 by default
const text = await result.text();
captions.current = parse(text, { strict: false }).cues;
} else {
captions.current = [];
}
}, [source?.caption?.url]);
if (!captions.current.length) return null;
const isVisible = (start: number, end: number): boolean => {
const delayedStart = start + captionSettings.delay;
const delayedEnd = end + captionSettings.delay;
return (
Math.max(0, delayedStart) <= videoTime &&
Math.max(0, delayedEnd) >= videoTime
);
};
return (
<Transition
className={[
"pointer-events-none absolute flex w-full flex-col items-center transition-[bottom]",
isControlsShown ? "bottom-24" : "bottom-12",
].join(" ")}
animation="slide-up"
show
>
{captions.current.map(
({ identifier, end, start, text }) =>
isVisible(start, end) && (
<CaptionCue key={identifier || `${start}-${end}`} text={text} />
)
)}
</Transition>
);
}

View File

@ -1,4 +1,4 @@
import { Icon, Icons } from "@/components/Icon";
import { Icons } from "@/components/Icon";
import { useTranslation } from "react-i18next";
import { PopoutListAction } from "../../popouts/PopoutUtils";
import { QualityDisplayAction } from "./QualityDisplayAction";

View File

@ -1,7 +1,6 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useMisc } from "@/video/state/logic/misc";
import { useSource } from "@/video/state/logic/source";
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
import { useEffect, useMemo, useRef } from "react";
@ -13,7 +12,6 @@ interface Props {
function VideoElement(props: Props) {
const descriptor = useVideoPlayerDescriptor();
const mediaPlaying = useMediaPlaying(descriptor);
const source = useSource(descriptor);
const misc = useMisc(descriptor);
const ref = useRef<HTMLVideoElement>(null);
@ -43,12 +41,8 @@ function VideoElement(props: Props) {
autoPlay={props.autoPlay}
muted={mediaPlaying.volume === 0}
playsInline
className="h-full w-full"
>
{source.source?.caption ? (
<track default kind="captions" src={source.source.caption.url} />
) : null}
</video>
className="z-0 h-full w-full"
/>
);
}

View File

@ -2,7 +2,8 @@ import { MWMediaMeta } from "@/backend/metadata/types";
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
import { Link } from "@/components/text/Link";
import { conf } from "@/setup/config";
import { Component, ReactNode } from "react";
import { Component } from "react";
import type { ReactNode } from "react-router-dom/node_modules/@types/react/index";
import { Trans } from "react-i18next";
import { VideoPlayerHeader } from "./VideoPlayerHeader";

View File

@ -70,7 +70,6 @@ export function CaptionSelectionPopout(props: {
const captionFile = e.target.files[0];
setCustomCaption(captionFile);
}
return (
<FloatingView
{...props.router.pageProps(props.prefix)}
@ -81,6 +80,18 @@ export function CaptionSelectionPopout(props: {
title={t("videoPlayer.popouts.captions")}
description={t("videoPlayer.popouts.descriptions.captions")}
goBack={() => props.router.navigate("/")}
action={
<button
type="button"
onClick={() =>
props.router.navigate(`${props.prefix}/caption-settings`)
}
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
>
<span>{t("videoPlayer.popouts.captionPreferences.title")}</span>
<Icon icon={Icons.GEAR} />
</button>
}
/>
<FloatingCardView.Content noSection>
<PopoutSection>

View File

@ -0,0 +1,150 @@
import { FloatingCardView } from "@/components/popout/FloatingCard";
import { FloatingView } from "@/components/popout/FloatingView";
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
import { useSettings } from "@/state/settings";
import { useTranslation } from "react-i18next";
import { ChangeEventHandler, useEffect, useRef } from "react";
import { Icon, Icons } from "@/components/Icon";
export type SliderProps = {
label: string;
min: number;
max: number;
step: number;
value: number;
valueDisplay?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
};
export function Slider(props: SliderProps) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
const e = ref.current as HTMLInputElement;
e.style.setProperty("--value", e.value);
e.style.setProperty("--min", e.min === "" ? "0" : e.min);
e.style.setProperty("--max", e.max === "" ? "100" : e.max);
e.addEventListener("input", () => e.style.setProperty("--value", e.value));
}, [ref]);
return (
<div className="mb-6 flex flex-row gap-4">
<div className="flex w-full flex-col gap-2">
<label className="font-bold">{props.label}</label>
<input
type="range"
ref={ref}
className="styled-slider slider-progress"
onChange={props.onChange}
value={props.value}
max={props.max}
min={props.min}
step={props.step}
/>
</div>
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1">
<div className="text-center font-bold text-white">
{props.valueDisplay ?? props.value}
</div>
</div>
</div>
);
}
export function CaptionSettingsPopout(props: {
router: ReturnType<typeof useFloatingRouter>;
prefix: string;
}) {
// For now, won't add label texts to language files since options are prone to change
const { t } = useTranslation();
const {
captionSettings,
setCaptionBackgroundColor,
setCaptionColor,
setCaptionDelay,
setCaptionFontSize,
} = useSettings();
const colors = ["#ffffff", "#00ffff", "#ffff00"];
return (
<FloatingView {...props.router.pageProps(props.prefix)} width={375}>
<FloatingCardView.Header
title={t("videoPlayer.popouts.captionPreferences.title")}
description={t("videoPlayer.popouts.descriptions.captionPreferences")}
goBack={() => props.router.navigate("/captions")}
/>
<FloatingCardView.Content>
<Slider
label={t("videoPlayer.popouts.captionPreferences.delay")}
max={10}
min={-10}
step={0.1}
valueDisplay={`${captionSettings.delay.toFixed(1)}s`}
value={captionSettings.delay}
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)}
/>
<Slider
label="Size"
min={14}
step={1}
max={60}
value={captionSettings.style.fontSize}
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
/>
<Slider
label={t("videoPlayer.popouts.captionPreferences.opacity")}
step={1}
min={0}
max={255}
valueDisplay={`${(
(parseInt(
captionSettings.style.backgroundColor.substring(7, 9),
16
) /
255) *
100
).toFixed(0)}%`}
value={parseInt(
captionSettings.style.backgroundColor.substring(7, 9),
16
)}
onChange={(e) =>
setCaptionBackgroundColor(
`${captionSettings.style.backgroundColor.substring(
0,
7
)}${e.target.valueAsNumber.toString(16)}`
)
}
/>
<div className="flex flex-row justify-between">
<label className="font-bold" htmlFor="color">
{t("videoPlayer.popouts.captionPreferences.color")}
</label>
<div className="flex flex-row gap-2">
{colors.map((color) => (
<div
className={`flex h-8 w-8 items-center justify-center rounded transition-[background-color,transform] duration-100 hover:bg-[#1c161b79] active:scale-110 ${
color === captionSettings.style.color ? "bg-[#1C161B]" : ""
}`}
onClick={() => setCaptionColor(color)}
>
<div
className="h-4 w-4 cursor-pointer appearance-none rounded-full"
style={{
backgroundColor: color,
}}
/>
<Icon
className={[
"absolute text-xs text-[#1C161B]",
color === captionSettings.style.color ? "" : "hidden",
].join(" ")}
icon={Icons.CHECKMARK}
/>
</div>
))}
</div>
</div>
</FloatingCardView.Content>
</FloatingView>
);
}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { useLoading } from "@/hooks/useLoading";

View File

@ -43,6 +43,8 @@ export function ScrollToActive(props: ScrollToActiveProps) {
const ref = createRef<HTMLDivElement>();
const inited = useRef<boolean>(false);
const SAFE_OFFSET = 30;
// Scroll to "active" child on first load (AKA mount except React dumb)
useEffect(() => {
if (inited.current) return;
@ -61,27 +63,31 @@ export function ScrollToActive(props: ScrollToActiveProps) {
wrapper?.querySelector(".active");
if (wrapper && active) {
let activeYPositionCentered = 0;
const setActiveYPositionCentered = () => {
activeYPositionCentered =
active.getBoundingClientRect().top -
wrapper.getBoundingClientRect().top +
active.offsetHeight / 2;
let wrapperHeight = 0;
let activePos = 0;
let activeHeight = 0;
let wrapperScroll = 0;
const getCoords = () => {
const activeRect = active.getBoundingClientRect();
const wrapperRect = wrapper.getBoundingClientRect();
wrapperHeight = wrapperRect.height;
activeHeight = activeRect.height;
activePos = activeRect.top - wrapperRect.top + wrapper.scrollTop;
wrapperScroll = wrapper.scrollTop;
};
setActiveYPositionCentered();
getCoords();
if (activeYPositionCentered >= wrapper.offsetHeight / 2) {
// Check if the active element is below the vertical center line, then scroll it into center
const isVisible =
activePos + activeHeight <
wrapperScroll + wrapperHeight - SAFE_OFFSET ||
activePos > wrapperScroll + SAFE_OFFSET;
if (isVisible) {
const activeMiddlePos = activePos + activeHeight / 2; // pos of middle of active element
const viewMiddle = wrapperHeight / 2; // half of the available height
const pos = activeMiddlePos - viewMiddle;
wrapper.scrollTo({
top: activeYPositionCentered - wrapper.offsetHeight / 2,
});
}
setActiveYPositionCentered();
if (activeYPositionCentered > wrapper.offsetHeight / 2) {
// If the element is over the vertical center line, scroll to the end
wrapper.scrollTo({
top: wrapper.scrollHeight,
top: pos,
});
}
}

View File

@ -7,6 +7,7 @@ import { CaptionsSelectionAction } from "@/video/components/actions/list-entries
import { SourceSelectionAction } from "@/video/components/actions/list-entries/SourceSelectionAction";
import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
import { SourceSelectionPopout } from "./SourceSelectionPopout";
import { CaptionSettingsPopout } from "./CaptionSettingsPopout";
export function SettingsPopout() {
const floatingRouter = useFloatingRouter();
@ -24,6 +25,10 @@ export function SettingsPopout() {
</FloatingView>
<SourceSelectionPopout router={floatingRouter} prefix="source" />
<CaptionSelectionPopout router={floatingRouter} prefix="captions" />
<CaptionSettingsPopout
router={floatingRouter}
prefix="caption-settings"
/>
</>
);
}

View File

@ -16,6 +16,7 @@
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./src",
"typeRoots": ["./src/@types"],
"paths": {
"@/*": ["./*"]
},

View File

@ -1,5 +1,5 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import react from "@vitejs/plugin-react";
import loadVersion from "vite-plugin-package-version";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
@ -7,10 +7,25 @@ import path from "path";
export default defineConfig({
plugins: [
react(),
react({
babel: {
presets: [
"@babel/preset-typescript",
[
"@babel/preset-env",
{
modules: false,
useBuiltIns: "entry",
corejs: {
version: "3.29",
},
},
],
],
},
}),
VitePWA({
registerType: "autoUpdate",
injectRegister: "inline",
workbox: {
globIgnores: ["**ping.txt**"],
},

2779
yarn.lock

File diff suppressed because it is too large Load Diff