feat: updates

This commit is contained in:
qier222 2023-01-07 14:39:03 +08:00
parent 884f3df41a
commit c6c59b2cd9
No known key found for this signature in database
84 changed files with 3531 additions and 2616 deletions

View file

@ -1,3 +1,3 @@
ELECTRON_WEB_SERVER_PORT = 42710 ELECTRON_WEB_SERVER_PORT = 42710
ELECTRON_DEV_NETEASE_API_PORT = 3000 ELECTRON_DEV_NETEASE_API_PORT = 30001
VITE_APP_NETEASE_API_URL = /netease VITE_APP_NETEASE_API_URL = /netease

View file

@ -9,6 +9,7 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:react/recommended', 'plugin:react/recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
], ],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
@ -20,8 +21,6 @@ module.exports = {
}, },
plugins: ['react', '@typescript-eslint', 'react-hooks'], plugins: ['react', '@typescript-eslint', 'react-hooks'],
rules: { rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-inferrable-types': 'off', '@typescript-eslint/no-inferrable-types': 'off',
}, },

View file

@ -71,20 +71,20 @@ jobs:
- name: Upload Artifact (macOS) - name: Upload Artifact (macOS)
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: YesPlayMusic-mac name: R3Play-mac
path: release/*-universal.dmg path: release/*-universal.dmg
if-no-files-found: ignore if-no-files-found: ignore
- name: Upload Artifact (Windows) - name: Upload Artifact (Windows)
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: YesPlayMusic-win name: R3Play-win
path: release/*x64-Setup.exe path: release/*x64-Setup.exe
if-no-files-found: ignore if-no-files-found: ignore
- name: Upload Artifact (Linux) - name: Upload Artifact (Linux)
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: YesPlayMusic-linux name: R3Play-linux
path: release/*.AppImage path: release/*.AppImage
if-no-files-found: ignore if-no-files-found: ignore

View file

@ -1,6 +1,6 @@
{ {
"name": "yesplamusic", "name": "r3play",
"productName": "YesPlayMusic-alpha", "productName": "R3Play",
"private": true, "private": true,
"version": "2.0.0", "version": "2.0.0",
"description": "A nifty third-party NetEase Music player", "description": "A nifty third-party NetEase Music player",
@ -9,7 +9,7 @@
"author": "qier222 <qier222@outlook.com>", "author": "qier222 <qier222@outlook.com>",
"repository": "github:qier222/YesPlayMusic", "repository": "github:qier222/YesPlayMusic",
"engines": { "engines": {
"node": "^14.13.1 || >=16.0.0" "node": ">=16.0.0"
}, },
"packageManager": "pnpm@7.0.0", "packageManager": "pnpm@7.0.0",
"scripts": { "scripts": {
@ -19,16 +19,18 @@
"pack": "turbo run build && turbo run pack", "pack": "turbo run build && turbo run pack",
"pack:test": "turbo run build && turbo run pack:test", "pack:test": "turbo run build && turbo run pack:test",
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel", "dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
"lint": "turbo run lint", "lint": "eslint .",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"", "format": "prettier --write \"**/*.{ts,tsx,mjs,js,jsx,md,css}\"",
"storybook": "pnpm -F web storybook", "storybook": "pnpm -F web storybook",
"storybook:build": "pnpm -F web storybook:build" "storybook:build": "pnpm -F web storybook:build"
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.21.0", "eslint": "^8.31.0",
"prettier": "^2.7.1", "prettier": "^2.8.1",
"turbo": "^1.6.1", "turbo": "^1.6.3",
"typescript": "^4.7.4" "typescript": "^4.9.4",
"tsx": "^3.12.1",
"prettier-plugin-tailwindcss": "^0.2.1"
} }
} }

View file

@ -7,7 +7,7 @@ const pkg = require('./package.json')
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '') const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
module.exports = { module.exports = {
appId: 'com.qier222.yesplaymusic.alpha', appId: 'app.r3play',
productName: pkg.productName, productName: pkg.productName,
copyright: 'Copyright © 2022 qier222', copyright: 'Copyright © 2022 qier222',
asar: true, asar: true,

View file

@ -3,7 +3,8 @@ import { app } from 'electron'
import fs from 'fs' import fs from 'fs'
import SQLite3 from 'better-sqlite3' import SQLite3 from 'better-sqlite3'
import log from './log' import log from './log'
import { createFileIfNotExist, dirname, isProd } from './utils' import { createFileIfNotExist, dirname } from './utils'
import { isProd } from './env'
import pkg from '../../../package.json' import pkg from '../../../package.json'
import { compare, validate } from 'compare-versions' import { compare, validate } from 'compare-versions'
import os from 'os' import os from 'os'

View file

@ -0,0 +1,6 @@
export const isDev = process.env.NODE_ENV === 'development'
export const isProd = process.env.NODE_ENV === 'production'
export const isWindows = process.platform === 'win32'
export const isMac = process.platform === 'darwin'
export const isLinux = process.platform === 'linux'
export const appName = 'R3Play'

View file

@ -1,12 +1,7 @@
import './preload' // must be first import './preload' // must be first
import './sentry' import './sentry'
import './server' import './server'
import { import { BrowserWindow, BrowserWindowConstructorOptions, app, shell } from 'electron'
BrowserWindow,
BrowserWindowConstructorOptions,
app,
shell,
} from 'electron'
import { release } from 'os' import { release } from 'os'
import { join } from 'path' import { join } from 'path'
import log from './log' import log from './log'
@ -15,7 +10,7 @@ import { createTray, YPMTray } from './tray'
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import { createTaskbar, Thumbar } from './windowsTaskbar' import { createTaskbar, Thumbar } from './windowsTaskbar'
import { createMenu } from './menu' import { createMenu } from './menu'
import { isDev, isWindows, isLinux, isMac } from './utils' import { isDev, isWindows, isLinux, isMac, appName } from './env'
import store from './store' import store from './store'
// import './surrealdb' // import './surrealdb'
// import Airplay from './airplay' // import Airplay from './airplay'
@ -81,7 +76,7 @@ class Main {
createWindow() { createWindow() {
const options: BrowserWindowConstructorOptions = { const options: BrowserWindowConstructorOptions = {
title: 'YesPlayMusic', title: appName,
webPreferences: { webPreferences: {
preload: join(__dirname, 'rendererPreload.js'), preload: join(__dirname, 'rendererPreload.js'),
}, },
@ -93,6 +88,7 @@ class Main {
trafficLightPosition: { x: 24, y: 24 }, trafficLightPosition: { x: 24, y: 24 },
frame: false, frame: false,
transparent: true, transparent: true,
backgroundColor: 'rgba(0, 0, 0, 0)',
show: false, show: false,
} }
if (store.get('window')) { if (store.get('window')) {
@ -132,35 +128,31 @@ class Main {
return headers return headers
} }
this.win.webContents.session.webRequest.onBeforeSendHeaders( this.win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
(details, callback) => { const { requestHeaders, url } = details
const { requestHeaders, url } = details addCORSHeaders(requestHeaders)
addCORSHeaders(requestHeaders)
// 不加这几个 header 的话,使用 axios 加载 YouTube 音频会很慢 // 不加这几个 header 的话,使用 axios 加载 YouTube 音频会很慢
if (url.includes('googlevideo.com')) { if (url.includes('googlevideo.com')) {
requestHeaders['Sec-Fetch-Mode'] = 'no-cors' requestHeaders['Sec-Fetch-Mode'] = 'no-cors'
requestHeaders['Sec-Fetch-Dest'] = 'audio' requestHeaders['Sec-Fetch-Dest'] = 'audio'
requestHeaders['Range'] = 'bytes=0-' requestHeaders['Range'] = 'bytes=0-'
}
callback({ requestHeaders })
} }
)
this.win.webContents.session.webRequest.onHeadersReceived( callback({ requestHeaders })
(details, callback) => { })
const { responseHeaders, url } = details
if (url.includes('sentry.io')) { this.win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({ responseHeaders }) const { responseHeaders, url } = details
return if (url.includes('sentry.io')) {
}
if (responseHeaders) {
addCORSHeaders(responseHeaders)
}
callback({ responseHeaders }) callback({ responseHeaders })
return
} }
) if (responseHeaders) {
addCORSHeaders(responseHeaders)
}
callback({ responseHeaders })
})
} }
handleWindowEvents() { handleWindowEvents() {
@ -176,13 +168,11 @@ class Main {
}) })
this.win.on('enter-full-screen', () => { this.win.on('enter-full-screen', () => {
this.win && this.win && this.win.webContents.send(IpcChannels.FullscreenStateChange, true)
this.win.webContents.send(IpcChannels.FullscreenStateChange, true)
}) })
this.win.on('leave-full-screen', () => { this.win.on('leave-full-screen', () => {
this.win && this.win && this.win.webContents.send(IpcChannels.FullscreenStateChange, false)
this.win.webContents.send(IpcChannels.FullscreenStateChange, false)
}) })
// Save window position // Save window position

View file

@ -7,7 +7,7 @@
import log from 'electron-log' import log from 'electron-log'
import pc from 'picocolors' import pc from 'picocolors'
import { isDev } from './utils' import { isDev } from './env'
Object.assign(console, log.functions) Object.assign(console, log.functions)
log.variables.process = 'main' log.variables.process = 'main'

View file

