第一次提交

This commit is contained in:
2026-05-07 22:27:55 +08:00
commit 463e8e95b6
95 changed files with 13167 additions and 0 deletions

285
src/App.vue Normal file
View File

@ -0,0 +1,285 @@
<template>
<div class="flex flex-col h-screen bg-gray-950 text-white overflow-hidden">
<!-- ========= 自定义标题栏可拖拽无边框 ========= -->
<div
data-tauri-drag-region
class="h-10 flex items-center justify-between px-4 bg-gray-900/90 backdrop-blur select-none flex-shrink-0"
>
<span class="text-xs text-gray-400 font-medium ml-2">Nekosonic Music</span>
<div class="flex items-center gap-1.5">
<!-- 最小化 -->
<button @click="minimizeWindow" class="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-400 transition" title="最小化"></button>
<!-- 最大化 / 还原 -->
<button @click="toggleMaximize" class="w-3 h-3 rounded-full bg-green-500 hover:bg-green-400 transition" title="最大化/还原"></button>
<!-- 关闭 -->
<button @click="closeWindow" class="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition" title="关闭"></button>
</div>
</div>
<!-- 主体内容区 -->
<div class="flex flex-1 overflow-hidden">
<!-- 左侧导航无边框 -->
<nav class="w-56 flex-shrink-0 flex flex-col bg-gray-900/80 backdrop-blur">
<div class="flex-1 p-4 overflow-y-auto pb-24 flex flex-col">
<!-- 推荐 & 发现 -->
<div class="space-y-0.5">
<router-link to="/"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
active-class="!text-white !bg-white/10">
<span>🏠</span> 推荐
</router-link>
<router-link to="/discover"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5"
active-class="!text-white !bg-white/10">
<span>🔍</span> 发现
</router-link>
<button
@click="openRoamFromSidebar"
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-200 text-white/60 hover:text-white hover:bg-white/5 w-full text-left"
>
<span>🌀</span> 漫游
</button>
</div>
<!-- 我的 -->
<div class="mt-4 mb-1 pt-2">
<p class="text-xs text-gray-500 px-3 mb-1">我的</p>
<router-link to="/favorites"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
<span></span> 我喜欢的音乐
</router-link>
<router-link to="/recent"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 transition">
<span>🕐</span> 最近播放
</router-link>
</div>
<!-- 创建的歌单可折叠 -->
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
@click="showCreatedPlaylists = !showCreatedPlaylists">
<p class="text-xs text-gray-500">我的歌单</p>
<span class="text-xs text-gray-500 transition-transform"
:class="{ 'rotate-90': showCreatedPlaylists }"></span>
</div>
<div v-show="showCreatedPlaylists" class="space-y-0.5">
<div v-for="pl in createdPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
{{ pl.name }}
</div>
</div>
</div>
<!-- 收藏的歌单可折叠 -->
<div class="mt-4 mb-1 pt-2" v-if="userStore.isLoggedIn">
<div class="flex items-center justify-between px-3 mb-1 cursor-pointer"
@click="showSubPlaylists = !showSubPlaylists">
<p class="text-xs text-gray-500">收藏的歌单</p>
<span class="text-xs text-gray-500 transition-transform" :class="{ 'rotate-90': showSubPlaylists }"></span>
</div>
<div v-show="showSubPlaylists" class="space-y-0.5">
<div v-for="pl in subPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="px-3 py-1.5 rounded-lg text-sm text-white/60 hover:text-white hover:bg-white/5 cursor-pointer truncate transition">
{{ pl.name }}
</div>
</div>
</div>
<!-- 用户区域 -->
<div class="mt-auto pt-4">
<div v-if="!userStore.isLoggedIn" class="px-2 space-y-2">
<p class="text-xs text-gray-500">登录后享受个人歌单</p>
<router-link to="/login"
class="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 transition text-sm font-medium text-green-400">
<span>🔑</span> 立即登录
</router-link>
</div>
<div v-else class="flex items-center gap-3 px-2">
<img :src="userStore.user?.avatarUrl" class="w-8 h-8 rounded-full ring-2 ring-green-400/50" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ userStore.user?.nickname }}</p>
<button @click="userStore.logout()"
class="text-xs text-gray-500 hover:text-red-400 transition">退出登录</button>
</div>
</div>
</div>
</div>
</nav>
<!-- 主内容区 -->
<main class="flex-1 overflow-y-auto pb-24">
<router-view v-slot="{ Component }">
<keep-alive :max="3" include="HomeView,DiscoverView">
<component :is="Component" />
</keep-alive>
</router-view>
</main>
</div>
<!-- 全屏漫游抽屉 -->
<Transition name="drawer">
<div
v-if="player.showRoamDrawer"
class="fixed inset-0 z-50 flex flex-col backdrop-blur-xl bg-black/80"
>
<div class="h-16 flex items-center px-6 flex-shrink-0">
<button @click="player.closeRoamDrawer()" class="text-white/80 hover:text-white transition">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 min-h-0 flex px-8 pb-8">
<div class="flex-shrink-0 mr-12 flex flex-col items-center self-center">
<img
:src="roamSong?.al?.picUrl || roamSong?.album?.picUrl"
class="w-72 h-72 rounded-3xl object-cover shadow-2xl mb-4"
/>
<h1 class="text-2xl font-bold text-white">{{ roamSong?.name }}</h1>
<p class="text-gray-400 mt-2">{{ roamArtists }}</p>
</div>
<div ref="lyricScrollContainer" class="flex-1 min-h-0 overflow-y-auto custom-scroll px-4">
<div v-if="lyrics.length > 0" class="w-full max-w-lg mx-auto text-center space-y-3 py-8">
<p
v-for="(line, idx) in lyrics"
:key="idx"
:class="idx === currentLyricIdx ? 'text-green-400 font-medium text-lg transition' : 'text-gray-400 text-base'"
>
{{ line.text }}
</p>
</div>
<div v-else class="text-gray-500 text-center mt-8">暂无歌词</div>
</div>
</div>
</div>
</Transition>
<!-- 底部播放栏 -->
<PlayerBar />
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from './stores/user';
import PlayerBar from './components/PlayerBar.vue';
import { usePlayerStore } from './stores/player';
import { useLyric } from './composables/UserLyric';
import { getCurrentWindow } from '@tauri-apps/api/window';
const router = useRouter();
const userStore = useUserStore();
const player = usePlayerStore();
const createdPlaylists = ref<any[]>([]);
const subPlaylists = ref<any[]>([]);
const showCreatedPlaylists = ref(true);
const showSubPlaylists = ref(true);
// 歌词
const { lyrics, currentLyricIdx } = useLyric();
const lyricScrollContainer = ref<HTMLElement | null>(null);
const roamSong = computed(() => player.currentSong);
const roamArtists = computed(() => {
if (!roamSong.value) return '';
return roamSong.value.ar?.map((a: any) => a.name).join(' / ') || '';
});
watch(currentLyricIdx, () => {
if (player.showRoamDrawer && lyricScrollContainer.value) {
nextTick(() => {
const active = lyricScrollContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
});
async function openRoamFromSidebar() {
if (player.isFmMode) {
player.openRoamDrawer();
} else {
await player.loadFm();
}
}
async function loadPlaylists() {
if (!userStore.isLoggedIn || !userStore.user) return;
try {
const jsonStr: string = await invoke('user_playlist', { uid: userStore.user.userId });
const data = JSON.parse(jsonStr);
createdPlaylists.value = (data.playlist || []).filter((p: any) => !p.subscribed);
subPlaylists.value = (data.playlist || []).filter((p: any) => p.subscribed);
} catch (e) { /* 忽略 */ }
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
watch(() => userStore.isLoggedIn, (val) => {
if (val) loadPlaylists();
});
onMounted(async () => {
if (userStore.isLoggedIn) loadPlaylists();
try { await invoke('stop_audio'); } catch {}
try {
const jsonStr: string = await invoke('get_login_status');
const data = JSON.parse(jsonStr);
if (data.account || data.profile) {
const profile = data.profile || data.account;
userStore.setUser({
userId: profile.userId,
nickname: profile.nickname,
avatarUrl: profile.avatarUrl,
});
}
} catch {}
});
// ---------- 窗口控制 ----------
const currentWindow = getCurrentWindow();
function minimizeWindow() { currentWindow.minimize(); }
async function toggleMaximize() {
const isMaximized = await currentWindow.isMaximized();
if (isMaximized) { currentWindow.unmaximize(); } else { currentWindow.maximize(); }
}
function closeWindow() { currentWindow.close(); }
import { listen } from '@tauri-apps/api/event';
onMounted(() => {
const unlisten1 = listen('tray-play-pause', () => {
player.toggle(); // 假设 player 是 usePlayerStore 的实例
});
const unlisten2 = listen('tray-next', () => {
player.next();
});
const unlisten3 = listen('tray-prev', () => {
player.prev();
});
// 在组件卸载时取消监听
onBeforeUnmount(() => {
unlisten1.then(fn => fn());
unlisten2.then(fn => fn());
unlisten3.then(fn => fn());
});
});
</script>
<style>
.drawer-enter-active,
.drawer-leave-active { transition: transform 0.3s ease; }
.drawer-enter-from,
.drawer-leave-to { transform: translateY(100%); }
.custom-scroll::-webkit-scrollbar { width: 4px; }
.custom-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
</style>

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,324 @@
<template>
<div v-if="player.currentSong"
class="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur border-t border-white/10 z-50 select-none">
<!-- 歌词精简条仅在非漫游全屏时显示 -->
<div v-if="currentLyricText && !player.showRoamDrawer" @click="showFullLyric = !showFullLyric"
class="px-6 py-1 text-center text-xs text-green-200/80 cursor-pointer hover:bg-white/5 transition truncate">
{{ currentLyricText }}
</div>
<!-- 进度条 -->
<div ref="progressBar" class="w-full h-1.5 bg-white/10 rounded-full relative group cursor-pointer overflow-visible"
@mousedown.prevent="startSeek">
<!-- 缓存进度灰白 -->
<div class="absolute left-0 top-0 h-full bg-white/20 rounded-full" :style="{ width: cacheProgress + '%' }"></div>
<!-- 播放进度绿色渐变 -->
<div class="absolute left-0 top-0 h-full bg-gradient-to-r from-green-400 to-emerald-500 rounded-full"
:style="{ width: displayProgress + '%' }"></div>
<!-- 拖动圆点基于容器定位left 百分比 -->
<div
class="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
:style="{ left: `calc(${displayProgress}% - 7px)` }"></div>
</div>
<!-- 主控制区 -->
<div class="flex items-center px-6 h-16">
<!-- 左侧歌曲信息 -->
<div class="flex items-center gap-3 w-56 min-w-0">
<img :src="player.currentSong.al?.picUrl"
class="w-10 h-10 rounded-md object-cover flex-shrink-0 cursor-pointer hover:scale-105 transition-transform"
@click="player.openRoamDrawer()" title="全屏展示" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ player.currentSong.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{player.currentSong.ar?.map((a: any) => a.name).join('/')}}
</p>
</div>
</div>
<!-- 中间控制按钮 + 时间 -->
<div class="flex-1 flex flex-col items-center justify-center gap-1">
<div class="flex items-center gap-5">
<button @click="player.prev()" :disabled="player.isFmMode" :class="[
'text-xl transition',
player.isFmMode ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 hover:text-white',
]">
</button>
<button @click="player.toggle()"
class="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20">
<svg v-if="player.playing" width="16" height="16" viewBox="0 0 16 16" fill="currentColor"
class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click="player.next()" class="text-xl text-gray-400 hover:text-white transition"></button>
</div>
<div class="flex items-center gap-2 text-xs text-gray-400">
<span>{{ formatTime(player.currentTime) }}</span>
<span>/</span>
<span>{{ formatTime(player.duration) }}</span>
</div>
</div>
<!-- 右侧音量模式播放列表 -->
<div class="w-56 flex justify-end items-center gap-2">
<div class="flex items-center gap-1">
<span class="text-sm text-gray-400">🔊</span>
<div class="relative w-24 h-6 flex items-center">
<input ref="volumeSlider" type="range" min="0" max="100" :value="volume"
:style="{ background: volumeBarBg }" @input="handleVolumeChange"
class="vol-slider w-full h-1.5 rounded-full appearance-none cursor-pointer bg-white/20 outline-none" />
</div>
</div>
<button @click="togglePlayMode" class="text-gray-400 hover:text-white transition text-lg" :title="modeTitle">
{{ modeIcon }}
</button>
<button @click="showQueuePanel = !showQueuePanel"
class="text-gray-400 hover:text-white transition text-xl relative" title="播放列表">
📋
<span v-if="player.queue.length > 0"
class="absolute -top-1 -right-1 bg-green-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{{ player.queue.length }}
</span>
</button>
</div>
</div>
<!-- 队列面板 -->
<Transition name="slide-up">
<div v-if="showQueuePanel"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-64 overflow-y-auto">
<div class="flex justify-between items-center mb-3">
<h3 class="text-sm font-semibold">播放列表 ({{ player.queue.length }})</h3>
<button @click="player.clearQueue()" class="text-xs text-red-400 hover:text-red-300 transition">清空</button>
</div>
<div class="space-y-1">
<div v-for="(song, idx) in player.queue" :key="song.id + '-' + idx" @click="playFromQueue(idx)" :class="[
'flex items-center gap-3 p-2 rounded-lg cursor-pointer transition',
idx === player.currentIndex ? 'bg-green-500/20 text-white' : 'hover:bg-white/5 text-gray-300',
]">
<span class="text-xs w-6 text-center">{{ idx + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-xs font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-500 truncate">
{{song.ar?.map((a: any) => a.name).join('/')}}
</p>
</div>
<button @click.stop="player.removeFromQueue(idx)"
class="text-gray-500 hover:text-red-400 transition text-sm">
</button>
</div>
</div>
</div>
</Transition>
<!-- 全屏歌词浮层 -->
<Transition name="slide-up">
<div v-if="showFullLyric && lyrics.length > 0 && !player.showRoamDrawer"
class="border-t border-white/10 bg-gray-900/95 backdrop-blur p-4 max-h-72 overflow-hidden flex flex-col"
@click.self="showFullLyric = false">
<div class="flex justify-between mb-2">
<h3 class="text-xs font-semibold">歌词</h3>
<button @click="showFullLyric = false" class="text-gray-400 hover:text-white">收起</button>
</div>
<div ref="lyricContainer"
class="flex-1 overflow-y-auto overflow-x-hidden whitespace-normal break-words space-y-1 text-sm text-center">
<p v-for="(line, idx) in lyrics" :key="idx" :class="idx === currentLyricIdx
? 'text-green-400 font-medium scale-105 transition'
: 'text-gray-400'
" class="px-4 py-0.5">
{{ line.text }}
</p>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount, watch, onMounted } from 'vue';
import { usePlayerStore, PlayMode } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
import { useLyric } from '../composables/UserLyric';
import { listen } from '@tauri-apps/api/event';
const player = usePlayerStore();
const showQueuePanel = ref(false);
const { lyrics, currentLyricIdx, currentLyricText } = useLyric();
const showFullLyric = ref(false);
const lyricContainer = ref<HTMLElement | null>(null);
const progressBar = ref<HTMLElement | null>(null);
const isSeeking = ref(false);
const previewTime = ref(0);
const cacheProgress = ref(0);
const volume = ref(100);
let unlistenCache: (() => void) | null = null;
// 缓存进度监听
onMounted(async () => {
const fn = await listen<number>('cache-progress', (event) => {
cacheProgress.value = event.payload;
});
unlistenCache = fn;
});
onBeforeUnmount(() => {
if (unlistenCache) unlistenCache();
});
// 播放模式
const modeTexts = { loop: '列表循环', shuffle: '随机播放', 'repeat-one': '单曲循环' };
const modeIcons = { loop: '🔁', shuffle: '🔀', 'repeat-one': '🔂' };
const modeIcon = computed(() => modeIcons[player.playMode] || '🔁');
const modeTitle = computed(() => modeTexts[player.playMode] || '列表循环');
function togglePlayMode() {
const modes: PlayMode[] = ['loop', 'shuffle', 'repeat-one'];
const next = modes[(modes.indexOf(player.playMode) + 1) % modes.length];
player.setPlayMode(next);
}
// 进度条拖拽逻辑
let onDocMove: ((e: MouseEvent) => void) | null = null;
let onDocUp: (() => void) | null = null;
function startSeek(e: MouseEvent) {
isSeeking.value = true;
updatePreview(e);
onDocMove = (ev: MouseEvent) => updatePreview(ev);
onDocUp = () => finishSeek();
document.addEventListener('mousemove', onDocMove);
document.addEventListener('mouseup', onDocUp);
}
function updatePreview(e: MouseEvent) {
const bar = progressBar.value;
if (!bar || player.duration === 0) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
previewTime.value = ratio * player.duration;
}
function finishSeek() {
if (!isSeeking.value) return;
isSeeking.value = false;
if (player.duration > 0) {
player.seek(previewTime.value);
}
if (onDocMove) document.removeEventListener('mousemove', onDocMove);
if (onDocUp) document.removeEventListener('mouseup', onDocUp);
onDocMove = null;
onDocUp = null;
}
onBeforeUnmount(() => {
if (onDocMove) document.removeEventListener('mousemove', onDocMove);
if (onDocUp) document.removeEventListener('mouseup', onDocUp);
});
const previewPercent = computed(() => {
if (!player.duration || player.duration === 0) return 0;
return (previewTime.value / player.duration) * 100;
});
const progressPercent = computed(() => {
if (!player.duration || player.duration === 0) return 0;
return (player.currentTime / player.duration) * 100;
});
const displayProgress = computed(() => {
return isSeeking.value ? previewPercent.value : progressPercent.value;
});
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')}`;
}
function playFromQueue(index: number) {
player.currentIndex = index;
player.playCurrent();
}
async function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
const val = parseInt(target.value, 10);
volume.value = val;
await invoke('set_volume', { vol: val / 100 });
}
const volumeBarBg = computed(() => {
const pct = volume.value;
return `linear-gradient(to right, #34d399 0%, #10b981 ${pct}%, rgba(255,255,255,0.15) ${pct}%)`;
});
// 歌词浮层自动滚动
watch(
() => currentLyricIdx.value,
() => {
if (showFullLyric.value && lyricContainer.value) {
nextTick(() => {
const active = lyricContainer.value?.querySelector('.text-green-400');
active?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
);
</script>
<style scoped>
/* 样式保持不变(原有歌词浮层过渡、滑块样式等) */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.2s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(10px);
}
.vol-slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
width: 100%;
height: 6px;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.vol-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right,
#34d399 0%,
#10b981 var(--vol-fill),
rgba(255, 255, 255, 0.15) var(--vol-fill));
}
.vol-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
margin-top: -3px;
cursor: pointer;
transition: transform 0.15s;
}
.vol-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
</style>

