Compare commits

...

8 Commits

20 changed files with 534 additions and 109 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

View File

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

View File

@@ -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>
## 技术栈 ## 技术栈

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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