初次提交
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
import { isBilibiliSong } from '~/utils/bilibiliSource'
|
||||
|
||||
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) => {
|
||||
// 对于哔哩哔哩视频,允许没有 musicUrl(会显示 iframe 预览)
|
||||
if (!song.musicUrl && !isBilibiliSong(song)) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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 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,
|
||||
logout,
|
||||
changePassword,
|
||||
setInitialPassword,
|
||||
refreshUser,
|
||||
initAuth,
|
||||
getAuthConfig
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
await import('@applemusic-like-lyrics/core/style.css')
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 用于管理哔哩哔哩视频预览弹窗的组合式函数
|
||||
* 提取了 SongManagement 和 ScheduleManager 组件中的通用逻辑
|
||||
*/
|
||||
export function useBilibiliPreview() {
|
||||
const showBilibiliPreview = ref(false)
|
||||
const previewBvid = ref('')
|
||||
const previewPage = ref(1)
|
||||
|
||||
const openBilibiliPreview = (song: any) => {
|
||||
if (!song.musicId) return
|
||||
|
||||
const parts = song.musicId.split(':')
|
||||
previewBvid.value = parts[0]
|
||||
previewPage.value = parts.length >= 3 ? parseInt(parts[2]) || 1 : 1
|
||||
showBilibiliPreview.value = true
|
||||
}
|
||||
|
||||
const closeBilibiliPreview = () => {
|
||||
showBilibiliPreview.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
showBilibiliPreview,
|
||||
previewBvid,
|
||||
previewPage,
|
||||
openBilibiliPreview,
|
||||
closeBilibiliPreview
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuth } from './useAuth'
|
||||
import type { Notification, NotificationSettings } from '~/types'
|
||||
|
||||
interface PaginationInfo {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
totalCount: number
|
||||
limit: number
|
||||
hasNextPage: boolean
|
||||
hasPrevPage: boolean
|
||||
}
|
||||
|
||||
export const useNotifications = () => {
|
||||
const { getAuthConfig, isAuthenticated } = useAuth()
|
||||
|
||||
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()
|
||||
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) {
|
||||
notifications.value = data.notifications || []
|
||||
unreadCount.value = data.unreadCount || 0
|
||||
|
||||
// 更新分页信息
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
export const usePermissions = () => {
|
||||
const auth = useAuth()
|
||||
|
||||
// 检查用户是否可以访问后台
|
||||
const canAccessAdmin = computed(() => {
|
||||
if (!auth.user.value) return false
|
||||
const role = auth.user.value.role
|
||||
return ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(role)
|
||||
})
|
||||
|
||||
// 检查用户是否可以访问指定页面
|
||||
const canAccessPage = (page: string): boolean => {
|
||||
if (!auth.user.value) return false
|
||||
|
||||
const role = auth.user.value.role
|
||||
|
||||
// 超级管理员可以访问所有页面
|
||||
if (role === 'SUPER_ADMIN') return true
|
||||
|
||||
// 管理员可以访问的页面
|
||||
if (role === 'ADMIN') {
|
||||
return ['overview', 'schedule', 'print', 'songs', 'users', 'data-analysis'].includes(page)
|
||||
}
|
||||
|
||||
// 歌曲管理员可以访问的页面
|
||||
if (role === 'SONG_ADMIN') {
|
||||
return ['schedule', 'print', 'songs'].includes(page)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取用户可访问的页面列表
|
||||
const getUserPages = computed(() => {
|
||||
if (!auth.user.value) return []
|
||||
|
||||
const role = auth.user.value.role
|
||||
|
||||
switch (role) {
|
||||
case 'SUPER_ADMIN':
|
||||
return [
|
||||
'overview',
|
||||
'schedule',
|
||||
'print',
|
||||
'songs',
|
||||
'users',
|
||||
'notifications',
|
||||
'smtp-config',
|
||||
'playtimes',
|
||||
'request-times',
|
||||
'semesters',
|
||||
'blacklist',
|
||||
'site-config',
|
||||
'database',
|
||||
'api-keys'
|
||||
]
|
||||
case 'ADMIN':
|
||||
return ['overview', 'schedule', 'print', 'songs', 'users', 'data-analysis']
|
||||
case 'SONG_ADMIN':
|
||||
return ['schedule', 'print', 'songs']
|
||||
default:
|
||||
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'].includes(role)
|
||||
})
|
||||
|
||||
const isSongAdminOrAbove = computed(() => {
|
||||
const role = auth.user.value?.role
|
||||
return ['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(role)
|
||||
})
|
||||
|
||||
// 检查是否可以管理用户角色
|
||||
const canManageUserRole = (targetRole: string): boolean => {
|
||||
if (!auth.user.value) return false
|
||||
|
||||
// 只有超级管理员可以分配角色
|
||||
if (auth.user.value.role === 'SUPER_ADMIN') {
|
||||
return ['USER', 'SONG_ADMIN', 'ADMIN'].includes(targetRole)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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>
|
||||
): Promise<T> => {
|
||||
const requestKey = generateRequestKey(type, params)
|
||||
|
||||
// 清理过期的请求
|
||||
cleanupPendingRequests()
|
||||
|
||||
// 检查是否有相同的请求正在进行
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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', {
|
||||
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')
|
||||
|
||||
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')
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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: '',
|
||||
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: '/favicon.ico',
|
||||
schoolLogoHomeUrl: '',
|
||||
schoolLogoPrintUrl: '',
|
||||
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
|
||||
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(() => siteConfig.value.siteLogoUrl || '/favicon.ico')
|
||||
const schoolLogoHomeUrl = computed(() => siteConfig.value.schoolLogoHomeUrl || '')
|
||||
const schoolLogoPrintUrl = computed(() => siteConfig.value.schoolLogoPrintUrl || '')
|
||||
const description = computed(
|
||||
() => siteConfig.value.siteDescription || '校园广播站点歌系统 - 让你的声音被听见'
|
||||
)
|
||||
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,
|
||||
guidelines,
|
||||
icp,
|
||||
gonganNumber,
|
||||
enableReplayRequests,
|
||||
enableCollaborativeSubmission,
|
||||
enableSubmissionRemarks,
|
||||
smtpEnabled,
|
||||
fetchSiteConfig,
|
||||
initSiteConfig,
|
||||
refreshSiteConfig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getMusicUrl } from '~/utils/musicUrl'
|
||||
import { isBilibiliSong } from '~/utils/bilibiliSource'
|
||||
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 || isBilibiliSong(currentGlobalSong))) {
|
||||
audioPlayer.playSong(currentGlobalSong)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let url = null
|
||||
|
||||
// 如果有手动填入的 playUrl,优先使用它(统一处理所有平台)
|
||||
if (song.playUrl && song.playUrl.trim()) {
|
||||
url = song.playUrl.trim()
|
||||
} else if (!isBilibiliSong(song)) {
|
||||
// 如果不是哔哩哔哩歌曲且没有手动 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
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')
|
||||
}
|
||||
const url = `/api/songs${params.toString() ? '?' + params.toString() : ''}`
|
||||
|
||||
// API请求
|
||||
return await $fetch(url, {
|
||||
...getAuthConfig()
|
||||
})
|
||||
},
|
||||
requestParams
|
||||
)
|
||||
|
||||
// 正确解析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')
|
||||
}
|
||||
const url = `/api/songs/public${params.toString() ? '?' + params.toString() : ''}`
|
||||
|
||||
const response = await $fetch(url, {
|
||||
...getAuthConfig()
|
||||
})
|
||||
return response
|
||||
},
|
||||
requestParams
|
||||
)
|
||||
|
||||
// 确保每个排期的歌曲都有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
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
// 显示成功通知
|
||||
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 fetchSongs()
|
||||
|
||||
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 fetchSongs()
|
||||
|
||||
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 fetchSongs()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
// 更新本地数据状态
|
||||
const songIndex = songs.value.findIndex((s) => s.id === songId)
|
||||
if (songIndex !== -1) {
|
||||
songs.value[songIndex].replayRequested = false
|
||||
}
|
||||
|
||||
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')
|
||||
return response
|
||||
})
|
||||
|
||||
// 正确解析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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user