feat: updates

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

View file

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

View file

@ -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',
},

View file

@ -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

View file

@ -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"
}
}

View file

@ -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,

View file

@ -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'

View file

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

View file

@ -1,12 +1,7 @@
import './preload' // must be first
import './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

View file

@ -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'

View file

@ -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) => {

View file

@ -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) {

View file

@ -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,

View file

@ -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%

View file

@ -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() {

View file

@ -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())
}

View file

@ -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`,

View file

@ -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": "*"
}
}

View file

@ -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'),
})

View file

@ -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

View file

@ -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 }

View file

@ -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/).

View file

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

View file

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

View file

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

View file

@ -2,18 +2,18 @@
import Fastify from 'fastify'
import 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 }

View file

@ -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()

View file

@ -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')

View file

@ -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 })
})

View file

@ -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 {

View file

@ -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
}

View file

@ -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,
})

View file

@ -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,

View file

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

View file

@ -42,21 +42,15 @@ export function simiMv(mvid) {
})
}
/**
* / MV
* 说明 : 调用此接口,/ 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(),
},
})
}

View file

@ -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,
})

View file

@ -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(),
},
})
}
/**
*

View file

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

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

View file

@ -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`)
},
},

View file

@ -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
)
}

View file

@ -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;

View file

@ -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>
)
}
)

View file

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

View file

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

View file

@ -2,13 +2,12 @@ import Main from '@/web/components/Main'
import Player from '@/web/components/Player'
import 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 />

View file

@ -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>

View file

@ -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)

View file

@ -19,8 +19,8 @@ const Progress = () => {
/>
<div className='mt-1 flex justify-between text-14 font-bold text-black/20 dark:text-white/20'>
<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>
)

View file

@ -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 />)}>

View file

@ -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;
`
)}
>

View file

@ -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

View file

@ -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>
</>
)

View file

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

View file

@ -3,32 +3,34 @@ import Hls from 'hls.js'
import { injectGlobal } from '@emotion/css'
import { 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>
)}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import axios from 'axios'
import { 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
}

View file

@ -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": {

View file

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

View file

@ -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>

View file

@ -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%

View file

@ -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"
}
}

View file

@ -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 */}

View file

@ -1,11 +1,11 @@
import { useNavigate, useParams } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import useArtistMV from '@/web/api/hooks/useArtistMV'
import { 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

View file

@ -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}

View file

@ -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 })

View file

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

View file

@ -15,6 +15,8 @@ import { AnimatePresence, motion } from 'framer-motion'
import { scrollToBottom } from '@/web/utils/common'
import { 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>

View file

@ -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
}

View file

@ -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 {

View file

@ -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: {},
}

View file

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

View file

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

View file

@ -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)

View file

@ -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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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": []
}
}