ghp / packages /server /src /controllers /playerController.ts
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
4.83 kB
import { EVENTS, ERROR_CODE, playerSeekSchema, playerSetModeSchema } from '@music-together/shared'
import type { TypedServer, TypedSocket } from '../middleware/types.js'
import { createWithPermission, defineAbilityForRoomUser } from '../middleware/withControl.js'
import { createWithRoom } from '../middleware/withRoom.js'
import { checkSocketRateLimit } from '../middleware/socketRateLimiter.js'
import { roomRepo } from '../repositories/roomRepository.js'
import * as playerService from '../services/playerService.js'
import * as roomService from '../services/roomService.js'
import { estimateCurrentTimeAt } from '../services/syncService.js'
import { logger } from '../utils/logger.js'
export function registerPlayerController(io: TypedServer, socket: TypedSocket) {
const withPermission = createWithPermission(io)
socket.on(
EVENTS.PLAYER_PLAY,
withPermission('play', 'Player', async (ctx, data) => {
if (!(await checkSocketRateLimit(ctx.socket))) return
const track = data?.track ?? ctx.room.currentTrack ?? ctx.room.queue[0]
if (!track) return
// Resume: same track already loaded and has stream URL → keep position
if (!data?.track && ctx.room.currentTrack?.id === track.id && ctx.room.currentTrack?.streamUrl) {
playerService.resumeTrack(ctx.io, ctx.roomId, ctx.socket)
return
}
await playerService.playTrackInRoom(ctx.io, ctx.roomId, track)
}),
)
socket.on(
EVENTS.PLAYER_PAUSE,
withPermission('pause', 'Player', (ctx) => {
playerService.pauseTrack(ctx.io, ctx.roomId, ctx.socket)
}),
)
socket.on(
EVENTS.PLAYER_SEEK,
withPermission('seek', 'Player', (ctx, data) => {
const parsed = playerSeekSchema.safeParse(data)
if (!parsed.success) return
playerService.seekTrack(ctx.io, ctx.roomId, parsed.data.currentTime, ctx.socket)
}),
)
// Conductor (hostId) auto-next bypasses CASL — system behavior, not manual user action.
// Non-conductor manual next still requires CASL permission check.
const withRoom = createWithRoom(io)
socket.on(
EVENTS.PLAYER_NEXT,
withRoom(async (ctx) => {
if (ctx.user.id !== ctx.room.hostId) {
const ability = defineAbilityForRoomUser(ctx.user.id, ctx.user.role)
if (!ability.can('next', 'Player')) {
ctx.socket.emit(EVENTS.ROOM_ERROR, {
code: ERROR_CODE.NO_PERMISSION,
message: '你没有权限执行此操作',
})
return
}
}
await playerService.playNextTrackInRoom(ctx.io, ctx.roomId, ctx.room.playMode)
}),
)
socket.on(
EVENTS.PLAYER_PREV,
withPermission('prev', 'Player', async (ctx) => {
await playerService.playPrevTrackInRoom(ctx.io, ctx.roomId)
}),
)
socket.on(
EVENTS.PLAYER_SET_MODE,
withPermission('set-mode', 'Player', (ctx, data) => {
const parsed = playerSetModeSchema.safeParse(data)
if (!parsed.success) return
ctx.room.playMode = parsed.data.mode
roomRepo.persist(ctx.roomId)
// Broadcast updated room state so all clients see the new play mode
ctx.io.to(ctx.roomId).emit(EVENTS.ROOM_STATE, roomService.toPublicRoomState(ctx.room))
logger.info(`房间 ${ctx.roomId} 的播放模式已切换为 ${parsed.data.mode}`, {
event: 'player.mode_changed',
roomId: ctx.roomId,
playMode: parsed.data.mode,
operatorId: ctx.user.id,
operator: ctx.user.nickname,
})
}),
)
// ---------------------------------------------------------------------------
// NTP clock synchronisation – reply instantly with server time
// ---------------------------------------------------------------------------
socket.on(EVENTS.NTP_PING, (data) => {
try {
// Store client-reported RTT for adaptive scheduling delay
if (data?.lastRttMs != null && data.lastRttMs > 0 && data.lastRttMs <= 10_000) {
roomRepo.setSocketRTT(socket.id, data.lastRttMs)
}
socket.emit(EVENTS.NTP_PONG, {
clientPingId: data?.clientPingId ?? 0,
serverTime: Date.now(),
})
} catch (err) {
logger.error('NTP_PING handler error', err, { socketId: socket.id })
}
})
socket.on(EVENTS.PLAYER_SYNC_REQUEST, () => {
try {
const mapping = roomRepo.getSocketMapping(socket.id)
if (!mapping) return
const room = roomRepo.get(mapping.roomId)
if (!room) return
const serverTimestamp = Date.now()
socket.emit(EVENTS.PLAYER_SYNC_RESPONSE, {
currentTime: estimateCurrentTimeAt(mapping.roomId, serverTimestamp),
isPlaying: room.playState.isPlaying,
serverTimestamp,
})
} catch (err) {
logger.error('PLAYER_SYNC_REQUEST handler error', err, {
socketId: socket.id,
})
}
})
}