WinterGram/Telegram/WatchApp/tgwatch Watch App/Messages/VoiceNoteBubbleView.swift
2026-05-29 02:03:31 +02:00

209 lines
7.5 KiB
Swift

import SwiftUI
/// Renders one voice-note bubble: rounded chrome (gray incoming / accent
/// outgoing) containing a play/pause glyph + 32-bar waveform + duration
/// label. Optional reply header sits inside the chrome above the row.
/// Caption (when non-empty) renders below the waveform inside the chrome.
///
/// Drives priority-1 viewport download via `.onScrollVisibilityChange`.
/// Tap goes through `ChatHistoryStore.togglePlayback(_:)`; the store kicks
/// a priority-2 download if the file isn't ready and routes the toggle to
/// the active `VoicePlaybackController`.
struct VoiceNoteBubbleView: View {
let note: VoiceNoteVisual
let caption: String
let isOutgoing: Bool
let replyHeader: ReplyHeader?
@Environment(ChatHistoryStore.self) private var store
@Environment(\.bubbleMetrics) private var metrics
private var style: BubbleStyle { .resolve(isOutgoing: isOutgoing) }
private var amplitudes: [Float] { unpackWaveform(note.waveform) }
private var glyph: VoiceGlyph { store.voicePlayback.glyph(for: note.voiceFileId) }
private var progress: Double { store.voicePlayback.progress(for: note.voiceFileId) }
private var preparingProgress: Int? {
if case .preparing(let id, let p) = store.voicePlayback.state, id == note.voiceFileId {
return Int(p * 100)
}
return nil
}
var body: some View {
chrome
.onScrollVisibilityChange(threshold: 0.01) { visible in
if visible {
store.requestFileDownload(fileId: note.voiceFileId)
} else {
store.cancelFileDownload(fileId: note.voiceFileId)
}
}
}
private var chrome: some View {
VStack(alignment: .leading, spacing: 4) {
if let header = replyHeader {
ReplyHeaderView(header: header, style: BubbleStyle.resolve(isOutgoing: isOutgoing))
}
HStack(spacing: 6) {
glyphView
WaveformBarsView(amplitudes: amplitudes, progress: progress, isOutgoing: isOutgoing)
trailing
}
if !caption.isEmpty {
Text(caption)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(minWidth: BubbleShape.minSize, maxWidth: metrics.bubbleMaxWidth, minHeight: BubbleShape.minSize, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: BubbleShape.cornerRadius)
.fill(style.fill)
)
.foregroundStyle(style.content)
.contentShape(Rectangle())
.onTapGesture { store.togglePlayback(note) }
}
@ViewBuilder
private var glyphView: some View {
// Fixed 32pt slot keeps the waveform width stable across glyph states.
switch glyph {
case .error:
// Edge state keep the red failure signal, no circle.
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 18)).foregroundStyle(.red)
.frame(width: 32, height: 32)
case .play, .pause, .spinner:
ZStack {
Circle().fill(style.playFill)
Group {
switch glyph {
case .play: Image(systemName: "play.fill").font(.system(size: 13))
case .pause: Image(systemName: "pause.fill").font(.system(size: 13))
default: ProgressView().controlSize(.mini).scaleEffect(0.7).tint(style.playIcon)
}
}
.foregroundStyle(style.playIcon)
}
.frame(width: 32, height: 32)
}
}
@ViewBuilder
private var trailing: some View {
VStack(alignment: .trailing, spacing: 1) {
if let pct = preparingProgress {
Text("\(pct)%")
.font(.system(size: 8))
.foregroundStyle(style.secondary)
} else {
Text(formatDuration(note.duration))
.font(.system(size: 9))
.foregroundStyle(style.secondary)
}
}
.fixedSize(horizontal: true, vertical: false)
}
}
#if DEBUG
import TDLibKit
private struct VoiceNotePreviewNoopLoader: ChatHistoryLoader {
func openChat(chatId: Int64) async throws {}
func closeChat(chatId: Int64) async throws {}
func loadHistory(chatId: Int64, fromMessageId: Int64, offset: Int, limit: Int) async throws -> [TDLibKit.Message] { [] }
func downloadFile(fileId: Int, priority: Int) async throws -> TDLibKit.File { throw CancellationError() }
func cancelDownloadFile(fileId: Int) async throws {}
func sendText(chatId: Int64, text: String) async throws -> TDLibKit.Message { throw CancellationError() }
func sendVoiceNote(chatId: Int64, fileURL: URL, duration: Int, waveform: Data) async throws -> TDLibKit.Message { throw CancellationError() }
func setChatDraftMessage(chatId: Int64, draftText: String) async throws {}
func viewMessages(chatId: Int64, messageIds: [Int64], forceRead: Bool) async throws {}
func setPollAnswer(chatId: Int64, messageId: Int64, optionIds: [Int]) async throws {}
func sendSticker(chatId: Int64, remoteFileId: String, emoji: String, width: Int, height: Int) async throws -> TDLibKit.Message {
throw CancellationError()
}
func sendLocation(chatId: Int64, latitude: Double, longitude: Double) async throws -> TDLibKit.Message { throw CancellationError() }
}
@MainActor
private func previewStore() -> ChatHistoryStore {
ChatHistoryStore(
chatId: 0,
chatType: .chatTypePrivate(ChatTypePrivate(userId: 0)),
lastReadInboxMessageId: 0, unreadCount: 0, lastMessageId: nil,
loader: VoiceNotePreviewNoopLoader()
)
}
private func previewVoice(id: Int = 1) -> VoiceNoteVisual {
VoiceNoteVisual(
voiceFileId: id, duration: 18, mimeType: "audio/ogg",
waveform: Data((0..<63).map { _ in UInt8.random(in: 0...255) }),
caption: "", localPath: nil
)
}
#Preview("Voice — incoming, idle") {
VoiceNoteBubbleView(
note: previewVoice(), caption: "",
isOutgoing: false,
replyHeader: nil
)
.bubblePreview()
.environment(previewStore())
}
#Preview("Voice — outgoing, idle") {
VoiceNoteBubbleView(
note: previewVoice(id: 2), caption: "",
isOutgoing: true,
replyHeader: nil
)
.bubblePreview()
.environment(previewStore())
}
#Preview("Voice — incoming with reply") {
VoiceNoteBubbleView(
note: previewVoice(id: 3), caption: "",
isOutgoing: false,
replyHeader: ReplyHeader(
senderName: "Bob",
snippet: "anchor message earlier in the chat",
minithumbnail: nil, isOutgoing: false
)
)
.bubblePreview()
.environment(previewStore())
}
#Preview("Voice — incoming with caption") {
VoiceNoteBubbleView(
note: previewVoice(id: 4),
caption: "Listen to this part carefully",
isOutgoing: false,
replyHeader: nil
)
.bubblePreview()
.environment(previewStore())
}
#Preview("Voice — incoming, empty waveform") {
VoiceNoteBubbleView(
note: VoiceNoteVisual(
voiceFileId: 5, duration: 5, mimeType: "audio/ogg",
waveform: Data(), caption: "", localPath: nil
),
caption: "", isOutgoing: false,
replyHeader: nil
)
.bubblePreview()
.environment(previewStore())
}
#endif