Compare commits

..

2 Commits

7 changed files with 584 additions and 2 deletions

View File

@@ -1 +1 @@
0.1.2 0.2.0

View File

@@ -14,6 +14,7 @@ import (
"playback/internal/ui" "playback/internal/ui"
"playback/internal/ui/header" "playback/internal/ui/header"
"playback/internal/ui/legend" "playback/internal/ui/legend"
"playback/internal/ui/search"
"playback/internal/ui/transcript" "playback/internal/ui/transcript"
"playback/internal/ui/waveform" "playback/internal/ui/waveform"
) )
@@ -37,6 +38,7 @@ type Model struct {
legend legend.Model legend legend.Model
waveform waveform.Model waveform waveform.Model
transcript transcript.Model transcript transcript.Model
search search.Model
// State // State
showHelp bool showHelp bool
@@ -61,6 +63,7 @@ func New(audioPath, transcriptPath string) Model {
legend: legend.New(), legend: legend.New(),
waveform: waveform.New(), waveform: waveform.New(),
transcript: transcript.New(), transcript: transcript.New(),
search: search.New(),
} }
return m return m
@@ -138,6 +141,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateLayout() m.updateLayout()
case tea.KeyMsg: 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 // All shortcuts are now global
switch { switch {
case key.Matches(msg, m.keys.Quit): case key.Matches(msg, m.keys.Quit):
@@ -168,6 +178,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.SeekBackwardBig): case key.Matches(msg, m.keys.SeekBackwardBig):
m.player.SeekRelative(-m.config.BigSeekStep) 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 // Transcript navigation and other keys forward to transcript
default: default:
cmd := m.transcript.Update(msg) cmd := m.transcript.Update(msg)
@@ -211,6 +225,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case transcript.SeekToCueMsg: case transcript.SeekToCueMsg:
m.player.Seek(msg.Position) m.player.Seek(msg.Position)
case search.SeekToSearchResultMsg:
m.player.Seek(msg.Position)
} }
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
@@ -247,6 +264,7 @@ func (m *Model) updateLayout() {
m.legend.SetWidth(m.width) m.legend.SetWidth(m.width)
m.waveform.SetSize(m.width, waveformHeight) m.waveform.SetSize(m.width, waveformHeight)
m.transcript.SetSize(m.width, transcriptHeight) m.transcript.SetSize(m.width, transcriptHeight)
m.search.SetSize(m.width, m.height)
} }
func (m Model) launchEditor() tea.Cmd { func (m Model) launchEditor() tea.Cmd {
@@ -285,6 +303,10 @@ func (m Model) View() string {
return m.renderHelp() return m.renderHelp()
} }
if m.search.IsOpen() {
return m.search.View()
}
// Header // Header
headerView := m.header.View(m.player.Position(), m.player.Duration(), m.player.IsPlaying()) headerView := m.header.View(m.player.Position(), m.player.Duration(), m.player.IsPlaying())

View File

@@ -23,6 +23,9 @@ type KeyMap struct {
// Editing // Editing
EnterEdit key.Binding EnterEdit key.Binding
// Search
Search key.Binding
} }
// DefaultKeyMap returns the default keybindings // DefaultKeyMap returns the default keybindings
@@ -84,6 +87,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("i"), key.WithKeys("i"),
key.WithHelp("i", "edit transcript"), key.WithHelp("i", "edit transcript"),
), ),
Search: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "search cues"),
),
} }
} }
@@ -109,5 +116,8 @@ Navigation (Global):
G Go to last cue G Go to last cue
Editing: Editing:
i Edit in $EDITOR at cue` i Edit in $EDITOR at cue
Navigation:
s Search cues`
} }

View File