View File

@ -0,0 +1,48 @@
import { ref, computed, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { parseLrc, getCurrentLyricIndex, LyricLine } from '../utils/lyric';
import { usePlayerStore } from '../stores/player';
export function useLyric() {
const player = usePlayerStore();
const lyrics = ref<LyricLine[]>([]);
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 = [];
currentLyricIdx.value = -1;
return;
}
try {
const jsonStr: string = await invoke('get_lyric', { id: song.id });
const data = JSON.parse(jsonStr);
const lrc = data?.lrc?.lyric || '';
lyrics.value = lrc ? parseLrc(lrc) : [];
currentLyricIdx.value = -1;
} catch {
lyrics.value = [];
}
}, { immediate: true });
watch(() => player.currentTime, (t) => {
if (lyrics.value.length === 0) return;
const idx = getCurrentLyricIndex(lyrics.value, t);
if (idx !== currentLyricIdx.value) {
currentLyricIdx.value = idx;
}
});
return {
lyrics,
currentLyricIdx,
currentLyricText,
};
}

34
src/main.ts Normal file
View File

@ -0,0 +1,34 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
import router from './router';
import { createPinia } from 'pinia';
// ---------- 彻底阻止双指拖动和手势 ----------
const preventGesture = (e: Event) => e.preventDefault();
// 阻止 iOS / macOS 手势缩放和页面拖动
document.addEventListener('gesturestart', preventGesture, { passive: false });
document.addEventListener('gesturechange', preventGesture, { passive: false });
document.addEventListener('gestureend', preventGesture, { passive: false });
// 阻止触控板双指水平滑动(若仍存在)
window.addEventListener('wheel', (e: WheelEvent) => {
// 只阻止水平方向,保留垂直滚动(内部容器会处理)
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
e.preventDefault();
}
}, { passive: false });
// 阻止移动端双指触摸移动(不影响单指滚动)
window.addEventListener('touchmove', (e: TouchEvent) => {
if (e.touches.length >= 2) {
e.preventDefault();
}
}, { passive: false });
// -------------------------------------------
const app = createApp(App);
app.use(router);
app.use(createPinia());
app.mount('#app');

