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:
+1
-1
Submodule nobilis updated: 60d2c7d812...aad1125e6a
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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[])
|
||||
: []),
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user