234 lines
5.2 KiB
Go
234 lines
5.2 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
|
|
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)
|
|
}
|