Compare commits

...

138 commits

Author SHA1 Message Date
71817baad0 去除听歌打卡功能 2025-06-17 09:13:43 +08:00
f6735c9c26
Update README.md
Some checks failed
Release / release (macos-latest) (push) Has been cancelled
Release / release (ubuntu-22.04) (push) Has been cancelled
Release / release (windows-latest) (push) Has been cancelled
更换demo的url
2025-06-15 22:39:46 +08:00
708a1a8eb8 去除签到功能、邮箱和登录入口 2025-06-14 12:36:22 +08:00
runnableAir
70ab357799
fix: 随机模式下当前播放歌曲于列表中的位置出现错误和不同步的情况 (#2378)
* fix(Player.js): 随机模式下列表初始化时当前歌曲的下标异常

异常情况:假设歌曲A其在歌单中的位置为10,随机模式下双击该歌曲后,this.current != 10
原因:随机模式下,通过 indexOf 计算 current 时,调用的数组与真实的列表数组不一致

* fix(Player.js): 随机模式切换时未同步当前歌曲下标

* fix: 随机播放顺序与实际列表不一致

使用 filter 不保证返回的数组的元素顺序与传入的id 顺序对应
2025-05-30 19:59:52 +08:00
李洋
c7e69158d2
Update package.json (#2347)
将版本号从0.4.8改成了0.4.9(有点强迫症)
2025-03-03 19:08:59 +08:00
Yi-Jyun Pan
022009b140
chore: upgrade dependencies 2024-11-01 15:01:39 +08:00
Yi-Jyun Pan
6849632abe
chore: add AppKit framework for macOS 2024-11-01 15:00:45 +08:00
Yi-Jyun Pan
c929beaf6c
Merge branch 'feat-realip' 2024-11-01 14:55:35 +08:00
Yi-Jyun Pan
dfb09b3ccd
chore: add devenv configuration 2024-11-01 14:55:06 +08:00
CuSO4Deposit
9809a758b4
style: Reformat with Prettier (#2296) 2024-11-01 14:20:55 +08:00
Frz
c9739b2d0e
feat: use cover image for Discord RPC (#2294) 2024-11-01 14:20:30 +08:00
bestlaw66
25e274a4f8
docs: 宝塔面板部署教程 2024-11-01 14:19:23 +08:00
pan93412
fc1c8d8512
Merge pull request #2292 from Younglina/master
添加复制歌词功能
2024-11-01 14:18:45 +08:00
StarsEnd
e27879aa94 update: NeteaseCloudMusicApi (iPhone OS fix) 2024-10-16 00:16:23 +08:00
StarsEnd
d219e48541 fix: real-ip config disabled css style 2024-10-16 00:14:38 +08:00
StarsEnd33A2D17
107f5765b0 Update to the latest NeteaseCloudMusicApi 2024-10-08 20:29:05 +08:00
StarsEnd33A2D17
adafffd86b feat: add RealIP setting 2024-09-30 13:35:22 +08:00
Younglina
9d807d1d63 feat: 复制歌词 2024-08-27 13:21:01 +08:00
Younglina
481ba6bce3
fix: 相似歌手接口需要登录;未登录获取艺人热门歌曲没有图片 (#2286) 2024-08-13 18:20:52 +08:00
StarsEnd
df82c7cd22
feat(ui): add fullscreen button (#2276)
* feat(ui): add fullscreen button

* fix: fullscreen图标修改,添加exit icon
2024-08-13 14:28:05 +08:00
Younglina
bd5af9c721
fix: 默认值错误导致加载空节点 (#2280) 2024-08-13 14:26:02 +08:00
5unV
7cb063d511
fix: 纠正脏标的判断 (#2268) 2024-08-07 22:37:29 +08:00
Felix_SANA
904e61bee6
fix: 阻止搜索引擎爬虫爬取网站的内容 (#2239) 2024-08-07 22:36:52 +08:00
Younglina
24477694f8
fix: 手机号placeholder显示问题 (#2197) 2024-03-18 15:16:48 +08:00
pan93412
87ef48b826
Merge pull request #2193 from rainbowflesh/master
fix github action missing env
2024-03-05 06:51:01 -06:00
是虹川肉
da8afc12cf
Update request.js 2024-03-05 19:00:27 +08:00
是虹川肉
84613dcf8a
Update build.yaml 2024-03-05 18:45:29 +08:00
pan93412
dd8aa175d1
chore: bump to 0.4.8 2024-03-05 00:16:14 +08:00
pan93412
e3caa24ca4
Merge pull request #2185 from undefined-ux/master
fix typo
2024-03-04 10:15:14 -06:00
pan93412
ae352f27e1
Merge pull request #2187 from DaiQiangReal/master
Fix: Fix build error and  /tmp/anonymous_token not exist.
2024-03-04 10:15:06 -06:00
代强
552a1d4b44 chore: remove useless space 2024-03-04 19:24:19 +08:00
代强
b0ed85689b chore: remove useless space 2024-03-04 19:24:10 +08:00
代强
a18e093d4a fix: fix build && adapte for bugs in NeteasyCloudMusicAPI 2024-03-04 19:22:47 +08:00
undefined
79a7c6d991
fix typo 2024-03-02 13:08:49 +08:00
pan93412
1a2c3e2843
Merge pull request #2178 from thedavidweng/master
Upstream Sync
2024-03-01 00:24:57 -06:00
pan93412
741fdc973c
Merge pull request #2127 from runnableAir/fix_player_enable
Fix: 右键播放歌曲无法激活播放栏
2024-03-01 00:13:14 -06:00
pan93412
1400636201
Merge pull request #2129 from jsonleex/patch
fix: play icon not appear in Safari
2024-03-01 00:12:22 -06:00
GH Action - Upstream Sync
6e737b50ee Merge branch 'master' of https://github.com/qier222/YesPlayMusic 2024-03-01 06:12:01 +00:00
pan93412
c409e3b6ed
Merge pull request #2167 from colawithsauce/master
[Feature] support UnblockNeteaseMusic with docker-compose deploy.
2024-03-01 00:11:16 -06:00
pan93412
e738d1e46d
Merge pull request #2176 from krishukr/master
fix(components): 修正描述小字内艺人链接
2024-03-01 00:10:25 -06:00
Davy
380c55a653
Create sync.yml 2024-02-05 17:05:06 -08:00
Kris Hu
9241b3a26a
same thing on MvRow 2024-01-31 12:33:12 +00:00
Kris Hu
3093b6f386
修正专辑下描述内艺人链接 2024-01-31 12:25:38 +00:00
colawithsauce
42366f4a32 remove unneeded empty line. 2023-12-09 01:01:04 +08:00
colawithsauce
6d6fd9a88c Make docker works 2023-12-09 00:55:11 +08:00
colawithsauce
dc1e0aaf90 Make docker works 2023-12-09 00:28:00 +08:00
colawithsauce
a5cb1f729d Remove unneeded env setting 2023-12-08 18:54:23 +08:00
colawithsauce
e997cd9907 Support unblock via docker. 2023-12-08 18:46:10 +08:00
leex
c5c7ccc89e
fix: play icon not appear in Safari 2023-09-04 22:49:21 +08:00
runnableAir
61d0b5953f refactor(Player.js): 确保在播放时播放器处于enabled状态 2023-08-31 06:16:05 +08:00
runnableAir
a5bf5c7dfd fix(Player.js): 右键播放不显示播放器(#1965) 2023-08-31 06:16:04 +08:00
pan93412
fd40a29180
Merge pull request #1818 from Revincx/revincx-pr 2023-08-26 11:17:42 +08:00
Revincx
486b04b70b
feat(player): sync playing progress to mpris service on linux
Co-authored-by: alex3236 <45303195+alex3236@users.noreply.github.com>
2023-08-26 10:31:43 +08:00
Revincx
6ad756b215
fix(ui): add max-width attr for settings selector 2023-08-26 10:31:43 +08:00
Revincx
ed1daab1f6
feat: use osdlyrics dbus interface to send lyric contents 2023-08-26 10:31:23 +08:00
runnableAir
f2f4e2ce58
fix(player): 插队曲目切换后下一首曲目丢失 (#2118) 2023-08-26 10:15:33 +08:00
Lvc Revincx
845bc8a921
Merge branch 'qier222:master' into revincx-pr 2023-08-25 23:28:35 +08:00
拆家大主教
f2efc4e682
feat(lyrics): Add pronunciation lyric mode (#2091)
* feat: Add pronunciation lyric mode

* fix(lyrics): Fixed issue where lyric-mode-switch displays when the translation setting is off
2023-08-07 23:32:28 +08:00
poly000
f4d3d67132
feat(mpris): Add xesam:url field (#2095)
chore: do not use fuo scheme, only netease music id is preserved

fix: lint prettier error
2023-08-07 23:31:38 +08:00
poly000
e14e6d73c6
ci: Use Ubuntu 22.04 for Packaging (#2107) 2023-08-07 23:30:37 +08:00
Siykt
4ec550dc46
fix(login): clear last interval when calling checkQrCodeLogin (#2094)
Fixed #2093
2023-07-24 13:01:49 +08:00
Anmizi
dd6d4bf1c6
feat(settings): Internationalize some strings (#2016) 2023-07-03 12:45:08 +08:00
guaqiu
a6e433bdc5
chore(deps): Add prettier to devDependencies (#2071) 2023-06-18 13:47:43 +08:00
Arthals
59898c7883
fix(navbar): Fixed the issue of overlapping with the control bar (#2073) 2023-06-18 13:47:20 +08:00
Kris Hu
1b7e33c222
fix: 艺人页面专辑区不显示精选集 (#2046) 2023-05-02 00:24:08 +08:00
Lvc Revincx
221ca63d3d
fix(player): Skip track when audio source not supported (#2033) 2023-04-15 10:26:38 +08:00
洩氏诹诹子
b7f7ac8d31
fix(player): 修复歌曲时长过长时的进度显示问题 (#1936)
原先进度条遇到时长超过 1hr 的歌曲,
不会呈现小时数的部分。这个 commit
将歌曲时长小时数加到分钟数中。
2023-04-08 23:16:01 +08:00
Holger
65f5df8a60
fix(request): cross domain api issue (#2026)
Fix the issue when NCMapi is not under the same domain
as the one frontend uses. The original method using
Vercel to proxy requests may cause latency under
some circumstances.
2023-04-08 23:13:28 +08:00
Younglina
8a50337854
fix(tracklist): TrackListItem 序号问题 (#2011)
直接使用 track.no 可能导致歌曲编号重复。改使用曲目在
阵列中的实际索引位置。
2023-04-08 23:12:13 +08:00
moonrailgun
7b97ac0139
chore: define node engines (#1943)
The `@achrinza/node-ipc` version we use in 1.x does not allow
a version of Node.js greater than 17. As YesPlayMusic has been in
maintenance mode, we define our supported Node.js engine version
rather than upgrade this dependency.

It may indicate the Vercel platform to not use 18 (or greater) in their
deployment.

The error message is:

    error @achrinza/node-ipc@9.2.2: The engine "node" is incompatible with this module. Expected version "8 || 10 || 12 || 14 || 16 || 17". Got "18.12.1"
2023-04-08 23:08:52 +08:00
qier222
1cb3e4b29f
Update README.md 2023-03-27 00:09:47 +08:00
Younglina
c89ebbdd22
fix: album.company显示问题 (#2009) 2023-03-22 14:16:47 +08:00
qier222
ce738f6b40
revert: change port to workaround 21H2's port 2023-01-30 12:38:38 +08:00
qier222
2a0af8f975
fix: update version 2023-01-28 12:16:47 +08:00
qier222
2f452dbe74
fix: bugs 2023-01-28 12:06:57 +08:00
タイムライン
622f95439d
fix: player volume bug (#1918)
* fix: player volume bug

* Update Player.js

* Update Player.js

* Update Player.js

* Update Player.js
2023-01-28 11:53:54 +08:00
タイムライン
210e65dd9a
update copyright year (#1917) 2023-01-27 01:11:48 +08:00
草方块
241de709da
fix: 更新依赖以解决手机号登录问题;UnblockNeteaseMusic 更新 (#1915)
* fix: 更新api以尝试解决邮箱登录问题

* fix: 又双更新api版本以修复手机号登录问题

(可能

* deps: [测试]更新UNM到0.4.0
2023-01-24 20:09:33 +08:00
草方块
c6804decfc fix: 更新api以尝试解决邮箱登录问题 2022-12-30 17:02:49 +08:00
Karbob
75d3e28ef8 feat: mount local time and time zone 2022-12-15 09:36:25 +08:00
Karbob
99371def54 refactor(dockerfile): separate nginx config from Dockerfile 2022-12-15 09:36:25 +08:00
Karbob
70d2713643 refactor(dockerfile): use awk to find NCMAPI version 2022-12-15 09:36:25 +08:00
Revincx
41b72563ff
refactor: improve lyric file download implement 2022-10-22 13:59:49 +08:00
Revincx
345f3588bd
chore: improve translations 2022-10-22 12:36:57 +08:00
Revincx
022f740c3f
feat: osdlyrics desktop lyrics support 2022-10-05 16:21:18 +08:00
Revincx
ce778afff6
feat: Tray icon theme now follows system theme 2022-10-05 15:23:11 +08:00
Kay W
9fcb6da960
feat: Add status text to like button when hover event triggers (#1789)
Signed-off-by: Kay Wei <weikaiii@sina.cn>
2022-09-09 12:45:22 +08:00
jbwfu
b589f82b6c
feat: add tutorial to deploy on Replit (#1731) 2022-08-31 12:08:13 +08:00
pan93412
f9e6164245
fix(ipcMain): unexpected “本草綱目“ from migu
This is just a workaround, and the core issue is on UNM.
However, refactoring UNM is time-comsuming and also requires
a lot of hard work. Therefore, we disable "migu" at this moment
simply.

Fixed #1713
Fixed #1711 (?)
2022-06-28 11:34:20 +08:00
pan93412
43c5bda806
chore(deps): upgrade dependencies (#1708) 2022-06-22 14:29:38 +08:00
pan93412
6d3508c62a
fix: change port to workaround 21H2's port (#1706)
* fix: change port to 35216 → workaround 21H2's port
* style: with restyled (#1707)
* Restyled by clang-format
* Restyled by prettier
* fix: change 21232 to 41342

Co-authored-by: restyled-io[bot] <32688539+restyled-io[bot]@users.noreply.github.com>
Co-authored-by: Restyled.io <commits@restyled.io>
2022-06-22 14:29:18 +08:00
marcus
9e64222bdf
feat: Add time to lyric page (#1676)
* feat: Add time on lyrics page

* feat: Add the setting item of whether to display the time on the lyrics page

* fix: fix some issue
2022-06-21 13:45:43 +08:00
marcus
7b911c1658
feat: Add "Volume Control" to Lyrics Page (#1672)
* feat: Add "Volume Control" to Lyrics Page

* fix: fix mute button
2022-06-21 13:45:32 +08:00
Viyerelu23333
a31d552788
fix(dockerfile): 设置 NCMAPI 版本 (#1689)
* fix: dockerfile ncapi pinning

* feat: ncmapi follows version in package.json
2022-06-19 20:54:08 +08:00
marcus
000cfda922
feat: Added "Add to Playlist" on lyrics page (#1671) 2022-06-19 20:53:28 +08:00
Changjian Gao
0abd616ca1
feat: Add context menu on MV page (#1670) 2022-06-19 20:52:26 +08:00
memorydream
2a2ac5a37d
fix: 私人推荐歌单 (#1665)
* fix: 私人推荐歌单

* update
2022-06-19 20:51:42 +08:00
memorydream
1496a8a0d0
fix: 歌名翻译文本位置 & FMCard背景颜色 (#1650)
* fix: 歌名翻译文本位置

* fix: 使FMCard的背景颜色永远随着歌曲改变
2022-06-19 20:51:30 +08:00
memorydream
6b690baef6
fix: 修复在长时间暂停后无法播放的问题 (#1627)
* fix: 修复在长时间暂停后无法播放的问题

* 增加doc
2022-06-19 20:51:16 +08:00
郭桓桓
b9cdade832 chore(ci/cd): bump actions/upload-artifact to v3 2022-06-03 14:45:50 +08:00
郭桓桓
fbc1e9903e chore(ci/cd): bump actions/checkout to v3 2022-06-03 14:45:50 +08:00
memorydream
439f368fd6
fix: 音乐库收藏的歌单少了第一张歌单 (#1657) 2022-05-23 01:27:49 +08:00
memorydream
bbbd729fdf
fix: 部分linux发行版环境的托盘行为 (#1647) 2022-05-21 06:03:24 +08:00
memorydream
c1efcb895c
fix: 歌词页视觉效果缺陷 (#1649) 2022-05-21 06:03:11 +08:00
qier222
cb59eb94a1
docs: update vercel links 2022-05-20 00:07:55 +08:00
pan93412
f355d5da50
fix(dockerfile): 限制最大標頭大小
原本的Nginx.conf没有定义最大标头大小。若不手动更改,则会出现无法登陆的bug,且Nginx会返回Header too big错误。

Fixed #1604

Co-Authored-By: huangyinhaow <64564727+huangyinhaow@users.noreply.github.com>
2022-05-14 20:07:13 +08:00
memorydream
6e1d58964e
fix: 专辑分碟排序错误 (#1630)
* fix: 专辑分碟排序错误

* update
2022-05-12 01:23:13 +08:00
qier222
c3aea5ee8d
fix: bug 2022-05-10 12:15:03 +08:00
qier222
f064859a27
docs: add powered by vercel 2022-05-10 12:10:47 +08:00
Hawtim
9e787bab03
Merge pull request #1598 from hawtim/fix-history-list-error
fix: render weekData error
2022-05-02 14:44:48 +08:00
Hawtim
97ac4117db
Merge pull request #1600 from hawtim/fix-node-vibrant-worker
Fix-node-vibrant-worker
2022-05-02 14:38:08 +08:00
Hawtim
8cd8ae4255
Merge pull request #1597 from hawtim/feature/support-image-lazy-loading
feat: add img tag with loading attribute for lazy loading
2022-05-02 14:36:06 +08:00
hawtimzhang
35edd84c22 fix: vibrant worker error 2022-05-02 14:30:11 +08:00
hawtimzhang
4613feff18 fix: render weekData error 2022-05-02 11:27:08 +08:00
hawtimzhang
fab099c6fb feat: add img tag with loading attribute for lazy loading 2022-05-02 00:47:42 +08:00
memorydream
107bf53a39
fix: 错误的选择了音源质量 (#1589) 2022-05-01 14:13:11 +08:00
qier222
e0f2d3fd57
build: release 0.4.5 2022-05-01 01:25:16 +08:00
memorydream
93c7ba2fd8
feat: 支持Hi-Res (#1585) 2022-05-01 01:24:20 +08:00
memorydream
5dd00bec87
fix: 修复因为使用了electron的clipboard导致的异常 (#1582) 2022-04-30 17:24:13 +08:00
memorydream
21c7b5ae44
feat: UNM 设置页面 l10n (#1578)
* feat: UNM 设置页面 l10n

* update translate

* update
2022-04-30 14:49:24 +08:00
memorydream
a9b05d66a6
fix(components/Titlebar): 还原/最大化按钮在双击标题栏时不会更新的问题 (#1575)
* fix(components/Titlebar): `还原/最大化`按钮在双击标题栏时不会更新的问题

* update
2022-04-29 19:52:22 +08:00
memorydream
c85af59b21
fix: bilibili音源无法播放的问题 (#1573) 2022-04-28 14:45:00 +08:00
pan93412
93ae57adbe
feat: switch to UNM (Rust) (#1536)
* refactor: use unm-rust-napi

* ci(build): install UNM dependencies for certain platforms

* feat: add the ability to configure UNM

* feat: add the UNM configuration in settings page

* refactor(jsconfig): jsx -> preserve

* fix(ci/build): use bash to get unm version

* chore(deps): upgrade UNM to 0.3.0-pre.0

* refactor(electron/ipcMain): update default sources

* fix(views/settings): remove duplicated config entry

* feat(settings): allow configuring QQ cookie

We also removed some duplicate entries in views/settings.vue.

* chore(deps): UNM -> 0.3.0-pre.1

* refactor: remove unused old UNM

* fix(utils/player): do not include rust-napi in client code

As we only imported the constant, I just expand it as the integer.

Co-authored-by: qier222 <qier222@outlook.com>
2022-04-28 01:02:41 +08:00
pan93412
e1f7618cbd refactor(views/lyrics): tweak size 2022-04-27 20:53:49 +08:00
memorydream
3c798a5606
feat: 使用svg输出登陆二维码 (#1568) 2022-04-27 18:38:37 +08:00
pan93412
d87c4bad21
chore(deps): update some dependencies to latest (#1564)
* chore(deps): upgrade dependencies

* chore(deps): upgrade some deps to major version

已經查過這些 packages 的 Changelog,確定是安全、可升級的。此外也進行了簡單的 E2E 測試,可正常使用。
2022-04-27 18:37:52 +08:00
pan93412
177a8c8eff fix(views/loginAccount): improve clarity of QR Code
渲染 384px 的 QR Code,而顯示在 192px 的方框中。這樣 QR Code 大小不變,但清晰度為原本的兩倍。

Fixed #1562
2022-04-26 07:10:47 +08:00
pan93412
aeda63faf7
fix(deps): correct the vue-gtag version (#1556) 2022-04-23 17:19:43 +08:00
pan93412
0a1c847b4b
fix(views/lyrics): use scale to make animation smooth (#1554) 2022-04-23 17:19:31 +08:00
pan93412
ab85c51831
fix(utils/player): preload & autoUnlock (#1557)
* fix(utils/player): preload & autoUnlock

* style: clang-format & prettier
2022-04-23 17:19:16 +08:00
qier222
f88addc95d
feat: update ga 2022-04-16 03:06:16 +08:00
GalvinGao
ea4b20755d
feat: add copy track link & fixed various visual defects (#1489)
* feat: add copy track link

* fix: various visual defects

* feat: add track low res fallback

* chore: remove redundant locale strings

* chore: separate playbackState for a new PR

* build: fix netlify failing to build site
2022-04-12 16:16:46 +08:00
amphineko
16b525915e
feat(utils/request): override realIP param with env var (#1514) 2022-04-12 00:08:44 +08:00
pan93412
af0a997609
ci: configure restyled.io better (#1499) 2022-04-09 01:15:30 +08:00
memorydream
3fca7d16bb fix: APP启动时检查窗口位置,避免窗口启动在屏幕外 2022-04-05 14:41:48 +08:00
memorydream
7d64dea29b fix: 禁止在TrackListItem上导航到id为0的歌手页 2022-04-05 14:41:48 +08:00
pan93412
999bf6fdb4
chore(release): 0.4.4-1 2022-04-04 00:47:53 +08:00
pan93412
871217713a
revert: isCreateTray 在瀏覽器環境應永遠為應永遠為 false
部分環境沒有 process.env.IS_ELECTRON,以致發生錯誤。

This reverts commit 748db54f52.
2022-04-04 00:46:08 +08:00
77 changed files with 7484 additions and 4871 deletions

4
.envrc Normal file
View file

@ -0,0 +1,4 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
export NIXPKGS_ALLOW_INSECURE=1
use devenv

View file

@ -1,5 +1,8 @@
name: Release name: Release
env:
YARN_INSTALL_NOPT: yarn add --ignore-platform --ignore-optional
on: on:
push: push:
branches: branches:
@ -14,11 +17,11 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [macos-latest, windows-latest, ubuntu-18.04] os: [macos-latest, windows-latest, ubuntu-22.04]
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v2 uses: actions/checkout@v3
with: with:
submodules: "recursive" submodules: "recursive"
@ -33,7 +36,7 @@ jobs:
run: | run: |
sudo apt-get update && sudo apt-get update &&
sudo apt-get install --no-install-recommends -y rpm && sudo apt-get install --no-install-recommends -y rpm &&
sudo apt-get install --no-install-recommends -y bsdtar && sudo apt-get install --no-install-recommends -y libarchive-tools &&
sudo apt-get install --no-install-recommends -y libopenjp2-tools sudo apt-get install --no-install-recommends -y libopenjp2-tools
- name: Install Snapcraft (on Ubuntu) - name: Install Snapcraft (on Ubuntu)
@ -42,9 +45,43 @@ jobs:
with: with:
snapcraft_token: ${{ secrets.snapcraft_token }} snapcraft_token: ${{ secrets.snapcraft_token }}
- id: get_unm_version
name: Get the installed UNM version
run: |
yarn --ignore-optional
unm_version=$(node -e "console.log(require('./node_modules/@unblockneteasemusic/rust-napi/package.json').version)")
echo "::set-output name=unmver::${unm_version}"
shell: bash
- name: Install UNM dependencies for Windows
if: runner.os == 'Windows'
run: |
${{ env.YARN_INSTALL_NOPT }} \
@unblockneteasemusic/rust-napi-win32-x64-msvc@${{steps.get_unm_version.outputs.unmver}}
shell: bash
- name: Install UNM dependencies for macOS
if: runner.os == 'macOS'
run: |
${{ env.YARN_INSTALL_NOPT }} \
@unblockneteasemusic/rust-napi-darwin-x64@${{steps.get_unm_version.outputs.unmver}} \
@unblockneteasemusic/rust-napi-darwin-arm64@${{steps.get_unm_version.outputs.unmver}} \
dmg-license
shell: bash
- name: Install UNM dependencies for Linux
if: runner.os == 'Linux'
run: |
${{ env.YARN_INSTALL_NOPT }} \
@unblockneteasemusic/rust-napi-linux-x64-gnu@${{steps.get_unm_version.outputs.unmver}} \
@unblockneteasemusic/rust-napi-linux-arm64-gnu@${{steps.get_unm_version.outputs.unmver}} \
@unblockneteasemusic/rust-napi-linux-arm-gnueabihf@${{steps.get_unm_version.outputs.unmver}}
shell: bash
- name: Build/release Electron app - name: Build/release Electron app
uses: samuelmeuli/action-electron-builder@v1.6.0 uses: samuelmeuli/action-electron-builder@v1.6.0
env: env:
VUE_APP_NETEASE_API_URL: /api
VUE_APP_ELECTRON_API_URL: /api VUE_APP_ELECTRON_API_URL: /api
VUE_APP_ELECTRON_API_URL_DEV: http://127.0.0.1:10754 VUE_APP_ELECTRON_API_URL_DEV: http://127.0.0.1:10754
VUE_APP_LASTFM_API_KEY: 09c55292403d961aa517ff7f5e8a3d9c VUE_APP_LASTFM_API_KEY: 09c55292403d961aa517ff7f5e8a3d9c
@ -60,19 +97,19 @@ jobs:
use_vue_cli: true use_vue_cli: true
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v3
with: with:
name: YesPlayMusic-mac name: YesPlayMusic-mac
path: dist_electron/*-universal.dmg path: dist_electron/*-universal.dmg
if-no-files-found: ignore if-no-files-found: ignore
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v3
with: with:
name: YesPlayMusic-win name: YesPlayMusic-win
path: dist_electron/*Setup*.exe path: dist_electron/*Setup*.exe
if-no-files-found: ignore if-no-files-found: ignore
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v3
with: with:
name: YesPlayMusic-linux name: YesPlayMusic-linux
path: dist_electron/*.AppImage path: dist_electron/*.AppImage

48
.github/workflows/sync.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: Upstream Sync
permissions:
contents: write
issues: write
actions: write
on:
schedule:
- cron: '0 * * * *' # every hour
workflow_dispatch:
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
if: ${{ github.event.repository.fork }}
steps:
- uses: actions/checkout@v4
- name: Clean issue notice
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
labels: '🚨 Sync Fail'
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
with:
upstream_sync_repo: qier222/YesPlayMusic
upstream_sync_branch: master
target_sync_branch: master
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
test_mode: false
- name: Sync check
if: failure()
uses: actions-cool/issues-helper@v3
with:
actions: 'create-issue'
title: '🚨 同步失败 | Sync Fail'
labels: '🚨 Sync Fail'
body: |
Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork.
由于上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次。

9
.gitignore vendored
View file

@ -32,3 +32,12 @@ NeteaseCloudMusicApi-master.zip
# Local Netlify folder # Local Netlify folder
.netlify .netlify
vercel.json vercel.json
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
14

View file

@ -1,43 +1,25 @@
FROM node:16.13.1-alpine as build FROM node:16.13.1-alpine as build
ENV VUE_APP_NETEASE_API_URL=/api ENV VUE_APP_NETEASE_API_URL=/api
WORKDIR /app WORKDIR /app
RUN apk add --no-cache python3 make g++ git RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories &&\
apk add --no-cache python3 make g++ git
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install RUN yarn install
COPY . . COPY . .
RUN yarn build RUN yarn config set electron_mirror https://npmmirror.com/mirrors/electron/ && \
yarn build
FROM nginx:1.20.2-alpine as app FROM nginx:1.20.2-alpine as app
RUN echo $'server { \n\
gzip on;\n\
listen 80; \n\
listen [::]:80; \n\
server_name localhost; \n\
\n\
location / { \n\
root /usr/share/nginx/html; \n\
index index.html; \n\
try_files $uri $uri/ /index.html; \n\
} \n\
\n\
location @rewrites { \n\
rewrite ^(.*)$ /index.html last; \n\
} \n\
\n\
location /api/ { \n\
proxy_set_header Host $host; \n\
proxy_set_header X-Real-IP $remote_addr; \n\
proxy_set_header X-Forwarded-For $remote_addr; \n\
proxy_set_header X-Forwarded-Host $remote_addr; \n\
proxy_set_header X-NginX-Proxy true; \n\
proxy_pass http://localhost:3000/; \n\
} \n\
}' > /etc/nginx/conf.d/default.conf
RUN apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.14/main libuv \ COPY --from=build /app/package.json /usr/local/lib/
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories &&\
apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.14/main libuv \
&& apk add --no-cache --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.14/main nodejs npm \ && apk add --no-cache --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.14/main nodejs npm \
&& npm i -g NeteaseCloudMusicApi && npm i -g $(awk -F \" '{if($2=="NeteaseCloudMusicApi") print $2"@"$4}' /usr/local/lib/package.json) \
&& rm -f /usr/local/lib/package.json
COPY --from=build /app/docker/nginx.conf.example /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
CMD nginx ; exec npx NeteaseCloudMusicApi CMD nginx ; exec npx NeteaseCloudMusicApi

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2020-2022 qier222 Copyright (c) 2020-2023 qier222
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -8,7 +8,7 @@
<p align="center"> <p align="center">
高颜值的第三方网易云播放器 高颜值的第三方网易云播放器
<br /> <br />
<a href="https://music.qier222.com" target="blank"><strong>🌎 访问DEMO</strong></a>&nbsp;&nbsp;|&nbsp;&nbsp; <a href="https://music.ineko.cc" target="blank"><strong>🌎 访问DEMO</strong></a>&nbsp;&nbsp;|&nbsp;&nbsp;
<a href="#%EF%B8%8F-安装" target="blank"><strong>📦️ 下载安装包</strong></a>&nbsp;&nbsp;|&nbsp;&nbsp; <a href="#%EF%B8%8F-安装" target="blank"><strong>📦️ 下载安装包</strong></a>&nbsp;&nbsp;|&nbsp;&nbsp;
<a href="https://t.me/yesplaymusic" target="blank"><strong>💬 加入交流群</strong></a> <a href="https://t.me/yesplaymusic" target="blank"><strong>💬 加入交流群</strong></a>
<br /> <br />
@ -16,7 +16,12 @@
</p> </p>
</p> </p>
[![Library][library-screenshot]](https://music.qier222.com) [![Library][library-screenshot]](https://music.ineko.cc)
## 全新版本
全新2.0 Alpha测试版已发布欢迎前往 [Releases](https://github.com/qier222/YesPlayMusic/releases) 页面下载。
当前版本将会进入维护模式除重大bug修复外不会再更新新功能。
## ✨ 特性 ## ✨ 特性
@ -37,7 +42,7 @@
- 🟥 支持 Last.fm Scrobble - 🟥 支持 Last.fm Scrobble
- ☁️ 支持音乐云盘 - ☁️ 支持音乐云盘
- ⌨️ 自定义快捷键和全局快捷键 - ⌨️ 自定义快捷键和全局快捷键
- 🎧 支持Mpris - 🎧 支持 Mpris
- 🛠 更多特性开发中 - 🛠 更多特性开发中
## 📦️ 安装 ## 📦️ 安装
@ -55,6 +60,10 @@ Electron 版本由 [@hawtim](https://github.com/hawtim) 和 [@qier222](https://g
除了下载安装包使用,你还可以将本项目部署到 Vercel 或你的服务器上。下面是部署到 Vercel 的方法。 除了下载安装包使用,你还可以将本项目部署到 Vercel 或你的服务器上。下面是部署到 Vercel 的方法。
本项目的 Demo (https://music.qier222.com) 就是部署在 Vercel 上的网站。
[![Powered by Vercel](https://www.datocms-assets.com/31049/1618983297-powered-by-vercel.svg)](https://vercel.com/?utm_source=ohmusic&utm_campaign=oss)
1. 部署网易云 API详情参见 [Binaryify/NeteaseCloudMusicApi](https://neteasecloudmusicapi.vercel.app/#/?id=%e5%ae%89%e8%a3%85) 1. 部署网易云 API详情参见 [Binaryify/NeteaseCloudMusicApi](https://neteasecloudmusicapi.vercel.app/#/?id=%e5%ae%89%e8%a3%85)
。你也可以将 API 部署到 Vercel。 。你也可以将 API 部署到 Vercel。
@ -116,6 +125,16 @@ yarn run build
7. 将 `/dist` 目录下的文件上传到你的 Web 服务器 7. 将 `/dist` 目录下的文件上传到你的 Web 服务器
## ⚙️ 宝塔面板 docker应用商店 部署
1. 安装宝塔面板,前往[宝塔面板官网](https://www.bt.cn/new/download.html) ,选择正式版的脚本下载安装。
2. 安装后登录宝塔面板,在左侧导航栏中点击 Docker首次进入会提示安装Docker服务点击立即安装按提示完成安装
3. 安装完成后在应用商店中找到YesPlayMusic点击安装配置域名、端口等基本信息即可完成安装。
4. 安装后在浏览器输入上一步骤设置的域名即可访问。
## ⚙️ Docker 部署 ## ⚙️ Docker 部署
1. 构建 Docker Image 1. 构建 Docker Image
@ -138,6 +157,24 @@ docker-compose up -d
YesPlayMusic 地址为 `http://localhost` YesPlayMusic 地址为 `http://localhost`
## ⚙️ 部署至 Replit
1. 新建 Repl选择 Bash 模板
2. 在 Replit shell 中运行以下命令
```sh
bash <(curl -s -L https://raw.githubusercontent.com/qier222/YesPlayMusic/main/install-replit.sh)
```
3. 首次运行成功后,只需点击绿色按钮 `Run` 即可再次运行
4. 由于 replit 个人版限制内存为 1G教育版为 3G构建过程中可能会失败请再次运行上述命令或运行以下命令
```sh
cd /home/runner/${REPL_SLUG}/music && yarn install && yarn run build
```
## 👷‍♂️ 打包客户端 ## 👷‍♂️ 打包客户端
如果在 Release 页面没有找到适合你的设备的安装包的话,你可以根据下面的步骤来打包自己的客户端。 如果在 Release 页面没有找到适合你的设备的安装包的话,你可以根据下面的步骤来打包自己的客户端。

132
devenv.lock Normal file
View file

@ -0,0 +1,132 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1730412360,
"owner": "cachix",
"repo": "devenv",
"rev": "45847cb1f14a6d8cfa86ea943703c54a8798ae7e",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1730272153,
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2d2a9ddbe3f2c00747398f3dc9b05f7f2ebb0f53",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1730327045,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "080166c15633801df010977d9d7474b4a6c549d7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nodejs16": {
"locked": {
"lastModified": 1700230496,
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a71323f68d4377d12c04a5410e214495ec598d4c",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"rev": "a71323f68d4377d12c04a5410e214495ec598d4c",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1730302582,
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "af8a16fe5c264f5e9e18bcee2859b40a656876cf",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs",
"nodejs16": "nodejs16",
"pre-commit-hooks": "pre-commit-hooks"
}
}
},
"root": "root",
"version": 7
}

53
devenv.nix Normal file
View file

@ -0,0 +1,53 @@
{ pkgs, lib, config, inputs, ... }:
let
nodejs16 = import inputs.nodejs16 { system = pkgs.stdenv.system; };
in
{
# https://devenv.sh/basics/
env.GREET = "devenv";
# https://devenv.sh/packages/
packages = [ pkgs.git ] ++ lib.optionals pkgs.stdenv.isDarwin (with pkgs.darwin.apple_sdk; [
frameworks.AppKit
]);
# https://devenv.sh/languages/
languages.javascript.enable = true;
languages.javascript.package = nodejs16.pkgs.nodejs_16;
languages.javascript.corepack.enable = true;
# languages.rust.enable = true;
# https://devenv.sh/processes/
# processes.cargo-watch.exec = "cargo-watch";
# https://devenv.sh/services/
# services.postgres.enable = true;
# https://devenv.sh/scripts/
scripts.hello.exec = ''
echo hello from $GREET
'';
enterShell = ''
hello
git --version
'';
# https://devenv.sh/tasks/
# tasks = {
# "myproj:setup".exec = "mytool build";
# "devenv:enterShell".after = [ "myproj:setup" ];
# };
# https://devenv.sh/tests/
enterTest = ''
echo "Running tests"
git --version | grep --color=auto "${pkgs.git.version}"
'';
# https://devenv.sh/pre-commit-hooks/
# pre-commit.hooks.shellcheck.enable = true;
# See full reference at https://devenv.sh/reference/options/
}

19
devenv.yaml Normal file
View file

@ -0,0 +1,19 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:nixos/nixpkgs/nixpkgs-unstable
nodejs16:
url: github:nixos/nixpkgs/a71323f68d4377d12c04a5410e214495ec598d4c
# https://github.com/cachix/devenv/issues/792#issuecomment-2043166453
impure: true
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend

View file

@ -4,6 +4,36 @@ services:
context: . context: .
image: yesplaymusic image: yesplaymusic
container_name: YesPlayMusic container_name: YesPlayMusic
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./docker/nginx.conf.example:/etc/nginx/conf.d/default.conf:ro
ports: ports:
- 80:80 - 80:80
restart: always restart: always
depends_on:
- UnblockNeteaseMusic
environment:
- NODE_TLS_REJECT_UNAUTHORIZED=0
networks:
my_network:
UnblockNeteaseMusic:
image: pan93412/unblock-netease-music-enhanced
command: -o kugou kuwo migu bilibili pyncmd -p 80:443 -f 45.127.129.53 -e -
# environment:
# JSON_LOG: true
# LOG_LEVEL: debug
networks:
my_network:
aliases:
- music.163.com
- interface.music.163.com
- interface3.music.163.com
- interface.music.163.com.163jiasu.com
- interface3.music.163.com.163jiasu.com
restart: always
networks:
my_network:
driver: bridge

28
docker/nginx.conf.example Normal file
View file

@ -0,0 +1,28 @@
server {
gzip on;
listen 80;
listen [::]:80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location @rewrites {
rewrite ^(.*)$ /index.html last;
}
location /api/ {
proxy_buffers 16 32k;
proxy_buffer_size 128k;
proxy_busy_buffers_size 128k;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $remote_addr;
proxy_set_header X-NginX-Proxy true;
proxy_pass http://localhost:3000/;
}
}

28
install-replit.sh Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/bash
# 初始化 .replit 和 replit.nix
if [[ $1 == i ]];then
echo -e 'run = ["bash", "main.sh"]\n\nentrypoint = "main.sh"' >.replit
echo -e "{ pkgs }: {\n\t\tdeps = [\n\t\t\tpkgs.nodejs-16_x\n\t\t\tpkgs.yarn\n\t\t\tpkgs.bashInteractive\n\t\t];\n}" > replit.nix
exit
fi
# 安装
if [[ ! -d api ]];then
mkdir api
git clone https://github.com/Binaryify/NeteaseCloudMusicApi ./api && \
cd api && npm install && cd ..
fi
if [[ ! -d music ]];then
mkdir music
git clone https://github.com/qier222/YesPlayMusic ./music && \
cd music && cp .env.example .env && npm install --force && npm run build && cd ..
fi
# 启动
PID=`ps -ef | grep npm | awk '{print $2}' | sed '$d'`
if [[ ! -z ${PID} ]];then echo $PID | xargs kill;fi
nohup bash -c 'cd api && PORT=35216 node app.js' > api.log 2>&1
nohup bash -c 'npx serve music/dist/' > music.log 2>&1

View file

@ -7,7 +7,8 @@
}, },
"target": "ES6", "target": "ES6",
"module": "commonjs", "module": "commonjs",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true,
"jsx": "preserve"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules"] "exclude": ["node_modules"]

View file

@ -1,6 +1,6 @@
{ {
"name": "yesplaymusic", "name": "yesplaymusic",
"version": "0.4.4", "version": "0.4.9",
"private": true, "private": true,
"description": "A third party music player for Netease Music", "description": "A third party music player for Netease Music",
"author": "qier222<qier222@outlook.com>", "author": "qier222<qier222@outlook.com>",
@ -22,13 +22,16 @@
"netease_api:run": "npx NeteaseCloudMusicApi" "netease_api:run": "npx NeteaseCloudMusicApi"
}, },
"main": "background.js", "main": "background.js",
"engines": {
"node": "14 || 16"
},
"dependencies": { "dependencies": {
"@unblockneteasemusic/server": "v0.27.0-rc.6", "@unblockneteasemusic/rust-napi": "^0.4.0",
"NeteaseCloudMusicApi": "^4.5.2", "NeteaseCloudMusicApi": "^4.23.3",
"axios": "^0.21.0", "axios": "^0.26.1",
"change-case": "^4.1.2", "change-case": "^4.1.2",
"cli-color": "^2.0.0", "cli-color": "^2.0.0",
"color": "^3.1.3", "color": "^4.2.3",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"crypto-js": "^4.0.0", "crypto-js": "^4.0.0",
"dayjs": "^1.8.36", "dayjs": "^1.8.36",
@ -36,14 +39,16 @@
"discord-rich-presence": "^0.0.8", "discord-rich-presence": "^0.0.8",
"electron": "^13.6.7", "electron": "^13.6.7",
"electron-builder": "^23.0.0", "electron-builder": "^23.0.0",
"electron-context-menu": "^2.3.0", "electron-context-menu": "^3.1.2",
"electron-debug": "^3.1.0", "electron-debug": "^3.1.0",
"electron-devtools-installer": "^3.2", "electron-devtools-installer": "^3.2",
"electron-icon-builder": "^1.0.2", "electron-icon-builder": "^2.0.1",
"electron-is-dev": "^1.2.0", "electron-is-dev": "^2.0.0",
"electron-log": "^4.3.0", "electron-log": "^4.3.0",
"electron-store": "^6.0.1", "electron-store": "^8.0.1",
"electron-updater": "^4.3.5", "electron-updater": "^5.0.1",
"esbuild": "^0.20.1",
"esbuild-loader": "^4.0.3",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.0", "express-fileupload": "^1.2.0",
"express-http-proxy": "^1.6.2", "express-http-proxy": "^1.6.2",
@ -59,15 +64,14 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"pac-proxy-agent": "^4.1.0", "pac-proxy-agent": "^4.1.0",
"plyr": "^3.6.2", "plyr": "^3.6.2",
"prettier": "2.5.1",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"register-service-worker": "^1.7.1", "register-service-worker": "^1.7.1",
"svg-sprite-loader": "^5.0.0", "svg-sprite-loader": "^6.0.11",
"tunnel": "^0.0.6", "tunnel": "^0.0.6",
"vscode-codicons": "^0.0.17", "vscode-codicons": "^0.0.17",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-analytics": "^5.22.1",
"vue-clipboard2": "^0.3.1", "vue-clipboard2": "^0.3.1",
"vue-gtag": "1",
"vue-i18n": "^8.22.0", "vue-i18n": "^8.22.0",
"vue-router": "^3.4.3", "vue-router": "^3.4.3",
"vue-slider-component": "^3.2.5", "vue-slider-component": "^3.2.5",
@ -87,6 +91,7 @@
"eslint-plugin-prettier": "^3.3.1", "eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-vue": "^7.9.0", "eslint-plugin-vue": "^7.9.0",
"husky": "^4.3.0", "husky": "^4.3.0",
"prettier": "2.5.1",
"sass": "^1.26.11", "sass": "^1.26.11",
"sass-loader": "^10.0.2", "sass-loader": "^10.0.2",
"vue-cli-plugin-electron-builder": "~2.1.1", "vue-cli-plugin-electron-builder": "~2.1.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -1,2 +1,2 @@
User-agent: * User-agent: *
Disallow: Disallow: /

7
restyled.yml Normal file
View file

@ -0,0 +1,7 @@
commit_template: 'style: with ${restyler.name}'
restylers:
- prettier
- prettier-json
- prettier-markdown
- prettier-yaml
- whitespace

View file

@ -1,5 +1,7 @@
import request from '@/utils/request'; import request from '@/utils/request';
import { mapTrackPlayableStatus } from '@/utils/common'; import { mapTrackPlayableStatus } from '@/utils/common';
import { isAccountLoggedIn } from '@/utils/auth';
import { getTrackDetail } from '@/api/track';
/** /**
* 获取歌手单曲 * 获取歌手单曲
@ -14,7 +16,13 @@ export function getArtist(id) {
id, id,
timestamp: new Date().getTime(), timestamp: new Date().getTime(),
}, },
}).then(data => { }).then(async data => {
if (!isAccountLoggedIn()) {
const trackIDs = data.hotSongs.map(t => t.id);
const tracks = await getTrackDetail(trackIDs.join(','));
data.hotSongs = tracks.songs;
return data;
}
data.hotSongs = mapTrackPlayableStatus(data.hotSongs); data.hotSongs = mapTrackPlayableStatus(data.hotSongs);
return data; return data;
}); });

View file

@ -26,7 +26,10 @@ export function dailyRecommendPlaylist(params) {
return request({ return request({
url: '/recommend/resource', url: '/recommend/resource',
method: 'get', method: 'get',
params: {
params, params,
timestamp: Date.now(),
},
}); });
} }
/** /**

View file

@ -15,16 +15,18 @@ import {
* @param {string} id - 音乐的 id例如 id=405998841,33894312 * @param {string} id - 音乐的 id例如 id=405998841,33894312
*/ */
export function getMP3(id) { export function getMP3(id) {
let br = const getBr = () => {
store.state.settings?.musicQuality !== undefined // 当返回的 quality >= 400000时就会优先返回 hi-res
? store.state.settings.musicQuality const quality = store.state.settings?.musicQuality ?? '320000';
: 320000; return quality === 'flac' ? '350000' : quality;
};
return request({ return request({
url: '/song/url', url: '/song/url',
method: 'get', method: 'get',
params: { params: {
id, id,
br, br: getBr(),
}, },
}); });
} }

View file

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" data-darkreader-inline-fill="" xmlns="http://www.w3.org/2000/svg">
<path id="path" d="M1 11L3 11L3 13C3 13.55 3.45 14 4 14C4.55 14 5 13.55 5 13L5 10C5 9.45 4.55 9 4 9L1 9C0.45 9 0 9.45 0 10C0 10.55 0.45 11 1 11ZM11 3L11 1C11 0.45 10.55 0 10 0C9.45 0 9 0.45 9 1L9 4C9 4.54 9.45 5 10 5L13 5C13.55 5 14 4.54 14 4C14 3.45 13.55 3 13 3L11 3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 396 B

View file

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" data-darkreader-inline-fill="" xmlns="http://www.w3.org/2000/svg">
<path id="path" d="M1 9C0.45 9 0 9.45 0 10L0 13C0 13.55 0.45 14 1 14L4 14C4.55 14 5 13.55 5 13C5 12.45 4.55 12 4 12L2 12L2 10C2 9.45 1.55 9 1 9ZM9 1C9 1.54 9.45 2 10 2L12 2L12 4C12 4.54 12.45 5 13 5C13.55 5 14 4.54 14 4L14 1C14 0.45 13.55 0 13 0L10 0C9.45 0 9 0.45 9 1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 396 B

View file

@ -7,6 +7,7 @@ import {
dialog, dialog,
globalShortcut, globalShortcut,
nativeTheme, nativeTheme,
screen,
} from 'electron'; } from 'electron';
import { import {
isWindows, isWindows,
@ -30,7 +31,8 @@ import { EventEmitter } from 'events';
import express from 'express'; import express from 'express';
import expressProxy from 'express-http-proxy'; import expressProxy from 'express-http-proxy';
import Store from 'electron-store'; import Store from 'electron-store';
import { createMpris } from '@/electron/mpris'; import { createMpris, createDbus } from '@/electron/mpris';
import { spawn } from 'child_process';
const clc = require('cli-color'); const clc = require('cli-color');
const log = text => { const log = text => {
console.log(`${clc.blueBright('[background.js]')} ${text}`); console.log(`${clc.blueBright('[background.js]')} ${text}`);
@ -201,8 +203,42 @@ class Background {
}; };
if (this.store.get('window.x') && this.store.get('window.y')) { if (this.store.get('window.x') && this.store.get('window.y')) {
options.x = this.store.get('window.x'); let x = this.store.get('window.x');
options.y = this.store.get('window.y'); let y = this.store.get('window.y');
let displays = screen.getAllDisplays();
let isResetWindiw = false;
if (displays.length === 1) {
let { bounds } = displays[0];
if (
x < bounds.x ||
x > bounds.x + bounds.width - 50 ||
y < bounds.y ||
y > bounds.y + bounds.height - 50
) {
isResetWindiw = true;
}
} else {
isResetWindiw = true;
for (let i = 0; i < displays.length; i++) {
let { bounds } = displays[i];
if (
x > bounds.x &&
x < bounds.x + bounds.width &&
y > bounds.y &&
y < bounds.y - bounds.height
) {
// 检测到APP窗口当前处于一个可用的屏幕里break
isResetWindiw = false;
break;
}
}
}
if (!isResetWindiw) {
options.x = x;
options.y = y;
}
} }
this.window = new BrowserWindow(options); this.window = new BrowserWindow(options);
@ -261,6 +297,7 @@ class Background {
this.window.once('ready-to-show', () => { this.window.once('ready-to-show', () => {
log('window ready-to-show event'); log('window ready-to-show event');
this.window.show(); this.window.show();
this.store.set('window', this.window.getBounds());
}); });
this.window.on('close', e => { this.window.on('close', e => {
@ -296,6 +333,14 @@ class Background {
this.store.set('window', this.window.getBounds()); this.store.set('window', this.window.getBounds());
}); });
this.window.on('maximize', () => {
this.window.webContents.send('isMaximized', true);
});
this.window.on('unmaximize', () => {
this.window.webContents.send('isMaximized', false);
});
this.window.webContents.on('new-window', function (e, url) { this.window.webContents.on('new-window', function (e, url) {
e.preventDefault(); e.preventDefault();
log('open url'); log('open url');
@ -376,6 +421,21 @@ class Background {
registerGlobalShortcut(this.window, this.store); registerGlobalShortcut(this.window, this.store);
} }
// try to start osdlyrics process on start
if (this.store.get('settings.enableOsdlyricsSupport')) {
await createDbus(this.window);
log('try to start osdlyrics process');
const osdlyricsProcess = spawn('osdlyrics');
osdlyricsProcess.on('error', err => {
log(`failed to start osdlyrics: ${err.message}`);
});
osdlyricsProcess.on('exit', (code, signal) => {
log(`osdlyrics process exited with code ${code}, signal ${signal}`);
});
}
// create mpris // create mpris
if (isCreateMpris) { if (isCreateMpris) {
createMpris(this.window); createMpris(this.window);

View file

@ -2,11 +2,13 @@
<span class="artist-in-line"> <span class="artist-in-line">
{{ computedPrefix }} {{ computedPrefix }}
<span v-for="(ar, index) in filteredArtists" :key="index"> <span v-for="(ar, index) in filteredArtists" :key="index">
<router-link v-if="ar.id !== 0" :to="`/artist/${ar.id}`"> <router-link v-if="ar.id !== 0" :to="`/artist/${ar.id}`">{{
{{ ar.name }} ar.name
</router-link> }}</router-link>
<span v-else>{{ ar.name }}</span> <span v-else>{{ ar.name }}</span>
<span v-if="index !== filteredArtists.length - 1">, </span> <span v-if="index !== filteredArtists.length - 1" class="separator"
>,</span
>
</span> </span>
</span> </span>
</template> </template>
@ -40,4 +42,12 @@ export default {
}; };
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped>
.separator {
/* make separator distinct enough in long list */
margin-left: 1px;
margin-right: 4px;
position: relative;
top: 0.5px;
}
</style>

View file

@ -80,11 +80,13 @@ export default {
box-shadow: 0 6px 12px -4px rgba(0, 0, 0, 0.08); box-shadow: 0 6px 12px -4px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgba(0, 0, 0, 0.06);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border-radius: 8px; border-radius: 12px;
box-sizing: border-box; box-sizing: border-box;
padding: 6px; padding: 6px;
z-index: 1000; z-index: 1000;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
transition: background 125ms ease-out, opacity 125ms ease-out,
transform 125ms ease-out;
&:focus { &:focus {
outline: none; outline: none;
@ -94,8 +96,9 @@ export default {
[data-theme='dark'] { [data-theme='dark'] {
.menu { .menu {
background: rgba(36, 36, 36, 0.78); background: rgba(36, 36, 36, 0.78);
backdrop-filter: blur(16px) contrast(120%); backdrop-filter: blur(16px) contrast(120%) brightness(60%);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 0 6px rgba(255, 255, 255, 0.08);
} }
.menu .item:hover { .menu .item:hover {
color: var(--color-text); color: var(--color-text);
@ -112,7 +115,7 @@ export default {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
padding: 10px 14px; padding: 10px 14px;
border-radius: 7px; border-radius: 8px;
cursor: default; cursor: default;
color: var(--color-text); color: var(--color-text);
display: flex; display: flex;
@ -120,6 +123,11 @@ export default {
&:hover { &:hover {
color: var(--color-primary); color: var(--color-primary);
background: var(--color-primary-bg-for-transparent); background: var(--color-primary-bg-for-transparent);
transition: opacity 125ms ease-out, transform 125ms ease-out;
}
&:active {
opacity: 0.75;
transform: scale(0.95);
} }
.svg-icon { .svg-icon {
@ -149,7 +157,7 @@ hr {
border-radius: 4px; border-radius: 4px;
} }
.info { .info {
margin-left: 8px; margin-left: 10px;
} }
.title { .title {
font-size: 16px; font-size: 16px;

View file

@ -16,7 +16,7 @@
><svg-icon icon-class="play" /> ><svg-icon icon-class="play" />
</button> </button>
</div> </div>
<img :src="imageUrl" :style="imageStyles" /> <img :src="imageUrl" :style="imageStyles" loading="lazy" />
<transition v-if="coverHover || alwaysShowShadow" name="fade"> <transition v-if="coverHover || alwaysShowShadow" name="fade">
<div <div
v-show="focus || alwaysShowShadow" v-show="focus || alwaysShowShadow"
@ -135,7 +135,7 @@ img {
cursor: default; cursor: default;
transition: 0.2s; transition: 0.2s;
.svg-icon { .svg-icon {
height: 44%; width: 50%;
margin: { margin: {
left: 4px; left: 4px;
} }

View file

@ -50,7 +50,7 @@ export default {
props: { props: {
items: { type: Array, required: true }, items: { type: Array, required: true },
type: { type: String, required: true }, type: { type: String, required: true },
subText: { type: String, default: 'null' }, subText: { type: String, default: 'none' },
subTextFontSize: { type: String, default: '16px' }, subTextFontSize: { type: String, default: '16px' },
showPlayCount: { type: Boolean, default: false }, showPlayCount: { type: Boolean, default: false },
columnNumber: { type: Number, default: 5 }, columnNumber: { type: Number, default: 5 },
@ -75,9 +75,9 @@ export default {
return new Date(item.publishTime).getFullYear(); return new Date(item.publishTime).getFullYear();
if (this.subText === 'artist') { if (this.subText === 'artist') {
if (item.artist !== undefined) if (item.artist !== undefined)
return `<a href="/#/artist/${item.artist.id}">${item.artist.name}</a>`; return `<a href="/artist/${item.artist.id}">${item.artist.name}</a>`;
if (item.artists !== undefined) if (item.artists !== undefined)
return `<a href="/#/artist/${item.artists[0].id}">${item.artists[0].name}</a>`; return `<a href="/artist/${item.artists[0].id}">${item.artists[0].name}</a>`;
} }
if (this.subText === 'albumType+releaseYear') { if (this.subText === 'albumType+releaseYear') {
let albumType = item.type; let albumType = item.type;
@ -96,7 +96,7 @@ export default {
return this.type === 'playlist' && item.privacy === 10; return this.type === 'playlist' && item.privacy === 10;
}, },
isExplicit(item) { isExplicit(item) {
return this.type === 'album' && item.mark === 1056768; return this.type === 'album' && (item.mark & 1048576) === 1048576;
}, },
getTitleLink(item) { getTitleLink(item) {
return `/${this.type}/${item.id}`; return `/${this.type}/${item.id}`;

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="daily-recommend-card" @click="goToDailyTracks"> <div class="daily-recommend-card" @click="goToDailyTracks">
<img :src="coverUrl" /> <img :src="coverUrl" loading="lazy" />
<div class="container"> <div class="container">
<div class="title-box"> <div class="title-box">
<div class="title"> <div class="title">

View file

@ -25,6 +25,8 @@ export default {
this.svgStyle = { this.svgStyle = {
height: this.size + 'px', height: this.size + 'px',
width: this.size + 'px', width: this.size + 'px',
position: 'relative',
left: '-1px',
}; };
}, },
}; };

View file

@ -1,9 +1,10 @@
<template> <template>
<div class="fm" :style="{ background }" data-theme="dark"> <div class="fm" :style="{ background }" data-theme="dark">
<img :src="nextTrackCover" style="display: none" /> <img :src="nextTrackCover" style="display: none" loading="lazy" />
<img <img
class="cover" class="cover"
:src="track.album && track.album.picUrl | resizeImage(512)" :src="track.album && track.album.picUrl | resizeImage(512)"
loading="lazy"
@click="goToAlbum" @click="goToAlbum"
/> />
<div class="right-part"> <div class="right-part">
@ -13,19 +14,20 @@
</div> </div>
<div class="controls"> <div class="controls">
<div class="buttons"> <div class="buttons">
<button-icon title="不喜欢" @click.native="moveToFMTrash" <button-icon title="不喜欢" @click.native="moveToFMTrash">
><svg-icon id="thumbs-down" icon-class="thumbs-down" <svg-icon id="thumbs-down" icon-class="thumbs-down" />
/></button-icon> </button-icon>
<button-icon <button-icon
:title="$t(isPlaying ? 'player.pause' : 'player.play')" :title="$t(isPlaying ? 'player.pause' : 'player.play')"
class="play" class="play"
@click.native="play" @click.native="play"
> >
<svg-icon :icon-class="isPlaying ? 'pause' : 'play'" <svg-icon :icon-class="isPlaying ? 'pause' : 'play'" />
/></button-icon> </button-icon>
<button-icon :title="$t('player.next')" @click.native="next" <button-icon :title="$t('player.next')" @click.native="next">
><svg-icon icon-class="next" /></button-icon <svg-icon icon-class="next" />
></div> </button-icon>
</div>
<div class="card-name"><svg-icon icon-class="fm" />私人FM</div> <div class="card-name"><svg-icon icon-class="fm" />私人FM</div>
</div> </div>
</div> </div>
@ -36,7 +38,7 @@
import ButtonIcon from '@/components/ButtonIcon.vue'; import ButtonIcon from '@/components/ButtonIcon.vue';
import ArtistsInLine from '@/components/ArtistsInLine.vue'; import ArtistsInLine from '@/components/ArtistsInLine.vue';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import * as Vibrant from 'node-vibrant'; import * as Vibrant from 'node-vibrant/dist/vibrant.worker.min.js';
import Color from 'color'; import Color from 'color';
export default { export default {
@ -65,6 +67,11 @@ export default {
)}?param=512y512`; )}?param=512y512`;
}, },
}, },
watch: {
track() {
this.getColor();
},
},
created() { created() {
this.getColor(); this.getColor();
window.ok = this.getColor; window.ok = this.getColor;
@ -74,11 +81,7 @@ export default {
this.player.playPersonalFM(); this.player.playPersonalFM();
}, },
next() { next() {
this.player.playNextFMTrack().then(result => { this.player.playNextFMTrack();
if (result) {
this.getColor();
}
});
}, },
goToAlbum() { goToAlbum() {
if (this.track.album.id === 0) return; if (this.track.album.id === 0) return;
@ -86,7 +89,6 @@ export default {
}, },
moveToFMTrash() { moveToFMTrash() {
this.player.moveToFMTrash(); this.player.moveToFMTrash();
this.getColor();
}, },
getColor() { getColor() {
if (!this.player.personalFMTrack?.album?.picUrl) return; if (!this.player.personalFMTrack?.album?.picUrl) return;

View file

@ -12,8 +12,8 @@
<div <div
class="button max-restore codicon" class="button max-restore codicon"
:class="{ :class="{
'codicon-chrome-restore': !isShowMaximized, 'codicon-chrome-restore': isMaximized,
'codicon-chrome-maximize': isShowMaximized, 'codicon-chrome-maximize': !isMaximized,
}" }"
@click="windowMaxRestore" @click="windowMaxRestore"
></div> ></div>
@ -40,7 +40,7 @@ export default {
name: 'LinuxTitlebar', name: 'LinuxTitlebar',
data() { data() {
return { return {
isShowMaximized: true, isMaximized: false,
}; };
}, },
computed: { computed: {
@ -49,9 +49,7 @@ export default {
created() { created() {
if (process.env.IS_ELECTRON === true) { if (process.env.IS_ELECTRON === true) {
ipcRenderer.on('isMaximized', (_, value) => { ipcRenderer.on('isMaximized', (_, value) => {
// valuefalse this.isMaximized = value;
// valuetrue
this.isShowMaximized = value;
}); });
} }
}, },

View file

@ -39,11 +39,16 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
minWidth: {
type: String,
default: 'calc(min(23rem, 100vw))',
},
}, },
computed: { computed: {
modalStyles() { modalStyles() {
return { return {
width: this.width, width: this.width,
minWidth: this.minWidth,
}; };
}, },
}, },

View file

@ -17,7 +17,7 @@
class="playlist" class="playlist"
@click="addTrackToPlaylist(playlist.id)" @click="addTrackToPlaylist(playlist.id)"
> >
<img :src="playlist.coverImgUrl | resizeImage(224)" /> <img :src="playlist.coverImgUrl | resizeImage(224)" loading="lazy" />
<div class="info"> <div class="info">
<div class="title">{{ playlist.name }}</div> <div class="title">{{ playlist.name }}</div>
<div class="track-count">{{ playlist.trackCount }} </div> <div class="track-count">{{ playlist.trackCount }} </div>

View file

@ -147,6 +147,7 @@ export default {
label { label {
font-size: 12px; font-size: 12px;
} }
user-select: none;
} }
} }
} }

View file

@ -7,7 +7,7 @@
@mouseleave="hoverVideoID = 0" @mouseleave="hoverVideoID = 0"
@click="goToMv(getID(mv))" @click="goToMv(getID(mv))"
> >
<img :src="getUrl(mv)" /> <img :src="getUrl(mv)" loading="lazy" />
<transition name="fade"> <transition name="fade">
<div <div
v-show="hoverVideoID === getID(mv)" v-show="hoverVideoID === getID(mv)"
@ -73,7 +73,7 @@ export default {
artistName = mv.creator[0].userName; artistName = mv.creator[0].userName;
artistID = mv.creator[0].userId; artistID = mv.creator[0].userId;
} }
return `<a href="/#/artist/${artistID}">${artistName}</a>`; return `<a href="/artist/${artistID}">${artistName}</a>`;
} else if (this.subtitle === 'publishTime') { } else if (this.subtitle === 'publishTime') {
return mv.publishTime; return mv.publishTime;
} }

View file

@ -43,7 +43,12 @@
</div> </div>
</div> </div>
</div> </div>
<img class="avatar" :src="avatarUrl" @click="showUserProfileMenu" /> <img
class="avatar"
:src="avatarUrl"
@click="showUserProfileMenu"
loading="lazy"
/>
</div> </div>
</nav> </nav>
@ -189,7 +194,7 @@ nav {
@media (max-width: 1336px) { @media (max-width: 1336px) {
nav { nav {
padding: 0 5vw; padding: 0 max(5vw, 90px);
} }
} }

View file

@ -27,6 +27,7 @@
<div class="container" @click.stop> <div class="container" @click.stop>
<img <img
:src="currentTrack.al && currentTrack.al.picUrl | resizeImage(224)" :src="currentTrack.al && currentTrack.al.picUrl | resizeImage(224)"
loading="lazy"
@click="goToAlbum" @click="goToAlbum"
/> />
<div class="track-info" :title="audioSource"> <div class="track-info" :title="audioSource">
@ -49,7 +50,11 @@
</div> </div>
<div class="like-button"> <div class="like-button">
<button-icon <button-icon
:title="$t('player.like')" :title="
player.isCurrentTrackLiked
? $t('player.unlike')
: $t('player.like')
"
@click.native="likeATrack(player.currentTrack.id)" @click.native="likeATrack(player.currentTrack.id)"
> >
<svg-icon <svg-icon
@ -182,6 +187,7 @@ import '@/assets/css/slider.css';
import ButtonIcon from '@/components/ButtonIcon.vue'; import ButtonIcon from '@/components/ButtonIcon.vue';
import VueSlider from 'vue-slider-component'; import VueSlider from 'vue-slider-component';
import { goToListSource, hasListSource } from '@/utils/playList'; import { goToListSource, hasListSource } from '@/utils/playList';
import { formatTrackTime } from '@/utils/common';
export default { export default {
name: 'Player', name: 'Player',
@ -234,10 +240,7 @@ export default {
: this.$router.push({ name: 'next' }); : this.$router.push({ name: 'next' });
}, },
formatTrackTime(value) { formatTrackTime(value) {
if (!value) return ''; return formatTrackTime(value);
let min = ~~((value / 60) % 60);
let sec = (~~(value % 60)).toString().padStart(2, '0');
return `${min}:${sec}`;
}, },
hasList() { hasList() {
return hasListSource(); return hasListSource();

View file

@ -2,7 +2,10 @@
<div class="track-list"> <div class="track-list">
<ContextMenu ref="menu"> <ContextMenu ref="menu">
<div v-show="type !== 'cloudDisk'" class="item-info"> <div v-show="type !== 'cloudDisk'" class="item-info">
<img :src="rightClickedTrackComputed.al.picUrl | resizeImage(224)" /> <img
:src="rightClickedTrackComputed.al.picUrl | resizeImage(224)"
loading="lazy"
/>
<div class="info"> <div class="info">
<div class="title">{{ rightClickedTrackComputed.name }}</div> <div class="title">{{ rightClickedTrackComputed.name }}</div>
<div class="subtitle">{{ rightClickedTrackComputed.ar[0].name }}</div> <div class="subtitle">{{ rightClickedTrackComputed.ar[0].name }}</div>
@ -46,6 +49,9 @@
@click="addTrackToPlaylist" @click="addTrackToPlaylist"
>{{ $t('contextMenu.addToPlaylist') }}</div >{{ $t('contextMenu.addToPlaylist') }}</div
> >
<div v-show="type !== 'cloudDisk'" class="item" @click="copyLink">{{
$t('contextMenu.copyUrl')
}}</div>
<div <div
v-if="extraContextMenuItem.includes('removeTrackFromCloudDisk')" v-if="extraContextMenuItem.includes('removeTrackFromCloudDisk')"
class="item" class="item"
@ -59,6 +65,7 @@
v-for="(track, index) in tracks" v-for="(track, index) in tracks"
:key="itemKey === 'id' ? track.id : `${track.id}${index}`" :key="itemKey === 'id' ? track.id : `${track.id}${index}`"
:track-prop="track" :track-prop="track"
:track-no="index + 1"
:highlight-playing-track="highlightPlayingTrack" :highlight-playing-track="highlightPlayingTrack"
@dblclick.native="playThisList(track.id || track.songId)" @dblclick.native="playThisList(track.id || track.songId)"
@click.right.native="openMenu($event, track, index)" @click.right.native="openMenu($event, track, index)"
@ -265,6 +272,17 @@ export default {
}); });
} }
}, },
copyLink() {
this.$copyText(
`https://music.163.com/song?id=${this.rightClickedTrack.id}`
)
.then(() => {
this.showToast(locale.t('toast.copied'));
})
.catch(err => {
this.showToast(`${locale.t('toast.copyFailed')}${err}`);
});
},
removeTrackFromQueue() { removeTrackFromQueue() {
this.$store.state.player.removeTrackFromQueue( this.$store.state.player.removeTrackFromQueue(
this.rightClickedTrackIndex this.rightClickedTrackIndex

View file

@ -10,6 +10,7 @@
<img <img
v-if="!isAlbum" v-if="!isAlbum"
:src="imgUrl" :src="imgUrl"
loading="lazy"
:class="{ hover: focus }" :class="{ hover: focus }"
@click="goToAlbum" @click="goToAlbum"
/> />
@ -20,7 +21,7 @@
style="height: 14px; width: 14px" style="height: 14px; width: 14px"
></svg-icon> ></svg-icon>
</button> </button>
<span v-show="(!focus || !playable) && !isPlaying">{{ track.no }}</span> <span v-show="(!focus || !playable) && !isPlaying">{{ trackNo }}</span>
<button v-show="isPlaying"> <button v-show="isPlaying">
<svg-icon <svg-icon
icon-class="volume" icon-class="volume"
@ -32,22 +33,24 @@
<div class="container"> <div class="container">
<div class="title"> <div class="title">
{{ track.name }} {{ track.name }}
<span v-if="isSubTitle" :title="subTitle" class="sub-title">
({{ subTitle }})
</span>
<span v-if="isAlbum" class="featured"> <span v-if="isAlbum" class="featured">
<ArtistsInLine <ArtistsInLine
:artists="track.ar" :artists="track.ar"
:exclude="$parent.albumObject.artist.name" :exclude="$parent.albumObject.artist.name"
prefix="-" prefix="-"
/></span> /></span>
<span v-if="isAlbum && track.mark === 1318912" class="explicit-symbol" <span
v-if="isAlbum && (track.mark & 1048576) === 1048576"
class="explicit-symbol"
><ExplicitSymbol ><ExplicitSymbol
/></span> /></span>
<span v-if="isSubTitle" :title="subTitle" class="sub-title">
({{ subTitle }})
</span>
</div> </div>
<div v-if="!isAlbum" class="artist"> <div v-if="!isAlbum" class="artist">
<span <span
v-if="track.mark === 1318912" v-if="(track.mark & 1048576) === 1048576"
class="explicit-symbol before-artist" class="explicit-symbol before-artist"
><ExplicitSymbol :size="15" ><ExplicitSymbol :size="15"
/></span> /></span>
@ -95,6 +98,7 @@ export default {
props: { props: {
trackProp: Object, trackProp: Object,
trackNo: Number,
highlightPlayingTrack: { highlightPlayingTrack: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -208,6 +212,7 @@ export default {
methods: { methods: {
goToAlbum() { goToAlbum() {
if (this.track.al.id === 0) return;
this.$router.push({ path: '/album/' + this.track.al.id }); this.$router.push({ path: '/album/' + this.track.al.id });
}, },
playTrack() { playTrack() {
@ -272,7 +277,6 @@ button {
} }
.explicit-symbol.before-artist { .explicit-symbol.before-artist {
margin-right: 2px;
.svg-icon { .svg-icon {
margin-bottom: -3px; margin-bottom: -3px;
} }
@ -316,7 +320,8 @@ button {
opacity: 0.72; opacity: 0.72;
} }
.sub-title { .sub-title {
color: #aeaeae; color: #7a7a7a;
opacity: 0.7;
margin-left: 4px; margin-left: 4px;
} }
} }
@ -364,6 +369,11 @@ button {
opacity: 0.88; opacity: 0.88;
color: var(--color-text); color: var(--color-text);
} }
.count {
font-weight: bold;
font-size: 22px;
line-height: 22px;
}
} }
.track.focus { .track.focus {
@ -425,7 +435,8 @@ button {
} }
.title .featured, .title .featured,
.artist, .artist,
.explicit-symbol { .explicit-symbol,
.count {
color: var(--color-primary); color: var(--color-primary);
opacity: 0.88; opacity: 0.88;
} }

View file

@ -9,8 +9,8 @@
<div <div
class="button max-restore codicon" class="button max-restore codicon"
:class="{ :class="{
'codicon-chrome-restore': !isShowMaximized, 'codicon-chrome-restore': isMaximized,
'codicon-chrome-maximize': isShowMaximized, 'codicon-chrome-maximize': !isMaximized,
}" }"
@click="windowMaxRestore" @click="windowMaxRestore"
></div> ></div>
@ -37,7 +37,7 @@ export default {
name: 'Win32Titlebar', name: 'Win32Titlebar',
data() { data() {
return { return {
isShowMaximized: true, isMaximized: false,
}; };
}, },
computed: { computed: {
@ -46,9 +46,7 @@ export default {
created() { created() {
if (process.env.IS_ELECTRON === true) { if (process.env.IS_ELECTRON === true) {
ipcRenderer.on('isMaximized', (_, value) => { ipcRenderer.on('isMaximized', (_, value) => {
// valuefalse this.isMaximized = value;
// valuetrue
this.isShowMaximized = value;
}); });
} }
}, },

View file

@ -1,5 +1,5 @@
import { app, dialog, globalShortcut, ipcMain } from 'electron'; import { app, dialog, globalShortcut, ipcMain } from 'electron';
import match from '@unblockneteasemusic/server'; import UNM from '@unblockneteasemusic/rust-napi';
import { registerGlobalShortcut } from '@/electron/globalShortcut'; import { registerGlobalShortcut } from '@/electron/globalShortcut';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import shortcuts from '@/utils/shortcuts'; import shortcuts from '@/utils/shortcuts';
@ -88,10 +88,10 @@ function toBuffer(data) {
} }
/** /**
* Get the file URI from bilivideo. * Get the file base64 data from bilivideo.
* *
* @param {string} url The URL to fetch. * @param {string} url The URL to fetch.
* @returns {Promise<string>} The file URI. * @returns {Promise<string>} The file base64 data.
*/ */
async function getBiliVideoFile(url) { async function getBiliVideoFile(url) {
const axios = await import('axios').then(m => m.default); const axios = await import('axios').then(m => m.default);
@ -106,61 +106,97 @@ async function getBiliVideoFile(url) {
const buffer = toBuffer(response.data); const buffer = toBuffer(response.data);
const encodedData = buffer.toString('base64'); const encodedData = buffer.toString('base64');
return `data:application/octet-stream;base64,${encodedData}`; return encodedData;
} }
/** /**
* Parse the source string (`a, b`) to source list `['a', 'b']`. * Parse the source string (`a, b`) to source list `['a', 'b']`.
* *
* @param {import("@unblockneteasemusic/rust-napi").Executor} executor
* @param {string} sourceString The source string. * @param {string} sourceString The source string.
* @returns {string[]} The source list. * @returns {string[]} The source list.
*/ */
function parseSourceStringToList(sourceString) { function parseSourceStringToList(executor, sourceString) {
return sourceString.split(',').map(s => s.trim()); const availableSource = executor.list();
return sourceString
.split(',')
.map(s => s.trim().toLowerCase())
.filter(s => {
const isAvailable = availableSource.includes(s);
if (!isAvailable) {
log(`This source is not one of the supported source: ${s}`);
}
return isAvailable;
});
} }
export function initIpcMain(win, store, trayEventEmitter) { export function initIpcMain(win, store, trayEventEmitter) {
ipcMain.handle('unblock-music', async (_, track, source) => { // WIP: Do not enable logging as it has some issues in non-blocking I/O environment.
// 兼容 unblockneteasemusic 所使用的 api 字段 // UNM.enableLogging(UNM.LoggingType.ConsoleEnv);
track.alias = track.alia || []; const unmExecutor = new UNM.Executor();
track.duration = track.dt || 0;
track.album = track.al || [];
track.artists = track.ar || [];
const timeoutPromise = new Promise((_, reject) => { ipcMain.handle(
setTimeout(() => { 'unblock-music',
reject('timeout'); /**
}, 5000); *
}); * @param {*} _
* @param {string | null} sourceListString
* @param {Record<string, any>} ncmTrack
* @param {UNM.Context} context
*/
async (_, sourceListString, ncmTrack, context) => {
// Formt the track input
// FIXME: Figure out the structure of Track
const song = {
id: ncmTrack.id && ncmTrack.id.toString(),
name: ncmTrack.name,
duration: ncmTrack.dt,
album: ncmTrack.al && {
id: ncmTrack.al.id && ncmTrack.al.id.toString(),
name: ncmTrack.al.name,
},
artists: ncmTrack.ar
? ncmTrack.ar.map(({ id, name }) => ({
id: id && id.toString(),
name,
}))
: [],
};
const sourceList = const sourceList =
typeof source === 'string' ? parseSourceStringToList(source) : null; typeof sourceListString === 'string'
log(`[UNM] using source: ${sourceList || '<default>'}`); ? parseSourceStringToList(unmExecutor, sourceListString)
: ['ytdl', 'bilibili', 'pyncm', 'kugou'];
log(`[UNM] using source: ${sourceList.join(', ')}`);
log(`[UNM] using configuration: ${JSON.stringify(context)}`);
try { try {
const matchedAudio = await Promise.race([
// TODO: tell users to install yt-dlp. // TODO: tell users to install yt-dlp.
// we passed "null" to source, to let UNM choose the default source. const matchedAudio = await unmExecutor.search(
match(track.id, sourceList, track), sourceList,
timeoutPromise, song,
]); context
);
if (!matchedAudio || !matchedAudio.url) { const retrievedSong = await unmExecutor.retrieve(matchedAudio, context);
throw new Error('no such a song found');
}
// bilibili's audio file needs some special treatment // bilibili's audio file needs some special treatment
if (matchedAudio.url.includes('bilivideo.com')) { if (retrievedSong.url.includes('bilivideo.com')) {
matchedAudio.url = await getBiliVideoFile(matchedAudio.url); retrievedSong.url = await getBiliVideoFile(retrievedSong.url);
} }
return matchedAudio; log(`respond with retrieve song…`);
log(JSON.stringify(matchedAudio));
return retrievedSong;
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? `${err.message}` : `${err}`; const errorMessage = err instanceof Error ? `${err.message}` : `${err}`;
log(`UnblockNeteaseMusic failed: ${errorMessage}`); log(`UnblockNeteaseMusic failed: ${errorMessage}`);
return null; return null;
} }
}); }
);
ipcMain.on('close', e => { ipcMain.on('close', e => {
if (isMac) { if (isMac) {
@ -186,9 +222,7 @@ export function initIpcMain(win, store, trayEventEmitter) {
}); });
ipcMain.on('maximizeOrUnmaximize', () => { ipcMain.on('maximizeOrUnmaximize', () => {
const isMaximized = win.isMaximized(); win.isMaximized() ? win.unmaximize() : win.maximize();
isMaximized ? win.unmaximize() : win.maximize();
win.webContents.send('isMaximized', isMaximized);
}); });
ipcMain.on('settings', (event, options) => { ipcMain.on('settings', (event, options) => {
@ -206,7 +240,7 @@ export function initIpcMain(win, store, trayEventEmitter) {
details: track.name + ' - ' + track.ar.map(ar => ar.name).join(','), details: track.name + ' - ' + track.ar.map(ar => ar.name).join(','),
state: track.al.name, state: track.al.name,
endTimestamp: Date.now() + track.dt, endTimestamp: Date.now() + track.dt,
largeImageKey: 'logo', largeImageKey: track.al.picUrl,
largeImageText: 'Listening ' + track.name, largeImageText: 'Listening ' + track.name,
smallImageKey: 'play', smallImageKey: 'play',
smallImageText: 'Playing', smallImageText: 'Playing',
@ -218,7 +252,7 @@ export function initIpcMain(win, store, trayEventEmitter) {
client.updatePresence({ client.updatePresence({
details: track.name + ' - ' + track.ar.map(ar => ar.name).join(','), details: track.name + ' - ' + track.ar.map(ar => ar.name).join(','),
state: track.al.name, state: track.al.name,
largeImageKey: 'logo', largeImageKey: track.al.picUrl,
largeImageText: 'YesPlayMusic', largeImageText: 'YesPlayMusic',
smallImageKey: 'pause', smallImageKey: 'pause',
smallImageText: 'Pause', smallImageText: 'Pause',

View file

@ -1,3 +1,4 @@
import dbus from 'dbus-next';
import { ipcMain, app } from 'electron'; import { ipcMain, app } from 'electron';
export function createMpris(window) { export function createMpris(window) {
@ -28,6 +29,8 @@ export function createMpris(window) {
}); });
ipcMain.on('metadata', (e, metadata) => { ipcMain.on('metadata', (e, metadata) => {
// 更新 Mpris 状态前将位置设为0, 否则 OSDLyrics 获取到的进度是上首音乐切换时的进度
player.getPosition = () => 0;
player.metadata = { player.metadata = {
'mpris:trackid': player.objectPath('track/' + metadata.trackId), 'mpris:trackid': player.objectPath('track/' + metadata.trackId),
'mpris:artUrl': metadata.artwork[0].src, 'mpris:artUrl': metadata.artwork[0].src,
@ -35,11 +38,17 @@ export function createMpris(window) {
'xesam:title': metadata.title, 'xesam:title': metadata.title,
'xesam:album': metadata.album, 'xesam:album': metadata.album,
'xesam:artist': metadata.artist.split(','), 'xesam:artist': metadata.artist.split(','),
'xesam:url': metadata.url,
}; };
}); });
ipcMain.on('playerCurrentTrackTime', (e, position) => { ipcMain.on('playerCurrentTrackTime', (e, position) => {
player.getPosition = () => position * 1000 * 1000; player.getPosition = () => position * 1000 * 1000;
player.seeked(position * 1000 * 1000);
});
ipcMain.on('seeked', (e, position) => {
player.seeked(position * 1000 * 1000);
}); });
ipcMain.on('switchRepeatMode', (e, mode) => { ipcMain.on('switchRepeatMode', (e, mode) => {
@ -60,3 +69,26 @@ export function createMpris(window) {
player.shuffle = shuffle; player.shuffle = shuffle;
}); });
} }
export async function createDbus(window) {
const bus = dbus.sessionBus();
const Variant = dbus.Variant;
const osdService = await bus.getProxyObject(
'org.osdlyrics.Daemon',
'/org/osdlyrics/Lyrics'
);
const osdInterface = osdService.getInterface('org.osdlyrics.Lyrics');
ipcMain.on('sendLyrics', async (e, { track, lyrics }) => {
const metadata = {
title: new Variant('s', track.name),
artist: new Variant('s', track.ar.map(ar => ar.name).join(', ')),
};
await osdInterface.SetLyricContent(metadata, Buffer.from(lyrics));
window.webContents.send('saveLyricFinished');
});
}

View file

@ -1,4 +1,5 @@
import clc from 'cli-color'; import clc from 'cli-color';
import checkAuthToken from '../utils/checkAuthToken';
import server from 'NeteaseCloudMusicApi/server'; import server from 'NeteaseCloudMusicApi/server';
export async function startNeteaseMusicApi() { export async function startNeteaseMusicApi() {

View file

@ -1,6 +1,6 @@
/* global __static */ /* global __static */
import path from 'path'; import path from 'path';
import { app, nativeImage, Tray, Menu } from 'electron'; import { app, nativeImage, Tray, Menu, nativeTheme } from 'electron';
import { isLinux } from '@/utils/platform'; import { isLinux } from '@/utils/platform';
function createMenuTemplate(win) { function createMenuTemplate(win) {
@ -97,6 +97,10 @@ function createMenuTemplate(win) {
// click在默认行为下会弹出一个contextMenu里面的唯一选项才会调用click事件 // click在默认行为下会弹出一个contextMenu里面的唯一选项才会调用click事件
// setContextMenu应该是目前唯一能在linux下使用托盘菜单api // setContextMenu应该是目前唯一能在linux下使用托盘菜单api
// 但是无法区分鼠标左右键 // 但是无法区分鼠标左右键
// 发现openSUSE KDE环境可以区分鼠标左右键
// 添加左键支持
// 2022.05.17
class YPMTrayLinuxImpl { class YPMTrayLinuxImpl {
constructor(tray, win, emitter) { constructor(tray, win, emitter) {
this.tray = tray; this.tray = tray;
@ -127,6 +131,10 @@ class YPMTrayLinuxImpl {
} }
handleEvents() { handleEvents() {
this.tray.on('click', () => {
this.win.show();
});
this.emitter.on('updateTooltip', title => this.tray.setToolTip(title)); this.emitter.on('updateTooltip', title => this.tray.setToolTip(title));
this.emitter.on('updatePlayState', isPlaying => { this.emitter.on('updatePlayState', isPlaying => {
this.contextMenu.getMenuItemById('play').visible = !isPlaying; this.contextMenu.getMenuItemById('play').visible = !isPlaying;
@ -189,8 +197,11 @@ class YPMTrayWindowsImpl {
} }
export function createTray(win, eventEmitter) { export function createTray(win, eventEmitter) {
// 感觉图标颜色应该不属于界面主题范畴,只需要跟随系统主题
let iconTheme = nativeTheme.shouldUseDarkColors ? 'light' : 'dark';
let icon = nativeImage let icon = nativeImage
.createFromPath(path.join(__static, 'img/icons/menu@88.png')) .createFromPath(path.join(__static, `img/icons/menu-${iconTheme}@88.png`))
.resize({ .resize({
height: 20, height: 20,
width: 20, width: 20,

View file

@ -102,6 +102,7 @@ export default {
}, },
player: { player: {
like: 'Like', like: 'Like',
unlike: 'Unlike',
previous: 'Previous Song', previous: 'Previous Song',
next: 'Next Song', next: 'Next Song',
repeat: 'Repeat', repeat: 'Repeat',
@ -112,6 +113,8 @@ export default {
pause: 'Pause', pause: 'Pause',
mute: 'Mute', mute: 'Mute',
nextUp: 'Next Up', nextUp: 'Next Up',
translationLyric: 'lyric (trans)',
PronunciationLyric: 'lyric (pronounce)',
}, },
modal: { modal: {
close: 'Close', close: 'Close',
@ -129,6 +132,17 @@ export default {
settings: 'Settings', settings: 'Settings',
logout: 'LOGOUT', logout: 'LOGOUT',
language: 'Languages', language: 'Languages',
lyric: 'Lyric',
others: 'Others',
customization: 'Customization',
MusicGenrePreference: {
text: 'Music Language Preference',
none: 'No preferences',
mandarin: 'Mandarin',
western: 'Europe & America',
korean: 'Korean',
japanese: 'Japanese',
},
musicQuality: { musicQuality: {
text: 'Music Quality', text: 'Music Quality',
low: 'Low', low: 'Low',
@ -166,6 +180,7 @@ export default {
subTitleDefault: 'Show Alias for Subtitle by default', subTitleDefault: 'Show Alias for Subtitle by default',
enableReversedMode: 'Enable Reversed Mode (Experimental)', enableReversedMode: 'Enable Reversed Mode (Experimental)',
enableCustomTitlebar: 'Enable custom title bar (Need restart)', enableCustomTitlebar: 'Enable custom title bar (Need restart)',
showLyricsTime: 'Display current time',
lyricsBackground: { lyricsBackground: {
text: 'Show Lyrics Background', text: 'Show Lyrics Background',
off: 'Off', off: 'Off',
@ -178,6 +193,41 @@ export default {
exit: 'Exit', exit: 'Exit',
minimizeToTray: 'Minimize to tray', minimizeToTray: 'Minimize to tray',
}, },
enableOsdlyricsSupport: {
title: 'desktop lyrics support',
desc1:
'Only takes effect under Linux. After enabled, it downloads the lyrics file to the local, and tries to launch OSDLyrics at startup.',
desc2:
'Please ensure that you have installed OSDLyrics before turning on this.',
},
unm: {
enable: 'Enable',
audioSource: {
title: 'Audio Sources',
},
enableFlac: {
title: 'Enable FLAC Sources',
desc: 'To take effect, it may be required to clear the cache after enabling this function.',
},
searchMode: {
title: 'Audio Search Mode',
fast: 'Speed Priority',
order: 'Order Priority',
},
cookie: {
joox: 'Cookie for Joox use',
qq: 'Cookie for QQ use',
desc1: 'Click here for the configuration instruction. ',
desc2: 'Leave empty to pick up the default value',
},
ytdl: 'The youtube-dl Executable File for YtDl',
proxy: {
title: 'Proxy Server for UNM',
desc1:
'The proxy server to use for requesting services such as YouTube',
desc2: 'Leave empty to pick up the default value',
},
},
}, },
contextMenu: { contextMenu: {
play: 'Play', play: 'Play',
@ -194,6 +244,8 @@ export default {
minePlaylists: 'My Playlists', minePlaylists: 'My Playlists',
likedPlaylists: 'Liked Playlists', likedPlaylists: 'Liked Playlists',
cardiacMode: 'Cardiac Mode', cardiacMode: 'Cardiac Mode',
copyLyric: 'Copy Lyric',
copyLyricWithTranslation: 'Copy Lyric With Translation',
}, },
toast: { toast: {
savedToPlaylist: 'Saved to playlist', savedToPlaylist: 'Saved to playlist',

View file

@ -98,6 +98,7 @@ export default {
}, },
player: { player: {
like: 'Beğen', like: 'Beğen',
unlike: 'Aksine',
previous: 'Önceki Müzik', previous: 'Önceki Müzik',
next: 'Sonraki Müzik', next: 'Sonraki Müzik',
repeat: 'Tekrarla', repeat: 'Tekrarla',
@ -107,6 +108,8 @@ export default {
pause: 'Durdur', pause: 'Durdur',
mute: 'Sesi kapat', mute: 'Sesi kapat',
nextUp: 'Sıradaki', nextUp: 'Sıradaki',
translationLyric: 'şarkı sözleri (çeviri)',
PronunciationLyric: 'şarkı sözleri (çeviri)',
}, },
modal: { modal: {
close: 'Kapat', close: 'Kapat',
@ -124,6 +127,17 @@ export default {
settings: 'Ayarlar', settings: 'Ayarlar',
logout: 'ÇIKIŞ YAP', logout: 'ÇIKIŞ YAP',
language: 'Diller', language: 'Diller',
lyric: 'Şarkı Sözleri',
others: 'Diğerleri',
customization: 'Özelleştirme',
MusicGenrePreference: {
text: 'Müzik Dili Tercihi',
none: 'Tercih yok',
mandarin: 'Çince dili',
western: 'Avrupa ve Amerika',
korean: 'Korece',
japanese: 'Japonca',
},
musicQuality: { musicQuality: {
text: 'Müzik Kalitesi', text: 'Müzik Kalitesi',
low: 'Düşük', low: 'Düşük',
@ -172,6 +186,34 @@ export default {
exit: 'Exit', exit: 'Exit',
minimizeToTray: 'Küçült', minimizeToTray: 'Küçült',
}, },
unm: {
enable: 'Enable',
audioSource: {
title: 'Audio Sources',
},
enableFlac: {
title: 'Enable FLAC Sources',
desc: 'To take effect, it may be required to clear the cache after enabling this function.',
},
searchMode: {
title: 'Audio Search Mode',
fast: 'Speed Priority',
order: 'Order Priority',
},
cookie: {
joox: 'Cookie for Joox use',
qq: 'Cookie for QQ use',
desc1: 'Click here for the configuration instruction. ',
desc2: 'Leave empty to pick up the default value',
},
ytdl: 'The youtube-dl Executable File for YtDl',
proxy: {
title: 'Proxy Server for UNM',
desc1:
'The proxy server to use for requesting services such as YouTube',
desc2: 'Leave empty to pick up the default value',
},
},
}, },
contextMenu: { contextMenu: {
play: 'Oynat', play: 'Oynat',
@ -188,6 +230,8 @@ export default {
minePlaylists: 'My Playlists', minePlaylists: 'My Playlists',
likedPlaylists: 'Liked Playlists', likedPlaylists: 'Liked Playlists',
cardiacMode: 'Cardiac Mode', cardiacMode: 'Cardiac Mode',
copyLyric: 'Copy Lyric',
copyLyricWithTranslation: 'Copy Lyric With Translation',
}, },
toast: { toast: {
savedToMyLikedSongs: 'Beğendiğim Müziklere Kaydet', savedToMyLikedSongs: 'Beğendiğim Müziklere Kaydet',

View file

@ -103,6 +103,7 @@ export default {
}, },
player: { player: {
like: '喜欢', like: '喜欢',
unlike: '取消喜欢',
previous: '上一首', previous: '上一首',
next: '下一首', next: '下一首',
repeat: '循环播放', repeat: '循环播放',
@ -113,6 +114,8 @@ export default {
pause: '暂停', pause: '暂停',
mute: '静音', mute: '静音',
nextUp: '播放列表', nextUp: '播放列表',
translationLyric: '歌词(译)',
PronunciationLyric: '歌词(音)',
}, },
modal: { modal: {
close: '关闭', close: '关闭',
@ -130,6 +133,17 @@ export default {
settings: '设置', settings: '设置',
logout: '登出', logout: '登出',
language: '语言', language: '语言',
lyric: '歌词',
others: '其他',
customization: '自定义',
MusicGenrePreference: {
text: '音乐语种偏好',
none: '无偏好',
mandarin: '华语',
western: '欧美',
korean: '韩语',
japanese: '日语',
},
musicQuality: { musicQuality: {
text: '音质选择', text: '音质选择',
low: '普通', low: '普通',
@ -173,12 +187,46 @@ export default {
on: '打开', on: '打开',
dynamic: '动态GPU 占用较高)', dynamic: '动态GPU 占用较高)',
}, },
showLyricsTime: '显示当前时间',
closeAppOption: { closeAppOption: {
text: '关闭主面板时...', text: '关闭主面板时...',
ask: '询问', ask: '询问',
exit: '退出', exit: '退出',
minimizeToTray: '最小化到托盘', minimizeToTray: '最小化到托盘',
}, },
enableOsdlyricsSupport: {
title: '桌面歌词支持',
desc1:
'仅 Linux 下生效。启用后会将歌词文件下载到本地,并在开启播放器时尝试拉起 OSDLyrics。',
desc2: '请在开启之前确保您已经正确安装了 OSDLyrics。',
},
unm: {
enable: '启用',
audioSource: {
title: '备选音源',
},
enableFlac: {
title: '启用 FLAC',
desc: '启用后需要清除歌曲缓存才能生效',
},
searchMode: {
title: '音源搜索模式',
fast: '速度优先',
order: '顺序优先',
},
cookie: {
joox: 'Joox 引擎的 Cookie',
qq: 'QQ 引擎的 Cookie',
desc1: '设置说明请参见此处',
desc2: ',留空则不进行相关设置',
},
ytdl: 'YtDl 引擎要使用的 youtube-dl 可执行文件',
proxy: {
title: '用于 UNM 的代理服务器',
desc1: '请求如 YouTube 音源服务时要使用的代理服务器',
desc2: '留空则不进行相关设置',
},
},
}, },
contextMenu: { contextMenu: {
play: '播放', play: '播放',
@ -195,6 +243,8 @@ export default {
minePlaylists: '创建的歌单', minePlaylists: '创建的歌单',
likedPlaylists: '收藏的歌单', likedPlaylists: '收藏的歌单',
cardiacMode: '心动模式', cardiacMode: '心动模式',
copyLyric: '复制歌词',
copyLyricWithTranslation: '复制歌词(含翻译)',
}, },
toast: { toast: {
savedToPlaylist: '已添加到歌单', savedToPlaylist: '已添加到歌单',

View file

@ -99,6 +99,7 @@ export default {
}, },
player: { player: {
like: '喜歡', like: '喜歡',
unlike: '取消喜歡',
previous: '上一首', previous: '上一首',
next: '下一首', next: '下一首',
repeat: '循環播放', repeat: '循環播放',
@ -109,6 +110,8 @@ export default {
pause: '暫停', pause: '暫停',
mute: '靜音', mute: '靜音',
nextUp: '播放清單', nextUp: '播放清單',
translationLyric: '歌詞(譯)',
PronunciationLyric: '歌詞(音)',
}, },
modal: { modal: {
close: '關閉', close: '關閉',
@ -126,6 +129,17 @@ export default {
settings: '設定', settings: '設定',
logout: '登出', logout: '登出',
language: '語言', language: '語言',
lyric: '歌詞',
others: '其他',
customization: '自訂',
MusicGenrePreference: {
text: '音樂語種偏好',
none: '無偏好',
mandarin: '華語',
western: '歐美',
korean: '韓語',
japanese: '日語',
},
musicQuality: { musicQuality: {
text: '音質選擇', text: '音質選擇',
low: '普通', low: '普通',
@ -164,6 +178,7 @@ export default {
subTitleDefault: '副標題使用別名', subTitleDefault: '副標題使用別名',
enableReversedMode: '啟用倒序播放功能 (實驗性功能)', enableReversedMode: '啟用倒序播放功能 (實驗性功能)',
enableCustomTitlebar: '啟用自訂標題列(重新啟動後生效)', enableCustomTitlebar: '啟用自訂標題列(重新啟動後生效)',
showLyricsTime: '顯示目前時間',
lyricsBackground: { lyricsBackground: {
text: '顯示歌詞背景', text: '顯示歌詞背景',
off: '關閉', off: '關閉',
@ -176,6 +191,39 @@ export default {
exit: '退出', exit: '退出',
minimizeToTray: '最小化到工作列角落', minimizeToTray: '最小化到工作列角落',
}, },
enableOsdlyricsSupport: {
title: '桌面歌詞支援',
desc1:
'只在 Linux 環境下生效。啟用後會將歌詞檔案下載至本機位置,並在開啟播放器時嘗試連帶啟動 OSDLyrics。',
desc2: '請在開啟之前確保您已經正確安裝了 OSDLyrics。',
},
unm: {
enable: '啟用',
audioSource: {
title: '備選音源',
},
enableFlac: {
title: '啟用 FLAC',
desc: '啟用後需要清除歌曲快取才能生效',
},
searchMode: {
title: '音源搜尋模式',
fast: '速度優先',
order: '順序優先',
},
cookie: {
joox: 'Joox 引擎的 Cookie',
qq: 'QQ 引擎的 Cookie',
desc1: '設定說明請參見此處',
desc2: ',留空則不進行相關設定',
},
ytdl: 'YtDl 引擎要使用的 youtube-dl 執行檔',
proxy: {
title: '用於 UNM 的 Proxy 伺服器',
desc1: '請求如 YouTube 音源服務時要使用的 Proxy 伺服器',
desc2: '留空則不進行相關設定',
},
},
}, },
contextMenu: { contextMenu: {
play: '播放', play: '播放',
@ -192,6 +240,8 @@ export default {
minePlaylists: '我建立的歌單', minePlaylists: '我建立的歌單',
likedPlaylists: '收藏的歌單', likedPlaylists: '收藏的歌單',
cardiacMode: '心動模式', cardiacMode: '心動模式',
copyLyric: '複製歌詞',
copyLyricWithTranslation: '複製歌詞(含翻譯)',
}, },
toast: { toast: {
savedToPlaylist: '已新增至歌單', savedToPlaylist: '已新增至歌單',

View file

@ -1,5 +1,5 @@
import Vue from 'vue'; import Vue from 'vue';
import VueAnalytics from 'vue-analytics'; import VueGtag from 'vue-gtag';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import store from './store'; import store from './store';
@ -7,7 +7,6 @@ import i18n from '@/locale';
import '@/assets/icons'; import '@/assets/icons';
import '@/utils/filters'; import '@/utils/filters';
import './registerServiceWorker'; import './registerServiceWorker';
import { dailyTask } from '@/utils/common';
import '@/assets/css/global.scss'; import '@/assets/css/global.scss';
import NProgress from 'nprogress'; import NProgress from 'nprogress';
import '@/assets/css/nprogress.css'; import '@/assets/css/nprogress.css';
@ -28,14 +27,16 @@ console.log(
'background:unset;color:unset;' 'background:unset;color:unset;'
); );
Vue.use(VueAnalytics, { Vue.use(
id: 'UA-180189423-1', VueGtag,
router, {
}); config: { id: 'G-KMJJCFZDKF' },
},
router
);
Vue.config.productionTip = false; Vue.config.productionTip = false;
NProgress.configure({ showSpinner: false, trickleSpeed: 100 }); NProgress.configure({ showSpinner: false, trickleSpeed: 100 });
dailyTask();
new Vue({ new Vue({
i18n, i18n,

View file

@ -23,6 +23,7 @@ let localStorage = {
nyancatStyle: false, nyancatStyle: false,
showLyricsTranslation: true, showLyricsTranslation: true,
lyricsBackground: true, lyricsBackground: true,
enableOsdlyricsSupport: false,
closeAppOption: 'ask', closeAppOption: 'ask',
enableDiscordRichPresence: false, enableDiscordRichPresence: false,
enableGlobalShortcut: true, enableGlobalShortcut: true,
@ -35,6 +36,8 @@ let localStorage = {
server: '', server: '',
port: null, port: null,
}, },
enableRealIP: false,
realIP: null,
shortcuts: shortcuts, shortcuts: shortcuts,
}, },
data: { data: {

View file

@ -22,6 +22,10 @@ export default {
artists: [], artists: [],
mvs: [], mvs: [],
cloudDisk: [], cloudDisk: [],
playHistory: {
weekData: [],
allData: [],
},
}, },
contextMenu: { contextMenu: {
clickObjectID: 0, clickObjectID: 0,

View file

@ -1,18 +1,30 @@
import { getTrackDetail, scrobble, getMP3 } from '@/api/track';
import shuffle from 'lodash/shuffle';
import { Howler, Howl } from 'howler';
import { cacheTrackSource, getTrackSource } from '@/utils/db';
import { getAlbum } from '@/api/album'; import { getAlbum } from '@/api/album';
import { getPlaylistDetail, intelligencePlaylist } from '@/api/playlist';
import { getArtist } from '@/api/artist'; import { getArtist } from '@/api/artist';
import { personalFM, fmTrash } from '@/api/others'; import { trackScrobble, trackUpdateNowPlaying } from '@/api/lastfm';
import { fmTrash, personalFM } from '@/api/others';
import { getPlaylistDetail, intelligencePlaylist } from '@/api/playlist';
import { getLyric, getMP3, getTrackDetail } from '@/api/track';
import store from '@/store'; import store from '@/store';
import { isAccountLoggedIn } from '@/utils/auth'; import { isAccountLoggedIn } from '@/utils/auth';
import { trackUpdateNowPlaying, trackScrobble } from '@/api/lastfm'; import { cacheTrackSource, getTrackSource } from '@/utils/db';
import { isCreateMpris, isCreateTray } from '@/utils/platform'; import { isCreateMpris, isCreateTray } from '@/utils/platform';
import { Howl, Howler } from 'howler';
import shuffle from 'lodash/shuffle';
import { decode as base642Buffer } from '@/utils/base64';
const PLAY_PAUSE_FADE_DURATION = 200; const PLAY_PAUSE_FADE_DURATION = 200;
const INDEX_IN_PLAY_NEXT = -1;
/**
* @readonly
* @enum {string}
*/
const UNPLAYABLE_CONDITION = {
PLAY_NEXT_TRACK: 'playNextTrack',
PLAY_PREV_TRACK: 'playPrevTrack',
};
const electron = const electron =
process.env.IS_ELECTRON === true ? window.require('electron') : null; process.env.IS_ELECTRON === true ? window.require('electron') : null;
const ipcRenderer = const ipcRenderer =
@ -34,14 +46,14 @@ function setTitle(track) {
? `${track.name} · ${track.ar[0].name} - YesPlayMusic` ? `${track.name} · ${track.ar[0].name} - YesPlayMusic`
: 'YesPlayMusic'; : 'YesPlayMusic';
if (isCreateTray) { if (isCreateTray) {
ipcRenderer.send('updateTrayTooltip', document.title); ipcRenderer?.send('updateTrayTooltip', document.title);
} }
store.commit('updateTitle', document.title); store.commit('updateTitle', document.title);
} }
function setTrayLikeState(isLiked) { function setTrayLikeState(isLiked) {
if (isCreateTray) { if (isCreateTray) {
ipcRenderer.send('updateTrayLikeState', isLiked); ipcRenderer?.send('updateTrayLikeState', isLiked);
} }
} }
@ -69,7 +81,9 @@ export default class {
this._playNextList = []; // 当这个list不为空时会优先播放这个list的歌 this._playNextList = []; // 当这个list不为空时会优先播放这个list的歌
this._isPersonalFM = false; // 是否是私人FM模式 this._isPersonalFM = false; // 是否是私人FM模式
this._personalFMTrack = { id: 0 }; // 私人FM当前歌曲 this._personalFMTrack = { id: 0 }; // 私人FM当前歌曲
this._personalFMNextTrack = { id: 0 }; // 私人FM下一首歌曲信息为了快速加载下一首 this._personalFMNextTrack = {
id: 0,
}; // 私人FM下一首歌曲信息为了快速加载下一首
/** /**
* The blob records for cleanup. * The blob records for cleanup.
@ -116,6 +130,8 @@ export default class {
if (shuffle) { if (shuffle) {
this._shuffleTheList(); this._shuffleTheList();
} }
// 同步当前歌曲在列表中的下标
this.current = this.list.indexOf(this.currentTrackID);
} }
get reversed() { get reversed() {
return this._reversed; return this._reversed;
@ -134,7 +150,7 @@ export default class {
} }
set volume(volume) { set volume(volume) {
this._volume = volume; this._volume = volume;
Howler.volume(volume); this._howler?.volume(volume);
} }
get list() { get list() {
return this.shuffle ? this._shuffledList : this._list; return this.shuffle ? this._shuffledList : this._list;
@ -161,6 +177,9 @@ export default class {
get currentTrack() { get currentTrack() {
return this._currentTrack; return this._currentTrack;
} }
get currentTrackID() {
return this._currentTrack?.id ?? 0;
}
get playlistSource() { get playlistSource() {
return this._playlistSource; return this._playlistSource;
} }
@ -184,6 +203,9 @@ export default class {
set progress(value) { set progress(value) {
if (this._howler) { if (this._howler) {
this._howler.seek(value); this._howler.seek(value);
if (isCreateMpris) {
ipcRenderer?.send('seeked', this._howler.seek());
}
} }
} }
get isCurrentTrackLiked() { get isCurrentTrackLiked() {
@ -192,13 +214,11 @@ export default class {
_init() { _init() {
this._loadSelfFromLocalStorage(); this._loadSelfFromLocalStorage();
Howler.autoUnlock = false; this._howler?.volume(this.volume);
Howler.usingWebAudio = true;
Howler.volume(this.volume);
if (this._enabled) { if (this._enabled) {
// 恢复当前播放歌曲 // 恢复当前播放歌曲
this._replaceCurrentTrack(this._currentTrack.id, false).then(() => { this._replaceCurrentTrack(this.currentTrackID, false).then(() => {
this._howler?.seek(localStorage.getItem('playerCurrentTrackTime') ?? 0); this._howler?.seek(localStorage.getItem('playerCurrentTrackTime') ?? 0);
}); // update audio source and init howler }); // update audio source and init howler
this._initMediaSession(); this._initMediaSession();
@ -222,18 +242,19 @@ export default class {
_setPlaying(isPlaying) { _setPlaying(isPlaying) {
this._playing = isPlaying; this._playing = isPlaying;
if (isCreateTray) { if (isCreateTray) {
ipcRenderer.send('updateTrayPlayState', this._playing); ipcRenderer?.send('updateTrayPlayState', this._playing);
} }
} }
_setIntervals() { _setIntervals() {
// 同步播放进度 // 同步播放进度
// TODO: 如果 _progress 在别的地方被改变了这个定时器会覆盖之前改变的值是bug // TODO: 如果 _progress 在别的地方被改变了,
// 这个定时器会覆盖之前改变的值是bug
setInterval(() => { setInterval(() => {
if (this._howler === null) return; if (this._howler === null) return;
this._progress = this._howler.seek(); this._progress = this._howler.seek();
localStorage.setItem('playerCurrentTrackTime', this._progress); localStorage.setItem('playerCurrentTrackTime', this._progress);
if (isCreateMpris) { if (isCreateMpris) {
ipcRenderer.send('playerCurrentTrackTime', this._progress); ipcRenderer?.send('playerCurrentTrackTime', this._progress);
} }
}, 1000); }, 1000);
} }
@ -241,8 +262,8 @@ export default class {
const next = this._reversed ? this.current - 1 : this.current + 1; const next = this._reversed ? this.current - 1 : this.current + 1;
if (this._playNextList.length > 0) { if (this._playNextList.length > 0) {
let trackID = this._playNextList.shift(); let trackID = this._playNextList[0];
return [trackID, this.current]; return [trackID, INDEX_IN_PLAY_NEXT];
} }
// 循环模式开启,则重新播放当前模式下的相对的下一首 // 循环模式开启,则重新播放当前模式下的相对的下一首
@ -276,7 +297,7 @@ export default class {
// 返回 [trackID, index] // 返回 [trackID, index]
return [this.list[next], next]; return [this.list[next], next];
} }
async _shuffleTheList(firstTrackID = this._currentTrack.id) { async _shuffleTheList(firstTrackID = this.currentTrackID) {
let list = this._list.filter(tid => tid !== firstTrackID); let list = this._list.filter(tid => tid !== firstTrackID);
if (firstTrackID === 'first') list = this._list; if (firstTrackID === 'first') list = this._list;
this._shuffledList = shuffle(list); this._shuffledList = shuffle(list);
@ -288,11 +309,6 @@ export default class {
); );
const trackDuration = ~~(track.dt / 1000); const trackDuration = ~~(track.dt / 1000);
time = completed ? trackDuration : ~~time; time = completed ? trackDuration : ~~time;
scrobble({
id: track.id,
sourceid: this.playlistSource.id,
time,
});
if ( if (
store.state.lastfm.key !== undefined && store.state.lastfm.key !== undefined &&
(time >= trackDuration / 2 || time >= 240) (time >= trackDuration / 2 || time >= 240)
@ -313,11 +329,35 @@ export default class {
this._howler = new Howl({ this._howler = new Howl({
src: [source], src: [source],
html5: true, html5: true,
preload: true,
format: ['mp3', 'flac'], format: ['mp3', 'flac'],
onend: () => { onend: () => {
this._nextTrackCallback(); this._nextTrackCallback();
}, },
}); });
this._howler.on('loaderror', (_, errCode) => {
// https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code
// code 3: MEDIA_ERR_DECODE
if (errCode === 3) {
this._playNextTrack(this._isPersonalFM);
} else if (errCode === 4) {
// code 4: MEDIA_ERR_SRC_NOT_SUPPORTED
store.dispatch('showToast', `无法播放: 不支持的音频格式`);
this._playNextTrack(this._isPersonalFM);
} else {
const t = this.progress;
this._replaceCurrentTrackAudio(this.currentTrack, false, false).then(
replaced => {
// 如果 replaced 为 false代表当前的 track 已经不是这里想要替换的track
// 此时则不修改当前的歌曲进度
if (replaced) {
this._howler?.seek(t);
this.play();
}
}
);
}
});
if (autoplay) { if (autoplay) {
this.play(); this.play();
if (this._currentTrack.name) { if (this._currentTrack.name) {
@ -327,12 +367,9 @@ export default class {
} }
this.setOutputDevice(); this.setOutputDevice();
} }
_getAudioSourceFromCache(id) { _getAudioSourceBlobURL(data) {
return getTrackSource(id).then(t => {
if (!t) return null;
// Create a new object URL. // Create a new object URL.
const source = URL.createObjectURL(new Blob([t.source])); const source = URL.createObjectURL(new Blob([data]));
// Clean up the previous object URLs since we've created a new one. // Clean up the previous object URLs since we've created a new one.
// Revoke object URLs can release the memory taken by a Blob, // Revoke object URLs can release the memory taken by a Blob,
@ -346,6 +383,11 @@ export default class {
this.createdBlobRecords = [source]; this.createdBlobRecords = [source];
return source; return source;
}
_getAudioSourceFromCache(id) {
return getTrackSource(id).then(t => {
if (!t) return null;
return this._getAudioSourceBlobURL(t.source);
}); });
} }
_getAudioSourceFromNetease(track) { _getAudioSourceFromNetease(track) {
@ -368,22 +410,71 @@ export default class {
} }
async _getAudioSourceFromUnblockMusic(track) { async _getAudioSourceFromUnblockMusic(track) {
console.debug(`[debug][Player.js] _getAudioSourceFromUnblockMusic`); console.debug(`[debug][Player.js] _getAudioSourceFromUnblockMusic`);
if ( if (
process.env.IS_ELECTRON !== true || process.env.IS_ELECTRON !== true ||
store.state.settings.enableUnblockNeteaseMusic === false store.state.settings.enableUnblockNeteaseMusic === false
) { ) {
return null; return null;
} }
const source = await ipcRenderer.invoke(
'unblock-music', /**
track, *
store.state.settings.unmSource * @param {string=} searchMode
); * @returns {import("@unblockneteasemusic/rust-napi").SearchMode}
if (store.state.settings.automaticallyCacheSongs && source?.url) { */
// TODO: 将unblockMusic字样换成真正的来源比如酷我咪咕等 const determineSearchMode = searchMode => {
cacheTrackSource(track, source.url, 128000, 'unblockMusic'); /**
* FastFirst = 0
* OrderFirst = 1
*/
switch (searchMode) {
case 'fast-first':
return 0;
case 'order-first':
return 1;
default:
return 0;
} }
return source?.url; };
const retrieveSongInfo = await ipcRenderer.invoke(
'unblock-music',
store.state.settings.unmSource,
track,
{
enableFlac: store.state.settings.unmEnableFlac || null,
proxyUri: store.state.settings.unmProxyUri || null,
searchMode: determineSearchMode(store.state.settings.unmSearchMode),
config: {
'joox:cookie': store.state.settings.unmJooxCookie || null,
'qq:cookie': store.state.settings.unmQQCookie || null,
'ytdl:exe': store.state.settings.unmYtDlExe || null,
},
}
);
if (store.state.settings.automaticallyCacheSongs && retrieveSongInfo?.url) {
// 对于来自 bilibili 的音源
// retrieveSongInfo.url 是音频数据的base64编码
// 其他音源为实际url
const url =
retrieveSongInfo.source === 'bilibili'
? `data:application/octet-stream;base64,${retrieveSongInfo.url}`
: retrieveSongInfo.url;
cacheTrackSource(track, url, 128000, `unm:${retrieveSongInfo.source}`);
}
if (!retrieveSongInfo) {
return null;
}
if (retrieveSongInfo.source !== 'bilibili') {
return retrieveSongInfo.url;
}
const buffer = base642Buffer(retrieveSongInfo.url);
return this._getAudioSourceBlobURL(buffer);
} }
_getAudioSource(track) { _getAudioSource(track) {
return this._getAudioSourceFromCache(String(track.id)) return this._getAudioSourceFromCache(String(track.id))
@ -397,34 +488,62 @@ export default class {
_replaceCurrentTrack( _replaceCurrentTrack(
id, id,
autoplay = true, autoplay = true,
ifUnplayableThen = 'playNextTrack' ifUnplayableThen = UNPLAYABLE_CONDITION.PLAY_NEXT_TRACK
) { ) {
if (autoplay && this._currentTrack.name) { if (autoplay && this._currentTrack.name) {
this._scrobble(this.currentTrack, this._howler?.seek()); this._scrobble(this.currentTrack, this._howler?.seek());
} }
return getTrackDetail(id).then(data => { return getTrackDetail(id).then(data => {
let track = data.songs[0]; const track = data.songs[0];
this._currentTrack = track; this._currentTrack = track;
this._updateMediaSessionMetaData(track); this._updateMediaSessionMetaData(track);
return this._replaceCurrentTrackAudio(
track,
autoplay,
true,
ifUnplayableThen
);
});
}
/**
* @returns 是否成功加载音频并使用加载完成的音频替换了howler实例
*/
_replaceCurrentTrackAudio(
track,
autoplay,
isCacheNextTrack,
ifUnplayableThen = UNPLAYABLE_CONDITION.PLAY_NEXT_TRACK
) {
return this._getAudioSource(track).then(source => { return this._getAudioSource(track).then(source => {
if (source) { if (source) {
let replaced = false;
if (track.id === this.currentTrackID) {
this._playAudioSource(source, autoplay); this._playAudioSource(source, autoplay);
replaced = true;
}
if (isCacheNextTrack) {
this._cacheNextTrack(); this._cacheNextTrack();
return source; }
return replaced;
} else { } else {
store.dispatch('showToast', `无法播放 ${track.name}`); store.dispatch('showToast', `无法播放 ${track.name}`);
if (ifUnplayableThen === 'playNextTrack') { switch (ifUnplayableThen) {
if (this.isPersonalFM) { case UNPLAYABLE_CONDITION.PLAY_NEXT_TRACK:
this.playNextFMTrack(); this._playNextTrack(this.isPersonalFM);
} else { break;
this.playNextTrack(); case UNPLAYABLE_CONDITION.PLAY_PREV_TRACK:
}
} else {
this.playPrevTrack(); this.playPrevTrack();
break;
default:
store.dispatch(
'showToast',
`undefined Unplayable condition: ${ifUnplayableThen}`
);
break;
} }
return false;
} }
}); });
});
} }
_cacheNextTrack() { _cacheNextTrack() {
let nextTrackID = this._isPersonalFM let nextTrackID = this._isPersonalFM
@ -456,11 +575,7 @@ export default class {
this.playPrevTrack(); this.playPrevTrack();
}); });
navigator.mediaSession.setActionHandler('nexttrack', () => { navigator.mediaSession.setActionHandler('nexttrack', () => {
if (this.isPersonalFM) { this._playNextTrack(this.isPersonalFM);
this.playNextFMTrack();
} else {
this.playNextTrack();
}
}); });
navigator.mediaSession.setActionHandler('stop', () => { navigator.mediaSession.setActionHandler('stop', () => {
this.pause(); this.pause();
@ -489,6 +604,11 @@ export default class {
artist: artists.join(','), artist: artists.join(','),
album: track.al.name, album: track.al.name,
artwork: [ artwork: [
{
src: track.al.picUrl + '?param=224y224',
type: 'image/jpg',
sizes: '224x224',
},
{ {
src: track.al.picUrl + '?param=512y512', src: track.al.picUrl + '?param=512y512',
type: 'image/jpg', type: 'image/jpg',
@ -497,13 +617,35 @@ export default class {
], ],
length: this.currentTrackDuration, length: this.currentTrackDuration,
trackId: this.current, trackId: this.current,
url: '/trackid/' + track.id,
}; };
navigator.mediaSession.metadata = new window.MediaMetadata(metadata); navigator.mediaSession.metadata = new window.MediaMetadata(metadata);
if (isCreateMpris) { if (isCreateMpris) {
ipcRenderer.send('metadata', metadata); this._updateMprisState(track, metadata);
} }
} }
// OSDLyrics 会检测 Mpris 状态并寻找对应歌词文件,所以要在更新 Mpris 状态之前保证歌词下载完成
async _updateMprisState(track, metadata) {
if (!store.state.settings.enableOsdlyricsSupport) {
return ipcRenderer?.send('metadata', metadata);
}
let lyricContent = await getLyric(track.id);
if (!lyricContent.lrc || !lyricContent.lrc.lyric) {
return ipcRenderer?.send('metadata', metadata);
}
ipcRenderer.send('sendLyrics', {
track,
lyrics: lyricContent.lrc.lyric,
});
ipcRenderer.on('saveLyricFinished', () => {
ipcRenderer?.send('metadata', metadata);
});
}
_updateMediaSessionPositionState() { _updateMediaSessionPositionState() {
if ('mediaSession' in navigator === false) { if ('mediaSession' in navigator === false) {
return; return;
@ -519,11 +661,9 @@ export default class {
_nextTrackCallback() { _nextTrackCallback() {
this._scrobble(this._currentTrack, 0, true); this._scrobble(this._currentTrack, 0, true);
if (!this.isPersonalFM && this.repeatMode === 'one') { if (!this.isPersonalFM && this.repeatMode === 'one') {
this._replaceCurrentTrack(this._currentTrack.id); this._replaceCurrentTrack(this.currentTrackID);
} else if (this.isPersonalFM) {
this.playNextFMTrack();
} else { } else {
this.playNextTrack(); this._playNextTrack(this.isPersonalFM);
} }
} }
_loadPersonalFMNextTrack() { _loadPersonalFMNextTrack() {
@ -557,7 +697,7 @@ export default class {
} }
let copyTrack = { ...track }; let copyTrack = { ...track };
copyTrack.dt -= seekTime * 1000; copyTrack.dt -= seekTime * 1000;
ipcRenderer.send('playDiscordPresence', copyTrack); ipcRenderer?.send('playDiscordPresence', copyTrack);
} }
_pauseDiscordPresence(track) { _pauseDiscordPresence(track) {
if ( if (
@ -566,13 +706,16 @@ export default class {
) { ) {
return null; return null;
} }
ipcRenderer.send('pauseDiscordPresence', track); ipcRenderer?.send('pauseDiscordPresence', track);
}
_playNextTrack(isPersonal) {
if (isPersonal) {
this.playNextFMTrack();
} else {
this.playNextTrack();
}
} }
currentTrackID() {
const { list, current } = this._getListAndCurrent();
return list[current];
}
appendTrack(trackID) { appendTrack(trackID) {
this.list.append(trackID); this.list.append(trackID);
} }
@ -584,7 +727,12 @@ export default class {
this._setPlaying(false); this._setPlaying(false);
return false; return false;
} }
this.current = index; let next = index;
if (index === INDEX_IN_PLAY_NEXT) {
this._playNextList.shift();
next = this.current;
}
this.current = next;
this._replaceCurrentTrack(trackID); this._replaceCurrentTrack(trackID);
return true; return true;
} }
@ -637,7 +785,11 @@ export default class {
const [trackID, index] = this._getPrevTrack(); const [trackID, index] = this._getPrevTrack();
if (trackID === undefined) return false; if (trackID === undefined) return false;
this.current = index; this.current = index;
this._replaceCurrentTrack(trackID, true, 'playPrevTrack'); this._replaceCurrentTrack(
trackID,
true,
UNPLAYABLE_CONDITION.PLAY_PREV_TRACK
);
return true; return true;
} }
saveSelfToLocalStorage() { saveSelfToLocalStorage() {
@ -668,6 +820,9 @@ export default class {
this._howler?.once('play', () => { this._howler?.once('play', () => {
this._howler?.fade(0, this.volume, PLAY_PAUSE_FADE_DURATION); this._howler?.fade(0, this.volume, PLAY_PAUSE_FADE_DURATION);
// 播放时确保开启player.
// 避免因"忘记设置"导致在播放时播放器不显示的Bug
this._enabled = true;
this._setPlaying(true); this._setPlaying(true);
if (this._currentTrack.name) { if (this._currentTrack.name) {
setTitle(this._currentTrack); setTitle(this._currentTrack);
@ -691,11 +846,14 @@ export default class {
this.play(); this.play();
} }
} }
seek(time = null) { seek(time = null, sendMpris = true) {
if (isCreateMpris && sendMpris && time) {
ipcRenderer?.send('seeked', time);
}
if (time !== null) { if (time !== null) {
this._howler?.seek(time); this._howler?.seek(time);
if (this._playing) if (this._playing)
this._playDiscordPresence(this._currentTrack, this.seek()); this._playDiscordPresence(this._currentTrack, this.seek(null, false));
} }
return this._howler === null ? 0 : this._howler.seek(); return this._howler === null ? 0 : this._howler.seek();
} }
@ -721,7 +879,6 @@ export default class {
autoPlayTrackID = 'first' autoPlayTrackID = 'first'
) { ) {
this._isPersonalFM = false; this._isPersonalFM = false;
if (!this._enabled) this._enabled = true;
this.list = trackIDs; this.list = trackIDs;
this.current = 0; this.current = 0;
this._playlistSource = { this._playlistSource = {
@ -732,7 +889,7 @@ export default class {
if (autoPlayTrackID === 'first') { if (autoPlayTrackID === 'first') {
this._replaceCurrentTrack(this.list[0]); this._replaceCurrentTrack(this.list[0]);
} else { } else {
this.current = trackIDs.indexOf(autoPlayTrackID); this.current = this.list.indexOf(autoPlayTrackID);
this._replaceCurrentTrack(autoPlayTrackID); this._replaceCurrentTrack(autoPlayTrackID);
} }
} }
@ -778,20 +935,13 @@ export default class {
addTrackToPlayNext(trackID, playNow = false) { addTrackToPlayNext(trackID, playNow = false) {
this._playNextList.push(trackID); this._playNextList.push(trackID);
if (playNow) { if (playNow) {
if (this.isPersonalFM) {
this.playNextFMTrack();
} else {
this.playNextTrack(); this.playNextTrack();
} }
} }
}
playPersonalFM() { playPersonalFM() {
this._isPersonalFM = true; this._isPersonalFM = true;
if (!this._enabled) this._enabled = true; if (this.currentTrackID !== this._personalFMTrack.id) {
if (this._currentTrack.id !== this._personalFMTrack.id) { this._replaceCurrentTrack(this._personalFMTrack.id, true);
this._replaceCurrentTrack(this._personalFMTrack.id).then(() =>
this.playOrPause()
);
} else { } else {
this.playOrPause(); this.playOrPause();
} }
@ -807,7 +957,7 @@ export default class {
sendSelfToIpcMain() { sendSelfToIpcMain() {
if (process.env.IS_ELECTRON !== true) return false; if (process.env.IS_ELECTRON !== true) return false;
let liked = store.state.liked.songs.includes(this.currentTrack.id); let liked = store.state.liked.songs.includes(this.currentTrack.id);
ipcRenderer.send('player', { ipcRenderer?.send('player', {
playing: this.playing, playing: this.playing,
likedCurrentTrack: liked, likedCurrentTrack: liked,
}); });
@ -823,13 +973,13 @@ export default class {
this.repeatMode = 'on'; this.repeatMode = 'on';
} }
if (isCreateMpris) { if (isCreateMpris) {
ipcRenderer.send('switchRepeatMode', this.repeatMode); ipcRenderer?.send('switchRepeatMode', this.repeatMode);
} }
} }
switchShuffle() { switchShuffle() {
this.shuffle = !this.shuffle; this.shuffle = !this.shuffle;
if (isCreateMpris) { if (isCreateMpris) {
ipcRenderer.send('switchShuffle', this.shuffle); ipcRenderer?.send('switchShuffle', this.shuffle);
} }
} }
switchReversed() { switchReversed() {

67
src/utils/base64.js Normal file
View file

@ -0,0 +1,67 @@
// https://github.com/niklasvh/base64-arraybuffer/blob/master/src/index.ts
// Copyright (c) 2012 Niklas von Hertzen Licensed under the MIT license.
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// Use a lookup table to find the index.
const lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
export const encode = arraybuffer => {
let bytes = new Uint8Array(arraybuffer),
i,
len = bytes.length,
base64 = '';
for (i = 0; i < len; i += 3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if (len % 3 === 2) {
base64 = base64.substring(0, base64.length - 1) + '=';
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + '==';
}
return base64;
};
export const decode = base64 => {
let bufferLength = base64.length * 0.75,
len = base64.length,
i,
p = 0,
encoded1,
encoded2,
encoded3,
encoded4;
if (base64[base64.length - 1] === '=') {
bufferLength--;
if (base64[base64.length - 2] === '=') {
bufferLength--;
}
}
const arraybuffer = new ArrayBuffer(bufferLength),
bytes = new Uint8Array(arraybuffer);
for (i = 0; i < len; i += 4) {
encoded1 = lookup[base64.charCodeAt(i)];
encoded2 = lookup[base64.charCodeAt(i + 1)];
encoded3 = lookup[base64.charCodeAt(i + 2)];
encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return arraybuffer;
};

View file

@ -0,0 +1,8 @@
import os from 'os';
import fs from 'fs';
import path from 'path';
// extract from NeteasyCloudMusicAPI/generateConfig.js and avoid bugs in there (generateConfig require main.js but the main.js has bugs)
if (!fs.existsSync(path.resolve(os.tmpdir(), 'anonymous_token'))) {
fs.writeFileSync(path.resolve(os.tmpdir(), 'anonymous_token'), '', 'utf-8');
}

View file

@ -221,7 +221,7 @@ export function bytesToSize(bytes) {
export function formatTrackTime(value) { export function formatTrackTime(value) {
if (!value) return ''; if (!value) return '';
let min = ~~((value / 60) % 60); let min = ~~(value / 60);
let sec = (~~(value % 60)).toString().padStart(2, '0'); let sec = (~~(value % 60)).toString().padStart(2, '0');
return `${min}:${sec}`; return `${min}:${sec}`;
} }

View file

@ -2,6 +2,7 @@ export function lyricParser(lrc) {
return { return {
lyric: parseLyric(lrc?.lrc?.lyric || ''), lyric: parseLyric(lrc?.lrc?.lyric || ''),
tlyric: parseLyric(lrc?.tlyric?.lyric || ''), tlyric: parseLyric(lrc?.tlyric?.lyric || ''),
romalyric: parseLyric(lrc?.romalrc?.lyric || ''),
lyricuser: lrc.lyricUser, lyricuser: lrc.lyricUser,
transuser: lrc.transUser, transuser: lrc.transUser,
}; };
@ -83,3 +84,30 @@ function trimContent(content) {
let t = content.trim(); let t = content.trim();
return t.length < 1 ? content : t; return t.length < 1 ? content : t;
} }
/**
* @param {string} lyric
*/
export async function copyLyric(lyric) {
const textToCopy = lyric;
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(textToCopy);
} catch (err) {
alert('复制失败,请手动复制!');
}
} else {
const tempInput = document.createElement('textarea');
tempInput.value = textToCopy;
tempInput.style.position = 'absolute';
tempInput.style.left = '-9999px';
document.body.appendChild(tempInput);
tempInput.select();
try {
document.execCommand('copy');
} catch (err) {
alert('复制失败,请手动复制!');
}
document.body.removeChild(tempInput);
}
}

View file

@ -3,6 +3,5 @@ export const isMac = process.platform === 'darwin';
export const isLinux = process.platform === 'linux'; export const isLinux = process.platform === 'linux';
export const isDevelopment = process.env.NODE_ENV === 'development'; export const isDevelopment = process.env.NODE_ENV === 'development';
export const isCreateTray = export const isCreateTray = isWindows || isLinux || isDevelopment;
process.env.IS_ELECTRON && (isWindows || isLinux || isDevelopment);
export const isCreateMpris = isLinux; export const isCreateMpris = isLinux;

View file

@ -1,5 +1,11 @@
import router from '../router'; import router from '../router';
import state from '../store/state'; import state from '../store/state';
import {
recommendPlaylist,
dailyRecommendPlaylist,
getPlaylistDetail,
} from '@/api/playlist';
import { isAccountLoggedIn } from '@/utils/auth';
export function hasListSource() { export function hasListSource() {
return !state.player.isPersonalFM && state.player.playlistSource.id !== 0; return !state.player.isPersonalFM && state.player.playlistSource.id !== 0;
@ -20,3 +26,36 @@ export function getListSourcePath() {
return `/${state.player.playlistSource.type}/${state.player.playlistSource.id}`; return `/${state.player.playlistSource.type}/${state.player.playlistSource.id}`;
} }
} }
export async function getRecommendPlayList(limit, removePrivateRecommand) {
if (isAccountLoggedIn()) {
const playlists = await Promise.all([
dailyRecommendPlaylist(),
recommendPlaylist({ limit }),
]);
let recommend = playlists[0].recommend ?? [];
if (recommend.length) {
if (removePrivateRecommand) recommend = recommend.slice(1);
await replaceRecommendResult(recommend);
}
return recommend.concat(playlists[1].result).slice(0, limit);
} else {
const response = await recommendPlaylist({ limit });
return response.result;
}
}
async function replaceRecommendResult(recommend) {
for (let r of recommend) {
if (specialPlaylist.indexOf(r.id) > -1) {
const data = await getPlaylistDetail(r.id, true);
const playlist = data.playlist;
if (playlist) {
r.name = playlist.name;
r.picUrl = playlist.coverImgUrl;
}
}
}
}
const specialPlaylist = [3136952023, 2829883282, 2829816518, 2829896389];

View file

@ -1,6 +1,6 @@
import axios from 'axios';
import { getCookie, doLogout } from '@/utils/auth';
import router from '@/router'; import router from '@/router';
import { doLogout, getCookie } from '@/utils/auth';
import axios from 'axios';
let baseURL = ''; let baseURL = '';
// Web 和 Electron 跑在不同端口避免同时启动时冲突 // Web 和 Electron 跑在不同端口避免同时启动时冲突
@ -23,7 +23,11 @@ const service = axios.create({
service.interceptors.request.use(function (config) { service.interceptors.request.use(function (config) {
if (!config.params) config.params = {}; if (!config.params) config.params = {};
if (baseURL.length) { if (baseURL.length) {
if (baseURL[0] !== '/' && !process.env.IS_ELECTRON) { if (
baseURL[0] !== '/' &&
!process.env.IS_ELECTRON &&
getCookie('MUSIC_U') !== null
) {
config.params.cookie = `MUSIC_U=${getCookie('MUSIC_U')};`; config.params.cookie = `MUSIC_U=${getCookie('MUSIC_U')};`;
} }
} else { } else {
@ -34,6 +38,17 @@ service.interceptors.request.use(function (config) {
config.params.realIP = '211.161.244.70'; config.params.realIP = '211.161.244.70';
} }
// Force real_ip
const enableRealIP = JSON.parse(
localStorage.getItem('settings')
).enableRealIP;
const realIP = JSON.parse(localStorage.getItem('settings')).realIP;
if (process.env.VUE_APP_REAL_IP) {
config.params.realIP = process.env.VUE_APP_REAL_IP;
} else if (enableRealIP) {
config.params.realIP = realIP;
}
const proxy = JSON.parse(localStorage.getItem('settings')).proxyConfig; const proxy = JSON.parse(localStorage.getItem('settings')).proxyConfig;
if (['HTTP', 'HTTPS'].includes(proxy.protocol)) { if (['HTTP', 'HTTPS'].includes(proxy.protocol)) {
config.params.proxy = `${proxy.protocol}://${proxy.server}:${proxy.port}`; config.params.proxy = `${proxy.protocol}://${proxy.server}:${proxy.port}`;
@ -49,8 +64,16 @@ service.interceptors.response.use(
}, },
async error => { async error => {
/** @type {import('axios').AxiosResponse | null} */ /** @type {import('axios').AxiosResponse | null} */
const response = error.response; let response;
const data = response.data; let data;
if (error === 'TypeError: baseURL is undefined') {
response = error;
data = error;
console.error("You must set up the baseURL in the service's config");
} else if (error.response) {
response = error.response;
data = response.data;
}
if ( if (
response && response &&

View file

@ -28,7 +28,9 @@
<span v-else>Compilation by Various Artists</span> <span v-else>Compilation by Various Artists</span>
</div> </div>
<div class="date-and-count"> <div class="date-and-count">
<span v-if="album.mark === 1056768" class="explicit-symbol" <span
v-if="(album.mark & 1048576) === 1048576"
class="explicit-symbol"
><ExplicitSymbol ><ExplicitSymbol
/></span> /></span>
<span :title="album.publishTime | formatDate">{{ <span :title="album.publishTime | formatDate">{{
@ -71,12 +73,12 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="Object.keys(tracksByDisc).length !== 1"> <div v-if="tracksByDisc.length > 1">
<div v-for="(disc, cd) in tracksByDisc" :key="cd"> <div v-for="item in tracksByDisc" :key="item.disc">
<h2 class="disc">Disc {{ cd }}</h2> <h2 class="disc">Disc {{ item.disc }}</h2>
<TrackList <TrackList
:id="album.id" :id="album.id"
:tracks="disc" :tracks="item.tracks"
:type="'album'" :type="'album'"
:album-object="album" :album-object="album"
/> />
@ -96,9 +98,7 @@
{{ $t('album.released') }} {{ $t('album.released') }}
{{ album.publishTime | formatDate('MMMM D, YYYY') }} {{ album.publishTime | formatDate('MMMM D, YYYY') }}
</div> </div>
<div v-if="album.company !== null" class="copyright"> <div v-if="album.company" class="copyright"> © {{ album.company }} </div>
© {{ album.company }}
</div>
</div> </div>
<div v-if="filteredMoreAlbums.length !== 0" class="more-by"> <div v-if="filteredMoreAlbums.length !== 0" class="more-by">
<div class="section-title"> <div class="section-title">
@ -153,7 +153,7 @@ import locale from '@/locale';
import { splitSoundtrackAlbumTitle, splitAlbumTitle } from '@/utils/common'; import { splitSoundtrackAlbumTitle, splitAlbumTitle } from '@/utils/common';
import NProgress from 'nprogress'; import NProgress from 'nprogress';
import { isAccountLoggedIn } from '@/utils/auth'; import { isAccountLoggedIn } from '@/utils/auth';
import { groupBy } from 'lodash'; import { groupBy, toPairs, sortBy } from 'lodash';
import ExplicitSymbol from '@/components/ExplicitSymbol.vue'; import ExplicitSymbol from '@/components/ExplicitSymbol.vue';
import ButtonTwoTone from '@/components/ButtonTwoTone.vue'; import ButtonTwoTone from '@/components/ButtonTwoTone.vue';
@ -222,7 +222,12 @@ export default {
} }
}, },
tracksByDisc() { tracksByDisc() {
return groupBy(this.tracks, 'cd'); if (this.tracks.length <= 1) return [];
const pairs = toPairs(groupBy(this.tracks, 'cd'));
return sortBy(pairs, p => p[0]).map(items => ({
disc: items[0],
tracks: items[1],
}));
}, },
}, },
created() { created() {

View file

@ -2,7 +2,7 @@
<div v-show="show" class="artist-page"> <div v-show="show" class="artist-page">
<div class="artist-info"> <div class="artist-info">
<div class="head"> <div class="head">
<img :src="artist.img1v1Url | resizeImage(1024)" /> <img :src="artist.img1v1Url | resizeImage(1024)" loading="lazy" />
</div> </div>
<div> <div>
<div class="name">{{ artist.name }}</div> <div class="name">{{ artist.name }}</div>
@ -75,7 +75,7 @@
@mouseleave="mvHover = false" @mouseleave="mvHover = false"
@click="goToMv(latestMV.id)" @click="goToMv(latestMV.id)"
> >
<img :src="latestMV.coverUrl" /> <img :src="latestMV.coverUrl" loading="lazy" />
<transition name="fade"> <transition name="fade">
<div <div
v-show="mvHover" v-show="mvHover"
@ -127,7 +127,7 @@
<div v-if="mvs.length !== 0" id="mvs" class="mvs"> <div v-if="mvs.length !== 0" id="mvs" class="mvs">
<div class="section-title" <div class="section-title"
>MVs >MVs
<router-link v-show="hasMoreMV" :to="`/artist/${this.artist.id}/mv`">{{ <router-link v-show="hasMoreMV" :to="`/artist/${artist.id}/mv`">{{
$t('home.seeMore') $t('home.seeMore')
}}</router-link> }}</router-link>
</div> </div>
@ -185,6 +185,7 @@ import {
followAArtist, followAArtist,
similarArtists, similarArtists,
} from '@/api/artist'; } from '@/api/artist';
import { getTrackDetail } from '@/api/track';
import locale from '@/locale'; import locale from '@/locale';
import { isAccountLoggedIn } from '@/utils/auth'; import { isAccountLoggedIn } from '@/utils/auth';
import NProgress from 'nprogress'; import NProgress from 'nprogress';
@ -241,7 +242,9 @@ export default {
computed: { computed: {
...mapState(['player']), ...mapState(['player']),
albums() { albums() {
return this.albumsData.filter(a => a.type === '专辑'); return this.albumsData.filter(
a => a.type === '专辑' || a.type === '精选集'
);
}, },
eps() { eps() {
return this.albumsData.filter(a => return this.albumsData.filter(a =>
@ -276,7 +279,7 @@ export default {
this.$parent.$refs.main.scrollTo({ top: 0 }); this.$parent.$refs.main.scrollTo({ top: 0 });
getArtist(id).then(data => { getArtist(id).then(data => {
this.artist = data.artist; this.artist = data.artist;
this.popularTracks = data.hotSongs; this.setPopularTracks(data.hotSongs);
if (next !== undefined) next(); if (next !== undefined) next();
NProgress.done(); NProgress.done();
this.show = true; this.show = true;
@ -289,9 +292,17 @@ export default {
this.mvs = data.mvs; this.mvs = data.mvs;
this.hasMoreMV = data.hasMore; this.hasMoreMV = data.hasMore;
}); });
if (isAccountLoggedIn()) {
similarArtists(id).then(data => { similarArtists(id).then(data => {
this.similarArtists = data.artists; this.similarArtists = data.artists;
}); });
}
},
setPopularTracks(hotSongs) {
const trackIDs = hotSongs.map(t => t.id);
getTrackDetail(trackIDs.join(',')).then(data => {
this.popularTracks = data.songs;
});
}, },
goToAlbum(id) { goToAlbum(id) {
this.$router.push({ this.$router.push({

View file

@ -1,9 +1,11 @@
<template> <template>
<div v-show="show"> <div v-show="show">
<h1> <h1>
<img class="avatar" :src="artist.img1v1Url | resizeImage(1024)" />{{ <img
artist.name class="avatar"
}}'s Music Videos :src="artist.img1v1Url | resizeImage(1024)"
loading="lazy"
/>{{ artist.name }}'s Music Videos
</h1> </h1>
<MvRow :mvs="mvs" subtitle="publishTime" /> <MvRow :mvs="mvs" subtitle="publishTime" />
<div class="load-more"> <div class="load-more">

View file

@ -66,13 +66,9 @@
<script> <script>
import { mapState, mapMutations } from 'vuex'; import { mapState, mapMutations } from 'vuex';
import NProgress from 'nprogress'; import NProgress from 'nprogress';
import { import { topPlaylist, highQualityPlaylist, toplists } from '@/api/playlist';
topPlaylist,
highQualityPlaylist,
recommendPlaylist,
toplists,
} from '@/api/playlist';
import { playlistCategories } from '@/utils/staticData'; import { playlistCategories } from '@/utils/staticData';
import { getRecommendPlayList } from '@/utils/playList';
import ButtonTwoTone from '@/components/ButtonTwoTone.vue'; import ButtonTwoTone from '@/components/ButtonTwoTone.vue';
import CoverRow from '@/components/CoverRow.vue'; import CoverRow from '@/components/CoverRow.vue';
@ -124,10 +120,13 @@ export default {
setTimeout(() => { setTimeout(() => {
if (!this.show) NProgress.start(); if (!this.show) NProgress.start();
}, 1000); }, 1000);
this.activeCategory = const queryCategory = this.$route.query.category;
this.$route.query.category === undefined if (queryCategory === undefined) {
? '全部' this.playlists = [];
: this.$route.query.category; this.activeCategory = '全部';
} else {
this.activeCategory = queryCategory;
}
this.getPlaylist(); this.getPlaylist();
}, },
goToCategory(Category) { goToCategory(Category) {
@ -155,9 +154,9 @@ export default {
return this.getTopPlayList(); return this.getTopPlayList();
}, },
getRecommendPlayList() { getRecommendPlayList() {
recommendPlaylist({ limit: 100 }).then(data => { getRecommendPlayList(100, true).then(list => {
this.playlists = []; this.playlists = [];
this.updatePlaylist(data.result); this.updatePlaylist(list);
}); });
}, },
getHighQualityPlaylist() { getHighQualityPlaylist() {

View file

@ -69,10 +69,11 @@
</template> </template>
<script> <script>
import { toplists, recommendPlaylist } from '@/api/playlist'; import { toplists } from '@/api/playlist';
import { toplistOfArtists } from '@/api/artist'; import { toplistOfArtists } from '@/api/artist';
import { byAppleMusic } from '@/utils/staticData';
import { newAlbums } from '@/api/album'; import { newAlbums } from '@/api/album';
import { byAppleMusic } from '@/utils/staticData';
import { getRecommendPlayList } from '@/utils/playList';
import NProgress from 'nprogress'; import NProgress from 'nprogress';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import CoverRow from '@/components/CoverRow.vue'; import CoverRow from '@/components/CoverRow.vue';
@ -112,10 +113,8 @@ export default {
setTimeout(() => { setTimeout(() => {
if (!this.show) NProgress.start(); if (!this.show) NProgress.start();
}, 1000); }, 1000);
recommendPlaylist({ getRecommendPlayList(10, false).then(items => {
limit: 10, this.recommendPlaylist.items = items;
}).then(data => {
this.recommendPlaylist.items = data.result;
NProgress.done(); NProgress.done();
this.show = true; this.show = true;
}); });

View file

@ -1,9 +1,11 @@
<template> <template>
<div v-show="show" ref="library"> <div v-show="show" ref="library">
<h1> <h1>
<img class="avatar" :src="data.user.avatarUrl | resizeImage" />{{ <img
data.user.nickname class="avatar"
}}{{ $t('library.sLibrary') }} :src="data.user.avatarUrl | resizeImage"
loading="lazy"
/>{{ data.user.nickname }}{{ $t('library.sLibrary') }}
</h1> </h1>
<div class="section-one"> <div class="section-one">
<div class="liked-songs" @click="goToLikedSongsList"> <div class="liked-songs" @click="goToLikedSongsList">
@ -112,7 +114,7 @@
<div v-show="currentTab === 'playlists'"> <div v-show="currentTab === 'playlists'">
<div v-if="liked.playlists.length > 1"> <div v-if="liked.playlists.length > 1">
<CoverRow <CoverRow
:items="filterPlaylists.slice(1)" :items="filterPlaylists"
type="playlist" type="playlist"
sub-text="creator" sub-text="creator"
:show-play-button="true" :show-play-button="true"
@ -153,10 +155,22 @@
</div> </div>
<div v-show="currentTab === 'playHistory'"> <div v-show="currentTab === 'playHistory'">
<button class="playHistory-button" @click="playHistoryMode = 'week'"> <button
:class="{
'playHistory-button': true,
'playHistory-button--selected': playHistoryMode === 'week',
}"
@click="playHistoryMode = 'week'"
>
{{ $t('library.playHistory.week') }} {{ $t('library.playHistory.week') }}
</button> </button>
<button class="playHistory-button" @click="playHistoryMode = 'all'"> <button
:class="{
'playHistory-button': true,
'playHistory-button--selected': playHistoryMode === 'all',
}"
@click="playHistoryMode = 'all'"
>
{{ $t('library.playHistory.all') }} {{ $t('library.playHistory.all') }}
</button> </button>
<TrackList <TrackList
@ -201,7 +215,7 @@
<script> <script>
import { mapActions, mapMutations, mapState } from 'vuex'; import { mapActions, mapMutations, mapState } from 'vuex';
import { randomNum, dailyTask } from '@/utils/common'; import { randomNum } from '@/utils/common';
import { isAccountLoggedIn } from '@/utils/auth'; import { isAccountLoggedIn } from '@/utils/auth';
import { uploadSong } from '@/api/user'; import { uploadSong } from '@/api/user';
import { getLyric } from '@/api/track'; import { getLyric } from '@/api/track';
@ -255,7 +269,7 @@ export default {
// Pick 3 or fewer lyrics based on the lyric lines. // Pick 3 or fewer lyrics based on the lyric lines.
const lyricsToPick = Math.min(lyricLine.length, 3); const lyricsToPick = Math.min(lyricLine.length, 3);
// The upperbound of the lyric line to pick // The upperBound of the lyric line to pick
const randomUpperBound = lyricLine.length - lyricsToPick; const randomUpperBound = lyricLine.length - lyricsToPick;
const startLyricLineIndex = randomNum(0, randomUpperBound - 1); const startLyricLineIndex = randomNum(0, randomUpperBound - 1);
@ -268,7 +282,7 @@ export default {
return this.data.libraryPlaylistFilter || 'all'; return this.data.libraryPlaylistFilter || 'all';
}, },
filterPlaylists() { filterPlaylists() {
const playlists = this.liked.playlists; const playlists = this.liked.playlists.slice(1);
const userId = this.data.user.userId; const userId = this.data.user.userId;
if (this.playlistFilter === 'mine') { if (this.playlistFilter === 'mine') {
return playlists.filter(p => p.creator.userId === userId); return playlists.filter(p => p.creator.userId === userId);
@ -280,7 +294,8 @@ export default {
playHistoryList() { playHistoryList() {
if (this.show && this.playHistoryMode === 'week') { if (this.show && this.playHistoryMode === 'week') {
return this.liked.playHistory.weekData; return this.liked.playHistory.weekData;
} else if (this.show && this.playHistoryMode === 'all') { }
if (this.show && this.playHistoryMode === 'all') {
return this.liked.playHistory.allData; return this.liked.playHistory.allData;
} }
return []; return [];
@ -295,7 +310,6 @@ export default {
activated() { activated() {
this.$parent.$refs.scrollbar.restorePosition(); this.$parent.$refs.scrollbar.restorePosition();
this.loadData(); this.loadData();
dailyTask();
}, },
methods: { methods: {
...mapActions(['showToast']), ...mapActions(['showToast']),
@ -581,13 +595,29 @@ button.tab-button {
button.playHistory-button { button.playHistory-button {
color: var(--color-text); color: var(--color-text);
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 6px 8px;
margin-bottom: 12px;
margin-right: 4px;
transition: 0.2s; transition: 0.2s;
opacity: 0.68; opacity: 0.68;
font-weight: 500; font-weight: 500;
cursor: pointer;
&:hover { &:hover {
opacity: 1; opacity: 1;
background: var(--color-secondary-bg); background: var(--color-secondary-bg);
} }
&:active {
transform: scale(0.95);
}
}
button.playHistory-button--selected {
color: var(--color-text);
background: var(--color-secondary-bg);
opacity: 1;
font-weight: 700;
&:active {
transform: none;
}
} }
</style> </style>

View file

@ -7,16 +7,19 @@
<div class="title">{{ $t('login.loginText') }}</div> <div class="title">{{ $t('login.loginText') }}</div>
<div class="section-2"> <div class="section-2">
<div v-show="mode === 'phone'" class="input-box"> <div v-show="mode === 'phone'" class="input-box">
<div class="container" :class="{ active: inputFocus === 'phone' }"> <div
class="container"
:class="{ active: ['phone', 'countryCode'].includes(inputFocus) }"
>
<svg-icon icon-class="mobile" /> <svg-icon icon-class="mobile" />
<div class="inputs"> <div class="inputs">
<input <input
id="countryCode" id="countryCode"
v-model="countryCode" v-model="countryCode"
:placeholder=" :placeholder="
inputFocus === 'phone' ? '' : $t('login.countryCode') inputFocus === 'countryCode' ? '' : $t('login.countryCode')
" "
@focus="inputFocus = 'phone'" @focus="inputFocus = 'countryCode'"
@blur="inputFocus = ''" @blur="inputFocus = ''"
@keyup.enter="login" @keyup.enter="login"
/> />
@ -68,8 +71,8 @@
</div> </div>
<div v-show="mode == 'qrCode'"> <div v-show="mode == 'qrCode'">
<div v-show="qrCodeImage" class="qr-code-container"> <div v-show="qrCodeSvg" class="qr-code-container">
<img :src="qrCodeImage" /> <img :src="qrCodeSvg" loading="lazy" />
</div> </div>
<div class="qr-code-info"> <div class="qr-code-info">
{{ qrCodeInformation }} {{ qrCodeInformation }}
@ -87,14 +90,14 @@
</button> </button>
</div> </div>
<div class="other-login"> <div class="other-login">
<a v-show="mode !== 'email'" @click="changeMode('email')">{{ <!-- <a v-show="mode !== 'email'" @click="changeMode('email')">{{
$t('login.loginWithEmail') $t('login.loginWithEmail')
}}</a> }}</a>
<span v-show="mode === 'qrCode'">|</span> <span v-show="mode === 'qrCode'">|</span>
<a v-show="mode !== 'phone'" @click="changeMode('phone')">{{ <a v-show="mode !== 'phone'" @click="changeMode('phone')">{{
$t('login.loginWithPhone') $t('login.loginWithPhone')
}}</a> }}</a>
<span v-show="mode !== 'qrCode'">|</span> <span v-show="mode !== 'qrCode'">|</span> -->
<a v-show="mode !== 'qrCode'" @click="changeMode('qrCode')"> <a v-show="mode !== 'qrCode'" @click="changeMode('qrCode')">
二维码登录 二维码登录
</a> </a>
@ -135,7 +138,7 @@ export default {
smsCode: '', smsCode: '',
inputFocus: '', inputFocus: '',
qrCodeKey: '', qrCodeKey: '',
qrCodeImage: '', qrCodeSvg: '',
qrCodeCheckInterval: null, qrCodeCheckInterval: null,
qrCodeInformation: '打开网易云音乐APP扫码登录', qrCodeInformation: '打开网易云音乐APP扫码登录',
}; };
@ -233,7 +236,7 @@ export default {
return loginQrCodeKey().then(result => { return loginQrCodeKey().then(result => {
if (result.code === 200) { if (result.code === 200) {
this.qrCodeKey = result.data.unikey; this.qrCodeKey = result.data.unikey;
QRCode.toDataURL( QRCode.toString(
`https://music.163.com/login?codekey=${this.qrCodeKey}`, `https://music.163.com/login?codekey=${this.qrCodeKey}`,
{ {
width: 192, width: 192,
@ -242,10 +245,13 @@ export default {
dark: '#335eea', dark: '#335eea',
light: '#00000000', light: '#00000000',
}, },
type: 'svg',
} }
) )
.then(url => { .then(svg => {
this.qrCodeImage = url; this.qrCodeSvg = `data:image/svg+xml;utf8,${encodeURIComponent(
svg
)}`;
}) })
.catch(err => { .catch(err => {
console.error(err); console.error(err);
@ -258,6 +264,8 @@ export default {
}); });
}, },
checkQrCodeLogin() { checkQrCodeLogin() {
//
clearInterval(this.qrCodeCheckInterval);
this.qrCodeCheckInterval = setInterval(() => { this.qrCodeCheckInterval = setInterval(() => {
if (this.qrCodeKey === '') return; if (this.qrCodeKey === '') return;
loginQrCodeCheck(this.qrCodeKey).then(result => { loginQrCodeCheck(this.qrCodeKey).then(result => {
@ -272,7 +280,7 @@ export default {
clearInterval(this.qrCodeCheckInterval); clearInterval(this.qrCodeCheckInterval);
this.qrCodeInformation = '登录成功,请稍等...'; this.qrCodeInformation = '登录成功,请稍等...';
result.code = 200; result.code = 200;
result.cookie = result.cookie.replace('HTTPOnly', ''); result.cookie = result.cookie.replaceAll(' HTTPOnly', '');
this.handleLoginResponse(result); this.handleLoginResponse(result);
} }
}); });

View file

@ -31,7 +31,11 @@
:class="{ active: user.nickname === activeUser.nickname }" :class="{ active: user.nickname === activeUser.nickname }"
@click="activeUser = user" @click="activeUser = user"
> >
<img class="head" :src="user.avatarUrl | resizeImage" /> <img
class="head"
:src="user.avatarUrl | resizeImage"
loading="lazy"
/>
<div class="nickname"> <div class="nickname">
{{ user.nickname }} {{ user.nickname }}
</div> </div>

View file

@ -32,9 +32,12 @@
<div class="left-side"> <div class="left-side">
<div> <div>
<div v-if="settings.showLyricsTime" class="date">
{{ date }}
</div>
<div class="cover"> <div class="cover">
<div class="cover-container"> <div class="cover-container">
<img :src="imageUrl" /> <img :src="imageUrl" loading="lazy" />
<div <div
class="shadow" class="shadow"
:style="{ backgroundImage: `url(${imageUrl})` }" :style="{ backgroundImage: `url(${imageUrl})` }"
@ -76,6 +79,29 @@
</span> </span>
</div> </div>
</div> </div>
<div class="top-right">
<div class="volume-control">
<button-icon :title="$t('player.mute')" @click.native="mute">
<svg-icon v-show="volume > 0.5" icon-class="volume" />
<svg-icon v-show="volume === 0" icon-class="volume-mute" />
<svg-icon
v-show="volume <= 0.5 && volume !== 0"
icon-class="volume-half"
/>
</button-icon>
<div class="volume-bar">
<vue-slider
v-model="volume"
:min="0"
:max="1"
:interval="0.01"
:drag-on-click="true"
:duration="0"
tooltip="none"
:dot-size="12"
></vue-slider>
</div>
</div>
<div class="buttons"> <div class="buttons">
<button-icon <button-icon
:title="$t('player.like')" :title="$t('player.like')"
@ -87,11 +113,18 @@
" "
/> />
</button-icon> </button-icon>
<button-icon
:title="$t('contextMenu.addToPlaylist')"
@click.native="addToPlaylist"
>
<svg-icon icon-class="plus" />
</button-icon>
<!-- <button-icon @click.native="openMenu" title="Menu" <!-- <button-icon @click.native="openMenu" title="Menu"
><svg-icon icon-class="more" ><svg-icon icon-class="more"
/></button-icon> --> /></button-icon> -->
</div> </div>
</div> </div>
</div>
<div class="progress-bar"> <div class="progress-bar">
<span>{{ formatTrackTime(player.progress) || '0:00' }}</span> <span>{{ formatTrackTime(player.progress) || '0:00' }}</span>
<div class="slider"> <div class="slider">
@ -168,6 +201,28 @@
> >
<svg-icon icon-class="shuffle" /> <svg-icon icon-class="shuffle" />
</button-icon> </button-icon>
<button-icon
v-show="
isShowLyricTypeSwitch &&
$store.state.settings.showLyricsTranslation &&
lyricType === 'translation'
"
:title="$t('player.translationLyric')"
@click.native="switchLyricType"
>
<span class="lyric-switch-icon"></span>
</button-icon>
<button-icon
v-show="
isShowLyricTypeSwitch &&
$store.state.settings.showLyricsTranslation &&
lyricType === 'romaPronunciation'
"
:title="$t('player.PronunciationLyric')"
@click.native="switchLyricType"
>
<span class="lyric-switch-icon"></span>
</button-icon>
</div> </div>
</div> </div>
</div> </div>
@ -182,7 +237,7 @@
> >
<div id="line-1" class="line"></div> <div id="line-1" class="line"></div>
<div <div
v-for="(line, index) in lyricWithTranslation" v-for="(line, index) in lyricToShow"
:id="`line${index}`" :id="`line${index}`"
:key="index" :key="index"
class="line" class="line"
@ -192,7 +247,12 @@
@click="clickLyricLine(line.time)" @click="clickLyricLine(line.time)"
@dblclick="clickLyricLine(line.time, true)" @dblclick="clickLyricLine(line.time, true)"
> >
<span v-if="line.contents[0]">{{ line.contents[0] }}</span> <div class="content">
<span
v-if="line.contents[0]"
@click.right="openLyricMenu($event, line, 0)"
>{{ line.contents[0] }}</span
>
<br /> <br />
<span <span
v-if=" v-if="
@ -200,10 +260,27 @@
$store.state.settings.showLyricsTranslation $store.state.settings.showLyricsTranslation
" "
class="translation" class="translation"
@click.right="openLyricMenu($event, line, 1)"
>{{ line.contents[1] }}</span >{{ line.contents[1] }}</span
> >
</div> </div>
</div> </div>
<ContextMenu v-if="!noLyric" ref="lyricMenu">
<div class="item" @click="copyLyric(false)">{{
$t('contextMenu.copyLyric')
}}</div>
<div
v-if="
rightClickLyric &&
rightClickLyric.contents[1] &&
$store.state.settings.showLyricsTranslation
"
class="item"
@click="copyLyric(true)"
>{{ $t('contextMenu.copyLyricWithTranslation') }}</div
>
</ContextMenu>
</div>
</transition> </transition>
</div> </div>
<div class="close-button" @click="toggleLyrics"> <div class="close-button" @click="toggleLyrics">
@ -211,6 +288,12 @@
<svg-icon icon-class="arrow-down" /> <svg-icon icon-class="arrow-down" />
</button> </button>
</div> </div>
<div class="close-button" style="left: 24px" @click="fullscreen">
<button>
<svg-icon v-if="isFullscreen" icon-class="fullscreen-exit" />
<svg-icon v-else icon-class="fullscreen" />
</button>
</div>
</div> </div>
</transition> </transition>
</template> </template>
@ -221,28 +304,37 @@
import { mapState, mapMutations, mapActions } from 'vuex'; import { mapState, mapMutations, mapActions } from 'vuex';
import VueSlider from 'vue-slider-component'; import VueSlider from 'vue-slider-component';
import ContextMenu from '@/components/ContextMenu.vue';
import { formatTrackTime } from '@/utils/common'; import { formatTrackTime } from '@/utils/common';
import { getLyric } from '@/api/track'; import { getLyric } from '@/api/track';
import { lyricParser } from '@/utils/lyrics'; import { lyricParser, copyLyric } from '@/utils/lyrics';
import ButtonIcon from '@/components/ButtonIcon.vue'; import ButtonIcon from '@/components/ButtonIcon.vue';
import * as Vibrant from 'node-vibrant'; import * as Vibrant from 'node-vibrant/dist/vibrant.worker.min.js';
import Color from 'color'; import Color from 'color';
import { isAccountLoggedIn } from '@/utils/auth';
import { hasListSource, getListSourcePath } from '@/utils/playList'; import { hasListSource, getListSourcePath } from '@/utils/playList';
import locale from '@/locale';
export default { export default {
name: 'Lyrics', name: 'Lyrics',
components: { components: {
VueSlider, VueSlider,
ButtonIcon, ButtonIcon,
ContextMenu,
}, },
data() { data() {
return { return {
lyricsInterval: null, lyricsInterval: null,
lyric: [], lyric: [],
tlyric: [], tlyric: [],
romalyric: [],
lyricType: 'translation', // or 'romaPronunciation'
highlightLyricIndex: -1, highlightLyricIndex: -1,
minimize: true, minimize: true,
background: '', background: '',
date: this.formatTime(new Date()),
isFullscreen: !!document.fullscreenElement,
rightClickLyric: null,
}; };
}, },
computed: { computed: {
@ -250,12 +342,28 @@ export default {
currentTrack() { currentTrack() {
return this.player.currentTrack; return this.player.currentTrack;
}, },
volume: {
get() {
return this.player.volume;
},
set(value) {
this.player.volume = value;
},
},
imageUrl() { imageUrl() {
return this.player.currentTrack?.al?.picUrl + '?param=1024y1024'; return this.player.currentTrack?.al?.picUrl + '?param=1024y1024';
}, },
bgImageUrl() { bgImageUrl() {
return this.player.currentTrack?.al?.picUrl + '?param=512y512'; return this.player.currentTrack?.al?.picUrl + '?param=512y512';
}, },
isShowLyricTypeSwitch() {
return this.romalyric.length > 0 && this.tlyric.length > 0;
},
lyricToShow() {
return this.lyricType === 'translation'
? this.lyricWithTranslation
: this.lyricWithRomaPronunciation;
},
lyricWithTranslation() { lyricWithTranslation() {
let ret = []; let ret = [];
// //
@ -287,6 +395,37 @@ export default {
} }
return ret; return ret;
}, },
lyricWithRomaPronunciation() {
let ret = [];
//
const lyricFiltered = this.lyric.filter(({ content }) =>
Boolean(content)
);
// content
if (lyricFiltered.length) {
lyricFiltered.forEach(l => {
const { rawTime, time, content } = l;
const lyricItem = { time, content, contents: [content] };
const sameTimeRomaLyric = this.romalyric.find(
({ rawTime: tLyricRawTime }) => tLyricRawTime === rawTime
);
if (sameTimeRomaLyric) {
const { content: romaLyricContent } = sameTimeRomaLyric;
if (content) {
lyricItem.contents.push(romaLyricContent);
}
}
ret.push(lyricItem);
});
} else {
ret = lyricFiltered.map(({ time, content }) => ({
time,
content,
contents: [content],
}));
}
return ret;
},
lyricFontSize() { lyricFontSize() {
return { return {
fontSize: `${this.$store.state.settings.lyricFontSize || 28}px`, fontSize: `${this.$store.state.settings.lyricFontSize || 28}px`,
@ -325,13 +464,71 @@ export default {
created() { created() {
this.getLyric(); this.getLyric();
this.getCoverColor(); this.getCoverColor();
this.initDate();
document.addEventListener('keydown', e => {
if (e.key === 'F11') {
e.preventDefault();
this.fullscreen();
}
});
document.addEventListener('fullscreenchange', () => {
this.isFullscreen = !!document.fullscreenElement;
});
},
beforeDestroy: function () {
if (this.timer) {
clearInterval(this.timer);
}
}, },
destroyed() { destroyed() {
clearInterval(this.lyricsInterval); clearInterval(this.lyricsInterval);
}, },
methods: { methods: {
...mapMutations(['toggleLyrics']), ...mapMutations(['toggleLyrics', 'updateModal']),
...mapActions(['likeATrack']), ...mapActions(['likeATrack']),
initDate() {
var _this = this;
clearInterval(this.timer);
this.timer = setInterval(function () {
_this.date = _this.formatTime(new Date());
}, 1000);
},
formatTime(value) {
let hour = value.getHours().toString();
let minute = value.getMinutes().toString();
let second = value.getSeconds().toString();
return (
hour.padStart(2, '0') +
':' +
minute.padStart(2, '0') +
':' +
second.padStart(2, '0')
);
},
fullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
},
addToPlaylist() {
if (!isAccountLoggedIn()) {
this.showToast(locale.t('toast.needToLogin'));
return;
}
this.$store.dispatch('fetchLikedPlaylist');
this.updateModal({
modalName: 'addTrackToPlaylistModal',
key: 'show',
value: true,
});
this.updateModal({
modalName: 'addTrackToPlaylistModal',
key: 'selectedTrackID',
value: this.currentTrack?.id,
});
},
playPrevTrack() { playPrevTrack() {
this.player.playPrevTrack(); this.player.playPrevTrack();
}, },
@ -351,9 +548,10 @@ export default {
if (!data?.lrc?.lyric) { if (!data?.lrc?.lyric) {
this.lyric = []; this.lyric = [];
this.tlyric = []; this.tlyric = [];
this.romalyric = [];
return false; return false;
} else { } else {
let { lyric, tlyric } = lyricParser(data); let { lyric, tlyric, romalyric } = lyricParser(data);
lyric = lyric.filter( lyric = lyric.filter(
l => !/^作(词|曲)\s*(:|)\s*无$/.exec(l.content) l => !/^作(词|曲)\s*(:|)\s*无$/.exec(l.content)
); );
@ -373,15 +571,27 @@ export default {
if (lyric.length === 1 && includeAM) { if (lyric.length === 1 && includeAM) {
this.lyric = []; this.lyric = [];
this.tlyric = []; this.tlyric = [];
this.romalyric = [];
return false; return false;
} else { } else {
this.lyric = lyric; this.lyric = lyric;
this.tlyric = tlyric; this.tlyric = tlyric;
this.romalyric = romalyric;
if (tlyric.length * romalyric.length > 0) {
this.lyricType = 'translation';
} else {
this.lyricType =
lyric.length > 0 ? 'translation' : 'romaPronunciation';
}
return true; return true;
} }
} }
}); });
}, },
switchLyricType() {
this.lyricType =
this.lyricType === 'translation' ? 'romaPronunciation' : 'translation';
},
formatTrackTime(value) { formatTrackTime(value) {
return formatTrackTime(value); return formatTrackTime(value);
}, },
@ -400,9 +610,24 @@ export default {
this.player.play(); this.player.play();
} }
}, },
openLyricMenu(e, lyric, idx) {
this.rightClickLyric = { ...lyric, idx };
this.$refs.lyricMenu.openMenu(e);
e.preventDefault();
},
copyLyric(withTranslation) {
if (this.rightClickLyric) {
const idx = this.rightClickLyric.idx;
if (!withTranslation) {
copyLyric(this.rightClickLyric.contents[idx]);
} else {
copyLyric(this.rightClickLyric.contents.join(' '));
}
}
},
setLyricsInterval() { setLyricsInterval() {
this.lyricsInterval = setInterval(() => { this.lyricsInterval = setInterval(() => {
const progress = this.player.seek() ?? 0; const progress = this.player.seek(null, false) ?? 0;
let oldHighlightLyricIndex = this.highlightLyricIndex; let oldHighlightLyricIndex = this.highlightLyricIndex;
this.highlightLyricIndex = this.lyric.findIndex((l, index) => { this.highlightLyricIndex = this.lyric.findIndex((l, index) => {
const nextLyric = this.lyric[index + 1]; const nextLyric = this.lyric[index + 1];
@ -431,13 +656,13 @@ export default {
}, },
getCoverColor() { getCoverColor() {
if (this.settings.lyricsBackground !== true) return; if (this.settings.lyricsBackground !== true) return;
const cover = this.currentTrack.al?.picUrl + '?param=1024y1024'; const cover = this.currentTrack.al?.picUrl + '?param=256y256';
Vibrant.from(cover, { colorCount: 1 }) Vibrant.from(cover, { colorCount: 1 })
.getPalette() .getPalette()
.then(palette => { .then(palette => {
const orignColor = Color.rgb(palette.DarkMuted._rgb); const originColor = Color.rgb(palette.DarkMuted._rgb);
const color = orignColor.darken(0.1).rgb().string(); const color = originColor.darken(0.1).rgb().string();
const color2 = orignColor.lighten(0.28).rotate(-30).rgb().string(); const color2 = originColor.lighten(0.28).rotate(-30).rgb().string();
this.background = `linear-gradient(to top left, ${color}, ${color2})`; this.background = `linear-gradient(to top left, ${color}, ${color2})`;
}); });
}, },
@ -447,6 +672,9 @@ export default {
getListPath() { getListPath() {
return getListSourcePath(); return getListSourcePath();
}, },
mute() {
this.player.mute();
},
}, },
}; };
</script> </script>
@ -534,6 +762,20 @@ export default {
z-index: 1; z-index: 1;
.date {
max-width: 54vh;
margin: 24px 0;
color: var(--color-text);
text-align: center;
font-size: 4rem;
font-weight: 600;
opacity: 0.88;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.controls { .controls {
max-width: 54vh; max-width: 54vh;
margin-top: 24px; margin-top: 24px;
@ -564,6 +806,19 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.top-right {
display: flex;
justify-content: space-between;
.volume-control {
margin: 0 10px;
display: flex;
align-items: center;
.volume-bar {
width: 84px;
}
}
.buttons { .buttons {
display: flex; display: flex;
align-items: center; align-items: center;
@ -578,6 +833,7 @@ export default {
} }
} }
} }
}
.progress-bar { .progress-bar {
margin-top: 22px; margin-top: 22px;
@ -639,6 +895,12 @@ export default {
width: 22px; width: 22px;
} }
} }
.lyric-switch-icon {
color: var(--color-text);
font-size: 14px;
line-height: 14px;
opacity: 0.88;
}
} }
} }
} }
@ -697,9 +959,12 @@ export default {
&:hover { &:hover {
background: var(--color-secondary-bg-for-transparent); background: var(--color-secondary-bg-for-transparent);
} }
&:active {
.content {
transform-origin: center left;
transform: scale(0.95); transform: scale(0.95);
} transition: all 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
user-select: none;
span { span {
opacity: 0.28; opacity: 0.28;
@ -710,7 +975,8 @@ export default {
span.translation { span.translation {
opacity: 0.2; opacity: 0.2;
font-size: 0.95em; font-size: 0.925em;
}
} }
} }
@ -722,15 +988,16 @@ export default {
margin-top: 0.1em; margin-top: 0.1em;
} }
.highlight span { .highlight div.content {
transform: scale(1);
span {
opacity: 0.98; opacity: 0.98;
display: inline-block; display: inline-block;
font-size: 1.25em;
} }
.highlight span.translation { span.translation {
opacity: 0.65; opacity: 0.65;
font-size: 1.1em; }
} }
} }

View file

@ -11,11 +11,14 @@
}}</router-link> }}</router-link>
- -
{{ mv.data.name }} {{ mv.data.name }}
<div class="like-button"> <div class="buttons">
<button-icon @click.native="likeMV"> <button-icon class="button" @click.native="likeMV">
<svg-icon v-if="mv.subed" icon-class="heart-solid"></svg-icon> <svg-icon v-if="mv.subed" icon-class="heart-solid"></svg-icon>
<svg-icon v-else icon-class="heart"></svg-icon> <svg-icon v-else icon-class="heart"></svg-icon>
</button-icon> </button-icon>
<button-icon class="button" @click.native="openMenu">
<svg-icon icon-class="more"></svg-icon>
</button-icon>
</div> </div>
</div> </div>
<div class="info"> <div class="info">
@ -28,6 +31,14 @@
<div class="section-title">{{ $t('mv.moreVideo') }}</div> <div class="section-title">{{ $t('mv.moreVideo') }}</div>
<MvRow :mvs="simiMvs" /> <MvRow :mvs="simiMvs" />
</div> </div>
<ContextMenu ref="mvMenu">
<div class="item" @click="copyUrl(mv.data.id)">{{
$t('contextMenu.copyUrl')
}}</div>
<div class="item" @click="openInBrowser(mv.data.id)">{{
$t('contextMenu.openInBrowser')
}}</div>
</ContextMenu>
</div> </div>
</template> </template>
@ -40,6 +51,7 @@ import '@/assets/css/plyr.css';
import Plyr from 'plyr'; import Plyr from 'plyr';
import ButtonIcon from '@/components/ButtonIcon.vue'; import ButtonIcon from '@/components/ButtonIcon.vue';
import ContextMenu from '@/components/ContextMenu.vue';
import MvRow from '@/components/MvRow.vue'; import MvRow from '@/components/MvRow.vue';
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
@ -48,6 +60,7 @@ export default {
components: { components: {
MvRow, MvRow,
ButtonIcon, ButtonIcon,
ContextMenu,
}, },
beforeRouteUpdate(to, from, next) { beforeRouteUpdate(to, from, next) {
this.getData(to.params.id); this.getData(to.params.id);
@ -127,6 +140,23 @@ export default {
if (data.code === 200) this.mv.subed = !this.mv.subed; if (data.code === 200) this.mv.subed = !this.mv.subed;
}); });
}, },
openMenu(e) {
this.$refs.mvMenu.openMenu(e);
},
copyUrl(id) {
let showToast = this.showToast;
this.$copyText(`https://music.163.com/#/mv?id=${id}`)
.then(function () {
showToast(locale.t('toast.copied'));
})
.catch(error => {
showToast(`${locale.t('toast.copyFailed')}${error}`);
});
},
openInBrowser(id) {
const url = `https://music.163.com/#/mv?id=${id}`;
window.open(url);
},
}, },
}; };
</script> </script>
@ -181,8 +211,11 @@ export default {
} }
} }
.like-button { .buttons {
display: inline-block; display: inline-block;
.button {
display: inline-block;
}
.svg-icon { .svg-icon {
height: 18px; height: 18px;
width: 18px; width: 18px;

View file

@ -57,7 +57,9 @@ export default {
this.player.current + 1, this.player.current + 1,
this.player.current + 100 this.player.current + 100
); );
return this.tracks.filter(t => trackIDs.includes(t.id)); return trackIDs
.map(tid => this.tracks.find(t => t.id === tid))
.filter(t => t);
}, },
playNextList() { playNextList() {
return this.player.playNextList; return this.player.playNextList;

View file

@ -139,9 +139,12 @@
<div v-if="isLikeSongsPage" class="user-info"> <div v-if="isLikeSongsPage" class="user-info">
<h1> <h1>
<img class="avatar" :src="data.user.avatarUrl | resizeImage" />{{ <img
data.user.nickname class="avatar"
}}{{ $t('library.sLikedSongs') }} :src="data.user.avatarUrl | resizeImage"
loading="lazy"
/>
{{ data.user.nickname }}{{ $t('library.sLikedSongs') }}
</h1> </h1>
<div class="search-box-likepage" @click="searchInPlaylist()"> <div class="search-box-likepage" @click="searchInPlaylist()">
<div class="container" :class="{ active: inputFocus }"> <div class="container" :class="{ active: inputFocus }">
@ -315,6 +318,10 @@ const specialPlaylist = {
name: '一周原创发现', name: '一周原创发现',
gradient: 'gradient-blue-purple', gradient: 'gradient-blue-purple',
}, },
2829883282: {
name: '华语私人雷达',
gradient: 'gradient-yellow-red',
},
3136952023: { 3136952023: {
name: '私人雷达', name: '私人雷达',
gradient: 'gradient-radar', gradient: 'gradient-radar',

File diff suppressed because one or more lines are too long

View file

@ -56,6 +56,23 @@ module.exports = {
symbolId: 'icon-[name]', symbolId: 'icon-[name]',
}) })
.end(); .end();
config.module
.rule('napi')
.test(/\.node$/)
.use('node-loader')
.loader('node-loader')
.end();
config.module
.rule('webpack4_es_fallback')
.test(/\.js$/)
.include.add(/node_modules/)
.end()
.use('esbuild-loader')
.loader('esbuild-loader')
.options({ target: 'es2015', format: "cjs" })
.end();
// LimitChunkCountPlugin 可以通过合并块来对块进行后期处理。用以解决 chunk 包太多的问题 // LimitChunkCountPlugin 可以通过合并块来对块进行后期处理。用以解决 chunk 包太多的问题
config.plugin('chunkPlugin').use(webpack.optimize.LimitChunkCountPlugin, [ config.plugin('chunkPlugin').use(webpack.optimize.LimitChunkCountPlugin, [
{ {
@ -69,10 +86,7 @@ module.exports = {
// electron-builder的配置文件 // electron-builder的配置文件
electronBuilder: { electronBuilder: {
nodeIntegration: true, nodeIntegration: true,
externals: [ externals: ['@unblockneteasemusic/rust-napi'],
'@unblockneteasemusic/server',
'@unblockneteasemusic/server/src/consts',
],
builderOptions: { builderOptions: {
productName: 'YesPlayMusic', productName: 'YesPlayMusic',
copyright: 'Copyright © YesPlayMusic', copyright: 'Copyright © YesPlayMusic',
@ -165,6 +179,16 @@ module.exports = {
'jsbi', 'jsbi',
path.join(__dirname, 'node_modules/jsbi/dist/jsbi-cjs.js') path.join(__dirname, 'node_modules/jsbi/dist/jsbi-cjs.js')
); );
config.module
.rule('webpack4_es_fallback')
.test(/\.js$/)
.include.add(/node_modules/)
.end()
.use('esbuild-loader')
.loader('esbuild-loader')
.options({ target: 'es2015', format: "cjs" })
.end();
}, },
// 渲染线程的配置文件 // 渲染线程的配置文件
chainWebpackRendererProcess: config => { chainWebpackRendererProcess: config => {

9584
yarn.lock

File diff suppressed because it is too large Load diff