Files
playback/internal/app/app.go

374 lines
7.8 KiB
Go

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/legend"
"playback/internal/ui/search"
"playback/internal/ui/transcript"
"playback/internal/ui/waveform"
)
// 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
legend legend.Model
waveform waveform.Model
transcript transcript.Model
search search.Model
// State
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(),
legend: legend.New(),
waveform: waveform.New(),
transcript: transcript.New(),
search: search.New(),
}
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:
// Search overlay takes priority
if m.search.IsOpen() {
cmd := m.search.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// All shortcuts are now global
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.EnterEdit):
m.player.Pause()
return m, m.launchEditor()
// Seeking shortcuts (previously waveform-only, now global)
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)
case key.Matches(msg, m.keys.Search):
cmd := m.search.Open(m.transcript.AllCues())
cmds = append(cmds, cmd)
// Transcript navigation and other keys forward to transcript
default:
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)
case search.SeekToSearchResultMsg:
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
// Legend: 1 line
legendHeight := 1
// Status bar: 1 line
statusHeight := 1
// Transcript: remaining space
transcriptHeight := m.height - headerHeight - waveformHeight - legendHeight - statusHeight - 2
m.header.SetWidth(m.width)
m.legend.SetWidth(m.width)
m.waveform.SetSize(m.width, waveformHeight)
m.transcript.SetSize(m.width, transcriptHeight)
m.search.SetSize(m.width, m.height)
}
func (m Model) launchEditor() tea.Cmd {
t := m.transcript.Transcript()
if t == nil {
return nil
}
lineNum := m.transcript.ActiveCueLineNumber()
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()
}
if m.search.IsOpen() {
return m.search.View()
}
// Header
headerView := m.header.View(m.player.Position(), m.player.Duration(), m.player.IsPlaying())
// Waveform
waveformView := m.waveform.View()
// Transcript
transcriptView := m.transcript.View()
// Legend
legendView := m.legend.View()
// Status bar
statusView := m.renderStatus()
return lipgloss.JoinVertical(
lipgloss.Left,
headerView,
"",
waveformView,
transcriptView,
legendView,
statusView,
)
}
func (m Model) renderStatus() string {
// Mode indicator
modeStyle := ui.ModeStyle
mode := modeStyle.Render(m.transcript.ModeString())
// Status message
statusMsg := ui.StatusBarStyle.Render(m.statusMsg)
// Help hint
helpHint := ui.HelpDescStyle.Render("Press ? for help")
return lipgloss.JoinHorizontal(
lipgloss.Center,
mode,
" ",
statusMsg,
lipgloss.NewStyle().Width(m.width-lipgloss.Width(mode)-lipgloss.Width(statusMsg)-lipgloss.Width(helpHint)-4).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),
)
}