26
src/router/index.ts Normal file
View File

@ -0,0 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router';
import Home from '@/views/Home.vue';
import Discover from '@/views/Discover.vue';
import PlaylistDetail from '@/views/PlaylistDetail.vue';
import Login from '@/views/Login.vue';
import FavoriteSongs from '@/views/FavoriteSongs.vue';
import RecentPlays from '@/views/RecentPlays.vue';
import DailySongs from '@/views/DailySongs.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: '/favorites', name: 'favorites', component: FavoriteSongs },
{ path: '/recent', name: 'recent', component: RecentPlays },
{ path: '/daily', name: 'daily', component: DailySongs }, // 每日推荐
{ path: '/login', name: 'login', component: Login },
{ path: '/playlist/:id', name: 'playlist', component: PlaylistDetail },
];
export default createRouter({
history: createWebHistory(),
routes,
});

402
src/stores/player.ts Normal file
View File

@ -0,0 +1,402 @@
import { defineStore } from 'pinia';
import { ref , watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { normalizeSong } from '../utils/song';
// 设置播放模式,目前只有顺序循环,后续可扩展
export type PlayMode = 'loop' | 'shuffle' | 'repeat-one';
export interface Song {
id: number;
name: string;
ar: { name: string }[];
al: { picUrl: string };
dt?: number;
// 兼容不同接口返回的可选字段
album?: { picUrl?: string };
artists?: { name: string }[];
duration?: number; // 某些接口的时长字段(单位可能是秒)
}
const cacheProgress = ref(0);
// 监听 Tauri 事件(需要在适当位置初始化一次)
import { listen } from '@tauri-apps/api/event';
export function setupCacheProgressListener() {
listen<number>('cache-progress', (event) => {
cacheProgress.value = event.payload;
});
}
// 在 store 定义外调用 setupCacheProgressListener(),或者在应用入口调用
export const usePlayerStore = defineStore('player', () => {
const currentSong = ref<Song | null>(null);
const playing = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const queue = ref<Song[]>([]);
const currentIndex = ref(-1);
let tickInterval: ReturnType<typeof setInterval> | null = null;
const isFmMode = ref(false);
let fmNextCallback: (() => void) | null = null;
function enableFmMode(onNext: () => void) {
isFmMode.value = true;
fmNextCallback = onNext;
}
function disableFmMode() {
isFmMode.value = false;
fmNextCallback = null;
}
// 播放私人漫游歌曲(清空队列,只播放这一首)
async function playFmSong(song: any) {
// 如果缺少时长,尝试从详情接口获取
if (!song.dt || song.dt === 0) {
try {
const jsonStr: string = await invoke('get_song_detail', { id: Number(song.id) });
const data = JSON.parse(jsonStr);
const full = data.songs?.[0];
if (full) {
song.dt = full.dt || 0;
song.al = full.al || song.al;
song.ar = full.ar || song.ar;
}
} catch (e) { /* 忽略 */ }
}
await invoke('stop_audio');
queue.value = [];
currentIndex.value = -1;
playing.value = false;
currentSong.value = song;
try {
const url: string = await invoke('get_song_url', { id: Number(song.id) });
if (!url) throw new Error('无播放源');
await invoke('play_audio', { url });
playing.value = true;
duration.value = (song.dt || 0) / 1000;
currentTime.value = 0;
startTick();
} catch (e) {
console.error('FM播放失败', e);
playing.value = false;
}
}
// 播放指定歌曲(如果不在队列中则加入并切换)
async function play(song: Song) {
disableFmMode();
const idx = queue.value.findIndex(s => s.id === song.id);
if (idx === -1) {
// 未在队列中,添加到队列并播放该位置
queue.value.push(song);
currentIndex.value = queue.value.length - 1;
} else {
currentIndex.value = idx;
}
await playCurrent();
}
async function playCurrent() {
const song = queue.value[currentIndex.value];
if (!song?.id) {
console.error('无效的歌曲数据', song);
return;
}
try {
// 重置状态
currentSong.value = song;
playing.value = false;
currentTime.value = 0;
duration.value = (song.dt || 0) / 1000;
// 获取 URL 并播放
const url: string = await invoke('get_song_url', { id: Number(song.id) });
if (!url) {
console.error('未获取到有效播放地址', song);
return;
}
await invoke('play_audio', { url });
playing.value = true;
startTick();
} catch (e) {
console.error('播放失败', e);
playing.value = false;
}
}
function startTick() {
if (tickInterval) clearInterval(tickInterval);
tickInterval = setInterval(() => {
if (playing.value && duration.value > 0) {
currentTime.value += 0.25;
if (currentTime.value >= duration.value) {
currentTime.value = duration.value;
next(); // 自动下一首
}
}
}, 250);
}
async function toggle() {
if (playing.value) {
await invoke('pause_audio');
playing.value = false;
} else {
await invoke('resume_audio');
playing.value = true;
}
}
async function stop() {
await invoke('stop_audio');
playing.value = false;
currentSong.value = null;
currentTime.value = 0;
if (tickInterval) clearInterval(tickInterval);
disableFmMode(); // 停止时退出漫游
}
function prev() {
if (isFmMode.value) return;
if (queue.value.length === 0) return;
currentIndex.value = (currentIndex.value - 1 + queue.value.length) % queue.value.length;
playCurrent();
}
// 批量添加歌曲到队列并播放第一首(用于“播放全部”)
async function playAll(songs: Song[]) {
if (songs.length === 0) return;
queue.value = [...songs];
currentIndex.value = 0;
await playCurrent();
}
function removeFromQueue(index: number) {
if (index < 0 || index >= queue.value.length) return;
const isCurrent = index === currentIndex.value;
if (isCurrent) {
// 如果移除的是当前正在播放的歌曲,先停止,然后调整索引
stop();
queue.value.splice(index, 1);
// 如果队列变空,则重置
if (queue.value.length === 0) {
currentIndex.value = -1;
return;
}
// 保持索引不变,但如果删的是最后一个,索引需要退一位
if (currentIndex.value >= queue.value.length) {
currentIndex.value = queue.value.length - 1;
}
// 不自动播放,等用户手动选择
} else {
queue.value.splice(index, 1);
// 调整当前索引
if (index < currentIndex.value) {
currentIndex.value -= 1;
}
}
}
function clearQueue() {
stop();
queue.value = [];
currentIndex.value = -1;
}
async function seek(time: number) {
try {
await invoke('seek_audio', { time });
currentTime.value = time;
} catch (e) {
console.error('seek 失败', e);
}
}
// 在 defineStore 内部添加
const playMode = ref<PlayMode>('loop');
function setPlayMode(mode: PlayMode) {
playMode.value = mode;
}
// 重写 next() 以根据模式选择下一首
function next() {
if (isFmMode.value && fmNextCallback) {
fmNextCallback();
return;
}
if (queue.value.length === 0) return;
let nextIndex: number;
switch (playMode.value) {
case 'repeat-one':
// 单曲循环,不改变索引,只重新播放当前
playCurrent();
return;
case 'shuffle':
// 随机下一首,且不与当前重复(除非只剩一首)
if (queue.value.length === 1) {
nextIndex = 0;
} else {
do {
nextIndex = Math.floor(Math.random() * queue.value.length);
} while (nextIndex === currentIndex.value);
}
break;
case 'loop':
default:
// 顺序循环
nextIndex = (currentIndex.value + 1) % queue.value.length;
break;
}
currentIndex.value = nextIndex;
playCurrent();
}
const showRoamDrawer = ref(false);
function openRoamDrawer() {
showRoamDrawer.value = true;
}
function closeRoamDrawer() {
showRoamDrawer.value = false;
}
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()); // 下一首回调
await playFmSong(song);
return true;
}
} catch (e) {
console.error(e);
}
return false;
}
// -------- FM 专属状态 --------
const fmSong = ref<any>(null);
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); // 设置下一首回调为 store 内的 nextFm
await playFmSong(song); // 使用 FM 专用播放方法
fmPlaying.value = true;
// showRoamDrawer.value = true; // 自动打开全屏抽屉
}
} catch (e) {
console.error('FM加载失败', e);
}
}
async function toggleFm() {
if (!fmSong.value) return;
if (fmPlaying.value) {
// 当前 FM 正在播放,切换暂停/恢复
await toggle(); // 全局暂停/播放
fmPlaying.value = playing.value;
} else {
// FM 处于暂停状态,或者当前被其他歌曲打断
if (currentSong.value?.id === fmSong.value.id) {
// FM 歌曲还是当前歌曲,直接恢复
await toggle();
fmPlaying.value = playing.value;
} else {
// 当前播放的是其他歌曲,重新以 FM 模式播放 FM 歌曲
enableFmMode(nextFm);
await playFmSong(fmSong.value);
fmPlaying.value = true;
}
}
}
async function nextFm() {
await loadFm(); // 加载下一首 FM 歌曲
}
// 监听全局播放变化,若用户选择了非 FM 歌曲,自动退出 FM 状态
watch(currentSong, (newSong) => {
if (isFmMode.value && newSong?.id !== fmSong.value?.id) {
fmPlaying.value = false;
// 注意:不调用 disableFmMode因为可能只是临时切歌但卡片需要知道 FM 已停止
disableFmMode(); // 退出 FM 模式,让上一首按钮恢复
}
});
watch(playing, (val) => {
// 只有当前正在播放的是 FM 歌曲时,才同步 fmPlaying
if (currentSong.value?.id === fmSong.value?.id) {
fmPlaying.value = val;
} else {
fmPlaying.value = false;
}
});
return {
currentSong,
playing,
currentTime,
duration,
queue,
currentIndex,
playMode,
isFmMode,
enableFmMode,
disableFmMode,
playFmSong,
setPlayMode,
play,
playAll,
toggle,
stop,
prev,
next,
seek,
playCurrent,
removeFromQueue,
clearQueue,
showRoamDrawer,
openRoamDrawer,
closeRoamDrawer,
loadFirstFmSong,
fmSong,
fmPlaying,
loadFm,
toggleFm,
nextFm,
};
});

