init commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
test/
|
||||
61
IDEA.MD
Normal file
61
IDEA.MD
Normal file
@@ -0,0 +1,61 @@
|
||||
# Application Idea
|
||||
|
||||
`playback` is a fullscreen TUI for playing audio files in parallel with rendering their transcription from an SRT file.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
**Waveform View**
|
||||
A section that renders the "timeline" which show the waveform as well as a needle showing the current location
|
||||
|
||||
**Transcript View**
|
||||
A scrollable view that shows the contents of the SRT. As the audio is playing (or paused) at any particular location, the
|
||||
relevant text from the transcript should be highlighted showing the current location. Allow for an edit mode to fix
|
||||
transcriptions. With vim style interactions. If vim can be incorperated itself then eve better.
|
||||
|
||||
**Keyboard Navigation**
|
||||
- When the waveform view is the active view, move forward and back in the audio with `left`, `right`, `h`, and `l`.
|
||||
- When the transcript view is active, use vim binding to move around. Including `i` to enter interactive/edit mode.
|
||||
- Use `ctrl + j` and `ctrl + k` to move between wave form and transcript mode.
|
||||
|
||||
**Layout**
|
||||
The two views will stack vertically on top of each other, with the waveform being on top. Not too much vertical room is
|
||||
needed for the waveform. The trascript section will take up the rest of the vertical space and will be scrollable.
|
||||
|
||||
There will be a small section at the top with the absolute path to the audio file and to the SRT file.
|
||||
|
||||
## Workflow
|
||||
|
||||
Application launched with
|
||||
|
||||
```bash
|
||||
playback ./audioFileName -t ./transcriptFileName.srt
|
||||
```
|
||||
|
||||
`-t` stands for transcript and it is optional. If `-t` is not provided the system will first try to find a file in the same
|
||||
directory as the audio file that is also named the same except with an `srt` extension. If that is not found them the application
|
||||
will launch with no transcript.
|
||||
|
||||
**No Transcript Mode**
|
||||
The app can function perfectly fine with no transcript. The user can play, pause, rewind, fast forward, no issue. In this mode,
|
||||
the application will actually create a stand in, empty transcript with a formualted name of `sameNameOfFile.srt.tmp` in the system `/tmp`
|
||||
folder. The user will be able to edit this file in the editor and save it (using the vim commands when not in edit mode). If the user
|
||||
saves the file and it is one of these temp files, then it will make a new file in the dir of the audio file named `sameNameOfFile.srt`
|
||||
|
||||
The tmp transcript will be mostly empty, other than message about no transcript was found and a temproary file was made, which they can
|
||||
edit here, and what it will be saved as. Also the file will inform them about the `transcribe` tool found at
|
||||
`https://git.beitzah.net/ysandler/transcribe`. It will also inform them about how to launch with a srt file is the mistakenly didn't
|
||||
include it
|
||||
|
||||
|
||||
## Configuration
|
||||
- config file is found at `~/.config/playback`
|
||||
|
||||
|
||||
## Tech stack
|
||||
|
||||
I want to build as much as possible in Golang.
|
||||
I want to have versioning with a file called `VERSION` that just contains the version value
|
||||
I want to use `make` to build
|
||||
I want an install.sh script that creates a desktop registry for app launchers (only worrying about linux right now)
|
||||
|
||||
36
Makefile
Normal file
36
Makefile
Normal file
@@ -0,0 +1,36 @@
|
||||
.PHONY: build release run test install uninstall clean
|
||||
|
||||
BINARY_NAME := playback
|
||||
BUILD_DIR := build
|
||||
INSTALL_DIR := /usr/local/bin
|
||||
|
||||
VERSION := $(shell cat VERSION)
|
||||
LDFLAGS := -X 'playback/pkg/version.Version=$(VERSION)'
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/playback
|
||||
|
||||
release:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build -ldflags="-s -w $(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/playback
|
||||
|
||||
run: build
|
||||
./$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
install: release
|
||||
sudo cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
chmod +x $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
./install.sh
|
||||
|
||||
uninstall:
|
||||
sudo rm -f $(INSTALL_DIR)/$(BINARY_NAME)
|
||||
rm -f ~/.local/share/applications/playback.desktop
|
||||
update-desktop-database ~/.local/share/applications 2>/dev/null || true
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
go clean
|
||||
52
README.md
Normal file
52
README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Playback
|
||||
|
||||
A terminal-based audio player with synchronized transcript viewing and editing.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
playback audio.mp3 # Auto-finds transcript (audio.srt)
|
||||
playback audio.mp3 -t transcript.srt # Specify transcript
|
||||
```
|
||||
|
||||
## Keybindings
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `space` | Play/Pause |
|
||||
| `ctrl+j` | Focus transcript |
|
||||
| `ctrl+k` | Focus waveform |
|
||||
| `q` | Quit |
|
||||
| `?` | Toggle help |
|
||||
|
||||
**Waveform (focused):**
|
||||
- `h/l` or arrows: Seek 5s
|
||||
- `H/L`: Seek 30s
|
||||
|
||||
**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
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file: `~/.config/playback/config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"seek_step_ms": 5000,
|
||||
"big_seek_step_ms": 30000,
|
||||
"volume": 1.0,
|
||||
"editor": "nvim"
|
||||
}
|
||||
```
|
||||
|
||||
Default editor is `vim`.
|
||||
72
cmd/playback/main.go
Normal file
72
cmd/playback/main.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/spf13/cobra"
|
||||
"playback/internal/app"
|
||||
"playback/internal/audio"
|
||||
"playback/pkg/version"
|
||||
)
|
||||
|
||||
var (
|
||||
transcriptPath string
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "playback <audio-file>",
|
||||
Short: "Audio player with synchronized transcript display",
|
||||
Long: `Playback is a fullscreen TUI application for playing audio files
|
||||
with synchronized SRT transcript display and vim-style editing.
|
||||
|
||||
Features:
|
||||
- Play MP3, WAV, FLAC, and OGG files
|
||||
- Display and highlight transcripts in sync with audio
|
||||
- Vim-style editing for transcript modification
|
||||
- Waveform visualization with seek controls
|
||||
- Configurable keybindings`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Version: version.Get(),
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&transcriptPath, "transcript", "t", "", "Path to SRT transcript file")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
audioPath := args[0]
|
||||
|
||||
// Validate audio file exists
|
||||
if _, err := os.Stat(audioPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("audio file not found: %s", audioPath)
|
||||
}
|
||||
|
||||
// Validate audio format
|
||||
if err := audio.ValidateFormat(audioPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate transcript file if specified
|
||||
if transcriptPath != "" {
|
||||
if _, err := os.Stat(transcriptPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("transcript file not found: %s", transcriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create and run the app
|
||||
m := app.New(audioPath, transcriptPath)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("error running app: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
47
go.mod
Normal file
47
go.mod
Normal file
@@ -0,0 +1,47 @@
|
||||
module playback
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astisub v0.38.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/gopxl/beep/v2 v2.1.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asticode/go-astikit v0.20.0 // indirect
|
||||
github.com/asticode/go-astits v1.8.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/ebitengine/oto/v3 v3.3.2 // indirect
|
||||
github.com/ebitengine/purego v0.8.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
|
||||
github.com/icza/bitio v1.1.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jfreymuth/oggvorbis v1.0.5 // indirect
|
||||
github.com/jfreymuth/vorbis v1.0.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mewkiz/flac v1.0.12 // indirect
|
||||
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
144
go.sum
Normal file
144
go.sum
Normal file
@@ -0,0 +1,144 @@
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
|
||||
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
|
||||
github.com/asticode/go-astisub v0.38.0 h1:Qh3IO8Cotn0wwok5maid7xqsIJTwn2DtABT1UajKJaI=
|
||||
github.com/asticode/go-astisub v0.38.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
|
||||
github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg=
|
||||
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/oto/v3 v3.3.2 h1:VTWBsKX9eb+dXzaF4jEwQbs4yWIdXukJ0K40KgkpYlg=
|
||||
github.com/ebitengine/oto/v3 v3.3.2/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
|
||||
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
|
||||
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU=
|
||||
github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E=
|
||||
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
|
||||
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
|
||||
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
|
||||
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
||||
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
|
||||
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
|
||||
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
|
||||
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
|
||||
github.com/jszwec/csvutil v1.5.1/go.mod h1:Rpu7Uu9giO9subDyMCIQfHVDuLrcaC36UA4YcJjGBkg=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mewkiz/flac v1.0.12 h1:5Y1BRlUebfiVXPmz7hDD7h3ceV2XNrGNMejNVjDpgPY=
|
||||
github.com/mewkiz/flac v1.0.12/go.mod h1:1UeXlFRJp4ft2mfZnPLRpQTd7cSjb/s17o7JQzzyrCA=
|
||||
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 h1:tnAPMExbRERsyEYkmR1YjhTgDM0iqyiBYf8ojRXxdbA=
|
||||
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14/go.mod h1:QYCFBiH5q6XTHEbWhR0uhR3M9qNPoD2CSQzr0g75kE4=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
|
||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
23
install.sh
Executable file
23
install.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Create desktop entry directory if it doesn't exist
|
||||
mkdir -p ~/.local/share/applications
|
||||
|
||||
# Create desktop entry
|
||||
cat > ~/.local/share/applications/playback.desktop << 'EOF'
|
||||
[Desktop Entry]
|
||||
Name=Playback
|
||||
Comment=Audio player with synchronized transcript display
|
||||
Exec=/usr/local/bin/playback %f
|
||||
Icon=audio-x-generic
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=AudioVideo;Audio;Player;
|
||||
MimeType=audio/mpeg;audio/mp3;audio/wav;audio/x-wav;audio/flac;audio/ogg;audio/x-vorbis+ogg;
|
||||
EOF
|
||||
|
||||
# Update desktop database
|
||||
update-desktop-database ~/.local/share/applications 2>/dev/null || true
|
||||
|
||||
echo "Desktop entry installed to ~/.local/share/applications/playback.desktop"
|
||||
echo "Playback is now available in your application menu"
|
||||
374
internal/app/app.go
Normal file
374
internal/app/app.go
Normal 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
130
internal/app/keys.go
Normal 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
29
internal/app/messages.go
Normal 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
|
||||
}
|
||||
60
internal/audio/formats.go
Normal file
60
internal/audio/formats.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AudioFormat represents a supported audio format
|
||||
type AudioFormat int
|
||||
|
||||
const (
|
||||
FormatUnknown AudioFormat = iota
|
||||
FormatMP3
|
||||
FormatWAV
|
||||
FormatFLAC
|
||||
FormatOGG
|
||||
)
|
||||
|
||||
// DetectFormat returns the audio format based on file extension
|
||||
func DetectFormat(path string) AudioFormat {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return FormatMP3
|
||||
case ".wav":
|
||||
return FormatWAV
|
||||
case ".flac":
|
||||
return FormatFLAC
|
||||
case ".ogg":
|
||||
return FormatOGG
|
||||
default:
|
||||
return FormatUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the format name
|
||||
func (f AudioFormat) String() string {
|
||||
switch f {
|
||||
case FormatMP3:
|
||||
return "MP3"
|
||||
case FormatWAV:
|
||||
return "WAV"
|
||||
case FormatFLAC:
|
||||
return "FLAC"
|
||||
case FormatOGG:
|
||||
return "OGG"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateFormat checks if the file format is supported
|
||||
func ValidateFormat(path string) error {
|
||||
format := DetectFormat(path)
|
||||
if format == FormatUnknown {
|
||||
return fmt.Errorf("unsupported audio format: %s", filepath.Ext(path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
313
internal/audio/player.go
Normal file
313
internal/audio/player.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
"github.com/gopxl/beep/v2/flac"
|
||||
"github.com/gopxl/beep/v2/mp3"
|
||||
"github.com/gopxl/beep/v2/speaker"
|
||||
"github.com/gopxl/beep/v2/vorbis"
|
||||
"github.com/gopxl/beep/v2/wav"
|
||||
)
|
||||
|
||||
// Player handles audio playback
|
||||
type Player struct {
|
||||
mu sync.Mutex
|
||||
|
||||
filePath string
|
||||
file *os.File
|
||||
streamer beep.StreamSeekCloser
|
||||
format beep.Format
|
||||
ctrl *beep.Ctrl
|
||||
resampler *beep.Resampler
|
||||
|
||||
playing bool
|
||||
initialized bool
|
||||
}
|
||||
|
||||
// NewPlayer creates a new audio player
|
||||
func NewPlayer() *Player {
|
||||
return &Player{}
|
||||
}
|
||||
|
||||
// Load opens an audio file for playback
|
||||
func (p *Player) Load(path string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Close any existing file
|
||||
if p.file != nil {
|
||||
p.streamer.Close()
|
||||
p.file.Close()
|
||||
}
|
||||
|
||||
format := DetectFormat(path)
|
||||
if format == FormatUnknown {
|
||||
return fmt.Errorf("unsupported audio format")
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open audio file: %w", err)
|
||||
}
|
||||
|
||||
var streamer beep.StreamSeekCloser
|
||||
var audioFormat beep.Format
|
||||
|
||||
switch format {
|
||||
case FormatMP3:
|
||||
streamer, audioFormat, err = mp3.Decode(file)
|
||||
case FormatWAV:
|
||||
streamer, audioFormat, err = wav.Decode(file)
|
||||
case FormatFLAC:
|
||||
streamer, audioFormat, err = flac.Decode(file)
|
||||
case FormatOGG:
|
||||
streamer, audioFormat, err = vorbis.Decode(file)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return fmt.Errorf("failed to decode audio: %w", err)
|
||||
}
|
||||
|
||||
p.filePath = path
|
||||
p.file = file
|
||||
p.streamer = streamer
|
||||
p.format = audioFormat
|
||||
p.ctrl = &beep.Ctrl{Streamer: streamer, Paused: true}
|
||||
|
||||
// Initialize speaker if not already done
|
||||
if !p.initialized {
|
||||
sampleRate := beep.SampleRate(44100)
|
||||
if err := speaker.Init(sampleRate, sampleRate.N(time.Second/10)); err != nil {
|
||||
return fmt.Errorf("failed to initialize speaker: %w", err)
|
||||
}
|
||||
p.initialized = true
|
||||
}
|
||||
|
||||
// Resample if needed
|
||||
targetRate := beep.SampleRate(44100)
|
||||
if audioFormat.SampleRate != targetRate {
|
||||
p.resampler = beep.Resample(4, audioFormat.SampleRate, targetRate, p.ctrl)
|
||||
speaker.Play(p.resampler)
|
||||
} else {
|
||||
p.resampler = nil
|
||||
speaker.Play(p.ctrl)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Play starts or resumes playback
|
||||
func (p *Player) Play() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.ctrl == nil {
|
||||
return
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
p.ctrl.Paused = false
|
||||
speaker.Unlock()
|
||||
p.playing = true
|
||||
}
|
||||
|
||||
// Pause pauses playback
|
||||
func (p *Player) Pause() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.ctrl == nil {
|
||||
return
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
p.ctrl.Paused = true
|
||||
speaker.Unlock()
|
||||
p.playing = false
|
||||
}
|
||||
|
||||
// Toggle toggles between play and pause
|
||||
func (p *Player) Toggle() {
|
||||
p.mu.Lock()
|
||||
if p.ctrl == nil {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
playing := p.playing
|
||||
p.mu.Unlock()
|
||||
|
||||
if playing {
|
||||
p.Pause()
|
||||
} else {
|
||||
p.Play()
|
||||
}
|
||||
}
|
||||
|
||||
// IsPlaying returns true if audio is currently playing
|
||||
func (p *Player) IsPlaying() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.playing
|
||||
}
|
||||
|
||||
// Position returns the current playback position
|
||||
func (p *Player) Position() time.Duration {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.streamer == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
pos := p.format.SampleRate.D(p.streamer.Position())
|
||||
speaker.Unlock()
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
// Duration returns the total duration
|
||||
func (p *Player) Duration() time.Duration {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.streamer == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return p.format.SampleRate.D(p.streamer.Len())
|
||||
}
|
||||
|
||||
// Seek moves to the specified position
|
||||
func (p *Player) Seek(pos time.Duration) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.streamer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sample := p.format.SampleRate.N(pos)
|
||||
if sample < 0 {
|
||||
sample = 0
|
||||
}
|
||||
if sample > p.streamer.Len() {
|
||||
sample = p.streamer.Len()
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
err := p.streamer.Seek(sample)
|
||||
speaker.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SeekRelative seeks relative to current position
|
||||
func (p *Player) SeekRelative(delta time.Duration) error {
|
||||
pos := p.Position()
|
||||
return p.Seek(pos + delta)
|
||||
}
|
||||
|
||||
// Close releases resources
|
||||
func (p *Player) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.streamer != nil {
|
||||
p.streamer.Close()
|
||||
}
|
||||
if p.file != nil {
|
||||
p.file.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Format returns the audio format info
|
||||
func (p *Player) Format() beep.Format {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.format
|
||||
}
|
||||
|
||||
// Streamer returns the underlying streamer (for waveform extraction)
|
||||
func (p *Player) Streamer() beep.StreamSeekCloser {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.streamer
|
||||
}
|
||||
|
||||
// GetSamples extracts sample data for waveform visualization
|
||||
func (p *Player) GetSamples(numSamples int) ([]float64, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.streamer == nil {
|
||||
return nil, fmt.Errorf("no audio loaded")
|
||||
}
|
||||
|
||||
totalSamples := p.streamer.Len()
|
||||
if totalSamples == 0 {
|
||||
return nil, fmt.Errorf("empty audio")
|
||||
}
|
||||
|
||||
samples := make([]float64, numSamples)
|
||||
samplesPerBucket := totalSamples / numSamples
|
||||
if samplesPerBucket < 1 {
|
||||
samplesPerBucket = 1
|
||||
}
|
||||
|
||||
// Save current position
|
||||
speaker.Lock()
|
||||
currentPos := p.streamer.Position()
|
||||
speaker.Unlock()
|
||||
|
||||
buf := make([][2]float64, samplesPerBucket)
|
||||
|
||||
for i := 0; i < numSamples; i++ {
|
||||
targetPos := i * samplesPerBucket
|
||||
if targetPos >= totalSamples {
|
||||
break
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
p.streamer.Seek(targetPos)
|
||||
speaker.Unlock()
|
||||
|
||||
speaker.Lock()
|
||||
n, ok := p.streamer.Stream(buf)
|
||||
speaker.Unlock()
|
||||
|
||||
if !ok || n == 0 {
|
||||
if err, isErr := p.streamer.(interface{ Err() error }); isErr && err.Err() != nil {
|
||||
if err.Err() != io.EOF {
|
||||
continue
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate average absolute amplitude
|
||||
var sum float64
|
||||
for j := 0; j < n; j++ {
|
||||
val := (buf[j][0] + buf[j][1]) / 2
|
||||
if val < 0 {
|
||||
val = -val
|
||||
}
|
||||
sum += val
|
||||
}
|
||||
samples[i] = sum / float64(n)
|
||||
}
|
||||
|
||||
// Restore position
|
||||
speaker.Lock()
|
||||
p.streamer.Seek(currentPos)
|
||||
speaker.Unlock()
|
||||
|
||||
return samples, nil
|
||||
}
|
||||
36
internal/audio/waveform.go
Normal file
36
internal/audio/waveform.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package audio
|
||||
|
||||
// WaveformData holds pre-computed waveform samples
|
||||
type WaveformData struct {
|
||||
Samples []float64
|
||||
MaxValue float64
|
||||
}
|
||||
|
||||
// NewWaveformData creates waveform data from raw samples
|
||||
func NewWaveformData(samples []float64) *WaveformData {
|
||||
wd := &WaveformData{
|
||||
Samples: samples,
|
||||
}
|
||||
|
||||
// Find max value for normalization
|
||||
for _, s := range samples {
|
||||
if s > wd.MaxValue {
|
||||
wd.MaxValue = s
|
||||
}
|
||||
}
|
||||
|
||||
if wd.MaxValue == 0 {
|
||||
wd.MaxValue = 1 // Avoid division by zero
|
||||
}
|
||||
|
||||
return wd
|
||||
}
|
||||
|
||||
// Normalized returns samples normalized to 0-1 range
|
||||
func (w *WaveformData) Normalized() []float64 {
|
||||
normalized := make([]float64, len(w.Samples))
|
||||
for i, s := range w.Samples {
|
||||
normalized[i] = s / w.MaxValue
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
92
internal/config/config.go
Normal file
92
internal/config/config.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds application configuration
|
||||
type Config struct {
|
||||
SeekStep time.Duration `json:"seek_step"`
|
||||
BigSeekStep time.Duration `json:"big_seek_step"`
|
||||
Volume float64 `json:"volume"`
|
||||
Theme string `json:"theme"`
|
||||
Editor string `json:"editor"`
|
||||
}
|
||||
|
||||
// configFile is the JSON serialization format
|
||||
type configFile struct {
|
||||
SeekStepMs int64 `json:"seek_step_ms"`
|
||||
BigSeekStepMs int64 `json:"big_seek_step_ms"`
|
||||
Volume float64 `json:"volume"`
|
||||
Theme string `json:"theme"`
|
||||
Editor string `json:"editor"`
|
||||
}
|
||||
|
||||
// configPath returns the path to the config file
|
||||
func configPath() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = os.Getenv("HOME")
|
||||
}
|
||||
return filepath.Join(configDir, "playback", "config.json")
|
||||
}
|
||||
|
||||
// Load loads configuration from disk, returning defaults if not found
|
||||
func Load() Config {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
data, err := os.ReadFile(configPath())
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
var cf configFile
|
||||
if err := json.Unmarshal(data, &cf); err != nil {
|
||||
return cfg
|
||||
}
|
||||
|
||||
if cf.SeekStepMs > 0 {
|
||||
cfg.SeekStep = time.Duration(cf.SeekStepMs) * time.Millisecond
|
||||
}
|
||||
if cf.BigSeekStepMs > 0 {
|
||||
cfg.BigSeekStep = time.Duration(cf.BigSeekStepMs) * time.Millisecond
|
||||
}
|
||||
if cf.Volume > 0 {
|
||||
cfg.Volume = cf.Volume
|
||||
}
|
||||
if cf.Theme != "" {
|
||||
cfg.Theme = cf.Theme
|
||||
}
|
||||
if cf.Editor != "" {
|
||||
cfg.Editor = cf.Editor
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Save writes configuration to disk
|
||||
func (c Config) Save() error {
|
||||
cf := configFile{
|
||||
SeekStepMs: c.SeekStep.Milliseconds(),
|
||||
BigSeekStepMs: c.BigSeekStep.Milliseconds(),
|
||||
Volume: c.Volume,
|
||||
Theme: c.Theme,
|
||||
Editor: c.Editor,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cf, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(configPath())
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath(), data, 0644)
|
||||
}
|
||||
22
internal/config/defaults.go
Normal file
22
internal/config/defaults.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Default configuration values
|
||||
const (
|
||||
DefaultSeekStep = 5 * time.Second
|
||||
DefaultBigSeekStep = 30 * time.Second
|
||||
DefaultVolume = 1.0
|
||||
DefaultEditor = "vim"
|
||||
)
|
||||
|
||||
// DefaultConfig returns the default configuration
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
SeekStep: DefaultSeekStep,
|
||||
BigSeekStep: DefaultBigSeekStep,
|
||||
Volume: DefaultVolume,
|
||||
Theme: "default",
|
||||
Editor: DefaultEditor,
|
||||
}
|
||||
}
|
||||
112
internal/srt/parser.go
Normal file
112
internal/srt/parser.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package srt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
astisub "github.com/asticode/go-astisub"
|
||||
)
|
||||
|
||||
// Load loads an SRT file from the given path
|
||||
func Load(path string) (*Transcript, error) {
|
||||
subs, err := astisub.OpenFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse SRT file: %w", err)
|
||||
}
|
||||
|
||||
transcript := &Transcript{
|
||||
FilePath: path,
|
||||
IsTemp: strings.HasSuffix(path, ".tmp"),
|
||||
Cues: make([]Cue, len(subs.Items)),
|
||||
}
|
||||
|
||||
lineNum := 1 // SRT files are 1-indexed
|
||||
for i, item := range subs.Items {
|
||||
var textParts []string
|
||||
for _, line := range item.Lines {
|
||||
var lineParts []string
|
||||
for _, lineItem := range line.Items {
|
||||
lineParts = append(lineParts, lineItem.Text)
|
||||
}
|
||||
textParts = append(textParts, strings.Join(lineParts, ""))
|
||||
}
|
||||
|
||||
text := strings.Join(textParts, "\n")
|
||||
transcript.Cues[i] = Cue{
|
||||
Index: i + 1,
|
||||
Start: item.StartAt,
|
||||
End: item.EndAt,
|
||||
Text: text,
|
||||
LineNumber: lineNum,
|
||||
}
|
||||
|
||||
// Calculate lines used by this cue:
|
||||
// 1 (index) + 1 (timestamp) + text lines + 1 (blank line)
|
||||
textLines := 1
|
||||
if text != "" {
|
||||
textLines = strings.Count(text, "\n") + 1
|
||||
}
|
||||
lineNum += 2 + textLines + 1 // index + timestamp + text + blank
|
||||
}
|
||||
|
||||
return transcript, nil
|
||||
}
|
||||
|
||||
// FindTranscript looks for an SRT file next to the audio file
|
||||
func FindTranscript(audioPath string) string {
|
||||
ext := filepath.Ext(audioPath)
|
||||
basePath := strings.TrimSuffix(audioPath, ext)
|
||||
|
||||
// Try common SRT naming patterns
|
||||
patterns := []string{
|
||||
basePath + ".srt",
|
||||
basePath + ".en.srt",
|
||||
audioPath + ".srt",
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if _, err := os.Stat(pattern); err == nil {
|
||||
return pattern
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// CreateTempTranscript creates a temporary SRT file with placeholder content
|
||||
func CreateTempTranscript(audioPath string) (string, error) {
|
||||
basename := filepath.Base(audioPath)
|
||||
ext := filepath.Ext(basename)
|
||||
nameOnly := strings.TrimSuffix(basename, ext)
|
||||
|
||||
tempPath := filepath.Join(os.TempDir(), nameOnly+".srt.tmp")
|
||||
|
||||
content := fmt.Sprintf(`1
|
||||
00:00:00,000 --> 00:00:05,000
|
||||
[No transcript found for: %s]
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:15,000
|
||||
This is a temporary transcript file.
|
||||
You can edit it using vim-style commands.
|
||||
Press 'i' to enter edit mode, 'esc' to exit.
|
||||
|
||||
3
|
||||
00:00:15,000 --> 00:00:25,000
|
||||
To generate a transcript automatically, try:
|
||||
https://git.beitzah.net/ysandler/transcribe
|
||||
|
||||
4
|
||||
00:00:25,000 --> 00:00:35,000
|
||||
Or launch with an existing transcript:
|
||||
playback %s -t /path/to/transcript.srt
|
||||
`, basename, basename)
|
||||
|
||||
if err := os.WriteFile(tempPath, []byte(content), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to create temp transcript: %w", err)
|
||||
}
|
||||
|
||||
return tempPath, nil
|
||||
}
|
||||
39
internal/srt/types.go
Normal file
39
internal/srt/types.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package srt
|
||||
|
||||
import "time"
|
||||
|
||||
// Cue represents a single subtitle entry
|
||||
type Cue struct {
|
||||
Index int
|
||||
Start time.Duration
|
||||
End time.Duration
|
||||
Text string
|
||||
LineNumber int // Line number in the SRT file (1-indexed)
|
||||
}
|
||||
|
||||
// Transcript represents a complete subtitle file
|
||||
type Transcript struct {
|
||||
Cues []Cue
|
||||
FilePath string
|
||||
IsTemp bool
|
||||
}
|
||||
|
||||
// CueAt returns the cue that contains the given time position
|
||||
func (t *Transcript) CueAt(pos time.Duration) *Cue {
|
||||
for i := range t.Cues {
|
||||
if pos >= t.Cues[i].Start && pos < t.Cues[i].End {
|
||||
return &t.Cues[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CueIndexAt returns the index of the cue at the given position, or -1
|
||||
func (t *Transcript) CueIndexAt(pos time.Duration) int {
|
||||
for i := range t.Cues {
|
||||
if pos >= t.Cues[i].Start && pos < t.Cues[i].End {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
79
internal/srt/writer.go
Normal file
79
internal/srt/writer.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package srt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// formatDuration formats a duration as SRT timestamp (HH:MM:SS,mmm)
|
||||
func formatDuration(d int64) string {
|
||||
ms := d % 1000
|
||||
d /= 1000
|
||||
s := d % 60
|
||||
d /= 60
|
||||
m := d % 60
|
||||
h := d / 60
|
||||
|
||||
return fmt.Sprintf("%02d:%02d:%02d,%03d", h, m, s, ms)
|
||||
}
|
||||
|
||||
// Save writes the transcript to an SRT file
|
||||
func (t *Transcript) Save() error {
|
||||
return t.SaveTo(t.FilePath)
|
||||
}
|
||||
|
||||
// SaveTo writes the transcript to the specified path
|
||||
func (t *Transcript) SaveTo(path string) error {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, cue := range t.Cues {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d\n", cue.Index))
|
||||
sb.WriteString(fmt.Sprintf("%s --> %s\n",
|
||||
formatDuration(cue.Start.Milliseconds()),
|
||||
formatDuration(cue.End.Milliseconds())))
|
||||
sb.WriteString(cue.Text)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PromoteTempFile saves the transcript to a permanent location
|
||||
func (t *Transcript) PromoteTempFile(audioPath string) (string, error) {
|
||||
if !t.IsTemp {
|
||||
return t.FilePath, nil
|
||||
}
|
||||
|
||||
// Create permanent path next to audio file
|
||||
ext := filepath.Ext(audioPath)
|
||||
permanentPath := strings.TrimSuffix(audioPath, ext) + ".srt"
|
||||
|
||||
if err := t.SaveTo(permanentPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Update transcript state
|
||||
t.FilePath = permanentPath
|
||||
t.IsTemp = false
|
||||
|
||||
// Remove temp file
|
||||
tempPath := filepath.Join(os.TempDir(), filepath.Base(audioPath))
|
||||
os.Remove(strings.TrimSuffix(tempPath, filepath.Ext(tempPath)) + ".srt.tmp")
|
||||
|
||||
return permanentPath, nil
|
||||
}
|
||||
97
internal/ui/header/header.go
Normal file
97
internal/ui/header/header.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package header
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"playback/internal/ui"
|
||||
)
|
||||
|
||||
// Model represents the header component
|
||||
type Model struct {
|
||||
AudioPath string
|
||||
TranscriptPath string
|
||||
IsTemp bool
|
||||
Width int
|
||||
}
|
||||
|
||||
// New creates a new header model
|
||||
func New() Model {
|
||||
return Model{}
|
||||
}
|
||||
|
||||
// SetPaths sets the file paths
|
||||
func (m *Model) SetPaths(audioPath, transcriptPath string, isTemp bool) {
|
||||
m.AudioPath = audioPath
|
||||
m.TranscriptPath = transcriptPath
|
||||
m.IsTemp = isTemp
|
||||
}
|
||||
|
||||
// SetWidth sets the header width
|
||||
func (m *Model) SetWidth(width int) {
|
||||
m.Width = width
|
||||
}
|
||||
|
||||
// formatDuration formats a duration as MM:SS
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
m := d / time.Minute
|
||||
s := (d % time.Minute) / time.Second
|
||||
return fmt.Sprintf("%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
// View renders the header
|
||||
func (m Model) View(position, duration time.Duration, playing bool) string {
|
||||
// Title
|
||||
title := ui.HeaderStyle.Render("♪ Playback")
|
||||
|
||||
// File info
|
||||
audioName := filepath.Base(m.AudioPath)
|
||||
transcriptName := filepath.Base(m.TranscriptPath)
|
||||
if m.IsTemp {
|
||||
transcriptName += " (temp)"
|
||||
}
|
||||
|
||||
fileInfo := ui.FilePathStyle.Render(
|
||||
fmt.Sprintf("Audio: %s | Transcript: %s", audioName, transcriptName),
|
||||
)
|
||||
|
||||
// Playback status
|
||||
status := "⏸ Paused"
|
||||
if playing {
|
||||
status = "▶ Playing"
|
||||
}
|
||||
|
||||
timeInfo := fmt.Sprintf("%s / %s", formatDuration(position), formatDuration(duration))
|
||||
|
||||
statusStyle := lipgloss.NewStyle().Foreground(ui.ColorSecondary)
|
||||
if !playing {
|
||||
statusStyle = lipgloss.NewStyle().Foreground(ui.ColorMuted)
|
||||
}
|
||||
|
||||
rightSide := lipgloss.JoinHorizontal(
|
||||
lipgloss.Center,
|
||||
statusStyle.Render(status),
|
||||
" ",
|
||||
ui.BaseStyle.Render(timeInfo),
|
||||
)
|
||||
|
||||
// Layout
|
||||
leftWidth := lipgloss.Width(title) + lipgloss.Width(fileInfo) + 2
|
||||
rightWidth := lipgloss.Width(rightSide)
|
||||
spacerWidth := m.Width - leftWidth - rightWidth - 4
|
||||
if spacerWidth < 1 {
|
||||
spacerWidth = 1
|
||||
}
|
||||
|
||||
return lipgloss.JoinHorizontal(
|
||||
lipgloss.Center,
|
||||
title,
|
||||
" ",
|
||||
fileInfo,
|
||||
lipgloss.NewStyle().Width(spacerWidth).Render(""),
|
||||
rightSide,
|
||||
)
|
||||
}
|
||||
106
internal/ui/styles.go
Normal file
106
internal/ui/styles.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Colors
|
||||
var (
|
||||
ColorPrimary = lipgloss.Color("#7C3AED") // Purple
|
||||
ColorSecondary = lipgloss.Color("#10B981") // Green
|
||||
ColorAccent = lipgloss.Color("#F59E0B") // Amber
|
||||
ColorMuted = lipgloss.Color("#6B7280") // Gray
|
||||
ColorBackground = lipgloss.Color("#1F2937") // Dark gray
|
||||
ColorForeground = lipgloss.Color("#F9FAFB") // Light gray
|
||||
ColorHighlight = lipgloss.Color("#374151") // Medium gray
|
||||
ColorError = lipgloss.Color("#EF4444") // Red
|
||||
)
|
||||
|
||||
// Styles
|
||||
var (
|
||||
// Base styles
|
||||
BaseStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorForeground)
|
||||
|
||||
// Header styles
|
||||
HeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(ColorPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
FilePathStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
Italic(true)
|
||||
|
||||
// Waveform styles
|
||||
WaveformStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorMuted).
|
||||
Padding(0, 1)
|
||||
|
||||
WaveformFocusedStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
NeedleStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorAccent).
|
||||
Bold(true)
|
||||
|
||||
// Transcript styles
|
||||
TranscriptStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorMuted).
|
||||
Padding(0, 1)
|
||||
|
||||
TranscriptFocusedStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorPrimary).
|
||||
Padding(0, 1)
|
||||
|
||||
CurrentCueStyle = lipgloss.NewStyle().
|
||||
Background(ColorHighlight).
|
||||
Foreground(ColorSecondary).
|
||||
Bold(true)
|
||||
|
||||
SelectedCueStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorAccent).
|
||||
Bold(true)
|
||||
|
||||
TimestampStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted)
|
||||
|
||||
SelectedTimestampStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorAccent)
|
||||
|
||||
// Status bar styles
|
||||
StatusBarStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted).
|
||||
Padding(0, 1)
|
||||
|
||||
ModeStyle = lipgloss.NewStyle().
|
||||
Background(ColorPrimary).
|
||||
Foreground(ColorForeground).
|
||||
Padding(0, 1).
|
||||
Bold(true)
|
||||
|
||||
InsertModeStyle = lipgloss.NewStyle().
|
||||
Background(ColorSecondary).
|
||||
Foreground(ColorForeground).
|
||||
Padding(0, 1).
|
||||
Bold(true)
|
||||
|
||||
CommandStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorAccent)
|
||||
|
||||
// Help styles
|
||||
HelpKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorSecondary).
|
||||
Bold(true)
|
||||
|
||||
HelpDescStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorMuted)
|
||||
|
||||
// Error styles
|
||||
ErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorError).
|
||||
Bold(true)
|
||||
)
|
||||
63
internal/ui/transcript/highlight.go
Normal file
63
internal/ui/transcript/highlight.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package transcript
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"playback/internal/srt"
|
||||
"playback/internal/ui"
|
||||
)
|
||||
|
||||
// RenderCue renders a single cue with optional highlighting
|
||||
func RenderCue(cue *srt.Cue, isCurrent, isSelected 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
|
||||
} else {
|
||||
textStyle = ui.BaseStyle
|
||||
timestampStyle = ui.TimestampStyle
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// formatTimestamp formats start/end times as SRT timestamp
|
||||
func formatTimestamp(start, end time.Duration) string {
|
||||
return fmt.Sprintf("%s --> %s",
|
||||
formatTime(start),
|
||||
formatTime(end),
|
||||
)
|
||||
}
|
||||
|
||||
// formatTime formats a duration as HH:MM:SS,mmm
|
||||
func formatTime(d time.Duration) string {
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
m := d / time.Minute
|
||||
d -= m * time.Minute
|
||||
s := d / time.Second
|
||||
d -= s * time.Second
|
||||
ms := d / time.Millisecond
|
||||
|
||||
return fmt.Sprintf("%02d:%02d:%02d,%03d", h, m, s, ms)
|
||||
}
|
||||
233
internal/ui/transcript/transcript.go
Normal file
233
internal/ui/transcript/transcript.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package transcript
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"playback/internal/srt"
|
||||
"playback/internal/ui"
|
||||
)
|
||||
|
||||
// SeekToCueMsg is sent when user wants to seek to a specific cue
|
||||
type SeekToCueMsg struct {
|
||||
Position time.Duration
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// New creates a new transcript model
|
||||
func New() Model {
|
||||
vp := viewport.New(80, 20)
|
||||
vp.Style = lipgloss.NewStyle()
|
||||
|
||||
return Model{
|
||||
viewport: vp,
|
||||
currentCue: -1,
|
||||
selectedCue: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// SetTranscript sets the transcript to display
|
||||
func (m *Model) SetTranscript(t *srt.Transcript) {
|
||||
m.transcript = t
|
||||
m.selectedCue = 0
|
||||
m.updateContent()
|
||||
m.scrollToCue(0)
|
||||
}
|
||||
|
||||
// Transcript returns the current transcript
|
||||
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) {
|
||||
return 1
|
||||
}
|
||||
return m.transcript.Cues[m.selectedCue].LineNumber
|
||||
}
|
||||
|
||||
// SetPosition updates which cue is highlighted based on playback position
|
||||
func (m *Model) SetPosition(pos time.Duration) {
|
||||
if m.transcript == nil {
|
||||
return
|
||||
}
|
||||
|
||||
newCue := m.transcript.CueIndexAt(pos)
|
||||
if newCue != m.currentCue {
|
||||
m.currentCue = newCue
|
||||
m.updateContent()
|
||||
// Only auto-scroll if not focused (let user navigate freely when focused)
|
||||
if !m.Focused && newCue >= 0 {
|
||||
m.scrollToCue(newCue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetSize sets the component dimensions
|
||||
func (m *Model) SetSize(width, height int) {
|
||||
m.Width = width
|
||||
m.Height = height
|
||||
m.viewport.Width = width - 4 // Account for border
|
||||
m.viewport.Height = height - 2
|
||||
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"
|
||||
}
|
||||
|
||||
// Update handles messages
|
||||
func (m *Model) Update(msg tea.Msg) tea.Cmd {
|
||||
if m.transcript == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "j", "down":
|
||||
// Move to next cue
|
||||
if m.selectedCue < len(m.transcript.Cues)-1 {
|
||||
m.selectedCue++
|
||||
m.refreshAndScroll()
|
||||
}
|
||||
return nil
|
||||
case "k", "up":
|
||||
// Move to previous cue
|
||||
if m.selectedCue > 0 {
|
||||
m.selectedCue--
|
||||
m.refreshAndScroll()
|
||||
}
|
||||
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
|
||||
}
|
||||
m.refreshAndScroll()
|
||||
return nil
|
||||
case "ctrl+u":
|
||||
// Jump 5 cues up
|
||||
m.selectedCue -= 5
|
||||
if m.selectedCue < 0 {
|
||||
m.selectedCue = 0
|
||||
}
|
||||
m.refreshAndScroll()
|
||||
return nil
|
||||
case "g":
|
||||
// Go to first cue
|
||||
m.selectedCue = 0
|
||||
m.refreshAndScroll()
|
||||
return nil
|
||||
case "G":
|
||||
// Go to last cue
|
||||
m.selectedCue = 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 nil
|
||||
}
|
||||
|
||||
// refreshAndScroll updates content and scrolls to selected cue
|
||||
func (m *Model) refreshAndScroll() {
|
||||
m.updateContent()
|
||||
m.scrollToCue(m.selectedCue)
|
||||
}
|
||||
|
||||
func (m *Model) updateContent() {
|
||||
if m.transcript == nil {
|
||||
m.viewport.SetContent("No transcript loaded")
|
||||
return
|
||||
}
|
||||
|
||||
// Track line positions for each cue
|
||||
m.cueLines = make([]int, len(m.transcript.Cues))
|
||||
currentLine := 0
|
||||
|
||||
var sb strings.Builder
|
||||
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)
|
||||
sb.WriteString(rendered)
|
||||
|
||||
// Count lines in this cue's rendering
|
||||
currentLine += strings.Count(rendered, "\n")
|
||||
|
||||
if i < len(m.transcript.Cues)-1 {
|
||||
sb.WriteString("\n")
|
||||
currentLine++ // blank line between cues
|
||||
}
|
||||
}
|
||||
|
||||
m.viewport.SetContent(sb.String())
|
||||
}
|
||||
|
||||
func (m *Model) scrollToCue(cueIndex int) {
|
||||
if cueIndex < 0 || m.transcript == nil || cueIndex >= len(m.cueLines) {
|
||||
return
|
||||
}
|
||||
|
||||
targetLine := m.cueLines[cueIndex]
|
||||
|
||||
// Center the cue in the viewport
|
||||
viewportHeight := m.viewport.Height
|
||||
offset := targetLine - viewportHeight/2
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
m.viewport.SetYOffset(offset)
|
||||
}
|
||||
|
||||
// View renders the transcript
|
||||
func (m Model) View() string {
|
||||
content := m.viewport.View()
|
||||
|
||||
style := ui.TranscriptStyle
|
||||
if m.Focused {
|
||||
style = ui.TranscriptFocusedStyle
|
||||
}
|
||||
|
||||
return style.Width(m.Width - 2).Height(m.Height - 2).Render(content)
|
||||
}
|
||||
77
internal/ui/waveform/render.go
Normal file
77
internal/ui/waveform/render.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package waveform
|
||||
|
||||
// Block characters for waveform rendering (bottom to top)
|
||||
var blocks = []rune{' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||
|
||||
// RenderWaveform converts normalized samples (0-1) to block characters
|
||||
func RenderWaveform(samples []float64, width int) string {
|
||||
if len(samples) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := make([]rune, width)
|
||||
|
||||
for i := 0; i < width; i++ {
|
||||
// Map position to sample index
|
||||
sampleIdx := i * len(samples) / width
|
||||
if sampleIdx >= len(samples) {
|
||||
sampleIdx = len(samples) - 1
|
||||
}
|
||||
|
||||
// Get sample value and map to block character
|
||||
value := samples[sampleIdx]
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
if value > 1 {
|
||||
value = 1
|
||||
}
|
||||
|
||||
blockIdx := int(value * float64(len(blocks)-1))
|
||||
result[i] = blocks[blockIdx]
|
||||
}
|
||||
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// RenderWaveformWithNeedle renders the waveform with a position indicator
|
||||
func RenderWaveformWithNeedle(samples []float64, width int, position float64) (string, int) {
|
||||
waveform := RenderWaveform(samples, width)
|
||||
|
||||
// Calculate needle position
|
||||
needlePos := int(position * float64(width))
|
||||
if needlePos < 0 {
|
||||
needlePos = 0
|
||||
}
|
||||
if needlePos >= width {
|
||||
needlePos = width - 1
|
||||
}
|
||||
|
||||
return waveform, needlePos
|
||||
}
|
||||
|
||||
// RenderWithColors returns the waveform with the needle position marked
|
||||
// Returns: left part, needle char, right part
|
||||
func RenderWithColors(samples []float64, width int, position float64) (string, string, string) {
|
||||
waveform := []rune(RenderWaveform(samples, width))
|
||||
if len(waveform) == 0 {
|
||||
return "", "|", ""
|
||||
}
|
||||
|
||||
needlePos := int(position * float64(len(waveform)))
|
||||
if needlePos < 0 {
|
||||
needlePos = 0
|
||||
}
|
||||
if needlePos >= len(waveform) {
|
||||
needlePos = len(waveform) - 1
|
||||
}
|
||||
|
||||
left := string(waveform[:needlePos])
|
||||
needle := "|"
|
||||
right := ""
|
||||
if needlePos+1 < len(waveform) {
|
||||
right = string(waveform[needlePos+1:])
|
||||
}
|
||||
|
||||
return left, needle, right
|
||||
}
|
||||
133
internal/ui/waveform/waveform.go
Normal file
133
internal/ui/waveform/waveform.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package waveform
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"playback/internal/audio"
|
||||
"playback/internal/ui"
|
||||
)
|
||||
|
||||
// Model represents the waveform visualization component
|
||||
type Model struct {
|
||||
Width int
|
||||
Height int
|
||||
Focused bool
|
||||
Samples []float64
|
||||
Position float64 // 0.0 to 1.0
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// New creates a new waveform model
|
||||
func New() Model {
|
||||
return Model{
|
||||
Height: 3,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSamples sets the waveform samples
|
||||
func (m *Model) SetSamples(data *audio.WaveformData) {
|
||||
if data != nil {
|
||||
m.Samples = data.Normalized()
|
||||
}
|
||||
}
|
||||
|
||||
// SetPosition sets the playback position (0.0 to 1.0)
|
||||
func (m *Model) SetPosition(pos float64) {
|
||||
m.Position = pos
|
||||
}
|
||||
|
||||
// SetDuration sets the total duration
|
||||
func (m *Model) SetDuration(d time.Duration) {
|
||||
m.Duration = d
|
||||
}
|
||||
|
||||
// SetSize sets the component dimensions
|
||||
func (m *Model) SetSize(width, height int) {
|
||||
m.Width = width
|
||||
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
|
||||
|
||||
if contentWidth < 10 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Render waveform with needle position
|
||||
left, needle, right := RenderWithColors(m.Samples, contentWidth, m.Position)
|
||||
|
||||
// Apply styles
|
||||
waveformLine := lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
ui.BaseStyle.Render(left),
|
||||
ui.NeedleStyle.Render(needle),
|
||||
ui.BaseStyle.Render(right),
|
||||
)
|
||||
|
||||
// Time markers
|
||||
startTime := "00:00"
|
||||
endTime := formatDuration(m.Duration)
|
||||
currentTime := formatDuration(time.Duration(m.Position * float64(m.Duration)))
|
||||
|
||||
timeMarkerWidth := contentWidth - len(startTime) - len(endTime)
|
||||
if timeMarkerWidth < 0 {
|
||||
timeMarkerWidth = 0
|
||||
}
|
||||
|
||||
// Calculate current time position
|
||||
currentTimePos := int(m.Position * float64(contentWidth))
|
||||
currentTimeWidth := len(currentTime)
|
||||
|
||||
// Build time marker line
|
||||
timeLine := ui.TimestampStyle.Render(startTime)
|
||||
|
||||
// Position current time
|
||||
spaceBefore := currentTimePos - len(startTime) - currentTimeWidth/2
|
||||
if spaceBefore < 0 {
|
||||
spaceBefore = 0
|
||||
}
|
||||
spaceAfter := contentWidth - len(startTime) - spaceBefore - currentTimeWidth - len(endTime)
|
||||
if spaceAfter < 0 {
|
||||
spaceAfter = 0
|
||||
}
|
||||
|
||||
timeLine = lipgloss.JoinHorizontal(
|
||||
lipgloss.Left,
|
||||
ui.TimestampStyle.Render(startTime),
|
||||
lipgloss.NewStyle().Width(spaceBefore).Render(""),
|
||||
ui.NeedleStyle.Render(currentTime),
|
||||
lipgloss.NewStyle().Width(spaceAfter).Render(""),
|
||||
ui.TimestampStyle.Render(endTime),
|
||||
)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
waveformLine,
|
||||
timeLine,
|
||||
)
|
||||
|
||||
// Apply border style based on focus
|
||||
style := ui.WaveformStyle
|
||||
if m.Focused {
|
||||
style = ui.WaveformFocusedStyle
|
||||
}
|
||||
|
||||
return style.Width(m.Width - 2).Render(content)
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
m := int(d / time.Minute)
|
||||
s := int((d % time.Minute) / time.Second)
|
||||
return string(rune('0'+m/10)) + string(rune('0'+m%10)) + ":" +
|
||||
string(rune('0'+s/10)) + string(rune('0'+s%10))
|
||||
}
|
||||
30
pkg/version/version.go
Normal file
30
pkg/version/version.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Version is set at build time or read from file
|
||||
var Version = "dev"
|
||||
|
||||
// Get returns the application version
|
||||
func Get() string {
|
||||
if Version != "dev" {
|
||||
return Version
|
||||
}
|
||||
|
||||
// Try to read from VERSION file at runtime (for development)
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if ok {
|
||||
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
||||
data, err := os.ReadFile(filepath.Join(root, "VERSION"))
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
|
||||
return Version
|
||||
}
|
||||
Reference in New Issue
Block a user