mirror of
https://github.com/jeffvli/feishin.git
synced 2024-11-20 06:27:09 +01:00
Improved lyric syncing, fetch
- uses a somewhat more sane way to parse lyrics and teardown timeouts - adds 'seeked' to setCurrentTime to make detecting seeks in lyric much easier - adds ability to fetch lyrics from genius/netease (desktop only)
This commit is contained in:
parent
23f9bd4e9f
commit
85d2576bdc
311
release/app/package-lock.json
generated
311
release/app/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"mpris-service": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -25,6 +26,7 @@
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"global-agent": "^3.0.0",
|
||||
"got": "^11.8.5",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^6.2.0",
|
||||
@ -147,6 +149,11 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
@ -207,6 +214,42 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"parse5": "^7.0.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/clone-response": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||
@ -219,12 +262,39 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
|
||||
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
|
||||
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/dbus-next": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
||||
"integrity": "sha512-tzQq/+wrTZ2yU+U5PoeXc97KABhX2v55C/T0finH3tSKYuI8H/SqppIFymBBrUHcK13LvEGY3vdj3ikPPenL5g==",
|
||||
"dependencies": {
|
||||
"@nornagon/put": "0.0.8",
|
||||
"abstract-socket": "^2.0.0",
|
||||
"event-stream": "3.3.4",
|
||||
"hexy": "^0.2.10",
|
||||
"jsbi": "^2.0.5",
|
||||
@ -327,6 +397,57 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
@ -359,6 +480,17 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@ -408,6 +540,7 @@
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/yauzl": "^2.9.1",
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
@ -633,6 +766,24 @@
|
||||
"hexy": "bin/hexy_cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
@ -719,6 +870,9 @@
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
@ -820,6 +974,17 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
||||
@ -861,6 +1026,29 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
|
||||
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
|
||||
"dependencies": {
|
||||
"entities": "^4.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
|
||||
"integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.2",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pause-stream": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||
@ -1248,6 +1436,11 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||
},
|
||||
"boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
@ -1296,6 +1489,33 @@
|
||||
"get-intrinsic": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"cheerio": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
|
||||
"requires": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"parse5": "^7.0.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"clone-response": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||
@ -1305,6 +1525,23 @@
|
||||
"mimic-response": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"css-select": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
|
||||
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"css-what": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
|
||||
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="
|
||||
},
|
||||
"dbus-next": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
||||
@ -1381,6 +1618,39 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
|
||||
},
|
||||
"domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
|
||||
"requires": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
}
|
||||
},
|
||||
"duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
@ -1406,6 +1676,11 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
|
||||
},
|
||||
"env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@ -1608,6 +1883,17 @@
|
||||
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
|
||||
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A=="
|
||||
},
|
||||
"htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"http-cache-semantics": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
@ -1756,6 +2042,14 @@
|
||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
||||
"dev": true
|
||||
},
|
||||
"nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"object-is": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
||||
@ -1785,6 +2079,23 @@
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||
"dev": true
|
||||
},
|
||||
"parse5": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
|
||||
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
|
||||
"requires": {
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
|
||||
"integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
|
||||
"requires": {
|
||||
"domhandler": "^5.0.2",
|
||||
"parse5": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"pause-stream": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||
|
@ -13,6 +13,7 @@
|
||||
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"mpris-service": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -1,2 +1,3 @@
|
||||
import './lyrics';
|
||||
import './player';
|
||||
import './settings';
|
||||
|
59
src/main/features/core/lyrics/genius.ts
Normal file
59
src/main/features/core/lyrics/genius.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { load } from 'cheerio';
|
||||
import type { QueueSong } from '/@/renderer/api/types';
|
||||
|
||||
const search_url = 'https://genius.com/api/search/song';
|
||||
|
||||
async function getSongURL(metadata: QueueSong) {
|
||||
let result: AxiosResponse<any, any>;
|
||||
try {
|
||||
result = await axios.get(search_url, {
|
||||
params: {
|
||||
per_page: '1',
|
||||
q: `${metadata.artistName} ${metadata.name}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Genius search request got an error!', e);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data.response?.sections?.[0]?.hits?.[0]?.result?.url;
|
||||
}
|
||||
|
||||
async function getLyricsFromGenius(url: string): Promise<string | null> {
|
||||
let result: AxiosResponse<string, any>;
|
||||
try {
|
||||
result = await axios.get<string>(url, { responseType: 'text' });
|
||||
} catch (e) {
|
||||
console.error('Genius lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const $ = load(result.data.split('<br/>').join('\n'));
|
||||
const lyricsDiv = $('div.lyrics');
|
||||
|
||||
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
|
||||
|
||||
const lyricSections = $('div[class^=Lyrics__Container]')
|
||||
.map((_, e) => $(e).text())
|
||||
.toArray()
|
||||
.join('\n');
|
||||
return lyricSections;
|
||||
}
|
||||
|
||||
export async function query(metadata: QueueSong): Promise<string | null> {
|
||||
const songId = await getSongURL(metadata);
|
||||
if (!songId) {
|
||||
console.error('Could not find the song on Genius!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = await getLyricsFromGenius(songId);
|
||||
if (!lyrics) {
|
||||
console.error('Could not get lyrics on Genius!');
|
||||
return null;
|
||||
}
|
||||
|
||||
return lyrics;
|
||||
}
|
26
src/main/features/core/lyrics/index.ts
Normal file
26
src/main/features/core/lyrics/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
import { query as queryGenius } from './genius';
|
||||
import { query as queryNetease } from './netease';
|
||||
import { LyricSource } from '../../../../renderer/types';
|
||||
import { ipcMain } from 'electron';
|
||||
import { getMainWindow } from '../../../main';
|
||||
import { store } from '../settings/index';
|
||||
|
||||
type SongFetcher = (song: QueueSong) => Promise<string | null>;
|
||||
|
||||
const FETCHERS: Record<LyricSource, SongFetcher> = {
|
||||
[LyricSource.GENIUS]: queryGenius,
|
||||
[LyricSource.NETEASE]: queryNetease,
|
||||
};
|
||||
|
||||
ipcMain.on('lyric-fetch', async (_event, song: QueueSong) => {
|
||||
const sources = store.get('lyrics', []) as LyricSource[];
|
||||
|
||||
for (const source of sources) {
|
||||
const lyric = await FETCHERS[source](song);
|
||||
if (lyric) {
|
||||
getMainWindow()?.webContents.send('lyric-get', song.name, source, lyric);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
58
src/main/features/core/lyrics/netease.ts
Normal file
58
src/main/features/core/lyrics/netease.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import type { QueueSong } from '/@/renderer/api/types';
|
||||
|
||||
const SEARCH_URL = 'https://music.163.com/api/search/get';
|
||||
const LYRICS_URL = 'https://music.163.com/api/song/lyric';
|
||||
|
||||
async function getSongId(metadata: QueueSong) {
|
||||
let result: AxiosResponse<any, any>;
|
||||
try {
|
||||
result = await axios.get(SEARCH_URL, {
|
||||
params: {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
s: `${metadata.artistName} ${metadata.name}`,
|
||||
type: '1',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('NetEase search request got an error!', e);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result?.data.result?.songs?.[0].id;
|
||||
}
|
||||
|
||||
async function getLyricsFromSongId(songId: string) {
|
||||
let result: AxiosResponse<any, any>;
|
||||
try {
|
||||
result = await axios.get(LYRICS_URL, {
|
||||
params: {
|
||||
id: songId,
|
||||
kv: '-1',
|
||||
lv: '-1',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('NetEase lyrics request got an error!', e);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result.data.klyric?.lyric || result.data.lrc?.lyric;
|
||||
}
|
||||
|
||||
export async function query(metadata: QueueSong): Promise<string | null> {
|
||||
const songId = await getSongId(metadata);
|
||||
if (!songId) {
|
||||
console.error('Could not find the song on NetEase!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = await getLyricsFromSongId(songId);
|
||||
if (!lyrics) {
|
||||
console.error('Could not get lyrics on NetEase!');
|
||||
return null;
|
||||
}
|
||||
|
||||
return lyrics;
|
||||
}
|
@ -128,3 +128,7 @@ ipcMain.on('player-volume', async (_event, value: number) => {
|
||||
ipcMain.on('player-mute', async () => {
|
||||
await getMpvInstance()?.mute();
|
||||
});
|
||||
|
||||
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
||||
return getMpvInstance()?.getTimePosition();
|
||||
});
|
||||
|
@ -2,6 +2,7 @@ import { contextBridge } from 'electron';
|
||||
import { browser } from './preload/browser';
|
||||
import { ipc } from './preload/ipc';
|
||||
import { localSettings } from './preload/local-settings';
|
||||
import { lyrics } from './preload/lyrics';
|
||||
import { mpris } from './preload/mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
||||
import { utils } from './preload/utils';
|
||||
@ -10,6 +11,7 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
browser,
|
||||
ipc,
|
||||
localSettings,
|
||||
lyrics,
|
||||
mpris,
|
||||
mpvPlayer,
|
||||
mpvPlayerListener,
|
||||
|
17
src/main/preload/lyrics.ts
Normal file
17
src/main/preload/lyrics.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
|
||||
const fetchLyrics = (song: QueueSong) => {
|
||||
ipcRenderer.send('lyric-fetch', song);
|
||||
};
|
||||
|
||||
const getLyrics = (
|
||||
cb: (event: IpcRendererEvent, songName: string, source: string, lyric: string) => void,
|
||||
) => {
|
||||
ipcRenderer.on('lyric-get', cb);
|
||||
};
|
||||
|
||||
export const lyrics = {
|
||||
fetchLyrics,
|
||||
getLyrics,
|
||||
};
|
@ -78,6 +78,10 @@ const quit = () => {
|
||||
ipcRenderer.send('player-quit');
|
||||
};
|
||||
|
||||
const getCurrentTime = async () => {
|
||||
return ipcRenderer.invoke('player-get-time');
|
||||
};
|
||||
|
||||
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-auto-next', cb);
|
||||
};
|
||||
@ -157,6 +161,7 @@ const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
|
||||
export const mpvPlayer = {
|
||||
autoNext,
|
||||
currentTime,
|
||||
getCurrentTime,
|
||||
initialize,
|
||||
mute,
|
||||
next,
|
||||
|
@ -10,7 +10,7 @@ interface SelectProps extends MantineSelectProps {
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps extends MantineMultiSelectProps {
|
||||
export interface MultiSelectProps extends MantineMultiSelectProps {
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
@ -2,7 +2,11 @@ import type { ChangeEvent } from 'react';
|
||||
import { MultiSelect } from '/@/renderer/components/select';
|
||||
import { Slider } from '/@/renderer/components/slider';
|
||||
import { Switch } from '/@/renderer/components/switch';
|
||||
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
useSettingsStoreActions,
|
||||
useSettingsStore,
|
||||
useLyricsSettings,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { TableColumn, TableType } from '/@/renderer/types';
|
||||
import { Option } from '/@/renderer/components/option';
|
||||
|
||||
@ -82,6 +86,7 @@ interface TableConfigDropdownProps {
|
||||
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const tableConfig = useSettingsStore((state) => state.tables);
|
||||
const lyricConfig = useLyricsSettings();
|
||||
|
||||
const handleAddOrRemoveColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = tableConfig[type].columns;
|
||||
@ -166,6 +171,15 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleLyricFollow = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
follow: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Option>
|
||||
@ -186,6 +200,15 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Follow current lyrics</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={lyricConfig.follow}
|
||||
onChange={handleLyricFollow}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
|
@ -1,20 +1,24 @@
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
import { TextTitle } from '/@/renderer/components/text-title';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface LyricLineProps extends ComponentPropsWithoutRef<'div'> {
|
||||
active: boolean;
|
||||
lyric: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const LyricLine = ({ lyric: text, active, ...props }: LyricLineProps) => {
|
||||
return (
|
||||
<TextTitle
|
||||
lh={active ? '4rem' : '3.5rem'}
|
||||
sx={{ fontSize: active ? '2.5rem' : '2rem' }}
|
||||
weight={active ? 800 : 100}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</TextTitle>
|
||||
);
|
||||
const StyledText = styled(TextTitle)`
|
||||
font-size: 2rem;
|
||||
font-weight: 100;
|
||||
line-height: 3.5rem;
|
||||
|
||||
&.active,
|
||||
&.credit {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 4rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LyricLine = ({ text, ...props }: LyricLineProps) => {
|
||||
return <StyledText {...props}>{text}</StyledText>;
|
||||
};
|
||||
|
@ -1,23 +1,66 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ErrorFallback } from '/@/renderer/features/action-required';
|
||||
import { useCurrentSong } from '/@/renderer/store';
|
||||
import { useCurrentServer, useCurrentSong } from '/@/renderer/store';
|
||||
import { SynchronizedLyricsArray, SynchronizedLyrics } from './synchronized-lyrics';
|
||||
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
|
||||
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
|
||||
|
||||
const lyrics = isElectron() ? window.electron.lyrics : null;
|
||||
|
||||
const ipc = isElectron() ? window.electron.ipc : null;
|
||||
|
||||
// use by https://github.com/ustbhuangyi/lyric-parser
|
||||
|
||||
const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]([^\n]+)\n/g;
|
||||
|
||||
export const Lyrics = () => {
|
||||
const currentServer = useCurrentServer();
|
||||
const currentSong = useCurrentSong();
|
||||
|
||||
const lyrics = useMemo(() => {
|
||||
if (currentSong?.lyrics) {
|
||||
const originalText = currentSong.lyrics;
|
||||
console.log(originalText);
|
||||
const [override, setOverride] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [songLyrics, setSongLyrics] = useState<SynchronizedLyricsArray | string | null>(null);
|
||||
|
||||
const synchronizedLines = originalText.matchAll(timeExp);
|
||||
const songRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
lyrics?.getLyrics((_event: any, songName: string, lyricSource: string, lyric: string) => {
|
||||
if (songName === songRef.current) {
|
||||
setSource(lyricSource);
|
||||
setOverride(lyric);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('lyric-get');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSong && !currentSong.lyrics) {
|
||||
lyrics?.fetchLyrics(currentSong);
|
||||
}
|
||||
|
||||
songRef.current = currentSong?.name ?? null;
|
||||
|
||||
setOverride(null);
|
||||
setSource(null);
|
||||
}, [currentSong]);
|
||||
|
||||
useEffect(() => {
|
||||
let lyrics: string | null = null;
|
||||
|
||||
if (currentSong?.lyrics) {
|
||||
lyrics = currentSong.lyrics;
|
||||
|
||||
setSource(currentServer?.name ?? 'music server');
|
||||
} else if (override) {
|
||||
lyrics = override;
|
||||
}
|
||||
|
||||
if (lyrics) {
|
||||
const synchronizedLines = lyrics.matchAll(timeExp);
|
||||
|
||||
const synchronizedTimes: SynchronizedLyricsArray = [];
|
||||
|
||||
@ -32,21 +75,30 @@ export const Lyrics = () => {
|
||||
}
|
||||
|
||||
if (synchronizedTimes.length === 0) {
|
||||
return originalText;
|
||||
setSongLyrics(lyrics);
|
||||
} else {
|
||||
setSongLyrics(synchronizedTimes);
|
||||
}
|
||||
return synchronizedTimes;
|
||||
} else {
|
||||
setSongLyrics(null);
|
||||
}
|
||||
return null;
|
||||
}, [currentSong?.lyrics]);
|
||||
}, [currentServer?.name, currentSong, override]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
{lyrics &&
|
||||
(Array.isArray(lyrics) ? (
|
||||
<SynchronizedLyrics lyrics={lyrics} />
|
||||
{songLyrics &&
|
||||
(Array.isArray(songLyrics) ? (
|
||||
<SynchronizedLyrics lyrics={songLyrics} />
|
||||
) : (
|
||||
<UnsynchronizedLyrics lyrics={lyrics} />
|
||||
<UnsynchronizedLyrics lyrics={songLyrics} />
|
||||
))}
|
||||
{source && (
|
||||
<LyricLine
|
||||
key="provided-by"
|
||||
className="credit"
|
||||
text={`Provided by: ${source}`}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
@ -1,7 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCurrentStatus, useCurrentTime } from '/@/renderer/store';
|
||||
import { PlayerStatus } from '/@/renderer/types';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useCurrentStatus,
|
||||
useCurrentTime,
|
||||
useLyricsSettings,
|
||||
usePlayerType,
|
||||
useSeeked,
|
||||
} from '/@/renderer/store';
|
||||
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
|
||||
import isElectron from 'is-electron';
|
||||
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
|
||||
export type SynchronizedLyricsArray = Array<[number, string]>;
|
||||
|
||||
@ -9,102 +19,192 @@ interface SynchronizedLyricsProps {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
}
|
||||
|
||||
const CLOSE_ENOUGH_TIME_DIFF_SEC = 0.2;
|
||||
|
||||
export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => {
|
||||
const [index, setIndex] = useState(-1);
|
||||
const playersRef = PlayersRef;
|
||||
const status = useCurrentStatus();
|
||||
const lastTimeUpdate = useRef<number>(Infinity);
|
||||
const previousTimestamp = useRef<number>(0);
|
||||
const playerType = usePlayerType();
|
||||
const now = useCurrentTime();
|
||||
const settings = useLyricsSettings();
|
||||
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const seeked = useSeeked();
|
||||
|
||||
const estimateElapsedTime = useCallback(() => {
|
||||
const now = new Date().getTime();
|
||||
return (now - previousTimestamp.current) / 1000;
|
||||
}, []);
|
||||
// A reference to the timeout handler
|
||||
const lyricTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const getCurrentLyric = useCallback(
|
||||
(timeInMs: number) => {
|
||||
for (let idx = 0; idx < lyrics.length; idx += 1) {
|
||||
if (timeInMs <= lyrics[idx][0]) {
|
||||
// A reference to the lyrics. This is necessary for the
|
||||
// timers, which are not part of react necessarily, to always
|
||||
// have the most updated values
|
||||
const lyricRef = useRef<SynchronizedLyricsArray>();
|
||||
|
||||
// A constantly increasing value, used to tell timers that may be out of date
|
||||
// whether to proceed or stop
|
||||
const timerEpoch = useRef(0);
|
||||
|
||||
const followRef = useRef<boolean>(settings.follow);
|
||||
|
||||
useEffect(() => {
|
||||
// Copy the follow settings into a ref that can be accessed in the timeout
|
||||
followRef.current = settings.follow;
|
||||
}, [settings.follow]);
|
||||
|
||||
const getCurrentTime = useCallback(async () => {
|
||||
if (isElectron() && playerType !== PlaybackType.WEB) {
|
||||
if (mpvPlayer) {
|
||||
return mpvPlayer.getCurrentTime();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (playersRef.current === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const player = (playersRef.current.player1 ?? playersRef.current.player2).getInternalPlayer();
|
||||
|
||||
// If it is null, this probably means we added a new song while the lyrics tab is open
|
||||
// and the queue was previously empty
|
||||
if (!player) return 0;
|
||||
|
||||
return player.currentTime;
|
||||
}, [playerType, playersRef]);
|
||||
|
||||
const getCurrentLyric = (timeInMs: number) => {
|
||||
if (lyricRef.current) {
|
||||
const activeLyrics = lyricRef.current;
|
||||
for (let idx = 0; idx < activeLyrics.length; idx += 1) {
|
||||
if (timeInMs <= activeLyrics[idx][0]) {
|
||||
return idx === 0 ? idx : idx - 1;
|
||||
}
|
||||
}
|
||||
return lyrics.length - 1;
|
||||
},
|
||||
[lyrics],
|
||||
);
|
||||
|
||||
const doSetNextTimeout = useCallback(
|
||||
(idx: number, currentTimeMs: number) => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(`#lyric-${idx}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setIndex(idx);
|
||||
|
||||
if (idx !== lyrics.length - 1) {
|
||||
const nextTimeMs = lyrics[idx + 1][0];
|
||||
const nextTime = nextTimeMs - currentTimeMs;
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
doSetNextTimeout(idx + 1, nextTimeMs);
|
||||
}, nextTime);
|
||||
} else {
|
||||
timeout.current = undefined;
|
||||
}
|
||||
},
|
||||
[lyrics],
|
||||
);
|
||||
|
||||
const handleTimeChange = useCallback(() => {
|
||||
const elapsedJs = estimateElapsedTime();
|
||||
const elapsedPlayer = now - lastTimeUpdate.current;
|
||||
|
||||
lastTimeUpdate.current = now;
|
||||
previousTimestamp.current = new Date().getTime();
|
||||
|
||||
if (Math.abs(elapsedJs - elapsedPlayer) >= CLOSE_ENOUGH_TIME_DIFF_SEC) {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
const currentTimeMs = now * 1000;
|
||||
const idx = getCurrentLyric(currentTimeMs);
|
||||
doSetNextTimeout(idx, currentTimeMs);
|
||||
return activeLyrics.length - 1;
|
||||
}
|
||||
}, [doSetNextTimeout, estimateElapsedTime, getCurrentLyric, now]);
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
const setCurrentLyric = useCallback((timeInMs: number, epoch?: number, targetIndex?: number) => {
|
||||
const start = performance.now();
|
||||
let nextEpoch: number;
|
||||
|
||||
if (epoch === undefined) {
|
||||
timerEpoch.current = (timerEpoch.current + 1) % 10000;
|
||||
nextEpoch = timerEpoch.current;
|
||||
} else if (epoch !== timerEpoch.current) {
|
||||
return;
|
||||
} else {
|
||||
nextEpoch = epoch;
|
||||
}
|
||||
|
||||
let index: number;
|
||||
|
||||
if (targetIndex === undefined) {
|
||||
index = getCurrentLyric(timeInMs);
|
||||
} else {
|
||||
index = targetIndex;
|
||||
}
|
||||
|
||||
// Directly modify the dom instead of using react to prevent rerender
|
||||
document
|
||||
.querySelectorAll('.synchronized-lyrics .active')
|
||||
.forEach((node) => node.classList.remove('active'));
|
||||
|
||||
if (index === -1) {
|
||||
lyricRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLyric = document.querySelector(`#lyric-${index}`);
|
||||
if (currentLyric === null) {
|
||||
lyricRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
currentLyric.classList.add('active');
|
||||
|
||||
if (followRef.current) {
|
||||
currentLyric.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
if (index !== lyricRef.current!.length - 1) {
|
||||
const [nextTime] = lyricRef.current![index + 1];
|
||||
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
lyricTimer.current = setTimeout(() => {
|
||||
setCurrentLyric(nextTime, nextEpoch, index + 1);
|
||||
}, nextTime - timeInMs - elapsed);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
lyricRef.current = lyrics;
|
||||
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
let rejected = false;
|
||||
|
||||
getCurrentTime()
|
||||
.then((timeInSec: number) => {
|
||||
if (rejected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setCurrentLyric(timeInSec * 1000);
|
||||
|
||||
return true;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return () => {
|
||||
// Case 1: cleanup happens before we hear back from
|
||||
// the main process. In this case, when the promise resolves, ignore the result
|
||||
rejected = true;
|
||||
|
||||
// Case 2: Cleanup happens after we hear back from main process but
|
||||
// (potentially) before the next lyric. In this case, clear the timer
|
||||
if (lyricTimer.current) clearTimeout(lyricTimer.current);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}, [getCurrentTime, lyrics, playerType, setCurrentLyric, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== PlayerStatus.PLAYING) {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = undefined;
|
||||
if (lyricTimer.current) {
|
||||
clearTimeout(lyricTimer.current);
|
||||
}
|
||||
|
||||
return () => {};
|
||||
return;
|
||||
}
|
||||
if (!seeked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changeTimeout = setTimeout(() => {
|
||||
handleTimeChange();
|
||||
}, 100);
|
||||
if (lyricTimer.current) {
|
||||
clearTimeout(lyricTimer.current);
|
||||
}
|
||||
|
||||
return () => clearTimeout(changeTimeout);
|
||||
}, [handleTimeChange, status]);
|
||||
setCurrentLyric(now * 1000);
|
||||
}, [now, seeked, setCurrentLyric, status]);
|
||||
|
||||
useEffect(() => {
|
||||
// Guaranteed cleanup; stop the timer, and just in case also increment
|
||||
// the epoch to instruct any dangling timers to stop
|
||||
if (lyricTimer.current) {
|
||||
clearTimeout(lyricTimer.current);
|
||||
}
|
||||
|
||||
timerEpoch.current += 1;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="synchronized-lyrics">
|
||||
{lyrics.map(([, text], idx) => (
|
||||
<LyricLine
|
||||
key={idx}
|
||||
active={idx === index}
|
||||
id={`lyric-${idx}`}
|
||||
lyric={text}
|
||||
text={text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -15,9 +15,8 @@ export const UnsynchronizedLyrics = ({ lyrics }: UnsynchronizedLyricsProps) => {
|
||||
{lines.map((text, idx) => (
|
||||
<LyricLine
|
||||
key={idx}
|
||||
active={false}
|
||||
id={`lyric-${idx}`}
|
||||
lyric={text}
|
||||
text={text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import isElectron from 'is-electron';
|
||||
import styled from 'styled-components';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
@ -16,6 +16,7 @@ import {
|
||||
import { CenterControls } from './center-controls';
|
||||
import { LeftControls } from './left-controls';
|
||||
import { RightControls } from './right-controls';
|
||||
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
|
||||
|
||||
const PlayerbarContainer = styled.div`
|
||||
width: 100%;
|
||||
@ -56,7 +57,7 @@ const utils = isElectron() ? window.electron.utils : null;
|
||||
const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||
|
||||
export const Playerbar = () => {
|
||||
const playersRef = useRef<any>();
|
||||
const playersRef = PlayersRef;
|
||||
const settings = useSettingsStore((state) => state.playback);
|
||||
const volume = useVolume();
|
||||
const player1 = usePlayer1Data();
|
||||
|
@ -60,8 +60,12 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
|
||||
const resetNextPlayer = useCallback(() => {
|
||||
currentPlayerRef.getInternalPlayer().volume = 0.1;
|
||||
nextPlayerRef.getInternalPlayer().currentTime = 0;
|
||||
nextPlayerRef.getInternalPlayer().pause();
|
||||
|
||||
const nextPlayer = nextPlayerRef.getInternalPlayer();
|
||||
if (nextPlayer) {
|
||||
nextPlayer.currentTime = 0;
|
||||
nextPlayer.pause();
|
||||
}
|
||||
}, [currentPlayerRef, nextPlayerRef]);
|
||||
|
||||
const stopPlayback = useCallback(() => {
|
||||
@ -380,7 +384,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
|
||||
// Reset the current track more than 10 seconds have elapsed
|
||||
if (currentTime >= 10) {
|
||||
setCurrentTime(0);
|
||||
setCurrentTime(0, true);
|
||||
handleScrobbleFromSongRestart(currentTime);
|
||||
mpris?.updateSeek(0);
|
||||
if (isMpvPlayer) {
|
||||
@ -509,7 +513,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
|
||||
const evaluatedTime = currentTime - seconds;
|
||||
const newTime = evaluatedTime < 0 ? 0 : evaluatedTime;
|
||||
setCurrentTime(newTime);
|
||||
setCurrentTime(newTime, true);
|
||||
mpris?.updateSeek(newTime);
|
||||
|
||||
if (isMpvPlayer) {
|
||||
@ -529,7 +533,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
const newTime = currentTime + seconds;
|
||||
mpvPlayer.seek(seconds);
|
||||
mpris?.updateSeek(newTime);
|
||||
setCurrentTime(newTime);
|
||||
setCurrentTime(newTime, true);
|
||||
} else {
|
||||
const checkNewTime = currentTime + seconds;
|
||||
const songDuration = currentPlayerRef.player.player.duration;
|
||||
@ -538,7 +542,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
mpris?.updateSeek(newTime);
|
||||
|
||||
resetNextPlayer();
|
||||
setCurrentTime(newTime);
|
||||
setCurrentTime(newTime, true);
|
||||
currentPlayerRef.seekTo(newTime);
|
||||
}
|
||||
};
|
||||
@ -553,7 +557,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||
|
||||
const handleSeekSlider = useCallback(
|
||||
(e: number | any) => {
|
||||
setCurrentTime(e);
|
||||
setCurrentTime(e, true);
|
||||
handleScrobbleFromSeek(e);
|
||||
debouncedSeek(e);
|
||||
|
||||
|
3
src/renderer/features/player/ref/players-ref.tsx
Normal file
3
src/renderer/features/player/ref/players-ref.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import { createRef } from 'react';
|
||||
|
||||
export const PlayersRef = createRef<any>();
|
@ -0,0 +1,88 @@
|
||||
import { Switch } from '@mantine/core';
|
||||
import {
|
||||
SettingOption,
|
||||
SettingsSection,
|
||||
} from '/@/renderer/features/settings/components/settings-section';
|
||||
import { useLyricsSettings, useSettingsStoreActions } from '/@/renderer/store';
|
||||
import { MultiSelect, MultiSelectProps } from '/@/renderer/components';
|
||||
import isElectron from 'is-electron';
|
||||
import styled from 'styled-components';
|
||||
import { LyricSource } from '/@/renderer/types';
|
||||
|
||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
|
||||
const WorkingButtonSelect = styled(MultiSelect)<MultiSelectProps>`
|
||||
& button {
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LyricSettings = () => {
|
||||
const settings = useLyricsSettings();
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
|
||||
const lyricOptions: SettingOption[] = [
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label="Follow lyrics"
|
||||
defaultChecked={settings.follow}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...settings,
|
||||
follow: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Enable or disable following of current lyric',
|
||||
title: 'Follow current lyric',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label="Enable fetching lyrics"
|
||||
defaultChecked={settings.fetch}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...settings,
|
||||
fetch: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Enable or disable fetching lyrics for the current song',
|
||||
isHidden: !isElectron(),
|
||||
title: 'Fetch lyrics from the internet',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<WorkingButtonSelect
|
||||
clearable
|
||||
aria-label="Lyric providers"
|
||||
data={Object.values(LyricSource)}
|
||||
defaultValue={settings.sources}
|
||||
width={300}
|
||||
onChange={(e: LyricSource[]) => {
|
||||
localSettings?.set('lyrics', e);
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...settings,
|
||||
sources: e,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'List of lyric fetchers (in order of preference)',
|
||||
isHidden: !isElectron(),
|
||||
title: 'Providers to fetch music',
|
||||
},
|
||||
];
|
||||
|
||||
return <SettingsSection options={lyricOptions} />;
|
||||
};
|
@ -3,6 +3,7 @@ import { Divider, Stack } from '@mantine/core';
|
||||
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
|
||||
import { ScrobbleSettings } from '/@/renderer/features/settings/components/playback/scrobble-settings';
|
||||
import isElectron from 'is-electron';
|
||||
import { LyricSettings } from '/@/renderer/features/settings/components/playback/lyric-settings';
|
||||
|
||||
const MpvSettings = lazy(() =>
|
||||
import('/@/renderer/features/settings/components/playback/mpv-settings').then((module) => {
|
||||
@ -17,6 +18,8 @@ export const PlaybackTab = () => {
|
||||
<Suspense fallback={<></>}>{isElectron() && <MpvSettings />}</Suspense>
|
||||
<Divider />
|
||||
<ScrobbleSettings />
|
||||
<Divider />
|
||||
<LyricSettings />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
5
src/renderer/preload.d.ts
vendored
5
src/renderer/preload.d.ts
vendored
@ -1,5 +1,6 @@
|
||||
import { IpcRendererEvent } from 'electron';
|
||||
import { PlayerData, PlayerState } from './store';
|
||||
import { QueueSong } from '/@/renderer/api/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -8,8 +9,11 @@ declare global {
|
||||
ipc: any;
|
||||
ipcRenderer: {
|
||||
APP_RESTART(): void;
|
||||
LYRIC_FETCH(data: QueueSong): void;
|
||||
LYRIC_GET(event: IpcRendererEvent, songName: string, source: string, lyric: string): void;
|
||||
PLAYER_AUTO_NEXT(data: PlayerData): void;
|
||||
PLAYER_CURRENT_TIME(): void;
|
||||
PLAYER_GET_TIME(): number | undefined;
|
||||
PLAYER_MEDIA_KEYS_DISABLE(): void;
|
||||
PLAYER_MEDIA_KEYS_ENABLE(): void;
|
||||
PLAYER_MUTE(): void;
|
||||
@ -44,6 +48,7 @@ declare global {
|
||||
windowUnmaximize(): void;
|
||||
};
|
||||
localSettings: any;
|
||||
lyrics: any;
|
||||
mpris: any;
|
||||
mpvPlayer: any;
|
||||
mpvPlayerListener: any;
|
||||
|
@ -15,6 +15,7 @@ export interface PlayerState {
|
||||
nextIndex: number;
|
||||
player: 1 | 2;
|
||||
previousIndex: number;
|
||||
seek: boolean;
|
||||
shuffledIndex: number;
|
||||
song?: QueueSong;
|
||||
status: PlayerStatus;
|
||||
@ -76,7 +77,7 @@ export interface PlayerSlice extends PlayerState {
|
||||
reorderQueue: (rowUniqueIds: string[], afterUniqueId?: string) => PlayerData;
|
||||
restoreQueue: (data: Partial<PlayerState>) => PlayerData;
|
||||
setCurrentIndex: (index: number) => PlayerData;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setCurrentTime: (time: number, seek?: boolean) => void;
|
||||
setCurrentTrack: (uniqueId: string) => PlayerData;
|
||||
setFavorite: (ids: string[], favorite: boolean) => string[];
|
||||
setMuted: (muted: boolean) => void;
|
||||
@ -668,8 +669,9 @@ export const usePlayerStore = create<PlayerSlice>()(
|
||||
|
||||
return get().actions.getPlayerData();
|
||||
},
|
||||
setCurrentTime: (time) => {
|
||||
setCurrentTime: (time, seek = false) => {
|
||||
set((state) => {
|
||||
state.current.seek = seek;
|
||||
state.current.time = time;
|
||||
});
|
||||
},
|
||||
@ -834,6 +836,7 @@ export const usePlayerStore = create<PlayerSlice>()(
|
||||
nextIndex: 0,
|
||||
player: 1,
|
||||
previousIndex: 0,
|
||||
seek: false,
|
||||
shuffledIndex: 0,
|
||||
song: {} as QueueSong,
|
||||
status: PlayerStatus.PAUSED,
|
||||
@ -944,6 +947,8 @@ export const useShuffleStatus = () => usePlayerStore((state) => state.shuffle);
|
||||
|
||||
export const useCurrentTime = () => usePlayerStore((state) => state.current.time);
|
||||
|
||||
export const useSeeked = () => usePlayerStore((state) => state.current.seek);
|
||||
|
||||
export const useVolume = () => usePlayerStore((state) => state.volume);
|
||||
|
||||
export const useMuted = () => usePlayerStore((state) => state.muted);
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
PlaybackType,
|
||||
TableType,
|
||||
Platform,
|
||||
LyricSource,
|
||||
} from '/@/renderer/types';
|
||||
|
||||
export type SidebarItemType = {
|
||||
@ -121,6 +122,11 @@ export interface SettingsState {
|
||||
bindings: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>;
|
||||
globalMediaHotkeys: boolean;
|
||||
};
|
||||
lyrics: {
|
||||
fetch: boolean;
|
||||
follow: boolean;
|
||||
sources: LyricSource[];
|
||||
};
|
||||
playback: {
|
||||
audioDeviceId?: string | null;
|
||||
crossfadeDuration: number;
|
||||
@ -202,6 +208,11 @@ const initialState: SettingsState = {
|
||||
},
|
||||
globalMediaHotkeys: true,
|
||||
},
|
||||
lyrics: {
|
||||
fetch: false,
|
||||
follow: true,
|
||||
sources: [],
|
||||
},
|
||||
playback: {
|
||||
audioDeviceId: undefined,
|
||||
crossfadeDuration: 5,
|
||||
@ -416,3 +427,5 @@ export const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys
|
||||
|
||||
export const useMpvSettings = () =>
|
||||
useSettingsStore((state) => state.playback.mpvProperties, shallow);
|
||||
|
||||
export const useLyricsSettings = () => useSettingsStore((state) => state.lyrics, shallow);
|
||||
|
@ -176,3 +176,8 @@ export type GridCardData = {
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
|
||||
export enum LyricSource {
|
||||
GENIUS = 'genius',
|
||||
NETEASE = 'netease',
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user