### 新功能 - 流式播放:边下载边播放,缓冲 64KB 后即刻开始,无需等待完整下载 - 本地音乐页面:支持浏览、播放本地歌曲,横向菜单含「从磁盘删除」 - 下载系统:支持下载歌曲到自定义路径,保存完整元数据(封面/专辑/时长) - 封面补全:本地音乐缺少封面时自动从网易云 API 获取 - 更新信息:接入 Gitea Releases API,查看最新版更新日志 ### 修复 - 修复私人漫游播完一首歌后跳三首的问题(双重触发:audio-ended + startTick) - 修复全屏漫游抽屉和漫游页面无封面歌曲显示破损图片 - 修复 PlayerBar 无封面歌曲显示破损图片 - 修复下载路径修改后不生效(Rust serde camelCase 映射) - 修复本地音乐始终只显示默认路径歌曲 - 修复下载完成提示弹出 4 次 - 修复播放网络歌曲时进度条先走但无声音(audio-started 事件同步) ### 优化 - PlayerBar 下载状态:未下载显示下载按钮,下载中显示进度,已下载不显示 - audio.rs 新增 manual_stop 标志防止 stop_audio 触发虚假 audio-ended - player.ts 新增 waitForAudioStart() 确保 playing 状态与实际播放同步 - 切歌/停止时立即清除 tickInterval 防止重复触发 next()
138 lines
5.1 KiB
Vue
138 lines
5.1 KiB
Vue
<template>
|
||
<div class="p-8 text-content">
|
||
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
|
||
|
||
<!-- 搜索框 -->
|
||
<input
|
||
v-model="keyword"
|
||
@keyup.enter="handleSearch"
|
||
placeholder="搜索歌曲、歌手、专辑..."
|
||
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">
|
||
<span
|
||
v-for="tag in hotTags"
|
||
:key="tag.searchWord"
|
||
@click="searchTag(tag.searchWord)"
|
||
class="px-3 py-1 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
|
||
>
|
||
{{ tag.searchWord }}
|
||
</span>
|
||
</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
|
||
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">
|
||
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
|
||
</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>
|
||
</div>
|
||
<p v-if="!loading && hasSearched && results.length === 0" class="text-content-2">无结果</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
defineOptions({ name: 'DiscoverView' });
|
||
|
||
import { ref, onMounted } 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';
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const player = usePlayerStore();
|
||
const download = useDownload();
|
||
|
||
const keyword = ref('');
|
||
const results = ref<any[]>([]);
|
||
const loading = ref(false);
|
||
const hasSearched = ref(false);
|
||
const hotTags = ref<any[]>([]);
|
||
|
||
const devices = ref<string[]>([]);
|
||
|
||
onMounted(async () => {
|
||
// 获取输出设备列表
|
||
try { devices.value = await invoke('get_output_devices'); } catch {}
|
||
|
||
// 获取热门搜索
|
||
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;
|
||
await handleSearch();
|
||
router.replace({ query: {} });
|
||
}
|
||
});
|
||
|
||
async function handleSearch() {
|
||
if (!keyword.value.trim()) return;
|
||
loading.value = true;
|
||
hasSearched.value = true;
|
||
try {
|
||
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
|
||
const data = JSON.parse(jsonStr);
|
||
results.value = data.result?.songs || [];
|
||
} catch (e) {
|
||
console.error('搜索出错:', e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
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>
|