v0.5.1: 修复缓存/FM/翻译/Linux等问题

This commit is contained in:
2026-05-25 19:38:48 +08:00
parent 65ed71503e
commit 6da544cffb
16 changed files with 147 additions and 33 deletions

View File

@ -1,3 +1,17 @@
## v0.5.1
### 🐛 修复
- 修复页面缓存不刷新的问题:切换回已缓存的页面时数据永远不更新,现在超过 5 分钟会自动重新加载
- 修复本地音乐页面空列表时刷新按钮不显示的问题
- 修复修改下载路径后本地音乐列表不更新的问题,现在会自动刷新
- 修复私人 FM 播放约二三十首后循环重复的问题:新增听歌打卡上报,服务端推荐不再重复
- 修复歌词界面切换翻译开关时歌词未居中的问题
- 修复 Linux 下从外部控制暂停时进度条跳回 0 的问题MPRIS 现在正确报告播放进度位置
### ⚡ 优化
- 私人 FM 预取队列优化,队列剩余不足时自动后台拉取下一批
## v0.5.0
### ✨ 新功能

View File

@ -1,7 +1,7 @@
{
"name": "nekosonic",
"private": true,
"version": "0.3.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite",

2
src-tauri/Cargo.lock generated
View File

@ -4,7 +4,7 @@ version = 4
[[package]]
name = "Nekosonic"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"base64 0.22.1",
"cpal",

View File

@ -1,6 +1,6 @@
[package]
name = "Nekosonic"
version = "0.5.0"
version = "0.5.1"
description = "A Simple music app"
authors = ["atdunbg"]
edition = "2021"

View File

@ -318,6 +318,21 @@ pub async fn personal_fm(state: State<'_, ApiController>) -> Result<String, Stri
api_call!(state, personal_fm)
}
/// 听歌打卡查询参数
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScrobbleQuery {
pub id: u64,
pub sourceid: Option<String>,
pub time: u64,
}
/// 听歌打卡
#[tauri::command]
pub async fn scrobble(query: ScrobbleQuery, state: State<'_, ApiController>) -> Result<String, String> {
api_call!(state, scrobble, params: [("id", &query.id.to_string()), ("sourceid", query.sourceid.as_deref().unwrap_or("")), ("time", &query.time.to_string())])
}
/// 获取歌曲详情
#[tauri::command]
pub async fn get_song_detail(id: String, state: State<'_, ApiController>) -> Result<String, String> {

View File

@ -145,6 +145,7 @@ pub fn run() {
api::recommend_resource,
api::recommend_songs,
api::personal_fm,
api::scrobble,
api::get_song_detail,
api::get_qr_key,
api::create_qr,

View File

@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Listener};
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback,
PlatformConfig, SeekDirection,
MediaPosition, PlatformConfig, SeekDirection,
};
struct MediaState {
@ -79,9 +79,15 @@ pub fn start_media_controls(app_handle: AppHandle, hwnd: Option<*mut std::ffi::c
};
if let Some(status) = data.get("status").and_then(|v| v.as_str()) {
let position_us = data.get("positionUs").and_then(|v| v.as_i64()).unwrap_or(0);
let progress = if position_us > 0 {
Some(MediaPosition(std::time::Duration::from_micros(position_us as u64)))
} else {
None
};
let playback = match status {
"playing" => MediaPlayback::Playing { progress: None },
"paused" => MediaPlayback::Paused { progress: None },
"playing" => MediaPlayback::Playing { progress },
"paused" => MediaPlayback::Paused { progress },
_ => MediaPlayback::Stopped,
};
let _ = s.controls.set_playback(playback);

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Nekosonic",
"version": "0.5.0",
"version": "0.5.1",
"identifier": "com.atdunbg.Nekosonic",
"build": {
"beforeDevCommand": "npm run dev",

View File

@ -375,6 +375,12 @@ watch(currentLyricIdx, () => {
}
});
watch(showTranslation, () => {
if (player.showRoamDrawer && !roamLyricHovering.value) {
nextTick(() => scrollToRoamActiveLyric());
}
});
function scrollToRoamActiveLyric() {
if (!lyricScrollContainer.value || roamLyricHovering.value) return;
const active = lyricScrollContainer.value.querySelector('.roam-lyric-active') as HTMLElement | null;

View File

@ -22,3 +22,9 @@ export function pageCacheDelete(key: string) {
export function pageCacheInvalidate(key: string) {
cache.delete(key);
}
export function pageCacheIsStale(key: string): boolean {
const entry = cache.get(key);
if (!entry) return true;
return Date.now() - entry.ts > TTL;
}

View File

@ -111,8 +111,34 @@ export const usePlayerStore = defineStore('player', () => {
}, { deep: true });
const isFmMode = ref(false);
const fmQueue: Song[] = [];
let fmNextCallback: (() => void) | null = null;
let lastScrobbleId: number | null = null;
let lastScrobbleStartTime: number = 0;
function reportScrobble() {
const song = currentSong.value;
if (!song || song.localPath || song.id == null) {
lastScrobbleId = null;
return;
}
if (lastScrobbleId === song.id && lastScrobbleStartTime > 0) {
const playedSec = Math.round((Date.now() - lastScrobbleStartTime) / 1000);
if (playedSec > 5) {
invoke('scrobble', {
query: {
id: song.id,
sourceid: isFmMode.value ? String(song.id) : '',
time: playedSec,
},
}).catch(() => {});
}
}
lastScrobbleId = song.id;
lastScrobbleStartTime = Date.now();
}
function enableFmMode(onNext: () => void) {
isFmMode.value = true;
fmNextCallback = onNext;
@ -121,6 +147,15 @@ export const usePlayerStore = defineStore('player', () => {
function disableFmMode() {
isFmMode.value = false;
fmNextCallback = null;
fmQueue.length = 0;
}
async function fetchFmBatch(): Promise<Song[]> {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const raw = data.data || data;
if (!Array.isArray(raw) || raw.length === 0) return [];
return raw.map((s: any) => normalizeSong(s));
}
let fmVipSkipCount = 0;
@ -128,6 +163,7 @@ export const usePlayerStore = defineStore('player', () => {
async function playFmSong(song: Song) {
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
reportScrobble();
if (!song.dt || song.dt === 0) {
try {
const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) });
@ -248,6 +284,7 @@ export const usePlayerStore = defineStore('player', () => {
async function playCurrent() {
if (tickInterval) { clearInterval(tickInterval); setTickInterval(null); }
reportScrobble();
const song = queue.value[currentIndex.value];
if (!song?.id) {
console.error('无效的歌曲数据', song);
@ -494,12 +531,11 @@ export const usePlayerStore = defineStore('player', () => {
async function loadFirstFmSong() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
enableFmMode(() => loadFirstFmSong());
const batch = await fetchFmBatch();
if (batch.length > 0) {
fmQueue.push(...batch);
const song = fmQueue.shift()!;
enableFmMode(nextFm);
await playFmSong(song);
return true;
}
@ -516,15 +552,18 @@ const fmPlaying = ref(false);
async function loadFm() {
try {
const jsonStr: string = await invoke('personal_fm');
const data = JSON.parse(jsonStr);
const songs = data.data || data;
if (songs && songs.length > 0) {
const song = normalizeSong(songs[0]);
fmSong.value = song;
enableFmMode(nextFm);
await playFmSong(song);
fmPlaying.value = true;
if (fmQueue.length === 0) {
const batch = await fetchFmBatch();
if (batch.length === 0) return;
fmQueue.push(...batch);
}
const song = fmQueue.shift()!;
fmSong.value = song;
enableFmMode(nextFm);
await playFmSong(song);
fmPlaying.value = true;
if (fmQueue.length <= 1) {
fetchFmBatch().then(batch => { fmQueue.push(...batch); }).catch(() => {});
}
} catch (e) {
console.error('FM加载失败', e);
@ -565,6 +604,7 @@ listen('audio-started', () => {
listen('audio-ended', () => {
if (_tickInterval) { clearInterval(_tickInterval); _tickInterval = null; }
const player = usePlayerStore();
player.reportScrobble();
player.next();
});
@ -597,6 +637,9 @@ listen<string>('mpris-command', (event) => {
} else if (cmd.startsWith('SetPosition:')) {
const posUs = parseInt(cmd.slice(13), 10);
const posSec = posUs / 1_000_000;
if (posSec < 1 && player.currentTime > 5) {
return;
}
player.seek(posSec);
} else if (cmd === 'Raise') {
getCurrentWindow().show().catch(() => {});
@ -666,6 +709,8 @@ watch(playing, (val) => {
toggleRoamDrawer,
loadFirstFmSong,
reportScrobble,
fmSong,
fmPlaying,
loadFm,

View File

@ -51,11 +51,11 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import SongListItem from '../components/SongListItem.vue';
import { usePlayerStore } from '../stores/player';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { normalizeSong, type Song } from '../utils/song';
import { useOnlineStatus } from '../composables/useOnlineStatus';
@ -92,6 +92,10 @@ async function loadData() {
onMounted(loadData);
onActivated(() => {
if (pageCacheIsStale('dailySongs')) loadData();
});
watch(isOnline, (val, old) => {
if (val && !old && songs.value.length === 0) {
pageCacheInvalidate('dailySongs');

View File

@ -44,13 +44,13 @@
<script setup lang="ts">
defineOptions({ name: 'DiscoverView' });
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import SongListItem from '../components/SongListItem.vue';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
const router = useRouter();
@ -89,6 +89,10 @@ onMounted(async () => {
}
});
onActivated(() => {
if (pageCacheIsStale('discover_hotTags')) loadHotTags();
});
watch(isOnline, (val, old) => {
if (val && !old && hotTags.value.length === 0) {
pageCacheInvalidate('discover_hotTags');

View File

@ -36,13 +36,13 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import SongListItem from '../components/SongListItem.vue';
import { usePlayerStore } from '../stores/player';
import { useUserStore } from '../stores/user';
import { normalizeSong, type Song } from '../utils/song';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
defineOptions({ name: 'FavoriteSongsView' });
@ -87,6 +87,10 @@ async function loadData() {
onMounted(loadData);
onActivated(() => {
if (pageCacheIsStale('favoriteSongs')) loadData();
});
watch(isOnline, (val, old) => {
if (val && !old && userStore.isLoggedIn && songs.value.length === 0) {
pageCacheInvalidate('favoriteSongs');

View File

@ -109,12 +109,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { pageCacheGet, pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import { useOnlineStatus } from '../composables/useOnlineStatus';
import { getCoverUrl } from '../utils/song';
@ -199,6 +199,10 @@ onMounted(async () => {
await loadData();
});
onActivated(() => {
if (pageCacheIsStale('home')) loadData();
});
watch(isOnline, (val, old) => {
if (val && !old && rankPlaylists.value.length === 0 && recPlaylists.value.length === 0) {
pageCacheInvalidate('home');

View File

@ -7,7 +7,6 @@
<h1 class="text-2xl font-bold">本地音乐</h1>
<span v-if="songs.length" class="text-xs text-content-3">{{ songs.length }} </span>
<button
v-if="songs.length"
@click="refresh"
class="px-3 py-1 bg-muted hover:bg-emphasis rounded-full text-xs transition"
>
@ -75,13 +74,13 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed, onMounted, onActivated, onBeforeUnmount, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useDownload } from '../composables/useDownload';
import { useSettingsStore } from '../stores/settings';
import { showToast } from '../composables/useToast';
import { pageCacheSet, pageCacheInvalidate } from '../composables/usePageCache';
import { pageCacheSet, pageCacheInvalidate, pageCacheIsStale } from '../composables/usePageCache';
import SongListItem from '../components/SongListItem.vue';
import type { Song } from '../utils/song';
@ -159,6 +158,12 @@ async function fetchMissingCovers() {
onMounted(refresh);
onActivated(() => {
if (pageCacheIsStale('localMusic')) refresh();
});
watch(() => settings.downloadPath, () => { refresh(); });
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];