Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions client/play/TossupClient.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import addTossupGameCard from './tossups/add-tossup-game-card.js';
import QuestionClient from './QuestionClient.js';
import audio from './audio.js';
import audioReader from './audio/AudioReaderController.js';
import { MODE_ENUM } from '../../shared/constants.js';

export const TossupClientMixin = (ClientClass) => class extends ClientClass {
Expand All @@ -12,13 +13,28 @@ export const TossupClientMixin = (ClientClass) => class extends ClientClass {
onmessage (message) {
const data = JSON.parse(message);
switch (data.type) {
case 'buzz': return this.buzz(data);
case 'end-current-tossup': return this.endCurrentTossup(data);
case 'buzz': {
audioReader.onBuzz();
return this.buzz(data);
}
case 'end-current-tossup': {
audioReader.onEndQuestion();
return this.endCurrentTossup(data);
}
case 'give-tossup-answer': return this.giveTossupAnswer(data);
case 'pause': return this.pause(data);
case 'reveal-tossup-answer': return this.revealTossupAnswer(data);
case 'pause': {
audioReader.onPause(data.paused, this.room, this.socket);
return this.pause(data);
}
case 'reveal-tossup-answer': {
if (audioReader.tryDeferReveal(() => this.revealTossupAnswer(data))) return;
return this.revealTossupAnswer(data);
}
case 'set-reading-speed': return this.setReadingSpeed(data);
case 'start-next-tossup': return this.startNextTossup(data);
case 'start-next-tossup': {
audioReader.onStartNextTossup(data.tossup, this.room.settings.readingSpeed, this.room, this.socket);
return this.startNextTossup(data);
}
case 'toggle-powermark-only': return this.togglePowermarkOnly(data);
case 'toggle-rebuzz': return this.toggleRebuzz(data);
case 'toggle-stop-on-power': return this.toggleStopOnPower(data);
Expand Down Expand Up @@ -76,6 +92,11 @@ export const TossupClientMixin = (ClientClass) => class extends ClientClass {
document.getElementById('reading-speed-display').textContent = readingSpeed;
}

timerUpdate ({ timeRemaining }) {
if (audioReader.shouldSuppressTimer()) return;
super.timerUpdate({ timeRemaining });
}

startNextTossup ({ tossup, packetLength }) {
this.startNextQuestion({ question: tossup, packetLength });
document.getElementById('buzz').textContent = 'Buzz';
Expand All @@ -98,6 +119,7 @@ export const TossupClientMixin = (ClientClass) => class extends ClientClass {
}

updateQuestion ({ word }) {
if (audioReader.isReading()) return;
if (word === '(*)' || word === '[*]' || word === '(+)') { return; }
document.getElementById('question').innerHTML += word + ' ';
}
Expand Down Expand Up @@ -140,6 +162,11 @@ function attachEventListeners (room, socket) {
this.blur();
socket.sendToServer({ type: 'toggle-stop-on-power', stopOnPower: this.checked });
});

document.getElementById('toggle-audio-reader')?.addEventListener('click', function () {
this.blur();
audioReader.onToggleChanged(this.checked);
});
}

const TossupClient = TossupClientMixin(QuestionClient);
Expand Down
6 changes: 6 additions & 0 deletions client/play/audio.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { questionAudioPlayer } from './audio/QuestionAudioPlayer.js';

export default class audio {
static soundEffects = window.localStorage.getItem('sound-effects') === 'true';
static buzz = new window.Audio('https://qbreader.github.io/website/buzz.mp3');
static correct = new window.Audio('https://qbreader.github.io/website/correct.mp3');
static incorrect = new window.Audio('https://qbreader.github.io/website/incorrect.mp3');
static power = new window.Audio('https://qbreader.github.io/website/power.mp3');
static questionReader = questionAudioPlayer;
}

export { questionAudioPlayer };

217 changes: 217 additions & 0 deletions client/play/audio/AudioReaderController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* AudioReaderController
*
* Encapsulates all state and logic for the audio question reader feature.
* TossupClient.js calls the thin public API below; no state lives in TossupClient.
*/

import audio from '../audio.js';

class AudioReaderController {
constructor () {
/** Toggle state queued for the *next* question (avoids mid-question desync). */
this.pendingEnable = false;

/** True once the current question's audio has fully finished playing. */
this.audioFinished = false;

/** True while the audio reader is actively playing and driving word reveal. */
this.isReadingActive = false;

/**
* Callback that performs the full reveal (including any subclass UI updates).
* Stored when the server's reveal-tossup-answer arrives before audio/countdown ends.
* @type {(() => void) | null}
*/
this._pendingRevealCallback = null;

/** setInterval handle for the client-side 5-second dead-time countdown. */
this._deadTimerInterval = null;

/** Remaining dead time in tenths of a second. */
this.deadTimeRemaining = 50;

/** True if the post-question countdown is currently paused. */
this.countdownPaused = false;
}

// ---------------------------------------------------------------------------
// Public API — called by TossupClient hooks
// ---------------------------------------------------------------------------

/**
* Call at the start of each new tossup.
* Resets state, applies the pending toggle, and starts audio playback.
*/
onStartNextTossup (tossup, readingSpeed, room, socket) {
this._clearDeadTimer();
this.audioFinished = false;
this.isReadingActive = false;
this._pendingRevealCallback = null;
this.countdownPaused = false;

// Apply the pending enable state — toggle takes effect per question.
audio.questionReader.enabled = this.pendingEnable;

if (!audio.questionReader.enabled || !tossup?.question) return;

this.isReadingActive = true;

// Immediately pause the simulated room's timeout loop so it doesn't run ahead of the audio.
clearTimeout(room.timeoutID);
room.paused = true;

// Clear question display — audio drives word-by-word reveal.
document.getElementById('question').innerHTML = '';

audio.questionReader.onWordCallback = (_index, word) => {
room.wordIndex = _index + 1;
document.getElementById('question').innerHTML += word;
};

audio.questionReader.onEndedCallback = () => {
this.audioFinished = true;
room.wordIndex = room.questionSplit.length;

this.deadTimeRemaining = 50;
this._startCountdown(socket);
};

audio.questionReader.loadAndPlay(tossup.question, readingSpeed);
}

_startCountdown (socket) {
clearInterval(this._deadTimerInterval);
this._deadTimerInterval = setInterval(() => {
this.deadTimeRemaining--;
const seconds = Math.floor(this.deadTimeRemaining / 10);
const tenths = this.deadTimeRemaining % 10;
const timerFace = document.querySelector('.timer .face');
const timerFraction = document.querySelector('.timer .fraction');
if (timerFace) timerFace.textContent = seconds;
if (timerFraction) timerFraction.textContent = '.' + tenths;

if (this.deadTimeRemaining <= 0) {
this._clearDeadTimer();
socket.sendToServer({ type: 'reveal-tossup-answer' });
}
}, 100);
}

/**
* Call when any player buzzes in.
* Stops audio immediately and lets the server answer-timer take over.
*/
onBuzz () {
this._clearDeadTimer();
this.audioFinished = true; // allow server timer-update messages through
this.isReadingActive = false; // fall back to server text updates if room continues (e.g. neg/rebuzz)
audio.questionReader.stop();
}

/**
* Call when the current tossup ends (skip, next, etc.).
* Fully resets audio state.
*/
onEndQuestion () {
this._clearDeadTimer();
audio.questionReader.stop();
this.isReadingActive = false;
this._pendingRevealCallback = null;
this.audioFinished = false;
}

/**
* Call when the room is paused or unpaused.
* @param {boolean} paused
*/
onPause (paused, room, socket) {
if (paused) {
audio.questionReader.pause();
clearTimeout(room.timeoutID);
room.paused = true;

if (this._deadTimerInterval !== null) {
clearInterval(this._deadTimerInterval);
this._deadTimerInterval = null;
this.countdownPaused = true;
}
} else {
audio.questionReader.resume();
clearTimeout(room.timeoutID);
room.paused = true;

if (this.countdownPaused) {
this.countdownPaused = false;
this._startCountdown(socket);
}
}
}

/**
* Returns true when server `timer-update` messages should be suppressed
* (i.e. audio is reading OR client countdown is still running).
*/
shouldSuppressTimer () {
return audio.questionReader.enabled && (!this.audioFinished || this._deadTimerInterval !== null || this.countdownPaused);
}

/**
* If audio is still reading or countdown is running, stores the full reveal
* callback for deferred execution.
* @param {() => void} revealCallback Zero-arg function that performs the full reveal.
* @returns {boolean} true if the reveal was deferred (caller should return early)
*/
tryDeferReveal (revealCallback) {
if (!audio.questionReader.enabled) return false;
// Defer while audio is still reading OR while the client countdown is running.
if (!this.audioFinished || this._deadTimerInterval !== null || this.countdownPaused) {
this._pendingRevealCallback = revealCallback;
return true;
}
return false;
}

/**
* Returns true while audio is active and driving the question display.
* Used by updateQuestion to skip server word-tick updates.
*/
isReading () {
return audio.questionReader.enabled && this.isReadingActive;
}

/**
* Call when the toggle switch changes.
* Turning OFF takes effect immediately; turning ON is deferred to next question.
* @param {boolean} checked
*/
onToggleChanged (checked) {
this.pendingEnable = checked;
if (!checked) {
this._clearDeadTimer();
audio.questionReader.stop();
audio.questionReader.enabled = false;
this.audioFinished = false;
this.isReadingActive = false;
}
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

_clearDeadTimer () {
clearInterval(this._deadTimerInterval);
this._deadTimerInterval = null;
this.countdownPaused = false;
}

_revealPending () {
if (!this._pendingRevealCallback) return;
const cb = this._pendingRevealCallback;
this._pendingRevealCallback = null;
cb();
}
}

export default new AudioReaderController();
Loading