31
src/stores/user.ts Normal file
View File

@ -0,0 +1,31 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { invoke } from '@tauri-apps/api/core';
export interface UserProfile {
userId: number;
nickname: string;
avatarUrl: string;
}
export const useUserStore = defineStore('user', () => {
const user = ref<UserProfile | null>(
JSON.parse(localStorage.getItem('user_profile') || 'null')
);
const isLoggedIn = ref(!!user.value);
function setUser(profile: UserProfile) {
user.value = profile;
isLoggedIn.value = true;
localStorage.setItem('user_profile', JSON.stringify(profile));
}
async function logout() {
try { await invoke('logout'); } catch {}
user.value = null;
isLoggedIn.value = false;
localStorage.removeItem('user_profile');
}
return { user, isLoggedIn, setUser, logout };
});

45
src/style.css Normal file
View File

@ -0,0 +1,45 @@
@import "tailwindcss";
@layer base {
:root {
--color-surface: 255 255 255;
--color-primary: 34 197 94;
}
/* 确保 html 也应用暗色背景,防止空白区域 */
html {
background: #0f172a;
overflow: hidden;
height: 100%;
overscroll-behavior: none;
}
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彻底消除整体拖动 */
position: fixed;
inset: 0;
overflow: hidden;
overscroll-behavior: none;
/* 阻止触控板手势触发页面导航 */
touch-action: none;
}
/* 自定义滚动条保持不变 */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
}
}