@@ -57,6 +57,7 @@ func (m Model) View() string {
{key: "j/↓", desc: "next cue"}, {key: "j/↓", desc: "next cue"},
{key: "k/↑", desc: "prev cue"}, {key: "k/↑", desc: "prev cue"},
{key: "ctrl+d/u", desc: "jump 5 cues"}, {key: "ctrl+d/u", desc: "jump 5 cues"},
{key: "s", desc: "search"},
}, },
}, },
{ {

View File

@@ -0,0 +1,532 @@
package search
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"playback/internal/srt"
"playback/internal/ui"
)
// SeekToSearchResultMsg is sent when user wants to seek to a search result
type SeekToSearchResultMsg struct {
Position time.Duration
}
// Model represents the search overlay component
type Model struct {
input textinput.Model
cues []srt.Cue
results []int // indices into cues
cursor int // selected index into results
scrollOffset int
width int
height int
open bool
lastQuery string
}
// New creates a new search model
func New() Model {
input := textinput.New()
input.Prompt = " search: "
input.PromptStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent)
input.TextStyle = lipgloss.NewStyle().Foreground(ui.ColorForeground)
// Disable NextSuggestion and PrevSuggestion so j/k work properly
input.KeyMap.NextSuggestion = (textinput.KeyMap{}).NextSuggestion
input.KeyMap.PrevSuggestion = (textinput.KeyMap{}).PrevSuggestion
return Model{
input: input,
}
}
// Open opens the search overlay with the given cues
func (m *Model) Open(cues []srt.Cue) tea.Cmd {
m.open = true
m.cues = cues
m.input.SetValue("")
m.cursor = 0
m.scrollOffset = 0
m.lastQuery = ""
m.filter()
m.input.Focus()
return m.input.Focus()
}
// Close closes the search overlay
func (m *Model) Close() {
m.open = false
m.input.Blur()
}
// IsOpen returns whether the search overlay is open
func (m Model) IsOpen() bool {
return m.open
}
// SetSize sets the component dimensions
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
}
// Update handles messages
func (m *Model) Update(msg tea.Msg) tea.Cmd {
if !m.open {
return nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
m.Close()
return nil
case "enter":
if len(m.results) > 0 {
m.Close()
return func() tea.Msg {
return SeekToSearchResultMsg{
Position: m.cues[m.results[m.cursor]].Start,
}
}
}
return nil
case "j", "down":
if m.cursor < len(m.results)-1 {
m.cursor++
m.ensureVisible()
}
return nil
case "k", "up":
if m.cursor > 0 {
m.cursor--
m.ensureVisible()
}
return nil
default:
// Forward other keys to textinput
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
// Check if query changed and rebuild results
if m.input.Value() != m.lastQuery {
m.cursor = 0
m.scrollOffset = 0
m.filter()
}
return cmd
}
}
return nil
}
// filter rebuilds the results list based on current query
func (m *Model) filter() {
m.lastQuery = m.input.Value()
m.results = make([]int, 0)
query := strings.ToLower(m.lastQuery)
if query == "" {
// Empty query: show all cues
for i := range m.cues {
m.results = append(m.results, i)
}
} else {
// Filter by query
for i, cue := range m.cues {
if strings.Contains(strings.ToLower(cue.Text), query) {
m.results = append(m.results, i)
}
}
}
// Reset cursor and scroll to start
m.cursor = 0
m.scrollOffset = 0
}
// ensureVisible adjusts scrollOffset so cursor stays visible
func (m *Model) ensureVisible() {
bodyHeight := m.bodyHeight()
if bodyHeight <= 0 {
return
}
if m.cursor < m.scrollOffset {
m.scrollOffset = m.cursor
} else if m.cursor >= m.scrollOffset+bodyHeight {
m.scrollOffset = m.cursor - bodyHeight + 1
}
}
// View renders the search overlay
func (m Model) View() string {
if !m.open {
return ""
}
overlayW := max(60, m.width*9/10)
overlayH := max(20, m.height*4/5)
innerW := overlayW - 2 - 2 // border + padding
innerH := overlayH - 2 - 1 - 1 - 1 // border, input, separator, footer
// Build content
content := m.buildContent(innerW, innerH)
// Apply border and padding
style := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ui.ColorPrimary).
Padding(0, 1)
bordered := style.Render(content)
// Place in center
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
bordered,
)
}
// buildContent builds the inner content of the overlay
func (m *Model) buildContent(innerW, innerH int) string {
var lines []string
// Input line with background
inputLine := m.renderInputLine(innerW)
lines = append(lines, inputLine)
// Separator
separator := strings.Repeat("─", innerW)
lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render(separator))
// Body
bodyHeight := innerH - 1 // -1 for separator already counted
if bodyHeight > 0 {
body := m.renderBody(innerW, bodyHeight)
bodyLines := strings.Split(body, "\n")
lines = append(lines, bodyLines...)
}
// Footer
footer := m.renderFooter(innerW)
lines = append(lines, footer)
return strings.Join(lines, "\n")
}
// renderInputLine renders the search input with background
func (m *Model) renderInputLine(width int) string {
inputView := m.input.View()
// Apply background to the entire input line width
style := lipgloss.NewStyle().
Width(width).
Background(ui.ColorHighlight)
return style.Render(inputView)
}
// renderBody renders the search results and preview
func (m *Model) renderBody(innerW, bodyHeight int) string {
resultsW := innerW * 2 / 5
separatorW := 3
previewW := innerW - resultsW - separatorW
// Render results column
resultsCol := m.renderResultsColumn(resultsW, bodyHeight)
// Render separator column
separatorLines := make([]string, bodyHeight)
for i := range separatorLines {
separatorLines[i] = lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("┃")
}
// Render preview column
previewCol := m.renderPreviewColumn(previewW, bodyHeight)
// Pad columns to full height and width
resultsLines := strings.Split(resultsCol, "\n")
previewLines := strings.Split(previewCol, "\n")
// Ensure all columns have same number of lines
for len(resultsLines) < bodyHeight {
resultsLines = append(resultsLines, "")
}
for len(previewLines) < bodyHeight {
previewLines = append(previewLines, "")
}
// Truncate to bodyHeight
if len(resultsLines) > bodyHeight {
resultsLines = resultsLines[:bodyHeight]
}
if len(previewLines) > bodyHeight {
previewLines = previewLines[:bodyHeight]
}
// Ensure each line has proper width
for i := range resultsLines {
resultsLines[i] = lipgloss.NewStyle().Width(resultsW).Render(resultsLines[i])
}
for i := range previewLines {
previewLines[i] = lipgloss.NewStyle().Width(previewW).Render(previewLines[i])
}
// Build the body by joining lines horizontally
var result []string
for i := 0; i < bodyHeight; i++ {
separatorLine := lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("┃")
line := lipgloss.JoinHorizontal(
lipgloss.Top,
resultsLines[i],
" ",
separatorLine,
" ",
previewLines[i],
)
result = append(result, line)
}
return strings.Join(result, "\n")
}
// renderResultsColumn renders the results list
func (m *Model) renderResultsColumn(width, height int) string {
var lines []string
visibleStart := m.scrollOffset
visibleEnd := min(m.scrollOffset+height, len(m.results))
for i := visibleStart; i < visibleEnd; i++ {
line := m.renderResultLine(i, width)
lines = append(lines, line)
}
// Pad with empty lines if needed
for i := len(lines); i < height; i++ {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
// renderResultLine renders a single result line
func (m *Model) renderResultLine(resultIdx int, width int) string {
cueIdx := m.results[resultIdx]
cue := m.cues[cueIdx]
// Prefix
prefix := " "
if resultIdx == m.cursor {
prefix = lipgloss.NewStyle().Foreground(ui.ColorSecondary).Render("> ")
}
// Timestamp
timestamp := formatTime(cue.Start)
if resultIdx == m.cursor {
timestamp = lipgloss.NewStyle().Foreground(ui.ColorSecondary).Render(timestamp)
} else {
timestamp = lipgloss.NewStyle().Foreground(ui.ColorMuted).Render(timestamp)
}
// Cue text with match highlighting
text := cue.Text
query := strings.ToLower(m.lastQuery)
if query != "" {
idx := strings.Index(strings.ToLower(text), query)
if idx >= 0 {
before := text[:idx]
match := text[idx : idx+len(query)]
after := text[idx+len(query):]
text = before + ui.SearchMatchStyle.Render(match) + after
}
}
// Calculate available width for text
availW := width - lipgloss.Width(prefix) - lipgloss.Width(timestamp) - 2
if availW < 5 {
availW = 5
}
// Truncate text if needed
if len(text) > availW {
text = text[:availW-1] + "…"
}
line := prefix + timestamp + " " + text
// Apply selection background if this is the cursor
if resultIdx == m.cursor {
line = ui.SearchSelectedStyle.Render(line)
}
return line
}
// renderPreviewColumn renders the preview of the selected cue
func (m *Model) renderPreviewColumn(width, height int) string {
if len(m.results) == 0 {
emptyMsg := lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("no matches")
return emptyMsg
}
cueIdx := m.results[m.cursor]
cue := m.cues[cueIdx]
var lines []string
// Timestamp line
timestamp := formatTimestampFull(cue.Start, cue.End)
lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render(timestamp))
lines = append(lines, "") // blank line
// Cue text (word-wrapped)
textLines := wordWrap(cue.Text, width)
for _, tline := range textLines {
lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorForeground).Render(tline))
}
// Context
if cueIdx > 0 {
lines = append(lines, "")
prevCue := m.cues[cueIdx-1]
prevText := truncate(prevCue.Text, width-2)
lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("↑ "+prevText))
}
if cueIdx < len(m.cues)-1 {
lines = append(lines, "")
nextCue := m.cues[cueIdx+1]
nextText := truncate(nextCue.Text, width-2)
lines = append(lines, lipgloss.NewStyle().Foreground(ui.ColorMuted).Render("↓ "+nextText))
}
// Limit to height
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
// renderFooter renders the footer
func (m *Model) renderFooter(width int) string {
var countStr string
if len(m.results) == 0 {
countStr = "no matches"
} else {
countStr = fmt.Sprintf("%d result", len(m.results))
if len(m.results) != 1 {
countStr += "s"
}
}
mutedStyle := lipgloss.NewStyle().Foreground(ui.ColorMuted)
left := mutedStyle.Render(countStr)
mid := mutedStyle.Render("↑↓ navigate")
right := mutedStyle.Render("esc close")
dots := mutedStyle.Render(" · ")
return lipgloss.JoinHorizontal(
lipgloss.Left,
left,
dots,
mid,
dots,
right,
)
}
// bodyHeight returns the available height for the body
func (m *Model) bodyHeight() int {
overlayH := max(20, m.height*4/5)
return overlayH - 2 - 1 - 1 - 1 // border, input, separator, footer
}
// Helper functions
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func formatTime(d time.Duration) string {
totalSecs := int(d.Seconds())
mins := totalSecs / 60
secs := totalSecs % 60
return fmt.Sprintf("%02d:%02d", mins, secs)
}
func formatTimestampFull(start, end time.Duration) string {
toHMS := func(d time.Duration) string {
totalMS := int(d.Milliseconds())
h := totalMS / 3600000
m := (totalMS % 3600000) / 60000
s := (totalMS % 60000) / 1000
ms := totalMS % 1000
return fmt.Sprintf("%02d:%02d:%02d,%03d", h, m, s, ms)
}
return toHMS(start) + " --> " + toHMS(end)
}
func truncate(text string, width int) string {
if len(text) <= width {
return text
}
return text[:width-1] + "…"
}
func wordWrap(text string, width int) []string {
if width < 5 {
width = 5
}
words := strings.Fields(text)
var lines []string
var currentLine string
for _, word := range words {
if currentLine == "" {
currentLine = word
} else if len(currentLine)+1+len(word) <= width {
currentLine += " " + word
} else {
lines = append(lines, currentLine)
currentLine = word
}
}
if currentLine != "" {
lines = append(lines, currentLine)
}
return lines
}

View File

@@ -105,4 +105,13 @@ var (
ErrorStyle = lipgloss.NewStyle(). ErrorStyle = lipgloss.NewStyle().
Foreground(ColorError). Foreground(ColorError).
Bold(true) Bold(true)
// Search overlay styles
SearchMatchStyle = lipgloss.NewStyle().
Background(ColorAccent).
Foreground(ColorBackground).
Bold(true)
SearchSelectedStyle = lipgloss.NewStyle().
Background(ColorHighlight)
) )

View File

@@ -209,6 +209,14 @@ func (m *Model) scrollToCue(cueIndex int) {
m.viewport.SetYOffset(offset) m.viewport.SetYOffset(offset)
} }
// AllCues returns all cues from the transcript
func (m *Model) AllCues() []srt.Cue {
if m.transcript == nil {
return nil
}
return m.transcript.Cues
}
// View renders the transcript // View renders the transcript
func (m Model) View() string { func (m Model) View() string {
content := m.viewport.View() content := m.viewport.View()