├── .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 |
128 |
143 |
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 | --------------------------------------------------------------------------------