220 lines
4.9 KiB
Go
220 lines
4.9 KiB
Go
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)
|
|
}
|