import SwiftUI // MARK: - WordData struct WordData: Identifiable, Codable { let word: String let start: Double let end: Double let pitch: Float // 0.0 … 1.0 normalised let energy: Float // 0.0 … 1.0 normalised var id: String { "\(start)-\(word)" } enum CodingKeys: String, CodingKey { case word, start, end, pitch, energy } } // MARK: - Flowing word-wrap layout (iOS 16+) struct WrapLayout: Layout { var hSpacing: CGFloat = 7 var vSpacing: CGFloat = 12 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let maxW = proposal.width ?? .infinity var x: CGFloat = 0 var y: CGFloat = 0 var rowH: CGFloat = 0 var totalH: CGFloat = 0 for view in subviews { let size = view.sizeThatFits(.unspecified) if x + size.width > maxW, x > 0 { y += rowH + vSpacing x = 0; rowH = 0 } rowH = max(rowH, size.height) x += size.width + hSpacing totalH = y + rowH } return CGSize(width: maxW, height: totalH) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let maxW = bounds.width var x = bounds.minX var y = bounds.minY var rowH: CGFloat = 0 for view in subviews { let size = view.sizeThatFits(.unspecified) if x - bounds.minX + size.width > maxW, x > bounds.minX { y += rowH + vSpacing x = bounds.minX; rowH = 0 } view.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: .unspecified) rowH = max(rowH, size.height) x += size.width + hSpacing } } } // MARK: - TranscriptView /// Karaoke-style transcript: all words plain bold white, current word /// gets a coloured underline (tintColor) that advances word by word. /// No size, offset, or colour changes — pace is shown by the underline alone. struct TranscriptView: View { let words: [WordData] let playbackPosition: Double var tintColor: Color = Color(hex: "#3b82f6") private let baseSize: CGFloat = 17 var body: some View { WrapLayout(hSpacing: 7, vSpacing: 12) { ForEach(words) { w in wordToken(w) } } .padding(.horizontal, 20) .padding(.vertical, 16) } @ViewBuilder private func wordToken(_ w: WordData) -> some View { let isCurrent = playbackPosition >= w.start && playbackPosition < w.end Text(w.word) .font(.system(size: baseSize, weight: .bold, design: .monospaced)) .foregroundColor(.white) .underline(isCurrent, color: tintColor) } } // MARK: - Preview #Preview { let sample: [WordData] = [ WordData(word: "hear", start: 0.0, end: 0.4, pitch: 0.5, energy: 0.7), WordData(word: "your", start: 0.4, end: 0.7, pitch: 0.6, energy: 0.5), WordData(word: "self", start: 0.7, end: 1.1, pitch: 0.4, energy: 0.9), WordData(word: "clear", start: 1.1, end: 1.5, pitch: 0.8, energy: 0.8), WordData(word: "er", start: 1.5, end: 1.8, pitch: 0.3, energy: 0.4), WordData(word: "every", start: 1.8, end: 2.2, pitch: 0.55, energy: 0.6), WordData(word: "single", start: 2.2, end: 2.6, pitch: 0.7, energy: 0.75), WordData(word: "time", start: 2.6, end: 3.0, pitch: 0.45, energy: 0.85), ] ZStack { Color(hex: "#080d14").ignoresSafeArea() VStack(spacing: 24) { Text("recording — red, pos 0.9s") .font(.system(size: 9, design: .monospaced)) .foregroundColor(Color(hex: "#475569")) TranscriptView(words: sample, playbackPosition: 0.9, tintColor: Color(hex: "#ef4444")) .background(Color(hex: "#0d1421")) Text("compare source — all spoken, red") .font(.system(size: 9, design: .monospaced)) .foregroundColor(Color(hex: "#475569")) TranscriptView(words: sample, playbackPosition: .infinity, tintColor: Color(hex: "#ef4444")) .background(Color(hex: "#0d1421")) Text("compare user — all spoken, green") .font(.system(size: 9, design: .monospaced)) .foregroundColor(Color(hex: "#475569")) TranscriptView(words: sample, playbackPosition: .infinity, tintColor: Color(hex: "#22c55e")) .background(Color(hex: "#0d1421")) } .padding(20) } }