6 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 dd44e76970 Kick: the points plaque follows Kick's push, and badge pictures are drawn beside names
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 23:11:26 -04:00
SalastilandClaude Sonnet 5.5 9b29285f71 nobilis: roster test fix
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:54:56 -04:00
SalastilandClaude Sonnet 5.5 987644ebf1 Kick: the recently-talking count keeps rising past what is listed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:51:35 -04:00
SalastilandClaude Sonnet 5.5 8e3d1100c9 Kick: threads from replies, and removed messages struck through, left out or replaced as chosen
A reply shows a button into its thread; the thread panel gathers every reply that names the same root, and
sending in it works even when the first message has left the scrollback. Messages Kick removes stay in the
log, and Settings -> Kick chooses Delete, Strikethrough (the default, in red) or a notice in place of the text.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:17:34 -04:00
SalastilandClaude Sonnet 5.5 a7ef798df5 Kick video: no prefetch guesses for streams whose segments differ in length, and a longer lead for long segments
A broadcaster that sends its own segments (3 to 5 s, not IVS's even 2) made the segments still being
made the wrong length, and the picture stopped in the gaps about every 8 s. Prefetch is now only taken
where the segments are alike, and the player stays a segment and a half behind where they are long.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 20:24:06 -04:00
SalastilandClaude Sonnet 5.5 3aff255375 Kick video: about 3.5 s behind instead of 20, no rebuild on every address, a resolution menu and live captions
The player was rebuilt whenever the channel payload's address changed, which was every fetch (a new token
each time): that was the stutter. hls.js counted its live edge in 6 s target durations and ignored IVS's
prefetch segments: that was the lag. Both are fixed, prefetch segments no longer feed the bandwidth
estimate, and the address now comes from the lighter playback-url endpoint with its ad opt-out kept.
Resolution and CC buttons sit in the player's controls.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 19:57:03 -04:00
24 changed files with 1255 additions and 83 deletions
+1 -1
Submodule nobilis updated: 289b5db54a...4c17be082b
+99
View File
@@ -0,0 +1,99 @@
// The page the Kick video probe plays in: one hls.js player per configuration, measured while it plays.
import Hls from 'hls.js'
import { ivsAbr, ivsLoader } from '../../src/renderer/src/lib/ivsplaylist'
interface Config {
name: string
url: string
seconds: number
prefetch: number
hls: Record<string, unknown>
cap?: number
}
declare global {
interface Window {
runProbe: (config: Config) => Promise<Record<string, unknown>>
}
}
window.runProbe = async (cfg) => {
// How far this clock is from the server's, from the time the playlist says it was made at.
const sent = Date.now()
const text = await (await fetch(cfg.url)).text()
const received = Date.now()
const server = Number(/SERVER-TIME",VALUE="([0-9.]+)"/.exec(text)?.[1] ?? NaN)
const offset = server * 1000 - (sent + received) / 2
return new Promise((resolve) => {
const video = document.getElementById('v') as HTMLVideoElement
const hls = new Hls({ ...cfg.hls, ...(cfg.prefetch > 0 ? { loader: ivsLoader(cfg.prefetch), abrController: ivsAbr() } : {}) } as never)
const latency: number[] = []
const levels = new Set<string>()
const fatal: string[] = []
let stalls = 0
let waits = 0
let switches = 0
let waitStart = 0
let waited = 0
let firstFrame = 0
const began = Date.now()
video.muted = true
video.onwaiting = (): void => {
waits++
if (!waitStart && firstFrame) waitStart = Date.now()
}
video.onplaying = (): void => {
if (!firstFrame) firstFrame = Date.now() - began
if (waitStart) {
waited += Date.now() - waitStart
waitStart = 0
}
}
hls.on(Hls.Events.ERROR, (_e, d) => {
if (d.details === 'bufferStalledError') stalls++
if (d.fatal) fatal.push(d.details)
})
hls.on(Hls.Events.LEVEL_SWITCHED, (_e, d) => {
switches++
levels.add(`${hls.levels[d.level]?.height}p`)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (cfg.cap) {
const best = hls.levels.reduce((b, l, i) => (l.height <= (cfg.cap ?? 720) && l.height >= (hls.levels[b]?.height ?? 0) ? i : b), -1)
if (best >= 0) hls.autoLevelCapping = best
}
void video.play().catch(() => {})
})
hls.attachMedia(video)
hls.loadSource(cfg.url)
const timer = setInterval(() => {
const at = hls.playingDate
if (at && !video.paused && firstFrame) latency.push((Date.now() + offset - at.getTime()) / 1000)
if (Date.now() - began < cfg.seconds * 1000) return
clearInterval(timer)
const q = video.getVideoPlaybackQuality()
const sorted = [...latency].sort((a, b) => a - b)
const pick = (p: number): number => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? NaN
const settled = sorted.slice(Math.floor(sorted.length * 0.2))
const mean = settled.length ? settled.reduce((a, b) => a + b, 0) / settled.length : NaN
hls.destroy()
resolve({
name: cfg.name,
startedInMs: firstFrame,
samples: latency.length,
latencyMin: +Math.min(...latency).toFixed(2),
latencyMax: +Math.max(...latency).toFixed(2),
latencyMedian: +pick(0.5).toFixed(2),
latencyP95: +pick(0.95).toFixed(2),
latencyMeanAfterSettling: +mean.toFixed(2),
rebufferSeconds: +(waited / 1000).toFixed(2),
waits,
stallErrors: stalls,
dropped: `${q.droppedVideoFrames}/${q.totalVideoFrames}`,
levelSwitches: switches,
levels: [...levels].join(','),
fatal: fatal.join(',')
})
}, 1000)
})
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Plays a live Kick stream with several hls.js configurations, one after the other, in the app's own
* Chromium, and says for each how far behind the broadcaster it ran, how often it stopped to wait,
* and how many frames it dropped. Nothing is signed in: the playlist is public.
*
* node scripts/probe-kick-video.mjs [channel] [seconds] (defaults: spreen, 75)
*/
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 75)
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'kick-video-'))
execFileSync(path.join(root, 'node_modules/.bin/esbuild'), [path.join(root, 'scripts/lib/hlsprobe.ts'), '--bundle', '--format=iife', `--outfile=${path.join(work, 'probe.js')}`, '--log-level=error'])
fs.writeFileSync(path.join(work, 'probe.html'), '<!doctype html><body style="margin:0"><video id="v" width="1280" height="720" autoplay muted playsinline></video><script src="probe.js"></script></body>')
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const playlist = async () => {
const r = await fetch(`https://kick.com/api/v2/channels/${channel}/playback-url`, { headers: { 'User-Agent': UA, Accept: 'application/json' } })
return (await r.json()).data
}
const base = { capLevelToPlayerSize: true }
const configs = [
{ name: 'as it is now (3 target durations, no prefetch)', prefetch: 0, hls: { ...base, liveSyncDurationCount: 3 } },
{ name: 'seconds: sync 4s, no prefetch', prefetch: 0, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 3s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
{ name: 'prefetch 1, sync 3s, catch-up 1.1', prefetch: 1, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 8, maxLiveSyncPlaybackRate: 1.1 } },
{ name: 'prefetch 2, sync 5s, catch-up 1.1', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10, maxLiveSyncPlaybackRate: 1.1 } }
]
if (process.env.LIST === 'steady') {
configs.length = 0
configs.push(
{ name: 'prefetch 2, sync 3s', prefetch: 2, hls: { ...base, liveSyncDuration: 3, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 4s', prefetch: 2, hls: { ...base, liveSyncDuration: 4, liveMaxLatencyDuration: 10 } },
{ name: 'prefetch 2, sync 5s', prefetch: 2, hls: { ...base, liveSyncDuration: 5, liveMaxLatencyDuration: 10 } }
)
}
const only = process.env.ONLY ? process.env.ONLY.split(',').map(Number) : null
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Page.navigate', { url: `file://${path.join(work, 'probe.html')}` })
await sleep(1500)
for (const [i, cfg] of configs.entries()) {
if (only && !only.includes(i)) continue
const url = await playlist()
const out = await page.evaluate(`window.runProbe(${JSON.stringify({ ...cfg, url, seconds, cap: 720 })})`)
console.log(JSON.stringify(out))
}
} finally {
await stop()
}
+19
View File
@@ -0,0 +1,19 @@
import fs from 'node:fs'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (c) => page.evaluate(c)
try {
try { const cl = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString()); for (const c of cl.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`]) } catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const a = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: a.accountId, name: 'spreen' })
const id = `${a.accountId}|spreen`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await sleep(20000)
console.log(await ev(`JSON.stringify({ images: document.querySelectorAll('.sender-badge-image').length, loaded: [...document.querySelectorAll('.sender-badge-image')].filter((i) => i.complete && i.naturalWidth > 0).length, words: [...document.querySelectorAll('.sender-badge')].map((b) => b.textContent).slice(0, 8), titles: [...new Set([...document.querySelectorAll('.sender-badge-image')].map((i) => i.title))].slice(0, 8) })`))
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(process.env.HOME + '/.local/share/moho-dev/shots/kick-badges.png', Buffer.from(data, 'base64'))
} finally { await stop() }
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Watches a real live Kick stream in a scratch moho with no account signed in, and checks what the
* player does: that it plays, how far behind it runs, that the resolution menu changes the picture, that
* the CC button shows the captions, and that the player is built once and not again as the address is
* renewed. Photographs the stream, each menu and the captions.
*
* node scripts/shot-kick-stream.mjs <output directory> [channel] [idle seconds]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'spreen'
const idle = Number(process.argv[4] ?? 60)
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
const click = (selector) => ev(`(() => { const b = document.querySelector(${JSON.stringify(selector)}); if (!b || b.disabled) return false; b.click(); return true })()`)
const video = (expr) => ev(`(() => { const v = document.querySelector('video.video-tile-picture'); return v ? (${expr}) : null })()`)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
say('account', JSON.stringify(added))
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
let live = null
for (let i = 0; i < 40 && !live?.playbackUrl; i++) {
await sleep(1000)
live = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}] || null`)
}
say('stream', JSON.stringify({ live: live?.live, hasUrl: !!live?.playbackUrl, expires: live?.playbackExpires, livestreamId: live?.livestreamId }))
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if ((await video('v.readyState >= 3 && v.currentTime > 1')) === true) break
await sleep(500)
}
say('playing', await video('({ t: v.currentTime, w: v.videoWidth, h: v.videoHeight, paused: v.paused })').then(JSON.stringify))
await sleep(4000)
await shot('stream-playing.png')
// Resolution.
say('quality menu', await click('button[title^="Resolution"]'))
await sleep(400)
say('quality choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
await shot('stream-quality-menu.png')
for (const want of ['480p', 'Auto']) {
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim().startsWith(${JSON.stringify(want)})); b && b.click() })()`)
await sleep(8000)
say(`after choosing ${want}`, JSON.stringify(await video('({ h: v.videoHeight, t: +v.currentTime.toFixed(1) })')), JSON.stringify(await ev(`document.querySelector('button[title^="Resolution"]')?.title`)))
if (want === '480p') await click('button[title^="Resolution"]')
}
// Captions.
say('captions button', await ev(`JSON.stringify({ title: document.querySelector('button[title*="aptions"]')?.title, disabled: document.querySelector('button[title*="aptions"]')?.disabled })`))
say('captions menu', await click('button[title*="aptions"]'))
await sleep(400)
say('caption choices', JSON.stringify(await ev(`[...document.querySelectorAll('.stream-menu .context-menu-item')].map((b) => b.textContent.trim())`)))
await shot('stream-captions-menu.png')
await ev(`(() => { const b = [...document.querySelectorAll('.stream-menu .context-menu-item')].find((x) => x.textContent.trim() === 'English'); b && b.click() })()`)
await sleep(15000)
say('text tracks', await video(`JSON.stringify([...v.textTracks].map((t) => ({ lang: t.language, mode: t.mode, cues: t.cues ? t.cues.length : 0 })))`))
await shot('stream-captions-on.png')
say('captions button now', await ev(`document.querySelector('button[title*="aptions"]')?.title`))
// Built once.
const sources = new Set()
const began = Date.now()
while (Date.now() - began < idle * 1000) {
sources.add(await video('v.src'))
await sleep(1000)
}
const later = await ev(`window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]`)
say(`over ${idle}s: ${sources.size} player source(s); address expires`, later?.playbackExpires, 'changed:', later?.playbackUrl !== live?.playbackUrl)
say('quality', JSON.stringify(await video('(() => { const q = v.getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })()')))
} finally {
await stop()
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Reads a busy live Kick channel in a scratch moho and checks what replies and removals look like: that a
* reply carries its thread, that the thread opens from it, and that a removed message is struck through in
* red, left out or replaced by a notice according to Settings -> Kick. Removal is the one thing it cannot
* wait for, so one is injected through the store the way the daemon's event would arrive.
*
* node scripts/shot-kick-threads.mjs <output directory> [channel]
*/
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const out = process.argv[2] ?? '.'
const channel = process.argv[3] ?? 'xqc'
fs.mkdirSync(out, { recursive: true })
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const say = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a)
const ev = (code) => page.evaluate(code)
const shot = async (name) => {
const { data } = await page.call('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(out, name), Buffer.from(data, 'base64'))
}
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
await page.call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false })
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
say('join', JSON.stringify(await rpc('joinBuffer', { accountId: added.accountId, name: channel })))
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
// Replies, as the daemon read them: each should name the thread it belongs to.
let replies = []
for (let i = 0; i < 120 && replies.length < 2; i++) {
await sleep(1000)
replies = JSON.parse(await ev(`JSON.stringify((window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []).filter((m) => m.replyTo).map((m) => ({ id: m.id, to: m.replyTo.id, root: m.replyTo.threadRoot, thread: m.replyTo.thread })))`))
}
say('replies seen', replies.length, JSON.stringify(replies.slice(0, 4)))
say('every reply has a thread root', replies.every((r) => r.root))
await sleep(1500)
await shot('kick-replies.png')
await ev(`document.querySelector('.reply-preview')?.scrollIntoView({ block: 'center' })`)
await sleep(500)
await shot('kick-reply-row.png')
// Open a thread from a reply.
say('thread button', await ev(`(() => { const b = document.querySelector('.reply-thread-open'); if (!b) return false; b.click(); return true })()`))
await sleep(1200)
say('thread panel', await ev(`JSON.stringify({ open: !!document.querySelector('.thread-pane'), rows: document.querySelectorAll('.thread-pane .message-row').length, title: document.querySelector('.thread-header')?.textContent })`))
await shot('kick-thread.png')
await ev(`window.__mohoShots.store.closeThreadPanel()`)
await sleep(300)
// A removal, as the daemon would announce it, in each of the three ways.
const victim = await ev(`(() => { const ms = window.__mohoShots.state().messagesByBuffer[${JSON.stringify(id)}] || []; const m = [...ms].reverse().find((x) => x.kind === 'chat' && x.body && !x.replyTo); return m && m.id })()`)
await ev(`window.__mohoShots.store.handleEvent({ event: 'messageModerated', data: { bufferId: ${JSON.stringify(id)}, id: ${JSON.stringify(victim)}, moderated: { ai: true, rules: ['hate'] } } })`)
await sleep(500)
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
await sleep(500)
say('removed row (default: strike)', await ev(`(() => { const r = document.querySelector('.message-row.removed .message-body'); if (!r) return null; const s = getComputedStyle(r); return JSON.stringify({ text: r.textContent.slice(0, 40), color: s.color, line: s.textDecorationLine, title: r.title }) })()`))
await shot('kick-removed-strike.png')
for (const [label, name] of [['Replace with notice', 'replace'], ['Delete', 'remove'], ['Strikethrough', 'strike']]) {
await ev(`window.__mohoShots.store.setActivePanel('settings')`)
await sleep(500)
await ev(`[...document.querySelectorAll('.settings-rail-item, .settings-rail button')].find((b) => b.textContent.trim().startsWith('Kick'))?.click()`)
await sleep(500)
say('setting', label, await ev(`(() => { const b = [...document.querySelectorAll('.setting-segmented button')].find((x) => x.textContent.trim() === ${JSON.stringify(label)}); if (!b) return false; b.click(); return true })()`))
await sleep(300)
if (name === 'replace') await shot('kick-settings.png')
await ev(`window.__mohoShots.store.setActivePanel('')`)
await sleep(500)
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await sleep(700)
say(name, await ev(`JSON.stringify({ removedRows: document.querySelectorAll('.message-row.removed').length, text: document.querySelector('.message-row.removed .message-body')?.textContent.slice(0, 50) })`))
await ev(`document.querySelector('.message-row.removed')?.scrollIntoView({ block: 'center' })`)
await sleep(300)
if (name !== 'strike') await shot(`kick-removed-${name}.png`)
}
} finally {
await stop()
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* Reads a busy live Kick channel in a scratch moho and reports how the chatters count grows against the
* names listed, and whether drawing a long list holds the window up.
*
* node scripts/watch-kick-roster.mjs [channel] [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 150)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
const id = `${added.accountId}|${channel}`
await ev(`window.__mohoShots.store.selectBuffer(${JSON.stringify(id)})`)
await ev(`(() => { window.__long = 0; window.__longMs = 0; new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__long++; window.__longMs += e.duration } }).observe({ entryTypes: ['longtask'] }) })()`)
const began = Date.now()
while (Date.now() - began < seconds * 1000) {
await sleep(15000)
console.log(await ev(`JSON.stringify({ header: document.querySelector('.nicklist-header span')?.textContent, rows: document.querySelectorAll('.nicklist .member-row, .nicklist-scroll > div > div:not(.nicklist-group)').length, state: (() => { const s = window.__mohoShots.state(); return { listed: (s.presenceByBuffer[${JSON.stringify(id)}] || []).length, total: s.presenceTotalByBuffer[${JSON.stringify(id)}] } })(), longTasks: window.__long, longMs: Math.round(window.__longMs) })`))
}
} finally {
await stop()
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Watches a live Kick stream in a scratch moho and reports every time the picture waits for data: when,
* for how long, and how much was buffered ahead of the playhead just before.
*
* node scripts/watch-kick-stalls.mjs [channel] [seconds]
*/
import { execFileSync } from 'node:child_process'
import { launchApp, sleep } from './lib/harness.mjs'
const channel = process.argv[2] ?? 'spreen'
const seconds = Number(process.argv[3] ?? 90)
const { page, stop } = await launchApp({ env: { MOHO_UI_SHOTS: '1' } })
const ev = (code) => page.evaluate(code)
try {
try {
const clients = JSON.parse(execFileSync('hyprctl', ['clients', '-j']).toString())
for (const c of clients.filter((c) => /electron|moho/i.test(c.class + c.title))) execFileSync('hyprctl', ['dispatch', 'movetoworkspacesilent', `2,address:${c.address}`])
} catch {}
await sleep(2500)
const rpc = (m, p) => ev(`window.moho.rpc(${JSON.stringify(m)}, ${JSON.stringify(p)}).catch((e) => ({ error: String(e.message || e) }))`)
const added = await rpc('addKickAccount', {})
await rpc('joinBuffer', { accountId: added.accountId, name: channel })
const id = `${added.accountId}|${channel}`
for (let i = 0; i < 40; i++) {
await sleep(1000)
if (await ev(`!!window.__mohoShots.state().kickStreams[${JSON.stringify(id)}]?.playbackUrl`)) break
}
await ev(`(async () => { await window.__mohoShots.store.selectBuffer(${JSON.stringify(id)}); window.__mohoShots.store.watchStream(${JSON.stringify(id)}) })()`)
for (let i = 0; i < 40; i++) {
if (await ev(`!!document.querySelector('video.video-tile-picture')`)) break
await sleep(500)
}
await ev(`(() => {
const v = document.querySelector('video.video-tile-picture')
const log = (window.__stalls = { events: [], samples: [], t0: performance.now() })
const at = () => +((performance.now() - log.t0) / 1000).toFixed(1)
const ahead = () => { const b = v.buffered; return b.length ? +(b.end(b.length - 1) - v.currentTime).toFixed(2) : 0 }
for (const n of ['waiting', 'stalled', 'playing', 'seeking', 'seeked', 'ratechange']) v.addEventListener(n, () => log.events.push({ t: at(), n, ahead: ahead(), rate: v.playbackRate, ct: +v.currentTime.toFixed(2) }))
setInterval(() => log.samples.push({ t: at(), ahead: ahead(), ct: +v.currentTime.toFixed(2), rate: v.playbackRate }), 500)
})()`)
await sleep(seconds * 1000)
const r = JSON.parse(await ev('JSON.stringify(window.__stalls)'))
const waits = r.events.filter((e) => e.n === 'waiting')
console.log(`${waits.length} waiting events in ${seconds}s`)
for (const e of r.events) console.log(JSON.stringify(e))
const a = r.samples.map((s) => s.ahead)
console.log('buffer ahead: min', Math.min(...a), 'max', Math.max(...a), 'mean', +(a.reduce((x, y) => x + y, 0) / a.length).toFixed(2))
console.log('rates seen', [...new Set(r.samples.map((s) => s.rate))])
console.log('quality', await ev(`JSON.stringify((() => { const q = document.querySelector('video.video-tile-picture').getVideoPlaybackQuality(); return { dropped: q.droppedVideoFrames, total: q.totalVideoFrames } })())`))
} finally {
await stop()
}
+68
View File
@@ -1115,6 +1115,35 @@ button.video-tile {
object-fit: cover;
}
/* The live captions of a stream, drawn by the video element itself. */
.video-tile-picture::cue {
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: clamp(13px, 2.2vw, 20px);
line-height: 1.3;
}
/* A list over the picture, in place of a menu in the window: a stream popped out is another document. */
.stream-menu-scrim {
position: absolute;
inset: 0;
z-index: 2;
}
.context-menu.stream-menu {
position: absolute;
top: var(--spacing-s);
right: var(--spacing-s);
z-index: 3;
max-height: calc(100% - 2 * var(--spacing-s));
overflow-y: auto;
transform-origin: top right;
}
.stream-menu-title {
padding: 2px var(--spacing-s) var(--spacing-xs);
}
/* Nothing to look at: the name and a face, which is what a call without
cameras actually is. */
.video-tile-picture.audio-only {
@@ -10913,3 +10942,42 @@ html[data-reduce-motion] *::after {
color: inherit;
cursor: pointer;
}
/* A message the service took down, kept so that what it said can still be read. */
.message-row.removed .message-body,
.message-row.removed .message-body * {
color: var(--danger-text);
text-decoration: line-through;
}
/* The notice that stands where the words were: nothing to strike through. */
.message-row.removed.replaced .message-body,
.message-row.removed.replaced .message-body * {
color: var(--text-muted);
font-style: italic;
text-decoration: none;
}
/* The way into a thread from a reply, at the end of the line it quotes. */
.reply-preview .reply-thread-open {
flex: none;
margin-left: auto;
padding: 0 4px;
background: none;
border: 0;
color: inherit;
cursor: pointer;
border-radius: var(--radius-xs);
}
.reply-preview .reply-thread-open:hover {
color: var(--surface-variant-text);
background: var(--surface-hover);
}
/* A badge Kick sends as a picture, at the height of the line's small text. */
.sender-badge-image {
display: inline-block;
width: 16px;
height: 16px;
margin-left: var(--spacing-xxs);
object-fit: contain;
vertical-align: middle;
}
+8 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { useStore } from '../state/hooks'
import { useChat, useStore } from '../state/hooks'
import { useEscapeLayer } from '../lib/layers'
/**
@@ -43,6 +43,13 @@ export function KickPoints({ bufferId }: { bufferId: string }): JSX.Element | nu
const [points, setPoints] = useState<number | null>(null)
// Told by Kick the moment it changes, where the daemon is following it; the
// timer below is for a channel that is not live, which is not followed.
const pushed = useChat((s) => s.kickPointsByBuffer[bufferId])
useEffect(() => {
if (pushed !== undefined) setPoints(pushed)
}, [pushed])
const refresh = (): void => {
void window.moho
.rpc<Answer>('listKickRewards', { bufferId })
+15 -8
View File
@@ -12,6 +12,7 @@ import { RouteAccount, useStrictRoute } from '../lib/route'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, dayLabel, isChatKind, startsNewDay } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
import type { ChatMessage } from '../state/store'
/**
@@ -234,6 +235,8 @@ export function MessageList(): JSX.Element {
const [showPolls] = usePref<boolean>('kick.showPolls', true)
const [showStream] = usePref<boolean>('kick.showStreamEvents', true)
const [showKickMod] = usePref<boolean>('kick.showModeration', true)
// What Kick took down: left out, struck through, or replaced by a notice.
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
const [showReaders] = usePref<boolean>('matrix.showReadReceipts', true)
const [blockedNicks] = usePref<string[]>('blockedNicks', [])
const readers = useChat((s) => s.readersByBuffer)[bufferId]
@@ -256,7 +259,8 @@ export function MessageList(): JSX.Element {
const threadReplies = useMemo(() => {
const counts: Record<string, number> = {}
for (const m of all) {
if (m.replyTo?.thread) counts[m.replyTo.id] = (counts[m.replyTo.id] ?? 0) + 1
const root = m.replyTo?.thread ? m.replyTo.id : m.replyTo?.threadRoot
if (root) counts[root] = (counts[root] ?? 0) + 1
}
return counts
}, [all])
@@ -279,14 +283,17 @@ export function MessageList(): JSX.Element {
stream: showStream,
moderation: showKickMod
}
return all.filter((m) => {
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
const kind = m.kind || 'chat'
if (isChatKind(kind) || kind === 'system') return true
return allowed[kind] !== false
})
return applyRemoved(
all.filter((m) => {
if (accountId && blockedNicks.includes(`${accountId}|${m.from}`)) return false
const kind = m.kind || 'chat'
if (isChatKind(kind) || kind === 'system') return true
return allowed[kind] !== false
}),
removedMode
)
}, [
all, accountId, blockedNicks,
all, accountId, blockedNicks, removedMode,
showJoin, showPart, showNick, showTopic, showMode,
showMxJoin, showMxInvite, showMxKick, showMxQuit,
showRedemptions, showSubs, showRaids, showPolls, showStream, showKickMod
+48 -10
View File
@@ -61,6 +61,7 @@ import {
nickColor
} from '../lib/util'
import { useEscapeLayer } from '../lib/layers'
import { removedReason } from '../lib/moderation'
/**
* A badge, short enough to sit beside a name.
@@ -350,6 +351,10 @@ function MessageRowBody({
// preview. The object itself, so the row only re-renders when that one
// message changes.
const replyId = message.replyTo && !message.replyTo.thread && !message.replyTo.forwarded ? message.replyTo.id : ''
// The thread open beside the room, if any: a reply to its first message has
// nothing to say that the panel's own heading does not.
const openRoot = useChat((s) => s.openThread?.rootId)
const [showBadgePictures] = usePref<boolean>('kick.showBadgePictures', true)
const original = useChat((s) => (replyId ? s.messagesByBuffer[bufferId]?.find((m) => m.id === replyId) : undefined))
// Which message is open for editing is the window's, not each row's, so
// Up arrow in the message box can open the last one.
@@ -815,6 +820,7 @@ function MessageRowBody({
isWarning(message.kind, message.id) && 'warning',
message.pending && 'pending',
message.failed && 'failed',
message.moderated && (message.moderated.replaced ? 'removed replaced' : 'removed'),
isSystem && 'system',
reward && 'reward',
kickEvent && 'has-kick-event',
@@ -928,15 +934,31 @@ function MessageRowBody({
{timeLabel}
</span>
)}
{message.badges?.map((badge) => (
<span
key={badge.type}
className={`sender-badge small ${badge.type}`}
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
>
{badgeLabel(badge)}
</span>
))}
{message.badges?.map((badge) =>
badge.image ? (
// Kick's own pictures, unless they are turned off. The tooltip
// and the alt text say what it is for anybody who cannot see it.
showBadgePictures && (
<img
key={badge.type}
className="sender-badge-image"
src={media(badge.image)}
alt={badge.text}
title={badge.text}
loading="lazy"
draggable={false}
/>
)
) : (
<span
key={badge.type}
className={`sender-badge small ${badge.type}`}
title={badge.count ? `${badge.text} — ${badge.count} months` : badge.text}
>
{badgeLabel(badge)}
</span>
)
)}
</span>
)}
@@ -977,6 +999,7 @@ function MessageRowBody({
the panel's title already says. */}
{message.replyTo &&
!(inThread && message.replyTo.thread) &&
!(inThread && message.replyTo.id === openRoot) &&
(message.replyTo.thread && !inThread ? (
<button
type="button"
@@ -1024,6 +1047,21 @@ function MessageRowBody({
{message.replyTo.from}
</span>
<span className="ellipsis">{message.replyTo.body}</span>
{/* Kick groups a chain of replies under the message it began
with; a reply is a way into that conversation. */}
{!inThread && message.replyTo.threadRoot && (
<button
type="button"
className="reply-thread-open small"
title="Open the thread"
onClick={(e) => {
e.stopPropagation()
void store.openThreadPanel(bufferId, message.replyTo!.threadRoot!)
}}
>
<Icon name="forum" size={13} />
</button>
)}
</div>
))}
@@ -1094,7 +1132,7 @@ function MessageRowBody({
<KickEventCard embed={kickEvent} />
) : (
parts.html && (
<span className="message-body selectable">
<span className="message-body selectable" title={message.moderated && !message.moderated.replaced ? removedReason(message) : undefined}>
<RichText
html={parts.html}
// Ours unless the sender sent their own formatting, which
+7 -6
View File
@@ -88,10 +88,10 @@ function kickPermissions(members: Member[], me: string): Record<string, boolean>
/**
* Why the list says what it says, for the one service where it needs saying.
*/
function rosterNote(service: string | undefined): string | undefined {
return service === 'kick'
? 'Kick has no viewer list — this is everybody who has spoken here recently'
: undefined
function rosterNote(service: string | undefined, total: number, listed: number): string | undefined {
if (service !== 'kick') return undefined
const base = 'Kick has no viewer list — this counts everybody who has spoken here since you opened it'
return total > listed ? `${base}. The ${listed.toLocaleString()} most recent are listed.` : base
}
/**
@@ -140,6 +140,7 @@ export function NickList(): JSX.Element {
const [query, setQuery] = useState('')
const members = (buffer && presenceByBuffer[buffer.id]) || []
const total = useChat((s) => (buffer ? s.presenceTotalByBuffer[buffer.id] : undefined)) ?? members.length
const account = buffer && accounts.find((a) => a.id === buffer.accountId)
// Any protocol that reports presence gets the online/offline split.
const hasPresence = members.some((m) => m.status !== undefined)
@@ -263,8 +264,8 @@ export function NickList(): JSX.Element {
// Named for what the list actually is. Kick has no viewer list to
// ask for - it is built from who has spoken - and calling that
// "members" would claim to know something nobody here can know.
<span className="small muted ellipsis" title={rosterNote(account?.service)}>
{members.length} {account?.service === 'kick' ? 'recently talking' : 'members'}
<span className="small muted ellipsis" title={rosterNote(account?.service, total, members.length)}>
{total.toLocaleString()} {account?.service === 'kick' ? 'recently talking' : 'members'}
</span>
)}
<IconButton
+195 -42
View File
@@ -1,9 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { IconButton } from './Icon'
import { Icon, IconButton } from './Icon'
import { ivsAbr, ivsLoader } from '../lib/ivsplaylist'
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf, type CaptionTrack, type Rendition } from '../lib/streamplayer'
import { useEscapeLayer } from '../lib/layers'
import { VideoStage } from './VideoStage'
import { fitTiles } from './CallStage'
import { useChat, useStore } from '../state/hooks'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName } from '../lib/util'
import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
@@ -24,8 +27,68 @@ import { StageWindow, STREAM_WINDOW_NAME } from './stage/StageWindow'
* stream has one picture, and the grid a call draws would be a grid of one.
*/
/** The rendition ceiling. See the comment where it is used. */
const MAX_HEIGHT = 720
/** What automatic choice of rendition may go up to: a picture this size beside a chat has no use for more. */
const AUTO_MAX_HEIGHT = 720
/**
* How far behind the newest segment the player settles, in seconds.
*
* In seconds because hls.js counts the other way in multiples of the playlist's target duration, and Kick's
* playlists say six seconds for segments that are two: "three segments" was eighteen seconds behind, which is
* where most of the delay between the broadcaster and the picture came from. With the prefetched segments
* (see lib/ivsplaylist.ts) this is a few seconds behind the broadcaster, and no catch-up speed: playing a little
* fast to close the gap was measured to stall.
*/
const LIVE_SYNC_SECONDS = 4
const LIVE_MAX_LATENCY_SECONDS = 10
/** The renditions of a player's playlist, as the choices are made from them. */
function renditionsOf(player: Hls): Rendition[] {
return player.levels.map((l) => ({ height: l.height, frameRate: Number(l.attrs?.['FRAME-RATE']) || undefined, bitrate: l.bitrate }))
}
interface MenuChoice {
label: string
detail?: string
checked: boolean
onClick: () => void
}
/** A short list drawn over the picture, since a popped-out window is a document of its own and a menu in this one would open in the wrong place. */
function StreamMenu({ title, choices, onClose }: { title: string; choices: MenuChoice[]; onClose: () => void }): JSX.Element {
useEscapeLayer(onClose)
return (
<>
<div className="stream-menu-scrim" onClick={onClose} />
<div className="context-menu stream-menu" role="menu" aria-label={title}>
<div className="stream-menu-title small muted">{title}</div>
{choices.map((c) => (
<button
key={c.label}
type="button"
role="menuitemradio"
aria-checked={c.checked}
className="context-menu-item"
onClick={() => {
c.onClick()
onClose()
}}
>
<span className="context-menu-label">{c.label}</span>
{c.detail && <span className="small muted">{c.detail}</span>}
{c.checked && <Icon name="check" size={16} className="context-menu-check" />}
</button>
))}
</div>
</>
)
}
interface Choice {
index: number
label: string
height: number
}
export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'window' }): JSX.Element | null {
const store = useStore()
@@ -36,17 +99,30 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
const video = useRef<HTMLVideoElement>(null)
const frame = useRef<HTMLDivElement>(null)
const hls = useRef<Hls | null>(null)
/** The newest playback URL, for the error path to reach without being
* rebuilt every time one arrives. */
/** The newest playback URL, for a player that has to begin again to reach without being rebuilt every time
* one arrives. */
const latest = useRef('')
const [room, setRoom] = useState({ width: 640, height: 260 })
const [muted, setMuted] = useState(false)
/** What the stream offers, once its playlist has been read. */
const [choices, setChoices] = useState<Choice[]>([])
const [captions, setCaptions] = useState<CaptionTrack[]>([])
/** What automatic choice is playing now, for the menu to say. */
const [playing, setPlaying] = useState('')
const [menu, setMenu] = useState<'quality' | 'captions' | null>(null)
// Remembered between streams: 0 is automatic, and an empty language is captions off.
const [quality, setQuality] = usePref<number>('stream.quality', 0)
const [captionLanguage, setCaptionLanguage] = usePref<string>('stream.captions', '')
const stream = watching ? streams[watching.bufferId] : undefined
// The current one rather than the one this started with: the signature in a
// playback URL expires, and the refresh already running is what replaces it.
// The newest address rather than the one this began with: the signature in it expires, and the daemon asks for
// a new one before it does. The player is not rebuilt for it - it is only where a player that has to start
// over, after being refused, starts from.
const url = stream?.playbackUrl ?? ''
latest.current = url
// A different picture is a different stream or a different broadcast; the same one with a new address is not.
const picture = `${watching?.bufferId ?? ''}|${stream?.livestreamId ?? ''}`
const hasUrl = url !== ''
// The same measuring the call stage does, for the same reason: this is a
// different shape in a narrow window, a full-screen one, and the corner.
@@ -78,49 +154,61 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
}
}, [mode, minimized, watching])
// The player itself, torn down and rebuilt only when the picture changes -
// not when the viewer count ticks over, which it does every minute.
// The player itself, torn down and rebuilt only when the picture changes - not when the viewer count ticks over,
// and not when the address is renewed, which it is every few minutes.
useEffect(() => {
const el = video.current
if (!el || !url || minimized) return
if (!el || !hasUrl || minimized) return
if (!Hls.isSupported()) {
store.toast('error', 'This build cannot play a stream')
return
}
const player = new Hls({
// Enough buffer to ride out a hiccup, not so much that "live" means two
// minutes ago: this is a chat window beside the stream, and people talk
// about what just happened.
liveSyncDurationCount: 3,
// Never fetch a rendition larger than the box it is drawn in.
capLevelToPlayerSize: true
// Kick's playlists, which list what is being made as well as what is made.
loader: ivsLoader(2),
abrController: ivsAbr(),
// How far behind to play, in seconds: see LIVE_SYNC_SECONDS.
liveSyncDuration: LIVE_SYNC_SECONDS,
liveMaxLatencyDuration: LIVE_MAX_LATENCY_SECONDS,
// Never fetch a rendition larger than the box it is drawn in, when the choice is automatic.
capLevelToPlayerSize: true,
// A stream that runs for hours must not keep them all.
backBufferLength: 20
})
hls.current = player
player.attachMedia(el)
player.loadSource(url)
player.loadSource(latest.current)
player.on(Hls.Events.MANIFEST_PARSED, () => {
// 1080p60 is 5.8 Mbps and a real amount of CPU to decode beside a chat
// client, and the ladder below it is cheaper for a picture this size.
// A ceiling rather than a fixed choice: the bitrate ladder underneath
// still adapts to the connection.
const ceiling = player.levels.reduce(
(best, level, i) => (level.height <= MAX_HEIGHT && level.height >= (player.levels[best]?.height ?? 0) ? i : best),
-1
)
if (ceiling >= 0) player.autoLevelCapping = ceiling
setChoices(menuOrder(renditionsOf(player)))
void el.play().catch(() => {
/* A picture that will not start is not worth an error dialog. */
})
})
player.on(Hls.Events.LEVEL_LOADED, (_e, data) => {
// Behind by a segment and a half at the least: where they are 5 seconds long, 4 seconds is less than one.
const longest = Math.max(0, ...data.details.fragments.map((f) => f.duration))
const behind = Math.max(LIVE_SYNC_SECONDS, longest * 1.5)
if (player.config.liveSyncDuration !== behind) {
player.config.liveSyncDuration = behind
player.config.liveMaxLatencyDuration = Math.max(LIVE_MAX_LATENCY_SECONDS, behind * 2.5)
}
})
player.on(Hls.Events.LEVEL_SWITCHED, (_e, data) => {
const level = player.levels[data.level]
if (level) setPlaying(renditionLabel({ height: level.height, frameRate: Number(level.attrs?.['FRAME-RATE']) || undefined }))
})
player.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_e, data) => {
setCaptions(data.subtitleTracks.map((t) => ({ id: t.id, name: t.name, lang: t.lang })))
})
player.on(Hls.Events.ERROR, (_e, data) => {
if (!data.fatal) return
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
// A refused segment is an expired token, not a dead stream: the
// daemon re-asks Kick about the channel every minute, so the next
// playback URL is already on its way and this only has to wait for
// it. Reloading the source picks up whichever one is current.
// A refused segment is an expired token, not a dead stream: the daemon
// renews the address before it expires, so the next one is already
// here and this only has to begin from it. Reloading the source picks
// up whichever one is current.
if (latest.current) player.loadSource(latest.current)
player.startLoad()
return
@@ -136,8 +224,34 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
return () => {
player.destroy()
hls.current = null
setChoices([])
setCaptions([])
setPlaying('')
}
}, [url, minimized, store, watching?.bufferId])
}, [hasUrl, picture, minimized, store])
// Which rendition: automatic, or the height that was chosen when this stream offers it.
useEffect(() => {
const player = hls.current
if (!player || choices.length === 0) return
const wanted = quality > 0 ? renditionOf(renditionsOf(player), quality) : -1
if (wanted >= 0) {
player.autoLevelCapping = -1
player.currentLevel = wanted
} else {
player.currentLevel = -1
player.autoLevelCapping = autoCeiling(renditionsOf(player), AUTO_MAX_HEIGHT)
}
}, [quality, choices])
// Which captions: none, or the language that was chosen when this stream has captions in it.
useEffect(() => {
const player = hls.current
if (!player) return
const track = captionTrack(captions, captionLanguage, navigator.language)
player.subtitleDisplay = track >= 0
player.subtitleTrack = track
}, [captionLanguage, captions])
// A stream that has ended is not a picture any more, and the surface should
// not sit there showing the last frame of it.
@@ -152,6 +266,7 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
const where = buffers.find((b) => b.id === watching.bufferId)?.name ?? ''
const fit = fitTiles(1, room.width, room.height, Infinity)
const captionsOn = captionTrack(captions, captionLanguage, navigator.language) >= 0
const watchers = stream?.viewers
const subtitle = `Live${watchers ? ` · ${watchers.toLocaleString()} watching` : ''}${
where ? ` · ${bufferDisplayName(where)}` : ''
@@ -171,21 +286,59 @@ export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' | 'wi
onPopOut={mode === 'window' ? undefined : () => store.setStreamPoppedOut(true)}
onBringBack={() => store.setStreamPoppedOut(false)}
controls={
<IconButton
name={muted ? 'volume_off' : 'volume_up'}
title={muted ? 'Unmute' : 'Mute'}
className={muted ? 'calling' : undefined}
onClick={() => {
const next = !muted
setMuted(next)
if (video.current) video.current.muted = next
}}
/>
<>
<IconButton
name={captionsOn ? 'closed_caption' : 'closed_caption_disabled'}
title={captions.length === 0 ? 'No captions for this stream' : captionsOn ? 'Captions on' : 'Captions off'}
className={captionsOn ? 'active' : undefined}
disabled={captions.length === 0}
onClick={() => {
if (captions.length === 1) setCaptionLanguage(captionsOn ? '' : captions[0].lang || captions[0].name)
else setMenu(menu === 'captions' ? null : 'captions')
}}
/>
<IconButton
name="high_quality"
title={quality > 0 ? `Resolution: ${quality}p` : 'Resolution: automatic'}
disabled={choices.length === 0}
onClick={() => setMenu(menu === 'quality' ? null : 'quality')}
/>
<IconButton
name={muted ? 'volume_off' : 'volume_up'}
title={muted ? 'Unmute' : 'Mute'}
className={muted ? 'calling' : undefined}
onClick={() => {
const next = !muted
setMuted(next)
if (video.current) video.current.muted = next
}}
/>
</>
}
>
<div className={mode === 'window' ? 'video-stage in-window' : 'video-stage'} ref={frame}>
<div className="video-tile" style={{ width: `${Math.floor(fit.tileWidth)}px` }}>
<video ref={video} className="video-tile-picture" autoPlay playsInline />
{menu === 'quality' && (
<StreamMenu
title="Resolution"
onClose={() => setMenu(null)}
choices={[
{ label: 'Auto', detail: quality === 0 && playing ? playing : undefined, checked: quality === 0 || !choices.some((c) => c.height === quality), onClick: () => setQuality(0) },
...choices.map((c) => ({ label: c.label, checked: quality === c.height, onClick: () => setQuality(c.height) }))
]}
/>
)}
{menu === 'captions' && (
<StreamMenu
title="Captions"
onClose={() => setMenu(null)}
choices={[
{ label: 'Off', checked: !captionsOn, onClick: () => setCaptionLanguage('') },
...captions.map((t) => ({ label: t.name, checked: captionsOn && captionTrack(captions, captionLanguage, navigator.language) === t.id, onClick: () => setCaptionLanguage(t.lang || t.name) }))
]}
/>
)}
</div>
</div>
</VideoStage>
+10 -4
View File
@@ -5,6 +5,7 @@ import { IconButton } from './Icon'
import { Composer } from './Composer'
import { useChat, usePref, useStore } from '../state/hooks'
import type { ChatMessage } from '../state/store'
import { applyRemoved, type RemovedMessages } from '../lib/moderation'
/**
* A thread, beside the room it belongs to.
@@ -26,6 +27,7 @@ export function ThreadPanel(): JSX.Element | null {
// to ask for `ui.*`, which nothing writes, so it ignored both.
const [comfy] = usePref<'classic' | 'comfy' | 'bubbles'>('display.messageMode', 'comfy')
const [relativeTimestamps] = usePref<boolean>('display.relativeTimestamps', false)
const [removedMode] = usePref<RemovedMessages>('kick.removedMessages', 'strike')
const store = useStore()
// Before the early return below, as every hook here has to be.
const shared = useRowContext(thread?.bufferId ?? '')
@@ -36,15 +38,15 @@ export function ThreadPanel(): JSX.Element | null {
const messages = useMemo((): ChatMessage[] => {
if (!thread) return []
const live = (messagesByBuffer[thread.bufferId] || []).filter(
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId)
(m) => m.id === thread.rootId || (m.replyTo?.thread && m.replyTo.id === thread.rootId) || m.replyTo?.threadRoot === thread.rootId
)
// By id, so a message that is both fetched and live appears once. The
// live copy wins: it is the one carrying edits and reactions that have
// happened since the fetch.
const byId = new Map<string, ChatMessage>()
for (const m of [...thread.messages, ...live]) byId.set(m.id, m)
return [...byId.values()].sort((a, b) => a.ts - b.ts)
}, [thread, messagesByBuffer])
return applyRemoved([...byId.values()].sort((a, b) => a.ts - b.ts), removedMode)
}, [thread, messagesByBuffer, removedMode])
// Opened on, and following, the newest reply: a thread is read from its end.
const count = messages.length
@@ -53,6 +55,10 @@ export function ThreadPanel(): JSX.Element | null {
if (el) el.scrollTop = el.scrollHeight
}, [thread?.rootId, count])
// Not counting the message it grew from - which is not always here: a thread can outlive the scrollback
// its first message was in, and then every message shown is a reply.
const replyCount = messages.filter((m) => m.id !== thread?.rootId).length
if (!thread) return null
return (
@@ -60,7 +66,7 @@ export function ThreadPanel(): JSX.Element | null {
<div className="thread-header">
<span className="thread-title ellipsis">Thread</span>
<span className="small muted">
{Math.max(0, messages.length - 1) === 1 ? '1 reply' : `${Math.max(0, messages.length - 1)} replies`}
{replyCount === 1 ? '1 reply' : `${replyCount} replies`}
</span>
<IconButton name="close" title="Close thread" onClick={() => store.closeThreadPanel()} />
</div>
@@ -1222,6 +1222,35 @@ function KickSettings(): JSX.Element {
defaultValue={true}
/>
</SettingsSection>
<SettingsSection
title="Badges"
description="Kick's level, founder and event badges, and a channel's own, drawn as pictures beside a name. Moderator, subscriber and the like stay as words."
>
<ToggleSetting
settingKey="kick.showBadgePictures"
label="Show badge pictures"
description="Loaded from Kick's servers as they are shown"
defaultValue={true}
/>
</SettingsSection>
<SettingsSection
title="Messages Kick removes"
description="When a moderator or Kick's automatic moderation takes a message down. The text is kept, so you can still see what was said."
>
<SelectionSetting
settingKey="kick.removedMessages"
label="Removed messages"
description="Struck through in red keeps what was said beside the fact that it is gone"
defaultValue="strike"
options={[
{ label: 'Delete', value: 'remove' },
{ label: 'Strikethrough', value: 'strike' },
{ label: 'Replace with notice', value: 'replace' }
]}
/>
</SettingsSection>
</>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { withPrefetch } from './ivsplaylist'
/** A rendition's playlist as Kick's stream serves one: two-second segments, six-second target, two prefetch lines. */
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:6',
'#EXT-X-MEDIA-SEQUENCE:2939',
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:41.442Z',
'#EXTINF:2.000,live',
'https://cdn.example/v1/segment/a',
'#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z',
'#EXTINF:2.000,live',
'https://cdn.example/v1/segment/b',
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/c',
'#EXT-X-PREFETCH:https://cdn.example/v1/segment/d',
''
].join('\n')
describe('a playlist that lists what is being made', () => {
it('has each prefetch line become a segment as long as the last complete one, after the complete ones', () => {
const lines = withPrefetch(playlist).split('\n').filter(Boolean)
expect(lines.slice(-4)).toEqual(['#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/c', '#EXTINF:2.000,prefetch', 'https://cdn.example/v1/segment/d'])
expect(lines.some((l) => l.startsWith('#EXT-X-PREFETCH'))).toBe(false)
expect(lines.filter((l) => l.startsWith('https://')).length).toBe(4)
})
it('can take only some of them, or none', () => {
expect(withPrefetch(playlist, 1).split('\n').filter((l) => l.startsWith('https://'))).toHaveLength(3)
expect(withPrefetch(playlist, 0)).toBe(playlist)
})
it('is left as it was when it has none, when it is of a stream that has ended, or when nothing is complete to measure by', () => {
const plain = playlist.replace(/#EXT-X-PREFETCH:\S+\n/g, '')
expect(withPrefetch(plain)).toBe(plain)
const ended = `${playlist}#EXT-X-ENDLIST\n`
expect(withPrefetch(ended)).toBe(ended)
const none = ['#EXTM3U', '#EXT-X-TARGETDURATION:6', '#EXT-X-PREFETCH:https://cdn.example/v1/segment/c', ''].join('\n')
expect(withPrefetch(none)).toBe(none)
})
it('keeps everything else about the playlist, in the order it had it', () => {
const out = withPrefetch(playlist).split('\n')
expect(out.slice(0, 5)).toEqual(playlist.split('\n').slice(0, 5))
expect(out).toContain('#EXT-X-PROGRAM-DATE-TIME:2026-10-13T22:58:43.442Z')
})
})
describe('withPrefetch on segments of uneven length', () => {
it('leaves the playlist as it is when the last segment is no guide to the next', () => {
const playlist = ['#EXTM3U', '#EXTINF:3.200,live', 'a.ts', '#EXTINF:5.166,live', 'b.ts', '#EXT-X-PREFETCH:c.ts', ''].join('\n')
expect(withPrefetch(playlist)).toBe(playlist)
})
})
+103
View File
@@ -0,0 +1,103 @@
import Hls from 'hls.js'
/**
* Amazon IVS's low-latency playlists, which Kick's streams are, list what is already complete and then
* what is being made: `#EXT-X-PREFETCH:<address>`, one per segment of the next few seconds. The address
* answers as soon as it is asked and sends the segment as it is produced, so asking early is the way to
* be seconds nearer the broadcaster. IVS's own player does that. hls.js does not know the tag and
* leaves it out, which is why a stream played with it runs several seconds behind one played with
* IVS's.
*
* Here each of those lines becomes a segment of the playlist like any other, the same length as the
* last complete one, so hls.js asks for it and plays it as it arrives.
*/
/**
* The addresses that were listed as prefetch, which hls.js must not judge the connection by: one of
* them is sent as it is made, so it takes as long to arrive as it takes to broadcast (two seconds of
* picture in two seconds), and bandwidth worked out from that is the stream's own bitrate. Taken for
* the connection's, it moved the picture down a rendition at every other segment.
*/
const ahead = new Set<string>()
function remember(address: string): void {
ahead.add(address)
// Only the last few matter; a stream that runs for hours must not grow this for ever.
if (ahead.size > 64) ahead.delete(ahead.values().next().value as string)
}
/** How far apart, in seconds, the longest and shortest segment may be for the next to be taken as their length. */
const UNIFORM_WITHIN = 0.25
export function withPrefetch(playlist: string, take = 2): string {
if (take <= 0 || !playlist.includes('#EXT-X-PREFETCH:') || playlist.includes('#EXT-X-ENDLIST')) return playlist
const lines = playlist.split('\n')
const kept: string[] = []
const ahead_: string[] = []
let length = ''
const lengths: number[] = []
for (const line of lines) {
const prefetch = /^#EXT-X-PREFETCH:(\S+)/.exec(line.trim())
if (prefetch) {
ahead_.push(prefetch[1])
continue
}
const segment = /^#EXTINF:([0-9.]+)/.exec(line)
if (segment) {
length = segment[1]
lengths.push(Number(segment[1]))
}
kept.push(line)
}
// Nothing to say how long they are if no segment is complete yet.
if (!length || ahead_.length === 0) return playlist
// How long a segment still being made will turn out to be is a guess from the ones before it, and only a
// good one where they are all alike. A broadcaster that is not re-encoded by IVS (one that sends its own
// segments) has them of 3 to 5 seconds, and a guess of 3 for a segment of 5 leaves a gap the picture then
// stops in, every few seconds.
if (Math.max(...lengths) - Math.min(...lengths) > UNIFORM_WITHIN) return playlist
while (kept.length > 0 && kept[kept.length - 1].trim() === '') kept.pop()
for (const address of ahead_.slice(0, take)) {
remember(address)
kept.push(`#EXTINF:${length},prefetch`, address)
}
return `${kept.join('\n')}\n`
}
/**
* hls.js's own judge of the connection, told to leave the prefetch segments out of it. See `ahead`.
*/
export function ivsAbr(): typeof Hls.DefaultConfig.abrController {
const Base = Hls.DefaultConfig.abrController as unknown as new (hls: unknown) => Record<string, unknown>
class Abr extends Base {
// Private in hls.js's types and called by each of its three handlers that measure a segment.
ignoreFragment(frag: { url?: string }): boolean {
const parent = (Base.prototype as { ignoreFragment?: (f: unknown) => boolean }).ignoreFragment
return ahead.has(frag.url ?? '') || (parent ? parent.call(this, frag) : false)
}
}
return Abr as unknown as typeof Hls.DefaultConfig.abrController
}
/**
* hls.js's own loader, with the playlist of a rendition read through `withPrefetch`. Everything else it
* loads - the master playlist, the segments, the captions - is as it was.
*/
export function ivsLoader(take = 2): typeof Hls.DefaultConfig.loader {
const Base = Hls.DefaultConfig.loader as unknown as new (config: unknown) => {
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void
}
class Loader extends Base {
load(context: Record<string, unknown>, config: unknown, callbacks: Record<string, (...args: unknown[]) => void>): void {
if (context.type !== 'level') return super.load(context, config, callbacks)
super.load(context, config, {
...callbacks,
onSuccess: (response, stats, ctx, details) => {
const r = response as { data?: unknown }
if (typeof r.data === 'string') response = { ...r, data: withPrefetch(r.data, take) }
callbacks.onSuccess(response, stats, ctx, details)
}
})
}
}
return Loader as unknown as typeof Hls.DefaultConfig.loader
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { ChatMessage } from '../state/store'
import { applyRemoved, removedReason, REMOVED_NOTICE } from './moderation'
const said = (id: string, extra: Partial<ChatMessage> = {}): ChatMessage =>
({ id, bufferId: 'b', from: 'x', body: `text ${id}`, ts: 1, isAction: false, isHighlight: false, kind: 'chat', ...extra }) as ChatMessage
const log = [said('a'), said('b', { moderated: { ai: true, rules: ['hate'] }, html: '<b>x</b>', embeds: [{ kind: 'gif' } as never] }), said('c')]
describe('removed messages', () => {
it('are left out when that is chosen', () => {
expect(applyRemoved(log, 'remove').map((m) => m.id)).toEqual(['a', 'c'])
})
it('keep their words when struck through is chosen, and the list is the same one', () => {
expect(applyRemoved(log, 'strike')).toBe(log)
})
it('have a notice put where their words were when that is chosen', () => {
const [, removed] = applyRemoved(log, 'replace')
expect(removed.body).toBe(REMOVED_NOTICE)
expect(removed.html).toBeUndefined()
expect(removed.embeds).toEqual([])
expect(removed.moderated).toEqual({ ai: true, rules: ['hate'], replaced: true })
// And only that one.
expect(applyRemoved(log, 'replace')[0].body).toBe('text a')
})
it('change nothing in a log where nothing was removed', () => {
const clean = [said('a')]
expect(applyRemoved(clean, 'remove')).toBe(clean)
expect(applyRemoved(clean, 'replace')).toBe(clean)
})
it('say why they were removed when they know', () => {
expect(removedReason(log[1])).toBe("Removed by Kick's automatic moderation: hate")
expect(removedReason(said('d', { moderated: {} }))).toBe('Removed')
})
})
+32
View File
@@ -0,0 +1,32 @@
import type { ChatMessage } from '../state/store'
/**
* What to do with a message the service has taken down. The daemon keeps the
* text and says it is gone; this is the window's say in how that looks.
*/
export type RemovedMessages = 'remove' | 'strike' | 'replace'
export const REMOVED_NOTICE = 'This message was removed by moderation'
/** What a removed message says it was removed for, for a tooltip. */
export function removedReason(m: ChatMessage): string {
const rules = m.moderated?.rules ?? []
const by = m.moderated?.ai ? "Removed by Kick's automatic moderation" : 'Removed'
return rules.length > 0 ? `${by}: ${rules.join(', ')}` : by
}
/**
* The messages to draw, with those that were removed handled as chosen:
* left out, kept as they were (the row draws them struck through), or with
* what they said swapped for a notice. A list with none removed comes back
* as it was, so nothing downstream re-renders for a setting it does not use.
*/
export function applyRemoved(messages: ChatMessage[], mode: RemovedMessages): ChatMessage[] {
if (mode === 'strike' || !messages.some((m) => m.moderated)) return messages
if (mode === 'remove') return messages.filter((m) => !m.moderated)
return messages.map((m) =>
m.moderated
? { ...m, body: REMOVED_NOTICE, html: undefined, embeds: [], attachments: [], components: [], moderated: { ...m.moderated, replaced: true } }
: m
)
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { autoCeiling, captionTrack, menuOrder, renditionLabel, renditionOf } from './streamplayer'
const ladder = [
{ height: 160, frameRate: 30, bitrate: 230000 },
{ height: 1080, frameRate: 60, bitrate: 8519450 },
{ height: 720, frameRate: 60, bitrate: 3422999 },
{ height: 480, frameRate: 30, bitrate: 1427999 },
{ height: 360, frameRate: 30, bitrate: 630000 }
]
describe('the renditions a stream offers', () => {
it('are named as Kick names them: the height, and the frame rate where it is above thirty', () => {
expect(renditionLabel({ height: 720, frameRate: 60 })).toBe('720p60')
expect(renditionLabel({ height: 480, frameRate: 30 })).toBe('480p')
expect(renditionLabel({ height: 360 })).toBe('360p')
expect(renditionLabel({ height: 1080, frameRate: 59.94 })).toBe('1080p60')
})
it('are listed tallest first, with where each is in the player\'s own list, and one to a height', () => {
expect(menuOrder(ladder).map((r) => [r.label, r.index])).toEqual([['1080p60', 1], ['720p60', 2], ['480p', 3], ['360p', 4], ['160p', 0]])
expect(menuOrder([...ladder, { height: 720, frameRate: 30, bitrate: 1 }]).filter((r) => r.height === 720)).toHaveLength(1)
})
it('are found by the height somebody wants, or by nothing when it is not offered', () => {
expect(renditionOf(ladder, 480)).toBe(3)
expect(renditionOf(ladder, 900)).toBe(-1)
})
it('have a ceiling for automatic choice: the tallest that fits, the shortest when none does, none when there is no ceiling', () => {
expect(autoCeiling(ladder, 720)).toBe(2)
expect(autoCeiling(ladder, 1440)).toBe(1)
expect(autoCeiling(ladder, 100)).toBe(0)
expect(autoCeiling(ladder, 0)).toBe(-1)
expect(autoCeiling([], 720)).toBe(-1)
})
})
describe('the captions a stream offers', () => {
const tracks = [{ id: 0, name: 'English', lang: 'en' }, { id: 1, name: 'Portuguese', lang: 'pt' }, { id: 2, name: 'Spanish', lang: 'es' }]
it('are off when nothing is wanted or there is nothing to show', () => {
expect(captionTrack(tracks, '')).toBe(-1)
expect(captionTrack([], 'en')).toBe(-1)
})
it('are the language that was chosen, however it was written', () => {
expect(captionTrack(tracks, 'pt')).toBe(1)
expect(captionTrack(tracks, 'PT-BR')).toBe(1)
})
it('fall back to the person\'s own language, and then to the first, when the one chosen is not offered', () => {
expect(captionTrack(tracks, 'ja', 'es')).toBe(2)
expect(captionTrack(tracks, 'ja', 'xx')).toBe(0)
expect(captionTrack(tracks, 'ja')).toBe(0)
})
})
+74
View File
@@ -0,0 +1,74 @@
/**
* The choices a stream's player offers - which rendition, which captions - kept apart from the player so
* that what they mean can be said and tested without one.
*/
/** A rendition as the playlist describes it. */
export interface Rendition {
height: number
frameRate?: number
bitrate?: number
}
/** "720p", and "720p60" for one that moves at 60 frames a second, as Kick's own player calls them. */
export function renditionLabel(r: Rendition): string {
const fps = r.frameRate && r.frameRate > 30 ? Math.round(r.frameRate) : 0
return `${r.height}p${fps || ''}`
}
/**
* The highest rendition that automatic choice may pick: the tallest that is no taller than `ceiling`, or
* the shortest there is when all are taller, since a ceiling below everything would leave nothing to play.
* -1 for no ceiling at all.
*/
export function autoCeiling(renditions: Rendition[], ceiling: number): number {
if (renditions.length === 0 || ceiling <= 0) return -1
let best = -1
let lowest = 0
renditions.forEach((r, i) => {
if (r.height < renditions[lowest].height) lowest = i
if (r.height <= ceiling && (best < 0 || r.height > renditions[best].height || (r.height === renditions[best].height && (r.bitrate ?? 0) > (renditions[best].bitrate ?? 0)))) best = i
})
return best >= 0 ? best : lowest
}
/** The rendition of a wanted height: the one with the most bits if there are two, -1 if there is none. */
export function renditionOf(renditions: Rendition[], height: number): number {
let found = -1
renditions.forEach((r, i) => {
if (r.height === height && (found < 0 || (r.bitrate ?? 0) > (renditions[found].bitrate ?? 0))) found = i
})
return found
}
/** The renditions as a menu lists them, tallest first, with each one's place in the player's own list. */
export function menuOrder(renditions: Rendition[]): { index: number; label: string; height: number }[] {
const seen = new Set<number>()
return renditions
.map((r, index) => ({ index, label: renditionLabel(r), height: r.height, bitrate: r.bitrate ?? 0 }))
.sort((a, b) => b.height - a.height || b.bitrate - a.bitrate)
// Two of one height are one choice.
.filter((r) => (seen.has(r.height) ? false : (seen.add(r.height), true)))
.map(({ index, label, height }) => ({ index, label, height }))
}
/** A caption track as the master playlist lists it. */
export interface CaptionTrack {
id: number
name: string
lang?: string
}
/**
* Which caption track a remembered choice means: its language where there is one, the first track when
* what was chosen is not offered here, and none when captions are off.
*/
export function captionTrack(tracks: CaptionTrack[], wanted: string, language = ''): number {
if (!wanted || tracks.length === 0) return -1
const same = (lang: string): number => tracks.findIndex((t) => (t.lang ?? '').toLowerCase().split('-')[0] === lang.toLowerCase().split('-')[0])
const chosen = same(wanted)
if (chosen >= 0) return tracks[chosen].id
// On, but in a language this stream has none of: the person's own language if it is there, else the first.
const own = language ? same(language) : -1
return tracks[own >= 0 ? own : 0].id
}
+40 -11
View File
@@ -333,11 +333,15 @@ export interface KickStream {
* Where the video is: a signed Amazon IVS playlist, good for watching in
* this window rather than in a browser tab beside it.
*
* Replaced on every refresh, because the signature in it expires - so
* whoever is playing it reads the current one rather than the one they
* started with.
* Replaced shortly before the signature in it expires, which is about every
* seven minutes - so whoever is playing it reads the current one rather
* than the one they started with, and does not start over for a new one.
*/
playbackUrl?: string | null
/** Unix seconds the address stops being accepted, where it says. */
playbackExpires?: number | null
/** The broadcast's own id: a different one is a different stream, even in the same channel. */
livestreamId?: number | null
}
/** One box on a form a Discord bot sent. */
@@ -535,6 +539,10 @@ export interface ChatState {
joinPanelAccountId: string
messagesByBuffer: Record<string, ChatMessage[]>
presenceByBuffer: Record<string, Member[]>
/** Points the signed-in account has in each Kick channel, as Kick last pushed them. */
kickPointsByBuffer: Record<string, number>
/** How many people a roster stands for, where the service says it is more than it lists (Kick). */
presenceTotalByBuffer: Record<string, number>
/** Buffers whose initial backlog fetch has completed. */
loadedBuffers: Record<string, true>
loadingMore: Record<string, true>
@@ -786,6 +794,8 @@ const INITIAL: ChatState = {
joinPanelAccountId: '',
messagesByBuffer: {},
presenceByBuffer: {},
presenceTotalByBuffer: {},
kickPointsByBuffer: {},
loadedBuffers: {},
loadingMore: {},
loadingNewer: {},
@@ -2603,7 +2613,7 @@ export class ChatStore {
*/
async openThreadPanel(bufferId: string, rootId: string): Promise<void> {
const known = (this.state.messagesByBuffer[bufferId] || []).filter(
(m) => m.id === rootId || (m.replyTo?.thread && m.replyTo.id === rootId)
(m) => m.id === rootId || (m.replyTo?.thread && m.replyTo.id === rootId) || m.replyTo?.threadRoot === rootId
)
this.set({ openThread: { bufferId, rootId, messages: known, loading: true } })
try {
@@ -2632,15 +2642,22 @@ export class ChatStore {
const open = this.state.openThread
if (!open || (!body.trim() && !attachment)) return
const root = open.messages.find((m) => m.id === open.rootId)
// Kick answers a message, not a thread, and the daemon quotes whatever it
// is told to answer from what it has stored. A thread whose first message
// is older than the scrollback still has replies on screen, and answering
// the first of those keeps the new one in the same thread.
const earliest = !root && this.accountFor(open.bufferId)?.service === 'kick' ? open.messages[0] : undefined
await this.dispatchSend(
open.bufferId,
body,
{
id: open.rootId,
from: root?.from ?? '',
body: root?.body ?? '',
thread: true
},
earliest
? { id: earliest.id, from: earliest.from, body: earliest.body }
: {
id: open.rootId,
from: root?.from ?? '',
body: root?.body ?? '',
thread: true
},
attachment,
spoilers
)
@@ -3592,6 +3609,17 @@ export class ChatStore {
)
break
// Kept rather than removed: the daemon says the service took it down and
// leaves the words, and what that looks like is the window's to decide.
// Kick says the balance changed: the five-minute earn, a bet, a reward.
case 'kickPoints':
this.set({ kickPointsByBuffer: { ...this.state.kickPointsByBuffer, [data.bufferId]: data.points } })
break
case 'messageModerated':
this.mapMessage(data.bufferId, data.id, (m) => ({ ...m, moderated: data.moderated ?? {} }))
break
case 'reactionsChanged':
this.mapMessage(data.bufferId, data.id, (m) => ({ ...m, reactions: data.reactions }))
break
@@ -3951,7 +3979,8 @@ export class ChatStore {
case 'presenceChange':
this.set({
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members }
presenceByBuffer: { ...this.state.presenceByBuffer, [data.bufferId]: data.members },
presenceTotalByBuffer: { ...this.state.presenceTotalByBuffer, [data.bufferId]: data.total ?? data.members.length }
})
break
+30
View File
@@ -419,6 +419,29 @@ export interface ReplyPreview {
* a forward is somebody else's message arriving.
*/
forwarded?: boolean
/**
* The message a whole chain of replies grew from, where the service groups
* them into a thread (Kick does).
*
* Unlike `thread`, `id` here is still the message that was answered - this
* says which thread that message is part of, and a reply can be both.
*/
threadRoot?: string
}
/**
* A message the service took down and the daemon kept.
*
* Whether to show it, strike it through or put a notice in its place is the
* window's choice (`kick.removedMessages`), so the text is still here.
*/
export interface Moderated {
/** Whether the service's automatic moderation did it, rather than a person. */
ai?: boolean
/** The rules it was said to break. */
rules?: string[]
/** Set by the window, never the daemon: this message's text has been swapped for a notice. */
replaced?: boolean
}
/**
@@ -588,6 +611,8 @@ export interface Message {
* button below it.
*/
components?: MessageComponent[]
/** Present once the service has removed the message. */
moderated?: Moderated
/** Matrix only - the sender's full MXID, for targeting moderation actions. */
senderId?: string
/**
@@ -718,6 +743,11 @@ export interface MessageBadge {
text: string
/** Months subscribed, for the badges that count. */
count?: number
/**
* A picture to draw it with, for the badges Kick sends as one - level,
* event and founder badges, a channel's own. Drawn instead of the word.
*/
image?: string
}
export interface CustomEmoji {