├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── dist ├── main.js ├── manifest.json └── preview.webp ├── package.json ├── screenshot1.jpg ├── screenshot2.jpg ├── screenshot3.jpg ├── screenshot4.jpg ├── src ├── FM.scss ├── background.js ├── background.scss ├── color-utils.js ├── compatibility-check.js ├── compatibility-check.scss ├── context-menu.js ├── context-menu.scss ├── cover-shadow.js ├── exclusive-modes.scss ├── experimental.scss ├── font-settings.js ├── font-settings.scss ├── liblyric │ ├── README.md │ └── index.ts ├── lyric-provider.js ├── lyrics.js ├── lyrics.scss ├── main.js ├── manifest.json ├── material-you-compatibility.scss ├── mini-song-info.js ├── mini-song-info.scss ├── preview.gif ├── preview.jpg ├── preview.webp ├── progressbar-preview.js ├── progressbar-preview.scss ├── refined-control-bar.js ├── refined-control-bar.scss ├── settings-menu.html ├── settings-menu.scss ├── styles.scss ├── utils.js ├── whats-new.js ├── whats-new.json └── whats-new.scss ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/syntax-dynamic-import"], 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false 8 | } 9 | ], 10 | "@babel/preset-react" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | *.lock 18 | package-lock.json 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 solstice23 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Refined Now Playing 2 | 3 | 一个美化网易云音乐播放界面的 [BetterNCM](https://github.com/MicroCBer/BetterNCM) 插件 4 | 5 | # Status 6 | 7 | Since I no longer use CloudMusic, the maintenance of this project has been suspended indefinitely. 8 | 9 | # 安装 10 | 11 | 0. 安装 [BetterNCM](https://github.com/MicroCBer/BetterNCM) 插件 12 | 1. 在插件商店中安装 13 | 14 | # 效果 15 | 16 | https://user-images.githubusercontent.com/23134847/216518149-9d85c6a6-4ad5-4c2c-9843-a2f65f610fd0.mp4 17 | 18 | ![screenshot1](screenshot1.jpg) 19 | 20 | ![screenshot2](screenshot3.jpg) 21 | 22 | ![screenshot3](screenshot2.jpg) 23 | 24 | ![screenshot4](screenshot4.jpg) 25 | -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 1, 3 | "name": "RefinedNowPlaying", 4 | "author": "solstice23", 5 | "author_links": ["https://github.com/solstice23"], 6 | "description": "重新设计的正在播放页面和歌词", 7 | "preview": "preview.webp", 8 | "version": "2.19.5", 9 | "type": "extension", 10 | "requirements": [], 11 | "loadAfter": ["MaterialYouTheme", "LibFrontendPlay", "SimpleAudioVisualizer"], 12 | "incompatible": ["Apple-Musiclike-lyrics"], 13 | "betterncm_version": ">=1.0.0", 14 | "injects": { 15 | "Main": [ 16 | { 17 | "file": "main.js" 18 | } 19 | ] 20 | }, 21 | "hijacks": { 22 | ">= 2.10.4": { 23 | "orpheus://orpheus/pub/core": { 24 | "type": "replace", 25 | "from": "function(t,i,e,r,n,a){var o;if(((this.U()||C).from||C).id==t)", 26 | "to": "async function(t,i,e,r,n,a){;i='online';window.onProcessLyrics&&(a=(await onProcessLyrics(a,e)));var o;if(((this.U()||C).from||C).id==t)" 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /dist/preview.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solstice23/refined-now-playing-netease/f3b3e2b38809e711d5c60cb2b7f8ce1eda318bda/dist/preview.webp -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@babel/cli": "^7.19.3", 4 | "@babel/core": "^7.20.5", 5 | "@babel/preset-env": "^7.20.2", 6 | "@babel/preset-react": "^7.18.6", 7 | "@webpack-cli/generators": "^3.0.1", 8 | "babel-loader": "^9.1.0", 9 | "copy-webpack-plugin": "^11.0.0", 10 | "css-loader": "^6.7.1", 11 | "html-webpack-plugin": "^5.5.0", 12 | "mini-css-extract-plugin": "^2.6.0", 13 | "prettier": "^2.8.1", 14 | "sass": "^1.52.3", 15 | "sass-loader": "^13.0.0", 16 | "style-loader": "^3.3.1", 17 | "ts-loader": "^9.4.2", 18 | "typescript": "^4.9.5", 19 | "webpack": "^5.75.0", 20 | "webpack-bundle-analyzer": "^4.7.0", 21 | "webpack-cli": "^5.0.1", 22 | "webpack-dev-server": "^4.11.1" 23 | }, 24 | "version": "1.0.0", 25 | "description": "RefinedNowPlaying", 26 | "name": "refined-now-playing", 27 | "scripts": { 28 | "build": "webpack --mode=production --node-env=production", 29 | "build:dev": "webpack --mode=development", 30 | "build:prod": "webpack --mode=production --node-env=production", 31 | "watch": "webpack --watch", 32 | "serve": "webpack serve" 33 | }, 34 | "dependencies": { 35 | "@emotion/react": "^11.10.6", 36 | "@emotion/styled": "^11.10.6", 37 | "@importantimport/material-color-utilities": "^0.2.1-alpha.0", 38 | "@mui/material": "^5.11.10", 39 | "colorthief": "^2.3.2", 40 | "compare-versions": "^6.0.0-rc.1", 41 | "fast-average-color": "^9.3.0", 42 | "lodash": "^4.17.21", 43 | "path-browserify": "^1.0.1", 44 | "react": "^18.2.0", 45 | "react-dom": "^18.2.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /screenshot1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solstice23/refined-now-playing-netease/f3b3e2b38809e711d5c60cb2b7f8ce1eda318bda/screenshot1.jpg -------------------------------------------------------------------------------- /screenshot2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solstice23/refined-now-playing-netease/f3b3e2b38809e711d5c60cb2b7f8ce1eda318bda/screenshot2.jpg -------------------------------------------------------------------------------- /screenshot3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solstice23/refined-now-playing-netease/f3b3e2b38809e711d5c60cb2b7f8ce1eda318bda/screenshot3.jpg -------------------------------------------------------------------------------- /screenshot4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/solstice23/refined-now-playing-netease/f3b3e2b38809e711d5c60cb2b7f8ce1eda318bda/screenshot4.jpg -------------------------------------------------------------------------------- /src/FM.scss: -------------------------------------------------------------------------------- 1 | .rnp-bg.fm-bg { 2 | position: fixed; 3 | top: 0; 4 | z-index: -1; 5 | } 6 | .m-fm { 7 | width: min(max(80%, 1500px), calc(100% - max(50px, 5%))); 8 | > .g-play { 9 | height: calc(100vh - var(--bottombar-height, 72px) - 60px + 20px); 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: flex-end; 13 | box-sizing: border-box; 14 | width: 45%; 15 | margin-bottom: 25px; 16 | padding-bottom: clamp(30px, 5%, 60px); 17 | .fmlrc { 18 | height: unset; 19 | } 20 | 21 | .fmplay { 22 | margin-left: 35px; 23 | width: unset !important; 24 | .covers::after { 25 | display: none; 26 | } 27 | .btnwrap { 28 | display: block; 29 | } 30 | .btn, .btn:hover { 31 | background: transparent; 32 | border: none; 33 | opacity: .6; 34 | svg { 35 | fill: var(--rnp-accent-color-shade-2) !important; 36 | } 37 | } 38 | .btn:hover { 39 | opacity: 1 !important; 40 | } 41 | .playbtn { 42 | right: 20px !important; 43 | bottom: 20px !important; 44 | left: unset !important; 45 | top: unset !important; 46 | background: #00000088; 47 | backdrop-filter: blur(10px); 48 | width: 20% !important; 49 | height: 20% !important; 50 | transition: opacity .3s ease, transform .25s ease; 51 | &:hover { 52 | transform: scale(1.05); 53 | } 54 | &[data-action='pause'] { 55 | opacity: 0; 56 | pointer-events: none; 57 | } 58 | svg.play { 59 | display: block; 60 | opacity: 1; 61 | } 62 | svg.pause { 63 | display: none; 64 | } 65 | } 66 | } 67 | .m-playlrc .word { 68 | display: none; 69 | } 70 | 71 | .fmplay .covers, .fmplay .covers .cvr { 72 | width: clamp(200px, 20vw, 500px) !important; 73 | height: clamp(200px, 20vw, 500px) !important; 74 | } 75 | .fmplay .covers .cvr { 76 | border-radius: 16px; 77 | box-shadow: 0 10px 10px rgb(0 0 0 / 5%), 0 0px 20px rgb(0 0 0 / 6%); 78 | transition: transform 0.6s ease, opacity 0.6s ease; 79 | } 80 | .fmplay .covers .cvr-next { 81 | opacity: 0.1; 82 | &:hover { 83 | opacity: 0.5; 84 | } 85 | } 86 | } 87 | .lyric { 88 | position: absolute; 89 | display: block; 90 | top: 10px; 91 | height: calc(100vh - var(--bottombar-height, 72px) - 120px); 92 | left: 50%; 93 | width: 45%; 94 | } 95 | 96 | .m-playlrc .inf{ 97 | .title h1 { 98 | font-size: 32px; 99 | line-height: 1.5; 100 | color: var(--rnp-accent-color-shade-2); 101 | } 102 | h2 { 103 | font-size: 20px; 104 | line-height: 1; 105 | opacity: .6; 106 | margin-bottom: 5px; 107 | color: rgba(var(--rnp-accent-color-shade-2-rgb), 0.55); 108 | } 109 | .playfrom { 110 | display: flex; 111 | flex-direction: column; 112 | } 113 | li { 114 | max-width: 100%; 115 | width: unset; 116 | color: #ffffff66; 117 | } 118 | .playfrom li span { 119 | color: rgba(var(--rnp-accent-color-shade-2-rgb), 0.45); 120 | } 121 | .playfrom li a span { 122 | color: rgba(var(--rnp-accent-color-shade-2-rgb), 0.45); 123 | } 124 | ul li a { 125 | color: rgba(var(--rnp-accent-color-shade-2-rgb), 0.45); 126 | margin-right: 4px; 127 | margin-left: 4px; 128 | } 129 | ul li label { 130 | color: transparent; 131 | width: 18px; 132 | display: inline-block; 133 | background-color: var(--rnp-accent-color-shade-2); 134 | -webkit-mask-position: center; 135 | -webkit-mask-repeat: no-repeat; 136 | pointer-events: none; 137 | margin-right: 4px; 138 | opacity: .4; 139 | } 140 | ul li:first-child label { 141 | -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3C!-- Font Awesome Pro 6.0.0-alpha2 by %40fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --%3E%3Cpath d='M256 16C123.461 16 16 123.419 16 256S123.461 496 256 496S496 388.581 496 256S388.539 16 256 16ZM80.715 256H79.627C70.549 256 63.229 247.99 64.065 238.658C72.364 146.017 146.49 72.06 239.274 64.055C248.291 63.278 256 70.791 256 80.132V80.132C256 88.482 249.786 95.363 241.727 96.077C164.745 102.898 103.148 164.347 96.153 241.354C95.401 249.634 88.771 256 80.715 256ZM256 352C202.976 352 160 309 160 256S202.976 160 256 160S352 203 352 256S309.024 352 256 352ZM256 224C238.303 224 224 238.25 224 256S238.303 288 256 288C273.697 288 288 273.75 288 256S273.697 224 256 224Z'/%3E%3C/svg%3E"); 142 | } 143 | ul li:last-child label { 144 | -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 512'%3E%3C!-- Font Awesome Pro 6.0.0-alpha2 by %40fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --%3E%3Cpath d='M389.418 347.664C358.834 320.578 318.732 304 274.664 304H173.336C77.609 304 0 381.602 0 477.332C0 496.477 15.523 512 34.664 512H355.193C333.4 493.432 320 468.27 320 440C320 399.045 348.041 364.709 389.418 347.664ZM224 256C294.695 256 352 198.691 352 128S294.695 0 224 0C153.312 0 96 57.309 96 128S153.312 256 224 256ZM601.725 160.631L505.725 179.832C490.768 182.824 480 195.957 480 211.211V372.408C469.945 369.727 459.281 368 448 368C394.98 368 352 400.234 352 440C352 479.764 394.98 512 448 512S544 479.764 544 440V300.176L614.275 286.121C629.232 283.131 640 269.996 640 254.742V192.01C640 171.816 621.525 156.672 601.725 160.631Z'/%3E%3C/svg%3E"); 145 | } 146 | .playfrom li { 147 | color: rgba(var(--rnp-accent-color-shade-2-rgb), 0.45); 148 | font-size: 16px; 149 | margin-top: 5px; 150 | width: unset; 151 | line-height: normal; 152 | &:last-child { 153 | order: -1; 154 | } 155 | } 156 | } 157 | ::selection { 158 | background-color: rgba(var(--rnp-accent-color-shade-2-rgb), 0.3) !important; 159 | } 160 | } 161 | body:not(.rectangle-cover) .m-fm .fmplay .covers .cvr{ 162 | border-radius: 1000px; 163 | box-shadow: 0 0 0 8px #ffffff22; 164 | } 165 | 166 | body.vertical-align-middle #page_pc_userfm_songplay > .g-play { 167 | padding-bottom: 0; 168 | justify-content: center; 169 | } -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | import './background.scss'; 2 | import { getGradientFromPalette } from './color-utils'; 3 | import ColorThief from 'colorthief'; 4 | 5 | const useState = React.useState; 6 | const useEffect = React.useEffect; 7 | const useRef = React.useRef; 8 | 9 | const colorThief = new ColorThief(); 10 | 11 | export function Background(props) { 12 | const [type, setType] = useState(props.type ?? 'blur'); // blur, gradient, fluid , solid 13 | const [url, setUrl] = useState(''); 14 | const [staticFluid, setStaticFluid] = useState(true); 15 | const image = props.image; 16 | 17 | 18 | if (!props.isFM) { 19 | useEffect(() => { 20 | const observer = new MutationObserver(() => { 21 | if (image.src === url) return; 22 | if (image.complete) { 23 | setUrl(image.src); 24 | } 25 | }); 26 | observer.observe(image, { attributes: true, attributeFilter: ['src'] }); 27 | const onload = () => { 28 | setUrl(image.src); 29 | }; 30 | image.addEventListener('load', onload); 31 | return () => { 32 | observer.disconnect(); 33 | image.removeEventListener('load', onload); 34 | } 35 | }, [image]); 36 | } else { 37 | useEffect(() => { 38 | const imageContainer = image; 39 | if (imageContainer.querySelector('.cvr.j-curr img')) { 40 | setUrl(imageContainer.querySelector('.cvr.j-curr img').src); 41 | props.imageChangedCallback(imageContainer.querySelector('.cvr.j-curr img')); 42 | } 43 | const observer = new MutationObserver(() => { 44 | if (imageContainer.querySelector('.cvr.j-curr img')) { 45 | setUrl(imageContainer.querySelector('.cvr.j-curr img').src); 46 | props.imageChangedCallback(imageContainer.querySelector('.cvr.j-curr img')); 47 | } 48 | }); 49 | observer.observe(imageContainer, { childList: true, subtree: true }); 50 | return () => { 51 | observer.disconnect(); 52 | } 53 | }, [image]); 54 | } 55 | 56 | useEffect(() => { 57 | document.addEventListener('rnp-background-type', (e) => { 58 | setType(e.detail.type ?? 'blur'); 59 | }); 60 | document.addEventListener('rnp-static-fluid', (e) => { 61 | setStaticFluid(e.detail ?? false); 62 | }); 63 | }, []); 64 | 65 | return ( 66 | <> 67 | {type === 'blur' && ( 68 | 69 | )} 70 | {type === 'gradient' && ( 71 | 72 | )} 73 | {type === 'fluid' && ( 74 | 75 | )} 76 | {type === 'solid' && ( 77 | 78 | )} 79 | {type === 'none' && ( 80 | <> 81 |
82 | 93 | 94 | )} 95 | 96 | ); 97 | } 98 | function BlurBackground(props) { 99 | const ref = useRef(); 100 | useEffect(() => { 101 | if (!props.url) return; 102 | ref.current.style.backgroundImage = `url(${props.url})`; 103 | ref.current.style.transition = 'background-image 1.5s ease'; 104 | }, [props.url]); 105 | 106 | return ( 107 |
108 | ); 109 | } 110 | 111 | function GradientBackground(props) { 112 | const [gradient, setGradient] = useState('linear-gradient(-45deg, #666, #fff)'); 113 | useEffect(() => { 114 | const image = new Image(); 115 | image.crossOrigin = 'Anonymous'; 116 | console.log('loading image'); 117 | image.onload = () => { 118 | console.log('image loaded'); 119 | const palette = colorThief.getPalette(image); 120 | setGradient(getGradientFromPalette(palette)); 121 | }; 122 | image.src = props.url; 123 | }, [props.url]); 124 | 125 | return ( 126 |
127 | ); 128 | } 129 | 130 | function FluidBackground(props) { 131 | const [canvas1, canvas2, canvas3, canvas4] = [useRef(), useRef(), useRef(), useRef()]; 132 | const [feTurbulence, feDisplacementMap] = [useRef(), useRef()]; 133 | const fluidContainer = useRef(); 134 | const staticFluidStyleRef = useRef(); 135 | const [songId, setSongId] = useState("0"); 136 | 137 | const playState = useRef(document.querySelector("#main-player .btnp").classList.contains("btnp-pause")); 138 | 139 | const onPlayStateChange = (id, state) => { 140 | //playState.current = (state.split('|')[1] == 'resume'); 141 | if (!props.isFM) { 142 | playState.current = document.querySelector("#main-player .btnp").classList.contains("btnp-pause"); 143 | } else { 144 | playState.current = document.querySelector(".m-player-fm .btnp").classList.contains("btnp-pause"); 145 | } 146 | setSongId(id); 147 | fluidContainer.current.classList.toggle("paused", !playState.current); 148 | //console.log(id, playState.current, state.split('|')[1], document.querySelector("#main-player .btnp").classList.contains("btnp-pause")); 149 | }; 150 | 151 | useEffect(() => { 152 | fluidContainer.current.classList.toggle("paused", !playState.current); 153 | }, [songId]); 154 | 155 | useEffect(() => { 156 | legacyNativeCmder.appendRegisterCall( 157 | "PlayState", 158 | "audioplayer", 159 | onPlayStateChange 160 | ); 161 | return () => { 162 | legacyNativeCmder.removeRegisterCall( 163 | "PlayState", 164 | "audioplayer", 165 | onPlayStateChange 166 | ); 167 | } 168 | }, []); 169 | 170 | useEffect(() => { 171 | canvas1.current.getContext('2d').filter = 'blur(5px)'; 172 | canvas2.current.getContext('2d').filter = 'blur(5px)'; 173 | canvas3.current.getContext('2d').filter = 'blur(5px)'; 174 | canvas4.current.getContext('2d').filter = 'blur(5px)'; 175 | }, []); 176 | 177 | useEffect(() => { 178 | const image = new Image(); 179 | image.crossOrigin = 'Anonymous'; 180 | image.onload = () => { 181 | const { width, height } = image; 182 | canvas1.current.getContext('2d').drawImage(image, 0, 0, width / 2, height / 2, 0, 0, 100, 100); 183 | canvas2.current.getContext('2d').drawImage(image, width / 2, 0, width / 2, height / 2, 0, 0, 100, 100); 184 | canvas3.current.getContext('2d').drawImage(image, 0, height / 2, width / 2, height / 2, 0, 0, 100, 100); 185 | canvas4.current.getContext('2d').drawImage(image, width / 2, height / 2, width / 2, height / 2, 0, 0, 100, 100); 186 | }; 187 | image.src = props.url; 188 | feTurbulence.current.setAttribute('seed', parseInt(Math.random() * 1000)); 189 | staticFluidStyleRef.current.innerHTML = ` 190 | body.static-fluid .rnp-background-fluid-rect { 191 | animation-play-state: paused !important; 192 | animation-delay: -${parseInt(Math.random() * 150)}s !important; 193 | } 194 | body.static-fluid .rnp-background-fluid-rect canvas { 195 | animation-play-state: paused !important; 196 | animation-delay: -${parseInt(Math.random() * 60)}s !important; 197 | } 198 | `; 199 | }, [props.url]); 200 | 201 | const onResize = () => { 202 | const { width, height } = document.body.getBoundingClientRect(); 203 | const viewSize = Math.max(width, height); 204 | const canvasSize = viewSize * 0.707; 205 | 206 | const canvasList = [canvas1, canvas2, canvas3, canvas4]; 207 | for (let x = 0; x <= 1; x++) { 208 | for (let y = 0; y <= 1; y++) { 209 | const canvas = canvasList[y * 2 + x]; 210 | canvas.current.style.width = `${canvasSize}px`; 211 | canvas.current.style.height = `${canvasSize}px`; 212 | const signX = x === 0 ? -1 : 1, signY = y === 0 ? -1 : 1; 213 | canvas.current.style.left = `${(width / 2 + signX * canvasSize * 0.35) - canvasSize / 2}px`; 214 | canvas.current.style.top = `${(height / 2 + signY * canvasSize * 0.35) - canvasSize / 2}px`; 215 | } 216 | } 217 | } 218 | 219 | useEffect(() => { 220 | window.addEventListener('resize', onResize); 221 | onResize(); 222 | return () => { 223 | window.removeEventListener('resize', onResize); 224 | } 225 | }, []); 226 | 227 | const setDisplacementScale = React.useCallback((value) => { 228 | if (!feDisplacementMap.current) return; 229 | feDisplacementMap.current.setAttribute('scale', value); 230 | }, []); 231 | 232 | // Audio-responsive background (For LibVolumeLevelProvider) 233 | if (loadedPlugins.LibFrontendPlay) { 234 | /*const processor = useRef({}); 235 | useEffect(() => { 236 | processor.current.audioContext = new AudioContext(); 237 | processor.current.audioSource = null; 238 | processor.current.analyser = processor.current.audioContext.createAnalyser(); 239 | //processor.current.analyser.connect(processor.current.audioContext.destination); 240 | processor.current.analyser.fftSize = 512; 241 | processor.current.filter = processor.current.audioContext.createBiquadFilter(); 242 | processor.current.filter.type = 'lowpass'; 243 | processor.current.bufferLength = processor.current.analyser.frequencyBinCount; 244 | processor.current.dataArray = new Float32Array(processor.current.bufferLength); 245 | }, []); 246 | 247 | const onAudioSourceChange = (e) => { 248 | processor.current.audio = e.detail; 249 | console.log('audio source changed', processor.current.audio); 250 | if (!processor.current.audio) return; 251 | if (processor.current.audioSource) processor.current.audioSource.disconnect(); 252 | processor.current.audioSource = processor.current.audioContext.createMediaElementSource(processor.current.audio); 253 | processor.current.audioSource.connect(processor.current.filter).connect(processor.current.analyser); 254 | processor.current.audioSource.connect(processor.current.audioContext.destination); 255 | }; 256 | 257 | useEffect(() => { 258 | loadedPlugins.LibFrontendPlay.addEventListener( 259 | "updateCurrentAudioPlayer", 260 | onAudioSourceChange 261 | ); 262 | return () => { 263 | loadedPlugins.LibFrontendPlay.removeEventListener( 264 | "updateCurrentAudioPlayer", 265 | onAudioSourceChange 266 | ); 267 | } 268 | }, []);*/ 269 | 270 | 271 | const processor = useRef({}); 272 | useEffect(() => { 273 | //processor.current.bufferLength = loadedPlugins.LibFrontendPlay.currentAudioAnalyser.frequencyBinCount; 274 | processor.current.bufferLength = 1024; 275 | processor.current.dataArray = new Float32Array(processor.current.bufferLength); 276 | }, []); 277 | 278 | 279 | 280 | const request = useRef(0); 281 | useEffect(() => { 282 | const animate = () => { 283 | request.current = requestAnimationFrame(animate); 284 | if (!playState.current) return; 285 | //processor.current.analyser.getFloatFrequencyData(processor.current.dataArray); 286 | //const max = Math.max(...processor.current.dataArray); 287 | loadedPlugins.LibFrontendPlay.currentAudioAnalyser.getFloatFrequencyData(processor.current.dataArray); 288 | const max = Math.max(...processor.current.dataArray); 289 | //const percentage = (max - processor.current.analyser.minDecibels) / (processor.current.analyser.maxDecibels - processor.current.analyser.minDecibels); 290 | const percentage = Math.pow(1.3, max / 20) * 2 - 1; 291 | //console.log(max, percentage, processor.current.audio.volume); 292 | setDisplacementScale(Math.min(600, Math.max(200, 800 - percentage * 800))); 293 | }; 294 | request.current = requestAnimationFrame(animate); 295 | return () => { 296 | cancelAnimationFrame(request.current); 297 | } 298 | }, []); 299 | } 300 | // Audio-responsive background (For LibVolumeLevelProvider) 301 | else if (typeof(registerAudioLevelCallback) == "function") { 302 | let audioLevels = {}, audioLevelSum = 0, now = 0; 303 | let maxq = [], minq = []; 304 | let percentage; 305 | const onAudioLevelChange = (value) => { 306 | if (!playState.current) return; 307 | now += 1; 308 | if (now <= 100) { 309 | audioLevels[now] = value; 310 | audioLevelSum += value; 311 | while (maxq.length && audioLevels[maxq[maxq.length - 1]] <= value) maxq.pop(); 312 | maxq.push(now); 313 | while (minq.length && audioLevels[minq[minq.length - 1]] >= value) minq.pop(); 314 | minq.push(now); 315 | setDisplacementScale(400 - value * 200); 316 | return; 317 | } 318 | audioLevelSum -= audioLevels[now - 100]; 319 | delete audioLevels[now - 100]; 320 | audioLevels[now] = value; 321 | audioLevelSum += value; 322 | while (maxq.length && audioLevels[maxq[maxq.length - 1]] <= value) maxq.pop(); 323 | maxq.push(now); 324 | while (maxq[0] <= now - 100) maxq.shift(); 325 | while (minq.length && audioLevels[minq[minq.length - 1]] >= value) minq.pop(); 326 | minq.push(now); 327 | while (minq[0] <= now - 100) minq.shift(); 328 | //console.log(audioLevels[maxq[0]], audioLevels[minq[0]], audioLevels[maxq[0]] - audioLevels[minq[0]]); 329 | //console.log(value, audioLevelSum / 100, value - audioLevelSum / 100); 330 | percentage = (value - audioLevels[minq[0]]) / (audioLevels[maxq[0]] - audioLevels[minq[0]]); 331 | if (percentage != percentage) percentage = 1 / 3; // NaN 332 | function easeInOutQuint(x) { 333 | return x < 0.5 ? 16 * x * x * x * x * x : 1 - Math.pow(-2 * x + 2, 5) / 2; 334 | } 335 | //console.log('percentage', percentage, easeInOutQuint(percentage)); 336 | percentage = easeInOutQuint(percentage); 337 | const scale = 500 - (percentage) * 300; 338 | //feDisplacementMap.current.setAttribute('scale', scale); 339 | if (!feDisplacementMap.current) return; 340 | const oldScale = parseFloat(feDisplacementMap.current.getAttribute('scale')); 341 | setDisplacementScale(oldScale + (scale - oldScale) * 0.1); 342 | } 343 | useEffect(() => { 344 | registerAudioLevelCallback(onAudioLevelChange); 345 | return () => { 346 | unregisterAudioLevelCallback(onAudioLevelChange); 347 | setDisplacementScale(400); 348 | } 349 | }, []); 350 | useEffect(() => { 351 | audioLevels = []; 352 | audioLevelSum = 0; 353 | }, [songId]); 354 | } 355 | 356 | return ( 357 | <> 358 | 371 | 372 | 373 | 374 | { 375 | props.static ? 376 | : 377 | 378 | } 379 | {/**/} 380 | 381 | 382 |
383 |
384 | 385 | 386 | 387 | 388 |
389 |
390 | 391 | ); 392 | } 393 | 394 | function SolidBackground() { 395 | return ( 396 |
397 | ); 398 | } 399 | -------------------------------------------------------------------------------- /src/background.scss: -------------------------------------------------------------------------------- 1 | .rnp-bg { 2 | display: block; 3 | width: 100%; 4 | height: 100%; 5 | opacity: 1; 6 | pointer-events: none; 7 | position: fixed; 8 | left: 0; 9 | top: 0; 10 | overflow: hidden; 11 | z-index: 0; 12 | transition: left 0.3s ease, top 0.3s ease, width 0.3s ease, height 0.3s ease, border-radius 0.3s ease; 13 | 14 | > div { 15 | position: absolute; 16 | width: 100%; 17 | height: 100%; 18 | left: 0; 19 | top: 0; 20 | pointer-events: none; 21 | } 22 | 23 | .rnp-background-blur { 24 | background-position: center; 25 | background-repeat: no-repeat; 26 | background-size: cover; 27 | will-change: background-image; 28 | &::before { // dim layer 29 | content: ""; 30 | position: absolute; 31 | width: 100%; 32 | height: 100%; 33 | background: var(--rnp-accent-color-overlay); 34 | opacity: var(--bg-dim, 0.55); 35 | pointer-events: none; 36 | } 37 | &::after { // blur layer 38 | content: ""; 39 | position: absolute; 40 | width: 100%; 41 | height: 100%; 42 | backdrop-filter: blur(var(--bg-blur, 36px)); 43 | pointer-events: none; 44 | } 45 | } 46 | 47 | 48 | .rnp-background-gradient { 49 | background-size: 400% 400%; 50 | background-position: 50% 50%; 51 | &::before { 52 | content: ""; 53 | position: absolute; 54 | width: 100%; 55 | height: 100%; 56 | background: var(--rnp-accent-color-overlay); 57 | opacity: var(--bg-dim-for-gradient-bg, 0.45); 58 | pointer-events: none; 59 | } 60 | } 61 | 62 | 63 | .rnp-background-fluid { 64 | background-size: cover; 65 | // transform: scale(1.15); 66 | width: calc(100% + 150px); 67 | height: calc(100% + 150px); 68 | left: -150px; 69 | top: -150px; 70 | .rnp-background-fluid-rect { 71 | animation: fluid-container-rotate 150s linear infinite; 72 | animation-play-state: running; 73 | width: max(100vw, 100vh); 74 | height: max(100vw, 100vh); 75 | top: calc(50% - 50vh); 76 | left: calc(50% - 50vw); 77 | position: relative; 78 | filter: saturate(1.5) brightness(0.8) url(#fluid-filter); 79 | canvas { 80 | position: absolute; 81 | animation: fluid-block-rotate 60s linear infinite; 82 | animation-play-state: running; 83 | opacity: 1; 84 | } 85 | &.paused { 86 | animation-play-state: paused; 87 | canvas { 88 | animation-play-state: paused; 89 | } 90 | } 91 | @for $i from 1 through 4 { 92 | canvas[canvasID="#{$i}"] { 93 | animation-delay: -#{($i - 1) * 5}s; 94 | } 95 | } 96 | @keyframes fluid-block-rotate { 97 | 0% { 98 | transform: rotate(0deg); 99 | } 100 | 100% { 101 | transform: rotate(360deg); 102 | } 103 | } 104 | @keyframes fluid-container-rotate { 105 | 0% { 106 | transform: scale(1.2) rotate(0deg); 107 | } 108 | 100% { 109 | transform: scale(1.2) rotate(-360deg); 110 | } 111 | } 112 | } 113 | &::after { 114 | content: ''; 115 | position: absolute; 116 | display: block; 117 | width: 100%; 118 | height: 100%; 119 | left: 0; 120 | top: 0; 121 | z-index: 1; 122 | backdrop-filter: blur(64px); 123 | } 124 | &::before { 125 | content: ""; 126 | position: absolute; 127 | width: 100%; 128 | height: 100%; 129 | background: var(--rnp-accent-color-overlay); 130 | opacity: var(--bg-dim-for-fluid-bg, 0.3); 131 | z-index: 1; 132 | pointer-events: none; 133 | } 134 | } 135 | 136 | .rnp-background-solid { 137 | background-color: var(--rnp-accent-color-bg); 138 | transition: background-color 1s ease; 139 | } 140 | 141 | .rnp-background-none { 142 | &::after { 143 | content: ''; 144 | position: absolute; 145 | display: block; 146 | width: 100%; 147 | height: 100%; 148 | left: 0; 149 | top: 0; 150 | z-index: 1; 151 | backdrop-filter: blur(var(--bg-blur-for-none-bg-mask, 0px)); 152 | } 153 | &::before { 154 | content: ""; 155 | position: absolute; 156 | width: 100%; 157 | height: 100%; 158 | background: var(--rnp-accent-color-overlay); 159 | opacity: var(--bg-dim-for-none-bg-mask, 0); 160 | z-index: 1; 161 | pointer-events: none; 162 | } 163 | } 164 | } 165 | 166 | 167 | body.gradient-bg-dynamic .rnp-bg .rnp-background-gradient { 168 | animation: bg-gradient-animation 120s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; 169 | } 170 | 171 | @keyframes bg-gradient-animation { 172 | 0% { 173 | background-position: 0% 0% 174 | } 175 | 25% { 176 | background-position: 100% 0% 177 | } 178 | 50% { 179 | background-position: 100% 100% 180 | } 181 | 75% { 182 | background-position: 0% 100% 183 | } 184 | 100% { 185 | background-position: 0% 0% 186 | } 187 | } 188 | 189 | 190 | 191 | body.rnp-light, body.rnp-auto.rnp-system-light { 192 | .rnp-background-fluid-rect { 193 | filter: saturate(1.5) brightness(1.08) url(#fluid-filter) !important; 194 | } 195 | } -------------------------------------------------------------------------------- /src/color-utils.js: -------------------------------------------------------------------------------- 1 | export const rgb2Hsl = ([r, g, b]) => { 2 | r /= 255, g /= 255, b /= 255; 3 | const max = Math.max(r, g, b), min = Math.min(r, g, b); 4 | let h, s, l = (max + min) / 2; 5 | 6 | if (max == min) { 7 | h = s = 0; 8 | } else { 9 | const d = max - min; 10 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 11 | switch (max) { 12 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 13 | case g: h = (b - r) / d + 2; break; 14 | case b: h = (r - g) / d + 4; break; 15 | } 16 | h /= 6; 17 | } 18 | return [h, s, l]; 19 | } 20 | export const hsl2Rgb = ([h, s, l]) => { 21 | let r, g, b; 22 | 23 | if (s == 0) { 24 | r = g = b = l; 25 | } else { 26 | const hue2rgb = (p, q, t) => { 27 | if (t < 0) t += 1; 28 | if (t > 1) t -= 1; 29 | if (t < 1 / 6) return p + (q - p) * 6 * t; 30 | if (t < 1 / 2) return q; 31 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 32 | return p; 33 | } 34 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 35 | const p = 2 * l - q; 36 | r = hue2rgb(p, q, h + 1 / 3); 37 | g = hue2rgb(p, q, h); 38 | b = hue2rgb(p, q, h - 1 / 3); 39 | } 40 | return [r * 255, g * 255, b * 255]; 41 | } 42 | export const normalizeColor = ([r, g, b]) => { 43 | if (Math.max(r, g, b) - Math.min(r, g, b) < 5) { 44 | return [150, 150, 150]; 45 | } 46 | 47 | const mix = (a, b, p) => Math.round(a * (1 - p) + b * p); 48 | 49 | const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; 50 | if (luminance < 60) { 51 | [r, g, b] = [r, g, b].map((c) => mix(c, 255, 0.3 * (1 - luminance / 60))); 52 | } else if (luminance > 180) { 53 | [r, g, b] = [r, g, b].map((c) => mix(c, 0, 0.5 * ((luminance - 180) / 76))); 54 | } 55 | 56 | let [h, s, l] = rgb2Hsl([r, g, b]); 57 | 58 | s = Math.max(0.3, Math.min(0.8, s)); 59 | l = Math.max(0.5, Math.min(0.8, l)); 60 | 61 | [r, g, b] = hsl2Rgb([h, s, l]); 62 | 63 | return [r, g, b]; 64 | } 65 | 66 | export const calcWhiteShadeColor = ([r, g, b], p = 0.50) => { 67 | const mix = (a, b, p) => Math.round(a * (1 - p) + b * p); 68 | return [r, g, b].map((c) => mix(c, 255, p)); 69 | } 70 | 71 | export const calcLuminance = (color) => { 72 | let [r, g, b] = color.map((c) => c / 255); 73 | [r, g, b] = [r, g, b].map((c) => { 74 | if (c <= 0.03928) { 75 | return c / 12.92; 76 | } 77 | return Math.pow((c + 0.055) / 1.055, 2.4); 78 | }); 79 | return 0.2126 * r + 0.7152 * g + 0.0722 * b; 80 | } 81 | 82 | export const rgb2Lab = (color) => { 83 | let [r, g, b] = color.map((c) => c / 255); 84 | [r, g, b] = [r, g, b].map((c) => { 85 | if (c <= 0.03928) { 86 | return c / 12.92; 87 | } 88 | return Math.pow((c + 0.055) / 1.055, 2.4); 89 | }); 90 | [r, g, b] = [r, g, b].map((c) => c * 100); 91 | const x = r * 0.4124 + g * 0.3576 + b * 0.1805; 92 | const y = r * 0.2126 + g * 0.7152 + b * 0.0722; 93 | const z = r * 0.0193 + g * 0.1192 + b * 0.9505; 94 | const xyz2Lab = (c) => { 95 | if (c > 0.008856) { 96 | return Math.pow(c, 1 / 3); 97 | } 98 | return 7.787 * c + 16 / 116; 99 | } 100 | const L = 116 * xyz2Lab(y / 100) - 16; 101 | const A = 500 * (xyz2Lab(x / 95.047) - xyz2Lab(y / 100)); 102 | const B = 200 * (xyz2Lab(y / 100) - xyz2Lab(z / 108.883)); 103 | return [L, A, B]; 104 | } 105 | 106 | export const calcColorDifference = (color1, color2) => { 107 | const [L1, A1, B1] = rgb2Lab(color1); 108 | const [L2, A2, B2] = rgb2Lab(color2); 109 | const deltaL = L1 - L2; 110 | const deltaA = A1 - A2; 111 | const deltaB = B1 - B2; 112 | return Math.sqrt(deltaL * deltaL + deltaA * deltaA + deltaB * deltaB); 113 | } 114 | 115 | export const getGradientFromPalette = (palette) => { 116 | palette = palette.sort((a, b) => { 117 | return calcLuminance(a) - calcLuminance(b); 118 | }); 119 | palette = palette.slice(palette.length / 2 - 4, palette.length / 2 + 4); 120 | palette = palette.sort((a, b) => { 121 | return rgb2Hsl(b)[1] - rgb2Hsl(a)[1]; 122 | }); 123 | palette = palette.slice(0, 6); 124 | 125 | let differences = new Array(6); 126 | for(let i = 0; i < differences.length; i++){ 127 | differences[i] = new Array(6).fill(0); 128 | } 129 | for (let i = 0; i < palette.length; i++) { 130 | for (let j = i + 1; j < palette.length; j++) { 131 | differences[i][j] = calcColorDifference(palette[i], palette[j]); 132 | differences[j][i] = differences[i][j]; 133 | } 134 | } 135 | 136 | let used = new Array(6).fill(false); 137 | let min = 10000000, ansSeq = []; 138 | const dfs = (depth, seq = [], currentMax = -1) => { 139 | if (depth === 6) { 140 | if (currentMax < min) { 141 | min = currentMax; 142 | ansSeq = seq; 143 | } 144 | return; 145 | } 146 | for (let i = 0; i < 6; i++) { 147 | if (used[i]) continue; 148 | used[i] = true; 149 | dfs(depth + 1, seq.concat(i), Math.max(currentMax, differences[seq[depth - 1]][i])); 150 | used[i] = false; 151 | } 152 | } 153 | for (let i = 0; i < 6; i++) { 154 | used[i] = true; 155 | dfs(1, [i]); 156 | used[i] = false; 157 | } 158 | 159 | let colors = []; 160 | for (let i of ansSeq) { 161 | colors.push(palette[ansSeq[i]]); 162 | } 163 | let ans = 'linear-gradient(-45deg,'; 164 | for (let i = 0; i < colors.length; i++) { 165 | ans += `rgb(${colors[i][0]}, ${colors[i][1]}, ${colors[i][2]})`; 166 | if (i !== colors.length - 1) { 167 | ans += ','; 168 | } 169 | } 170 | ans += ')'; 171 | return ans; 172 | } 173 | export const argb2Rgb = (x) => { 174 | // const a = (x >> 24) & 0xff; 175 | const r = (x >> 16) & 0xff; 176 | const g = (x >> 8) & 0xff; 177 | const b = x & 0xff; 178 | return [r, g, b]; 179 | }; 180 | export const rgb2Argb = (r, g, b) => { 181 | return (0xff << 24) | (r << 16) | (g << 8) | b; 182 | }; 183 | export const Rgb2Hex = (r, g, b) => { 184 | return '#' + [r, g, b].map((x) => { 185 | const hex = x.toString(16); 186 | return hex.length === 1 ? '0' + hex : hex; 187 | }).join(''); 188 | }; -------------------------------------------------------------------------------- /src/compatibility-check.js: -------------------------------------------------------------------------------- 1 | import './compatibility-check.scss'; 2 | import { compareVersions } from 'compare-versions'; 3 | 4 | const useState = React.useState; 5 | const useEffect = React.useEffect; 6 | const useRef = React.useRef; 7 | 8 | function Wizard(props) { 9 | const [isNCMOutdated, setIsNCMOutdated] = useState(false); 10 | const [isBetterNCMOutdated, setIsBetterNCMOutdated] = useState(false); 11 | const [isGPUDisabled, setIsGPUDisabled] = useState(false); 12 | const [isHijackDisabled, setIsHijackDisabled] = useState(false); 13 | 14 | useEffect(async () => { 15 | try { 16 | if (compareVersions(betterncm.ncm.getNCMVersion(), "2.10.6") < 0) { 17 | setIsNCMOutdated(true); 18 | } 19 | } catch (e) { 20 | } 21 | }, []); 22 | 23 | useEffect(async () => { 24 | try { 25 | if ( 26 | typeof(betterncm_native) == "undefined" || 27 | typeof(betterncm.app.writeConfig) == "undefined" || 28 | typeof(betterncm.app.readConfig) == "undefined" || 29 | typeof(betterncm_native.app.reloadIgnoreCache) == "undefined" 30 | ) { 31 | setIsBetterNCMOutdated(true); 32 | } 33 | } catch (e) { 34 | setIsBetterNCMOutdated(true); 35 | } 36 | }, []); 37 | 38 | useEffect(async () => { 39 | if (typeof(betterncm.app.readConfig) == "undefined") return; 40 | try { 41 | if ( 42 | await betterncm.app.readConfig("cc.microblock.betterncm.remove-disable-gpu") != "true" && 43 | await new Promise((resolve, reject) => { 44 | channel.call( 45 | "app.getLocalConfig", 46 | (GpuAccelerationEnabled) => { 47 | if (!~~GpuAccelerationEnabled) { 48 | resolve(true); 49 | } else { 50 | resolve(false); 51 | } 52 | }, 53 | ["setting", "hardware-acceleration"] 54 | ); 55 | }) 56 | 57 | ) { 58 | setIsGPUDisabled(true); 59 | } 60 | } catch (e) { 61 | } 62 | }, []); 63 | 64 | useEffect(async () => { 65 | if (typeof(betterncm.app.readConfig) == "undefined") return; 66 | try { 67 | if (await betterncm.app.readConfig("cc.microblock.betterncm.cpp_side_inject_feature_disabled") == "true") 68 | setIsHijackDisabled(true); 69 | } catch (e) { 70 | } 71 | }, []); 72 | 73 | useEffect(() => { 74 | if (isNCMOutdated || isBetterNCMOutdated || isGPUDisabled || isHijackDisabled) { 75 | return; 76 | } 77 | localStorage.setItem("refined-now-playing-wizard-done", "true"); 78 | }, [isNCMOutdated, isBetterNCMOutdated, isGPUDisabled, isHijackDisabled]); 79 | 80 | 81 | 82 | return ( 83 |
84 |
85 |

兼容性检查

86 |

Refined Now Playing

87 |
88 |
89 |

欢迎使用 Refined Now Playing。

90 |

在开始之前,请依照本提示检查和更正兼容性问题,否则可能会遇到渲染错误、性能降低、功能失效等问题。

91 | {isNCMOutdated && 92 | <> 93 |

网易云版本

94 |

Refined Now Playing 需要 2.10.6 及以上版本的网易云才能正常工作。

95 |

检测到您的网易云版本过旧,将会导致 Refined Now Playing 无法正常工作。请更新网易云。

96 | 186 | { 187 | (isNCMOutdated || isBetterNCMOutdated || isGPUDisabled || isHijackDisabled) && 188 | <> 189 |
199 |
200 | ) 201 | } 202 | 203 | function Button(props) { 204 | const [clicked, setClicked] = useState(false); 205 | const [disabled, setDisabled] = useState(false); 206 | return ( 207 | 223 | ) 224 | } 225 | 226 | export function compatibilityWizard(force = false) { 227 | if (force) { 228 | localStorage.removeItem("refined-now-playing-wizard-done"); 229 | } 230 | const wizardDone = localStorage.getItem("refined-now-playing-wizard-done"); 231 | if (wizardDone) return; 232 | const wizard = document.createElement("div"); 233 | wizard.id = "refined-now-playing-wizard"; 234 | document.body.appendChild(wizard); 235 | ReactDOM.render(, wizard); 236 | } 237 | 238 | function HijackFailureNotice() { 239 | const [clicked, setClicked] = useState(false); 240 | 241 | if (clicked) { 242 | return null; 243 | } 244 | 245 | return ( 246 |
247 |
248 |
Hijack 错误
249 |
Refined Now Playing 无法正常工作,可能导致歌词无法显示。请重启网易云以修复此问题。
250 |
251 |
252 | 255 |
256 |
257 | ); 258 | } 259 | 260 | 261 | export async function hijackFailureNoticeCheck() { 262 | if ((await betterncm.app.getSucceededHijacks()).filter(x => x.includes('RefinedNowPlaying')).length > 0) { 263 | return; 264 | } 265 | 266 | const notice = document.createElement("div"); 267 | notice.id = "refined-now-playing-hijack-failure-notice"; 268 | document.body.appendChild(notice); 269 | ReactDOM.render(, notice); 270 | } -------------------------------------------------------------------------------- /src/compatibility-check.scss: -------------------------------------------------------------------------------- 1 | #refined-now-playing-wizard { 2 | display: block; 3 | position: fixed; 4 | left: 0; 5 | right: 0; 6 | top: 0; 7 | bottom: 0; 8 | background: #00000088; 9 | z-index: 2147483647; 10 | 11 | .rnp-compatibility-check { 12 | position: fixed; 13 | z-index: 2147483648; 14 | background: #fff; 15 | color: #333; 16 | font-size: 16px; 17 | left: 50%; 18 | top: 50%; 19 | transform: translate(-50%, -50%); 20 | border-radius: 8px; 21 | padding: 50px 30px; 22 | overflow-y: auto; 23 | width: 500px; 24 | box-shadow: 0 10px 20px rgb(0 0 0 / 20%); 25 | max-height: 70vh; 26 | box-sizing: border-box; 27 | } 28 | 29 | .rnp-compatibility-check::-webkit-scrollbar-thumb { 30 | background: #00000033 !important; 31 | } 32 | 33 | .rnp-compatibility-check__title h2 { 34 | font-size: 30px; 35 | font-weight: bold; 36 | } 37 | .rnp-compatibility-check__title h3 { 38 | font-size: 22px; 39 | font-weight: bold; 40 | margin-top: 10px; 41 | margin-bottom: 30px; 42 | } 43 | .rnp-compatibility-check__content { 44 | line-height: 1.8; 45 | } 46 | .rnp-compatibility-check__content h1 { 47 | font-size: 22px; 48 | margin-top: 20px; 49 | margin-bottom: 10px; 50 | } 51 | .rnp-compatibility-check__content p { 52 | margin-bottom: 10px; 53 | b { 54 | font-weight: bold; 55 | } 56 | li::before { 57 | content: "•"; 58 | margin-right: 5px; 59 | } 60 | } 61 | p.pass { 62 | color: #04aa6d; 63 | } 64 | p.warning { 65 | color: #cb2027; 66 | } 67 | 68 | li::before { 69 | content: '· '; 70 | } 71 | 72 | button { 73 | color: #333; 74 | padding: 2px 15px; 75 | border: none; 76 | border-radius: 100px; 77 | margin-top: 10px; 78 | background: #00000022; 79 | margin-right: 5px; 80 | } 81 | button:hover { 82 | background: #0000002a; 83 | } 84 | button[disabled] { 85 | pointer-events: none; 86 | opacity: 0.4; 87 | } 88 | button.finish:not([disabled]) { 89 | background: #2ea44f; 90 | color: #fff; 91 | } 92 | } 93 | 94 | #refined-now-playing-hijack-failure-notice { 95 | .hijack-failure-notice { 96 | position: fixed; 97 | right: 50px; 98 | top: 100px; 99 | background: #0006; 100 | backdrop-filter: blur(32px); 101 | z-index: 10000; 102 | border-radius: 12px; 103 | padding: 15px; 104 | font-size: 15px; 105 | width: 400px; 106 | display: flex; 107 | flex-direction: row; 108 | align-items: center; 109 | 110 | .info > div:first-child { 111 | font-size: 1.2em; 112 | margin-bottom: 0.5em; 113 | } 114 | .info > div:last-child { 115 | line-height: 1.5; 116 | strong { 117 | color: #fc2; 118 | } 119 | } 120 | .action > button { 121 | margin-left: 10px; 122 | border: none; 123 | background: none; 124 | font-size: 25px; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/context-menu.js: -------------------------------------------------------------------------------- 1 | import './context-menu.scss'; 2 | 3 | const useEffect = React.useEffect; 4 | const useLayoutEffect = React.useLayoutEffect; 5 | const useCallback = React.useCallback; 6 | const useState = React.useState; 7 | const useRef = React.useRef; 8 | 9 | function ContextMenu(props) { 10 | // props: 11 | // items: [{html: '', label: '', callback: () => {}}, ...] // label or html is required, if both are provided, html will be used 12 | // x: number 13 | // y: number 14 | const menuRef = useRef(null); 15 | const [position, setPosition] = useState({x: props.x ?? 0, y: props.y ?? 0}); 16 | 17 | useLayoutEffect(() => { 18 | const menu = menuRef.current; 19 | const {x, y} = position; 20 | const {width, height} = menu.getBoundingClientRect(); 21 | const {innerWidth, innerHeight} = window; 22 | 23 | menu.style.left = ''; 24 | menu.style.right = ''; 25 | menu.style.top = ''; 26 | menu.style.bottom = ''; 27 | 28 | let anchor = ''; 29 | 30 | if (x + width > innerWidth) { 31 | menu.style.right = `${innerWidth - x}px`; 32 | anchor += 'right'; 33 | } else { 34 | menu.style.left = `${x}px`; 35 | anchor += 'left'; 36 | } 37 | anchor += ' '; 38 | if (y + height > innerHeight) { 39 | menu.style.bottom = `${innerHeight - y}px`; 40 | anchor += 'bottom'; 41 | } else { 42 | menu.style.top = `${y}px`; 43 | anchor += 'top'; 44 | } 45 | menu.style.transformOrigin = anchor; 46 | menu.animate([ 47 | {width: '0px', height: '0px', opacity: 0.3}, 48 | {width: `${width}px`, height: `${height}px`, opacity: 1} 49 | ], { 50 | duration: 150, 51 | easing: 'cubic-bezier(0.4, 0, 0, 1)', 52 | fill: 'forwards' 53 | }); 54 | }, [position]); 55 | 56 | const closeMenu = useCallback(() => { 57 | menuRef.current.animate([ 58 | {opacity: 1}, 59 | {opacity: 0} 60 | ], { 61 | duration: 150, 62 | easing: 'ease-out', 63 | fill: 'forwards' 64 | }).onfinish = () => { 65 | ReactDOM.unmountComponentAtNode(menuRef.current); 66 | menuRef.current.remove(); 67 | props.parent.remove(); 68 | } 69 | }, []); 70 | 71 | useEffect(() => { 72 | menuRef.current.focus(); 73 | menuRef.current.addEventListener('blur', closeMenu); 74 | return () => { 75 | menuRef.current.removeEventListener('blur', closeMenu); 76 | } 77 | }, []); 78 | 79 | 80 | return ( 81 |
82 | {props.items.map((item, index) => ( 83 | item.divider ? 84 |
85 | : 86 |
{ 91 | if (item.callback) { 92 | item.callback(); 93 | } 94 | closeMenu(); 95 | } 96 | } 97 | > 98 | {item.html ?
: item.label} 99 |
100 | ))} 101 |
102 | ) 103 | } 104 | 105 | export function showContextMenu(x, y, items) { 106 | const div = document.createElement('div'); 107 | document.body.appendChild(div); 108 | ReactDOM.render(, div); 109 | } -------------------------------------------------------------------------------- /src/context-menu.scss: -------------------------------------------------------------------------------- 1 | .rnp-context-menu { 2 | position: fixed; 3 | padding: 0; 4 | background-color: #00000055; 5 | border-radius: 10px; 6 | backdrop-filter: blur(10px); 7 | z-index: 1000; 8 | overflow: hidden; 9 | .rnp-context-menu-item { 10 | display: flex; 11 | padding: 13px 18px; 12 | color: #fff; 13 | transition: background-color 0.15s ease; 14 | white-space: nowrap; 15 | font-size: 16px; 16 | &:hover { 17 | background-color: #00000055; 18 | } 19 | .rnp-context-menu-item-icon { 20 | margin-right: 10px; 21 | opacity: 0.7; 22 | } 23 | } 24 | .rnp-context-menu-devider { 25 | height: 1px; 26 | background-color: #ffffff22; 27 | margin: 0 10px; 28 | } 29 | } -------------------------------------------------------------------------------- /src/cover-shadow.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from './utils.js'; 2 | const useState = React.useState; 3 | const useEffect = React.useEffect; 4 | 5 | const getCoverType = () => { 6 | const type = getSetting('cover-blurry-shadow', 'true'); 7 | if (type == 'true') { 8 | return 'colorful'; 9 | } else { 10 | return 'black'; 11 | } 12 | }; 13 | 14 | export function CoverShadow(props) { 15 | const [type, setType] = useState(getCoverType()); // black and colorful 16 | const [rectangleCover, setRectangleCover] = useState(getSetting('rectangle-cover', true)); 17 | const [url, setUrl] = useState(''); 18 | 19 | const image = props.image; 20 | 21 | useEffect(() => { 22 | const observer = new MutationObserver(() => { 23 | if (image.src === url) return; 24 | if (image.complete) { 25 | setUrl(image.src); 26 | } 27 | }); 28 | observer.observe(image, { attributes: true, attributeFilter: ['src'] }); 29 | const onload = () => { 30 | setUrl(image.src); 31 | }; 32 | image.addEventListener('load', onload); 33 | return () => { 34 | observer.disconnect(); 35 | image.removeEventListener('load', onload); 36 | } 37 | }, [image]); 38 | 39 | useEffect(() => { 40 | const observer = new MutationObserver(() => { 41 | setRectangleCover(document.body.classList.contains('rectangle-cover')); 42 | }); 43 | observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); 44 | setRectangleCover(document.body.classList.contains('rectangle-cover')); 45 | return () => { 46 | observer.disconnect(); 47 | } 48 | }, []); 49 | 50 | useEffect(() => { 51 | document.addEventListener('rnp-cover-shadow-type', (e) => { 52 | setType(e.detail.type ?? 'colorful'); 53 | }); 54 | }, []); 55 | 56 | if (!url) return null; 57 | 58 | if (type === 'black') { 59 | return null; 60 | } 61 | 62 | return ( 63 | 96 | ); 97 | } -------------------------------------------------------------------------------- /src/exclusive-modes.scss: -------------------------------------------------------------------------------- 1 | // lyric-only mode 2 | 3 | body.lyric-only { 4 | .n-single > .wrap > *:not(.lyric) { 5 | visibility: hidden; 6 | pointer-events: none; 7 | } 8 | .n-single > .wrap > .lyric { 9 | left: 60px; 10 | width: calc(100% - 100px); 11 | top: 0; 12 | height: unset; 13 | bottom: clamp(30px, 5%, 60px); 14 | } 15 | .m-fm > .g-play { 16 | visibility: hidden; 17 | pointer-events: none; 18 | } 19 | .m-fm .lyric { 20 | left: 5%; 21 | width: 90%; 22 | } 23 | } 24 | 25 | // song-info only mode 26 | 27 | body.song-info-only { 28 | .n-single > .wrap > .content { 29 | width: 100%; 30 | max-width: 100%; 31 | } 32 | .n-single > .wrap > .lyric { 33 | visibility: hidden; 34 | pointer-events: none; 35 | } 36 | &.horizontal-align-center { 37 | .n-single > .wrap > .content { 38 | width: 100%; 39 | max-width: 100%; 40 | > *, > * > * { 41 | margin-left: 0; 42 | padding-left: 0; 43 | } 44 | } 45 | } 46 | .m-fm .lyric { 47 | visibility: hidden; 48 | pointer-events: none; 49 | } 50 | .m-fm > .g-play { 51 | width: 90%; 52 | } 53 | } 54 | 55 | 56 | // center lyric (only for lyric-only mode) 57 | 58 | body.lyric-only.center-lyric { 59 | .rnp-lyrics { 60 | display: flex; 61 | justify-content: center; 62 | text-align: center; 63 | margin-left: 0; 64 | .rnp-interlude-inner { 65 | transform-origin: center !important; 66 | } 67 | .rnp-lyrics-line { 68 | transform-origin: center !important; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/experimental.scss: -------------------------------------------------------------------------------- 1 | /* experimental */ 2 | body.rnp-fluid-max-framerate-5 { 3 | .rnp-background-fluid .rnp-background-fluid-rect { 4 | animation-timing-function: steps(750); 5 | canvas { 6 | animation-timing-function: steps(300); 7 | } 8 | } 9 | } 10 | body.rnp-fluid-max-framerate-10 { 11 | .rnp-background-fluid .rnp-background-fluid-rect { 12 | animation-timing-function: steps(1500); 13 | canvas { 14 | animation-timing-function: steps(600); 15 | } 16 | } 17 | } 18 | body.rnp-fluid-max-framerate-30 { 19 | .rnp-background-fluid .rnp-background-fluid-rect { 20 | animation-timing-function: steps(4500); 21 | canvas { 22 | animation-timing-function: steps(1800); 23 | } 24 | } 25 | } 26 | body.rnp-fluid-max-framerate-60 { 27 | .rnp-background-fluid .rnp-background-fluid-rect { 28 | animation-timing-function: steps(9000); 29 | canvas { 30 | animation-timing-function: steps(3600); 31 | } 32 | } 33 | } 34 | 35 | 36 | 37 | .rnp-background-fluid { 38 | &::after { 39 | backdrop-filter: blur(var(--fluid-blur, 64px)) !important; 40 | } 41 | } 42 | 43 | 44 | 45 | body:not(.always-show-bottombar).mq-playing.hide-entire-bottombar-when-idle { 46 | .m-pinfo { 47 | transition: opacity 0.3s ease; 48 | } 49 | &.rnp-idle { 50 | .m-pinfo, #main-player { 51 | opacity: 0 !important; 52 | } 53 | } 54 | } 55 | 56 | 57 | 58 | body.presentation-mode { 59 | --bottombar-height: 20px !important; 60 | .m-pinfo, #main-player, header.g-hd, .m-winctrl { 61 | display: none; 62 | } 63 | .rnp-full-screen-button { 64 | left: 25px; 65 | } 66 | .g-single a { 67 | pointer-events: none !important; 68 | } 69 | // hide comments 70 | .g-single-track .g-bd2, 71 | .g-singlec .g-bd2 { 72 | display: none; 73 | } 74 | .g-singlec-ct { 75 | overflow-y: hidden; 76 | } 77 | .g-bd2 { 78 | display: none; 79 | } 80 | .m-fm .fmcmt { 81 | display: none; 82 | } 83 | } -------------------------------------------------------------------------------- /src/font-settings.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import TextField from '@mui/material/TextField'; 3 | import Autocomplete from '@mui/material/Autocomplete'; 4 | import { ThemeProvider, createTheme } from '@mui/material/styles'; 5 | import { getSetting, setSetting } from "./utils"; 6 | 7 | const darkTheme = createTheme({ 8 | palette: { 9 | mode: 'dark', 10 | }, 11 | }); 12 | 13 | import './font-settings.scss'; 14 | 15 | const useEffect = React.useEffect; 16 | const useState = React.useState; 17 | 18 | export function FontSettings(props) { 19 | const [fontList, setFontList] = useState([]); 20 | const [fontFamily, setFontFamily] = useState(JSON.parse(getSetting('font-family', '[]'))); 21 | 22 | useEffect(() => { 23 | async function getFontList() { 24 | setFontList((await legacyNativeCmder.call("os.querySystemFonts"))[1] ?? []); 25 | }; 26 | getFontList(); 27 | }, []); 28 | 29 | useEffect(() => { 30 | let style = document.querySelector('#rnp-font-family-controller'); 31 | if (!style) { 32 | style = document.createElement('style'); 33 | style.id = 'rnp-font-family-controller'; 34 | document.head.appendChild(style); 35 | } 36 | style.innerHTML = ` 37 | body.rnp-custom-font .g-single-track .lyric *, 38 | body.rnp-custom-font .n-single .head *, 39 | body.rnp-custom-font .m-fm > *:not(.fmcmt) * { 40 | font-family: ${fontFamily.length ? fontFamily.map(font => `'${font}'`).join(', ') : 'inherit'} !important; 41 | } 42 | `; 43 | setSetting('font-family', JSON.stringify(fontFamily)); 44 | }, [fontFamily]); 45 | 46 | return ( 47 | <> 48 | 49 | { 53 | setFontFamily(newValue); 54 | }} 55 | options={fontList} 56 | getOptionLabel={(option) => option} 57 | defaultValue={[]} 58 | fullWidth 59 | freeSolo 60 | forcePopupIcon={false} 61 | renderInput={(params) => ( 62 | 68 | )} 69 | /> 70 | 71 | 某些字体可能不在列表中,需要手动输入 72 | 如果顺序在前的字体缺少某些字符,则会使用顺序在后的字体,依次顺延 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | ); 82 | } 83 | function FontPreset(props) { 84 | const hasFont = props.fonts.some(font => props.fontList.includes(font)); 85 | 86 | return ( 87 |
88 | 89 | { 90 | hasFont && ( 91 | 92 | ) 93 | } 94 | { 95 | !hasFont && ( 96 | 101 | ) 102 | } 103 |
104 | ) 105 | } -------------------------------------------------------------------------------- /src/font-settings.scss: -------------------------------------------------------------------------------- 1 | .rnp-custom-font-section { 2 | svg { 3 | pointer-events: all; 4 | } 5 | .MuiAutocomplete-root { 6 | margin-bottom: 20px; 7 | } 8 | .Mui-focused .MuiOutlinedInput-notchedOutline { 9 | border-color: var(--rnp-accent-color) !important; 10 | } 11 | label.MuiFormLabel-root.Mui-focused { 12 | color: var(--rnp-accent-color) !important; 13 | } 14 | .rnp-checkbox-note { 15 | padding-left: 0; 16 | } 17 | .rnp-font-preset { 18 | font-size: 90%; 19 | margin-top: 10px; 20 | display: flex; 21 | align-items: center; 22 | 23 | .rnp-font-preset-button { 24 | color: #fff; 25 | background: #ffffff44; 26 | border: none; 27 | padding: 5px 8px; 28 | border-radius: 6px; 29 | margin-left: 10px; 30 | font-size: 90%; 31 | } 32 | .rnp-font-preset-label { 33 | opacity: .6; 34 | } 35 | .rnp-download-font-button { 36 | color: #fff; 37 | background: #ffffff44; 38 | border: none; 39 | padding: 5px 8px 1px 8px; 40 | border-radius: 6px; 41 | margin-left: 10px; 42 | font-size: 90%; 43 | svg { 44 | fill: #fff; 45 | width: 17px; 46 | height: 17px; 47 | } 48 | } 49 | } 50 | } 51 | .MuiAutocomplete-root * , .MuiAutocomplete-popper *{ 52 | font-family: inherit !important; 53 | } -------------------------------------------------------------------------------- /src/liblyric/README.md: -------------------------------------------------------------------------------- 1 | Forked from https://github.com/Steve-xmh/LibLyric/ -------------------------------------------------------------------------------- /src/liblyric/index.ts: -------------------------------------------------------------------------------- 1 | import { findLast } from "lodash"; 2 | 3 | export interface DynamicLyricWord { 4 | time: number; 5 | duration: number; 6 | flag: number; 7 | word: string; 8 | isCJK?: boolean; 9 | endsWithSpace?: boolean; 10 | trailing?: boolean; 11 | } 12 | 13 | export interface LyricLine { 14 | time: number; 15 | duration: number; 16 | originalLyric: string; 17 | translatedLyric?: string; 18 | romanLyric?: string; 19 | rawLyric?: string; 20 | dynamicLyricTime?: number; 21 | dynamicLyric?: DynamicLyricWord[]; 22 | } 23 | 24 | export interface LyricPureLine { 25 | time: number; 26 | lyric: string; 27 | originalLyric?: string; 28 | translatedLyric?: string; 29 | romanLyric?: string; 30 | rawLyric?: string; 31 | unsynced?: boolean; 32 | } 33 | 34 | 35 | export const PURE_MUSIC_LYRIC_LINE = [ 36 | { 37 | time: 0, 38 | duration: 5940000, 39 | originalLyric: "纯音乐,请欣赏", 40 | }, 41 | ]; 42 | 43 | export const PURE_MUSIC_LYRIC_DATA = { 44 | sgc: false, 45 | sfy: false, 46 | qfy: false, 47 | needDesc: true, 48 | lrc: { 49 | version: 1, 50 | lyric: "[99:00.00]纯音乐,请欣赏\n", 51 | }, 52 | code: 200, 53 | briefDesc: null, 54 | }; 55 | 56 | 57 | const simularityCache: Record = {}; 58 | function calcSimularity(a: string, b: string) { 59 | if (typeof(a) === "undefined") a = ""; 60 | if (typeof(b) === "undefined") b = ""; 61 | const key = `${a}::${b}`; 62 | if (simularityCache[key] !== undefined) { 63 | return simularityCache[key]; 64 | } 65 | const m = a.length; 66 | const n = b.length; 67 | const d: number[][] = []; 68 | for (let i = 0; i <= m; i++) { 69 | d[i] = []; 70 | d[i][0] = i; 71 | } 72 | for (let j = 0; j <= n; j++) { 73 | d[0][j] = j; 74 | } 75 | for (let i = 1; i <= m; i++) { 76 | for (let j = 1; j <= n; j++) { 77 | if (a[i - 1] === b[j - 1]) { 78 | d[i][j] = d[i - 1][j - 1]; 79 | } else { 80 | d[i][j] = Math.min(d[i - 1][j - 1] + 1, d[i][j - 1] + 1, d[i - 1][j] + 1); 81 | } 82 | } 83 | } 84 | return d[m][n]; 85 | } 86 | 87 | 88 | const isEnglishSentense = (str: string) => { 89 | if (str.replace(/[\p{P}\p{S}]/gu, '').match(/^[\s\w\u00C0-\u024F]+$/u)) { 90 | return true; 91 | } 92 | return false; 93 | } 94 | const replaceChineseSymbolsToEnglish = (str: string) => { 95 | return str.replace(/[‘’′]/g, '\'') 96 | .replace(/[“”″]/g, '"') 97 | .replace(/(/g, '(') 98 | .replace(/)/g, ')') 99 | .replace(/,/g, ',') 100 | .replace(/!/g, '!') 101 | .replace(/?/g, '?') 102 | .replace(/:/g, ':') 103 | .replace(/;/g, ';'); 104 | } 105 | 106 | export function parseLyric( 107 | original: string, 108 | translated: string = "", 109 | roman: string = "", 110 | dynamic: string = "", 111 | ): LyricLine[] { 112 | if (dynamic.trim().length === 0) { 113 | const result: LyricLine[] = parsePureLyric(original).map((v) => ({ 114 | time: v.time, 115 | originalLyric: v.lyric, 116 | duration: 0, 117 | ...(v.unsynced ? { unsynced: true } : {}), 118 | })); 119 | 120 | parsePureLyric(translated).forEach((line) => { 121 | const target = result.find((v) => v.time === line.time); 122 | if (target) { 123 | target.translatedLyric = line.lyric; 124 | } 125 | }); 126 | 127 | parsePureLyric(roman).forEach((line) => { 128 | const target = result.find((v) => v.time === line.time); 129 | if (target) { 130 | target.romanLyric = line.lyric; 131 | } 132 | }); 133 | 134 | result.sort((a, b) => a.time - b.time); 135 | 136 | // log("原始歌词解析", JSON.parse(JSON.stringify(result))); 137 | 138 | const processed = processLyric(result); 139 | 140 | // log("处理完成歌词解析", JSON.parse(JSON.stringify(processed))); 141 | 142 | for (let i = 0; i < processed.length; i++) { 143 | if (i < processed.length - 1) { 144 | processed[i].duration = processed[i + 1].time - processed[i].time; 145 | } 146 | } 147 | 148 | return processLyric(result); 149 | } else { 150 | const processed = parsePureDynamicLyric(dynamic); 151 | 152 | const originalLyrics = parsePureLyric(original); 153 | 154 | const attachOriginalLyric = (lyric: LyricPureLine[]) => { 155 | let attachMatchingMode = 'equal'; 156 | 157 | const lyricTimeSet = new Set(lyric.map((v) => v.time)); 158 | const originalLyricTimeSet = new Set(originalLyrics.map((v) => v.time)); 159 | const intersection = new Set([...lyricTimeSet].filter((v) => originalLyricTimeSet.has(v))); 160 | if (intersection.size / lyricTimeSet.size < 0.1) { 161 | attachMatchingMode = 'closest'; 162 | } 163 | 164 | //console.log(JSON.parse(JSON.stringify(originalLyrics)), JSON.parse(JSON.stringify(lyric))); 165 | originalLyrics.forEach((line) => { 166 | //let target = findLast(lyric, (v) => v.time === line.time); 167 | let target: LyricPureLine | null = null; 168 | if (attachMatchingMode === 'equal') { 169 | //target = findLast(lyric, (v) => v.time === line.time); 170 | target = findLast(lyric, (v) => Math.abs(v.time - line.time) < 20) 171 | } else { 172 | lyric.forEach((v) => { 173 | if (target) { 174 | if ( 175 | Math.abs(target.time - line.time) > Math.abs(v.time - line.time) 176 | ) { 177 | target = v; 178 | } 179 | } else { 180 | target = v; 181 | } 182 | }); 183 | } 184 | 185 | //console.log(line, target); 186 | /*if (!target) { 187 | lyric.forEach((v) => { 188 | if (target) { 189 | if ( 190 | Math.abs(target.time - line.time) > Math.abs(v.time - line.time) 191 | ) { 192 | target = v; 193 | } 194 | } else { 195 | target = v; 196 | } 197 | }); 198 | }*/ 199 | if (target) { 200 | target.originalLyric = target.originalLyric || ""; 201 | if (target.originalLyric.length > 0) { 202 | target.originalLyric += " "; 203 | } 204 | target.originalLyric += line.lyric; 205 | } 206 | }); 207 | //console.log(JSON.parse(JSON.stringify(originalLyrics)), JSON.parse(JSON.stringify(lyric))); 208 | return lyric; 209 | } 210 | const attachLyricToDynamic = (lyric: LyricPureLine[], field: string) => { 211 | lyric.forEach((line, index) => { 212 | let targetIndex = 0; 213 | processed.forEach((v, index) => { 214 | if ( 215 | Math.abs(processed[targetIndex].time - line.time) > Math.abs(v.time - line.time) 216 | ) { 217 | targetIndex = index; 218 | } 219 | }); 220 | //console.log(line, index, targetIndex); 221 | let sequence = [targetIndex]; 222 | for (let offset = 1; offset <= 5; offset++) { 223 | if (targetIndex - offset >= 0) sequence.push(targetIndex - offset); 224 | if (targetIndex + offset < processed.length) sequence.push(targetIndex + offset); 225 | }/* 226 | if (targetIndex - 1 >= 0) sequence.push(targetIndex - 1); 227 | if (targetIndex + 1 < processed.length) sequence.push(targetIndex + 1); 228 | if (targetIndex - 2 >= 0) sequence.push(targetIndex - 2); 229 | if (targetIndex + 2 < processed.length) sequence.push(targetIndex + 2);*/ 230 | 231 | sequence = sequence.reverse(); 232 | 233 | //console.log(sequence); 234 | 235 | //let minSimilarity = 1000000000; 236 | let minWeight = 1000000000; 237 | 238 | for (let index of sequence) { 239 | const v = processed[index]; 240 | const similarity = calcSimularity(line.originalLyric as string, v.originalLyric as string); 241 | const weight = similarity * 1000 + (v[field] ? 1 : 0); 242 | //console.log("similarity", similarity, line.originalLyric, v.originalLyric); 243 | //console.log("weight", index, weight, line.originalLyric, v.originalLyric); 244 | if (weight < minWeight) { 245 | minWeight = weight; 246 | targetIndex = index; 247 | } 248 | } 249 | 250 | //console.log(targetIndex); 251 | 252 | const target = processed[targetIndex]; 253 | 254 | //console.log(targetIndex, target); 255 | target[field] = target[field] || ""; 256 | if (target[field].length > 0) { 257 | target[field] += " "; 258 | } 259 | target[field] += line.lyric; 260 | }); 261 | } 262 | 263 | const translatedParsed = attachOriginalLyric(parsePureLyric(translated)); 264 | const romanParsed = attachOriginalLyric(parsePureLyric(roman)); 265 | const rawParsed = attachOriginalLyric(parsePureLyric(original)); 266 | 267 | //console.log("translatedParsed", JSON.parse(JSON.stringify(translatedParsed))); 268 | 269 | attachLyricToDynamic(translatedParsed, 'translatedLyric'); 270 | attachLyricToDynamic(romanParsed, 'romanLyric'); 271 | attachLyricToDynamic(rawParsed, 'rawLyric'); 272 | 273 | 274 | //console.log("processed", JSON.parse(JSON.stringify(processed))); 275 | 276 | // 插入空行 277 | for (let i = 0; i < processed.length; i++) { 278 | const thisLine = processed[i]; 279 | const nextLine = processed[i + 1]; 280 | if ( 281 | thisLine && 282 | nextLine && 283 | thisLine.originalLyric.trim().length > 0 && 284 | nextLine.originalLyric.trim().length > 0 && 285 | thisLine.duration > 0 286 | ) { 287 | const thisLineEndTime = 288 | (thisLine?.dynamicLyricTime || thisLine.time) + thisLine.duration; 289 | let nextLineStartTime = nextLine.time; 290 | if ( 291 | nextLine.dynamicLyricTime && 292 | nextLineStartTime > nextLine.dynamicLyricTime 293 | ) { 294 | nextLineStartTime = nextLine.dynamicLyricTime; 295 | } 296 | if (nextLineStartTime - thisLineEndTime >= 5000) { 297 | processed.splice(i + 1, 0, { 298 | time: thisLineEndTime, 299 | originalLyric: "", 300 | duration: nextLineStartTime - thisLineEndTime, 301 | }); 302 | } 303 | } 304 | } 305 | 306 | //同步原文空格到逐字 307 | for (let i = 0; i < processed.length; i++) { 308 | const thisLine = processed[i]; 309 | let raw = thisLine.rawLyric?.trim() ?? ""; 310 | const dynamic = thisLine.dynamicLyric || []; 311 | 312 | for (let j = 0; j < dynamic.length; j++) { 313 | const thisWord = dynamic[j].word.trimEnd(); 314 | if (raw.startsWith(thisWord)) { 315 | raw = raw.substring(thisWord.length); 316 | } else { 317 | break; 318 | } 319 | const match = raw.match(/^\s+/); 320 | if (match) { 321 | raw = raw.substring(match[0].length); 322 | if (!dynamic[j].word.match(/\s$/)) { 323 | dynamic[j].word += " "; 324 | } 325 | } 326 | } 327 | } 328 | 329 | // 标记 CJK 字符和是否空格结尾 330 | const CJKRegex = /([\p{Unified_Ideograph}|\u3040-\u309F|\u30A0-\u30FF])/gu; 331 | for (let i = 0; i < processed.length; i++) { 332 | const thisLine = processed[i]; 333 | const dynamic = thisLine.dynamicLyric || []; 334 | for (let j = 0; j < dynamic.length; j++) { 335 | if (dynamic[j]?.word?.match(CJKRegex)) { 336 | dynamic[j].isCJK = true; 337 | } 338 | if (dynamic[j]?.word?.match(/\s$/)) { 339 | dynamic[j].endsWithSpace = true; 340 | } 341 | } 342 | } 343 | 344 | // 标记尾部拖长音 345 | // 尾部或每个空格之前的第一个非特殊符号字符,长度超过 1 秒 346 | for (let i = 0; i < processed.length; i++) { 347 | const thisLine = processed[i]; 348 | const dynamic = thisLine.dynamicLyric || []; 349 | 350 | const searchIndexes: number[] = [-1]; 351 | for (let j = 0; j < dynamic.length - 1; j++) { 352 | if (dynamic[j]?.endsWithSpace || dynamic[j]?.word?.match(/[\,\.\,\。\!\?\?\、\;\:\…\—\~\~\·\‘\’\“\”\゙]/)) { 353 | if (!dynamic[j]?.word?.match(/[a-zA-Z]+(\'\‘\’)*[a-zA-Z]*/)) { 354 | searchIndexes.push(j); 355 | } 356 | } 357 | } 358 | searchIndexes.push(dynamic.length - 1); 359 | 360 | for (let j = searchIndexes.length - 1; j >= 1; j--) { 361 | let targetIndex: number | null = null; 362 | for (let k = searchIndexes[j]; k > searchIndexes[j - 1]; k--) { 363 | const word = dynamic[k].word.trim(); 364 | // special chars and punctuations 365 | if (word.match(/[\p{P}\p{S}]/gu)) { 366 | continue; 367 | } 368 | // space 369 | if (word.match(/^\s*$/)) { 370 | continue; 371 | } 372 | targetIndex = k; 373 | break; 374 | } 375 | if (targetIndex === null) { 376 | continue; 377 | } 378 | const target = dynamic[targetIndex]; 379 | if (target.duration >= 1000) { 380 | target.trailing = true; 381 | } 382 | } 383 | } 384 | 385 | return processLyric(processed); 386 | } 387 | } 388 | 389 | const yrcLineRegexp = /^\[(?