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
|
||||
}
|
||||
Reference in New Issue
Block a user