File size: 4,828 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | 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,
})
}
})
}
|