openclaw/apps/shared/MoltbotKit/Sources/MoltbotChatUI/ChatMarkdownRenderer.swift
Aditya Bhuran df1895ee1a feat(webchat): add inline audio playback for TTS-generated audio
Implements feature request #3504 - Inline audio playback in WebChat UI

Changes:
- Add InlineAudioParser to detect MEDIA: prefixed paths pointing to audio files
- Add InlineAudioPlayerView SwiftUI component with play/pause controls
- Integrate audio player rendering into ChatMarkdownRenderer
- Add comprehensive unit tests for audio path parsing

Supported audio formats: .mp3, .opus, .m4a, .ogg, .oga, .wav, .aac, .flac

The inline audio player displays:
- Play/pause button
- Audio file name
- Progress bar with duration
- Graceful error handling for missing files

Closes #3504
2026-01-28 13:18:59 -05:00

104 lines
3.2 KiB
Swift

import SwiftUI
import Textual
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
case standard
case compact
}
@MainActor
struct ChatMarkdownRenderer: View {
enum Context {
case user
case assistant
}
let text: String
let context: Context
let variant: ChatMarkdownVariant
let font: Font
let textColor: Color
var body: some View {
// First extract any MEDIA: audio references
let audioResult = InlineAudioParser.parse(self.text)
// Then process images from the remaining text
let processed = ChatMarkdownPreprocessor.preprocess(markdown: audioResult.cleaned)
VStack(alignment: .leading, spacing: 10) {
// Only render text if there's content after processing
if !processed.cleaned.isEmpty {
StructuredText(markdown: processed.cleaned)
.modifier(ChatMarkdownStyle(
variant: self.variant,
context: self.context,
font: self.font,
textColor: self.textColor))
}
// Render inline audio players
if !audioResult.audioFiles.isEmpty {
InlineAudioList(audioFiles: audioResult.audioFiles)
}
// Render inline images
if !processed.images.isEmpty {
InlineImageList(images: processed.images)
}
}
}
}
private struct ChatMarkdownStyle: ViewModifier {
let variant: ChatMarkdownVariant
let context: ChatMarkdownRenderer.Context
let font: Font
let textColor: Color
func body(content: Content) -> some View {
Group {
if self.variant == .compact {
content.textual.structuredTextStyle(.default)
} else {
content.textual.structuredTextStyle(.gitHub)
}
}
.font(self.font)
.foregroundStyle(self.textColor)
.textual.inlineStyle(self.inlineStyle)
.textual.textSelection(.enabled)
}
private var inlineStyle: InlineStyle {
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
return InlineStyle()
.code(.monospaced, .fontScale(codeScale))
.link(.foregroundColor(linkColor))
}
}
@MainActor
private struct InlineImageList: View {
let images: [ChatMarkdownPreprocessor.InlineImage]
var body: some View {
ForEach(images, id: \.id) { item in
if let img = item.image {
MoltbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}