feat: updates

This commit is contained in:
qier222 2022-06-12 15:29:14 +08:00
parent 196a974a64
commit 8f4c3d8e5b
No known key found for this signature in database
GPG Key ID: 9C85007ED905F14D
24 changed files with 572 additions and 93 deletions

View File

@ -0,0 +1,84 @@
import { logger } from '@sentry/utils'
import axios from 'axios'
// 'https://mvod.itunes.apple.com/itunes-assets/HLSMusic116/v4/de/52/95/de52957b-fcf1-ae96-b114-0445cb8c41d4/P359420813_default.m3u8'
const searchAlbum = async (
keyword: string
): Promise<
| {
id: string
href: string
attributes: {
artistName: string
url: string
name: string
editorialNotes?: {
standard: string
short: string
}
}
}
| undefined
> => {
const r = await axios.get(
`https://amp-api.music.apple.com/v1/catalog/cn/search`,
{
params: {
term: keyword,
l: 'zh-cn',
platform: 'web',
types: 'albums',
limit: 1,
},
headers: {
authorization: 'Bearer xxxxxx', // required token
},
}
)
return r.data?.results?.albums?.data?.[0]
}
export const getCoverVideo = async ({
name,
artists,
}: {
name: string
artists: string[]
}): Promise<string | undefined> => {
const keyword = `${artists.join(' ')} ${name}`
logger.debug(`[appleMusic] getCoverVideo: ${keyword}`)
const album = await searchAlbum(keyword).catch(e => {
console.log(e)
logger.debug('[appleMusic] Search album error', e)
})
const url = album?.attributes.url
if (!url) {
logger.info('[appleMusic] no url')
return
}
let { data: html } = await axios.get(url)
if (!html) return
const regex =
/<script type="fastboot\/shoebox" id="shoebox-media-api-cache-amp-music">(.*?)<\/script>/
html = html
.match(regex)[0]
.replace(
'<script type="fastboot/shoebox" id="shoebox-media-api-cache-amp-music">',
''
)
.replace('</script>', '')
html = JSON.parse(html)
const data = JSON.parse(html[Object.keys(html)[1]])
const m3u8 =
data?.d?.[0]?.attributes?.editorialVideo?.motionSquareVideo1x1?.video
logger.debug(`[appleMusic] ${m3u8}`)
return m3u8
}

View File

