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