feat: unified navigation

fix: install script copying build binary
chore: updated readme
This commit is contained in:
2026-01-25 21:49:34 -06:00
parent 1bbfc332d8
commit 6165a10481
10 changed files with 273 additions and 209 deletions

169
README.md
View File

@@ -2,43 +2,119 @@
A terminal-based audio player with synchronized transcript viewing and editing. A terminal-based audio player with synchronized transcript viewing and editing.
## Installation ## Quick Start
```bash
make install
```
## Usage
```bash ```bash
playback audio.mp3 # Auto-finds transcript (audio.srt) playback audio.mp3 # Auto-finds transcript (audio.srt)
playback audio.mp3 -t transcript.srt # Specify transcript 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 <repo-url>
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 ## Keybindings
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `space` | Play/Pause |
| `ctrl+j` | Focus transcript |
| `ctrl+k` | Focus waveform |
| `q` | Quit | | `q` | Quit |
| `?` | Toggle help | | `?` | Toggle help |
| `space` | Play/Pause |
| `ctrl+j` | Focus transcript view |
| `ctrl+k` | Focus waveform view |
**Waveform (focused):** ### Waveform View (When Focused)
- `h/l` or arrows: Seek 5s
- `H/L`: Seek 30s
**Transcript (focused):** | Key | Action |
- `j/k`: Navigate cues |-----|--------|
- `ctrl+d/u`: Jump 5 cues | `h` / `l` or `←` / `→` | Seek 5 seconds |
- `g/G`: First/last cue | `H` / `L` | Seek 30 seconds |
- `enter`: Seek audio to cue | `g` | Jump to beginning |
- `i`: Edit in external editor | `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 ## Configuration
Config file: `~/.config/playback/config.json` Create a config file at `~/.config/playback/config.json`:
```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 <repo-url>
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]

View File

@@ -1 +1 @@
0.1.0 0.1.1

View File

@@ -1,5 +1,18 @@
#!/bin/bash #!/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 # Create desktop entry directory if it doesn't exist
mkdir -p ~/.local/share/applications mkdir -p ~/.local/share/applications
@@ -19,5 +32,6 @@ EOF
# Update desktop database # Update desktop database
update-desktop-database ~/.local/share/applications 2>/dev/null || true 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 "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"

View File

