This commit is contained in:
2026-09-12 18:38:30 +08:00
parent 4f574cb5fa
commit 80d764ed51
11 changed files with 206 additions and 29 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + Typescript App</title>
<title>Thing</title>
</head>
<body>
+9 -7
View File
@@ -44,13 +44,14 @@ use monitor_kernel::{
};
use music::{
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config,
feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric,
feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection,
music_cancel_runtime_install, music_download, music_download_cancel, music_env_status,
music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping,
music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete,
webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload, MusicManager,
feiniu_cover_data, feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve,
feiniu_get_config, feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout,
feiniu_lyric, feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local,
feiniu_test_connection, music_cancel_runtime_install, music_download, music_download_cancel,
music_env_status, music_get_settings, music_get_sources, music_install_runtime,
music_parse_playlist, music_ping, music_resolve, music_save_settings, music_search,
music_stop_bridge, webdav_delete, webdav_get_secret, webdav_save_secret, webdav_test,
webdav_upload, MusicManager,
};
use network_monitor::network_status;
use osd_window::{
@@ -281,6 +282,7 @@ pub fn run() {
feiniu_get_config,
feiniu_list_tracks,
feiniu_lyric,
feiniu_cover_data,
feiniu_media_prefix,
feiniu_scan_local,
feiniu_cache_status,
+22
View File
@@ -380,6 +380,28 @@ pub async fn feiniu_lyric(
Ok(json!({ "lyric": text }))
}
/// 封面 data URLWindows SMTC / MediaSession artwork 用):
/// Rust 侧取 NAS 封面字节转 base64,规避 WebView 对 artwork 的 CORS 要求。
/// 返回 Value 且未标注 specta:前端直接按 JSON 使用(同 music_ping 豁免模式)。
#[tauri::command]
pub async fn feiniu_cover_data(
state: State<'_, MusicManager>,
cover_id: String,
size: Option<u32>,
) -> Result<serde_json::Value, String> {
state.feiniu.sync_with_settings(&state.load_settings());
let (bytes, ct) = state
.feiniu
.cover_bytes(&cover_id, size.unwrap_or(320))
.await?;
use base64::Engine;
let data = format!(
"data:{ct};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
);
Ok(json!({ "data": data }))
}
/// 本地媒体地址前缀:{ mediaPrefix }(首次调用惰性启动本地流代理)。
#[tauri::command]
pub async fn feiniu_media_prefix(
+38
View File
@@ -315,6 +315,44 @@ impl Feiniu {
Ok(extract_lyric_text(&v))
}
/// 封面二进制(MediaSession artwork 用):`GET /music/api/v1/static/cover`。
/// 返回 (字节, content-type)。与流代理同款认证注入,但不走代理——
/// 前端拿到的是 data URL,规避 WebView 对 artwork 的 CORS 要求。
pub async fn cover_bytes(&self, cover_id: &str, size: u32) -> Result<(Vec<u8>, String), String> {
let (base, token, access_code) = self.auth_triple();
if base.is_empty() || token.is_empty() {
return Err("未登录".into());
}
let (_, client) = self.conn_client();
let query = [
("coverId".to_string(), cover_id.to_string()),
("size".to_string(), size.to_string()),
];
let mut rb = client.get(format!("{base}/music/api/v1/static/cover")).query(&query);
rb = rb.header("cookie", format!("music-token={token}"));
if !access_code.is_empty() {
use base64::Engine;
rb = rb
.header(
"x-access-code",
base64::engine::general_purpose::STANDARD.encode(access_code.as_bytes()),
)
.header("x-access-source", "app");
}
let resp = rb.send().await.map_err(|e| format!("请求失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("请求失败(HTTP {}", resp.status().as_u16()));
}
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let bytes = resp.bytes().await.map_err(|e| format!("读取失败: {e}"))?;
Ok((bytes.to_vec(), ct))
}
/// 本地媒体地址前缀:`http://127.0.0.1:<port>/feiniu`。首次调用惰性启动代理。
pub async fn media_prefix(&self) -> Result<String, String> {
let port = self.ensure_proxy().await?;
+8 -7
View File
@@ -24,13 +24,14 @@ pub use feiniu::{extract_fn_id, normalize_base_url, resolve_base_url, Feiniu, Fe
pub use commands::{
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config,
feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric,
feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection,
music_cancel_runtime_install, music_download, music_download_cancel, music_env_status,
music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping,
music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete,
webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload,
feiniu_cover_data, feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve,
feiniu_get_config, feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout,
feiniu_lyric, feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local,
feiniu_test_connection, music_cancel_runtime_install, music_download, music_download_cancel,
music_env_status, music_get_settings, music_get_sources, music_install_runtime,
music_parse_playlist, music_ping, music_resolve, music_save_settings, music_search,
music_stop_bridge, webdav_delete, webdav_get_secret, webdav_save_secret, webdav_test,
webdav_upload,
};
use serde::{Deserialize, Serialize};
+1 -1
View File
@@ -24,7 +24,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src ipc: http://ipc.localhost; media-src 'self' data: blob:"
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost http: https:; font-src 'self' data:; connect-src ipc: http://ipc.localhost http: https:; media-src 'self' data: blob: file: http: https:"
}
},
"bundle": {
+127 -11
View File
@@ -96,14 +96,14 @@ function writeLS(key: string, value: string) {
}
}
/** 把浏览器抛出的播放异常翻译成可读文案 */
function describePlayError(e: unknown): string {
const name = (e as { name?: string })?.name ?? ''
if (name === 'NotSupportedError') return '该音频无法播放:格式不支持或链接已失效'
if (name === 'NotAllowedError') return '播放被浏览器策略阻止,请再次点击播放'
if (name === 'AbortError') return '播放已中断'
return `播放失败:${e instanceof Error ? e.message : String(e)}`
}
/** 把浏览器抛出的播放异常翻译成可读文案 */
function describePlayError(e: unknown): string {
const name = (e as { name?: string })?.name ?? ''
if (name === 'NotSupportedError') return '该音频无法播放:格式不支持或链接已失效'
if (name === 'NotAllowedError') return '播放被浏览器策略阻止,请再次点击播放'
if (name === 'AbortError') return '播放已中断'
return `播放失败:${e instanceof Error ? e.message : String(e)}`
}
export const useFeiniuStore = defineStore('feiniu', () => {
// ===== 连接 =====
@@ -185,6 +185,100 @@ export const useFeiniuStore = defineStore('feiniu', () => {
const shufflePool = ref<number[]>([])
const historyStack = ref<number[]>([])
// ===== Windows 系统媒体(SMTC)集成 =====
// WebView2 会把页面媒体暴露给系统媒体浮层/媒体键,元数据需通过 MediaSession API 提供;
// 不提供时系统显示的是 document.title(此前为 "Tauri+Vue+Typescript App")。
/** 封面 data URL 缓存(coverId → dataUrl),避免同一首歌重复取封面 */
const smtcCoverCache = new Map<string, string>()
/** SMTC 进度上报节流(timeupdate ~4Hz1s 一次足够) */
let smtcLastPosAt = 0
/** 设置系统媒体浮层的歌曲信息(歌名/歌手/专辑/封面)。封面为飞牛曲目时经
* Rust 命令取字节转 data URL——MediaSession artwork 由 WebView 以 CORS 模式
* 拉取,代理地址无 CORS 头会失败,data URL 可绕过。 */
function updateMediaSession(item: PlayableItem) {
if (!('mediaSession' in navigator)) return
const base = {
title: item.title,
artist: item.artistNames || '未知歌手',
album: item.album || ''
}
if (item.source === 'feiniu' && item.coverId) {
const key = item.coverId
const cached = smtcCoverCache.get(key)
// 歌名/歌手立即上屏,封面就绪后补挂
navigator.mediaSession.metadata = new MediaMetadata({
...base,
artwork: cached ? [{ src: cached, sizes: '320x320' }] : []
})
if (!cached) {
invoke<{ data: string }>('feiniu_cover_data', { coverId: key, size: 320 })
.then((r) => {
if (!r?.data) return
if (smtcCoverCache.size > 50) smtcCoverCache.clear()
smtcCoverCache.set(key, r.data)
// 播放期间用户可能已切歌:仅当仍是这首时才刷新封面
const cur = navigator.mediaSession.metadata
if (cur?.title === item.title) {
navigator.mediaSession.metadata = new MediaMetadata({
...base,
artwork: [{ src: r.data, sizes: '320x320' }]
})
}
})
.catch(() => {
/* 封面取不到就只显示文字信息 */
})
}
return
}
// preview 源有直链封面;local/无封面源仅文字信息
const artwork: MediaImage[] = []
if (item.coverUrl) artwork.push({ src: item.coverUrl, sizes: '320x320' })
navigator.mediaSession.metadata = new MediaMetadata({ ...base, artwork })
}
/** 注册系统媒体键/浮层按钮动作(一次性) */
function setupMediaSessionHandlers(a: HTMLAudioElement) {
if (!('mediaSession' in navigator)) return
const ms = navigator.mediaSession
try {
ms.setActionHandler('play', () => void a.play().catch(() => {}))
ms.setActionHandler('pause', () => a.pause())
ms.setActionHandler('previoustrack', () => prev())
ms.setActionHandler('nexttrack', () => next())
ms.setActionHandler('seekbackward', (d) => seek(Math.max(0, position.value - (d?.seekOffset ?? 10))))
ms.setActionHandler('seekforward', (d) => seek(position.value + (d?.seekOffset ?? 10)))
ms.setActionHandler('seekto', (d) => {
if (d.seekTime != null) seek(d.seekTime)
})
} catch {
/* 个别动作类型 WebView 不支持时忽略 */
}
}
function setSmtcState(state: 'playing' | 'paused') {
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = state
}
/** 同步播放进度到系统媒体浮层(SMTC 时间轴)。必须在时长已知后调用,
* 否则浮层只显示歌名没有进度条;position 越界会被 WebView 拒绝,做夹取。 */
function updateSmtcPosition() {
if (!('mediaSession' in navigator)) return
const d = duration.value
if (!isFinite(d) || d <= 0) return
try {
navigator.mediaSession.setPositionState({
duration: d,
playbackRate: audioEl?.playbackRate || 1,
position: Math.min(Math.max(position.value, 0), d)
})
} catch {
/* 元数据切换瞬间的非法参数(如新歌时长未就绪)忽略 */
}
}
function computeBuffered(a: HTMLAudioElement): number {
const d = a.duration
if (!isFinite(d) || d <= 0 || a.buffered.length === 0) return 0
@@ -204,28 +298,47 @@ export const useFeiniuStore = defineStore('feiniu', () => {
a.addEventListener('loadedmetadata', () => {
duration.value = isFinite(a.duration) ? a.duration : 0
bufferedPercent.value = computeBuffered(a)
updateSmtcPosition()
})
a.addEventListener('durationchange', () => {
duration.value = isFinite(a.duration) ? a.duration : 0
updateSmtcPosition()
})
a.addEventListener('timeupdate', () => (position.value = a.currentTime))
a.addEventListener('timeupdate', () => {
position.value = a.currentTime
const now = Date.now()
if (now - smtcLastPosAt >= 1000) {
smtcLastPosAt = now
updateSmtcPosition()
}
})
a.addEventListener('seeked', () => updateSmtcPosition())
a.addEventListener('progress', () => (bufferedPercent.value = computeBuffered(a)))
a.addEventListener('waiting', () => (loadingPlay.value = true))
a.addEventListener('canplay', () => (loadingPlay.value = false))
a.addEventListener('playing', () => {
loadingPlay.value = false
playing.value = true
setSmtcState('playing')
})
a.addEventListener('play', () => {
playing.value = true
setSmtcState('playing')
})
a.addEventListener('pause', () => {
playing.value = false
setSmtcState('paused')
})
a.addEventListener('play', () => (playing.value = true))
a.addEventListener('pause', () => (playing.value = false))
a.addEventListener('ended', () => onEnded())
a.addEventListener('error', () => {
// 主动清空 src 时也会触发 error,此时不应报错
if (!a.getAttribute('src')) return
loadingPlay.value = false
playing.value = false
setSmtcState('paused')
playError.value = '音频加载失败:网络不可达或链接已失效'
})
setupMediaSessionHandlers(a)
audioEl = a
}
return audioEl
@@ -238,6 +351,7 @@ export const useFeiniuStore = defineStore('feiniu', () => {
position.value = 0
duration.value = 0
bufferedPercent.value = 0
setSmtcState('paused')
if (audioEl) {
audioEl.pause()
audioEl.removeAttribute('src')
@@ -805,6 +919,7 @@ export const useFeiniuStore = defineStore('feiniu', () => {
const t = current.value
if (!t) return
preview.value = null
updateMediaSession(t)
if (t.source === 'feiniu') {
void loadLyric(t.guid)
} else {
@@ -872,6 +987,7 @@ export const useFeiniuStore = defineStore('feiniu', () => {
const a = ensureAudio()
a.pause()
preview.value = { ...item, source: 'preview' }
updateMediaSession(preview.value)
lyricLines.value = []
lyricRaw = ''
lyricIdx = -1
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

-1
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"error":"internal","message":".NET number values such as positive and negative infinity cannot be written as valid JSON. To make it work when using 'JsonSerializer', consider specifying 'JsonNumberHandling.AllowNamedFloatingPointLiterals' (see https://docs.microsoft.com/dotnet/api/system.text.json.serialization.jsonnumberhandling)."}