38
src/utils/lyric.ts Normal file
View File

@ -0,0 +1,38 @@
export interface LyricLine {
time: number; // 秒
text: string;
}
export function parseLrc(lrcStr: string): LyricLine[] {
const lines = lrcStr.split('\n');
const result: LyricLine[] = [];
const timeReg = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
for (const line of lines) {
const match = line.match(timeReg);
if (match) {
const min = parseInt(match[1], 10);
const sec = parseInt(match[2], 10);
const ms = parseInt(match[3], 10) / (match[3].length === 3 ? 1000 : 100);
const time = min * 60 + sec + ms;
const text = line.replace(timeReg, '').trim();
if (text) {
result.push({ time, text });
}
}
}
// 按时长排序
result.sort((a, b) => a.time - b.time);
return result;
}
export function getCurrentLyricIndex(lyrics: LyricLine[], currentTime: number): number {
let index = -1;
for (let i = 0; i < lyrics.length; i++) {
if (currentTime >= lyrics[i].time) {
index = i;
} else {
break;
}
}
return index;
}

18
src/utils/song.ts Normal file
View File

@ -0,0 +1,18 @@
/**
* 统一规范化歌曲对象,确保 al.picUrl、ar、dt 字段存在且合理
*/
export function normalizeSong(song: any) {
const normalized = { ...song };
// 封面 / 艺术家兼容
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
// 时长:只保留合理的 dt100ms ~ 2小时否则置 0
if (!normalized.dt || normalized.dt < 100 || normalized.dt > 7200000) {
normalized.dt = 0;
}
return normalized;
}

56
src/views/DailySongs.vue Normal file
View File

