diff options
| author | Adam <[email protected]> | 2026-01-22 11:34:30 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-22 12:22:10 -0600 |
| commit | 62115832f5f3a9ed98a6b117cf820a11db12fae3 (patch) | |
| tree | 281af07304ffc5555ade84f60564d602bf73d291 /packages | |
| parent | 496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2 (diff) | |
| download | opencode-62115832f5f3a9ed98a6b117cf820a11db12fae3.tar.gz opencode-62115832f5f3a9ed98a6b117cf820a11db12fae3.zip | |
feat(app): render audio players in session review
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/ui/src/components/session-review.css | 32 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 74 |
2 files changed, 102 insertions, 4 deletions
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index e682c19b4..a53289b9a 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -138,6 +138,20 @@ justify-content: flex-end; } + [data-slot="session-review-change"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + } + + [data-slot="session-review-change"][data-type="added"] { + color: var(--icon-diff-add-base); + } + + [data-slot="session-review-change"][data-type="removed"] { + color: var(--icon-diff-delete-base); + } + [data-slot="session-review-file-container"] { padding: 0; } @@ -163,4 +177,22 @@ font-size: var(--font-size-small); color: var(--text-weak); } + + [data-slot="session-review-audio-container"] { + padding: 12px; + display: flex; + justify-content: center; + background: var(--background-stronger); + } + + [data-slot="session-review-audio"] { + width: 100%; + max-width: 560px; + } + + [data-slot="session-review-audio-placeholder"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + color: var(--text-weak); + } } diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 4d2123ff0..a19a8741d 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -36,6 +36,7 @@ export interface SessionReviewProps { } const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"]) +const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"]) function getExtension(file: string): string { const idx = file.lastIndexOf(".") @@ -47,17 +48,21 @@ function isImageFile(file: string): boolean { return imageExtensions.has(getExtension(file)) } +function isAudioFile(file: string): boolean { + return audioExtensions.has(getExtension(file)) +} + function dataUrl(content: FileContent | undefined): string | undefined { if (!content) return if (content.encoding !== "base64") return const mime = content.mimeType ?? "" - if (!mime.startsWith("image/")) return + if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return return `data:${mime};base64,${content.content}` } function dataUrlFromValue(value: unknown): string | undefined { if (typeof value === "string") { - if (value.startsWith("data:image/")) return value + if (value.startsWith("data:image/") || value.startsWith("data:audio/")) return value return } if (!value || typeof value !== "object") return @@ -69,7 +74,7 @@ function dataUrlFromValue(value: unknown): string | undefined { if (typeof content !== "string") return if (encoding !== "base64") return if (typeof mimeType !== "string") return - if (!mimeType.startsWith("image/")) return + if (!mimeType.startsWith("image/") && !mimeType.startsWith("audio/")) return return `data:${mimeType};base64,${content}` } @@ -150,11 +155,16 @@ export const SessionReview = (props: SessionReviewProps) => { const isAdded = () => beforeText().length === 0 && afterText().length > 0 const isDeleted = () => afterText().length === 0 && beforeText().length > 0 const isImage = () => isImageFile(diff.file) + const isAudio = () => isAudioFile(diff.file) const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before) const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc) const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle") + const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before) + const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc) + const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle") + createEffect(() => { if (!open().includes(diff.file)) return if (!isImage()) return @@ -180,6 +190,31 @@ export const SessionReview = (props: SessionReviewProps) => { }) }) + createEffect(() => { + if (!open().includes(diff.file)) return + if (!isAudio()) return + if (audioSrc()) return + if (audioStatus() !== "idle") return + + const reader = props.readFile + if (!reader) return + + setAudioStatus("loading") + reader(diff.file) + .then((result) => { + const src = dataUrl(result) + if (!src) { + setAudioStatus("error") + return + } + setAudioSrc(src) + setAudioStatus("idle") + }) + .catch(() => { + setAudioStatus("error") + }) + }) + const fileForCode = () => { const contents = afterText() || beforeText() return { @@ -216,7 +251,21 @@ export const SessionReview = (props: SessionReviewProps) => { </div> </div> <div data-slot="session-review-trigger-actions"> - <DiffChanges changes={diff} /> + <Switch> + <Match when={isAdded()}> + <span data-slot="session-review-change" data-type="added"> + Added + </span> + </Match> + <Match when={isDeleted()}> + <span data-slot="session-review-change" data-type="removed"> + Removed + </span> + </Match> + <Match when={true}> + <DiffChanges changes={diff} /> + </Match> + </Switch> <Icon name="chevron-grabber-vertical" size="small" /> </div> </div> @@ -241,6 +290,23 @@ export const SessionReview = (props: SessionReviewProps) => { </Show> </div> </Match> + <Match when={isAudio()}> + <div data-slot="session-review-audio-container"> + <Show + when={audioSrc()} + fallback={ + <div data-slot="session-review-audio-placeholder"> + <Switch> + <Match when={audioStatus() === "loading"}>Loading audio...</Match> + <Match when={true}>Audio preview unavailable</Match> + </Switch> + </div> + } + > + <audio data-slot="session-review-audio" controls src={audioSrc()!} /> + </Show> + </div> + </Match> <Match when={isAdded() || isDeleted()}> <div data-slot="session-review-file-container"> <Dynamic component={codeComponent} file={fileForCode()} overflow="scroll" /> |
