init commit
This commit is contained in:
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))
|
||||
}
|
||||
Reference in New Issue
Block a user