@ -0,0 +1,56 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
返回
</button>
<h1 class="text-2xl font-bold mb-6">每日推荐</h1>
<div v-if="loading" class="text-gray-400">加载中...</div>
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="player.play(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<img :src="song.al?.picUrl" class="w-10 h-10 rounded object-cover" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const player = usePlayerStore();
const songs = ref<any[]>([]);
const loading = ref(true);
onMounted(async () => {
try {
const jsonStr: string = await invoke('recommend_songs');
const data = JSON.parse(jsonStr);
songs.value = data.data?.dailySongs || [];
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
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')}`;
}
</script>

123
src/views/Discover.vue Normal file
View File

@ -0,0 +1,123 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">发现音乐</h1>
<!-- 搜索框 -->
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲、歌手、专辑..."
class="mb-4 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
/>
<!-- 热门搜索标签仅在没有搜索且未显示结果时出现 -->
<div v-if="!hasSearched && !loading && hotTags.length" class="mb-6">
<h2 class="text-sm font-semibold mb-3">🔥 热门搜索</h2>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in hotTags"
:key="tag.searchWord"
@click="searchTag(tag.searchWord)"
class="px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 cursor-pointer transition text-sm"
>
{{ tag.searchWord }}
</span>
</div>
</div>
<!-- 输出设备选择 -->
<!-- <div class="mb-4">
<label class="mr-2 text-sm text-gray-400">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1 text-sm">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<!-- 搜索结果 -->
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'DiscoverView' });
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const router = useRouter();
const route = useRoute();
const player = usePlayerStore();
const keyword = ref('');
const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const hotTags = ref<any[]>([]);
const devices = ref<string[]>([]);
onMounted(async () => {
// 获取输出设备列表
try { devices.value = await invoke('get_output_devices'); } catch {}
// 获取热门搜索
try {
const json = await invoke('get_hot_search');
const data = JSON.parse(json as string);
hotTags.value = (data.data || []).slice(0, 12);
} catch {}
// 检查路由是否有查询关键词,自动搜索
const q = route.query.q as string;
if (q) {
keyword.value = q;
await handleSearch();
router.replace({ query: {} });
}
});
async function handleSearch() {
if (!keyword.value.trim()) return;
loading.value = true;
hasSearched.value = true;
try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const data = JSON.parse(jsonStr);
results.value = data.result?.songs || [];
} catch (e) {
console.error('搜索出错:', e);
} finally {
loading.value = false;
}
}
function searchTag(tag: string) {
keyword.value = tag;
handleSearch();
}
async function playSong(song: any) {
player.play(song);
}
</script>

View File

@ -0,0 +1,6 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4"> 我喜欢的音乐</h1>
<p class="text-gray-400">正在施工...</p>
</div>
</template>

204
src/views/Home.vue Normal file
View File

@ -0,0 +1,204 @@
<template>
<div class="p-8 text-white">
<!-- 第一行每日推荐 & 私人漫游 卡片 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<!-- 每日推荐 -->
<div
class="h-48 bg-gradient-to-br from-pink-600 to-purple-700 rounded-3xl overflow-hidden relative cursor-pointer group"
@click="goDaily"
>
<div class="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition"></div>
<div class="relative z-10 p-6 flex flex-col justify-between h-full">
<div>
<p class="text-xs text-white/60 mb-1">📅 {{ todayStr }}</p>
<h2 class="text-2xl font-bold">每日推荐</h2>
</div>
<p class="text-xs text-white/60">根据你的口味生成每天 6:00 更新</p>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20">🎧</div>
</div>
<!-- 私人漫游 卡片 -->
<!-- 私人漫游 卡片 -->
<div
class="h-48 bg-gradient-to-br from-blue-600 to-cyan-500 rounded-3xl overflow-hidden relative group select-none"
@click="!userStore.isLoggedIn ? goLogin() : null"
>
<!-- 模糊封面层仅在有歌曲且有封面时显示低透明度模糊 -->
<div
v-if="player.fmSong && fmCoverUrl"
class="absolute inset-0 bg-cover bg-center opacity-30 blur-md scale-110"
:style="{ backgroundImage: `url(${fmCoverUrl})` }"
></div>
<!-- 遮罩 -->
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition"></div>
<!-- 内容 -->
<div class="relative z-10 h-full">
<!-- 未登录 -->
<div v-if="!userStore.isLoggedIn" class="flex flex-col items-center justify-center h-full">
<p class="text-xs text-white/60 mb-1">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<p class="text-xs text-white/60 mt-2">登录后即可开启沉浸式音乐探索</p>
</div>
<!-- 登录后无歌曲 垂直居中播放按钮 -->
<div
v-else-if="!player.fmSong"
class="flex flex-col items-center justify-center h-full gap-3 cursor-pointer"
@click.stop="startFmPlay"
>
<p class="text-xs text-white/60">🌀 一键探索</p>
<h2 class="text-2xl font-bold">私人漫游</h2>
<button
class="w-12 h-12 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition mt-2"
>
<svg width="22" height="22" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
</div>
<!-- 有歌曲 横向布局左侧信息右侧按钮 -->
<div v-else class="flex items-center justify-between h-full px-6 cursor-pointer" @click.stop="player.toggleFm">
<!-- 左侧封面 + 歌曲信息 -->
<div class="flex items-center gap-3 min-w-0">
<img :src="fmCoverUrl" class="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
<div class="min-w-0">
<p class="text-sm font-semibold truncate">{{ fmDisplayName }}</p>
<p class="text-xs text-white/70 truncate">{{ fmDisplayArtists }}</p>
</div>
</div>
<!-- 右侧控制按钮 -->
<div class="flex items-center gap-3 ml-4">
<button @click.stop="player.toggleFm"
class="w-10 h-10 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition"
>
<svg v-if="player.fmPlaying" width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<svg v-else width="18" height="18" viewBox="0 0 16 16" fill="currentColor" class="text-white">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button @click.stop="player.nextFm" class="text-xl text-white/80 hover:text-white transition"></button>
</div>
</div>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-6xl opacity-20 pointer-events-none">🌊</div>
</div>
</div>
<!-- 第二行为你推荐需登录 -->
<div v-if="userStore.isLoggedIn && recPlaylists.length" class="mb-10">
<h2 class="text-xl font-semibold mb-4">🎯 为你推荐</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in recPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer">
<img :src="pl.picUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
<p class="text-xs text-gray-400 mt-1">{{ pl.copywriter || '' }}</p>
</div>
</div>
</div>
</div>
<!-- 第三行热门歌单排行榜 -->
<div>
<h2 class="text-xl font-semibold mb-4">📈 热门歌单</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div v-for="pl in rankPlaylists" :key="pl.id" @click="goPlaylist(pl.id)"
class="bg-white/5 rounded-xl overflow-hidden hover:bg-white/10 transition cursor-pointer backdrop-blur-sm">
<img :src="pl.coverImgUrl" class="w-full aspect-square object-cover" />
<div class="p-3">
<p class="text-sm font-medium truncate">{{ pl.name }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { useUserStore } from '../stores/user';
import { usePlayerStore } from '../stores/player';
const player = usePlayerStore();
const router = useRouter();
const userStore = useUserStore();
const rankPlaylists = ref<any[]>([]);
const recPlaylists = ref<any[]>([]);
const todayStr = ref('');
const RANK_IDS = [3778678, 3779629, 19723756, 2884035];
import { computed } from 'vue';
const fmCoverUrl = computed(() => {
return player.fmSong?.al?.picUrl || player.fmSong?.album?.picUrl || '';
});
const fmDisplayName = computed(() => player.fmSong?.name || '私人漫游');
const fmDisplayArtists = computed(() => {
if (!player.fmSong) return '';
return player.fmSong.ar?.map((a: any) => a.name).join(' / ') ||
player.fmSong.artists?.map((a: any) => a.name).join(' / ') || '';
});
// 首次点击播放按钮:开始 FM 并播放
async function startFmPlay() {
// 如果还没加载过 FM或者之前加载了但被停止了重新加载
if (!player.fmSong) {
await player.loadFm(); // loadFm 内部会设置 fmSong 并播放
} else {
// 已有歌曲但未播放状态(比如之前暂停/停止了),直接播放
await player.toggleFm();
}
}
onMounted(async () => {
const d = new Date();
todayStr.value = `${d.getMonth() + 1}${d.getDate()}`;
// 排行榜
const results = await Promise.allSettled(
RANK_IDS.map(id => invoke('get_playlist_detail', { id }))
);
rankPlaylists.value = results
.filter(r => r.status === 'fulfilled')
.map((r: any) => {
const data = JSON.parse(r.value);
return data.playlist;
})
.filter(Boolean);
// 推荐歌单(需登录)
if (userStore.isLoggedIn) {
try {
const json = await invoke('recommend_resource');
const data = JSON.parse(json as string);
recPlaylists.value = data.recommend || [];
} catch { }
}
});
function goDaily() {
router.push('/daily');
}
function goPlaylist(id: number) {
router.push({ name: 'playlist', params: { id } });
}
function goLogin() {
router.push('/login');
}
</script>

148
src/views/Login.vue Normal file
View File

@ -0,0 +1,148 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-950 text-white">
<div class="bg-white/5 backdrop-blur-md border border-white/10 p-8 rounded-2xl w-full max-w-sm text-center">
<h1 class="text-xl font-bold mb-4">扫码登录</h1>
<p class="text-sm text-gray-400 mb-6">请使用网易云音乐 App 扫描二维码</p>
<!-- 二维码展示区 -->
<div v-if="qrimg" class="bg-white p-3 rounded-xl inline-block mb-4">
<img :src="qrimg" alt="二维码" class="w-48 h-48" />
</div>
<div v-else class="w-48 h-48 bg-white/5 rounded-xl flex items-center justify-center mx-auto mb-4">
<span v-if="qrLoading" class="text-gray-400">加载中...</span>
<span v-else-if="qrError" class="text-red-400 text-sm">{{ qrError }}</span>
</div>
<!-- 状态提示 -->
<p class="text-sm" :class="statusColor">{{ statusText }}</p>
<button @click="refreshQr" class="mt-4 text-xs text-green-400 hover:underline">重新获取二维码</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { useRouter } from 'vue-router';
import { useUserStore } from '../stores/user';
import QRCode from 'qrcode';
const router = useRouter();
const userStore = useUserStore();
const qrimg = ref('');
const qrLoading = ref(true);
const qrError = ref('');
const statusText = ref('等待扫码...');
const statusColor = ref('text-gray-400');
let qrKey = '';
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(async () => {
if (userStore.isLoggedIn) {
router.push('/');
return;
}
await refreshQr();
});
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
});
async function refreshQr() {
qrLoading.value = true;
qrError.value = '';
if (pollTimer) clearInterval(pollTimer);
try {
// 1. 获取 unikey
qrKey = await invoke('get_qr_key');
if (!qrKey) {
qrError.value = '未获取到登录密钥';
qrLoading.value = false;
return;
}
// 2. 拼接网易云标准扫码链接(无需 create_qr
const qrUrl = `https://music.163.com/login?codekey=${qrKey}&type=1`;
// 3. 用 qrcode 生成二维码图片
const canvas = document.createElement('canvas');
await QRCode.toCanvas(canvas, qrUrl, { width: 200, margin: 1 });
qrimg.value = canvas.toDataURL('image/png');
qrLoading.value = false;
// 4. 开始轮询状态
startPolling();
} catch (e: any) {
qrError.value = '获取二维码失败';
qrLoading.value = false;
}
}
// 新增函数:用 Canvas 生成二维码并赋值给 qrimg
// async function drawQrCode(url: string) {
// try {
// // 等待 DOM 准备好 canvas 元素
// const canvas = document.createElement('canvas');
// await QRCode.toCanvas(canvas, url, { width: 201, margin: 1 });
// // 转为 data URL 赋值给响应式的图片地址
// qrimg.value = canvas.toDataURL('image/png');
// } catch (e) {
// console.error('生成二维码失败', e);
// qrError.value = '生成二维码失败';
// }
// }
function startPolling() {
pollTimer = setInterval(async () => {
try {
const jsonStr: string = await invoke('check_qr_status', { query: { key: qrKey } });
const data = JSON.parse(jsonStr);
const code = data.code;
if (code === 800) {
statusText.value = '二维码已过期,请刷新';
statusColor.value = 'text-red-400';
clearInterval(pollTimer!);
} else if (code === 801) {
statusText.value = '等待扫码...';
statusColor.value = 'text-gray-400';
} else if (code === 802) {
statusText.value = '请在手机上确认登录';
statusColor.value = 'text-yellow-400';
} else if (code === 803) {
// 登录成功
clearInterval(pollTimer!);
statusText.value = '登录成功!';
statusColor.value = 'text-green-400';
// 存储 cookie 到 NcmApi后台线程中自动保留后续请求都带登录态
// 获取用户信息(简化,可从 /login/status 获取)
// 这里需要额外调用获取用户详情的 API但因为 NcmApi 已有 cookie可以直接在后台线程中添加
// 暂时用简易方式:调用 /user/account 获取用户简档
await fetchUserProfile();
setTimeout(() => router.push('/'), 500);
}
} catch (e) {
console.error('轮询失败', e);
}
}, 3000);
}
async function fetchUserProfile() {
try {
// 添加一个快速获取用户信息的命令(可复用之前的 login 命令中获取 profile 的逻辑)
// 这里简化,由于后台 NcmApi 已有 cookie我们可以直接用 reqwest 调 /user/account
// 但最好添加一个新命令,这里直接调用现有的 login 逻辑不适用,因此我们在 Rust 侧添加一个 get_login_status 命令
const profileJson: string = await invoke('get_login_status');
const profile = JSON.parse(profileJson);
if (profile.profile) {
userStore.setUser({
userId: profile.profile.userId,
nickname: profile.profile.nickname,
avatarUrl: profile.profile.avatarUrl,
});
}
} catch (e) {
console.error('获取用户信息失败', e);
}
}
</script>

View File

