mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-10 23:15:06 +01:00
Merge remote-tracking branch 'original/dev' into dev
This commit is contained in:
commit
bbb9072bc9
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
3
.github/workflows/deploying.yml
vendored
3
.github/workflows/deploying.yml
vendored
@ -18,12 +18,13 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
run: yarn build
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v3
|
||||
|
48
.github/workflows/linting_annotate.yml
vendored
Normal file
48
.github/workflows/linting_annotate.yml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
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"
|
30
.github/workflows/linting_testing.yml
vendored
30
.github/workflows/linting_testing.yml
vendored
@ -5,8 +5,7 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
linting:
|
||||
@ -21,6 +20,7 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
@ -30,11 +30,27 @@ jobs:
|
||||
# continue on error, so it still reports it in the next step
|
||||
continue-on-error: true
|
||||
|
||||
- name: Annotate Code Linting Results
|
||||
uses: ataylorme/eslint-annotate-action@v2
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
report-json: "eslint_report.json"
|
||||
name: eslint_report.json
|
||||
path: eslint_report.json
|
||||
|
||||
building:
|
||||
name: Build project
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Yarn packages
|
||||
run: yarn install
|
||||
|
||||
- name: Build Project
|
||||
run: npm run build
|
||||
run: yarn build
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "movie-web",
|
||||
"version": "3.0.4",
|
||||
"version": "3.0.5",
|
||||
"private": true,
|
||||
"homepage": "https://movie.squeezebox.dev",
|
||||
"dependencies": {
|
||||
|
5
public/_headers
Normal file
5
public/_headers
Normal file
@ -0,0 +1,5 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-XSS-Protection: 1; mode=block
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: origin-when-cross-origin
|
@ -1,7 +1,6 @@
|
||||
window.__CONFIG__ = {
|
||||
// url must NOT end with a slash
|
||||
VITE_CORS_PROXY_URL: "",
|
||||
|
||||
VITE_TMDB_API_KEY: "b030404650f279792a8d3287232358e3",
|
||||
VITE_OMDB_API_KEY: "aa0937c0",
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import { mwFetch, proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
|
||||
export const CUSTOM_CAPTION_ID = "customCaption";
|
||||
export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
if (caption.type === MWCaptionType.SRT) {
|
||||
let captionBlob: Blob;
|
||||
@ -32,3 +33,18 @@ export async function getCaptionUrl(caption: MWCaption): Promise<string> {
|
||||
|
||||
throw new Error("invalid type");
|
||||
}
|
||||
|
||||
export async function convertCustomCaptionFileToWebVTT(file: File) {
|
||||
const header = await file.slice(0, 6).text();
|
||||
const isWebVTT = header === "WEBVTT";
|
||||
if (!isWebVTT) {
|
||||
return toWebVTT(file);
|
||||
}
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
export function revokeCaptionBlob(url: string | undefined) {
|
||||
if (url && url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
@ -54,12 +54,17 @@ export async function getMetaFromId(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const imdbId = data.external_ids.find(
|
||||
let imdbId = data.external_ids.find(
|
||||
(v) => v.provider === "imdb_latest"
|
||||
)?.external_id;
|
||||
const tmdbId = data.external_ids.find(
|
||||
if (!imdbId)
|
||||
imdbId = data.external_ids.find((v) => v.provider === "imdb")?.external_id;
|
||||
|
||||
let tmdbId = data.external_ids.find(
|
||||
(v) => v.provider === "tmdb_latest"
|
||||
)?.external_id;
|
||||
if (!tmdbId)
|
||||
tmdbId = data.external_ids.find((v) => v.provider === "tmdb")?.external_id;
|
||||
|
||||
if (!imdbId || !tmdbId) throw new Error("not enough info");
|
||||
|
||||
|
@ -1,6 +1,10 @@
|
||||
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 netfilmBase = "https://net-film.vercel.app";
|
||||
@ -18,7 +22,6 @@ registerProvider({
|
||||
displayName: "NetFilm",
|
||||
rank: 15,
|
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||
disabled: true, // https://github.com/lamhoang1256/netfilm/issues/25
|
||||
|
||||
async scrape({ media, episode, progress }) {
|
||||
// search for relevant item
|
||||
@ -48,20 +51,29 @@ registerProvider({
|
||||
}
|
||||
);
|
||||
|
||||
const { qualities } = watchInfo.data;
|
||||
const data = watchInfo.data;
|
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) =>
|
||||
const source = data.qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
needsProxy: false,
|
||||
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
|
||||
type: MWCaptionType.SRT,
|
||||
langIso: sub.language,
|
||||
}));
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -109,20 +121,29 @@ registerProvider({
|
||||
}
|
||||
);
|
||||
|
||||
const { qualities } = episodeStream.data;
|
||||
const data = episodeStream.data;
|
||||
|
||||
// get best quality source
|
||||
const source = qualities.reduce((p: any, c: any) =>
|
||||
const source = data.qualities.reduce((p: any, c: any) =>
|
||||
c.quality > p.quality ? c : p
|
||||
);
|
||||
|
||||
const mappedCaptions = data.subtitles.map((sub: Record<string, any>) => ({
|
||||
needsProxy: false,
|
||||
url: sub.url.replace("https://convert-srt-to-vtt.vercel.app/?url=", ""),
|
||||
type: MWCaptionType.SRT,
|
||||
langIso: sub.language,
|
||||
}));
|
||||
|
||||
return {
|
||||
embeds: [],
|
||||
stream: {
|
||||
streamUrl: source.url,
|
||||
streamUrl: source.url
|
||||
.replace("akm-cdn", "aws-cdn")
|
||||
.replace("gg-cdn", "aws-cdn"),
|
||||
quality: qualityMap[source.quality as QualityInMap],
|
||||
type: MWStreamType.HLS,
|
||||
captions: [],
|
||||
captions: mappedCaptions,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
@ -38,6 +38,7 @@ export enum Icons {
|
||||
DOWNLOAD = "download",
|
||||
GEAR = "gear",
|
||||
WATCH_PARTY = "watch_party",
|
||||
PICTURE_IN_PICTURE = "pictureInPicture",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
@ -83,6 +84,7 @@ const iconList: Record<Icons, string> = {
|
||||
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>`,
|
||||
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>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
@ -1,4 +1,4 @@
|
||||
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.4";
|
||||
export const APP_VERSION = "3.0.5";
|
||||
export const GA_ID = "G-44YVXRL61C";
|
||||
|
@ -4,12 +4,13 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-denim-100 text-denim-700 font-open-sans overflow-x-hidden;
|
||||
@apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
html[data-full], html[data-full] body {
|
||||
html[data-full],
|
||||
html[data-full] body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
|
@ -62,7 +62,8 @@
|
||||
"source": "Source",
|
||||
"captions": "Captions",
|
||||
"download": "Download",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"pictureInPicture": "Picture in Picture"
|
||||
},
|
||||
"popouts": {
|
||||
"sources": "Sources",
|
||||
@ -71,6 +72,8 @@
|
||||
"episode": "E{{index}} - {{title}}",
|
||||
"noCaptions": "No captions",
|
||||
"linkedCaptions": "Linked captions",
|
||||
"customCaption": "Custom caption",
|
||||
"uploadCustomCaption": "Upload caption (SRT, VTT)",
|
||||
"noEmbeds": "No embeds were found for this source",
|
||||
"errors": {
|
||||
"loadingWentWong": "Something went wrong loading the episodes for {{seasonTitle}}",
|
||||
|
@ -38,3 +38,11 @@ export function canWebkitFullscreen(): boolean {
|
||||
export function canFullscreen(): boolean {
|
||||
return canFullscreenAnyElement() || canWebkitFullscreen();
|
||||
}
|
||||
|
||||
export function canPictureInPicture(): boolean {
|
||||
return "pictureInPictureEnabled" in document;
|
||||
}
|
||||
|
||||
export function canWebkitPictureInPicture(): boolean {
|
||||
return "webkitSupportsPresentationMode" in document.createElement("video");
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||
import { CastingTextAction } from "@/video/components/actions/CastingTextAction";
|
||||
import { SettingsAction } from "./actions/SettingsAction";
|
||||
import { DividerAction } from "./actions/DividerAction";
|
||||
import { PictureInPictureAction } from "./actions/PictureInPictureAction";
|
||||
|
||||
type Props = VideoPlayerBaseProps;
|
||||
|
||||
@ -142,6 +143,7 @@ export function VideoPlayer(props: Props) {
|
||||
<div />
|
||||
<div className="flex items-center justify-center">
|
||||
<SeriesSelectionAction />
|
||||
<PictureInPictureAction />
|
||||
<SettingsAction />
|
||||
</div>
|
||||
<FullscreenAction />
|
||||
@ -155,6 +157,7 @@ export function VideoPlayer(props: Props) {
|
||||
<SettingsAction />
|
||||
<ChromecastAction />
|
||||
<AirplayAction />
|
||||
<PictureInPictureAction />
|
||||
<FullscreenAction />
|
||||
</>
|
||||
)}
|
||||
|
40
src/video/components/actions/PictureInPictureAction.tsx
Normal file
40
src/video/components/actions/PictureInPictureAction.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
canPictureInPicture,
|
||||
canWebkitPictureInPicture,
|
||||
} from "@/utils/detectFeatures";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PictureInPictureAction(props: Props) {
|
||||
const { isMobile } = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
controls.togglePictureInPicture();
|
||||
}, [controls]);
|
||||
|
||||
if (!canPictureInPicture() && !canWebkitPictureInPicture()) return null;
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
icon={Icons.PICTURE_IN_PICTURE}
|
||||
onClick={handleClick}
|
||||
text={
|
||||
isMobile ? (t("videoPlayer.buttons.pictureInPicture") as string) : ""
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
@ -22,7 +22,7 @@ export function DownloadAction() {
|
||||
return (
|
||||
<PopoutListAction
|
||||
href={isHLS ? undefined : sourceInterface.source?.url}
|
||||
download={title ? normalizeTitle(title) : undefined}
|
||||
download={title ? `${normalizeTitle(title)}.mp4` : undefined}
|
||||
icon={Icons.DOWNLOAD}
|
||||
>
|
||||
{t("videoPlayer.buttons.download")}
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { getCaptionUrl } from "@/backend/helpers/captions";
|
||||
import {
|
||||
getCaptionUrl,
|
||||
convertCustomCaptionFileToWebVTT,
|
||||
CUSTOM_CAPTION_ID,
|
||||
} from "@/backend/helpers/captions";
|
||||
import { MWCaption } from "@/backend/helpers/streams";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
||||
@ -9,7 +13,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useSource } from "@/video/state/logic/source";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { ChangeEvent, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
||||
|
||||
@ -43,6 +47,29 @@ export function CaptionSelectionPopout(props: {
|
||||
);
|
||||
|
||||
const currentCaption = source.source?.caption?.id;
|
||||
const customCaptionUploadElement = useRef<HTMLInputElement>(null);
|
||||
const [setCustomCaption, loadingCustomCaption, errorCustomCaption] =
|
||||
useLoading(async (captionFile: File) => {
|
||||
if (
|
||||
!captionFile.name.endsWith(".srt") &&
|
||||
!captionFile.name.endsWith(".vtt")
|
||||
) {
|
||||
throw new Error("Only SRT or VTT files are allowed");
|
||||
}
|
||||
controls.setCaption(
|
||||
CUSTOM_CAPTION_ID,
|
||||
await convertCustomCaptionFileToWebVTT(captionFile)
|
||||
);
|
||||
controls.closePopout();
|
||||
});
|
||||
|
||||
async function handleUploadCaption(e: ChangeEvent<HTMLInputElement>) {
|
||||
if (!e.target.files) {
|
||||
return;
|
||||
}
|
||||
const captionFile = e.target.files[0];
|
||||
setCustomCaption(captionFile);
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingView
|
||||
@ -66,6 +93,26 @@ export function CaptionSelectionPopout(props: {
|
||||
>
|
||||
{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-300 px-5 py-3 text-xs font-bold uppercase">
|
||||
|
@ -112,7 +112,7 @@ export function PopoutListEntryBase(props: PopoutListEntryRootTypes) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
|
||||
"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",
|
||||
hover,
|
||||
props.active
|
||||
? `${bg} active text-white outline-denim-700`
|
||||
|
@ -13,6 +13,7 @@ export type ControlMethods = {
|
||||
setMeta(data?: VideoPlayerMeta): void;
|
||||
setCurrentEpisode(sId: string, eId: string): void;
|
||||
setDraggingTime(num: number): void;
|
||||
togglePictureInPicture(): void;
|
||||
};
|
||||
|
||||
export function useControls(
|
||||
@ -100,5 +101,9 @@ export function useControls(
|
||||
updateMeta(descriptor, state);
|
||||
}
|
||||
},
|
||||
togglePictureInPicture() {
|
||||
state.stateProvider?.togglePictureInPicture();
|
||||
updateInterface(descriptor, state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
} from "@/video/components/hooks/volumeStore";
|
||||
import { resetStateForSource } from "@/video/state/providers/helpers";
|
||||
import { updateInterface } from "@/video/state/logic/interface";
|
||||
import { revokeCaptionBlob } from "@/backend/helpers/captions";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { updateMediaPlaying } from "../logic/mediaplaying";
|
||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||
@ -83,6 +84,9 @@ export function createCastingStateProvider(
|
||||
state.pausedWhenSeeking = state.mediaPlaying.isPaused;
|
||||
this.pause();
|
||||
},
|
||||
togglePictureInPicture() {
|
||||
// no picture in picture while casting
|
||||
},
|
||||
async setVolume(v) {
|
||||
// clamp time between 0 and 1
|
||||
let volume = Math.min(v, 1);
|
||||
@ -135,6 +139,7 @@ export function createCastingStateProvider(
|
||||
},
|
||||
setCaption(id, url) {
|
||||
if (state.source) {
|
||||
revokeCaptionBlob(state.source.caption?.url);
|
||||
state.source.caption = {
|
||||
id,
|
||||
url,
|
||||
@ -144,6 +149,7 @@ export function createCastingStateProvider(
|
||||
},
|
||||
clearCaption() {
|
||||
if (state.source) {
|
||||
revokeCaptionBlob(state.source.caption?.url);
|
||||
state.source.caption = null;
|
||||
updateSource(descriptor, state);
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ export type VideoPlayerStateController = {
|
||||
setCaption(id: string, url: string): void;
|
||||
clearCaption(): void;
|
||||
getId(): string;
|
||||
togglePictureInPicture(): void;
|
||||
};
|
||||
|
||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||
|
@ -5,6 +5,8 @@ import {
|
||||
canFullscreen,
|
||||
canFullscreenAnyElement,
|
||||
canWebkitFullscreen,
|
||||
canPictureInPicture,
|
||||
canWebkitPictureInPicture,
|
||||
} from "@/utils/detectFeatures";
|
||||
import { MWStreamType } from "@/backend/helpers/streams";
|
||||
import { updateInterface } from "@/video/state/logic/interface";
|
||||
@ -16,6 +18,7 @@ import {
|
||||
import { updateError } from "@/video/state/logic/error";
|
||||
import { updateMisc } from "@/video/state/logic/misc";
|
||||
import { resetStateForSource } from "@/video/state/providers/helpers";
|
||||
import { revokeCaptionBlob } from "@/backend/helpers/captions";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { updateMediaPlaying } from "../logic/mediaplaying";
|
||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||
@ -191,6 +194,7 @@ export function createVideoStateProvider(
|
||||
},
|
||||
setCaption(id, url) {
|
||||
if (state.source) {
|
||||
revokeCaptionBlob(state.source.caption?.url);
|
||||
state.source.caption = {
|
||||
id,
|
||||
url,
|
||||
@ -200,10 +204,28 @@ export function createVideoStateProvider(
|
||||
},
|
||||
clearCaption() {
|
||||
if (state.source) {
|
||||
revokeCaptionBlob(state.source.caption?.url);
|
||||
state.source.caption = null;
|
||||
updateSource(descriptor, state);
|
||||
}
|
||||
},
|
||||
togglePictureInPicture() {
|
||||
if (canWebkitPictureInPicture()) {
|
||||
const webkitPlayer = player as any;
|
||||
webkitPlayer.webkitSetPresentationMode(
|
||||
webkitPlayer.webkitPresentationMode === "picture-in-picture"
|
||||
? "inline"
|
||||
: "picture-in-picture"
|
||||
);
|
||||
}
|
||||
if (canPictureInPicture()) {
|
||||
if (player !== document.pictureInPictureElement) {
|
||||
player.requestPictureInPicture();
|
||||
} else {
|
||||
document.exitPictureInPicture();
|
||||
}
|
||||
}
|
||||
},
|
||||
providerStart() {
|
||||
this.setVolume(getStoredVolume());
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user