mirror of
https://github.com/hanxi/xiaomusic.git
synced 2026-04-18 21:29:21 +08:00
feat: 添加正在播放页面 (#386)
* 引入tailwindcss, 更新默认主题ui * 引入tailwindcss, 添加新的主题ui * 引入tailwindcss, 添加新的主题ui * 更新tailwind主题 * fix merge error * fix merge error * feat: Enhance song metadata display and API fetching - Add more detailed song metadata in frontend (title, artist, album, year, genre, lyrics) - Update API call to include music tags - Replace default music icon with song cover image - Improve type hinting in httpserver.py * feat: Add keyboard shortcuts for music playback control - Implement keyboard shortcuts for play/pause (Space), previous/next track (Left/Right arrows) - Add volume control via Up/Down arrow keys - Prevent default browser actions for shortcut keys - Add event listener for keydown and remove on component unmount - Enhance user interaction with music player * feat: Improve song metadata handling and error resilience - Enhance current song information retrieval with more robust error handling - Add fallback values for song metadata when API calls fail - Update current song state with additional properties like cover image - Optimize song loading process with better error management - Ensure consistent song information display even with incomplete data
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
if __name__ == "__main__":
|
||||
from xiaomusic.cli import main
|
||||
|
||||
main()
|
||||
main()
|
||||
@@ -8,7 +8,10 @@ import tempfile
|
||||
import urllib.parse
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import asdict
|
||||
from typing import Annotated
|
||||
from typing import Annotated, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xiaomusic.xiaomusic import XiaoMusic
|
||||
|
||||
import aiofiles
|
||||
from fastapi import (
|
||||
@@ -49,7 +52,7 @@ from xiaomusic.utils import (
|
||||
update_version,
|
||||
)
|
||||
|
||||
xiaomusic = None
|
||||
xiaomusic: "XiaoMusic" = None
|
||||
config = None
|
||||
log = None
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const API = {
|
||||
.map(name => `name=${encodeURIComponent(name)}`)
|
||||
.join('&');
|
||||
|
||||
const response = await fetch(`/musicinfos?${queryParams}`);
|
||||
const response = await fetch(`/musicinfos?${queryParams}&musictag=true`);
|
||||
return response.json();
|
||||
},
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-base-200 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span class="material-icons icon-sm opacity-50">music_note</span>
|
||||
<img :src="song.cover" :alt="song.title" class="w-10 h-10 rounded-lg">
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium"
|
||||
@@ -251,12 +251,14 @@
|
||||
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="avatar">
|
||||
<div class="w-12 rounded-lg">
|
||||
<img :src="currentSong?.cover" :alt="currentSong?.title" />
|
||||
</div>
|
||||
<div class="avatar cursor-pointer">
|
||||
<a href="./now_playing.html" target="_blank">
|
||||
<div class="w-12 rounded-lg">
|
||||
<img :src="currentSong?.cover" :alt="currentSong?.title" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cursor-pointer hover:text-primary transition-colors">
|
||||
<div class="font-bold">{{ currentSong?.title }}</div>
|
||||
<div class="text-sm opacity-50">
|
||||
{{ currentSong?.artist }} - {{ currentSong?.album }}
|
||||
@@ -485,21 +487,31 @@
|
||||
currentTime.value = data.offset || 0;
|
||||
duration.value = data.duration || 0;
|
||||
|
||||
if (data.cur_music) {
|
||||
const existingSong = songs.value.find(s => s.title === data.cur_music);
|
||||
if (existingSong) {
|
||||
currentSong.value = existingSong;
|
||||
} else {
|
||||
const songInfo = await API.getMusicInfo(data.cur_music);
|
||||
if (songInfo && songInfo.ret === 'OK') {
|
||||
if (data.cur_music && data.cur_music !== currentSong.value?.title) {
|
||||
try {
|
||||
// 获取音乐详细信息
|
||||
const musicInfo = await API.getMusicInfo(data.cur_music);
|
||||
if (musicInfo && musicInfo.ret === 'OK') {
|
||||
const tags = musicInfo.tags || {};
|
||||
currentSong.value = {
|
||||
title: data.cur_music,
|
||||
url: songInfo.url,
|
||||
artist: songInfo.tags?.artist || '未知歌手',
|
||||
album: songInfo.tags?.album || '未知专辑',
|
||||
tags: songInfo.tags
|
||||
title: tags.title || data.cur_music,
|
||||
artist: tags.artist || '未知歌手',
|
||||
album: tags.album || '未知专辑',
|
||||
cover: tags.picture || "/static/xiaoai.png",
|
||||
url: musicInfo.url,
|
||||
isPlaying: data.is_playing
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting music info:', error);
|
||||
// 如果获取详细信息失败,至少更新基本信息
|
||||
currentSong.value = {
|
||||
title: data.cur_music,
|
||||
artist: '未知歌手',
|
||||
album: '未知专辑',
|
||||
cover: "/static/xiaoai.png",
|
||||
isPlaying: data.is_playing
|
||||
};
|
||||
}
|
||||
|
||||
localStorage.setItem('cur_music', data.cur_music);
|
||||
@@ -669,18 +681,31 @@
|
||||
isLoading: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
const songData = {
|
||||
id: songName,
|
||||
title: songName,
|
||||
title: info.tags?.title || songName,
|
||||
artist: info.tags?.artist || '未知歌手',
|
||||
album: info.tags?.album || '未知专辑',
|
||||
year: info.tags?.year || '',
|
||||
genre: info.tags?.genre || '',
|
||||
duration: info.duration ? formatTime(info.duration) : '0:00',
|
||||
cover: info.cover || '/static/xiaoai.png',
|
||||
cover: info.tags?.picture || '/static/xiaoai.png',
|
||||
url: info.url,
|
||||
lyrics: info.tags?.lyrics || '',
|
||||
isLoading: false
|
||||
};
|
||||
});
|
||||
|
||||
// 如果这是当前播放的歌曲,更新currentSong
|
||||
if (currentSong.value && songName === currentSong.value.title) {
|
||||
currentSong.value = {
|
||||
...currentSong.value,
|
||||
cover: songData.cover
|
||||
};
|
||||
}
|
||||
|
||||
return songData;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading songs:', error);
|
||||
songs.value = [];
|
||||
@@ -1241,6 +1266,9 @@
|
||||
|
||||
// 开始更新播放状态
|
||||
startPlayingStatusUpdate();
|
||||
|
||||
// 添加键盘事件监听
|
||||
document.addEventListener('keydown', handleKeyPress)
|
||||
} catch (error) {
|
||||
console.error('Error in onMounted:', error);
|
||||
}
|
||||
@@ -1249,8 +1277,48 @@
|
||||
// 在组件卸载时停止更新
|
||||
onUnmounted(() => {
|
||||
stopPlayingStatusUpdate();
|
||||
document.removeEventListener('keydown', handleKeyPress)
|
||||
});
|
||||
|
||||
// 处理键盘事件
|
||||
async function handleKeyPress(event) {
|
||||
// 如果用户正在输入,不处理快捷键
|
||||
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'Space': // 空格键:播放/暂停
|
||||
event.preventDefault() // 防止页面滚动
|
||||
if (currentSong.value) {
|
||||
await togglePlay(currentSong.value)
|
||||
}
|
||||
break
|
||||
case 'ArrowLeft': // 左方向键:上一首
|
||||
event.preventDefault()
|
||||
await sendCommand(API.commands.PLAY_PREVIOUS)
|
||||
break
|
||||
case 'ArrowRight': // 右方向键:下一首
|
||||
event.preventDefault()
|
||||
await sendCommand(API.commands.PLAY_NEXT)
|
||||
break
|
||||
case 'ArrowUp': // 上方向键:增加音量
|
||||
event.preventDefault()
|
||||
if (volume.value < 100) {
|
||||
volume.value = Math.min(100, volume.value + 5)
|
||||
await setVolume({ target: { value: volume.value } })
|
||||
}
|
||||
break
|
||||
case 'ArrowDown': // 下方向键:减小音量
|
||||
event.preventDefault()
|
||||
if (volume.value > 0) {
|
||||
volume.value = Math.max(0, volume.value - 5)
|
||||
await setVolume({ target: { value: volume.value } })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 返回所有需要的数据和方法
|
||||
return {
|
||||
isDarkTheme,
|
||||
|
||||
245
xiaomusic/static/tailwind/now_playing.html
Normal file
245
xiaomusic/static/tailwind/now_playing.html
Normal file
@@ -0,0 +1,245 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>正在播放 - XiaoMusic</title>
|
||||
<link rel="icon" href="./favicon.ico">
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.23/dist/full.min.css" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<script src="https://unpkg.com/vue@3.5.13/dist/vue.global.js"></script>
|
||||
<script src="./api.js"></script>
|
||||
</head>
|
||||
|
||||
<body class="font-sans">
|
||||
<div id="app" class="h-screen flex flex-col">
|
||||
<!-- Toast 提示 -->
|
||||
<div class="toast toast-top toast-center z-[9999]">
|
||||
<div v-if="showToast" :class="['alert', toastType]">
|
||||
<span>{{ toastMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="fixed top-4 right-4 z-50 alert alert-error shadow-lg max-w-sm">
|
||||
<span class="material-icons">error</span>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 背景图 -->
|
||||
<div class="fixed inset-0 bg-cover bg-center transition-all duration-1000 ease-in-out"
|
||||
:style="{ backgroundImage: `url(${currentSong.cover})` }"></div>
|
||||
<div class="fixed inset-0 backdrop-blur-2xl bg-black/40"></div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="relative z-10 flex h-full">
|
||||
<!-- 左侧内容 -->
|
||||
<div class="flex-1">
|
||||
<!-- 顶部信息 -->
|
||||
<div class="text-center p-6">
|
||||
<h2 class="text-3xl font-bold text-white mb-1 tracking-wide">{{ currentSong.title }}</h2>
|
||||
<p class="text-l text-white/40 mb-1">{{ currentSong.artist }} - {{ currentSong.album }}</p>
|
||||
<!-- 标签显示 -->
|
||||
<div v-if="currentSong.tags" class="flex justify-center gap-2 mt-2 flex-wrap">
|
||||
<span v-for="(value, key) in currentSong.tags" :key="key"
|
||||
class="px-2 py-1 rounded-full bg-primary/20 text-primary text-sm">
|
||||
{{ key }}: {{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌词区域 -->
|
||||
<div class="flex-grow overflow-y-auto lyrics-container px-4 py-2 scrollbar-hide">
|
||||
<div class="max-w-2xl mx-auto pb-24">
|
||||
<div v-if="currentSong.lyrics.length === 0" class="text-center text-white/50 text-xl mt-10">
|
||||
暂无歌词
|
||||
</div>
|
||||
<p v-else v-for="(line, index) in currentSong.lyrics" :key="index"
|
||||
:class="{'text-white text-3xl font-semibold': currentLyricIndex === index, 'text-white/50 text-xl': currentLyricIndex !== index}"
|
||||
class="mb-6 transition-all duration-300 text-center"
|
||||
:data-index="index">
|
||||
{{ line.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧控制面板 -->
|
||||
<div class="fixed right-8 top-1/2 -translate-y-1/2 flex items-start">
|
||||
<!-- 控制面板内容 -->
|
||||
<div class="transition-all duration-300"
|
||||
:class="[showControlPanel ? 'translate-x-0 opacity-100' : 'translate-x-8 opacity-0']">
|
||||
<div class="bg-black/30 backdrop-blur-lg rounded-xl p-6 shadow-2xl">
|
||||
<!-- 快捷键提示 -->
|
||||
<div class="flex flex-col gap-4 text-white/50 text-sm mb-8">
|
||||
<h3 class="text-white/70 font-semibold mb-2">快捷键</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<kbd class="kbd kbd-sm">Space</kbd>
|
||||
<span>播放/暂停</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-1">
|
||||
<kbd class="kbd kbd-sm">←</kbd>
|
||||
<kbd class="kbd kbd-sm">→</kbd>
|
||||
</div>
|
||||
<span>切换歌曲</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-1">
|
||||
<kbd class="kbd kbd-sm">↑</kbd>
|
||||
<kbd class="kbd kbd-sm">↓</kbd>
|
||||
</div>
|
||||
<span>调节音量</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌词偏移控制 -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="text-white/70 font-semibold">歌词偏移</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="adjustLyricsOffset(-0.5)"
|
||||
class="btn btn-sm btn-ghost text-white/70 hover:text-white">
|
||||
<span class="material-icons text-lg">remove</span>
|
||||
</button>
|
||||
<div class="text-white/70 text-sm">
|
||||
{{ lyricsOffset.toFixed(1) }}s
|
||||
</div>
|
||||
<button @click="adjustLyricsOffset(0.5)"
|
||||
class="btn btn-sm btn-ghost text-white/70 hover:text-white">
|
||||
<span class="material-icons text-lg">add</span>
|
||||
</button>
|
||||
<button @click="resetLyricsOffset"
|
||||
class="btn btn-sm btn-ghost text-white/70 hover:text-white"
|
||||
title="重置歌词偏移">
|
||||
<span class="material-icons text-lg">restart_alt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 切换按钮 -->
|
||||
<button @click="showControlPanel = !showControlPanel"
|
||||
class="w-6 h-6 bg-black/30 backdrop-blur-lg rounded-full flex items-center justify-center hover:bg-black/50 transition-colors ml-2"
|
||||
:class="{'rotate-180': !showControlPanel}">
|
||||
<span class="material-icons text-white/70 text-sm">chevron_left</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/60 to-transparent pt-10 pb-4 px-4">
|
||||
<!-- 进度条 -->
|
||||
<div class="mb-4">
|
||||
<input type="range" min="0" :max="duration" v-model="currentTime"
|
||||
class="range range-xs range-primary w-full opacity-70 hover:opacity-100 transition-opacity" @input="seek">
|
||||
<div class="flex justify-between text-xs text-white/50 mt-1">
|
||||
<span>{{ formatTime(currentTime) }}</span>
|
||||
<span>{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 播放模式 -->
|
||||
<div class="flex space-x-3">
|
||||
<button class="text-white/70 hover:text-white transition-colors p-2 rounded-full"
|
||||
:class="{'bg-primary/20 text-primary': playMode === 'repeat'}" @click="setPlayMode('repeat')">
|
||||
<span class="material-icons text-xl">repeat</span>
|
||||
</button>
|
||||
<button class="text-white/70 hover:text-white transition-colors p-2 rounded-full"
|
||||
:class="{'bg-primary/20 text-primary': playMode === 'repeat_one'}" @click="setPlayMode('repeat_one')">
|
||||
<span class="material-icons text-xl">repeat_one</span>
|
||||
</button>
|
||||
<button class="text-white/70 hover:text-white transition-colors p-2 rounded-full"
|
||||
:class="{'bg-primary/20 text-primary': playMode === 'shuffle'}" @click="setPlayMode('shuffle')">
|
||||
<span class="material-icons text-xl">shuffle</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 播放控制 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="text-white/70 hover:text-white transition-colors" @click="previousSong">
|
||||
<span class="material-icons text-3xl">skip_previous</span>
|
||||
</button>
|
||||
<button class="bg-white text-black rounded-full p-2 hover:bg-primary hover:text-white transition-all"
|
||||
@click="togglePlay">
|
||||
<span class="material-icons text-3xl">{{ isPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
<button class="text-white/70 hover:text-white transition-colors" @click="nextSong">
|
||||
<span class="material-icons text-3xl">skip_next</span>
|
||||
</button>
|
||||
<button class="text-white/70 hover:text-white transition-colors" @click="stopPlay">
|
||||
<span class="material-icons text-3xl">stop</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 音量控制 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="material-icons text-white/70 text-xl hover:text-white transition-colors cursor-pointer">
|
||||
{{ volume > 0 ? 'volume_up' : 'volume_off' }}
|
||||
</span>
|
||||
<input type="range" min="0" max="100" step="1" v-model.number="volume"
|
||||
class="range range-primary w-32" @input="setVolume">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* 添加平滑滚动效果 */
|
||||
.lyrics-container {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 自定义范围输入样式 */
|
||||
.range {
|
||||
height: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.range::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* 歌词动画效果 */
|
||||
.lyrics-container p {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.lyrics-container p.active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="./now_playing.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
406
xiaomusic/static/tailwind/now_playing.js
Normal file
406
xiaomusic/static/tailwind/now_playing.js
Normal file
@@ -0,0 +1,406 @@
|
||||
const { createApp, ref, computed, onMounted, watch, onUnmounted } = Vue
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const currentSong = ref({
|
||||
title: '',
|
||||
artist: '',
|
||||
album: '',
|
||||
cover: '',
|
||||
lyrics: [],
|
||||
tags: null,
|
||||
name: '' // 原始文件名
|
||||
})
|
||||
const isPlaying = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const volume = ref(1)
|
||||
const playMode = ref('repeat') // 'repeat', 'repeat_one', 'shuffle'
|
||||
const currentLyricIndex = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const lyricsOffset = ref(0) // 歌词偏移值(秒)
|
||||
const showControlPanel = ref(true) // 控制面板显示状态
|
||||
|
||||
// Toast 提示相关
|
||||
const showToast = ref(false)
|
||||
const toastMessage = ref('')
|
||||
const toastType = ref('alert-info')
|
||||
let toastTimer = null
|
||||
|
||||
// 获取设备ID
|
||||
const deviceId = localStorage.getItem('cur_did') || 'web_device'
|
||||
// 保存设备ID到localStorage
|
||||
localStorage.setItem('cur_did', deviceId)
|
||||
|
||||
// 从localStorage获取保存的歌词偏移值
|
||||
const savedOffset = localStorage.getItem('lyrics_offset')
|
||||
if (savedOffset !== null) {
|
||||
lyricsOffset.value = parseFloat(savedOffset)
|
||||
}
|
||||
|
||||
// 调整歌词偏移
|
||||
function adjustLyricsOffset(seconds) {
|
||||
lyricsOffset.value += seconds
|
||||
// 保存偏移值到localStorage
|
||||
localStorage.setItem('lyrics_offset', lyricsOffset.value.toString())
|
||||
// 重新解析歌词
|
||||
if (currentSong.value.tags?.lyrics) {
|
||||
currentSong.value.lyrics = parseLyrics(currentSong.value.tags.lyrics)
|
||||
updateCurrentLyric()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置歌词偏移
|
||||
function resetLyricsOffset() {
|
||||
lyricsOffset.value = 0
|
||||
localStorage.setItem('lyrics_offset', '0')
|
||||
if (currentSong.value.tags?.lyrics) {
|
||||
currentSong.value.lyrics = parseLyrics(currentSong.value.tags.lyrics)
|
||||
updateCurrentLyric()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
// 获取并更新当前音量
|
||||
try {
|
||||
const volumeResponse = await API.getVolume(deviceId)
|
||||
if (volumeResponse.ret === 'OK') {
|
||||
volume.value = parseInt(volumeResponse.volume)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error getting volume:', err)
|
||||
}
|
||||
|
||||
// 开始定时获取播放状态
|
||||
updatePlayingStatus()
|
||||
setInterval(updatePlayingStatus, 1000)
|
||||
|
||||
// 添加键盘事件监听
|
||||
document.addEventListener('keydown', handleKeyPress)
|
||||
})
|
||||
|
||||
// 处理键盘事件
|
||||
async function handleKeyPress(event) {
|
||||
// 如果用户正在输入,不处理快捷键
|
||||
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'Space': // 空格键:播放/暂停
|
||||
event.preventDefault() // 防止页面滚动
|
||||
await togglePlay()
|
||||
break
|
||||
case 'ArrowLeft': // 左方向键:上一首
|
||||
event.preventDefault()
|
||||
await previousSong()
|
||||
break
|
||||
case 'ArrowRight': // 右方向键:下一首
|
||||
event.preventDefault()
|
||||
await nextSong()
|
||||
break
|
||||
case 'ArrowUp': // 上方向键:增加音量
|
||||
event.preventDefault()
|
||||
if (volume.value < 100) {
|
||||
volume.value = Math.min(100, volume.value + 5)
|
||||
await setVolume()
|
||||
}
|
||||
break
|
||||
case 'ArrowDown': // 下方向键:减小音量
|
||||
event.preventDefault()
|
||||
if (volume.value > 0) {
|
||||
volume.value = Math.max(0, volume.value - 5)
|
||||
await setVolume()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 在组件销毁时移除事件监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyPress)
|
||||
})
|
||||
|
||||
// 更新播放状态
|
||||
async function updatePlayingStatus() {
|
||||
try {
|
||||
error.value = null
|
||||
// 获取当前播放状态
|
||||
const status = await API.getPlayingStatus(deviceId)
|
||||
if (status.ret === 'OK') {
|
||||
// 更新播放状态
|
||||
isPlaying.value = status.is_playing
|
||||
currentTime.value = status.offset || 0
|
||||
duration.value = status.duration || 0
|
||||
|
||||
// 如果有正在播放的音乐且音乐发生改变
|
||||
if (status.cur_music && status.cur_music !== currentSong.value.name) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 获取音乐详细信息
|
||||
const musicInfo = await API.getMusicInfo(status.cur_music)
|
||||
if (musicInfo && musicInfo.ret === 'OK') {
|
||||
const tags = musicInfo.tags || {}
|
||||
currentSong.value = {
|
||||
title: tags.title || musicInfo.name,
|
||||
artist: tags.artist || '未知歌手',
|
||||
album: tags.album || '未知专辑',
|
||||
cover: tags.picture || `/cover?name=${encodeURIComponent(musicInfo.name)}`,
|
||||
lyrics: parseLyrics(tags.lyrics || ''),
|
||||
tags: {
|
||||
year: tags.year,
|
||||
genre: tags.genre
|
||||
},
|
||||
name: musicInfo.name
|
||||
}
|
||||
// 更新当前歌词
|
||||
updateCurrentLyric()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
} else {
|
||||
// 即使歌曲没有改变,也要更新当前歌词(因为时间在变化)
|
||||
updateCurrentLyric()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '获取播放状态失败'
|
||||
console.error('Error updating playing status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析歌词
|
||||
function parseLyrics(lyricsText) {
|
||||
if (!lyricsText) return []
|
||||
|
||||
const lines = lyricsText.split('\n')
|
||||
const lyrics = []
|
||||
const timeRegex = /\[(\d{2}):(\d{2})\.(\d{2,3})\](.*)/
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(timeRegex)
|
||||
if (match) {
|
||||
const minutes = parseInt(match[1])
|
||||
const seconds = parseInt(match[2])
|
||||
const milliseconds = parseInt(match[3])
|
||||
const text = match[4].trim()
|
||||
|
||||
// 只保留实际歌词行,排除元数据
|
||||
if (text && !text.startsWith('[') &&
|
||||
!text.includes('Lyricist') && !text.includes('Composer') &&
|
||||
!text.includes('Producer') && !text.includes('Engineer') &&
|
||||
!text.includes('Studio') && !text.includes('Company') &&
|
||||
!text.includes(':') && !text.includes('Original') &&
|
||||
!text.includes('Design') && !text.includes('Director') &&
|
||||
!text.includes('Supervisor') && !text.includes('Promoter')) {
|
||||
// 保存原始时间戳,不应用偏移
|
||||
const time = minutes * 60 + seconds + (milliseconds / 1000)
|
||||
lyrics.push({
|
||||
time: Math.max(0, time),
|
||||
text: text
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lyrics.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
// 更新当前歌词
|
||||
function updateCurrentLyric() {
|
||||
const lyrics = currentSong.value.lyrics
|
||||
if (!lyrics.length) return
|
||||
|
||||
// 找到当前时间对应的歌词
|
||||
let foundIndex = -1
|
||||
// 应用偏移后的当前时间
|
||||
const currentTimeWithOffset = currentTime.value - lyricsOffset.value
|
||||
|
||||
// 二分查找优化性能
|
||||
let left = 0
|
||||
let right = lyrics.length - 1
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2)
|
||||
const lyricTime = lyrics[mid].time
|
||||
|
||||
if (mid === lyrics.length - 1) {
|
||||
if (currentTimeWithOffset >= lyricTime) {
|
||||
foundIndex = mid
|
||||
break
|
||||
}
|
||||
} else {
|
||||
const nextTime = lyrics[mid + 1].time
|
||||
if (currentTimeWithOffset >= lyricTime && currentTimeWithOffset < nextTime) {
|
||||
foundIndex = mid
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTimeWithOffset < lyricTime) {
|
||||
right = mid - 1
|
||||
} else {
|
||||
left = mid + 1
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到新的歌词索引,更新显示
|
||||
if (foundIndex !== -1 && foundIndex !== currentLyricIndex.value) {
|
||||
currentLyricIndex.value = foundIndex
|
||||
|
||||
// 获取歌词容器和当前歌词元素
|
||||
const container = document.querySelector('.lyrics-container')
|
||||
const currentLyric = container?.querySelector(`[data-index="${foundIndex}"]`)
|
||||
|
||||
if (container && currentLyric) {
|
||||
// 计算目标滚动位置,使当前歌词保持在容器中央
|
||||
const containerHeight = container.offsetHeight
|
||||
const lyricHeight = currentLyric.offsetHeight
|
||||
const targetPosition = currentLyric.offsetTop - (containerHeight / 2) + (lyricHeight / 2)
|
||||
|
||||
// 使用平滑滚动
|
||||
container.scrollTo({
|
||||
top: targetPosition,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
// 添加高亮动画效果
|
||||
currentLyric.style.transition = 'transform 0.3s ease-out'
|
||||
currentLyric.style.transform = 'scale(1.05)'
|
||||
setTimeout(() => {
|
||||
currentLyric.style.transform = 'scale(1)'
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显示提示
|
||||
function showMessage(message, type = 'info') {
|
||||
if (toastTimer) {
|
||||
clearTimeout(toastTimer)
|
||||
}
|
||||
toastMessage.value = message
|
||||
toastType.value = `alert-${type}`
|
||||
showToast.value = true
|
||||
toastTimer = setTimeout(() => {
|
||||
showToast.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 播放控制
|
||||
async function togglePlay() {
|
||||
const cmd = isPlaying.value ? API.commands.PLAY_PAUSE : API.commands.PLAY_CONTINUE
|
||||
const response = await API.sendCommand(deviceId, cmd)
|
||||
if (response.ret === 'OK') {
|
||||
isPlaying.value = !isPlaying.value
|
||||
showMessage(isPlaying.value ? '开始播放' : '暂停播放')
|
||||
}
|
||||
}
|
||||
|
||||
async function previousSong() {
|
||||
const response = await API.sendCommand(deviceId, API.commands.PLAY_PREVIOUS)
|
||||
if (response.ret === 'OK') {
|
||||
showMessage('播放上一首')
|
||||
}
|
||||
}
|
||||
|
||||
async function nextSong() {
|
||||
const response = await API.sendCommand(deviceId, API.commands.PLAY_NEXT)
|
||||
if (response.ret === 'OK') {
|
||||
showMessage('播放下一首')
|
||||
}
|
||||
}
|
||||
|
||||
async function stopPlay() {
|
||||
const response = await API.sendCommand(deviceId, API.commands.PLAY_PAUSE)
|
||||
if (response.ret === 'OK') {
|
||||
isPlaying.value = false
|
||||
showMessage('停止播放')
|
||||
}
|
||||
}
|
||||
|
||||
async function setPlayMode(mode) {
|
||||
let cmd
|
||||
let modeName
|
||||
switch (mode) {
|
||||
case 'repeat':
|
||||
cmd = API.commands.PLAY_MODE_SEQUENCE
|
||||
modeName = '顺序播放'
|
||||
break
|
||||
case 'repeat_one':
|
||||
cmd = API.commands.PLAY_MODE_SINGLE
|
||||
modeName = '单曲循环'
|
||||
break
|
||||
case 'shuffle':
|
||||
cmd = API.commands.PLAY_MODE_RANDOM
|
||||
modeName = '随机播放'
|
||||
break
|
||||
}
|
||||
if (cmd) {
|
||||
const response = await API.sendCommand(deviceId, cmd)
|
||||
if (response.ret === 'OK') {
|
||||
playMode.value = mode
|
||||
showMessage(`切换到${modeName}模式`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 音量控制
|
||||
async function setVolume() {
|
||||
try {
|
||||
const volumeValue = parseInt(volume.value)
|
||||
const response = await API.setVolume(deviceId, volumeValue)
|
||||
if (response.ret === 'OK') {
|
||||
showMessage(`音量: ${volumeValue}%`)
|
||||
} else {
|
||||
console.error('Failed to set volume:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting volume:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 进度控制
|
||||
function seek() {
|
||||
// 更新歌词显示
|
||||
updateCurrentLyric()
|
||||
}
|
||||
|
||||
// 时间格式化
|
||||
function formatTime(time) {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return {
|
||||
currentSong,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
playMode,
|
||||
currentLyricIndex,
|
||||
isLoading,
|
||||
error,
|
||||
lyricsOffset,
|
||||
showToast,
|
||||
toastMessage,
|
||||
toastType,
|
||||
showControlPanel,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
previousSong,
|
||||
nextSong,
|
||||
stopPlay,
|
||||
setPlayMode,
|
||||
formatTime,
|
||||
adjustLyricsOffset,
|
||||
resetLyricsOffset
|
||||
}
|
||||
}
|
||||
}).mount('#app')
|
||||
Reference in New Issue
Block a user