feat(video player): support m3u8 and relative path transform (#2288)

This commit is contained in:
Aaron Liu 2025-04-24 15:23:01 +08:00
parent 8c26d11734
commit 587e9f29cf
7 changed files with 361 additions and 146 deletions

View File

@ -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",

View File

@ -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",

View File

@ -112,6 +112,10 @@
"dashboard": "管理面板"
},
"fileManager": {
"quality": "清晰度",
"audioTrack": "音轨",
"auto": "自动",
"default": "默认",
"shareWithMeEmpty": "没有找到别人的分享",
"shareWithMeEmptyDes": "如需要在此看到别人的分享,请在访问别人分享链接时,在右上角将快捷方式保存到你的文件中的任意位置。",
"selectAll": "全选",

View File

@ -112,6 +112,10 @@
"dashboard": "管理面板"
},
"fileManager": {
"quality": "清晰度",
"audioTrack": "音軌",
"auto": "自動",
"default": "預設",
"shareWithMeEmpty": "沒有找到別人的分享",
"shareWithMeEmptyDes": "如需要在此看到別人的分享,請在訪問別人分享連結時,在右上角將快捷方式保存到你的文件中的任意位置。",
"selectAll": "全選",

View File

@ -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<string>;
getEntityUrl?: (url: string) => Promise<string>;
}
const playM3u8 =
(
urlTransform?: (url: string, isPlaylist?: boolean) => Promise<string>,
getEntityUrl?: (url: string) => Promise<string>,
) =>
(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<Artplayer>();
@ -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) {

View File

@ -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<Artplayer | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const currentExpire = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const currentExpire = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [subtitles, setSubtitles] = useState<FileResponse[]>([]);
const [subtitleSelected, setSubtitleSelected] = useState<FileResponse | null>(
null,
);
const [subtitleSelected, setSubtitleSelected] = useState<FileResponse | null>(null);
const [subtitleStyleOpen, setSubtitleStyleOpen] = useState(false);
const currentUrl = useRef<string | null>(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<string> => {
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 (
<ViewerDialog
@ -304,9 +379,7 @@ const VideoViewer = () => {
<ListItemIcon>
<TextEditStyle fontSize={"small"} />{" "}
</ListItemIcon>
<ListItemText>
{t("application:fileManager.subtitleStyles")}
</ListItemText>
<ListItemText>{t("application:fileManager.subtitleStyles")}</ListItemText>
</SquareMenuItem>
<DenseDivider />
{subtitles.length == 0 && (
@ -319,9 +392,7 @@ const VideoViewer = () => {
{subtitles.length > 0 && (
<SquareMenuItem onClick={() => switchSubtitle()} dense>
<em>
<ListItemText
primary={t("application:fileManager.disableSubtitle")}
/>
<ListItemText primary={t("application:fileManager.disableSubtitle")} />
</em>
</SquareMenuItem>
)}
@ -337,7 +408,7 @@ const VideoViewer = () => {
whiteSpace: "nowrap",
overflow: "hidden",
},
}
},
}}
/>
{subtitleSelected?.id == sub.id && (
@ -352,6 +423,8 @@ const VideoViewer = () => {
<Suspense fallback={<ViewerLoading minHeight={"calc(100vh - 350px)"} />}>
<Player
key={viewerState?.file?.path}
m3u8UrlTransform={m3u8UrlTransform}
getEntityUrl={getUnmaskedEntityUrl}
sx={{
width: "100%",
height: "100%",

View File

@ -3833,6 +3833,11 @@ artplayer-plugin-chapter@^1.0.0:
resolved "https://registry.yarnpkg.com/artplayer-plugin-chapter/-/artplayer-plugin-chapter-1.0.0.tgz#cb921176bc76550d9238498774d76bd0ed41b05f"
integrity sha512-moT7syJPeXQIv4xgm5QVucZB/5n0yMqfR6iscAyxyUIBJ08cVRmazBGRA82BSBZPqmgvSmH6bl0u8pMyEI+VcQ==
artplayer-plugin-hls-control@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/artplayer-plugin-hls-control/-/artplayer-plugin-hls-control-1.0.1.tgz#7d28905340d5c04121df450f80ca0eb2878ce773"
integrity sha512-rbOeH/mzqgZuosOtxJ9NERil6siOLd9K7nsCRWARrSyT+zH3xdDo0WunYgDQiATniNySicxZ//ex/pPLxYECUg==
artplayer@5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/artplayer/-/artplayer-5.2.2.tgz#889643a8e3ce12042def839b20a02e8538bafb0c"
@ -5301,6 +5306,11 @@ highlight-words-core@^1.2.0:
resolved "https://registry.yarnpkg.com/highlight-words-core/-/highlight-words-core-1.2.3.tgz#781f37b2a220bf998114e4ef8c8cb6c7a4802ea8"
integrity sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ==
hls.js@^1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.6.2.tgz#02272bea644b5f61f71741256618d6b629ae7834"
integrity sha512-rx+pETSCJEDThm/JCm8CuadcAC410cVjb1XVXFNDKFuylaayHk1+tFxhkjvnMDAfqsJHxZXDAJ3Uc2d5xQyWlQ==
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz"