音乐模块调整

This commit is contained in:
zhongluofeng
2026-09-15 17:17:16 +08:00
parent 4f574cb5fa
commit 8f853f7ef3
31 changed files with 3819 additions and 936 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Music2, Pause, Play, Plus, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Trash2, X } from '@lucide/vue'
import { ListMusic, Music2, Pause, Play, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Trash2, X } from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import ScrubBar from '@/components/common/ScrubBar.vue'
import SegmentedNav from '@/components/common/SegmentedNav.vue'
@@ -210,10 +210,11 @@ function toggleAt(index: number) {
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:class="{ 'text-foreground': store.nowPlayingTab === 'queue' }"
aria-label="播放队列"
:aria-label="`播放队列(${store.queue.length}`"
:title="`播放队列(${store.queue.length}`"
@click="store.nowPlayingTab = 'queue'"
>
<Plus class="size-4" />
<ListMusic class="size-4" />
</button>
</div>
</div>
+166 -26
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import {
Disc3,
@@ -17,13 +17,116 @@ import {
VolumeX
} from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { createLogger } from '@/lib/logger'
import ScrubBar from '@/components/common/ScrubBar.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
const store = useFeiniuStore()
const logger = createLogger('music-widget')
const open = ref(false)
/** 悬停多久后打开播放控制窗 */
const HOVER_OPEN_DELAY = 300
/** 弹层是否由悬停打开:据此决定鼠标移出时是否自动关闭(点击打开则不自动关) */
let hoverOpened = false
let hoverTimer: ReturnType<typeof setTimeout> | null = null
let closeTimer: ReturnType<typeof setTimeout> | null = null
/** 整库起播进行中(拉列表可能耗时,兼作重复点击保护) */
const starting = ref(false)
function cancelHoverTimer() {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
}
function cancelCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
/** 悬停 0.3s 打开控制窗(不依赖 Popover 默认的点击展开) */
function onTriggerEnter() {
cancelCloseTimer()
cancelHoverTimer()
hoverTimer = setTimeout(() => {
hoverTimer = null
if (open.value) return
hoverOpened = true
open.value = true
}, HOVER_OPEN_DELAY)
}
function onTriggerLeave() {
cancelHoverTimer()
// 悬停打开的:给鼠标从按钮移到弹层留一点余量
if (!open.value || !hoverOpened) return
cancelCloseTimer()
closeTimer = setTimeout(() => (open.value = false), 250)
}
function onContentEnter() {
cancelCloseTimer()
}
function onContentLeave() {
if (!hoverOpened) return
cancelCloseTimer()
closeTimer = setTimeout(() => (open.value = false), 180)
}
watch(open, (v) => {
if (v) return
cancelCloseTimer()
cancelHoverTimer()
hoverOpened = false
})
/** 点击标题:打开播放控制窗(点击打开的弹层不随鼠标移出自动关闭) */
function onTitleClick() {
cancelHoverTimer()
cancelCloseTimer()
hoverOpened = false
open.value = true
}
/**
* 点击**音乐图标**:播放 / 暂停。
* - 有播放上下文(队列非空)→ 播放 / 暂停;
* - 完全空载 → 依次尝试飞牛曲库**全部列表** → 本地曲库;两者皆空则不做任何反应。
*/
async function onIconClick() {
if (store.nowPlaying || store.queue.length) {
store.toggle()
return
}
if (starting.value) return
starting.value = true
try {
try {
await store.loadAllTracks()
} catch (e) {
// 未登录 / 网络不通:按「飞牛曲库为空」处理,继续尝试本地
logger.error(`加载飞牛曲库失败: ${e}`)
}
if (store.tracks.length) {
store.playQueue(store.tracks, 0)
return
}
try {
await store.scanLocal()
} catch (e) {
logger.error(`扫描本地曲库失败: ${e}`)
}
if (store.localTracks.length) store.playQueue(store.localTracks, 0)
} finally {
starting.value = false
}
}
const coverUrl = computed(() => {
const t = store.nowPlaying
if (!t) return ''
@@ -84,44 +187,81 @@ watch(
store.clearPlayError()
}
)
// 应用启动即初始化音乐 store(幂等):恢复播放列表/播放进度并预载上次在播的曲目。
// 这样标题栏控件不必等用户先进「音乐库」页,也能显示并续播上次的歌。
onMounted(() => {
store.init().catch((e) => logger.error(`音乐初始化失败: ${e}`))
})
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<!-- 音乐栏唱片图标 + 歌名未播放过时只显示图标 -->
<button
type="button"
class="mr-2 flex h-7 max-w-[210px] items-center gap-2 rounded-md px-1.5 text-left transition-colors hover:bg-secondary/60"
:title="hasTrack ? `${title}${subtitle ? ' · ' + subtitle : ''}` : '未在播放'"
:aria-label="hasTrack ? `音乐控制${title}` : '音乐控制'"
<PopoverAnchor as-child>
<!-- 音乐栏图标 = 播放/暂停悬停有遮罩标题 = 打开控制窗
悬停 0.3s 也会打开控制窗容器本身不响应点击避免与图标语义冲突 -->
<div
class="mr-2 flex h-7 max-w-[210px] items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-secondary/60"
@mouseenter="onTriggerEnter"
@mouseleave="onTriggerLeave"
@mousedown.stop
>
<span
class="disc flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full"
:class="{ 'is-playing': store.playing }"
<!-- 唱片图标点击开始/暂停 -->
<button
type="button"
class="group/icon relative flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full"
:aria-label="store.playing ? '暂停' : '播放'"
@click.stop="onIconClick"
>
<!-- 旋转动画只作用于这一层否则遮罩图标会被带着一起转 -->
<span
class="disc flex size-5 items-center justify-center rounded-full ring-1 ring-border/50"
:class="{ 'is-playing': store.playing && !starting }"
>
<span
v-if="starting"
class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent text-muted-foreground"
/>
<img
v-else-if="coverUrl && !coverFailed"
:src="coverUrl"
class="size-full rounded-full object-cover"
alt=""
referrerpolicy="no-referrer"
@error="coverFailed = true"
/>
<Disc3 v-else class="size-4 text-muted-foreground" />
</span>
<!-- 播放态遮罩悬停图标时浮现当前可执行的操作 -->
<span
v-if="!starting"
class="absolute inset-0 hidden items-center justify-center bg-foreground/50 text-primary-foreground group-hover/icon:flex"
>
<Pause v-if="store.playing" class="size-3" />
<Play v-else class="size-3 translate-x-px" />
</span>
</button>
<!-- 标题点击打开播放控制窗 -->
<button
v-if="hasTrack"
type="button"
class="min-w-0 flex-1 cursor-pointer truncate text-left text-xs text-foreground/90"
:aria-label="`打开播放控制:${title}`"
@click.stop="onTitleClick"
>
<img
v-if="coverUrl && !coverFailed"
:src="coverUrl"
class="size-full object-cover"
alt=""
referrerpolicy="no-referrer"
@error="coverFailed = true"
/>
<Disc3 v-else class="size-4 text-muted-foreground" />
</span>
<span v-if="hasTrack" class="min-w-0 flex-1 truncate text-xs text-foreground/90">
{{ title }}
</span>
</button>
</PopoverTrigger>
</button>
</div>
</PopoverAnchor>
<!-- 方形播放控制窗 -->
<PopoverContent
align="end"
:side-offset="8"
class="w-[300px] p-3"
@mouseenter="onContentEnter"
@mouseleave="onContentLeave"
@mousedown.stop
>
<div class="flex flex-col items-center gap-3">
+30 -2
View File
@@ -254,7 +254,18 @@ export const commands = {
musicStopBridge: () => __TAURI_INVOKE<null>("music_stop_bridge"),
/** 读取音乐模块设置 */
musicGetSettings: () => __TAURI_INVOKE<MusicSettings>("music_get_settings"),
/** 保存音乐模块设置(立即生效) */
/**
* 保存音乐模块设置(立即生效)
*
* 飞牛音乐**连接相关字段一律以磁盘为准**,不接受前端传值:
* 连接列表 / 激活连接 / 旧版单连接字段只由 `feiniu_save_connection`、
* `feiniu_activate_connection`、`feiniu_delete_connection`、`feiniu_login`
* 等专用命令维护。
*
* 原因:前端 `musicStore` 只在 init 时读一次整份设置并长期复用快照,
* 若允许它整份回写,删除连接后任意一次设置保存(哪怕是切页触发的)
* 都会把已删除的连接从旧快照里写回来。
*/
musicSaveSettings: (settings: MusicSettings) => __TAURI_INVOKE<null>("music_save_settings", { settings }),
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
@@ -567,6 +578,11 @@ export type FeiniuConnection = {
insecure: boolean,
/** fnconnect 连接的 fnId(如 fnos.net/<id> 或裸 id */
fnId?: string,
/**
* 是否经由 FnConnect 中继链路(`<fnId>.fnos.net`)。
* 中继要求所有请求携带 `Cookie: mode=relay`,否则网关 302 回登录页。
*/
relay?: boolean,
};
export type FileEntry = {
@@ -623,6 +639,13 @@ export type MusicEnvStatus = {
musicdlInstalled: boolean,
/** musicdl 版本 */
musicdlVersion: string | null,
/** 本应用锁定的 musicdl 版本(`MUSICDL_VERSION`):是否过期、更新到哪个版本都以它为准 */
musicdlExpected: string,
/**
* 已装 musicdl 是否与锁定版本不一致。
* 未安装时恒为 false(那是「安装」引导的事,不是「更新」)。
*/
musicdlOutdated: boolean,
/** FFmpeg 是否可用(部分音源需要,非必需) */
ffmpeg: string | null,
/** 桥接进程是否在运行 */
@@ -651,6 +674,11 @@ export type MusicSettings = {
selectQualityOnDownload: boolean,
/** 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K" */
defaultDownloadQuality: string,
/**
* QQ 音乐 Cookie(可选):用于解析需要登录的歌单(含自己的隐私歌单)与 VIP 音质。
* 传给 musicdl 的 default_search/parse/download_cookies;空=游客身份。
*/
qqCookie?: string,
/** 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置) */
feiniuBaseUrl?: string,
/** 飞牛音乐登录 token(登录成功后保存) */
@@ -661,7 +689,7 @@ export type MusicSettings = {
feiniuDeviceId?: string,
/** 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空) */
feiniuAccessCode?: string,
/** 飞牛音乐连接列表(多连接:本地 / frp / 预留 fnconnect */
/** 飞牛音乐连接列表(多连接:局域网 / FnConnect */
feiniuConnections?: FeiniuConnection[],
/** 当前激活连接的 id */
feiniuActiveId?: string,
+9 -5
View File
@@ -29,16 +29,18 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
* ```
*
* ## 工作原理
* 1. onMounted 时注册标签到 moduleTabsStoreTitleBar 据此渲染浮动切换器
* 1. onMounted 时注册标签到 moduleTabsStore(**并复用该模块上次停留的 tab**),
* TitleBar 据此渲染浮动切换器
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
* 4. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
* 5. onUnmounted 时清理 observer 并注销标签
* 5. onUnmounted 时清理 observer 并把当前 tab 记到 store(按模块)后注销标签
*
* ## 约束
* - TitleBar 高度固定为 40px (h-10)composable 内部已用 44px 裁剪(含缓冲)
* - 一个模块同一时间只能注册一组标签(store 是单例)
* - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用)
* - tab 记忆只保内存、不落盘:tab 集合可能随版本变化,跨重启恢复旧值风险更大
*/
export function useModuleTabs(
moduleId: string,
@@ -97,10 +99,12 @@ export function useModuleTabs(
})
onMounted(async () => {
tabsStore.registerTabs(tabs, activeTab.value)
// 注册时带上模块 id:store 据此复用「上次停留的 tab」(模块卸载时保存)
const restored = tabsStore.registerTabs(moduleId, tabs, activeTab.value)
if (restored !== activeTab.value) activeTab.value = restored
await nextTick()
setupObserver()
// 搜索导航跳转:模块刚挂载,消费待跳转 tab
// 搜索导航跳转:模块刚挂载,消费待跳转 tab(优先级高于上次停留)
applyPendingTab()
})
@@ -109,7 +113,7 @@ export function useModuleTabs(
observer.disconnect()
observer = null
}
tabsStore.unregisterTabs()
tabsStore.unregisterTabs(moduleId)
})
return tabsListRef
File diff suppressed because it is too large Load Diff
+254 -49
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import {
Cloud,
@@ -16,7 +16,7 @@ import {
Search,
Trash2
} from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { useFeiniuStore, type PlayableItem } from '@/stores/feiniuStore'
import TrackItem from './TrackItem.vue'
import PlaylistEditorDialog from './PlaylistEditorDialog.vue'
import { Button } from '@/components/ui/button'
@@ -46,21 +46,96 @@ const sourceNav = computed(() => [
{ value: 'playlists' as const, label: '我的歌单', icon: ListMusic, count: store.playlists.length }
])
// ===== 飞牛曲库搜索(直接绑定 store.searchKeyword,此前绑定的是孤立局部变量,导致搜索完全无效)=====
// ===== 曲库搜索:飞牛 track/list 接口**不支持**关键字过滤(实测忽略该参数),
// 因此搜索框只做**本地筛选**(歌名/歌手/专辑);输入时自动补齐剩余分页,
// 保证筛选覆盖全库而不是已加载的前几页。两个视图(飞牛/本地)共用同一个关键字。
let searchTimer: ReturnType<typeof setTimeout> | undefined
let searchSeq = 0
/** 全量补齐进行中:输入防抖与视图切换可能同时触发,用标志位避免并发重复翻页 */
let fillingAll = false
/** 本组件已完成挂载初始化:此前不触发补齐,避免与 ensureTracksFresh 的首页请求并发 */
let mountedReady = false
/**
* 补齐飞牛曲库的剩余分页。
* 飞牛 `track/list` 不支持关键字过滤,搜索框只做**本地筛选**,
* 因此必须先把剩余分页拉完,否则只能在「已加载的前几页」里找,
* 表现为「曲库里明明有这首歌却搜不到」。
*
* 必须避让任何在途的分页请求:补齐是「按页追加」,而首页刷新是
* `tracks = mapped`(整体替换);两者交错会把刚追加的页直接抹掉,造成曲目缺口。
*/
async function fillRemainingFeiniu() {
if (fillingAll || !store.hasMoreTracks) return
if (store.loading || store.refreshing || store.loadingMore) return
fillingAll = true
const seq = ++searchSeq
try {
await store.loadRemainingTracks()
} catch (e) {
if (seq === searchSeq) toast.error(String(e))
} finally {
fillingAll = false
}
}
/**
* 搜索框输入:仅飞牛曲库需要补齐分页(本地曲库是已全量在内存的数组,输入即筛)。
* 两个视图共用同一个关键字,但按各自的数据来源决定是否发请求。
*/
function onSearchInput() {
clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
const seq = ++searchSeq
try {
await store.loadTracks(1)
} catch (e) {
if (seq === searchSeq) toast.error(String(e))
}
}, 400)
searchTimer = setTimeout(() => {
if (view.value !== 'feiniu') return
void fillRemainingFeiniu()
}, 300)
}
const isFiltering = computed(() => !!filterKeyword.value)
/** 关键字规范化:全角空格→半角、压缩连续空白、转小写(中文不受影响) */
function normalizeKeyword(s: string) {
return s
.replace(/\u3000/g, ' ')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase()
}
const filterKeyword = computed(() => normalizeKeyword(store.searchKeyword))
/**
* 保证飞牛曲库的筛选覆盖全库。
* 关键字在两个视图之间共享,所以「带着关键字从本地切回飞牛曲库」时
* 也必须补齐分页,否则只会在已加载的前几页里筛,结果数明显偏小。
* 挂载阶段由 onMounted 的串行链负责,此处只在挂载后响应视图 / 关键字变化。
*/
function ensureFeiniuFilterCoverage() {
if (!mountedReady) return
if (view.value !== 'feiniu' || !filterKeyword.value) return
void fillRemainingFeiniu()
}
watch([view, filterKeyword], ensureFeiniuFilterCoverage)
function matchesKeyword(t: PlayableItem, kw: string) {
// 曲库条目(PlayableItem)的字段是 title=歌名 / artistNames=歌手 / album=专辑。
// 千万不要用 musicdl 的 songName/singers——那是「发现音乐」的结构,用错字段
// 会导致 haystack 只剩专辑,表现为"歌名/歌手都搜不到"。
return `${t.title ?? ''} ${t.artistNames ?? ''} ${t.album ?? ''}`.toLowerCase().includes(kw)
}
const filteredTracks = computed(() => {
const kw = filterKeyword.value
if (!kw) return store.tracks
return store.tracks.filter((t) => matchesKeyword(t, kw))
})
const filteredLocalTracks = computed(() => {
const kw = filterKeyword.value
if (!kw) return store.localTracks
return store.localTracks.filter((t) => matchesKeyword(t, kw))
})
function refreshFeiniu() {
store.loadTracks(1).catch((e) => toast.error(String(e)))
}
@@ -69,23 +144,66 @@ function refreshLocal() {
}
function playFeiniu() {
if (store.tracks.length) store.playQueue(store.tracks, 0)
// 有筛选时只播放筛选结果
if (filteredTracks.value.length) store.playQueue(filteredTracks.value, 0)
}
function playLocal() {
if (store.localTracks.length) store.playQueue(store.localTracks, 0)
if (filteredLocalTracks.value.length) store.playQueue(filteredLocalTracks.value, 0)
}
function playActivePlaylist() {
if (activePlaylist.value?.items.length) store.playQueue(activePlaylist.value.items, 0)
}
// ===== 滚动加载更多(飞牛曲库分页)=====
// ===== 列表增量渲染 + 滚动加载更多 =====
/**
* 一次挂载多少行。曲库没有虚拟滚动,补齐分页后行数可达 MAX_QUEUE_TRACKS(4000)
* 一次性挂载 4000 个 TrackItem(每个含封面 <img> 与下拉菜单)会明显卡顿,
* 因此改成「先渲染一屏,滚到底再追加」(与发现音乐页、播放队列同一策略)。
*/
const LIST_STEP = 200
const renderLimit = ref(LIST_STEP)
/** 当前视图正在展示的完整列表 */
const activeList = computed<PlayableItem[]>(() => {
if (view.value === 'feiniu') return filteredTracks.value
if (view.value === 'local') return filteredLocalTracks.value
return activePlaylist.value?.items ?? []
})
const visibleFeiniu = computed(() => filteredTracks.value.slice(0, renderLimit.value))
const visibleLocal = computed(() => filteredLocalTracks.value.slice(0, renderLimit.value))
const visiblePlaylistItems = computed(() => (activePlaylist.value?.items ?? []).slice(0, renderLimit.value))
/** 还有没渲染出来的行 */
const hasMoreRows = computed(() => renderLimit.value < activeList.value.length)
/** 实际渲染出来的行数(提示文案用) */
const shownRows = computed(() => Math.min(renderLimit.value, activeList.value.length))
/** 哨兵是否显示:要么还有行没渲染,要么飞牛曲库还有分页没拉 */
const showSentinel = computed(
() => hasMoreRows.value || (view.value === 'feiniu' && store.hasMoreTracks)
)
// 换视图 / 换关键字 / 换歌单时把渲染窗口收回一屏(监听放在 activePlaylistId 声明之后)
const loadMoreRef = ref<HTMLElement | null>(null)
let loadMoreObserver: IntersectionObserver | null = null
onMounted(() => {
loadMoreObserver = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return
store.loadMoreTracks().catch(() => {})
// 先把已加载的数据渲染出来,都渲染完了再向 NAS 要下一页
if (renderLimit.value < activeList.value.length) {
renderLimit.value += LIST_STEP
// 哨兵节点没换位置、仍处于交叉状态时 IntersectionObserver 不会再触发,
// 需要重新 observe 一次才能连续追加
nextTick(() => {
const el = loadMoreRef.value
if (el && loadMoreObserver) {
loadMoreObserver.unobserve(el)
loadMoreObserver.observe(el)
}
})
return
}
if (view.value === 'feiniu' && store.hasMoreTracks) store.loadMoreTracks().catch(() => {})
},
{ rootMargin: '400px' }
)
@@ -118,6 +236,11 @@ const editorOpen = ref(false)
// 切换歌单时关闭编辑器,避免对着已切换的目标继续编辑
watch(activePlaylistId, () => (editorOpen.value = false))
// 换视图 / 换关键字 / 换歌单时把增量渲染窗口收回一屏,避免「切回来看到一屏旧内容」
watch([view, filterKeyword, activePlaylistId], () => {
renderLimit.value = LIST_STEP
})
// 切到歌单视图时自动选中第一个
watch(
[view, () => store.playlists.length],
@@ -159,9 +282,44 @@ function removePlaylist() {
activePlaylistId.value = ''
}
// ===== 视图与筛选持久化:曲库页每次挂载都会重置本地状态,
// 而"搜索结果每次都要重新输"是用户明确反馈的问题 → 存 localStorage(跨重启) =====
const LIB_VIEW_KEY = 'thing.music.library.view'
const LIB_FILTER_KEY = 'thing.music.library.filter'
watch(
() => [view.value, store.searchKeyword] as const,
([v, kw]) => {
try {
localStorage.setItem(LIB_VIEW_KEY, v)
localStorage.setItem(LIB_FILTER_KEY, kw ?? '')
} catch {
/* 存储不可用时静默 */
}
}
)
onMounted(async () => {
await store.init()
if (store.config.loggedIn) store.loadTracks(1).catch(() => {})
try {
// 恢复上次的视图与筛选关键字
const savedView = localStorage.getItem(LIB_VIEW_KEY)
if (savedView === 'feiniu' || savedView === 'local' || savedView === 'playlists') {
view.value = savedView
}
store.searchKeyword = localStorage.getItem(LIB_FILTER_KEY) ?? ''
await store.init()
// 进入曲库页:命中新鲜窗口直接用内存缓存(不请求),过期才后台静默刷新,
// 避免每次切页都清空列表 + 重新拉取 + 重下封面
if (store.config.loggedIn) await store.ensureTracksFresh().catch(() => {})
// 恢复的是本地视图时自动扫描(标签有缓存,通常很快),否则恢复的筛选无内容可筛
if (view.value === 'local') void store.scanLocal().catch(() => {})
} finally {
// 挂载初始化结束才让「视图 / 关键字变化 → 补齐分页」的监听生效,
// 避免它与上面的首页请求并发(并发会因列表整体替换而丢页);
// 随后按同一串行顺序补一次,覆盖「启动时已带关键字」的情况
mountedReady = true
ensureFeiniuFilterCoverage()
}
})
/** 表格表头与 TrackItem 行保持同一条网格 */
@@ -169,10 +327,9 @@ const GRID = { gridTemplateColumns: '28px 36px minmax(0,1fr) 74px 28px' }
const listEmptyHint = computed(() => {
if (view.value === 'feiniu') {
if (store.searchKeyword.trim()) return '没有匹配「' + store.searchKeyword.trim() + '」的曲目'
return store.config.loggedIn ? '曲库为空,试试刷新或检查 NAS 曲库目录' : '请先在「设置 → 飞牛音乐连接」登录'
}
return '暂无本地音乐,点击「扫描本地曲库」或先到「发现音乐」下载'
return '暂无本地音乐,点击「扫描本地曲库」,或到「设置 → 存储与上传」添加本地曲库目录'
})
</script>
@@ -249,15 +406,19 @@ const listEmptyHint = computed(() => {
<!-- ===== 内容key 随来源变化复用全局 tab-animate 切换动画 ===== -->
<section :key="view" class="tab-animate flex min-w-0 flex-1 flex-col">
<!-- 未登录态 -->
<div v-if="needsLogin" class="flex min-h-0 flex-1 items-center justify-center px-6">
<!-- 未登录态**只挡飞牛曲库**本地曲库与我的歌单完全不依赖 NAS 登录
此前把它们一起挡掉等于让只用发现音乐 + 本地曲库的用户点进来只看得到登录提示 -->
<div
v-if="view === 'feiniu' && needsLogin"
class="flex min-h-0 flex-1 items-center justify-center px-6"
>
<Empty>
<EmptyMedia>
<Music2 class="size-8 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>还没有可用的飞牛音乐连接</EmptyTitle>
<EmptyTitle>飞牛曲库需要一个可用的音乐连接</EmptyTitle>
<EmptyDescription>
设置 飞牛音乐连接添加并登录或先到发现音乐下载歌曲到本地曲库
设置 飞牛音乐连接添加并登录本地曲库与我的歌单不依赖 NAS可直接在左侧切换查看
</EmptyDescription>
</Empty>
</div>
@@ -266,36 +427,65 @@ const listEmptyHint = computed(() => {
<!-- 工具栏 -->
<header class="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
<template v-if="view === 'feiniu'">
<div class="relative w-full max-w-sm">
<div class="relative w-full max-w-[280px]">
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="store.searchKeyword"
class="h-8 pl-8"
placeholder="搜索歌名 / 歌手 / 专辑"
placeholder="搜索歌名 / 歌手 / 专辑(本地筛选)"
@input="onSearchInput"
@keydown.enter="onSearchInput"
/>
</div>
<Button variant="outline" size="sm" class="h-8" :disabled="store.loading" @click="refreshFeiniu">
<RefreshCw :class="store.loading ? 'size-3.5 animate-spin' : 'size-3.5'" />
<Button
variant="outline"
size="sm"
class="h-8"
:disabled="store.loading || store.refreshing"
@click="refreshFeiniu"
>
<RefreshCw :class="store.loading || store.refreshing ? 'size-3.5 animate-spin' : 'size-3.5'" />
刷新
</Button>
<span class="ml-1 text-[11.5px] text-muted-foreground">
{{ store.tracks.length }}<template v-if="store.total > store.tracks.length"> / {{ store.total }}</template>
<span class="ml-1 shrink-0 whitespace-nowrap text-[11.5px] text-muted-foreground">
<template v-if="isFiltering">
匹配 {{ filteredTracks.length }} / 已加载 {{ store.tracks.length }}
</template>
<template v-else>
{{ store.tracks.length }}<template v-if="store.total > store.tracks.length"> / {{ store.total }}</template>
</template>
</span>
<Button size="sm" class="ml-auto h-8" :disabled="!store.tracks.length" @click="playFeiniu">
<Button size="sm" class="ml-auto h-8" :disabled="!filteredTracks.length" @click="playFeiniu">
<Play class="size-3.5" /> 播放全部
</Button>
</template>
<template v-else-if="view === 'local'">
<!-- 结构与飞牛曲库完全一致搜索框 操作按钮 计数 播放全部
提示语与筛选字段歌名 / 歌手 / 专辑也保持一致
宽度收窄到与飞牛曲库同宽把余量留给右侧的匹配数与播放全部 -->
<div class="relative w-full max-w-[280px]">
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="store.searchKeyword"
class="h-8 pl-8"
placeholder="搜索歌名 / 歌手 / 专辑(本地筛选)"
@input="onSearchInput"
@keydown.enter="onSearchInput"
/>
</div>
<Button variant="outline" size="sm" class="h-8" :disabled="store.localScanBusy" @click="refreshLocal">
<Loader2 v-if="store.localScanBusy" class="size-3.5 animate-spin" />
<FolderOpen v-else class="size-3.5" />
扫描本地曲库
</Button>
<span class="text-[11.5px] text-muted-foreground">{{ store.localTracks.length }} </span>
<Button size="sm" class="ml-auto h-8" :disabled="!store.localTracks.length" @click="playLocal">
<span class="ml-1 shrink-0 whitespace-nowrap text-[11.5px] text-muted-foreground">
<template v-if="isFiltering">
匹配 {{ filteredLocalTracks.length }} / 已扫描 {{ store.localTracks.length }}
</template>
<template v-else>{{ store.localTracks.length }} </template>
</span>
<Button size="sm" class="ml-auto h-8" :disabled="!filteredLocalTracks.length" @click="playLocal">
<Play class="size-3.5" /> 播放全部
</Button>
</template>
@@ -344,8 +534,8 @@ const listEmptyHint = computed(() => {
<!-- 表头 -->
<div
v-if="
(view === 'feiniu' && store.tracks.length) ||
(view === 'local' && store.localTracks.length) ||
(view === 'feiniu' && filteredTracks.length) ||
(view === 'local' && filteredLocalTracks.length) ||
(view === 'playlists' && activePlaylist?.items.length)
"
class="grid h-8 items-center gap-3 px-2 text-[11px] text-muted-foreground"
@@ -365,26 +555,25 @@ const listEmptyHint = computed(() => {
</div>
<Empty v-else-if="!store.tracks.length">
<EmptyMedia><Music2 class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>{{ store.searchKeyword.trim() ? '没有匹配的曲目' : '曲库为空' }}</EmptyTitle>
<EmptyTitle>曲库为空</EmptyTitle>
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
</Empty>
<Empty v-else-if="!filteredTracks.length">
<EmptyMedia><Search class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>没有匹配「{{ store.searchKeyword.trim() }}」的曲目</EmptyTitle>
<EmptyDescription>
已在{{ store.tracks.length }}首曲库中筛选(歌名 / 歌手 / 专辑)。
</EmptyDescription>
</Empty>
<template v-else>
<TrackItem
v-for="(t, i) in store.tracks"
v-for="(t, i) in visibleFeiniu"
:key="t.guid || i"
:item="t"
:index="i"
:context="store.tracks"
:context="filteredTracks"
:active="store.current?.guid === t.guid"
/>
<div
v-if="store.hasMoreTracks"
ref="loadMoreRef"
class="flex items-center justify-center gap-2 py-4 text-[12px] text-muted-foreground"
>
<Loader2 v-if="store.loadingMore" class="size-3.5 animate-spin" />
正在加载更多…
</div>
</template>
</template>
@@ -395,13 +584,18 @@ const listEmptyHint = computed(() => {
<EmptyTitle>暂无本地音乐</EmptyTitle>
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
</Empty>
<template v-else>
<Empty v-else-if="store.localTracks.length && !filteredLocalTracks.length">
<EmptyMedia><Search class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>没有匹配「{{ store.searchKeyword.trim() }}」的本地曲目</EmptyTitle>
<EmptyDescription>已在扫描到的 {{ store.localTracks.length }} 首中筛选。</EmptyDescription>
</Empty>
<template v-else-if="filteredLocalTracks.length">
<TrackItem
v-for="(t, i) in store.localTracks"
v-for="(t, i) in visibleLocal"
:key="t.guid || i"
:item="t"
:index="i"
:context="store.localTracks"
:context="filteredLocalTracks"
:active="store.current?.guid === t.guid"
/>
</template>
@@ -422,7 +616,7 @@ const listEmptyHint = computed(() => {
</Button>
</Empty>
<TrackItem
v-for="(t, i) in activePlaylist.items"
v-for="(t, i) in visiblePlaylistItems"
:key="`${t.source}:${t.guid}:${i}`"
:item="t"
:index="i"
@@ -439,6 +633,17 @@ const listEmptyHint = computed(() => {
<EmptyDescription>点击左侧「新建歌单」开始整理你的收藏。</EmptyDescription>
</Empty>
</template>
<!-- 增量渲染 / 分页共用哨兵:滚到这里先追加一批行,行渲染完了再向 NAS 要下一页 -->
<div
v-if="showSentinel && activeList.length"
ref="loadMoreRef"
class="flex items-center justify-center gap-2 py-4 text-[12px] text-muted-foreground"
>
<Loader2 v-if="store.loadingMore" class="size-3.5 animate-spin" />
<template v-if="hasMoreRows">已显示 {{ shownRows }} / {{ activeList.length }} 首,继续滚动加载</template>
<template v-else>正在加载更多…</template>
</div>
</div>
</ScrollArea>
</template>
+13 -11
View File
@@ -106,26 +106,26 @@ async function uploadToFeiniu() {
<template>
<div
class="group grid h-11 items-center gap-3 rounded-md px-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50"
class="group grid h-11 items-center gap-3 rounded-md px-2 transition-colors"
:class="[
showAlbum
? 'grid-cols-[28px_36px_minmax(0,1fr)_minmax(0,0.75fr)_74px_28px]'
: 'grid-cols-[28px_36px_minmax(0,1fr)_74px_28px]',
isCurrent ? 'bg-accent/60' : 'hover:bg-accent/40'
]"
role="button"
tabindex="0"
@dblclick="play"
@keydown.enter.prevent="play"
>
<!-- 序号 / 播放态指示悬停时原位切换不占用固定的空白列 -->
<div class="flex size-7 items-center justify-center text-[11.5px] tabular-nums text-muted-foreground">
<!-- 序号 / 播放态指示 / 播放按钮
行本身不再是 role=button内部还嵌着下拉菜单的按钮嵌套交互元素对读屏是噪音
播放改由下面这个**常驻 DOM** 的按钮承担原来它是 hover display:noneblock
display:none 的元素无法获得焦点键盘用户根本按不到 -->
<div class="relative flex size-7 items-center justify-center text-[11.5px] tabular-nums text-muted-foreground">
<span v-if="isCurrent" class="music-eq" :class="{ 'is-paused': !store.playing }"><i /><i /><i /></span>
<template v-else>
<span class="group-hover:hidden">{{ index + 1 }}</span>
<span class="transition-opacity group-hover:opacity-0">{{ index + 1 }}</span>
<button
type="button"
class="hidden text-foreground group-hover:block"
class="absolute inset-0 flex items-center justify-center rounded text-foreground opacity-0 outline-none transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50"
:aria-label="'播放 ' + item.title"
@click.stop="play"
>
@@ -223,9 +223,11 @@ async function uploadToFeiniu() {
>
<Cloud class="size-4" /> 上传到飞牛曲库
</DropdownMenuItem>
<DropdownMenuItem disabled>
<FolderOpen class="size-4" /> {{ item.dir || '未知目录' }}
</DropdownMenuItem>
<!-- 所在目录只是信息不是动作 disabled 的菜单项展示会让人以为点了没反应 -->
<div class="flex items-center gap-2 px-2 py-1.5 text-[11.5px] text-muted-foreground">
<FolderOpen class="size-3.5 shrink-0" />
<span class="truncate" :title="item.dir || ''">{{ item.dir || '未知目录' }}</span>
</div>
</template>
<template v-if="playlistId">
@@ -22,6 +22,7 @@ const editing = ref<Partial<FeiniuConnection> | null>(null)
const editOpen = ref(false)
const password = ref('')
const testing = ref(false)
const saving = ref(false)
function newForm() {
editing.value = { name: '', kind: 'lan', baseUrl: '', username: '', accessCode: '', insecure: false, fnId: '' }
@@ -30,46 +31,94 @@ function newForm() {
}
function editForm(c: FeiniuConnection) {
editing.value = { ...c }
// 历史数据里的 frp 等内网转发方式已并入「局域网」
editing.value = { ...c, kind: c.kind === 'fnconnect' ? 'fnconnect' : 'lan' }
password.value = ''
editOpen.value = true
}
async function save() {
if (!editing.value) return
if (!editing.value.name?.trim() || !editing.value.baseUrl?.trim()) {
toast.error('请填写名称与服务器地址')
if (!editing.value || saving.value) return
const draft = { ...editing.value }
if (!draft.name?.trim()) {
toast.error('请填写名称')
return
}
// 局域网需要服务器地址;FnConnect 需要飞牛 ID(服务器地址在登录时自动解析)
if (draft.kind === 'fnconnect') {
if (!draft.fnId?.trim()) {
toast.error('请填写飞牛 ID')
return
}
} else if (!draft.baseUrl?.trim()) {
toast.error('请填写服务器地址')
return
}
const pw = password.value
saving.value = true
try {
const id = await store.saveConnection({
id: editing.value.id || '',
name: editing.value.name.trim(),
kind: editing.value.kind || 'lan',
baseUrl: editing.value.baseUrl.trim(),
username: editing.value.username || '',
accessCode: editing.value.accessCode || '',
insecure: !!editing.value.insecure,
fnId: editing.value.fnId || ''
id: draft.id || '',
name: draft.name.trim(),
kind: draft.kind || 'lan',
baseUrl: draft.baseUrl?.trim() || '',
username: draft.username || '',
accessCode: draft.accessCode || '',
insecure: !!draft.insecure,
fnId: draft.fnId?.trim() || '',
relay: !!draft.relay
})
if (password.value) {
await store.login(id, editing.value.username || '', password.value)
}
// 先关闭对话框再登录:FnConnect 登录要先探测可达地址(可能数秒),
// 若等待其完成才关窗,观感就是「已保存但对话框卡住」。
editOpen.value = false
toast.success('已保存')
if (pw && id) {
void store
.login(id, draft.username || '', pw)
.then(() => toast.success('登录成功'))
.catch((e) => toast.error(`登录失败:${e}`))
}
} catch (e) {
toast.error(String(e))
} finally {
saving.value = false
}
}
async function test(c: FeiniuConnection) {
/** 测试当前对话框里的草案(无需先保存,新建连接也可以直接测) */
async function test() {
const d = editing.value
if (!d || testing.value) return
if (!password.value) {
toast.error('请输入密码再测试')
return
}
if (d.kind === 'fnconnect') {
if (!d.fnId?.trim()) {
toast.error('请填写飞牛 ID')
return
}
} else if (!d.baseUrl?.trim()) {
toast.error('请填写服务器地址')
return
}
testing.value = true
try {
await store.testConnection(c.id, c.username, password.value)
await store.testConnection(
{
id: d.id || '',
name: d.name?.trim() || '测试',
kind: d.kind || 'lan',
baseUrl: d.baseUrl?.trim() || '',
username: d.username || '',
accessCode: d.accessCode || '',
insecure: !!d.insecure,
fnId: d.fnId?.trim() || '',
relay: !!d.relay
},
d.username || '',
password.value
)
toast.success('连接成功')
} catch (e) {
toast.error(String(e))
@@ -93,8 +142,9 @@ async function remove(c: FeiniuConnection) {
toast.success('已删除')
}
/** kind → 展示名:局域网涵盖 frp / 内网转发等直连方式,其余为 FnConnect */
function kindLabel(k: string) {
return k === 'lan' ? '局域网' : k === 'frp' ? 'frp 域名' : 'FnConnect'
return k === 'fnconnect' ? 'FnConnect' : '局域网'
}
onMounted(() => store.refreshConnections())
@@ -122,7 +172,8 @@ onMounted(() => store.refreshConnections())
<Badge v-if="store.activeId === c.id" variant="default" class="text-[10px]">激活</Badge>
</div>
<div class="truncate text-xs text-muted-foreground">
{{ c.baseUrl }}<template v-if="c.username"> · {{ c.username }}</template>
{{ c.baseUrl }}<template v-if="c.kind === 'fnconnect' && c.relay"> · 中继</template
><template v-if="c.username"> · {{ c.username }}</template>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
@@ -167,7 +218,7 @@ onMounted(() => store.refreshConnections())
</div>
</div>
<p v-if="!store.connections.length" class="text-xs text-muted-foreground">
还没有连接新建一个并填写 NAS 地址局域网 http://192.168.x.x:5666frp 域名 FnConnect fnId
还没有连接新建一个并填写 NAS 地址局域网 http://192.168.x.x:5666frp 等内网转发同样填最终访问地址 FnConnect fnId
</p>
</div>
@@ -179,17 +230,17 @@ onMounted(() => store.refreshConnections())
<div class="flex flex-col gap-3">
<div class="space-y-1.5">
<Label>名称</Label>
<Input v-model="editing!.name" placeholder="如:家里 NAS / frp 远程 / FnConnect" />
<Input v-model="editing!.name" placeholder="如:家里 NAS / FnConnect 远程" />
</div>
<div class="space-y-1.5">
<Label>类型</Label>
<div class="flex gap-2">
<Button
v-for="k in (['lan', 'frp', 'fnconnect'] as const)"
v-for="k in (['lan', 'fnconnect'] as const)"
:key="k"
type="button"
size="sm"
:variant="editing!.kind === k ? 'default' : 'outline'"
:variant="(editing!.kind ?? 'lan') === k ? 'default' : 'outline'"
@click="editing!.kind = k"
>
{{ kindLabel(k) }}
@@ -197,15 +248,19 @@ onMounted(() => store.refreshConnections())
</div>
</div>
<div v-if="editing!.kind === 'fnconnect'" class="space-y-1.5">
<Label>FnConnect fnIdfnos.net/xxx 或裸 id</Label>
<Input v-model="editing!.fnId" placeholder="fnos.net/zy2060537" />
<Label>飞牛 ID</Label>
<Input v-model="editing!.fnId" placeholder="请输入飞牛 ID,如 abc123" />
<p class="text-xs text-muted-foreground">
保存后点登录会自动解析到可达地址服务器地址会回填
fnos.net/ 后面的那段也可直接粘贴 fnos.net/abc123
保存后点登录会自动解析到可达地址内网 / 公网 / 中继服务器地址会回填
</p>
</div>
<div v-else class="space-y-1.5">
<Label>服务器地址</Label>
<Input v-model="editing!.baseUrl" placeholder="http://192.168.1.10:5666 或 https://xxx.xxx.com" />
<Input
v-model="editing!.baseUrl"
placeholder="http://192.168.1.10:5666 或 https://xxx.xxx.comfrp 填转发后的地址)"
/>
</div>
<div class="space-y-1.5">
<Label>账号</Label>
@@ -221,17 +276,14 @@ onMounted(() => store.refreshConnections())
</div>
</div>
<DialogFooter class="gap-2">
<Button variant="outline" :disabled="testing" @click="test(editing as unknown as FeiniuConnection)">
<Button variant="outline" :disabled="testing || saving" @click="test">
<Loader2 v-if="testing" class="size-4 animate-spin" /> 测试
</Button>
<Button @click="save">保存</Button>
<Button :disabled="saving" @click="save">
<Loader2 v-if="saving" class="size-4 animate-spin" /> 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<p class="text-xs text-muted-foreground">
提示上传音乐到 NAS下载到飞牛/ 上传 / 删除已改用 <strong class="font-medium">WebDAV</strong>
存储与上传分组里配置无需在此登录文件服务
</p>
</div>
</template>
+736 -82
View File
File diff suppressed because it is too large Load Diff
+31 -5
View File
@@ -31,6 +31,17 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
/** 待跳转 tab(搜索导航设置):{ moduleId, tab },模块挂载/已挂载时消费 */
const pendingTab = ref<{ moduleId: string; tab: string } | null>(null)
/**
* 各模块上次停留的 tab(内存记忆)。
*
* 模块被切走时组件卸载、`activeTab` 随之丢失,再切回来总是回到第一个 tab——
* 对「曲库 / 发现音乐 / 设置」这种三级结构的模块尤其别扭。
* 只做内存记忆(不落盘):tab 集合可能随版本变化,跨版本恢复旧值反而容易出错。
*/
const lastTabByModule = ref<Record<string, string>>({})
/** 当前已注册的模块 idunregister 时据此保存) */
let currentModuleId = ''
/** 设置待跳转 tab(搜索结果点击时调用) */
const setPendingTab = (moduleId: string, tab: string) => {
pendingTab.value = { moduleId, tab }
@@ -56,15 +67,30 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
/** 是否显示保存按钮(当前在设置 tab 且注册了保存处理函数) */
const saveVisible = computed(() => saveHandler.value !== null && activeTab.value === 'settings')
/** 模块注册标签(onMounted 时调用) */
const registerTabs = (tabList: ModuleTab[], current: string) => {
/**
* 模块注册标签(onMounted 时调用)。
* 返回该模块**应当使用**的 tab:优先复用上次停留的(且在当前 tab 集合内),
* 否则用传入的初始值。调用方若拿到不同值,需同步自己的 activeTab。
*/
const registerTabs = (moduleId: string, tabList: ModuleTab[], current: string): string => {
currentModuleId = moduleId
tabs.value = tabList
activeTab.value = current
const remembered = lastTabByModule.value[moduleId]
const restored =
remembered && tabList.some((t) => t.value === remembered) ? remembered : current
activeTab.value = restored
floatingVisible.value = false
return restored
}
/** 模块注销标签(onUnmounted 时调用) */
const unregisterTabs = () => {
/**
* 模块注销标签(onUnmounted 时调用)。
* `moduleId` 缺省时用当前注册的模块(保留旧调用点的语义)。
*/
const unregisterTabs = (moduleId?: string) => {
const id = moduleId ?? currentModuleId
if (id && activeTab.value) lastTabByModule.value[id] = activeTab.value
currentModuleId = ''
tabs.value = []
activeTab.value = ''
floatingVisible.value = false
+202 -31
View File
@@ -119,6 +119,8 @@ const logger = createLogger('music')
export const useMusicStore = defineStore('music', () => {
const settings = ref<MusicSettings | null>(null)
const env = ref<MusicEnvStatus | null>(null)
/** 环境探测进行中(探测要串行 spawn 4 个子进程,便携 Python 冷启动可达数秒) */
const envLoading = ref(false)
/** musicdl 已注册的全部搜索源(客户端名) */
const availableSources = ref<string[]>([])
@@ -136,23 +138,118 @@ export const useMusicStore = defineStore('music', () => {
const installError = ref('')
let progressUnlisten: UnlistenFn | null = null
async function init() {
restoreTasks()
await Promise.all([loadSettings(), refreshEnv()])
/**
* 幂等初始化。
*
* 标题栏音乐控件与音乐模块各会调用一次:不做守卫就会**重复**执行
* `restoreTasks()` 与 `refreshEnv()`,而后者每次要串行 spawn 4 个探测子进程
* (便携 Python 冷启动可达数秒)——表现为每次进入音乐模块都重新探测一遍环境、
* 环境面板反复闪「检测中」。失败时清空在途 Promise,允许下次重试。
*/
let initPromise: Promise<void> | null = null
function init(): Promise<void> {
if (initPromise) return initPromise
initPromise = (async () => {
restoreTasks()
await Promise.all([loadSettings(), refreshEnv()])
})().catch((e) => {
initPromise = null
throw e
})
return initPromise
}
/**
* QQ 音乐 Cookie 存放在**系统凭据管理器**Rust 侧 `music_secret_*`),
* 不再明文写进 settings.json —— 与 WebDAV 密码、飞牛 token 保持同一安全姿态。
* 这里保留内存副本供同步读取(`currentQqCookie()`)。
*/
const QQ_COOKIE_KEY = 'music-qq-cookie'
const qqCookie = ref('')
let qqCookieTimer: ReturnType<typeof setTimeout> | null = null
async function loadQqCookie() {
try {
const r = (await invoke('music_secret_get', { key: QQ_COOKIE_KEY })) as { value: string | null }
qqCookie.value = r?.value ?? ''
} catch (e) {
// 凭据库不可用:退回 settings 里的明文(若历史版本留下过)
logger.error(`读取 QQ 音乐 Cookie 失败: ${e}`)
qqCookie.value = settings.value?.qqCookie ?? ''
}
}
/** 写入 QQ 音乐 Cookie(节流落盘;空串表示清除) */
function setQqCookie(v: string) {
qqCookie.value = v.trim()
if (qqCookieTimer) clearTimeout(qqCookieTimer)
qqCookieTimer = setTimeout(() => {
qqCookieTimer = null
invoke('music_secret_set', { key: QQ_COOKIE_KEY, value: qqCookie.value }).catch((e) =>
logger.error(`保存 QQ 音乐 Cookie 失败: ${e}`)
)
}, 600)
}
/**
* 一次性迁移:旧版本把 Cookie 明文写在 `settings.qqCookie`。
* 先写凭据库成功,再清字段并落盘;写失败就保留明文(功能优先,不丢用户配置)。
*/
async function migrateQqCookie() {
const legacy = settings.value?.qqCookie?.trim() ?? ''
if (!legacy) {
await loadQqCookie()
return
}
try {
await invoke('music_secret_set', { key: QQ_COOKIE_KEY, value: legacy })
qqCookie.value = legacy
if (settings.value) {
settings.value.qqCookie = ''
await saveSettings()
}
} catch (e) {
logger.error(`迁移 QQ 音乐 Cookie 到凭据管理器失败(保留明文): ${e}`)
qqCookie.value = legacy
}
}
/**
* 最近一次与后端一致的设置快照(JSON)。
* `musicGetSettings()` 读回来的对象会被 `watch(store.settings, {deep:true})` 观察到,
* 若不比对就会把刚读到的内容原样写回——既是无谓写盘,又可能用旧快照覆盖后端新状态。
*/
let syncedSettingsJson = ''
async function loadSettings() {
settings.value = await commands.musicGetSettings()
syncedSettingsJson = settings.value ? JSON.stringify(settings.value) : ''
// Cookie 的真身在系统凭据管理器:先做一次性迁移,再从凭据库读回内存
await migrateQqCookie()
}
/** 相对上次读取/保存是否有真实改动(供 debounce 保存前判断,避免回写快照) */
function settingsChanged(): boolean {
if (!settings.value) return false
return JSON.stringify(settings.value) !== syncedSettingsJson
}
/** 保存设置(调用方先修改 settings 再调用;前端用 400ms debounce */
async function saveSettings() {
if (!settings.value) return
await commands.musicSaveSettings(settings.value)
syncedSettingsJson = JSON.stringify(settings.value)
}
async function refreshEnv() {
env.value = await commands.musicEnvStatus()
// 探测期间 `env` 可能是旧的/为 null,UI 必须靠这个标志区分
// 「正在检测」与「确实未安装」,否则冷启动会短暂误报「未安装」并诱导用户重装
envLoading.value = true
try {
env.value = await commands.musicEnvStatus()
} finally {
envLoading.value = false
}
}
async function loadSources() {
@@ -169,11 +266,41 @@ export const useMusicStore = defineStore('music', () => {
return (await invoke('music_ping')) as MusicPingPayload
}
/**
* 音乐请求(搜索 / 解析 / 下载)该使用的代理地址。
*
* 未开「搜索/下载走代理」时返回**空串**,桥接侧据此强制直连并屏蔽系统代理——
* 这一点必须显式传:requests 缺省会读系统代理(Windows 下连注册表的
* Internet Settings 都会读),用户一开代理模块的系统代理,搜索就会被静默
* 送进 mihomo,国内源无结果。
*/
function currentProxyUrl(): string {
if (!settings.value?.useProxy) return ''
const port = useProxyStore().settings?.mixedPort
return port ? `http://127.0.0.1:${port}` : ''
}
/**
* QQ 音乐 Cookie(可选,用户在设置里粘贴)。
* 用于:解析需要登录的歌单(含自己的隐私歌单)、VIP 音质(无损)下载。
* 桥接侧只在配置了 Cookie 时注入(musicdl 的既有逻辑:配了 Cookie 的源
* 会跳过第三方解析源,全部走官方接口)。
* 真身存在系统凭据管理器,这里只返回内存副本。
*/
function currentQqCookie(): string {
return qqCookie.value.trim()
}
async function search(keyword: string, sources: string[]) {
searching.value = true
searchError.value = ''
try {
const v = (await invoke('music_search', { keyword, sources })) as MusicSearchPayload
const v = (await invoke('music_search', {
keyword,
sources,
proxyUrl: currentProxyUrl(),
qqCookie: currentQqCookie()
})) as MusicSearchPayload
results.value = v.results ?? {}
resultTotal.value = v.total ?? 0
searched.value = true
@@ -194,7 +321,12 @@ export const useMusicStore = defineStore('music', () => {
parsing.value = true
parseError.value = ''
try {
const v = (await invoke('music_parse_playlist', { url, sources })) as {
const v = (await invoke('music_parse_playlist', {
url,
sources,
proxyUrl: currentProxyUrl(),
qqCookie: currentQqCookie()
})) as {
songs: MusicSong[]
count: number
}
@@ -376,7 +508,12 @@ export const useMusicStore = defineStore('music', () => {
if (!song) return null
if (song.downloadUrl) return song
if (!song.rawSearch) return null
const v = (await invoke('music_resolve', { song, quality })) as {
const v = (await invoke('music_resolve', {
song,
quality,
proxyUrl: currentProxyUrl(),
qqCookie: currentQqCookie()
})) as {
songs: (MusicSong | null)[]
}
const resolved = v.songs?.[0]
@@ -412,7 +549,12 @@ export const useMusicStore = defineStore('music', () => {
const lazy = songs.filter((s) => !s.downloadUrl && s.rawSearch)
if (lazy.length > 0) {
try {
const v = (await invoke('music_resolve', { songs: lazy, quality })) as {
const v = (await invoke('music_resolve', {
songs: lazy,
quality,
proxyUrl: currentProxyUrl(),
qqCookie: currentQqCookie()
})) as {
songs: (MusicSong | null)[]
}
// 返回与输入顺序对齐,失败位为 null
@@ -479,6 +621,7 @@ export const useMusicStore = defineStore('music', () => {
lyric: opts.lyric,
cover: opts.cover,
proxyUrl: opts.proxyUrl,
qqCookie: currentQqCookie(),
maxConcurrent: opts.maxConcurrent,
quality
})
@@ -526,17 +669,11 @@ export const useMusicStore = defineStore('music', () => {
}
const s = settings.value
if (!s) return
// 代理 URL:惰性读取代理模块设置(避免 store 初始化时跨 store 依赖)
let proxy = ''
if (s.useProxy) {
const port = useProxyStore().settings?.mixedPort
proxy = port ? `http://127.0.0.1:${port}` : ''
}
await startDownload(task.songsData, {
savedir: s.savedir,
lyric: s.lyricDownload,
cover: s.coverDownload,
proxyUrl: proxy,
proxyUrl: currentProxyUrl(),
engine: s.downloadEngine,
maxConcurrent: s.maxConcurrent,
// 沿用原任务的目标音质("最高" 存为空串),而不是当前默认音质
@@ -544,32 +681,59 @@ export const useMusicStore = defineStore('music', () => {
})
}
/** 挂上运行时安装 / 更新的进度监听(两个动作共用同一条事件通道) */
async function attachRuntimeProgress() {
if (progressUnlisten) return
progressUnlisten = await listen<MusicInstallProgress>('music-runtime-install-progress', (e) => {
installProgress.value = e.payload
})
}
/** 动作收尾:摘掉监听。失败原因留下面板展示;进度停在最后事件值需清掉,避免误导 */
function detachRuntimeProgress(err: unknown) {
installing.value = false
installError.value = err ? (typeof err === 'string' ? err : JSON.stringify(err)) : ''
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
}
async function installRuntime() {
if (installing.value) return
installing.value = true
installError.value = ''
installProgress.value = null
if (!progressUnlisten) {
progressUnlisten = await listen<MusicInstallProgress>(
'music-runtime-install-progress',
(e) => {
installProgress.value = e.payload
}
)
}
try {
await attachRuntimeProgress()
env.value = await commands.musicInstallRuntime()
} catch (e) {
// 保留失败原因供设置页面板展示;进度停在最后事件值,需清掉避免误导
installError.value = typeof e === 'string' ? e : JSON.stringify(e)
detachRuntimeProgress(e)
throw e
} finally {
installing.value = false
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
}
detachRuntimeProgress(null)
}
/**
* 把 musicdl 对齐到本应用锁定的版本(`force = true` 为强制重装)。
*
* 存在的理由:安装流程的闸门是「能否 import」,不看版本,所以仅升级应用
* 不会让已装环境换版本;版本不一致时必须由用户显式触发这次更新。
* 只升到锁定版本,不升 PyPI 最新(bridge.py 的补丁与 musicdl 版本强耦合)。
*/
async function updateMusicdl(force = false) {
if (installing.value) return
installing.value = true
installError.value = ''
installProgress.value = null
try {
await attachRuntimeProgress()
env.value = (await invoke('music_update_musicdl', { force })) as MusicEnvStatus
} catch (e) {
detachRuntimeProgress(e)
throw e
}
detachRuntimeProgress(null)
}
function cancelInstall() {
@@ -583,6 +747,9 @@ export const useMusicStore = defineStore('music', () => {
return {
settings,
env,
envLoading,
qqCookie,
setQqCookie,
availableSources,
searching,
searchError,
@@ -597,10 +764,13 @@ export const useMusicStore = defineStore('music', () => {
tasks,
init,
loadSettings,
settingsChanged,
saveSettings,
refreshEnv,
loadSources,
pingBridge,
currentProxyUrl,
currentQqCookie,
search,
parsePlaylist,
resolveSong,
@@ -609,6 +779,7 @@ export const useMusicStore = defineStore('music', () => {
removeTask,
redownloadTask,
installRuntime,
updateMusicdl,
cancelInstall,
stopBridge
}
+323
View File
@@ -0,0 +1,323 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { invoke } from '@tauri-apps/api/core'
import { useMusicStore, type MusicDownloadTask, type MusicSong } from '@/stores/musicStore'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { createLogger } from '@/lib/logger'
const logger = createLogger('music-upload')
/** feiniu_scan_local 返回的本地音频条目(只取用到的字段) */
interface LocalAudioFile {
path: string
/** 文件名(不含扩展名) */
name?: string
/** 音频标签里的标题 */
title?: string
/** 修改时间(Unix 秒) */
mtim?: number
}
/**
* 下载 → 上传飞牛的记账中枢。
*
* 为什么必须是一个 **store** 而不是组件内逻辑:
* 这套记账是「任务完成时触发上传」的**唯一触发点**,一旦放在 `MusicModule.vue`
* 的 setup 里,用户下载途中切走音乐模块(组件卸载 → watcher 被销毁)就永远不会触发——
* 表现为「下载到飞牛」下载完了却没上传、本地副本也没删,自动上传同样静默失效。
* Pinia 的 setup store 活在独立 effect scope 中,watcher 不随组件卸载而销毁。
*
* 记账粒度到**任务**(而非「最近 N 分钟的文件」):
* 任务行上的「上传到飞牛曲库」只应传它自己下载的文件,
* 否则会把别的任务、甚至用户手动放进下载目录的文件一起传上去。
*
* 所有上传走**同一条串行队列**`feiniu_scan_local` 要遍历整个曲库目录做标签解析,
* 并发跑两个上传等于并发扫两遍盘,还会让 `uploading` 标志提前归位(按钮状态错乱)。
*/
export const useMusicUploadStore = defineStore('musicUpload', () => {
const music = useMusicStore()
const feiniu = useFeiniuStore()
/** 行级「下载到飞牛」进行中的 key`${source}|${index}`),驱动行内 spinner */
const rowUploadingKeys = ref<Set<string>>(new Set())
/** 已上传过的任务 id:每个任务最多上传一次,避免重复全量上传 */
const handledTaskIds = new Set<string>()
/** 「下载到飞牛」的临时任务:完成后上传 → 删本地副本 → 移除记录 */
const tempTaskIds = new Set<string>()
/** taskId → 行按钮 spinner 的 key */
const tempTaskKeys = new Map<string, string>()
/** 下载目标选「飞牛」的 musicdl 任务:完成后自动上传 */
const uploadOnDoneTaskIds = new Set<string>()
/**
* 初始化完成前不触发自动上传。
* 由 UI 在 `music.init()`(恢复历史任务)之后调用 `armAfterInit()` 置真,
* 否则启动时会拿着上一轮已完成的任务重传一遍。
*/
let armed = false
function setRowUploading(key: string, on: boolean) {
const next = new Set(rowUploadingKeys.value)
if (on) next.add(key)
else next.delete(key)
rowUploadingKeys.value = next
}
function releaseRowKey(taskId: string) {
const key = tempTaskKeys.get(taskId)
tempTaskKeys.delete(taskId)
if (key) setRowUploading(key, false)
}
// ===== 上传串行队列 =====
const uploadQueue: Array<() => Promise<void>> = []
let draining = false
/**
* 入队一个上传作业并保证串行执行。
* 队列排空后再扫一遍任务列表:上传期间新完成的任务在这一轮被接上,
* 因此不需要「跳过并等下一次状态变化」这种会漏单的写法。
*/
function enqueueUpload(job: () => Promise<void>) {
uploadQueue.push(job)
if (draining) return
draining = true
void (async () => {
try {
while (uploadQueue.length > 0) {
const next = uploadQueue.shift()
if (!next) break
await next().catch((e) => {
logger.error(`上传作业失败: ${e}`)
toast.error(`上传到飞牛失败:${e}`)
})
}
} finally {
draining = false
}
resolvePendingUploads()
})()
}
/**
* 上传某个下载任务产出的音频文件。
*
* 匹配策略(从精确到宽松):
* 1. 文件名或音频标签标题命中任务内任一歌曲名;
* 2. 文件名以「歌名 - 」开头也算命中——`bridge.py::_rename_downloaded_files`
* 在下载完成后会把落盘文件从「歌名 - <标识>.ext」改名成「歌名 - 歌手.ext」;
* 3. 都不命中时退回「修改时间 ≥ 任务创建时刻」。
* 所有规则都限定在任务时间之后,因此不会带上历史文件。
*/
async function uploadTaskFiles(
task: MusicDownloadTask,
deleteLocal: boolean
): Promise<{ total: number; ok: number; deleted: number }> {
if (!feiniu.webdavReady) throw new Error('请先在「设置 → 存储与上传」完成 WebDAV 配置')
// 同步置位:UI 的「上传中」禁用态依赖它
feiniu.uploading = true
try {
// 只列举下载目录(非递归,不解析标签):musicdl 的落盘位置恒为 savedir
// bridge.py `si.work_dir = savedir`)。用 feiniu_scan_local 会递归遍历整个
// 曲库目录并解析标签——为一次上传扫全库是纯浪费。
const dir = task.savedir || music.settings?.savedir || ''
if (!dir) throw new Error('未配置下载目录,无法定位下载产物')
const r = await invoke<{ items: LocalAudioFile[] }>('feiniu_list_audio_files', { dir })
const items = r?.items ?? []
// 任务创建时刻(毫秒)→ 秒,留 5s 余量吸收文件系统时间戳抖动
const since = task.createdAt / 1000 - 5
const recent = items.filter((f) => (f.mtim || 0) >= since)
const names = [
...new Set(task.songsData.map((s) => (s.songName ?? '').trim().toLowerCase()).filter(Boolean))
]
const nameSet = new Set(names)
const byName =
names.length > 0
? recent.filter((f) => {
const stem = String(f.name ?? '').trim().toLowerCase()
const title = String(f.title ?? '').trim().toLowerCase()
if (nameSet.has(stem) || nameSet.has(title)) return true
return names.some((n) => stem.startsWith(`${n} - `))
})
: []
const list = byName.length > 0 ? byName : recent
let ok = 0
let deleted = 0
for (const f of list) {
const path = String(f.path)
const name = path.split(/[\\/]/).pop() || 'music.bin'
try {
await feiniu.uploadToFeiniu(path, name)
ok++
if (deleteLocal) deleted += await feiniu.deleteLocalMedia(path)
} catch (e) {
logger.error(`上传失败 ${name}: ${e}`)
}
}
return { total: list.length, ok, deleted }
} finally {
feiniu.uploading = false
}
}
/** 任务行上的「上传到飞牛曲库」:只传该任务下载的文件 */
function uploadTask(taskId: string) {
const task = music.tasks.find((t) => t.taskId === taskId)
if (!task) return
if (!feiniu.webdavReady) {
toast.error('请先在「设置 → 存储与上传」完成 WebDAV 配置(地址 / 账号 / 密码 / 目标目录)')
return
}
enqueueUpload(async () => {
const { total, ok } = await uploadTaskFiles(task, false)
if (total === 0) toast.info('未找到该任务下载的文件(可能已被移动或删除)')
else toast.success(`已上传 ${ok}/${total} 首到飞牛曲库`)
})
}
/** 临时任务的收尾:上传 → 删本地副本 → 移除任务记录。失败时保留本地文件与记录供排查。 */
async function finishTempUpload(taskId: string) {
const task = music.tasks.find((t) => t.taskId === taskId)
try {
if (!task) throw new Error('任务记录已不存在')
const { total, ok, deleted } = await uploadTaskFiles(task, true)
const name = task.songs?.[0]?.songName || ''
if (ok > 0) {
toast.success(`已保存到飞牛曲库${name ? `${name}` : ''}`)
music.removeTask(taskId)
if (deleted < total) toast.info(`${total - deleted} 个本地副本未能删除(可能被占用)`)
} else if (total === 0) {
toast.error('下载完成,但未在下载目录找到新文件——请检查下载目录设置')
} else {
throw new Error('上传失败')
}
} finally {
releaseRowKey(taskId)
}
}
/**
* 单曲「下载到飞牛」:走 musicdl 下载(含引擎侧解析、代理、音质),
* 任务完成后上传并删除本地副本,最后移除任务记录。
*/
async function downloadToFeiniu(song: MusicSong, source: string, index: number) {
const key = `${source}|${index}`
if (rowUploadingKeys.value.has(key)) return
if (!feiniu.webdavReady) {
toast.error('请先在「设置 → 存储与上传」完成 WebDAV 配置(地址 / 账号 / 密码 / 目标目录)')
return
}
const s = music.settings
if (!s) return
// Rust 引擎的任务在「下载器」模块管理,本模块拿不到落盘结果
if (s.downloadEngine === 'rust') {
toast.error(
'Rust 引擎的下载任务在「下载器」模块中管理,无法自动回传飞牛曲库。请改用 musicdl 引擎。'
)
return
}
setRowUploading(key, true)
try {
const { engine, skipped, taskId } = await music.startDownload([song], {
savedir: s.savedir,
lyric: s.lyricDownload,
cover: s.coverDownload,
proxyUrl: s.useProxy ? music.currentProxyUrl() : '',
engine: s.downloadEngine,
maxConcurrent: s.maxConcurrent,
quality: s.defaultDownloadQuality ?? ''
})
if (engine === 'rust' || skipped > 0) {
toast.error(skipped > 0 ? '该歌曲没有可用的下载源' : 'Rust 引擎无法回传飞牛曲库')
setRowUploading(key, false)
return
}
if (taskId) {
tempTaskIds.add(taskId)
tempTaskKeys.set(taskId, key)
} else {
setRowUploading(key, false)
}
} catch (e) {
toast.error(`启动下载失败:${e}`)
setRowUploading(key, false)
}
}
/** 标记「该任务完成后自动上传」(下载目标选了飞牛时调用) */
function markUploadOnDone(taskId: string) {
uploadOnDoneTaskIds.add(taskId)
}
/** 由 UI 在 music.init() 之后调用一次:把历史已完成任务视为已处理,然后开始接收新任务 */
function armAfterInit() {
if (armed) return
for (const t of music.tasks) if (t.status === 'done') handledTaskIds.add(t.taskId)
armed = true
}
/**
* 任务状态变化的唯一驱动:
* - 临时任务(下载到飞牛)完成 → 上传 + 删本地副本 + 移除记录;
* - 普通任务完成且要求自动上传 → 上传该任务的文件;
* - 临时任务失败/被取消/被移除 → 释放行内 spinner 并提示。
*
* 只负责「发现作业并入队」,并发控制交给串行队列。
*/
function resolvePendingUploads() {
if (!armed) return
const alive = new Set(music.tasks.map((t) => t.taskId))
for (const id of [...handledTaskIds]) if (!alive.has(id)) handledTaskIds.delete(id)
for (const t of music.tasks) {
if (t.status !== 'done') continue
if (tempTaskIds.has(t.taskId)) {
// 先摘标记再入队:队列可能同步执行,重复入队会导致同一任务上传两次
tempTaskIds.delete(t.taskId)
const id = t.taskId
enqueueUpload(() => finishTempUpload(id))
continue
}
const wanted = uploadOnDoneTaskIds.has(t.taskId) || feiniu.autoUpload
if (!wanted || handledTaskIds.has(t.taskId)) continue
handledTaskIds.add(t.taskId)
uploadOnDoneTaskIds.delete(t.taskId)
const task = t
enqueueUpload(async () => {
const { total, ok } = await uploadTaskFiles(task, false)
if (total > 0 && ok > 0) toast.success(`已上传 ${ok}/${total} 首到飞牛曲库`)
})
}
// 临时任务下载失败/被取消:释放行按钮 spinner 与临时标记,避免永久转圈
for (const t of music.tasks) {
if (t.status === 'done' || t.status === 'downloading' || t.status === 'cancelling') continue
if (!tempTaskIds.has(t.taskId)) continue
tempTaskIds.delete(t.taskId)
releaseRowKey(t.taskId)
toast.error(`下载到飞牛失败:${t.errorMessage || '任务未完成,文件保留在本地下载目录'}`)
}
// 临时任务记录被手动删除(下载中移除):同样释放 spinner
for (const id of [...tempTaskIds]) {
if (alive.has(id)) continue
tempTaskIds.delete(id)
releaseRowKey(id)
}
}
watch(
() => music.tasks.map((t) => `${t.taskId}:${t.status}`).join('|'),
resolvePendingUploads
)
return {
rowUploadingKeys,
uploadTask,
uploadTaskFiles,
downloadToFeiniu,
markUploadOnDone,
armAfterInit
}
})