package transcript import ( "strings" "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "playback/internal/srt" "playback/internal/ui" ) // SeekToCueMsg is sent when user wants to seek to a specific cue type SeekToCueMsg struct { Position time.Duration } // Model represents the transcript view component type Model struct { viewport viewport.Model transcript *srt.Transcript activeCue int // Cue currently active (playback position + navigation cursor) cueLines []int // Starting line number (in rendered view) for each cue Width int Height int } // New creates a new transcript model func New() Model { vp := viewport.New(80, 20) vp.Style = lipgloss.NewStyle() return Model{ viewport: vp, activeCue: -1, } } // SetTranscript sets the transcript to display func (m *Model) SetTranscript(t *srt.Transcript) { m.transcript = t m.activeCue = 0 m.updateContent() m.scrollToCue(0) } // Transcript returns the current transcript func (m *Model) Transcript() *srt.Transcript { return m.transcript } // ActiveCueLineNumber returns the line number of the active cue for vim func (m *Model) ActiveCueLineNumber() int { if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) { return 1 } return m.transcript.Cues[m.activeCue].LineNumber } // SetPosition updates which cue is highlighted based on playback position func (m *Model) SetPosition(pos time.Duration) { if m.transcript == nil { return } newCue := m.transcript.CueIndexAt(pos) if newCue != m.activeCue { m.activeCue = newCue m.updateContent() // Always auto-scroll during playback if newCue >= 0 { m.scrollToCue(newCue) } } } // SetSize sets the component dimensions func (m *Model) SetSize(width, height int) { m.Width = width m.Height = height m.viewport.Width = width - 4 // Account for border m.viewport.Height = height - 2 m.updateContent() } // ModeString returns the mode as a string func (m *Model) ModeString() string { return "VIEW" } // seekToActiveCue returns a command to seek audio to the active cue func (m *Model) seekToActiveCue() tea.Cmd { if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) { return nil } return func() tea.Msg { return SeekToCueMsg{Position: m.transcript.Cues[m.activeCue].Start} } } // Update handles messages func (m *Model) Update(msg tea.Msg) tea.Cmd { if m.transcript == nil { return nil } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "j", "down": // Move to next cue and seek if m.activeCue < len(m.transcript.Cues)-1 { m.activeCue++ m.refreshAndScroll() return m.seekToActiveCue() } return nil case "k", "up": // Move to previous cue and seek if m.activeCue > 0 { m.activeCue-- m.refreshAndScroll() return m.seekToActiveCue() } return nil case "ctrl+d": // Jump 5 cues down and seek m.activeCue += 5 if m.activeCue >= len(m.transcript.Cues) { m.activeCue = len(m.transcript.Cues) - 1 } m.refreshAndScroll() return m.seekToActiveCue() case "ctrl+u": // Jump 5 cues up and seek m.activeCue -= 5 if m.activeCue < 0 { m.activeCue = 0 } m.refreshAndScroll() return m.seekToActiveCue() case "g": // Go to first cue and seek m.activeCue = 0 m.refreshAndScroll() return m.seekToActiveCue() case "G": // Go to last cue and seek m.activeCue = len(m.transcript.Cues) - 1 m.refreshAndScroll() return m.seekToActiveCue() } } return nil } // refreshAndScroll updates content and scrolls to active cue func (m *Model) refreshAndScroll() { m.updateContent() m.scrollToCue(m.activeCue) } func (m *Model) updateContent() { if m.transcript == nil { m.viewport.SetContent("No transcript loaded") return } // Track line positions for each cue m.cueLines = make([]int, len(m.transcript.Cues)) currentLine := 0 var sb strings.Builder for i, cue := range m.transcript.Cues { m.cueLines[i] = currentLine isActive := i == m.activeCue rendered := RenderCue(&cue, isActive, m.Width-4) sb.WriteString(rendered) // Count lines in this cue's rendering currentLine += strings.Count(rendered, "\n") if i < len(m.transcript.Cues)-1 { sb.WriteString("\n") currentLine++ // blank line between cues } } m.viewport.SetContent(sb.String()) } func (m *Model) scrollToCue(cueIndex int) { if cueIndex < 0 || m.transcript == nil || cueIndex >= len(m.cueLines) { return } targetLine := m.cueLines[cueIndex] // Center the cue in the viewport viewportHeight := m.viewport.Height offset := targetLine - viewportHeight/2 if offset < 0 { offset = 0 } m.viewport.SetYOffset(offset) } // View renders the transcript func (m Model) View() string { content := m.viewport.View() style := ui.TranscriptFocusedStyle return style.Width(m.Width - 2).Height(m.Height - 2).Render(content) }