├── .gitignore
├── .babelrc
├── src
├── index.css
├── effect.jsx
├── index.js
├── Comments.jsx
└── DanmakuButton.jsx
├── Dockerfile.Jellyfin
├── Dockerfile
├── webpack.config.mjs
├── package.json
├── .github
└── workflows
│ └── main.yml
├── server
└── index.mjs
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /data
3 | build-jellyfin.sh
4 | .env
5 | /dist
6 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react"
5 | ]
6 | }
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | .skinHeader-withBackground.skinHeader-withBackground.osdHeader {
2 | z-index: 2;
3 | }
4 | .videoOsdBottom.videoOsdBottom {
5 | z-index: 2;
6 | }
--------------------------------------------------------------------------------
/Dockerfile.Jellyfin:
--------------------------------------------------------------------------------
1 | ARG JELLYFIN_IMAGE=linuxserver/jellyfin:latest
2 | FROM $JELLYFIN_IMAGE
3 | ARG JFDMK_HOST
4 | RUN sed -i "s/<\/body>/
104 | ```
105 |
106 | 刷新之后,打开对应的视频,就应该能看到弹幕了。
107 |
108 | 如果你使用容器部署 Jellyfin,你需要修改的文件位于 `/usr/share/jellyfin/web/index.html` ,不过这样的问题在于并不能持久化,对此你可以在本地 build 包含 jfdmk 的 Jellyfin 镜像,例如:
109 |
110 | ```bash
111 | docker build \
112 | -f Dockerfile.Jellyfin \
113 | --build-arg JELLYFIN_IMAGE=linuxserver/jellyfin:latest \
114 | --build-arg JFDMK_HOST= \
115 | -t /jellyfin-jfdmk:latest \
116 | .
117 | ```
118 |
119 | 这里的 `JELLYFIN_IMAGE` 可以替换成其他的 Jellyfin 镜像,默认是 `linuxserver/jellyfin:latest` ,以及由于 jfdmk 本体部署在其他域名下,你需要在 build 时指定它。之所以需要本地 build 而非提供公用镜像,是因为没有什么简单的动态配置手段,如果你对自己的 jfdmk 部署域名没有保密需求,你也可以配置 CI 自动 build 并 push 到 docker hub。
120 |
121 | 镜像 build 完成之后,你可以将原先的 Jellyfin 镜像替换成 `/jellyfin_jfdmk:latest` 。
122 |
123 | 注意这样 build 得到的镜像无法自动更新 Jellyfin 版本,你可以写一个脚本定期自动运行上述 build 流程。
124 |
125 | jfdmk 只在 Web 端启用,手机端的渲染效果比较差,之后可能会启用。
126 |
127 | ## 开发
128 |
129 | 开发后端时使用:
130 |
131 | ```bash
132 | $ yarn dev:server
133 | ```
134 |
135 | 它使用 `nodemon` 监听 `server/index.js` 的变化,在变化时自动重启服务器。
136 |
137 | 它也默认在 10086 端口上运行,注意不要和运行中的服务冲突。
138 |
139 | 开发前端时首先需要启动后端,然后:
140 |
141 | ```bash
142 | $ yarn dev:frontend
143 | ```
144 |
145 | 它会在 3000 端口启动 `webpack-dev-server` ,自动打包前端代码,并反代后端请求。
146 |
147 | 开发时你需要在 Jellyfin 中插入:
148 |
149 | ```html
150 |
151 | ```
152 |
153 | 配置里允许从任何地址访问后端,请注意权限问题。
154 |
155 | 为了从 Jellyfin 中访问 `webpack-dev-server` 的 WebSocket,你需要在 `.env` 中配置:
156 |
157 | ```
158 | DEV_WEBSOCKET_URL=ws://your_jfdmk_dev_host/ws
159 | ```
160 |
161 | 如果不进行配置,可能无法获得 HMR 支持。
162 | ## 作者
163 |
164 | 张海川 - Haichuan Zhang - [me@std4453.com](mailto:me@std4453.com) - [Homepage](https://blog.std4453.com:444)
165 |
--------------------------------------------------------------------------------
/src/Comments.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo, useState } from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | import {
5 | CommentManager,
6 | CommentProvider,
7 | BilibiliFormat,
8 | } from "@std4453/comment-core-library";
9 | import "@std4453/comment-core-library/dist/css/style.min.css";
10 | import DanmakuButton from "./DanmakuButton";
11 |
12 | import "./index.css";
13 |
14 | const Comments = ({ video, buttonContainer, itemIdSubject, basePath }) => {
15 | const [container, setContainer] = useState(null);
16 | const cm = useMemo(() => {
17 | if (!container) return;
18 | return new CommentManager(container);
19 | }, [container]);
20 | useEffect(() => {
21 | if (!cm) return;
22 | cm.init();
23 | }, [cm]);
24 | useEffect(() => {
25 | if (!video || !cm) return;
26 | video.addEventListener("timeupdate", () => {
27 | cm.time(video.currentTime * 1000);
28 | });
29 | video.addEventListener("play", () => {
30 | cm.start();
31 | });
32 | video.addEventListener("playing", () => {
33 | cm.start();
34 | });
35 | video.addEventListener("waiting", () => {
36 | cm.stop();
37 | });
38 | video.addEventListener("pause", () => {
39 | cm.stop();
40 | });
41 | video.addEventListener("seeked", () => {
42 | cm.clear();
43 | });
44 | }, [cm, video]);
45 | useEffect(() => {
46 | if (!cm) return;
47 | const listener = () => {
48 | cm.width = window.innerWidth;
49 | cm.height = window.innerHeight;
50 | cm.setBounds();
51 | };
52 | window.addEventListener("resize", listener);
53 | return () => {
54 | window.removeEventListener("resize", listener);
55 | };
56 | }, [cm]);
57 |
58 | const [visible, setVisible] = useState(true);
59 |
60 | const [itemId, setItemId] = useState(itemIdSubject.getValue());
61 | useEffect(() => {
62 | const subscription = itemIdSubject.subscribe((itemId) => setItemId(itemId));
63 | return () => {
64 | subscription.unsubscribe();
65 | };
66 | }, [itemIdSubject]);
67 |
68 | useEffect(() => {
69 | if (!itemId || !video || !cm) return;
70 | (async () => {
71 | try {
72 | const data = await ApiClient.getItem(
73 | ApiClient.getCurrentUserId(),
74 | itemId
75 | );
76 | const { Type, SeriesName, ParentIndexNumber, IndexNumber } = data;
77 | if (Type !== "Episode") {
78 | throw new Error("not_found");
79 | }
80 | const resp = await fetch(
81 | `${basePath}/query?series=${encodeURIComponent(
82 | SeriesName
83 | )}&season=${ParentIndexNumber}&episode=${IndexNumber}`
84 | );
85 | const { code, query } = await resp.json();
86 | if (code !== 200) {
87 | throw new Error("not_found");
88 | }
89 | const provider = new CommentProvider();
90 | provider.addParser(
91 | new BilibiliFormat.XMLParser(),
92 | CommentProvider.SOURCE_XML
93 | );
94 | provider.addTarget(cm);
95 | provider.addStaticSource(
96 | CommentProvider.XMLProvider("GET", `${basePath}/danmaku?${query}`),
97 | CommentProvider.SOURCE_XML
98 | );
99 | await provider.load();
100 | if (!video.paused) {
101 | console.log("[jfdmk] Danmaku loaded, starting CommentManager");
102 | cm.start();
103 | cm.time(video.currentTime * 1000);
104 | cm.clear();
105 | }
106 | } catch (e) {
107 | if (e.message === "not_found") {
108 | console.log(`[jfdmk] ${itemId} has no matching danmaku`);
109 | } else {
110 | console.error(`[jfdmk] loading danmaku failed for ${itemId}`);
111 | console.error(e);
112 | }
113 | }
114 | })();
115 | }, [itemId, cm, video, basePath]);
116 |
117 | const [opacity, setOpacity] = useState(1.0);
118 | const [speed, setSpeed] = useState(0.75);
119 | useEffect(() => {
120 | if (!cm) return;
121 | cm.options.scroll.scale = 1 / speed;
122 | }, [cm, speed]);
123 | const [fontSize, setFontSize] = useState(1.0);
124 |
125 | return (
126 | <>
127 |
144 | {ReactDOM.createPortal(
145 | ,
155 | buttonContainer
156 | )}
157 | >
158 | );
159 | };
160 |
161 | export default Comments;
162 |
--------------------------------------------------------------------------------
/src/DanmakuButton.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Backdrop,
3 | List,
4 | ListItem,
5 | ListItemText,
6 | Popper,
7 | Paper,
8 | Grow,
9 | createTheme,
10 | ThemeProvider,
11 | Slider,
12 | Grid,
13 | } from "@mui/material";
14 | import { makeStyles } from "@mui/styles";
15 | import clsx from "clsx";
16 | import React, { useCallback, useState } from "react";
17 |
18 | const theme = createTheme({
19 | palette: {
20 | mode: "dark",
21 | },
22 | });
23 |
24 | const useStyles = makeStyles({
25 | list: {
26 | width: 300,
27 | },
28 | text: {
29 | width: 100,
30 | },
31 | slider: {
32 | margin: '-8px 0',
33 | },
34 | });
35 |
36 | const DanmakuButton = ({
37 | visible,
38 | setVisible,
39 | opacity,
40 | setOpacity,
41 | speed,
42 | setSpeed,
43 | fontSize,
44 | setFontSize,
45 | }) => {
46 | const [buttonEl, setButtonEl] = useState(null);
47 | const [open, setOpen] = useState(false);
48 | const handleSubtitlesClick = useCallback(() => {
49 | setVisible((visible) => !visible);
50 | }, [setVisible]);
51 | const handleSettingsClick = useCallback(() => {
52 | setOpen((open) => !open);
53 | }, []);
54 | const handleClose = useCallback(() => {
55 | setOpen(false);
56 | }, []);
57 | const handleOpacityChange = useCallback((event, value) => {
58 | setOpacity(value / 100);
59 | }, [setOpacity]);
60 | const handleSpeedChange = useCallback((event, value) => {
61 | setSpeed(value / 100);
62 | }, [setSpeed]);
63 | const handleFontSizeChange = useCallback((event, value) => {
64 | setFontSize(value / 100);
65 | }, [setFontSize]);
66 |
67 | const classes = useStyles();
68 |
69 | return (
70 |
71 |
82 |
89 |
96 |
104 | {({ TransitionProps }) => (
105 |
106 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | `${x}%`}
126 | defaultValue={opacity * 100}
127 | onChangeCommitted={handleOpacityChange}
128 | className={classes.slider}
129 | />
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 | `${(x / 100).toFixed(2)}x`}
144 | defaultValue={speed * 100}
145 | onChangeCommitted={handleSpeedChange}
146 | className={classes.slider}
147 | />
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 | `${x}%`}
162 | defaultValue={fontSize * 100}
163 | onChangeCommitted={handleFontSizeChange}
164 | className={classes.slider}
165 | />
166 |
167 |
168 |
169 |
170 |
171 |
172 | )}
173 |
174 |
175 | );
176 | };
177 |
178 | export default DanmakuButton;
179 |
--------------------------------------------------------------------------------