import SwiftUI /// Speaker-correction editor: rename a speaker, merge two speakers, or reassign an /// individual transcript line to a different speaker. Saving rewrites speakers.json, /// re-renders the recap, and updates the voiceprint memory. struct TranscriptEditorView: View { @ObservedObject var model: RecapEditModel @State private var renameTarget: String? @State private var renameText = "" @State private var reassignNewIndex: Int? @State private var newSpeakerText = "" var body: some View { VStack(alignment: .leading, spacing: 0) { header Divider() transcript Divider() footer } .frame(minWidth: 560, minHeight: 480) .sheet(item: Binding(get: { renameTarget.map { IdentifiableString($0) } }, set: { renameTarget = $0?.value })) { item in renameSheet(for: item.value) } .sheet(item: Binding(get: { reassignNewIndex.map { IdentifiableInt($0) } }, set: { reassignNewIndex = $0?.value })) { item in newSpeakerSheet(for: item.value) } } // MARK: - Header / legend private var header: some View { VStack(alignment: .leading, spacing: 8) { Text(model.title).font(.headline) Text("Fix speaker names, merge duplicates, or reassign a line. Saving updates the recap and remembers any names you set.") .font(.caption).foregroundStyle(.secondary) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(model.speakers, id: \.self) { name in Menu { Button("Rename…") { renameText = name; renameTarget = name } if model.speakers.count > 1 { Menu("Merge into") { ForEach(model.speakers.filter { $0 != name }, id: \.self) { other in Button(other) { model.merge(name, into: other) } } } } } label: { Text(name).font(.caption).padding(.horizontal, 8).padding(.vertical, 3) .background(Color.accentColor.opacity(0.18)).clipShape(Capsule()) } .menuStyle(.borderlessButton).fixedSize() } } .padding(.vertical, 2) } } .padding(12) } // MARK: - Transcript private var transcript: some View { List { ForEach(Array(model.segments.enumerated()), id: \.offset) { idx, seg in HStack(alignment: .top, spacing: 10) { Text(RecapAnalyzer.mmss(seg.start)) .font(.caption.monospacedDigit()).foregroundStyle(.secondary) .frame(width: 52, alignment: .trailing) Menu { ForEach(model.speakers, id: \.self) { name in Button(name) { model.reassign(idx, to: name) } } Divider() Button("New name…") { newSpeakerText = ""; reassignNewIndex = idx } } label: { Text(seg.speaker).font(.caption.weight(.semibold)) .frame(width: 96, alignment: .leading) } .menuStyle(.borderlessButton).fixedSize() Text(seg.text ?? "").font(.callout).textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 2) } } .listStyle(.inset) } // MARK: - Footer private var footer: some View { HStack(spacing: 10) { Button("Save corrections") { model.save() } .keyboardShortcut("s", modifiers: .command) .disabled(!model.dirty || model.regenerating) Button("Regenerate recap") { Task { await model.regenerate() } } .help("Re-run the analysis on the corrected transcript so summaries use the fixed names.") .disabled(model.regenerating) if model.regenerating { ProgressView().controlSize(.small) } if let s = model.status { Text(s).font(.caption).foregroundStyle(.secondary) } Spacer() if let recap = recapURL { Button("Open recap") { NSWorkspace.shared.open(recap) } } } .padding(12) } private var recapURL: URL? { let u = model.folder.appendingPathComponent("recap.html") return FileManager.default.fileExists(atPath: u.path) ? u : nil } // MARK: - Sheets private func renameSheet(for name: String) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Rename “\(name)”").font(.headline) TextField("New name", text: $renameText).textFieldStyle(.roundedBorder).frame(width: 260) HStack { Spacer() Button("Cancel") { renameTarget = nil } Button("Rename") { model.rename(name, to: renameText) renameTarget = nil }.keyboardShortcut(.defaultAction).disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty) } } .padding(16) } private func newSpeakerSheet(for index: Int) -> some View { VStack(alignment: .leading, spacing: 12) { Text("Assign this line to a new speaker").font(.headline) TextField("Speaker name", text: $newSpeakerText).textFieldStyle(.roundedBorder).frame(width: 260) HStack { Spacer() Button("Cancel") { reassignNewIndex = nil } Button("Assign") { let n = newSpeakerText.trimmingCharacters(in: .whitespaces) if !n.isEmpty { model.reassign(index, to: n) } reassignNewIndex = nil }.keyboardShortcut(.defaultAction).disabled(newSpeakerText.trimmingCharacters(in: .whitespaces).isEmpty) } } .padding(16) } } private struct IdentifiableString: Identifiable { let value: String; var id: String { value }; init(_ v: String) { value = v } } private struct IdentifiableInt: Identifiable { let value: Int; var id: Int { value }; init(_ v: Int) { value = v } }