init commit
This commit is contained in:
233
internal/ui/transcript/transcript.go
Normal file
233
internal/ui/transcript/transcript.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user