初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+450
View File
@@ -0,0 +1,450 @@
import { ref } from 'vue'
import { useAuth } from './useAuth'
import type { PlayTime, SystemSettings } from '~~/types'
export const useAdmin = () => {
const { getAuthConfig, isAdmin } = useAuth()
const loading = ref(false)
const error = ref('')
// 创建歌曲排期
const createSchedule = async (
songId: number,
playDate: Date,
playTimeId?: number | null,
sequence: number = 1
) => {
if (!isAdmin.value) {
error.value = '只有管理员才能创建排期'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/schedule', {
method: 'POST',
body: { songId, playDate, playTimeId, sequence },
...authConfig
})
return data
} catch (err: any) {
error.value = err.message || '创建排期失败'
throw err
} finally {
loading.value = false
}
}
// 移除排期
const removeSchedule = async (scheduleId: number) => {
if (!isAdmin.value) {
error.value = '只有管理员才能移除排期'
return { success: false, message: error.value } as const
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const response = await $fetch<{
success: boolean
message?: string
schedule?: any
}>('/api/admin/schedule/remove', {
method: 'POST',
body: { scheduleId },
...authConfig
})
// 检查响应
if (!response.success) {
error.value = response.message || '移除排期失败'
return { success: false, message: error.value } as const
}
return response
} catch (err: any) {
const errorMessage = err.data?.message || err.message || '移除排期失败'
error.value = errorMessage
console.error('移除排期错误:', err)
return { success: false, message: errorMessage } as const
} finally {
loading.value = false
}
}
// 更新排期顺序
const updateScheduleSequence = async (schedules: Array<{ id: number; sequence: number }>) => {
if (!isAdmin.value) {
error.value = '只有管理员才能更新排期顺序'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/schedule/sequence', {
method: 'POST',
body: { schedules },
...authConfig
})
return data
} catch (err: any) {
error.value = err.message || '更新排期顺序失败'
throw err
} finally {
loading.value = false
}
}
// 标记歌曲已播放
const markSongAsPlayed = async (songId: number) => {
if (!isAdmin.value) {
error.value = '只有管理员才能标记歌曲已播放'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/songs/mark-played', {
method: 'POST',
body: { songId },
...authConfig
})
return data
} catch (err: any) {
error.value = err.message || '标记播放失败'
throw err
} finally {
loading.value = false
}
}
// 发送管理员通知
const sendAdminNotification = async (notificationData: {
title: string
content: string
scope: 'ALL' | 'GRADE' | 'CLASS' | 'MULTI_CLASS'
filter: {
grade?: string
class?: string
classes?: Array<{ grade: string; class: string }>
}
}) => {
if (!isAdmin.value) {
error.value = '只有管理员才能发送系统通知'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/notifications/send', {
method: 'POST',
body: notificationData,
...authConfig
})
return data
} catch (err: any) {
error.value = err.message || '发送通知失败'
throw err
} finally {
loading.value = false
}
}
// 获取系统设置
const getSystemSettings = async () => {
if (!isAdmin.value) {
error.value = '只有管理员才能获取系统设置'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/system-settings', {
...authConfig
})
return data as SystemSettings
} catch (err: any) {
error.value = err.message || '获取系统设置失败'
throw err
} finally {
loading.value = false
}
}
// 更新系统设置
const updateSystemSettings = async (settings: Partial<SystemSettings>) => {
if (!isAdmin.value) {
error.value = '只有管理员才能更新系统设置'
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/system-settings', {
method: 'POST',
body: settings,
...authConfig
})
return data as SystemSettings
} catch (err: any) {
error.value = err.message || '更新系统设置失败'
throw err
} finally {
loading.value = false
}
}
// 获取所有播放时段
const getPlayTimes = async () => {
if (!isAdmin.value) {
error.value = '只有管理员才能获取播放时段'
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/play-times', {
...authConfig
})
return data as PlayTime[]
} catch (err: any) {
error.value = err.message || '获取播放时段失败'
throw err
} finally {
loading.value = false
}
}
// 创建播放时段
const createPlayTime = async (playTimeData: Partial<PlayTime>) => {
if (!isAdmin.value) {
error.value = '只有管理员才能创建播放时段'
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/play-times', {
method: 'POST',
body: playTimeData,
...authConfig
})
return data as PlayTime
} catch (err: any) {
error.value = err.message || '创建播放时段失败'
throw err
} finally {
loading.value = false
}
}
// 更新播放时段
const updatePlayTime = async (id: number, playTimeData: Partial<PlayTime>) => {
if (!isAdmin.value) {
error.value = '只有管理员才能更新播放时段'
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch(`/api/admin/play-times/${id}`, {
method: 'PUT',
body: playTimeData,
...authConfig
})
return data as PlayTime
} catch (err: any) {
error.value = err.message || '更新播放时段失败'
throw err
} finally {
loading.value = false
}
}
// 删除播放时段
const deletePlayTime = async (id: number) => {
if (!isAdmin.value) {
error.value = '只有管理员才能删除播放时段'
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch(`/api/admin/play-times/${id}`, {
method: 'DELETE',
...authConfig
})
return data
} catch (err: any) {
error.value = err.message || '删除播放时段失败'
throw err
} finally {
loading.value = false
}
}
// 删除歌曲
const deleteSong = async (songId: number) => {
if (!isAdmin.value) {
error.value = '只有管理员才能删除歌曲'
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/songs/delete', {
method: 'POST',
body: { songId },
...authConfig
})
return data
} catch (err: any) {
error.value = err.message || '删除歌曲失败'
throw err
} finally {
loading.value = false
}
}
// 更新歌曲
const updateSong = async (songId: number, songData: any) => {
if (!isAdmin.value) {
error.value = '只有管理员才能更新歌曲'
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch(`/api/songs/${songId}/update`, {
method: 'PUT',
body: songData,
...authConfig
})
return data
} catch (err: any) {
error.value = err.message || '更新歌曲失败'
throw err
} finally {
loading.value = false
}
}
// 添加歌曲
const addSong = async (songData: any) => {
if (!isAdmin.value) {
error.value = '只有管理员才能添加歌曲'
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/songs/add', {
method: 'POST',
body: songData,
...authConfig
})
return data
} catch (err: any) {
error.value = err.message || '添加歌曲失败'
throw err
} finally {
loading.value = false
}
}
return {
loading,
error,
createSchedule,
removeSchedule,
markSongAsPlayed,
updateScheduleSequence,
sendAdminNotification,
getSystemSettings,
updateSystemSettings,
getPlayTimes,
createPlayTime,
updatePlayTime,
deletePlayTime,
deleteSong,
updateSong,
addSong
}
}
+395
View File
@@ -0,0 +1,395 @@
import { computed, readonly, ref } from 'vue'
export interface PlayableSong {
id: number
title: string
artist: string
musicUrl?: string | null
cover?: string | null
musicPlatform?: string
musicId?: string
playUrl?: string | null
}
interface PlaylistItem {
id: number
song: PlayableSong
playDate?: string
playTimeId?: number
}
// 创建一个单例实例
const currentSong = ref<PlayableSong | null>(null)
const isPlaying = ref(false)
const currentPlaylist = ref<PlaylistItem[]>([])
const currentPlaylistIndex = ref(-1)
const currentPosition = ref(0) // 当前播放位置(秒)
const duration = ref(0) // 歌曲总时长(秒)
export function useAudioPlayer() {
// 播放歌曲
const playSong = (song: PlayableSong, playlist?: PlayableSong[], playlistIndex?: number) => {
if (!song.musicUrl) {
console.error('歌曲没有可播放的URL')
return false
}
// 如果提供了播放列表,更新播放列表
if (playlist && playlist.length > 0) {
// 将PlayableSong数组转换为PlaylistItem数组
currentPlaylist.value = playlist.map((s) => ({ song: s }))
// 使用提供的索引或查找当前歌曲在播放列表中的位置
if (
typeof playlistIndex === 'number' &&
playlistIndex >= 0 &&
playlistIndex < playlist.length
) {
currentPlaylistIndex.value = playlistIndex
} else {
const index = playlist.findIndex((item) => item.id === song.id)
currentPlaylistIndex.value = index >= 0 ? index : -1
}
} else if (currentSong.value && currentSong.value.id !== song.id) {
// 如果没有提供播放列表且是新歌曲,清空播放列表
currentPlaylist.value = []
currentPlaylistIndex.value = -1
}
// 如果是同一首歌,则开始播放(不切换状态,由调用方决定)
if (currentSong.value && currentSong.value.id === song.id) {
// 保持当前歌曲的引用更新,以便获取可能更新的 URL 等信息
currentSong.value = song
isPlaying.value = true
return true
}
// 播放新歌曲
currentSong.value = song
isPlaying.value = true
currentPosition.value = 0
duration.value = 0 // 重置时长,等待新歌曲加载后更新
return true
}
// 暂停播放
const pauseSong = () => {
isPlaying.value = false
}
// 停止播放
const stopSong = () => {
isPlaying.value = false
currentSong.value = null
currentPosition.value = 0
duration.value = 0
}
// 播放下一首歌曲
const playNext = async () => {
if (currentPlaylist.value.length === 0 || currentPlaylistIndex.value === -1) {
console.log('没有播放列表或当前歌曲不在播放列表中')
return false
}
let nextIndex = currentPlaylistIndex.value + 1
let attempts = 0
const maxAttempts = currentPlaylist.value.length // 最多尝试整个播放列表的长度
// 循环尝试找到可播放的下一首歌曲
while (nextIndex < currentPlaylist.value.length && attempts < maxAttempts) {
const nextSong = currentPlaylist.value[nextIndex].song
// 如果已经有播放URL,直接使用
if (nextSong.musicUrl) {
currentPlaylistIndex.value = nextIndex
currentSong.value = nextSong
currentPosition.value = 0
duration.value = 0 // 重置时长
isPlaying.value = true
return true
}
// 如果没有播放URL,尝试动态获取
if (nextSong.musicPlatform && nextSong.musicId) {
try {
const { getMusicUrl } = await import('~/utils/musicUrl')
// 检查是否为播客内容
const isPodcast =
nextSong.musicPlatform === 'netease-podcast' ||
nextSong.sourceInfo?.type === 'voice' ||
(nextSong.sourceInfo?.source === 'netease-backup' &&
nextSong.sourceInfo?.type === 'voice')
const options = isPodcast ? { unblock: false } : {}
const url = await getMusicUrl(
nextSong.musicPlatform,
nextSong.musicId,
nextSong.playUrl,
options
)
if (url) {
nextSong.musicUrl = url
currentPlaylistIndex.value = nextIndex
currentSong.value = nextSong
currentPosition.value = 0
duration.value = 0 // 重置时长
isPlaying.value = true
return true
} else {
const msg = `无法获取《${nextSong.title}》的播放链接,已自动跳过`
console.warn(msg)
if (window.$showNotification) {
window.$showNotification(msg, 'warning')
}
}
} catch (error) {
const msg = `《${nextSong.title}》获取播放链接失败,已自动跳过`
console.warn(msg, error)
if (window.$showNotification) {
window.$showNotification(msg, 'error')
}
}
} else {
const msg = `《${nextSong.title}》缺少播放信息,已自动跳过`
console.warn(msg)
if (window.$showNotification) {
window.$showNotification(msg, 'warning')
}
}
// 尝试下一首
nextIndex++
attempts++
}
console.log('没有找到可播放的下一首歌曲')
return false
}
// 播放上一首歌曲
const playPrevious = async () => {
if (currentPlaylist.value.length === 0 || currentPlaylistIndex.value === -1) {
console.log('没有播放列表或当前歌曲不在播放列表中')
return false
}
let prevIndex = currentPlaylistIndex.value - 1
let attempts = 0
const maxAttempts = currentPlaylist.value.length // 最多尝试整个播放列表的长度
// 循环尝试找到可播放的上一首歌曲
while (prevIndex >= 0 && attempts < maxAttempts) {
const prevSong = currentPlaylist.value[prevIndex].song
// 如果已经有播放URL,直接使用
if (prevSong.musicUrl) {
currentPlaylistIndex.value = prevIndex
currentSong.value = prevSong
currentPosition.value = 0
duration.value = 0 // 重置时长
isPlaying.value = true
return true
}
// 如果没有播放URL,尝试动态获取
if (prevSong.musicPlatform && prevSong.musicId) {
try {
const { getMusicUrl } = await import('~/utils/musicUrl')
// 检查是否为播客内容
const isPodcast =
prevSong.musicPlatform === 'netease-podcast' ||
prevSong.sourceInfo?.type === 'voice' ||
(prevSong.sourceInfo?.source === 'netease-backup' &&
prevSong.sourceInfo?.type === 'voice')
const options = isPodcast ? { unblock: false } : {}
const url = await getMusicUrl(
prevSong.musicPlatform,
prevSong.musicId,
prevSong.playUrl,
options
)
if (url) {
prevSong.musicUrl = url
currentPlaylistIndex.value = prevIndex
currentSong.value = prevSong
currentPosition.value = 0
duration.value = 0 // 重置时长
isPlaying.value = true
return true
} else {
console.warn(`跳过第${prevIndex + 1}首歌曲:无法获取播放链接,可能是付费内容`)
}
} catch (error) {
console.warn(`跳过第${prevIndex + 1}首歌曲:获取播放URL失败`, error)
}
} else {
console.warn(`跳过第${prevIndex + 1}首歌曲:缺少音乐平台或ID信息`)
}
// 尝试上一首
prevIndex--
attempts++
}
console.log('没有找到可播放的上一首歌曲')
return false
}
// 设置播放位置
const setPosition = (position: number) => {
currentPosition.value = Math.max(0, Math.min(position, duration.value))
}
// 设置歌曲总时长
const setDuration = (newDuration: number) => {
duration.value = Math.max(0, newDuration)
}
// 更新播放位置(由音频元素的timeupdate事件调用)
const updatePosition = (position: number) => {
currentPosition.value = position
}
// 检查指定ID的歌曲是否正在播放
const isCurrentPlaying = (songId: number) => {
return isPlaying.value && currentSong.value && currentSong.value.id === songId
}
// 检查指定ID的歌曲是否为当前歌曲(不管是否在播放)
const isCurrentSong = (songId: number) => {
return currentSong.value && currentSong.value.id === songId
}
// 检查是否有下一首歌曲
const hasNext = computed(() => {
return (
currentPlaylist.value.length > 0 &&
currentPlaylistIndex.value >= 0 &&
currentPlaylistIndex.value < currentPlaylist.value.length - 1
)
})
// 检查是否有上一首歌曲
const hasPrevious = computed(() => {
return currentPlaylist.value.length > 0 && currentPlaylistIndex.value > 0
})
// 提前预加载下一首歌曲的URL
let isPrefetching = false
const prefetchNextSong = async () => {
if (isPrefetching || !hasNext.value) return false
const nextIndex = currentPlaylistIndex.value + 1
const nextSong = currentPlaylist.value[nextIndex]?.song
if (!nextSong || nextSong.musicUrl || !nextSong.musicPlatform || !nextSong.musicId) {
return false
}
isPrefetching = true
try {
const { getMusicUrl } = await import('~/utils/musicUrl')
const isPodcast =
nextSong.musicPlatform === 'netease-podcast' ||
nextSong.sourceInfo?.type === 'voice' ||
(nextSong.sourceInfo?.source === 'netease-backup' &&
nextSong.sourceInfo?.type === 'voice')
const options = isPodcast ? { unblock: false } : {}
const url = await getMusicUrl(
nextSong.musicPlatform,
nextSong.musicId,
nextSong.playUrl,
options
)
if (url) {
nextSong.musicUrl = url
console.log(`成功预加载下一首歌曲: ${nextSong.title}`)
}
} catch (error) {
console.warn(`预加载下一首歌曲《${nextSong.title}》失败:`, error)
// 注意:这里不显示错误通知,失败会在真正尝试播放时处理并提示
} finally {
isPrefetching = false
}
return true
}
// 获取当前播放列表
const getCurrentPlaylist = () => {
return readonly(currentPlaylist)
}
// 获取当前播放列表索引
const getCurrentPlaylistIndex = () => {
return readonly(currentPlaylistIndex)
}
// 获取当前播放的歌曲信息
const getCurrentSong = () => {
return readonly(currentSong)
}
// 获取当前播放状态
const getPlayingStatus = () => {
return readonly(isPlaying)
}
// 获取当前播放位置
const getCurrentPosition = () => {
return readonly(currentPosition)
}
// 获取歌曲总时长
const getDuration = () => {
return readonly(duration)
}
// 获取播放进度百分比
const getProgress = computed(() => {
if (duration.value === 0) return 0
return (currentPosition.value / duration.value) * 100
})
// 更新当前歌曲信息(用于音质切换等场景)
const updateCurrentSong = (updatedSong: PlayableSong) => {
if (currentSong.value && currentSong.value.id === updatedSong.id) {
currentSong.value = updatedSong
// 同时更新播放列表中的对应项
if (currentPlaylistIndex.value !== -1 && currentPlaylist.value[currentPlaylistIndex.value]) {
currentPlaylist.value[currentPlaylistIndex.value].song = updatedSong
}
return true
}
return false
}
return {
playSong,
pauseSong,
stopSong,
playNext,
playPrevious,
updateCurrentSong,
setPosition,
setDuration,
updatePosition,
isCurrentPlaying,
isCurrentSong,
getCurrentSong,
getPlayingStatus,
getCurrentPosition,
getDuration,
getProgress,
hasNext,
hasPrevious,
prefetchNextSong,
getCurrentPlaylist,
getCurrentPlaylistIndex
}
}
+856
View File
@@ -0,0 +1,856 @@
import { computed, nextTick, ref } from 'vue'
import { useAudioQuality } from '~/composables/useAudioQuality'
import { useLyrics } from '~/composables/useLyrics'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
// 单例状态
const audioPlayer = ref<HTMLAudioElement | null>(null)
const isPlaying = ref(false)
const progress = ref(0)
const currentTime = ref(0)
const duration = ref(0)
const hasError = ref(false)
const coverError = ref(false)
const showQualitySettings = ref(false)
// 拖拽状态
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartProgress = ref(0)
const dragProgress = ref(0)
// 同步状态
const isSyncingFromGlobal = ref(false)
const isLoadingNewSong = ref(false)
const isLoadingTrack = ref(false)
const progressBarRef = ref<HTMLElement | null>(null)
const hasUserInteracted = ref(false)
// 播放模式: 'off' (单曲播放完退出) | 'order' (顺序播放列表) | 'loopOne' (单曲循环)
const playMode = ref<'off' | 'order' | 'loopOne'>('order')
// 共享歌词实例
const lyrics = useLyrics()
export const useAudioPlayerControl = () => {
// 音质 (Composable 使用应在 setup/function 内部)
const { getQualityLabel, getQuality, getQualityOptions, saveQuality } = useAudioQuality()
// 全局音频播放器
const globalAudioPlayer = useAudioPlayer()
// 基本播放控制
const play = async (): Promise<boolean> => {
// 等待音频播放器引用设置完成
let retryCount = 0
const maxRetries = 20
while (!audioPlayer.value && retryCount < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 50))
retryCount++
}
if (!audioPlayer.value || hasError.value) {
return false
}
try {
// 确保音频已经加载
if (audioPlayer.value.readyState < 2) {
await waitForCanPlay(audioPlayer.value)
}
// 设置音频属性以支持自动播放
audioPlayer.value.autoplay = true
audioPlayer.value.preload = 'auto'
const playPromise = audioPlayer.value.play()
// 处理播放 Promise
if (playPromise !== undefined) {
await playPromise
}
return true
} catch (error) {
// 检查是否是自动播放被阻止的错误
if (error.name === 'NotAllowedError') {
console.warn('[AudioPlayerControl] ⚠️ 自动播放被浏览器阻止,需要用户交互')
// 不设置 hasError,因为这不是真正的错误
return false
} else {
console.error('[AudioPlayerControl] ❌ 播放失败:', error)
hasError.value = true
return false
}
}
}
const pause = (): boolean => {
if (!audioPlayer.value) {
console.warn('[AudioPlayerControl] pause called but audioPlayer is null')
return false
}
try {
audioPlayer.value.pause()
return true
} catch (error) {
console.error('暂停失败:', error)
return false
}
}
const stop = (): boolean => {
if (!audioPlayer.value) return false
try {
audioPlayer.value.pause()
audioPlayer.value.removeAttribute('src')
audioPlayer.value.load()
// 清理歌词状态
lyrics.clearLyrics()
resetState()
return true
} catch (error) {
console.error('停止失败:', error)
return false
}
}
const seek = async (timeInSeconds: number): Promise<boolean> => {
if (!audioPlayer.value) return false
try {
// 如果 duration 为 0,可能元数据还未加载,直接使用传入时间
const maxTime = duration.value > 0 ? duration.value : timeInSeconds + 1000
const targetTime = Math.max(0, Math.min(timeInSeconds, maxTime))
audioPlayer.value.currentTime = targetTime
currentTime.value = targetTime
if (duration.value > 0) {
progress.value = (targetTime / duration.value) * 100
}
return true
} catch (error) {
console.error('跳转失败:', error)
return false
}
}
// 抽象后的 seekAndPlay 方法
const seekAndPlay = async (timeInSeconds: number) => {
if (!audioPlayer.value) return
// 1. 跳转
await seek(timeInSeconds)
// 2. 如果暂停则播放
try {
await audioPlayer.value.play()
} catch (e) {
console.warn('Play failed:', e)
}
// 3. 与全局音频播放器同步状态
globalAudioPlayer.setPosition(timeInSeconds)
if (!globalAudioPlayer.getPlayingStatus().value) {
const current = globalAudioPlayer.getCurrentSong().value
if (current) {
globalAudioPlayer.playSong(current)
}
}
}
// 等待音频可以播放
const waitForCanPlay = (audio: HTMLAudioElement): Promise<void> => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
reject(new Error('音频加载超时'))
}, 10000) // 10秒超时
const onCanPlay = () => {
clearTimeout(timeout)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
resolve()
}
const onError = (error: Event) => {
clearTimeout(timeout)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
reject(error)
}
audio.addEventListener('canplay', onCanPlay)
audio.addEventListener('error', onError)
})
}
// 加载新歌曲
const loadSong = async (
songUrlOrSong: string | any,
retryCount: number = 0
): Promise<boolean> => {
if (!audioPlayer.value) return false
stop()
isLoadingNewSong.value = true
isLoadingTrack.value = true // 开始加载时设置加载状态
hasError.value = false
// 立即清空之前的歌词,避免显示上一首歌的歌词
lyrics.clearLyrics()
try {
let songUrl: string
let songInfo: any = null
const lyricsPromise: Promise<void> | null = null
// 如果传入的是歌曲对象,检查是否有音乐平台信息
if (typeof songUrlOrSong === 'object' && songUrlOrSong !== null) {
songInfo = songUrlOrSong
// 优先检查是否已有直接的播放URL
if (songUrlOrSong.musicUrl) {
songUrl = songUrlOrSong.musicUrl
// 清理URL中的反引号和空格(特别是网易云备用源)
songUrl = songUrl.trim().replace(/`/g, '')
console.log('使用已有的播放URL:', songUrl)
// 如果有音乐平台信息,加载歌词
if (songUrlOrSong.musicPlatform && songUrlOrSong.musicId) {
// lyricsPromise = lyrics.fetchLyrics(songUrlOrSong.musicPlatform, songUrlOrSong.musicId)
// 迁移到 useLyricManager 统一管理,避免重复请求
}
} else if (songUrlOrSong.musicPlatform && songUrlOrSong.musicId) {
// 检查是否是网易云备用源,如果是则不应该调用getMusicUrl
if (songUrlOrSong.sourceInfo?.source === 'netease-backup') {
throw new Error('网易云备用源歌曲缺少播放URL,请重新获取')
}
console.log('正在获取歌曲URL:', songUrlOrSong.musicPlatform, songUrlOrSong.musicId)
// 检查是否为播客内容
const isPodcast =
songUrlOrSong.musicPlatform === 'netease-podcast' ||
songUrlOrSong.sourceInfo?.type === 'voice' ||
(songUrlOrSong.sourceInfo?.source === 'netease-backup' &&
songUrlOrSong.sourceInfo?.type === 'voice')
const options = isPodcast ? { unblock: false } : {}
songUrl = await getMusicUrl(
songUrlOrSong.musicPlatform,
songUrlOrSong.musicId,
songUrlOrSong.playUrl,
options
)
if (!songUrl) {
throw new Error('无法获取歌曲URL')
}
// 并行加载歌词(不阻塞音频加载)
// lyricsPromise = lyrics.fetchLyrics(songUrlOrSong.musicPlatform, songUrlOrSong.musicId)
// 迁移到 useLyricManager 统一管理,避免重复请求
} else {
throw new Error('歌曲缺少播放信息(音乐平台ID或直接URL)')
}
} else if (typeof songUrlOrSong === 'string') {
songUrl = songUrlOrSong
} else {
throw new Error('无效的歌曲参数')
}
console.log('设置音频源:', songUrl)
audioPlayer.value.src = songUrl
audioPlayer.value.load()
// 等待音频可以播放
await waitForCanPlay(audioPlayer.value)
// 等待歌词加载完成(如果有的话)
if (lyricsPromise) {
try {
await lyricsPromise
// 歌词加载完成
} catch (lyricsError) {
console.warn('歌词加载失败,但继续播放音频:', lyricsError)
}
}
console.log('歌曲加载成功:', songInfo?.title || songUrl)
isLoadingNewSong.value = false
// 自动开始播放
console.log('尝试自动播放音乐')
const playResult = await play()
if (!playResult) {
console.log('自动播放失败,可能被浏览器阻止,等待用户交互')
} else {
console.log('自动播放成功')
}
return true
} catch (error) {
// 重试逻辑(针对网络抖动等临时错误)
if (
retryCount < 2 &&
!hasError.value &&
typeof songUrlOrSong === 'object' &&
songUrlOrSong?.musicPlatform
) {
await new Promise((resolve) => setTimeout(resolve, 1000))
if (hasError.value) return false
return await loadSong(songUrlOrSong, retryCount + 1)
}
hasError.value = true
isLoadingNewSong.value = false
return false
}
}
// 切换播放/暂停
const togglePlay = async (): Promise<boolean> => {
// 标记用户已交互
hasUserInteracted.value = true
if (isPlaying.value) {
return pause()
} else {
return await play()
}
}
// 音质切换
const switchQuality = async (
platform: string,
musicId: string,
qualityValue: string
): Promise<boolean> => {
if (!audioPlayer.value) return false
// 保存当前播放进度和状态
const currentTimeBackup = audioPlayer.value.currentTime
const wasPlaying = isPlaying.value
// 立即暂停
if (wasPlaying) {
pause()
}
try {
// 保存音质设置
saveQuality(platform, qualityValue)
// 获取新音质的URL
const newUrl = await getMusicUrl(
platform,
musicId,
globalAudioPlayer.getCurrentSong().value?.playUrl
)
if (!newUrl) {
throw new Error('获取新音质URL失败')
}
// 加载新音频
const loadSuccess = await loadSong(newUrl)
if (!loadSuccess) {
throw new Error('加载新音质失败')
}
// 恢复播放进度
if (currentTimeBackup > 0) {
await seek(currentTimeBackup)
}
// 如果之前在播放,恢复播放状态
if (wasPlaying) {
// 先尝试播放,再设置进度,避免自动播放限制
try {
await play()
if (currentTimeBackup > 0) {
await nextTick()
await seek(currentTimeBackup)
}
} catch (playError) {
console.warn('恢复播放失败,可能需要用户手动播放:', playError)
}
}
return true
} catch (error) {
console.error('切换音质失败:', error)
// 如果切换失败,尝试恢复之前的播放状态
if (wasPlaying && !audioPlayer.value.error) {
try {
await play()
} catch (playError) {
console.error('恢复播放失败:', playError)
}
}
return false
}
}
// 动态获取音乐URL(委托到统一逻辑)
const getMusicUrl = async (
platform: string,
musicId: string,
playUrl: string | null = null,
options?: {
unblock?: boolean
quality?: number
}
): Promise<string | null> => {
try {
const { getMusicUrl: coreGetMusicUrl } = await import('~/utils/musicUrl')
const url = await coreGetMusicUrl(platform, musicId, playUrl ?? undefined, options)
return url
} catch (error) {
console.error('获取音乐URL错误:', error)
if (error instanceof TypeError && (error as any).message?.includes('fetch')) {
throw new Error('网络连接失败,请检查网络连接')
} else if ((error as any).name === 'AbortError') {
throw new Error('请求超时,请稍后重试')
}
throw error
}
}
// 进度条拖拽
const startDrag = (event: MouseEvent, progressBar: HTMLElement) => {
if (event.button !== 0) return
if (!audioPlayer.value || !progressBar) {
return
}
isDragging.value = true
dragStartX.value = event.clientX
dragStartProgress.value = progress.value
const onDrag = (e: MouseEvent) => {
if (!isDragging.value || !progressBar) return
const rect = progressBar.getBoundingClientRect()
const newX = e.clientX - rect.left
const percentage = Math.max(0, Math.min(100, (newX / rect.width) * 100))
progress.value = percentage
// 拖拽时只更新进度条显示,不实时更新音频位置
if (duration.value) {
const newTime = (percentage / 100) * duration.value
currentTime.value = newTime
}
}
const endDrag = () => {
isDragging.value = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', endDrag)
// 拖拽结束时才更新音频位置
if (audioPlayer.value && duration.value) {
const newTime = (progress.value / 100) * duration.value
audioPlayer.value.currentTime = newTime
currentTime.value = newTime
}
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', endDrag)
event.preventDefault()
}
// 触摸拖拽
const startTouchDrag = (event: TouchEvent, progressBar: HTMLElement) => {
if (event.touches.length !== 1) return
if (!audioPlayer.value || !progressBar) {
return
}
// 阻止默认行为,防止页面滚动
event.preventDefault()
event.stopPropagation()
isDragging.value = true
const touch = event.touches[0]
dragStartX.value = touch.clientX
dragStartProgress.value = progress.value
const onTouchDrag = (e: TouchEvent) => {
if (!isDragging.value || !progressBar || e.touches.length !== 1) return
// 阻止默认行为和事件冒泡
e.preventDefault()
e.stopPropagation()
const touch = e.touches[0]
const rect = progressBar.getBoundingClientRect()
const newX = touch.clientX - rect.left
const percentage = Math.max(0, Math.min(100, (newX / rect.width) * 100))
progress.value = percentage
// 拖拽时只更新进度条显示,不实时更新音频位置
if (duration.value) {
const newTime = (percentage / 100) * duration.value
currentTime.value = newTime
}
}
const endTouchDrag = (e: TouchEvent) => {
e.preventDefault()
e.stopPropagation()
isDragging.value = false
document.removeEventListener('touchmove', onTouchDrag)
document.removeEventListener('touchend', endTouchDrag)
// 触摸结束时才更新音频位置
if (audioPlayer.value && duration.value) {
const newTime = (progress.value / 100) * duration.value
audioPlayer.value.currentTime = newTime
currentTime.value = newTime
}
}
document.addEventListener('touchmove', onTouchDrag, { passive: false })
document.addEventListener('touchend', endTouchDrag, { passive: false })
}
// 进度条点击跳转
const seekToPosition = (event: MouseEvent) => {
if (!audioPlayer.value || isDragging.value) return
const progressBar = event.currentTarget as HTMLElement
if (!progressBar) {
return
}
const clickPosition = event.offsetX
const barWidth = progressBar.clientWidth
const seekPercentage = clickPosition / barWidth
const newTime = seekPercentage * duration.value
seek(newTime)
}
// 音质相关计算属性
const currentQualityText = computed(() => {
return (platform: string) => {
if (!platform) return '音质'
const quality = getQuality(platform)
const label = getQualityLabel(platform, quality)
return label.replace(/音质|音乐/, '').trim() || '音质'
}
})
const currentPlatformOptions = computed(() => {
return (platform: string) => {
if (!platform) return []
return getQualityOptions(platform)
}
})
const isCurrentQuality = (platform: string, qualityValue: string) => {
if (!platform) return false
return getQuality(platform) === qualityValue
}
// 格式化时间
const formatTime = (seconds: number) => {
if (!seconds || isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 音频事件处理
let hasPrefetchedForCurrentSong = false
const onTimeUpdate = (currentTimeValue: number) => {
if (isDragging.value) return
currentTime.value = currentTimeValue
if (duration.value > 0) {
progress.value = (currentTimeValue / duration.value) * 100
// 距离结束还有15秒时,提前预加载下一首
if (!hasPrefetchedForCurrentSong && duration.value - currentTimeValue <= 15) {
hasPrefetchedForCurrentSong = true
globalAudioPlayer.prefetchNextSong()
}
}
// 更新歌词时间
const timeInMs = currentTimeValue * 1000
lyrics.updateCurrentLyricIndex(timeInMs) // 转换为毫秒
}
const onLoaded = (durationValue: number) => {
duration.value = durationValue
hasError.value = false
hasPrefetchedForCurrentSong = false // 重置预加载标志
}
const onError = (error: any) => {
console.error('音频播放错误:', error)
// 获取更详细的错误信息
let errorMessage = '音频播放失败'
if (audioPlayer.value && audioPlayer.value.error) {
const mediaError = audioPlayer.value.error
switch (mediaError.code) {
case MediaError.MEDIA_ERR_ABORTED:
errorMessage = '音频加载被中止'
break
case MediaError.MEDIA_ERR_NETWORK:
errorMessage = '网络错误,无法加载音频'
break
case MediaError.MEDIA_ERR_DECODE:
errorMessage = '音频解码失败,格式不支持'
break
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = '音频源不支持或无效'
break
default:
errorMessage = `音频播放错误 (代码: ${mediaError.code})`
}
console.error(`详细错误信息: ${errorMessage}`, {
code: mediaError.code,
message: mediaError.message,
src: audioPlayer.value.src
})
}
hasError.value = true
isPlaying.value = false
// 显示用户友好的错误提示
if (window.$showNotification) {
window.$showNotification(errorMessage, 'error')
}
}
const onPlay = () => {
isPlaying.value = true
hasError.value = false
isLoadingTrack.value = false // 音频开始播放时立即清除加载状态
}
const onPause = () => {
isPlaying.value = false
}
const onEnded = () => {
// 根据播放模式处理播放结束事件
if (playMode.value === 'loopOne') {
// 单曲循环:重新播放当前歌曲
if (audioPlayer.value) {
audioPlayer.value.currentTime = 0
// 使用 Promise 处理播放,避免自动播放策略问题
const playPromise = audioPlayer.value.play()
if (playPromise !== undefined) {
playPromise.catch((error) => {
console.error('单曲循环播放失败:', error)
// 如果自动播放失败,更新播放状态
isPlaying.value = false
})
}
}
} else if (playMode.value === 'order') {
// 顺序播放:播放下一首
// 如果没有下一首(列表结束),useAudioPlayer.playNext 会返回 false,此时自然停止
const hasNext = globalAudioPlayer.hasNext.value
if (hasNext) {
globalAudioPlayer.playNext()
} else {
// 列表播放结束,停止播放
isPlaying.value = false
progress.value = 0
currentTime.value = 0
}
} else {
// 默认/播放完退出 (off):停止播放
isPlaying.value = false
progress.value = 0
currentTime.value = 0
}
}
const onLoadStart = () => {
hasError.value = false
}
const onCanPlay = () => {
hasError.value = false
}
// 设置进度条引用
const setProgressBarRef = (element: HTMLElement | null) => {
progressBarRef.value = element
}
// 设置音频播放器引用
const setAudioPlayerRef = (element: HTMLAudioElement | null) => {
audioPlayer.value = element
}
// 清理资源
const cleanup = () => {
if (audioPlayer.value) {
// 先暂停播放
audioPlayer.value.pause()
// 不设置空的 src,避免触发 MEDIA_ERR_SRC_NOT_SUPPORTED 错误
// 只是暂停播放即可,让组件自然销毁
}
resetState()
}
// 重置状态
const resetState = () => {
isPlaying.value = false
progress.value = 0
currentTime.value = 0
duration.value = 0
hasError.value = false
coverError.value = false
isDragging.value = false
isSyncingFromGlobal.value = false
isLoadingNewSong.value = false
isLoadingTrack.value = false
}
// 强制更新位置(用于鸿蒙侧同步)
const forceUpdatePosition = (timeInSeconds: number) => {
// 如果正在拖拽,不要更新位置
if (isDragging.value) {
return false
}
if (!audioPlayer.value) {
return false
}
try {
// 设置同步标志,防止触发其他事件
isSyncingFromGlobal.value = true
// 如果有duration,使用它来限制范围;否则直接使用传入的时间
const targetTime =
duration.value > 0
? Math.max(0, Math.min(timeInSeconds, duration.value))
: Math.max(0, timeInSeconds)
// 更新音频播放器位置
audioPlayer.value.currentTime = targetTime
// 强制更新UI状态
currentTime.value = targetTime
// 只有在duration存在时才计算进度百分比
if (duration.value > 0) {
progress.value = (targetTime / duration.value) * 100
}
// 更新歌词时间
const timeInMs = targetTime * 1000
lyrics.updateCurrentLyricIndex(timeInMs)
// 使用nextTick确保DOM更新
nextTick(() => {
isSyncingFromGlobal.value = false
})
return true
} catch (error) {
isSyncingFromGlobal.value = false
return false
}
}
// 设置播放模式
const setPlayMode = (mode: 'off' | 'order' | 'loopOne') => {
if (['off', 'order', 'loopOne'].includes(mode)) {
playMode.value = mode
return true
}
return false
}
return {
// 状态
audioPlayer,
isPlaying,
progress,
currentTime,
duration,
hasError,
coverError,
showQualitySettings,
isDragging,
isSyncingFromGlobal,
isLoadingNewSong,
isLoadingTrack,
progressBarRef,
hasUserInteracted,
playMode, // 暴露播放模式
// 基本控制
play,
setPlayMode, // 暴露设置播放模式方法
pause,
stop,
seek,
seekAndPlay, // 新增
togglePlay,
loadSong,
forceUpdatePosition,
// 音频事件处理
onTimeUpdate,
onLoaded,
onError,
onPlay,
onPause,
onEnded,
onLoadStart,
onCanPlay,
setProgressBarRef,
setAudioPlayerRef,
// 音质控制
switchQuality,
currentQualityText,
currentPlatformOptions,
isCurrentQuality,
// 拖拽控制
startDrag,
startTouchDrag,
seekToPosition,
// 工具函数
formatTime,
resetState,
waitForCanPlay,
cleanup,
// 歌词功能
lyrics
}
}
+354
View File
@@ -0,0 +1,354 @@
import { computed, ref } from 'vue'
import { useAudioQuality } from './useAudioQuality'
export const useAudioPlayerEnhanced = () => {
const { getQuality, getQualityOptions, saveQuality } = useAudioQuality()
// 使用全局通知函数
const addNotification = (options) => {
if (typeof window !== 'undefined' && window.$showNotification) {
if (typeof options === 'string') {
window.$showNotification(options, 'info')
} else {
window.$showNotification(
options.message,
options.type || 'info',
true,
options.duration || 3000
)
}
} else {
console.log('通知:', options.message || options)
}
}
const retryCount = ref(0)
const maxRetries = ref(3)
const isRetrying = ref(false)
const qualitySwitchAttempted = ref(false)
// 重置重试状态
const resetRetryState = () => {
retryCount.value = 0
isRetrying.value = false
qualitySwitchAttempted.value = false
}
// 获取音乐链接(统一委托 + 支持传入临时音质或直接播放链接)
const getMusicUrl = async (
platform,
musicId,
qualityOrPlayUrl,
options?: { unblock?: boolean; quality?: number }
) => {
try {
const requestOptions =
typeof qualityOrPlayUrl === 'number'
? { ...options, quality: qualityOrPlayUrl }
: options
const { getMusicUrl: coreGetMusicUrl } = await import('~/utils/musicUrl')
const url = await coreGetMusicUrl(
platform,
musicId,
typeof qualityOrPlayUrl === 'string' ? qualityOrPlayUrl : undefined,
requestOptions
)
if (url) {
return { success: true, url }
}
return { success: false, error: '未获取到播放链接' }
} catch (error) {
console.error('获取音乐链接失败:', error)
return { success: false, error: (error as any)?.message || '网络错误' }
}
}
// 尝试切换音质
const tryQualitySwitch = async (song) => {
if (!song?.musicPlatform || !song?.musicId || qualitySwitchAttempted.value) {
return { success: false, error: '无法切换音质' }
}
const platform = song.musicPlatform
const currentQuality = getQuality(platform)
const qualityOptions = getQualityOptions(platform)
// 找到当前音质的索引
const currentIndex = qualityOptions.findIndex((option) => option.value === currentQuality)
// 尝试下一个音质
for (let i = currentIndex + 1; i < qualityOptions.length; i++) {
const newQuality = qualityOptions[i].value
console.log(`尝试切换到音质: ${qualityOptions[i].label}`)
// 检查是否为播客内容
const isPodcast =
song.musicPlatform === 'netease-podcast' ||
song.sourceInfo?.type === 'voice' ||
(song.sourceInfo?.source === 'netease-backup' && song.sourceInfo?.type === 'voice')
const options = isPodcast ? { unblock: false } : {}
const result = await getMusicUrl(platform, song.musicId, newQuality, options)
if (result.success) {
// 保存新音质设置
saveQuality(platform, newQuality)
qualitySwitchAttempted.value = true
addNotification({
type: 'info',
message: `已切换到${qualityOptions[i].label}`,
duration: 3000
})
return {
success: true,
url: result.url,
quality: newQuality,
qualityLabel: qualityOptions[i].label
}
}
}
// 如果所有更低音质都失败,尝试更高音质
for (let i = currentIndex - 1; i >= 0; i--) {
const newQuality = qualityOptions[i].value
console.log(`尝试切换到音质: ${qualityOptions[i].label}`)
// 检查是否为播客内容
const isPodcast =
song.musicPlatform === 'netease-podcast' ||
song.sourceInfo?.type === 'voice' ||
(song.sourceInfo?.source === 'netease-backup' && song.sourceInfo?.type === 'voice')
const options = isPodcast ? { unblock: false } : {}
const result = await getMusicUrl(platform, song.musicId, newQuality, options)
if (result.success) {
// 保存新音质设置
saveQuality(platform, newQuality)
qualitySwitchAttempted.value = true
addNotification({
type: 'info',
message: `已切换到${qualityOptions[i].label}`,
duration: 3000
})
return {
success: true,
url: result.url,
quality: newQuality,
qualityLabel: qualityOptions[i].label
}
}
}
qualitySwitchAttempted.value = true
return { success: false, error: '所有音质都无法播放' }
}
// 处理播放错误,包含重试和音质切换逻辑
const handlePlaybackError = async (
error,
song,
onSongChange,
onNext,
onClose,
isPlaylistMode = true
) => {
console.log('播放错误:', error, '重试次数:', retryCount.value)
// 如果已经在重试中,避免重复处理
if (isRetrying.value) {
return { handled: true }
}
isRetrying.value = true
try {
// 首先尝试音质切换(只尝试一次)
if (!qualitySwitchAttempted.value && song?.musicPlatform && song?.musicId) {
console.log('尝试切换音质解决播放问题...')
const qualityResult = await tryQualitySwitch(song)
if (qualityResult.success) {
// 更新歌曲的音乐链接
const updatedSong = {
...song,
musicUrl: qualityResult.url
}
// 通知父组件更新歌曲
if (onSongChange) {
onSongChange(updatedSong)
}
isRetrying.value = false
return { handled: true, success: true, newSong: updatedSong }
}
}
// 如果音质切换失败或不可用,尝试重试当前歌曲
if (retryCount.value < maxRetries.value) {
retryCount.value++
console.log(`重试播放当前歌曲 (${retryCount.value}/${maxRetries.value})`)
// 延迟重试
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount.value))
isRetrying.value = false
return { handled: true, shouldRetry: true }
}
// 重试次数用完,根据播放模式决定是否自动跳过
if (isPlaylistMode) {
console.log('当前歌曲重试次数用完,尝试下一首')
if (onNext) {
const nextResult = await onNext()
if (nextResult?.success && nextResult?.newSong) {
// 重置重试状态,为新歌曲准备
resetRetryState()
isRetrying.value = false
return { handled: true, success: true, newSong: nextResult.newSong }
}
}
// 如果没有下一首或下一首也失败,关闭播放器
console.log('没有可播放的歌曲,关闭播放器')
addNotification({
type: 'error',
message: '播放失败,已关闭播放器',
duration: 3000
})
if (onClose) {
onClose()
}
isRetrying.value = false
return { handled: true, shouldClose: true }
} else {
// 非播放列表模式(如投稿页面),只显示错误提示,不自动跳过
console.log('播放失败,显示错误提示(非播放列表模式)')
addNotification({
type: 'error',
message: '歌曲播放失败,请检查链接或稍后重试',
duration: 5000
})
isRetrying.value = false
return { handled: true, showError: true }
}
} catch (err) {
console.error('处理播放错误时发生异常:', err)
isRetrying.value = false
return { handled: false, error: err }
}
}
// 增强的音质切换功能
const enhancedQualitySwitch = async (song, newQuality) => {
if (!song?.musicPlatform || !song?.musicId) {
addNotification({
type: 'error',
message: '无法切换音质:歌曲信息不完整',
duration: 3000
})
return { success: false, error: '歌曲信息不完整' }
}
try {
console.log(`尝试切换到音质: ${newQuality}`)
// 先尝试获取新音质的链接(传入整数音质值)
const result = await getMusicUrl(song.musicPlatform, song.musicId, newQuality)
if (result.success) {
// 保存音质设置
saveQuality(song.musicPlatform, newQuality)
const qualityOptions = getQualityOptions(song.musicPlatform)
const qualityLabel =
qualityOptions.find((option) => option.value === newQuality)?.label || newQuality
addNotification({
type: 'success',
message: `已切换到${qualityLabel}`,
duration: 3000
})
return {
success: true,
url: result.url,
quality: newQuality,
qualityLabel
}
} else {
addNotification({
type: 'error',
message: `切换音质失败: ${result.error}`,
duration: 3000
})
return { success: false, error: result.error }
}
} catch (error) {
console.error('切换音质时发生错误:', error)
addNotification({
type: 'error',
message: '切换音质失败,请稍后重试',
duration: 3000
})
return { success: false, error: error.message }
}
}
// 处理热重载时的清理
const handleHotReload = () => {
// 停止所有正在播放的音频
const audioElements = document.querySelectorAll('audio')
audioElements.forEach((audio) => {
if (!audio.paused) {
audio.pause()
audio.currentTime = 0
}
})
// 清理全局播放器状态
if (typeof window !== 'undefined') {
if (window.voiceHubPlayerInstance) {
window.voiceHubPlayerInstance = null
}
// 清理鸿蒙侧状态
if (window.voiceHubPlayer && window.voiceHubPlayer.onPlayStateChanged) {
window.voiceHubPlayer.onPlayStateChanged(false, {
position: 0,
duration: 0
})
}
}
console.log('热重载清理完成')
}
return {
retryCount: computed(() => retryCount.value),
maxRetries: computed(() => maxRetries.value),
isRetrying: computed(() => isRetrying.value),
qualitySwitchAttempted: computed(() => qualitySwitchAttempted.value),
resetRetryState,
getMusicUrl,
tryQualitySwitch,
handlePlaybackError,
enhancedQualitySwitch,
handleHotReload
}
}
+700
View File
@@ -0,0 +1,700 @@
import { ref } from 'vue'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
import { useMusicWebSocket } from '~/composables/useMusicWebSocket'
import { useAuth } from '~/composables/useAuth'
import { useLyrics } from '~/composables/useLyrics'
export const useAudioPlayerSync = () => {
const globalAudioPlayer = useAudioPlayer()
const control = useAudioPlayerControl()
const musicWebSocket = useMusicWebSocket()
const lyrics = useLyrics()
// 节流控制
const lastWebSocketUpdate = ref(0)
const lastHarmonyUpdate = ref(0)
const lastProgressDiff = ref(0)
// HarmonyOS 事件处理器引用
let harmonyOSHandlers: Record<string, Function> | null = null
// 检测是否在鸿蒙环境
const isHarmonyOS = () => {
if (typeof window === 'undefined') return false
// 1. UA 检测(最可靠的初始检测)
if (window.navigator.userAgent.includes('HarmonyOS')) return true
// 2. 桥接对象检测
if (window.harmonyOSBridge) return true
// 3. 播放器对象检测(确保是原生注入的,即包含特定的原生方法)
if (window.voiceHubPlayer && typeof window.voiceHubPlayer.onPlayStateChanged === 'function') {
return true
}
return false
}
// 通知鸿蒙应用播放状态变化(改进事件驱动机制)
const notifyHarmonyOS = (
action: string,
extraData: Record<string, any> = {},
song?: any,
lyrics?: string
) => {
if (typeof window !== 'undefined' && song) {
// 处理封面URL,确保是完整的URL
let coverUrl = song.cover || ''
if (coverUrl && !coverUrl.startsWith('http')) {
if (coverUrl.startsWith('/')) {
coverUrl = window.location.origin + coverUrl
} else {
coverUrl = window.location.origin + '/' + coverUrl
}
}
// 映射播放模式
// HarmonyOS: 0=SEQUENCE, 1=SINGLE, 2=LIST, 3=SHUFFLE
let loopMode = 0; // SEQUENCE ('off')
if (control.playMode.value === 'loopOne') {
loopMode = 1; // SINGLE
} else if (control.playMode.value === 'order') {
loopMode = 2; // LIST
}
const songInfo = {
title: extraData.title || song.title || '',
artist: extraData.artist || song.artist || '',
album: extraData.album || song.album || '',
cover: extraData.artwork || coverUrl,
duration: extraData.duration !== undefined ? extraData.duration : 0,
position: extraData.position !== undefined ? extraData.position : 0,
lyrics: lyrics || '',
loopMode: loopMode
}
// 只有在鸿蒙环境中才调用相关API
if (window.voiceHubPlayer) {
if (action === 'play') {
window.voiceHubPlayer.onPlayStateChanged(true, {
position: songInfo.position,
duration: songInfo.duration,
loopMode: songInfo.loopMode
})
} else if (action === 'pause') {
window.voiceHubPlayer.onPlayStateChanged(false, {
position: songInfo.position,
duration: songInfo.duration,
loopMode: songInfo.loopMode
})
} else if (action === 'stop') {
window.voiceHubPlayer.onPlayStateChanged(false, {
position: 0,
duration: songInfo.duration,
loopMode: songInfo.loopMode
})
} else if (action === 'progress') {
// 进度更新时,只更新位置,不改变播放状态
if (window.voiceHubPlayer.updatePosition) {
window.voiceHubPlayer.updatePosition(songInfo.position)
} else {
window.voiceHubPlayer.onPlayStateChanged(true, {
position: songInfo.position,
duration: songInfo.duration,
loopMode: songInfo.loopMode
})
}
} else if (action === 'metadata') {
// 元数据更新时,只更新歌曲信息,不改变播放状态和位置
window.voiceHubPlayer.onSongChanged(songInfo)
// 延迟同步播放列表状态,但不更新播放状态
setTimeout(() => {
notifyPlaylistState()
}, 100)
} else if (action === 'seek') {
window.voiceHubPlayer.onPlayStateChanged(true, {
position: songInfo.position,
duration: songInfo.duration,
loopMode: songInfo.loopMode
})
}
}
}
}
// 通知播放列表状态变化
const notifyPlaylistState = () => {
if (
typeof window !== 'undefined' &&
window.voiceHubPlayer &&
window.voiceHubPlayer.updatePlaylistState
) {
const currentIndex = globalAudioPlayer.getCurrentPlaylistIndex().value
const playlist = globalAudioPlayer.getCurrentPlaylist().value
const hasNext = globalAudioPlayer.hasNext.value
const hasPrevious = globalAudioPlayer.hasPrevious.value
// 映射播放模式
// HarmonyOS: 0=SEQUENCE, 1=SINGLE, 2=LIST, 3=SHUFFLE
let loopMode = 0; // SEQUENCE ('off')
if (control.playMode.value === 'loopOne') {
loopMode = 1; // SINGLE
} else if (control.playMode.value === 'order') {
// 为了区分,把 'order' 映射为 2 (LIST),显示列表循环图标。
loopMode = 2; // LIST
}
const playlistState = {
currentIndex: currentIndex,
playlistLength: playlist ? playlist.length : 0,
hasNext: hasNext,
hasPrevious: hasPrevious,
loopMode: loopMode
}
// 播放列表状态更新(减少日志输出)
window.voiceHubPlayer.updatePlaylistState(playlistState)
// 备用方案:直接通过HarmonyOS接口发送
if (window.HarmonyOS && window.HarmonyOS.callNative) {
window.HarmonyOS.callNative('updatePlaylistState', playlistState)
}
}
}
// 节流的进度更新
const throttledProgressUpdate = (
currentTime: number,
duration: number,
isPlaying: boolean,
song?: any
) => {
if (!isHarmonyOS()) return
// 只在播放状态下发送进度更新,避免暂停时发送错误的位置信息
if (!isPlaying) return
const now = Date.now()
const progressDiff = Math.abs(currentTime - lastProgressDiff.value)
// WebSocket状态更新(每2秒最多一次)
if (now - lastWebSocketUpdate.value > 2000) {
lastWebSocketUpdate.value = now
musicWebSocket.sendStateUpdate({
songId: song?.id,
isPlaying: isPlaying,
position: currentTime,
duration: duration,
volume: 1,
playlistIndex: globalAudioPlayer.getCurrentPlaylistIndex().value
})
}
// 鸿蒙应用进度更新(每1秒最多一次,或进度差值超过0.5秒)
if (now - lastHarmonyUpdate.value > 1000 || progressDiff > 0.5) {
lastHarmonyUpdate.value = now
lastProgressDiff.value = currentTime
// 只更新播放状态和进度,不传递歌曲信息(避免覆盖元数据)
if (
typeof window !== 'undefined' &&
window.voiceHubPlayer &&
window.voiceHubPlayer.onPlayStateChanged
) {
// 获取当前循环模式
let loopMode = 0;
if (control.playMode.value === 'loopOne') {
loopMode = 1;
} else if (control.playMode.value === 'order') {
loopMode = 2;
}
window.voiceHubPlayer.onPlayStateChanged(isPlaying, {
position: currentTime,
duration: duration,
loopMode: loopMode
})
}
}
}
// WebSocket 状态更新
const sendWebSocketUpdate = (data: any) => {
if (isHarmonyOS()) {
musicWebSocket.sendStateUpdate(data)
}
}
// 播放上一首歌曲
const playPrevious = async (song?: any) => {
try {
// 检查是否有上一首歌曲
if (!globalAudioPlayer.hasPrevious.value) {
if (window.$showNotification) {
window.$showNotification('没有上一首歌曲', 'warning')
}
return { success: false, newSong: null }
}
// 记录当前播放状态 - 优先检查鸿蒙侧状态
let wasPlaying = globalAudioPlayer.getPlayingStatus().value
// 如果在鸿蒙环境,尝试获取鸿蒙侧的播放状态
if (isHarmonyOS() && window.voiceHubPlayer && window.voiceHubPlayer.getPlayState) {
try {
wasPlaying = window.voiceHubPlayer.getPlayState()
} catch (e) {
console.warn('无法获取鸿蒙侧播放状态,使用Web端状态')
}
}
const success = await globalAudioPlayer.playPrevious()
if (success) {
const newSong = globalAudioPlayer.getCurrentSong().value
if (newSong) {
// 只在鸿蒙环境下发送WebSocket消息
if (isHarmonyOS()) {
await musicWebSocket.sendSongChange({
songId: newSong.id,
title: newSong.title,
artist: newSong.artist,
cover: newSong.cover || '',
duration: 0,
playlistIndex: globalAudioPlayer.getCurrentPlaylistIndex().value
})
// 获取新歌曲的歌词(但不立即设置元数据,让AudioPlayer的handleLoaded处理)
if (newSong.musicPlatform && newSong.musicId) {
await lyrics.fetchLyrics(newSong.musicPlatform, newSong.musicId)
}
// 如果之前是播放状态,切换歌曲后自动开始播放
// 不在这里传递歌词,让AudioPlayer的handleLoaded方法负责歌词传递
if (wasPlaying) {
setTimeout(() => {
notifyHarmonyOS(
'play',
{
position: 0,
duration: newSong.duration || 0
},
newSong
) // 移除歌词参数,避免与handleLoaded竞争
}, 500) // 延迟确保handleLoaded完成元数据设置
}
// 通知播放列表状态变化
setTimeout(() => {
notifyPlaylistState()
}, 100)
}
return { success: true, newSong }
}
} else {
if (window.$showNotification) {
window.$showNotification('播放上一首歌曲失败', 'error')
}
}
} catch (error) {
console.error('播放上一首歌曲失败:', error)
if (window.$showNotification) {
window.$showNotification('播放上一首歌曲失败', 'error')
}
}
return { success: false, newSong: null }
}
// 获取音乐链接
const getMusicUrl = async (platform, musicId, quality) => {
try {
let apiUrl
if (platform === 'netease') {
apiUrl = `https://api.vkeys.cn/v2/music/netease?id=${musicId}&quality=${quality}`
} else if (platform === 'tencent') {
apiUrl = `https://api.vkeys.cn/v2/music/tencent?id=${musicId}&quality=${quality}`
} else {
return { success: false, error: '不支持的音乐平台' }
}
const response = await fetch(apiUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
})
if (!response.ok) {
return { success: false, error: `获取音乐URL失败: ${response.status}` }
}
const data = await response.json()
if (data.code === 200 && data.data && data.data.url) {
// 将HTTP URL改为HTTPS
let url = data.data.url
if (url.startsWith('http://')) {
url = url.replace('http://', 'https://')
}
return { success: true, url }
} else {
return { success: false, error: data.message || `API错误: ${data.code}` }
}
} catch (error) {
console.error('获取音乐链接失败:', error)
return { success: false, error: error.message || '网络错误' }
}
}
// 播放下一首歌曲
const playNext = async (song?: any) => {
try {
// 检查是否有下一首歌曲
if (!globalAudioPlayer.hasNext.value) {
if (window.$showNotification) {
window.$showNotification('没有下一首歌曲', 'warning')
}
return { success: false, newSong: null }
}
// 记录当前播放状态 - 优先检查鸿蒙侧状态
let wasPlaying = globalAudioPlayer.getPlayingStatus().value
// 如果在鸿蒙环境,尝试获取鸿蒙侧的播放状态
if (isHarmonyOS() && window.voiceHubPlayer && window.voiceHubPlayer.getPlayState) {
try {
wasPlaying = window.voiceHubPlayer.getPlayState()
} catch (e) {
console.warn('无法获取鸿蒙侧播放状态,使用Web端状态')
}
}
const success = await globalAudioPlayer.playNext()
if (success) {
const newSong = globalAudioPlayer.getCurrentSong().value
if (newSong) {
// 只在鸿蒙环境下发送WebSocket消息
if (isHarmonyOS()) {
await musicWebSocket.sendSongChange({
songId: newSong.id,
title: newSong.title,
artist: newSong.artist,
cover: newSong.cover || '',
duration: 0,
playlistIndex: globalAudioPlayer.getCurrentPlaylistIndex().value
})
// 获取新歌曲的歌词(但不立即设置元数据,让AudioPlayer的handleLoaded处理)
if (newSong.musicPlatform && newSong.musicId) {
await lyrics.fetchLyrics(newSong.musicPlatform, newSong.musicId)
}
// 如果之前是播放状态,切换歌曲后自动开始播放
// 不在这里传递歌词,让AudioPlayer的handleLoaded方法负责歌词传递
if (wasPlaying) {
setTimeout(() => {
notifyHarmonyOS(
'play',
{
position: 0,
duration: newSong.duration || 0
},
newSong
) // 移除歌词参数,避免与handleLoaded竞争
}, 500) // 延迟确保handleLoaded完成元数据设置
}
// 通知播放列表状态变化
setTimeout(() => {
notifyPlaylistState()
}, 100)
}
return { success: true, newSong }
}
} else {
if (window.$showNotification) {
window.$showNotification('播放下一首歌曲失败', 'error')
}
}
} catch (error) {
console.error('播放下一首歌曲失败:', error)
if (window.$showNotification) {
window.$showNotification('播放下一首歌曲失败', 'error')
}
}
return { success: false, newSong: null }
}
// 同步播放状态到全局
const syncPlayStateToGlobal = (isPlaying: boolean, song?: any) => {
if (isPlaying && song) {
globalAudioPlayer.playSong(song)
} else {
globalAudioPlayer.pauseSong()
}
}
// 同步停止状态到全局
const syncStopToGlobal = () => {
globalAudioPlayer.stopSong()
}
// 更新全局播放位置
const updateGlobalPosition = (position: number, duration?: number) => {
globalAudioPlayer.updatePosition(position)
if (duration !== undefined) {
globalAudioPlayer.setDuration(duration)
}
}
// 设置全局播放列表
const setGlobalPlaylist = (song: any, playlist: any[]) => {
if (song && playlist && playlist.length > 0) {
globalAudioPlayer.playSong(song, playlist)
}
}
// 初始化 WebSocket 连接
const initializeWebSocket = () => {
if (isHarmonyOS()) {
const { getToken } = useAuth()
const token = getToken()
musicWebSocket.connect(token || undefined)
// 设置WebSocket事件监听器
musicWebSocket.setStateUpdateListener((state) => {
// 处理来自其他客户端的状态更新
})
musicWebSocket.setSongChangeListener((songInfo) => {
// 处理歌曲切换通知
console.log('Received song change notification:', songInfo)
})
}
}
// 初始化鸿蒙系统控制事件
const initializeHarmonyOSControls = (callbacks: {
onPlay: () => void
onPause: () => void
onStop: () => void
onNext: () => void
onPrevious: () => void
onSeek: (time: number) => void
onPositionUpdate?: (time: number) => void
}) => {
// 只有在检测到鸿蒙环境时才初始化
if (!isHarmonyOS()) return
const handleHarmonyOSPlay = () => {
callbacks.onPlay()
}
const handleHarmonyOSPause = () => {
callbacks.onPause()
}
const handleHarmonyOSStop = () => {
callbacks.onStop()
}
const handleHarmonyOSNext = () => {
callbacks.onNext()
}
const handleHarmonyOSPrevious = () => {
callbacks.onPrevious()
}
const handleHarmonyOSSeek = (event: CustomEvent) => {
const time = event.detail?.time || event.time
if (typeof time === 'number') {
callbacks.onSeek(time)
}
}
const handleHarmonyOSPositionUpdate = (event: CustomEvent) => {
const time = event.detail?.time || event.time
if (typeof time === 'number' && callbacks.onPositionUpdate) {
callbacks.onPositionUpdate(time)
}
}
const handleHarmonyOSSetLoopMode = (event: CustomEvent) => {
const mode = event.detail?.mode;
// 处理数字模式(HarmonyOS原生值)或字符串模式
// HarmonyOS: 0=SEQUENCE, 1=SINGLE, 2=LIST, 3=SHUFFLE
// Web: 'off', 'loopOne', 'order'
let targetMode: 'off' | 'order' | 'loopOne' | null = null;
if (typeof mode === 'number') {
if (mode === 1) { // SINGLE
targetMode = 'loopOne';
} else if (mode === 2) { // LIST
// 映射为列表模式
targetMode = 'order';
} else if (mode === 0) { // SEQUENCE
targetMode = 'off';
} else if (mode === 3) { // SHUFFLE
targetMode = 'order';
}
} else if (typeof mode === 'string') {
if (['off', 'order', 'loopOne'].includes(mode)) {
targetMode = mode as 'off' | 'order' | 'loopOne';
} else if (mode === 'shuffle') {
targetMode = 'order';
}
}
if (targetMode) {
control.setPlayMode(targetMode);
// 立即通知状态更新
notifyPlaylistState();
}
}
// 保存处理函数引用以便清理
harmonyOSHandlers = {
handleHarmonyOSPlay,
handleHarmonyOSPause,
handleHarmonyOSStop,
handleHarmonyOSNext,
handleHarmonyOSPrevious,
handleHarmonyOSSeek,
handleHarmonyOSPositionUpdate,
handleHarmonyOSSetLoopMode
}
// 添加事件监听器
window.addEventListener('harmonyos-play', handleHarmonyOSPlay)
window.addEventListener('harmonyos-pause', handleHarmonyOSPause)
window.addEventListener('harmonyos-stop', handleHarmonyOSStop)
window.addEventListener('harmonyos-next', handleHarmonyOSNext)
window.addEventListener('harmonyos-previous', handleHarmonyOSPrevious)
window.addEventListener('harmonyos-seek', handleHarmonyOSSeek as EventListener)
window.addEventListener('harmonyos-position-update',
handleHarmonyOSPositionUpdate as EventListener
)
window.addEventListener('harmonyos-set-loop-mode', handleHarmonyOSSetLoopMode as EventListener)
// 增强:直接暴露方法给 window.voiceHubPlayer,确保 Index.ets 可以直接调用
if (typeof window !== 'undefined') {
// 确保对象存在
window.voiceHubPlayer = window.voiceHubPlayer || {};
// 添加/覆盖控制方法
// 注意:我们不覆盖 onPlayStateChanged 和 onSongChanged,因为它们可能是由 Index.ets 注入的
// 我们只添加控制方法,这样 Index.ets 可以直接调用它们
const methodsToAdd = {
play: handleHarmonyOSPlay,
pause: handleHarmonyOSPause,
stop: handleHarmonyOSStop,
next: handleHarmonyOSNext, // Index.ets 使用 next()
playNext: handleHarmonyOSNext, // 兼容性
previous: handleHarmonyOSPrevious, // Index.ets 使用 previous()
playPrevious: handleHarmonyOSPrevious, // 兼容性
seek: (time: number) => callbacks.onSeek(time),
setLoopMode: (mode: number | string) => {
// 模拟事件对象调用处理函数
const event = { detail: { mode } } as CustomEvent;
handleHarmonyOSSetLoopMode(event);
}
};
Object.assign(window.voiceHubPlayer, methodsToAdd);
// 同时也尝试暴露给 voiceHubPlayerInstance (Index.ets 的备用方案)
if (window.voiceHubPlayerInstance) {
Object.assign(window.voiceHubPlayerInstance, {
setPlayMode: (mode: number | string) => {
const event = { detail: { mode } } as CustomEvent;
handleHarmonyOSSetLoopMode(event);
}
});
}
console.log('VoiceHub: HarmonyOS direct control methods attached');
}
}
// 清理鸿蒙系统控制事件
const cleanupHarmonyOSControls = () => {
if (harmonyOSHandlers) {
window.removeEventListener(
'harmonyos-play',
harmonyOSHandlers.handleHarmonyOSPlay as EventListener
)
window.removeEventListener(
'harmonyos-pause',
harmonyOSHandlers.handleHarmonyOSPause as EventListener
)
window.removeEventListener(
'harmonyos-stop',
harmonyOSHandlers.handleHarmonyOSStop as EventListener
)
window.removeEventListener(
'harmonyos-next',
harmonyOSHandlers.handleHarmonyOSNext as EventListener
)
window.removeEventListener(
'harmonyos-previous',
harmonyOSHandlers.handleHarmonyOSPrevious as EventListener
)
window.removeEventListener(
'harmonyos-seek',
harmonyOSHandlers.handleHarmonyOSSeek as EventListener
)
window.removeEventListener(
'harmonyos-position-update',
harmonyOSHandlers.handleHarmonyOSPositionUpdate as EventListener
)
window.removeEventListener(
'harmonyos-set-loop-mode',
harmonyOSHandlers.handleHarmonyOSSetLoopMode as EventListener
)
harmonyOSHandlers = null
}
}
return {
// 状态
globalAudioPlayer,
lyrics,
// 鸿蒙通知
notifyHarmonyOS,
notifyPlaylistState,
// 节流更新
throttledProgressUpdate,
sendWebSocketUpdate,
// 播放控制
playPrevious,
playNext,
// 全局状态同步
syncPlayStateToGlobal,
syncStopToGlobal,
updateGlobalPosition,
setGlobalPlaylist,
// 初始化和清理
initializeWebSocket,
initializeHarmonyOSControls,
cleanupHarmonyOSControls,
// 工具函数
isHarmonyOS
}
}
+155
View File
@@ -0,0 +1,155 @@
import { computed, readonly, ref, watch } from 'vue'
// 音质配置
export const QUALITY_OPTIONS = {
netease: [
{ value: 2, label: '标准', description: '适合流量有限的情况' },
{ value: 4, label: 'HQ极高', description: '高品质音乐体验' },
{ value: 5, label: 'SQ无损', description: '无损音质,文件较大' },
{ value: 6, label: 'Hi-Res', description: '高解析度无损' },
{ value: 9, label: '超清母带', description: '最高音质' }
],
tencent: [
{ value: 4, label: '标准', description: '适合流量有限的情况' },
{ value: 8, label: 'HQ高音质', description: '高品质音乐体验' },
{ value: 10, label: 'SQ无损', description: '无损音质,文件较大' },
{ value: 11, label: 'Hi-Res', description: '高解析度音质' },
{ value: 14, label: '臻品母带2.0', description: '最高音质' }
],
bilibili: [{ value: 1, label: '普通', description: '默认音质' }]
}
// 默认音质设置
const DEFAULT_QUALITY = {
netease: 4, // HQ极高 (320k)
tencent: 8, // HQ高音质
bilibili: 1
}
// 全局音质状态,确保所有组件共享同一个状态
let globalAudioQuality: any = null
// 网易云登录状态,全局共享
const isNeteaseLoggedIn = ref(false)
let isLoginStatusInitialized = false
export function useAudioQuality() {
// 检查网易云登录状态
const checkNeteaseLoginStatus = () => {
if (typeof window === 'undefined') return
const cookie = localStorage.getItem('netease_cookie')
isNeteaseLoggedIn.value = !!cookie
}
// 从localStorage读取音质设置
const getStoredQuality = () => {
try {
const stored = localStorage.getItem('audioQuality')
return stored ? JSON.parse(stored) : DEFAULT_QUALITY
} catch {
return DEFAULT_QUALITY
}
}
// 使用全局状态,确保所有组件共享
if (!globalAudioQuality) {
globalAudioQuality = ref(getStoredQuality())
// 监听状态变化,自动保存到localStorage
watch(
globalAudioQuality,
(newValue) => {
try {
localStorage.setItem('audioQuality', JSON.stringify(newValue))
} catch (error) {
console.error('保存音质设置失败:', error)
}
},
{ deep: true }
)
}
// 初始化登录状态监听
if (!isLoginStatusInitialized && typeof window !== 'undefined') {
checkNeteaseLoginStatus()
window.addEventListener('storage', (e) => {
if (e.key === 'netease_cookie') {
checkNeteaseLoginStatus()
}
})
isLoginStatusInitialized = true
}
const audioQuality = globalAudioQuality
// 保存音质设置到localStorage
const saveQuality = (platform: string, quality: number) => {
audioQuality.value[platform] = quality
// localStorage保存已经通过watch自动处理
}
// 获取指定平台的音质设置
const getQuality = (platform: string) => {
// 处理 netease-podcast 别名
if (platform === 'netease-podcast') {
platform = 'netease'
}
return audioQuality.value[platform] || DEFAULT_QUALITY[platform]
}
// 获取指定平台的音质选项
const getQualityOptions = (platform: string) => {
// 处理 netease-podcast 别名
if (platform === 'netease-podcast') {
platform = 'netease'
}
return QUALITY_OPTIONS[platform] || []
}
// 获取音质标签
const getQualityLabel = (platform: string, quality: number) => {
// 处理 netease-podcast 别名
if (platform === 'netease-podcast') {
platform = 'netease'
}
const options = getQualityOptions(platform)
const option = options.find((opt) => opt.value === quality)
return option ? option.label : '未知音质'
}
// 获取音质描述
const getQualityDescription = (platform: string, quality: number) => {
// 处理 netease-podcast 别名
if (platform === 'netease-podcast') {
platform = 'netease'
}
const options = getQualityOptions(platform)
const option = options.find((opt) => opt.value === quality)
return option ? option.description : ''
}
// 计算属性:当前音质设置的可读文本
const currentQualityText = computed(() => {
const netease = getQualityLabel('netease', getQuality('netease'))
const tencent = getQualityLabel('tencent', getQuality('tencent'))
return {
netease,
tencent
}
})
return {
audioQuality: readonly(audioQuality),
saveQuality,
getQuality,
getQualityOptions,
getQualityLabel,
getQualityDescription,
currentQualityText,
QUALITY_OPTIONS,
DEFAULT_QUALITY,
checkNeteaseLoginStatus,
isNeteaseLoggedIn: readonly(isNeteaseLoggedIn)
}
}
+140
View File
@@ -0,0 +1,140 @@
import { ref, onUnmounted } from 'vue'
// WeakMap 用于存储每个 HTMLMediaElement 的 MediaElementAudioSourceNode
// 防止为同一元素创建多个源导致报错
const sourceCache = new WeakMap<HTMLMediaElement, MediaElementAudioSourceNode>()
// 全局 AudioContext(懒加载)以避免"AudioContext 过多"错误
let globalAudioContext: AudioContext | null = null
export const useAudioVisualizer = () => {
const audioContext = ref<AudioContext | null>(null)
const analyser = ref<AnalyserNode | null>(null)
const source = ref<MediaElementAudioSourceNode | null>(null)
const isInitialized = ref(false)
const getAudioContext = () => {
if (!globalAudioContext) {
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext
if (AudioContextClass) {
globalAudioContext = new AudioContextClass()
}
}
return globalAudioContext
}
const initialize = (audioElement: HTMLMediaElement) => {
if (isInitialized.value || !audioElement) return
try {
const ctx = getAudioContext()
if (!ctx) {
console.warn('不支持 AudioContext')
return
}
audioContext.value = ctx
// 创建分析器
if (!analyser.value) {
analyser.value = ctx.createAnalyser()
analyser.value.fftSize = 256 // 根据需要调整分辨率
analyser.value.smoothingTimeConstant = 0.8
}
// 获取或创建源
if (sourceCache.has(audioElement)) {
source.value = sourceCache.get(audioElement)!
} else {
// 连接源到分析器和目标(以听到声音)
// 注意:如果音频元素已连接到其他目标(默认),
// 创建 MediaElementSource 会断开其与默认输出的连接。
// 所以必须将其连回 ctx.destination。
// 假设目前只有我们在做可视化。
// 检查是否设置了 crossOrigin,CDN 音频需要
// 注意:在 src 设置之前应设置 crossOrigin="anonymous"。
if (!audioElement.crossOrigin) {
audioElement.crossOrigin = 'anonymous'
}
try {
const src = ctx.createMediaElementSource(audioElement)
sourceCache.set(audioElement, src)
source.value = src
// 连接链:源 -> 分析器 -> 目标
src.connect(analyser.value)
analyser.value.connect(ctx.destination)
} catch (e) {
console.error('创建 MediaElementSource 失败,可能是 CORS 问题:', e)
// 如果 CORS 失败,无法可视化,但必须确保音频继续播放。
// 因为 createMediaElementSource 失败,默认连接应该没断,
// 所以这里不做额外处理。
isInitialized.value = false
return
}
}
// 如果源是缓存的,可能需要重新连接
// 连接源 -> 分析器 -> 目标
if (source.value && analyser.value) {
try {
source.value.connect(analyser.value)
analyser.value.connect(ctx.destination)
} catch (e) {
// 已连接或其他错误
}
}
isInitialized.value = true
// 如果上下文被挂起(浏览器策略),恢复它
if (ctx.state === 'suspended') {
ctx.resume()
}
} catch (error) {
console.error('初始化音频可视化失败:', error)
}
}
const getFrequencyData = () => {
if (!analyser.value) return new Uint8Array(0)
const dataArray = new Uint8Array(analyser.value.frequencyBinCount)
analyser.value.getByteFrequencyData(dataArray)
return dataArray
}
const dispose = () => {
// 当组件卸载或关闭时,我们需要清理分析器连接
// 但必须保持音频播放,所以需要将 source 直接连回 destination
if (source.value) {
try {
// 断开所有连接(包括连向 analyser 的)
source.value.disconnect()
// 恢复直接连接到输出设备,确保声音继续播放
if (audioContext.value) {
source.value.connect(audioContext.value.destination)
} else if (globalAudioContext) {
source.value.connect(globalAudioContext.destination)
}
} catch (e) {
console.error('清理过程出错:', e)
}
}
isInitialized.value = false
}
onUnmounted(() => {
dispose()
})
return {
initialize,
getFrequencyData,
dispose,
isInitialized,
analyser
}
}
+208
View File
@@ -0,0 +1,208 @@
import { navigateTo, useState } from '#app'
import type { User } from '~~/types'
interface LoginResponse {
success: boolean
user?: User
requires2FA?: boolean
userId?: number
methods?: string[]
tempToken?: string // 预认证临时令牌
}
export const useAuth = () => {
const user = useState<User | null>('user', () => null)
const token = useState<string | null>('token', () => null)
const isAuthenticated = useState<boolean>('isAuthenticated', () => false)
const isAdmin = useState<boolean>('isAdmin', () => false)
const loading = useState<boolean>('loading', () => false)
const clearAuthState = () => {
user.value = null
token.value = null
isAuthenticated.value = false
isAdmin.value = false
}
const setAuthState = (loggedInUser: User) => {
token.value = 'cookie-based'
user.value = loggedInUser
isAuthenticated.value = true
isAdmin.value = ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(loggedInUser.role)
}
const initAuth = async () => {
// 客户端执行
if (typeof window === 'undefined' || import.meta.server) {
return null
}
// 如果已认证,直接返回缓存的用户信息
if (isAuthenticated.value && user.value) {
return user.value
}
try {
const data = await $fetch<{ user: User }>('/api/auth/verify', {
headers: {
'Cache-Control': 'no-cache'
}
})
if (data && data.user) {
user.value = data.user
isAuthenticated.value = true
isAdmin.value = ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(data.user.role)
token.value = 'cookie-based'
return data.user
} else {
clearAuthState()
return null
}
} catch (error: any) {
const hadAuth = isAuthenticated.value
// 只有当之前是已认证状态,且接口明确返回401(Token无效/过期),才进行清理和跳转
if (hadAuth && error.statusCode === 401) {
clearAuthState()
// Token失效,重定向到登录页
await navigateTo('/login?error=SessionExpired')
} else if (!hadAuth && error.statusCode === 401) {
// 未登录状态下的 401,仅确保状态清理,不跳转
clearAuthState()
}
return null
}
}
const login = async (username: string, password: string) => {
const response = await $fetch<LoginResponse>('/api/auth/login', {
method: 'POST',
body: { username, password }
})
if (response.success) {
if (response.requires2FA) {
return response
}
if (response.user) {
setAuthState(response.user)
return response
}
}
throw new Error('登录响应格式错误')
}
const verify2FA = async (userId: number, code: string, type: 'totp' | 'email', tempToken?: string) => {
const response = await $fetch<{ success: boolean; user: User }>('/api/auth/2fa/verify', {
method: 'POST',
body: { userId, code, type, token: tempToken }
})
if (response.success && response.user) {
setAuthState(response.user)
return response
}
throw new Error('验证失败')
}
const changePassword = async (currentPassword: string, newPassword: string) => {
loading.value = true
try {
await $fetch('/api/auth/change-password', {
method: 'POST',
body: { currentPassword, newPassword }
})
} catch (error: any) {
// 处理FetchError,提取错误信息
if (error.data && error.data.statusMessage) {
throw new Error(error.data.statusMessage)
} else if (error.data && error.data.message) {
throw new Error(error.data.message)
} else if (error.statusMessage) {
throw new Error(error.statusMessage)
} else if (error.message) {
throw new Error(error.message)
} else {
throw new Error('密码修改失败,请重试')
}
} finally {
loading.value = false
}
}
const setInitialPassword = async (newPassword: string) => {
loading.value = true
try {
await $fetch('/api/auth/set-initial-password', {
method: 'POST',
body: { newPassword }
})
if (user.value) {
user.value.needsPasswordChange = false
}
} finally {
loading.value = false
}
}
const refreshUser = async () => {
const data = await $fetch<{ user: User }>('/api/auth/verify')
if (data && data.user) {
user.value = data.user
isAuthenticated.value = true
isAdmin.value = ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(data.user.role)
}
}
const register = async (data: Record<string, any>) => {
const response = await $fetch<LoginResponse>('/api/auth/register', {
method: 'POST',
body: data
})
if (response.success && response.user) {
setAuthState(response.user)
}
return response
}
const logout = async (redirect = true) => {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
} catch (error) {
// 忽略登出错误
}
clearAuthState()
if (import.meta.client && redirect) {
await navigateTo('/')
}
}
const getAuthConfig = () => {
// 返回认证配置
return {
credentials: 'include' as RequestCredentials
}
}
return {
user,
token,
isAuthenticated,
isAdmin,
loading,
login,
verify2FA,
register,
logout,
changePassword,
setInitialPassword,
refreshUser,
initAuth,
getAuthConfig
}
}
+351
View File
@@ -0,0 +1,351 @@
import { computed, onUnmounted, ref } from 'vue'
// 动态仅客户端加载 AMLL 核心与样式( 类型导入不会影响 SSR)
import type {
BackgroundRender,
MeshGradientRenderer,
PixiRenderer
} from '@applemusic-like-lyrics/core'
// 在客户端按需加载核心与样式
const isClient = typeof window !== 'undefined'
let CoreModule: typeof import('@applemusic-like-lyrics/core') | null = null
const ensureCoreModule = async () => {
if (!isClient) return null
if (!CoreModule) {
CoreModule = await import('@applemusic-like-lyrics/core')
// css import removed here, will rely on nuxt config replacement or main component import
}
return CoreModule
}
export interface BackgroundConfig {
type: 'gradient' | 'cover'
dynamic: boolean
flowSpeed: number
colorMask: boolean
maskColor: string
maskOpacity: number
}
export const useBackgroundRenderer = () => {
const backgroundRenderer = ref<BackgroundRender<PixiRenderer | MeshGradientRenderer> | null>(null)
const containerElement = ref<HTMLElement | null>(null)
const coverBlurElement = ref<HTMLElement | null>(null)
const isInitialized = ref(false)
const isRendering = ref(false)
// 当前渲染器类型与封面地址
const currentRenderer = ref<'gradient' | 'cover'>('gradient')
const currentCoverUrl = ref<string>('')
// 默认配置
const defaultConfig: BackgroundConfig = {
type: 'gradient',
dynamic: true,
flowSpeed: 4,
colorMask: true,
maskColor: '#000000',
maskOpacity: 45
}
const config = ref<BackgroundConfig>({ ...defaultConfig })
// 初始化背景渲染器
const initializeBackground = async (container: HTMLElement) => {
if (isInitialized.value || !container) return
try {
containerElement.value = container
// 仅在客户端加载并创建渲染器
const Core = await ensureCoreModule()
if (!Core) return
// 创建 MeshGradient 渲染器
backgroundRenderer.value = Core.BackgroundRender.new(Core.MeshGradientRenderer)
currentRenderer.value = 'gradient'
// 将渲染器的 canvas 元素添加到容器中
const canvas = backgroundRenderer.value.getElement()
container.appendChild(canvas)
// 设置 canvas 样式
canvas.style.position = 'absolute'
canvas.style.top = '0'
canvas.style.left = '0'
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.zIndex = '-1'
// 应用初始配置
await applyConfig()
isInitialized.value = true
console.log('背景渲染器初始化成功')
} catch (error) {
console.error('背景渲染器初始化失败:', error)
}
}
// 切换底层渲染器
const switchRenderer = (type: 'gradient' | 'cover') => {
if (!containerElement.value) return
if (!CoreModule) return
const Core = CoreModule
const container = containerElement.value
const oldCanvas = backgroundRenderer.value?.getElement()
try {
if (oldCanvas) {
container.removeChild(oldCanvas)
}
// 释放旧实例
backgroundRenderer.value?.dispose()
// 创建对应渲染器(客户端)
backgroundRenderer.value =
type === 'gradient'
? Core.BackgroundRender.new(Core.MeshGradientRenderer)
: Core.BackgroundRender.new(Core.PixiRenderer)
currentRenderer.value = type
// 挂载新 canvas
const canvas = backgroundRenderer.value.getElement()
container.appendChild(canvas)
canvas.style.position = 'absolute'
canvas.style.top = '0'
canvas.style.left = '0'
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.zIndex = '-1'
} catch (error) {
console.error('切换背景渲染器失败:', error)
}
}
// 应用配置
const applyConfig = async () => {
if (!backgroundRenderer.value) return
try {
if (config.value.type === 'gradient') {
// 如有必要切换回 MeshGradientRenderer
if (currentRenderer.value !== 'gradient') {
switchRenderer('gradient')
}
// 根据配置设置动态/静态
backgroundRenderer.value.setStaticMode(!config.value.dynamic)
backgroundRenderer.value.setFlowSpeed(config.value.flowSpeed)
// 使用当前封面作为颜色来源
if (currentCoverUrl.value) {
await backgroundRenderer.value.setAlbum(currentCoverUrl.value)
}
// 隐藏旧的封面模糊元素
if (coverBlurElement.value) {
coverBlurElement.value.style.display = 'none'
}
// 清除可能存在的滤镜
backgroundRenderer.value.getElement().style.filter = ''
backgroundRenderer.value.getElement().style.transform = ''
} else {
// 封面动态模糊:使用 PixiRenderer 承载封面并应用模糊
if (currentRenderer.value !== 'cover') {
switchRenderer('cover')
}
// 设置封面并根据需要动态流动
if (currentCoverUrl.value) {
await backgroundRenderer.value.setAlbum(currentCoverUrl.value)
}
backgroundRenderer.value.setStaticMode(!config.value.dynamic)
backgroundRenderer.value.setFlowSpeed(config.value.flowSpeed)
// 为画布应用模糊与轻微放大效果
const canvas = backgroundRenderer.value.getElement()
const blurAmount = config.value.blur || 40
canvas.style.filter = `blur(${blurAmount}px)`
canvas.style.transform = 'scale(1.1)'
// 不再使用独立的 CSS 封面模糊层
if (coverBlurElement.value) {
coverBlurElement.value.style.display = 'none'
}
}
// 应用颜色蒙版 - 注意:当前版本的 BackgroundRender 可能不支持颜色蒙版功能
// 如果需要颜色蒙版,可能需要通过 CSS 或其他方式实现
if (config.value.colorMask) {
const maskOpacity = config.value.maskOpacity / 100
const canvas = backgroundRenderer.value.getElement()
// 使用 CSS 滤镜实现颜色蒙版效果
canvas.style.filter =
`${canvas.style.filter || ''} sepia(1) hue-rotate(${config.value.maskColor}) opacity(${1 - maskOpacity})`.trim()
} else {
const canvas = backgroundRenderer.value.getElement()
// 清除颜色蒙版滤镜,保留其他滤镜(如模糊)
canvas.style.filter =
canvas.style.filter
?.replace(/sepia\([^)]*\)\s*hue-rotate\([^)]*\)\s*opacity\([^)]*\)\s*/g, '')
.trim() || ''
}
} catch (error) {
console.error('应用背景配置失败:', error)
}
}
// 更新配置
const updateConfig = async (newConfig: Partial<BackgroundConfig>) => {
config.value = { ...config.value, ...newConfig }
await applyConfig()
}
// 设置封面背景(同时更新 AMLL 背景封面来源)
const setCoverBackground = async (coverUrl: string) => {
currentCoverUrl.value = coverUrl
// 兼容旧的独立封面模糊元素设置(作为备选,不再显示)
if (coverBlurElement.value) {
try {
coverBlurElement.value.style.backgroundImage = `url(${coverUrl})`
coverBlurElement.value.style.backgroundSize = 'cover'
coverBlurElement.value.style.backgroundPosition = 'center'
coverBlurElement.value.style.filter = 'blur(20px)'
coverBlurElement.value.style.transform = 'scale(1.1)'
const overlay = coverBlurElement.value.querySelector('.cover-blur-overlay') as HTMLElement
if (overlay) {
overlay.style.background = 'rgba(0, 0, 0, 0.5)'
}
} catch (error) {
console.error('设置封面背景失败:', error)
}
}
// 将封面应用到渲染器
if (backgroundRenderer.value) {
try {
await backgroundRenderer.value.setAlbum(coverUrl)
} catch (error) {
console.error('应用封面到背景失败:', error)
}
}
}
// 设置封面模糊元素(旧实现备用,不默认使用)
const setCoverBlurElement = (element: HTMLElement) => {
coverBlurElement.value = element
}
// 设置渐变颜色(基于歌曲封面)
const setGradientFromCover = async (coverUrl: string) => {
if (!backgroundRenderer.value) return
currentCoverUrl.value = coverUrl
try {
await backgroundRenderer.value.setAlbum(coverUrl)
} catch (error) {
console.error('设置背景图片失败:', error)
}
}
// 开始渲染
const startRender = () => {
if (!backgroundRenderer.value || isRendering.value) return
try {
// BackgroundRender 类没有 start 方法,渲染是自动进行的
// 只需要设置为非静态模式即可开始动画
backgroundRenderer.value.setStaticMode(false)
isRendering.value = true
console.log('背景渲染已开始')
} catch (error) {
console.error('开始背景渲染失败:', error)
}
}
// 停止渲染
const stopRender = () => {
if (!backgroundRenderer.value || !isRendering.value) return
try {
// BackgroundRender 类没有 stop 方法,使用静态模式来停止动画
backgroundRenderer.value.setStaticMode(true)
isRendering.value = false
console.log('背景渲染已停止')
} catch (error) {
console.error('停止背景渲染失败:', error)
}
}
// 暂停/恢复渲染
const pauseRender = () => {
if (!backgroundRenderer.value) return
try {
backgroundRenderer.value.pause()
} catch (error) {
console.error('暂停背景渲染失败:', error)
}
}
const resumeRender = () => {
if (!backgroundRenderer.value) return
try {
backgroundRenderer.value.resume()
} catch (error) {
console.error('恢复背景渲染失败:', error)
}
}
// 清理资源
const dispose = () => {
if (backgroundRenderer.value) {
try {
if (isRendering.value) {
backgroundRenderer.value.setStaticMode(true)
}
backgroundRenderer.value.dispose()
backgroundRenderer.value = null
} catch (error) {
console.error('清理背景渲染器失败:', error)
}
}
isInitialized.value = false
isRendering.value = false
containerElement.value = null
coverBlurElement.value = null
currentCoverUrl.value = ''
currentRenderer.value = 'gradient'
}
// 组件卸载时清理
onUnmounted(() => {
dispose()
})
return {
// 状态
backgroundRenderer: computed(() => backgroundRenderer.value),
isInitialized: computed(() => isInitialized.value),
isRendering: computed(() => isRendering.value),
config: computed(() => config.value),
// 方法
initializeBackground,
updateConfig,
setCoverBackground,
setCoverBlurElement,
setGradientFromCover,
startRender,
stopRender,
pauseRender,
resumeRender,
dispose,
applyConfig
}
}
+121
View File
@@ -0,0 +1,121 @@
import { useAuth } from './useAuth'
// 防抖机制
let isHandling401 = false
const lastHandle401Time = 0
const HANDLE_401_DEBOUNCE_TIME = 2000
export const useErrorHandler = () => {
// 处理401认证失效错误
const handle401Error = async (error: any) => {
// 防抖机制
if (isHandling401) {
return
}
isHandling401 = true
try {
const currentPath = import.meta.client ? window.location.pathname : ''
// 检查是否在登录页面
const isLoginPage = currentPath === '/login'
if (isLoginPage) {
// 在登录页面,解析错误信息
let errorMessage = '登录失败'
if (error?.data?.message) {
errorMessage = error.data.message
} else if (error?.message) {
errorMessage = error.message
}
console.log('登录失败:', errorMessage)
throw error
} else {
// 不在登录页面,清除认证状态并跳转到登录页
// 检查是否已认证
const auth = useAuth()
const wasAuthenticated = auth.isAuthenticated.value
// 如果之前未认证(如首次访问),则不显示错误提示
if (!wasAuthenticated) {
console.log('未认证状态下收到401,忽略提示')
return
}
console.log('检测到401错误,清除认证状态并跳转到登录页')
let errorMessage = '认证失败,请重新登录'
if (error?.data?.message) {
errorMessage = error.data.message
} else if (error?.message) {
errorMessage = error.message
}
console.log('认证错误:', errorMessage)
// 清除认证状态
auth.user.value = null
auth.token.value = null
auth.isAuthenticated.value = false
auth.isAdmin.value = false
// 清除 cookie
if (import.meta.client) {
// 删除 auth-token cookie
document.cookie = 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'
// 如果是 HTTPS,也尝试删除 secure cookie
if (window.location.protocol === 'https:') {
document.cookie = 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; Secure'
}
}
// 显示错误提示
if (import.meta.client) {
const toast = useToast()
toast.error(errorMessage)
}
// 跳转到登录页
if (import.meta.client) {
await navigateTo('/login')
}
}
} finally {
// 延迟重置防抖标志
setTimeout(() => {
isHandling401 = false
}, 1000)
}
}
// 检查响应是否为401错误并处理
const checkAndHandle401 = async (response: Response, error?: any) => {
if (response.status === 401) {
await handle401Error(error)
return true
}
return false
}
// 检查useFetch错误是否为401并处理
const checkAndHandleFetchError = async (error: any) => {
if (error?.status === 401 || error?.statusCode === 401) {
await handle401Error(error)
return true
}
return false
}
return {
handle401Error,
checkAndHandle401,
checkAndHandleFetchError
}
}
+226
View File
@@ -0,0 +1,226 @@
import { ref, watch } from 'vue'
import { useAudioPlayer } from './useAudioPlayer'
import { useMusicSources } from './useMusicSources'
import { useLyricSettings } from './useLyricSettings'
import {
parseSmartLrc,
alignLyrics,
cleanTTMLTranslations,
parseQRCLyric,
type LrcFormat
} from '~/utils/lyric/lyricParser'
import { formatLyric } from '~/utils/lyric/lyricFormat'
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
import { parseTTML, parseYrc } from '@applemusic-like-lyrics/lyric'
export const useLyricManager = () => {
const audioPlayer = useAudioPlayer()
const { getLyrics } = useMusicSources()
const settings = useLyricSettings()
// 状态
const lyrics = ref<LyricLine[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const currentTrackId = ref<string | null>(null)
const hasTranslation = ref(false)
const hasRoma = ref(false)
const lyricFormat = ref<LrcFormat | 'ttml' | 'qrc'>('line')
/**
* 清空歌词
*/
const clearLyrics = () => {
lyrics.value = []
error.value = null
hasTranslation.value = false
hasRoma.value = false
currentTrackId.value = null
}
/**
* 获取并解析歌词
*/
const fetchLyric = async (track: any) => {
if (!track || !track.id) return
// 如果已经在加载同一首歌,跳过
if (loading.value && currentTrackId.value === track.id?.toString()) return
// 如果是同一首歌且已有歌词,不重新加载
if (currentTrackId.value === track.id?.toString() && lyrics.value.length > 0) return
currentTrackId.value = track.id?.toString()
loading.value = true
try {
// 确定平台
const platform = track.musicPlatform || 'netease'
const musicId = track.musicId || track.id
console.log(`[LyricManager] 开始获取歌词: ${track.title} (${platform})`)
// 1. 获取歌词数据
const result = await getLyrics(platform, musicId)
if (!result.success || !result.data) {
throw new Error(result.error || '未找到歌词')
}
const { lrc, trans, yrc, ttml } = result.data
console.log(
`[LyricManager] 获取结果: LRC=${!!lrc}, TRANS=${!!trans}, YRC=${!!yrc}, TTML=${!!ttml}`
)
// 2. 解析歌词
let parsedLyrics: LyricLine[] = []
let format: LrcFormat | 'ttml' | 'qrc' = 'line'
// 优先处理 TTML (如果启用且存在)
if (settings.enableOnlineTTMLLyric.value && ttml) {
try {
const cleaned = cleanTTMLTranslations(ttml)
const parsed = parseTTML(cleaned)
const lines = (parsed as any)?.lines || []
if (lines.length > 0) {
parsedLyrics = lines
format = 'ttml'
console.log('[LyricManager] 使用 TTML 格式')
}
} catch (e) {
console.warn('[LyricManager] TTML解析失败,回退到其他格式', e)
}
}
// 如果没有 TTML 或解析失败,尝试 YRC
if (parsedLyrics.length === 0 && yrc) {
// 检测 QRC (QQ音乐 XML格式)
if (yrc.trim().startsWith('<') || yrc.includes('LyricContent="')) {
try {
const qrcLines = parseQRCLyric(yrc, trans, undefined) // QQ 音乐 QRC 可能带有翻译
if (qrcLines.length > 0) {
parsedLyrics = qrcLines
format = 'qrc'
console.log(`[LyricManager] 使用 QRC 格式`)
}
} catch (e) {
console.warn('[LyricManager] QRC解析失败', e)
}
}
if (parsedLyrics.length === 0) {
try {
// 尝试使用库的 parseYrc (Netease JSON)
const lines = parseYrc(yrc)
if (lines && lines.length > 0) {
// 检查解析质量:如果所有行的 words 都为空,说明解析失败
const validLines = lines.filter((l) => l.words && l.words.length > 0)
if (validLines.length > 0) {
parsedLyrics = lines
format = 'word-by-word'
console.log(`[LyricManager] 使用 YRC 格式 (Library)`)
} else {
// 解析结果无效,回退
console.warn('[LyricManager] YRC (Library) 解析结果无效(无words),尝试回退')
parsedLyrics = [] // 触发后续逻辑
}
} else {
const { lines, format: detectedFormat } = parseSmartLrc(yrc)
if (lines.length > 0) {
parsedLyrics = lines
format = detectedFormat
console.log(`[LyricManager] 使用 YRC 格式 (Smart: ${detectedFormat})`)
}
}
} catch (e) {
// 回退到 parseSmartLrc
const { lines, format: detectedFormat } = parseSmartLrc(yrc)
if (lines.length > 0) {
parsedLyrics = lines
format = detectedFormat
console.log(`[LyricManager] 使用 YRC 格式 (Fallback: ${detectedFormat})`)
}
}
}
}
// 如果还是没有,使用 LRC
if (parsedLyrics.length === 0 && lrc) {
const { lines, format: detectedFormat } = parseSmartLrc(lrc)
parsedLyrics = lines
format = detectedFormat
console.log(`[LyricManager] 使用 LRC 格式 (${detectedFormat})`)
}
// 3. 对齐翻译和罗马音
if (parsedLyrics.length > 0) {
// 如果有翻译
if (trans) {
const { lines: transLines } = parseSmartLrc(trans)
if (transLines.length > 0) {
parsedLyrics = alignLyrics(parsedLyrics, transLines, 'translatedLyric')
hasTranslation.value = true
console.log('[LyricManager] 已对齐翻译')
}
}
} else {
// 没有任何歌词
error.value = '暂无歌词'
}
// 4. 格式化 (应用配置,如括号替换)
if (parsedLyrics.length > 0) {
// 检查当前播放的歌曲 ID 是否仍然是请求的歌曲 ID
if (currentTrackId.value !== track.id?.toString()) {
console.log('[LyricManager] 歌曲已切换,丢弃过期的歌词响应')
return
}
// 提取元数据用于清洗
const metadata = {
title: track.title,
artists: track.artist ? [track.artist] : undefined
}
lyrics.value = formatLyric(parsedLyrics, settings, metadata)
lyricFormat.value = format
error.value = null
// 调试
console.log('[LyricManager] 解析完成,首行:', lyrics.value[0])
console.log('[LyricManager] 解析完成,总行数:', lyrics.value.length)
}
} catch (e: any) {
console.error('[LyricManager] 获取歌词失败:', e)
error.value = e.message || '获取歌词失败'
lyrics.value = []
} finally {
loading.value = false
}
}
// 监听当前播放歌曲变化
watch(
() => audioPlayer.getCurrentSong().value,
(newTrack) => {
if (newTrack) {
fetchLyric(newTrack)
} else {
clearLyrics()
}
},
{ immediate: true }
)
return {
lyrics,
loading,
error,
currentTrackId,
hasTranslation,
hasRoma,
lyricFormat,
fetchLyric,
clearLyrics
}
}
+325
View File
@@ -0,0 +1,325 @@
import { computed, onUnmounted, ref, watch } from 'vue'
export interface LyricRenderConfig {
fontSize: number
lineHeight: number
activeColor: string
inactiveColor: string
passedColor: string
enableBlur: boolean
enableScale: boolean
enableSpring: boolean
alignPosition: number
}
export interface PlayerLyricLine {
startTime: number
endTime: number
words: Array<{
startTime: number
endTime: number
word: string
romanWord?: string
}>
translatedLyric?: string
romanLyric?: string
isBG?: boolean
isDuet?: boolean
}
export const useLyricPlayer = () => {
const lyricPlayer = ref<any | null>(null)
const containerElement = ref<HTMLElement | null>(null)
const isInitialized = ref(false)
const currentLyrics = ref<PlayerLyricLine[]>([])
const currentTime = ref(0)
const isPlaying = ref(false)
const onLineClickCallback = ref<((seconds: number) => void) | null>(null)
// 默认配置
const defaultConfig: LyricRenderConfig = {
fontSize: 24,
lineHeight: 1.6,
activeColor: '#ffffff',
inactiveColor: 'rgba(255,255,255,0.6)',
passedColor: 'rgba(255,255,255,0.4)',
enableBlur: true,
enableScale: true,
enableSpring: true,
alignPosition: 0.5
}
const config = ref<LyricRenderConfig>({ ...defaultConfig })
// 初始化歌词播放器
const initializeLyricPlayer = async (container: HTMLElement) => {
if (isInitialized.value || !container) return
try {
containerElement.value = container
console.log('[useLyricPlayer] 开始初始化歌词播放器,容器:', container)
// 动态导入 @applemusic-like-lyrics/core
const { LyricPlayer } = await import('@applemusic-like-lyrics/core')
console.log('[useLyricPlayer] LyricPlayer 类:', LyricPlayer)
if (!LyricPlayer) {
throw new Error('LyricPlayer 类未找到')
}
lyricPlayer.value = new LyricPlayer()
console.log('[useLyricPlayer] LyricPlayer 实例创建成功')
console.log(
'[useLyricPlayer] 歌词播放器方法:',
Object.getOwnPropertyNames(Object.getPrototypeOf(lyricPlayer.value))
)
// 设置容器
container.appendChild(lyricPlayer.value.getElement())
console.log('[useLyricPlayer] 容器设置完成')
// 应用初始配置
if (lyricPlayer.value.setConfig) {
lyricPlayer.value.setConfig(defaultConfig)
}
console.log('[useLyricPlayer] 配置应用完成')
// 注册行点击事件,实现交互跳转
if (lyricPlayer.value && lyricPlayer.value.addEventListener) {
lyricPlayer.value.addEventListener('line-click', (evt: any) => {
try {
const startMs =
(evt?.line?.getLine ? evt.line.getLine().startTime : evt?.line?.startTime) ??
evt?.detail?.startTime ??
0
const seconds = (startMs || 0) / 1000
if (onLineClickCallback.value) {
onLineClickCallback.value(seconds)
}
} catch (e) {
console.warn('[useLyricPlayer] 处理 line-click 事件失败:', e)
}
})
}
isInitialized.value = true
console.log('[useLyricPlayer] 歌词播放器初始化成功')
} catch (error) {
console.error('[useLyricPlayer] 歌词播放器初始化失败:', error)
// 如果初始化失败,创建一个简单的占位符
createFallbackPlayer(container)
}
}
// 创建备用播放器
const createFallbackPlayer = (container: HTMLElement) => {
container.innerHTML = `
<div style="
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.6);
font-size: 1.1rem;
text-align: center;
">
<div>
<div style="margin-bottom: 1rem;">🎵</div>
<div>歌词播放器加载中...</div>
</div>
</div>
`
isInitialized.value = true
}
// 应用配置
const applyConfig = async () => {
if (!lyricPlayer.value || !lyricPlayer.value.setLyricLineStyle) return
try {
// 设置字体大小和行高
lyricPlayer.value.setLyricLineStyle({
fontSize: config.value.fontSize,
lineHeight: config.value.lineHeight
})
// 设置颜色
lyricPlayer.value.setLyricLineColor({
active: config.value.activeColor,
inactive: config.value.inactiveColor,
passed: config.value.passedColor
})
// 设置效果
lyricPlayer.value.setLyricLineEffect({
blur: config.value.enableBlur,
scale: config.value.enableScale,
spring: config.value.enableSpring
})
// 设置对齐位置
lyricPlayer.value.setAlignPosition(config.value.alignPosition)
} catch (error) {
console.error('应用歌词配置失败:', error)
}
}
// 更新配置
const updateConfig = async (newConfig: Partial<LyricRenderConfig>) => {
config.value = { ...config.value, ...newConfig }
await applyConfig()
}
// 设置歌词数据
const setLyrics = async (lyrics: PlayerLyricLine[]) => {
if (!lyricPlayer.value) {
console.error('[useLyricPlayer] 歌词播放器未初始化')
return
}
if (!lyricPlayer.value.setLyricLines) {
console.error('[useLyricPlayer] 歌词播放器缺少 setLyricLines 方法')
return
}
try {
console.log('[useLyricPlayer] 开始设置歌词数据,行数:', lyrics.length)
console.log('[useLyricPlayer] 第一行歌词示例:', lyrics[0])
currentLyrics.value = lyrics
await lyricPlayer.value.setLyricLines(lyrics)
console.log('[useLyricPlayer] 歌词数据设置成功')
} catch (error) {
console.error('[useLyricPlayer] 设置歌词数据失败:', error)
}
}
// 更新播放时间
const updateTime = (time: number) => {
if (!lyricPlayer.value || !lyricPlayer.value.setCurrentTime) return
try {
currentTime.value = time
// 确保时间是整数毫秒,符合 AMLL 要求
const timeInMs = Math.floor(time)
lyricPlayer.value.setCurrentTime(timeInMs)
} catch (error) {
console.error('[useLyricPlayer] 更新播放时间失败:', error)
}
}
// 更新歌词播放器(类似于 applemusic-like-lyrics-page-main 中的 update 方法)
const update = (deltaTime: number) => {
if (!lyricPlayer.value || !lyricPlayer.value.update) return
try {
lyricPlayer.value.update(deltaTime)
} catch (error) {
console.error('[useLyricPlayer] 更新歌词播放器失败:', error)
}
}
// 设置播放状态
const setPlayingState = (playing: boolean) => {
if (!lyricPlayer.value) return
try {
isPlaying.value = playing
if (playing && lyricPlayer.value.resume) {
lyricPlayer.value.resume()
} else if (!playing && lyricPlayer.value.pause) {
lyricPlayer.value.pause()
}
} catch (error) {
console.error('设置播放状态失败:', error)
}
}
// 跳转到指定时间
const seekTo = (time: number) => {
if (!lyricPlayer.value || !lyricPlayer.value.setCurrentTime) return
try {
// 确保时间是整数毫秒,符合 AMLL 要求,并标记为 seek 操作
const timeInMs = Math.floor(time)
// 强制中断当前动画 - 先暂停播放状态
const wasPlaying = isPlaying.value
if (lyricPlayer.value.pause) {
lyricPlayer.value.pause()
}
// 立即跳转到目标时间,第二个参数 true 表示这是一个 seek 操作
lyricPlayer.value.setCurrentTime(timeInMs, true)
// 如果之前是播放状态,恢复播放状态
if (wasPlaying && lyricPlayer.value.resume) {
// 使用短暂延迟确保时间跳转完成后再恢复播放状态
setTimeout(() => {
if (lyricPlayer.value && lyricPlayer.value.resume) {
lyricPlayer.value.resume()
}
}, 50) // 50ms 延迟确保跳转完成
}
currentTime.value = time
console.log('[useLyricPlayer] 跳转到时间:', timeInMs, 'ms')
} catch (error) {
console.error('跳转时间失败:', error)
}
}
// 对外暴露:注册歌词行点击回调
const onLineClick = (cb: (seconds: number) => void) => {
onLineClickCallback.value = cb
}
// 清理资源
const dispose = () => {
if (lyricPlayer.value && lyricPlayer.value.dispose) {
try {
lyricPlayer.value.dispose()
lyricPlayer.value = null
} catch (error) {
console.error('清理歌词播放器失败:', error)
}
}
isInitialized.value = false
containerElement.value = null
}
// 监听配置变化
watch(
config,
async (newConfig) => {
await applyConfig()
},
{ deep: true }
)
// 组件卸载时清理
onUnmounted(() => {
dispose()
})
return {
// 状态
lyricPlayer: computed(() => lyricPlayer.value),
isInitialized: computed(() => isInitialized.value),
currentLyrics: computed(() => currentLyrics.value),
currentTime: computed(() => currentTime.value),
isPlaying: computed(() => isPlaying.value),
update,
config: computed(() => config.value),
// 方法
initializeLyricPlayer,
updateConfig,
setLyrics,
updateTime,
setPlayingState,
seekTo,
dispose,
applyConfig,
onLineClick
}
}
+142
View File
@@ -0,0 +1,142 @@
export const useLyricSettings = () => {
// 根据设备类型设置默认字体大小
const getDefaultFontSize = () => {
if (typeof window === 'undefined') return 44
return window.innerWidth <= 1024 ? 30 : 44
}
const getDefaultTranFontSize = () => {
if (typeof window === 'undefined') return 22
return window.innerWidth <= 1024 ? 18 : 22
}
const getDefaultRomaFontSize = () => {
if (typeof window === 'undefined') return 18
return window.innerWidth <= 1024 ? 14 : 18
}
const lyricFontSize = useState('lyric-font-size', getDefaultFontSize)
const lyricTranFontSize = useState('lyric-tran-font-size', getDefaultTranFontSize)
const lyricRomaFontSize = useState('lyric-roma-font-size', getDefaultRomaFontSize)
const lyricFontSizeMode = useState('lyric-font-size-mode', () => 'auto') // auto | custom
const useAMLyrics = useState('lyric-use-am-style', () => true)
const useAMSpring = useState('lyric-use-spring', () => true)
const lyricAlignRight = useState('lyric-align-right', () => false)
const showTranslation = useState('lyric-show-translation', () => true)
const showRoma = useState('lyric-show-roma', () => true)
const showWordsRoma = useState('lyric-show-words-roma', () => true) // 逐字罗马音
const swapTranRoma = useState('lyric-swap-tran-roma', () => false)
const lyricOffset = useState('lyric-offset', () => 0)
// 括号替换设置
const replaceLyricBrackets = useState('lyric-replace-brackets', () => true)
const bracketReplacementPreset = useState('lyric-bracket-preset', () => 'dash')
const customBracketReplacement = useState('lyric-custom-bracket', () => '-')
// 歌词模糊与缩放
const lyricsBlur = useState('lyric-blur', () => true)
const lyricsScrollOffset = useState('lyric-scroll-offset', () => 0.5)
const hidePassedLines = useState('lyric-hide-passed', () => false)
const wordFadeWidth = useState('lyric-word-fade-width', () => 0.5)
const countDownShow = useState('lyric-countdown-show', () => true)
// 歌词字体
const lyricFontWeight = useState('lyric-font-weight', () => 'bold')
const LyricFont = useState('lyric-font-family', () => 'follow') // 'follow' 跟随系统
// 歌词排除规则
const enableExcludeLyrics = useState('lyric-exclude-enable', () => false)
const excludeLyricsUserKeywords = useState<string[]>('lyric-exclude-keywords', () => [])
const excludeLyricsUserRegexes = useState<string[]>('lyric-exclude-regexes', () => [])
const enableExcludeLyricsTTML = useState('lyric-exclude-ttml', () => false)
const enableExcludeLyricsLocal = useState('lyric-exclude-local', () => false)
// 优先级
const lyricPriority = useState<'qm' | 'official' | 'ttml' | 'auto'>(
'lyric-priority',
() => 'auto'
)
const enableQQMusicLyric = useState('lyric-enable-qm', () => true)
const enableOnlineTTMLLyric = useState('lyric-enable-ttml', () => true)
// AMLL TTML DB Server 配置
const amllDbServer = useState(
'lyric-amll-db-server',
() => 'https://amlldb.bikonoo.com/ncm-lyrics/%s.ttml'
)
const localLyricQQMusicMatch = useState('lyric-local-qm-match', () => true)
const localLyricPath = useState<string[]>('lyric-local-path', () => [])
// 繁简转换
const preferTraditionalChinese = useState('lyric-prefer-traditional', () => false)
const traditionalChineseVariant = useState('lyric-traditional-variant', () => 'hk')
// 显示 YRC
const showYrc = useState('lyric-show-yrc', () => true)
const lyricHorizontalOffset = useState('lyric-horizontal-offset', () => 0)
// 全屏播放器元素开关
const fullscreenPlayerElements = useState('fullscreen-elements', () => ({
copyLyric: true,
lyricOffset: true,
lyricSettings: true
}))
const lyricOffsetStep = useState('lyric-offset-step', () => 100)
// 根据屏幕尺寸调整字体大小
const adjustFontSizeForDevice = () => {
if (typeof window === 'undefined') return
const isMobile = window.innerWidth <= 1024
// 只在 auto 模式下自动调整
if (lyricFontSizeMode.value === 'auto') {
lyricFontSize.value = isMobile ? 30 : 44
lyricTranFontSize.value = isMobile ? 18 : 22
lyricRomaFontSize.value = isMobile ? 14 : 18
}
}
return {
lyricFontSize,
lyricTranFontSize,
lyricRomaFontSize,
lyricFontSizeMode,
useAMLyrics,
useAMSpring,
lyricAlignRight,
showTranslation,
showRoma,
showWordsRoma,
swapTranRoma,
lyricOffset,
replaceLyricBrackets,
bracketReplacementPreset,
customBracketReplacement,
lyricsBlur,
lyricsScrollOffset,
hidePassedLines,
wordFadeWidth,
countDownShow,
lyricFontWeight,
LyricFont,
enableExcludeLyrics,
excludeLyricsUserKeywords,
excludeLyricsUserRegexes,
enableExcludeLyricsTTML,
enableExcludeLyricsLocal,
lyricPriority,
enableQQMusicLyric,
enableOnlineTTMLLyric,
amllDbServer,
localLyricQQMusicMatch,
localLyricPath,
preferTraditionalChinese,
traditionalChineseVariant,
showYrc,
lyricHorizontalOffset,
fullscreenPlayerElements,
lyricOffsetStep,
adjustFontSizeForDevice
}
}
+428
View File
@@ -0,0 +1,428 @@
import { computed, ref } from 'vue'
// 歌词行接口
export interface ParsedLyricLine {
time: number // 时间戳(毫秒)
content: string // 歌词内容
words?: Array<{
// 逐字歌词(可选)
time: number
duration: number
content: string
}>
}
// 歌词数据接口
export interface LyricData {
lrc: string // 普通歌词
trans?: string // 翻译歌词
yrc?: string // 逐字歌词
}
export const useLyrics = () => {
// 响应式状态
const currentLyrics = ref<ParsedLyricLine[]>([])
const translationLyrics = ref<ParsedLyricLine[]>([])
const wordByWordLyrics = ref<ParsedLyricLine[]>([])
const currentLyricIndex = ref(0)
const currentTime = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
// 歌词显示设置
const showTranslation = ref(false)
const showWordByWord = ref(false)
// 解析LRC格式歌词
const parseLrcLyrics = (lrcText: string): ParsedLyricLine[] => {
if (!lrcText) return []
const lines = lrcText.split('\n')
const lyrics: ParsedLyricLine[] = []
for (const line of lines) {
// 匹配时间标签 [mm:ss.xx] 或 [mm:ss:xx]
const timeMatch = line.match(/\[(\d{2}):(\d{2})[.:](\d{2,3})\](.*)/)
if (timeMatch) {
const minutes = parseInt(timeMatch[1])
const seconds = parseInt(timeMatch[2])
const milliseconds = parseInt(timeMatch[3].padEnd(3, '0'))
const content = timeMatch[4].trim()
const time = minutes * 60 * 1000 + seconds * 1000 + milliseconds
if (content) {
// 只添加有内容的歌词行
lyrics.push({ time, content })
}
}
}
return lyrics.sort((a, b) => a.time - b.time)
}
// 解析网易云逐字歌词
const parseYrcLyrics = (yrcText: string): ParsedLyricLine[] => {
if (!yrcText) return []
const lines = yrcText.split('\n')
const lyrics: ParsedLyricLine[] = []
for (const line of lines) {
try {
// 网易云逐字歌词格式:{"t":12420,"c":[{"tx":"ya ","t":290},{"tx":"la ","t":380}]}
if (line.startsWith('{')) {
const data = JSON.parse(line)
if (typeof data.t === 'number' && Array.isArray(data.c)) {
const start = data.t
let accTime = start
const words = data.c.map((word: any) => {
const d = typeof word.t === 'number' ? word.t : 0 // t 为毫秒时长
const wContent = typeof word.tx === 'string' ? word.tx : ''
const wTime = accTime
accTime += d
return {
time: wTime,
duration: d,
content: wContent
}
})
const content = data.c
.map((word: any) => (typeof word.tx === 'string' ? word.tx : ''))
.join('')
lyrics.push({
time: start,
content,
words
})
}
}
// 方括号 + 括号 的逐字格式
else if (line.includes('[')) {
// 示例 A(绝对时间在括号前):[12420,3470](12420,290,0)ya (12710,380,0)la
// 示例 B(相对时间在括号后):[0,4690]嘘(0,293)つ(293,293)き(586,293)
const timeMatch = line.match(/\[(\d+),(\d+)\](.*)/)
if (!timeMatch) continue
const startTime = parseInt(timeMatch[1])
const duration = parseInt(timeMatch[2])
const wordsText = timeMatch[3]
const words: Array<{ time: number; duration: number; content: string }> = []
// 解析示例 B:字(相对或绝对时间, 时长) —— 单位均为毫秒
for (const wordMatch of wordsText.matchAll(/([^()]+)\((\d+),(\d+)\)/g)) {
const content = wordMatch[1]
const t = parseInt(wordMatch[2])
const durMs = parseInt(wordMatch[3])
// 如果时间小于行起始时间,视为相对时间;否则视为绝对时间
const absTime = startTime > 0 && t < startTime ? startTime + t : t
words.push({
time: absTime,
duration: durMs,
content
})
}
// 若上面未匹配到,则解析示例 A: (绝对时间, 时长, 0)字 —— 单位为毫秒
if (words.length === 0) {
for (const wordMatch of wordsText.matchAll(/\((\d+),(\d+),\d+\)([^()]*)/g)) {
const absTime = parseInt(wordMatch[1])
const durMs = parseInt(wordMatch[2])
const content = wordMatch[3]
words.push({
time: absTime,
duration: durMs,
content
})
}
}
const content = words.map((w) => w.content).join('')
if (content.length > 0) {
lyrics.push({
time: startTime,
content,
words
})
}
}
} catch (e) {
console.warn('解析逐字歌词行失败:', line, e)
}
}
return lyrics.sort((a, b) => a.time - b.time)
}
// 统一调度后,移除旧的备用源歌词获取逻辑
// 统一调度后,移除旧的网易云逐字歌词解析函数(现统一用 parseYrcLyrics)
// 获取歌词(统一调度:先备用源,再vkeys)
const fetchLyrics = async (platform: string, musicId: string): Promise<void> => {
if (!platform || !musicId) {
console.error('fetchLyrics 参数错误:', { platform, musicId })
error.value = '缺少必要参数'
currentLyrics.value = []
translationLyrics.value = []
wordByWordLyrics.value = []
currentLyricIndex.value = 0
notifyHarmonyOSLyricsUpdate('')
return
}
isLoading.value = true
error.value = null
currentLyrics.value = []
translationLyrics.value = []
wordByWordLyrics.value = []
currentLyricIndex.value = 0
notifyHarmonyOSLyricsUpdate('')
try {
const { getLyrics } = useMusicSources()
const result = await getLyrics(platform as 'netease' | 'tencent', musicId)
if (result.success && result.data) {
const lyricData = result.data
// 优先使用逐字歌词
if (lyricData.yrc) {
wordByWordLyrics.value = parseYrcLyrics(lyricData.yrc)
currentLyrics.value = wordByWordLyrics.value
} else if (lyricData.lrc) {
currentLyrics.value = parseLrcLyrics(lyricData.lrc)
} else {
currentLyrics.value = []
}
// 翻译歌词
if (lyricData.trans) {
translationLyrics.value = parseLrcLyrics(lyricData.trans)
showTranslation.value = true
} else {
translationLyrics.value = []
showTranslation.value = false
}
const harmonyLyrics = getFormattedLyricsForHarmonyOS()
notifyHarmonyOSLyricsUpdate(harmonyLyrics)
error.value = null
isLoading.value = false
return
} else {
throw new Error(result.error || '未获取到歌词')
}
} catch (e: any) {
console.error('获取歌词失败:', e?.message || e)
error.value = e?.message || '获取歌词失败'
currentLyrics.value = []
translationLyrics.value = []
wordByWordLyrics.value = []
currentLyricIndex.value = 0
notifyHarmonyOSLyricsUpdate('')
} finally {
isLoading.value = false
}
}
// 通知鸿蒙侧歌词更新的辅助函数
const notifyHarmonyOSLyricsUpdate = (lyrics: string) => {
if (typeof window !== 'undefined' && window.HarmonyOS && window.HarmonyOS.postMessage) {
try {
const message = {
method: 'updateLyrics',
parameters: {
lyrics: lyrics
}
}
window.HarmonyOS.postMessage(JSON.stringify(message))
// 通知鸿蒙侧更新歌词
} catch (error) {
console.error('通知鸿蒙侧更新歌词失败:', error)
}
} else {
console.warn('鸿蒙侧消息接口不可用,无法更新歌词')
}
}
// 根据当前时间更新歌词索引
const updateCurrentLyricIndex = (time: number): void => {
currentTime.value = time
const lyrics = currentLyrics.value
if (lyrics.length === 0) {
// 没有歌词数据
return
}
// 找到当前时间对应的歌词行
for (let i = 0; i < lyrics.length; i++) {
if (time >= lyrics[i].time && (i === lyrics.length - 1 || time < lyrics[i + 1].time)) {
if (currentLyricIndex.value !== i) {
// 歌词索引更新
currentLyricIndex.value = i
}
return
}
}
// 未找到匹配的歌词行
}
// 获取当前歌词内容
const currentLyricContent = computed(() => {
const lyrics = currentLyrics.value
if (lyrics.length === 0 || currentLyricIndex.value >= lyrics.length) {
return ''
}
return lyrics[currentLyricIndex.value].content
})
// 获取当前翻译歌词
const currentTranslationContent = computed(() => {
if (!showTranslation.value || translationLyrics.value.length === 0) {
return ''
}
const currentTime = currentLyrics.value[currentLyricIndex.value]?.time
if (!currentTime) return ''
// 找到对应时间的翻译歌词
for (let i = 0; i < translationLyrics.value.length; i++) {
if (
currentTime >= translationLyrics.value[i].time &&
(i === translationLyrics.value.length - 1 ||
currentTime < translationLyrics.value[i + 1].time)
) {
return translationLyrics.value[i].content
}
}
return ''
})
// 获取格式化的LRC歌词文本
const getFormattedLrcText = (): string => {
if (currentLyrics.value.length === 0) {
return '[00:00.00]暂无歌词'
}
return currentLyrics.value
.map((line) => {
const minutes = Math.floor(line.time / 60000)
const seconds = Math.floor((line.time % 60000) / 1000)
const milliseconds = Math.floor((line.time % 1000) / 10)
const timeStr = `[${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}]`
return `${timeStr}${line.content}`
})
.join('\r\n')
}
// 获取格式化的歌词用于鸿蒙侧,符合AVMetadata.lyric要求
const getFormattedLyricsForHarmonyOS = (): string => {
if (currentLyrics.value.length === 0) {
return '[00:00.00]暂无歌词'
}
// 按照鸿蒙侧要求格式化歌词:[mm:ss.xx]歌词内容\r\n
let formattedLyrics = currentLyrics.value
.map((line) => {
const minutes = Math.floor(line.time / 60000)
const seconds = Math.floor((line.time % 60000) / 1000)
const milliseconds = Math.floor((line.time % 1000) / 10)
// 格式:[mm:ss.xx]歌词内容
const timeStr = `[${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}]`
return `${timeStr}${line.content}`
})
.join('\r\n')
// 鸿蒙侧字符串长度限制:<40960字节
const maxBytes = 40960
const encoder = new TextEncoder()
// 检查字符串长度是否超过限制
if (encoder.encode(formattedLyrics).length > maxBytes) {
console.warn('歌词长度超过鸿蒙侧限制,正在截断...')
// 逐行添加歌词,直到接近字节限制
let truncatedLyrics = ''
for (const line of currentLyrics.value) {
const minutes = Math.floor(line.time / 60000)
const seconds = Math.floor((line.time % 60000) / 1000)
const milliseconds = Math.floor((line.time % 1000) / 10)
const timeStr = `[${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}]`
const lineText = `${timeStr}${line.content}\r\n`
// 检查添加这一行后是否会超过限制
const testLyrics = truncatedLyrics + lineText
if (encoder.encode(testLyrics).length > maxBytes - 100) {
// 留100字节缓冲
break
}
truncatedLyrics += lineText
}
formattedLyrics = truncatedLyrics.trim()
}
// 歌词格式化完成
return formattedLyrics
}
// 清空歌词
const clearLyrics = (): void => {
currentLyrics.value = []
translationLyrics.value = []
wordByWordLyrics.value = []
currentLyricIndex.value = 0
currentTime.value = 0
error.value = null
}
// 跳转到指定歌词行
const seekToLyricLine = (index: number): number => {
if (index >= 0 && index < currentLyrics.value.length) {
return currentLyrics.value[index].time / 1000 // 返回秒数
}
return 0
}
return {
// 状态
currentLyrics: readonly(currentLyrics),
translationLyrics: readonly(translationLyrics),
wordByWordLyrics: readonly(wordByWordLyrics),
currentLyricIndex: readonly(currentLyricIndex),
currentTime: readonly(currentTime),
isLoading: readonly(isLoading),
error: readonly(error),
// 设置
showTranslation,
showWordByWord,
// 计算属性
currentLyricContent,
currentTranslationContent,
// 方法
fetchLyrics,
updateCurrentLyricIndex,
getFormattedLrcText,
getFormattedLyricsForHarmonyOS, // 保持向后兼容
notifyHarmonyOSLyricsUpdate, // 鸿蒙侧歌词更新通知
clearLyrics,
seekToLyricLine,
parseLrcLyrics,
parseYrcLyrics
}
}
+258
View File
@@ -0,0 +1,258 @@
import { onMounted, onUnmounted, readonly, ref } from 'vue'
interface MediaSessionSong {
id: number
title: string
artist: string
cover?: string | null
musicUrl?: string | null
}
interface MediaSessionCallbacks {
onPlay?: () => void
onPause?: () => void
onStop?: () => void
onSeekBackward?: (details: MediaSessionActionDetails) => void
onSeekForward?: (details: MediaSessionActionDetails) => void
onSeekTo?: (details: MediaSessionActionDetails) => void
onPreviousTrack?: () => void
onNextTrack?: () => void
}
export const useMediaSession = () => {
const isSupported = ref(false)
const isActive = ref(false)
const currentSong = ref<MediaSessionSong | null>(null)
const callbacks = ref<MediaSessionCallbacks>({})
// 检查浏览器是否支持 Media Session API
const checkSupport = () => {
isSupported.value = 'mediaSession' in navigator
return isSupported.value
}
// 设置媒体元数据
const setMetadata = (song: MediaSessionSong) => {
if (!isSupported.value || !song) return
currentSong.value = song
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: song.title || '未知歌曲',
artist: song.artist || '未知艺术家',
album: '', // 可以根据需要添加专辑信息
artwork: song.cover
? [
{
src: song.cover,
sizes: '96x96',
type: 'image/png'
},
{
src: song.cover,
sizes: '128x128',
type: 'image/png'
},
{
src: song.cover,
sizes: '192x192',
type: 'image/png'
},
{
src: song.cover,
sizes: '256x256',
type: 'image/png'
},
{
src: song.cover,
sizes: '384x384',
type: 'image/png'
},
{
src: song.cover,
sizes: '512x512',
type: 'image/png'
}
]
: []
})
} catch (error) {
console.warn('设置媒体元数据失败:', error)
}
}
// 设置播放状态
const setPlaybackState = (state: MediaSessionPlaybackState) => {
if (!isSupported.value) return
try {
navigator.mediaSession.playbackState = state
isActive.value = state !== 'none'
} catch (error) {
console.warn('设置播放状态失败:', error)
}
}
// 设置位置状态
const setPositionState = (duration: number, playbackRate: number = 1, position: number = 0) => {
if (!isSupported.value || !navigator.mediaSession.setPositionState) return
try {
navigator.mediaSession.setPositionState({
duration: Math.max(0, duration),
playbackRate: Math.max(0, playbackRate),
position: Math.max(0, Math.min(position, duration))
})
} catch (error) {
console.warn('设置位置状态失败:', error)
}
}
// 设置动作处理器
const setActionHandlers = (newCallbacks: MediaSessionCallbacks) => {
if (!isSupported.value) return
callbacks.value = { ...callbacks.value, ...newCallbacks }
try {
// 播放/暂停
if (callbacks.value.onPlay) {
navigator.mediaSession.setActionHandler('play', callbacks.value.onPlay)
}
if (callbacks.value.onPause) {
navigator.mediaSession.setActionHandler('pause', callbacks.value.onPause)
}
if (callbacks.value.onStop) {
navigator.mediaSession.setActionHandler('stop', callbacks.value.onStop)
}
// 跳转控制
if (callbacks.value.onSeekBackward) {
navigator.mediaSession.setActionHandler('seekbackward', callbacks.value.onSeekBackward)
}
if (callbacks.value.onSeekForward) {
navigator.mediaSession.setActionHandler('seekforward', callbacks.value.onSeekForward)
}
if (callbacks.value.onSeekTo) {
navigator.mediaSession.setActionHandler('seekto', callbacks.value.onSeekTo)
}
// 上一首/下一首
if (callbacks.value.onPreviousTrack) {
navigator.mediaSession.setActionHandler('previoustrack', callbacks.value.onPreviousTrack)
}
if (callbacks.value.onNextTrack) {
navigator.mediaSession.setActionHandler('nexttrack', callbacks.value.onNextTrack)
}
} catch (error) {
console.warn('设置动作处理器失败:', error)
}
}
// 清除所有动作处理器
const clearActionHandlers = () => {
if (!isSupported.value) return
try {
const actions: MediaSessionAction[] = [
'play',
'pause',
'stop',
'seekbackward',
'seekforward',
'seekto',
'previoustrack',
'nexttrack'
]
actions.forEach((action) => {
navigator.mediaSession.setActionHandler(action, null)
})
} catch (error) {
console.warn('清除动作处理器失败:', error)
}
}
// 初始化 Media Session
const initialize = (song?: MediaSessionSong, actionCallbacks?: MediaSessionCallbacks) => {
if (!checkSupport()) {
console.warn('当前浏览器不支持 Media Session API')
return false
}
if (song) {
setMetadata(song)
}
if (actionCallbacks) {
setActionHandlers(actionCallbacks)
}
return true
}
// 清理 Media Session
const cleanup = () => {
if (!isSupported.value) return
try {
clearActionHandlers()
navigator.mediaSession.metadata = null
setPlaybackState('none')
isActive.value = false
currentSong.value = null
} catch (error) {
console.warn('清理 Media Session 失败:', error)
}
}
// 更新歌曲信息
const updateSong = (song: MediaSessionSong) => {
if (!isSupported.value) return
setMetadata(song)
}
// 更新播放状态
const updatePlaybackState = (isPlaying: boolean) => {
if (!isSupported.value) return
setPlaybackState(isPlaying ? 'playing' : 'paused')
}
// 更新播放进度
const updatePosition = (position: number, duration: number, playbackRate: number = 1) => {
if (!isSupported.value) return
setPositionState(duration, playbackRate, position)
}
// 组件挂载时检查支持
onMounted(() => {
checkSupport()
})
// 组件卸载时清理
onUnmounted(() => {
cleanup()
})
return {
// 状态
isSupported: readonly(isSupported),
isActive: readonly(isActive),
currentSong: readonly(currentSong),
// 方法
initialize,
cleanup,
updateSong,
updatePlaybackState,
updatePosition,
setActionHandlers,
clearActionHandlers,
// 底层方法(高级用法)
setMetadata,
setPlaybackState,
setPositionState
}
}
+326
View File
@@ -0,0 +1,326 @@
/**
* 音源管理器 Composable
* 提供多音源搜索、故障转移和状态监控功能
*/
import {
getEnabledSources,
getSourceById,
MUSIC_SOURCE_CONFIG,
type MusicSearchParams,
type MusicSearchResult,
type MusicSource,
type MusicSourceConfig,
type SongDetailParams,
type SongDetailResult,
type SourceStatus
} from '~/utils/musicSources'
/**
* 音源管理器 Composable
*/
export const useMusicSources = () => {
// 配置
const config = ref<MusicSourceConfig>(MUSIC_SOURCE_CONFIG)
// 当前使用的音源
const currentSource = ref<string>(config.value.primarySource)
// 音源状态
const sourceStatus = ref<Record<string, SourceStatus>>({})
/**
* 验证播放链接的有效性
* @param url 播放链接
* @returns Promise<{valid: boolean, duration?: number, error?: string}>
*/
const validatePlayUrl = async (
url: string
): Promise<{
valid: boolean
duration?: number
error?: string
}> => {
try {
// 修复HTTP/HTTPS协议问题
let validatedUrl = url
if (url.startsWith('http://')) {
validatedUrl = url.replace('http://', 'https://')
}
// 使用HEAD请求检查链接可用性,避免下载整个文件
const response = await fetch(validatedUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(5000) // 5秒超时
})
if (!response.ok) {
return {
valid: false,
error: `HTTP ${response.status}: ${response.statusText}`
}
}
// 检查Content-Type是否为音频文件
const contentType = response.headers.get('content-type')
if (contentType && !contentType.includes('audio') && !contentType.includes('video')) {
return {
valid: false,
error: `不是音频文件: ${contentType}`
}
}
// 尝试获取文件大小来估算时长
const contentLength = response.headers.get('content-length')
let estimatedDuration = 0
if (contentLength) {
const fileSizeBytes = parseInt(contentLength)
// 粗略估算:假设128kbps的音质,1MB约为64秒
estimatedDuration = (fileSizeBytes / 1024 / 1024) * 64
if (estimatedDuration < 5) {
return {
valid: false,
duration: estimatedDuration,
error: `歌曲时长过短: ${estimatedDuration.toFixed(1)}秒`
}
}
}
return {
valid: true,
duration: estimatedDuration
}
} catch (error: any) {
return {
valid: false,
error: error.message || '链接验证失败'
}
}
}
// 是否正在搜索
const isSearching = ref(false)
// 最后一次搜索的音源
const lastUsedSource = ref<string>('')
// 服务器是否在中国
const isServerInChina = ref<boolean | null>(null)
// 检测服务器位置
const checkServerLocation = async () => {
if (isServerInChina.value !== null) return
try {
const data = await $fetch('/api/system/location')
if (data && data.success) {
isServerInChina.value = data.data.isInChina
console.log(`[useMusicSources] 服务器位置检测: ${isServerInChina.value ? '中国' : '海外'}`)
}
} catch (e) {
console.warn('[useMusicSources] 服务器位置检测失败,默认为海外:', e)
isServerInChina.value = false
}
}
/**
* 搜索歌曲(带故障转移)
*/
const searchSongs = async (
params: MusicSearchParams,
signal?: AbortSignal
): Promise<MusicSearchResult> => {
if (signal?.aborted) {
throw new DOMException('搜索请求已被取消', 'AbortError')
}
isSearching.value = true
try {
const url = `/api/music/kg/search?keyword=${encodeURIComponent(params.keywords)}`
const response: any = await $fetch(url, { signal })
if (!response || !response.data || !response.data.info) {
throw new Error('获取酷狗搜索结果失败')
}
const results = response.data.info.map((item: any) => ({
id: item.hash,
title: item.songname,
artist: item.singername,
cover: item.pic || '',
album: item.album_name || '',
duration: item.duration * 1000, // Assuming duration is in seconds
musicPlatform: 'kugou',
musicId: item.hash,
hasUrl: true,
isVip: item.isVip,
sourceInfo: {
source: 'kugou',
originalId: item.hash,
fetchedAt: new Date()
}
}))
currentSource.value = 'kugou'
lastUsedSource.value = 'kugou'
return {
success: true,
source: 'kugou',
data: results,
error: undefined
}
} catch (error: any) {
return {
success: false,
source: 'kugou',
data: [],
error: error.message || '搜索失败'
}
} finally {
isSearching.value = false
}
}
/**
* 获取歌曲详情
*/
const getSongDetail = async (params: SongDetailParams): Promise<SongDetailResult> => {
// For Kugou, we don't have a specific detail endpoint in our proxy yet,
// and usually the search result provides enough info or we can just return empty detail.
// To satisfy the interface, we return a mock or basic info.
return {
success: true,
source: 'kugou',
data: { songs: [] },
error: undefined
}
}
/**
* 更新音源状态
*/
const updateSourceStatus = (
sourceId: string,
status: SourceStatus['status'],
errorMessage?: string,
responseTime?: number
) => {
sourceStatus.value[sourceId] = {
status,
responseTime,
lastCheck: new Date(),
errorMessage
}
}
/**
* 获取歌曲播放URL
*/
const getSongUrl = async (
id: number | string,
quality?: number,
platform?: string,
cookie?: string,
options?: {
unblock?: boolean
}
): Promise<{
success: boolean
url?: string
error?: string
}> => {
try {
const hashParam = Array.isArray(id) ? id[0] : id.toString()
// Fetch play URL via Kugou proxy
const response: any = await $fetch(`/api/music/kg/playurl?hash=${hashParam}`)
if (response && response.data && response.data.play_url) {
const playUrl = response.data.play_url
// Skip HEAD validation for Kugou URLs as they often fail HEAD requests or CORS
return { success: true, url: playUrl }
}
return { success: false, error: '无法获取有效的播放链接' }
} catch (error: any) {
return { success: false, error: error?.message || '获取播放链接失败' }
}
}
/**
* 获取歌词
*/
const getLyrics = async (
platform: string,
id: number | string
): Promise<{
success: boolean
data?: { lrc: string; trans?: string; yrc?: string; ttml?: string }
error?: string
}> => {
return { success: false, error: '歌词获取功能正在重构' }
}
/**
* 获取音源状态摘要
*/
const getSourceStatusSummary = computed(() => {
const summary = {
total: config.value.sources.length,
online: 0,
offline: 0,
error: 0,
unknown: 0
}
config.value.sources.forEach((source) => {
const status = sourceStatus.value[source.id]
if (!status) {
summary.unknown++
} else {
summary[status.status]++
}
})
return summary
})
/**
* 获取当前音源信息
*/
const getCurrentSourceInfo = computed(() => {
const source = getSourceById(currentSource.value)
const status = sourceStatus.value[currentSource.value]
return {
source,
status,
isHealthy: status?.status === 'online'
}
})
return {
// 状态
config: readonly(config),
currentSource: readonly(currentSource),
sourceStatus: readonly(sourceStatus),
isSearching: readonly(isSearching),
lastUsedSource: readonly(lastUsedSource),
// 计算属性
sourceStatusSummary: getSourceStatusSummary,
currentSourceInfo: getCurrentSourceInfo,
// 方法
searchSongs,
getSongDetail,
getSongUrl,
getLyrics,
updateSourceStatus,
validatePlayUrl
}
}
+247
View File
@@ -0,0 +1,247 @@
import { onBeforeUnmount, onMounted, ref, readonly } from 'vue'
interface MusicState {
songId?: number
isPlaying: boolean
position: number
duration: number
volume: number
playlistIndex?: number
timestamp: number
}
interface SongInfo {
songId?: number
title: string
artist: string
cover: string
duration: number
playlistIndex?: number
timestamp: number
}
export function useMusicWebSocket() {
const isConnected = ref(false)
const connectionId = ref<string | null>(null)
const lastHeartbeat = ref<number>(0)
let eventSource: EventSource | null = null
let reconnectTimer: NodeJS.Timeout | null = null
let heartbeatTimer: NodeJS.Timeout | null = null
// 事件回调
const onStateUpdate = ref<((state: MusicState) => void) | null>(null)
const onSongChange = ref<((songInfo: SongInfo) => void) | null>(null)
const onPlaylistUpdate = ref<((playlist: any[]) => void) | null>(null)
const onConnectionChange = ref<((connected: boolean) => void) | null>(null)
// 连接WebSocket
const connect = (token?: string) => {
if (eventSource) {
disconnect()
}
try {
const url = new URL('/api/music/websocket', window.location.origin)
if (token) {
url.searchParams.set('token', token)
}
eventSource = new EventSource(url.toString())
eventSource.onopen = () => {
isConnected.value = true
onConnectionChange.value?.(true)
// 清除重连定时器
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
eventSource.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
handleMessage(message)
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
eventSource.onerror = (error) => {
console.error('Music WebSocket error:', error)
isConnected.value = false
onConnectionChange.value?.(false)
// 自动重连
scheduleReconnect()
}
} catch (error) {
console.error('Failed to create WebSocket connection:', error)
scheduleReconnect()
}
}
// 处理消息
const handleMessage = (message: any) => {
const { type, data } = message
switch (type) {
case 'connection_established':
connectionId.value = data.connectionId
break
case 'music_state_update':
onStateUpdate.value?.(data)
break
case 'song_change':
onSongChange.value?.(data)
break
case 'playlist_update':
onPlaylistUpdate.value?.(data.playlist)
break
case 'heartbeat':
lastHeartbeat.value = data.timestamp
break
default:
}
}
// 断开连接
const disconnect = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (heartbeatTimer) {
clearTimeout(heartbeatTimer)
heartbeatTimer = null
}
isConnected.value = false
connectionId.value = null
onConnectionChange.value?.(false)
}
// 计划重连
const scheduleReconnect = () => {
if (reconnectTimer) return
reconnectTimer = setTimeout(() => {
connect()
}, 5000) // 5秒后重连
}
// 发送音乐状态更新
const sendStateUpdate = async (state: Partial<MusicState>) => {
try {
await $fetch('/api/music/state', {
method: 'POST',
body: {
type: 'state_update',
data: state
}
})
} catch (error) {
console.error('Failed to send music state update:', error)
}
}
// 发送歌曲切换通知
const sendSongChange = async (songInfo: Partial<SongInfo>) => {
try {
await $fetch('/api/music/state', {
method: 'POST',
body: {
type: 'song_change',
data: songInfo
}
})
} catch (error) {
console.error('Failed to send song change:', error)
}
}
// 发送播放位置更新
const sendPositionUpdate = async (position: number, duration: number, songId?: number) => {
try {
await $fetch('/api/music/state', {
method: 'POST',
body: {
type: 'position_update',
data: {
songId,
position,
duration,
isPlaying: true
}
}
})
} catch (error) {
console.error('Failed to send position update:', error)
}
}
// 设置事件监听器
const setStateUpdateListener = (callback: (state: MusicState) => void) => {
onStateUpdate.value = callback
}
const setSongChangeListener = (callback: (songInfo: SongInfo) => void) => {
onSongChange.value = callback
}
const setPlaylistUpdateListener = (callback: (playlist: any[]) => void) => {
onPlaylistUpdate.value = callback
}
const setConnectionChangeListener = (callback: (connected: boolean) => void) => {
onConnectionChange.value = callback
}
// 生命周期管理
onMounted(() => {
// 启动心跳检测
heartbeatTimer = setInterval(() => {
if (isConnected.value && lastHeartbeat.value > 0) {
const timeSinceLastHeartbeat = Date.now() - lastHeartbeat.value
if (timeSinceLastHeartbeat > 60000) {
// 60秒没有心跳
console.warn('Music WebSocket heartbeat timeout, reconnecting...')
disconnect()
scheduleReconnect()
}
}
}, 30000) // 每30秒检查一次
})
onBeforeUnmount(() => {
disconnect()
})
return {
isConnected: readonly(isConnected),
connectionId: readonly(connectionId),
connect,
disconnect,
sendStateUpdate,
sendSongChange,
sendPositionUpdate,
setStateUpdateListener,
setSongChangeListener,
setPlaylistUpdateListener,
setConnectionChangeListener
}
}
+431
View File
@@ -0,0 +1,431 @@
import { computed, ref } from 'vue'
import { useAuth } from './useAuth'
import type { Notification, NotificationSettings } from '~~/types'
import { useToast } from './useToast'
interface PaginationInfo {
currentPage: number
totalPages: number
totalCount: number
limit: number
hasNextPage: boolean
hasPrevPage: boolean
}
let lastTopNotificationId: number | null = null
let lastUnreadCountSnapshot: number | null = null
let notificationsBootstrapped = false
let lastToastAt = 0
export const useNotifications = () => {
const { getAuthConfig, isAuthenticated } = useAuth()
const toast = useToast()
const notifications = ref<Notification[]>([])
const unreadCount = ref(0)
const settings = ref<NotificationSettings | null>(null)
const loading = ref(false)
const error = ref('')
// 分页相关状态
const currentPage = ref(1)
const pageSize = ref(10)
const totalPages = ref(0)
const totalCount = ref(0)
const hasNextPage = computed(() => currentPage.value < totalPages.value)
const hasPrevPage = computed(() => currentPage.value > 1)
const isPaginationLoading = ref(false)
const fetchNotifications = async (page?: number, limit?: number) => {
if (!isAuthenticated.value) {
notifications.value = []
unreadCount.value = 0
resetPagination()
lastTopNotificationId = null
lastUnreadCountSnapshot = null
notificationsBootstrapped = false
return
}
// 如果是分页请求(非初始加载),显示分页加载状态
if (page && page !== 1) {
isPaginationLoading.value = true
} else {
loading.value = true
}
error.value = ''
// 使用传入的参数或当前状态
const requestPage = page || currentPage.value
const requestLimit = limit || pageSize.value
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/notifications', {
query: {
page: requestPage,
limit: requestLimit
},
...authConfig
})
if (data) {
const nextNotifications: Notification[] = data.notifications || []
const nextUnread = data.unreadCount || 0
notifications.value = data.notifications || []
unreadCount.value = nextUnread
const isFirstPage = (page || currentPage.value) === 1
const topId = nextNotifications[0]?.id ?? null
if (isFirstPage) {
if (!notificationsBootstrapped) {
notificationsBootstrapped = true
lastTopNotificationId = topId
lastUnreadCountSnapshot = nextUnread
} else {
const now = Date.now()
const prevTopId = lastTopNotificationId
const prevUnread = lastUnreadCountSnapshot ?? 0
const delta = Math.max(0, nextUnread - prevUnread)
const hasNewTop =
(topId && prevTopId && topId > prevTopId) || (topId && prevTopId === null)
if (hasNewTop && now - lastToastAt > 3000) {
toast.info(delta > 0 ? `收到 ${delta} 条新消息` : '收到新消息')
lastToastAt = now
}
lastTopNotificationId = topId
lastUnreadCountSnapshot = nextUnread
}
}
// 更新分页信息
if (data.pagination) {
currentPage.value = data.pagination.currentPage
totalPages.value = data.pagination.totalPages
totalCount.value = data.pagination.totalCount
pageSize.value = data.pagination.limit
}
}
} catch (err: any) {
const errorHandler = useErrorHandler()
if (await errorHandler.checkAndHandleFetchError(err)) {
return
}
console.error('获取通知错误:', err)
notifications.value = []
unreadCount.value = 0
resetPagination()
} finally {
loading.value = false
isPaginationLoading.value = false
}
}
// 重置分页状态
const resetPagination = () => {
currentPage.value = 1
totalPages.value = 0
totalCount.value = 0
}
// 分页导航方法
const goToPage = async (page: number) => {
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
await fetchNotifications(page, pageSize.value)
}
}
const nextPage = async () => {
if (hasNextPage.value) {
await goToPage(currentPage.value + 1)
}
}
const prevPage = async () => {
if (hasPrevPage.value) {
await goToPage(currentPage.value - 1)
}
}
const changePageSize = async (newSize: number) => {
if (newSize !== pageSize.value) {
pageSize.value = newSize
// 重新计算当前页,确保不超出范围
const newTotalPages = Math.ceil(totalCount.value / newSize)
const newCurrentPage = Math.min(currentPage.value, newTotalPages || 1)
await fetchNotifications(newCurrentPage, newSize)
}
}
const fetchNotificationSettings = async () => {
if (!isAuthenticated.value) {
settings.value = null
return
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/notifications/settings', {
...authConfig
})
if (data) {
settings.value = data
}
} catch (err: any) {
const errorHandler = useErrorHandler()
if (await errorHandler.checkAndHandleFetchError(err)) {
return
}
console.error('获取通知设置错误:', err)
settings.value = null
} finally {
loading.value = false
}
}
const updateNotificationSettings = async (newSettings: Partial<NotificationSettings>) => {
if (!isAuthenticated.value) {
error.value = '需要登录才能更新通知设置'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/notifications/settings', {
method: 'POST',
body: newSettings,
...authConfig
})
if (data) {
settings.value = data
return data
}
return null
} catch (err: any) {
const errorHandler = useErrorHandler()
if (await errorHandler.checkAndHandleFetchError(err)) {
return null
}
error.value = err.message || '更新通知设置失败'
console.error('更新通知设置错误:', err)
return null
} finally {
loading.value = false
}
}
// 标记通知为已读
const markAsRead = async (notificationId: number) => {
if (!isAuthenticated.value) {
error.value = '需要登录才能标记通知'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch(`/api/notifications/${notificationId}/read`, {
method: 'POST',
...authConfig
})
const index = notifications.value.findIndex((n: Notification) => n.id === notificationId)
if (index !== -1) {
notifications.value[index].read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
return data
} catch (err: any) {
const errorHandler = useErrorHandler()
if (await errorHandler.checkAndHandleFetchError(err)) {
return null
}
error.value = err.message || '标记通知失败'
console.error('标记通知错误:', err)
return null
} finally {
loading.value = false
}
}
// 标记所有通知为已读
const markAllAsRead = async () => {
if (!isAuthenticated.value) {
error.value = '需要登录才能标记通知'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/notifications/read-all', {
method: 'POST',
...authConfig
})
if (data) {
notifications.value.forEach((n) => (n.read = true))
unreadCount.value = 0
return data
}
return null
} catch (err: any) {
const errorHandler = useErrorHandler()
if (await errorHandler.checkAndHandleFetchError(err)) {
return null
}
error.value = err.message || '标记所有通知失败'
console.error('标记所有通知错误:', err)
return null
} finally {
loading.value = false
}
}
// 删除通知
const deleteNotification = async (notificationId: number) => {
if (!isAuthenticated.value) {
error.value = '需要登录才能删除通知'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
await $fetch(`/api/notifications/${notificationId}`, {
method: 'DELETE',
...authConfig
})
// 更新本地通知列表
const index = notifications.value.findIndex((n: Notification) => n.id === notificationId)
if (index !== -1) {
if (!notifications.value[index].read) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
notifications.value.splice(index, 1)
}
// 删除后重新获取当前页数据,以补充后续通知
if (notifications.value.length === 0 && currentPage.value > 1) {
// 如果当前页数据已被清空且不是第一页,则跳转到上一页
await goToPage(currentPage.value - 1)
} else {
// 否则重新加载当前页(如果有下一页数据会自动补充上来)
await fetchNotifications(currentPage.value, pageSize.value)
}
return true
} catch (err: any) {
// 检查是否为401错误
const errorHandler = useErrorHandler()
if (await errorHandler.checkAndHandleFetchError(err)) {
return null
}
error.value = err.message || '删除通知失败'
console.error('删除通知错误:', err)
return null
} finally {
loading.value = false
}
}
// 清空所有通知
const clearAllNotifications = async () => {
if (!isAuthenticated.value) {
error.value = '需要登录才能清空通知'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
await $fetch('/api/notifications/clear-all', {
method: 'DELETE',
...authConfig
})
// 清空本地通知列表
notifications.value = []
unreadCount.value = 0
resetPagination()
return true
} catch (err: any) {
// 检查是否为401错误
const errorHandler = useErrorHandler()
if (await errorHandler.checkAndHandleFetchError(err)) {
return null
}
error.value = err.message || '清空通知失败'
console.error('清空通知错误:', err)
return null
} finally {
loading.value = false
}
}
// 计算未读通知数量
const computedUnreadCount = computed(() => unreadCount.value)
return {
notifications,
unreadCount: computedUnreadCount,
settings,
loading,
error,
// 分页相关状态
currentPage,
pageSize,
totalPages,
totalCount,
hasNextPage,
hasPrevPage,
isPaginationLoading,
// 原有方法
fetchNotifications,
fetchNotificationSettings,
updateNotificationSettings,
markAsRead,
markAllAsRead,
deleteNotification,
clearAllNotifications,
// 分页方法
goToPage,
nextPage,
prevPage,
changePageSize,
resetPagination
}
}
+113
View File
@@ -0,0 +1,113 @@
import { computed, ref } from 'vue'
import { useAuth } from './useAuth'
export const rolesConfig = ref(null)
export const fetchRolesConfig = async () => {
if (rolesConfig.value) return
try {
const data = await $fetch('/api/roles')
rolesConfig.value = data
} catch (err) {
console.error('Failed to fetch roles config', err)
}
}
export const usePermissions = () => {
const auth = useAuth()
// 检查用户是否可以访问后台
const canAccessAdmin = computed(() => {
if (!auth.user.value || !auth.user.value.role) return false
const role = auth.user.value.role
if (rolesConfig.value && rolesConfig.value[role]) {
return rolesConfig.value[role].canAccessAdmin
}
// Fallback if config not loaded
return ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN', 'STAFF', 'VICE_MINISTER', 'MINISTER', 'RESIDENT'].includes(role)
})
// 检查用户是否可以访问指定页面
const canAccessPage = (page: string): boolean => {
if (!auth.user.value || !auth.user.value.role) return false
const role = auth.user.value.role
// 超级管理员可以访问所有页面 (作为兜底保护)
if (role === 'SUPER_ADMIN') return true
if (rolesConfig.value && rolesConfig.value[role]) {
return rolesConfig.value[role].pages.includes(page)
}
return false
}
// 获取用户可访问的页面列表
const getUserPages = computed(() => {
if (!auth.user.value || !auth.user.value.role) return []
const role = auth.user.value.role
if (rolesConfig.value && rolesConfig.value[role]) {
return rolesConfig.value[role].pages
}
return []
})
// 角色检查
const isUser = computed(() => auth.user.value?.role === 'USER')
const isSongAdmin = computed(() => auth.user.value?.role === 'SONG_ADMIN')
const isAdmin = computed(() => auth.user.value?.role === 'ADMIN')
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
// 权限组合检查 (为了兼容性保留,但最好根据实际权限判断)
const isAdminOrAbove = computed(() => {
const role = auth.user.value?.role
return ['ADMIN', 'SUPER_ADMIN', 'VICE_MINISTER', 'MINISTER'].includes(role)
})
const isSongAdminOrAbove = computed(() => {
const role = auth.user.value?.role
return ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN', 'STAFF', 'VICE_MINISTER', 'MINISTER', 'RESIDENT'].includes(role)
})
// 检查是否可以管理用户角色
const canManageUserRole = (targetRole: string): boolean => {
if (!auth.user.value) return false
// 只有超级管理员可以分配角色
if (auth.user.value.role === 'SUPER_ADMIN') {
return true
}
return false
}
// 检查是否可以打印排期
const canPrintSchedule = computed(() => {
if (!auth.user.value) return false
const role = auth.user.value.role
return ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(role)
})
return {
// 页面访问权限
canAccessAdmin,
canAccessPage,
getUserPages,
// 角色检查
isUser,
isSongAdmin,
isAdmin,
isSuperAdmin,
isAdminOrAbove,
isSongAdminOrAbove,
// 功能权限
canPrintSchedule,
// 用户管理权限
canManageUserRole
}
}
+114
View File
@@ -0,0 +1,114 @@
import { computed, ref } from 'vue'
export const useProgress = () => {
const progress = ref(0)
const message = ref('')
const subMessage = ref('')
const active = ref(false)
const indeterminate = ref(false)
const completed = ref(false)
const error = ref('')
// 进度百分比,确保在0-100范围内
const percentage = computed(() => {
return Math.min(100, Math.max(0, progress.value))
})
// 启动进度条
const start = (initialMessage = '处理中...') => {
progress.value = 0
message.value = initialMessage
subMessage.value = ''
active.value = true
indeterminate.value = false
completed.value = false
error.value = ''
}
// 启动不确定进度条(不显示具体百分比,用于未知进度的操作)
const startIndeterminate = (initialMessage = '处理中...') => {
progress.value = 0
message.value = initialMessage
subMessage.value = ''
active.value = true
indeterminate.value = true
completed.value = false
error.value = ''
}
// 更新进度
const update = (newProgress: number, newMessage?: string, newSubMessage?: string) => {
progress.value = newProgress
if (newMessage !== undefined) {
message.value = newMessage
}
if (newSubMessage !== undefined) {
subMessage.value = newSubMessage
}
if (progress.value >= 100) {
complete()
}
}
// 完成进度
const complete = (finalMessage = '处理完成') => {
progress.value = 100
message.value = finalMessage
active.value = true
indeterminate.value = false
completed.value = true
// 2秒后自动隐藏进度条
setTimeout(() => {
if (completed.value) {
active.value = false
}
}, 2000)
}
// 设置错误
const setError = (errorMessage: string) => {
error.value = errorMessage
active.value = true
indeterminate.value = false
// 3秒后自动隐藏错误
setTimeout(() => {
if (error.value) {
active.value = false
error.value = ''
}
}, 3000)
}
// 重置进度条
const reset = () => {
progress.value = 0
message.value = ''
subMessage.value = ''
active.value = false
indeterminate.value = false
completed.value = false
error.value = ''
}
return {
progress,
percentage,
message,
subMessage,
active,
indeterminate,
completed,
error,
start,
startIndeterminate,
update,
complete,
setError,
reset
}
}
+150
View File
@@ -0,0 +1,150 @@
import { onUnmounted, ref } from 'vue'
import { useProgress } from './useProgress'
import { useAuth } from './useAuth'
export const useProgressEvents = () => {
const {
percentage,
message,
subMessage,
active,
indeterminate,
completed,
error,
start,
startIndeterminate,
update,
complete,
setError,
reset
} = useProgress()
const progressId = ref('')
let eventSource: EventSource | null = null
// 连接到进度事件流
const connect = async (id: string) => {
// 确保之前的连接已关闭
disconnect()
progressId.value = id
startIndeterminate('正在连接...')
try {
// 创建新的事件源,通过URL参数传递认证token
const auth = useAuth()
const token = auth.getToken()
eventSource = new EventSource(`/api/progress/events?id=${id}&token=${token}`)
// 连接打开
eventSource.onopen = () => {
startIndeterminate('已连接,等待进度更新...')
}
// 接收消息
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.connected) {
startIndeterminate('已连接,等待进度更新...')
return
}
if (data.error) {
setError(data.error)
disconnect()
return
}
if (data.completed) {
complete(data.message || '操作完成')
disconnect()
return
}
// 更新进度
if (data.progress !== undefined) {
// 如果之前是不确定状态,切换到确定状态
if (indeterminate.value) {
start(data.message || '处理中...')
}
update(
data.progress,
data.message !== undefined ? data.message : undefined,
data.subMessage !== undefined ? data.subMessage : undefined
)
}
} catch (err) {
console.error('处理进度事件时出错:', err)
}
}
// 错误处理
eventSource.onerror = (err) => {
console.error('进度事件流错误:', err)
setError('连接进度更新失败')
disconnect()
}
// 关闭事件
eventSource.addEventListener('close', () => {
disconnect()
})
} catch (err) {
console.error('创建EventSource时出错:', err)
setError('无法连接到进度更新服务')
}
}
// 断开连接
const disconnect = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
// 获取新的进度ID
const getProgressId = async () => {
try {
const response = await fetch('/api/progress/id', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error('获取进度ID失败')
}
const data = await response.json()
progressId.value = data.id
return data.id
} catch (err) {
console.error('获取进度ID时出错:', err)
return null
}
}
// 清理
onUnmounted(() => {
disconnect()
})
return {
progressId,
percentage,
message,
subMessage,
active,
indeterminate,
completed,
error,
connect,
disconnect,
getProgressId
}
}
+127
View File
@@ -0,0 +1,127 @@
import { ref } from 'vue'
interface PendingRequest {
promise: Promise<any>
timestamp: number
}
// 请求去重超时时间
const REQUEST_DEDUP_TIMEOUT = 5000 // 5秒
export const useRequestDedup = () => {
// 正在进行的请求(用于去重)
const pendingRequests = new Map<string, PendingRequest>()
// 生成请求键
const generateRequestKey = (type: string, params?: Record<string, any>): string => {
if (!params || Object.keys(params).length === 0) {
return type
}
const sortedParams = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&')
return `${type}?${sortedParams}`
}
// 清理过期的请求
const cleanupPendingRequests = (): void => {
const now = Date.now()
const keysToDelete: string[] = []
pendingRequests.forEach((request, key) => {
if (now - request.timestamp > REQUEST_DEDUP_TIMEOUT) {
keysToDelete.push(key)
}
})
keysToDelete.forEach((key) => pendingRequests.delete(key))
}
// 带去重的请求函数
const dedupedRequest = async <T>(
type: string,
requestFn: () => Promise<T>,
params?: Record<string, any>,
force = false
): Promise<T> => {
const requestKey = generateRequestKey(type, params)
// 清理过期的请求
cleanupPendingRequests()
if (force) {
// 强制刷新时,如果有旧的请求正在进行,我们可以选择覆盖或者只是新开一个不存入 pending 的请求。
// 为保证拿到最新数据并避免干扰其他 pending 请求,我们直接执行并不保存到 pendingRequests。
return await requestFn()
}
// 检查是否有相同的请求正在进行
const pendingRequest = pendingRequests.get(requestKey)
if (pendingRequest) {
try {
return await pendingRequest.promise
} catch (error) {
// 如果请求失败,移除pending状态
pendingRequests.delete(requestKey)
throw error
}
}
// 创建新的请求
const promise = requestFn()
pendingRequests.set(requestKey, {
promise,
timestamp: Date.now()
})
try {
const data = await promise
// 移除pending状态
pendingRequests.delete(requestKey)
return data
} catch (error) {
// 请求失败时移除pending状态
pendingRequests.delete(requestKey)
throw error
}
}
// 清除所有pending请求
const clearAllPendingRequests = (): void => {
pendingRequests.clear()
}
// 获取pending请求统计
const getPendingStats = () => {
return {
totalPendingRequests: pendingRequests.size,
pendingByType: {} as Record<string, number>
}
}
return {
// 核心去重方法
dedupedRequest,
// 管理方法
clearAllPendingRequests,
cleanupPendingRequests,
// 工具方法
generateRequestKey,
getPendingStats
}
}
// 全局去重实例
let globalDedupInstance: ReturnType<typeof useRequestDedup> | null = null
export const getGlobalDedup = () => {
if (!globalDedupInstance) {
globalDedupInstance = useRequestDedup()
}
return globalDedupInstance
}
+305
View File
@@ -0,0 +1,305 @@
import { readonly, ref } from 'vue'
import type { Semester } from '~~/types'
// 全局事件总线用于学期更新通知
const semesterUpdateEvent = ref(0)
// SessionStorage 键名常量
const SELECTED_SEMESTER_KEY = 'voicehub_selected_semester'
const AVAILABLE_SEMESTERS_KEY = 'voicehub_available_semesters'
// 持久化工具函数
const persistenceUtils = {
// 保存选中的学期
saveSelectedSemester: (semester: string | null) => {
try {
if (semester) {
sessionStorage.setItem(SELECTED_SEMESTER_KEY, semester)
} else {
sessionStorage.removeItem(SELECTED_SEMESTER_KEY)
}
} catch (error) {
console.warn('保存选中学期失败:', error)
}
},
// 获取选中的学期
getSelectedSemester: (): string | null => {
try {
return sessionStorage.getItem(SELECTED_SEMESTER_KEY)
} catch (error) {
console.warn('获取选中学期失败:', error)
return null
}
},
// 保存可用学期列表
saveAvailableSemesters: (semesters: string[]) => {
try {
sessionStorage.setItem(AVAILABLE_SEMESTERS_KEY, JSON.stringify(semesters))
} catch (error) {
console.warn('保存可用学期列表失败:', error)
}
},
// 获取可用学期列表
getAvailableSemesters: (): string[] => {
try {
const stored = sessionStorage.getItem(AVAILABLE_SEMESTERS_KEY)
return stored ? JSON.parse(stored) : []
} catch (error) {
console.warn('获取可用学期列表失败:', error)
return []
}
},
// 清除所有持久化数据
clearAll: () => {
try {
sessionStorage.removeItem(SELECTED_SEMESTER_KEY)
sessionStorage.removeItem(AVAILABLE_SEMESTERS_KEY)
} catch (error) {
console.warn('清除持久化数据失败:', error)
}
}
}
export function useSemesters() {
const semesters = ref<Semester[]>([])
const currentSemester = ref<Semester | null>(null)
const loading = ref(false)
const error = ref('')
// 触发学期更新事件
const triggerSemesterUpdate = () => {
semesterUpdateEvent.value++
}
// 获取学期列表(管理员专用)
const fetchSemesters = async () => {
const { isAuthenticated, getAuthConfig } = useAuth()
if (!isAuthenticated.value) {
error.value = '需要登录才能获取学期列表'
return
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const response = await fetch(`/api/admin/semesters?t=${Date.now()}`, {
headers: {
'Content-Type': 'application/json'
},
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `获取学期列表失败: ${response.status}`)
}
const data = await response.json()
semesters.value = data as Semester[]
} catch (err: any) {
error.value = err.message || '获取学期列表失败'
} finally {
loading.value = false
}
}
// 获取当前活跃学期
const fetchCurrentSemester = async () => {
try {
const response = await fetch(`/api/semesters/current?t=${Date.now()}`)
if (!response.ok) {
throw new Error('获取当前学期失败')
}
const data = await response.json()
currentSemester.value = data
} catch (err: any) {
console.error('获取当前学期失败:', err)
}
}
// 创建学期
const createSemester = async (semesterData: { name: string; isActive?: boolean }) => {
const { isAuthenticated, getAuthConfig } = useAuth()
if (!isAuthenticated.value) {
error.value = '需要登录才能创建学期'
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const response = await fetch('/api/admin/semesters', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(semesterData),
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || '创建学期失败')
}
const data = await response.json()
// 更新学期列表
await fetchSemesters()
// 如果设置为活跃学期,也更新当前学期
if (semesterData.isActive) {
await fetchCurrentSemester()
}
// 触发全局学期更新事件
triggerSemesterUpdate()
return data
} catch (err: any) {
error.value = err.message || '创建学期失败'
return null
} finally {
loading.value = false
}
}
// 设置活跃学期
const setActiveSemester = async (semesterId: number) => {
const { isAuthenticated, getAuthConfig } = useAuth()
if (!isAuthenticated.value) {
error.value = '需要登录才能设置活跃学期'
return false
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const response = await fetch('/api/admin/semesters/set-active', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ semesterId }),
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || '设置活跃学期失败')
}
// 更新学期列表
await fetchSemesters()
await fetchCurrentSemester()
// 触发全局学期更新事件
triggerSemesterUpdate()
return true
} catch (err: any) {
error.value = err.message || '设置活跃学期失败'
return false
} finally {
loading.value = false
}
}
// 删除学期
const deleteSemester = async (semesterId: number) => {
const { isAuthenticated, getAuthConfig } = useAuth()
if (!isAuthenticated.value) {
error.value = '需要登录才能删除学期'
return false
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const response = await fetch(`/api/admin/semesters/${semesterId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || '删除学期失败')
}
// 更新学期列表
await fetchSemesters()
// 触发全局学期更新事件
triggerSemesterUpdate()
return true
} catch (err: any) {
error.value = err.message || '删除学期失败'
return false
} finally {
loading.value = false
}
}
// 获取所有学期选项
const fetchSemesterOptions = async () => {
loading.value = true
error.value = ''
try {
const response = await fetch(`/api/semesters/options?t=${Date.now()}`)
if (!response.ok) {
throw new Error('获取学期选项失败')
}
const data = await response.json()
if (data.success) {
semesters.value = data.data
}
} catch (err: any) {
console.error('获取学期选项失败:', err)
error.value = err.message || '获取学期选项失败'
} finally {
loading.value = false
}
}
return {
semesters: readonly(semesters),
currentSemester: readonly(currentSemester),
loading: readonly(loading),
error: readonly(error),
semesterUpdateEvent: readonly(semesterUpdateEvent),
fetchSemesters,
fetchCurrentSemester,
fetchSemesterOptions,
createSemester,
setActiveSemester,
deleteSemester,
triggerSemesterUpdate,
// 导出持久化工具函数
persistenceUtils
}
}
+133
View File
@@ -0,0 +1,133 @@
import { computed, ref, readonly } from 'vue'
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
2. 除DJ外,其他类型歌曲均接收(包括小语种)
3. 禁止投递含有违规内容的歌曲
4. 点播的歌曲将由管理员进行审核
5. 审核通过后将安排在播放时段播出
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
8. 最终解释权归广播站所有`
// 站点配置状态
const siteConfig = ref({
siteTitle: '',
siteLogoUrl: '',
schoolLogoHomeUrl: '',
schoolLogoPrintUrl: '',
siteDescription: '',
siteVersion: '',
submissionGuidelines: '',
icpNumber: '',
gonganNumber: ''
})
const isLoaded = ref(false)
const isLoading = ref(false)
export const useSiteConfig = () => {
// 获取站点配置
const fetchSiteConfig = async () => {
if (isLoading.value) return
try {
isLoading.value = true
const response = await fetch('/api/site-config')
if (!response.ok) {
throw new Error('获取站点配置失败')
}
const data = await response.json()
siteConfig.value = data
isLoaded.value = true
} catch (error) {
console.error('获取站点配置失败:', error)
// 使用默认配置
siteConfig.value = {
siteTitle: '校园广播站点歌系统',
siteLogoUrl: '/images/site-logo.png',
schoolLogoHomeUrl: '/images/school-logo-white.png',
schoolLogoPrintUrl: '/images/school-logo-transparent.png',
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
siteVersion: '',
submissionGuidelines: defaultSubmissionGuidelines,
icpNumber: '',
gonganNumber: '',
enableReplayRequests: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false
}
isLoaded.value = true
} finally {
isLoading.value = false
}
}
// 计算属性
const siteTitle = computed(() => siteConfig.value.siteTitle || '校园广播站点歌系统')
const logoUrl = computed(() => {
const url = siteConfig.value.siteLogoUrl
return (!url || url === '/favicon.ico') ? '/images/site-logo.png' : url
})
const schoolLogoHomeUrl = computed(() => {
const raw = (siteConfig.value.schoolLogoHomeUrl || '').trim()
const fallback = '/images/school-logo-white.png'
if (!raw) return fallback
if (raw === '/images/school-logo-white.png' || raw === '/images/school-logo-black.png') return fallback
return raw
})
const schoolLogoPrintUrl = computed(() => siteConfig.value.schoolLogoPrintUrl || '/images/school-logo.png')
const description = computed(
() => siteConfig.value.siteDescription || '校园广播站点歌系统 - 让你的声音被听见'
)
const siteVersion = computed(() => (siteConfig.value.siteVersion || '').trim())
const guidelines = computed(
() => siteConfig.value.submissionGuidelines || defaultSubmissionGuidelines
)
const icp = computed(() => siteConfig.value.icpNumber || '')
const gonganNumber = computed(() => siteConfig.value.gonganNumber || '')
const enableReplayRequests = computed(() => siteConfig.value.enableReplayRequests || false)
const enableCollaborativeSubmission = computed(
() => siteConfig.value.enableCollaborativeSubmission !== false
)
const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
// 初始化配置(仅在客户端执行)
const initSiteConfig = async () => {
if (typeof window !== 'undefined' && !isLoaded.value) {
await fetchSiteConfig()
}
}
// 刷新配置
const refreshSiteConfig = async () => {
isLoaded.value = false
await fetchSiteConfig()
}
return {
siteConfig: readonly(siteConfig),
isLoaded: readonly(isLoaded),
isLoading: readonly(isLoading),
siteTitle,
logoUrl,
schoolLogoHomeUrl,
schoolLogoPrintUrl,
description,
siteVersion,
guidelines,
icp,
gonganNumber,
enableReplayRequests,
enableCollaborativeSubmission,
enableSubmissionRemarks,
smtpEnabled,
fetchSiteConfig,
initSiteConfig,
refreshSiteConfig
}
}
+62
View File
@@ -0,0 +1,62 @@
import { getMusicUrl } from '~/utils/musicUrl'
import { useAudioPlayer, type PlayableSong } from './useAudioPlayer'
import { useToast } from './useToast'
import type { Song } from '~~/types'
export const useSongPlayer = () => {
const audioPlayer = useAudioPlayer()
const { showToast } = useToast()
const playSong = async (song: Song | PlayableSong) => {
// 如果是当前选中的歌曲
if (audioPlayer.isCurrentSong(song.id)) {
// 如果正在播放,则暂停
if (audioPlayer.getPlayingStatus().value) {
audioPlayer.pauseSong()
return
}
// 如果是当前歌曲但暂停了,则恢复播放
const currentGlobalSong = audioPlayer.getCurrentSong().value
if (currentGlobalSong && (currentGlobalSong.musicUrl)) {
audioPlayer.playSong(currentGlobalSong)
return
}
}
try {
let url = null
// 如果有手动填入的 playUrl,优先使用它(统一处理所有平台)
if (song.playUrl && song.playUrl.trim()) {
url = song.playUrl.trim()
} else if (true) {
// 如果不是哔哩哔哩歌曲且没有手动 playUrl,通过 API 获取 URL
url = await getMusicUrl(song.musicPlatform, song.musicId, song.playUrl)
}
// 如果是哔哩哔哩歌曲且没有手动 playUrl,url 保持为 null,播放器会处理
const playableSong: PlayableSong = {
...song,
musicUrl: url
}
audioPlayer.playSong(playableSong, [playableSong])
} catch (error: any) {
console.error('播放失败:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
showToast('播放失败: ' + errorMessage, 'error')
// 即使获取 URL 失败,也应该调用 playSong 以触发播放器的错误处理和弹窗逻辑
const playableSong: PlayableSong = {
...song,
musicUrl: null
}
audioPlayer.playSong(playableSong, [playableSong])
}
}
return {
playSong
}
}
+968
View File
@@ -0,0 +1,968 @@
import { computed, ref } from 'vue'
import { useAuth } from './useAuth'
import { getGlobalDedup } from './useRequestDedup'
import type { PlayTime, Schedule, Song } from '~~/types'
export const useSongs = () => {
const { isAuthenticated, user, getAuthConfig, isAdmin } = useAuth()
const dedup = getGlobalDedup()
const songs = ref<Song[]>([])
const publicSchedules = ref<Schedule[]>([])
const publicSongs = ref<Song[]>([])
const loading = ref(false)
const error = ref('')
const notification = ref({ show: false, message: '', type: '' })
const similarSongFound = ref<Song | null>(null)
const playTimes = ref<PlayTime[]>([])
const playTimeEnabled = ref(false)
const songCount = ref(0)
// 显示通知
const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
// 使用全局通知
if (window.$showNotification) {
window.$showNotification(message, type)
} else {
// 备用方案
notification.value = {
show: true,
message,
type
}
// 3秒后自动关闭
setTimeout(() => {
notification.value.show = false
}, 3000)
}
}
// 获取播放时段列表
const fetchPlayTimes = async () => {
loading.value = true
error.value = ''
try {
const response = await $fetch('/api/play-times')
playTimeEnabled.value = response.enabled
// 确保类型兼容性
if (response.playTimes && Array.isArray(response.playTimes)) {
playTimes.value = response.playTimes.map((pt) => ({
id: pt.id,
name: pt.name,
startTime: pt.startTime || undefined,
endTime: pt.endTime || undefined,
enabled: pt.enabled,
description: pt.description || undefined
}))
} else {
playTimes.value = []
}
return response
} catch (err: any) {
error.value = err.message || '获取播放时段失败'
return { enabled: false, playTimes: [] }
} finally {
loading.value = false
}
}
// 获取歌曲列表
const fetchSongs = async (
silent = false,
semester?: string,
forceRefresh = false,
bypassCache = false
) => {
if (!silent) {
loading.value = true
}
error.value = ''
try {
const requestParams = semester ? { semester } : undefined
const response = await dedup.dedupedRequest(
'songs',
async () => {
// 构建URL参数
const params = new URLSearchParams()
if (semester) {
params.append('semester', semester)
}
// 只有当 bypassCache 为 true 时才添加 bypass_cache 参数
if (bypassCache) {
params.append('bypass_cache', 'true')
}
if (forceRefresh) {
params.append('t', Date.now().toString())
}
const url = `/api/songs${params.toString() ? '?' + params.toString() : ''}`
// API请求
return await $fetch(url, {
...getAuthConfig()
})
},
requestParams,
forceRefresh
)
// 正确解析API返回的数据结构
if (response && response.success && response.data && Array.isArray(response.data.songs)) {
songs.value = response.data.songs as Song[]
} else {
songs.value = []
console.warn('API返回的数据格式不正确:', response)
}
} catch (err: any) {
error.value = err.message || '获取歌曲列表失败'
} finally {
if (!silent) {
loading.value = false
}
}
}
// 静默刷新歌曲列表 - 不显示加载状态
const refreshSongsSilent = async () => {
return fetchSongs(true)
}
// 从排期数据中提取歌曲信息
const extractSongsFromSchedules = (schedules: Schedule[]): Song[] => {
const songsMap = new Map<string, Song>()
schedules.forEach((schedule) => {
if (schedule.song) {
const songId = String(schedule.song.id)
if (!songsMap.has(songId)) {
// 将排期中的歌曲信息转换为完整的Song对象
const completeSong: Song = {
id: schedule.song.id,
title: schedule.song.title,
artist: schedule.song.artist,
requester: schedule.song.requester,
requesterGrade: schedule.song.requesterGrade || null,
requesterClass: schedule.song.requesterClass || null,
requesterId: 0, // 默认值,公共API不提供这个信息
voteCount: schedule.song.voteCount,
played: schedule.song.played || false,
playedAt: schedule.song.playedAt || null,
semester: schedule.song.semester || null,
requestedAt: schedule.song.requestedAt || new Date().toISOString(),
cover: schedule.song.cover || null,
musicPlatform: schedule.song.musicPlatform || null,
musicId: schedule.song.musicId || null
}
songsMap.set(songId, completeSong)
}
}
})
return Array.from(songsMap.values())
}
// 获取公共排期(无需登录)
const fetchPublicSchedules = async (
silent = false,
semester?: string,
forceRefresh = false,
bypassCache = false
) => {
if (!silent) {
loading.value = true
}
error.value = ''
try {
const requestParams = semester ? { semester } : undefined
const data = await dedup.dedupedRequest(
'public-schedules',
async () => {
// 构建URL参数
const params = new URLSearchParams()
if (semester) {
params.append('semester', semester)
}
// 只有当 bypassCache 为 true 时才添加 bypass_cache 参数
if (bypassCache) {
params.append('bypass_cache', 'true')
}
if (forceRefresh) {
params.append('t', Date.now().toString())
}
const url = `/api/songs/public${params.toString() ? '?' + params.toString() : ''}`
const response = await $fetch(url, {
...getAuthConfig()
})
return response
},
requestParams,
forceRefresh
)
// 确保每个排期的歌曲都有played属性,并处理null/undefined转换
const processedData = data.map((schedule: any) => {
// 处理歌曲属性
if (schedule.song && schedule.song.played === undefined) {
schedule.song.played = false
}
// 处理播放时间属性
let processedPlayTime = null
if (schedule.playTime) {
processedPlayTime = {
...schedule.playTime,
startTime: schedule.playTime.startTime || undefined,
endTime: schedule.playTime.endTime || undefined
}
}
// 返回符合Schedule类型的对象
return {
...schedule,
playTime: processedPlayTime
} as Schedule
})
publicSchedules.value = processedData
// 直接从排期数据中提取歌曲信息,避免重复请求
publicSongs.value = extractSongsFromSchedules(processedData)
} catch (err: any) {
error.value = err.message || '获取排期失败'
} finally {
if (!silent) {
loading.value = false
}
}
}
// 静默刷新公共排期 - 不显示加载状态
const refreshSchedulesSilent = async () => {
return fetchPublicSchedules(true)
}
// 字符串相似度计算(编辑距离)
const calculateSimilarity = (str1: string, str2: string): number => {
const len1 = str1.length
const len2 = str2.length
if (len1 === 0) return len2
if (len2 === 0) return len1
const matrix = Array(len1 + 1)
.fill(null)
.map(() => Array(len2 + 1).fill(null))
for (let i = 0; i <= len1; i++) matrix[i][0] = i
for (let j = 0; j <= len2; j++) matrix[0][j] = j
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // 删除
matrix[i][j - 1] + 1, // 插入
matrix[i - 1][j - 1] + cost // 替换
)
}
}
const maxLen = Math.max(len1, len2)
return (maxLen - matrix[len1][len2]) / maxLen
}
// 标准化字符串(去除标点符号、空格,转换为小写)
const normalizeString = (str: string): string => {
return str
.toLowerCase()
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
.replace(/[&&]/g, 'and')
.replace(/[feat\.?|ft\.?]/gi, '')
.trim()
}
// 获取当前学期名称(从数据库)
const getCurrentSemesterName = async () => {
try {
const response = await fetch('/api/semesters/current')
if (!response.ok) {
throw new Error('获取当前学期失败')
}
const data = await response.json()
return data?.name || null
} catch (error) {
console.error('获取当前学期失败:', error)
return null
}
}
// 检查相似歌曲
const checkSimilarSongs = async (title: string, artist: string): Promise<Song[]> => {
similarSongFound.value = null
const normalizedTitle = normalizeString(title)
const normalizedArtist = normalizeString(artist)
// 获取当前学期名称
const currentSemesterName = await getCurrentSemesterName()
// 1. 检查完全相同的歌曲(标准化后)
const exactSongs = songs.value.filter((song) => {
const songTitle = normalizeString(song.title)
const songArtist = normalizeString(song.artist)
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
// 如果有当前学期信息,只检查当前学期的歌曲
if (currentSemesterName) {
return titleMatch && song.semester === currentSemesterName
}
// 如果没有学期信息,检查所有歌曲(向后兼容)
return titleMatch
})
if (exactSongs.length > 0) {
return exactSongs
}
// 2. 检查高度相似的歌曲
const similarSongs: Array<{ song: Song; similarity: number }> = []
songs.value.forEach((song) => {
// 如果有当前学期信息,只检查当前学期的歌曲
if (currentSemesterName && song.semester !== currentSemesterName) {
return
}
const songTitle = normalizeString(song.title)
const songArtist = normalizeString(song.artist)
// 计算标题相似度
const titleSimilarity = calculateSimilarity(normalizedTitle, songTitle)
// 计算艺术家相似度
const artistSimilarity = calculateSimilarity(normalizedArtist, songArtist)
// 检查是否为高度相似
const isHighlySimilar =
// 标题完全相同,艺术家高度相似
(titleSimilarity >= 0.95 && artistSimilarity >= 0.8) ||
// 标题高度相似,艺术家完全相同
(titleSimilarity >= 0.8 && artistSimilarity >= 0.95) ||
// 标题和艺术家都高度相似
(titleSimilarity >= 0.85 && artistSimilarity >= 0.85) ||
// 标题包含关系且艺术家相似
((songTitle.includes(normalizedTitle) || normalizedTitle.includes(songTitle)) &&
artistSimilarity >= 0.8 &&
Math.abs(songTitle.length - normalizedTitle.length) <= 3)
if (isHighlySimilar) {
const overallSimilarity = titleSimilarity * 0.7 + artistSimilarity * 0.3
similarSongs.push({ song, similarity: overallSimilarity })
}
})
// 3. 按相似度排序并返回
if (similarSongs.length > 0) {
const sortedSimilar = similarSongs
.sort((a, b) => b.similarity - a.similarity)
.map((item) => item.song)
similarSongFound.value = sortedSimilar[0] // 保持兼容性
return sortedSimilar
}
// 4. 如果没有高度相似的,检查可能的相似歌曲(降低阈值)
const possibleSimilar: Array<{ song: Song; similarity: number }> = []
songs.value.forEach((song) => {
// 如果有当前学期信息,只检查当前学期的歌曲
if (currentSemesterName && song.semester !== currentSemesterName) {
return
}
const songTitle = normalizeString(song.title)
const songArtist = normalizeString(song.artist)
const titleSimilarity = calculateSimilarity(normalizedTitle, songTitle)
const artistSimilarity = calculateSimilarity(normalizedArtist, songArtist)
// 检查可能相似的条件(更宽松)
const isPossiblySimilar =
// 标题相似度较高
(titleSimilarity >= 0.7 && artistSimilarity >= 0.6) ||
// 标题包含关系
((songTitle.includes(normalizedTitle) || normalizedTitle.includes(songTitle)) &&
artistSimilarity >= 0.5 &&
normalizedTitle.length >= 3)
if (isPossiblySimilar) {
const overallSimilarity = titleSimilarity * 0.7 + artistSimilarity * 0.3
possibleSimilar.push({ song, similarity: overallSimilarity })
}
})
if (possibleSimilar.length > 0) {
const sortedPossible = possibleSimilar
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 3) // 最多返回3个可能相似的歌曲
.map((item) => item.song)
similarSongFound.value = sortedPossible[0] // 保持兼容性
return sortedPossible
}
return []
}
// 请求歌曲
const requestSong = async (songData: {
title: string
artist: string
preferredPlayTimeId?: number | null
cover?: string | null
musicPlatform?: string | null
musicId?: string | null
playUrl?: string | null
expectedPlayDate?: string | null
expectedPlaySequence?: number | null
collaborators?: number[]
submissionNote?: string | null
submissionNotePublic?: boolean
}) => {
if (!isAuthenticated.value) {
showNotification('需要登录才能点歌', 'error')
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/songs/request', {
method: 'POST',
body: songData,
...authConfig
})
// 更新歌曲列表
await fetchSongs(true, undefined, true, true)
return data
} catch (err: any) {
const errorMsg = err.data?.message || err.message || '点歌失败'
// 如果是重复投稿错误,只显示通知而不设置全局错误
if (errorMsg.includes('已经在列表中') || errorMsg.includes('不能重复投稿')) {
showNotification(errorMsg, 'info')
} else {
error.value = errorMsg
showNotification(errorMsg, 'error')
}
return null
} finally {
loading.value = false
}
}
// 投票
const voteSong = async (songId: number | { id: number; unvote?: boolean }) => {
if (!isAuthenticated.value) {
showNotification('需要登录才能投票', 'error')
return null
}
loading.value = true
error.value = ''
// 处理传入的可能是对象的情况
const actualSongId = typeof songId === 'object' ? songId.id : songId
const isUnvote = typeof songId === 'object' && songId.unvote === true
try {
// 检查是否已经投过票(只在正常投票时检查)
const targetSong = songs.value.find((s) => s.id === actualSongId)
if (!isUnvote && targetSong && targetSong.voted) {
showNotification('您已经为这首歌投过票了', 'info')
loading.value = false
return null
}
// 使用认证配置
const authConfig = getAuthConfig()
try {
const data = await $fetch('/api/songs/vote', {
method: 'POST',
body: {
songId: actualSongId,
unvote: isUnvote // 添加取消投票参数
},
...authConfig
})
// 立即更新本地歌曲的投票状态
if (targetSong) {
if (isUnvote) {
// 取消投票
targetSong.voted = false
targetSong.voteCount = Math.max(0, (targetSong.voteCount || 1) - 1)
showNotification(`已取消对歌曲《${targetSong.title}》的投票`, 'success')
} else {
// 正常投票
targetSong.voted = true
targetSong.voteCount = (targetSong.voteCount || 0) + 1
showNotification(`为歌曲《${targetSong.title}》投票成功!`, 'success')
}
}
return data
} catch (fetchErr: any) {
// 处理API返回的错误
const errorMsg = fetchErr.data?.message || fetchErr.message || '投票失败'
// 如果是已投票错误,只显示通知,不抛出异常
if (errorMsg.includes('已经为这首歌投过票')) {
showNotification('您已经为这首歌投过票了', 'info')
// 如果API返回已投票错误,也更新本地状态
if (targetSong && !targetSong.voted) {
targetSong.voted = true
}
return null
}
// 其他错误则抛出
throw fetchErr
}
} catch (err: any) {
const errorMsg = err.data?.message || err.message || '投票失败'
error.value = errorMsg
showNotification(errorMsg, 'error')
return null
} finally {
loading.value = false
}
}
// 撤回歌曲(只能撤回自己的投稿)
const withdrawSong = async (songId: number) => {
if (!isAuthenticated.value) {
showNotification('需要登录才能撤回歌曲', 'error')
return null
}
loading.value = true
error.value = ''
try {
// 查找歌曲信息用于通知
const targetSong = songs.value.find((s) => s.id === songId)
const songTitle = targetSong ? targetSong.title : '歌曲'
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/songs/withdraw', {
method: 'POST',
body: { songId },
...authConfig
})
// 更新歌曲列表
await fetchSongs(true, undefined, true, true)
// 显示成功通知
let message = ''
if (data.action === 'leave') {
message = `已成功退出歌曲《${songTitle}》的联合投稿`
} else if (data.message) {
// 如果后端返回了特定消息,优先使用(除了撤回投稿的情况,我们要保留带标题的格式)
message = data.message
} else {
message = data.quotaReturned
? `已成功撤回《${songTitle}》的投稿,投稿配额已返还`
: `已成功撤回《${songTitle}》的投稿`
}
if (data.action !== 'leave') {
message = data.quotaReturned
? `已成功撤回《${songTitle}》的投稿,投稿配额已返还`
: `已成功撤回《${songTitle}》的投稿`
}
showNotification(message, 'success')
return data
} catch (err: any) {
const errorMsg = err.data?.message || err.message || '撤回歌曲失败'
error.value = errorMsg
showNotification(errorMsg, 'error')
return null
} finally {
loading.value = false
}
}
// 删除歌曲(管理员专用)
const deleteSong = async (songId: number) => {
if (!isAuthenticated.value) {
showNotification('需要登录才能删除歌曲', 'error')
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/songs/delete', {
method: 'POST',
body: { songId },
...authConfig
})
// 更新歌曲列表和公开排期
await Promise.all([
fetchSongs(true, undefined, true, true),
fetchPublicSchedules(true, undefined, true, true)
])
showNotification('歌曲已成功删除!', 'success')
return data
} catch (err: any) {
const errorMsg = err.data?.message || err.message || '删除歌曲失败'
error.value = errorMsg
showNotification(errorMsg, 'error')
return null
} finally {
loading.value = false
}
}
// 标记歌曲为已播放(管理员专用)
const markPlayed = async (songId: number) => {
if (!isAuthenticated.value) {
showNotification('需要登录才能标记歌曲', 'error')
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/songs/mark-played', {
method: 'POST',
body: { songId },
...authConfig
})
// 更新歌曲列表和公开排期
await Promise.all([
fetchSongs(true, undefined, true, true),
fetchPublicSchedules(true, undefined, true, true)
])
showNotification('歌曲已成功标记为已播放!', 'success')
return data
} catch (err: any) {
const errorMsg = err.data?.message || err.message || '标记歌曲失败'
error.value = errorMsg
showNotification(errorMsg, 'error')
return null
} finally {
loading.value = false
}
}
// 撤回歌曲已播放状态(管理员专用)
const unmarkPlayed = async (songId: number) => {
if (!isAuthenticated.value) {
showNotification('需要登录才能撤回歌曲已播放状态', 'error')
return null
}
loading.value = true
error.value = ''
try {
// 使用认证配置
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/songs/mark-played', {
method: 'POST',
body: { songId, unmark: true },
...authConfig
})
// 更新歌曲列表和公开排期
await Promise.all([
fetchSongs(true, undefined, true, true),
fetchPublicSchedules(true, undefined, true, true)
])
showNotification('歌曲已成功撤回已播放状态!', 'success')
return data
} catch (err: any) {
const errorMsg = err.data?.message || err.message || '撤回歌曲已播放状态失败'
error.value = errorMsg
showNotification(errorMsg, 'error')
return null
} finally {
loading.value = false
}
}
// 申请重播
const requestReplay = async (songId: number) => {
if (!isAuthenticated.value) {
showNotification('需要登录才能申请重播', 'error')
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/songs/replay', {
method: 'POST',
body: { songId },
...authConfig
})
// 更新本地数据状态
const songIndex = songs.value.findIndex((s) => s.id === songId)
if (songIndex !== -1) {
songs.value[songIndex].replayRequested = true
}
// 更新歌曲列表
await fetchSongs(true, undefined, true, true)
showNotification('重播申请已提交,请等待管理员审核', 'success')
return data
} catch (err: any) {
const errorMsg = err.data?.message || err.message || '申请重播失败'
if (errorMsg.includes('已经申请')) {
showNotification('您已经申请过重播这首歌了', 'info')
} else {
showNotification(errorMsg, 'error')
}
return null
} finally {
loading.value = false
}
}
/**
* 撤回重播申请
* @param songId 歌曲ID
*/
const withdrawReplay = async (songId: number) => {
const { user } = useAuth()
if (!user.value) {
showNotification('需要登录才能取消重播申请', 'error')
return null
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/songs/replay', {
method: 'DELETE',
body: { songId },
...authConfig
})
// 更新歌曲列表
await fetchSongs(true, undefined, true, true)
showNotification('已取消重播申请', 'success')
return data
} catch (err: any) {
const errorMsg = err.data?.message || err.message || '取消重播申请失败'
showNotification(errorMsg, 'error')
return null
} finally {
loading.value = false
}
}
// 按热度排序的歌曲
const songsByPopularity = computed(() => {
return [...songs.value].sort((a, b) => b.voteCount - a.voteCount)
})
// 按创建时间排序的歌曲
const songsByDate = computed(() => {
return [...songs.value].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
})
// 已播放的歌曲
const playedSongs = computed(() => {
return songs.value.filter((song) => song.played)
})
// 未播放的歌曲
const unplayedSongs = computed(() => {
return songs.value.filter((song) => !song.played)
})
// 我的投稿歌曲
const mySongs = computed(() => {
if (!user.value) return []
return songs.value.filter((song) => song.requesterId === user.value?.id)
})
// 我的重播申请歌曲
const myReplaySongs = computed(() => {
if (!user.value) return []
return songs.value.filter((song) => song.replayRequested)
})
// 所有可见的歌曲(登录用户看到的 + 公共歌曲)
const visibleSongs = computed(() => {
if (songs.value && songs.value.length > 0) {
return songs.value
} else {
return publicSongs.value
}
})
// 根据播放时间段过滤歌曲排期
const filterSchedulesByPlayTime = (schedules: Schedule[], playTimeId: number | null) => {
if (playTimeId === null) {
return schedules.filter((s) => s.playTimeId === null)
}
return schedules.filter((s) => s.playTimeId === playTimeId)
}
// 获取播放时间名称
const getPlayTimeName = (playTimeId: number | null) => {
if (playTimeId === null) {
return '未指定时段'
}
const foundTime = playTimes.value.find((pt) => pt.id === playTimeId)
return foundTime ? foundTime.name : '未知时段'
}
// 格式化播放时间显示
const formatPlayTimeDisplay = (playTime: PlayTime | null) => {
if (!playTime) {
return '全天'
}
let displayText = playTime.name
// 如果有开始和结束时间,添加时间信息
if (playTime.startTime && playTime.endTime) {
displayText += ` (${playTime.startTime}-${playTime.endTime})`
}
// 如果只有开始时间
else if (playTime.startTime) {
displayText += ` (${playTime.startTime}起)`
}
// 如果只有结束时间
else if (playTime.endTime) {
displayText += ` (至${playTime.endTime})`
}
return displayText
}
// 获取歌曲总数(缓存版本)
const fetchSongCount = async (forceRefresh = false) => {
try {
const response = await dedup.dedupedRequest('song-count', async () => {
const response = await $fetch(`/api/songs/count${forceRefresh ? '?t=' + Date.now() : ''}`)
return response
}, undefined, forceRefresh)
// 正确解析API返回的数据结构
if (response && typeof response.count === 'number') {
songCount.value = response.count
return response.count
} else {
console.warn('歌曲总数API返回的数据格式不正确:', response)
songCount.value = 0
return 0
}
} catch (err: any) {
console.error('获取歌曲总数失败:', err)
return 0
}
}
// 初始化加载
const initialize = async () => {
await fetchPlayTimes()
if (isAuthenticated.value) {
await fetchSongs()
} else {
await fetchPublicSchedules()
}
}
return {
songs,
publicSongs,
publicSchedules,
visibleSongs,
songCount,
loading,
error,
notification,
similarSongFound,
playTimes,
playTimeEnabled,
showNotification,
fetchSongs,
fetchPublicSchedules,
fetchPlayTimes,
fetchSongCount,
refreshSongsSilent,
refreshSchedulesSilent,
checkSimilarSongs,
requestSong,
voteSong,
withdrawSong,
deleteSong,
markPlayed,
unmarkPlayed,
requestReplay,
withdrawReplay,
filterSchedulesByPlayTime,
getPlayTimeName,
formatPlayTimeDisplay,
extractSongsFromSchedules,
initialize,
songsByPopularity,
songsByDate,
playedSongs,
unplayedSongs,
mySongs,
myReplaySongs
}
}
+175
View File
@@ -0,0 +1,175 @@
import { computed, ref, watch } from 'vue'
const STORAGE_KEY = 'voicehub:theme'
type ThemeName = string & {}
const currentTheme = ref<string | null>(null)
const initialized = ref(false)
function isBrowser() {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function getMetaThemeColor() {
if (!isBrowser()) return null
return document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
}
function applyTheme(theme: string) {
if (!isBrowser()) return
const root = document.documentElement
const appRoot = document.querySelector<HTMLElement>('div.app')
root.setAttribute('data-theme', theme)
if (appRoot) appRoot.setAttribute('data-theme', theme)
if (theme === 'dark') {
root.classList.add('dark')
root.classList.remove('light')
root.style.colorScheme = 'dark'
} else if (theme === 'light') {
root.classList.add('light')
root.classList.remove('dark')
root.style.colorScheme = 'light'
} else {
root.classList.remove('dark', 'light')
root.style.colorScheme = 'light dark'
}
const meta = getMetaThemeColor()
if (meta) {
if (theme === 'light') {
meta.content = '#f8fafc'
} else {
meta.content = '#111111'
}
}
try {
localStorage.setItem(STORAGE_KEY, theme)
} catch {
/* ignore storage errors */
}
}
function detectSystemTheme(): 'dark' | 'light' {
if (!isBrowser()) return 'dark'
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
return prefersDark ? 'dark' : 'light'
}
export function useTheme() {
const config = useRuntimeConfig()
const enabledThemes = computed<string[]>(() => {
const list = (config.public?.enabledThemes as unknown) as string[] | undefined
return Array.isArray(list) && list.length > 0 ? list : ['dark', 'light']
})
const defaultTheme = computed<string>(() => {
const value = config.public?.defaultTheme as string | undefined
if (value && enabledThemes.value.includes(value)) return value
return enabledThemes.value.includes('dark') ? 'dark' : enabledThemes.value[0] || 'dark'
})
const theme = computed<string>({
get: () => currentTheme.value ?? defaultTheme.value,
set: (next: string) => setTheme(next)
})
const isDark = computed(() => {
const t = theme.value
if (t === 'dark') return true
if (t === 'light') return false
return detectSystemTheme() === 'dark'
})
function isThemeEnabled(name: string) {
return enabledThemes.value.includes(name)
}
function setTheme(next: string, { persist = true } = {}) {
let candidate = next
if (!enabledThemes.value.includes(candidate)) {
candidate = defaultTheme.value
}
if (currentTheme.value === candidate && initialized.value) return
currentTheme.value = candidate
applyTheme(candidate)
if (!persist && isBrowser()) {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
/* ignore */
}
}
}
function toggleTheme() {
const order = enabledThemes.value.length >= 2 ? enabledThemes.value : ['dark', 'light']
const current = theme.value
const idx = order.indexOf(current)
const next = order[(idx + 1) % order.length]
setTheme(next)
}
function resetToDefault() {
setTheme(defaultTheme.value, { persist: false })
}
function init() {
if (initialized.value || !isBrowser()) return
initialized.value = true
let stored: string | null = null
try {
stored = localStorage.getItem(STORAGE_KEY)
} catch {
stored = null
}
if (stored && enabledThemes.value.includes(stored)) {
setTheme(stored)
return
}
setTheme(defaultTheme.value, { persist: false })
if (window.matchMedia) {
const mql = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => {
let s: string | null = null
try {
s = localStorage.getItem(STORAGE_KEY)
} catch {
s = null
}
if (!s) {
setTheme(defaultTheme.value, { persist: false })
}
}
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', onChange)
} else if (typeof mql.addListener === 'function') {
mql.addListener(onChange)
}
}
}
return {
theme,
currentTheme,
defaultTheme,
enabledThemes,
isDark,
isThemeEnabled,
setTheme,
toggleTheme,
resetToDefault,
init
}
}
+58
View File
@@ -0,0 +1,58 @@
import { ref } from 'vue'
interface ToastMessage {
id: number
message: string
type: 'success' | 'error' | 'info' | 'warning'
duration: number
}
const toasts = ref<ToastMessage[]>([])
let toastId = 0
export const useToast = () => {
const showToast = (
message: string,
type: 'success' | 'error' | 'info' | 'warning' = 'info',
duration = 3000
) => {
const id = ++toastId
const toast: ToastMessage = {
id,
message,
type,
duration
}
toasts.value.push(toast)
// 自动移除toast
setTimeout(() => {
removeToast(id)
}, duration)
return id
}
const removeToast = (id: number) => {
const index = toasts.value.findIndex((toast) => toast.id === id)
if (index > -1) {
toasts.value.splice(index, 1)
}
}
const success = (message: string, duration?: number) => showToast(message, 'success', duration)
const error = (message: string, duration?: number) => showToast(message, 'error', duration)
const info = (message: string, duration?: number) => showToast(message, 'info', duration)
const warning = (message: string, duration?: number) => showToast(message, 'warning', duration)
return {
toasts,
showToast,
removeToast,
success,
error,
info,
warning
}
}