From c8c6bd44652de21ee2b22097848c19d4ecb17261 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Sat, 26 Sep 2026 00:30:16 -0700 Subject: [PATCH] Consolidate storage, sync, NIP-98, push server, OpenRouter, audio, call and title helpers in src/app and src/lib --- .agents/skills/flotilla-architecture/SKILL.md | 15 +- .agents/skills/flotilla-model/SKILL.md | 2 +- .agents/skills/flotilla-model/kinds.md | 2 +- .agents/skills/flotilla-state/SKILL.md | 22 +- src/app/app.css | 4 - src/app/call.ts | 180 +-------------- src/app/callEngine.ts | 95 +++++++- src/app/commands.ts | 21 +- src/app/components/CallControlBar.svelte | 12 +- src/app/components/HomeActivity.svelte | 2 +- src/app/components/HomeInbox.svelte | 3 +- src/app/components/MicLevelMeter.svelte | 60 ++--- src/app/components/RoomChat.svelte | 14 +- .../components/SocketStatusIndicator.svelte | 18 +- src/app/components/SocketStatusToast.svelte | 4 +- src/app/components/VideoCallContent.svelte | 116 ++-------- .../VoiceCallAudioSettingsDialog.svelte | 19 +- src/app/components/VoiceRoomItem.svelte | 13 +- src/app/components/VoiceRoomJoinDialog.svelte | 11 +- src/app/dictation.ts | 46 +--- src/app/hosting.ts | 32 +-- src/app/inbox.ts | 41 ---- src/app/notifications.ts | 118 +++++----- src/app/openrouter.ts | 25 ++ src/app/push/adapters/capacitor.ts | 30 ++- src/app/push/server.ts | 40 ++++ src/app/relays.ts | 50 ++-- src/app/reminders.ts | 40 +--- src/app/settings.ts | 48 ++-- src/app/signer.ts | 33 ++- src/app/speech.ts | 69 +----- src/app/storage.ts | 215 ++++++++---------- src/app/sync.ts | 149 +++++------- src/app/title.ts | 60 ++--- src/lib/audio.ts | 89 ++++++++ src/lib/tileGrid.ts | 174 ++++++++++++++ 36 files changed, 878 insertions(+), 994 deletions(-) delete mode 100644 src/app/inbox.ts create mode 100644 src/app/openrouter.ts create mode 100644 src/app/push/server.ts create mode 100644 src/lib/audio.ts create mode 100644 src/lib/tileGrid.ts diff --git a/.agents/skills/flotilla-architecture/SKILL.md b/.agents/skills/flotilla-architecture/SKILL.md index 7277760f..0f946883 100644 --- a/.agents/skills/flotilla-architecture/SKILL.md +++ b/.agents/skills/flotilla-architecture/SKILL.md @@ -114,19 +114,22 @@ imports. `flotilla-views` covers component conventions. - `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts` - `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression) -- `notifications.ts` (unread state, badges), `inbox.ts` (the home inbox) +- `notifications.ts` (unread state, badges, the home inbox) **Messaging and calls** -- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices, and the - `AbortError`/`TimeoutError` a join rejects with) +- `chats.ts`, `call.ts` (call state), `callEngine.ts` (LiveKit join, leave, devices, the video + tiles a call shows, and the `AbortError`/`TimeoutError` a join rejects with). `call.ts` stays + free of livekit values, since the root layout loads it and `callEngine.ts` is imported lazily. **Identity and payments** - `nip46.ts`, `pomade.ts` (email login), `lightning.ts` (wallet, invoices), `healthChecks.ts` (prompts for missing inbox/outbox relays) **Platform and voice** -- `push/` (notification adapters), `share.ts`, `deepLinks.ts`, `keyboard.ts` -- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through OpenRouter +- `push/` (notification adapters, and `server.ts` for the push server's HTTP API), `share.ts`, + `deepLinks.ts`, `keyboard.ts` +- `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through `postOpenRouter` in + `openrouter.ts` A feature gets a `src/app/.ts` only when it has non-UI logic to hold. Polls, goals, threads and calendar events have no module; their components use domain readers directly. @@ -142,6 +145,8 @@ client. - `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on - `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to `@app/feeds`, which loads events. +- `audio.ts` (device lists, a live input level, WAV encoding), `tileGrid.ts` (the call grid's + geometry) - `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next) - `test/`: the DEV-only hooks the e2e harness injects through - `components/`: the design system, entered through `theme.css` (see `flotilla-views`), which diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md index 2adb3301..58dff5e4 100644 --- a/.agents/skills/flotilla-model/SKILL.md +++ b/.agents/skills/flotilla-model/SKILL.md @@ -300,7 +300,7 @@ These predate the rule or are waiting on welshman. Don't copy them; fix one when - **No factory yet:** `MESSAGE` (9) in `RoomChat` and `publishRoomQuote`; 9009 room invites in `src/app/access.ts`; `LIVEKIT_PARTICIPANTS` (39004, a local constant in `src/app/call.ts`); `STATUS` (30315) in `src/app/statuses.ts`, where `ProfileStatus` still uses `tags.find`; push - subscriptions (a literal 30390) in `src/app/push/adapters/capacitor.ts`; `DIRECT_MESSAGE_FILE` + subscriptions (`PUSH_SUBSCRIPTION`, 30390, local to `src/app/push/adapters/capacitor.ts`); `DIRECT_MESSAGE_FILE` (15) in `Chat.svelte`. - **Factory exists but bypassed:** DMs built with `makeEvent` in `Chat.svelte` (`DirectMessage`); relay lists in `SignUp.svelte` (`RelayList`, `MessagingRelayList`); deletes in diff --git a/.agents/skills/flotilla-model/kinds.md b/.agents/skills/flotilla-model/kinds.md index 21a39a02..2a254319 100644 --- a/.agents/skills/flotilla-model/kinds.md +++ b/.agents/skills/flotilla-model/kinds.md @@ -52,4 +52,4 @@ Gift wraps are only synced once the user opts in (`shouldUnwrap` in `src/app/syn | Follows and mutes | `FOLLOWS` (3), `MUTES` (10000) | `FollowList`, `MuteList` | `src/app/social.ts` | people pages, muting | | Relay lists | `RELAYS` (10002), `MESSAGING_RELAYS` (10050), `SEARCH_RELAYS` (10007), `BLOCKED_RELAYS` (10006), `BLOSSOM_SERVERS` (10063) | `RelayList`, `MessagingRelayList`, `SearchRelayList`, `BlockedRelayList`, `BlossomServerList` | plugins in `src/app/core.ts` | `settings/*` | | Account deletion | `VANISH` (62, written as a literal), `DELETE` (5) | none; `Delete` bypassed | none | `ProfileDelete` | -| Push subscriptions | 30390 (a literal, no constant) | none | `src/app/push/adapters/capacitor.ts` | background | +| Push subscriptions | 30390 (`PUSH_SUBSCRIPTION`, local) | none | `src/app/push/adapters/capacitor.ts` | background | diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md index 55969e4b..ec113d0a 100644 --- a/.agents/skills/flotilla-state/SKILL.md +++ b/.agents/skills/flotilla-state/SKILL.md @@ -62,8 +62,8 @@ The following run outside that gate: - every app policy This code reads `app.get().user?.pubkey` and bails when it is missing, as -`syncUserSpaceMembership` in `src/app/sync.ts` and `nip98Header` in `src/app/notifications.ts` -do. +`syncUserSpaceMembership` in `src/app/sync.ts` and `syncCheckedRemote` in +`src/app/notifications.ts` do. Stores derived from `user` throw as soon as they are subscribed signed out. That includes `isEventMuted` (`social.ts`), `deriveUserIsRoomAdmin` (`rooms.ts`) and `deriveUserCanCreateRoom` @@ -106,7 +106,8 @@ Bookkeeping for one identity lives on a plugin instance, so it is discarded with `Commands` in `src/app/commands.ts` keeps its `pulled` set on the plugin for this reason. Module-level caches are fine when their contents do not depend on who is signed in, or when what -they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores. +they cache is itself a rebinding store. `deriveCommandsForUrl` (`commands.ts`) caches `fromApp` +stores with `simpleCache`. `hasBlossomSupport` (`uploads.ts`) and `deriveHasLivekit` (`relays.ts`) use `simpleCache` from `@welshman/lib` to share one store per url across every component that asks. @@ -272,8 +273,9 @@ Room messages, threads and other content are not kept, and background sync pulls next tick. To persist another kind, add it to `kinds` in `storage.ts`. To persist a new map-backed plugin, -add a `TABLES` entry and an `init*` method shaped like `initHandles`: load the rows, subscribe -to `onItem`, and batch the writes. +add a `TABLES` entry and an `init*` method that calls `persistMapPlugin` with the table, the +plugin, and how to turn a row into a key and item and back. It loads the rows, subscribes to +`onItem`, and batches the writes. ### kv, ss and synced stores @@ -346,8 +348,9 @@ call tears down the previous run. - `syncDMs` pulls gift wraps from the user's messaging relays, only when `shouldUnwrap` is on. `pullAndListen` is a negentropy `Sync.pull` plus a live `limit: 0` request, both stopped through -an `AbortController`. `syncSpaces` and `syncUserData` diff their `unsubscribersBy*` maps against -the room list, so a new filter goes into the right `pullAndListen` call. +an `AbortController`. `syncUserData`, `syncSpaces` and `syncDMs` each keep one sync per url with +`makeKeyedSyncs`, whose `reconcile(urls)` starts the new ones and stops the dropped ones. A run +reads the pubkey once when it starts, since login triggers a fresh `syncApplicationData`. Background sync keeps badges, navigation, the inbox and notifications correct on any page. Data that must be current app-wide belongs here. Data only one page shows is loaded by that page's @@ -415,8 +418,9 @@ NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return ## Other app-level stores - **A join over many sources.** `notifications.ts` derives `latestActivityByPath`, then - `allNotifications`, then `notifications` and the counts. `inbox.ts` derives from the same two - stores, so the inbox matches the badges. + `allNotifications`, then `notifications` and the counts. The inbox stores + (`inboxConversations`, `inboxSpaceContent`) derive from the same two, so the inbox matches the + badges. - **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`, `currentCallSession`, …). - **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`. diff --git a/src/app/app.css b/src/app/app.css index 2c74193d..2c11a9b7 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -7,8 +7,4 @@ @import "./components/room.css"; @import "./components/space-menu.css"; -/* SocketStatusIndicator builds its class as bg-{status.theme}, which the source - scanner can't see. Must stay below every @import to keep those valid. */ -@source inline("bg-success bg-warning bg-error bg-gray-500"); - @config "../../tailwind.config.js"; diff --git a/src/app/call.ts b/src/app/call.ts index d2ba972b..7268e631 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -9,181 +9,6 @@ import {events, network} from "@app/core" export const LIVEKIT_PARTICIPANTS = 39004 -/** The lower aspect bound is 1:1 on landscape and 3:4 on portrait, and the upper is 16:9. */ -const TILE_ASPECT_PORTRAIT = 3 / 4 -const TILE_ASPECT_LANDSCAPE = 16 / 9 -const TILE_GAP = 8 - -/** Below this the grid scrolls rather than forcing portrait tiles, which is ~6-8 tiles on a portrait phone. */ -const MIN_TILE_HEIGHT = 120 - -export type TileRow = { - columnCount: number - tileWidth: number - tileHeight: number - /** Total width of this row including gaps — for the template's max-width */ - rowWidth: number - aspectRatio: number -} - -/** All tiles share one size. Full rows fill the container width and a partial row is centered. */ -export type AdaptiveTileGrid = { - rows: TileRow[] - totalWidth: number - totalHeight: number -} - -/** Score for comparing candidate layouts. Lower is better. */ -type LayoutScore = { - /** Penalty for vertical overflow: 0 if none, else huge */ - verticalOverflowPenalty: number - /** Penalty for horizontal overflow: 0 if none, else huge */ - horizontalOverflowPenalty: number - /** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */ - minTileHeightPenalty: number - /** Negative tile area (we want to maximize area, so negate) */ - negativeArea: number - /** Leftover whitespace (underflow) in px */ - whitespace: number - /** Average absolute deviation from 16:9 across all tiles */ - aspectDeviation: number -} - -const compareScores = (a: LayoutScore, b: LayoutScore): number => { - const fields: (keyof LayoutScore)[] = [ - "verticalOverflowPenalty", - "horizontalOverflowPenalty", - "minTileHeightPenalty", - "negativeArea", - "whitespace", - "aspectDeviation", - ] - for (const field of fields) { - if (a[field] !== b[field]) { - return a[field] - b[field] - } - } - return 0 -} - -/** The largest tile that fits, shrunk to whichever dimension is more constraining. */ -const fitTile = (availWidth: number, availHeight: number, minAspect: number) => { - const fillAspect = availWidth / availHeight - const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect)) - if (fillAspect >= aspectRatio) { - const tileWidth = availHeight * aspectRatio - return {tileWidth, tileHeight: availHeight, aspectRatio} - } - const tileHeight = availWidth / aspectRatio - return {tileWidth: availWidth, tileHeight, aspectRatio} -} - -/** A candidate grid for a given column count, with the partial last row centered. */ -const buildCandidate = ( - tileCount: number, - columnCount: number, - containerWidth: number, - containerHeight: number, - minAspect: number, -): AdaptiveTileGrid | undefined => { - const gap = TILE_GAP - const fullRowCount = Math.floor(tileCount / columnCount) - const remainder = tileCount % columnCount - const totalRowCount = fullRowCount + (remainder > 0 ? 1 : 0) - - const availHeight = (containerHeight - (totalRowCount - 1) * gap) / totalRowCount - const availWidth = (containerWidth - (columnCount - 1) * gap) / columnCount - if (availWidth <= 0 || availHeight <= 0) { - return undefined - } - - const {tileWidth, tileHeight, aspectRatio} = fitTile(availWidth, availHeight, minAspect) - - const rows: TileRow[] = [] - for (let r = 0; r < fullRowCount; r++) { - rows.push({ - columnCount, - tileWidth, - tileHeight, - rowWidth: columnCount * tileWidth + (columnCount - 1) * gap, - aspectRatio, - }) - } - if (remainder > 0) { - rows.push({ - columnCount: remainder, - tileWidth, - tileHeight, - rowWidth: remainder * tileWidth + (remainder - 1) * gap, - aspectRatio, - }) - } - - const totalHeight = totalRowCount * tileHeight + (totalRowCount - 1) * gap - const totalWidth = columnCount * tileWidth + (columnCount - 1) * gap - - return {rows, totalWidth, totalHeight} -} - -/** Scored on overflow first, then whitespace, then distance from 16:9, then tile size. */ -export const computeAdaptiveGrid = ( - tileCount: number, - containerWidth: number, - containerHeight: number, -): AdaptiveTileGrid | undefined => { - if (tileCount <= 0 || containerWidth <= 0 || containerHeight <= 0) { - return undefined - } - - const minAspect = containerWidth / containerHeight >= 4 / 3 ? 1 : TILE_ASPECT_PORTRAIT - - let best: AdaptiveTileGrid | undefined - let bestScore: LayoutScore | undefined - - for (let columnCount = 1; columnCount <= tileCount; columnCount++) { - const candidate = buildCandidate( - tileCount, - columnCount, - containerWidth, - containerHeight, - minAspect, - ) - if (!candidate) { - continue - } - - const {tileWidth, tileHeight, aspectRatio} = candidate.rows[0] - const verticalOverflow = Math.max(0, candidate.totalHeight - containerHeight) - const horizontalOverflow = Math.max(0, candidate.totalWidth - containerWidth) - const whitespace = - Math.max(0, containerWidth - candidate.totalWidth) + - Math.max(0, containerHeight - candidate.totalHeight) - const totalArea = tileWidth * tileHeight * tileCount - - const overflowPenaltyMultiplier = tileHeight >= MIN_TILE_HEIGHT ? 1_000_000 : 1 - const verticalOverflowPenalty = verticalOverflow * overflowPenaltyMultiplier - const horizontalOverflowPenalty = horizontalOverflow * overflowPenaltyMultiplier - const minTileHeightPenalty = - tileHeight < MIN_TILE_HEIGHT ? (MIN_TILE_HEIGHT - tileHeight) * 1000 : 0 - - const score: LayoutScore = { - verticalOverflowPenalty, - horizontalOverflowPenalty, - minTileHeightPenalty, - negativeArea: -totalArea, - whitespace, - aspectDeviation: Math.abs(aspectRatio - TILE_ASPECT_LANDSCAPE), - } - - if (!bestScore || compareScores(score, bestScore) < 0) { - bestScore = score - best = candidate - } - } - - return best -} - export type CallSession = { url: string h: string @@ -231,6 +56,11 @@ export const isCallActive = derived( $state => $state === CallState.Joining || $state === CallState.Connected, ) +export const deriveRoomCallState = (url: string, h?: string) => + derived([callState, callTargetRoom], ([$callState, $targetRoom]) => + $targetRoom?.url === url && $targetRoom.h === h ? $callState : CallState.Disconnected, + ) + export const deriveIsCallActiveElsewhere = (url: string | undefined, h: string | undefined) => derived( [isCallActive, callTargetRoom], diff --git a/src/app/callEngine.ts b/src/app/callEngine.ts index 6a1a8a1c..addea7a3 100644 --- a/src/app/callEngine.ts +++ b/src/app/callEngine.ts @@ -13,11 +13,10 @@ import { import {App} from "@capacitor/app" import type {PluginListenerHandle} from "@capacitor/core" import {get} from "svelte/store" -import {not, nthEq, reject} from "@welshman/lib" -import {makeHttpAuth, makeHttpAuthHeader} from "@welshman/util" +import {not, nthEq, reject, spec} from "@welshman/lib" import {makeRoomKey} from "@welshman/app" -import {user} from "@app/core" import {getLivekitEndpoint} from "@app/relays" +import {getHttpAuthHeader} from "@app/signer" import {pushToast} from "@app/toast" import { CallState, @@ -32,6 +31,7 @@ import { speakingParticipants, triggerVideoTrackRevision, videoPrimaryTileKey, + type CallSession, type ParticipantMediaState, } from "@app/call" @@ -443,12 +443,8 @@ const fetchLivekitToken = async ( throw new DOMException("Aborted", "AbortError") } - const template = await makeHttpAuth(endpoint, "GET") - const signedEvent = await user.get().signer.sign(template) - const authHeader = makeHttpAuthHeader(signedEvent) - const response = await fetch(endpoint, { - headers: {Authorization: authHeader}, + headers: {Authorization: await getHttpAuthHeader(endpoint)}, signal, }) @@ -667,3 +663,86 @@ const onLocalTrackUnpublished = ( const abortJoinVoiceRoom = () => { joinAbortController?.abort() } + +export type VideoTile = { + liveKitIdentity: string + isLocal: boolean + trackSid: string + track: Track | undefined + source: Track.Source.Camera | Track.Source.ScreenShare +} + +// LiveKit can change trackSid after publish, so a tile is keyed by identity and source. +export const getVideoTileKey = (tile: VideoTile) => `${tile.liveKitIdentity}\x1f${tile.source}` + +export const getVideoTiles = ({livekit, cameraOn, screenShareOn}: CallSession) => { + const videoTiles: VideoTile[] = [] + const user = livekit.localParticipant + + if (cameraOn) { + const localPub = user.getTrackPublication(Track.Source.Camera) + videoTiles.push({ + liveKitIdentity: user.identity, + isLocal: true, + trackSid: localPub?.trackSid ?? "local-camera", + track: localPub?.track, + source: Track.Source.Camera, + }) + } + + if (screenShareOn) { + const localPub = user.getTrackPublication(Track.Source.ScreenShare) + videoTiles.push({ + liveKitIdentity: user.identity, + isLocal: true, + trackSid: localPub?.trackSid ?? "local-screen", + track: localPub?.track, + source: Track.Source.ScreenShare, + }) + } + + for (const rp of livekit.remoteParticipants.values()) { + const camPub = rp.getTrackPublication(Track.Source.Camera) + // Camera off mutes the publication rather than unsubscribing; still render avatar. + if (camPub?.isSubscribed && camPub.track && !camPub.isMuted) { + videoTiles.push({ + liveKitIdentity: rp.identity, + isLocal: false, + trackSid: camPub.trackSid, + track: camPub.track, + source: Track.Source.Camera, + }) + } + const screenPub = rp.getTrackPublication(Track.Source.ScreenShare) + if (screenPub?.isSubscribed && screenPub.track && !screenPub.isMuted) { + videoTiles.push({ + liveKitIdentity: rp.identity, + isLocal: false, + trackSid: screenPub.trackSid, + track: screenPub.track, + source: Track.Source.ScreenShare, + }) + } + if (!videoTiles.some(spec({liveKitIdentity: rp.identity}))) { + videoTiles.push({ + liveKitIdentity: rp.identity, + isLocal: false, + trackSid: `avatar-${rp.identity}`, + track: undefined, + source: Track.Source.Camera, + }) + } + } + + if (!videoTiles.some(spec({liveKitIdentity: user.identity}))) { + videoTiles.push({ + liveKitIdentity: user.identity, + isLocal: true, + trackSid: "local-avatar", + track: undefined, + source: Track.Source.Camera, + }) + } + + return videoTiles +} diff --git a/src/app/commands.ts b/src/app/commands.ts index 18525c8d..031879cf 100644 --- a/src/app/commands.ts +++ b/src/app/commands.ts @@ -1,5 +1,6 @@ import {derived} from "svelte/store" import type {Readable} from "svelte/store" +import {simpleCache} from "@welshman/lib" import { Address, COMMAND, @@ -76,16 +77,8 @@ export const getCommandsForTarget = (target: CommandScopeTarget) => .filter(command => command.matches(target)) // One store per url, and subscribing to it is what pulls the definitions. -const commandsByUrl = new Map>() - -const deriveCommandsForUrl = (url: string): Readable => { - const cached = commandsByUrl.get(url) - - if (cached) { - return cached - } - - const store = fromApp($app => { +const deriveCommandsForUrl = simpleCache(([url]: [string]) => + fromApp($app => { const plugin = $app.use(Commands) if (url) { @@ -93,12 +86,8 @@ const deriveCommandsForUrl = (url: string): Readable => { } return plugin.forUrl(url).$ - }) - - commandsByUrl.set(url, store) - - return store -} + }), +) // Content outside a space has no url, and so no commands. export const deriveValidCommands = (target: CommandScopeTarget): Readable => diff --git a/src/app/components/CallControlBar.svelte b/src/app/components/CallControlBar.svelte index 624eada5..78cde20f 100644 --- a/src/app/components/CallControlBar.svelte +++ b/src/app/components/CallControlBar.svelte @@ -26,9 +26,8 @@ videoCallLayout, CallState, currentCallSession, - callTargetRoom, callMicMuted, - callState, + deriveRoomCallState, } from "@app/call" import { cancelJoinVoiceRoom, @@ -49,12 +48,9 @@ const room = $derived($roomStore) const isVoiceRoom = $derived(room !== undefined && getRoomType(room) === RoomType.Voice) - const isTargetingThisRoom = $derived( - $callTargetRoom !== undefined && $callTargetRoom.url === url && $callTargetRoom.h === h, - ) - - const joiningHere = $derived($callState === CallState.Joining && isTargetingThisRoom) - const connectedHere = $derived($callState === CallState.Connected && isTargetingThisRoom) + const roomCallState = $derived(deriveRoomCallState(url, h)) + const joiningHere = $derived($roomCallState === CallState.Joining) + const connectedHere = $derived($roomCallState === CallState.Connected) // callTargetRoom isn't cleared on cancel or leave, so it can still point at this room. const showJoin = $derived(isVoiceRoom && !joiningHere && !connectedHere) diff --git a/src/app/components/HomeActivity.svelte b/src/app/components/HomeActivity.svelte index e8eb64f8..09dbd78d 100644 --- a/src/app/components/HomeActivity.svelte +++ b/src/app/components/HomeActivity.svelte @@ -6,7 +6,7 @@ import RelayIcon from "@app/components/RelayIcon.svelte" import RelayName from "@app/components/RelayName.svelte" import {displayContentCount} from "@app/content" - import {inboxSpaceContent} from "@app/inbox" + import {inboxSpaceContent} from "@app/notifications" import {makeSpacePath} from "@app/routes" diff --git a/src/app/components/HomeInbox.svelte b/src/app/components/HomeInbox.svelte index 66ae95ec..ffb4ab1c 100644 --- a/src/app/components/HomeInbox.svelte +++ b/src/app/components/HomeInbox.svelte @@ -7,9 +7,8 @@ import Button from "@lib/components/Button.svelte" import HomeSection from "@app/components/HomeSection.svelte" import HomeInboxItem from "@app/components/HomeInboxItem.svelte" - import {inboxConversations} from "@app/inbox" import {navigate} from "@app/modal" - import {allNotifications, setChecked} from "@app/notifications" + import {allNotifications, inboxConversations, setChecked} from "@app/notifications" const conversations = $derived($inboxConversations.slice(0, 8)) diff --git a/src/app/components/MicLevelMeter.svelte b/src/app/components/MicLevelMeter.svelte index c5836be3..77d58e8f 100644 --- a/src/app/components/MicLevelMeter.svelte +++ b/src/app/components/MicLevelMeter.svelte @@ -2,6 +2,7 @@ import cx from "classnames" import Microphone from "@assets/icons/microphone.svg?dataurl" import Icon from "@lib/components/Icon.svelte" + import {watchAudioLevel} from "@lib/audio" type Props = { track: MediaStreamTrack | undefined @@ -11,57 +12,34 @@ const {track, error, offMessage = "Microphone is off"}: Props = $props() + const NOISE_FLOOR = 0.03 + let level = $state(0) $effect(() => { const t = track + level = 0 - if (!t || error) { - return - } + if (t && !error) { + // Reading the `$state` this effect writes would make it depend on its own output. + let smoothed = 0 - let frame: number | undefined - const context = new AudioContext() - const analyser = context.createAnalyser() - analyser.fftSize = 512 - context.createMediaStreamSource(new MediaStream([t])).connect(analyser) - const data = new Uint8Array(analyser.frequencyBinCount) + return watchAudioLevel(new MediaStream([t]), rms => { + if (t.enabled && t.readyState === "live") { + const gated = rms < NOISE_FLOOR ? 0 : (rms - NOISE_FLOOR) / (1 - NOISE_FLOOR) - // Reading the `$state` this effect writes would make it depend on its own output. - let smoothed = 0 + smoothed += (Math.min(1, gated * 4) - smoothed) * 0.5 - const tick = () => { - if (!t.enabled || t.readyState !== "live") { - smoothed = 0 - level = 0 - frame = requestAnimationFrame(tick) - return - } + if (smoothed < 0.005) { + smoothed = 0 + } + } else { + smoothed = 0 + } - analyser.getByteTimeDomainData(data) - let sumSquares = 0 - for (const v of data) { - const normalized = (v - 128) / 128 - sumSquares += normalized * normalized - } - const rms = Math.sqrt(sumSquares / data.length) - const NOISE_FLOOR = 0.03 - const gated = rms < NOISE_FLOOR ? 0 : (rms - NOISE_FLOOR) / (1 - NOISE_FLOOR) - smoothed += (Math.min(1, gated * 4) - smoothed) * 0.5 - if (smoothed < 0.005) { - smoothed = 0 - } - level = smoothed - frame = requestAnimationFrame(tick) - } - tick() - - return () => { - if (frame !== undefined) { - cancelAnimationFrame(frame) - } - context.close().catch(() => {}) + level = smoothed + }) } }) diff --git a/src/app/components/RoomChat.svelte b/src/app/components/RoomChat.svelte index b5688d78..ca88ae5f 100644 --- a/src/app/components/RoomChat.svelte +++ b/src/app/components/RoomChat.svelte @@ -19,7 +19,13 @@ import ThunkToast from "@app/components/ThunkToast.svelte" import CallControlBar from "@app/components/CallControlBar.svelte" import {deletes, relays, rooms, thunks, user, writer} from "@app/core" - import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call" + import { + CallState, + callState, + deriveRoomCallState, + VideoCallLayout, + videoCallLayout, + } from "@app/call" import { RoomType, deriveUserRoomMembershipStatus, @@ -42,11 +48,9 @@ const room = h ? $rooms.forRoom(url, h) : readable(undefined) const isVoiceRoom = $derived($room && getRoomType($room) === RoomType.Voice) - const isCallTargetingThisRoom = $derived($callTargetRoom?.url === url && $callTargetRoom?.h === h) + const roomCallState = deriveRoomCallState(url, h) - const voiceConnectedHere = $derived( - isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom, - ) + const voiceConnectedHere = $derived(isVoiceRoom && $roomCallState === CallState.Connected) // voiceConnectedHere can't stay true for a voice room the call isn't targeting. const callChatOpen = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split) diff --git a/src/app/components/SocketStatusIndicator.svelte b/src/app/components/SocketStatusIndicator.svelte index b4ee6cd9..7ba2d798 100644 --- a/src/app/components/SocketStatusIndicator.svelte +++ b/src/app/components/SocketStatusIndicator.svelte @@ -1,6 +1,6 @@ -{$status.title} +{#if $status === RelayConnection.Connecting} + Connecting +{:else if $status === RelayConnection.Disconnected} + Not Connected +{:else if $status === RelayConnection.ConnectFailed} + Failed to Connect +{:else if $status === RelayConnection.Authenticating} + Authenticating +{:else if $status === RelayConnection.AuthFailed} + Failed to Authenticate +{:else if $status === RelayConnection.AccessDenied} + Access Denied +{:else} + Connected +{/if} diff --git a/src/app/components/SocketStatusToast.svelte b/src/app/components/SocketStatusToast.svelte index b34c48aa..285bf6fb 100644 --- a/src/app/components/SocketStatusToast.svelte +++ b/src/app/components/SocketStatusToast.svelte @@ -1,7 +1,7 @@ -{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)} +{#snippet videoTile(tile: VideoTile, layout: TileLayoutVariant)} {@const media = $mediaStateByIdentity(tile.liveKitIdentity)} {@const label = labelFor(tile.liveKitIdentity, tile.source)}
{#if videoTiles.length > 1} - {@const pinned = $videoPrimaryTileKey === tileKey(tile)} + {@const pinned = $videoPrimaryTileKey === getVideoTileKey(tile)} {/if} @@ -244,7 +164,7 @@ {#if secondaryTiles.length > 0}
- {#each secondaryTiles as tile (tileKey(tile))} + {#each secondaryTiles as tile (getVideoTileKey(tile))} {@render videoTile(tile, "strip")} {/each}
@@ -264,7 +184,7 @@
- {#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))} + {#each videoTiles.slice(offset, offset + row.columnCount) as tile (getVideoTileKey(tile))}
diff --git a/src/app/components/VoiceCallAudioSettingsDialog.svelte b/src/app/components/VoiceCallAudioSettingsDialog.svelte index 878062d7..a40df182 100644 --- a/src/app/components/VoiceCallAudioSettingsDialog.svelte +++ b/src/app/components/VoiceCallAudioSettingsDialog.svelte @@ -9,6 +9,7 @@ import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte" + import {listMediaDevices} from "@lib/audio" import MicLevelMeter from "@app/components/MicLevelMeter.svelte" import {currentCallSession, DeviceKind, type CallSession} from "@app/call" import {switchCallActiveDevice} from "@app/callEngine" @@ -35,19 +36,11 @@ ?.mediaStreamTrack const loadDevices = async () => { - if (!navigator.mediaDevices?.enumerateDevices) { - return - } - try { - const devices = await navigator.mediaDevices.enumerateDevices() - audioInputs = devices.filter(spec({kind: "audioinput"})) - audioOutputs = devices.filter(spec({kind: "audiooutput"})) - videoInputs = devices.filter(spec({kind: "videoinput"})) - } catch { - audioInputs = [] - audioOutputs = [] - videoInputs = [] - } + const devices = await listMediaDevices() + + audioInputs = devices.filter(spec({kind: "audioinput"})) + audioOutputs = devices.filter(spec({kind: "audiooutput"})) + videoInputs = devices.filter(spec({kind: "videoinput"})) } $effect(() => { diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 26fad793..c48a44d3 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -1,6 +1,5 @@