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), ) }