@ -31,8 +31,9 @@ class Cache {
break
}
case APIs.Track: {
if (!data.songs) return
const tracks = (data as FetchTracksResponse).songs.map(t => ({
const res = data as FetchTracksResponse
if (!res.songs) return
const tracks = res.songs.map(t => ({
id: t.id,
json: JSON.stringify(t),
updatedAt: Date.now(),
@ -106,6 +107,16 @@ class Cache {
db.upsert(Tables.CoverColor, {
id: data.id,
color: data.color,
queriedAt: Date.now(),
})
break
}
case APIs.VideoCover: {
if (!data.id) return
db.upsert(Tables.VideoCover, {
id: data.id,
url: data.url || 'no',
queriedAt: Date.now(),
})
}
}
@ -196,6 +207,10 @@ class Cache {
if (isNaN(Number(params?.id))) return
return db.find(Tables.CoverColor, params.id)?.color
}
case APIs.VideoCover: {
if (isNaN(Number(params?.id))) return
return db.find(Tables.VideoCover, params.id)?.url
}
}
}
@ -278,7 +293,7 @@ class Cache {
br,
type: type as TablesStructures[Tables.Audio]['type'],
source,
updatedAt: Date.now(),
queriedAt: Date.now(),
})
log.info(`[cache] cacheAudio ${id}-${br}.${type}`)

View File

@ -18,6 +18,7 @@ export const enum Tables {
AccountData = 'AccountData',
CoverColor = 'CoverColor',
AppData = 'AppData',
VideoCover = 'VideoCover',
}
interface CommonTableStructure {
id: number
@ -50,16 +51,22 @@ export interface TablesStructures {
| 'qq'
| 'bilibili'
| 'joox'
updatedAt: number
queriedAt: number
}
[Tables.CoverColor]: {
id: number
color: string
queriedAt: number
}
[Tables.AppData]: {
id: 'appVersion' | 'skippedVersion'
value: string
}
[Tables.VideoCover]: {
id: number
url: string
queriedAt: number
}
}
type TableNames = keyof TablesStructures

View File

@ -12,6 +12,7 @@ import { Thumbar } from './windowsTaskbar'
import fastFolderSize from 'fast-folder-size'
import path from 'path'
import prettyBytes from 'pretty-bytes'
import { getCoverVideo } from './appleMusic'
const on = <T extends keyof IpcChannelsParams>(
channel: T,
@ -20,6 +21,16 @@ const on = <T extends keyof IpcChannelsParams>(
ipcMain.on(channel, listener)
}
const handle = <T extends keyof IpcChannelsParams>(
channel: T,
listener: (
event: Electron.IpcMainInvokeEvent,
params: IpcChannelsParams[T]
) => void
) => {
return ipcMain.handle(channel, listener)
}
export function initIpcMain(
win: BrowserWindow | null,
tray: YPMTray | null,
@ -143,6 +154,22 @@ function initOtherIpcMain() {
)
})
/**
*
*/
on(IpcChannels.SetVideoCover, (event, args) => {
const { id, url } = args
cache.set(APIs.VideoCover, { id, url })
})
/**
*
*/
on(IpcChannels.GetVideoCover, (event, args) => {
const { id } = args
event.returnValue = cache.get(APIs.VideoCover, { id })
})
/**
* tables到json文件便table大小dev环境
*/

View File

@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld('log', log)
contextBridge.exposeInMainWorld('ipcRenderer', {
sendSync: ipcRenderer.sendSync,
invoke: ipcRenderer.invoke,
send: ipcRenderer.send,
on: (
channel: IpcChannels,

View File

@ -2,9 +2,11 @@ CREATE TABLE IF NOT EXISTS "AccountData" ("id" text NOT NULL,"json" text NOT NUL
CREATE TABLE IF NOT EXISTS "Album" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "ArtistAlbum" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Artist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Audio" ("id" integer NOT NULL,"br" int NOT NULL,"type" text NOT NULL,"source" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Lyric" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" integer NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Playlist" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Track" ("id" integer NOT NULL,"json" text NOT NULL,"updatedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "CoverColor" ("id" integer NOT NULL,"color" text NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "AppData" ("id" text NOT NULL,"value" text, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "CoverColor" ("id" integer NOT NULL,"color" text NOT NULL, "queriedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "Audio" ("id" integer NOT NULL,"br" int NOT NULL,"type" text NOT NULL,"source" text NOT NULL,"updatedAt" int NOT NULL, "queriedAt" int NOT NULL, PRIMARY KEY (id));
CREATE TABLE IF NOT EXISTS "VideoCover" ("id" integer NOT NULL,"url" text NOT NULL,"updatedAt" int NOT NULL, "queriedAt" int NOT NULL, PRIMARY KEY (id));

View File

@ -9,7 +9,7 @@
"dev": "node scripts/build.main.mjs --watch",
"build": "node scripts/build.main.mjs",
"pack": "electron-builder build -c .electron-builder.config.js",
"test:types": "tsc --noEmit --project src/main/tsconfig.json",
"test:types": "tsc --noEmit --project ./tsconfig.json",
"lint": "eslint --ext .ts,.js ./",
"format": "prettier --write './**/*.{ts,js,tsx,jsx}'"
},
@ -29,6 +29,7 @@
"electron-store": "^8.0.1",
"express": "^4.18.1",
"fast-folder-size": "^1.7.0",
"m3u8-parser": "^4.7.1",
"pretty-bytes": "^6.0.0"
},
"devDependencies": {

View File

@ -21,7 +21,6 @@ export const enum APIs {
Album = 'album',
Artist = 'artists',
ArtistAlbum = 'artist/album',
CoverColor = 'cover_color',
Likelist = 'likelist',
Lyric = 'lyric',
Personalized = 'personalized',
@ -33,13 +32,16 @@ export const enum APIs {
UserAlbums = 'album/sublist',
UserArtists = 'artist/sublist',
UserPlaylist = 'user/playlist',
// not netease api
CoverColor = 'cover_color',
VideoCover = 'video_cover',
}
export interface APIsParams {
[APIs.Album]: { id: number }
[APIs.Artist]: { id: number }
[APIs.ArtistAlbum]: { id: number }
[APIs.CoverColor]: { id: number }
[APIs.Likelist]: void
[APIs.Lyric]: { id: number }
[APIs.Personalized]: void
@ -51,13 +53,14 @@ export interface APIsParams {
[APIs.UserAlbums]: void
[APIs.UserArtists]: void
[APIs.UserPlaylist]: void
[APIs.CoverColor]: { id: number }
[APIs.VideoCover]: { id: number }
}
export interface APIsResponse {
[APIs.Album]: FetchAlbumResponse
[APIs.Artist]: FetchArtistResponse
[APIs.ArtistAlbum]: FetchArtistAlbumsResponse
[APIs.CoverColor]: string | undefined
[APIs.Likelist]: FetchUserLikedTracksIDsResponse
[APIs.Lyric]: FetchLyricResponse
[APIs.Personalized]: FetchRecommendedPlaylistsResponse
@ -69,4 +72,6 @@ export interface APIsResponse {
[APIs.UserAlbums]: FetchUserAlbumsResponse
[APIs.UserArtists]: FetchUserArtistsResponse
[APIs.UserPlaylist]: FetchUserPlaylistsResponse
[APIs.CoverColor]: string | undefined
[APIs.VideoCover]: string | undefined
}

View File

@ -23,6 +23,8 @@ export const enum IpcChannels {
SyncSettings = 'SyncSettings',
GetAudioCacheSize = 'GetAudioCacheSize',
ResetWindowSize = 'ResetWindowSize',
GetVideoCover = 'GetVideoCover',
SetVideoCover = 'SetVideoCover',
}
// ipcMain.on params
@ -58,6 +60,8 @@ export interface IpcChannelsParams {
[IpcChannels.SyncSettings]: Store['settings']
[IpcChannels.GetAudioCacheSize]: void
[IpcChannels.ResetWindowSize]: void
[IpcChannels.GetVideoCover]: { id: number }
[IpcChannels.SetVideoCover]: { id: number; url: string }
}
// ipcRenderer.on params
@ -79,4 +83,6 @@ export interface IpcChannelsReturns {
[IpcChannels.Like]: void
[IpcChannels.Repeat]: RepeatMode
[IpcChannels.GetAudioCacheSize]: void
[IpcChannels.GetVideoCover]: string | undefined
[IpcChannels.SetVideoCover]: void
}

View File

@ -22,7 +22,7 @@ const Image = ({
className?: string
alt: string
lazyLoad?: boolean
placeholder?: 'artist' | 'album' | 'playlist' | 'podcast' | 'blank' | null
placeholder?: 'artist' | 'album' | 'playlist' | 'podcast' | 'blank' | false
onClick?: (e: React.MouseEvent<HTMLImageElement>) => void
onMouseOver?: (e: React.MouseEvent<HTMLImageElement>) => void
animation?: boolean
@ -48,6 +48,7 @@ const Image = ({
? {
animate,
initial: { opacity: 0 },
exit: { opacity: 0 },
transition,
}
: {}
@ -60,22 +61,30 @@ const Image = ({
: {}
return (
<div className={cx('relative overflow-hidden', className)}>
<div
className={cx(
'overflow-hidden',
className,
className?.includes('absolute') === false && 'relative'
)}
>
{/* Image */}
<motion.img
alt={alt}
className='absolute inset-0 h-full w-full'
src={src}
srcSet={srcSet}
sizes={sizes}
decoding='async'
loading={lazyLoad ? 'lazy' : undefined}
onError={onError}
onLoad={onLoad}
onClick={onClick}
onMouseOver={onMouseOver}
{...motionProps}
/>
<AnimatePresence>
<motion.img
alt={alt}
className='absolute inset-0 h-full w-full'
src={src}
srcSet={srcSet}
sizes={sizes}
decoding='async'
loading={lazyLoad ? 'lazy' : undefined}
onError={onError}
onLoad={onLoad}
onClick={onClick}
onMouseOver={onMouseOver}
{...motionProps}
/>
</AnimatePresence>
{/* Placeholder / Error fallback */}
<AnimatePresence>

View File

@ -4,6 +4,7 @@ import Router from './Router'
const Main = () => {
return (
<main
id='main'
className={cx(
'no-scrollbar overflow-y-auto pb-16 pr-6 pl-10',
css`

View File

@ -8,6 +8,10 @@ import { AnimatePresence, motion } from 'framer-motion'
import Image from './Image'
import Wave from './Wave'
import Icon from '@/web/components/Icon'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
import { useWindowSize } from 'react-use'
import { playerWidth, topbarHeight } from '@/web/utils/const'
const Header = () => {
return (
@ -46,13 +50,13 @@ const Track = ({
return (
<motion.div
className='flex items-center justify-between'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
transition={{
duration: 0.24,
}}
layout
// initial={{ opacity: 0 }}
// animate={{ opacity: 1 }}
// exit={{ x: '100%', opacity: 0 }}
// transition={{
// duration: 0.24,
// }}
// layout
onClick={e => {
if (e.detail === 2 && track?.id) player.playTrack(track.id)
}}
@ -62,6 +66,8 @@ const Track = ({
alt='Cover'
className='mr-4 aspect-square h-14 w-14 flex-shrink-0 rounded-12'
src={resizeImage(track?.al?.picUrl || '', 'sm')}
animation={false}
placeholder={false}
/>
{/* Track info */}
@ -93,41 +99,82 @@ const Track = ({
)
}
const PlayingNext = ({ className }: { className?: string }) => {
const TrackList = ({ className }: { className?: string }) => {
const { trackList, trackIndex, state } = useSnapshot(player)
const { data: tracks } = useTracks({ ids: trackList })
const { data: tracksRaw } = useTracks({ ids: trackList })
const tracks = tracksRaw?.songs || []
const parentRef = useRef<HTMLDivElement>(null)
const { height } = useWindowSize()
const listHeight = height - topbarHeight - playerWidth - 24 - 20 // 24是封面与底部间距20是list与封面间距
const rowVirtualizer = useVirtualizer({
count: tracks.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 76,
overscan: 5,
})
return (
<>
<Header />
<div
ref={parentRef}
style={{
height: `${listHeight}px`,
}}
className={cx(
'no-scrollbar relative z-10 overflow-scroll',
'no-scrollbar relative z-10 w-full overflow-auto',
className,
css`
padding-top: 42px;
mask-image: linear-gradient(to bottom, transparent 0, black 42px);
mask-image: linear-gradient(
to bottom,
transparent 0,
black 42px
); // 顶部渐变遮罩
`
)}
>
<motion.div className='grid gap-4'>
<AnimatePresence>
{tracks?.songs?.map((track, index) => (
<div
className='relative w-full'
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((row: any) => (
<div
key={row.index}
className='absolute top-0 left-0 w-full'
style={{
height: `${row.size}px`,
transform: `translateY(${row.start}px)`,
}}
>
<Track
key={track.id}
track={track}
index={index}
track={tracks?.[row.index]}
index={row.index}
playingTrackIndex={trackIndex}
state={state}
/>
))}
{(tracks?.songs?.length || 0) >= 4 && (
<div className='pointer-events-none sticky bottom-0 h-8 w-full bg-gradient-to-t from-black'></div>
)}
</AnimatePresence>
</motion.div>
</div>
))}
</div>
</div>
{/* 底部渐变遮罩 */}
<div
className='pointer-events-none absolute right-0 left-0 z-20 h-14 bg-gradient-to-t from-black to-transparent'
style={{ top: `${listHeight - 56}px` }}
></div>
</>
)
}
const PlayingNext = ({ className }: { className?: string }) => {
return (
<>
<Header />
<TrackList className={className} />
</>
)
}

View File

@ -1,10 +1,134 @@
import { formatDate, formatDuration, resizeImage } from '@/web/utils/common'
import {
formatDate,
formatDuration,
isIOS,
isSafari,
resizeImage,
} from '@/web/utils/common'
import { css, cx } from '@emotion/css'
import Icon from '@/web/components/Icon'
import dayjs from 'dayjs'
import { useMemo } from 'react'
import Image from './Image'
import useIsMobile from '@/web/hooks/useIsMobile'
import { memo, useEffect, useMemo, useRef } from 'react'
import Hls from 'hls.js'
import Plyr, { APITypes, PlyrProps, PlyrInstance } from 'plyr-react'
import useVideoCover from '@/web/hooks/useVideoCover'
import { motion } from 'framer-motion'
import { ease } from '@/web/utils/const'
import { injectGlobal } from '@emotion/css'
injectGlobal`
.plyr__video-wrapper,
.plyr--video {
background-color: transparent !important;
}
`
const VideoCover = ({ source }: { source?: string }) => {
const ref = useRef<APITypes>(null)
useEffect(() => {
const loadVideo = async () => {
if (!source || !Hls.isSupported()) return
const video = document.getElementById('plyr') as HTMLVideoElement
const hls = new Hls()
hls.loadSource(source)
hls.attachMedia(video)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref.current!.plyr.media = video
hls.on(Hls.Events.MANIFEST_PARSED, () => {
// eslint-disable-next-line @typescript-eslint/no-extra-semi
;(ref.current!.plyr as PlyrInstance).play()
})
}
loadVideo()
})
return (
<div className='z-10 aspect-square overflow-hidden rounded-24'>
<Plyr
id='plyr'
options={{
volume: 0,
controls: [],
autoplay: true,
clickToPlay: false,
loop: {
active: true,
},
}}
source={{} as PlyrProps['source']}
ref={ref}
/>
</div>
)
}
const Cover = memo(
({ album, playlist }: { album?: Album; playlist?: Playlist }) => {
const isMobile = useIsMobile()
const { data: videoCover } = useVideoCover({
id: album?.id,
name: album?.name,
artist: album?.artist.name,
})
const cover = album?.picUrl || playlist?.coverImgUrl || ''
return (
<>
<div className='relative z-10 aspect-square w-full overflow-auto rounded-24 '>
<Image
className='absolute inset-0 h-full w-full'
src={resizeImage(cover, 'lg')}
alt='Cover'
/>
{videoCover && (
<motion.div
initial={{ opacity: isIOS ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease }}
className='absolute inset-0 h-full w-full'
>
{isSafari ? (
<video
src={videoCover}
className='h-full w-full'
autoPlay
loop
muted
playsInline
></video>
) : (
<VideoCover source={videoCover} />
)}
</motion.div>
)}
</div>
{/* Blur bg */}
{!isMobile && (
<img
className={cx(
'absolute z-0 object-cover opacity-70',
css`
top: -400px;
left: -370px;
width: 1572px;
height: 528px;
filter: blur(256px) saturate(1.2);
`
)}
src={resizeImage(cover, 'sm')}
/>
)}
</>
)
}
)
Cover.displayName = 'Cover'
const TrackListHeader = ({
album,
@ -15,18 +139,16 @@ const TrackListHeader = ({
playlist?: Playlist
onPlay: () => void
}) => {
const isMobile = useIsMobile()
const albumDuration = useMemo(() => {
const duration = album?.songs?.reduce((acc, cur) => acc + cur.dt, 0) || 0
return formatDuration(duration, 'en', 'hh[hr] mm[min]')
}, [album?.songs])
const isMobile = useIsMobile()
const cover = album?.picUrl || playlist?.coverImgUrl || ''
return (
<div
className={cx(
'mx-2.5 rounded-48 p-8 dark:bg-white/10',
'z-10 mx-2.5 rounded-48 p-8 dark:bg-white/10',
'lg:mx-0 lg:grid lg:grid-rows-1 lg:gap-10 lg:rounded-none lg:p-0 lg:dark:bg-transparent',
!isMobile &&
css`
@ -34,29 +156,7 @@ const TrackListHeader = ({
`
)}
>
{/* Cover */}
<Image
className='z-10 aspect-square w-full rounded-24'
src={resizeImage(cover, 'lg')}
alt='Cover'
/>
{/* Blur bg */}
{!isMobile && (
<img
className={cx(
'absolute z-0 object-cover opacity-70',
css`
top: -400px;
left: -370px;
width: 1572px;
height: 528px;
filter: blur(256px) saturate(1.2);
`
)}
src={resizeImage(cover, 'sm')}
/>
)}
<Cover {...{ album, playlist }} />
<div className='flex flex-col justify-between'>
<div>

View File

@ -10,6 +10,10 @@ declare global {
channel: T,
params?: IpcChannelsParams[T]
) => IpcChannelsReturns[T]
invoke: <T extends keyof IpcChannelsParams>(
channel: T,
params?: IpcChannelsParams[T]
) => Promise<IpcChannelsReturns[T]>
send: <T extends keyof IpcChannelsParams>(
channel: T,
params?: IpcChannelsParams[T]

View File

@ -0,0 +1,43 @@
import { IpcChannels } from '@/shared/IpcChannels'
import axios from 'axios'
import { useQuery } from 'react-query'
export default function useVideoCover(props: {
id?: number
name?: string
artist?: string
}) {
const { id, name, artist } = props
return useQuery(
['useVideoCover', props],
async () => {
if (!id || !name || !artist) return
const fromCache = window.ipcRenderer?.sendSync(
IpcChannels.GetVideoCover,
{
id,
}
)
if (fromCache) {
return fromCache === 'no' ? undefined : fromCache
}
const fromRemote = await axios.get('/yesplaymusic/video-cover', {
params: props,
})
window.ipcRenderer?.send(IpcChannels.SetVideoCover, {
id,
url: fromRemote.data.url || '',
})
if (fromRemote?.data?.url) {
return fromRemote.data.url
}
},
{
enabled: !!id && !!name && !!artist,
refetchOnWindowFocus: false,
refetchInterval: false,
}
)
}

View File

@ -5,7 +5,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/public/favicon.svg" />
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline' www.googletagmanager.com;" />
<meta http-equiv="Content-Security-Policy"
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>

View File

@ -14,6 +14,7 @@ import ReactGA from 'react-ga4'
import { ipcRenderer } from './ipcRenderer'
import { QueryClientProvider } from 'react-query'
import reactQueryClient from '@/web/utils/reactQueryClient'
import ReactDOM from 'react-dom'
ReactGA.initialize('G-KMJJCFZDKF')
@ -34,12 +35,23 @@ ipcRenderer()
const container = document.getElementById('root') as HTMLElement
const root = ReactDOMClient.createRoot(container)
root.render(
// root.render(
// <StrictMode>
// <BrowserRouter>
// <QueryClientProvider client={reactQueryClient}>
// <App />
// </QueryClientProvider>
// </BrowserRouter>
// </StrictMode>
// )
ReactDOM.render(
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={reactQueryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</StrictMode>
</StrictMode>,
document.getElementById('root')
)

View File

@ -25,15 +25,19 @@
"@emotion/css": "^11.9.0",
"@sentry/react": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"@tanstack/react-virtual": "3.0.0-beta.2",
"axios": "^0.27.2",
"color.js": "^1.2.0",
"colord": "^2.9.2",
"dayjs": "^1.11.1",
"framer-motion": "^6.3.4",
"hls.js": "^1.1.5",
"howler": "^2.2.3",
"js-cookie": "^3.0.1",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"plyr": "^3.7.2",
"plyr-react": "^5.0.2",
"qrcode": "^1.5.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",

View File

@ -11,6 +11,7 @@ import useArtistAlbums from '@/web/api/hooks/useArtistAlbums'
import { css, cx } from '@emotion/css'
import CoverRow from '@/web/components/New/CoverRow'
import { useMemo } from 'react'
import 'plyr-react/plyr.css'
const MoreByArtist = ({ album }: { album?: Album }) => {
const { data: albums } = useArtistAlbums({

View File

@ -39,7 +39,10 @@ const Albums = () => {
const Playlists = () => {
const { data: playlists } = useUserPlaylists()
return (
<CoverRow playlists={playlists?.playlist} className='mt-6 px-2.5 lg:px-0' />
<CoverRow
playlists={playlists?.playlist?.slice(1)}
className='mt-6 px-2.5 lg:px-0'
/>
)
}

View File

@ -160,7 +160,9 @@ export async function calcCoverColor(coverUrl: string) {
}
export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
export const isSafari = /Safari/.test(navigator.userAgent)
export const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent
)
export const isPWA =
(navigator as any).standalone ||
window.matchMedia('(display-mode: standalone)').matches

View File

@ -1,4 +1,7 @@
// 动画曲线
export const ease: [number, number, number, number] = [0.4, 0, 0.2, 1]
// 屏幕断点
export const breakpoint = {
sm: '@media (min-width: 640px)',
md: '@media (min-width: 768px)',
@ -6,3 +9,6 @@ export const breakpoint = {
xl: '@media (min-width: 1280px)',
'2xl': '@media (min-width: 1536px)',
}
export const topbarHeight = 132 // 桌面端顶栏高度 (px)
export const playerWidth = 318 // 桌面端播放器宽度 (px)

View File

@ -91,6 +91,11 @@ export default defineConfig({
changeOrigin: true,
rewrite: path => (IS_ELECTRON ? path : path.replace(/^\/netease/, '')),
},
'/yesplaymusic/video-cover': {
target: `http://168.138.40.199:51324`,
// target: `http://127.0.0.1:51324`,
changeOrigin: true,
},
'/yesplaymusic/': {
target: `http://127.0.0.1:${
process.env.ELECTRON_DEV_NETEASE_API_PORT || 3000

View File

@ -48,6 +48,7 @@ importers:
express: ^4.18.1
express-fileupload: ^1.4.0
fast-folder-size: ^1.7.0
m3u8-parser: ^4.7.1
minimist: ^1.2.6
music-metadata: ^7.12.3
NeteaseCloudMusicApi: ^4.6.2
@ -70,6 +71,7 @@ importers:
electron-store: 8.0.1
express: 4.18.1
fast-folder-size: 1.7.0
m3u8-parser: 4.7.1
NeteaseCloudMusicApi: 4.6.2
pretty-bytes: 6.0.0
devDependencies:
@ -115,6 +117,7 @@ importers:
'@storybook/builder-vite': ^0.1.35
'@storybook/react': ^6.5.5
'@storybook/testing-library': ^0.0.11
'@tanstack/react-virtual': 3.0.0-beta.2
'@testing-library/react': ^13.3.0
'@types/howler': ^2.2.7
'@types/js-cookie': ^3.0.2
@ -138,12 +141,15 @@ importers:
eslint-plugin-react: ^7.30.0
eslint-plugin-react-hooks: ^4.5.0
framer-motion: ^6.3.4
hls.js: ^1.1.5
howler: ^2.2.3
js-cookie: ^3.0.1
jsdom: ^19.0.0
lodash-es: ^4.17.21
md5: ^2.3.0
open-cli: ^7.0.1
plyr: ^3.7.2
plyr-react: ^5.0.2
postcss: ^8.4.14
prettier: '*'
prettier-plugin-tailwindcss: ^0.1.11
@ -169,15 +175,19 @@ importers:
'@emotion/css': 11.9.0
'@sentry/react': 6.19.7_react@18.1.0
'@sentry/tracing': 6.19.7
'@tanstack/react-virtual': 3.0.0-beta.2
axios: 0.27.2
color.js: 1.2.0
colord: 2.9.2
dayjs: 1.11.2
framer-motion: 6.3.10_ef5jwxihqo6n7gxfmzogljlgcm
hls.js: 1.1.5
howler: 2.2.3
js-cookie: 3.0.1
lodash-es: 4.17.21
md5: 2.3.0
plyr: 3.7.2
plyr-react: 5.0.2_react@18.1.0
qrcode: 1.5.0
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
@ -4527,6 +4537,10 @@ packages:
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
dev: true
/@reach/observe-rect/1.2.0:
resolution: {integrity: sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==}
dev: false
/@rollup/plugin-babel/5.3.1_4kojsos35jimftt7mhjohcqk6y:
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
engines: {node: '>= 10.0.0'}
@ -6386,6 +6400,13 @@ packages:
defer-to-connect: 2.0.1
dev: true
/@tanstack/react-virtual/3.0.0-beta.2:
resolution: {integrity: sha512-pwA9URTHYXX/2PgIISoMcf1P77hxf5oI3L/IDQ19Q1xuAc76o2R2CwHv6vvl5fDhwVj5klOfBxJvuT61Lhy9/w==}
engines: {node: '>=12'}
dependencies:
'@reach/observe-rect': 1.2.0
dev: false
/@testing-library/dom/8.13.0:
resolution: {integrity: sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==}
engines: {node: '>=12'}
@ -7119,6 +7140,15 @@ packages:
'@unblockneteasemusic/rust-napi-win32-x64-msvc': 0.3.0
dev: false
/@videojs/vhs-utils/3.0.5:
resolution: {integrity: sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==}
engines: {node: '>=8', npm: '>=5'}
dependencies:
'@babel/runtime': 7.18.3
global: 4.4.0
url-toolkit: 2.2.5
dev: false
/@vitejs/plugin-react/1.3.2:
resolution: {integrity: sha512-aurBNmMo0kz1O4qRoY+FM4epSA39y3ShWGuqfLRA/3z0oEJAdtoSfgA3aO98/PCCHAqMaduLxIxErWrVKIFzXA==}
engines: {node: '>=12.0.0'}
@ -9424,7 +9454,6 @@ packages:
/core-js/3.22.7:
resolution: {integrity: sha512-Jt8SReuDKVNZnZEzyEQT5eK6T2RRCXkfTq7Lo09kpm+fHjgGewSbNjV+Wt4yZMhPDdzz2x1ulI5z/w4nxpBseg==}
requiresBuild: true
dev: true
/core-util-is/1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@ -9698,6 +9727,10 @@ packages:
dev: true
optional: true
/custom-event-polyfill/1.0.7:
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==}
dev: false
/cyclist/1.0.1:
resolution: {integrity: sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==}
dev: true
@ -10109,7 +10142,6 @@ packages:
/dom-walk/0.1.2:
resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==}
dev: true
/domain-browser/1.2.0:
resolution: {integrity: sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==}
@ -12259,7 +12291,6 @@ packages:
dependencies:
min-document: 2.19.0
process: 0.11.10
dev: true
/globals/11.12.0:
resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
@ -12578,6 +12609,10 @@ packages:
dependencies:
'@babel/runtime': 7.18.3
/hls.js/1.1.5:
resolution: {integrity: sha512-mQX5TSNtJEzGo5HPpvcQgCu+BWoKDQM6YYtg/KbgWkmVAcqOCvSTi0SuqG2ZJLXxIzdnFcKU2z7Mrw/YQWhPOA==}
dev: false
/hmac-drbg/1.0.1:
resolution: {integrity: sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=}
dependencies:
@ -13861,6 +13896,10 @@ packages:
json5: 2.2.1
dev: true
/loadjs/4.2.0:
resolution: {integrity: sha512-AgQGZisAlTPbTEzrHPb6q+NYBMD+DP9uvGSIjSUM5uG+0jG15cb8axWpxuOIqrmQjn6scaaH8JwloiP27b2KXA==}
dev: false
/local-pkg/0.4.1:
resolution: {integrity: sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==}
engines: {node: '>=14'}
@ -14001,6 +14040,14 @@ packages:
readable-stream: 3.6.0
dev: true
/m3u8-parser/4.7.1:
resolution: {integrity: sha512-pbrQwiMiq+MmI9bl7UjtPT3AK603PV9bogNlr83uC+X9IoxqL5E4k7kU7fMQ0dpRgxgeSMygqUa0IMLQNXLBNA==}
dependencies:
'@babel/runtime': 7.18.3
'@videojs/vhs-utils': 3.0.5
global: 4.4.0
dev: false
/magic-string/0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
dependencies:
@ -14357,10 +14404,9 @@ packages:
engines: {node: '>=10'}
/min-document/2.19.0:
resolution: {integrity: sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=}
resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==}
dependencies:
dom-walk: 0.1.2
dev: true
/min-indent/1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
@ -15472,6 +15518,30 @@ packages:
xmlbuilder: 9.0.7
dev: true
/plyr-react/5.0.2_react@18.1.0:
resolution: {integrity: sha512-CksykyesFtmPoslasOVIplYZkduJ2OQ/q3QNUdktjy8Ds4Rhxw9u57jKBjSLdpyhENp/Yu+lDC7lOHc1o9iPUQ==}
engines: {node: '>=12.7.0'}
peerDependencies:
react: '>=16.8'
peerDependenciesMeta:
react:
optional: true
dependencies:
plyr: 3.7.2
react: 18.1.0
react-aptor: 2.0.0-alpha.1_react@18.1.0
dev: false
/plyr/3.7.2:
resolution: {integrity: sha512-I0ZC/OI4oJ0iWG9s2rrnO0YFO6aLyrPiQBq9kum0FqITYljwTPBbYL3TZZu8UJQJUq7tUWN18Q7ACwNCkGKABQ==}
dependencies:
core-js: 3.22.7
custom-event-polyfill: 1.0.7
loadjs: 4.2.0
rangetouch: 2.0.1
url-polyfill: 1.1.12
dev: false
/pngjs/5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
@ -15793,9 +15863,8 @@ packages:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
/process/0.11.10:
resolution: {integrity: sha1-czIwDoQBYb2j5podHZGn1LwW8YI=}
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
dev: true
/progress/2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
@ -16020,6 +16089,10 @@ packages:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
/rangetouch/2.0.1:
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==}
dev: false
/raw-body/2.5.1:
resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
engines: {node: '>= 0.8'}
@ -16049,6 +16122,18 @@ packages:
minimist: 1.2.6
strip-json-comments: 2.0.1
/react-aptor/2.0.0-alpha.1_react@18.1.0:
resolution: {integrity: sha512-FbvxQKsZMUZcLr2WdrQEmxH0kifsN4N+v6YdL1g3At03zouJCEcPXv+o+bhP3Ci3ya4QPvNHK/bpbrCzuKWOMw==}
engines: {node: '>=12.7.0'}
peerDependencies:
react: '>=16.8'
peerDependenciesMeta:
react:
optional: true
dependencies:
react: 18.1.0
dev: false
/react-docgen-typescript/2.2.2_typescript@4.7.3:
resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==}
peerDependencies:
@ -18653,6 +18738,14 @@ packages:
prepend-http: 2.0.0
dev: true
/url-polyfill/1.1.12:
resolution: {integrity: sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A==}
dev: false
/url-toolkit/2.2.5:
resolution: {integrity: sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==}
dev: false
/url/0.11.0:
resolution: {integrity: sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=}
dependencies: