feat: 架构重构与跨平台媒体控制集成

## 后端

- 替换 rodio 为 symphonia + ringbuf,重构 audio.rs 播放引擎
- 重构 api.rs,使用 api_call! 宏统一 API 调用模式
- 新增 media_controls.rs,使用 souvlaki 实现跨平台系统媒体控制
  (Linux MPRIS / Windows SMTC / macOS Now Playing)
- 版本号升至 v0.5.0

## 前端 - 新增

- 新增 SongListItem 通用组件
- 新增 useOnlineStatus composable,检测网络状态
- 新增 usePageCache composable,页面数据缓存与失效
- 新增 getCoverUrl()、formatDate() 工具函数
- 新增 emitPlaybackState() 同步播放状态到系统媒体控制
- 新增 mpris-command 事件监听,响应系统媒体控制命令
- 新增 Toast 离线/恢复在线提示
- 各页面新增断网恢复后自动重试加载
- 新增路由守卫:已登录用户访问 /login 重定向至首页
- 新增音量持久化(settings store + localStorage)
- 新增禁用右键菜单与用户选择限制(输入框除外)

## 前端 - 变更

- Song 接口从 player.ts 迁移至 song.ts 并导出
- AlbumDetail/ArtistDetail/PlaylistDetail/RecentPlays/LocalMusic 迁移至 SongListItem
- PlayerBar 队列列表迁移至 SongListItem,封面使用 getCoverUrl()
- downloadSong 参数类型从内联对象改为 Song,使用 getCoverUrl()
- 默认主题从 green 改为 blue,ThemeName 及相关列表中 blue 移至首位
- 全局快捷键从 Alt+Control 改为 Control+Alt 顺序
- formatShortcut 新增 KeyP → P 显示
- keep-alive 从 max=3 固定 include 改为 max=5 动态列表,窗口隐藏时释放
- App.vue 封面使用 getCoverUrl() 替代手动 al/album 回退
- formatPlayCount 提取常量
- Login.vue text-warning 改为 text-yellow-400

## 前端 - 删除

- 删除 Search.vue(与 Discover.vue 重复)
- 删除 SongItemMenu.vue(被 SongListItem 替代)

## 修复

- 更新器跳过版本逻辑:仅静默检查时跳过已忽略版本,手动检查不再跳过
- 重复播放同一首歌时无法恢复播放
- settings.ts 重复的 ThemeName 定义
- PlayerBar.vue modeTexts 缺少类型注解
- Home.vue map 回调参数缺少类型
- Settings.vue v-for key 类型不匹配
This commit is contained in:
2026-05-23 14:43:47 +08:00
parent 970fb15f5a
commit 65ed71503e
35 changed files with 2771 additions and 1328 deletions

View File

@ -19,46 +19,38 @@
当前文件夹下没有音乐文件支持 mp3flacwavoggaacm4a 格式
</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
<SongListItem
v-for="(song, index) in normalizedSongs"
:key="song.id + '-' + index"
@click="playLocalSong(song, index)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-subtle transition cursor-pointer"
:class="{ 'bg-subtle': player.currentSong?.id === song.id }"
:song="song"
:index="index"
:is-current="player.currentSong?.id === song.id"
show-index
show-duration
:container-class="player.currentSong?.id === song.id ? 'bg-subtle hover:bg-subtle' : 'hover:bg-subtle'"
@click="player.playFromList(normalizedSongs, index)"
>
<span class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
<div class="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-muted">
<img v-if="song.cover" :src="song.cover" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-content-3"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<template #actions>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(songs[index].fileSize) }}</span>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(songs[index].id)"
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
title="更多"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
</button>
<Transition name="fade">
<div v-if="openMenuId === songs[index].id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
<button @click="confirmDelete(songs[index])" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
从磁盘中删除
</button>
</div>
</Transition>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
{{ song.artist }}<template v-if="song.album"> · {{ song.album }}</template>
</p>
</div>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.duration) }}</span>
<span class="text-xs text-content-3 flex-shrink-0">{{ formatFileSize(song.fileSize) }}</span>
<div class="relative flex-shrink-0">
<button
@click.stop="toggleMenu(song.id)"
class="text-content-3 hover:text-content transition p-1 rounded hover:bg-muted"
title="更多"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
</button>
<Transition name="fade">
<div v-if="openMenuId === song.id" class="absolute right-0 top-full mt-1 w-44 bg-surface border border-line rounded-xl shadow-2xl overflow-hidden z-50" @click.stop>
<button @click="confirmDelete(song)" class="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-danger/80 hover:bg-danger/10 transition">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
从磁盘中删除
</button>
</div>
</Transition>
</div>
</div>
</template>
</SongListItem>
</div>
<Transition name="fade">
@ -83,12 +75,17 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore, type Song } from '../stores/player';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast';
import { pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import SongListItem from '../components/SongListItem.vue';
import type { Song } from '../utils/song';
defineOptions({ name: 'LocalMusicView' });
const player = usePlayerStore();
const download = useDownload();
@ -113,6 +110,8 @@ const showDeleteConfirm = ref(false);
const deleteTarget = ref<LocalSong | null>(null);
const openMenuId = ref<number | null>(null);
const normalizedSongs = computed(() => songs.value.map(toSong));
function toggleMenu(id: number) {
openMenuId.value = openMenuId.value === id ? null : id;
}
@ -126,9 +125,11 @@ onBeforeUnmount(() => { document.removeEventListener('click', closeMenu); });
async function refresh() {
loading.value = true;
pageCacheInvalidate('localMusic');
try {
const list = await invoke<LocalSong[]>('list_local_songs', { downloadPath: settings.downloadPath || null });
songs.value = list;
pageCacheSet('localMusic', list);
fetchMissingCovers();
} catch (e) {
console.error(e);
@ -165,14 +166,6 @@ function formatFileSize(bytes: number): string {
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
function formatDuration(ms: number): string {
if (!ms || ms === 0) return '--:--';
const totalSec = Math.floor(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
}
function toSong(local: LocalSong): Song {
return {
id: local.id,
@ -180,18 +173,10 @@ function toSong(local: LocalSong): Song {
ar: local.artist ? [{ name: local.artist }] : [],
al: { picUrl: local.cover || '', name: local.album || undefined },
dt: local.duration || undefined,
artists: local.artist ? [{ name: local.artist }] : [],
album: { picUrl: local.cover || undefined, name: local.album || undefined },
duration: local.duration || undefined,
localPath: local.path,
};
}
async function playLocalSong(_song: LocalSong, index: number) {
const normalized = songs.value.map(s => toSong(s));
player.playFromList(normalized, index);
}
function confirmDelete(song: LocalSong) {
openMenuId.value = null;
deleteTarget.value = song;