mirror of
https://github.com/GiriNeko/YesPlayMusic.git
synced 2025-12-15 20:58:01 +00:00
feat: updates
This commit is contained in:
parent
884f3df41a
commit
c6c59b2cd9
84 changed files with 3531 additions and 2616 deletions
|
|
@ -1,3 +1,3 @@
|
|||
ELECTRON_WEB_SERVER_PORT = 42710
|
||||
ELECTRON_DEV_NETEASE_API_PORT = 3000
|
||||
ELECTRON_DEV_NETEASE_API_PORT = 30001
|
||||
VITE_APP_NETEASE_API_URL = /netease
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ module.exports = {
|
|||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
|
|
@ -20,8 +21,6 @@ module.exports = {
|
|||
},
|
||||
plugins: ['react', '@typescript-eslint', 'react-hooks'],
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
},
|
||||
|
|
|
|||
6
.github/workflows/build.yaml
vendored
6
.github/workflows/build.yaml
vendored
|
|
@ -71,20 +71,20 @@ jobs:
|
|||
- name: Upload Artifact (macOS)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: YesPlayMusic-mac
|
||||
name: R3Play-mac
|
||||
path: release/*-universal.dmg
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload Artifact (Windows)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: YesPlayMusic-win
|
||||
name: R3Play-win
|
||||
path: release/*x64-Setup.exe
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload Artifact (Linux)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: YesPlayMusic-linux
|
||||
name: R3Play-linux
|
||||
path: release/*.AppImage
|
||||
if-no-files-found: ignore
|
||||
|
|
|
|||
20
package.json
20
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "yesplamusic",
|
||||
"productName": "YesPlayMusic-alpha",
|
||||
"name": "r3play",
|
||||
"productName": "R3Play",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"description": "A nifty third-party NetEase Music player",
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"author": "qier222 <qier222@outlook.com>",
|
||||
"repository": "github:qier222/YesPlayMusic",
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@7.0.0",
|
||||
"scripts": {
|
||||
|
|
@ -19,16 +19,18 @@
|
|||
"pack": "turbo run build && turbo run pack",
|
||||
"pack:test": "turbo run build && turbo run pack:test",
|
||||
"dev": "cross-env-shell IS_ELECTRON=yes turbo run dev --parallel",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,mjs,js,jsx,md,css}\"",
|
||||
"storybook": "pnpm -F web storybook",
|
||||
"storybook:build": "pnpm -F web storybook:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.21.0",
|
||||
"prettier": "^2.7.1",
|
||||
"turbo": "^1.6.1",
|
||||
"typescript": "^4.7.4"
|
||||
"eslint": "^8.31.0",
|
||||
"prettier": "^2.8.1",
|
||||
"turbo": "^1.6.3",
|
||||
"typescript": "^4.9.4",
|
||||
"tsx": "^3.12.1",
|
||||
"prettier-plugin-tailwindcss": "^0.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const pkg = require('./package.json')
|
|||
const electronVersion = pkg.devDependencies.electron.replaceAll('^', '')
|
||||
|
||||
module.exports = {
|
||||
appId: 'com.qier222.yesplaymusic.alpha',
|
||||
appId: 'app.r3play',
|
||||
productName: pkg.productName,
|
||||
copyright: 'Copyright © 2022 qier222',
|
||||
asar: true,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { app } from 'electron'
|
|||
import fs from 'fs'
|
||||
import SQLite3 from 'better-sqlite3'
|
||||
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 { compare, validate } from 'compare-versions'
|
||||
import os from 'os'
|
||||
|
|
|
|||
6
packages/desktop/main/env.ts
Normal file
6
packages/desktop/main/env.ts
Normal 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'
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
import './preload' // must be first
|
||||
import './sentry'
|
||||
import './server'
|
||||
import {
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
app,
|
||||
shell,
|
||||
} from 'electron'
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions, app, shell } from 'electron'
|
||||
import { release } from 'os'
|
||||
import { join } from 'path'
|
||||
import log from './log'
|
||||
|
|
@ -15,7 +10,7 @@ import { createTray, YPMTray } from './tray'
|
|||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { createTaskbar, Thumbar } from './windowsTaskbar'
|
||||
import { createMenu } from './menu'
|
||||
import { isDev, isWindows, isLinux, isMac } from './utils'
|
||||
import { isDev, isWindows, isLinux, isMac, appName } from './env'
|
||||
import store from './store'
|
||||
// import './surrealdb'
|
||||
// import Airplay from './airplay'
|
||||
|
|
@ -81,7 +76,7 @@ class Main {
|
|||
|
||||
createWindow() {
|
||||
const options: BrowserWindowConstructorOptions = {
|
||||
title: 'YesPlayMusic',
|
||||
title: appName,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'rendererPreload.js'),
|
||||
},
|
||||
|
|
@ -93,6 +88,7 @@ class Main {
|
|||
trafficLightPosition: { x: 24, y: 24 },
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||
show: false,
|
||||
}
|
||||
if (store.get('window')) {
|
||||
|
|
@ -132,35 +128,31 @@ class Main {
|
|||
return headers
|
||||
}
|
||||
|
||||
this.win.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
(details, callback) => {
|
||||
const { requestHeaders, url } = details
|
||||
addCORSHeaders(requestHeaders)
|
||||
this.win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const { requestHeaders, url } = details
|
||||
addCORSHeaders(requestHeaders)
|
||||
|
||||
// 不加这几个 header 的话,使用 axios 加载 YouTube 音频会很慢
|
||||
if (url.includes('googlevideo.com')) {
|
||||
requestHeaders['Sec-Fetch-Mode'] = 'no-cors'
|
||||
requestHeaders['Sec-Fetch-Dest'] = 'audio'
|
||||
requestHeaders['Range'] = 'bytes=0-'
|
||||
}
|
||||
|
||||
callback({ requestHeaders })
|
||||
// 不加这几个 header 的话,使用 axios 加载 YouTube 音频会很慢
|
||||
if (url.includes('googlevideo.com')) {
|
||||
requestHeaders['Sec-Fetch-Mode'] = 'no-cors'
|
||||
requestHeaders['Sec-Fetch-Dest'] = 'audio'
|
||||
requestHeaders['Range'] = 'bytes=0-'
|
||||
}
|
||||
)
|
||||
|
||||
this.win.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
const { responseHeaders, url } = details
|
||||
if (url.includes('sentry.io')) {
|
||||
callback({ responseHeaders })
|
||||
return
|
||||
}
|
||||
if (responseHeaders) {
|
||||
addCORSHeaders(responseHeaders)
|
||||
}
|
||||
callback({ requestHeaders })
|
||||
})
|
||||
|
||||
this.win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const { responseHeaders, url } = details
|
||||
if (url.includes('sentry.io')) {
|
||||
callback({ responseHeaders })
|
||||
return
|
||||
}
|
||||
)
|
||||
if (responseHeaders) {
|
||||
addCORSHeaders(responseHeaders)
|
||||
}
|
||||
callback({ responseHeaders })
|
||||
})
|
||||
}
|
||||
|
||||
handleWindowEvents() {
|
||||
|
|
@ -176,13 +168,11 @@ class Main {
|
|||
})
|
||||
|
||||
this.win.on('enter-full-screen', () => {
|
||||
this.win &&
|
||||
this.win.webContents.send(IpcChannels.FullscreenStateChange, true)
|
||||
this.win && this.win.webContents.send(IpcChannels.FullscreenStateChange, true)
|
||||
})
|
||||
|
||||
this.win.on('leave-full-screen', () => {
|
||||
this.win &&
|
||||
this.win.webContents.send(IpcChannels.FullscreenStateChange, false)
|
||||
this.win && this.win.webContents.send(IpcChannels.FullscreenStateChange, false)
|
||||
})
|
||||
|
||||
// Save window position
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import log from 'electron-log'
|
||||
import pc from 'picocolors'
|
||||
import { isDev } from './utils'
|
||||
import { isDev } from './env'
|
||||
|
||||
Object.assign(console, log.functions)
|
||||
log.variables.process = 'main'
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import {
|
|||
MenuItemConstructorOptions,
|
||||
shell,
|
||||
} from 'electron'
|
||||
import { logsPath, isMac } from './utils'
|
||||
import { isMac } from './env'
|
||||
import { logsPath } from './utils'
|
||||
import { exec } from 'child_process'
|
||||
|
||||
export const createMenu = (win: BrowserWindow) => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import log from './log'
|
||||
import { app } from 'electron'
|
||||
import { isDev } from './env'
|
||||
import {
|
||||
createDirIfNotExist,
|
||||
devUserDataPath,
|
||||
isDev,
|
||||
portableUserDataPath,
|
||||
devUserDataPath,
|
||||
} from './utils'
|
||||
|
||||
if (isDev) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { isLinux, isMac, isProd, isWindows } from './utils'
|
||||
import { isLinux, isMac, isProd, isWindows } from './env'
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
if (isProd) {
|
||||
const log = require('electron-log')
|
||||
log.transports.file.level = 'info'
|
||||
log.transports.ipc.level = false
|
||||
log.variables.process = 'renderer'
|
||||
contextBridge.exposeInMainWorld('log', log)
|
||||
}
|
||||
// if (isProd) {
|
||||
// const log = require('electron-log')
|
||||
// log.transports.file.level = 'info'
|
||||
// log.transports.ipc.level = false
|
||||
// log.variables.process = 'renderer'
|
||||
// contextBridge.exposeInMainWorld('log', log)
|
||||
// }
|
||||
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
invoke: ipcRenderer.invoke,
|
||||
|
|
@ -27,8 +27,7 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
|
|||
|
||||
contextBridge.exposeInMainWorld('env', {
|
||||
isElectron: true,
|
||||
isEnableTitlebar:
|
||||
process.platform === 'win32' || process.platform === 'linux',
|
||||
isEnableTitlebar: process.platform === 'win32' || process.platform === 'linux',
|
||||
isLinux,
|
||||
isMac,
|
||||
isWindows,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import * as Sentry from '@sentry/electron'
|
||||
import pkg from '../../../package.json'
|
||||
import { appName } from './env'
|
||||
import log from './log'
|
||||
|
||||
log.info(`[sentry] sentry initializing`)
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://2aaaa67f1c3d4d6baefafa5d58fcf340@o436528.ingest.sentry.io/6274637',
|
||||
release: `yesplaymusic@${pkg.version}`,
|
||||
release: `${appName}@${pkg.version}`,
|
||||
environment: process.env.NODE_ENV,
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import fs from 'fs'
|
|||
import { app } from 'electron'
|
||||
import type { FetchAudioSourceResponse } from '@/shared/api/Track'
|
||||
import { APIs as CacheAPIs } from '@/shared/CacheAPIs'
|
||||
import { isProd } from './utils'
|
||||
import { appName, isProd } from './env'
|
||||
import { APIs } from '@/shared/CacheAPIs'
|
||||
import history from 'connect-history-api-fallback'
|
||||
import { db, Tables } from './db'
|
||||
|
|
@ -19,7 +19,7 @@ class Server {
|
|||
port = Number(
|
||||
isProd
|
||||
? 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()
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
|
@ -39,9 +39,7 @@ class Server {
|
|||
neteaseHandler() {
|
||||
Object.entries(this.netease).forEach(([name, handler]: [string, any]) => {
|
||||
// 例外处理
|
||||
if (
|
||||
['serveNcmApi', 'getModulesDefinitions', APIs.SongUrl].includes(name)
|
||||
) {
|
||||
if (['serveNcmApi', 'getModulesDefinitions', APIs.SongUrl].includes(name)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +101,7 @@ class Server {
|
|||
{
|
||||
source: cache.source,
|
||||
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,
|
||||
size: 0,
|
||||
md5: '',
|
||||
|
|
@ -137,9 +135,7 @@ class Server {
|
|||
}
|
||||
}
|
||||
|
||||
const getFromNetease = async (
|
||||
req: Request
|
||||
): Promise<FetchAudioSourceResponse | undefined> => {
|
||||
const getFromNetease = async (req: Request): Promise<FetchAudioSourceResponse | undefined> => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const getSongUrl = (require('NeteaseCloudMusicApi') as any).song_url
|
||||
|
|
@ -155,12 +151,10 @@ class Server {
|
|||
// const unmExecutor = new UNM.Executor()
|
||||
const getFromUNM = async (id: number, req: Request) => {
|
||||
log.debug('[server] Fetching audio url from UNM')
|
||||
let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) })
|
||||
?.songs?.[0]
|
||||
let track: Track = cache.get(CacheAPIs.Track, { ids: String(id) })?.songs?.[0]
|
||||
if (!track) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const getSongDetail = (require('NeteaseCloudMusicApi') as any)
|
||||
.song_detail
|
||||
const getSongDetail = (require('NeteaseCloudMusicApi') as any).song_detail
|
||||
|
||||
track = await getSongDetail({ ...req.query, cookie: req.cookies })
|
||||
}
|
||||
|
|
@ -184,14 +178,9 @@ class Server {
|
|||
|
||||
const sourceList = ['ytdl']
|
||||
const context = {}
|
||||
const matchedAudio = await unmExecutor.search(
|
||||
sourceList,
|
||||
trackForUNM,
|
||||
context
|
||||
)
|
||||
const matchedAudio = await unmExecutor.search(sourceList, trackForUNM, context)
|
||||
const retrievedSong = await unmExecutor.retrieve(matchedAudio, context)
|
||||
const source =
|
||||
retrievedSong.source === 'ytdl' ? 'youtube' : retrievedSong.source
|
||||
const source = retrievedSong.source === 'ytdl' ? 'youtube' : retrievedSong.source
|
||||
if (retrievedSong.url) {
|
||||
log.debug(
|
||||
`[server] UMN match: ${matchedAudio.song?.name} (https://youtube.com/v/${matchedAudio.song?.id})`
|
||||
|
|
@ -291,45 +280,38 @@ class Server {
|
|||
|
||||
cacheAudioHandler() {
|
||||
this.app.get(
|
||||
'/yesplaymusic/audio/:filename',
|
||||
`/${appName.toLowerCase()}/audio/:filename`,
|
||||
async (req: Request, res: Response) => {
|
||||
cache.getAudio(req.params.filename, res)
|
||||
}
|
||||
)
|
||||
this.app.post(
|
||||
'/yesplaymusic/audio/:id',
|
||||
async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id)
|
||||
const { url } = req.query
|
||||
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 })
|
||||
}
|
||||
this.app.post(`/${appName.toLowerCase()}/audio/:id`, async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id)
|
||||
const { url } = req.query
|
||||
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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
listen() {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from 'electron'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import { RepeatMode } from '@/shared/playerDataTypes'
|
||||
import { appName } from './env'
|
||||
|
||||
const iconDirRoot =
|
||||
process.env.NODE_ENV === 'development'
|
||||
|
|
@ -134,7 +135,7 @@ class YPMTrayImpl implements YPMTray {
|
|||
this._contextMenu = Menu.buildFromTemplate(this._template)
|
||||
|
||||
this._updateContextMenu()
|
||||
this.setTooltip('YesPlayMusic')
|
||||
this.setTooltip(appName)
|
||||
|
||||
this._tray.on('click', () => win.show())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,13 @@ import fs from 'fs'
|
|||
import path from 'path'
|
||||
import os from 'os'
|
||||
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 devUserDataPath = path.resolve(process.cwd(), '../../tmp/userData')
|
||||
export const portableUserDataPath = path.resolve(
|
||||
process.env.PORTABLE_EXECUTABLE_DIR || '',
|
||||
'./YesPlayMusic-UserData'
|
||||
`./${appName.toLowerCase()}-UserData`
|
||||
)
|
||||
export const logsPath = {
|
||||
linux: `~/.config/${pkg.productName}/logs`,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
{
|
||||
"name": "desktop",
|
||||
"productName": "YesPlayMusic-alpha",
|
||||
"productName": "R3Play",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"main": "./main/index.js",
|
||||
"author": "*",
|
||||
"scripts": {
|
||||
"post-install": "node scripts/build.sqlite3.js",
|
||||
"dev": "node scripts/build.main.mjs --watch",
|
||||
"build": "node scripts/build.main.mjs",
|
||||
"post-install": "tsx scripts/build.sqlite3.ts",
|
||||
"dev": "tsx scripts/build.main.ts --watch",
|
||||
"build": "tsx scripts/build.main.ts",
|
||||
"pack": "electron-builder build -c .electron-builder.config.js",
|
||||
"pack:test": "electron-builder build -c .electron-builder.config.js --publish never --mac --dir --arm64",
|
||||
"test:types": "tsc --noEmit --project ./tsconfig.json",
|
||||
"lint": "eslint --ext .ts,.js ./",
|
||||
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
|
|
@ -23,48 +21,45 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@sentry/electron": "^3.0.7",
|
||||
"NeteaseCloudMusicApi": "^4.6.7",
|
||||
"better-sqlite3": "7.6.2",
|
||||
"NeteaseCloudMusicApi": "^4.8.4",
|
||||
"better-sqlite3": "8.0.1",
|
||||
"change-case": "^4.1.2",
|
||||
"chromecast-api": "^0.4.0",
|
||||
"compare-versions": "^4.1.3",
|
||||
"connect-history-api-fallback": "^2.0.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-store": "^8.1.0",
|
||||
"express": "^4.18.1",
|
||||
"fast-folder-size": "^1.7.0",
|
||||
"express": "^4.18.2",
|
||||
"fast-folder-size": "^1.7.1",
|
||||
"pretty-bytes": "^6.0.0",
|
||||
"type-fest": "^3.0.0",
|
||||
"zx": "^7.0.8"
|
||||
"type-fest": "^3.5.0",
|
||||
"zx": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/better-sqlite3": "^7.6.3",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express": "^4.17.15",
|
||||
"@types/express-fileupload": "^1.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.32.0",
|
||||
"@typescript-eslint/parser": "^5.32.0",
|
||||
"@vitest/ui": "^0.20.3",
|
||||
"axios": "^0.27.2",
|
||||
"axios": "^1.2.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"electron": "^18.3.6",
|
||||
"electron-builder": "23.3.3",
|
||||
"dotenv": "^16.0.3",
|
||||
"electron": "^22.0.0",
|
||||
"electron-builder": "23.6.0",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"electron-releases": "^3.1091.0",
|
||||
"esbuild": "^0.14.53",
|
||||
"eslint": "*",
|
||||
"electron-releases": "^3.1171.0",
|
||||
"esbuild": "^0.16.10",
|
||||
"express-fileupload": "^1.4.0",
|
||||
"minimist": "^1.2.6",
|
||||
"music-metadata": "^7.12.5",
|
||||
"open-cli": "^7.0.1",
|
||||
"minimist": "^1.2.7",
|
||||
"music-metadata": "^8.1.0",
|
||||
"open-cli": "^7.1.0",
|
||||
"ora": "^6.1.2",
|
||||
"picocolors": "^1.0.0",
|
||||
"prettier": "*",
|
||||
"typescript": "*",
|
||||
"vitest": "^0.20.3",
|
||||
"wait-on": "^6.0.1"
|
||||
"wait-on": "^7.0.1",
|
||||
"tsx": "*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import dotenv from 'dotenv'
|
|||
import pc from 'picocolors'
|
||||
import minimist from 'minimist'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const env = dotenv.config({
|
||||
path: path.resolve(process.cwd(), '../../.env'),
|
||||
})
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
# Getting Started with [Fastify-CLI](https://www.npmjs.com/package/fastify-cli)
|
||||
|
||||
This project was bootstrapped with Fastify-CLI.
|
||||
|
||||
## Available Scripts
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { join } from 'path';
|
||||
import AutoLoad, {AutoloadPluginOptions} from '@fastify/autoload';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { join } from 'path'
|
||||
import AutoLoad, { AutoloadPluginOptions } from '@fastify/autoload'
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
|
||||
export type AppOptions = {
|
||||
// Place your custom options for app below here.
|
||||
} & Partial<AutoloadPluginOptions>;
|
||||
} & Partial<AutoloadPluginOptions>
|
||||
|
||||
const app: FastifyPluginAsync<AppOptions> = async (
|
||||
fastify,
|
||||
opts
|
||||
fastify,
|
||||
opts
|
||||
): Promise<void> => {
|
||||
// Place here your custom code!
|
||||
|
||||
|
|
@ -19,17 +19,16 @@ const app: FastifyPluginAsync<AppOptions> = async (
|
|||
// through your application
|
||||
void fastify.register(AutoLoad, {
|
||||
dir: join(__dirname, 'plugins'),
|
||||
options: opts
|
||||
options: opts,
|
||||
})
|
||||
|
||||
// This loads all plugins defined in routes
|
||||
// define your routes in one of these
|
||||
void fastify.register(AutoLoad, {
|
||||
dir: join(__dirname, 'routes'),
|
||||
options: opts
|
||||
options: opts,
|
||||
})
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
export { app }
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ that will then be used in the rest of your application.
|
|||
|
||||
Check out:
|
||||
|
||||
* [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 lifecycle](https://www.fastify.io/docs/latest/Reference/Lifecycle/).
|
||||
- [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 lifecycle](https://www.fastify.io/docs/latest/Reference/Lifecycle/).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,6 @@ import sensible, { SensibleOptions } from '@fastify/sensible'
|
|||
*/
|
||||
export default fp<SensibleOptions>(async (fastify, opts) => {
|
||||
fastify.register(sensible, {
|
||||
errorHandler: false
|
||||
errorHandler: false,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@ export default fp<SupportPluginOptions>(async (fastify, opts) => {
|
|||
// When using .decorate you have to specify added properties for Typescript
|
||||
declare module 'fastify' {
|
||||
export interface FastifyInstance {
|
||||
someSupport(): string;
|
||||
someSupport(): string
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ const root: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
|
|||
})
|
||||
}
|
||||
|
||||
export default root;
|
||||
export default root
|
||||
|
|
|
|||
|
|
@ -2,18 +2,18 @@
|
|||
import Fastify from 'fastify'
|
||||
import fp from 'fastify-plugin'
|
||||
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
|
||||
// needed for testing the application
|
||||
async function config () {
|
||||
async function config() {
|
||||
return {}
|
||||
}
|
||||
|
||||
// Automatically build and tear down our instance
|
||||
async function build (t: Test) {
|
||||
async function build(t: Test) {
|
||||
const app = Fastify()
|
||||
|
||||
// fastify-plugin ensures that all decorators
|
||||
|
|
@ -21,7 +21,7 @@ async function build (t: Test) {
|
|||
// different from the production setup
|
||||
void app.register(fp(App), await config())
|
||||
|
||||
await app.ready();
|
||||
await app.ready()
|
||||
|
||||
// Tear down our app after we are done
|
||||
t.teardown(() => void app.close())
|
||||
|
|
@ -29,7 +29,4 @@ async function build (t: Test) {
|
|||
return app
|
||||
}
|
||||
|
||||
export {
|
||||
config,
|
||||
build
|
||||
}
|
||||
export { config, build }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { test } from 'tap'
|
|||
import Fastify from 'fastify'
|
||||
import Support from '../../src/plugins/support'
|
||||
|
||||
test('support works standalone', async (t) => {
|
||||
test('support works standalone', async t => {
|
||||
const fastify = Fastify()
|
||||
void fastify.register(Support)
|
||||
await fastify.ready()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { test } from 'tap'
|
||||
import { build } from '../helper'
|
||||
|
||||
test('example is loaded', async (t) => {
|
||||
test('example is loaded', async t => {
|
||||
const app = await build(t)
|
||||
|
||||
const res = await app.inject({
|
||||
url: '/example'
|
||||
url: '/example',
|
||||
})
|
||||
|
||||
t.equal(res.payload, 'this is an example')
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { test } from 'tap'
|
||||
import { build } from '../helper'
|
||||
|
||||
test('default root route', async (t) => {
|
||||
test('default root route', async t => {
|
||||
const app = await build(t)
|
||||
|
||||
const res = await app.inject({
|
||||
url: '/'
|
||||
url: '/',
|
||||
})
|
||||
t.same(JSON.parse(res.payload), { root: true })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export enum UserApiNames {
|
|||
FetchUserAlbums = 'fetchUserAlbums',
|
||||
FetchUserArtists = 'fetchUserArtists',
|
||||
FetchListenedRecords = 'fetchListenedRecords',
|
||||
FetchUserVideos = 'fetchUserVideos',
|
||||
}
|
||||
|
||||
// 获取账号详情
|
||||
|
|
@ -107,6 +108,14 @@ export interface FetchUserArtistsResponse {
|
|||
count: number
|
||||
data: Artist[]
|
||||
}
|
||||
// 获取收藏的MV
|
||||
export interface FetchUserVideosParams {}
|
||||
export interface FetchUserVideosResponse {
|
||||
code: number
|
||||
hasMore: boolean
|
||||
count: number
|
||||
data: Video[]
|
||||
}
|
||||
|
||||
// 听歌排名
|
||||
export interface FetchListenedRecordsParams {
|
||||
|
|
|
|||
16
packages/shared/interface.d.ts
vendored
16
packages/shared/interface.d.ts
vendored
|
|
@ -206,3 +206,19 @@ declare interface User {
|
|||
anchor: boolean
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { State as PlayerState } from '@/web/utils/player'
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffectOnce } from 'react-use'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { appName } from './utils/const'
|
||||
|
||||
const IpcRendererReact = () => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
|
@ -26,7 +27,7 @@ const IpcRendererReact = () => {
|
|||
useEffect(() => {
|
||||
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, {
|
||||
text,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,12 +6,10 @@ import { useQuery } from '@tanstack/react-query'
|
|||
import useUser from './useUser'
|
||||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
|
||||
export default function useUserListenedRecords(params: {
|
||||
type: 'week' | 'all'
|
||||
}) {
|
||||
export default function useUserListenedRecords(params: { type: 'week' | 'all' }) {
|
||||
const { data: user } = useUser()
|
||||
const uid = user?.account?.id || 0
|
||||
const key = [UserApiNames.FetchListenedRecords]
|
||||
const key = [UserApiNames.FetchListenedRecords, uid]
|
||||
|
||||
return useQuery(
|
||||
key,
|
||||
|
|
|
|||
71
packages/web/api/hooks/useUserVideos.ts
Normal file
71
packages/web/api/hooks/useUserVideos.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -42,21 +42,15 @@ export function simiMv(mvid) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏/取消收藏 MV
|
||||
* 说明 : 调用此接口,可收藏/取消收藏 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()
|
||||
// 收藏/取消收藏视频
|
||||
export function likeAVideo(params: { id: number | string; t?: number }) {
|
||||
return request({
|
||||
url: '/mv/sub',
|
||||
method: 'post',
|
||||
params,
|
||||
params: {
|
||||
mvid: params.id,
|
||||
t: params.t,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import axios, { AxiosInstance } from 'axios'
|
||||
import { appName } from '../utils/const'
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: '/yesplaymusic',
|
||||
baseURL: `/${appName.toLowerCase()}`,
|
||||
withCredentials: true,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
FetchUserArtistsResponse,
|
||||
FetchListenedRecordsParams,
|
||||
FetchListenedRecordsResponse,
|
||||
FetchUserVideosResponse,
|
||||
FetchUserVideosParams,
|
||||
} from '@/shared/api/User'
|
||||
|
||||
/**
|
||||
|
|
@ -135,20 +137,18 @@ export function fetchUserArtists(): Promise<FetchUserArtistsResponse> {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏的MV(需要登录)
|
||||
* 说明 : 调用此接口可获取到用户收藏的MV
|
||||
*/
|
||||
// export function likedMVs(params) {
|
||||
// return request({
|
||||
// url: '/mv/sublist',
|
||||
// method: 'get',
|
||||
// params: {
|
||||
// limit: params.limit,
|
||||
// timestamp: new Date().getTime(),
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
// 获取收藏的MV
|
||||
export function fetchUserVideos(): Promise<FetchUserVideosResponse> {
|
||||
return request({
|
||||
url: '/mv/sublist',
|
||||
method: 'get',
|
||||
params: {
|
||||
limit: 1000,
|
||||
// offset: 1,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传歌曲到云盘(需要登录)
|
||||
|
|
|
|||
3
packages/web/assets/icons/fullscreen-enter.svg
Normal file
3
packages/web/assets/icons/fullscreen-enter.svg
Normal 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 |
3
packages/web/assets/icons/fullscreen-exit.svg
Normal file
3
packages/web/assets/icons/fullscreen-exit.svg
Normal 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 |
3
packages/web/assets/icons/video-settings.svg
Normal file
3
packages/web/assets/icons/video-settings.svg
Normal 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 |
|
|
@ -52,7 +52,7 @@ const ArtistInline = ({
|
|||
>
|
||||
{artist.name}
|
||||
</span>
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import useUserAlbums, {
|
||||
useMutationLikeAAlbum,
|
||||
} from '@/web/api/hooks/useUserAlbums'
|
||||
import useUserAlbums, { useMutationLikeAAlbum } from '@/web/api/hooks/useUserAlbums'
|
||||
import contextMenus, { closeContextMenu } from '@/web/states/contextMenus'
|
||||
import player from '@/web/states/player'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
|
|
@ -14,16 +12,15 @@ import BasicContextMenu from './BasicContextMenu'
|
|||
const AlbumContextMenu = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { cursorPosition, type, dataSourceID, target, options } =
|
||||
useSnapshot(contextMenus)
|
||||
const { cursorPosition, type, dataSourceID, target, options } = useSnapshot(contextMenus)
|
||||
const likeAAlbum = useMutationLikeAAlbum()
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const { data: likedAlbums } = useUserAlbums()
|
||||
const addToLibraryLabel = useMemo(() => {
|
||||
return likedAlbums?.data?.find(a => a.id === Number(dataSourceID))
|
||||
? 'Remove from Library'
|
||||
: 'Add to Library'
|
||||
? t`context-menu.remove-from-library`
|
||||
: t`context-menu.add-to-library`
|
||||
}, [dataSourceID, likedAlbums?.data])
|
||||
|
||||
return (
|
||||
|
|
@ -82,19 +79,15 @@ const AlbumContextMenu = () => {
|
|||
type: 'item',
|
||||
label: t`context-menu.copy-netease-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`https://music.163.com/#/album?id=${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`https://music.163.com/#/album?id=${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Copy YPM Link',
|
||||
label: t`context-menu.copy-r3play-link`,
|
||||
onClick: () => {
|
||||
copyToClipboard(
|
||||
`${window.location.origin}/album/${dataSourceID}`
|
||||
)
|
||||
copyToClipboard(`${window.location.origin}/album/${dataSourceID}`)
|
||||
toast.success(t`toasts.copied`)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import useLockMainScroll from '@/web/hooks/useLockMainScroll'
|
|||
import useMeasure from 'react-use-measure'
|
||||
import { ContextMenuItem } from './MenuItem'
|
||||
import MenuPanel from './MenuPanel'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ContextMenuPosition } from './types'
|
||||
|
||||
const BasicContextMenu = ({
|
||||
onClose,
|
||||
|
|
@ -19,15 +21,14 @@ const BasicContextMenu = ({
|
|||
cursorPosition: { x: number; y: number }
|
||||
options?: {
|
||||
useCursorPosition?: boolean
|
||||
fixedPosition?: `${'top' | 'bottom'}-${'left' | 'right'}`
|
||||
} | null
|
||||
classNames?: string
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [measureRef, menu] = useMeasure()
|
||||
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null
|
||||
)
|
||||
const [position, setPosition] = useState<ContextMenuPosition | null>(null)
|
||||
|
||||
useClickAway(menuRef, onClose)
|
||||
useLockMainScroll(!!position)
|
||||
|
|
@ -43,6 +44,22 @@ const BasicContextMenu = ({
|
|||
y: bottomY + menu.height < window.innerHeight ? bottomY : topY,
|
||||
}
|
||||
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 {
|
||||
const button = target.getBoundingClientRect()
|
||||
const leftX = button.x
|
||||
|
|
@ -57,7 +74,7 @@ const BasicContextMenu = ({
|
|||
}
|
||||
}, [target, menu, options?.useCursorPosition, cursorPosition])
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<>
|
||||
<MenuPanel
|
||||
position={{ x: 99999, y: 99999 }}
|
||||
|
|
@ -78,7 +95,8 @@ const BasicContextMenu = ({
|
|||
classNames={classNames}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { css, cx } from '@emotion/css'
|
||||
import { ForwardedRef, forwardRef, useRef, useState } from 'react'
|
||||
import Icon from '../Icon'
|
||||
|
||||
export interface ContextMenuItem {
|
||||
type: 'item' | 'submenu' | 'divider'
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
items?: ContextMenuItem[]
|
||||
}
|
||||
import { ContextMenuItem } from './types'
|
||||
|
||||
const MenuItem = ({
|
||||
item,
|
||||
|
|
@ -63,7 +57,7 @@ const MenuItem = ({
|
|||
onSubmenuClose()
|
||||
}}
|
||||
className={cx(
|
||||
'relative',
|
||||
'relative cursor-default',
|
||||
className,
|
||||
css`
|
||||
padding-right: 9px;
|
||||
|
|
|
|||
|
|
@ -7,14 +7,11 @@ import {
|
|||
useState,
|
||||
} from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import MenuItem, { ContextMenuItem } from './MenuItem'
|
||||
import MenuItem from './MenuItem'
|
||||
import { ContextMenuItem, ContextMenuPosition } from './types'
|
||||
|
||||
interface PanelProps {
|
||||
position: {
|
||||
x: number
|
||||
y: number
|
||||
transformOrigin?: `origin-${'top' | 'bottom'}-${'left' | 'right'}`
|
||||
}
|
||||
position: ContextMenuPosition
|
||||
items: ContextMenuItem[]
|
||||
onClose: (e: MouseEvent) => void
|
||||
forMeasure?: boolean
|
||||
|
|
@ -36,33 +33,33 @@ const MenuPanel = forwardRef(
|
|||
|
||||
return (
|
||||
// Container (to add padding for submenus)
|
||||
<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 }}
|
||||
<div
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'fixed',
|
||||
position.transformOrigin || 'origin-top-left',
|
||||
isSubmenu ? 'submenu z-20 px-1' : 'z-10'
|
||||
'fixed select-none',
|
||||
isSubmenu ? 'submenu z-30 px-1' : 'z-20'
|
||||
)}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{/* 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(
|
||||
'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`
|
||||
min-width: 200px;
|
||||
`,
|
||||
classNames
|
||||
classNames,
|
||||
position.transformOrigin || 'origin-top-left'
|
||||
)}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
|
|
@ -76,7 +73,7 @@ const MenuPanel = forwardRef(
|
|||
className={isSubmenu ? 'submenu' : ''}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Submenu */}
|
||||
<SubMenu
|
||||
|
|
@ -86,7 +83,7 @@ const MenuPanel = forwardRef(
|
|||
itemRect={submenuProps?.itemRect}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
12
packages/web/components/ContextMenus/types.ts
Normal file
12
packages/web/components/ContextMenus/types.ts
Normal 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[]
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -2,13 +2,12 @@ import Main from '@/web/components/Main'
|
|||
import Player from '@/web/components/Player'
|
||||
import MenuBar from '@/web/components/MenuBar'
|
||||
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 { useSnapshot } from 'valtio'
|
||||
import Login from './Login'
|
||||
import TrafficLight from './TrafficLight'
|
||||
import BlurBackground from './BlurBackground'
|
||||
import Airplay from './Airplay'
|
||||
import TitleBar from './TitleBar'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import ContextMenus from './ContextMenus/ContextMenus'
|
||||
|
|
@ -39,7 +38,11 @@ const Layout = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{(window.env?.isWindows || window.env?.isLinux) && <TitleBar />}
|
||||
{(window.env?.isWindows ||
|
||||
window.env?.isLinux ||
|
||||
window.localStorage.getItem('showWindowsTitleBar') === 'true') && (
|
||||
<TitleBar />
|
||||
)}
|
||||
|
||||
<ContextMenus />
|
||||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ const Login = () => {
|
|||
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'
|
||||
>
|
||||
<Icon name='x' className='h-7 w-7 ' />
|
||||
<Icon name='x' className='h-6 w-6' />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import persistedUiStates from '@/web/states/persistedUiStates'
|
|||
import { motion, useAnimation } from 'framer-motion'
|
||||
import { sleep } from '@/web/utils/common'
|
||||
import player from '@/web/states/player'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
|
||||
const Main = () => {
|
||||
const playerSnapshot = useSnapshot(player)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ const Progress = () => {
|
|||
/>
|
||||
|
||||
<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(track?.dt || 0, 'en', 'hh:mm:ss')}</span>
|
||||
<span>{formatDuration(progress * 1000, 'en-US', 'hh:mm:ss')}</span>
|
||||
<span>{formatDuration(track?.dt || 0, 'en-US', 'hh:mm:ss')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import React, { ReactNode, Suspense } from 'react'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
|
||||
const My = React.lazy(() => import('@/web/pages/My'))
|
||||
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 Playlist = React.lazy(() => import('@/web/pages/Playlist'))
|
||||
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 Search = React.lazy(() => import('@/web/pages/Search'))
|
||||
|
||||
|
|
@ -20,7 +20,8 @@ const Router = () => {
|
|||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<AnimatePresence exitBeforeEnter>
|
||||
<AnimatePresence mode='wait'>
|
||||
<VideoPlayer />
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path='/' element={lazy(<My />)} />
|
||||
<Route path='/discover' element={lazy(<Discover />)} />
|
||||
|
|
@ -28,7 +29,6 @@ const Router = () => {
|
|||
<Route path='/album/:id' element={lazy(<Album />)} />
|
||||
<Route path='/playlist/:id' element={lazy(<Playlist />)} />
|
||||
<Route path='/artist/:id' element={lazy(<Artist />)} />
|
||||
<Route path='/mv/:id' element={lazy(<MV />)} />
|
||||
{/* <Route path='/settings' element={lazy(<Settings />)} /> */}
|
||||
<Route path='/lyrics' element={lazy(<Lyrics />)} />
|
||||
<Route path='/search/:keywords' element={lazy(<Search />)}>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import player from '@/web/states/player'
|
||||
import Icon from './Icon'
|
||||
import { IpcChannels } from '@/shared/IpcChannels'
|
||||
import useIpcRenderer from '@/web/hooks/useIpcRenderer'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useState } from 'react'
|
||||
import { css, cx } from '@emotion/css'
|
||||
|
||||
const Controls = () => {
|
||||
|
|
@ -50,7 +48,8 @@ const Controls = () => {
|
|||
className={cx(
|
||||
classNames,
|
||||
css`
|
||||
margin-right: 5px;
|
||||
border-radius: 4px 22px 4px 4px;
|
||||
margin-right: 4px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import useHoverLightSpot from '@/web/hooks/useHoverLightSpot'
|
||||
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 { useParams } from 'react-router-dom'
|
||||
import Icon from '../Icon'
|
||||
|
|
@ -15,60 +18,101 @@ const Actions = ({
|
|||
onPlay: () => void
|
||||
onLike?: () => void
|
||||
}) => {
|
||||
const params = useParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start'>
|
||||
<div className='flex items-end'>
|
||||
{/* Menu */}
|
||||
<button
|
||||
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 className='mt-11 flex items-end justify-between lg:mt-4 lg:justify-start lg:gap-2.5'>
|
||||
<div className='flex items-end gap-2.5'>
|
||||
<MenuButton isLoading={isLoading} />
|
||||
<LikeButton {...{ isLiked, isLoading, onLike }} />
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
<PlayButton onPlay={onPlay} isLoading={isLoading} />
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { resizeImage } from '@/web/utils/common'
|
|||
import Image from '@/web/components/Image'
|
||||
import { memo, useEffect } from 'react'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
import VideoCover from './VideoCover'
|
||||
import VideoCover from '@/web/components/VideoCover'
|
||||
|
||||
const Cover = memo(
|
||||
({ cover, videoCover }: { cover?: string; videoCover?: string }) => {
|
||||
|
|
@ -18,7 +18,7 @@ const Cover = memo(
|
|||
src={resizeImage(cover || '', 'lg')}
|
||||
/>
|
||||
|
||||
{videoCover && <VideoCover videoCover={videoCover} />}
|
||||
{videoCover && <VideoCover source={videoCover} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -3,32 +3,34 @@ import Hls from 'hls.js'
|
|||
import { injectGlobal } from '@emotion/css'
|
||||
import { isIOS, isSafari } from '@/web/utils/common'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import uiStates from '../states/uiStates'
|
||||
|
||||
injectGlobal`
|
||||
.plyr__video-wrapper,
|
||||
.plyr--video {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
`
|
||||
|
||||
const VideoCover = ({
|
||||
source,
|
||||
onPlay,
|
||||
}: {
|
||||
source?: string
|
||||
onPlay?: () => void
|
||||
}) => {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>(new Hls())
|
||||
const VideoCover = ({ source, onPlay }: { source?: string; onPlay?: () => void }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hls = useRef<Hls>()
|
||||
|
||||
useEffect(() => {
|
||||
if (source && Hls.isSupported()) {
|
||||
const video = document.querySelector('#video-cover') as HTMLVideoElement
|
||||
if (source && Hls.isSupported() && videoRef.current) {
|
||||
if (hls.current) hls.current.destroy()
|
||||
hls.current = new Hls()
|
||||
hls.current.loadSource(source)
|
||||
hls.current.attachMedia(video)
|
||||
hls.current.attachMedia(videoRef.current)
|
||||
}
|
||||
|
||||
return () => hls.current && hls.current.destroy()
|
||||
}, [source])
|
||||
|
||||
// Pause video cover when playing another video
|
||||
const { playingVideoID } = useSnapshot(uiStates)
|
||||
useEffect(() => {
|
||||
if (playingVideoID) {
|
||||
videoRef?.current?.pause()
|
||||
} else {
|
||||
videoRef?.current?.play()
|
||||
}
|
||||
}, [playingVideoID])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: isIOS ? 1 : 0 }}
|
||||
|
|
@ -38,23 +40,26 @@ const VideoCover = ({
|
|||
>
|
||||
{isSafari ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={source}
|
||||
className='h-full w-full'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
preload='auto'
|
||||
onPlay={() => onPlay?.()}
|
||||
></video>
|
||||
) : (
|
||||
<div className='aspect-square'>
|
||||
<video
|
||||
id='video-cover'
|
||||
ref={ref}
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
preload='auto'
|
||||
onPlay={() => onPlay?.()}
|
||||
className='h-full w-full'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
260
packages/web/components/VideoPlayer/VideoInstance.tsx
Normal file
260
packages/web/components/VideoPlayer/VideoInstance.tsx
Normal 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
|
||||
117
packages/web/components/VideoPlayer/VideoPlayer.tsx
Normal file
117
packages/web/components/VideoPlayer/VideoPlayer.tsx
Normal 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
|
||||
2
packages/web/components/VideoPlayer/index.tsx
Normal file
2
packages/web/components/VideoPlayer/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import VideoPlayer from './VideoPlayer'
|
||||
export default VideoPlayer
|
||||
25
packages/web/components/VideoRow.tsx
Normal file
25
packages/web/components/VideoRow.tsx
Normal 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
|
||||
75
packages/web/hooks/useHoverLightSpot.tsx
Normal file
75
packages/web/hooks/useHoverLightSpot.tsx
Normal 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
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import axios from 'axios'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { appName } from '../utils/const'
|
||||
|
||||
export default function useVideoCover(props: {
|
||||
id?: number
|
||||
|
|
@ -13,9 +14,12 @@ export default function useVideoCover(props: {
|
|||
async () => {
|
||||
if (!id || !name || !artist) return
|
||||
|
||||
const fromRemote = await axios.get('/yesplaymusic/video-cover', {
|
||||
params: props,
|
||||
})
|
||||
const fromRemote = await axios.get(
|
||||
`/${appName.toLowerCase()}/video-cover`,
|
||||
{
|
||||
params: props,
|
||||
}
|
||||
)
|
||||
if (fromRemote?.data?.url) {
|
||||
return fromRemote.data.url
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@
|
|||
"follow": "Follow",
|
||||
"unfollow": "Unfollow",
|
||||
"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": {},
|
||||
"artist": {
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@
|
|||
"unfollow": "取消关注",
|
||||
"follow": "关注",
|
||||
"followed": "已关注",
|
||||
"unfollowed": "已取消关注"
|
||||
"unfollowed": "已取消关注",
|
||||
"add-to-library": "添加到音乐库",
|
||||
"remove-from-library": "从音乐库中移除",
|
||||
"copy-r3play-link": "复制R3PLAY链接"
|
||||
},
|
||||
"toast": {},
|
||||
"artist": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
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-status-bar-style" content="black-translucent" />
|
||||
<title>YesPlayMusic</title>
|
||||
<title>R3Play</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
|||
import reactQueryClient from '@/web/utils/reactQueryClient'
|
||||
import React from 'react'
|
||||
import './i18n/i18n'
|
||||
import { appName } from './utils/const'
|
||||
|
||||
ReactGA.initialize('G-KMJJCFZDKF')
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ Sentry.init({
|
|||
),
|
||||
}),
|
||||
],
|
||||
release: `yesplaymusic@${pkg.version}`,
|
||||
release: `${appName}@${pkg.version}`,
|
||||
environment: import.meta.env.MODE,
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
|
|
|
|||
|
|
@ -10,48 +10,45 @@
|
|||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:types": "tsc --noEmit --project ./tsconfig.json",
|
||||
"lint": "eslint --ext .ts,.js,.tsx,.jsx ./",
|
||||
"analyze:css": "npx windicss-analysis",
|
||||
"analyze:js": "npm run build && open-cli bundle-stats-renderer.html",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"storybook:build": "build-storybook",
|
||||
"generate:accent-color-css": "node ./scripts/generate.accent.color.css.js",
|
||||
"api:netease": "npx NeteaseCloudMusicApi@latest",
|
||||
"format": "prettier --config ../../prettier.config.js --write './**/*.{ts,tsx,js,jsx,css}' --ignore-path ../../.prettierignore"
|
||||
"api:netease": "npx NeteaseCloudMusicApi@latest"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.10.0",
|
||||
"@sentry/react": "^7.8.1",
|
||||
"@sentry/tracing": "^7.8.1",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
"@tanstack/react-query-devtools": "^4.0.10",
|
||||
"ahooks": "^3.6.2",
|
||||
"axios": "^0.27.2",
|
||||
"@emotion/css": "^11.10.5",
|
||||
"@sentry/react": "^7.29.0",
|
||||
"@sentry/tracing": "^7.29.0",
|
||||
"@tanstack/react-query": "^4.20.9",
|
||||
"@tanstack/react-query-devtools": "^4.20.9",
|
||||
"ahooks": "^3.7.4",
|
||||
"axios": "^1.2.2",
|
||||
"color.js": "^1.2.0",
|
||||
"colord": "^2.9.2",
|
||||
"dayjs": "^1.11.4",
|
||||
"framer-motion": "^6.5.1",
|
||||
"hls.js": "^1.2.0",
|
||||
"colord": "^2.9.3",
|
||||
"dayjs": "^1.11.7",
|
||||
"framer-motion": "^8.1.7",
|
||||
"hls.js": "^1.2.9",
|
||||
"howler": "^2.2.3",
|
||||
"i18next": "^21.9.1",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"plyr-react": "^5.1.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-ga4": "^1.4.1",
|
||||
"react-hot-toast": "^2.3.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-i18next": "^11.18.4",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-router-dom": "^6.6.1",
|
||||
"react-use": "^17.4.0",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"react-virtuoso": "^2.16.6",
|
||||
"valtio": "^1.6.3"
|
||||
"valtio": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "^6.5.5",
|
||||
|
|
@ -71,28 +68,23 @@
|
|||
"@types/qrcode": "^1.4.2",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.32.0",
|
||||
"@typescript-eslint/parser": "^5.32.0",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"@vitest/ui": "^0.20.3",
|
||||
"autoprefixer": "^10.4.8",
|
||||
"@vitejs/plugin-react-swc": "^3.0.1",
|
||||
"@vitest/ui": "^0.26.3",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"c8": "^7.12.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"eslint": "*",
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jsdom": "^20.0.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"jsdom": "^20.0.3",
|
||||
"open-cli": "^7.0.1",
|
||||
"postcss": "^8.4.14",
|
||||
"postcss": "^8.4.20",
|
||||
"prettier": "*",
|
||||
"prettier-plugin-tailwindcss": "^0.1.11",
|
||||
"rollup-plugin-visualizer": "^5.6.0",
|
||||
"prettier-plugin-tailwindcss": "*",
|
||||
"rollup-plugin-visualizer": "^5.9.0",
|
||||
"storybook-tailwind-dark-mode": "^1.0.12",
|
||||
"tailwindcss": "^3.1.7",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "*",
|
||||
"vite": "^3.0.4",
|
||||
"vite-plugin-pwa": "^0.12.3",
|
||||
"vite": "^4.0.4",
|
||||
"vite-plugin-pwa": "^0.14.1",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vitest": "^0.20.3"
|
||||
"vitest": "^0.26.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import Header from './Header'
|
|||
import Popular from './Popular'
|
||||
import ArtistAlbum from './ArtistAlbums'
|
||||
import FansAlsoLike from './FansAlsoLike'
|
||||
import ArtistMVs from './ArtistMVs'
|
||||
import ArtistVideos from './ArtistVideos'
|
||||
|
||||
const Artist = () => {
|
||||
return (
|
||||
|
|
@ -12,7 +12,7 @@ const Artist = () => {
|
|||
<div className='mt-10 mb-7.5 h-px w-full bg-white/20'></div>
|
||||
<Popular />
|
||||
<ArtistAlbum />
|
||||
<ArtistMVs />
|
||||
<ArtistVideos />
|
||||
<FansAlsoLike />
|
||||
|
||||
{/* Page padding */}
|
||||
|
|
|
|||
|
|
@ -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 { useTranslation } from 'react-i18next'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
|
||||
const ArtistMVs = () => {
|
||||
const ArtistVideos = () => {
|
||||
const { t } = useTranslation()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: videos } = useArtistMV({ id: Number(params.id) || 0 })
|
||||
|
||||
return (
|
||||
|
|
@ -16,10 +16,13 @@ const ArtistMVs = () => {
|
|||
|
||||
<div className='grid grid-cols-3 gap-6'>
|
||||
{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
|
||||
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'>
|
||||
{video.name}
|
||||
|
|
@ -31,4 +34,4 @@ const ArtistMVs = () => {
|
|||
)
|
||||
}
|
||||
|
||||
export default ArtistMVs
|
||||
export default ArtistVideos
|
||||
|
|
@ -8,6 +8,7 @@ import { useMemo } from 'react'
|
|||
import useArtistMV from '@/web/api/hooks/useArtistMV'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import uiStates from '@/web/states/uiStates'
|
||||
|
||||
const Album = ({ album }: { album?: Album }) => {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -49,14 +50,12 @@ const Album = ({ album }: { album?: Album }) => {
|
|||
}
|
||||
|
||||
const Video = ({ video }: { video?: any }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<>
|
||||
{video && (
|
||||
<div
|
||||
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
|
||||
src={video.imgurl16v9}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
fetchFromCache,
|
||||
} from '@/web/api/hooks/usePlaylist'
|
||||
import { fetchTracksWithReactQuery } from '@/web/api/hooks/useTracks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { sampleSize } from 'lodash-es'
|
||||
import { FetchPlaylistResponse } from '@/shared/api/Playlists'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
|
@ -31,27 +30,19 @@ const getAlbumsFromAPI = async () => {
|
|||
7463185187, // 开发者夹带私货
|
||||
]
|
||||
|
||||
const playlists = (await Promise.all(
|
||||
sampleSize(playlistsIds, 5).map(
|
||||
id =>
|
||||
new Promise(resolve => {
|
||||
const cache = fetchFromCache(id)
|
||||
if (cache) {
|
||||
resolve(cache)
|
||||
return
|
||||
}
|
||||
resolve(fetchPlaylistWithReactQuery({ id }))
|
||||
})
|
||||
)
|
||||
)) as FetchPlaylistResponse[]
|
||||
const playlists: FetchPlaylistResponse[] = await Promise.all(
|
||||
sampleSize(playlistsIds, 5).map(async id => {
|
||||
const cache = await fetchFromCache({ id })
|
||||
if (cache) return cache
|
||||
return fetchPlaylistWithReactQuery({ id })
|
||||
})
|
||||
)
|
||||
|
||||
let ids: number[] = []
|
||||
playlists.forEach(playlist =>
|
||||
playlist?.playlist?.trackIds?.forEach(t => ids.push(t.id))
|
||||
)
|
||||
if (!ids.length) {
|
||||
return []
|
||||
}
|
||||
if (!ids.length) return []
|
||||
ids = sampleSize(ids, 100)
|
||||
|
||||
const tracks = await fetchTracksWithReactQuery({ ids })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -15,6 +15,8 @@ import { AnimatePresence, motion } from 'framer-motion'
|
|||
import { scrollToBottom } from '@/web/utils/common'
|
||||
import { throttle } from 'lodash-es'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import VideoRow from '@/web/components/VideoRow'
|
||||
import useUserVideos from '@/web/api/hooks/useUserVideos'
|
||||
|
||||
const Albums = () => {
|
||||
const { data: albums } = useUserAlbums()
|
||||
|
|
@ -35,6 +37,11 @@ const Artists = () => {
|
|||
return <ArtistRow artists={artists?.data || []} />
|
||||
}
|
||||
|
||||
const Videos = () => {
|
||||
const { data: videos } = useUserVideos()
|
||||
return <VideoRow videos={videos?.data || []} />
|
||||
}
|
||||
|
||||
const CollectionTabs = ({ showBg }: { showBg: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -130,6 +137,7 @@ const Collections = () => {
|
|||
{selectedTab === 'albums' && <Albums />}
|
||||
{selectedTab === 'playlists' && <Playlists />}
|
||||
{selectedTab === 'artists' && <Artists />}
|
||||
{selectedTab === 'videos' && <Videos />}
|
||||
</div>
|
||||
<div ref={observePoint}></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface UIStates {
|
|||
mobileShowPlayingNext: boolean
|
||||
blurBackgroundImage: string | null
|
||||
fullscreen: boolean
|
||||
playingVideoID: number | null
|
||||
}
|
||||
|
||||
const initUIStates: UIStates = {
|
||||
|
|
@ -19,10 +20,16 @@ const initUIStates: UIStates = {
|
|||
mobileShowPlayingNext: false,
|
||||
blurBackgroundImage: null,
|
||||
fullscreen: false,
|
||||
playingVideoID: null,
|
||||
}
|
||||
|
||||
window.ipcRenderer
|
||||
?.invoke(IpcChannels.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,10 +93,9 @@
|
|||
|
||||
body,
|
||||
input {
|
||||
font-family: Roboto, ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei,
|
||||
Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei, microsoft uighur,
|
||||
sans-serif;
|
||||
font-family: Roboto, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue,
|
||||
PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei,
|
||||
microsoft uighur, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const fontSizeDefault = {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
content: ['./index.html', './**/*.{vue,js,ts,jsx,tsx}'],
|
||||
content: ['./index.html', './**/*.{vue,js,ts,jsx,tsx}', '!./node_modules/**/*'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
|
|
@ -110,7 +110,15 @@ module.exports = {
|
|||
15: '.15',
|
||||
25: '.25',
|
||||
},
|
||||
backdropBlur: {
|
||||
sm: '2px',
|
||||
DEFAULT: '4px',
|
||||
md: '6px',
|
||||
lg: '8px',
|
||||
xl: '12px',
|
||||
'2xl': '20px',
|
||||
'3xl': '45px',
|
||||
}
|
||||
},
|
||||
},
|
||||
variants: {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ export function resizeImage(
|
|||
)
|
||||
}
|
||||
|
||||
export function toHttps(url: string | undefined): string {
|
||||
return url ? url.replace(/^http:/, 'https:') : ''
|
||||
}
|
||||
|
||||
export const storage = {
|
||||
get(key: string): object | [] | null {
|
||||
const text = localStorage.getItem(key)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export const appName = 'R3Play'
|
||||
|
||||
// 动画曲线
|
||||
export const ease: [number, number, number, number] = [0.4, 0, 0.2, 1]
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
} from '@/web/api/hooks/useTracks'
|
||||
import { fetchPersonalFMWithReactQuery } from '@/web/api/hooks/usePersonalFM'
|
||||
import { fmTrash } from '@/web/api/personalFM'
|
||||
import { cacheAudio } from '@/web/api/yesplaymusic'
|
||||
import { cacheAudio } from '@/web/api/r3play'
|
||||
import { clamp } from 'lodash-es'
|
||||
import axios from 'axios'
|
||||
import { resizeImage } from './common'
|
||||
|
|
@ -16,6 +16,7 @@ import { RepeatMode } from '@/shared/playerDataTypes'
|
|||
import toast from 'react-hot-toast'
|
||||
import { scrobble } from '@/web/api/user'
|
||||
import { fetchArtistWithReactQuery } from '../api/hooks/useArtist'
|
||||
import { appName } from './const'
|
||||
|
||||
type TrackID = number
|
||||
export enum TrackListSourceType {
|
||||
|
|
@ -200,22 +201,19 @@ export class Player {
|
|||
}, 1000)
|
||||
} else if (this._isAirplay) {
|
||||
// Airplay
|
||||
let isFetchAirplayPlayingInfo = false
|
||||
this._progressInterval = setInterval(async () => {
|
||||
if (isFetchAirplayPlayingInfo) return
|
||||
|
||||
isFetchAirplayPlayingInfo = true
|
||||
|
||||
const playingInfo = await window?.ipcRenderer?.invoke(
|
||||
'airplay-get-playing',
|
||||
{ deviceID: this.remoteDevice?.id }
|
||||
)
|
||||
if (playingInfo) {
|
||||
this._progress = playingInfo.position || 0
|
||||
}
|
||||
|
||||
isFetchAirplayPlayingInfo = false
|
||||
}, 1000)
|
||||
// let isFetchAirplayPlayingInfo = false
|
||||
// this._progressInterval = setInterval(async () => {
|
||||
// if (isFetchAirplayPlayingInfo) return
|
||||
// isFetchAirplayPlayingInfo = true
|
||||
// const playingInfo = await window?.ipcRenderer?.invoke(
|
||||
// 'airplay-get-playing',
|
||||
// { deviceID: this.remoteDevice?.id }
|
||||
// )
|
||||
// if (playingInfo) {
|
||||
// this._progress = playingInfo.position || 0
|
||||
// }
|
||||
// isFetchAirplayPlayingInfo = false
|
||||
// }, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -328,15 +326,15 @@ export class Player {
|
|||
}
|
||||
|
||||
private async _playAudioViaAirplay(audio: string) {
|
||||
if (!this._isAirplay) {
|
||||
console.log('No airplay device selected')
|
||||
return
|
||||
}
|
||||
const result = await window.ipcRenderer?.invoke('airplay-play-url', {
|
||||
deviceID: this.remoteDevice?.id,
|
||||
url: audio,
|
||||
})
|
||||
console.log(result)
|
||||
// if (!this._isAirplay) {
|
||||
// console.log('No airplay device selected')
|
||||
// return
|
||||
// }
|
||||
// const result = await window.ipcRenderer?.invoke('airplay-play-url', {
|
||||
// deviceID: this.remoteDevice?.id,
|
||||
// url: audio,
|
||||
// })
|
||||
// console.log(result)
|
||||
}
|
||||
|
||||
private _howlerOnEndCallback() {
|
||||
|
|
@ -349,7 +347,7 @@ export class Player {
|
|||
}
|
||||
|
||||
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'))
|
||||
if (isNaN(id) || !id) return
|
||||
cacheAudio(id, audio)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/// <reference types="vitest" />
|
||||
import react from '@vitejs/plugin-react'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import dotenv from 'dotenv'
|
||||
import path, { join } from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
|
@ -7,6 +7,7 @@ import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
|||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import filenamesToType from './vitePluginFilenamesToType'
|
||||
import { appName } from './utils/const'
|
||||
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '../../.env') })
|
||||
const IS_ELECTRON = process.env.IS_ELECTRON
|
||||
|
|
@ -21,7 +22,7 @@ export default defineConfig({
|
|||
base: '/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': join(__dirname, '../'),
|
||||
'@': join(__dirname, '..'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
|
@ -36,32 +37,33 @@ export default defineConfig({
|
|||
/**
|
||||
* @see https://vite-plugin-pwa.netlify.app/guide/generate.html
|
||||
*/
|
||||
VitePWA({
|
||||
manifest: {
|
||||
name: 'YesPlayMusic',
|
||||
short_name: 'YPM',
|
||||
description: 'Description of your app',
|
||||
theme_color: '#000',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
// VitePWA({
|
||||
// registerType: 'autoUpdate',
|
||||
// manifest: {
|
||||
// name: appName,
|
||||
// short_name: appName,
|
||||
// description: 'Description of your app',
|
||||
// theme_color: '#000',
|
||||
// icons: [
|
||||
// {
|
||||
// src: 'pwa-192x192.png',
|
||||
// sizes: '192x192',
|
||||
// type: 'image/png',
|
||||
// },
|
||||
// {
|
||||
// src: 'pwa-512x512.png',
|
||||
// sizes: '512x512',
|
||||
// type: 'image/png',
|
||||
// },
|
||||
// {
|
||||
// src: 'pwa-512x512.png',
|
||||
// sizes: '512x512',
|
||||
// type: 'image/png',
|
||||
// purpose: 'any maskable',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// }),
|
||||
|
||||
/**
|
||||
* @see https://github.com/vbenjs/vite-plugin-svg-icons
|
||||
|
|
@ -78,12 +80,12 @@ export default defineConfig({
|
|||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
plugins: [
|
||||
visualizer({
|
||||
filename: './bundle-stats.html',
|
||||
gzipSize: true,
|
||||
projectRoot: './',
|
||||
template: 'treemap',
|
||||
}),
|
||||
// visualizer({
|
||||
// filename: './bundle-stats.html',
|
||||
// gzipSize: true,
|
||||
// projectRoot: './',
|
||||
// template: 'treemap',
|
||||
// }),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -93,20 +95,16 @@ export default defineConfig({
|
|||
proxy: {
|
||||
'/netease/': {
|
||||
// target: `http://192.168.50.111:${
|
||||
target: `http://127.0.0.1:${
|
||||
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
|
||||
}`,
|
||||
target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
|
||||
changeOrigin: true,
|
||||
rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
|
||||
},
|
||||
'/yesplaymusic/video-cover': {
|
||||
[`/${appName.toLowerCase()}/video-cover`]: {
|
||||
target: `http://168.138.40.199:51324`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/yesplaymusic/': {
|
||||
target: `http://127.0.0.1:${
|
||||
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000
|
||||
}`,
|
||||
[`/${appName.toLowerCase()}/`]: {
|
||||
target: `http://127.0.0.1:${process.env.ELECTRON_DEV_NETEASE_API_PORT || 30001}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
4361
pnpm-lock.yaml
generated
4361
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ module.exports = {
|
|||
htmlWhitespaceSensitivity: 'strict',
|
||||
singleQuote: true,
|
||||
jsxSingleQuote: true,
|
||||
printWidth: 100,
|
||||
|
||||
// Tailwind CSS
|
||||
plugins: [require('prettier-plugin-tailwindcss')],
|
||||
|
|
|
|||
23
turbo.json
23
turbo.json
|
|
@ -8,23 +8,30 @@
|
|||
"cache": false
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
"dist/**"
|
||||
],
|
||||
"cache": false
|
||||
},
|
||||
"pack": {
|
||||
"outputs": ["release/**"],
|
||||
"outputs": [
|
||||
"release/**"
|
||||
],
|
||||
"cache": false
|
||||
},
|
||||
"pack:test": {
|
||||
"outputs": ["release/**"],
|
||||
"outputs": [
|
||||
"release/**"
|
||||
],
|
||||
"cache": false
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["build"],
|
||||
"outputs": []
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue