adding skip in cooldown and probing for media files

This commit is contained in:
RandyJC
2025-12-08 21:32:27 +01:00
parent ea49971609
commit 03b00b2499
6 changed files with 120 additions and 16 deletions

View File

@@ -73,6 +73,7 @@ export interface ClientToServerEvents {
"manager:kickPlayer": (_message: { gameId: string; playerId: string }) => void "manager:kickPlayer": (_message: { gameId: string; playerId: string }) => void
"manager:startGame": (_message: MessageGameId) => void "manager:startGame": (_message: MessageGameId) => void
"manager:abortQuiz": (_message: MessageGameId) => void "manager:abortQuiz": (_message: MessageGameId) => void
"manager:skipQuestionIntro": (_message: MessageGameId) => void
"manager:nextQuestion": (_message: MessageGameId) => void "manager:nextQuestion": (_message: MessageGameId) => void
"manager:showLeaderboard": (_message: MessageGameId) => void "manager:showLeaderboard": (_message: MessageGameId) => void
"manager:getQuizz": (_quizzId: string) => void "manager:getQuizz": (_quizzId: string) => void

View File

@@ -171,6 +171,10 @@ io.on("connection", (socket) => {
withGame(gameId, socket, (game) => game.nextRound(socket)) withGame(gameId, socket, (game) => game.nextRound(socket))
) )
socket.on("manager:skipQuestionIntro", ({ gameId }) =>
withGame(gameId, socket, (game) => game.skipQuestionIntro(socket))
)
socket.on("manager:showLeaderboard", ({ gameId }) => socket.on("manager:showLeaderboard", ({ gameId }) =>
withGame(gameId, socket, (game) => game.showLeaderboard()) withGame(gameId, socket, (game) => game.showLeaderboard())
) )

View File

@@ -293,6 +293,18 @@ class Game {
this.cooldown.active &&= false this.cooldown.active &&= false
} }
skipQuestionIntro(socket: Socket) {
if (this.manager.id !== socket.id) {
return
}
if (!this.started) {
return
}
this.abortCooldown()
}
async start(socket: Socket) { async start(socket: Socket) {
if (this.manager.id !== socket.id) { if (this.manager.id !== socket.id) {
return return
@@ -346,11 +358,11 @@ class Game {
this.broadcastStatus(STATUS.SHOW_QUESTION, { this.broadcastStatus(STATUS.SHOW_QUESTION, {
question: question.question, question: question.question,
image: question.image, image: question.image,
media: question.media, media: question.media,
cooldown: question.cooldown, cooldown: question.cooldown,
}) })
await sleep(question.cooldown) await this.startCooldown(question.cooldown)
if (!this.started) { if (!this.started) {
return return
@@ -362,7 +374,7 @@ class Game {
question: question.question, question: question.question,
answers: question.answers, answers: question.answers,
image: question.image, image: question.image,
media: question.media, media: question.media,
time: question.time, time: question.time,
totalPlayer: this.players.length, totalPlayer: this.players.length,
}) })

View File

@@ -65,6 +65,11 @@ const ManagerGame = () => {
break break
case STATUS.SHOW_QUESTION:
socket?.emit("manager:skipQuestionIntro", { gameId })
break
case STATUS.SELECT_ANSWER: case STATUS.SELECT_ANSWER:
socket?.emit("manager:abortQuiz", { gameId }) socket?.emit("manager:abortQuiz", { gameId })

View File

@@ -65,6 +65,7 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
const [uploading, setUploading] = useState<Record<number, boolean>>({}) const [uploading, setUploading] = useState<Record<number, boolean>>({})
const [deleting, setDeleting] = useState<Record<number, boolean>>({}) const [deleting, setDeleting] = useState<Record<number, boolean>>({})
const [refreshingLibrary, setRefreshingLibrary] = useState(false) const [refreshingLibrary, setRefreshingLibrary] = useState(false)
const [probing, setProbing] = useState<Record<number, boolean>>({})
useEvent("manager:quizzLoaded", (quizz) => { useEvent("manager:quizzLoaded", (quizz) => {
setDraft(quizz) setDraft(quizz)
@@ -257,12 +258,86 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
} }
setQuestionMedia(qIndex, nextMedia) setQuestionMedia(qIndex, nextMedia)
adjustTimingWithMedia(qIndex, nextMedia)
} }
const clearQuestionMedia = (qIndex: number) => { const clearQuestionMedia = (qIndex: number) => {
setQuestionMedia(qIndex, undefined) setQuestionMedia(qIndex, undefined)
} }
const probeMediaDuration = async (url: string, type: QuestionMedia["type"]) => {
if (!url || (type !== "audio" && type !== "video")) {
return null
}
try {
const el = document.createElement(type)
el.preload = "metadata"
el.src = url
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
el.onloadedmetadata = null
el.onerror = null
}
el.onloadedmetadata = () => {
cleanup()
resolve()
}
el.onerror = () => {
cleanup()
reject(new Error("Failed to load media metadata"))
}
})
const duration = el.duration
return Number.isFinite(duration) && duration > 0 ? duration : null
} catch (error) {
console.warn("Failed to probe media duration", error)
return null
}
}
const adjustTimingWithMedia = async (
qIndex: number,
media: QuestionMedia | undefined,
) => {
if (!draft || !media?.url || !media.type || media.type === "image") {
return
}
setProbing((prev) => ({ ...prev, [qIndex]: true }))
try {
const duration = await probeMediaDuration(media.url, media.type)
if (!duration || !draft) {
return
}
const rounded = Math.ceil(duration)
const buffer = 3
const minCooldown = rounded
const minAnswer = rounded + buffer
const question = draft.questions[qIndex]
const nextCooldown = Math.max(question.cooldown, minCooldown)
const nextTime = Math.max(question.time, minAnswer)
if (nextCooldown !== question.cooldown || nextTime !== question.time) {
updateQuestion(qIndex, {
cooldown: nextCooldown,
time: nextTime,
})
toast.success(
`Adjusted timing to media length (~${rounded}s, answers ${nextTime}s)`,
{ id: `timing-${qIndex}` },
)
}
} finally {
setProbing((prev) => ({ ...prev, [qIndex]: false }))
}
}
const handleMediaUpload = async (qIndex: number, file: File) => { const handleMediaUpload = async (qIndex: number, file: File) => {
if (!draft) return if (!draft) return
const question = draft.questions[qIndex] const question = draft.questions[qIndex]
@@ -296,6 +371,11 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
url: uploaded.url, url: uploaded.url,
fileName: uploaded.fileName, fileName: uploaded.fileName,
}) })
adjustTimingWithMedia(qIndex, {
type,
url: uploaded.url,
fileName: uploaded.fileName,
})
toast.success("Media uploaded") toast.success("Media uploaded")
refreshMediaLibrary() refreshMediaLibrary()
} catch (error) { } catch (error) {
@@ -509,18 +589,20 @@ const QuizEditor = ({ quizzList, onBack, onListUpdate }: Props) => {
</select> </select>
</label> </label>
<div className="flex flex-col gap-2 rounded-md border border-gray-200 p-3"> <div className="flex flex-col gap-2 rounded-md border border-gray-200 p-3">
<div className="flex items-center justify-between text-sm font-semibold text-gray-600"> <div className="flex items-center justify-between text-sm font-semibold text-gray-600">
<span>Media upload</span> <span>Media upload</span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{isUploading {isUploading
? "Uploading..." ? "Uploading..."
: refreshingLibrary : probing[qIndex]
? "Refreshing..." ? "Probing..."
: mediaFileName : refreshingLibrary
? "Stored" ? "Refreshing..."
: "Not saved"} : mediaFileName
</span> ? "Stored"
: "Not saved"}
</span>
</div> </div>
<input <input
type="file" type="file"

View File

@@ -66,7 +66,7 @@ export const MANAGER_SKIP_BTN = {
[STATUS.SHOW_ROOM]: "Start Game", [STATUS.SHOW_ROOM]: "Start Game",
[STATUS.SHOW_START]: null, [STATUS.SHOW_START]: null,
[STATUS.SHOW_PREPARED]: null, [STATUS.SHOW_PREPARED]: null,
[STATUS.SHOW_QUESTION]: null, [STATUS.SHOW_QUESTION]: "Skip",
[STATUS.SELECT_ANSWER]: "Skip", [STATUS.SELECT_ANSWER]: "Skip",
[STATUS.SHOW_RESULT]: null, [STATUS.SHOW_RESULT]: null,
[STATUS.SHOW_RESPONSES]: "Next", [STATUS.SHOW_RESPONSES]: "Next",