diff --git a/nobilis b/nobilis index a5751c0..ac6ef9c 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit a5751c0b685f2486b68972b94e9644c2c4a543a3 +Subproject commit ac6ef9cbb5241a6e7e2c455d20f3f9aa880f5ffa diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 07b7c9b..bea74b1 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -7314,3 +7314,66 @@ video.lightbox-media { background: var(--surface-container-high); border-bottom: 1px solid var(--outline-light); } + +/* Something a call button asks before it acts, above the button. */ +.stage-popover { + position: fixed; + z-index: 1000; + transform: translateX(-50%); +} + +/* Discord's Go Live question: how good a stream. */ +.go-live { + display: flex; + flex-direction: column; + gap: var(--spacing-s); + width: max-content; + max-width: 92vw; + padding: var(--spacing-m); + border: 1px solid var(--outline-medium); + border-radius: var(--corner-radius); + background: var(--surface-container-high); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); +} + +.go-live-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.go-live-note { + margin: 0; +} + +.segmented { + display: inline-flex; + flex-wrap: wrap; + border: 1px solid var(--outline-medium); + border-radius: var(--corner-radius-small); + overflow: hidden; +} + +.segmented button { + padding: 4px 10px; + border: none; + background: transparent; + color: inherit; + font: inherit; + font-variant-numeric: tabular-nums; + cursor: pointer; +} + +.segmented button + button { + border-left: 1px solid var(--outline-medium); +} + +.segmented button.active { + background: var(--primary); + color: var(--primary-text); +} + +/* Your own camera, as you would see yourself in a mirror. */ +.stage-picture.mirrored { + transform: scaleX(-1); +} diff --git a/src/renderer/src/components/CallView.tsx b/src/renderer/src/components/CallView.tsx index 40cbf1d..ea5e658 100644 --- a/src/renderer/src/components/CallView.tsx +++ b/src/renderer/src/components/CallView.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Icon } from './Icon' import { useChat, useStore } from '../state/hooks' import { Stage, type StageButton, type StageTile } from './stage/Stage' import { FeedPicture } from './stage/FeedPicture' import { cameraKey, streamKey } from '../lib/framefeed' +import type { VideoQuality } from '../lib/discordscreen' +import { usePref } from '../state/hooks' import type { VoiceMember, VoiceSession } from '../../../shared/wire' /** How often to ask who is talking. */ @@ -76,6 +78,8 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where: const buffers = useChat((s) => s.buffers) const cameras = useChat((s) => s.discordCameras) const sharing = useChat((s) => s.discordSharing) + const ownCamera = useChat((s) => s.discordCamera) + const [choosing, setChoosing] = useState(false) const watching = useChat((s) => s.discordWatching) const voicePrefs = useChat((s) => s.voicePrefs) const [members, setMembers] = useState([]) @@ -139,7 +143,14 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where: speaking: m.isSelf ? speaking.self !== undefined : (speaking[m.userId] ?? 0) > 0, muted: m.muted, deafened: m.deafened, - picture: camera ? () => : undefined + // Your own camera is drawn from the capture rather than from the + // call: Discord does not send a picture back to the one sending it. + picture: + m.isSelf && ownCamera + ? () => + : camera + ? () => + : undefined }) // A shared screen is a tile of its own, the way Discord shows it: dark // until somebody chooses to watch, because watching opens a connection. @@ -178,11 +189,30 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where: off: voicePrefs.deafened, onClick: () => void store.setVoiceMuted({ deafened: !voicePrefs.deafened }) }, + { + icon: ownCamera ? 'videocam' : 'videocam_off', + label: ownCamera ? 'Turn the camera off' : 'Turn the camera on', + off: !ownCamera, + onClick: () => void store.toggleDiscordCamera(accountId) + }, { icon: sharing ? 'stop_screen_share' : 'screen_share', label: sharing ? 'Stop sharing' : 'Share your screen', active: sharing, - onClick: () => void store.toggleScreenShare() + // Stopping needs no questions; starting asks how good a stream, the + // way Discord's own client does, beside the button that asked. + onClick: () => (sharing ? void store.toggleScreenShare() : setChoosing(!choosing)), + popover: + choosing && !sharing ? ( + { + setChoosing(false) + void store.toggleScreenShare(quality) + }} + onClose={() => setChoosing(false)} + /> + ) : null }, { icon: 'call_end', label: 'Disconnect', danger: true, onClick: () => void store.leaveVoice(accountId) } ] @@ -201,3 +231,119 @@ export function DiscordStage({ session, where }: { session: VoiceSession; where: /> ) } + +/** This end's own camera, from the capture. Mirrored, as a mirror is. */ +function LocalPicture({ stream }: { stream: MediaStream }): JSX.Element { + return ( +