Watch the stream in the window that has the chat

A Kick channel is a stream as much as a chat, and the video was in a
browser tab beside the window that had the conversation in it.

Not a player of its own. The frame a call draws into is now its own
thing - VideoStage - and a call and a stream are two sources feeding it:
the same pane, the same corner it retreats to, the same minimise that
puts the picture down without ending it, the same sizing. What differs
is the pipe. A call is real-time media that arrives as a MediaStream on
srcObject; a stream is one-to-many HLS off a CDN, which needs Media
Source Extensions and so needs hls.js, and that is the only piece here
a call has no use for.

Receiving only, and one at a time: nothing broadcasts, nobody else is in
it, and starting a stream during a call is refused rather than fought
over - the picture has one place to go.

Offered where the channel is, by right-clicking it, and only while there
is something to watch: an offline channel's playlist is a signed URL to
nothing, and an entry that fails a few seconds after being pressed is
worse than one that isn't there. The same menu is how it stops.

Capped below 1080p60. That rendition is 5.8 Mbps of 60fps decode beside
a chat client, and the ladder underneath it still adapts. A refused
segment is read as an expired token rather than a dead stream, and
answered by loading whichever playback URL the daemon has since sent.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-06 00:58:07 -04:00
co-authored by Claude Opus 5
parent 7eec5bffa7
commit 9e3fa49321
12 changed files with 421 additions and 70 deletions
+1 -1
Submodule nobilis updated: 60d2c7d812...aad1125e6a
+8
View File
@@ -20,6 +20,7 @@
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"hls.js": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.7.2",
@@ -4495,6 +4496,13 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.2.tgz",
"integrity": "sha512-CW/pPvSOFIRsosbwxrYaE9ERmpTo5fbTqL7wCvuCFlqW1Bmb1K5fXsY7yiH95rmlr4rVuA7UXHbM/cIXQ6AXwg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+1
View File
@@ -30,6 +30,7 @@
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"hls.js": "^1.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.7.2",
+7
View File
@@ -21,6 +21,7 @@ import { JoinPanel } from './components/JoinPanel'
import { Toasts } from './components/Toasts'
import { IncomingCallPanel } from './components/IncomingCallPanel'
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { StreamStage } from './components/StreamStage'
import { FileDrop } from './components/FileDrop'
import { TransferPanel } from './components/TransferPanel'
import { Icon, IconButton } from './components/Icon'
@@ -41,6 +42,8 @@ export default function App(): JSX.Element {
// the conversation; this is the corner it retreats to.
const activeCall = useChat((s) => s.activeCall)
const activeGroupId = useChat((s) => s.activeGroupId)
// A stream being watched, which walks away the same way a call does.
const watching = useChat((s) => s.watching)
const joinPanelAccountId = useChat((s) => s.joinPanelAccountId)
const buffer = useActiveBuffer()
const joinAccount = accounts.find((a) => a.id === joinPanelAccountId)
@@ -48,6 +51,7 @@ export default function App(): JSX.Element {
// one being read - and not while a panel is covering the log either, since
// that is walking away from it too.
const callElsewhere = !!activeCall && (activeCall.bufferId !== activeBufferId || activePanel !== '')
const streamElsewhere = !!watching && (watching.bufferId !== activeBufferId || activePanel !== '')
const [sidebarFolded, setSidebarFolded] = usePref<boolean>('ui.sidebarFolded', false)
const [userListFolded, setUserListFolded] = usePref<boolean>('ui.userListFolded', false)
@@ -220,6 +224,9 @@ export default function App(): JSX.Element {
it belongs to it sits above the log instead - see MessageList - so
the two are never both on screen. */}
{callElsewhere && <CallStage mode="pip" />}
{/* A stream keeps playing when you go and read something else, and goes
to the same corner - it is the same surface, differently fed. */}
{streamElsewhere && <StreamStage mode="pip" />}
{/* Over everything for the same reason: a file is offered while you are
reading something else, and often in a conversation you are not. */}
<TransferPanel />
+3
View File
@@ -9,6 +9,7 @@ import { BufferFace } from './components/BufferFace'
import { FileDrop } from './components/FileDrop'
import { Toasts } from './components/Toasts'
import { CallStage, IncomingMatrixCall, ScreenPicker } from './components/CallStage'
import { StreamStage } from './components/StreamStage'
import { Icon, IconButton } from './components/Icon'
import { useActiveBuffer, useChat, usePref, usePrefsReady, useStore } from './state/hooks'
import { bufferDisplayName } from './lib/util'
@@ -41,6 +42,7 @@ export function Popout({ bufferId }: { bufferId: string }): JSX.Element {
const buffer = useActiveBuffer()
const linkUp = useChat((s) => s.linkUp)
const activeCall = useChat((s) => s.activeCall)
const watching = useChat((s) => s.watching)
// Folded by default: a popout is a narrow window opened to read one
// conversation, and a member list would take a third of it before anything
// has been read.
@@ -139,6 +141,7 @@ export function Popout({ bufferId }: { bufferId: string }): JSX.Element {
{/* A popped-out conversation is one conversation, so a call that is not
this one is in the corner and this one is above the log. */}
{activeCall && activeCall.bufferId !== bufferId && <CallStage mode="pip" />}
{watching && watching.bufferId !== bufferId && <StreamStage mode="pip" />}
<Toasts />
</div>
)
+8 -3
View File
@@ -942,16 +942,21 @@
width: 100%;
aspect-ratio: 16 / 9;
min-width: 0;
/* It is a button now - pressing a face is how somebody is singled out - so
the button's own chrome has to go before it can look like a picture. */
/* A tile is a button where pressing it means something - a face in a call
is how somebody is singled out - so the button's own chrome has to go
before it can look like a picture. A stream's single tile is not a
button, and should not offer a hand to a click that does nothing. */
padding: 0;
border: none;
border-radius: var(--corner-radius-small);
overflow: hidden;
cursor: pointer;
background: var(--surface-container-highest);
}
button.video-tile {
cursor: pointer;
}
/*
* One person, large, with everybody else in a row underneath.
*
@@ -286,6 +286,8 @@ export function BufferList(): JSX.Element {
: undefined
}
autojoins={store.autojoins(b.accountId, bufferDisplayName(b.name))}
onWatch={b.accountId.startsWith('kick:') ? () => store.watchStream(b.id) : undefined}
onStopWatching={() => store.stopWatching()}
onOpenInBrowser={
b.accountId.startsWith('kick:')
? () => void window.moho.openExternal(`https://kick.com/${bufferDisplayName(b.name)}`)
@@ -694,6 +696,9 @@ interface BufferRowProps {
onToggleAutojoin?: () => void
/** Kick channels only: watch the stream where streams are watched. */
onOpenInBrowser?: () => void
/** Kick channels only: play the stream in this window, or stop. */
onWatch?: () => void
onStopWatching?: () => void
/** Whether it is in that list now. */
autojoins?: boolean
onHide: () => void
@@ -732,6 +737,8 @@ function BufferRow({
onToggleAutojoin,
autojoins,
onOpenInBrowser,
onWatch,
onStopWatching,
onHide,
onClose,
onCall,
@@ -754,6 +761,9 @@ function BufferRow({
// than the map, so a viewer count ticking over in one channel does not
// re-render every row in the list.
const live = useChat((s) => s.kickStreams[buffer.id]?.live ?? false)
// Whether this channel's stream is the one in the window. Selected down to
// a boolean for the same reason as `live` above.
const watched = useChat((s) => s.watching?.bufferId === buffer.id)
// Under an account header the row's own kind is what's worth showing (a
// channel vs a DM vs the server buffer). A pinned row has no header above
@@ -800,6 +810,16 @@ function BufferRow({
// A Kick channel is a stream as well as a chat, and the stream is not
// something this client shows - so the way to watch it belongs on the
// row, next to the way to open its chat in a window.
// Watching is offered only while there is something to watch: an offline
// channel's playlist is a signed URL to nothing, and an entry that fails
// a few seconds after being pressed is worse than one that isn't there.
...(onWatch && (live || watched)
? ([
watched
? { label: 'Stop watching', icon: 'stop_circle', danger: true, onClick: onStopWatching! }
: { label: 'Watch the stream', icon: 'live_tv', onClick: onWatch }
] as MenuEntry[])
: []),
...(onOpenInBrowser
? ([{ label: 'Open in browser', icon: 'public', onClick: onOpenInBrowser }] as MenuEntry[])
: []),
+26 -66
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { VideoStage } from './VideoStage'
import { Avatar } from './Avatar'
import { useChat, useStore } from '../state/hooks'
import type { CallTile } from '../state/store'
@@ -144,25 +145,6 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX
const phase =
call.phase === 'ringing' ? 'Ringing…' : call.phase === 'connecting' ? 'Connecting…' : 'Connected'
// Put away without being hung up. A call somebody is listening to while
// reading something else does not need a picture on screen, and closing the
// picture must not close the call - so this is a bar with a way back.
if (minimized) {
return (
<div className={classes('call-stage', 'minimized', mode === 'pip' && 'pip')}>
<div className="call-stage-controls">
<Icon name="call" size={16} />
<span className="small muted ellipsis">
{phase}
{where ? ` · ${bufferDisplayName(where)}` : ''}
</span>
<IconButton name="expand_less" title="Show the call" onClick={() => store.setCallMinimized(false)} />
<IconButton name="call_end" title="Hang up" className="calling" onClick={() => store.hangUpMatrixCall()} />
</div>
</div>
)
}
// Who is in it, named. Only in the corner: in the conversation the tiles are
// large enough to read the names off, and a second list of them would be the
// same information twice.
@@ -215,27 +197,32 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX
)
return (
<div className={classes('call-stage', mode === 'pip' && 'pip')}>
{mode === 'pip' && (
<div className="call-stage-title">
<Icon name="call" size={14} />
<span className="small ellipsis" title={everybody}>
{everybody || phase}
</span>
{/* Back to where the call is being held, which turns this corner
back into the conversation - the picture stops being a corner of
somewhere else and becomes the room you are in. */}
<VideoStage
mode={mode}
title={everybody}
subtitle={`${phase}${where ? ` · ${bufferDisplayName(where)}` : ''}`}
minimized={minimized}
onMinimized={(m) => store.setCallMinimized(m)}
onGoTo={() => void store.selectBuffer(call.bufferId)}
onEnd={() => store.hangUpMatrixCall()}
endLabel="Hang up"
controls={
<>
<IconButton
name="open_in_full"
size={14}
title="Go to the call"
onClick={() => void store.selectBuffer(call.bufferId)}
name={call.muted ? 'mic_off' : 'mic'}
title={call.muted ? 'Unmute' : 'Mute'}
className={call.muted ? 'calling' : undefined}
onClick={() => store.toggleCallMute()}
/>
{/* Where a window's own control would be, because in the corner this
is a window: the thing you close is closed from its top right. */}
<IconButton name="remove" size={14} title="Minimise" onClick={() => store.setCallMinimized(true)} />
</div>
)}
<IconButton
name={call.sharingScreen ? 'stop_screen_share' : 'screen_share'}
title={call.sharingScreen ? 'Stop sharing' : 'Share a screen or window'}
className={call.sharingScreen ? 'active' : undefined}
onClick={() => void store.toggleScreenShare()}
/>
</>
}
>
{/* Everybody in the call, one tile each, in as square a grid as the
number allows - which is what every client with more than two people
in a call has arrived at, because faces are roughly square and a row
@@ -278,34 +265,7 @@ export function CallStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX
</div>
)}
<div className="call-stage-controls">
{/* Which conversation this call is in, because the stage no longer
sits under it: a call outlives looking at something else. */}
<span className="small muted ellipsis">
{phase}
{where ? ` · ${bufferDisplayName(where)}` : ''}
</span>
<IconButton
name={call.muted ? 'mic_off' : 'mic'}
title={call.muted ? 'Unmute' : 'Mute'}
className={call.muted ? 'calling' : undefined}
onClick={() => store.toggleCallMute()}
/>
<IconButton
name={call.sharingScreen ? 'stop_screen_share' : 'screen_share'}
title={call.sharingScreen ? 'Stop sharing' : 'Share a screen or window'}
className={call.sharingScreen ? 'active' : undefined}
onClick={() => void store.toggleScreenShare()}
/>
{/* Out of the way without ending: the call keeps going, and the bar
that is left says so and offers it back. In the corner this sits in
the title bar instead, where a window's own controls live. */}
{mode !== 'pip' && (
<IconButton name="expand_more" title="Minimise" onClick={() => store.setCallMinimized(true)} />
)}
<IconButton name="call_end" title="Hang up" className="calling" onClick={() => store.hangUpMatrixCall()} />
</div>
</div>
</VideoStage>
)
}
@@ -4,6 +4,7 @@ import { MessageRow, type MessageMode } from './MessageRow'
import { LiveCards } from './LiveCards'
import { CallView } from './CallView'
import { CallStage } from './CallStage'
import { StreamStage } from './StreamStage'
import { useChat, usePref, useStore } from '../state/hooks'
import { bufferDisplayName, isChatKind } from '../lib/util'
import type { ChannelIndex } from '../lib/format'
@@ -117,6 +118,7 @@ export function MessageList(): JSX.Element {
const bufferId = useChat((s) => s.activeBufferId)
const messagesByBuffer = useChat((s) => s.messagesByBuffer)
const activeCall = useChat((s) => s.activeCall)
const watching = useChat((s) => s.watching)
const loadingMore = useChat((s) => s.loadingMore)
const loadingNewer = useChat((s) => s.loadingNewer)
const historyGapAfter = useChat((s) => s.historyGapAfter)
@@ -454,6 +456,10 @@ export function MessageList(): JSX.Element {
and the conversation it is in are the same conversation. It moves to
a corner of its own only when you walk away from it - see App. */}
{activeCall?.bufferId === bufferId && <CallStage mode="inline" />}
{/* And a Kick stream in the same place, for the same reason: the picture
and the chat about it are one conversation. Never both at once - the
store refuses to start a stream during a call. */}
{watching?.bufferId === bufferId && <StreamStage mode="inline" />}
{/* Over the log rather than in it: a poll is one question being
answered while the chat keeps moving underneath, and a line in the
log would scroll away mid-vote. */}
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useRef, useState } from 'react'
import Hls from 'hls.js'
import { IconButton } from './Icon'
import { VideoStage } from './VideoStage'
import { fitTiles } from './CallStage'
import { useChat, useStore } from '../state/hooks'
import { bufferDisplayName } from '../lib/util'
/**
* A Kick channel's stream, in the surface a call uses.
*
* The other half of the picture this window can show. A call is real-time
* media negotiated with somebody and arrives as a `MediaStream` on
* `srcObject`; a stream is one-to-many HLS off a CDN and arrives as a playlist
* that has to be demuxed into Media Source Extensions - hls.js does that, and
* it is the only thing here that a call does not also need.
*
* Everything above the pipe is shared: the frame, the corner it retreats to,
* the minimise, the sizing. Which is the point - this is not a player, it is
* the same video surface with a third source feeding it.
*
* Receiving only. Nothing here broadcasts, and there is nobody else in it: a
* 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
export function StreamStage({ mode = 'inline' }: { mode?: 'inline' | 'pip' }): JSX.Element | null {
const store = useStore()
const watching = useChat((s) => s.watching)
const minimized = useChat((s) => s.watchMinimized)
const streams = useChat((s) => s.kickStreams)
const buffers = useChat((s) => s.buffers)
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. */
const latest = useRef('')
const [room, setRoom] = useState({ width: 640, height: 260 })
const [muted, setMuted] = useState(false)
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.
const url = stream?.playbackUrl ?? ''
latest.current = 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.
useEffect(() => {
const el = frame.current
if (!el) return
const measure = (): void =>
setRoom({
width: el.clientWidth,
height: Math.min(window.innerHeight * (mode === 'pip' ? 0.3 : 0.45), mode === 'pip' ? 260 : 460)
})
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
window.addEventListener('resize', measure)
return () => {
observer.disconnect()
window.removeEventListener('resize', measure)
}
}, [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.
useEffect(() => {
const el = video.current
if (!el || !url || 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
})
hls.current = player
player.attachMedia(el)
player.loadSource(url)
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
void el.play().catch(() => {
/* A picture that will not start is not worth an error dialog. */
})
})
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.
if (latest.current) player.loadSource(latest.current)
player.startLoad()
return
}
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
player.recoverMediaError()
return
}
store.toast('error', 'That stream stopped playing')
store.stopWatching()
})
return () => {
player.destroy()
hls.current = null
}
}, [url, minimized, store, watching?.bufferId])
// A stream that has ended is not a picture any more, and the surface should
// not sit there showing the last frame of it.
useEffect(() => {
if (watching && stream && !stream.live) {
store.toast('info', `${watching.title} has finished streaming`)
store.stopWatching()
}
}, [watching, stream, store])
if (!watching) return null
const where = buffers.find((b) => b.id === watching.bufferId)?.name ?? ''
const fit = fitTiles(1, room.width, room.height, Infinity)
const watchers = stream?.viewers
const subtitle = `Live${watchers ? ` · ${watchers.toLocaleString()} watching` : ''}${
where ? ` · ${bufferDisplayName(where)}` : ''
}`
return (
<VideoStage
mode={mode}
title={stream?.title || watching.title}
subtitle={subtitle}
minimized={minimized}
onMinimized={(m) => store.setWatchMinimized(m)}
onGoTo={() => void store.selectBuffer(watching.bufferId)}
onEnd={() => store.stopWatching()}
endLabel="Stop watching"
endIcon="stop_circle"
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
}}
/>
}
>
<div className="video-stage" ref={frame}>
<div className="video-tile" style={{ width: `${Math.floor(fit.tileWidth)}px` }}>
<video ref={video} className="video-tile-picture" autoPlay playsInline />
</div>
</div>
</VideoStage>
)
}
@@ -0,0 +1,99 @@
import type { ReactNode } from 'react'
import { Icon, IconButton } from './Icon'
import { classes } from '../lib/util'
/**
* The frame a moving picture lives in, whatever is feeding it.
*
* A Matrix call and a Kick stream have almost nothing in common underneath -
* one is real-time media negotiated with another person, the other is a
* signed playlist off a CDN - but on screen they are the same thing: a picture
* above the conversation, a corner it retreats to when you walk away, a way to
* put it down without ending it, and a way to end it. That is what this holds,
* so the two sources argue about codecs rather than about margins.
*
* What varies goes in as children and controls; what must not vary - where the
* minimise lives, what the corner's title bar looks like, what the put-away
* bar says - lives here.
*/
export function VideoStage({
mode,
/** Who or what is on screen, named. Shown in the corner's title bar. */
title,
/** What it is doing: "Connected", "Live · 4,102 watching". */
subtitle,
minimized,
onMinimized,
/** Back to the conversation this belongs to, from the corner. */
onGoTo,
/** The one that ends it, and what to call that. */
onEnd,
endLabel,
endIcon = 'call_end',
/** Buttons that belong to this source: a microphone, a volume. */
controls,
children
}: {
mode: 'inline' | 'pip'
title: string
subtitle: string
minimized: boolean
onMinimized: (minimized: boolean) => void
onGoTo: () => void
onEnd: () => void
endLabel: string
endIcon?: string
controls?: ReactNode
children: ReactNode
}): JSX.Element {
// Put away without being ended. Something being listened to while reading
// something else does not need a picture on screen, and closing the picture
// must not close what is behind it - so this is a bar with a way back.
if (minimized) {
return (
<div className={classes('call-stage', 'minimized', mode === 'pip' && 'pip')}>
<div className="call-stage-controls">
<Icon name={endIcon === 'call_end' ? 'call' : 'live_tv'} size={16} />
<span className="small muted ellipsis">{subtitle}</span>
<IconButton name="expand_less" title="Show it again" onClick={() => onMinimized(false)} />
<IconButton name={endIcon} title={endLabel} className="calling" onClick={onEnd} />
</div>
</div>
)
}
return (
<div className={classes('call-stage', mode === 'pip' && 'pip')}>
{mode === 'pip' && (
<div className="call-stage-title">
<Icon name={endIcon === 'call_end' ? 'call' : 'live_tv'} size={14} />
<span className="small ellipsis" title={title}>
{title || subtitle}
</span>
{/* Back to where this is happening, which turns the corner back into
the conversation - the picture stops being a corner of somewhere
else and becomes the room you are in. */}
<IconButton name="open_in_full" size={14} title="Go to it" onClick={onGoTo} />
{/* Where a window's own control would be, because in the corner this
is a window: the thing you close is closed from its top right. */}
<IconButton name="remove" size={14} title="Minimise" onClick={() => onMinimized(true)} />
</div>
)}
{children}
<div className="call-stage-controls">
{/* Which conversation this belongs to, because the picture no longer
sits under it: it outlives looking at something else. */}
<span className="small muted ellipsis">{subtitle}</span>
{controls}
{/* Out of the way without ending. In the corner this sits in the title
bar instead, where a window's own controls live. */}
{mode !== 'pip' && (
<IconButton name="expand_more" title="Minimise" onClick={() => onMinimized(true)} />
)}
<IconButton name={endIcon} title={endLabel} className="calling" onClick={onEnd} />
</div>
</div>
)
}
+62
View File
@@ -248,6 +248,15 @@ export interface KickStream {
* follows, and a button offering to follow there would fail when pressed.
*/
following?: boolean | null
/**
* 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.
*/
playbackUrl?: string | null
}
export type ActivePanel = '' | 'accounts' | 'settings' | 'join' | 'downloads'
@@ -417,6 +426,17 @@ export interface ChatState {
ringingCall: RingingCall | null
/** The call put away without being hung up. */
callMinimized: boolean
/**
* The Kick stream being watched, if any.
*
* Beside the call rather than inside it: they draw into the same surface
* and only one of them can have it, but a stream is a broadcast being
* received and a call is a conversation being held, and the daemon knows
* nothing about this one - it is a URL and a video element.
*/
watching: { bufferId: string; accountId: string; title: string } | null
/** The stream put away without being stopped. */
watchMinimized: boolean
/** The screens and windows on offer, while somebody is choosing one. */
screenSources: { id: string; name: string; thumbnail: string }[] | null
activeCall: {
@@ -512,6 +532,8 @@ const INITIAL: ChatState = {
discordFriends: {},
ringingCall: null,
callMinimized: false,
watching: null,
watchMinimized: false,
screenSources: null,
activeCall: null,
matrixIgnored: {},
@@ -1705,6 +1727,46 @@ export class ChatStore {
this.set({ callMinimized: minimized })
}
/**
* Starts watching a Kick channel's stream in this window.
*
* Only what is on air: the playlist for an offline channel is a signed URL
* to nothing, and a player pointed at it fails a few seconds later with an
* error about segments rather than saying the obvious thing.
*
* One at a time, and never beside a call - both draw into the same surface,
* and a call is the one that cannot be restarted with a click.
*/
watchStream(bufferId: string): void {
const stream = this.state.kickStreams[bufferId]
const buffer = this.state.buffers.find((b) => b.id === bufferId)
if (!buffer) return
if (!stream?.live || !stream.playbackUrl) {
this.toast('info', `${bufferDisplayName(buffer.name)} is not streaming right now`)
return
}
if (this.state.activeCall) {
this.toast('info', 'Finish the call first - the picture has one place to go')
return
}
this.set({
watchMinimized: false,
watching: {
bufferId,
accountId: buffer.accountId,
title: stream.title || bufferDisplayName(buffer.name)
}
})
}
stopWatching(): void {
this.set({ watching: null, watchMinimized: false })
}
setWatchMinimized(minimized: boolean): void {
this.set({ watchMinimized: minimized })
}
hangUpMatrixCall(): void {
this.matrixCalls.end()
}