@@ -17,13 +17,6 @@ import (
"playback/internal/ui/waveform" "playback/internal/ui/waveform"
) )
// FocusedView represents which view has focus
type FocusedView int
const (
FocusWaveform FocusedView = iota
FocusTranscript
)
// Model is the main application model // Model is the main application model
type Model struct { type Model struct {
@@ -44,7 +37,6 @@ type Model struct {
transcript transcript.Model transcript transcript.Model
// State // State
focused FocusedView
showHelp bool showHelp bool
width int width int
height int height int
@@ -66,7 +58,6 @@ func New(audioPath, transcriptPath string) Model {
header: header.New(), header: header.New(),
waveform: waveform.New(), waveform: waveform.New(),
transcript: transcript.New(), transcript: transcript.New(),
focused: FocusWaveform,
} }
return m return m
@@ -144,7 +135,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateLayout() m.updateLayout()
case tea.KeyMsg: case tea.KeyMsg:
// Global keys // All shortcuts are now global
switch { switch {
case key.Matches(msg, m.keys.Quit): case key.Matches(msg, m.keys.Quit):
m.quitting = true 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): case key.Matches(msg, m.keys.PlayPause):
m.player.Toggle() 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): case key.Matches(msg, m.keys.EnterEdit):
if m.focused == FocusTranscript {
m.player.Pause() m.player.Pause()
return m, m.launchEditor() return m, m.launchEditor()
}
}
// Context-specific keys // Seeking shortcuts (previously waveform-only, now global)
if m.focused == FocusWaveform {
switch {
case key.Matches(msg, m.keys.SeekForward): case key.Matches(msg, m.keys.SeekForward):
m.player.SeekRelative(m.config.SeekStep) m.player.SeekRelative(m.config.SeekStep)
case key.Matches(msg, m.keys.SeekBackward): case key.Matches(msg, m.keys.SeekBackward):
m.player.SeekRelative(-m.config.SeekStep) m.player.SeekRelative(-m.config.SeekStep)
case key.Matches(msg, m.keys.SeekForwardBig): case key.Matches(msg, m.keys.SeekForwardBig):
m.player.SeekRelative(m.config.BigSeekStep) m.player.SeekRelative(m.config.BigSeekStep)
case key.Matches(msg, m.keys.SeekBackwardBig): case key.Matches(msg, m.keys.SeekBackwardBig):
m.player.SeekRelative(-m.config.BigSeekStep) m.player.SeekRelative(-m.config.BigSeekStep)
}
}
if m.focused == FocusTranscript { // Transcript navigation and other keys forward to transcript
default:
cmd := m.transcript.Update(msg) cmd := m.transcript.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
@@ -262,9 +240,6 @@ func (m *Model) updateLayout() {
m.header.SetWidth(m.width) m.header.SetWidth(m.width)
m.waveform.SetSize(m.width, waveformHeight) m.waveform.SetSize(m.width, waveformHeight)
m.transcript.SetSize(m.width, transcriptHeight) m.transcript.SetSize(m.width, transcriptHeight)
m.waveform.SetFocused(m.focused == FocusWaveform)
m.transcript.SetFocused(m.focused == FocusTranscript)
} }
func (m Model) launchEditor() tea.Cmd { func (m Model) launchEditor() tea.Cmd {
@@ -272,7 +247,7 @@ func (m Model) launchEditor() tea.Cmd {
if t == nil { if t == nil {
return nil return nil
} }
lineNum := m.transcript.SelectedCueLineNumber() lineNum := m.transcript.ActiveCueLineNumber()
c := exec.Command(m.config.Editor, fmt.Sprintf("+%d", lineNum), t.FilePath) c := exec.Command(m.config.Editor, fmt.Sprintf("+%d", lineNum), t.FilePath)
return tea.ExecProcess(c, func(err error) tea.Msg { return tea.ExecProcess(c, func(err error) tea.Msg {
return VimExitedMsg{Path: t.FilePath, Err: err} return VimExitedMsg{Path: t.FilePath, Err: err}
@@ -330,13 +305,6 @@ func (m Model) renderStatus() string {
modeStyle := ui.ModeStyle modeStyle := ui.ModeStyle
mode := modeStyle.Render(m.transcript.ModeString()) 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 // Status message
statusMsg := ui.StatusBarStyle.Render(m.statusMsg) statusMsg := ui.StatusBarStyle.Render(m.statusMsg)
@@ -347,10 +315,8 @@ func (m Model) renderStatus() string {
lipgloss.Center, lipgloss.Center,
mode, mode,
" ", " ",
focus,
" ",
statusMsg, 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, helpHint,
) )
} }

View File

@@ -9,17 +9,11 @@ type KeyMap struct {
Help key.Binding Help key.Binding
PlayPause key.Binding PlayPause key.Binding
// Focus // Navigation (global)
FocusWaveform key.Binding
FocusTranscript key.Binding
// Waveform navigation
SeekForward key.Binding SeekForward key.Binding
SeekBackward key.Binding SeekBackward key.Binding
SeekForwardBig key.Binding SeekForwardBig key.Binding
SeekBackwardBig key.Binding SeekBackwardBig key.Binding
// Transcript navigation
ScrollUp key.Binding ScrollUp key.Binding
ScrollDown key.Binding ScrollDown key.Binding
PageUp key.Binding PageUp key.Binding
@@ -46,14 +40,6 @@ func DefaultKeyMap() KeyMap {
key.WithKeys(" "), key.WithKeys(" "),
key.WithHelp("space", "play/pause"), 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( SeekForward: key.NewBinding(
key.WithKeys("l", "right"), key.WithKeys("l", "right"),
key.WithHelp("l/→", "seek forward"), key.WithHelp("l/→", "seek forward"),
@@ -72,27 +58,27 @@ func DefaultKeyMap() KeyMap {
), ),
ScrollUp: key.NewBinding( ScrollUp: key.NewBinding(
key.WithKeys("k", "up"), key.WithKeys("k", "up"),
key.WithHelp("k/↑", "scroll up"), key.WithHelp("k/↑", "previous cue"),
), ),
ScrollDown: key.NewBinding( ScrollDown: key.NewBinding(
key.WithKeys("j", "down"), key.WithKeys("j", "down"),
key.WithHelp("j/↓", "scroll down"), key.WithHelp("j/↓", "next cue"),
), ),
PageUp: key.NewBinding( PageUp: key.NewBinding(
key.WithKeys("ctrl+u"), key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "page up"), key.WithHelp("ctrl+u", "jump 5 cues up"),
), ),
PageDown: key.NewBinding( PageDown: key.NewBinding(
key.WithKeys("ctrl+d"), key.WithKeys("ctrl+d"),
key.WithHelp("ctrl+d", "page down"), key.WithHelp("ctrl+d", "jump 5 cues down"),
), ),
GoTop: key.NewBinding( GoTop: key.NewBinding(
key.WithKeys("g"), key.WithKeys("g"),
key.WithHelp("gg", "go to top"), key.WithHelp("g", "go to first cue"),
), ),
GoBottom: key.NewBinding( GoBottom: key.NewBinding(
key.WithKeys("G"), key.WithKeys("G"),
key.WithHelp("G", "go to bottom"), key.WithHelp("G", "go to last cue"),
), ),
EnterEdit: key.NewBinding( EnterEdit: key.NewBinding(
key.WithKeys("i"), key.WithKeys("i"),
@@ -107,24 +93,21 @@ func (k KeyMap) HelpView() string {
Global: Global:
space Play/Pause space Play/Pause
ctrl+j Focus transcript
ctrl+k Focus waveform
q Quit q Quit
? Toggle help ? Toggle help
Waveform (when focused): Navigation (Global):
h / ← Seek backward (5s) h / ← Seek backward (5s)
l / → Seek forward (5s) l / → Seek forward (5s)
H Seek backward (30s) H Seek backward (30s)
L Seek forward (30s) L Seek forward (30s)
Transcript (when focused):
j / ↓ Next cue j / ↓ Next cue
k / ↑ Previous cue k / ↑ Previous cue
ctrl+d Jump 5 cues down ctrl+d Jump 5 cues down
ctrl+u Jump 5 cues up ctrl+u Jump 5 cues up
g Go to first cue g Go to first cue
G Go to last cue G Go to last cue
enter Seek audio to cue
Editing:
i Edit in $EDITOR at cue` i Edit in $EDITOR at cue`
} }

View File

@@ -11,7 +11,13 @@ import (
// Load loads an SRT file from the given path // Load loads an SRT file from the given path
func Load(path string) (*Transcript, error) { 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 { if err != nil {
return nil, fmt.Errorf("failed to parse SRT file: %w", err) return nil, fmt.Errorf("failed to parse SRT file: %w", err)
} }

View File

@@ -56,21 +56,18 @@ var (
BorderForeground(ColorPrimary). BorderForeground(ColorPrimary).
Padding(0, 1) Padding(0, 1)
CurrentCueStyle = lipgloss.NewStyle(). ActiveCueStyle = lipgloss.NewStyle().
Background(ColorHighlight). Background(ColorSecondary).
Foreground(ColorSecondary). Foreground(ColorBackground).
Bold(true) Bold(true)
SelectedCueStyle = lipgloss.NewStyle(). ActiveTimestampStyle = lipgloss.NewStyle().
Foreground(ColorAccent). Foreground(ColorBackground).
Bold(true) Bold(true)
TimestampStyle = lipgloss.NewStyle(). TimestampStyle = lipgloss.NewStyle().
Foreground(ColorMuted) Foreground(ColorMuted)
SelectedTimestampStyle = lipgloss.NewStyle().
Foreground(ColorAccent)
// Status bar styles // Status bar styles
StatusBarStyle = lipgloss.NewStyle(). StatusBarStyle = lipgloss.NewStyle().
Foreground(ColorMuted). Foreground(ColorMuted).

View File

@@ -10,34 +10,28 @@ import (
) )
// RenderCue renders a single cue with optional highlighting // 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 // Format timestamp
timestamp := formatTimestamp(cue.Start, cue.End) timestamp := formatTimestamp(cue.Start, cue.End)
// Apply styles based on state // Apply styles based on state
var textStyle, timestampStyle lipgloss.Style var textStyle, timestampStyle lipgloss.Style
if isSelected { var prefix string
// Selected cue (navigation cursor) - use accent color
textStyle = ui.SelectedCueStyle if isActive {
timestampStyle = ui.SelectedTimestampStyle // Active cue (playback position + navigation cursor)
} else if isCurrent { textStyle = ui.ActiveCueStyle
// Current cue (playback position) timestampStyle = ui.ActiveTimestampStyle
textStyle = ui.CurrentCueStyle prefix = "> "
timestampStyle = ui.TimestampStyle
} else { } else {
textStyle = ui.BaseStyle textStyle = ui.BaseStyle
timestampStyle = ui.TimestampStyle timestampStyle = ui.TimestampStyle
prefix = " "
} }
timestampStr := timestampStyle.Render(timestamp) timestampStr := timestampStyle.Render(timestamp)
textStr := textStyle.Render(cue.Text) 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) return fmt.Sprintf("%s%s\n%s%s\n", prefix, timestampStr, prefix, textStr)
} }

View File

@@ -20,12 +20,10 @@ type SeekToCueMsg struct {
type Model struct { type Model struct {
viewport viewport.Model viewport viewport.Model
transcript *srt.Transcript transcript *srt.Transcript
currentCue int // Cue currently playing (from playback position) activeCue int // Cue currently active (playback position + navigation cursor)
selectedCue int // Cue selected by user navigation
cueLines []int // Starting line number (in rendered view) for each cue cueLines []int // Starting line number (in rendered view) for each cue
Width int Width int
Height int Height int
Focused bool
} }
// New creates a new transcript model // New creates a new transcript model
@@ -35,15 +33,14 @@ func New() Model {
return Model{ return Model{
viewport: vp, viewport: vp,
currentCue: -1, activeCue: -1,
selectedCue: 0,
} }
} }
// SetTranscript sets the transcript to display // SetTranscript sets the transcript to display
func (m *Model) SetTranscript(t *srt.Transcript) { func (m *Model) SetTranscript(t *srt.Transcript) {
m.transcript = t m.transcript = t
m.selectedCue = 0 m.activeCue = 0
m.updateContent() m.updateContent()
m.scrollToCue(0) m.scrollToCue(0)
} }
@@ -53,12 +50,12 @@ func (m *Model) Transcript() *srt.Transcript {
return m.transcript return m.transcript
} }
// SelectedCueLineNumber returns the line number of the selected cue for vim // ActiveCueLineNumber returns the line number of the active cue for vim
func (m *Model) SelectedCueLineNumber() int { func (m *Model) ActiveCueLineNumber() int {
if m.transcript == nil || m.selectedCue < 0 || m.selectedCue >= len(m.transcript.Cues) { if m.transcript == nil || m.activeCue < 0 || m.activeCue >= len(m.transcript.Cues) {
return 1 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 // 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) newCue := m.transcript.CueIndexAt(pos)
if newCue != m.currentCue { if newCue != m.activeCue {
m.currentCue = newCue m.activeCue = newCue
m.updateContent() m.updateContent()
// Only auto-scroll if not focused (let user navigate freely when focused) // Always auto-scroll during playback
if !m.Focused && newCue >= 0 { if newCue >= 0 {
m.scrollToCue(newCue) m.scrollToCue(newCue)
} }
} }
@@ -87,22 +84,21 @@ func (m *Model) SetSize(width, height int) {
m.updateContent() 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 // ModeString returns the mode as a string
func (m *Model) ModeString() string { func (m *Model) ModeString() string {
return "VIEW" 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 // Update handles messages
func (m *Model) Update(msg tea.Msg) tea.Cmd { func (m *Model) Update(msg tea.Msg) tea.Cmd {
if m.transcript == nil { if m.transcript == nil {
@@ -113,63 +109,57 @@ func (m *Model) Update(msg tea.Msg) tea.Cmd {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "j", "down": case "j", "down":
// Move to next cue // Move to next cue and seek
if m.selectedCue < len(m.transcript.Cues)-1 { if m.activeCue < len(m.transcript.Cues)-1 {
m.selectedCue++ m.activeCue++
m.refreshAndScroll() m.refreshAndScroll()
return m.seekToActiveCue()
} }
return nil return nil
case "k", "up": case "k", "up":
// Move to previous cue // Move to previous cue and seek
if m.selectedCue > 0 { if m.activeCue > 0 {
m.selectedCue-- m.activeCue--
m.refreshAndScroll() m.refreshAndScroll()
return m.seekToActiveCue()
} }
return nil return nil
case "ctrl+d": case "ctrl+d":
// Jump 5 cues down // Jump 5 cues down and seek
m.selectedCue += 5 m.activeCue += 5
if m.selectedCue >= len(m.transcript.Cues) { if m.activeCue >= len(m.transcript.Cues) {
m.selectedCue = len(m.transcript.Cues) - 1 m.activeCue = len(m.transcript.Cues) - 1
} }
m.refreshAndScroll() m.refreshAndScroll()
return nil return m.seekToActiveCue()
case "ctrl+u": case "ctrl+u":
// Jump 5 cues up // Jump 5 cues up and seek
m.selectedCue -= 5 m.activeCue -= 5
if m.selectedCue < 0 { if m.activeCue < 0 {
m.selectedCue = 0 m.activeCue = 0
} }
m.refreshAndScroll() m.refreshAndScroll()
return nil return m.seekToActiveCue()
case "g": case "g":
// Go to first cue // Go to first cue and seek
m.selectedCue = 0 m.activeCue = 0
m.refreshAndScroll() m.refreshAndScroll()
return nil return m.seekToActiveCue()
case "G": case "G":
// Go to last cue // Go to last cue and seek
m.selectedCue = len(m.transcript.Cues) - 1 m.activeCue = len(m.transcript.Cues) - 1
m.refreshAndScroll() m.refreshAndScroll()
return nil return m.seekToActiveCue()
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 return nil
} }
// refreshAndScroll updates content and scrolls to selected cue // refreshAndScroll updates content and scrolls to active cue
func (m *Model) refreshAndScroll() { func (m *Model) refreshAndScroll() {
m.updateContent() m.updateContent()
m.scrollToCue(m.selectedCue) m.scrollToCue(m.activeCue)
} }
func (m *Model) updateContent() { func (m *Model) updateContent() {
@@ -186,9 +176,8 @@ func (m *Model) updateContent() {
for i, cue := range m.transcript.Cues { for i, cue := range m.transcript.Cues {
m.cueLines[i] = currentLine m.cueLines[i] = currentLine
isCurrent := i == m.currentCue isActive := i == m.activeCue
isSelected := i == m.selectedCue rendered := RenderCue(&cue, isActive, m.Width-4)
rendered := RenderCue(&cue, isCurrent, isSelected, m.Width-4)
sb.WriteString(rendered) sb.WriteString(rendered)
// Count lines in this cue's rendering // Count lines in this cue's rendering
@@ -224,10 +213,7 @@ func (m *Model) scrollToCue(cueIndex int) {
func (m Model) View() string { func (m Model) View() string {
content := m.viewport.View() content := m.viewport.View()
style := ui.TranscriptStyle style := ui.TranscriptFocusedStyle
if m.Focused {
style = ui.TranscriptFocusedStyle
}
return style.Width(m.Width - 2).Height(m.Height - 2).Render(content) return style.Width(m.Width - 2).Height(m.Height - 2).Render(content)
} }

View File

@@ -13,7 +13,6 @@ import (
type Model struct { type Model struct {
Width int Width int
Height int Height int
Focused bool
Samples []float64 Samples []float64
Position float64 // 0.0 to 1.0 Position float64 // 0.0 to 1.0
Duration time.Duration Duration time.Duration
@@ -49,11 +48,6 @@ func (m *Model) SetSize(width, height int) {
m.Height = height m.Height = height
} }
// SetFocused sets the focus state
func (m *Model) SetFocused(focused bool) {
m.Focused = focused
}
// View renders the waveform // View renders the waveform
func (m Model) View() string { func (m Model) View() string {
contentWidth := m.Width - 4 // Account for border and padding contentWidth := m.Width - 4 // Account for border and padding
@@ -115,11 +109,8 @@ func (m Model) View() string {
timeLine, timeLine,
) )
// Apply border style based on focus // Apply focused border style
style := ui.WaveformStyle style := ui.WaveformFocusedStyle
if m.Focused {
style = ui.WaveformFocusedStyle
}
return style.Width(m.Width - 2).Render(content) return style.Width(m.Width - 2).Render(content)
} }