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
14 changes: 12 additions & 2 deletions src/html/@client/mention-notification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { playSound, stopSound } from './play-sound'

const MENTION_PREFIX = '★ '
const mentionSoundId = 'sound-mention'

// Only one tab plays the mention sound, and every tab shows the star — so reading the
// chat in one tab has to clear the others too. It matters most for the sound: that
// clip is ~6s, where the rest are ~1.5s.
const channel = 'BroadcastChannel' in globalThis ? new BroadcastChannel('tf2pickup-mention') : null

let hasMention = false

Expand Down Expand Up @@ -34,25 +40,29 @@ function setMention(event: CustomEvent<{ volume: number }>) {
}

hasMention = true
playSound(document.getElementById('sound-mention'), event.detail.volume)
playSound(document.getElementById(mentionSoundId), event.detail.volume)

chatTabButton()?.classList.add('has-mention')
applyMentionTitle()
}

function clearMention() {
hasMention = false
stopSound(document.getElementById('sound-mention'))
stopSound(document.getElementById(mentionSoundId))
chatTabButton()?.classList.remove('has-mention')
clearMentionTitle()
}

function maybeClear() {
if (hasMention && isUserReadingChat()) {
clearMention()
channel?.postMessage('cleared')
}
}

// BroadcastChannel does not echo to the sender, so this cannot loop back.
channel?.addEventListener('message', clearMention)

// Re-apply prefix if SetTitle overwrites document.title while mentioned
const titleObserver = new MutationObserver(() => {
if (hasMention) {
Expand Down
21 changes: 21 additions & 0 deletions src/html/@client/play-exclusively.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Every open tab holds its own websocket, so without coordination a player with N
// tabs hears the same notification N times. Tabs race for a Web Lock keyed by the
// sound and only the winner plays.
//
// The lock is held briefly after playing so that tabs reacting to the same websocket
// message lose the race, while a notification arriving later still gets through.
const holdMs = 250

export async function playExclusively(soundId: string, play: () => void) {
// Older Safari has no Web Locks; fall back to every tab playing, as before.
if (!('locks' in navigator)) {
play()
return
}

await navigator.locks.request(soundId, { ifAvailable: true }, async lock => {
if (!lock) return
play()
await new Promise(resolve => setTimeout(resolve, holdMs))
})
}
12 changes: 8 additions & 4 deletions src/html/@client/play-sound.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import htmx from './htmx'
import { Howl, Howler } from 'howler'
import { playExclusively } from './play-exclusively'

interface HtmxNodeInternalData {
sound?: Howl
Expand All @@ -19,19 +20,22 @@ function loadSound(element: Element) {
internalData.sound = new Howl({ src: [src] })
}

async function resumeAndPlay(sound: Howl) {
async function resumeAndPlay(sound: Howl, soundId: string) {
if (Howler.ctx.state === 'suspended') {
await Howler.ctx.resume().catch(console.warn)
}
sound.play()
// Resuming first means a tab that cannot unlock its audio context drops out before
// racing for the sound, instead of winning it and swallowing the notification.
if (Howler.ctx.state !== 'running') return
await playExclusively(soundId, () => sound.play())
}

export function playSound(element: Element | null, volume?: number) {
if (!element) return
if (!element?.id) return
const sound = api.getInternalData(element).sound
if (!sound) return
if (volume !== undefined) sound.volume(volume)
void resumeAndPlay(sound)
void resumeAndPlay(sound, element.id)
}

export function stopSound(element: Element | null) {
Expand Down
Loading