feat: v0.6.0 - 亮色主题、封面主色、发现页重做、漫游页重做、减少推荐、列表风格统一

新功能:
- 亮色主题:新增浅色外观模式,7种主题色各有对应亮色变体
- 封面主色背景:漫游抽屉自动提取封面主色,PlayerBar跟随继承
- 发现页重做:多类型搜索(歌曲/歌手/专辑)+搜索建议+搜索历史
- 漫游页重做:进入即播放,布局改为封面+歌名+播放/下一首/减少推荐
- 减少推荐:FM模式下可标记不推荐歌曲或歌手
- 列表风格统一:播放指示器跳动动画+hover播放图标+图标统一使用Lucide

修复:
- 专辑页艺术家过多时窗口缩小竖排,改为自动换行
- FM播放时退出登录后首页仍可点击下一首
- 本地音乐播放时缓冲进度条未重置
- 亮色主题下多处文字不可见
- 退出FM模式时状态未正确清理
- 暗色模式下关闭抽屉时PlayerBar闪烁亮色(改用opacity过渡)
- player.ts tickInterval双变量状态不同步,统一为clearTick/setTick

变更:
- 移除播放列表按钮数字角标
- 主页卡片标题固定白色不随主题变化
- 全项目空catch块格式统一
- 清理冗余注释和代码
This commit is contained in:
2026-05-28 22:50:22 +08:00
parent 6da544cffb
commit c275461015
36 changed files with 1079 additions and 475 deletions

View File

@ -9,11 +9,11 @@
<div class="flex flex-col justify-between min-w-0">
<div>
<h1 class="text-2xl font-bold leading-tight">{{ album.name }}</h1>
<div v-if="album.artists?.length" class="flex items-center gap-1 mt-2 text-sm text-content-2">
<div v-if="album.artists?.length" class="flex flex-wrap items-center gap-x-1 gap-y-0.5 mt-2 text-sm text-content-2">
<template v-for="(ar, idx) in album.artists" :key="ar.id">
<span v-if="(idx as number) > 0" class="text-content-3">/</span>
<span
class="hover:text-accent-text cursor-pointer transition"
class="hover:text-accent-text cursor-pointer transition whitespace-nowrap"
@click="ar.id && router.push({ name: 'artist', params: { id: ar.id } })"
>{{ ar.name }}</span>
</template>
@ -27,7 +27,7 @@
@click="playAll"
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
<IconPlay class="w-4 h-4 fill-current" />
播放全部
</button>
</div>
@ -51,23 +51,7 @@
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(songs, index)"
>
<template #index="{ index: idx, isCurrent }">
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrent" class="flex items-center justify-end">
<div class="flex items-center gap-[3px] h-4">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div>
<template v-else>
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
</template>
</SongListItem>
/>
</div>
</div>
</template>
@ -80,6 +64,7 @@ import { usePlayerStore } from '../stores/player';
import { normalizeSong, type Song } from '../utils/song';
import { formatDate } from '../utils/format';
import SongListItem from '../components/SongListItem.vue';
import IconPlay from '~icons/lucide/play';
const route = useRoute();
const router = useRouter();

View File

@ -18,7 +18,7 @@
@click="playAll"
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
<IconPlay class="w-4 h-4 fill-current" />
播放全部
</button>
</div>
@ -55,23 +55,7 @@
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(songs, index)"
>
<template #index="{ index: idx, isCurrent }">
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrent" class="flex items-center justify-end">
<div class="flex items-center gap-[3px] h-4">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div>
<template v-else>
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
</template>
</SongListItem>
/>
</div>
<div v-if="activeTab === 'albums'" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@ -104,6 +88,7 @@ import { usePlayerStore } from '../stores/player';
import { formatPlayCount, formatDate } from '../utils/format';
import { normalizeSong, type Song } from '../utils/song';
import SongListItem from '../components/SongListItem.vue';
import IconPlay from '~icons/lucide/play';
const route = useRoute();
const router = useRouter();

