diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/deploying.yml b/.github/workflows/deploying.yml index 182a7895..532bb67e 100644 --- a/.github/workflows/deploying.yml +++ b/.github/workflows/deploying.yml @@ -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 diff --git a/.github/workflows/linting_annotate.yml b/.github/workflows/linting_annotate.yml new file mode 100644 index 00000000..2bc9b613 --- /dev/null +++ b/.github/workflows/linting_annotate.yml @@ -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" diff --git a/.github/workflows/linting_testing.yml b/.github/workflows/linting_testing.yml index e51fc630..53d7b5ca 100644 --- a/.github/workflows/linting_testing.yml +++ b/.github/workflows/linting_testing.yml @@ -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 diff --git a/package.json b/package.json index 08d3bae5..fe430fa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "movie-web", - "version": "3.0.4", + "version": "3.0.5", "private": true, "homepage": "https://movie.squeezebox.dev", "dependencies": { diff --git a/public/_headers b/public/_headers new file mode 100644 index 00000000..1216e42d --- /dev/null +++ b/public/_headers @@ -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 diff --git a/public/config.js b/public/config.js index eb936081..b69f60eb 100644 --- a/public/config.js +++ b/public/config.js @@ -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", }; diff --git a/src/backend/helpers/captions.ts b/src/backend/helpers/captions.ts index ca230fa9..61adbdc9 100644 --- a/src/backend/helpers/captions.ts +++ b/src/backend/helpers/captions.ts @@ -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 { if (caption.type === MWCaptionType.SRT) { let captionBlob: Blob; @@ -32,3 +33,18 @@ export async function getCaptionUrl(caption: MWCaption): Promise { 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); + } +} diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts index cb622e3b..c0c9e92c 100644 --- a/src/backend/metadata/getmeta.ts +++ b/src/backend/metadata/getmeta.ts @@ -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"); diff --git a/src/backend/providers/netfilm.ts b/src/backend/providers/netfilm.ts index 777c77cd..9b4faafa 100644 --- a/src/backend/providers/netfilm.ts +++ b/src/backend/providers/netfilm.ts @@ -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) => ({ + 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) => ({ + 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, }, }; }, diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 67a9dbe3..ae33aad7 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -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 = { download: ``, gear: ``, watch_party: ``, + pictureInPicture: ``, }; function ChromeCastButton() { diff --git a/src/setup/constants.ts b/src/setup/constants.ts index db766055..8350efdd 100644 --- a/src/setup/constants.ts +++ b/src/setup/constants.ts @@ -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"; diff --git a/src/setup/index.css b/src/setup/index.css index c3d97c26..ebb56bec 100644 --- a/src/setup/index.css +++ b/src/setup/index.css @@ -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; } @@ -46,7 +47,7 @@ body[data-no-select] { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; - -webkit-box-orient: vertical; + -webkit-box-orient: vertical; overflow: hidden; } diff --git a/src/setup/locales/en/translation.json b/src/setup/locales/en/translation.json index 630709b6..b4dd8ce5 100644 --- a/src/setup/locales/en/translation.json +++ b/src/setup/locales/en/translation.json @@ -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}}", diff --git a/src/utils/detectFeatures.ts b/src/utils/detectFeatures.ts index 15be4c69..af62720d 100644 --- a/src/utils/detectFeatures.ts +++ b/src/utils/detectFeatures.ts @@ -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"); +} diff --git a/src/video/components/VideoPlayer.tsx b/src/video/components/VideoPlayer.tsx index f86a5161..0dc9628c 100644 --- a/src/video/components/VideoPlayer.tsx +++ b/src/video/components/VideoPlayer.tsx @@ -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) {
+
@@ -155,6 +157,7 @@ export function VideoPlayer(props: Props) { + )} diff --git a/src/video/components/actions/PictureInPictureAction.tsx b/src/video/components/actions/PictureInPictureAction.tsx new file mode 100644 index 00000000..d60ec446 --- /dev/null +++ b/src/video/components/actions/PictureInPictureAction.tsx @@ -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 ( + + ); +} diff --git a/src/video/components/actions/list-entries/DownloadAction.tsx b/src/video/components/actions/list-entries/DownloadAction.tsx index 09c60fd8..76910efd 100644 --- a/src/video/components/actions/list-entries/DownloadAction.tsx +++ b/src/video/components/actions/list-entries/DownloadAction.tsx @@ -22,7 +22,7 @@ export function DownloadAction() { return ( {t("videoPlayer.buttons.download")} diff --git a/src/video/components/popouts/CaptionSelectionPopout.tsx b/src/video/components/popouts/CaptionSelectionPopout.tsx index d464e82e..7d3b9a98 100644 --- a/src/video/components/popouts/CaptionSelectionPopout.tsx +++ b/src/video/components/popouts/CaptionSelectionPopout.tsx @@ -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(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) { + if (!e.target.files) { + return; + } + const captionFile = e.target.files[0]; + setCustomCaption(captionFile); + } return ( {t("videoPlayer.popouts.noCaptions")} + { + customCaptionUploadElement.current?.click(); + }} + > + {currentCaption === CUSTOM_CAPTION_ID + ? t("videoPlayer.popouts.customCaption") + : t("videoPlayer.popouts.uploadCustomCaption")} + +

diff --git a/src/video/components/popouts/PopoutUtils.tsx b/src/video/components/popouts/PopoutUtils.tsx index 3573a86f..23475365 100644 --- a/src/video/components/popouts/PopoutUtils.tsx +++ b/src/video/components/popouts/PopoutUtils.tsx @@ -112,7 +112,7 @@ export function PopoutListEntryBase(props: PopoutListEntryRootTypes) { return (