@ -0,0 +1,91 @@
<template>
<div class="p-8 text-white">
<button @click="$router.back()" class="mb-4 text-gray-400 hover:text-white transition">
返回
</button>
<!-- 歌单信息 -->
<div v-if="playlist" class="flex gap-6 mb-8">
<img :src="playlist.coverImgUrl" class="w-40 h-40 rounded-xl object-cover shadow-lg" />
<div>
<h1 class="text-2xl font-bold">{{ playlist.name }}</h1>
<p class="text-sm text-gray-400 mt-2">{{ playlist.description }}</p>
<p class="text-xs text-gray-500 mt-2">
{{ playlist.trackCount }} 首歌曲 · 播放 {{ playlist.playCount }}
</p>
<button
@click="playAll"
class="mt-4 px-4 py-2 bg-green-500 hover:bg-green-600 rounded-full text-white font-medium transition"
>
播放全部
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="text-gray-400">加载中...</div>
<!-- 歌曲列表 -->
<div v-else class="space-y-2">
<div
v-for="(song, index) in songs"
:key="song.id"
@click="playSingle(song)"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer"
>
<span class="text-xs text-gray-500 w-6 text-right">{{ index + 1 }}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ song.name }}</p>
<p class="text-xs text-gray-400 truncate">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
<span class="text-xs text-gray-500">{{ formatDuration(song.dt) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
const route = useRoute();
const player = usePlayerStore();
const playlist = ref<any>(null);
const songs = ref<any[]>([]);
const loading = ref(true);
onMounted(async () => {
const id = Number(route.params.id);
try {
const jsonStr: string = await invoke('get_playlist_detail', { id });
const data = JSON.parse(jsonStr);
playlist.value = data.playlist;
songs.value = data.playlist.tracks || [];
} catch (e) {
console.error('获取歌单详情失败', e);
} finally {
loading.value = false;
}
});
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')}`;
}
async function playSingle(song: any) {
player.play(song);
}
function playAll() {
if (songs.value.length === 0) return;
player.playAll(songs.value);
}
</script>

View File

@ -0,0 +1,6 @@
<template>
<div class="p-8 text-white">
<h1 class="text-2xl font-bold mb-4">🕐 最近播放</h1>
<p class="text-gray-400">正在施工...</p>
</div>
</template>

126
src/views/Roam.vue Normal file
View File

@ -0,0 +1,126 @@
<template>
<div class="p-8 text-white flex flex-col items-center justify-center min-h-full">
<!-- 无歌曲时提示 -->
<div v-if="!currentSong" class="text-center">
<p class="text-gray-400 mb-4">私人漫游未启动</p>
<button
@click="startFm"
class="px-6 py-2 bg-white/10 hover:bg-white/20 rounded-full transition"
>
开始漫游
</button>
</div>
<!-- 歌曲信息展示 -->
<template v-else>
<!-- 专辑封面 -->
<img
:src="currentSong.al?.picUrl || currentSong.album?.picUrl"
class="w-80 h-80 rounded-3xl object-cover shadow-2xl mb-8"
/>
<!-- 歌曲名和艺术家 -->
<h1 class="text-3xl font-bold mb-2">{{ currentSong.name }}</h1>
<p class="text-lg text-gray-400 mb-8">
{{ artists }}
</p>
<!-- 控制按钮 -->
<div class="flex items-center gap-8">
<button
@click="togglePlay"
class="w-16 h-16 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition border border-white/20"
>
<!-- 暂停图标 -->
<svg v-if="player.playing" width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="2" width="3" height="12" rx="0.5" />
<rect x="10" y="2" width="3" height="12" rx="0.5" />
</svg>
<!-- 播放图标 -->
<svg v-else width="28" height="28" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 2.5v11l9-5.5z" />
</svg>
</button>
<button
@click="nextSong"
class="text-3xl text-gray-400 hover:text-white transition"
>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { usePlayerStore } from '../stores/player';
import { invoke } from '@tauri-apps/api/core';
const player = usePlayerStore();
// 当前正在播放的歌曲如果处于FM模式则显示当前歌曲
const currentSong = computed(() => {
// FM 模式下直接显示正在播放的歌曲可能是FM歌曲
if (player.isFmMode && player.currentSong) {
return player.currentSong;
}
return null;
});
const artists = computed(() => {
if (!currentSong.value) return '';
return currentSong.value.ar?.map((a: any) => a.name).join(' / ') ||
currentSong.value.artists?.map((a: any) => a.name).join(' / ') || '';
});
// 进入页面时如果FM未启动自动开始
onMounted(async () => {
if (!player.isFmMode || !player.currentSong) {
await startFm();
}
});
async function startFm() {
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]);
player.enableFmMode(nextSong);
await player.playFmSong(song);
}
} catch (e) {
console.error('启动漫游失败', e);
}
}
function normalizeSong(song: any) {
const normalized = { ...song };
if (!normalized.al?.picUrl && normalized.album?.picUrl) {
normalized.al = { ...normalized.al, picUrl: normalized.album.picUrl };
}
if (!normalized.ar || normalized.ar.length === 0) {
normalized.ar = normalized.artists || [];
}
return normalized;
}
async function togglePlay() {
if (player.playing) {
await invoke('pause_audio');
} else {
if (player.currentSong) {
// 恢复播放
await invoke('resume_audio');
} else {
await startFm();
}
}
}
async function nextSong() {
await startFm();
}
</script>

108
src/views/Search.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<div class="text-white">
<h1 class="text-2xl font-bold mb-4">搜索</h1>
<!-- 输出设备选择-->
<!-- <div class="mb-4">
<label class="mr-2">输出设备</label>
<select v-model="selectedDevice" @change="changeDevice" class="bg-white/10 text-white rounded p-1">
<option :value="null">跟随系统默认</option>
<option v-for="dev in devices" :key="dev" :value="dev">{{ dev }}</option>
</select>
</div> -->
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索歌曲..."
class="mb-6 w-full rounded-xl bg-white/10 p-3 text-white placeholder-gray-400 outline-none backdrop-blur"
/>
<div v-if="loading" class="text-gray-400">搜索中...</div>
<div v-else class="space-y-3">
<div
v-for="song in results"
:key="song.id"
@click="playSong(song)"
class="flex items-center gap-4 p-3 rounded-xl backdrop-blur-md bg-white/5 hover:bg-white/10 border border-white/5 cursor-pointer transition-all duration-200 hover:scale-[1.01] active:scale-95"
>
<img :src="song.al?.picUrl" class="w-12 h-12 rounded-lg object-cover" />
<div>
<p class="font-medium">{{ song.name }}</p>
<p class="text-sm text-gray-400">
{{ song.ar?.map((a: any) => a.name).join(' / ') }}
</p>
</div>
</div>
<p v-if="!loading && hasSearched && results.length === 0" class="text-gray-400">无结果</p>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'SearchView' });
import { useRoute } from 'vue-router';
import { watch } from 'vue';
import { ref, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../stores/player';
import { useRouter } from 'vue-router';
const router = useRouter();
const keyword = ref('');
const results = ref<any[]>([]);
const loading = ref(false);
const hasSearched = ref(false);
const player = usePlayerStore();
const route = useRoute();
// 监听从首页或其他地方传来的 query 参数,自动搜索
watch(
() => route.query.q,
(newQ) => {
if (newQ) {
keyword.value = newQ as string;
handleSearch();
// 清除 query防止刷新后重复搜索
router.replace({ query: {} });
}
},
{ immediate: true }
);
async function handleSearch() {
if (!keyword.value.trim()) return;
loading.value = true;
hasSearched.value = true;
try {
const jsonStr: string = await invoke('search_songs', { query: { keyword: keyword.value } });
const data = JSON.parse(jsonStr);
results.value = data.result?.songs || [];
} catch (e) {
console.error('搜索出错:', e);
} finally {
loading.value = false;
}
}
async function playSong(song: any) {
try {
await player.play(song);
} catch (e) {
alert('暂无播放源或需登录');
}
}
const devices = ref<string[]>([]);
// const selectedDevice = ref<string | null>(null);
onMounted(async () => {
devices.value = await invoke('get_output_devices');
});
// async function changeDevice() {
// await invoke('set_output_device', { device: selectedDevice.value });
// }
</script>

7
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}