音乐模块调整
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -29,16 +29,18 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
|
||||
* ```
|
||||
*
|
||||
* ## 工作原理
|
||||
* 1. onMounted 时注册标签到 moduleTabsStore,TitleBar 据此渲染浮动切换器
|
||||
* 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
|
||||
|
||||
+466
-363
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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:none→block,
|
||||
而 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:5666、frp 域名或 FnConnect fnId)。
|
||||
还没有连接。新建一个并填写 NAS 地址(局域网 http://192.168.x.x:5666,frp 等内网转发同样填最终访问地址)或 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 fnId(fnos.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.com(frp 填转发后的地址)"
|
||||
/>
|
||||
</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
File diff suppressed because it is too large
Load Diff
@@ -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>>({})
|
||||
/** 当前已注册的模块 id(unregister 时据此保存) */
|
||||
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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user