fix:优化滚动位置问题,某平台 歌单上限导入失败问题,优化包体积,修复歌曲下载失败

This commit is contained in:
sqj
2025-10-11 22:54:10 +08:00
parent 0c512bccff
commit d44be6022a
18 changed files with 627 additions and 719 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.4.2",
"version": "1.4.3",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
@@ -47,7 +47,7 @@
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/howler": "^2.2.12",
"@types/needle": "^3.3.0",
"NeteaseCloudMusicApi": "^4.27.0",
"animate.css": "^4.1.1",
@@ -57,8 +57,7 @@
"dompurify": "^3.2.6",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3",
"howler": "^2.2.4",
"hpagent": "^1.2.0",
"iconv-lite": "^0.7.0",
"jss": "^10.10.0",
@@ -69,7 +68,7 @@
"mitt": "^3.0.1",
"needle": "^3.3.1",
"node-fetch": "2",
"node-id3": "^0.2.9",
"node-taglib-sharp": "^6.0.1",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"tdesign-icons-vue-next": "^0.4.1",

View File

@@ -0,0 +1,92 @@
export const QUALITY_ORDER = [
'master',
'atmos_plus',
'atmos',
'hires',
'flac24bit',
'flac',
'320k',
'192k',
'128k'
] as const
export type KnownQuality = (typeof QUALITY_ORDER)[number]
export type QualityInput = KnownQuality | string | { type: string; size?: string }
const DISPLAY_NAME_MAP: Record<string, string> = {
'128k': '标准',
'192k': '高品',
'320k': '超高',
flac: '无损',
flac24bit: '超高解析',
hires: '高清臻音',
atmos: '全景环绕',
atmos_plus: '全景增强',
master: '超清母带'
}
/**
* 统一获取音质中文显示名称
*/
export function getQualityDisplayName(quality: QualityInput | null | undefined): string {
if (!quality) return ''
const type = typeof quality === 'object' ? (quality as any).type : quality
return DISPLAY_NAME_MAP[type] || String(type || '')
}
/**
* 比较两个音质优先级(返回负数表示 a 优于 b
*/
export function compareQuality(aType: string, bType: string): number {
const ia = QUALITY_ORDER.indexOf(aType as KnownQuality)
const ib = QUALITY_ORDER.indexOf(bType as KnownQuality)
const va = ia === -1 ? QUALITY_ORDER.length : ia
const vb = ib === -1 ? QUALITY_ORDER.length : ib
return va - vb
}
/**
* 规范化 types兼容 string 与 {type,size}
*/
export function normalizeTypes(
types: Array<string | { type: string; size?: string }> | null | undefined
): string[] {
if (!types || !Array.isArray(types)) return []
return types
.map((t) => (typeof t === 'object' ? (t as any).type : t))
.filter((t): t is string => Boolean(t))
}
/**
* 获取数组中最高音质类型
*/
export function getHighestQualityType(
types: Array<string | { type: string; size?: string }> | null | undefined
): string | null {
const arr = normalizeTypes(types)
if (!arr.length) return null
return arr.sort(compareQuality)[0]
}
/**
* 构建并按优先级排序的 [{type, size}] 列表
* 支持传入:
* - 数组:[{type,size}]
* - _types 映射:{ [type]: { size } }
*/
export function buildQualityFormats(
input:
| Array<{ type: string; size?: string }>
| Record<string, { size?: string }>
| null
| undefined
): Array<{ type: string; size?: string }> {
if (!input) return []
let list: Array<{ type: string; size?: string }>
if (Array.isArray(input)) {
list = input.map((i) => ({ type: i.type, size: i.size }))
} else {
list = Object.keys(input).map((k) => ({ type: k, size: input[k]?.size }))
}
return list.sort((a, b) => compareQuality(a.type, b.type))
}

150
src/main/events/index.ts Normal file
View File

