import SwiftUI /// The wizard's emphasis-review step. /// /// Every decision here is about a *sentence* read from the original /// transcription, so the phrases are listed in full — each line shows the text /// as it will be said, a switch to keep or drop it from the cut, and the /// emphasis level. Selecting a line in the list also selects its block on the /// timeline below, and vice-versa. struct PhraseReviewView: View { @ObservedObject var model: PhraseReviewModel var body: some View { VSplitView { VStack(spacing: 0) { inspectorHeader Divider() List(selection: $model.selection) { ForEach($model.phrases) { $phrase in PhraseRow(phrase: $phrase, model: model) .tag(phrase.id) } } .listStyle(.inset) .onChange(of: model.selection) { _, newValue in if let newValue { model.goTo(phraseID: newValue) } } Divider() summaryBar } .frame(minHeight: 240) TimelineTracksView(model: model) .frame(minHeight: 190, idealHeight: 210) } .overlay { if model.isLoading { loadingOverlay } } .focusable() .onKeyPress(.space) { model.togglePlay(); return .handled } .onKeyPress(.return) { model.playSelectedPhrase(); return .handled } .onKeyPress(.leftArrow) { model.selectNeighbour(-1); return .handled } .onKeyPress(.rightArrow) { model.selectNeighbour(1); return .handled } .onKeyPress(characters: .decimalDigits) { press in guard let level = Int(press.characters), (0...3).contains(level), let selection = model.selection else { return .ignored } model.setEmphasis(level, for: selection) return .handled } } private var loadingOverlay: some View { ZStack { Color(nsColor: .windowBackgroundColor).opacity(0.85) VStack(spacing: 10) { ProgressView() Text("Montando a revisão…").font(.callout).foregroundStyle(.secondary) } } } private var summaryBar: some View { HStack(spacing: 16) { summaryItem("text.quote", "\(model.phrases.count) frases") summaryItem("sparkles", "\(model.emphasisCount) com ênfase") summaryItem("scissors", "\(model.removedCount) fora do corte") summaryItem("clock", durationLabel(model.keptDuration)) if !model.zooms.isEmpty { summaryItem("plus.magnifyingglass", "\(model.zooms.count) zooms") } Spacer() if let phrase = selectedPhrase, !phrase.reason.isEmpty { Label(phrase.reason, systemImage: "brain") .font(.caption).foregroundStyle(.secondary) .lineLimit(1).truncationMode(.tail) } } .padding(.horizontal, 14) .padding(.vertical, 8) } private func summaryItem(_ icon: String, _ text: String) -> some View { Label(text, systemImage: icon).font(.caption).foregroundStyle(.secondary) } private func durationLabel(_ seconds: Double) -> String { String(format: "%02d:%02d finais", Int(seconds) / 60, Int(seconds) % 60) } private func pickMedia() { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.prompt = "Usar esta mídia" panel.message = model.source.isEmpty ? "Escolha o arquivo de vídeo desta gravação." : "Escolha onde está \(model.source)." if panel.runModal() == .OK, let url = panel.url { model.useMedia(at: url.path) } } private var selectedPhrase: ReviewPhrase? { guard let selection = model.selection else { return nil } return model.phrases.first { $0.id == selection } } // MARK: - Inspector de frases private var inspectorPane: some View { VStack(spacing: 0) { inspectorHeader Divider() List(selection: $model.selection) { ForEach($model.phrases) { $phrase in PhraseRow(phrase: $phrase, model: model) .tag(phrase.id) } } .listStyle(.inset) .onChange(of: model.selection) { _, newValue in if let newValue { model.goTo(phraseID: newValue) } } } } private var inspectorHeader: some View { VStack(alignment: .leading, spacing: 6) { Text("Frases").font(.headline) Text("Só as frases com ênfase recebem zoom e legenda dinâmica. O resto fica com legenda comum.") .font(.caption).foregroundStyle(.secondary) if !model.emotionAvailable { Label("Emoção da fala não foi detectada nesta análise — ligue em Avançado → Análise de Voz e refaça o passo 3.", systemImage: "waveform.path.ecg") .font(.caption2).foregroundStyle(.secondary) } HStack(spacing: 8) { Button("Limpar ênfases") { model.setEmphasisForAll(0) } .buttonStyle(.link).font(.caption) Spacer() Text("0–3 no teclado · ← → navega") .font(.caption2).foregroundStyle(.secondary) } } .padding(12) } } /// One phrase in the inspector: the line as it will be said, plus every /// decision attached to it. Kept in one row on purpose — jumping to a separate /// detail pane to set a toggle would double the clicks on the most repeated /// action in the screen. private struct PhraseRow: View { @Binding var phrase: ReviewPhrase @ObservedObject var model: PhraseReviewModel @State private var isEditing = false var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Text(phrase.timecode) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) if phrase.takeBoundary { Image(systemName: "scissors.badge.ellipsis") .font(.caption2).foregroundStyle(.orange) .help("Nova tomada começa aqui") } if phrase.isTrimmed { Image(systemName: "arrow.left.and.right.square") .font(.caption2).foregroundStyle(.blue) .help("Frase cortada nas pontas") } if model.emotionAvailable { emotionChip } Spacer() Toggle("", isOn: $phrase.active) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() .help(phrase.active ? "No corte" : "Fora do corte") } if isEditing { TextField("Texto da frase", text: $phrase.text, axis: .vertical) .textFieldStyle(.roundedBorder) .font(.callout) .onSubmit { isEditing = false } } else { Text(phrase.text.isEmpty ? "(sem texto)" : phrase.text) .font(.callout) .foregroundStyle(phrase.active ? .primary : .secondary) .strikethrough(!phrase.active) .onTapGesture(count: 2) { isEditing = true } } HStack(spacing: 8) { Picker("", selection: $phrase.emphasis) { ForEach(0..<4, id: \.self) { level in Text(EmphasisPalette.label(level)).tag(level) } } .pickerStyle(.segmented) .controlSize(.mini) .labelsHidden() .disabled(!phrase.active) Picker("", selection: $phrase.track) { Text("Roteiro").tag(ReviewPhrase.trackScript) Text("Bastidor").tag(ReviewPhrase.trackBackstage) } .pickerStyle(.menu) .controlSize(.mini) .labelsHidden() .frame(width: 92) } if model.selection == phrase.id && !phrase.words.isEmpty { wordTrimmer } } .padding(.vertical, 4) .opacity(phrase.active ? 1 : 0.55) } /// The delivery emotion the acoustics suggest. Shown faded below its own /// confidence: a guess the analysis is unsure about should not compete for /// attention with the emphasis decision, which is the point of the row. private var emotionChip: some View { let (label, icon) = ReviewPhrase.emotionLabel(phrase.emotion) return Label(label, systemImage: icon) .font(.caption2) .padding(.horizontal, 5) .padding(.vertical, 1) .background( Capsule().fill(Color.secondary.opacity(0.12)) ) .foregroundStyle(phrase.emotionConfidence >= 0.5 ? .secondary : .tertiary) .help("Emoção da entrega: \(label) — confiança \(Int(phrase.emotionConfidence * 100))%") } /// Trimming by pointing at the transcript: click a word to start the phrase /// there, option-click to end it there. Same edit as dragging the block's /// edge on the timeline, but reachable while reading the line. private var wordTrimmer: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { Text("Cortar pelas palavras").font(.caption2).foregroundStyle(.secondary) Spacer() if phrase.isTrimmed { Button("Inteira") { model.resetTrim(phrase.id) } .buttonStyle(.link).font(.caption2) } } FlowWords(words: phrase.words, phrase: phrase) { word, edge in model.trimToWord(word, edge: edge, in: phrase.id) } Text("Clique = começa/desfaz aqui · ⌥clique = termina/desfaz aqui · sublinhado = ênfase da palavra") .font(.caption2).foregroundStyle(.tertiary) } .padding(.top, 2) } } /// The phrase's words as wrapping chips, dimmed where they fall outside the /// trim and underlined where the acoustics mark them as an emphasis peak — /// the same word-level signal `05-zoom.md` picks a punch-in's `start` from, /// made visible instead of buried in the JSON. private struct FlowWords: View { let words: [ReviewWord] let phrase: ReviewPhrase let onTrim: (ReviewWord, TrimEdge) -> Void var body: some View { // A LazyVGrid with adaptive columns wraps chips without a custom layout; // phrases are short enough that the slight raggedness beats the cost of // hand-rolling a flow layout here. LazyVGrid(columns: [GridItem(.adaptive(minimum: 44), spacing: 3)], alignment: .leading, spacing: 3) { ForEach(words) { word in let kept = word.start >= phrase.trimStart - 0.001 && word.end <= phrase.trimEnd + 0.001 let level = EmphasisPalette.levelFromScore(word.emphasis) Text(word.text) .font(.caption2) .fontWeight(level >= 2 ? .semibold : .regular) .padding(.horizontal, 4) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 3) .fill(kept ? Color.accentColor.opacity(0.12) : Color.secondary.opacity(0.08)) ) .overlay(alignment: .bottom) { if level >= 1 { Rectangle() .fill(EmphasisPalette.color(level)) .frame(height: 2) .padding(.horizontal, 3) } } .foregroundStyle(kept ? .primary : .secondary) .strikethrough(!kept) .help( level >= 1 ? "Ênfase \(EmphasisPalette.label(level).lowercased()) (\(Int(word.emphasis * 100))%)" : "Sem ênfase" ) .onTapGesture { onTrim(word, NSEvent.modifierFlags.contains(.option) ? .end : .start) } } } } }