init commit
This commit is contained in:
97
internal/ui/header/header.go
Normal file
97
internal/ui/header/header.go
Normal 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
106
internal/ui/styles.go
Normal 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)
|
||||
)
|
||||
63
internal/ui/transcript/highlight.go
Normal file
63
internal/ui/transcript/highlight.go
Normal 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)
|
||||
}
|
||||
233
internal/ui/transcript/transcript.go
Normal file
233
internal/ui/transcript/transcript.go
Normal 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)
|
||||
}
|
||||
77
internal/ui/waveform/render.go
Normal file
77
internal/ui/waveform/render.go
Normal 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
|
||||
}
|
||||
133
internal/ui/waveform/waveform.go
Normal file
133
internal/ui/waveform/waveform.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user