package model import ( "fmt" "math" "slices" "strings" "time" "unicode/utf8" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/provider" "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" ) // titleScrollSep is the separator runes for cyclic title scrolling, // pre-allocated to avoid per-frame conversion. var titleScrollSep = []rune(" ♫ ") // Pre-built styles for elements created per-render to avoid repeated allocation. var ( seekFillStyle = lipgloss.NewStyle().Foreground(ui.ColorSeekBar) seekDimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim) volBarStyle = lipgloss.NewStyle().Foreground(ui.ColorVolume) activeToggle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true) ) // providerEmptyStateHint, keyed by lowercase provider Name(), returns the // remediation hint shown under the generic "No playlists in X" message. var providerEmptyStateHint = map[string]string{ "local playlists": "Add .toml playlists to ~/.config/cliamp/playlists/.", "local": "Add .toml playlists to ~/.config/cliamp/playlists/.", "spotify": "Sign in via Spotify, or check SPOTIFY_REFRESH_TOKEN.", "navidrome": "Verify [navidrome] url/username/password in config.toml.", "jellyfin": "Verify [jellyfin] url and token in config.toml.", "emby": "Verify [emby] url and token or username/password in config.toml.", "plex": "Verify [plex] server URL and token or library filter in config.toml.", "youtube music": "Run `cliamp ytmusic-login` to authorize, then refresh.", "ytmusic": "Run `cliamp ytmusic-login` to authorize, then refresh.", "soundcloud": "Set [soundcloud] user in config.toml to browse a profile.", "netease cloud music": "Run `cliamp setup` and configure NetEase browser cookies.", } // renderProviderEmptyState explains why the playlists pane is empty for the // current provider and offers a remediation hint. Always pads to budget so the // pane height stays stable. func (m Model) renderProviderEmptyState(budget int) string { name := "this provider" if m.provider != nil { name = m.provider.Name() } lines := []string{ dimStyle.Render(fmt.Sprintf(" No playlists in %s.", name)), "", } if _, searchable := m.provider.(provider.Searcher); searchable { lines = append(lines, dimStyle.Render(" Press ")+helpKeyStyle.Render(" Ctrl+F ")+dimStyle.Render(" to search.")) } if m.provider != nil { if hint, ok := providerEmptyStateHint[strings.ToLower(m.provider.Name())]; ok { lines = append(lines, dimStyle.Render(" "+hint)) } } return strings.Join(fitLines(lines, budget), "\n") } // providerRowStyle picks the prefix and style for a provider-list row. // Cursor takes precedence; "currently loaded" gets the active-track style and // the ▶ prefix so users can see at a glance which playlist is in the queue. func (m Model) providerRowStyle(p playlist.PlaylistInfo, isCursor bool) (string, lipgloss.Style) { if isCursor { return "> ", playlistSelectedStyle } if m.isProviderRowActive(p) { return "▶ ", playlistActiveStyle } return " ", playlistItemStyle } // isProviderRowActive reports whether the given playlist is the one whose // tracks are currently loaded into the player. func (m Model) isProviderRowActive(p playlist.PlaylistInfo) bool { if m.activeProviderPlaylistID != "" && m.activeProviderPlaylistID == p.ID { return true } if m.loadedPlaylist != "" && m.loadedPlaylist == p.Name { return true } return false } // playlistLabel formats a playlist entry, omitting fields the provider didn't // supply. Track count and total duration are appended when available. func playlistLabel(prefix string, p playlist.PlaylistInfo) string { out := prefix + p.Name parts := make([]string, 0, 2) if p.TrackCount > 0 { parts = append(parts, fmt.Sprintf("%d tracks", p.TrackCount)) } if d := formatPlaylistDuration(p.DurationSecs); d != "" { parts = append(parts, d) } if len(parts) > 0 { out += " · " + strings.Join(parts, " · ") } return out } // View renders the full TUI frame. func (m Model) View() tea.View { if m.quitting { return tea.NewView("") } screen := m.activeScreen() if !screen.hidesVisualizer() { m.refreshVisualizerIfPending() } var content string switch screen { case screenFullVisualizer: content = m.renderFullVisualizer() default: // Every overlay renders inline in the playlist region (renderMainBody), // with its header/help supplied by renderPlaylistHeader / renderHelp, so // the now-playing + visualizer chrome stays live above and the layout // height never shifts when an overlay opens. content = strings.Join(m.mainSections(m.renderMainBody(), true), "\n") } // Every screen now renders within the main frame, so frame and center // uniformly. rendered := m.centerFrame(ui.FrameStyle.Render(content)) view := tea.NewView(rendered) view.AltScreen = true view.WindowTitle = currentTerminalTitle(m.termTitle, m.width, m.terminalTitleValues()) return view } func trimTrailingEmpty(sections []string) []string { for len(sections) > 0 && sections[len(sections)-1] == "" { sections = sections[:len(sections)-1] } return sections } func (m Model) mainSections(playlist string, includeTransient bool) []string { sections := []string{ // Now playing m.renderTitle(), m.renderTrackInfo(), m.renderTimeStatus(), "", // ui.Visualizer m.renderSpectrum(), m.renderSeekBar(), "", // Controls m.renderControls(), m.renderProviderPill(), "", // Playlist m.renderPlaylistHeader(), } if playlist != "" { sections = append(sections, playlist) } sections = append(sections, "", // Help m.renderHelp(), m.renderBottomStatus(), ) if includeTransient { if m.err != nil { sections = append(sections, errorStyle.Render(fmt.Sprintf("ERR: %s", m.err))) } sections = append(sections, m.footerMessages()...) } return trimTrailingEmpty(sections) } func (m Model) footerMessages() []string { var lines []string if text := m.save.activityText(); text != "" { lines = append(lines, statusStyle.Render(text)) } if m.status.text != "" { lines = append(lines, statusStyle.Render(m.status.text)) } for _, l := range m.logLines { lines = append(lines, dimStyle.Render(l.text)) } return lines } // centerFrame centers a pre-rendered frame in the terminal using plain string // padding instead of allocating a new lipgloss.Style every render. func (m Model) centerFrame(frame string) string { frameW := lipgloss.Width(frame) frameH := lipgloss.Height(frame) padLeft := max(0, (m.width-frameW)/2) padTop := max(0, (m.height-frameH)/2) if padLeft == 0 { return strings.Repeat("\n", padTop) + frame } // Indent every line by padLeft spaces. prefix := strings.Repeat(" ", padLeft) lines := strings.Split(frame, "\n") for i, l := range lines { lines[i] = prefix + l } return strings.Repeat("\n", padTop) + strings.Join(lines, "\n") } func (m Model) renderTitle() string { title := titleStyle.Render("C L I A M P") label := m.focus.label() if label == "" { return title } indicator := dimStyle.Render("[" + label + "]") gap := max(ui.PanelWidth-lipgloss.Width(title)-lipgloss.Width(indicator), 1) return title + strings.Repeat(" ", gap) + indicator } func (m Model) renderTrackInfo() string { track, _ := m.currentPlaybackTrack() name := track.DisplayName() if name == "" { name = "No track loaded" } // Show live ICY stream title instead of static track name for radio streams. if m.streamTitle != "" && track.Stream { name = m.streamTitle } // Append album to the title line to save vertical space. // The album is truncated (never scrolled) so artist/song stays readable. album := track.Album if m.streamTitle != "" && track.Stream { album = "" } maxW := ui.PanelWidth - 4 if maxW < 1 { return trackStyle.Render("♫ " + name) } nameRunes := []rune(name) if album != "" { sep := " · " sepLen := len([]rune(sep)) remaining := maxW - len(nameRunes) - sepLen if remaining >= 4 { name += sep + truncate(album, remaining) } // remaining < 4: drop album, name alone fits or scrolls below. } runes := []rune(name) if len(runes) <= maxW { return trackStyle.Render("♫ " + name) } // Cyclic scrolling for long titles (only artist/song, album already handled) padded := append(runes, titleScrollSep...) total := len(padded) off := m.titleOff % total display := make([]rune, maxW) for i := range maxW { display[i] = padded[(off+i)%total] } return trackStyle.Render("♫ " + string(display)) } func (m Model) renderTimeStatus() string { // Use per-tick cached values to avoid repeated speaker.Lock() calls. pos := m.cachedPos dur := m.cachedDur posMin := int(pos.Minutes()) posSec := int(pos.Seconds()) % 60 durMin := int(dur.Minutes()) durSec := int(dur.Seconds()) % 60 timeStr := fmt.Sprintf("%02d:%02d / %02d:%02d", posMin, posSec, durMin, durSec) track, _ := m.currentPlaybackTrack() var status string switch { case m.seek.active: status = statusStyle.Render("⟳ Seeking...") case m.buffering: if elapsed := int(time.Since(m.bufferingAt).Seconds()); elapsed > 0 { status = statusStyle.Render(fmt.Sprintf("◌ Buffering... (%ds)", elapsed)) } else { status = statusStyle.Render("◌ Buffering...") } case m.player.IsPlaying() && m.player.IsPaused(): status = statusStyle.Render("⏸ Paused") case m.player.IsPlaying() && track.Stream: status = statusStyle.Render("● Streaming") case m.player.IsPlaying(): status = statusStyle.Render("▶ Playing") default: status = dimStyle.Render("■ Stopped") } left := timeStyle.Render(timeStr) gap := max(ui.PanelWidth-lipgloss.Width(left)-lipgloss.Width(status), 1) return left + strings.Repeat(" ", gap) + status } func (m Model) renderSpectrum() string { if m.vis.Mode == ui.VisNone { return "" } return m.vis.Render() } // renderFullVisualizer renders a full-screen view showing only the visualizer // with minimal track info and a seek bar. func (m Model) renderFullVisualizer() string { sections := []string{ m.renderTrackInfo(), m.renderTimeStatus(), "", m.renderSpectrum(), m.renderSeekBar(), "", helpKey("V", "Exit ") + helpKey("v", "Mode:"+m.vis.ModeName()+" ") + helpKey("Spc", "▶❚❚ ") + helpKey("<>", "Trk ") + helpKey("+-", "Vol"), } return strings.Join(sections, "\n") } func (m Model) renderSeekBar() string { if ui.PanelWidth <= 0 { return "" } // During buffering, show a dim bar — avoids speaker.Lock() contention. if m.buffering { return seekDimStyle.Render(strings.Repeat("━", ui.PanelWidth)) } // Show a static streaming bar for non-seekable streams with no known duration. if !m.player.Seekable() && m.player.IsPlaying() && m.cachedDur == 0 { label := " STREAMING " pad := ui.PanelWidth - lipgloss.Width(label) if pad < 0 { return seekFillStyle.Render(label[:ui.PanelWidth]) } left := pad / 2 right := pad - left return seekFillStyle.Render(strings.Repeat("━", left) + label + strings.Repeat("━", right)) } pos := m.cachedPos dur := m.cachedDur var progress float64 if dur > 0 { progress = float64(pos) / float64(dur) } progress = max(0, min(1, progress)) // Half-cell resolution: each cell is two sub-units, so a 4-minute track on // an 80-wide bar advances the tip every ~1.5s instead of ~3s. The // transition cell uses ╸ (heavy left-half) as a half-step stub. w := ui.PanelWidth subPos := int(progress * 2 * float64(w)) fullCells := subPos / 2 hasHalf := subPos%2 == 1 && fullCells < w var b strings.Builder b.WriteString(seekFillStyle.Render(strings.Repeat("━", fullCells))) if hasHalf { b.WriteString(seekFillStyle.Render("╸")) b.WriteString(seekDimStyle.Render(strings.Repeat("━", w-fullCells-1))) } else { b.WriteString(seekDimStyle.Render(strings.Repeat("━", w-fullCells))) } return b.String() } func (m Model) renderControls() string { // ── EQ [Preset] (left) ····· VOL bar dB [Mono] (right) ── bands := m.player.EQBands() presetName := m.EQPresetName() eqParts := make([]string, 10) eqLabels := [10]string{"70", "180", "320", "600", "1k", "3k", "6k", "12k", "14k", "16k"} for i, label := range eqLabels { style := eqInactiveStyle if bands[i] != 0 { label = fmt.Sprintf("%+.0f", bands[i]) } if m.focus == focusEQ && i == m.eqCursor { style = eqActiveStyle } eqParts[i] = style.Render(label) } eqLabel := labelStyle.Render("EQ ") if m.focus == focusEQ { eqLabel = activeToggle.Render("EQ ▸ ") } left := eqLabel + dimStyle.Render("[") + activeToggle.Render(presetName) + dimStyle.Render("] ") + strings.Join(eqParts, " ") vol := m.player.Volume() volMin := m.player.VolumeMin() frac := max(0, min(1, (vol-volMin)/(6-volMin))) dbStr := fmt.Sprintf(" %+.0fdB", vol) monoStr := "" if m.player.Mono() { monoStr = " " + activeToggle.Render("[M]") } leftW := lipgloss.Width(left) volLabel := labelStyle.Render("VOL ") volSuffix := dimStyle.Render(dbStr) + monoStr volLabelW := lipgloss.Width(volLabel) volSuffixW := lipgloss.Width(volSuffix) barW := max(6, (ui.PanelWidth-leftW-2-volLabelW-volSuffixW)*3/4) filled := int(frac * float64(barW)) bar := volBarStyle.Render(strings.Repeat("█", filled)) + dimStyle.Render(strings.Repeat("░", barW-filled)) right := volLabel + bar + volSuffix rightW := lipgloss.Width(right) gap := max(1, ui.PanelWidth-leftW-rightW) return left + strings.Repeat(" ", gap) + right } func (m Model) renderProviderPill() string { if len(m.providers) <= 1 { return "" } var pills []string for i, pe := range m.providers { name := pe.Name if m.focus == focusProvPill && i == m.provPillIdx { pills = append(pills, activeToggle.Render("["+name+"]")) } else if i == m.provPillIdx { pills = append(pills, dimStyle.Render("[")+trackStyle.Render(name)+dimStyle.Render("]")) } else { pills = append(pills, dimStyle.Render("["+name+"]")) } } srcLabel := labelStyle.Render("SRC ") if m.focus == focusProvPill { srcLabel = activeToggle.Render("SRC ▸ ") } return srcLabel + strings.Join(pills, " ") } func (m Model) renderPlaylistHeader() string { if ov, ok := m.activeOverlay(); ok { return ov.header(&m) } if m.focus == focusProvider { return dimStyle.Render(labeledSeparator("", fmt.Sprintf("%s Playlists", m.provider.Name()))) } var shuffle string if m.playlist.Shuffled() { shuffle = activeToggle.Render("[Shuffle]") } else { shuffle = dimStyle.Render("[") + trackStyle.Render("Shuffle") + dimStyle.Render("]") } repeatVal := m.playlist.Repeat().String() if m.playlist.Repeat() != 0 { repeatStr := fmt.Sprintf("[Repeat: %s]", repeatVal) repeatStr = activeToggle.Render(repeatStr) shuffle += " " + repeatStr } else { repeatStr := dimStyle.Render("[") + trackStyle.Render("Repeat") + dimStyle.Render(": ") + dimStyle.Render(repeatVal) + dimStyle.Render("]") shuffle += " " + repeatStr } var queueStr string if qLen := m.playlist.QueueLen(); qLen > 0 { queueStr = " " + activeToggle.Render(fmt.Sprintf("[Queue: %d]", qLen)) } var bookmarkStr string if bookmarkCount := m.playlist.BookmarkCount(); bookmarkCount > 0 { bookmarkStr = " " + activeToggle.Render(fmt.Sprintf("[★ %d]", bookmarkCount)) } var themeStr string if name := m.ThemeName(); name != theme.DefaultName { themeStr = " " + activeToggle.Render("[Theme: "+name+"]") } var posStr string if total := m.playlist.Len(); total > 0 { posStr = " " + dimStyle.Render(fmt.Sprintf("[%d/%d]", m.playlist.Index()+1, total)) } headerStyle := dimStyle headerLabel := "── Playlist ── " if m.focus == focusPlaylist { headerStyle = activeToggle headerLabel = "▸─ Playlist ── " } return headerStyle.Render(headerLabel) + shuffle + queueStr + bookmarkStr + posStr + themeStr + " " + dimStyle.Render("──") } func (m Model) renderProviderList() string { visibleBudget := m.effectivePlaylistVisible() if visibleBudget <= 0 { return "" } if m.provSignIn { return dimStyle.Render(fmt.Sprintf(" Sign in to %s. Press Enter to continue.", m.provider.Name())) } if m.provLoading { lines := []string{loadingLine(fmt.Sprintf("Loading %s…", m.provider.Name()))} if m.provAuthURL != "" { lines = append(lines, "", dimStyle.Render(" If your browser didn't open, visit this URL to sign in:"), " "+m.provAuthURL, ) } for len(lines) < visibleBudget { lines = append(lines, "") } return strings.Join(lines, "\n") } if len(m.providerLists) == 0 { return m.renderProviderEmptyState(visibleBudget) } sl, isRadio := m.provider.(provider.SectionedList) var lines []string if m.provSearch.active { lines = append(lines, playlistSelectedStyle.Render(" / "+m.provSearch.query+"_")) if isRadio { if m.provSearch.query == "" { lines = append(lines, dimStyle.Render(" Type a station name, Enter to search…")) } else { lines = append(lines, dimStyle.Render(" Press Enter to search")) } } else { if m.provSearch.query == "" { lines = append(lines, dimStyle.Render(" Type to filter…")) } else if len(m.provSearch.results) == 0 { lines = append(lines, dimStyle.Render(" No matches")) } else { visible := max(0, min(visibleBudget-1, len(m.provSearch.results))) scroll := m.provSearch.scroll for j := scroll; j < scroll+visible && j < len(m.provSearch.results); j++ { idx := m.provSearch.results[j] p := m.providerLists[idx] prefix, style := m.providerRowStyle(p, j == m.provSearch.cursor) lines = append(lines, style.Render(playlistLabel(prefix, p))) } lines = append(lines, dimStyle.Render(fmt.Sprintf(" %d/%d playlists", len(m.provSearch.results), len(m.providerLists)))) } } } else { scroll := max(0, m.provScroll) if scroll >= len(m.providerLists) { scroll = max(0, len(m.providerLists)-1) } if m.provCursor < scroll { scroll = m.provCursor } hasSections := !isRadio && slices.ContainsFunc(m.providerLists, func(p playlist.PlaylistInfo) bool { return p.Section != "" }) if isRadio { for scroll < len(m.providerLists)-1 && m.providerRowsFromScroll(sl, scroll, m.provCursor) > visibleBudget { scroll++ } } else if m.provCursor >= scroll+visibleBudget { scroll = m.provCursor - visibleBudget + 1 } prevPrefix := "" if isRadio && scroll > 0 { prevPrefix = sl.IDPrefix(m.providerLists[scroll-1].ID) } prevSection := "" if hasSections && scroll > 0 { prevSection = m.providerLists[scroll-1].Section } for j := scroll; j < len(m.providerLists) && len(lines) < visibleBudget; j++ { p := m.providerLists[j] if isRadio { pfx := sl.IDPrefix(p.ID) if pfx != prevPrefix { var header string switch pfx { case "f": header = labeledSeparator(" ", "Favorites") case "c": header = labeledSeparator(" ", "Catalog") case "s": header = labeledSeparator(" ", "Search Results") } if header != "" && len(lines) < visibleBudget { lines = append(lines, dimStyle.Render(header)) } prevPrefix = pfx } } else if hasSections && p.Section != prevSection { header := labeledSeparator(" ", p.Section) if len(lines) < visibleBudget { lines = append(lines, dimStyle.Render(header)) } prevSection = p.Section } if len(lines) >= visibleBudget { break } prefix, style := m.providerRowStyle(p, j == m.provCursor) lines = append(lines, style.Render(playlistLabel(prefix, p))) } } // Loading indicator for catalog batch (never displace selected row if full). if isRadio && m.catalogBatch.loading && len(lines) < visibleBudget { lines = append(lines, loadingLine("Loading more stations…")) } return strings.Join(fitLines(lines, visibleBudget), "\n") } func (m Model) renderPlaylist() string { budget := m.effectivePlaylistVisible() if budget <= 0 { return "" } if m.focus == focusProvider { return m.renderProviderList() } tracks := m.playlist.Tracks() if len(tracks) == 0 { var lines []string if m.feedLoading { lines = append(lines, loadingLine("Loading feed…")) } else { lines = append(lines, dimStyle.Render(" No tracks loaded")) } return strings.Join(fitLines(lines, budget), "\n") } currentIdx := m.playlist.Index() scroll := m.playlistScroll(budget) lines := make([]string, 0, budget) numWidth := len(fmt.Sprintf("%d", len(tracks))) for row := range m.playlistRows(tracks, scroll, m.showAlbumHeaders) { if row.Index < 0 { if len(lines)+1 >= budget { break } lines = append(lines, m.albumSeparator(row.Album, row.Year)) continue } if len(lines) >= budget { break } i, t := row.Index, row.Track prefix := " " style := playlistItemStyle if i == currentIdx && m.player.IsPlaying() { prefix = "▶ " style = playlistActiveStyle } else if strings.HasPrefix(t.Path, "ssh://") { prefix = "↗ " } if m.focus == focusPlaylist && i == m.plCursor { style = playlistSelectedStyle } if t.Unplayable { if m.focus == focusPlaylist && i == m.plCursor { style = dimStyle } else { style = playlistUnavailableStyle } } name := t.DisplayName() isBookmark := t.Bookmark bookmarkBudget := 0 if isBookmark { bookmarkBudget = 2 // "★ " } queueSuffix := "" if qp := m.playlist.QueuePosition(i); qp > 0 { queueSuffix = fmt.Sprintf(" [Q%d]", qp) } queueLen := utf8.RuneCountInString(queueSuffix) linePrefixWidth := utf8.RuneCountInString(prefix) + numWidth + 2 // 2 for ". " // Truncate the track name only against queue/bookmark overhead, never album. name = truncate(name, ui.PanelWidth-linePrefixWidth-queueLen-bookmarkBudget) // Truncate the album to fit whatever space remains after the track name. albumSuffix := "" nameLen := utf8.RuneCountInString(name) if t.Unplayable { remaining := ui.PanelWidth - linePrefixWidth - bookmarkBudget - nameLen - queueLen if remaining >= 4 { albumSuffix = truncate(" (unavailable)", remaining) } } else if album := t.Album; album != "" && !m.showAlbumHeaders { remaining := ui.PanelWidth - linePrefixWidth - bookmarkBudget - nameLen - queueLen - 3 // 3 = " · " if remaining >= 4 { albumSuffix = " · " + truncate(album, remaining) } } numStr := fmt.Sprintf("%s%*d. ", prefix, numWidth, i+1) line := style.Render(numStr) if isBookmark { line += activeToggle.Render("★ ") } line += style.Render(name) if albumSuffix != "" { line += dimStyle.Render(albumSuffix) } if queueSuffix != "" { line += activeToggle.Render(queueSuffix) } lines = append(lines, line) } return strings.Join(padLines(lines, budget, len(lines)), "\n") } func (m Model) renderHelp() string { if ov, ok := m.activeOverlay(); ok { return fitHelpLine(ov.help(&m)) } if m.focus == focusProvider { help := helpKey("↓↑", "Scroll ") + helpKey("Enter", "Load ") + helpKey("/", "Search ") if _, ok := m.provider.(provider.FavoriteToggler); ok { help += helpKey("f", "Fav ") } return help + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys") } if m.focus == focusProvPill { return helpKey("←→", "Select ") + helpKey("Enter", "Open ") + helpKey("Esc", "Back ") + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys") } // Show only the 4-5 most relevant keys per mode; Ctrl+K always anchored for full list. var hints []helpHint if m.focus == focusSpeed { hints = append(hints, helpHint{helpKey("←→", "Speed "), 100}, helpHint{helpKey("[]", "Speed "), 90}, helpHint{helpKey("Spc", "▶❚❚ "), 80}, helpHint{helpKey("Tab", "Focus "), 70}, helpHint{helpKey("Ctrl+K", "Keys"), 100}, ) } else if m.focus == focusEQ { hints = append(hints, helpHint{helpKey("←→", "Band "), 100}, helpHint{helpKey("↓↑", "Gain "), 100}, helpHint{helpKey("e", "Preset "), 90}, helpHint{helpKey("Spc", "▶❚❚ "), 80}, helpHint{helpKey("Tab", "Focus "), 70}, helpHint{helpKey("Ctrl+K", "Keys"), 100}, ) } else { // focusPlaylist (default) hints = append(hints, helpHint{helpKey("↓↑", "Scroll "), 100}, helpHint{helpKey("Enter", "Play "), 100}, helpHint{helpKey("Spc", "▶❚❚ "), 90}, ) track, _ := m.currentPlaybackTrack() if !track.Stream || m.player.Seekable() { hints = append(hints, helpHint{helpKey("←→", "Seek "), 80}) } if m.loadedPlaylist != "" { hints = append(hints, helpHint{helpKey("f", "Bookmark "), 75}) } hints = append(hints, helpHint{helpKey("Tab", "Focus "), 70}, helpHint{helpKey("Ctrl+K", "Keys"), 100}, ) } return fitHints(hints, ui.PanelWidth) } // helpHint is a rendered help key with an associated display priority. type helpHint struct { text string priority int } // fitHints drops lowest-priority hints until they fit within maxWidth. // Widths are pre-computed once to avoid repeated lipgloss.Width calls. func fitHints(hints []helpHint, maxWidth int) string { active := make([]bool, len(hints)) widths := make([]int, len(hints)) var total int for i, h := range hints { active[i] = true widths[i] = lipgloss.Width(h.text) total += widths[i] } for total > maxWidth { // Find lowest-priority active hint and drop it. minPri := math.MaxInt minIdx := -1 for i, h := range hints { if active[i] && h.priority < minPri { minPri = h.priority minIdx = i } } if minIdx < 0 { break } active[minIdx] = false total -= widths[minIdx] } var sb strings.Builder for i, h := range hints { if active[i] { sb.WriteString(h.text) } } return sb.String() } // renderBottomStatus renders the bottom status line: speed (left) and // network stats (right) on the same row. func (m Model) renderBottomStatus() string { // Left: speed indicator. speed := m.player.Speed() if speed == 0 { speed = 1.0 } speedVal := fmt.Sprintf("%.2gx", speed) var left string speedLabel := labelStyle.Render("SPD ") if m.focus == focusSpeed { speedLabel = activeToggle.Render("SPD ▸ ") left = speedLabel + activeToggle.Render("["+speedVal+"]") } else if speed != 1.0 { left = speedLabel + activeToggle.Render("["+speedVal+"]") } else { left = speedLabel + dimStyle.Render("[") + trackStyle.Render(speedVal) + dimStyle.Render("]") } // Right: network stream stats (empty for local files). var right string downloaded, total := m.player.StreamBytes() if downloaded > 0 || total > 0 { mb := float64(downloaded) / (1024 * 1024) if total > 0 { totalMB := float64(total) / (1024 * 1024) pct := float64(downloaded) / float64(total) * 100 right = fmt.Sprintf("↓ %.1f / %.1f MB (%.0f%%)", mb, totalMB, pct) } else { right = fmt.Sprintf("↓ %.1f MB", mb) } if m.network.speed > 0 { kbs := m.network.speed / 1024 if kbs >= 1024 { right += fmt.Sprintf(" %.1f MB/s", kbs/1024) } else { right += fmt.Sprintf(" %.0f KB/s", kbs) } } right = dimStyle.Render(right) } leftW := lipgloss.Width(left) rightW := lipgloss.Width(right) gap := max(1, ui.PanelWidth-leftW-rightW) if right == "" { return left } return left + strings.Repeat(" ", gap) + right }