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

374
internal/app/app.go Normal file
View File

@@ -0,0 +1,374 @@
package app
import (
"fmt"
"os/exec"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"playback/internal/audio"
"playback/internal/config"
"playback/internal/srt"
"playback/internal/ui"
"playback/internal/ui/header"
"playback/internal/ui/transcript"
"playback/internal/ui/waveform"
)
// FocusedView represents which view has focus
type FocusedView int
const (
FocusWaveform FocusedView = iota
FocusTranscript
)
// Model is the main application model
type Model struct {
// Configuration
config config.Config
keys KeyMap
// Audio
player *audio.Player
audioPath string
// Transcript
transcriptPath string
// UI Components
header header.Model
waveform waveform.Model
transcript transcript.Model
// State
focused FocusedView
showHelp bool
width int
height int
err error
statusMsg string
quitting bool
}
// New creates a new application model
func New(audioPath, transcriptPath string) Model {
cfg := config.Load()
m := Model{
config: cfg,
keys: DefaultKeyMap(),
player: audio.NewPlayer(),
audioPath: audioPath,
transcriptPath: transcriptPath,
header: header.New(),
waveform: waveform.New(),
transcript: transcript.New(),
focused: FocusWaveform,
}
return m
}
// Init initializes the application
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.loadAudio(),
m.loadTranscript(),
m.tickCmd(),
)
}
func (m Model) loadAudio() tea.Cmd {
return func() tea.Msg {
if err := m.player.Load(m.audioPath); err != nil {
return ErrorMsg{Err: fmt.Errorf("failed to load audio: %w", err)}
}
// Load waveform data
samples, err := m.player.GetSamples(200)
if err != nil {
return WaveformLoadedMsg{Err: err}
}
return WaveformLoadedMsg{Samples: samples}
}
}
func (m Model) loadTranscript() tea.Cmd {
return func() tea.Msg {
path := m.transcriptPath
// Try to find transcript if not specified
if path == "" {
path = srt.FindTranscript(m.audioPath)
}
// Create temp file if no transcript found
if path == "" {
var err error
path, err = srt.CreateTempTranscript(m.audioPath)
if err != nil {
return ErrorMsg{Err: fmt.Errorf("failed to create temp transcript: %w", err)}
}
}
t, err := srt.Load(path)
if err != nil {
return ErrorMsg{Err: fmt.Errorf("failed to load transcript: %w", err)}
}
return transcriptLoadedMsg{transcript: t}
}
}
type transcriptLoadedMsg struct {
transcript *srt.Transcript
}
func (m Model) tickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// Update handles messages
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateLayout()
case tea.KeyMsg:
// Global keys
switch {
case key.Matches(msg, m.keys.Quit):
m.quitting = true
m.player.Close()
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.showHelp = !m.showHelp
case key.Matches(msg, m.keys.PlayPause):
m.player.Toggle()
case key.Matches(msg, m.keys.FocusWaveform):
m.focused = FocusWaveform
m.waveform.SetFocused(true)
m.transcript.SetFocused(false)
case key.Matches(msg, m.keys.FocusTranscript):
m.focused = FocusTranscript
m.waveform.SetFocused(false)
m.transcript.SetFocused(true)
case key.Matches(msg, m.keys.EnterEdit):
if m.focused == FocusTranscript {
m.player.Pause()
return m, m.launchEditor()
}
}
// Context-specific keys
if m.focused == FocusWaveform {
switch {
case key.Matches(msg, m.keys.SeekForward):
m.player.SeekRelative(m.config.SeekStep)
case key.Matches(msg, m.keys.SeekBackward):
m.player.SeekRelative(-m.config.SeekStep)
case key.Matches(msg, m.keys.SeekForwardBig):
m.player.SeekRelative(m.config.BigSeekStep)
case key.Matches(msg, m.keys.SeekBackwardBig):
m.player.SeekRelative(-m.config.BigSeekStep)
}
}
if m.focused == FocusTranscript {
cmd := m.transcript.Update(msg)
cmds = append(cmds, cmd)
}
case TickMsg:
m.updatePosition()
cmds = append(cmds, m.tickCmd())
case WaveformLoadedMsg:
if msg.Err != nil {
m.statusMsg = fmt.Sprintf("Waveform: %v", msg.Err)
} else {
data := audio.NewWaveformData(msg.Samples)
m.waveform.SetSamples(data)
m.waveform.SetDuration(m.player.Duration())
}
case transcriptLoadedMsg:
m.transcript.SetTranscript(msg.transcript)
m.transcriptPath = msg.transcript.FilePath
m.header.SetPaths(m.audioPath, m.transcriptPath, msg.transcript.IsTemp)
case ErrorMsg:
m.err = msg.Err
case SavedMsg:
if msg.Err != nil {
m.statusMsg = fmt.Sprintf("Save failed: %v", msg.Err)
} else {
m.statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
}
case VimExitedMsg:
if msg.Err != nil {
m.statusMsg = fmt.Sprintf("Editor error: %v", msg.Err)
} else {
return m, m.reloadTranscript(msg.Path)
}
case transcript.SeekToCueMsg:
m.player.Seek(msg.Position)
}
return m, tea.Batch(cmds...)
}
func (m *Model) updatePosition() {
pos := m.player.Position()
dur := m.player.Duration()
if dur > 0 {
m.waveform.SetPosition(float64(pos) / float64(dur))
}
m.transcript.SetPosition(pos)
}
func (m *Model) updateLayout() {
// Header: 2 lines
headerHeight := 2
// Waveform: 5 lines
waveformHeight := 5
// Status bar: 1 line
statusHeight := 1
// Transcript: remaining space
transcriptHeight := m.height - headerHeight - waveformHeight - statusHeight - 2
m.header.SetWidth(m.width)
m.waveform.SetSize(m.width, waveformHeight)
m.transcript.SetSize(m.width, transcriptHeight)
m.waveform.SetFocused(m.focused == FocusWaveform)
m.transcript.SetFocused(m.focused == FocusTranscript)
}
func (m Model) launchEditor() tea.Cmd {
t := m.transcript.Transcript()
if t == nil {
return nil
}
lineNum := m.transcript.SelectedCueLineNumber()
c := exec.Command(m.config.Editor, fmt.Sprintf("+%d", lineNum), t.FilePath)
return tea.ExecProcess(c, func(err error) tea.Msg {
return VimExitedMsg{Path: t.FilePath, Err: err}
})
}
func (m Model) reloadTranscript(path string) tea.Cmd {
return func() tea.Msg {
t, err := srt.Load(path)
if err != nil {
return ErrorMsg{Err: err}
}
return transcriptLoadedMsg{transcript: t}
}
}
// View renders the application
func (m Model) View() string {
if m.quitting {
return ""
}
if m.err != nil {
return ui.ErrorStyle.Render(fmt.Sprintf("Error: %v\n\nPress 'q' to quit.", m.err))
}
if m.showHelp {
return m.renderHelp()
}
// Header
headerView := m.header.View(m.player.Position(), m.player.Duration(), m.player.IsPlaying())
// Waveform
waveformView := m.waveform.View()
// Transcript
transcriptView := m.transcript.View()
// Status bar
statusView := m.renderStatus()
return lipgloss.JoinVertical(
lipgloss.Left,
headerView,
"",
waveformView,
transcriptView,
statusView,
)
}
func (m Model) renderStatus() string {
// Mode indicator
modeStyle := ui.ModeStyle
mode := modeStyle.Render(m.transcript.ModeString())
// Focus indicator
focusStr := "Waveform"
if m.focused == FocusTranscript {
focusStr = "Transcript"
}
focus := ui.BaseStyle.Render(fmt.Sprintf("[%s]", focusStr))
// Status message
statusMsg := ui.StatusBarStyle.Render(m.statusMsg)
// Help hint
helpHint := ui.HelpDescStyle.Render("Press ? for help")
return lipgloss.JoinHorizontal(
lipgloss.Center,
mode,
" ",
focus,
" ",
statusMsg,
lipgloss.NewStyle().Width(m.width-lipgloss.Width(mode)-lipgloss.Width(focus)-lipgloss.Width(statusMsg)-lipgloss.Width(helpHint)-8).Render(""),
helpHint,
)
}
func (m Model) renderHelp() string {
help := m.keys.HelpView()
helpStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ui.ColorPrimary).
Padding(1, 2).
Width(60)
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
helpStyle.Render(help),
)
}

