mirror of
https://github.com/atdunbg/Nekosonic-Music.git
synced 2026-06-22 00:58:51 +08:00
refactor: 拆分 App.vue 为独立组件,修复多项敷衍方案
- 提取 TitleBar、Sidebar、RoamDrawer、CloseModal 四个组件 - App.vue 从 676 行精简至 238 行,职责更清晰 - 修复评论点赞无限+1:改为基于服务端 liked 字段切换 - 修复评论点赞无防重复:添加 likingSet 锁 - 修复评论点赞用本地缓存:likedIds 改为从服务端 likelist API 同步 - 删除 likedIds 写 localStorage 的 watch - 播放失败/FM加载失败/减少推荐失败添加 showToast 用户提示 - 抽屉遮罩下标题栏按钮保持原色(z-10 提升至遮罩之上)
This commit is contained in:
456
src/App.vue
456
src/App.vue
@ -1,130 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
|
<div class="flex flex-col h-screen bg-base text-content overflow-hidden">
|
||||||
<div
|
<TitleBar @close="closeWindow" />
|
||||||
data-tauri-drag-region
|
|
||||||
class="h-10 flex items-center justify-between px-4 bg-surface/90 backdrop-blur select-none flex-shrink-0"
|
|
||||||
>
|
|
||||||
<span class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
|
|
||||||
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
|
|
||||||
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
<div class="flex flex-1 overflow-hidden" v-if="windowVisible">
|
||||||
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
|
<Sidebar />
|
||||||
<div class="flex-1 p-4 overflow-y-auto min-h-0">
|
|
||||||
<div class="flex flex-col min-h-full">
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<router-link to="/"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconHome class="w-[18px] h-[18px]" />
|
|
||||||
推荐
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/discover"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconSearch class="w-[18px] h-[18px]" />
|
|
||||||
发现
|
|
||||||
</router-link>
|
|
||||||
<button
|
|
||||||
@click="openRoamFromSidebar"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
|
|
||||||
>
|
|
||||||
<IconRadio class="w-[18px] h-[18px]" />
|
|
||||||
漫游
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 mb-1 pt-2">
|
|
||||||
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<router-link to="/favorites"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconHeart class="w-[18px] h-[18px]" />
|
|
||||||
我喜欢的音乐
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/recent"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconClock class="w-[18px] h-[18px]" />
|
|
||||||
最近播放
|
|
||||||
</router-link>
|
|
||||||
<router-link to="/local-music"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconMusic class="w-[18px] h-[18px]" />
|
|
||||||
本地音乐
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
|
||||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
|
||||||
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
|
||||||
<p class="text-xs text-content-3">我的歌单</p>
|
|
||||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
|
|
||||||
</div>
|
|
||||||
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
|
||||||
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
|
||||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
|
||||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
|
||||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
|
||||||
<span class="text-sm truncate"
|
|
||||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
|
||||||
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
|
||||||
@click="showSubPlaylists = !showSubPlaylists">
|
|
||||||
<p class="text-xs text-content-3">收藏的歌单</p>
|
|
||||||
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
|
|
||||||
</div>
|
|
||||||
<div v-show="showSubPlaylists" class="space-y-0.5">
|
|
||||||
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
|
||||||
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
|
||||||
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
|
||||||
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
|
||||||
<span class="text-sm truncate"
|
|
||||||
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
|
|
||||||
<div class="px-1">
|
|
||||||
<router-link to="/settings"
|
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
|
||||||
active-class="!text-content !bg-muted">
|
|
||||||
<IconSettings class="w-[18px] h-[18px]" />
|
|
||||||
设置
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
|
|
||||||
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
|
|
||||||
<router-link to="/login"
|
|
||||||
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
|
|
||||||
<IconLogIn class="w-4 h-4" />
|
|
||||||
立即登录
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center gap-3 px-2 mt-3">
|
|
||||||
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
|
||||||
<button @click="userStore.logout(); player.stop()"
|
|
||||||
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto pb-24">
|
<main class="flex-1 overflow-y-auto pb-24">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
@ -135,101 +14,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="drawer">
|
<RoamDrawer :visible="windowVisible && player.showRoamDrawer" />
|
||||||
<div
|
|
||||||
v-if="windowVisible && player.showRoamDrawer"
|
|
||||||
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl"
|
|
||||||
:class="!player.dominantColor && 'bg-surface/95'"
|
|
||||||
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
|
|
||||||
>
|
|
||||||
<div v-if="player.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
|
||||||
<div class="h-10 flex items-center justify-between px-4 flex-shrink-0 relative z-10" data-tauri-drag-region>
|
|
||||||
<button @click="player.closeRoamDrawer()" :class="player.dominantColor ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
|
|
||||||
<IconChevronDown class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
|
|
||||||
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
|
|
||||||
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0 relative z-10">
|
|
||||||
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
|
||||||
<img
|
|
||||||
v-if="roamCoverUrl && !roamCoverError"
|
|
||||||
:src="roamCoverUrl"
|
|
||||||
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
|
||||||
@error="roamCoverError = true"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
|
|
||||||
:class="player.dominantColor ? 'bg-white/10' : 'bg-muted'"
|
|
||||||
>
|
|
||||||
<IconMusic class="w-16 h-16" :class="player.dominantColor ? 'text-white/30' : 'text-content-4'" />
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold text-center" :class="player.dominantColor ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
|
|
||||||
<p class="mt-2 text-center" :class="player.dominantColor ? 'text-white/70' : 'text-content-2'">
|
|
||||||
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
|
|
||||||
<span v-if="i > 0" :class="player.dominantColor ? 'text-white/40' : 'text-content-3'">/</span>
|
|
||||||
<span class="hover:text-accent-text cursor-pointer transition" @click="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-if="roamSong?.al?.name">
|
|
||||||
<span :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
|
|
||||||
<span class="hover:text-accent-text cursor-pointer transition" @click="roamSong!.al.id && navigateFromDrawer({ name: 'album', params: { id: roamSong!.al.id } })">{{ roamSong.al.name }}</span>
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
|
||||||
<div class="flex items-center gap-1 mb-3 px-4">
|
|
||||||
<button @click="roamTab = 'lyric'"
|
|
||||||
class="px-3 py-1 rounded-full text-sm transition"
|
|
||||||
:class="player.dominantColor
|
|
||||||
? (roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
|
||||||
: (roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
|
||||||
歌词
|
|
||||||
</button>
|
|
||||||
<button @click="roamTab = 'comment'"
|
|
||||||
class="px-3 py-1 rounded-full text-sm transition"
|
|
||||||
:class="player.dominantColor
|
|
||||||
? (roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
|
||||||
: (roamTab === 'comment' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
|
||||||
评论
|
|
||||||
</button>
|
|
||||||
<button v-if="hasTranslation" @click="toggleTranslation"
|
|
||||||
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
|
|
||||||
:class="player.dominantColor
|
|
||||||
? (showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70')
|
|
||||||
: (showTranslation ? 'bg-muted text-content font-medium' : 'text-content-4 hover:text-content-2')">
|
|
||||||
<IconLanguages class="w-3 h-3" />
|
|
||||||
译
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
|
||||||
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
|
||||||
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
|
||||||
<p
|
|
||||||
v-for="(line, idx) in lyrics"
|
|
||||||
:key="idx"
|
|
||||||
:class="getRoamLyricClass(idx)"
|
|
||||||
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer transition-all duration-300"
|
|
||||||
@click="seekToRoamLyric(line.time)"
|
|
||||||
@mouseenter="roamLyricHovering = true"
|
|
||||||
@mouseleave="roamLyricHovering = false"
|
|
||||||
>
|
|
||||||
{{ line.text }}
|
|
||||||
<span v-if="showTranslation && line.translation" class="block text-sm opacity-60 mt-1">{{ line.translation }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
|
||||||
</div>
|
|
||||||
<div v-show="roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
|
||||||
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<PlayerBar v-if="player.currentSong" />
|
<PlayerBar v-if="player.currentSong" />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
@ -243,82 +28,34 @@
|
|||||||
@ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')"
|
@ignore="updater.ignoreVersion(updater.updateInfo.value?.version || '')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Transition name="fade">
|
<CloseModal
|
||||||
<div v-if="showCloseModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCloseModal = false">
|
:visible="showCloseModal"
|
||||||
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
@confirm="handleCloseAction"
|
||||||
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2>
|
@cancel="showCloseModal = false"
|
||||||
<p class="text-sm text-content-2 mb-5">你希望如何处理?</p>
|
/>
|
||||||
<div class="space-y-2.5 mb-4">
|
|
||||||
<button @click="handleCloseAction('minimize')"
|
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
|
||||||
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
|
|
||||||
<IconMaximize2 class="w-[18px] h-[18px] text-accent-text" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-content">最小化到托盘</p>
|
|
||||||
<p class="text-xs text-content-3">程序继续在后台运行</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button @click="handleCloseAction('exit')"
|
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
|
||||||
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
|
|
||||||
<IconX class="w-[18px] h-[18px] text-danger" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-content">退出程序</p>
|
|
||||||
<p class="text-xs text-content-3">完全关闭应用程序</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
|
|
||||||
<input type="checkbox" v-model="closeDontAskAgain" />
|
|
||||||
<span class="text-xs text-content-2">不再询问,记住我的选择</span>
|
|
||||||
</label>
|
|
||||||
<button @click="showCloseModal = false"
|
|
||||||
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useUserStore } from './stores/user';
|
import { useUserStore } from './stores/user';
|
||||||
import { useSettingsStore, type CloseAction } from './stores/settings';
|
import { useSettingsStore, type CloseAction } from './stores/settings';
|
||||||
|
import { usePlayerStore } from './stores/player';
|
||||||
|
import TitleBar from './components/TitleBar.vue';
|
||||||
|
import Sidebar from './components/Sidebar.vue';
|
||||||
|
import RoamDrawer from './components/RoamDrawer.vue';
|
||||||
import PlayerBar from './components/PlayerBar.vue';
|
import PlayerBar from './components/PlayerBar.vue';
|
||||||
import ToastContainer from './components/ToastContainer.vue';
|
import ToastContainer from './components/ToastContainer.vue';
|
||||||
import CommentSection from './components/CommentSection.vue';
|
import CloseModal from './components/CloseModal.vue';
|
||||||
import UpdateDialog from './components/UpdateDialog.vue';
|
import UpdateDialog from './components/UpdateDialog.vue';
|
||||||
import { usePlayerStore } from './stores/player';
|
|
||||||
import { getCoverUrl, extractDominantColor } from './utils/song';
|
|
||||||
import { useOnlineStatus } from './composables/useOnlineStatus';
|
import { useOnlineStatus } from './composables/useOnlineStatus';
|
||||||
import { showToast } from './composables/useToast';
|
import { showToast } from './composables/useToast';
|
||||||
import { useLyric } from './composables/UserLyric';
|
|
||||||
import { useUpdater } from './composables/useUpdater';
|
import { useUpdater } from './composables/useUpdater';
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
|
import { register, unregister } from '@tauri-apps/plugin-global-shortcut';
|
||||||
import IconHome from '~icons/lucide/home';
|
|
||||||
import IconSearch from '~icons/lucide/search';
|
|
||||||
import IconRadio from '~icons/lucide/radio';
|
|
||||||
import IconHeart from '~icons/lucide/heart';
|
|
||||||
import IconSettings from '~icons/lucide/settings';
|
|
||||||
import IconLogIn from '~icons/lucide/log-in';
|
|
||||||
import IconChevronDown from '~icons/lucide/chevron-down';
|
|
||||||
import IconChevronRight from '~icons/lucide/chevron-right';
|
|
||||||
import IconMaximize2 from '~icons/lucide/maximize-2';
|
|
||||||
import IconX from '~icons/lucide/x';
|
|
||||||
import IconClock from '~icons/lucide/clock';
|
|
||||||
import IconMusic from '~icons/lucide/music';
|
|
||||||
import IconLanguages from '~icons/lucide/languages';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const route = useRoute();
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const player = usePlayerStore();
|
const player = usePlayerStore();
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
@ -330,12 +67,7 @@ watch(isOnline, (val, old) => {
|
|||||||
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
|
else if (!val && old) showToast('网络已断开,部分功能不可用', 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdPlaylists = ref<any[]>([]);
|
|
||||||
const subPlaylists = ref<any[]>([]);
|
|
||||||
const showCreatedPlaylists = ref(true);
|
|
||||||
const showSubPlaylists = ref(true);
|
|
||||||
const showCloseModal = ref(false);
|
const showCloseModal = ref(false);
|
||||||
const closeDontAskAgain = ref(false);
|
|
||||||
const windowVisible = ref(true);
|
const windowVisible = ref(true);
|
||||||
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
|
const keepAliveInclude = ref<string[]>(['HomeView', 'DiscoverView', 'FavoriteSongsView', 'DailySongsView', 'LocalMusicView']);
|
||||||
|
|
||||||
@ -343,136 +75,8 @@ watch(() => settings.dataTheme, (val) => {
|
|||||||
document.documentElement.setAttribute('data-theme', val);
|
document.documentElement.setAttribute('data-theme', val);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
|
||||||
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
|
||||||
const roamLyricHovering = ref(false);
|
|
||||||
const roamLyricPadPx = ref(0);
|
|
||||||
const roamSong = computed(() => player.currentSong);
|
|
||||||
const roamCoverError = ref(false);
|
|
||||||
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
|
||||||
const roamCoverUrl = computed(() => {
|
|
||||||
if (!roamSong.value) return '';
|
|
||||||
return getCoverUrl(roamSong.value) || '';
|
|
||||||
});
|
|
||||||
watch(roamCoverUrl, async (url) => {
|
|
||||||
roamCoverError.value = false;
|
|
||||||
if (url) {
|
|
||||||
const color = await extractDominantColor(url);
|
|
||||||
player.dominantColor = color;
|
|
||||||
} else {
|
|
||||||
player.dominantColor = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let roamResizeObserver: ResizeObserver | null = null;
|
|
||||||
|
|
||||||
function updateRoamLyricPad() {
|
|
||||||
if (lyricScrollContainer.value) {
|
|
||||||
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => player.showRoamDrawer, (val) => {
|
|
||||||
if (val) {
|
|
||||||
roamTab.value = player.roamInitialTab;
|
|
||||||
nextTick(() => {
|
|
||||||
updateRoamLyricPad();
|
|
||||||
if (roamResizeObserver) roamResizeObserver.disconnect();
|
|
||||||
if (lyricScrollContainer.value) {
|
|
||||||
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
|
|
||||||
roamResizeObserver.observe(lyricScrollContainer.value);
|
|
||||||
}
|
|
||||||
scrollToRoamActiveLyric();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (roamResizeObserver) {
|
|
||||||
roamResizeObserver.disconnect();
|
|
||||||
roamResizeObserver = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (roamResizeObserver) {
|
|
||||||
roamResizeObserver.disconnect();
|
|
||||||
roamResizeObserver = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(currentLyricIdx, () => {
|
|
||||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
|
||||||
nextTick(() => scrollToRoamActiveLyric());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(showTranslation, () => {
|
|
||||||
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
|
||||||
nextTick(() => scrollToRoamActiveLyric());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function scrollToRoamActiveLyric() {
|
|
||||||
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
|
|
||||||
const active = lyricScrollContainer.value.querySelector('.roam-lyric-active') as HTMLElement | null;
|
|
||||||
if (active) {
|
|
||||||
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRoamLyricClass(idx: number): string {
|
|
||||||
const diff = Math.abs(idx - currentLyricIdx.value);
|
|
||||||
const hasColor = !!player.dominantColor;
|
|
||||||
if (idx === currentLyricIdx.value) {
|
|
||||||
return 'roam-lyric-active text-accent-text font-semibold text-xl';
|
|
||||||
}
|
|
||||||
if (diff === 1) return hasColor ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
|
|
||||||
if (diff === 2) return hasColor ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
|
|
||||||
return hasColor ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
|
|
||||||
}
|
|
||||||
|
|
||||||
function seekToRoamLyric(time: number) {
|
|
||||||
if (time != null && player.duration > 0) {
|
|
||||||
player.seek(time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
|
||||||
player.closeRoamDrawer();
|
|
||||||
router.push(routeLocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openRoamFromSidebar() {
|
|
||||||
if (!userStore.isLoggedIn) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (player.isFmMode) {
|
|
||||||
player.openRoamDrawer();
|
|
||||||
} else {
|
|
||||||
await player.loadFm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPlaylists() {
|
|
||||||
if (!userStore.isLoggedIn || !userStore.user) return;
|
|
||||||
try {
|
|
||||||
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
|
|
||||||
const data = JSON.parse(jsonStr);
|
|
||||||
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
|
|
||||||
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function goPlaylist(id: number) {
|
|
||||||
router.push({ name: 'playlist', params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlaylistActive(id: number): boolean {
|
|
||||||
return route.name === 'playlist' && Number(route.params.id) === id;
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => userStore.isLoggedIn, (val) => {
|
watch(() => userStore.isLoggedIn, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
loadPlaylists();
|
|
||||||
player.loadLikedIds();
|
player.loadLikedIds();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -481,7 +85,6 @@ onMounted(async () => {
|
|||||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
|
||||||
if (userStore.isLoggedIn) {
|
if (userStore.isLoggedIn) {
|
||||||
loadPlaylists();
|
|
||||||
player.loadLikedIds();
|
player.loadLikedIds();
|
||||||
}
|
}
|
||||||
try { await invoke('stop_audio'); } catch { /* 忽略 */ }
|
try { await invoke('stop_audio'); } catch { /* 忽略 */ }
|
||||||
@ -500,7 +103,6 @@ onMounted(async () => {
|
|||||||
|
|
||||||
updater.checkForUpdate(true);
|
updater.checkForUpdate(true);
|
||||||
|
|
||||||
// 恢复保存的输出设备设置
|
|
||||||
if (settings.outputDevice) {
|
if (settings.outputDevice) {
|
||||||
try {
|
try {
|
||||||
await invoke('set_output_device', { device: settings.outputDevice });
|
await invoke('set_output_device', { device: settings.outputDevice });
|
||||||
@ -509,14 +111,9 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentWindow = getCurrentWindow();
|
const currentWindow = getCurrentWindow();
|
||||||
function minimizeWindow() { currentWindow.minimize(); }
|
|
||||||
async function toggleMaximize() {
|
|
||||||
const isMaximized = await currentWindow.isMaximized();
|
|
||||||
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
|
|
||||||
}
|
|
||||||
function closeWindow() {
|
function closeWindow() {
|
||||||
if (settings.closeAction === 'ask') {
|
if (settings.closeAction === 'ask') {
|
||||||
closeDontAskAgain.value = false;
|
|
||||||
showCloseModal.value = true;
|
showCloseModal.value = true;
|
||||||
} else if (settings.closeAction === 'minimize') {
|
} else if (settings.closeAction === 'minimize') {
|
||||||
currentWindow.hide();
|
currentWindow.hide();
|
||||||
@ -524,8 +121,9 @@ function closeWindow() {
|
|||||||
invoke('exit_app');
|
invoke('exit_app');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function handleCloseAction(action: CloseAction) {
|
|
||||||
if (closeDontAskAgain.value) {
|
function handleCloseAction(action: CloseAction, remember: boolean) {
|
||||||
|
if (remember) {
|
||||||
settings.setCloseAction(action);
|
settings.setCloseAction(action);
|
||||||
}
|
}
|
||||||
showCloseModal.value = false;
|
showCloseModal.value = false;
|
||||||
@ -638,21 +236,3 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.drawer-enter-active,
|
|
||||||
.drawer-leave-active { transition: transform 0.3s ease; }
|
|
||||||
.drawer-enter-from,
|
|
||||||
.drawer-leave-to { transform: translateY(100%); }
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active { transition: opacity 0.2s ease; }
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to { opacity: 0; }
|
|
||||||
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
|
||||||
.roam-lyric-line:hover {
|
|
||||||
background: var(--c-subtle);
|
|
||||||
}
|
|
||||||
.roam-lyric-active:hover {
|
|
||||||
background: var(--c-subtle) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
69
src/components/CloseModal.vue
Normal file
69
src/components/CloseModal.vue
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="visible" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="$emit('cancel')">
|
||||||
|
<div class="bg-surface border border-line rounded-2xl shadow-2xl w-80 p-6 select-auto">
|
||||||
|
<h2 class="text-lg font-semibold text-content mb-1">关闭确认</h2>
|
||||||
|
<p class="text-sm text-content-2 mb-5">你希望如何处理?</p>
|
||||||
|
<div class="space-y-2.5 mb-4">
|
||||||
|
<button @click="handleAction('minimize')"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||||
|
<div class="w-9 h-9 rounded-lg bg-accent-dim flex items-center justify-center flex-shrink-0">
|
||||||
|
<IconMaximize2 class="w-[18px] h-[18px] text-accent-text" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-content">最小化到托盘</p>
|
||||||
|
<p class="text-xs text-content-3">程序继续在后台运行</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button @click="handleAction('exit')"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-subtle hover:bg-muted transition text-left">
|
||||||
|
<div class="w-9 h-9 rounded-lg bg-danger-dim flex items-center justify-center flex-shrink-0">
|
||||||
|
<IconX class="w-[18px] h-[18px] text-danger" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-content">退出程序</p>
|
||||||
|
<p class="text-xs text-content-3">完全关闭应用程序</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer mb-4 select-none">
|
||||||
|
<input type="checkbox" v-model="dontAskAgain" />
|
||||||
|
<span class="text-xs text-content-2">不再询问,记住我的选择</span>
|
||||||
|
</label>
|
||||||
|
<button @click="$emit('cancel')"
|
||||||
|
class="w-full py-2 rounded-lg bg-muted hover:bg-emphasis text-sm text-content-2 transition">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { CloseAction } from '../stores/settings';
|
||||||
|
import IconMaximize2 from '~icons/lucide/maximize-2';
|
||||||
|
import IconX from '~icons/lucide/x';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
confirm: [action: CloseAction, remember: boolean];
|
||||||
|
cancel: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dontAskAgain = ref(false);
|
||||||
|
|
||||||
|
function handleAction(action: CloseAction) {
|
||||||
|
emit('confirm', action, dontAskAgain.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to { opacity: 0; }
|
||||||
|
</style>
|
||||||
@ -1,32 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-subtle rounded-xl p-3" ref="scrollContainer">
|
<div class="bg-subtle rounded-xl p-3" ref="scrollContainer">
|
||||||
<div v-if="loading" class="py-8 text-center text-content-2 text-sm">加载中...</div>
|
<div v-if="loading" class="py-8 text-center text-sm" :class="darkMode ? 'text-white/60' : 'text-content-2'">加载中...</div>
|
||||||
|
|
||||||
<div v-else-if="comments.length === 0" class="py-8 text-center">
|
<div v-else-if="comments.length === 0" class="py-8 text-center">
|
||||||
<IconMessageSquare class="mx-auto mb-2 text-content-3 w-10 h-10" />
|
<IconMessageSquare class="mx-auto mb-2 w-10 h-10" :class="darkMode ? 'text-white/40' : 'text-content-3'" />
|
||||||
<p class="text-content-3 text-sm">暂无评论</p>
|
<p class="text-sm" :class="darkMode ? 'text-white/40' : 'text-content-3'">暂无评论</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="comment in comments"
|
v-for="comment in comments"
|
||||||
:key="comment.commentId"
|
:key="comment.commentId"
|
||||||
class="p-3 rounded-xl bg-surface/50"
|
class="p-3 rounded-xl"
|
||||||
|
:class="darkMode ? 'bg-white/8' : 'bg-surface/50'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<img :src="comment.user.avatarUrl" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
<img :src="comment.user.avatarUrl" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium text-content truncate">{{ comment.user.nickname }}</p>
|
<p class="text-sm font-medium truncate" :class="darkMode ? 'text-white/90' : 'text-content'">{{ comment.user.nickname }}</p>
|
||||||
<p class="text-xs text-content-3">{{ new Date(comment.time).toLocaleDateString('zh-CN') }}</p>
|
<p class="text-xs" :class="darkMode ? 'text-white/40' : 'text-content-3'">{{ new Date(comment.time).toLocaleDateString('zh-CN') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-content-2 leading-relaxed">{{ comment.content }}</p>
|
<p class="mt-2 text-sm leading-relaxed" :class="darkMode ? 'text-white/70' : 'text-content-2'">{{ comment.content }}</p>
|
||||||
<div class="mt-2 flex justify-end">
|
<div class="mt-2 flex justify-end">
|
||||||
<button
|
<button
|
||||||
@click="likeComment(comment.commentId)"
|
@click="likeComment(comment.commentId)"
|
||||||
class="flex items-center gap-1 text-content-3 hover:text-danger transition text-xs"
|
class="flex items-center gap-1 transition text-xs"
|
||||||
|
:class="comment.liked ? 'text-danger' : (darkMode ? 'text-white/40 hover:text-danger' : 'text-content-3 hover:text-danger')"
|
||||||
>
|
>
|
||||||
<IconHeart style="font-size: 14px" />
|
<IconHeart style="font-size: 14px" :class="comment.liked ? '[&>path]:fill-current [&>path]:stroke-0' : ''" />
|
||||||
<span>{{ comment.likedCount }}</span>
|
<span>{{ comment.likedCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -34,7 +36,7 @@
|
|||||||
<div ref="sentinel" class="h-1"></div>
|
<div ref="sentinel" class="h-1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingMore" class="py-4 text-center text-content-3 text-sm">加载中...</div>
|
<div v-if="loadingMore" class="py-4 text-center text-sm" :class="darkMode ? 'text-white/40' : 'text-content-3'">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ import IconHeart from '~icons/lucide/heart'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
type: number
|
type: number
|
||||||
id: number
|
id: number
|
||||||
|
darkMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const comments = ref<any[]>([])
|
const comments = ref<any[]>([])
|
||||||
@ -104,22 +107,29 @@ function loadMore() {
|
|||||||
fetchComments()
|
fetchComments()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const likingSet = ref(new Set<number>())
|
||||||
|
|
||||||
async function likeComment(cid: number) {
|
async function likeComment(cid: number) {
|
||||||
|
if (likingSet.value.has(cid)) return
|
||||||
|
const target = comments.value.find(c => c.commentId === cid)
|
||||||
|
if (!target) return
|
||||||
|
const liked = !!target.liked
|
||||||
|
likingSet.value.add(cid)
|
||||||
try {
|
try {
|
||||||
await invoke('comment_like', {
|
await invoke('comment_like', {
|
||||||
query: {
|
query: {
|
||||||
t: 1,
|
t: liked ? 0 : 1,
|
||||||
type: props.type,
|
type: props.type,
|
||||||
id: props.id,
|
id: props.id,
|
||||||
cid
|
cid
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const target = comments.value.find(c => c.commentId === cid)
|
target.liked = !liked
|
||||||
if (target) {
|
target.likedCount += liked ? -1 : 1
|
||||||
target.likedCount++
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
likingSet.value.delete(cid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
245
src/components/RoamDrawer.vue
Normal file
245
src/components/RoamDrawer.vue
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="drawer">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl"
|
||||||
|
:class="!player.dominantColor && 'bg-surface/95'"
|
||||||
|
:style="player.dominantColor ? { backgroundColor: player.dominantColor } : {}"
|
||||||
|
>
|
||||||
|
<div v-if="player.dominantColor" class="absolute inset-0 bg-black/60 pointer-events-none"></div>
|
||||||
|
<TitleBar :dark-mode="!!player.dominantColor" @close="player.closeRoamDrawer()">
|
||||||
|
<template #left>
|
||||||
|
<button @click="player.closeRoamDrawer()" :class="player.dominantColor ? 'text-white/60 hover:text-white' : 'text-content-2 hover:text-content'" class="transition">
|
||||||
|
<IconChevronDown class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</TitleBar>
|
||||||
|
<div class="flex-1 min-h-0 flex px-8 pb-8 gap-0 relative z-10">
|
||||||
|
<div class="w-2/5 flex flex-col items-center justify-center flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="roamCoverUrl && !roamCoverError"
|
||||||
|
:src="roamCoverUrl"
|
||||||
|
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
|
||||||
|
@error="roamCoverError = true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-72 h-72 rounded-3xl flex items-center justify-center shadow-2xl mb-4"
|
||||||
|
:class="player.dominantColor ? 'bg-white/10' : 'bg-muted'"
|
||||||
|
>
|
||||||
|
<IconMusic class="w-16 h-16" :class="player.dominantColor ? 'text-white/30' : 'text-content-4'" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-center" :class="player.dominantColor ? 'text-white' : 'text-content'">{{ roamSong?.name }}</h1>
|
||||||
|
<p class="mt-2 text-center" :class="player.dominantColor ? 'text-white/70' : 'text-content-2'">
|
||||||
|
<template v-for="(a, i) in roamSong?.ar || []" :key="a.id || i">
|
||||||
|
<span v-if="i > 0" :class="player.dominantColor ? 'text-white/40' : 'text-content-3'">/</span>
|
||||||
|
<span class="hover:text-accent-text cursor-pointer transition" @click="a.id && navigateFromDrawer({ name: 'artist', params: { id: a.id } })">{{ a.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="roamSong?.al?.name">
|
||||||
|
<span :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="mx-1">·</span>
|
||||||
|
<span class="hover:text-accent-text cursor-pointer transition" @click="roamSong!.al.id && navigateFromDrawer({ name: 'album', params: { id: roamSong!.al.id } })">{{ roamSong.al.name }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-3/5 relative min-h-0 overflow-hidden flex flex-col">
|
||||||
|
<div class="flex items-center gap-1 mb-3 px-4">
|
||||||
|
<button @click="roamTab = 'lyric'"
|
||||||
|
class="px-3 py-1 rounded-full text-sm transition"
|
||||||
|
:class="player.dominantColor
|
||||||
|
? (roamTab === 'lyric' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||||
|
: (roamTab === 'lyric' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||||
|
歌词
|
||||||
|
</button>
|
||||||
|
<button @click="roamTab = 'comment'"
|
||||||
|
class="px-3 py-1 rounded-full text-sm transition"
|
||||||
|
:class="player.dominantColor
|
||||||
|
? (roamTab === 'comment' ? 'bg-white/15 text-white font-medium' : 'text-white/50 hover:text-white/80')
|
||||||
|
: (roamTab === 'comment' ? 'bg-muted text-content font-medium' : 'text-content-3 hover:text-content')">
|
||||||
|
评论
|
||||||
|
</button>
|
||||||
|
<button v-if="hasTranslation" @click="toggleTranslation"
|
||||||
|
class="ml-auto px-2.5 py-1 rounded-full text-xs transition flex items-center gap-1"
|
||||||
|
:class="player.dominantColor
|
||||||
|
? (showTranslation ? 'bg-white/15 text-white font-medium' : 'text-white/40 hover:text-white/70')
|
||||||
|
: (showTranslation ? 'bg-muted text-content font-medium' : 'text-content-4 hover:text-content-2')">
|
||||||
|
<IconLanguages class="w-3 h-3" />
|
||||||
|
译
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-show="roamTab === 'lyric'" ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
|
||||||
|
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center"
|
||||||
|
:style="{ paddingTop: roamLyricPadPx + 'px', paddingBottom: roamLyricPadPx + 'px' }">
|
||||||
|
<p
|
||||||
|
v-for="(line, idx) in lyrics"
|
||||||
|
:key="idx"
|
||||||
|
:class="getRoamLyricClass(idx)"
|
||||||
|
class="roam-lyric-line px-4 py-3 rounded-lg cursor-pointer whitespace-nowrap transition-[font-size] duration-300 ease-out"
|
||||||
|
@click="seekToRoamLyric(line.time)"
|
||||||
|
@mouseenter="roamLyricHovering = true"
|
||||||
|
@mouseleave="roamLyricHovering = false"
|
||||||
|
>
|
||||||
|
{{ line.text }}
|
||||||
|
<span v-if="showTranslation && line.translation" class="block text-sm mt-1" :class="getTranslationClass(idx)">{{ line.translation }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else :class="player.dominantColor ? 'text-white/40' : 'text-content-3'" class="text-center mt-8">暂无歌词</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="roamTab === 'comment'" class="flex-1 min-h-0 overflow-y-auto px-4 pb-4">
|
||||||
|
<CommentSection v-if="roamSong" :type="0" :id="player.commentSongId || roamSong.id" :key="player.commentSongId || roamSong.id" :dark-mode="!!player.dominantColor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { getCoverUrl, extractDominantColor } from '../utils/song';
|
||||||
|
import { useLyric } from '../composables/UserLyric';
|
||||||
|
import TitleBar from './TitleBar.vue';
|
||||||
|
import CommentSection from './CommentSection.vue';
|
||||||
|
import IconChevronDown from '~icons/lucide/chevron-down';
|
||||||
|
import IconMusic from '~icons/lucide/music';
|
||||||
|
import IconLanguages from '~icons/lucide/languages';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const player = usePlayerStore();
|
||||||
|
|
||||||
|
const { lyrics, currentLyricIdx, hasTranslation, showTranslation, toggleTranslation } = useLyric();
|
||||||
|
const lyricScrollContainer = ref<HTMLElement | null>(null);
|
||||||
|
const roamLyricHovering = ref(false);
|
||||||
|
const roamLyricPadPx = ref(0);
|
||||||
|
const roamSong = computed(() => player.currentSong);
|
||||||
|
const roamCoverError = ref(false);
|
||||||
|
const roamTab = ref<'lyric' | 'comment'>('lyric');
|
||||||
|
const roamCoverUrl = computed(() => {
|
||||||
|
if (!roamSong.value) return '';
|
||||||
|
return getCoverUrl(roamSong.value) || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(roamCoverUrl, async (url) => {
|
||||||
|
roamCoverError.value = false;
|
||||||
|
if (url) {
|
||||||
|
const color = await extractDominantColor(url);
|
||||||
|
player.dominantColor = color;
|
||||||
|
} else {
|
||||||
|
player.dominantColor = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let roamResizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
function updateRoamLyricPad() {
|
||||||
|
if (lyricScrollContainer.value) {
|
||||||
|
roamLyricPadPx.value = Math.floor(lyricScrollContainer.value.clientHeight / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => player.showRoamDrawer, (val) => {
|
||||||
|
if (val) {
|
||||||
|
roamTab.value = player.roamInitialTab;
|
||||||
|
nextTick(() => {
|
||||||
|
updateRoamLyricPad();
|
||||||
|
if (roamResizeObserver) roamResizeObserver.disconnect();
|
||||||
|
if (lyricScrollContainer.value) {
|
||||||
|
roamResizeObserver = new ResizeObserver(() => updateRoamLyricPad());
|
||||||
|
roamResizeObserver.observe(lyricScrollContainer.value);
|
||||||
|
}
|
||||||
|
scrollToRoamActiveLyric();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (roamResizeObserver) {
|
||||||
|
roamResizeObserver.disconnect();
|
||||||
|
roamResizeObserver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (roamResizeObserver) {
|
||||||
|
roamResizeObserver.disconnect();
|
||||||
|
roamResizeObserver = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(currentLyricIdx, () => {
|
||||||
|
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||||
|
nextTick(() => scrollToRoamActiveLyric());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(showTranslation, () => {
|
||||||
|
if (player.showRoamDrawer && !roamLyricHovering.value) {
|
||||||
|
nextTick(() => scrollToRoamActiveLyric());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function scrollToRoamActiveLyric() {
|
||||||
|
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
|
||||||
|
const container = lyricScrollContainer.value;
|
||||||
|
const active = container.querySelector('.roam-lyric-active') as HTMLElement | null;
|
||||||
|
if (!active) return;
|
||||||
|
const target = active.offsetTop - container.clientHeight / 2 + active.clientHeight / 2;
|
||||||
|
const start = container.scrollTop;
|
||||||
|
const distance = target - start;
|
||||||
|
if (Math.abs(distance) < 1) return;
|
||||||
|
const duration = 400;
|
||||||
|
const startTime = performance.now();
|
||||||
|
function animate(now: number) {
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const ease = 1 - Math.pow(1 - progress, 3);
|
||||||
|
container.scrollTop = start + distance * ease;
|
||||||
|
if (progress < 1) requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTranslationClass(idx: number): string {
|
||||||
|
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||||
|
const hasColor = !!player.dominantColor;
|
||||||
|
if (idx === currentLyricIdx.value) return hasColor ? 'text-[var(--c-accent)]' : 'text-accent-text';
|
||||||
|
if (diff === 1) return hasColor ? 'text-white/70' : 'text-content/70';
|
||||||
|
if (diff === 2) return hasColor ? 'text-white/50' : 'text-content-2/50';
|
||||||
|
return hasColor ? 'text-white/35' : 'text-content-3/35';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoamLyricClass(idx: number): string {
|
||||||
|
const diff = Math.abs(idx - currentLyricIdx.value);
|
||||||
|
const hasColor = !!player.dominantColor;
|
||||||
|
if (idx === currentLyricIdx.value) {
|
||||||
|
return hasColor
|
||||||
|
? 'roam-lyric-active font-bold text-xl text-[var(--c-accent)]'
|
||||||
|
: 'roam-lyric-active text-accent-text font-semibold text-xl';
|
||||||
|
}
|
||||||
|
if (diff === 1) return hasColor ? 'text-white/70 text-lg' : 'text-content/70 text-lg';
|
||||||
|
if (diff === 2) return hasColor ? 'text-white/50 text-[1rem]' : 'text-content-2/50 text-[1rem]';
|
||||||
|
return hasColor ? 'text-white/35 text-[1rem]' : 'text-content-3/35 text-[1rem]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekToRoamLyric(time: number) {
|
||||||
|
if (time != null && player.duration > 0) {
|
||||||
|
player.seek(time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateFromDrawer(routeLocation: { name: string; params: any }) {
|
||||||
|
player.closeRoamDrawer();
|
||||||
|
router.push(routeLocation);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drawer-enter-active,
|
||||||
|
.drawer-leave-active { transition: transform 0.3s ease; }
|
||||||
|
.drawer-enter-from,
|
||||||
|
.drawer-leave-to { transform: translateY(100%); }
|
||||||
|
.custom-scroll::-webkit-scrollbar { width: 0; display: none; }
|
||||||
|
</style>
|
||||||
184
src/components/Sidebar.vue
Normal file
184
src/components/Sidebar.vue
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="w-56 flex-shrink-0 flex flex-col bg-surface/80 backdrop-blur">
|
||||||
|
<div class="flex-1 p-4 overflow-y-auto min-h-0">
|
||||||
|
<div class="flex flex-col min-h-full">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<router-link to="/"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
|
active-class="!text-content !bg-muted">
|
||||||
|
<IconHome class="w-[18px] h-[18px]" />
|
||||||
|
推荐
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/discover"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
|
active-class="!text-content !bg-muted">
|
||||||
|
<IconSearch class="w-[18px] h-[18px]" />
|
||||||
|
发现
|
||||||
|
</router-link>
|
||||||
|
<button
|
||||||
|
@click="openRoamFromSidebar"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle w-full text-left"
|
||||||
|
>
|
||||||
|
<IconRadio class="w-[18px] h-[18px]" />
|
||||||
|
漫游
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 mb-1 pt-2">
|
||||||
|
<p class="text-xs text-content-3 px-3 mb-1">我的</p>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<router-link to="/favorites"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
|
active-class="!text-content !bg-muted">
|
||||||
|
<IconHeart class="w-[18px] h-[18px]" />
|
||||||
|
我喜欢的音乐
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/recent"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
|
active-class="!text-content !bg-muted">
|
||||||
|
<IconClock class="w-[18px] h-[18px]" />
|
||||||
|
最近播放
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/local-music"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
|
active-class="!text-content !bg-muted">
|
||||||
|
<IconMusic class="w-[18px] h-[18px]" />
|
||||||
|
本地音乐
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||||
|
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||||
|
@click="showCreatedPlaylists = !showCreatedPlaylists">
|
||||||
|
<p class="text-xs text-content-3">我的歌单</p>
|
||||||
|
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showCreatedPlaylists }" />
|
||||||
|
</div>
|
||||||
|
<div v-show="showCreatedPlaylists" class="space-y-0.5">
|
||||||
|
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||||
|
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||||
|
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||||
|
<span class="text-sm truncate"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
|
||||||
|
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer group"
|
||||||
|
@click="showSubPlaylists = !showSubPlaylists">
|
||||||
|
<p class="text-xs text-content-3">收藏的歌单</p>
|
||||||
|
<IconChevronRight class="w-3 h-3 text-content-3 transition-transform" :class="{ 'rotate-90': showSubPlaylists }" />
|
||||||
|
</div>
|
||||||
|
<div v-show="showSubPlaylists" class="space-y-0.5">
|
||||||
|
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
|
||||||
|
class="flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-200"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'bg-muted' : 'hover:bg-subtle'">
|
||||||
|
<img :src="pl.coverImgUrl + '?param=80y80'" class="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||||
|
<span class="text-sm truncate"
|
||||||
|
:class="isPlaylistActive(pl.id) ? 'text-content font-medium' : 'text-content-2'">{{ pl.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto pt-4" :class="player.currentSong ? 'pb-20' : 'pb-2'">
|
||||||
|
<div class="px-1">
|
||||||
|
<router-link to="/settings"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-content-2 hover:text-content hover:bg-subtle"
|
||||||
|
active-class="!text-content !bg-muted">
|
||||||
|
<IconSettings class="w-[18px] h-[18px]" />
|
||||||
|
设置
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-if="!userStore.isLoggedIn" class="mt-3 p-3 rounded-xl bg-subtle/60">
|
||||||
|
<p class="text-xs text-content-3 mb-2">强烈建议登录以提升体验</p>
|
||||||
|
<router-link to="/login"
|
||||||
|
class="flex items-center justify-center gap-2 w-full px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover transition text-sm font-medium text-white">
|
||||||
|
<IconLogIn class="w-4 h-4" />
|
||||||
|
立即登录
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-3 px-2 mt-3">
|
||||||
|
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-accent/50" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
|
||||||
|
<button @click="userStore.logout(); player.stop()"
|
||||||
|
class="text-xs text-content-3 hover:text-danger transition">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useUserStore } from '../stores/user';
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import IconHome from '~icons/lucide/home';
|
||||||
|
import IconSearch from '~icons/lucide/search';
|
||||||
|
import IconRadio from '~icons/lucide/radio';
|
||||||
|
import IconHeart from '~icons/lucide/heart';
|
||||||
|
import IconSettings from '~icons/lucide/settings';
|
||||||
|
import IconLogIn from '~icons/lucide/log-in';
|
||||||
|
import IconChevronRight from '~icons/lucide/chevron-right';
|
||||||
|
import IconClock from '~icons/lucide/clock';
|
||||||
|
import IconMusic from '~icons/lucide/music';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const player = usePlayerStore();
|
||||||
|
|
||||||
|
const createdPlaylists = ref<any[]>([]);
|
||||||
|
const subPlaylists = ref<any[]>([]);
|
||||||
|
const showCreatedPlaylists = ref(true);
|
||||||
|
const showSubPlaylists = ref(true);
|
||||||
|
|
||||||
|
async function loadPlaylists() {
|
||||||
|
if (!userStore.isLoggedIn || !userStore.user) return;
|
||||||
|
try {
|
||||||
|
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed).slice(1);
|
||||||
|
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPlaylist(id: number) {
|
||||||
|
router.push({ name: 'playlist', params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaylistActive(id: number): boolean {
|
||||||
|
return route.name === 'playlist' && Number(route.params.id) === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRoamFromSidebar() {
|
||||||
|
if (!userStore.isLoggedIn) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (player.isFmMode) {
|
||||||
|
player.openRoamDrawer();
|
||||||
|
} else {
|
||||||
|
await player.loadFm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => userStore.isLoggedIn, (val) => {
|
||||||
|
if (val) {
|
||||||
|
loadPlaylists();
|
||||||
|
player.loadLikedIds();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
loadPlaylists();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
43
src/components/TitleBar.vue
Normal file
43
src/components/TitleBar.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
class="h-10 flex items-center justify-between px-4 flex-shrink-0 select-none relative z-10"
|
||||||
|
:class="darkMode ? '' : 'bg-surface/90 backdrop-blur'"
|
||||||
|
>
|
||||||
|
<slot name="left">
|
||||||
|
<span v-if="!darkMode" class="text-xs text-content-3 font-medium ml-2">Nekosonic Music</span>
|
||||||
|
</slot>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
|
||||||
|
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
|
||||||
|
<button @click="$emit('close')" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
darkMode?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const currentWindow = getCurrentWindow();
|
||||||
|
|
||||||
|
function minimizeWindow() {
|
||||||
|
currentWindow.minimize();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMaximize() {
|
||||||
|
const isMaximized = await currentWindow.isMaximized();
|
||||||
|
if (isMaximized) {
|
||||||
|
currentWindow.unmaximize();
|
||||||
|
} else {
|
||||||
|
currentWindow.maximize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -20,14 +20,6 @@ function loadRecentLocal(): Song[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLikedIdsFromStorage(): Set<number> {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('liked_ids');
|
|
||||||
if (raw) return new Set(JSON.parse(raw));
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePlayerStore = defineStore('player', () => {
|
export const usePlayerStore = defineStore('player', () => {
|
||||||
const currentSong = ref<Song | null>(null);
|
const currentSong = ref<Song | null>(null);
|
||||||
const playing = ref(false);
|
const playing = ref(false);
|
||||||
@ -53,7 +45,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const recentLocal = ref<Song[]>(loadRecentLocal());
|
const recentLocal = ref<Song[]>(loadRecentLocal());
|
||||||
const MAX_RECENT = 200;
|
const MAX_RECENT = 200;
|
||||||
|
|
||||||
const likedIds = ref<Set<number>>(loadLikedIdsFromStorage());
|
const likedIds = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
function emitPlaybackState() {
|
function emitPlaybackState() {
|
||||||
const song = currentSong.value;
|
const song = currentSong.value;
|
||||||
@ -82,7 +74,9 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const data = JSON.parse(json);
|
const data = JSON.parse(json);
|
||||||
const ids: number[] = data.ids || data.data?.ids || [];
|
const ids: number[] = data.ids || data.data?.ids || [];
|
||||||
likedIds.value = new Set(ids);
|
likedIds.value = new Set(ids);
|
||||||
} catch { /* 忽略 */ }
|
} catch (e) {
|
||||||
|
console.error('加载喜欢列表失败', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleLike(songId: number) {
|
async function toggleLike(songId: number) {
|
||||||
@ -111,10 +105,6 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
localStorage.setItem('recent_local', JSON.stringify(val));
|
localStorage.setItem('recent_local', JSON.stringify(val));
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
watch(likedIds, (val) => {
|
|
||||||
localStorage.setItem('liked_ids', JSON.stringify([...val]));
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
const isFmMode = ref(false);
|
const isFmMode = ref(false);
|
||||||
const fmQueue: Song[] = [];
|
const fmQueue: Song[] = [];
|
||||||
let fmNextCallback: (() => void) | null = null;
|
let fmNextCallback: (() => void) | null = null;
|
||||||
@ -171,6 +161,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
await invoke('fm_trash', { query: { id: songId, time: 25 } });
|
await invoke('fm_trash', { query: { id: songId, time: 25 } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('fm_trash 失败', e);
|
console.error('fm_trash 失败', e);
|
||||||
|
showToast('减少推荐失败', 'error');
|
||||||
}
|
}
|
||||||
await nextFm();
|
await nextFm();
|
||||||
}
|
}
|
||||||
@ -254,6 +245,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('FM播放失败', e);
|
console.error('FM播放失败', e);
|
||||||
playing.value = false;
|
playing.value = false;
|
||||||
|
showToast('FM 播放失败', 'error');
|
||||||
if (fmNextCallback) {
|
if (fmNextCallback) {
|
||||||
fmNextCallback();
|
fmNextCallback();
|
||||||
} else {
|
} else {
|
||||||
@ -373,6 +365,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('播放失败', e);
|
console.error('播放失败', e);
|
||||||
playing.value = false;
|
playing.value = false;
|
||||||
|
showToast('播放失败,请稍后重试', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -576,6 +569,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
showToast('FM 加载失败', 'error');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -600,6 +594,7 @@ async function loadFm() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('FM加载失败', e);
|
console.error('FM加载失败', e);
|
||||||
|
showToast('FM 加载失败', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user