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 currentCue int // Cue currently playing (from playback position) selectedCue int // Cue selected by user navigation cueLines []int // Starting line number (in rendered view) for each cue Width int Height int Focused bool } // New creates a new transcript model func New() Model { vp := viewport.New(80, 20) vp.Style = lipgloss.NewStyle() return Model{ viewport: vp, currentCue: -1, selectedCue: 0, } } // SetTranscript sets the transcript to display func (m *Model) SetTranscript(t *srt.Transcript) { m.transcript = t m.selectedCue = 0 m.updateContent() m.scrollToCue(0) } // Transcript returns the current transcript func (m *Model) Transcript() *srt.Transcript { return m.transcript } // SelectedCueLineNumber returns the line number of the selected cue for vim func (m *Model) SelectedCueLineNumber() int { if m.transcript == nil || m.selectedCue < 0 || m.selectedCue >= len(m.transcript.Cues) { return 1 } return m.transcript.Cues[m.selectedCue].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.currentCue { m.currentCue = newCue m.updateContent() // Only auto-scroll if not focused (let user navigate freely when focused) if !m.Focused && 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() } // SetFocused sets the focus state func (m *Model) SetFocused(focused bool) { m.Focused = focused // When focusing, sync selected cue to current playback position if valid if focused && m.currentCue >= 0 { m.selectedCue = m.currentCue m.updateContent() m.scrollToCue(m.selectedCue) } } // ModeString returns the mode as a string func (m *Model) ModeString() string { return "VIEW" } // 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 if m.selectedCue < len(m.transcript.Cues)-1 { m.selectedCue++ m.refreshAndScroll() } return nil case "k", "up": // Move to previous cue if m.selectedCue > 0 { m.selectedCue-- m.refreshAndScroll() } return nil case "ctrl+d": // Jump 5 cues down m.selectedCue += 5 if m.selectedCue >= len(m.transcript.Cues) { m.selectedCue = len(m.transcript.Cues) - 1 } m.refreshAndScroll() return nil case "ctrl+u": // Jump 5 cues up m.selectedCue -= 5 if m.selectedCue < 0 { m.selectedCue = 0 } m.refreshAndScroll() return nil case "g": // Go to first cue m.selectedCue = 0 m.refreshAndScroll() return nil case "G": // Go to last cue m.selectedCue = len(m.transcript.Cues) - 1 m.refreshAndScroll() return nil case "enter": // Seek to selected cue if m.selectedCue >= 0 && m.selectedCue < len(m.transcript.Cues) { return func() tea.Msg { return SeekToCueMsg{Position: m.transcript.Cues[m.selectedCue].Start} } } return nil } } return nil } // refreshAndScroll updates content and scrolls to selected cue func (m *Model) refreshAndScroll() { m.updateContent() m.scrollToCue(m.selectedCue) } 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 isCurrent := i == m.currentCue isSelected := i == m.selectedCue rendered := RenderCue(&cue, isCurrent, isSelected, 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.TranscriptStyle if m.Focused { style = ui.TranscriptFocusedStyle } return style.Width(m.Width - 2).Height(m.Height - 2).Render(content) }