init commit

This commit is contained in:
2026-01-25 17:13:15 -06:00
commit 1bbfc332d8
27 changed files with 2462 additions and 0 deletions

View File

@@ -0,0 +1,233 @@
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)
}