@@ -0,0 +1,150 @@
import InitPluginService from './plugins'
import '../services/musicSdk/index'
import aiEvents from '../events/ai'
import { app, powerSaveBlocker, Menu } from 'electron'
import path from 'node:path'
import { type BrowserWindow, Tray, ipcMain } from 'electron'
export default function InitEventServices(mainWindow: BrowserWindow) {
InitPluginService()
aiEvents(mainWindow)
basisEvent(mainWindow)
}
function basisEvent(mainWindow: BrowserWindow) {
let psbId: number | null = null
let tray: Tray | null = null
let isQuitting = false
function createTray(): void {
// 创建系统托盘
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
tray = new Tray(trayIconPath)
// 创建托盘菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
click: () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
}
},
{
label: '播放/暂停',
click: () => {
// 这里可以添加播放控制逻辑
console.log('music-control')
mainWindow?.webContents.send('music-control')
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
tray.setContextMenu(contextMenu)
tray.setToolTip('Ceru Music')
// 单击托盘图标显示窗口
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
}
})
}
createTray()
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
})
// 窗口控制 IPC 处理
ipcMain.on('window-minimize', () => {
mainWindow.minimize()
})
ipcMain.on('window-maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
})
ipcMain.on('window-close', () => {
mainWindow.close()
})
// Mini 模式 IPC 处理 - 最小化到系统托盘
ipcMain.on('window-mini-mode', (_, isMini) => {
if (mainWindow) {
if (isMini) {
// 进入 Mini 模式:隐藏窗口到系统托盘
mainWindow.hide()
// 显示托盘通知(可选)
if (tray) {
tray.displayBalloon({
title: '澜音 Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
} else {
// 退出 Mini 模式:显示窗口
mainWindow.show()
mainWindow.focus()
}
}
})
// 全屏模式 IPC 处理
ipcMain.on('window-toggle-fullscreen', () => {
const isFullScreen = mainWindow.isFullScreen()
mainWindow.setFullScreen(!isFullScreen)
})
// 阻止窗口关闭,改为隐藏到系统托盘
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
mainWindow?.hide()
// 显示托盘通知
if (tray) {
tray.displayBalloon({
title: 'Ceru Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
}
})
// 阻止系统息屏 IPC开启/关闭)
ipcMain.handle('power-save-blocker:start', () => {
if (psbId == null) {
psbId = powerSaveBlocker.start('prevent-display-sleep')
}
return psbId
})
ipcMain.handle('power-save-blocker:stop', () => {
if (psbId != null && powerSaveBlocker.isStarted(psbId)) {
powerSaveBlocker.stop(psbId)
}
psbId = null
return true
})
// 获取应用版本号
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
}

View File

@@ -0,0 +1,82 @@
import { ipcMain } from 'electron'
import pluginService from '../services/plugin'
function PluginEvent() {
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
try {
return await pluginService.selectAndAddPlugin(type)
} catch (error: any) {
console.error('Error selecting and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
try {
return await pluginService.downloadAndAddPlugin(url, type)
} catch (error: any) {
console.error('Error downloading and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
try {
return await pluginService.addPlugin(pluginCode, pluginName)
} catch (error: any) {
console.error('Error adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginById', async (_, id): Promise<any> => {
try {
return pluginService.getPluginById(id)
} catch (error: any) {
console.error('Error getting plugin by id:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-loadAllPlugins', async (): Promise<any> => {
try {
// 使用新的 getPluginsList 方法,但保持 API 兼容性
return await pluginService.getPluginsList()
} catch (error: any) {
console.error('Error loading all plugins:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginLog', async (_, pluginId): Promise<any> => {
try {
return await pluginService.getPluginLog(pluginId)
} catch (error: any) {
console.error('Error getting plugin log:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<any> => {
try {
return await pluginService.uninstallPlugin(pluginId)
} catch (error: any) {
console.error('Error uninstalling plugin:', error)
return { error: error.message }
}
})
}
export default function InitPluginService() {
setTimeout(async () => {
// 初始化插件系统
try {
await pluginService.initializePlugins()
PluginEvent()
console.log('插件系统初始化完成')
} catch (error) {
console.error('插件系统初始化失败:', error)
}
}, 1000)
}

View File

@@ -1,12 +1,16 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen, powerSaveBlocker } from 'electron'
import { app, shell, BrowserWindow, ipcMain, screen } from 'electron'
import { configManager } from './services/ConfigManager'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/logo.png?asset'
import path from 'node:path'
import pluginService from './services/plugin'
import aiEvents from './events/ai'
import './services/musicSdk/index'
import InitEventServices from './events'
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock()
@@ -24,72 +28,9 @@ if (!gotTheLock) {
}
})
}
// import wy from './utils/musicSdk/wy/index'
// import kg from './utils/musicSdk/kg/index'
// wy.hotSearch.getList().then((res) => {
// console.log(res)
// })
// kg.hotSearch.getList().then((res) => {
// console.log(res)
// })
let tray: Tray | null = null
let psbId: number | null = null
let mainWindow: BrowserWindow | null = null
let isQuitting = false
function createTray(): void {
// 创建系统托盘
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
tray = new Tray(trayIconPath)
// 创建托盘菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
click: () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
}
},
{
label: '播放/暂停',
click: () => {
// 这里可以添加播放控制逻辑
console.log('music-control')
mainWindow?.webContents.send('music-control')
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
tray.setContextMenu(contextMenu)
tray.setToolTip('Ceru Music')
// 双击托盘图标显示窗口
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
}
})
}
function createWindow(): void {
// return
// 获取保存的窗口位置和大小
const savedBounds = configManager.getWindowBounds()
@@ -174,29 +115,11 @@ function createWindow(): void {
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
// 阻止窗口关闭,改为隐藏到系统托盘
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
mainWindow?.hide()
// 显示托盘通知
if (tray) {
tray.displayBalloon({
iconType: 'info',
title: 'Ceru Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
}
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url).then()
return { action: 'deny' }
})
InitEventServices(mainWindow)
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
@@ -206,80 +129,6 @@ function createWindow(): void {
}
}
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
try {
return await pluginService.selectAndAddPlugin(type)
} catch (error: any) {
console.error('Error selecting and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
try {
return await pluginService.downloadAndAddPlugin(url, type)
} catch (error: any) {
console.error('Error downloading and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
try {
return await pluginService.addPlugin(pluginCode, pluginName)
} catch (error: any) {
console.error('Error adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginById', async (_, id): Promise<any> => {
try {
return pluginService.getPluginById(id)
} catch (error: any) {
console.error('Error getting plugin by id:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-loadAllPlugins', async (): Promise<any> => {
try {
// 使用新的 getPluginsList 方法,但保持 API 兼容性
return await pluginService.getPluginsList()
} catch (error: any) {
console.error('Error loading all plugins:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginLog', async (_, pluginId): Promise<any> => {
try {
return await pluginService.getPluginLog(pluginId)
} catch (error: any) {
console.error('Error getting plugin log:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<any> => {
try {
return await pluginService.uninstallPlugin(pluginId)
} catch (error: any) {
console.error('Error uninstalling plugin:', error)
return { error: error.message }
}
})
// 获取应用版本号
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
aiEvents(mainWindow)
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
// This method will be called when Electron has finished
@@ -296,16 +145,6 @@ app.whenReady().then(() => {
app.setName('澜音')
}
setTimeout(async () => {
// 初始化插件系统
try {
await pluginService.initializePlugins()
console.log('插件系统初始化完成')
} catch (error) {
console.error('插件系统初始化失败:', error)
}
}, 1000)
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -313,79 +152,7 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// 窗口控制 IPC 处理
ipcMain.on('window-minimize', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
window.minimize()
}
})
ipcMain.on('window-maximize', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
if (window.isMaximized()) {
window.unmaximize()
} else {
window.maximize()
}
}
})
ipcMain.on('window-close', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
window.close()
}
})
// Mini 模式 IPC 处理 - 最小化到系统托盘
ipcMain.on('window-mini-mode', (_, isMini) => {
if (mainWindow) {
if (isMini) {
// 进入 Mini 模式:隐藏窗口到系统托盘
mainWindow.hide()
// 显示托盘通知(可选)
if (tray) {
tray.displayBalloon({
title: '澜音 Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
} else {
// 退出 Mini 模式:显示窗口
mainWindow.show()
mainWindow.focus()
}
}
})
// 全屏模式 IPC 处理
ipcMain.on('window-toggle-fullscreen', () => {
if (mainWindow) {
const isFullScreen = mainWindow.isFullScreen()
mainWindow.setFullScreen(!isFullScreen)
}
})
// 阻止系统息屏 IPC开启/关闭)
ipcMain.handle('power-save-blocker:start', () => {
if (psbId == null) {
psbId = powerSaveBlocker.start('prevent-display-sleep')
}
return psbId
})
ipcMain.handle('power-save-blocker:stop', () => {
if (psbId != null && powerSaveBlocker.isStarted(psbId)) {
powerSaveBlocker.stop(psbId)
}
psbId = null
return true
})
createWindow()
createTray()
// 注册自动更新事件
registerAutoUpdateEvents()
@@ -415,67 +182,19 @@ app.on('window-all-closed', () => {
// 在其他平台上,我们也保持应用运行,因为有系统托盘
})
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
let ping: NodeJS.Timeout
function startPing() {
let interval = 3000
// 已迁移到 Howler不再使用 DOM <audio> 轮询。
// 如需主进程感知播放结束,请在渲染进程通过 IPC 主动通知。
if (ping) {
clearInterval(ping)
}
ping = setInterval(() => {
if (mainWindow) {
mainWindow.webContents
.executeJavaScript(
`
(function() {
const audio = document.getElementById("globaAudio");
if(!audio) return { playing:false, ended: false };
if(audio.ended) return { playing:false, ended: true };
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
})()
`
)
.then((res) => {
console.log(res)
if (res.duration - res.currentTime <= 20) {
clearInterval(ping)
interval = 500
ping = setInterval(() => {
if (mainWindow) {
mainWindow.webContents
.executeJavaScript(
`
(function() {
const audio = document.getElementById("globaAudio");
if(!audio) return { playing:false, ended: false };
if(audio.ended) return { playing:false, ended: true };
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
})()
`
)
.then((res) => {
console.log(res)
if (res && res.ended) {
mainWindow?.webContents.send('song-ended')
console.log('next song')
clearInterval(ping)
}
})
.catch((err) => console.warn(err))
}
}, interval)
}
})
.catch((err) => console.warn(err))
}
}, interval)
// 保留占位,避免调用方报错;不再做任何轮询。
// 可在此处监听自定义 IPC 事件以扩展行为。
clearInterval(ping)
}, 1000)
}

View File

@@ -1,6 +1,4 @@
import NodeID3 from 'node-id3'
import ffmpegStatic from 'ffmpeg-static'
import ffmpeg from 'fluent-ffmpeg'
import { File, Picture, Id3v2Settings } from 'node-taglib-sharp'
import path from 'node:path'
import axios from 'axios'
import fs from 'fs'
@@ -75,6 +73,32 @@ function formatTimestamp(timeMs: number): string {
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
}
// 根据图片 URL 和 Content-Type 解析扩展名,默认返回 .jpg
function resolveCoverExt(imgUrl: string, contentType?: string): string {
const validExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp'])
let urlExt: string | undefined
try {
const pathname = new URL(imgUrl).pathname
const i = pathname.lastIndexOf('.')
if (i !== -1) {
urlExt = pathname.substring(i).toLowerCase()
}
} catch {}
if (urlExt && validExts.has(urlExt)) {
return urlExt === '.jpeg' ? '.jpg' : urlExt
}
if (contentType) {
if (contentType.includes('image/png')) return '.png'
if (contentType.includes('image/webp')) return '.webp'
if (contentType.includes('image/jpeg') || contentType.includes('image/jpg')) return '.jpg'
if (contentType.includes('image/bmp')) return '.bmp'
}
return '.jpg'
}
/**
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
*/
@@ -156,265 +180,6 @@ function convertOldFormat(timestamp: string, content: string): string {
return `[${timestamp}]${convertedContent}`
}
// 写入音频标签的辅助函数
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
// 获取文件扩展名来判断格式
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
console.log('文件格式:', fileExtension)
// 根据文件格式选择不同的标签写入方法
if (fileExtension === 'mp3') {
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
} else {
console.warn('不支持的音频格式:', fileExtension)
// 尝试使用 NodeID3 作为后备方案
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
}
} catch (error) {
console.error('写入音频标签时发生错误:', error)
throw error
}
}
// MP3 格式标签写入
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
const tags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
tags.title = songInfo.name || ''
tags.artist = songInfo.singer || ''
tags.album = songInfo.albumName || ''
tags.year = songInfo.year || ''
tags.genre = songInfo.genre || ''
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
tags.unsynchronisedLyrics = {
language: 'chi',
shortText: 'Lyrics',
text: convertedLrc
}
}
// 写入封面
if (tagWriteOptions.cover && songInfo.img) {
try {
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
tags.image = {
mime: 'image/jpeg',
type: {
id: 3,
name: 'front cover'
},
description: 'Cover',
imageBuffer: Buffer.from(coverResponse.data)
}
}
} catch (coverError) {
console.warn('获取封面失败:', coverError)
}
}
// 写入标签到文件
if (Object.keys(tags).length > 0) {
const success = NodeID3.write(tags, filePath)
if (success) {
console.log('MP3音频标签写入成功:', filePath)
} else {
console.warn('MP3音频标签写入失败:', filePath)
}
}
}
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入 FLAC 标签:', filePath)
// 准备新的标签数据
const newTags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
if (songInfo.name) newTags.TITLE = songInfo.name
if (songInfo.singer) newTags.ARTIST = songInfo.singer
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
if (songInfo.year) newTags.DATE = songInfo.year.toString()
if (songInfo.genre) newTags.GENRE = songInfo.genre
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
newTags.LYRICS = convertedLrc
}
console.log('准备写入的标签:', newTags)
// 使用 ffmpeg-static 写入 FLAC 标签
if (path.extname(filePath).toLowerCase() === '.flac') {
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
} else {
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
}
} catch (error) {
console.error('写入 Vorbis Comment 标签失败:', error)
throw error
}
}
// 使用 fluent-ffmpeg 写入 FLAC 标签
async function writeFLACTagsWithFFmpeg(
filePath: string,
tags: any,
songInfo: any,
tagWriteOptions: any
) {
let tempOutputPath: string | null = null
let tempCoverPath: string | null = null
try {
if (!ffmpegStatic) {
throw new Error('ffmpeg-static 不可用')
}
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
// 创建临时输出文件
tempOutputPath = filePath + '.temp.flac'
// 创建 fluent-ffmpeg 实例
let command = ffmpeg(filePath)
.audioCodec('copy') // 复制音频编解码器,不重新编码
.output(tempOutputPath)
// 添加元数据标签
for (const [key, value] of Object.entries(tags)) {
if (value) {
// fluent-ffmpeg 会自动处理特殊字符转义
command = command.outputOptions(['-metadata', `${key}=${value}`])
}
}
// 处理封面
if (tagWriteOptions.cover && songInfo.img) {
try {
console.log('开始下载封面:', songInfo.img)
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
// 保存临时封面文件
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
// 添加封面作为输入
command = command.input(tempCoverPath).outputOptions([
'-map',
'0:a', // 映射原始文件的音频流
'-map',
'1:v', // 映射封面的视频流
'-c:v',
'copy', // 复制视频编解码器
'-disposition:v:0',
'attached_pic' // 设置为附加图片
])
console.log('封面已添加到命令中')
}
} catch (coverError) {
console.warn(
'下载封面失败,跳过封面写入:',
coverError instanceof Error ? coverError.message : coverError
)
}
}
// 执行 ffmpeg 命令
await new Promise<void>((resolve, reject) => {
command
.on('start', () => {
console.log('执行 ffmpeg 命令')
})
.on('progress', (progress) => {
if (progress.percent) {
console.log('处理进度:', Math.round(progress.percent) + '%')
}
})
.on('end', () => {
console.log('ffmpeg 处理完成')
resolve()
})
.on('error', (err, _, stderr) => {
console.error('ffmpeg 错误:', err.message)
if (stderr) {
console.error('ffmpeg stderr:', stderr)
}
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
})
.run()
})
// 检查临时文件是否创建成功
if (!fs.existsSync(tempOutputPath)) {
throw new Error('ffmpeg 未能创建输出文件')
}
// 替换原文件
await fsPromise.rename(tempOutputPath, filePath)
tempOutputPath = null // 标记已处理,避免重复清理
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
} catch (error) {
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
throw error
} finally {
// 清理所有临时文件
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
for (const tempFile of filesToClean) {
try {
if (fs.existsSync(tempFile)) {
await fsPromise.unlink(tempFile)
console.log('已清理临时文件:', tempFile)
}
} catch (cleanupError) {
console.warn(
'清理临时文件失败:',
tempFile,
cleanupError instanceof Error ? cleanupError.message : cleanupError
)
}
}
}
}
// MP4/M4A 格式标签写入
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
// 可以使用 ffmpeg 或其他工具实现
}
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
// 使用配置管理服务获取下载目录
@@ -492,13 +257,71 @@ export default async function download(
delete fileLock[songPath]
}
// 写入标签信息
// 写入标签信息(使用 node-taglib-sharp
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
await writeAudioTags(songPath, songInfo, tagWriteOptions)
const baseName = path.basename(songPath, path.extname(songPath))
const dirName = path.dirname(songPath)
let coverExt = '.jpg'
let coverPath = path.join(dirName, `${baseName}${coverExt}`)
let coverDownloaded = false
// 下载封面仅当启用且有URL
if (tagWriteOptions.cover && songInfo?.img) {
try {
const coverRes = await axios.get(songInfo.img, {
responseType: 'arraybuffer',
timeout: 10000
})
const ct =
(coverRes.headers && (coverRes.headers['content-type'] as string | undefined)) ||
undefined
coverExt = resolveCoverExt(songInfo.img, ct)
coverPath = path.join(dirName, `${baseName}${coverExt}`)
await fsPromise.writeFile(coverPath, Buffer.from(coverRes.data))
coverDownloaded = true
} catch (e) {
console.warn('下载封面失败:', e instanceof Error ? e.message : e)
}
}
// 读取歌曲文件并设置标签
const songFile = File.createFromPath(songPath)
// 使用默认 ID3v2.3
Id3v2Settings.forceDefaultVersion = true
Id3v2Settings.defaultVersion = 3
songFile.tag.title = songInfo?.name || '未知曲目'
songFile.tag.album = songInfo?.albumName || '未知专辑'
const artists = songInfo?.singer ? [songInfo.singer] : ['未知艺术家']
songFile.tag.performers = artists
songFile.tag.albumArtists = artists
// 写入歌词(转换为标准 LRC
if (tagWriteOptions.lyrics && songInfo?.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
songFile.tag.lyrics = convertedLrc
}
// 写入封面
if (tagWriteOptions.cover && coverDownloaded) {
const songCover = Picture.fromPath(coverPath)
songFile.tag.pictures = [songCover]
}
// 保存并释放
songFile.save()
songFile.dispose()
// 删除临时封面
if (coverDownloaded) {
try {
await fsPromise.unlink(coverPath)
} catch {}
}
} catch (error) {
console.warn('写入音频标签失败:', error)
throw ffmpegStatic
console.warn('写入音乐元信息失败:', error)
}
}
return {

View File

@@ -83,7 +83,7 @@ export default {
const { statusCode, body } = await requestObj_listDetail.promise
if (statusCode !== 200 || body.code !== this.successCode)
return this.getListDetail(id, page, ++tryNum)
let limit = 1000
let limit = 50
let rangeStart = (page - 1) * limit
let list
try {

View File

@@ -18,6 +18,7 @@ declare module 'vue' {
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
NBackTop: typeof import('naive-ui')['NBackTop']
NBadge: typeof import('naive-ui')['NBadge']
NCard: typeof import('naive-ui')['NCard']
NIcon: typeof import('naive-ui')['NIcon']

View File

@@ -94,90 +94,106 @@ watch(
() => props.songId,
async (newId) => {
if (!newId || !props.songInfo) return
let lyricText = ''
let parsedLyrics: LyricLine[] = []
// 创建一个符合 MusicItem 接口的对象,只包含必要的基本属性
// 工具函数:清洗响应式对象,避免序列化问题
const getCleanSongInfo = () => JSON.parse(JSON.stringify(toRaw(props.songInfo)))
// 工具函数:按来源解析逐字歌词
const parseCrLyricBySource = (source: string, text: string): LyricLine[] => {
return source === 'tx' ? parseQrc(text) : parseYrc(text)
}
// 工具函数:合并翻译到主歌词
const mergeTranslation = (base: LyricLine[], tlyric?: string): LyricLine[] => {
if (!tlyric || base.length === 0) return base
const translated = parseLrc(tlyric)
if (!translated || translated.length === 0) return base
// 将译文按 startTime-endTime 建立索引,便于精确匹配
const keyOf = (s: number, e: number) => `${s}-${e}`
const joinWords = (line: LyricLine) => (line.words || []).map(w => w.word).join('')
const tMap = new Map<string, LyricLine>()
for (const tl of translated) {
tMap.set(keyOf(tl.startTime, tl.endTime), tl)
}
// 容差时间(毫秒),用于无法精确匹配时的最近行匹配
const TOLERANCE_MS = 1000
for (const bl of base) {
// 1) 先尝试精确匹配
let tLine = tMap.get(keyOf(bl.startTime, bl.endTime))
// 2) 若无精确匹配,按 startTime 进行容差范围内最近匹配
if (!tLine) {
let best: { line: LyricLine; diff: number } | null = null
for (const tl of translated) {
const diff = Math.abs(tl.startTime - bl.startTime)
// 要求结束时间也尽量接近,避免跨行误配
const endDiff = Math.abs(tl.endTime - bl.endTime)
const score = diff + endDiff
if (diff <= TOLERANCE_MS && endDiff <= TOLERANCE_MS) {
if (!best || score < best.diff) best = { line: tl, diff: score }
}
}
tLine = best?.line
}
if (tLine) {
const text = joinWords(tLine)
if (text) bl.translatedLyric = text
}
}
return base
}
try {
// 检查是否为网易云音乐只有网易云才使用ttml接口
const isNetease =
props.songInfo && 'source' in props.songInfo && props.songInfo.source === 'wy'
const songinfo: any = _.cloneDeep(toRaw(props.songInfo))
const source =
props.songInfo && 'source' in props.songInfo ? (props.songInfo as any).source : 'kg'
let parsedLyrics: LyricLine[] = []
if (isNetease) {
// 网易云音乐优先尝试ttml接口
if (source === 'wy') {
// 网易云优先尝试 TTML
try {
const res = (await (
const res = await (
await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`)
).text()) as any
).text()
if (!res || res.length < 100) throw new Error('ttml 无歌词')
parsedLyrics = parseTTML(res).lines
} catch {
// ttml失败后使用新的歌词API
// 回退到统一歌词 API
const lyricData = await window.api.music.requestSdk('getLyric', {
source: 'wy',
songInfo: songinfo
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
})
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
parsedLyrics = parseYrc(lyricText)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
parsedLyrics = parseLrc(lyricText)
if (lyricData?.crlyric) {
parsedLyrics = parseYrc(lyricData.crlyric)
} else if (lyricData?.lyric) {
parsedLyrics = parseLrc(lyricData.lyric)
}
if (lyricData.tlyric) {
const translatedline = parseLrc(lyricData.tlyric)
for (let i = 0; i < parsedLyrics.length; i++) {
if (translatedline[i] && translatedline[i].words[0]) {
parsedLyrics[i].translatedLyric = translatedline[i].words[0].word
}
}
}
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
}
} else {
// 其他音乐平台直接使用新的歌词API
const source = props.songInfo && 'source' in props.songInfo ? props.songInfo.source : 'kg'
// 创建一个纯净的对象避免Vue响应式对象序列化问题
const cleanSongInfo = JSON.parse(JSON.stringify(toRaw(props.songInfo)))
// 其他来源:直接统一歌词 API
const lyricData = await window.api.music.requestSdk('getLyric', {
source: source,
songInfo: cleanSongInfo
source,
songInfo: getCleanSongInfo()
})
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
if (source === 'tx') {
parsedLyrics = parseQrc(lyricText)
} else {
parsedLyrics = parseYrc(lyricText)
}
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
parsedLyrics = parseLrc(lyricText)
if (lyricData?.crlyric) {
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
} else if (lyricData?.lyric) {
parsedLyrics = parseLrc(lyricData.lyric)
}
if (lyricData.tlyric) {
const translatedline = parseLrc(lyricData.tlyric)
for (let i = 0; i < parsedLyrics.length; i++) {
if (translatedline[i] && translatedline[i].words[0]) {
parsedLyrics[i].translatedLyric = translatedline[i].words[0].word
}
}
}
}
if (parsedLyrics.length > 0) {
state.lyricLines = parsedLyrics
} else {
state.lyricLines = []
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
}
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
} catch (error) {
console.error('获取歌词失败:', error)
state.lyricLines = []

View File

@@ -606,8 +606,8 @@ const handleSuggestionSelect = (suggestion: any, _type: any) => {
.mainContent {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
// overflow-y: auto;
overflow: hidden;
position: relative;
height: 0;
/* 确保flex子元素能够正确计算高度 */

View File

@@ -67,18 +67,11 @@ function setAnimate(routerObj: RouteRecordRaw[]) {
}
}
setAnimate(routes)
const option: RouterOptions = {
history: createWebHashHistory(),
routes,
scrollBehavior(_to_, _from_, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
routes
}
const router = createRouter(option)
export default router

View File

@@ -2,6 +2,14 @@ import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { useSettingsStore } from '@renderer/store/Settings'
import { toRaw, h } from 'vue'
import {
QUALITY_ORDER,
getQualityDisplayName,
buildQualityFormats,
getHighestQualityType,
compareQuality,
type KnownQuality
} from '@common/utils/quality'
interface MusicItem {
singer: string
@@ -18,33 +26,17 @@ interface MusicItem {
typeUrl: Record<string, any>
}
const qualityMap: Record<string, string> = {
'128k': '标准音质',
'192k': '高品音质',
'320k': '超高品质',
flac: '无损音质',
flac24bit: '超高解析',
hires: '高清臻音',
atmos: '全景环绕',
master: '超清母带'
}
const qualityKey = Object.keys(qualityMap)
// 创建音质选择弹窗
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
return new Promise((resolve) => {
// 获取歌曲支持的音质列表
const availableQualities = songInfo.types || []
const availableQualities = buildQualityFormats(songInfo.types || [])
// 展示全部音质,但对超出用户最高音质的项做禁用呈现
const userMaxIndex = qualityKey.indexOf(userQuality)
const userMaxIndex = QUALITY_ORDER.indexOf(userQuality as KnownQuality)
const qualityOptions = [...availableQualities]
// 按音质优先级排序
qualityOptions.sort((a, b) => {
const aIndex = qualityKey.indexOf(a.type)
const bIndex = qualityKey.indexOf(b.type)
return bIndex - aIndex // 降序排列,高音质在前
})
// 按音质优先级排序(高→低)
qualityOptions.sort((a, b) => compareQuality(a.type, b.type))
const dialog = DialogPlugin.confirm({
header: '选择下载音质(可滚动)',
@@ -70,8 +62,8 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
}
},
qualityOptions.map((quality) => {
const idx = qualityKey.indexOf(quality.type)
const disabled = idx !== -1 && idx > userMaxIndex
const idx = QUALITY_ORDER.indexOf(quality.type as KnownQuality)
const disabled = userMaxIndex !== -1 && idx !== -1 && idx < userMaxIndex
return h(
'div',
{
@@ -132,7 +124,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
: '#333'
}
},
qualityMap[quality.type] || quality.type
getQualityDisplayName(quality.type)
),
h(
'div',
@@ -193,16 +185,16 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
return
}
let quality = selectedQuality
let quality = selectedQuality as string
// 检查选择的音质是否超出歌曲支持的最高音质
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
const songMaxQuality = getHighestQualityType(songInfo.types)
if (songMaxQuality && QUALITY_ORDER.indexOf(quality as KnownQuality) < QUALITY_ORDER.indexOf(songMaxQuality as KnownQuality)) {
quality = songMaxQuality
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${getQualityDisplayName(quality)}`)
}
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
console.log(`使用音质下载: ${quality} - ${getQualityDisplayName(quality)}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const result = await window.api.music.requestSdk('downloadSingleSong', {

View File

@@ -23,10 +23,3 @@
<PlayMusic />
</div>
</template>
<style lang="scss" scoped>
.animate__animated {
position: absolute;
width: 100%;
}
</style>

View File

@@ -221,6 +221,8 @@ onUnmounted(() => {
.find-container {
padding: 2rem;
width: 100%;
height: 100%;
overflow-y: auto;
margin: 0 auto;
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
import { ref, onMounted, toRaw, computed } from 'vue'
import { useRoute } from 'vue-router'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
@@ -27,6 +27,10 @@ const LocalUserDetail = LocalUserDetailStore()
// 响应式状态
const songs = ref<MusicItem[]>([])
const loading = ref(true)
const loadingMore = ref(false)
const hasMore = ref(true)
const currentPage = ref(1)
const pageSize = 50
const currentSong = ref<MusicItem | null>(null)
const isPlaying = ref(false)
const playlistInfo = ref({
@@ -60,8 +64,8 @@ const fetchPlaylistSongs = async () => {
// 处理本地歌单
await fetchLocalPlaylistSongs()
} else {
// 处理网络歌单
await fetchNetworkPlaylistSongs()
// 处理网络歌单(重置并加载第一页)
await fetchNetworkPlaylistSongs(true)
}
} catch (error) {
console.error('获取歌单歌曲失败:', error)
@@ -116,22 +120,43 @@ const fetchLocalPlaylistSongs = async () => {
}
}
// 获取网络歌单歌曲
const fetchNetworkPlaylistSongs = async () => {
/**
* 获取网络歌单歌曲,支持重置与分页追加
* @param reset 是否重置为第一页
*/
const fetchNetworkPlaylistSongs = async (reset = false) => {
try {
// 调用API获取歌单详情和歌曲列表
// 并发保护:首次加载使用 loading分页加载使用 loadingMore
if ((reset && !loading.value) || (!reset && loadingMore.value)) return
if (reset) {
currentPage.value = 1
hasMore.value = true
songs.value = []
loading.value = true
} else {
if (!hasMore.value) return
loadingMore.value = true
}
const result = (await window.api.music.requestSdk('getPlaylistDetail', {
source: playlistInfo.value.source,
id: playlistInfo.value.id,
page: 1
page: currentPage.value
})) as any
const limit = Number(result?.limit ?? pageSize)
console.log(result)
if (result && result.list) {
songs.value = result.list
if (result && Array.isArray(result.list)) {
const newList = result.list
// 获取歌曲封面
setPic(0, playlistInfo.value.source)
if (reset) {
songs.value = newList
} else {
songs.value = [...songs.value, ...newList]
}
// 获取新增歌曲封面
setPic((currentPage.value - 1) * limit, playlistInfo.value.source)
// 如果API返回了歌单详细信息更新歌单信息
if (result.info) {
@@ -143,10 +168,28 @@ const fetchNetworkPlaylistSongs = async () => {
total: result.info.total || playlistInfo.value.total
}
}
// 更新分页状态
currentPage.value += 1
const total = result.info?.total ?? playlistInfo.value.total ?? 0
if (total) {
hasMore.value = songs.value.length < total
} else {
hasMore.value = newList.length >= limit
}
} else {
hasMore.value = false
}
} catch (error) {
console.error('获取网络歌单失败:', error)
songs.value = []
if (reset) songs.value = []
hasMore.value = false
} finally {
if (reset) {
loading.value = false
} else {
loadingMore.value = false
}
}
}
@@ -389,45 +432,44 @@ const handleShufflePlaylist = () => {
}
})
}
// 滚动事件处理
/**
* 滚动事件处理:更新头部紧凑状态,并在接近底部时触发分页加载
*/
const handleScroll = (event?: Event) => {
let scrollTop = 0
let scrollHeight = 0
let clientHeight = 0
if (event && event.target) {
scrollTop = (event.target as HTMLElement).scrollTop
const target = event.target as HTMLElement
scrollTop = target.scrollTop
scrollHeight = target.scrollHeight
clientHeight = target.clientHeight
} else if (scrollContainer.value) {
scrollTop = scrollContainer.value.scrollTop
scrollHeight = scrollContainer.value.scrollHeight
clientHeight = scrollContainer.value.clientHeight
}
scrollY.value = scrollTop
// 当滚动超过100px时启用紧凑模式
isHeaderCompact.value = scrollY.value > 100
// 触底加载(参考 search.vue
if (
scrollHeight > 0 &&
scrollHeight - scrollTop - clientHeight < 100 &&
!loadingMore.value &&
hasMore.value &&
!isLocalPlaylist.value
) {
fetchNetworkPlaylistSongs(false)
}
}
// 组件挂载时获取数据
onMounted(() => {
fetchPlaylistSongs()
// 延迟添加滚动事件监听,等待 SongVirtualList 组件渲染完成
setTimeout(() => {
// 查找 SongVirtualList 内部的虚拟滚动容器
const virtualListContainer = document.querySelector('.virtual-scroll-container')
if (virtualListContainer) {
scrollContainer.value = virtualListContainer as HTMLElement
virtualListContainer.addEventListener('scroll', handleScroll, { passive: true })
console.log('滚动监听器已添加到:', virtualListContainer)
} else {
console.warn('未找到虚拟滚动容器')
}
}, 200)
})
// 组件卸载时清理事件监听
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll)
}
})
</script>

View File

@@ -517,7 +517,7 @@ const setPicForPlaylist = async (songs: any[], source: string) => {
// 处理网络歌单导入
const handleNetworkPlaylistImport = async (input: string) => {
try {
const load1 = MessagePlugin.loading('正在解析歌单链接...')
const load1 = MessagePlugin.loading('正在解析歌单链接...', 0)
let playlistId: string = ''
let platformName: string = ''
@@ -691,7 +691,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
load1.then((res) => res.close())
// 获取歌单详情
const load2 = MessagePlugin.loading('正在获取歌单信息...')
const load2 = MessagePlugin.loading('正在获取歌单信息,请不要离开页面...', 0)
const getListDetail = async (page: number) => {
let detailResult: any
@@ -1441,7 +1441,8 @@ onMounted(() => {
<style lang="scss" scoped>
.page {
width: 100%;
// height: 100%;
height: 100%;
overflow-y: auto;
}
.local-container {
padding: 2rem;

View File

@@ -210,7 +210,9 @@ const formatPlayTime = (timeStr: string): string => {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
min-height: 100vh;
// min-height: 100%;
height: 100%;
overflow-y: auto;
}
.page-header {

View File

@@ -233,6 +233,7 @@ const handleScroll = (event: Event) => {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden auto;
}
.search-header {