mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 10:48:05 +08:00
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:
@ -2,7 +2,6 @@
|
||||
<div class="p-8 text-content">
|
||||
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<input
|
||||
v-model="keyword"
|
||||
@keyup.enter="handleSearch"
|
||||
@ -10,7 +9,6 @@
|
||||
class="mb-4 w-full rounded-xl bg-muted p-3 text-content placeholder-content-2 outline-none backdrop-blur"
|
||||
/>
|
||||
|
||||
<!-- 热门搜索标签(仅在没有搜索且未显示结果时出现) -->
|
||||
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
|
||||
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@ -25,45 +23,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输出设备选择 -->
|
||||
<!-- <div class="mb-4">
|
||||
<label class="mr-2 text-sm text-content-2">输出设备:</label>
|
||||
<select v-model="selectedDevice" @change="changeDevice" class="bg-muted text-white rounded p-1 text-sm">
|
||||
<option :value="null">跟随系统默认</option>
|
||||
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
|
||||
</select>
|
||||
</div> -->
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="loading" class="text-content-2">搜索中...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
<SongListItem
|
||||
v-for="(song, index) in results"
|
||||
:key="song.id"
|
||||
@click="playSong(song, index)"
|
||||
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-subtle hover:bg-muted border border-line-2 cursor-pointer transition"
|
||||
>
|
||||
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate">{{ song.name }}</p>
|
||||
<p class="text-sm text-content-2 truncate">
|
||||
<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 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>
|
||||
<button @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 :song-id="song.id" />
|
||||
</div>
|
||||
:song="song"
|
||||
:index="index"
|
||||
show-download
|
||||
show-menu
|
||||
cover-size="w-12 h-12"
|
||||
container-class="backdrop-blur-md bg-subtle hover:bg-muted border border-line-2"
|
||||
@click="player.playFromList(results, index)"
|
||||
/>
|
||||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -72,38 +44,43 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'DiscoverView' });
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
import { useDownload } from '../composables/useDownload';
|
||||
import SongItemMenu from '../components/SongItemMenu.vue';
|
||||
import SongListItem from '../components/SongListItem.vue';
|
||||
import { normalizeSong, type Song } from '../utils/song';
|
||||
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
|
||||
import { useOnlineStatus } from '../composables/useOnlineStatus';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const player = usePlayerStore();
|
||||
const download = useDownload();
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
const keyword = ref('');
|
||||
const results = ref<any[]>([]);
|
||||
const results = ref<Song[]>([]);
|
||||
const loading = ref(false);
|
||||
const hasSearched = ref(false);
|
||||
const hotTags = ref<any[]>([]);
|
||||
|
||||
const devices = ref<string[]>([]);
|
||||
async function loadHotTags() {
|
||||
const cached = pageCacheGet('discover_hotTags');
|
||||
if (cached) {
|
||||
hotTags.value = cached;
|
||||
} else {
|
||||
try {
|
||||
const json = await invoke('get_hot_search');
|
||||
const data = JSON.parse(json as string);
|
||||
hotTags.value = (data.data || []).slice(0, 12);
|
||||
pageCacheSet('discover_hotTags', hotTags.value);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 获取输出设备列表
|
||||
try { devices.value = await invoke('get_output_devices'); } catch {}
|
||||
await loadHotTags();
|
||||
|
||||
// 获取热门搜索
|
||||
try {
|
||||
const json = await invoke('get_hot_search');
|
||||
const data = JSON.parse(json as string);
|
||||
hotTags.value = (data.data || []).slice(0, 12);
|
||||
} catch {}
|
||||
|
||||
// 检查路由是否有查询关键词,自动搜索
|
||||
const q = route.query.q as string;
|
||||
if (q) {
|
||||
keyword.value = q;
|
||||
@ -112,6 +89,13 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOnline, (val, old) => {
|
||||
if (val && !old && hotTags.value.length === 0) {
|
||||
pageCacheInvalidate('discover_hotTags');
|
||||
loadHotTags();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!keyword.value.trim()) return;
|
||||
loading.value = true;
|
||||
@ -119,7 +103,7 @@ async function handleSearch() {
|
||||
try {
|
||||
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
|
||||
const data = JSON.parse(jsonStr);
|
||||
results.value = data.result?.songs || [];
|
||||
results.value = (data.result?.songs || []).map(normalizeSong);
|
||||
} catch (e) {
|
||||
console.error('搜索出错:', e);
|
||||
} finally {
|
||||
@ -131,16 +115,4 @@ function searchTag(tag: string) {
|
||||
keyword.value = tag;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
async function playSong(_song: any, index: number) {
|
||||
const normalized = results.value.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
ar: s.ar || s.artists || [],
|
||||
al: s.al || s.album || { picUrl: '' },
|
||||
dt: s.dt || 0,
|
||||
}));
|
||||
player.playFromList(normalized, index);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user