@ -6,7 +6,8 @@ import {
MenuItemConstructorOptions, MenuItemConstructorOptions,
shell, shell,
} from 'electron' } from 'electron'
import { logsPath, isMac } from './utils' import { isMac } from './env'
import { logsPath } from './utils'
import { exec } from 'child_process' import { exec } from 'child_process'
export const createMenu = (win: BrowserWindow) => { export const createMenu = (win: BrowserWindow) => {

View file

@ -1,10 +1,10 @@
import log from './log' import log from './log'
import { app } from 'electron' import { app } from 'electron'
import { isDev } from './env'
import { import {
createDirIfNotExist, createDirIfNotExist,
devUserDataPath,
isDev,
portableUserDataPath, portableUserDataPath,
devUserDataPath,
} from './utils' } from './utils'
if (isDev) { if (isDev) {

View file

@ -1,15 +1,15 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import { isLinux, isMac, isProd, isWindows } from './utils' import { isLinux, isMac, isProd, isWindows } from './env'
const { contextBridge, ipcRenderer } = require('electron') const { contextBridge, ipcRenderer } = require('electron')
if (isProd) { // if (isProd) {
const log = require('electron-log') // const log = require('electron-log')
log.transports.file.level = 'info' // log.transports.file.level = 'info'
log.transports.ipc.level = false // log.transports.ipc.level = false
log.variables.process = 'renderer' // log.variables.process = 'renderer'
contextBridge.exposeInMainWorld('log', log) // contextBridge.exposeInMainWorld('log', log)
} // }
contextBridge.exposeInMainWorld('ipcRenderer', { contextBridge.exposeInMainWorld('ipcRenderer', {
invoke: ipcRenderer.invoke, invoke: ipcRenderer.invoke,
@ -27,8 +27,7 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
contextBridge.exposeInMainWorld('env', { contextBridge.exposeInMainWorld('env', {
isElectron: true, isElectron: true,
isEnableTitlebar: isEnableTitlebar: process.platform === 'win32' || process.platform === 'linux',
process.platform === 'win32' || process.platform === 'linux',
isLinux, isLinux,
isMac, isMac,
isWindows, isWindows,

View file

@ -1,12 +1,13 @@
import * as Sentry from '@sentry/electron' import * as Sentry from '@sentry/electron'
import pkg from '../../../package.json' import pkg from '../../../package.json'
import { appName } from './env'
import log from './log' import log from './log'
log.info(`[sentry] sentry initializing`) log.info(`[sentry] sentry initializing`)
Sentry.init({ Sentry.init({
dsn: 'https://2aaaa67f1c3d4d6baefafa5d58fcf340@o436528.ingest.sentry.io/6274637', dsn: 'https://2aaaa67f1c3d4d6baefafa5d58fcf340@o436528.ingest.sentry.io/6274637',
release: `yesplaymusic@${pkg.version}`, release: `${appName}@${pkg.version}`,
environment: process.env.NODE_ENV, environment: process.env.NODE_ENV,
// Set tracesSampleRate to 1.0 to capture 100% // Set tracesSampleRate to 1.0 to capture 100%

View file

@ -10,7 +10,7 @@ import fs from 'fs'
import { app } from 'electron' import { app } from 'electron'
import type { FetchAudioSourceResponse } from '@/shared/api/Track' import type { FetchAudioSourceResponse } from '@/shared/api/Track'
import { APIs as CacheAPIs } from '@/shared/CacheAPIs' import { APIs as CacheAPIs } from '@/shared/CacheAPIs'
import { isProd } from './utils' import { appName, isProd } from './env'
import { APIs } from '@/shared/CacheAPIs' import { APIs } from '@/shared/CacheAPIs'
import history from 'connect-history-api-fallback' import history from 'connect-history-api-fallback'
import { db, Tables } from './db' import { db, Tables } from './db'
@ -19,7 +19,7 @@ class Server {
port = Number( port = Number(
isProd isProd
? process.env.ELECTRON_WEB_SERVER_PORT || 42710 ? process.env.ELECTRON_WEB_SERVER_PORT || 42710
: process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000 : process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001
) )
app = express() app = express()
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
@ -39,9 +39,7 @@ class Server {
neteaseHandler() { neteaseHandler() {
Object.entries(this.netease).forEach(([name, handler]: [string, any]) => { Object.entries(this.netease).forEach(([name, handler]: [string, any]) => {
// 例外处理 // 例外处理
if ( if (['serveNcmApi', 'getModulesDefinitions', APIs.SongUrl].includes(name)) {
['serveNcmApi', 'getModulesDefinitions', APIs.SongUrl].includes(name)
) {
return return
} }
@ -103,7 +101,7 @@ class Server {
{ {
source: cache.source, source: cache.source,
id: cache.id, id: cache.id,
url: `http://127.0.0.1:42710/yesplaymusic/audio/${audioFileName}`, url: `http://127.0.0.1:42710/${appName.toLowerCase()}/audio/${audioFileName}`,
br: cache.br, br: cache.br,
size: 0, size: 0,
md5: '', md5: '',
@ -137,9 +135,7 @@ class Server {
} }
} }
const getFromNetease = async ( const getFromNetease = async (req: Request): Promise<FetchAudioSourceResponse | undefined> => {
req: Request
): Promise<FetchAudioSourceResponse | undefined> => {
try { try {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const getSongUrl = (require('NeteaseCloudMusicApi') as any).song_url const getSongUrl = (require('NeteaseCloudMusicApi') as any).song_url
@ -155,12 +151,10 @@ class Server {
// const unmExecutor = new UNM.Executor() // const unmExecutor = new UNM.Executor()
const getFromUNM = async (id: number, req: Request) => { const getFromUNM = async (id: number, req: Request) => {
log.debug('[server] Fetching audio url from UNM') log.debug('[server] Fetching audio url from UNM')
let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) }) let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) })?.songs?.[0]
?.songs?.[0]
if (!track) { if (!track) {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const getSongDetail = (require('NeteaseCloudMusicApi') as any) const getSongDetail = (require('NeteaseCloudMusicApi') as any).song_detail
.song_detail
track = await getSongDetail({ ...req.query, cookie: req.cookies }) track = await getSongDetail({ ...req.query, cookie: req.cookies })
} }
@ -184,14 +178,9 @@ class Server {
const sourceList = ['ytdl'] const sourceList = ['ytdl']
const context = {} const context = {}
const matchedAudio = await unmExecutor.search( const matchedAudio = await unmExecutor.search(sourceList, trackForUNM, context)
sourceList,
trackForUNM,
context
)
const retrievedSong = await unmExecutor.retrieve(matchedAudio, context) const retrievedSong = await unmExecutor.retrieve(matchedAudio, context)
const source = const source = retrievedSong.source === 'ytdl' ? 'youtube' : retrievedSong.source
retrievedSong.source === 'ytdl' ? 'youtube' : retrievedSong.source
if (retrievedSong.url) { if (retrievedSong.url) {
log.debug( log.debug(
`[server] UMN match: ${matchedAudio.song?.name} (https://youtube.com/v/${matchedAudio.song?.id})` `[server] UMN match: ${matchedAudio.song?.name} (https://youtube.com/v/${matchedAudio.song?.id})`
@ -291,45 +280,38 @@ class Server {
cacheAudioHandler() { cacheAudioHandler() {
this.app.get( this.app.get(
'/yesplaymusic/audio/:filename', `/${appName.toLowerCase()}/audio/:filename`,
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
cache.getAudio(req.params.filename, res) cache.getAudio(req.params.filename, res)
} }
) )
this.app.post( this.app.post(`/${appName.toLowerCase()}/audio/:id`, async (req: Request, res: Response) => {
'/yesplaymusic/audio/:id', const id = Number(req.params.id)
async (req: Request, res: Response) => { const { url } = req.query
const id = Number(req.params.id) if (isNaN(id)) {
const { url } = req.query return res.status(400).send({ error: 'Invalid param id' })
if (isNaN(id)) {
return res.status(400).send({ error: 'Invalid param id' })
}
if (!url) {
return res.status(400).send({ error: 'Invalid query url' })
}
if (
!req.files ||
Object.keys(req.files).length === 0 ||
!req.files.file
) {
return res.status(400).send('No audio were uploaded.')
}
if ('length' in req.files.file) {
return res.status(400).send('Only can upload one audio at a time.')
}
try {
await cache.setAudio(req.files.file.data, {
id,
url: String(req.query.url) || '',
})
res.status(200).send('Audio cached!')
} catch (error) {
res.status(500).send({ error })
}
} }
) if (!url) {
return res.status(400).send({ error: 'Invalid query url' })
}
if (!req.files || Object.keys(req.files).length === 0 || !req.files.file) {
return res.status(400).send('No audio were uploaded.')
}
if ('length' in req.files.file) {
return res.status(400).send('Only can upload one audio at a time.')
}
try {
await cache.setAudio(req.files.file.data, {
id,
url: String(req.query.url) || '',
})
res.status(200).send('Audio cached!')
} catch (error) {
res.status(500).send({ error })
}
})
} }
listen() { listen() {

View file

@ -9,6 +9,7 @@ import {
} from 'electron' } from 'electron'
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import { RepeatMode } from '@/shared/playerDataTypes' import { RepeatMode } from '@/shared/playerDataTypes'
import { appName } from './env'
const iconDirRoot = const iconDirRoot =
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
@ -134,7 +135,7 @@ class YPMTrayImpl implements YPMTray {
this._contextMenu = Menu.buildFromTemplate(this._template) this._contextMenu = Menu.buildFromTemplate(this._template)
this._updateContextMenu() this._updateContextMenu()
this.setTooltip('YesPlayMusic') this.setTooltip(appName)
this._tray.on('click', () => win.show()) this._tray.on('click', () => win.show())
} }

View file

@ -2,17 +2,13 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import os from 'os' import os from 'os'
import pkg from '../../../package.json' import pkg from '../../../package.json'
import { appName, isDev } from './env'
export const isDev = process.env.NODE_ENV === 'development'
export const isProd = process.env.NODE_ENV === 'production'
export const isWindows = process.platform === 'win32'
export const isMac = process.platform === 'darwin'
export const isLinux = process.platform === 'linux'
export const dirname = isDev ? process.cwd() : __dirname export const dirname = isDev ? process.cwd() : __dirname
export const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData') export const devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData')
export const portableUserDataPath = path.resolve( export const portableUserDataPath = path.resolve(
process.env.PORTABLE_EXECUTABLE_DIR || '', process.env.PORTABLE_EXECUTABLE_DIR || '',
'./YesPlayMusic-UserData' `./${appName.toLowerCase()}-UserData`
) )
export const logsPath = { export const logsPath = {
linux: `~/.config/${pkg.productName}/logs`, linux: `~/.config/${pkg.productName}/logs`,

View file

@ -1,19 +1,17 @@
{ {
"name": "desktop", "name": "desktop",
"productName": "YesPlayMusic-alpha", "productName": "R3Play",
"private": true, "private": true,
"version": "2.0.0", "version": "2.0.0",
"main": "./main/index.js", "main": "./main/index.js",
"author": "*", "author": "*",
"scripts": { "scripts": {
"post-install": "node scripts/build.sqlite3.js", "post-install": "tsx scripts/build.sqlite3.ts",
"dev": "node scripts/build.main.mjs --watch", "dev": "tsx scripts/build.main.ts --watch",
"build": "node scripts/build.main.mjs", "build": "tsx scripts/build.main.ts",
"pack": "electron-builder build -c .electron-builder.config.js", "pack": "electron-builder build -c .electron-builder.config.js",
"pack:test": "electron-builder build -c .electron-builder.config.js --publish never --mac --dir --arm64", "pack:test": "electron-builder build -c .electron-builder.config.js --publish never --mac --dir --arm64",
"test:types": "tsc --noEmit --project ./tsconfig.json", "test:types": "tsc --noEmit --project ./tsconfig.json",
"lint": "eslint --ext .ts,.js ./",
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
@ -23,48 +21,45 @@
}, },
"dependencies": { "dependencies": {
"@sentry/electron": "^3.0.7", "@sentry/electron": "^3.0.7",
"NeteaseCloudMusicApi": "^4.6.7", "NeteaseCloudMusicApi": "^4.8.4",
"better-sqlite3": "7.6.2", "better-sqlite3": "8.0.1",
"change-case": "^4.1.2", "change-case": "^4.1.2",
"chromecast-api": "^0.4.0",
"compare-versions": "^4.1.3", "compare-versions": "^4.1.3",
"connect-history-api-fallback": "^2.0.0", "connect-history-api-fallback": "^2.0.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"electron-log": "^4.4.8", "electron-log": "^4.4.8",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"express": "^4.18.1", "express": "^4.18.2",
"fast-folder-size": "^1.7.0", "fast-folder-size": "^1.7.1",
"pretty-bytes": "^6.0.0", "pretty-bytes": "^6.0.0",
"type-fest": "^3.0.0", "type-fest": "^3.5.0",
"zx": "^7.0.8" "zx": "^7.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.0", "@types/better-sqlite3": "^7.6.3",
"@types/cookie-parser": "^1.4.3", "@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13", "@types/express": "^4.17.15",
"@types/express-fileupload": "^1.2.3", "@types/express-fileupload": "^1.2.3",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"@vitest/ui": "^0.20.3", "@vitest/ui": "^0.20.3",
"axios": "^0.27.2", "axios": "^1.2.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.0.0", "dotenv": "^16.0.3",
"electron": "^18.3.6", "electron": "^22.0.0",
"electron-builder": "23.3.3", "electron-builder": "23.6.0",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-rebuild": "^3.2.9", "electron-rebuild": "^3.2.9",
"electron-releases": "^3.1091.0", "electron-releases": "^3.1171.0",
"esbuild": "^0.14.53", "esbuild": "^0.16.10",
"eslint": "*",
"express-fileupload": "^1.4.0", "express-fileupload": "^1.4.0",
"minimist": "^1.2.6", "minimist": "^1.2.7",
"music-metadata": "^7.12.5", "music-metadata": "^8.1.0",
"open-cli": "^7.0.1", "open-cli": "^7.1.0",
"ora": "^6.1.2", "ora": "^6.1.2",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
"prettier": "*", "prettier": "*",
"typescript": "*", "typescript": "*",
"vitest": "^0.20.3", "vitest": "^0.20.3",
"wait-on": "^6.0.1" "wait-on": "^7.0.1",
"tsx": "*"
} }
} }

View file

@ -9,6 +9,7 @@ import dotenv from 'dotenv'
import pc from 'picocolors' import pc from 'picocolors'
import minimist from 'minimist' import minimist from 'minimist'
const isDev = process.env.NODE_ENV === 'development'
const env = dotenv.config({ const env = dotenv.config({
path: path.resolve(process.cwd(), '../../.env'), path: path.resolve(process.cwd(), '../../.env'),
}) })

View file

@ -1,4 +1,5 @@
# Getting Started with [Fastify-CLI](https://www.npmjs.com/package/fastify-cli) # Getting Started with [Fastify-CLI](https://www.npmjs.com/package/fastify-cli)
This project was bootstrapped with Fastify-CLI. This project was bootstrapped with Fastify-CLI.
## Available Scripts ## Available Scripts

View file

@ -1,14 +1,14 @@
import { join } from 'path'; import { join } from 'path'
import AutoLoad, {AutoloadPluginOptions} from '@fastify/autoload'; import AutoLoad, { AutoloadPluginOptions } from '@fastify/autoload'
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify'
export type AppOptions = { export type AppOptions = {
// Place your custom options for app below here. // Place your custom options for app below here.
} & Partial<AutoloadPluginOptions>; } & Partial<AutoloadPluginOptions>
const app: FastifyPluginAsync<AppOptions> = async ( const app: FastifyPluginAsync<AppOptions> = async (
fastify, fastify,
opts opts
): Promise<void> => { ): Promise<void> => {
// Place here your custom code! // Place here your custom code!
@ -19,17 +19,16 @@ const app: FastifyPluginAsync<AppOptions> = async (
// through your application // through your application
void fastify.register(AutoLoad, { void fastify.register(AutoLoad, {
dir: join(__dirname, 'plugins'), dir: join(__dirname, 'plugins'),
options: opts options: opts,
}) })
// This loads all plugins defined in routes // This loads all plugins defined in routes
// define your routes in one of these // define your routes in one of these
void fastify.register(AutoLoad, { void fastify.register(AutoLoad, {
dir: join(__dirname, 'routes'), dir: join(__dirname, 'routes'),
options: opts options: opts,
}) })
}
}; export default app
export default app;
export { app } export { app }

View file

@ -11,6 +11,6 @@ that will then be used in the rest of your application.
Check out: Check out:
* [The hitchhiker's guide to plugins](https://www.fastify.io/docs/latest/Guides/Plugins-Guide/) - [The hitchhiker's guide to plugins](https://www.fastify.io/docs/latest/Guides/Plugins-Guide/)
* [Fastify decorators](https://www.fastify.io/docs/latest/Reference/Decorators/). - [Fastify decorators](https://www.fastify.io/docs/latest/Reference/Decorators/).
* [Fastify lifecycle](https://www.fastify.io/docs/latest/Reference/Lifecycle/). - [Fastify lifecycle](https://www.fastify.io/docs/latest/Reference/Lifecycle/).

View file

@ -8,6 +8,6 @@ import sensible, { SensibleOptions } from '@fastify/sensible'
*/ */
export default fp<SensibleOptions>(async (fastify, opts) => { export default fp<SensibleOptions>(async (fastify, opts) => {
fastify.register(sensible, { fastify.register(sensible, {
errorHandler: false errorHandler: false,
}) })
}) })

View file

@ -15,6 +15,6 @@ export default fp<SupportPluginOptions>(async (fastify, opts) => {
// When using .decorate you have to specify added properties for Typescript // When using .decorate you have to specify added properties for Typescript
declare module 'fastify' { declare module 'fastify' {
export interface FastifyInstance { export interface FastifyInstance {
someSupport(): string; someSupport(): string
} }
} }

View file

@ -6,4 +6,4 @@ const root: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
}) })
} }
export default root; export default root

View file

@ -2,18 +2,18 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import fp from 'fastify-plugin' import fp from 'fastify-plugin'
import App from '../src/app' import App from '../src/app'
import * as tap from 'tap'; import * as tap from 'tap'
export type Test = typeof tap['Test']['prototype']; export type Test = typeof tap['Test']['prototype']
// Fill in this config with all the configurations // Fill in this config with all the configurations
// needed for testing the application // needed for testing the application
async function config () { async function config() {
return {} return {}
} }
// Automatically build and tear down our instance // Automatically build and tear down our instance
async function build (t: Test) { async function build(t: Test) {
const app = Fastify() const app = Fastify()
// fastify-plugin ensures that all decorators // fastify-plugin ensures that all decorators
@ -21,7 +21,7 @@ async function build (t: Test) {
// different from the production setup // different from the production setup
void app.register(fp(App), await config()) void app.register(fp(App), await config())
await app.ready(); await app.ready()
// Tear down our app after we are done // Tear down our app after we are done
t.teardown(() => void app.close()) t.teardown(() => void app.close())
@ -29,7 +29,4 @@ async function build (t: Test) {
return app return app
} }
export { export { config, build }
config,
build
}

View file

@ -2,7 +2,7 @@ import { test } from 'tap'
import Fastify from 'fastify' import Fastify from 'fastify'
import Support from '../../src/plugins/support' import Support from '../../src/plugins/support'
test('support works standalone', async (t) => { test('support works standalone', async t => {
const fastify = Fastify() const fastify = Fastify()
void fastify.register(Support) void fastify.register(Support)
await fastify.ready() await fastify.ready()

View file

@ -1,11 +1,11 @@
import { test } from 'tap' import { test } from 'tap'
import { build } from '../helper' import { build } from '../helper'
test('example is loaded', async (t) => { test('example is loaded', async t => {
const app = await build(t) const app = await build(t)
const res = await app.inject({ const res = await app.inject({
url: '/example' url: '/example',
}) })
t.equal(res.payload, 'this is an example') t.equal(res.payload, 'this is an example')

View file

@ -1,11 +1,11 @@
import { test } from 'tap' import { test } from 'tap'
import { build } from '../helper' import { build } from '../helper'
test('default root route', async (t) => { test('default root route', async t => {
const app = await build(t) const app = await build(t)
const res = await app.inject({ const res = await app.inject({
url: '/' url: '/',
}) })
t.same(JSON.parse(res.payload), { root: true }) t.same(JSON.parse(res.payload), { root: true })
}) })

View file

@ -5,6 +5,7 @@ export enum UserApiNames {
FetchUserAlbums = 'fetchUserAlbums', FetchUserAlbums = 'fetchUserAlbums',
FetchUserArtists = 'fetchUserArtists', FetchUserArtists = 'fetchUserArtists',
FetchListenedRecords = 'fetchListenedRecords', FetchListenedRecords = 'fetchListenedRecords',
FetchUserVideos = 'fetchUserVideos',
} }
// 获取账号详情 // 获取账号详情
@ -107,6 +108,14 @@ export interface FetchUserArtistsResponse {
count: number count: number
data: Artist[] data: Artist[]
} }
// 获取收藏的MV
export interface FetchUserVideosParams {}
export interface FetchUserVideosResponse {
code: number
hasMore: boolean
count: number
data: Video[]
}
// 听歌排名 // 听歌排名
export interface FetchListenedRecordsParams { export interface FetchListenedRecordsParams {

View file

@ -206,3 +206,19 @@ declare interface User {
anchor: boolean anchor: boolean
avatarImgId_str: string avatarImgId_str: string
} }
declare interface Video {
alg: null
aliaName: string
coverUrl: string
creator: { userId: number; userName: string }[]
userId: number
userName: string
durationms: number
markTypes: null
playTime: number
title: string
transName: string
type: number
vid: string
}

View file

@ -8,6 +8,7 @@ import { State as PlayerState } from '@/web/utils/player'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useEffectOnce } from 'react-use' import { useEffectOnce } from 'react-use'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { appName } from './utils/const'
const IpcRendererReact = () => { const IpcRendererReact = () => {
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
@ -26,7 +27,7 @@ const IpcRendererReact = () => {
useEffect(() => { useEffect(() => {
trackIDRef.current = track?.id ?? 0 trackIDRef.current = track?.id ?? 0
const text = track?.name ? `${track.name} - YesPlayMusic` : 'YesPlayMusic' const text = track?.name ? `${track.name} - ${appName}` : appName
window.ipcRenderer?.send(IpcChannels.SetTrayTooltip, { window.ipcRenderer?.send(IpcChannels.SetTrayTooltip, {
text, text,
}) })

View file

@ -6,12 +6,10 @@ import { useQuery } from '@tanstack/react-query'
import useUser from './useUser' import useUser from './useUser'
import reactQueryClient from '@/web/utils/reactQueryClient' import reactQueryClient from '@/web/utils/reactQueryClient'
export default function useUserListenedRecords(params: { export default function useUserListenedRecords(params: { type: 'week' | 'all' }) {
type: 'week' | 'all'
}) {
const { data: user } = useUser() const { data: user } = useUser()
const uid = user?.account?.id || 0 const uid = user?.account?.id || 0
const key = [UserApiNames.FetchListenedRecords] const key = [UserApiNames.FetchListenedRecords, uid]
return useQuery( return useQuery(
key, key,

View file

@ -0,0 +1,71 @@
import {
UserApiNames,
FetchListenedRecordsResponse,
FetchUserVideosResponse,
} from '@/shared/api/User'
import { APIs } from '@/shared/CacheAPIs'
import { IpcChannels } from '@/shared/IpcChannels'
import { useMutation, useQuery } from '@tanstack/react-query'
import useUser from './useUser'
import reactQueryClient from '@/web/utils/reactQueryClient'
import { fetchUserVideos } from '../user'
import { likeAVideo } from '../mv'
import toast from 'react-hot-toast'
import { cloneDeep } from 'lodash-es'
export default function useUserVideos() {
const { data: user } = useUser()
const uid = user?.account?.id ?? 0
const key = [UserApiNames.FetchUserVideos, uid]
return useQuery(
key,
() => {
// const existsQueryData = reactQueryClient.getQueryData(key)
// if (!existsQueryData) {
// window.ipcRenderer
// ?.invoke(IpcChannels.GetApiCache, {
// api: APIs.Likelist,
// query: {
// uid,
// },
// })
// .then(cache => {
// if (cache) reactQueryClient.setQueryData(key, cache)
// })
// }
return fetchUserVideos()
},
{
enabled: !!(uid && uid !== 0),
refetchOnWindowFocus: true,
}
)
}
export const useMutationLikeAVideo = () => {
const { data: user } = useUser()
const { data: userVideos, refetch } = useUserVideos()
const uid = user?.account?.id ?? 0
const key = [UserApiNames.FetchUserVideos, uid]
return useMutation(
async (videoID: string | number) => {
if (!videoID || userVideos?.data === undefined) {
throw new Error('playlist id is required or userPlaylists is undefined')
}
const response = await likeAVideo({
id: videoID,
t: userVideos.data.find(v => String(v.vid) === String(videoID)) ? 2 : 1,
})
if (response.code !== 200) throw new Error((response as any).msg)
return response
},
{
onSuccess: () => {
refetch()
},
}
)
}

View file

@ -42,21 +42,15 @@ export function simiMv(mvid) {
}) })
} }
/** // 收藏/取消收藏视频
* / MV export function likeAVideo(params: { id: number | string; t?: number }) {
* 说明 : 调用此接口,/ MV
* - mvid: mv id
* - t: 1 ,
* @param {Object} params
* @param {number} params.mvid
* @param {number=} params.t
*/
export function likeAMV(params) {
params.timestamp = new Date().getTime()
return request({ return request({
url: '/mv/sub', url: '/mv/sub',
method: 'post', method: 'post',
params, params: {
mvid: params.id,
t: params.t,
timestamp: new Date().getTime(),
},
}) })
} }

View file

@ -1,7 +1,8 @@
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import { appName } from '../utils/const'
const request: AxiosInstance = axios.create({ const request: AxiosInstance = axios.create({
baseURL: '/yesplaymusic', baseURL: `/${appName.toLowerCase()}`,
withCredentials: true, withCredentials: true,
timeout: 15000, timeout: 15000,
}) })

View file

@ -10,6 +10,8 @@ import {
FetchUserArtistsResponse, FetchUserArtistsResponse,
FetchListenedRecordsParams, FetchListenedRecordsParams,
FetchListenedRecordsResponse, FetchListenedRecordsResponse,
FetchUserVideosResponse,
FetchUserVideosParams,
} from '@/shared/api/User' } from '@/shared/api/User'
/** /**
@ -135,20 +137,18 @@ export function fetchUserArtists(): Promise<FetchUserArtistsResponse> {
}) })
} }
/** // 获取收藏的MV
* MV export function fetchUserVideos(): Promise<FetchUserVideosResponse> {
* 说明 : 调用此接口可获取到用户收藏的MV return request({
*/ url: '/mv/sublist',
// export function likedMVs(params) { method: 'get',
// return request({ params: {
// url: '/mv/sublist', limit: 1000,
// method: 'get', // offset: 1,
// params: { timestamp: new Date().getTime(),
// limit: params.limit, },
// timestamp: new Date().getTime(), })
// }, }
// })
// }
/** /**
* *

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.50293 10.5557C5.05664 10.5557 5.46973 10.1514 5.46973 9.58887V8.96484L5.28516 6.24023L7.31543 8.37598L9.8291 10.9072C10.0137 11.1006 10.251 11.1885 10.5059 11.1885C11.1035 11.1885 11.5342 10.7842 11.5342 10.1865C11.5342 9.91406 11.4287 9.67676 11.2441 9.4834L8.72168 6.96094L6.57715 4.93945L9.31934 5.12402H10.0049C10.5586 5.12402 10.9805 4.71973 10.9805 4.15723C10.9805 3.59473 10.5674 3.18164 10.0049 3.18164H5.12695C4.11621 3.18164 3.52734 3.77051 3.52734 4.78125V9.58887C3.52734 10.1426 3.94043 10.5557 4.50293 10.5557ZM13.9863 20.1357H18.8643C19.875 20.1357 20.4639 19.5469 20.4639 18.5361V13.7285C20.4639 13.1748 20.042 12.7617 19.4883 12.7617C18.9346 12.7617 18.5215 13.166 18.5215 13.7285V14.3525L18.7061 17.0771L16.6758 14.9414L14.1621 12.4102C13.9775 12.2168 13.7402 12.1289 13.4766 12.1289C12.8789 12.1289 12.4482 12.5332 12.4482 13.1309C12.4482 13.4033 12.5537 13.6494 12.7471 13.834L15.2695 16.3564L17.4141 18.3779L14.6719 18.1934H13.9863C13.4238 18.1934 13.0107 18.5977 13.0107 19.1602C13.0107 19.7227 13.4238 20.1357 13.9863 20.1357Z" fill="white" fill-opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.88086 10.9521H9.75879C10.7695 10.9521 11.3584 10.3633 11.3584 9.35254V4.54492C11.3584 3.99121 10.9365 3.57812 10.374 3.57812C9.82031 3.57812 9.41602 3.98242 9.41602 4.54492V5.16895L9.60059 7.89355L7.57031 5.75781L5.05664 3.22656C4.87207 3.0332 4.63477 2.94531 4.37109 2.94531C3.77344 2.94531 3.34277 3.34961 3.34277 3.94727C3.34277 4.21973 3.44824 4.46582 3.6416 4.65039L6.16406 7.17285L8.2998 9.20312L5.55762 9.01855H4.88086C4.31836 9.01855 3.90527 9.41406 3.90527 9.97656C3.90527 10.5391 4.31836 10.9521 4.88086 10.9521ZM13.5732 19.8467C14.127 19.8467 14.5312 19.4424 14.5312 18.8799V18.168L14.3467 15.4521L16.377 17.5879L18.9434 20.1631C19.1191 20.3564 19.3652 20.4443 19.6201 20.4443C20.2178 20.4443 20.6484 20.04 20.6484 19.4424C20.6484 19.1699 20.543 18.9326 20.3584 18.7393L17.7832 16.1729L15.6475 14.1426L18.3896 14.3271H19.1455C19.708 14.3271 20.1211 13.9229 20.1211 13.3691C20.1211 12.7979 19.708 12.3936 19.1455 12.3936H14.1885C13.1777 12.3936 12.5889 12.9824 12.5889 13.9932V18.8799C12.5889 19.4336 13.0107 19.8467 13.5732 19.8467Z" fill="white" fill-opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.1447 23.6543H14.8647C15.4286 23.6543 15.8515 23.316 15.9737 22.7614L16.4436 20.7784C16.7632 20.6656 17.0639 20.5434 17.3459 20.4118L19.0846 21.4832C19.5451 21.7746 20.0996 21.7276 20.485 21.3423L21.6974 20.1299C22.0827 19.7445 22.1391 19.1712 21.8289 18.7013L20.7669 16.9814C20.8985 16.6994 21.0207 16.3987 21.1147 16.1073L23.1165 15.6374C23.6711 15.5152 24 15.0923 24 14.5284V12.8366C24 12.2821 23.6711 11.8498 23.1165 11.7276L21.1335 11.2483C21.0301 10.9193 20.8985 10.628 20.7857 10.3742L21.8477 8.6167C22.1485 8.13738 22.1109 7.60167 21.7068 7.20693L20.485 5.99452C20.0902 5.63738 19.5921 5.57159 19.1128 5.83475L17.3459 6.92497C17.0733 6.79339 16.7726 6.67121 16.453 6.55843L15.9737 4.54715C15.8515 3.99264 15.4286 3.6543 14.8647 3.6543H13.1447C12.5714 3.6543 12.1485 3.99264 12.0263 4.54715L11.5564 6.54903C11.2368 6.65242 10.9267 6.7746 10.6447 6.91557L8.88722 5.83475C8.40789 5.57159 7.91917 5.62798 7.51504 5.99452L6.29323 7.20693C5.8891 7.60167 5.8515 8.13738 6.15226 8.6167L7.21429 10.3742C7.1015 10.628 6.97932 10.9193 6.86654 11.2483L4.88346 11.7276C4.32895 11.8498 4 12.2821 4 12.8366V14.5284C4 15.0923 4.32895 15.5152 4.88346 15.6374L6.88534 16.1073C6.97932 16.3987 7.1015 16.6994 7.23308 16.9814L6.17105 18.7013C5.8609 19.1712 5.91729 19.7445 6.31203 20.1299L7.51504 21.3423C7.90038 21.7276 8.45489 21.7746 8.92481 21.4832L10.6541 20.4118C10.9361 20.5434 11.2368 20.6656 11.5564 20.7784L12.0263 22.7614C12.1485 23.316 12.5714 23.6543 13.1447 23.6543ZM14 16.9438C12.1955 16.9438 10.7199 15.4588 10.7199 13.6449C10.7199 11.8498 12.1955 10.3742 14 10.3742C15.8139 10.3742 17.2895 11.8498 17.2895 13.6449C17.2895 15.4588 15.8139 16.9438 14 16.9438Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -52,7 +52,7 @@ const ArtistInline = ({
> >
{artist.name} {artist.name}
</span> </span>
{index < artists.length - 1 ? ', ' : ''}&nbsp; {index < artists.length - 1 ? ', ' : ''}
</span> </span>
))} ))}
</div> </div>

View file

@ -1,6 +1,4 @@
import useUserAlbums, { import useUserAlbums, { useMutationLikeAAlbum } from '@/web/api/hooks/useUserAlbums'
useMutationLikeAAlbum,
} from '@/web/api/hooks/useUserAlbums'
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus' import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
import player from '@/web/states/player' import player from '@/web/states/player'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence } from 'framer-motion'
@ -14,16 +12,15 @@ import BasicContextMenu from './BasicContextMenu'
const AlbumContextMenu = () => { const AlbumContextMenu = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { cursorPosition, type, dataSourceID, target, options } = const { cursorPosition, type, dataSourceID, target, options } = useSnapshot(contextMenus)
useSnapshot(contextMenus)
const likeAAlbum = useMutationLikeAAlbum() const likeAAlbum = useMutationLikeAAlbum()
const [, copyToClipboard] = useCopyToClipboard() const [, copyToClipboard] = useCopyToClipboard()
const { data: likedAlbums } = useUserAlbums() const { data: likedAlbums } = useUserAlbums()
const addToLibraryLabel = useMemo(() => { const addToLibraryLabel = useMemo(() => {
return likedAlbums?.data?.find(a => a.id === Number(dataSourceID)) return likedAlbums?.data?.find(a => a.id === Number(dataSourceID))
? 'Remove from Library' ? t`context-menu.remove-from-library`
: 'Add to Library' : t`context-menu.add-to-library`
}, [dataSourceID, likedAlbums?.data]) }, [dataSourceID, likedAlbums?.data])
return ( return (
@ -82,19 +79,15 @@ const AlbumContextMenu = () => {
type: 'item', type: 'item',
label: t`context-menu.copy-netease-link`, label: t`context-menu.copy-netease-link`,
onClick: () => { onClick: () => {
copyToClipboard( copyToClipboard(`https://music.163.com/#/album?id=${dataSourceID}`)
`https://music.163.com/#/album?id=${dataSourceID}`
)
toast.success(t`toasts.copied`) toast.success(t`toasts.copied`)
}, },
}, },
{ {
type: 'item', type: 'item',
label: 'Copy YPM Link', label: t`context-menu.copy-r3play-link`,
onClick: () => { onClick: () => {
copyToClipboard( copyToClipboard(`${window.location.origin}/album/${dataSourceID}`)
`${window.location.origin}/album/${dataSourceID}`
)
toast.success(t`toasts.copied`) toast.success(t`toasts.copied`)
}, },
}, },

View file

@ -4,6 +4,8 @@ import useLockMainScroll from '@/web/hooks/useLockMainScroll'
import useMeasure from 'react-use-measure' import useMeasure from 'react-use-measure'
import { ContextMenuItem } from './MenuItem' import { ContextMenuItem } from './MenuItem'
import MenuPanel from './MenuPanel' import MenuPanel from './MenuPanel'
import { createPortal } from 'react-dom'
import { ContextMenuPosition } from './types'
const BasicContextMenu = ({ const BasicContextMenu = ({
onClose, onClose,
@ -19,15 +21,14 @@ const BasicContextMenu = ({
cursorPosition: { x: number; y: number } cursorPosition: { x: number; y: number }
options?: { options?: {
useCursorPosition?: boolean useCursorPosition?: boolean
fixedPosition?: `${'top' | 'bottom'}-${'left' | 'right'}`
} | null } | null
classNames?: string classNames?: string
}) => { }) => {
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const [measureRef, menu] = useMeasure() const [measureRef, menu] = useMeasure()
const [position, setPosition] = useState<{ x: number; y: number } | null>( const [position, setPosition] = useState<ContextMenuPosition | null>(null)
null
)
useClickAway(menuRef, onClose) useClickAway(menuRef, onClose)
useLockMainScroll(!!position) useLockMainScroll(!!position)
@ -43,6 +44,22 @@ const BasicContextMenu = ({
y: bottomY + menu.height < window.innerHeight ? bottomY : topY, y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
} }
setPosition(position) setPosition(position)
} else if (options?.fixedPosition) {
const [vertical, horizontal] = options.fixedPosition.split('-') as [
'top' | 'bottom',
'left' | 'right'
]
const button = target.getBoundingClientRect()
const leftX = button.x
const rightX = button.x - menu.width + button.width
const bottomY = button.y + button.height + 8
const topY = button.y - menu.height - 8
const position: ContextMenuPosition = {
x: horizontal === 'left' ? leftX : rightX,
y: vertical === 'bottom' ? bottomY : topY,
transformOrigin: `origin-${options.fixedPosition}`,
}
setPosition(position)
} else { } else {
const button = target.getBoundingClientRect() const button = target.getBoundingClientRect()
const leftX = button.x const leftX = button.x
@ -57,7 +74,7 @@ const BasicContextMenu = ({
} }
}, [target, menu, options?.useCursorPosition, cursorPosition]) }, [target, menu, options?.useCursorPosition, cursorPosition])
return ( return createPortal(
<> <>
<MenuPanel <MenuPanel
position={{ x: 99999, y: 99999 }} position={{ x: 99999, y: 99999 }}
@ -78,7 +95,8 @@ const BasicContextMenu = ({
classNames={classNames} classNames={classNames}
/> />
)} )}
</> </>,
document.body
) )
} }

View file

@ -1,13 +1,7 @@
import { css, cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import { ForwardedRef, forwardRef, useRef, useState } from 'react' import { ForwardedRef, forwardRef, useRef, useState } from 'react'
import Icon from '../Icon' import Icon from '../Icon'
import { ContextMenuItem } from './types'
export interface ContextMenuItem {
type: 'item' | 'submenu' | 'divider'
label?: string
onClick?: (e: MouseEvent) => void
items?: ContextMenuItem[]
}
const MenuItem = ({ const MenuItem = ({
item, item,
@ -63,7 +57,7 @@ const MenuItem = ({
onSubmenuClose() onSubmenuClose()
}} }}
className={cx( className={cx(
'relative', 'relative cursor-default',
className, className,
css` css`
padding-right: 9px; padding-right: 9px;

View file

@ -7,14 +7,11 @@ import {
useState, useState,
} from 'react' } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import MenuItem, { ContextMenuItem } from './MenuItem' import MenuItem from './MenuItem'
import { ContextMenuItem, ContextMenuPosition } from './types'
interface PanelProps { interface PanelProps {
position: { position: ContextMenuPosition
x: number
y: number
transformOrigin?: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
}
items: ContextMenuItem[] items: ContextMenuItem[]
onClose: (e: MouseEvent) => void onClose: (e: MouseEvent) => void
forMeasure?: boolean forMeasure?: boolean
@ -36,33 +33,33 @@ const MenuPanel = forwardRef(
return ( return (
// Container (to add padding for submenus) // Container (to add padding for submenus)
<motion.div <div
initial={{ opacity: 0, scale: forMeasure ? 1 : 0.96 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: 0.1,
},
}}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2 }}
ref={ref} ref={ref}
className={cx( className={cx(
'fixed', 'fixed select-none',
position.transformOrigin || 'origin-top-left', isSubmenu ? 'submenu z-30 px-1' : 'z-20'
isSubmenu ? 'submenu z-20 px-1' : 'z-10'
)} )}
style={{ left: position.x, top: position.y }} style={{ left: position.x, top: position.y }}
> >
{/* The real panel */} {/* The real panel */}
<div <motion.div
initial={{ opacity: 0, scale: forMeasure ? 1 : 0.96 }}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: 0.1,
},
}}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2 }}
className={cx( className={cx(
'rounded-12 border border-white/[.06] bg-gray-900/95 p-px py-2.5 shadow-xl outline outline-1 outline-black backdrop-blur-3xl', 'rounded-12 border border-white/[.06] bg-gray-900/95 p-px py-2.5 shadow-xl outline outline-1 outline-black backdrop-blur-3xl',
css` css`
min-width: 200px; min-width: 200px;
`, `,
classNames classNames,
position.transformOrigin || 'origin-top-left'
)} )}
> >
{items.map((item, index) => ( {items.map((item, index) => (
@ -76,7 +73,7 @@ const MenuPanel = forwardRef(
className={isSubmenu ? 'submenu' : ''} className={isSubmenu ? 'submenu' : ''}
/> />
))} ))}
</div> </motion.div>
{/* Submenu */} {/* Submenu */}
<SubMenu <SubMenu
@ -86,7 +83,7 @@ const MenuPanel = forwardRef(
itemRect={submenuProps?.itemRect} itemRect={submenuProps?.itemRect}
onClose={onClose} onClose={onClose}
/> />
</motion.div> </div>
) )
} }
) )

View file

@ -0,0 +1,12 @@
export interface ContextMenuPosition {
x: number
y: number
transformOrigin?: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
}
export interface ContextMenuItem {
type: 'item' | 'submenu' | 'divider'
label?: string
onClick?: (e: MouseEvent) => void
items?: ContextMenuItem[]
}

View file

@ -1 +1 @@
export type IconNames = 'back' | 'caret-right' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | 'heart-outline' | 'heart' | 'hide-list' | 'lock' | 'lyrics' | 'more' | 'music-note' | 'my' | 'next' | 'pause' | 'phone' | 'play-fill' | 'play' | 'player-handler' | 'playlist' | 'plus' | 'previous' | 'qrcode' | 'repeat-1' | 'repeat' | 'search' | 'settings' | 'shuffle' | 'user' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x' export type IconNames = 'back' | 'caret-right' | 'discovery' | 'dislike' | 'dj' | 'email' | 'explicit' | 'explore' | 'eye-off' | 'eye' | 'fm' | 'forward' | 'fullscreen-enter' | 'fullscreen-exit' | 'heart-outline' | 'heart' | 'hide-list' | 'lock' | 'lyrics' | 'more' | 'music-note' | 'my' | 'next' | 'pause' | 'phone' | 'play-fill' | 'play' | 'player-handler' | 'playlist' | 'plus' | 'previous' | 'qrcode' | 'repeat-1' | 'repeat' | 'search' | 'settings' | 'shuffle' | 'user' | 'video-settings' | 'volume-half' | 'volume-mute' | 'volume' | 'windows-close' | 'windows-maximize' | 'windows-minimize' | 'windows-un-maximize' | 'x'

View file

@ -2,13 +2,12 @@ import Main from '@/web/components/Main'
import Player from '@/web/components/Player' import Player from '@/web/components/Player'
import MenuBar from '@/web/components/MenuBar' import MenuBar from '@/web/components/MenuBar'
import Topbar from '@/web/components/Topbar/TopbarDesktop' import Topbar from '@/web/components/Topbar/TopbarDesktop'
import { css, cx } from '@emotion/css' import { cx } from '@emotion/css'
import player from '@/web/states/player' import player from '@/web/states/player'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import Login from './Login' import Login from './Login'
import TrafficLight from './TrafficLight' import TrafficLight from './TrafficLight'
import BlurBackground from './BlurBackground' import BlurBackground from './BlurBackground'
import Airplay from './Airplay'
import TitleBar from './TitleBar' import TitleBar from './TitleBar'
import uiStates from '@/web/states/uiStates' import uiStates from '@/web/states/uiStates'
import ContextMenus from './ContextMenus/ContextMenus' import ContextMenus from './ContextMenus/ContextMenus'
@ -39,7 +38,11 @@ const Layout = () => {
</div> </div>
)} )}
{(window.env?.isWindows || window.env?.isLinux) && <TitleBar />} {(window.env?.isWindows ||
window.env?.isLinux ||
window.localStorage.getItem('showWindowsTitleBar') === 'true') && (
<TitleBar />
)}
<ContextMenus /> <ContextMenus />

View file

@ -151,7 +151,7 @@ const Login = () => {
onClick={() => (uiStates.showLoginPanel = false)} onClick={() => (uiStates.showLoginPanel = false)}
className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70' className='mt-10 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70'
> >
<Icon name='x' className='h-7 w-7 ' /> <Icon name='x' className='h-6 w-6' />
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</motion.div> </motion.div>

View file

@ -9,6 +9,7 @@ import persistedUiStates from '@/web/states/persistedUiStates'
import { motion, useAnimation } from 'framer-motion' import { motion, useAnimation } from 'framer-motion'
import { sleep } from '@/web/utils/common' import { sleep } from '@/web/utils/common'
import player from '@/web/states/player' import player from '@/web/states/player'
import VideoPlayer from './VideoPlayer'
const Main = () => { const Main = () => {
const playerSnapshot = useSnapshot(player) const playerSnapshot = useSnapshot(player)

View file

@ -19,8 +19,8 @@ const Progress = () => {
/> />
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'> <div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
<span>{formatDuration(progress * 1000, 'en', 'hh:mm:ss')}</span> <span>{formatDuration(progress * 1000, 'en-US', 'hh:mm:ss')}</span>
<span>{formatDuration(track?.dt || 0, 'en', 'hh:mm:ss')}</span> <span>{formatDuration(track?.dt || 0, 'en-US', 'hh:mm:ss')}</span>
</div> </div>
</div> </div>
) )

View file

@ -1,6 +1,7 @@
import { Route, Routes, useLocation } from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence } from 'framer-motion'
import React, { ReactNode, Suspense } from 'react' import React, { ReactNode, Suspense } from 'react'
import VideoPlayer from './VideoPlayer'
const My = React.lazy(() => import('@/web/pages/My')) const My = React.lazy(() => import('@/web/pages/My'))
const Discover = React.lazy(() => import('@/web/pages/Discover')) const Discover = React.lazy(() => import('@/web/pages/Discover'))
@ -8,7 +9,6 @@ const Browse = React.lazy(() => import('@/web/pages/Browse'))
const Album = React.lazy(() => import('@/web/pages/Album')) const Album = React.lazy(() => import('@/web/pages/Album'))
const Playlist = React.lazy(() => import('@/web/pages/Playlist')) const Playlist = React.lazy(() => import('@/web/pages/Playlist'))
const Artist = React.lazy(() => import('@/web/pages/Artist')) const Artist = React.lazy(() => import('@/web/pages/Artist'))
const MV = React.lazy(() => import('@/web/pages/MV'))
const Lyrics = React.lazy(() => import('@/web/pages/Lyrics')) const Lyrics = React.lazy(() => import('@/web/pages/Lyrics'))
const Search = React.lazy(() => import('@/web/pages/Search')) const Search = React.lazy(() => import('@/web/pages/Search'))
@ -20,7 +20,8 @@ const Router = () => {
const location = useLocation() const location = useLocation()
return ( return (
<AnimatePresence exitBeforeEnter> <AnimatePresence mode='wait'>
<VideoPlayer />
<Routes location={location} key={location.pathname}> <Routes location={location} key={location.pathname}>
<Route path='/' element={lazy(<My />)} /> <Route path='/' element={lazy(<My />)} />
<Route path='/discover' element={lazy(<Discover />)} /> <Route path='/discover' element={lazy(<Discover />)} />
@ -28,7 +29,6 @@ const Router = () => {
<Route path='/album/:id' element={lazy(<Album />)} /> <Route path='/album/:id' element={lazy(<Album />)} />
<Route path='/playlist/:id' element={lazy(<Playlist />)} /> <Route path='/playlist/:id' element={lazy(<Playlist />)} />
<Route path='/artist/:id' element={lazy(<Artist />)} /> <Route path='/artist/:id' element={lazy(<Artist />)} />
<Route path='/mv/:id' element={lazy(<MV />)} />
{/* <Route path='/settings' element={lazy(<Settings />)} /> */} {/* <Route path='/settings' element={lazy(<Settings />)} /> */}
<Route path='/lyrics' element={lazy(<Lyrics />)} /> <Route path='/lyrics' element={lazy(<Lyrics />)} />
<Route path='/search/:keywords' element={lazy(<Search />)}> <Route path='/search/:keywords' element={lazy(<Search />)}>

View file

@ -1,9 +1,7 @@
import player from '@/web/states/player'
import Icon from './Icon' import Icon from './Icon'
import { IpcChannels } from '@/shared/IpcChannels' import { IpcChannels } from '@/shared/IpcChannels'
import useIpcRenderer from '@/web/hooks/useIpcRenderer' import useIpcRenderer from '@/web/hooks/useIpcRenderer'
import { useState, useMemo } from 'react' import { useState } from 'react'
import { useSnapshot } from 'valtio'
import { css, cx } from '@emotion/css' import { css, cx } from '@emotion/css'
const Controls = () => { const Controls = () => {
@ -50,7 +48,8 @@ const Controls = () => {
className={cx( className={cx(
classNames, classNames,
css` css`
margin-right: 5px; border-radius: 4px 22px 4px 4px;
margin-right: 4px;
` `
)} )}
> >

View file

@ -1,5 +1,8 @@
import useHoverLightSpot from '@/web/hooks/useHoverLightSpot'
import { openContextMenu } from '@/web/states/contextMenus' import { openContextMenu } from '@/web/states/contextMenus'
import { cx } from '@emotion/css' import { css, cx } from '@emotion/css'
import { motion, useMotionValue } from 'framer-motion'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import Icon from '../Icon' import Icon from '../Icon'
@ -15,60 +18,101 @@ const Actions = ({
onPlay: () => void onPlay: () => void
onLike?: () => void onLike?: () => void
}) => { }) => {
const params = useParams()
const { t } = useTranslation()
return ( return (
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'> <div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start lg:gap-2.5'>
<div className='flex items-end'> <div className='flex items-end gap-2.5'>
{/* Menu */} <MenuButton isLoading={isLoading} />
<button <LikeButton {...{ isLiked, isLoading, onLike }} />
onClick={event => {
params?.id &&
openContextMenu({
event,
type: 'album',
dataSourceID: params.id,
})
}}
className={cx(
'mr-2.5 flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
)}
>
<Icon name='more' className='pointer-events-none h-7 w-7' />
</button>
{/* Like */}
{onLike && (
<button
onClick={() => onLike()}
className={cx(
'flex h-14 w-14 items-center justify-center rounded-full bg-white/10 transition duration-400 lg:mr-2.5',
isLoading
? 'text-transparent'
: 'text-white/40 hover:text-white/70 hover:dark:bg-white/30'
)}
>
<Icon
name={isLiked ? 'heart' : 'heart-outline'}
className='h-7 w-7'
/>
</button>
)}
</div> </div>
<button <PlayButton onPlay={onPlay} isLoading={isLoading} />
onClick={() => onPlay()}
className={cx(
'h-14 rounded-full px-10 text-18 font-medium',
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
)}
>
{t`player.play`}
</button>
</div> </div>
) )
} }
const MenuButton = ({ isLoading }: { isLoading?: boolean }) => {
const params = useParams()
// hover animation
const { buttonRef, buttonStyle, LightSpot } = useHoverLightSpot({
opacity: 0.8,
size: 16,
})
return (
<motion.button
ref={buttonRef}
style={buttonStyle}
onClick={event => {
params?.id &&
openContextMenu({
event,
type: 'album',
dataSourceID: params.id,
})
}}
className={cx(
'relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-white/10 transition duration-300 ease-linear',
isLoading ? 'text-transparent' : 'text-white/40'
)}
>
<Icon name='more' className='pointer-events-none h-7 w-7' />
{LightSpot()}
</motion.button>
)
}
const LikeButton = ({
onLike,
isLiked,
isLoading,
}: {
onLike?: () => void
isLiked?: boolean
isLoading?: boolean
}) => {
// hover animation
const { buttonRef, buttonStyle, LightSpot } = useHoverLightSpot({
opacity: 0.8,
size: 16,
})
if (!onLike) return null
return (
<motion.button
ref={buttonRef}
onClick={() => onLike()}
style={buttonStyle}
className={cx(
'relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-white/10 transition-transform duration-300 ease-linear',
isLoading ? 'text-transparent' : 'text-white/40 '
)}
>
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-7 w-7' />
{LightSpot()}
</motion.button>
)
}
const PlayButton = ({ onPlay, isLoading }: { onPlay: () => void; isLoading?: boolean }) => {
const { t } = useTranslation()
// hover animation
const { buttonRef, buttonStyle, LightSpot } = useHoverLightSpot()
return (
<motion.button
ref={buttonRef}
style={buttonStyle}
onClick={() => onPlay()}
className={cx(
'relative h-14 overflow-hidden rounded-full px-10 text-18 font-medium transition-transform duration-300 ease-linear',
isLoading ? 'bg-white/10 text-transparent' : 'bg-brand-700 text-white'
)}
>
{t`player.play`}
{LightSpot()}
</motion.button>
)
}
export default Actions export default Actions

View file

@ -2,7 +2,7 @@ import { resizeImage } from '@/web/utils/common'
import Image from '@/web/components/Image' import Image from '@/web/components/Image'
import { memo, useEffect } from 'react' import { memo, useEffect } from 'react'
import uiStates from '@/web/states/uiStates' import uiStates from '@/web/states/uiStates'
import VideoCover from './VideoCover' import VideoCover from '@/web/components/VideoCover'
const Cover = memo( const Cover = memo(
({ cover, videoCover }: { cover?: string; videoCover?: string }) => { ({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
@ -18,7 +18,7 @@ const Cover = memo(
src={resizeImage(cover || '', 'lg')} src={resizeImage(cover || '', 'lg')}
/> />
{videoCover && <VideoCover videoCover={videoCover} />} {videoCover && <VideoCover source={videoCover} />}
</div> </div>
</> </>
) )

View file

@ -1,51 +0,0 @@
import { useEffect, useRef } from 'react'
import Hls from 'hls.js'
import { injectGlobal } from '@emotion/css'
import { isIOS, isSafari } from '@/web/utils/common'
import { motion } from 'framer-motion'
injectGlobal`
.plyr__video-wrapper,
.plyr--video {
background-color: transparent !important;
}
`
const VideoCover = ({ videoCover }: { videoCover?: string }) => {
const ref = useRef<HTMLVideoElement>(null)
const hls = useRef<Hls>(new Hls())
useEffect(() => {
if (videoCover && Hls.isSupported()) {
const video = document.querySelector('#video-cover') as HTMLVideoElement
hls.current.loadSource(videoCover)
hls.current.attachMedia(video)
}
}, [videoCover])
return (
<motion.div
initial={{ opacity: isIOS ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
className='absolute inset-0'
>
{isSafari ? (
<video
src={videoCover}
className='h-full w-full'
autoPlay
loop
muted
playsInline
></video>
) : (
<div className='aspect-square'>
<video id='video-cover' ref={ref} autoPlay muted loop />
</div>
)}
</motion.div>
)
}
export default VideoCover

View file

@ -3,32 +3,34 @@ import Hls from 'hls.js'
import { injectGlobal } from '@emotion/css' import { injectGlobal } from '@emotion/css'
import { isIOS, isSafari } from '@/web/utils/common' import { isIOS, isSafari } from '@/web/utils/common'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useSnapshot } from 'valtio'
import uiStates from '../states/uiStates'
injectGlobal` const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }) => {
.plyr__video-wrapper, const videoRef = useRef<HTMLVideoElement>(null)
.plyr--video { const hls = useRef<Hls>()
background-color: transparent !important;
}
`
const VideoCover = ({
source,
onPlay,
}: {
source?: string
onPlay?: () => void
}) => {
const ref = useRef<HTMLVideoElement>(null)
const hls = useRef<Hls>(new Hls())
useEffect(() => { useEffect(() => {
if (source && Hls.isSupported()) { if (source && Hls.isSupported() && videoRef.current) {
const video = document.querySelector('#video-cover') as HTMLVideoElement if (hls.current) hls.current.destroy()
hls.current = new Hls()
hls.current.loadSource(source) hls.current.loadSource(source)
hls.current.attachMedia(video) hls.current.attachMedia(videoRef.current)
} }
return () => hls.current && hls.current.destroy()
}, [source]) }, [source])
// Pause video cover when playing another video
const { playingVideoID } = useSnapshot(uiStates)
useEffect(() => {
if (playingVideoID) {
videoRef?.current?.pause()
} else {
videoRef?.current?.play()
}
}, [playingVideoID])
return ( return (
<motion.div <motion.div
initial={{ opacity: isIOS ? 1 : 0 }} initial={{ opacity: isIOS ? 1 : 0 }}
@ -38,23 +40,26 @@ const VideoCover = ({
> >
{isSafari ? ( {isSafari ? (
<video <video
ref={videoRef}
src={source} src={source}
className='h-full w-full' className='h-full w-full'
autoPlay autoPlay
loop loop
muted muted
playsInline playsInline
preload='auto'
onPlay={() => onPlay?.()} onPlay={() => onPlay?.()}
></video> ></video>
) : ( ) : (
<div className='aspect-square'> <div className='aspect-square'>
<video <video
id='video-cover' ref={videoRef}
ref={ref}
autoPlay autoPlay
muted muted
loop loop
preload='auto'
onPlay={() => onPlay?.()} onPlay={() => onPlay?.()}
className='h-full w-full'
/> />
</div> </div>
)} )}

View file

@ -0,0 +1,260 @@
import useUserVideos, { useMutationLikeAVideo } from '@/web/api/hooks/useUserVideos'
import player from '@/web/states/player'
import uiStates from '@/web/states/uiStates'
import { formatDuration } from '@/web/utils/common'
import { css, cx } from '@emotion/css'
import { motion, useAnimationControls } from 'framer-motion'
import React, { useEffect, useMemo, useRef } from 'react'
import Icon from '../Icon'
import Slider from '../Slider'
import { proxy, useSnapshot } from 'valtio'
import { throttle } from 'lodash-es'
const videoStates = proxy({
currentTime: 0,
duration: 0,
isPaused: true,
isFullscreen: false,
})
const VideoInstance = ({ src, poster }: { src: string; poster: string }) => {
const videoRef = useRef<HTMLVideoElement>(null)
const videoContainerRef = useRef<HTMLDivElement>(null)
const video = videoRef.current
const { isPaused, isFullscreen } = useSnapshot(videoStates)
useEffect(() => {
if (!video || !src) return
const handleDurationChange = () => (videoStates.duration = video.duration * 1000)
const handleTimeUpdate = () => (videoStates.currentTime = video.currentTime * 1000)
const handleFullscreenChange = () => (videoStates.isFullscreen = !!document.fullscreenElement)
const handlePause = () => (videoStates.isPaused = true)
const handlePlay = () => (videoStates.isPaused = false)
video.addEventListener('timeupdate', handleTimeUpdate)
video.addEventListener('durationchange', handleDurationChange)
document.addEventListener('fullscreenchange', handleFullscreenChange)
video.addEventListener('pause', handlePause)
video.addEventListener('play', handlePlay)
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate)
video.removeEventListener('durationchange', handleDurationChange)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
video.removeEventListener('pause', handlePause)
video.removeEventListener('play', handlePlay)
}
})
// if video is playing, pause music
useEffect(() => {
if (!isPaused) player.pause()
}, [isPaused])
const togglePlay = () => {
videoStates.isPaused ? videoRef.current?.play() : videoRef.current?.pause()
}
const toggleFullscreen = async () => {
if (document.fullscreenElement) {
document.exitFullscreen()
videoStates.isFullscreen = false
} else {
if (videoContainerRef.current) {
videoContainerRef.current.requestFullscreen()
videoStates.isFullscreen = true
}
}
}
// reset video state when src changes
useEffect(() => {
videoStates.currentTime = 0
videoStates.duration = 0
videoStates.isPaused = true
videoStates.isFullscreen = false
}, [src])
// animation controls
const animationControls = useAnimationControls()
const controlsTimestamp = useRef(0)
const isControlsVisible = useRef(false)
const isMouseOverControls = useRef(false)
// hide controls after 2 seconds
const showControls = () => {
isControlsVisible.current = true
controlsTimestamp.current = Date.now()
animationControls.start('visible')
}
const hideControls = () => {
isControlsVisible.current = false
animationControls.start('hidden')
}
useEffect(() => {
if (!isFullscreen) return
const interval = setInterval(() => {
if (
isControlsVisible.current &&
Date.now() - controlsTimestamp.current > 2000 &&
!isMouseOverControls.current
) {
hideControls()
}
}, 300)
return () => clearInterval(interval)
}, [isFullscreen])
if (!src) return null
return (
<motion.div
initial='hidden'
animate={animationControls}
ref={videoContainerRef}
className={cx(
'relative aspect-video overflow-hidden rounded-24 bg-black',
css`
video::-webkit-media-controls {
display: none !important;
}
`,
!isFullscreen &&
css`
height: 60vh;
`
)}
onClick={togglePlay}
onMouseOver={showControls}
onMouseOut={hideControls}
onMouseMove={() => !isControlsVisible.current && isFullscreen && showControls()}
>
<video ref={videoRef} src={src} controls={false} poster={poster} className='h-full w-full' />
<Controls
videoRef={videoRef}
toggleFullscreen={toggleFullscreen}
togglePlay={togglePlay}
onMouseOver={() => (isMouseOverControls.current = true)}
onMouseOut={() => (isMouseOverControls.current = false)}
/>
</motion.div>
)
}
const Controls = ({
videoRef,
toggleFullscreen,
togglePlay,
onMouseOver,
onMouseOut,
}: {
videoRef: React.RefObject<HTMLVideoElement>
toggleFullscreen: () => void
togglePlay: () => void
onMouseOver: () => void
onMouseOut: () => void
}) => {
const video = videoRef.current
const { playingVideoID } = useSnapshot(uiStates)
const { currentTime, duration, isPaused, isFullscreen } = useSnapshot(videoStates)
const { data: likedVideos } = useUserVideos()
const isLiked = useMemo(() => {
return !!likedVideos?.data?.find(video => String(video.vid) === String(playingVideoID))
}, [likedVideos])
const likeAVideo = useMutationLikeAVideo()
const onLike = async () => {
if (playingVideoID) likeAVideo.mutateAsync(playingVideoID)
}
// keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
toggleFullscreen()
break
case ' ':
togglePlay()
break
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
const animationVariants = {
hidden: { y: '48px', opacity: 0 },
visible: { y: 0, opacity: 1 },
}
const animationTransition = { type: 'spring', bounce: 0.4, duration: 0.5 }
return (
<div onClick={e => e.stopPropagation()} onMouseOver={onMouseOver} onMouseOut={onMouseOut}>
{/* Current Time */}
<motion.div
variants={animationVariants}
transition={animationTransition}
className={cx(
'pointer-events-none absolute left-5 cursor-default select-none font-extrabold text-white/40',
css`
bottom: 100px;
font-size: 120px;
line-height: 120%;
letter-spacing: 0.02em;
-webkit-text-stroke-width: 1px;
-webkit-text-stroke-color: rgba(255, 255, 255, 0.7);
`
)}
>
{formatDuration(currentTime || 0, 'en-US', 'hh:mm:ss')}
</motion.div>
{/* Controls */}
<motion.div
variants={{
hidden: { y: '48px', opacity: 0 },
visible: { y: 0, opacity: 1 },
}}
transition={animationTransition}
className='absolute bottom-5 left-5 flex rounded-20 bg-black/70 py-3 px-5 backdrop-blur-3xl'
>
<button
onClick={togglePlay}
className='flex h-11 w-11 items-center justify-center rounded-full bg-white/20 text-white/80 transition-colors duration-400 hover:bg-white/30'
>
<Icon name={isPaused ? 'play' : 'pause'} className='h-6 w-6' />
</button>
<button
onClick={onLike}
className='ml-3 flex h-11 w-11 items-center justify-center rounded-full bg-white/20 text-white/80 transition-colors duration-400 hover:bg-white/30'
>
<Icon name={isLiked ? 'heart' : 'heart-outline'} className='h-6 w-6' />
</button>
<button
onClick={toggleFullscreen}
className='ml-3 flex h-11 w-11 items-center justify-center rounded-full bg-white/20 text-white/80 transition-colors duration-400 hover:bg-white/30'
>
<Icon name={isFullscreen ? 'fullscreen-exit' : 'fullscreen-enter'} className='h-6 w-6' />
</button>
{/* Slider */}
<div className='ml-5 flex items-center'>
<div
className={css`
width: 214px;
`}
>
<Slider
min={0}
max={duration || 99999}
value={currentTime || 0}
onChange={value => video?.currentTime && (video.currentTime = value)}
onlyCallOnChangeAfterDragEnded={true}
/>
</div>
{/* Duration */}
<span className='ml-4 text-14 font-bold text-white/20'>
{formatDuration(duration || 0, 'en-US', 'hh:mm:ss')}
</span>
</div>
</motion.div>
</div>
)
}
export default VideoInstance

View file

@ -0,0 +1,117 @@
import { css, cx } from '@emotion/css'
import { createPortal } from 'react-dom'
import useMV, { useMVUrl } from '../../api/hooks/useMV'
import { AnimatePresence, motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import Icon from '../Icon'
import VideoInstance from './VideoInstance'
import { toHttps } from '@/web/utils/common'
import uiStates, { closeVideoPlayer } from '@/web/states/uiStates'
import { useSnapshot } from 'valtio'
import { useNavigate } from 'react-router-dom'
const VideoPlayer = () => {
const { playingVideoID } = useSnapshot(uiStates)
const { fullscreen } = useSnapshot(uiStates)
const navigate = useNavigate()
const { data: mv, isLoading } = useMV({ mvid: playingVideoID || 0 })
const { data: mvDetails } = useMVUrl({ id: playingVideoID || 0 })
const mvUrl = toHttps(mvDetails?.data?.url)
const poster = toHttps(mv?.data.cover)
return createPortal(
<AnimatePresence>
{playingVideoID && (
<div
id='video-player'
className={cx(
'fixed inset-0 z-20 flex select-none items-center justify-center overflow-hidden',
window.env?.isElectron && !fullscreen && 'rounded-24'
)}
>
{/* Blur bg */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease, duration: 0.5 }}
className='absolute inset-0 bg-gray-50/80 backdrop-blur-3xl'
></motion.div>
<motion.div
variants={{
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease,
delay: 0.2,
},
},
hidden: {
opacity: 0,
y: 100,
transition: {
duration: 0.3,
ease,
},
},
}}
initial='hidden'
animate='visible'
exit='hidden'
className='relative'
>
{/* Video Title */}
<div className='absolute -top-16 flex cursor-default text-32 font-medium text-white transition-all'>
{isLoading ? (
<span className='rounded-full bg-white/10 text-transparent'>PLACEHOLDER2023</span>
) : (
<>
<div className='line-clamp-1' title={mv?.data.artistName + ' - ' + mv?.data.name}>
<a
onClick={() => {
if (!mv?.data.artistId) return
closeVideoPlayer()
navigate('/artist/' + mv.data.artistId)
}}
className='transition duration-400 hover:underline'
>
{mv?.data.artistName}
</a>{' '}
- {mv?.data.name}
</div>
<div className='ml-4 text-white/20'>{mv?.data.publishTime.slice(0, 4)}</div>
</>
)}
</div>
{/* Video */}
<VideoInstance src={mvUrl} poster={poster} />
{/* Close button */}
<div className='absolute -bottom-24 flex w-full justify-center'>
<motion.div
layout='position'
transition={{ ease }}
onClick={() => {
const video = document.querySelector('#video-player video') as HTMLVideoElement
video?.pause()
closeVideoPlayer()
}}
className='flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-white/50 transition-colors duration-300 hover:bg-white/20 hover:text-white/70'
>
<Icon name='x' className='h-6 w-6' />
</motion.div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
)
}
export default VideoPlayer

View file

@ -0,0 +1,2 @@
import VideoPlayer from './VideoPlayer'
export default VideoPlayer

View file

@ -0,0 +1,25 @@
import useUserVideos from '../api/hooks/useUserVideos'
import uiStates from '../states/uiStates'
const VideoRow = ({ videos }: { videos: Video[] }) => {
return (
<div className='grid grid-cols-3 gap-6'>
{videos.map(video => (
<div
key={video.vid}
onClick={() => (uiStates.playingVideoID = Number(video.vid))}
>
<img
src={video.coverUrl}
className='aspect-video w-full rounded-24 border border-white/5 object-contain'
/>
<div className='line-clamp-2 mt-2 text-12 font-medium text-neutral-600'>
{video.creator?.at(0)?.userName} - {video.title}
</div>
</div>
))}
</div>
)
}
export default VideoRow

View file

@ -0,0 +1,75 @@
import { css, cx } from '@emotion/css'
import { motion, useMotionValue } from 'framer-motion'
import { RefObject, useEffect, useRef } from 'react'
const useHoverLightSpot = (
config: { opacity: number; size: number } = { opacity: 0.8, size: 32 }
) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const opacity = useMotionValue(0)
const x = useMotionValue(0)
const y = useMotionValue(0)
const buttonX = useMotionValue(0)
const buttonY = useMotionValue(0)
useEffect(() => {
if (!buttonRef.current) return
const button = buttonRef.current
const handleMouseOver = () => {
opacity.set(config.opacity)
}
const handleMouseOut = () => {
opacity.set(0)
buttonX.set(0)
buttonY.set(0)
}
const handleMouseMove = (event: MouseEvent) => {
if (!buttonRef.current) return
const spotSize = config.size / 2
const button = buttonRef.current.getBoundingClientRect()
const cursorX = event.clientX - button.x
const cursorY = event.clientY - button.y
const newSpotX = cursorX - spotSize
const newSpotY = cursorY - spotSize
x.set(newSpotX)
y.set(newSpotY)
buttonX.set((cursorX - button.width / 2) / 8)
buttonY.set((cursorY - button.height / 2) / 8)
}
button.addEventListener('mouseover', handleMouseOver)
button.addEventListener('mouseout', handleMouseOut)
button.addEventListener('mousemove', handleMouseMove)
return () => {
button.removeEventListener('mouseover', handleMouseOver)
button.removeEventListener('mouseout', handleMouseOut)
button.removeEventListener('mousemove', handleMouseMove)
}
}, [buttonRef.current])
const LightSpot = () => {
return (
<motion.div
initial={{ opacity: 0 }}
className={cx(
'pointer-events-none absolute top-0 left-0 rounded-full transition-opacity duration-400',
css`
filter: blur(16px);
background: rgb(255, 255, 255);
`
)}
style={{ opacity, x, y, height: config.size, width: config.size }}
></motion.div>
)
}
return {
buttonRef,
LightSpot,
buttonStyle: {
x: buttonX,
y: buttonY,
},
}
}
export default useHoverLightSpot

View file

@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { appName } from '../utils/const'
export default function useVideoCover(props: { export default function useVideoCover(props: {
id?: number id?: number
@ -13,9 +14,12 @@ export default function useVideoCover(props: {
async () => { async () => {
if (!id || !name || !artist) return if (!id || !name || !artist) return
const fromRemote = await axios.get('/yesplaymusic/video-cover', { const fromRemote = await axios.get(
params: props, `/${appName.toLowerCase()}/video-cover`,
}) {
params: props,
}
)
if (fromRemote?.data?.url) { if (fromRemote?.data?.url) {
return fromRemote.data.url return fromRemote.data.url
} }

View file

@ -77,7 +77,10 @@
"follow": "Follow", "follow": "Follow",
"unfollow": "Unfollow", "unfollow": "Unfollow",
"followed": "Followed", "followed": "Followed",
"unfollowed": "Unfollowed" "unfollowed": "Unfollowed",
"add-to-library": "Add to library",
"remove-from-library": "Remove from library",
"copy-r3play-link": "Copy R3PLAY Link"
}, },
"toast": {}, "toast": {},
"artist": { "artist": {

View file

@ -77,7 +77,10 @@
"unfollow": "取消关注", "unfollow": "取消关注",
"follow": "关注", "follow": "关注",
"followed": "已关注", "followed": "已关注",
"unfollowed": "已取消关注" "unfollowed": "已取消关注",
"add-to-library": "添加到音乐库",
"remove-from-library": "从音乐库中移除",
"copy-r3play-link": "复制R3PLAY链接"
}, },
"toast": {}, "toast": {},
"artist": { "artist": {

View file

@ -9,7 +9,7 @@
content="script-src 'self' 'unsafe-inline' www.googletagmanager.com blob:;" /> content="script-src 'self' 'unsafe-inline' www.googletagmanager.com blob:;" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>YesPlayMusic</title> <title>R3Play</title>
</head> </head>
<body> <body>

View file

@ -22,6 +22,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import reactQueryClient from '@/web/utils/reactQueryClient' import reactQueryClient from '@/web/utils/reactQueryClient'
import React from 'react' import React from 'react'
import './i18n/i18n' import './i18n/i18n'
import { appName } from './utils/const'
ReactGA.initialize('G-KMJJCFZDKF') ReactGA.initialize('G-KMJJCFZDKF')
@ -38,7 +39,7 @@ Sentry.init({
), ),
}), }),
], ],
release: `yesplaymusic@${pkg.version}`, release: `${appName}@${pkg.version}`,
environment: import.meta.env.MODE, environment: import.meta.env.MODE,
// Set tracesSampleRate to 1.0 to capture 100% // Set tracesSampleRate to 1.0 to capture 100%

View file

@ -10,48 +10,45 @@
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:types": "tsc --noEmit --project ./tsconfig.json", "test:types": "tsc --noEmit --project ./tsconfig.json",
"lint": "eslint --ext .ts,.js,.tsx,.jsx ./",
"analyze:css": "npx windicss-analysis", "analyze:css": "npx windicss-analysis",
"analyze:js": "npm run build && open-cli bundle-stats-renderer.html", "analyze:js": "npm run build && open-cli bundle-stats-renderer.html",
"storybook": "start-storybook -p 6006", "storybook": "start-storybook -p 6006",
"storybook:build": "build-storybook", "storybook:build": "build-storybook",
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js", "generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
"api:netease": "npx NeteaseCloudMusicApi@latest", "api:netease": "npx NeteaseCloudMusicApi@latest"
"format": "prettier --config ../../prettier.config.js --write './**/*.{ts,tsx,js,jsx,css}' --ignore-path ../../.prettierignore"
}, },
"engines": { "engines": {
"node": "^14.13.1 || >=16.0.0" "node": "^14.13.1 || >=16.0.0"
}, },
"dependencies": { "dependencies": {
"@emotion/css": "^11.10.0", "@emotion/css": "^11.10.5",
"@sentry/react": "^7.8.1", "@sentry/react": "^7.29.0",
"@sentry/tracing": "^7.8.1", "@sentry/tracing": "^7.29.0",
"@tanstack/react-query": "^4.0.10", "@tanstack/react-query": "^4.20.9",
"@tanstack/react-query-devtools": "^4.0.10", "@tanstack/react-query-devtools": "^4.20.9",
"ahooks": "^3.6.2", "ahooks": "^3.7.4",
"axios": "^0.27.2", "axios": "^1.2.2",
"color.js": "^1.2.0", "color.js": "^1.2.0",
"colord": "^2.9.2", "colord": "^2.9.3",
"dayjs": "^1.11.4", "dayjs": "^1.11.7",
"framer-motion": "^6.5.1", "framer-motion": "^8.1.7",
"hls.js": "^1.2.0", "hls.js": "^1.2.9",
"howler": "^2.2.3", "howler": "^2.2.3",
"i18next": "^21.9.1", "i18next": "^21.9.1",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"md5": "^2.3.0", "md5": "^2.3.0",
"plyr-react": "^5.1.0",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-ga4": "^1.4.1", "react-ga4": "^1.4.1",
"react-hot-toast": "^2.3.0", "react-hot-toast": "^2.4.0",
"react-i18next": "^11.18.4", "react-i18next": "^11.18.4",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.6.1",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"react-use-measure": "^2.1.1", "react-use-measure": "^2.1.1",
"react-virtuoso": "^2.16.6", "react-virtuoso": "^2.16.6",
"valtio": "^1.6.3" "valtio": "^1.8.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "^6.5.5", "@storybook/addon-actions": "^6.5.5",
@ -71,28 +68,23 @@
"@types/qrcode": "^1.4.2", "@types/qrcode": "^1.4.2",
"@types/react": "^18.0.15", "@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.32.0", "@vitejs/plugin-react-swc": "^3.0.1",
"@typescript-eslint/parser": "^5.32.0", "@vitest/ui": "^0.26.3",
"@vitejs/plugin-react": "^2.0.0", "autoprefixer": "^10.4.13",
"@vitest/ui": "^0.20.3",
"autoprefixer": "^10.4.8",
"c8": "^7.12.0", "c8": "^7.12.0",
"dotenv": "^16.0.1", "dotenv": "^16.0.3",
"eslint": "*", "jsdom": "^20.0.3",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^20.0.0",
"open-cli": "^7.0.1", "open-cli": "^7.0.1",
"postcss": "^8.4.14", "postcss": "^8.4.20",
"prettier": "*", "prettier": "*",
"prettier-plugin-tailwindcss": "^0.1.11", "prettier-plugin-tailwindcss": "*",
"rollup-plugin-visualizer": "^5.6.0", "rollup-plugin-visualizer": "^5.9.0",
"storybook-tailwind-dark-mode": "^1.0.12", "storybook-tailwind-dark-mode": "^1.0.12",
"tailwindcss": "^3.1.7", "tailwindcss": "^3.2.4",
"typescript": "*", "typescript": "*",
"vite": "^3.0.4", "vite": "^4.0.4",
"vite-plugin-pwa": "^0.12.3", "vite-plugin-pwa": "^0.14.1",
"vite-plugin-svg-icons": "^2.0.1", "vite-plugin-svg-icons": "^2.0.1",
"vitest": "^0.20.3" "vitest": "^0.26.3"
} }
} }

View file

@ -2,7 +2,7 @@ import Header from './Header'
import Popular from './Popular' import Popular from './Popular'
import ArtistAlbum from './ArtistAlbums' import ArtistAlbum from './ArtistAlbums'
import FansAlsoLike from './FansAlsoLike' import FansAlsoLike from './FansAlsoLike'
import ArtistMVs from './ArtistMVs' import ArtistVideos from './ArtistVideos'
const Artist = () => { const Artist = () => {
return ( return (
@ -12,7 +12,7 @@ const Artist = () => {
<div className='mt-10 mb-7.5 h-px w-full bg-white/20'></div> <div className='mt-10 mb-7.5 h-px w-full bg-white/20'></div>
<Popular /> <Popular />
<ArtistAlbum /> <ArtistAlbum />
<ArtistMVs /> <ArtistVideos />
<FansAlsoLike /> <FansAlsoLike />
{/* Page padding */} {/* Page padding */}

View file

@ -1,11 +1,11 @@
import { useNavigate, useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import useArtistMV from '@/web/api/hooks/useArtistMV' import useArtistMV from '@/web/api/hooks/useArtistMV'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import uiStates from '@/web/states/uiStates'
const ArtistMVs = () => { const ArtistVideos = () => {
const { t } = useTranslation() const { t } = useTranslation()
const params = useParams() const params = useParams()
const navigate = useNavigate()
const { data: videos } = useArtistMV({ id: Number(params.id) || 0 }) const { data: videos } = useArtistMV({ id: Number(params.id) || 0 })
return ( return (
@ -16,10 +16,13 @@ const ArtistMVs = () => {
<div className='grid grid-cols-3 gap-6'> <div className='grid grid-cols-3 gap-6'>
{videos?.mvs?.slice(0, 6)?.map(video => ( {videos?.mvs?.slice(0, 6)?.map(video => (
<div key={video.id} onClick={() => navigate(`/mv/${video.id}`)}> <div
key={video.id}
onClick={() => (uiStates.playingVideoID = video.id)}
>
<img <img
src={video.imgurl16v9} src={video.imgurl16v9}
className='aspect-video w-full rounded-24 object-contain' className='aspect-video w-full rounded-24 border border-white/5 object-contain'
/> />
<div className='mt-2 text-12 font-medium text-neutral-600'> <div className='mt-2 text-12 font-medium text-neutral-600'>
{video.name} {video.name}
@ -31,4 +34,4 @@ const ArtistMVs = () => {
) )
} }
export default ArtistMVs export default ArtistVideos

View file

@ -8,6 +8,7 @@ import { useMemo } from 'react'
import useArtistMV from '@/web/api/hooks/useArtistMV' import useArtistMV from '@/web/api/hooks/useArtistMV'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import uiStates from '@/web/states/uiStates'
const Album = ({ album }: { album?: Album }) => { const Album = ({ album }: { album?: Album }) => {
const navigate = useNavigate() const navigate = useNavigate()
@ -49,14 +50,12 @@ const Album = ({ album }: { album?: Album }) => {
} }
const Video = ({ video }: { video?: any }) => { const Video = ({ video }: { video?: any }) => {
const navigate = useNavigate()
return ( return (
<> <>
{video && ( {video && (
<div <div
className='group mt-4 flex rounded-24 bg-white/10 p-2.5 transition-colors duration-400 hover:bg-white/20' className='group mt-4 flex rounded-24 bg-white/10 p-2.5 transition-colors duration-400 hover:bg-white/20'
onClick={() => navigate(`/mv/${video.id}`)} onClick={() => (uiStates.playingVideoID = video.id)}
> >
<img <img
src={video.imgurl16v9} src={video.imgurl16v9}

View file

@ -5,7 +5,6 @@ import {
fetchFromCache, fetchFromCache,
} from '@/web/api/hooks/usePlaylist' } from '@/web/api/hooks/usePlaylist'
import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks' import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
import { useEffect, useState } from 'react'
import { sampleSize } from 'lodash-es' import { sampleSize } from 'lodash-es'
import { FetchPlaylistResponse } from '@/shared/api/Playlists' import { FetchPlaylistResponse } from '@/shared/api/Playlists'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
@ -31,27 +30,19 @@ const getAlbumsFromAPI = async () => {
7463185187, // 开发者夹带私货 7463185187, // 开发者夹带私货
] ]
const playlists = (await Promise.all( const playlists: FetchPlaylistResponse[] = await Promise.all(
sampleSize(playlistsIds, 5).map( sampleSize(playlistsIds, 5).map(async id => {
id => const cache = await fetchFromCache({ id })
new Promise(resolve => { if (cache) return cache
const cache = fetchFromCache(id) return fetchPlaylistWithReactQuery({ id })
if (cache) { })
resolve(cache) )
return
}
resolve(fetchPlaylistWithReactQuery({ id }))
})
)
)) as FetchPlaylistResponse[]
let ids: number[] = [] let ids: number[] = []
playlists.forEach(playlist => playlists.forEach(playlist =>
playlist?.playlist?.trackIds?.forEach(t => ids.push(t.id)) playlist?.playlist?.trackIds?.forEach(t => ids.push(t.id))
) )
if (!ids.length) { if (!ids.length) return []
return []
}
ids = sampleSize(ids, 100) ids = sampleSize(ids, 100)
const tracks = await fetchTracksWithReactQuery({ ids }) const tracks = await fetchTracksWithReactQuery({ ids })

View file

@ -1,62 +0,0 @@
import PageTransition from '@/web/components/PageTransition'
import useMV, { useMVUrl } from '@/web/api/hooks/useMV'
import { useParams } from 'react-router-dom'
import Plyr, { PlyrOptions, PlyrSource } from 'plyr-react'
import 'plyr-react/plyr.css'
import { useMemo } from 'react'
import { css, cx } from '@emotion/css'
const plyrStyle = css`
--plyr-color-main: rgb(152 208 11);
--plyr-video-control-background-hover: rgba(255, 255, 255, 0.3);
--plyr-control-radius: 8px;
--plyr-range-fill-background: white;
button[data-plyr='play']:not(.plyr__controls__item) {
--plyr-video-control-background-hover: var(--plyr-color-main);
}
`
const plyrOptions: PlyrOptions = {
settings: [],
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'fullscreen',
],
resetOnEnd: true,
ratio: '16:9',
}
const MV = () => {
const params = useParams()
const { data: mv } = useMV({ mvid: Number(params.id) || 0 })
const { data: mvUrl } = useMVUrl({ id: Number(params.id) || 0 })
const source: PlyrSource = useMemo(
() => ({
type: 'video',
sources: [
{
src: mvUrl?.data?.url || '',
},
],
poster: mv?.data.cover,
title: mv?.data.name,
}),
[mv?.data.cover, mv?.data.name, mvUrl?.data?.url]
)
return (
<PageTransition>
<div className='text-white'>{mv?.data.name}</div>
<div className={cx('aspect-video overflow-hidden rounded-24', plyrStyle)}>
{mvUrl && <Plyr options={plyrOptions} source={source} />}
</div>
</PageTransition>
)
}
export default MV

View file

@ -15,6 +15,8 @@ import { AnimatePresence, motion } from 'framer-motion'
import { scrollToBottom } from '@/web/utils/common' import { scrollToBottom } from '@/web/utils/common'
import { throttle } from 'lodash-es' import { throttle } from 'lodash-es'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import VideoRow from '@/web/components/VideoRow'
import useUserVideos from '@/web/api/hooks/useUserVideos'
const Albums = () => { const Albums = () => {
const { data: albums } = useUserAlbums() const { data: albums } = useUserAlbums()
@ -35,6 +37,11 @@ const Artists = () => {
return <ArtistRow artists={artists?.data || []} /> return <ArtistRow artists={artists?.data || []} />
} }
const Videos = () => {
const { data: videos } = useUserVideos()
return <VideoRow videos={videos?.data || []} />
}
const CollectionTabs = ({ showBg }: { showBg: boolean }) => { const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -130,6 +137,7 @@ const Collections = () => {
{selectedTab === 'albums' && <Albums />} {selectedTab === 'albums' && <Albums />}
{selectedTab === 'playlists' && <Playlists />} {selectedTab === 'playlists' && <Playlists />}
{selectedTab === 'artists' && <Artists />} {selectedTab === 'artists' && <Artists />}
{selectedTab === 'videos' && <Videos />}
</div> </div>
<div ref={observePoint}></div> <div ref={observePoint}></div>
</div> </div>

View file

@ -9,6 +9,7 @@ interface UIStates {
mobileShowPlayingNext: boolean mobileShowPlayingNext: boolean
blurBackgroundImage: string | null blurBackgroundImage: string | null
fullscreen: boolean fullscreen: boolean
playingVideoID: number | null
} }
const initUIStates: UIStates = { const initUIStates: UIStates = {
@ -19,10 +20,16 @@ const initUIStates: UIStates = {
mobileShowPlayingNext: false, mobileShowPlayingNext: false,
blurBackgroundImage: null, blurBackgroundImage: null,
fullscreen: false, fullscreen: false,
playingVideoID: null,
} }
window.ipcRenderer window.ipcRenderer
?.invoke(IpcChannels.IsMaximized) ?.invoke(IpcChannels.IsMaximized)
.then(isMaximized => (initUIStates.fullscreen = !!isMaximized)) .then(isMaximized => (initUIStates.fullscreen = !!isMaximized))
export default proxy<UIStates>(initUIStates) const uiStates = proxy<UIStates>(initUIStates)
export default uiStates
export const closeVideoPlayer = () => {
uiStates.playingVideoID = null
}

View file

@ -93,10 +93,9 @@
body, body,
input { input {
font-family: Roboto, ui-sans-serif, system-ui, -apple-system, font-family: Roboto, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue,
BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei, PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei,
Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei, microsoft uighur, microsoft uighur, sans-serif;
sans-serif;
} }
body { body {

View file

@ -8,7 +8,7 @@ const fontSizeDefault = {
} }
module.exports = { module.exports = {
content: ['./index.html', './**/*.{vue,js,ts,jsx,tsx}'], content: ['./index.html', './**/*.{vue,js,ts,jsx,tsx}', '!./node_modules/**/*'],
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: { extend: {
@ -110,7 +110,15 @@ module.exports = {
15: '.15', 15: '.15',
25: '.25', 25: '.25',
}, },
backdropBlur: {
sm: '2px',
DEFAULT: '4px',
md: '6px',
lg: '8px',
xl: '12px',
'2xl': '20px',
'3xl': '45px',
}
}, },
}, },
variants: {},
} }

View file

@ -35,6 +35,10 @@ export function resizeImage(
) )
} }
export function toHttps(url: string | undefined): string {
return url ? url.replace(/^http:/, 'https:') : ''
}
export const storage = { export const storage = {
get(key: string): object | [] | null { get(key: string): object | [] | null {
const text = localStorage.getItem(key) const text = localStorage.getItem(key)

View file

@ -1,3 +1,5 @@
export const appName = 'R3Play'
// 动画曲线 // 动画曲线
export const ease: [number, number, number, number] = [0.4, 0, 0.2, 1] export const ease: [number, number, number, number] = [0.4, 0, 0.2, 1]

View file

@ -5,7 +5,7 @@ import {
} from '@/web/api/hooks/useTracks' } from '@/web/api/hooks/useTracks'
import { fetchPersonalFMWithReactQuery } from '@/web/api/hooks/usePersonalFM' import { fetchPersonalFMWithReactQuery } from '@/web/api/hooks/usePersonalFM'
import { fmTrash } from '@/web/api/personalFM' import { fmTrash } from '@/web/api/personalFM'
import { cacheAudio } from '@/web/api/yesplaymusic' import { cacheAudio } from '@/web/api/r3play'
import { clamp } from 'lodash-es' import { clamp } from 'lodash-es'
import axios from 'axios' import axios from 'axios'
import { resizeImage } from './common' import { resizeImage } from './common'
@ -16,6 +16,7 @@ import { RepeatMode } from '@/shared/playerDataTypes'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { scrobble } from '@/web/api/user' import { scrobble } from '@/web/api/user'
import { fetchArtistWithReactQuery } from '../api/hooks/useArtist' import { fetchArtistWithReactQuery } from '../api/hooks/useArtist'
import { appName } from './const'
type TrackID = number type TrackID = number
export enum TrackListSourceType { export enum TrackListSourceType {
@ -200,22 +201,19 @@ export class Player {
}, 1000) }, 1000)
} else if (this._isAirplay) { } else if (this._isAirplay) {
// Airplay // Airplay
let isFetchAirplayPlayingInfo = false // let isFetchAirplayPlayingInfo = false
this._progressInterval = setInterval(async () => { // this._progressInterval = setInterval(async () => {
if (isFetchAirplayPlayingInfo) return // if (isFetchAirplayPlayingInfo) return
// isFetchAirplayPlayingInfo = true
isFetchAirplayPlayingInfo = true // const playingInfo = await window?.ipcRenderer?.invoke(
// 'airplay-get-playing',
const playingInfo = await window?.ipcRenderer?.invoke( // { deviceID: this.remoteDevice?.id }
'airplay-get-playing', // )
{ deviceID: this.remoteDevice?.id } // if (playingInfo) {
) // this._progress = playingInfo.position || 0
if (playingInfo) { // }
this._progress = playingInfo.position || 0 // isFetchAirplayPlayingInfo = false
} // }, 1000)
isFetchAirplayPlayingInfo = false
}, 1000)
} }
} }
@ -328,15 +326,15 @@ export class Player {
} }
private async _playAudioViaAirplay(audio: string) { private async _playAudioViaAirplay(audio: string) {
if (!this._isAirplay) { // if (!this._isAirplay) {
console.log('No airplay device selected') // console.log('No airplay device selected')
return // return
} // }
const result = await window.ipcRenderer?.invoke('airplay-play-url', { // const result = await window.ipcRenderer?.invoke('airplay-play-url', {
deviceID: this.remoteDevice?.id, // deviceID: this.remoteDevice?.id,
url: audio, // url: audio,
}) // })
console.log(result) // console.log(result)
} }
private _howlerOnEndCallback() { private _howlerOnEndCallback() {
@ -349,7 +347,7 @@ export class Player {
} }
private _cacheAudio(audio: string) { private _cacheAudio(audio: string) {
if (audio.includes('yesplaymusic') || !window.ipcRenderer) return if (audio.includes(appName.toLowerCase()) || !window.ipcRenderer) return
const id = Number(new URL(audio).searchParams.get('dash-id')) const id = Number(new URL(audio).searchParams.get('dash-id'))
if (isNaN(id) || !id) return if (isNaN(id) || !id) return
cacheAudio(id, audio) cacheAudio(id, audio)

View file

@ -1,5 +1,5 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react-swc'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import path, { join } from 'path' import path, { join } from 'path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
@ -7,6 +7,7 @@ import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import filenamesToType from './vitePluginFilenamesToType' import filenamesToType from './vitePluginFilenamesToType'
import { appName } from './utils/const'
dotenv.config({ path: path.resolve(process.cwd(), '../../.env') }) dotenv.config({ path: path.resolve(process.cwd(), '../../.env') })
const IS_ELECTRON = process.env.IS_ELECTRON const IS_ELECTRON = process.env.IS_ELECTRON
@ -21,7 +22,7 @@ export default defineConfig({
base: '/', base: '/',
resolve: { resolve: {
alias: { alias: {
'@': join(__dirname, '../'), '@': join(__dirname, '..'),
}, },
}, },
plugins: [ plugins: [
@ -36,32 +37,33 @@ export default defineConfig({
/** /**
* @see https://vite-plugin-pwa.netlify.app/guide/generate.html * @see https://vite-plugin-pwa.netlify.app/guide/generate.html
*/ */
VitePWA({ // VitePWA({
manifest: { // registerType: 'autoUpdate',
name: 'YesPlayMusic', // manifest: {
short_name: 'YPM', // name: appName,
description: 'Description of your app', // short_name: appName,
theme_color: '#000', // description: 'Description of your app',
icons: [ // theme_color: '#000',
{ // icons: [
src: 'pwa-192x192.png', // {
sizes: '192x192', // src: 'pwa-192x192.png',
type: 'image/png', // sizes: '192x192',
}, // type: 'image/png',
{ // },
src: 'pwa-512x512.png', // {
sizes: '512x512', // src: 'pwa-512x512.png',
type: 'image/png', // sizes: '512x512',
}, // type: 'image/png',
{ // },
src: 'pwa-512x512.png', // {
sizes: '512x512', // src: 'pwa-512x512.png',
type: 'image/png', // sizes: '512x512',
purpose: 'any maskable', // type: 'image/png',
}, // purpose: 'any maskable',
], // },
}, // ],
}), // },
// }),
/** /**
* @see https://github.com/vbenjs/vite-plugin-svg-icons * @see https://github.com/vbenjs/vite-plugin-svg-icons
@ -78,12 +80,12 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
rollupOptions: { rollupOptions: {
plugins: [ plugins: [
visualizer({ // visualizer({
filename: './bundle-stats.html', // filename: './bundle-stats.html',
gzipSize: true, // gzipSize: true,
projectRoot: './', // projectRoot: './',
template: 'treemap', // template: 'treemap',
}), // }),
], ],
}, },
}, },
@ -93,20 +95,16 @@ export default defineConfig({
proxy: { proxy: {
'/netease/': { '/netease/': {
// target: `http://192.168.50.111:${ // target: `http://192.168.50.111:${
target: `http://127.0.0.1:${ target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
}`,
changeOrigin: true, changeOrigin: true,
rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')), rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
}, },
'/yesplaymusic/video-cover': { [`/${appName.toLowerCase()}/video-cover`]: {
target: `http://168.138.40.199:51324`, target: `http://168.138.40.199:51324`,
changeOrigin: true, changeOrigin: true,
}, },
'/yesplaymusic/': { [`/${appName.toLowerCase()}/`]: {
target: `http://127.0.0.1:${ target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
}`,
changeOrigin: true, changeOrigin: true,
}, },
}, },

4361
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ module.exports = {
htmlWhitespaceSensitivity: 'strict', htmlWhitespaceSensitivity: 'strict',
singleQuote: true, singleQuote: true,
jsxSingleQuote: true, jsxSingleQuote: true,
printWidth: 100,
// Tailwind CSS // Tailwind CSS
plugins: [require('prettier-plugin-tailwindcss')], plugins: [require('prettier-plugin-tailwindcss')],

View file

@ -8,23 +8,30 @@
"cache": false "cache": false
}, },
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": [
"outputs": ["dist/**"], "^build"
],
"outputs": [
"dist/**"
],
"cache": false "cache": false
}, },
"pack": { "pack": {
"outputs": ["release/**"], "outputs": [
"release/**"
],
"cache": false "cache": false
}, },
"pack:test": { "pack:test": {
"outputs": ["release/**"], "outputs": [
"release/**"
],
"cache": false "cache": false
}, },
"test": { "test": {
"dependsOn": ["build"], "dependsOn": [
"outputs": [] "build"
}, ],
"lint": {
"outputs": [] "outputs": []
} }
} }