diff --git a/README.md b/README.md index 12920b6..316e3fc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,81 @@ -# Tauri + Vue + TypeScript +# Nekosonic -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` \ No newline at end of file +.roam-lyric-active:hover { + background: var(--c-subtle) !important; + color: var(--c-content) !important; +} + diff --git a/src/assets/app-icon.png b/src/assets/app-icon.png new file mode 100644 index 0000000..0a65c08 Binary files /dev/null and b/src/assets/app-icon.png differ diff --git a/src/components/CustomSelect.vue b/src/components/CustomSelect.vue new file mode 100644 index 0000000..5f19352 --- /dev/null +++ b/src/components/CustomSelect.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/components/PlayerBar.vue b/src/components/PlayerBar.vue index 5e213ec..f4d9d63 100644 --- a/src/components/PlayerBar.vue +++ b/src/components/PlayerBar.vue @@ -1,52 +1,44 @@ \ No newline at end of file + diff --git a/src/components/ToastContainer.vue b/src/components/ToastContainer.vue new file mode 100644 index 0000000..956021b --- /dev/null +++ b/src/components/ToastContainer.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/composables/UserLyric.ts b/src/composables/UserLyric.ts index bb4ff0e..ca15e15 100644 --- a/src/composables/UserLyric.ts +++ b/src/composables/UserLyric.ts @@ -1,4 +1,4 @@ -import { ref, computed, watch } from 'vue'; +import { ref, watch } from 'vue'; import { invoke } from '@tauri-apps/api/core'; import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric'; import { usePlayerStore } from '../stores/player'; @@ -9,12 +9,6 @@ export function useLyric() { const lyrics = ref([]); const currentLyricIdx = ref(-1); - const currentLyricText = computed(() => { - if (lyrics.value.length === 0) return ''; - const idx = currentLyricIdx.value; - return idx >= 0 && idx < lyrics.value.length ? lyrics.value[idx].text : ''; - }); - watch(() => player.currentSong, async (song) => { if (!song) { lyrics.value = []; @@ -43,6 +37,5 @@ export function useLyric() { return { lyrics, currentLyricIdx, - currentLyricText, }; } \ No newline at end of file diff --git a/src/composables/useToast.ts b/src/composables/useToast.ts new file mode 100644 index 0000000..0dba215 --- /dev/null +++ b/src/composables/useToast.ts @@ -0,0 +1,22 @@ +import { ref } from 'vue'; + +export interface Toast { + id: number; + message: string; + type: 'success' | 'error' | 'info'; +} + +const toasts = ref([]); +let nextId = 0; + +export function showToast(message: string, type: 'success' | 'error' | 'info' = 'info', duration = 3000) { + const id = nextId++; + toasts.value.push({ id, message, type }); + setTimeout(() => { + toasts.value = toasts.value.filter(t => t.id !== id); + }, duration); +} + +export function useToast() { + return { toasts, showToast }; +} diff --git a/src/router/index.ts b/src/router/index.ts index 32d647b..67134d6 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -6,18 +6,20 @@ import Login from '@/views/Login.vue'; import FavoriteSongs from '@/views/FavoriteSongs.vue'; import RecentPlays from '@/views/RecentPlays.vue'; import DailySongs from '@/views/DailySongs.vue'; +import Settings from '@/views/Settings.vue'; const routes = [ { path: '/', name: 'home', component: Home }, { path: '/discover', name: 'discover', component: Discover }, - { path: '/search', name: 'search', component: Discover }, // 同样指向Discover,保留兼容 - { path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') }, // 漫游页面 + { path: '/search', name: 'search', component: Discover }, + { path: '/roam', name: 'roam', component: () => import('@/views/Roam.vue') }, { path: '/favorites', name: 'favorites', component: FavoriteSongs }, { path: '/recent', name: 'recent', component: RecentPlays }, - { path: '/daily', name: 'daily', component: DailySongs }, // 每日推荐 + { path: '/daily', name: 'daily', component: DailySongs }, { path: '/login', name: 'login', component: Login }, { path: '/playlist/:id', name: 'playlist', component: PlaylistDetail }, + { path: '/settings', name: 'settings', component: Settings }, ]; export default createRouter({ diff --git a/src/stores/player.ts b/src/stores/player.ts index 2d27359..2bcab6b 100644 --- a/src/stores/player.ts +++ b/src/stores/player.ts @@ -2,8 +2,9 @@ import { defineStore } from 'pinia'; import { ref , watch } from 'vue'; import { invoke } from '@tauri-apps/api/core'; import { normalizeSong } from '../utils/song'; +import { useSettingsStore } from './settings'; +import { useUserStore } from './user'; -// 设置播放模式,目前只有顺序循环,后续可扩展 export type PlayMode = 'loop' | 'shuffle' | 'repeat-one'; export interface Song { @@ -33,6 +34,22 @@ export function setupCacheProgressListener() { // 在 store 定义外调用 setupCacheProgressListener(),或者在应用入口调用 +function loadRecentLocal(): Song[] { + try { + const raw = localStorage.getItem('recent_local'); + if (raw) return JSON.parse(raw); + } catch {} + return []; +} + +function loadLikedIdsFromStorage(): Set { + try { + const raw = localStorage.getItem('liked_ids'); + if (raw) return new Set(JSON.parse(raw)); + } catch {} + return new Set(); +} + export const usePlayerStore = defineStore('player', () => { const currentSong = ref(null); const playing = ref(false); @@ -44,6 +61,56 @@ export const usePlayerStore = defineStore('player', () => { let tickInterval: ReturnType | null = null; + const recentLocal = ref(loadRecentLocal()); + const MAX_RECENT = 200; + + const likedIds = ref>(loadLikedIdsFromStorage()); + + function isLiked(songId: number): boolean { + return likedIds.value.has(songId); + } + + async function loadLikedIds() { + const userStore = useUserStore(); + if (!userStore.isLoggedIn) return; + try { + const json: string = await invoke('likelist', { uid: userStore.user!.userId }); + const data = JSON.parse(json); + const ids: number[] = data.ids || data.data?.ids || []; + likedIds.value = new Set(ids); + } catch { /* 忽略 */ } + } + + async function toggleLike(songId: number) { + const wasLiked = likedIds.value.has(songId); + const newLike = !wasLiked; + try { + await invoke('like_song', { query: { id: songId, like: newLike ? 'true' : 'false' } }); + if (newLike) { + likedIds.value.add(songId); + } else { + likedIds.value.delete(songId); + } + likedIds.value = new Set(likedIds.value); + } catch { /* 忽略 */ } + } + + function addRecent(song: Song) { + recentLocal.value = recentLocal.value.filter(s => s.id !== song.id); + recentLocal.value.unshift(song); + if (recentLocal.value.length > MAX_RECENT) { + recentLocal.value = recentLocal.value.slice(0, MAX_RECENT); + } + } + + watch(recentLocal, (val) => { + localStorage.setItem('recent_local', JSON.stringify(val)); + }, { deep: true }); + + watch(likedIds, (val) => { + localStorage.setItem('liked_ids', JSON.stringify([...val])); + }, { deep: true }); + const isFmMode = ref(false); let fmNextCallback: (() => void) | null = null; @@ -62,7 +129,7 @@ export const usePlayerStore = defineStore('player', () => { // 如果缺少时长,尝试从详情接口获取 if (!song.dt || song.dt === 0) { try { - const jsonStr: string = await invoke('get_song_detail', { id: Number(song.id) }); + const jsonStr: string = await invoke('get_song_detail', { id: String(song.id) }); const data = JSON.parse(jsonStr); const full = data.songs?.[0]; if (full) { @@ -80,13 +147,15 @@ export const usePlayerStore = defineStore('player', () => { currentSong.value = song; try { - const url: string = await invoke('get_song_url', { id: Number(song.id) }); + const settings = useSettingsStore(); + const url: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } }); if (!url) throw new Error('无播放源'); await invoke('play_audio', { url }); playing.value = true; duration.value = (song.dt || 0) / 1000; currentTime.value = 0; startTick(); + addRecent(song); } catch (e) { console.error('FM播放失败', e); playing.value = false; @@ -122,8 +191,8 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0; duration.value = (song.dt || 0) / 1000; - // 获取 URL 并播放 - const url: string = await invoke('get_song_url', { id: Number(song.id) }); + const settings = useSettingsStore(); + const url: string = await invoke('get_song_url', { query: { id: Number(song.id), level: settings.audioQuality } }); if (!url) { console.error('未获取到有效播放地址', song); return; @@ -132,6 +201,7 @@ export const usePlayerStore = defineStore('player', () => { await invoke('play_audio', { url }); playing.value = true; startTick(); + addRecent(song); } catch (e) { console.error('播放失败', e); playing.value = false; @@ -279,6 +349,10 @@ export const usePlayerStore = defineStore('player', () => { showRoamDrawer.value = false; } + function toggleRoamDrawer() { + showRoamDrawer.value = !showRoamDrawer.value; + } + async function loadFirstFmSong() { try { const jsonStr: string = await invoke('personal_fm'); @@ -388,9 +462,17 @@ watch(playing, (val) => { removeFromQueue, clearQueue, + recentLocal, + + likedIds, + isLiked, + loadLikedIds, + toggleLike, + showRoamDrawer, openRoamDrawer, closeRoamDrawer, + toggleRoamDrawer, loadFirstFmSong, fmSong, diff --git a/src/stores/settings.ts b/src/stores/settings.ts new file mode 100644 index 0000000..12719ec --- /dev/null +++ b/src/stores/settings.ts @@ -0,0 +1,86 @@ +import { defineStore } from 'pinia'; +import { ref, watch } from 'vue'; + +export type AudioQuality = 'standard' | 'higher' | 'exhigh' | 'lossless' | 'hires'; +export type ThemeMode = 'dark' | 'light'; +export type CloseAction = 'ask' | 'minimize' | 'exit'; + +export const qualityLabels: Record = { + standard: '标准', + higher: '较高', + exhigh: '极高 (HQ)', + lossless: '无损 (SQ)', + hires: 'Hi-Res', +}; + +export const closeActionLabels: Record = { + ask: '每次询问', + minimize: '最小化到托盘', + exit: '直接退出', +}; + +interface SettingsData { + audioQuality: AudioQuality; + downloadPath: string; + theme: ThemeMode; + closeAction: CloseAction; +} + +function loadSettings(): SettingsData { + try { + const raw = localStorage.getItem('app_settings'); + if (raw) return JSON.parse(raw); + } catch {} + return { + audioQuality: 'standard', + downloadPath: '', + theme: 'dark', + closeAction: 'ask', + }; +} + +export const useSettingsStore = defineStore('settings', () => { + const saved = loadSettings(); + + const audioQuality = ref(saved.audioQuality); + const downloadPath = ref(saved.downloadPath); + const theme = ref(saved.theme); + const closeAction = ref(saved.closeAction || 'ask'); + + function setAudioQuality(q: AudioQuality) { + audioQuality.value = q; + } + + function setDownloadPath(p: string) { + downloadPath.value = p; + } + + function setTheme(t: ThemeMode) { + theme.value = t; + } + + function setCloseAction(a: CloseAction) { + closeAction.value = a; + } + + watch([audioQuality, downloadPath, theme, closeAction], () => { + const data: SettingsData = { + audioQuality: audioQuality.value, + downloadPath: downloadPath.value, + theme: theme.value, + closeAction: closeAction.value, + }; + localStorage.setItem('app_settings', JSON.stringify(data)); + }, { deep: true }); + + return { + audioQuality, + downloadPath, + theme, + closeAction, + setAudioQuality, + setDownloadPath, + setTheme, + setCloseAction, + }; +}); diff --git a/src/style.css b/src/style.css index 77c08af..193f667 100644 --- a/src/style.css +++ b/src/style.css @@ -1,14 +1,74 @@ @import "tailwindcss"; +@theme { + --color-base: var(--c-bg); + --color-surface: var(--c-surface); + --color-subtle: var(--c-subtle); + --color-muted: var(--c-muted); + --color-emphasis: var(--c-emphasis); + --color-content: var(--c-content); + --color-content-2: var(--c-content-2); + --color-content-3: var(--c-content-3); + --color-content-4: var(--c-content-4); + --color-line: var(--c-line); + --color-line-2: var(--c-line-2); + --color-accent: var(--c-accent); + --color-accent-hover: var(--c-accent-hover); + --color-accent-text: var(--c-accent-text); + --color-accent-dim: var(--c-accent-dim); + --color-danger: var(--c-danger); + --color-danger-dim: var(--c-danger-dim); + --color-warning: var(--c-warning); + --color-info: var(--c-info); +} + @layer base { :root { - --color-surface: 255 255 255; - --color-primary: 34 197 94; + --c-bg: #030712; + --c-surface: #111827; + --c-subtle: rgba(255, 255, 255, 0.05); + --c-muted: rgba(255, 255, 255, 0.10); + --c-emphasis: rgba(255, 255, 255, 0.18); + --c-content: #ffffff; + --c-content-2: #9ca3af; + --c-content-3: #6b7280; + --c-content-4: #4b5563; + --c-line: rgba(255, 255, 255, 0.10); + --c-line-2: rgba(255, 255, 255, 0.05); + --c-accent: #22c55e; + --c-accent-hover: #16a34a; + --c-accent-text: #4ade80; + --c-accent-dim: rgba(34, 197, 94, 0.20); + --c-danger: #ef4444; + --c-danger-dim: rgba(239, 68, 68, 0.20); + --c-warning: #eab308; + --c-info: #3b82f6; + } + + [data-theme="light"] { + --c-bg: #f3f4f6; + --c-surface: #ffffff; + --c-subtle: rgba(0, 0, 0, 0.04); + --c-muted: rgba(0, 0, 0, 0.08); + --c-emphasis: rgba(0, 0, 0, 0.12); + --c-content: #111827; + --c-content-2: #4b5563; + --c-content-3: #6b7280; + --c-content-4: #9ca3af; + --c-line: rgba(0, 0, 0, 0.10); + --c-line-2: rgba(0, 0, 0, 0.05); + --c-accent: #16a34a; + --c-accent-hover: #15803d; + --c-accent-text: #16a34a; + --c-accent-dim: rgba(22, 163, 74, 0.15); + --c-danger: #dc2626; + --c-danger-dim: rgba(220, 38, 38, 0.15); + --c-warning: #ca8a04; + --c-info: #2563eb; } - /* 确保 html 也应用暗色背景,防止空白区域 */ html { - background: #0f172a; + background: var(--c-bg); overflow: hidden; height: 100%; overscroll-behavior: none; @@ -17,17 +77,14 @@ body { @apply antialiased; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%); - /* 关键:锁住 body,彻底消除整体拖动 */ + background: var(--c-bg); position: fixed; inset: 0; overflow: hidden; overscroll-behavior: none; - /* 阻止触控板手势触发页面导航 */ touch-action: none; } - /* 自定义滚动条保持不变 */ ::-webkit-scrollbar { width: 5px; height: 5px; @@ -36,10 +93,64 @@ background: transparent; } ::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.2); + background-color: var(--c-muted); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.4); + background-color: var(--c-emphasis); } -} \ No newline at end of file + + select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 30px; + cursor: pointer; + } + + select:focus { + border-color: var(--c-accent); + box-shadow: 0 0 0 2px var(--c-accent-dim); + } + + select option { + background: var(--c-surface); + color: var(--c-content); + padding: 8px; + } + + input[type="checkbox"] { + appearance: none; + width: 16px; + height: 16px; + border: 2px solid var(--c-emphasis); + border-radius: 4px; + background: transparent; + cursor: pointer; + position: relative; + transition: all 0.15s ease; + flex-shrink: 0; + } + + input[type="checkbox"]:hover { + border-color: var(--c-accent); + } + + input[type="checkbox"]:checked { + background: var(--c-accent); + border-color: var(--c-accent); + } + + input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } +} diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..1a4725d --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,20 @@ +export function formatDuration(ms: number): string { + const sec = Math.floor(ms / 1000); + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export function formatTime(sec: number): string { + if (!sec || isNaN(sec)) return '0:00'; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export function formatPlayCount(count: number): string { + if (!count) return '0'; + if (count >= 100000000) return (count / 100000000).toFixed(1) + '亿'; + if (count >= 10000) return (count / 10000).toFixed(1) + '万'; + return count.toString(); +} diff --git a/src/views/DailySongs.vue b/src/views/DailySongs.vue index 341a64c..4341a53 100644 --- a/src/views/DailySongs.vue +++ b/src/views/DailySongs.vue @@ -1,26 +1,52 @@ + + diff --git a/src/views/Home.vue b/src/views/Home.vue index fc0aa99..b887a9e 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -1,7 +1,7 @@ + + diff --git a/src/views/Roam.vue b/src/views/Roam.vue index 8546c92..0371f7c 100644 --- a/src/views/Roam.vue +++ b/src/views/Roam.vue @@ -1,51 +1,44 @@