commit 1bbfc332d81e7ae968c7354c14d2485d003217e0 Author: ysandler Date: Sun Jan 25 17:13:15 2026 -0600 init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65e3ba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test/ diff --git a/IDEA.MD b/IDEA.MD new file mode 100644 index 0000000..250a3ee --- /dev/null +++ b/IDEA.MD @@ -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) + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e1ee03f --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa1b318 --- /dev/null +++ b/README.md @@ -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`. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/playback/main.go b/cmd/playback/main.go new file mode 100644 index 0000000..2e9671d --- /dev/null +++ b/cmd/playback/main.go @@ -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 ", + 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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9f5bd4f --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ba4a58b --- /dev/null +++ b/go.sum @@ -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= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..bfc5203 --- /dev/null +++ b/install.sh @@ -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" diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..f3bb099 --- /dev/null +++ b/internal/app/app.go @@ -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), + ) +} diff --git a/internal/app/keys.go b/internal/app/keys.go new file mode 100644 index 0000000..701dfc0 --- /dev/null +++ b/internal/app/keys.go @@ -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` +} diff --git a/internal/app/messages.go b/internal/app/messages.go new file mode 100644 index 0000000..e09ed05 --- /dev/null +++ b/internal/app/messages.go @@ -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 +} diff --git a/internal/audio/formats.go b/internal/audio/formats.go new file mode 100644 index 0000000..18e881e --- /dev/null +++ b/internal/audio/formats.go @@ -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 +} diff --git a/internal/audio/player.go b/internal/audio/player.go new file mode 100644 index 0000000..d09edaa --- /dev/null +++ b/internal/audio/player.go @@ -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 +} diff --git a/internal/audio/waveform.go b/internal/audio/waveform.go new file mode 100644 index 0000000..81f652f --- /dev/null +++ b/internal/audio/waveform.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8132ba3 --- /dev/null +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..26bb415 --- /dev/null +++ b/internal/config/defaults.go @@ -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, + } +} diff --git a/internal/srt/parser.go b/internal/srt/parser.go new file mode 100644 index 0000000..bc01644 --- /dev/null +++ b/internal/srt/parser.go @@ -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 +} diff --git a/internal/srt/types.go b/internal/srt/types.go new file mode 100644 index 0000000..73cebad --- /dev/null +++ b/internal/srt/types.go @@ -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 +} diff --git a/internal/srt/writer.go b/internal/srt/writer.go new file mode 100644 index 0000000..e49ed94 --- /dev/null +++ b/internal/srt/writer.go @@ -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 +} diff --git a/internal/ui/header/header.go b/internal/ui/header/header.go new file mode 100644 index 0000000..6cc090f --- /dev/null +++ b/internal/ui/header/header.go @@ -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, + ) +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..8dbdbe7 --- /dev/null +++ b/internal/ui/styles.go @@ -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) +) diff --git a/internal/ui/transcript/highlight.go b/internal/ui/transcript/highlight.go new file mode 100644 index 0000000..cb83b90 --- /dev/null +++ b/internal/ui/transcript/highlight.go @@ -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) +} diff --git a/internal/ui/transcript/transcript.go b/internal/ui/transcript/transcript.go new file mode 100644 index 0000000..4c8341b --- /dev/null +++ b/internal/ui/transcript/transcript.go @@ -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) +} diff --git a/internal/ui/waveform/render.go b/internal/ui/waveform/render.go new file mode 100644 index 0000000..04334a1 --- /dev/null +++ b/internal/ui/waveform/render.go @@ -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 +} diff --git a/internal/ui/waveform/waveform.go b/internal/ui/waveform/waveform.go new file mode 100644 index 0000000..15e806b --- /dev/null +++ b/internal/ui/waveform/waveform.go @@ -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)) +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..dce1e45 --- /dev/null +++ b/pkg/version/version.go @@ -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 +}