From 587e9f29cf45c650546191a9a0ce7bac6642af2a Mon Sep 17 00:00:00 2001 From: Aaron Liu Date: Thu, 24 Apr 2025 15:23:01 +0800 Subject: [PATCH] feat(video player): support m3u8 and relative path transform (#2288) --- package.json | 2 + public/locales/en-US/application.json | 4 + public/locales/zh-CN/application.json | 4 + public/locales/zh-TW/application.json | 4 + src/component/Viewers/Video/Artplayer.tsx | 126 ++++++- src/component/Viewers/Video/VideoViewer.tsx | 357 ++++++++++++-------- yarn.lock | 10 + 7 files changed, 361 insertions(+), 146 deletions(-) diff --git a/package.json b/package.json index 2440140..b390e0f 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,11 @@ "@uiw/react-color-sketch": "^2.1.1", "artplayer": "5.2.2", "artplayer-plugin-chapter": "^1.0.0", + "artplayer-plugin-hls-control": "^1.0.1", "axios": "^1.6.2", "dayjs": "^1.11.10", "fuse.js": "^7.0.0", + "hls.js": "^1.6.2", "i18next": "^23.7.11", "i18next-browser-languagedetector": "^7.2.0", "i18next-chained-backend": "^4.6.2", diff --git a/public/locales/en-US/application.json b/public/locales/en-US/application.json index 99e354f..073239c 100644 --- a/public/locales/en-US/application.json +++ b/public/locales/en-US/application.json @@ -111,6 +111,10 @@ "dashboard": "Dashboard" }, "fileManager": { + "quality": "Quality", + "audioTrack": "Audio", + "auto": "Auto", + "default": "Default", "shareWithMeEmpty": "No shared files found", "shareWithMeEmptyDes": "If you need to see others' shares here, please save the shortcut to any location in your files when you visit a shared link.", "selectAll": "Select all", diff --git a/public/locales/zh-CN/application.json b/public/locales/zh-CN/application.json index 1e0364a..c8506a4 100644 --- a/public/locales/zh-CN/application.json +++ b/public/locales/zh-CN/application.json @@ -112,6 +112,10 @@ "dashboard": "管理面板" }, "fileManager": { + "quality": "清晰度", + "audioTrack": "音轨", + "auto": "自动", + "default": "默认", "shareWithMeEmpty": "没有找到别人的分享", "shareWithMeEmptyDes": "如需要在此看到别人的分享,请在访问别人分享链接时,在右上角将快捷方式保存到你的文件中的任意位置。", "selectAll": "全选", diff --git a/public/locales/zh-TW/application.json b/public/locales/zh-TW/application.json index d21e545..f5959b1 100644 --- a/public/locales/zh-TW/application.json +++ b/public/locales/zh-TW/application.json @@ -112,6 +112,10 @@ "dashboard": "管理面板" }, "fileManager": { + "quality": "清晰度", + "audioTrack": "音軌", + "auto": "自動", + "default": "預設", "shareWithMeEmpty": "沒有找到別人的分享", "shareWithMeEmptyDes": "如需要在此看到別人的分享,請在訪問別人分享連結時,在右上角將快捷方式保存到你的文件中的任意位置。", "selectAll": "全選", diff --git a/src/component/Viewers/Video/Artplayer.tsx b/src/component/Viewers/Video/Artplayer.tsx index 5adf47d..c2ef56d 100644 --- a/src/component/Viewers/Video/Artplayer.tsx +++ b/src/component/Viewers/Video/Artplayer.tsx @@ -1,19 +1,107 @@ -import { useEffect, useRef } from "react"; -import Artplayer from "artplayer"; import { Box, BoxProps } from "@mui/material"; -import "./artplayer.css"; +import Artplayer from "artplayer"; import artplayerPluginChapter from "artplayer-plugin-chapter"; +import artplayerPluginHlsControl from "artplayer-plugin-hls-control"; +import Hls, { HlsConfig } from "hls.js"; +import i18next from "i18next"; +import { useEffect, useRef } from "react"; +import "./artplayer.css"; +export const CrMaskedPrefix = "https://cloudreve_masked/"; export interface PlayerProps extends BoxProps { option: any; getInstance?: (instance: Artplayer) => void; chapters?: any; + m3u8UrlTransform?: (url: string, isPlaylist?: boolean) => Promise; + getEntityUrl?: (url: string) => Promise; } +const playM3u8 = + ( + urlTransform?: (url: string, isPlaylist?: boolean) => Promise, + getEntityUrl?: (url: string) => Promise, + ) => + (video: HTMLVideoElement, url: string, art: Artplayer) => { + if (Hls.isSupported()) { + if (art.hls) art.hls.destroy(); + const hls = new Hls({ + fLoader: class extends Hls.DefaultConfig.loader { + constructor(config: HlsConfig) { + super(config); + var load = this.load.bind(this); + this.load = function (context, config, callbacks) { + console.log("fragment loader", context); + if (urlTransform) { + urlTransform(context.url).then((url) => { + console.log(url); + console.log({ ...context, frag: { ...context.frag, relurl: url, _url: url }, url }); + const complete = callbacks.onSuccess; + callbacks.onSuccess = (loaderResponse, stats, successContext, networkDetails) => { + // Do something with loaderResponse.data + loaderResponse.url = url; + console.log("fragment loader success", loaderResponse); + complete(loaderResponse, stats, successContext, networkDetails); + }; + load({ ...context, frag: { ...context.frag, relurl: url, _url: url }, url }, config, callbacks); + }); + } else { + load(context, config, callbacks); + } + }; + } + }, + pLoader: class extends Hls.DefaultConfig.loader { + constructor(config: HlsConfig) { + super(config); + var load = this.load.bind(this); + this.load = function (context, config, callbacks) { + console.log("playlist loader", context); + if (urlTransform) { + urlTransform(context.url, true).then((url) => { + console.log(url); + const complete = callbacks.onSuccess; + callbacks.onSuccess = (loaderResponse, stats, successContext, networkDetails) => { + // Do something with loaderResponse.data + loaderResponse.url = url; + console.log("playlist loader success", loaderResponse); + complete(loaderResponse, stats, successContext, networkDetails); + }; + load({ ...context, url }, config, callbacks); + }); + } else { + load(context, config, callbacks); + } + }; + } + }, + xhrSetup: async (xhr, url) => { + console.log("xhrSetup", xhr, url); + // Always send cookies, even for cross-origin calls. + if (url.startsWith(CrMaskedPrefix)) { + if (getEntityUrl) { + xhr.open("GET", await getEntityUrl(url), true); + return; + } + } + }, + }); + hls.loadSource(url); + hls.attachMedia(video); + art.hls = hls; + art.on("destroy", () => hls.destroy()); + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = url; + } else { + art.notice.show = "Unsupported playback format: m3u8"; + } + }; + export default function Player({ option, chapters, getInstance, + m3u8UrlTransform, + getEntityUrl, ...rest }: PlayerProps) { const artRef = useRef(); @@ -21,8 +109,38 @@ export default function Player({ useEffect(() => { const opts = { ...option, - plugins: [...option.plugins], + plugins: [ + ...option.plugins, + artplayerPluginHlsControl({ + quality: { + // Show qualitys in control + control: true, + // Show qualitys in setting + setting: true, + // Get the quality name from level + getName: (level) => (level.height ? level.height + "P" : i18next.t("application:fileManager.default")), + // I18n + title: i18next.t("application:fileManager.quality"), + auto: i18next.t("application:fileManager.auto"), + }, + audio: { + // Show audios in control + control: true, + // Show audios in setting + setting: true, + // Get the audio name from track + getName: (track) => track.name, + // I18n + title: i18next.t("application:fileManager.audioTrack"), + auto: i18next.t("application:fileManager.auto"), + }, + }), + ], container: artRef.current, + customType: { + ...option.customType, + m3u8: playM3u8(m3u8UrlTransform, getEntityUrl), + }, }; if (chapters) { diff --git a/src/component/Viewers/Video/VideoViewer.tsx b/src/component/Viewers/Video/VideoViewer.tsx index 5dc27e4..8720cb4 100644 --- a/src/component/Viewers/Video/VideoViewer.tsx +++ b/src/component/Viewers/Video/VideoViewer.tsx @@ -1,44 +1,27 @@ -import { useTranslation } from "react-i18next"; -import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; -import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx"; -import React, { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { closeVideoViewer } from "../../../redux/globalStateSlice.ts"; -import { - Box, - IconButton, - ListItemIcon, - ListItemText, - Menu, - Tooltip, - Typography, - useTheme, -} from "@mui/material"; -import { getFileEntityUrl } from "../../../api/api.ts"; -import { fileExtension, fileNameNoExt, getFileLinkedUri } from "../../../util"; +import { Box, IconButton, ListItemIcon, ListItemText, Menu, Tooltip, Typography, useTheme } from "@mui/material"; import Artplayer from "artplayer"; import dayjs from "dayjs"; +import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getFileEntityUrl } from "../../../api/api.ts"; import { FileResponse } from "../../../api/explorer.ts"; +import { closeVideoViewer } from "../../../redux/globalStateSlice.ts"; +import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { findSubtitleOptions } from "../../../redux/thunks/viewer.ts"; -import Subtitles from "../../Icons/Subtitles.tsx"; -import { - DenseDivider, - SquareMenuItem, -} from "../../FileManager/ContextMenu/ContextMenu.tsx"; -import Checkmark from "../../Icons/Checkmark.tsx"; import SessionManager, { UserSettings } from "../../../session"; +import { fileExtension, fileNameNoExt, getFileLinkedUri } from "../../../util"; +import CrUri from "../../../util/uri.ts"; +import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; +import Checkmark from "../../Icons/Checkmark.tsx"; +import Subtitles from "../../Icons/Subtitles.tsx"; import TextEditStyle from "../../Icons/TextEditStyle.tsx"; +import ViewerDialog, { ViewerLoading } from "../ViewerDialog.tsx"; import SubtitleStyleDialog from "./SubtitleStyleDialog.tsx"; const Player = lazy(() => import("./Artplayer.tsx")); +export const CrMaskedPrefix = "https://cloudreve_masked/"; + export interface SubtitleStyle { fontSize?: number; fontColor?: string; @@ -54,116 +37,16 @@ const VideoViewer = () => { const [loaded, setLoaded] = useState(false); const [art, setArt] = useState(null); const [anchorEl, setAnchorEl] = useState(null); - const currentExpire = useRef | undefined>( - undefined, - ); + const currentExpire = useRef | undefined>(undefined); const [subtitles, setSubtitles] = useState([]); - const [subtitleSelected, setSubtitleSelected] = useState( - null, - ); + const [subtitleSelected, setSubtitleSelected] = useState(null); const [subtitleStyleOpen, setSubtitleStyleOpen] = useState(false); + const currentUrl = useRef(null); const subtitleStyle = useMemo(() => { - return SessionManager.getWithFallback( - UserSettings.SubtitleStyle, - ) as SubtitleStyle; + return SessionManager.getWithFallback(UserSettings.SubtitleStyle) as SubtitleStyle; }, []); - // refresh video src before entity url expires - const refreshSrc = useCallback(() => { - if (!viewerState || !viewerState.file || !art) { - return; - } - - const firstLoad = !currentExpire.current; - - dispatch( - getFileEntityUrl({ - uris: [getFileLinkedUri(viewerState.file)], - entity: viewerState.version, - }), - ) - .then((res) => { - const current = art.currentTime; - - let timeOut = - dayjs(res.expires).diff(dayjs(), "millisecond") - srcRefreshMargin; - if (timeOut < 0) { - timeOut = 2000; - } - currentExpire.current = setTimeout(refreshSrc, timeOut); - - art.switchUrl(res.urls[0]).then(() => { - art.currentTime = current; - }); - - if (firstLoad) { - const subs = dispatch(findSubtitleOptions()); - setSubtitles(subs); - if ( - subs.length > 0 && - subs[0].name.startsWith(fileNameNoExt(viewerState.file.name) + ".") - ) { - switchSubtitle(subs[0]); - } - } - }) - .catch((e) => { - console.error(e); - onClose(); - }); - }, [viewerState?.file, art]); - - const chapters = useMemo(() => { - if (!viewerState || !viewerState.file?.metadata) { - return undefined; - } - - const chapterMap: { - [key: string]: { - start: number; - end: number; - title: string; - }; - } = {}; - - Object.keys(viewerState.file.metadata).map((k) => { - if (k.startsWith("stream:chapter_")) { - const id = k.split("_")[1]; - // type = remove prefix - const type = k.replace(`stream:chapter_${id}_`, ""); - if (!chapterMap[id]) { - chapterMap[id] = { - start: 0, - end: 0, - title: "", - }; - } - switch (type) { - case "start_time": - chapterMap[id].start = parseFloat( - viewerState.file?.metadata?.[k] ?? "0", - ); - break; - case "end_time": - chapterMap[id].end = parseFloat( - viewerState.file?.metadata?.[k] ?? "0", - ); - break; - case "name": - chapterMap[id].title = viewerState.file?.metadata?.[k] ?? ""; - break; - } - } - }); - - return Object.values(chapterMap).map((c) => ({ - start: c.start, - end: c.end, - title: c.title, - })); - }, [viewerState]); - const switchSubtitle = useCallback( async (subtitle?: FileResponse) => { if (!art) { @@ -198,6 +81,115 @@ const VideoViewer = () => { [art], ); + const loadSubtitles = useCallback(() => { + if (!viewerState?.file) { + return; + } + + const subs = dispatch(findSubtitleOptions()); + setSubtitles(subs); + if (subs.length > 0 && subs[0].name.startsWith(fileNameNoExt(viewerState.file.name) + ".")) { + switchSubtitle(subs[0]); + } + }, [viewerState?.file, switchSubtitle]); + + // refresh video src before entity url expires + const refreshSrc = useCallback(() => { + if (!viewerState || !viewerState.file || !art) { + return; + } + + const firstLoad = !currentExpire.current; + const isM3u8 = viewerState.file.name.endsWith(".m3u8"); + if (isM3u8) { + // For m3u8, use masked url + const crFileUrl = new CrUri(getFileLinkedUri(viewerState.file)); + const maskedUrl = `${CrMaskedPrefix}${crFileUrl.path()}`; + art.switchUrl(maskedUrl); + loadSubtitles(); + return; + } + + dispatch( + getFileEntityUrl({ + uris: [getFileLinkedUri(viewerState.file)], + entity: viewerState.version, + }), + ) + .then((res) => { + const current = art.currentTime; + currentUrl.current = res.urls[0]; + + let timeOut = dayjs(res.expires).diff(dayjs(), "millisecond") - srcRefreshMargin; + if (timeOut < 0) { + timeOut = 2000; + } + currentExpire.current = setTimeout(refreshSrc, timeOut); + + art.switchUrl(res.urls[0]).then(() => { + art.currentTime = current; + }); + + if (firstLoad) { + const subs = dispatch(findSubtitleOptions()); + setSubtitles(subs); + if (subs.length > 0 && subs[0].name.startsWith(fileNameNoExt(viewerState.file.name) + ".")) { + switchSubtitle(subs[0]); + } + } + }) + .catch((e) => { + console.error(e); + onClose(); + }); + }, [viewerState?.file, art, loadSubtitles]); + + const chapters = useMemo(() => { + if (!viewerState || !viewerState.file?.metadata) { + return undefined; + } + + const chapterMap: { + [key: string]: { + start: number; + end: number; + title: string; + }; + } = {}; + + Object.keys(viewerState.file.metadata).map((k) => { + if (k.startsWith("stream:chapter_")) { + const id = k.split("_")[1]; + // type = remove prefix + const type = k.replace(`stream:chapter_${id}_`, ""); + if (!chapterMap[id]) { + chapterMap[id] = { + start: 0, + end: 0, + title: "", + }; + } + switch (type) { + case "start_time": + chapterMap[id].start = parseFloat(viewerState.file?.metadata?.[k] ?? "0"); + break; + case "end_time": + chapterMap[id].end = parseFloat(viewerState.file?.metadata?.[k] ?? "0"); + break; + case "name": + chapterMap[id].title = viewerState.file?.metadata?.[k] ?? ""; + break; + } + } + }); + + return Object.values(chapterMap).map((c) => ({ + start: c.start, + end: c.end, + title: c.title, + })); + }, [viewerState]); + useEffect(() => { if (!art) { return; @@ -260,6 +252,89 @@ const VideoViewer = () => { [art], ); + const m3u8UrlTransform = useCallback( + async (url: string, isPlaylist?: boolean): Promise => { + let realUrl = ""; + if (isPlaylist) { + // Loading playlist + + if (!currentUrl.current) { + return url; + } + + const currentParsed = new URL(currentUrl.current); + const requestParsed = new URL(url); + if (currentParsed.origin != requestParsed.origin) { + // Playlist is from different origin, return original URL + return url; + } + + // Trim pfrefix(currentParsed.pathname) of requestParsed.pathname to get relative path + const currentPathParts = currentParsed.pathname.split("/"); + const requestPathParts = requestParsed.pathname.split("/"); + + // Find where paths diverge + let i = 0; + while ( + i < currentPathParts.length && + i < requestPathParts.length && + currentPathParts[i] === requestPathParts[i] + ) { + i++; + } + + // Get relative path by joining remaining parts + const relativePath = requestPathParts.slice(i).join("/"); + + if (!viewerState?.file) { + return url; + } + + const currentFileUrl = new CrUri(getFileLinkedUri(viewerState?.file)); + const base = i == 0 ? new CrUri(currentFileUrl.base()) : currentFileUrl.parent(); + realUrl = base.join(relativePath).path(); + return `${CrMaskedPrefix}${realUrl}`; + } else { + // Loading fragment + if (url.startsWith("http://") || url.startsWith("https://") || !viewerState?.file) { + // If fragment URL is not a path, return it + return url; + } + + // Request real fragment/playlist URL + const currentFileUrl = new CrUri(getFileLinkedUri(viewerState?.file)); + const base = url.startsWith("/") ? new CrUri(currentFileUrl.base()) : currentFileUrl.parent(); + realUrl = base.join(url).path(); + return `${CrMaskedPrefix}${realUrl}`; + } + }, + [viewerState?.file], + ); + + const getUnmaskedEntityUrl = useCallback( + async (url: string) => { + if (!viewerState?.file) { + return url; + } + // remove cloudreve_masked prefix of url + if (!url.startsWith(CrMaskedPrefix)) { + return url; + } + url = url.replace(CrMaskedPrefix, ""); + const currentFileUrl = new CrUri(getFileLinkedUri(viewerState.file)); + const base = new CrUri(currentFileUrl.base()); + const realUrl = base.join(url); + try { + const res = await dispatch(getFileEntityUrl({ uris: [realUrl.toString()] })); + return res.urls[0]; + } catch (e) { + console.error(e); + return url; + } + }, + [dispatch, viewerState?.file], + ); + // TODO: Add artplayer-plugin-chapter after it's released to npm return ( { {" "} - - {t("application:fileManager.subtitleStyles")} - + {t("application:fileManager.subtitleStyles")} {subtitles.length == 0 && ( @@ -319,9 +392,7 @@ const VideoViewer = () => { {subtitles.length > 0 && ( switchSubtitle()} dense> - + )} @@ -337,7 +408,7 @@ const VideoViewer = () => { whiteSpace: "nowrap", overflow: "hidden", }, - } + }, }} /> {subtitleSelected?.id == sub.id && ( @@ -352,6 +423,8 @@ const VideoViewer = () => { }>