mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 19:37:38 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce743e1b65 | ||
|
|
32c9fdbfeb | ||
|
|
9df236b2e0 | ||
|
|
0988c71282 | ||
|
|
60881f7f48 | ||
|
|
775f87aa86 | ||
|
|
b1c471f15c | ||
|
|
f7ecfa1fa9 |
11
README.md
11
README.md
@@ -295,7 +295,11 @@ CeruMuisc/
|
|||||||
|
|
||||||
## 联系方式
|
## 联系方式
|
||||||
|
|
||||||
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
|
如有技术问题或合作意向
|
||||||
|
可通过如下方式联系
|
||||||
|
- QQ: 2115295703
|
||||||
|
- 微信:13600973542
|
||||||
|
- 邮箱:sqj@shiqianjiang.cn
|
||||||
|
|
||||||
## 项目开发者
|
## 项目开发者
|
||||||
|
|
||||||
@@ -357,8 +361,3 @@ CeruMuisc/
|
|||||||
|
|
||||||
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
|
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
|
||||||
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
|
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
|
||||||
|
|
||||||
## 联系
|
|
||||||
|
|
||||||
关于项目问题也可联系
|
|
||||||
邮箱:sqj@shiqianjiang.cn
|
|
||||||
|
|||||||
BIN
docs/assets/image-20251003173109619.png
Normal file
BIN
docs/assets/image-20251003173109619.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 958 KiB |
BIN
docs/assets/image-20251003173141699.png
Normal file
BIN
docs/assets/image-20251003173141699.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1020 KiB |
BIN
docs/assets/image-20251003173654569.png
Normal file
BIN
docs/assets/image-20251003173654569.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 870 KiB |
@@ -14,5 +14,7 @@
|
|||||||
| 青禾 | 8.8 |
|
| 青禾 | 8.8 |
|
||||||
| li peng | 200 |
|
| li peng | 200 |
|
||||||
| **群友**:XIZ | 3 |
|
| **群友**:XIZ | 3 |
|
||||||
|
| YL | 10 |
|
||||||
|
| **群友**:way1437 | 50 |
|
||||||
|
|
||||||
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn
|
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ features:
|
|||||||
|
|
||||||
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
|
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
|
||||||
|
|
||||||
<img src="./assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="./assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem">
|
||||||
|
<img src="./assets/image-20251003173109619.png" alt="image-20251003173109619"/>
|
||||||
|
<img src= "./assets/image-20251003173654569.png">
|
||||||
|
</div>
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ceru-music",
|
"name": "ceru-music",
|
||||||
"version": "1.4.3",
|
"version": "1.4.6",
|
||||||
"description": "一款简洁优雅的音乐播放器",
|
"description": "一款简洁优雅的音乐播放器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "sqj,wldss,star",
|
"author": "sqj,wldss,star",
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import pluginService from '../services/plugin'
|
import pluginService from '../services/plugin'
|
||||||
function PluginEvent() {
|
function PluginEvent() {
|
||||||
|
|
||||||
|
|
||||||
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
|
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
return await pluginService.selectAndAddPlugin(type)
|
return await pluginService.selectAndAddPlugin(type)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import ManageSongList, { SongListError } from '../services/songList/ManageSongList'
|
import ManageSongList, { SongListError } from '../services/songList/ManageSongList'
|
||||||
import type { SongList, Songs } from '@common/types/songList'
|
import type { SongList, Songs } from '@common/types/songList'
|
||||||
|
import { configManager } from '../services/ConfigManager'
|
||||||
|
|
||||||
// 创建新歌单
|
// 创建新歌单
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
@@ -21,6 +22,31 @@ ipcMain.handle(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 喜欢歌单ID持久化
|
||||||
|
ipcMain.handle('songlist:get-favorites-id', async () => {
|
||||||
|
try {
|
||||||
|
const id = configManager.get<string>('favoritesHashId', '')
|
||||||
|
return { success: true, data: id || null }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取喜欢歌单ID失败:', error)
|
||||||
|
return { success: false, error: '获取喜欢歌单ID失败' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('songlist:set-favorites-id', async (_, id: string) => {
|
||||||
|
try {
|
||||||
|
if (!id || typeof id !== 'string' || !id.trim()) {
|
||||||
|
return { success: false, error: '无效的歌单ID' }
|
||||||
|
}
|
||||||
|
configManager.set('favoritesHashId', id.trim())
|
||||||
|
const ok = configManager.saveConfig()
|
||||||
|
return { success: ok, message: ok ? '已保存喜欢歌单ID' : '保存失败' }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置喜欢歌单ID失败:', error)
|
||||||
|
return { success: false, error: '设置喜欢歌单ID失败' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 获取所有歌单
|
// 获取所有歌单
|
||||||
ipcMain.handle('songlist:get-all', async () => {
|
ipcMain.handle('songlist:get-all', async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, shell, BrowserWindow, ipcMain, screen } from 'electron'
|
import { app, shell, BrowserWindow, ipcMain, screen, Rectangle, Display } from 'electron'
|
||||||
import { configManager } from './services/ConfigManager'
|
import { configManager } from './services/ConfigManager'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
@@ -28,25 +28,45 @@ if (!gotTheLock) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据窗口当前所在的显示器,动态更新窗口的最大尺寸限制。
|
||||||
|
* 这样可以确保窗口在任何显示器上都能正常最大化或全屏。
|
||||||
|
* @param {BrowserWindow} win - 要更新的窗口实例
|
||||||
|
*/
|
||||||
|
function updateWindowMaxLimits(win: BrowserWindow | null): void {
|
||||||
|
if (!win) return
|
||||||
|
|
||||||
|
// 1. 获取窗口的当前边界 (bounds)
|
||||||
|
const currentBounds: Rectangle = win.getBounds()
|
||||||
|
|
||||||
|
// 2. 查找包含该边界的显示器
|
||||||
|
const currentDisplay: Display = screen.getDisplayMatching(currentBounds)
|
||||||
|
|
||||||
|
// 3. 获取该显示器的完整尺寸 (full screen size)
|
||||||
|
const { width: currentScreenWidth, height: currentScreenHeight } = currentDisplay.size
|
||||||
|
|
||||||
|
// 4. 应用新的最大尺寸限制
|
||||||
|
// 移除 maxWidth/maxHeight 上的硬限制,使其能够最大化到当前屏幕的尺寸。
|
||||||
|
// 注意:设置为 0, 0 意味着没有最小限制,我们只关注最大限制。
|
||||||
|
win.setMaximumSize(currentScreenWidth, currentScreenHeight)
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// 获取保存的窗口位置和大小
|
// 获取保存的窗口位置和大小
|
||||||
const savedBounds = configManager.getWindowBounds()
|
const savedBounds = configManager.getWindowBounds()
|
||||||
|
|
||||||
// 获取屏幕尺寸
|
|
||||||
const primaryDisplay = screen.getPrimaryDisplay()
|
|
||||||
// 使用完整屏幕尺寸而不是工作区域,以支持真正的全屏模式
|
|
||||||
const { width: screenWidth, height: screenHeight } = primaryDisplay.size
|
|
||||||
|
|
||||||
// 默认窗口配置
|
// 默认窗口配置
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 750,
|
height: 750,
|
||||||
minWidth: 1100,
|
minWidth: 1100,
|
||||||
minHeight: 670,
|
minHeight: 670,
|
||||||
maxWidth: screenWidth,
|
// ⚠️ 关键修改 1: 移除 maxWidth 和 maxHeight 的硬编码限制
|
||||||
maxHeight: screenHeight,
|
// maxWidth: screenWidth,
|
||||||
|
// maxHeight: screenHeight,
|
||||||
show: false,
|
show: false,
|
||||||
center: !savedBounds, // 如果有保存的位置,则不居中
|
center: !savedBounds, // 如果有保存的位置,则不居中
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
@@ -72,24 +92,30 @@ function createWindow(): void {
|
|||||||
mainWindow = new BrowserWindow(defaultOptions)
|
mainWindow = new BrowserWindow(defaultOptions)
|
||||||
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
|
||||||
|
|
||||||
// 监听窗口移动和调整大小事件,保存窗口位置和大小
|
// ⚠️ 关键修改 2: 监听 'moved' 事件,动态更新最大尺寸
|
||||||
mainWindow.on('moved', () => {
|
mainWindow.on('moved', () => {
|
||||||
|
// 当窗口移动时,确保最大尺寸限制随屏幕变化
|
||||||
|
updateWindowMaxLimits(mainWindow)
|
||||||
|
|
||||||
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
||||||
const bounds = mainWindow.getBounds()
|
const bounds = mainWindow.getBounds()
|
||||||
configManager.saveWindowBounds(bounds)
|
configManager.saveWindowBounds(bounds)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ⚠️ 关键修改 3: 窗口创建后立即应用一次最大尺寸限制
|
||||||
|
updateWindowMaxLimits(mainWindow)
|
||||||
|
|
||||||
mainWindow.on('resized', () => {
|
mainWindow.on('resized', () => {
|
||||||
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
|
||||||
const bounds = mainWindow.getBounds()
|
const bounds = mainWindow.getBounds()
|
||||||
|
|
||||||
// 获取当前屏幕尺寸
|
// 获取当前屏幕尺寸 (已在文件顶部导入 screen,无需 require)
|
||||||
const { screen } = require('electron')
|
|
||||||
const currentDisplay = screen.getDisplayMatching(bounds)
|
const currentDisplay = screen.getDisplayMatching(bounds)
|
||||||
|
// 使用 workAreaSize 避免窗口超出任务栏/Dock
|
||||||
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
|
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
|
||||||
|
|
||||||
// 确保窗口不超过屏幕尺寸
|
// 确保窗口不超过屏幕工作区域尺寸
|
||||||
let needResize = false
|
let needResize = false
|
||||||
const newBounds = { ...bounds }
|
const newBounds = { ...bounds }
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ export default {
|
|||||||
if (!result) throw new Error('failed')
|
if (!result) throw new Error('failed')
|
||||||
}
|
}
|
||||||
id = result[1]
|
id = result[1]
|
||||||
// console.log(id)
|
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
@@ -222,6 +221,7 @@ export default {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { body } = await requestObj_listDetail.promise
|
const { body } = await requestObj_listDetail.promise
|
||||||
|
console.log(body);
|
||||||
|
|
||||||
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
|
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
|
||||||
const cdlist = body.cdlist[0]
|
const cdlist = body.cdlist[0]
|
||||||
|
|||||||
2
src/preload/index.d.ts
vendored
2
src/preload/index.d.ts
vendored
@@ -53,6 +53,8 @@ interface CustomAPI {
|
|||||||
validateIntegrity: (hashId: string) => Promise<any>
|
validateIntegrity: (hashId: string) => Promise<any>
|
||||||
repairData: (hashId: string) => Promise<any>
|
repairData: (hashId: string) => Promise<any>
|
||||||
forceSave: (hashId: string) => Promise<any>
|
forceSave: (hashId: string) => Promise<any>
|
||||||
|
getFavoritesId: () => Promise<any>
|
||||||
|
setFavoritesId: (favoritesId: string) => Promise<any>
|
||||||
}
|
}
|
||||||
|
|
||||||
ai: {
|
ai: {
|
||||||
|
|||||||
@@ -116,7 +116,11 @@ const api = {
|
|||||||
validateIntegrity: (hashId: string) =>
|
validateIntegrity: (hashId: string) =>
|
||||||
ipcRenderer.invoke('songlist:validate-integrity', hashId),
|
ipcRenderer.invoke('songlist:validate-integrity', hashId),
|
||||||
repairData: (hashId: string) => ipcRenderer.invoke('songlist:repair-data', hashId),
|
repairData: (hashId: string) => ipcRenderer.invoke('songlist:repair-data', hashId),
|
||||||
forceSave: (hashId: string) => ipcRenderer.invoke('songlist:force-save', hashId)
|
forceSave: (hashId: string) => ipcRenderer.invoke('songlist:force-save', hashId),
|
||||||
|
|
||||||
|
// 喜欢歌单ID持久化
|
||||||
|
getFavoritesId: () => ipcRenderer.invoke('songlist:get-favorites-id'),
|
||||||
|
setFavoritesId: (id: string) => ipcRenderer.invoke('songlist:set-favorites-id', id)
|
||||||
},
|
},
|
||||||
|
|
||||||
getUserConfig: () => ipcRenderer.invoke('get-user-config'),
|
getUserConfig: () => ipcRenderer.invoke('get-user-config'),
|
||||||
|
|||||||
1
src/renderer/components.d.ts
vendored
1
src/renderer/components.d.ts
vendored
@@ -18,7 +18,6 @@ declare module 'vue' {
|
|||||||
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
|
GlobalAudio: typeof import('./src/components/Play/GlobalAudio.vue')['default']
|
||||||
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
|
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
|
||||||
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
||||||
NBackTop: typeof import('naive-ui')['NBackTop']
|
|
||||||
NBadge: typeof import('naive-ui')['NBadge']
|
NBadge: typeof import('naive-ui')['NBadge']
|
||||||
NCard: typeof import('naive-ui')['NCard']
|
NCard: typeof import('naive-ui')['NCard']
|
||||||
NIcon: typeof import('naive-ui')['NIcon']
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
|
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
|
||||||
<div
|
<div
|
||||||
v-for="(song, index) in visibleItems"
|
v-for="(song, index) in visibleItems"
|
||||||
:key="song.id || song.songmid"
|
:key="`${song.source || ''}-${song.songmid}-${song.albumId || ''}-${index}`"
|
||||||
class="song-item"
|
class="song-item"
|
||||||
@mouseenter="hoveredSong = song.id || song.songmid"
|
@mouseenter="hoveredSong = song.id || song.songmid"
|
||||||
@mouseleave="hoveredSong = null"
|
@mouseleave="hoveredSong = null"
|
||||||
@@ -58,8 +58,17 @@
|
|||||||
|
|
||||||
<!-- 喜欢按钮 -->
|
<!-- 喜欢按钮 -->
|
||||||
<div class="col-like">
|
<div class="col-like">
|
||||||
<button class="action-btn like-btn" @click.stop>
|
<button
|
||||||
<i class="icon-heart"></i>
|
class="action-btn like-btn"
|
||||||
|
title="喜欢/取消喜欢"
|
||||||
|
@click.stop="onToggleLike(song)"
|
||||||
|
>
|
||||||
|
<HeartIcon
|
||||||
|
:fill-color="isLiked(song) ? ['#e5484d', '#e5484d'] : ''"
|
||||||
|
:stroke-color="isLiked(song) ? [] : [contrastTextColor, contrastTextColor]"
|
||||||
|
:stroke-width="isLiked(song) ? 0 : 2"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +119,8 @@ import {
|
|||||||
PlayCircleIcon,
|
PlayCircleIcon,
|
||||||
AddIcon,
|
AddIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
DeleteIcon
|
DeleteIcon,
|
||||||
|
HeartIcon
|
||||||
} from 'tdesign-icons-vue-next'
|
} from 'tdesign-icons-vue-next'
|
||||||
import ContextMenu from '../ContextMenu/ContextMenu.vue'
|
import ContextMenu from '../ContextMenu/ContextMenu.vue'
|
||||||
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
|
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
|
||||||
@@ -290,8 +300,9 @@ const getQualityDisplayName = (quality: any) => {
|
|||||||
|
|
||||||
// 处理滚动事件
|
// 处理滚动事件
|
||||||
const onScroll = (event: Event) => {
|
const onScroll = (event: Event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement | null
|
||||||
scrollTop.value = target.scrollTop
|
// 兼容程序触发的假事件,target 可能为 null
|
||||||
|
scrollTop.value = target?.scrollTop ?? scrollContainer.value?.scrollTop ?? 0
|
||||||
emit('scroll', event)
|
emit('scroll', event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +416,89 @@ const loadPlaylists = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 喜欢功能(列表内心形) ===
|
||||||
|
const favoritesId = ref<string | null>(null)
|
||||||
|
const likedSet = ref<Set<string | number>>(new Set())
|
||||||
|
const contrastTextColor = 'var(--song-list-btn-color)'
|
||||||
|
|
||||||
|
const loadFavorites = async () => {
|
||||||
|
try {
|
||||||
|
const favIdRes = await (window as any).api?.songList?.getFavoritesId?.()
|
||||||
|
const id: string | null = (favIdRes && favIdRes.data) || null
|
||||||
|
favoritesId.value = id
|
||||||
|
if (!id) {
|
||||||
|
likedSet.value = new Set()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existsRes = await songListAPI.exists(id)
|
||||||
|
if (!existsRes.success || !existsRes.data) {
|
||||||
|
favoritesId.value = null
|
||||||
|
likedSet.value = new Set()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const songsRes = await songListAPI.getSongs(id)
|
||||||
|
if (songsRes.success && Array.isArray(songsRes.data)) {
|
||||||
|
likedSet.value = new Set(songsRes.data.map((s: any) => s.songmid))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载“我的喜欢”失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLiked = (song: Song) => likedSet.value.has(song.songmid)
|
||||||
|
|
||||||
|
const ensureFavoritesId = async (): Promise<string | null> => {
|
||||||
|
if (favoritesId.value) {
|
||||||
|
const existsRes = await songListAPI.exists(favoritesId.value)
|
||||||
|
if (existsRes.success && existsRes.data) return favoritesId.value
|
||||||
|
favoritesId.value = null
|
||||||
|
}
|
||||||
|
const searchRes = await songListAPI.search('我的喜欢', 'local')
|
||||||
|
if (searchRes.success && Array.isArray(searchRes.data)) {
|
||||||
|
const exact = searchRes.data.find((pl) => pl.name === '我的喜欢' && pl.source === 'local')
|
||||||
|
if (exact?.id) {
|
||||||
|
favoritesId.value = exact.id
|
||||||
|
await (window as any).api?.songList?.setFavoritesId?.(favoritesId.value)
|
||||||
|
return favoritesId.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const createRes = await songListAPI.create('我的喜欢', '', 'local')
|
||||||
|
if (!createRes.success || !createRes.data?.id) {
|
||||||
|
MessagePlugin.error(createRes.error || '创建“我的喜欢”失败')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
favoritesId.value = createRes.data.id
|
||||||
|
await (window as any).api?.songList?.setFavoritesId?.(favoritesId.value)
|
||||||
|
return favoritesId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleLike = async (song: Song) => {
|
||||||
|
try {
|
||||||
|
const id = await ensureFavoritesId()
|
||||||
|
if (!id) return
|
||||||
|
if (isLiked(song)) {
|
||||||
|
const removeRes = await songListAPI.removeSong(id, song.songmid)
|
||||||
|
if (removeRes.success && removeRes.data) {
|
||||||
|
likedSet.value.delete(song.songmid)
|
||||||
|
// MessagePlugin.success('已取消喜欢')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(removeRes.error || '取消喜欢失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const addRes = await songListAPI.addSongs(id, [toRaw(song) as any])
|
||||||
|
if (addRes.success) {
|
||||||
|
likedSet.value.add(song.songmid)
|
||||||
|
// MessagePlugin.success('已添加到“我的喜欢”')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(addRes.error || '添加到“我的喜欢”失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('切换喜欢失败:', e)
|
||||||
|
MessagePlugin.error(e?.message || '操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加歌曲到歌单
|
// 添加歌曲到歌单
|
||||||
const handleAddToSongList = async (song: Song, playlist: SongList) => {
|
const handleAddToSongList = async (song: Song, playlist: SongList) => {
|
||||||
try {
|
try {
|
||||||
@@ -420,7 +514,7 @@ const handleAddToSongList = async (song: Song, playlist: SongList) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// 组件挂载后触发一次重新计算
|
// 组件挂载后触发一次重新计算
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (scrollContainer.value) {
|
if (scrollContainer.value) {
|
||||||
@@ -431,10 +525,15 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 加载歌单列表
|
// 加载歌单列表
|
||||||
loadPlaylists()
|
await loadPlaylists()
|
||||||
|
// 预加载“我的喜欢”集合(确保方法存在于当前文件作用域)
|
||||||
|
await loadFavorites()
|
||||||
|
|
||||||
// 监听歌单变化事件
|
// 监听歌单变化事件
|
||||||
window.addEventListener('playlist-updated', loadPlaylists)
|
window.addEventListener('playlist-updated', async () => {
|
||||||
|
await loadPlaylists()
|
||||||
|
await loadFavorites()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -92,8 +92,15 @@ const state = reactive({
|
|||||||
// 监听歌曲ID变化,获取歌词
|
// 监听歌曲ID变化,获取歌词
|
||||||
watch(
|
watch(
|
||||||
() => props.songId,
|
() => props.songId,
|
||||||
async (newId) => {
|
async (newId, _oldId, onCleanup) => {
|
||||||
if (!newId || !props.songInfo) return
|
if (!newId || !props.songInfo) return
|
||||||
|
// 竞态与取消控制,防止内存泄漏与过期结果覆盖
|
||||||
|
let active = true
|
||||||
|
const abort = new AbortController()
|
||||||
|
onCleanup(() => {
|
||||||
|
active = false
|
||||||
|
abort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
// 工具函数:清洗响应式对象,避免序列化问题
|
// 工具函数:清洗响应式对象,避免序列化问题
|
||||||
const getCleanSongInfo = () => JSON.parse(JSON.stringify(toRaw(props.songInfo)))
|
const getCleanSongInfo = () => JSON.parse(JSON.stringify(toRaw(props.songInfo)))
|
||||||
@@ -112,41 +119,51 @@ watch(
|
|||||||
|
|
||||||
// 将译文按 startTime-endTime 建立索引,便于精确匹配
|
// 将译文按 startTime-endTime 建立索引,便于精确匹配
|
||||||
const keyOf = (s: number, e: number) => `${s}-${e}`
|
const keyOf = (s: number, e: number) => `${s}-${e}`
|
||||||
const joinWords = (line: LyricLine) => (line.words || []).map(w => w.word).join('')
|
const joinWords = (line: LyricLine) => (line.words || []).map((w) => w.word).join('')
|
||||||
|
|
||||||
const tMap = new Map<string, LyricLine>()
|
const tMap = new Map<string, LyricLine>()
|
||||||
for (const tl of translated) {
|
for (const tl of translated) {
|
||||||
tMap.set(keyOf(tl.startTime, tl.endTime), tl)
|
tMap.set(keyOf(tl.startTime, tl.endTime), tl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 容差时间(毫秒),用于无法精确匹配时的最近行匹配
|
// 动态容差:与行时长相关,避免长/短行同一阈值导致误配
|
||||||
const TOLERANCE_MS = 1000
|
const baseTolerance = 300 // 上限
|
||||||
|
const ratioTolerance = 0.4 // 与行时长的比例
|
||||||
|
|
||||||
for (const bl of base) {
|
// 锚点对齐 + 顺序映射:以第一行为锚点,后续按索引顺序插入译文
|
||||||
// 1) 先尝试精确匹配
|
const translatedSorted = translated.slice().sort((a, b) => a.startTime - b.startTime)
|
||||||
let tLine = tMap.get(keyOf(bl.startTime, bl.endTime))
|
|
||||||
|
|
||||||
// 2) 若无精确匹配,按 startTime 进行容差范围内最近匹配
|
if (base.length > 0) {
|
||||||
if (!tLine) {
|
const firstBase = base[0]
|
||||||
let best: { line: LyricLine; diff: number } | null = null
|
const firstDuration = Math.max(1, firstBase.endTime - firstBase.startTime)
|
||||||
for (const tl of translated) {
|
const firstTol = Math.min(baseTolerance, firstDuration * ratioTolerance)
|
||||||
const diff = Math.abs(tl.startTime - bl.startTime)
|
|
||||||
// 要求结束时间也尽量接近,避免跨行误配
|
// 在容差内寻找与第一行起始时间最接近的译文行作为锚点
|
||||||
const endDiff = Math.abs(tl.endTime - bl.endTime)
|
let anchorIndex: number | null = null
|
||||||
const score = diff + endDiff
|
let bestDiff = Number.POSITIVE_INFINITY
|
||||||
if (diff <= TOLERANCE_MS && endDiff <= TOLERANCE_MS) {
|
for (let i = 0; i < translatedSorted.length; i++) {
|
||||||
if (!best || score < best.diff) best = { line: tl, diff: score }
|
const diff = Math.abs(translatedSorted[i].startTime - firstBase.startTime)
|
||||||
|
if (diff <= firstTol && diff < bestDiff) {
|
||||||
|
bestDiff = diff
|
||||||
|
anchorIndex = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tLine = best?.line
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tLine) {
|
if (anchorIndex !== null) {
|
||||||
const text = joinWords(tLine)
|
// 从锚点开始顺序映射
|
||||||
|
let j = anchorIndex
|
||||||
|
for (let i = 0; i < base.length && j < translatedSorted.length; i++, j++) {
|
||||||
|
const bl = base[i]
|
||||||
|
const tl = translatedSorted[j]
|
||||||
|
if (tl.words[0].word === '//' || !bl.words[0].word) continue
|
||||||
|
const text = joinWords(tl)
|
||||||
if (text) bl.translatedLyric = text
|
if (text) bl.translatedLyric = text
|
||||||
}
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 未找到锚点:保持原样
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +176,11 @@ watch(
|
|||||||
// 网易云:优先尝试 TTML
|
// 网易云:优先尝试 TTML
|
||||||
try {
|
try {
|
||||||
const res = await (
|
const res = await (
|
||||||
await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`)
|
await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`, {
|
||||||
|
signal: abort.signal
|
||||||
|
})
|
||||||
).text()
|
).text()
|
||||||
|
if (!active) return
|
||||||
if (!res || res.length < 100) throw new Error('ttml 无歌词')
|
if (!res || res.length < 100) throw new Error('ttml 无歌词')
|
||||||
parsedLyrics = parseTTML(res).lines
|
parsedLyrics = parseTTML(res).lines
|
||||||
} catch {
|
} catch {
|
||||||
@@ -169,6 +189,7 @@ watch(
|
|||||||
source: 'wy',
|
source: 'wy',
|
||||||
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
|
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
|
||||||
})
|
})
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
if (lyricData?.crlyric) {
|
if (lyricData?.crlyric) {
|
||||||
parsedLyrics = parseYrc(lyricData.crlyric)
|
parsedLyrics = parseYrc(lyricData.crlyric)
|
||||||
@@ -184,6 +205,7 @@ watch(
|
|||||||
source,
|
source,
|
||||||
songInfo: getCleanSongInfo()
|
songInfo: getCleanSongInfo()
|
||||||
})
|
})
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
if (lyricData?.crlyric) {
|
if (lyricData?.crlyric) {
|
||||||
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
|
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
|
||||||
@@ -193,9 +215,12 @@ watch(
|
|||||||
|
|
||||||
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
|
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
|
||||||
}
|
}
|
||||||
|
if (!active) return
|
||||||
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
|
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取歌词失败:', error)
|
console.error('获取歌词失败:', error)
|
||||||
|
// 若已无效或已清理,避免写入与持有引用
|
||||||
|
if (!active) return
|
||||||
state.lyricLines = []
|
state.lyricLines = []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -227,7 +252,9 @@ async function updateTextColor() {
|
|||||||
useBlackText.value = false // 默认使用白色文本
|
useBlackText.value = false // 默认使用白色文本
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const jumpTime = (e) => {
|
||||||
|
if (Audio.value.audio) Audio.value.audio.currentTime = e.line.getLine().startTime / 1000
|
||||||
|
}
|
||||||
// 监听封面图片变化
|
// 监听封面图片变化
|
||||||
watch(() => actualCoverImage.value, updateTextColor, { immediate: true })
|
watch(() => actualCoverImage.value, updateTextColor, { immediate: true })
|
||||||
|
|
||||||
@@ -272,6 +299,8 @@ onBeforeUnmount(async () => {
|
|||||||
if (unsubscribePlay.value) {
|
if (unsubscribePlay.value) {
|
||||||
unsubscribePlay.value()
|
unsubscribePlay.value()
|
||||||
}
|
}
|
||||||
|
bgRef.value?.bgRender?.dispose()
|
||||||
|
lyricPlayerRef.value?.lyricPlayer?.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听音频URL变化
|
// 监听音频URL变化
|
||||||
@@ -332,7 +361,7 @@ const lyricTranslateY = computed(() => {
|
|||||||
:album-is-video="false"
|
:album-is-video="false"
|
||||||
:fps="30"
|
:fps="30"
|
||||||
:flow-speed="4"
|
:flow-speed="4"
|
||||||
:has-lyric="state.lyricLines.length > 10 && playSetting.getBgPlaying"
|
:has-lyric="state.lyricLines.length > 10"
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: -1"
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: -1"
|
||||||
/>
|
/>
|
||||||
<!-- 全屏按钮 -->
|
<!-- 全屏按钮 -->
|
||||||
@@ -398,11 +427,7 @@ const lyricTranslateY = computed(() => {
|
|||||||
class="lyric-player"
|
class="lyric-player"
|
||||||
:enable-spring="playSetting.getisJumpLyric"
|
:enable-spring="playSetting.getisJumpLyric"
|
||||||
:enable-scale="playSetting.getisJumpLyric"
|
:enable-scale="playSetting.getisJumpLyric"
|
||||||
@line-click="
|
@line-click="jumpTime"
|
||||||
(e) => {
|
|
||||||
if (Audio.audio) Audio.audio.currentTime = e.line.getLine().startTime / 1000
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
</LyricPlayer>
|
</LyricPlayer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,12 +29,45 @@ import {
|
|||||||
} from '@renderer/utils/playlist/playlistManager'
|
} from '@renderer/utils/playlist/playlistManager'
|
||||||
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
||||||
import defaultCoverImg from '/default-cover.png'
|
import defaultCoverImg from '/default-cover.png'
|
||||||
|
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
||||||
|
import { HeartIcon, DownloadIcon } from 'tdesign-icons-vue-next'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import { songListAPI } from '@renderer/api/songList'
|
||||||
|
|
||||||
const controlAudio = ControlAudioStore()
|
const controlAudio = ControlAudioStore()
|
||||||
const localUserStore = LocalUserDetailStore()
|
const localUserStore = LocalUserDetailStore()
|
||||||
const { Audio } = storeToRefs(controlAudio)
|
const { Audio } = storeToRefs(controlAudio)
|
||||||
const { list, userInfo } = storeToRefs(localUserStore)
|
const { list, userInfo } = storeToRefs(localUserStore)
|
||||||
const { setCurrentTime, start, stop, setVolume, setUrl } = controlAudio
|
const { setCurrentTime, start, stop, setVolume, setUrl } = controlAudio
|
||||||
|
|
||||||
|
// 当前歌曲是否已在“我的喜欢”
|
||||||
|
const likeState = ref(false)
|
||||||
|
const isLiked = computed(() => likeState.value)
|
||||||
|
|
||||||
|
const refreshLikeState = async () => {
|
||||||
|
try {
|
||||||
|
if (!userInfo.value.lastPlaySongId) {
|
||||||
|
likeState.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const favIdRes = await window.api.songList.getFavoritesId()
|
||||||
|
const favoritesId: string | null = (favIdRes && favIdRes.data) || null
|
||||||
|
if (!favoritesId) {
|
||||||
|
likeState.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hasRes = await songListAPI.hasSong(favoritesId, userInfo.value.lastPlaySongId)
|
||||||
|
likeState.value = !!(hasRes.success && hasRes.data)
|
||||||
|
} catch {
|
||||||
|
likeState.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => userInfo.value.lastPlaySongId,
|
||||||
|
() => refreshLikeState()
|
||||||
|
)
|
||||||
|
onMounted(() => refreshLikeState())
|
||||||
const showFullPlay = ref(false)
|
const showFullPlay = ref(false)
|
||||||
document.addEventListener('keydown', KeyEvent)
|
document.addEventListener('keydown', KeyEvent)
|
||||||
// 处理最小化右键的事件
|
// 处理最小化右键的事件
|
||||||
@@ -362,6 +395,19 @@ const handleVolumeDragEnd = () => {
|
|||||||
window.removeEventListener('mouseup', handleVolumeDragEnd)
|
window.removeEventListener('mouseup', handleVolumeDragEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleVolumeWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const volumeStep = event.deltaY > 0 ? -5 : 5
|
||||||
|
const updatedVolume = Math.max(0, Math.min(100, volumeValue.value + volumeStep))
|
||||||
|
|
||||||
|
if (updatedVolume === volumeValue.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
volumeValue.value = updatedVolume
|
||||||
|
}
|
||||||
|
|
||||||
// 播放列表相关
|
// 播放列表相关
|
||||||
const showPlaylist = ref(false)
|
const showPlaylist = ref(false)
|
||||||
const playlistDrawerRef = ref<InstanceType<typeof PlaylistDrawer> | null>(null)
|
const playlistDrawerRef = ref<InstanceType<typeof PlaylistDrawer> | null>(null)
|
||||||
@@ -620,6 +666,86 @@ const toggleFullPlay = () => {
|
|||||||
showFullPlay.value = !showFullPlay.value
|
showFullPlay.value = !showFullPlay.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 左侧操作:喜欢/取消喜欢(支持切换)
|
||||||
|
const onToggleLike = async () => {
|
||||||
|
try {
|
||||||
|
// 获取当前播放歌曲对象
|
||||||
|
const currentSong = list.value.find((s) => s.songmid === userInfo.value.lastPlaySongId)
|
||||||
|
if (!currentSong) {
|
||||||
|
MessagePlugin.warning('当前没有正在播放的歌曲')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取持久化的“我的喜欢”歌单ID
|
||||||
|
const favIdRes = await window.api.songList.getFavoritesId()
|
||||||
|
let favoritesId: string | null = (favIdRes && favIdRes.data) || null
|
||||||
|
|
||||||
|
// 如果已有ID但歌单不存在,则置空
|
||||||
|
if (favoritesId) {
|
||||||
|
const existsRes = await songListAPI.exists(favoritesId)
|
||||||
|
if (!existsRes.success || !existsRes.data) {
|
||||||
|
favoritesId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有ID,尝试查找同名歌单;找不到则创建
|
||||||
|
if (!favoritesId) {
|
||||||
|
const searchRes = await songListAPI.search('我的喜欢', 'local')
|
||||||
|
if (searchRes.success && Array.isArray(searchRes.data)) {
|
||||||
|
const exact = searchRes.data.find((pl) => pl.name === '我的喜欢' && pl.source === 'local')
|
||||||
|
favoritesId = exact?.id || null
|
||||||
|
}
|
||||||
|
if (!favoritesId) {
|
||||||
|
const createRes = await songListAPI.create('我的喜欢', '', 'local')
|
||||||
|
if (!createRes.success || !createRes.data?.id) {
|
||||||
|
MessagePlugin.error(createRes.error || '创建“我的喜欢”失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
favoritesId = createRes.data.id
|
||||||
|
}
|
||||||
|
// 持久化ID到主进程配置
|
||||||
|
await window.api.songList.setFavoritesId(favoritesId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据当前状态决定添加或移除
|
||||||
|
if (likeState.value) {
|
||||||
|
const removeRes = await songListAPI.removeSong(
|
||||||
|
favoritesId!,
|
||||||
|
userInfo.value.lastPlaySongId as any
|
||||||
|
)
|
||||||
|
if (removeRes.success && removeRes.data) {
|
||||||
|
likeState.value = false
|
||||||
|
// MessagePlugin.success('已取消喜欢')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(removeRes.error || '取消喜欢失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const addRes = await songListAPI.addSongs(favoritesId!, [
|
||||||
|
_.cloneDeep(toRaw(currentSong)) as any
|
||||||
|
])
|
||||||
|
if (addRes.success) {
|
||||||
|
likeState.value = true
|
||||||
|
// MessagePlugin.success('已添加到“我的喜欢”')
|
||||||
|
} else {
|
||||||
|
MessagePlugin.error(addRes.error || '添加到“我的喜欢”失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('切换喜欢状态失败:', error)
|
||||||
|
MessagePlugin.error('操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDownload = async () => {
|
||||||
|
try {
|
||||||
|
await downloadSingleSong(_.cloneDeep(toRaw(songInfo.value)) as any)
|
||||||
|
MessagePlugin.success('开始下载当前歌曲')
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('下载失败:', e)
|
||||||
|
MessagePlugin.error('下载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 进度条相关
|
// 进度条相关
|
||||||
const progressRef = ref<HTMLDivElement | null>(null)
|
const progressRef = ref<HTMLDivElement | null>(null)
|
||||||
const isDraggingProgress = ref(false)
|
const isDraggingProgress = ref(false)
|
||||||
@@ -864,6 +990,36 @@ watch(showFullPlay, (val) => {
|
|||||||
<div class="song-name">{{ songInfo.name }}</div>
|
<div class="song-name">{{ songInfo.name }}</div>
|
||||||
<div class="artist-name">{{ songInfo.singer }}</div>
|
<div class="artist-name">{{ songInfo.singer }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="left-actions">
|
||||||
|
<t-tooltip :content="isLiked ? '已喜欢' : '喜欢'">
|
||||||
|
<t-button
|
||||||
|
class="control-btn"
|
||||||
|
variant="text"
|
||||||
|
shape="circle"
|
||||||
|
:disabled="!songInfo.songmid"
|
||||||
|
@click.stop="onToggleLike"
|
||||||
|
>
|
||||||
|
<heart-icon
|
||||||
|
:fill-color="isLiked ? ['#FF7878', '#FF7878'] : ''"
|
||||||
|
:stroke-color="isLiked ? [] : [contrastTextColor, contrastTextColor]"
|
||||||
|
:stroke-width="isLiked ? 0 : 2"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</t-button>
|
||||||
|
</t-tooltip>
|
||||||
|
<t-tooltip content="下载">
|
||||||
|
<t-button
|
||||||
|
class="control-btn"
|
||||||
|
variant="text"
|
||||||
|
shape="circle"
|
||||||
|
:disabled="!songInfo.songmid"
|
||||||
|
@click.stop="onDownload"
|
||||||
|
>
|
||||||
|
<DownloadIcon size="18" />
|
||||||
|
</t-button>
|
||||||
|
</t-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 中间:播放控制 -->
|
<!-- 中间:播放控制 -->
|
||||||
@@ -909,6 +1065,7 @@ watch(showFullPlay, (val) => {
|
|||||||
class="volume-control"
|
class="volume-control"
|
||||||
@mouseenter="showVolumeSlider = true"
|
@mouseenter="showVolumeSlider = true"
|
||||||
@mouseleave="showVolumeSlider = false"
|
@mouseleave="showVolumeSlider = false"
|
||||||
|
@wheel.prevent="handleVolumeWheel"
|
||||||
>
|
>
|
||||||
<button class="control-btn">
|
<button class="control-btn">
|
||||||
<shengyin style="width: 1.5em; height: 1.5em" />
|
<shengyin style="width: 1.5em; height: 1.5em" />
|
||||||
@@ -1176,6 +1333,38 @@ watch(showFullPlay, (val) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 左侧操作按钮 */
|
||||||
|
.left-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 12px;
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: v-bind(contrastTextColor);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.iconfont {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: v-bind(hoverColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 中间:播放控制 */
|
/* 中间:播放控制 */
|
||||||
.center-controls {
|
.center-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
|
|||||||
|
|
||||||
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
console.log('开始下载', toRaw(songInfo))
|
||||||
const LocalUserDetail = LocalUserDetailStore()
|
const LocalUserDetail = LocalUserDetailStore()
|
||||||
const userQuality = LocalUserDetail.userSource.quality as string
|
const userQuality = LocalUserDetail.userSource.quality as string
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
@@ -189,7 +190,11 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
|
|||||||
|
|
||||||
// 检查选择的音质是否超出歌曲支持的最高音质
|
// 检查选择的音质是否超出歌曲支持的最高音质
|
||||||
const songMaxQuality = getHighestQualityType(songInfo.types)
|
const songMaxQuality = getHighestQualityType(songInfo.types)
|
||||||
if (songMaxQuality && QUALITY_ORDER.indexOf(quality as KnownQuality) < QUALITY_ORDER.indexOf(songMaxQuality as KnownQuality)) {
|
if (
|
||||||
|
songMaxQuality &&
|
||||||
|
QUALITY_ORDER.indexOf(quality as KnownQuality) <
|
||||||
|
QUALITY_ORDER.indexOf(songMaxQuality as KnownQuality)
|
||||||
|
) {
|
||||||
quality = songMaxQuality
|
quality = songMaxQuality
|
||||||
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${getQualityDisplayName(quality)}`)
|
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${getQualityDisplayName(quality)}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,16 +83,34 @@ export async function addToPlaylistAndPlay(
|
|||||||
playSongCallback: (song: SongList) => Promise<void>
|
playSongCallback: (song: SongList) => Promise<void>
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// 使用store的方法添加歌曲到第一位
|
// 获取当前正在播放的歌曲索引
|
||||||
localUserStore.addSongToFirst(song)
|
const currentId = localUserStore.userInfo?.lastPlaySongId
|
||||||
|
const currentIndex =
|
||||||
|
currentId !== undefined && currentId !== null
|
||||||
|
? localUserStore.list.findIndex((item: SongList) => item.songmid === currentId)
|
||||||
|
: -1
|
||||||
|
|
||||||
// 播放歌曲 - 确保正确处理Promise
|
// 如果目标歌曲已在列表中,先移除以避免重复
|
||||||
|
const existingIndex = localUserStore.list.findIndex(
|
||||||
|
(item: SongList) => item.songmid === song.songmid
|
||||||
|
)
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
localUserStore.list.splice(existingIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex !== -1) {
|
||||||
|
// 正在播放:插入到当前歌曲的下一首
|
||||||
|
localUserStore.list.splice(currentIndex + 1, 0, song)
|
||||||
|
} else {
|
||||||
|
// 未在播放:添加到第一位
|
||||||
|
localUserStore.addSongToFirst(song)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放插入的歌曲
|
||||||
const playResult = playSongCallback(song)
|
const playResult = playSongCallback(song)
|
||||||
if (playResult && typeof playResult.then === 'function') {
|
if (playResult && typeof playResult.then === 'function') {
|
||||||
await playResult
|
await playResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// await MessagePlugin.success('已添加到播放列表并开始播放')
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('播放失败:', error)
|
console.error('播放失败:', error)
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ const localSongs = ref<LocalSong[]>([
|
|||||||
// 歌单列表
|
// 歌单列表
|
||||||
const playlists = ref<SongList[]>([])
|
const playlists = ref<SongList[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
// 喜欢歌单ID(用于排序与标记)
|
||||||
|
const favoritesId = ref<string | null>(null)
|
||||||
|
|
||||||
// 对话框状态
|
// 对话框状态
|
||||||
const showCreatePlaylistDialog = ref(false)
|
const showCreatePlaylistDialog = ref(false)
|
||||||
@@ -192,6 +194,18 @@ const loadPlaylists = async () => {
|
|||||||
const result = await songListAPI.getAll()
|
const result = await songListAPI.getAll()
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
playlists.value = result.data || []
|
playlists.value = result.data || []
|
||||||
|
// 读取“我的喜欢”ID并置顶与标记
|
||||||
|
try {
|
||||||
|
const favRes = await (window as any).api?.songList?.getFavoritesId?.()
|
||||||
|
favoritesId.value = (favRes && favRes.data) || null
|
||||||
|
if (favoritesId.value) {
|
||||||
|
const idx = playlists.value.findIndex((p) => p.id === favoritesId.value)
|
||||||
|
if (idx > 0) {
|
||||||
|
const fav = playlists.value.splice(idx, 1)[0]
|
||||||
|
playlists.value.unshift(fav)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
MessagePlugin.error(result.error || '加载歌单失败')
|
MessagePlugin.error(result.error || '加载歌单失败')
|
||||||
}
|
}
|
||||||
@@ -541,14 +555,26 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
}
|
}
|
||||||
platformName = '网易云音乐'
|
platformName = '网易云音乐'
|
||||||
} else if (importPlatformType.value === 'tx') {
|
} else if (importPlatformType.value === 'tx') {
|
||||||
// QQ音乐歌单ID解析 - 支持多种链接格式
|
// QQ音乐歌单ID解析:优先通过 SDK 解析,失败再回退到正则
|
||||||
|
let parsedId = ''
|
||||||
|
try {
|
||||||
|
const parsed: any = await window.api.music.requestSdk('parsePlaylistId', {
|
||||||
|
source: 'tx',
|
||||||
|
url: input
|
||||||
|
})
|
||||||
|
console.log('QQ音乐歌单解析结果', parsed)
|
||||||
|
if (parsed) parsedId = parsed
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (parsedId) {
|
||||||
|
playlistId = parsedId
|
||||||
|
} else {
|
||||||
const qqPlaylistRegexes = [
|
const qqPlaylistRegexes = [
|
||||||
// 标准歌单链接
|
// 标准歌单链接(强烈推荐)
|
||||||
/(?:y\.qq\.com\/n\/ryqq\/playlist\/|music\.qq\.com\/.*[?&]id=|playlist[?&]id=)(\d+)/i,
|
/(?:y\.qq\.com\/n\/ryqq\/playlist\/|music\.qq\.com\/.*[?&]id=|playlist[?&]id=)(\d+)/i,
|
||||||
// 分享链接格式
|
// 分享链接格式
|
||||||
/(?:i\.y\.qq\.com\/n2\/m\/share\/details\/taoge\.html.*[?&]id=)(\d+)/i,
|
/(?:i\.y\.qq\.com\/n2\/m\/share\/details\/taoge\.html.*[?&]id=)(\d+)/i,
|
||||||
// 其他可能的分享格式
|
// 其他可能的分享格式 https:\/\/c\d+\.y\.qq\.com\/base\/fcgi-bin\/u\?.*__=([A-Za-z0-9]+)/i,
|
||||||
/(?:c\.y\.qq\.com\/base\/fcgi-bin\/u\?.*__=)(\d+)/i,
|
|
||||||
// 手机版链接
|
// 手机版链接
|
||||||
/(?:i\.y\.qq\.com\/v8\/playsquare\/playlist\.html.*[?&]id=)(\d+)/i,
|
/(?:i\.y\.qq\.com\/v8\/playsquare\/playlist\.html.*[?&]id=)(\d+)/i,
|
||||||
// 通用ID提取 - 匹配 id= 或 &id= 参数
|
// 通用ID提取 - 匹配 id= 或 &id= 参数
|
||||||
@@ -575,6 +601,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
platformName = 'QQ音乐'
|
platformName = 'QQ音乐'
|
||||||
} else if (importPlatformType.value === 'kw') {
|
} else if (importPlatformType.value === 'kw') {
|
||||||
// 酷我音乐歌单ID解析
|
// 酷我音乐歌单ID解析
|
||||||
@@ -680,13 +707,6 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证歌单ID是否有效
|
|
||||||
if (!playlistId || playlistId.length < 6) {
|
|
||||||
MessagePlugin.error('歌单ID格式不正确')
|
|
||||||
load1.then((res) => res.close())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭加载提示
|
// 关闭加载提示
|
||||||
load1.then((res) => res.close())
|
load1.then((res) => res.close())
|
||||||
|
|
||||||
@@ -701,6 +721,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
id: playlistId,
|
id: playlistId,
|
||||||
page: page
|
page: page
|
||||||
})) as any
|
})) as any
|
||||||
|
console.log('list', detailResult)
|
||||||
} catch {
|
} catch {
|
||||||
MessagePlugin.error(`获取${platformName}歌单详情失败:歌曲信息可能有误`)
|
MessagePlugin.error(`获取${platformName}歌单详情失败:歌曲信息可能有误`)
|
||||||
load2.then((res) => res.close())
|
load2.then((res) => res.close())
|
||||||
@@ -728,6 +749,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
if (detailResult.total < songs.length) break
|
||||||
page++
|
page++
|
||||||
const { list: songsList } = await getListDetail(page)
|
const { list: songsList } = await getListDetail(page)
|
||||||
if (!(songsList && songsList.length)) {
|
if (!(songsList && songsList.length)) {
|
||||||
@@ -1022,6 +1044,14 @@ onMounted(() => {
|
|||||||
<div class="playlist-info">
|
<div class="playlist-info">
|
||||||
<div class="playlist-name" :title="playlist.name" @click="viewPlaylist(playlist)">
|
<div class="playlist-name" :title="playlist.name" @click="viewPlaylist(playlist)">
|
||||||
{{ playlist.name }}
|
{{ playlist.name }}
|
||||||
|
<t-tag
|
||||||
|
v-if="playlist.id === favoritesId"
|
||||||
|
theme="danger"
|
||||||
|
variant="light-outline"
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 6px"
|
||||||
|
>我的喜欢</t-tag
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="playlist.description"
|
v-if="playlist.description"
|
||||||
@@ -1775,7 +1805,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.playlist-cover {
|
.playlist-cover {
|
||||||
height: 180px;
|
height: 180px;
|
||||||
background: linear-gradient(135deg, var(--td-brand-color-4) 0%, var(--td-brand-color-6) 100%);
|
background: #e4e4e4;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user