diff --git a/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx b/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx
new file mode 100644
index 000000000..e60a7f418
--- /dev/null
+++ b/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx
@@ -0,0 +1,49 @@
+import React, { useCallback, useMemo } from 'react';
+import { Room } from 'matrix-js-sdk';
+import { usePowerLevels } from '../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { SoundboardPackEditor } from './SoundboardPackEditor';
+import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
+import { StateEvent } from '../../../types/matrix/room';
+import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
+import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
+import { randomStr } from '../../utils/common';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+
+type RoomSoundboardPackProps = {
+ room: Room;
+ stateKey: string;
+};
+
+export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps) {
+ const mx = useMatrixClient();
+ const userId = mx.getUserId()!;
+ const powerLevels = usePowerLevels(room);
+ const creators = useRoomCreators(room);
+ const permissions = useRoomPermissions(creators, powerLevels);
+ const canEdit = permissions.stateEvent(
+ StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
+ userId,
+ );
+
+ const fallbackPack = useMemo(
+ () => new SoundboardPack(randomStr(4), {}, new PackAddress(room.roomId, stateKey)),
+ [room.roomId, stateKey],
+ );
+ const pack = useRoomSoundboardPack(room, stateKey) ?? fallbackPack;
+
+ const handleUpdate = useCallback(
+ async (content: SoundboardContent) => {
+ await mx.sendStateEvent(
+ room.roomId,
+ StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
+ content as never,
+ stateKey,
+ );
+ },
+ [mx, room.roomId, stateKey],
+ );
+
+ return ;
+}
diff --git a/src/app/components/soundboard-pack-view/SoundboardPackEditor.tsx b/src/app/components/soundboard-pack-view/SoundboardPackEditor.tsx
new file mode 100644
index 000000000..2188c0252
--- /dev/null
+++ b/src/app/components/soundboard-pack-view/SoundboardPackEditor.tsx
@@ -0,0 +1,407 @@
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import {
+ Box,
+ Button,
+ Chip,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ PopOut,
+ Spinner,
+ Text,
+ color,
+ config,
+ toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { EmojiBoard } from '../emoji-board';
+import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
+import { uniqueShortcode } from '../../plugins/soundboard/utils';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import {
+ playClipLocally,
+ resolveClipObjectUrl,
+ SOUNDBOARD_ACCEPT,
+ SOUNDBOARD_MAX_CLIP_BYTES,
+ SOUNDBOARD_MAX_CLIPS,
+} from '../../utils/soundboardClips';
+import { stopPropagation } from '../../utils/keyboard';
+
+type ClipDraft = {
+ url: string;
+ body: string;
+ emoji: string;
+ volume: number;
+ info?: SoundboardClip['info'];
+};
+
+type SoundboardPackEditorProps = {
+ pack: SoundboardPack;
+ canEdit?: boolean;
+ onUpdate: (content: SoundboardContent) => Promise;
+};
+
+/**
+ * Reusable single-pack soundboard manager (used by the settings page and the
+ * in-call management mode). Mirrors image-pack-view/ImagePackContent's staged-
+ * edit + batched-save pattern, but per-clip fields are name + emoji + volume.
+ */
+export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) {
+ const mx = useMatrixClient();
+
+ // Staged, unsaved state:
+ const [drafts, setDrafts] = useState