feat: unified navigation
fix: install script copying build binary chore: updated readme
This commit is contained in:
169
README.md
169
README.md
@@ -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]
|
||||||
16
install.sh
16
install.sh
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user