374 lines
7.8 KiB
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),
|
|
)
|
|
}
|