130
internal/app/keys.go Normal file
View File

@@ -0,0 +1,130 @@
package app
import "github.com/charmbracelet/bubbles/key"
// KeyMap defines all keybindings
type KeyMap struct {
// Global
Quit key.Binding
Help key.Binding
PlayPause key.Binding
// Focus
FocusWaveform key.Binding
FocusTranscript key.Binding
// Waveform navigation
SeekForward key.Binding
SeekBackward key.Binding
SeekForwardBig key.Binding
SeekBackwardBig key.Binding
// Transcript navigation
ScrollUp key.Binding
ScrollDown key.Binding
PageUp key.Binding
PageDown key.Binding
GoTop key.Binding
GoBottom key.Binding
// Editing
EnterEdit key.Binding
}
// DefaultKeyMap returns the default keybindings
func DefaultKeyMap() KeyMap {
return KeyMap{
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "help"),
),
PlayPause: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "play/pause"),
),
FocusWaveform: key.NewBinding(
key.WithKeys("ctrl+k"),
key.WithHelp("ctrl+k", "focus waveform"),
),
FocusTranscript: key.NewBinding(
key.WithKeys("ctrl+j"),
key.WithHelp("ctrl+j", "focus transcript"),
),
SeekForward: key.NewBinding(
key.WithKeys("l", "right"),
key.WithHelp("l/→", "seek forward"),
),
SeekBackward: key.NewBinding(
key.WithKeys("h", "left"),
key.WithHelp("h/←", "seek backward"),
),
SeekForwardBig: key.NewBinding(
key.WithKeys("L", "shift+right"),
key.WithHelp("L", "seek forward (big)"),
),
SeekBackwardBig: key.NewBinding(
key.WithKeys("H", "shift+left"),
key.WithHelp("H", "seek backward (big)"),
),
ScrollUp: key.NewBinding(
key.WithKeys("k", "up"),
key.WithHelp("k/↑", "scroll up"),
),
ScrollDown: key.NewBinding(
key.WithKeys("j", "down"),
key.WithHelp("j/↓", "scroll down"),
),
PageUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("ctrl+d"),
key.WithHelp("ctrl+d", "page down"),
),
GoTop: key.NewBinding(
key.WithKeys("g"),
key.WithHelp("gg", "go to top"),
),
GoBottom: key.NewBinding(
key.WithKeys("G"),
key.WithHelp("G", "go to bottom"),
),
EnterEdit: key.NewBinding(
key.WithKeys("i"),
key.WithHelp("i", "edit transcript"),
),
}
}
// HelpView returns a formatted help string
func (k KeyMap) HelpView() string {
return `Keybindings:
Global:
space Play/Pause
ctrl+j Focus transcript
ctrl+k Focus waveform
q Quit
? Toggle help
Waveform (when focused):
h / ← Seek backward (5s)
l / → Seek forward (5s)
H Seek backward (30s)
L Seek forward (30s)
Transcript (when focused):
j / ↓ Next cue
k / ↑ Previous cue
ctrl+d Jump 5 cues down
ctrl+u Jump 5 cues up
g Go to first cue
G Go to last cue
enter Seek audio to cue
i Edit in $EDITOR at cue`
}

29
internal/app/messages.go Normal file
View File

@@ -0,0 +1,29 @@
package app
import "time"
// TickMsg is sent periodically to update playback position
type TickMsg time.Time
// WaveformLoadedMsg is sent when waveform data is ready
type WaveformLoadedMsg struct {
Samples []float64
Err error
}
// ErrorMsg represents an error
type ErrorMsg struct {
Err error
}
// SavedMsg is sent when transcript is saved
type SavedMsg struct {
Path string
Err error
}
// VimExitedMsg is sent when vim finishes editing
type VimExitedMsg struct {
Path string
Err error
}