View File

@ -29,23 +29,7 @@
show-playing-overlay
:container-class="isCurrentSong(song.id) ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(songs, index)"
>
<template #index="{ index: idx, isCurrent }">
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrent" class="flex items-center justify-end">
<div class="flex items-center gap-[3px] h-4">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div>
<template v-else>
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
</template>
</SongListItem>
/>
</div>
</div>
</template>

View File

@ -1,13 +1,55 @@
<template>
<div class="p-8 text-content">
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
<div class="p-8 text-content" @click="showSuggestions = false">
<div class="relative mb-6" @click.stop>
<div class="flex items-center gap-3">
<div class="relative flex-1">
<IconSearch class="absolute left-3.5 top-1/2 -translate-y-1/2 text-content-3 w-[18px] h-[18px]" />
<input
ref="searchInput"
v-model="keyword"
@input="onInputChange"
@keydown.enter="handleSearch"
@focus="onInputFocus"
placeholder="搜索歌曲、歌手、专辑..."
class="w-full rounded-xl bg-muted pl-10 pr-10 py-3 text-content placeholder-content-3 outline-none focus:bg-subtle focus:ring-1 focus:ring-accent/30 transition"
/>
<button v-if="keyword" @click="clearSearch" class="absolute right-3 top-1/2 -translate-y-1/2 text-content-3 hover:text-content transition">
<IconX class="w-4 h-4" />
</button>
</div>
</div>
<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="showSuggestions && !hasSearched"
class="absolute z-30 left-0 right-0 top-full mt-2 bg-surface border border-line-2 rounded-xl shadow-xl overflow-hidden max-h-[60vh] overflow-y-auto">
<div v-if="suggestions.length" class="p-2">
<p class="text-xs text-content-3 px-3 py-1.5">搜索建议</p>
<button v-for="s in suggestions" :key="s" @click="searchTag(s)"
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
<IconSearch style="font-size: 14px" class="text-content-3 flex-shrink-0" />
<span>{{ s }}</span>
</button>
</div>
<div v-if="searchHistory.length && !suggestions.length" class="p-2">
<div class="flex items-center justify-between px-3 py-1.5">
<p class="text-xs text-content-3">搜索历史</p>
<button @click.stop="clearHistory" class="text-xs text-content-3 hover:text-danger transition">清空</button>
</div>
<button v-for="h in searchHistory" :key="h" @click="searchTag(h)"
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
<IconHistory style="font-size: 14px" class="text-content-3 flex-shrink-0" />
<span>{{ h }}</span>
</button>
</div>
<div v-if="hotTags.length && !suggestions.length && !searchHistory.length" class="p-2">
<p class="text-xs text-content-3 px-3 py-1.5">热门搜索</p>
<button v-for="tag in hotTags" :key="tag.searchWord" @click="searchTag(tag.searchWord)"
class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-muted transition flex items-center gap-2">
<IconClock style="font-size: 14px" class="text-content-3 flex-shrink-0" />
<span>{{ tag.searchWord }}</span>
</button>
</div>
</div>
</div>
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
@ -16,27 +58,84 @@
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"
class="px-3 py-1.5 rounded-full bg-muted hover:bg-emphasis cursor-pointer transition text-sm"
>
{{ tag.searchWord }}
</span>
</div>
</div>
<div v-if="loading" class="text-content-2">搜索中...</div>
<div v-else class="space-y-3">
<SongListItem
v-for="(song, index) in results"
:key="song.id"
: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 v-if="hasSearched">
<div class="flex items-center gap-1 mb-4 bg-muted rounded-lg p-1 w-fit">
<button v-for="tab in tabs" :key="tab.type" @click="switchTab(tab.type)"
:class="['px-4 py-1.5 rounded-md text-sm font-medium transition', activeTab === tab.type ? 'bg-surface text-content shadow-sm' : 'text-content-2 hover:text-content']">
{{ tab.label }}
<span v-if="resultCache.has(tab.type) && resultCache.get(tab.type)!.count > 0" class="text-xs text-content-3 ml-1">{{ resultCache.get(tab.type)!.count }}</span>
</button>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="flex items-end gap-1 h-6">
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0s"></span>
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.12s"></span>
<span class="eq-bar w-[3px] bg-accent rounded-full" style="animation-delay: 0.24s"></span>
</div>
</div>
<template v-else>
<div v-if="activeTab === 1">
<div v-if="currentResults.length" class="space-y-2">
<SongListItem
v-for="(song, index) in currentResults"
:key="song.id"
:song="song"
:index="index"
show-download
show-menu
cover-size="w-12 h-12"
container-class="bg-subtle hover:bg-muted border border-line-2"
@click="player.playFromList(currentResults, index)"
/>
</div>
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌曲' }}</p>
</div>
<div v-else-if="activeTab === 100">
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="artist in currentResults" :key="artist.id" @click="router.push({ name: 'artist', params: { id: artist.id } })"
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl p-4 cursor-pointer transition flex items-center gap-3">
<img v-if="artist.picUrl" :src="artist.picUrl + '?param=100y100'" class="w-14 h-14 rounded-full object-cover flex-shrink-0" />
<div v-else class="w-14 h-14 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<IconUserRound class="w-5 h-5 text-content-3" />
</div>
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ artist.name }}</p>
<p v-if="artist.alias?.length" class="text-xs text-content-3 truncate">{{ artist.alias[0] }}</p>
<p v-if="artist.musicSize" class="text-xs text-content-3">{{ artist.musicSize }} 首歌曲</p>
</div>
</div>
</div>
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关歌手' }}</p>
</div>
<div v-else-if="activeTab === 10">
<div v-if="currentResults.length" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div v-for="album in currentResults" :key="album.id" @click="router.push({ name: 'album', params: { id: album.id } })"
class="bg-subtle hover:bg-muted border border-line-2 rounded-xl overflow-hidden cursor-pointer transition">
<img v-if="album.picUrl" :src="album.picUrl + '?param=200y200'" class="w-full aspect-square object-cover" />
<div v-else class="w-full aspect-square bg-muted flex items-center justify-center">
<IconDisc class="w-8 h-8 text-content-3" />
</div>
<div class="p-3">
<p class="text-sm font-medium truncate">{{ album.name }}</p>
<p class="text-xs text-content-2 truncate mt-0.5">{{ album.artist?.name || '' }}</p>
<p v-if="album.publishTime" class="text-xs text-content-3 mt-0.5">{{ formatDate(album.publishTime) }}</p>
</div>
</div>
</div>
<p v-else class="text-content-2 text-center py-8">{{ cacheError ? '搜索失败,点击其他标签页刷新重试' : '未找到相关专辑' }}</p>
</div>
</template>
</div>
</div>
</template>
@ -44,25 +143,110 @@
<script setup lang="ts">
defineOptions({ name: 'DiscoverView' });
import { ref, onMounted, onActivated, watch } from 'vue';
import { ref, computed, onMounted, onActivated, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import SongListItem from '../components/SongListItem.vue';
import { normalizeSong, type Song } from '../utils/song';
import { formatDate } from '../utils/format';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
import IconSearch from '~icons/lucide/search';
import IconX from '~icons/lucide/x';
import IconHistory from '~icons/lucide/history';
import IconClock from '~icons/lucide/clock';
import IconUserRound from '~icons/lucide/user-round';
import IconDisc from '~icons/lucide/disc';
const router = useRouter();
const route = useRoute();
const player = usePlayerStore();
const { isOnline } = useOnlineStatus();
const searchInput = ref<HTMLInputElement | null>(null);
const keyword = ref('');
const results = ref<Song[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const hotTags = ref<any[]>([]);
const suggestions = ref<string[]>([]);
const showSuggestions = ref(false);
const activeTab = ref(1);
const cacheError = ref(false);
interface CacheEntry {
data: Song[] | any[];
count: number;
dirty: boolean;
}
const resultCache = ref<Map<number, CacheEntry>>(new Map());
const lastSearchKeyword = ref('');
const currentResults = computed(() => {
const entry = resultCache.value.get(activeTab.value);
return entry ? entry.data : [];
});
const tabs = [
{ type: 1, label: '歌曲' },
{ type: 100, label: '歌手' },
{ type: 10, label: '专辑' },
];
const HISTORY_KEY = 'search_history';
const MAX_HISTORY = 15;
function loadSearchHistory(): string[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
if (raw) return JSON.parse(raw);
} catch { /* 忽略 */ }
return [];
}
function saveSearchHistory(q: string) {
let history = loadSearchHistory();
history = history.filter(h => h !== q);
history.unshift(q);
if (history.length > MAX_HISTORY) history = history.slice(0, MAX_HISTORY);
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
const searchHistory = ref<string[]>(loadSearchHistory());
function clearHistory() {
searchHistory.value = [];
localStorage.removeItem(HISTORY_KEY);
}
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
function onInputChange() {
if (suggestTimer) clearTimeout(suggestTimer);
if (!keyword.value.trim()) {
suggestions.value = [];
showSuggestions.value = true;
return;
}
suggestTimer = setTimeout(async () => {
try {
const jsonStr: string = await invoke('search_suggest', { query: { keyword: keyword.value.trim() } });
const data = JSON.parse(jsonStr);
const all = data.result?.allMatch || [];
suggestions.value = all.map((m: any) => m.keyword).slice(0, 8);
showSuggestions.value = true;
} catch {
suggestions.value = [];
}
}, 300);
}
function onInputFocus() {
if (!hasSearched.value) {
showSuggestions.value = true;
}
}
async function loadHotTags() {
const cached = pageCacheGet('discover_hotTags');
@ -74,13 +258,12 @@ async function loadHotTags() {
const data = JSON.parse(json as string);
hotTags.value = (data.data || []).slice(0, 12);
pageCacheSet('discover_hotTags', hotTags.value);
} catch {}
} catch { /* 忽略 */ }
}
}
onMounted(async () => {
await loadHotTags();
const q = route.query.q as string;
if (q) {
keyword.value = q;
@ -89,8 +272,14 @@ onMounted(async () => {
}
});
onActivated(() => {
onActivated(async () => {
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
const q = route.query.q as string;
if (q && q !== lastSearchKeyword.value) {
keyword.value = q;
await handleSearch();
router.replace({ query: {} });
}
});
watch(isOnline, (val, old) => {
@ -101,22 +290,81 @@ watch(isOnline, (val, old) => {
});
async function handleSearch() {
if (!keyword.value.trim()) return;
loading.value = true;
const q = keyword.value.trim();
if (!q) return;
showSuggestions.value = false;
hasSearched.value = true;
cacheError.value = false;
saveSearchHistory(q);
searchHistory.value = loadSearchHistory();
if (q === lastSearchKeyword.value && resultCache.value.size > 0) return;
lastSearchKeyword.value = q;
resultCache.value.clear();
await Promise.all([
fetchTabResults(1),
fetchTabResults(100),
fetchTabResults(10),
]);
}
async function fetchTabResults(type: number) {
const entry = resultCache.value.get(type);
if (entry && !entry.dirty) return;
loading.value = true;
cacheError.value = false;
try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const jsonStr: string = await invoke('cloudsearch', {
query: { keyword: lastSearchKeyword.value, searchType: type, limit: 30 }
});
const data = JSON.parse(jsonStr);
results.value = (data.result?.songs || []).map(normalizeSong);
const result = data.result || {};
let items: any[] = [];
if (type === 1) {
items = (result.songs || []).map(normalizeSong);
} else if (type === 100) {
items = result.artists || [];
} else if (type === 10) {
items = result.albums || [];
}
resultCache.value.set(type, { data: items, count: items.length, dirty: false });
} catch (e) {
console.error('搜索出错:', e);
resultCache.value.set(type, { data: [], count: 0, dirty: true });
cacheError.value = true;
} finally {
loading.value = false;
}
}
async function switchTab(type: number) {
if (type === activeTab.value) return;
activeTab.value = type;
const entry = resultCache.value.get(type);
if (!entry || entry.dirty) {
await fetchTabResults(type);
}
}
function searchTag(tag: string) {
keyword.value = tag;
handleSearch();
}
function clearSearch() {
keyword.value = '';
hasSearched.value = false;
resultCache.value.clear();
lastSearchKeyword.value = '';
cacheError.value = false;
suggestions.value = [];
showSuggestions.value = true;
nextTick(() => searchInput.value?.focus());
}
</script>

View File

@ -3,13 +3,14 @@
<button @click="$router.back()" class="mb-4 text-content-2 hover:text-content transition">
返回
</button>
<div class="flex items-center gap-4 mb-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">我喜欢的音乐</h1>
<button
v-if="songs.length"
@click="player.playAll(songs)"
class="px-4 py-1.5 bg-muted hover:bg-emphasis rounded-full text-sm transition"
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
>
<IconPlay class="w-4 h-4 fill-current" />
播放全部
</button>
</div>
@ -18,17 +19,20 @@
</div>
<div v-else-if="loading" class="text-content-2">加载中...</div>
<div v-else-if="songs.length === 0" class="text-content-2">暂无喜欢的音乐</div>
<div v-else class="space-y-2">
<div v-else class="space-y-1">
<SongListItem
v-for="(song, index) in songs"
:key="song.id"
:song="song"
:index="index"
:is-current="player.currentSong?.id === song.id"
show-index
show-like
show-download
show-menu
show-duration
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(songs, index)"
/>
</div>
@ -44,6 +48,7 @@ import { useUserStore } from '../stores/user';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
import IconPlay from '~icons/lucide/play';
defineOptions({ name: 'FavoriteSongsView' });

View File

@ -11,7 +11,7 @@
<div class="relative z-10 p-6 flex flex-col justify-between h-full">
<div>
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
<h2 class="text-2xl font-bold">每日推荐</h2>
<h2 class="text-2xl font-bold text-white">每日推荐</h2>
</div>
<p class="text-xs text-white/60">根据你的口味生成每天凌晨更新</p>
</div>
@ -33,15 +33,15 @@
<div class="relative z-10 h-full flex flex-col justify-between p-6">
<div class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-white/50"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>
<IconRadio class="w-4 h-4 text-white/50" />
<span class="text-xs text-white/50 font-medium">私人漫游</span>
</div>
<div class="flex items-end justify-between gap-4">
<div class="min-w-0 flex-1">
<h2 class="text-xl font-bold" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
<h2 class="text-xl font-bold" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
<h2 class="text-lg font-bold truncate" v-else>{{ fmDisplayName }}</h2>
<h2 class="text-xl font-bold text-white" v-if="!player.fmSong && userStore.isLoggedIn">发现新音乐</h2>
<h2 class="text-xl font-bold text-white" v-else-if="!userStore.isLoggedIn">私人漫游</h2>
<h2 class="text-lg font-bold truncate text-white" v-else>{{ fmDisplayName }}</h2>
<p v-if="!userStore.isLoggedIn" class="text-xs text-white/50 mt-1">登录后开启沉浸式音乐探索</p>
<p v-else-if="!player.fmSong" class="text-xs text-white/50 mt-1">根据你的喜好为你推荐意想不到的好歌</p>
<p v-else class="text-xs text-white/60 truncate mt-1">{{ fmDisplayArtists }}</p>
@ -50,24 +50,17 @@
<button v-if="userStore.isLoggedIn && !player.fmSong"
@click.stop="startFmPlay"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
<IconPlay class="w-4 h-4 fill-current text-white" />
</button>
<template v-if="player.fmSong">
<button @click.stop="player.toggleFm"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/15 hover:bg-white/25 backdrop-blur-sm transition">
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
<IconPause v-if="player.fmPlaying" class="w-[18px] h-[18px] fill-current text-white" />
<IconPlay v-else class="w-[18px] h-[18px] fill-current text-white" />
</button>
<button @click.stop="player.nextFm"
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-sm transition">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="text-white"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
<IconSkipForward class="w-[14px] h-[14px] text-white" />
</button>
</template>
</div>
@ -131,6 +124,10 @@ const todayStr = ref('');
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
import { computed } from 'vue';
import IconRadio from '~icons/lucide/radio';
import IconPlay from '~icons/lucide/play';
import IconPause from '~icons/lucide/pause';
import IconSkipForward from '~icons/lucide/skip-forward';
const fmCoverUrl = computed(() => {
@ -187,7 +184,7 @@ async function loadData() {
const json = await invoke('recommend_resource');
const data = JSON.parse(json as string);
recPlaylists.value = data.recommend || [];
} catch { }
} catch { /* 忽略 */ }
}
pageCacheSet('home', { rankPlaylists: rankPlaylists.value, recPlaylists: recPlaylists.value });

View File

@ -26,7 +26,8 @@
: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'"
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(normalizedSongs, index)"
>
<template #actions>
@ -37,12 +38,12 @@
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>
<IconEllipsis class="w-4 h-4 fill-current" />
</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>
<IconTrash2 style="font-size: 14px" />
从磁盘中删除
</button>
</div>
@ -82,6 +83,8 @@ import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast';
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import SongListItem from '../components/SongListItem.vue';
import IconEllipsis from '~icons/lucide/ellipsis';
import IconTrash2 from '~icons/lucide/trash-2';
import type { Song } from '../utils/song';
defineOptions({ name: 'LocalMusicView' });
@ -153,7 +156,7 @@ async function fetchMissingCovers() {
const url = detailMap.get(song.id);
if (url) song.cover = url;
}
} catch {}
} catch { /* 忽略 */ }
}
onMounted(refresh);

View File

@ -23,7 +23,7 @@
@click="playAll"
class="px-5 py-2 bg-accent hover:bg-accent-hover rounded-full text-white font-medium transition flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
<IconPlay class="w-4 h-4 fill-current" />
播放全部
</button>
<button
@ -32,10 +32,7 @@
class="px-4 py-2 bg-muted hover:bg-emphasis rounded-full text-sm transition flex items-center gap-2"
:class="subscribed ? 'text-accent-text' : 'text-content/70'"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path v-if="subscribed" d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
<path v-else d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
</svg>
<IconBookmark class="w-4 h-4" :class="subscribed ? 'fill-current' : ''" />
{{ subscribed ? '已收藏' : '收藏歌单' }}
</button>
</div>
@ -59,23 +56,7 @@
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(songs, index)"
>
<template #index="{ index: idx, isCurrent }">
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrent" class="flex items-center justify-end">
<div class="flex items-center gap-[3px] h-4">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div>
<template v-else>
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
</template>
</SongListItem>
/>
</div>
<div v-if="playlist" class="mt-8">
@ -95,6 +76,8 @@ import { formatPlayCount } from '../utils/format';
import { normalizeSong, type Song } from '../utils/song';
import SongListItem from '../components/SongListItem.vue';
import CommentSection from '../components/CommentSection.vue';
import IconPlay from '~icons/lucide/play';
import IconBookmark from '~icons/lucide/bookmark';
const route = useRoute();
const player = usePlayerStore();

View File

@ -20,23 +20,7 @@
show-playing-overlay
:container-class="player.currentSong?.id === song.id ? 'bg-accent-dim hover:bg-accent-dim' : 'hover:bg-subtle'"
@click="player.playFromList(player.recentLocal, index)"
>
<template #index="{ index: idx, isCurrent }">
<div class="w-6 text-right flex-shrink-0 flex items-center justify-end h-5">
<div v-if="isCurrent" class="flex items-center justify-end">
<div class="flex items-center gap-[3px] h-4">
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 50%; animation-delay: 0ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 100%; animation-delay: 150ms"></span>
<span class="w-[3px] bg-accent-text rounded-full animate-bounce" style="height: 35%; animation-delay: 300ms"></span>
</div>
</div>
<template v-else>
<span class="text-xs text-content-3 group-hover:hidden">{{ idx + 1 }}</span>
<svg class="hidden group-hover:block text-content" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5v11l9-5.5z"/></svg>
</template>
</div>
</template>
</SongListItem>
/>
</div>
</div>
</template>

View File

@ -1,120 +0,0 @@
<template>
<div class="p-8 text-content flex flex-col items-center justify-center min-h-full">
<div v-if="!currentSong" class="text-center">
<p class="text-content-2 mb-4">私人漫游未启动</p>
<button
@click="startFm"
class="px-6 py-2 bg-muted hover:bg-emphasis rounded-full transition"
>
开始漫游
</button>
</div>
<template v-else>
<img
v-if="coverUrl && !coverError"
:src="coverUrl"
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
@error="coverError = true"
/>
<div
v-else
class="w-80 h-80 rounded-3xl bg-muted flex items-center justify-center shadow-2xl mb-8"
>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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>
</div>
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
<p class="text-lg text-content-2 mb-8">
<template v-for="(a, i) in currentSong.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="a.id && router.push({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
</template>
<template v-if="currentSong.al?.name">
<span class="text-content-3 mx-1">·</span>
<span class="hover:text-accent-text cursor-pointer transition" @click="currentSong.al.id && router.push({ name: 'album', params: { id: currentSong.al.id } })">{{ currentSong.al.name }}</span>
</template>
</p>
<div class="flex items-center gap-8">
<button
@click="player.toggle()"
class="w-16 h-16 flex items-center justify-center rounded-full bg-muted hover:bg-emphasis transition border border-emphasis"
>
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button
@click="nextSong"
class="text-content-2 hover:text-content transition"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { usePlayerStore } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
import { normalizeSong, getCoverUrl } from '../utils/song';
import { useRouter } from 'vue-router';
import { useOnlineStatus } from '../composables/useOnlineStatus';
const player = usePlayerStore();
const router = useRouter();
const { isOnline } = useOnlineStatus();
const coverError = ref(false);
const currentSong = computed(() => {
if (player.isFmMode && player.currentSong) {
return player.currentSong;
}
return null;
});
const coverUrl = computed(() => {
if (!currentSong.value) return '';
return getCoverUrl(currentSong.value) || '';
});
watch(coverUrl, () => { coverError.value = false; });
onMounted(async () => {
if (!player.isFmMode || !player.currentSong) {
await startFm();
}
});
async function startFm() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
player.enableFmMode(nextSong);
await player.playFmSong(song);
}
} catch (e) {
console.error('启动漫游失败', e);
}
}
async function nextSong() {
await startFm();
}
watch(isOnline, (val, old) => {
if (val && !old && !currentSong.value) {
startFm();
}
});
</script>

View File

@ -28,19 +28,37 @@
<section class="mb-8">
<h2 class="text-sm text-content-2 uppercase tracking-wider mb-4">外观</h2>
<div>
<p class="text-sm font-medium mb-3">主题色</p>
<div class="grid grid-cols-4 gap-3">
<button
v-for="(color, key) in themeColors"
:key="key"
@click="settings.setTheme(key)"
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
:class="settings.theme === key ? 'border-white/30 bg-white/5 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
>
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
</button>
<div class="space-y-5">
<div>
<p class="text-sm font-medium mb-3">外观模式</p>
<div class="flex gap-3">
<button
v-for="(label, key) in appearanceLabels"
:key="key"
@click="settings.setAppearance(key)"
class="flex items-center gap-2 px-4 py-2.5 rounded-xl transition-all border-2"
:class="settings.appearance === key ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
>
<IconSun v-if="key === 'light'" class="w-4 h-4" :class="settings.appearance === key ? 'text-accent-text' : 'text-content-3'" />
<IconMoon v-else class="w-4 h-4" :class="settings.appearance === key ? 'text-accent-text' : 'text-content-3'" />
<span class="text-sm" :class="settings.appearance === key ? 'text-content font-medium' : 'text-content-3'">{{ label }}</span>
</button>
</div>
</div>
<div>
<p class="text-sm font-medium mb-3">主题色</p>
<div class="grid grid-cols-4 gap-3">
<button
v-for="(color, key) in themeColors"
:key="key"
@click="settings.setTheme(key)"
class="flex flex-col items-center gap-2 p-3 rounded-xl transition-all border-2"
:class="settings.theme === key ? 'border-accent/40 bg-accent/10 scale-[1.02]' : 'border-transparent bg-subtle hover:bg-muted'"
>
<div class="w-8 h-8 rounded-full shadow-md" :style="{ backgroundColor: color }"></div>
<span class="text-xs" :class="settings.theme === key ? 'text-content font-medium' : 'text-content-3'">{{ themeLabels[key] }}</span>
</button>
</div>
</div>
</div>
</section>
@ -109,7 +127,7 @@
class="w-6 h-6 flex items-center justify-center rounded-md text-content-4 hover:text-danger hover:bg-danger/10 transition"
title="恢复默认"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<IconX style="font-size: 14px" />
</button>
<button
@click="startRecording(String(id))"
@ -167,8 +185,8 @@
:disabled="updater.checking.value"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
<svg v-if="!updater.checking.value" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<svg v-else class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.22-8.56"/></svg>
<IconFileText v-if="!updater.checking.value" class="w-4 h-4" />
<IconLoader2 v-else class="w-4 h-4 animate-spin" />
{{ updater.checking.value ? '检查中...' : '检查更新' }}
</button>
<button
@ -176,7 +194,7 @@
:disabled="fetchingChangelog"
class="flex items-center gap-2 px-4 py-2 bg-subtle hover:bg-muted rounded-lg text-sm transition"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<IconFileText class="w-4 h-4" />
{{ fetchingChangelog ? '获取中...' : '更新日志' }}
</button>
</div>
@ -237,7 +255,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, type CloseAction } from '../stores/settings';
import { useSettingsStore, qualityLabels, closeActionLabels, defaultShortcuts, themeLabels, themeColors, appearanceLabels, type CloseAction } from '../stores/settings';
import { useToast } from '../composables/useToast';
import { useUpdater } from '../composables/useUpdater';
import { invoke } from '@tauri-apps/api/core';
@ -245,6 +263,11 @@ import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '@tauri-apps/plugin-opener';
import { open } from '@tauri-apps/plugin-dialog';
import CustomSelect from '../components/CustomSelect.vue';
import IconX from '~icons/lucide/x';
import IconFileText from '~icons/lucide/file-text';
import IconLoader2 from '~icons/lucide/loader-2';
import IconSun from '~icons/lucide/sun';
import IconMoon from '~icons/lucide/moon';
const settings = useSettingsStore();
const { showToast } = useToast();
@ -287,7 +310,7 @@ onMounted(async () => {
appVersion.value = await getVersion();
try {
defaultDownloadPath.value = await invoke<string>('get_default_download_path');
} catch { }
} catch { /* 忽略 */ }
loadDevices();
});