diff --git a/README.md b/README.md index aa1b318..0b89dbe 100644 --- a/README.md +++ b/README.md @@ -2,43 +2,119 @@ A terminal-based audio player with synchronized transcript viewing and editing. -## Installation - -```bash -make install -``` - -## Usage +## Quick Start ```bash playback audio.mp3 # Auto-finds transcript (audio.srt) playback audio.mp3 -t transcript.srt # Specify transcript ``` +## Features + +- **Multi-format Audio Support**: Play MP3, WAV, FLAC, OGG files +- **Synchronized Transcripts**: Display and highlight SRT subtitles synced with audio +- **Vim-style Editing**: Edit transcripts using external editor (vim/nvim/emacs/etc) +- **Waveform Visualization**: Visual seek controls with progress indication +- **Auto Transcript Discovery**: Automatically finds matching .srt files +- **Configurable Keybindings**: Customizable shortcuts for all actions + +## Installation + +### Prerequisites + +- **Go** 1.25.4 or higher +- Standard Linux tools (for desktop entry: `update-desktop-database`) + +No additional native dependencies required - all dependencies are Go modules. + +### Build from Source + +```bash +# Clone the repository +git clone +cd playback + +# Install Go dependencies +go mod download + +# Build +make build + +# Run +./build/playback audio.mp3 +``` + +### Install to System + +```bash +# Build and install +make install + +# Or using the install script +./install.sh +``` + +Playback will be installed to `/usr/local/bin/playback` and a desktop entry created for your application menu. + +## Usage + +### Basic Usage + +```bash +# Auto-detect transcript +playback audio.mp3 + +# Specify transcript file +playback audio.mp3 -t transcript.srt + +# Run from system +playback audio.mp3 +``` + +### Transcript Auto-Discovery + +Playback automatically searches for a transcript file by: +1. Using the filename without extension (e.g., `audio.mp3` → `audio.srt`) +2. If not found, prompts you to specify a path + +### Editor Configuration + +Configure your preferred external editor in the config file (see Configuration below). + ## Keybindings | Key | Action | |-----|--------| -| `space` | Play/Pause | -| `ctrl+j` | Focus transcript | -| `ctrl+k` | Focus waveform | | `q` | Quit | | `?` | Toggle help | +| `space` | Play/Pause | +| `ctrl+j` | Focus transcript view | +| `ctrl+k` | Focus waveform view | -**Waveform (focused):** -- `h/l` or arrows: Seek 5s -- `H/L`: Seek 30s +### Waveform View (When Focused) -**Transcript (focused):** -- `j/k`: Navigate cues -- `ctrl+d/u`: Jump 5 cues -- `g/G`: First/last cue -- `enter`: Seek audio to cue -- `i`: Edit in external editor +| Key | Action | +|-----|--------| +| `h` / `l` or `←` / `→` | Seek 5 seconds | +| `H` / `L` | Seek 30 seconds | +| `g` | Jump to beginning | +| `G` | Jump to end | + +### Transcript View (When Focused) + +| Key | Action | +|-----|--------| +| `j` / `k` | Navigate to previous/next cue | +| `ctrl+d` | Jump 5 cues down | +| `ctrl+u` | Jump 5 cues up | +| `g` | Jump to first cue | +| `G` | Jump to last cue | +| `enter` | Seek audio to current cue position | +| `i` | Edit transcript in external editor | ## Configuration -Config file: `~/.config/playback/config.json` +Create a config file at `~/.config/playback/config.json`: ```json { @@ -49,4 +125,55 @@ Config file: `~/.config/playback/config.json` } ``` -Default editor is `vim`. +### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `seek_step_ms` | integer | 5000 | Seek step in milliseconds (small jumps) | +| `big_seek_step_ms` | integer | 30000 | Big seek step in milliseconds (large jumps) | +| `volume` | float | 1.0 | Initial volume level (0.0 - 1.0) | +| `editor` | string | "vim" | External editor command to use | + +## Troubleshooting + +### Audio Not Playing + +Ensure your audio files are supported (MP3, WAV, FLAC, OGG). + +### Transcript Not Found + +Specify the transcript file explicitly with `-t` flag: +```bash +playback audio.mp3 -t path/to/transcript.srt +``` + +### Desktop Entry Not Appearing + +Run `update-desktop-database ~/.local/share/applications` after installation. + +## Project Setup for Developers + +```bash +# Clone the repository +git clone +cd playback + +# Install Go dependencies +go mod download + +# Build +make build + +# Run +make run ./audio.mp3 + +# Run tests +make test + +# Clean build artifacts +make clean +``` + +## License + +[Add your license here] \ No newline at end of file diff --git a/VERSION b/VERSION index 6e8bf73..17e51c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.1.1 diff --git a/install.sh b/install.sh index bfc5203..519b2c8 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,18 @@ #!/bin/bash +set -e + +# Check if binary exists +if [ ! -f "build/playback" ]; then + echo "Error: build/playback not found. Run 'make build' first." + exit 1 +fi + +# Install binary to /usr/local/bin +echo "Installing binary to /usr/local/bin/playback..." +sudo cp build/playback /usr/local/bin/playback +sudo chmod +x /usr/local/bin/playback + # Create desktop entry directory if it doesn't exist mkdir -p ~/.local/share/applications @@ -19,5 +32,6 @@ EOF # Update desktop database update-desktop-database ~/.local/share/applications 2>/dev/null || true +echo "Binary installed to /usr/local/bin/playback" echo "Desktop entry installed to ~/.local/share/applications/playback.desktop" -echo "Playback is now available in your application menu" +echo "Playback is now available in your application menu and via 'playback' command" diff --git a/internal/app/app.go b/internal/app/app.go index f3bb099..b22a929 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,13 +17,6 @@ import ( "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 { @@ -44,7 +37,6 @@ type Model struct { transcript transcript.Model // State - focused FocusedView showHelp bool width int height int @@ -66,7 +58,6 @@ func New(audioPath, transcriptPath string) Model { header: header.New(), waveform: waveform.New(), transcript: transcript.New(), - focused: FocusWaveform, } return m @@ -144,7 +135,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateLayout() case tea.KeyMsg: - // Global keys + // All shortcuts are now global switch { case key.Matches(msg, m.keys.Quit): m.quitting = true @@ -157,38 +148,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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() - } - } + 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) - } - } + // Seeking shortcuts (previously waveform-only, now global) + case key.Matches(msg, m.keys.SeekForward): + m.player.SeekRelative(m.config.SeekStep) - if m.focused == FocusTranscript { + 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) + + // Transcript navigation and other keys forward to transcript + default: cmd := m.transcript.Update(msg) cmds = append(cmds, cmd) } @@ -262,9 +240,6 @@ func (m *Model) updateLayout() { 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 { @@ -272,7 +247,7 @@ func (m Model) launchEditor() tea.Cmd { if t == nil { return nil } - lineNum := m.transcript.SelectedCueLineNumber() + lineNum := m.transcript.ActiveCueLineNumber() 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} @@ -330,13 +305,6 @@ func (m Model) renderStatus() string { 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) @@ -347,10 +315,8 @@ func (m Model) renderStatus() string { lipgloss.Center, mode, " ", - focus, - " ", statusMsg, - lipgloss.NewStyle().Width(m.width-lipgloss.Width(mode)-lipgloss.Width(focus)-lipgloss.Width(statusMsg)-lipgloss.Width(helpHint)-8).Render(""), + lipgloss.NewStyle().Width(m.width-lipgloss.Width(mode)-lipgloss.Width(statusMsg)-lipgloss.Width(helpHint)-4).Render(""), helpHint, ) } diff --git a/internal/app/keys.go b/internal/app/keys.go index 701dfc0..9f7eb0a 100644 --- a/internal/app/keys.go +++ b/internal/app/keys.go @@ -9,17 +9,11 @@ type KeyMap struct { Help key.Binding PlayPause key.Binding - // Focus - FocusWaveform key.Binding - FocusTranscript key.Binding - - // Waveform navigation + // Navigation (global) SeekForward key.Binding SeekBackward key.Binding SeekForwardBig key.Binding SeekBackwardBig key.Binding - - // Transcript navigation ScrollUp key.Binding ScrollDown key.Binding PageUp key.Binding @@ -46,14 +40,6 @@ func DefaultKeyMap() KeyMap { 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"), @@ -72,27 +58,27 @@ func DefaultKeyMap() KeyMap { ), ScrollUp: key.NewBinding( key.WithKeys("k", "up"), - key.WithHelp("k/↑", "scroll up"), + key.WithHelp("k/↑", "previous cue"), ), ScrollDown: key.NewBinding( key.WithKeys("j", "down"), - key.WithHelp("j/↓", "scroll down"), + key.WithHelp("j/↓", "next cue"), ), PageUp: key.NewBinding( key.WithKeys("ctrl+u"), - key.WithHelp("ctrl+u", "page up"), + key.WithHelp("ctrl+u", "jump 5 cues up"), ), PageDown: key.NewBinding( key.WithKeys("ctrl+d"), - key.WithHelp("ctrl+d", "page down"), + key.WithHelp("ctrl+d", "jump 5 cues down"), ), GoTop: key.NewBinding( key.WithKeys("g"), - key.WithHelp("gg", "go to top"), + key.WithHelp("g", "go to first cue"), ), GoBottom: key.NewBinding( key.WithKeys("G"), - key.WithHelp("G", "go to bottom"), + key.WithHelp("G", "go to last cue"), ), EnterEdit: key.NewBinding( key.WithKeys("i"), @@ -107,24 +93,21 @@ func (k KeyMap) HelpView() string { Global: space Play/Pause - ctrl+j Focus transcript - ctrl+k Focus waveform q Quit ? Toggle help -Waveform (when focused): +Navigation (Global): 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 + +Editing: i Edit in $EDITOR at cue` } diff --git a/internal/srt/parser.go b/internal/srt/parser.go index bc01644..1396e3e 100644 --- a/internal/srt/parser.go +++ b/internal/srt/parser.go @@ -11,7 +11,13 @@ import ( // Load loads an SRT file from the given path func Load(path string) (*Transcript, error) { - subs, err := astisub.OpenFile(path) + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + subs, err := astisub.ReadFromSRT(f) if err != nil { return nil, fmt.Errorf("failed to parse SRT file: %w", err) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 8dbdbe7..cd18549 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -56,21 +56,18 @@ var ( BorderForeground(ColorPrimary). Padding(0, 1) - CurrentCueStyle = lipgloss.NewStyle(). - Background(ColorHighlight). - Foreground(ColorSecondary). + ActiveCueStyle = lipgloss.NewStyle(). + Background(ColorSecondary). + Foreground(ColorBackground). Bold(true) - SelectedCueStyle = lipgloss.NewStyle(). - Foreground(ColorAccent). - Bold(true) + ActiveTimestampStyle = lipgloss.NewStyle(). + Foreground(ColorBackground). + Bold(true) TimestampStyle = lipgloss.NewStyle(). Foreground(ColorMuted) - SelectedTimestampStyle = lipgloss.NewStyle(). - Foreground(ColorAccent) - // Status bar styles StatusBarStyle = lipgloss.NewStyle(). Foreground(ColorMuted). diff --git a/internal/ui/transcript/highlight.go b/internal/ui/transcript/highlight.go index cb83b90..8a664b7 100644 --- a/internal/ui/transcript/highlight.go +++ b/internal/ui/transcript/highlight.go @@ -10,34 +10,28 @@ import ( ) // RenderCue renders a single cue with optional highlighting -func RenderCue(cue *srt.Cue, isCurrent, isSelected bool, width int) string { +func RenderCue(cue *srt.Cue, isActive bool, width int) string { // Format timestamp timestamp := formatTimestamp(cue.Start, cue.End) // Apply styles based on state var textStyle, timestampStyle lipgloss.Style - if isSelected { - // Selected cue (navigation cursor) - use accent color - textStyle = ui.SelectedCueStyle - timestampStyle = ui.SelectedTimestampStyle - } else if isCurrent { - // Current cue (playback position) - textStyle = ui.CurrentCueStyle - timestampStyle = ui.TimestampStyle + var prefix string + + if isActive { + // Active cue (playback position + navigation cursor) + textStyle = ui.ActiveCueStyle + timestampStyle = ui.ActiveTimestampStyle + prefix = "> " } else { textStyle = ui.BaseStyle timestampStyle = ui.TimestampStyle + prefix = " " } timestampStr := timestampStyle.Render(timestamp) textStr := textStyle.Render(cue.Text) - // Add selection indicator - prefix := " " - if isSelected { - prefix = "> " - } - return fmt.Sprintf("%s%s\n%s%s\n", prefix, timestampStr, prefix, textStr) } diff --git a/internal/ui/transcript/transcript.go b/internal/ui/transcript/transcript.go index 4c8341b..f12d824 100644 --- a/internal/ui/transcript/transcript.go +++ b/internal/ui/transcript/transcript.go @@ -18,14 +18,12 @@ type SeekToCueMsg struct { // 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 + viewport viewport.Model + transcript *srt.Transcript + activeCue int // Cue currently active (playback position + navigation cursor) + cueLines []int // Starting line number (in rendered view) for each cue + Width int + Height int } // New creates a new transcript model @@ -34,16 +32,15 @@ func New() Model { vp.Style = lipgloss.NewStyle() return Model{ - viewport: vp, - currentCue: -1, - selectedCue: 0, + viewport: vp, + activeCue: -1, } } // SetTranscript sets the transcript to display func (m *Model) SetTranscript(t *srt.Transcript) { m.transcript = t - m.selectedCue = 0 + m.activeCue = 0 m.updateContent() m.scrollToCue(0) } @@ -53,12 +50,12 @@ 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) { +// ActiveCueLineNumber returns the line number of the active cue for vim +func (m *Model) ActiveCueLineNumber() int { + if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) { return 1 } - return m.transcript.Cues[m.selectedCue].LineNumber + return m.transcript.Cues[m.activeCue].LineNumber } // SetPosition updates which cue is highlighted based on playback position @@ -68,11 +65,11 @@ func (m *Model) SetPosition(pos time.Duration) { } newCue := m.transcript.CueIndexAt(pos) - if newCue != m.currentCue { - m.currentCue = newCue + if newCue != m.activeCue { + m.activeCue = newCue m.updateContent() - // Only auto-scroll if not focused (let user navigate freely when focused) - if !m.Focused && newCue >= 0 { + // Always auto-scroll during playback + if newCue >= 0 { m.scrollToCue(newCue) } } @@ -87,22 +84,21 @@ func (m *Model) SetSize(width, height int) { 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" } +// seekToActiveCue returns a command to seek audio to the active cue +func (m *Model) seekToActiveCue() tea.Cmd { + if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) { + return nil + } + return func() tea.Msg { + return SeekToCueMsg{Position: m.transcript.Cues[m.activeCue].Start} + } +} + // Update handles messages func (m *Model) Update(msg tea.Msg) tea.Cmd { if m.transcript == nil { @@ -113,63 +109,57 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd { case tea.KeyMsg: switch msg.String() { case "j", "down": - // Move to next cue - if m.selectedCue < len(m.transcript.Cues)-1 { - m.selectedCue++ + // Move to next cue and seek + if m.activeCue < len(m.transcript.Cues)-1 { + m.activeCue++ m.refreshAndScroll() + return m.seekToActiveCue() } return nil case "k", "up": - // Move to previous cue - if m.selectedCue > 0 { - m.selectedCue-- + // Move to previous cue and seek + if m.activeCue > 0 { + m.activeCue-- m.refreshAndScroll() + return m.seekToActiveCue() } 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 + // Jump 5 cues down and seek + m.activeCue += 5 + if m.activeCue >= len(m.transcript.Cues) { + m.activeCue = len(m.transcript.Cues) - 1 } m.refreshAndScroll() - return nil + return m.seekToActiveCue() case "ctrl+u": - // Jump 5 cues up - m.selectedCue -= 5 - if m.selectedCue < 0 { - m.selectedCue = 0 + // Jump 5 cues up and seek + m.activeCue -= 5 + if m.activeCue < 0 { + m.activeCue = 0 } m.refreshAndScroll() - return nil + return m.seekToActiveCue() case "g": - // Go to first cue - m.selectedCue = 0 + // Go to first cue and seek + m.activeCue = 0 m.refreshAndScroll() - return nil + return m.seekToActiveCue() case "G": - // Go to last cue - m.selectedCue = len(m.transcript.Cues) - 1 + // Go to last cue and seek + m.activeCue = 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 m.seekToActiveCue() } } return nil } -// refreshAndScroll updates content and scrolls to selected cue +// refreshAndScroll updates content and scrolls to active cue func (m *Model) refreshAndScroll() { m.updateContent() - m.scrollToCue(m.selectedCue) + m.scrollToCue(m.activeCue) } func (m *Model) updateContent() { @@ -186,9 +176,8 @@ func (m *Model) updateContent() { 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) + isActive := i == m.activeCue + rendered := RenderCue(&cue, isActive, m.Width-4) sb.WriteString(rendered) // Count lines in this cue's rendering @@ -224,10 +213,7 @@ func (m *Model) scrollToCue(cueIndex int) { func (m Model) View() string { content := m.viewport.View() - style := ui.TranscriptStyle - if m.Focused { - style = ui.TranscriptFocusedStyle - } + style := ui.TranscriptFocusedStyle return style.Width(m.Width - 2).Height(m.Height - 2).Render(content) } diff --git a/internal/ui/waveform/waveform.go b/internal/ui/waveform/waveform.go index 15e806b..ac3c8c5 100644 --- a/internal/ui/waveform/waveform.go +++ b/internal/ui/waveform/waveform.go @@ -13,7 +13,6 @@ import ( type Model struct { Width int Height int - Focused bool Samples []float64 Position float64 // 0.0 to 1.0 Duration time.Duration @@ -49,11 +48,6 @@ func (m *Model) SetSize(width, height int) { m.Height = height } -// SetFocused sets the focus state -func (m *Model) SetFocused(focused bool) { - m.Focused = focused -} - // View renders the waveform func (m Model) View() string { contentWidth := m.Width - 4 // Account for border and padding @@ -115,11 +109,8 @@ func (m Model) View() string { timeLine, ) - // Apply border style based on focus - style := ui.WaveformStyle - if m.Focused { - style = ui.WaveformFocusedStyle - } + // Apply focused border style + style := ui.WaveformFocusedStyle return style.Width(m.Width - 2).Render(content) }