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

@ -0,0 +1,97 @@
<template>
<div :class="['flex items-center gap-4 p-3 rounded-xl cursor-pointer transition group', containerClass]">
<slot name="index" :index="index" :is-current="isCurrent">
<span v-if="showIndex" class="text-xs text-content-3 w-6 text-right flex-shrink-0">{{ index + 1 }}</span>
</slot>
<div :class="['rounded-md overflow-hidden flex-shrink-0 relative', coverClass]">
<img v-if="coverSrc" :src="coverSrc" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full bg-muted flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-content-4"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</div>
<div v-if="isCurrent && showPlayingOverlay"
class="absolute inset-0 bg-black/30 flex items-center justify-center">
<div class="flex items-end gap-[2px] h-3">
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0s"></span>
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.12s"></span>
<span class="eq-bar-sm w-[2px] bg-white rounded-full" style="animation-delay: 0.24s"></span>
</div>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate" :class="nameClass">{{ song.name }}</p>
<p class="text-xs text-content-2 truncate">
<template v-if="song.ar?.length">
<template v-for="(a, i) in song.ar" :key="a.id || i">
<span v-if="i > 0" class="text-content-3">/</span>
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
</template>
</template>
<template v-if="song.al?.name">
<span class="text-content-3 mx-1">·</span>
<span class="hover:text-accent-text cursor-pointer transition" @click.stop="song.al.id && router.push({ name: 'album', params: { id: song.al.id } })">{{ song.al.name }}</span>
</template>
</p>
</div>
<slot name="actions">
<button v-if="showLike" @click.stop="player.toggleLike(song.id)" class="text-content-3 hover:text-danger transition flex-shrink-0">
<svg v-if="player.isLiked(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" class="text-danger"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button v-if="showDownload" @click.stop="download.downloadSong(song)" class="text-content-3 hover:text-accent-text transition flex-shrink-0" :title="download.isDownloaded(song.id) ? '已下载' : '下载'">
<svg v-if="download.isDownloading(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<svg v-else-if="download.isDownloaded(song.id)" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent-text"><polyline points="20 6 9 17 4 12"/></svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<SongItemMenu v-if="showMenu" :song-id="song.id" />
</slot>
<span v-if="showDuration && song.dt" class="text-xs text-content-3 flex-shrink-0">{{ formatDuration(song.dt) }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { getCoverUrl, type Song } from '../utils/song';
import { formatDuration } from '../utils/format';
import SongItemMenu from './SongItemMenu.vue';
const router = useRouter();
const player = usePlayerStore();
const download = useDownload();
const props = withDefaults(defineProps<{
song: Song;
index: number;
isCurrent?: boolean;
showIndex?: boolean;
showLike?: boolean;
showDownload?: boolean;
showMenu?: boolean;
showDuration?: boolean;
showPlayingOverlay?: boolean;
coverSize?: string;
coverSizeParam?: string;
containerClass?: string;
}>(), {
isCurrent: false,
showIndex: false,
showLike: false,
showDownload: false,
showMenu: false,
showDuration: false,
showPlayingOverlay: false,
coverSize: 'w-10 h-10',
coverSizeParam: '',
containerClass: 'hover:bg-subtle',
});
const coverClass = computed(() => props.coverSize);
const coverSrc = computed(() => getCoverUrl(props.song, props.coverSizeParam));
const nameClass = computed(() => props.isCurrent ? 'text-accent-text' : '');
</script>