[FEATURE] add search with preview #1
@@ -14,6 +14,7 @@ import (
|
||||
"playback/internal/ui"
|
||||
"playback/internal/ui/header"
|
||||
"playback/internal/ui/legend"
|
||||
"playback/internal/ui/search"
|
||||
"playback/internal/ui/transcript"
|
||||
"playback/internal/ui/waveform"
|
||||
)
|
||||
@@ -37,6 +38,7 @@ type Model struct {
|
||||
legend legend.Model
|
||||
waveform waveform.Model
|
||||
transcript transcript.Model
|
||||
search search.Model
|
||||
|
||||
// State
|
||||
showHelp bool
|
||||
@@ -61,6 +63,7 @@ func New(audioPath, transcriptPath string) Model {
|
||||
legend: legend.New(),
|
||||
waveform: waveform.New(),
|
||||
transcript: transcript.New(),
|
||||
search: search.New(),
|
||||
}
|
||||
|
||||
return m
|
||||
@@ -138,6 +141,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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):
|
||||
@@ -168,6 +178,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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)
|
||||
@@ -211,6 +225,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case transcript.SeekToCueMsg:
|
||||
m.player.Seek(msg.Position)
|
||||
|
||||
case search.SeekToSearchResultMsg:
|
||||
m.player.Seek(msg.Position)
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
@@ -247,6 +264,7 @@ func (m *Model) updateLayout() {
|
||||
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 {
|
||||
@@ -285,6 +303,10 @@ func (m Model) View() string {
|
||||
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())
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ type KeyMap struct {
|
||||
|
||||
// Editing
|
||||
EnterEdit key.Binding
|
||||
|
||||
// Search
|
||||
Search key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default keybindings
|
||||
@@ -84,6 +87,10 @@ func DefaultKeyMap() KeyMap {
|
||||
key.WithKeys("i"),
|
||||
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
|
||||
|
||||
Editing:
|
||||
i Edit in $EDITOR at cue`
|
||||
i Edit in $EDITOR at cue
|
||||
|
||||
Navigation:
|
||||
s Search cues`
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ func (m Model) View() string {
|
||||
{key: "j/↓", desc: "next cue"},
|
||||
{key: "k/↑", desc: "prev cue"},
|
||||
{key: "ctrl+d/u", desc: "jump 5 cues"},
|
||||
{key: "s", desc: "search"},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
532
internal/ui/search/search.go
Normal file
532
internal/ui/search/search.go
Normal 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
|
||||
}
|
||||
@@ -105,4 +105,13 @@ var (
|
||||
ErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(ColorError).
|
||||
Bold(true)
|
||||
|
||||
// Search overlay styles
|
||||
SearchMatchStyle = lipgloss.NewStyle().
|
||||
Background(ColorAccent).
|
||||
Foreground(ColorBackground).
|
||||
Bold(true)
|
||||
|
||||
SearchSelectedStyle = lipgloss.NewStyle().
|
||||
Background(ColorHighlight)
|
||||
)
|
||||
|
||||
@@ -209,6 +209,14 @@ func (m *Model) scrollToCue(cueIndex int) {
|
||||
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
|
||||
func (m Model) View() string {
|
||||
content := m.viewport.View()
|
||||
|
||||
Reference in New Issue
Block a user