init commit

This commit is contained in:
2026-01-25 17:13:15 -06:00
commit 1bbfc332d8
27 changed files with 2462 additions and 0 deletions

View 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
View 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)
)

View 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)
}

View 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)
}

View 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
}

View 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))
}