Skip to content

Update Discord badge in README.md#4

Merged
duysqubix merged 1 commit into
devfrom
update-discord-badge
Oct 7, 2023
Merged

Update Discord badge in README.md#4
duysqubix merged 1 commit into
devfrom
update-discord-badge

Conversation

@MarkKremer

Copy link
Copy Markdown
Contributor

No description provided.

@MarkKremer MarkKremer changed the base branch from master to dev October 7, 2023 20:58
@MarkKremer MarkKremer requested a review from a team October 7, 2023 21:14
@duysqubix duysqubix merged commit f2f355d into dev Oct 7, 2023
@duysqubix duysqubix deleted the update-discord-badge branch October 7, 2023 23:17
@duysqubix duysqubix restored the update-discord-badge branch October 7, 2023 23:17
@MarkKremer MarkKremer deleted the update-discord-badge branch October 8, 2023 09:58
gjermundgaraba pushed a commit to gjermundgaraba/beep that referenced this pull request Apr 11, 2026
* Add Spotify provider using go-librespot for native playback

Integrates Spotify streaming directly into cliamp's Beep audio pipeline,
giving full EQ, visualizer, and gapless playback support for Spotify
Premium accounts.

Architecture:
- external/spotify/streamer.go: Bridges go-librespot AudioSource
  (interleaved float32) to beep.StreamSeekCloser ([2]float64 pairs)
- external/spotify/session.go: OAuth2 authentication with credential
  persistence in ~/.config/cliamp/spotify_credentials.json
- external/spotify/provider.go: playlist.Provider using Spotify Web API
  for playlists/tracks, go-librespot player.NewStream for audio
- player/player.go: StreamerFactory hook for custom URI schemes
- player/pipeline.go: spotify:track:xxx URI detection and routing
- config/config.go: [spotify] section with enabled flag

Enable with `enabled = true` under `[spotify]` in config.toml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Improve Spotify OAuth flow: print auth URL, open browser, allow Enter to retry

* Add retry with backoff on 429 rate limits, improve auth UX messaging

* Add stderr logging for 429 retries, increase timeout to 5min, bump max retries to 8

* Bypass spclient for Web API calls — use direct HTTP with Bearer token to avoid Client-Token header rate limits

* Own OAuth2 flow: capture Web API token, auto-close browser tab, use SpotifyTokenCredentials

The spclient's internal login5 token gets aggressively rate-limited (429 with
Retry-After: 86400) when used against api.spotify.com. Root cause: it's not
a standard Web API token.

Fix: Run our own OAuth2 flow with the same client_id/scopes, capture the
access_token for Web API calls, and pass it to go-librespot via
SpotifyTokenCredentials for session auth.

Also: custom callback server serves HTML with window.close() script,
fixing the browser tab not auto-closing after auth.

* Use registered Spotify Developer app client_id for Web API

The go-librespot internal client_id (65b708073fc0480ea92a077233ca87bd) is
shared across all librespot users and gets aggressively rate-limited for
Web API calls (Retry-After: 86400).

Now requires a registered Spotify Developer app:
- client_id in config.toml [spotify] section
- Fixed callback port 19872 for redirect URI
- OAuth2 PKCE flow (no client_secret needed)

Config:
  [spotify]
  enabled = true
  client_id = "your-client-id"

Spotify Developer app redirect URI:
  http://127.0.0.1:19872/login

* Remove internal Spotify scopes that cause 'Illegal scope' with registered apps

* Minimal OAuth2 scopes — only playlist-read + streaming + user-read-private

* Fix TUI freeze: stop stdin reader goroutine after auth completes

The bufio.Scanner goroutine for Enter-to-retry kept reading stdin after
OAuth completed, stealing input from Bubbletea's raw terminal handler.
Replaced with raw os.Stdin.Read + authDone channel to stop cleanly.

* Fix stored credential sessions: do fresh OAuth2 for Web API token on each launch

The spclient's login5 token gets 429'd on Web API. On second launch
(stored credentials), we were trying refreshWebAPIToken which tested the
spclient token — always fails. Now does a quick OAuth2 PKCE flow on each
launch to get a fresh Web API token. Falls back to full interactive auth
if the token flow fails.

* Fix nil CountryCode panic when playing Spotify tracks

go-librespot's Player.getUnrestrictedTrack dereferences CountryCode
to check media restrictions. We never set it, causing a nil pointer
panic. Default to 'US'.

* Fix pause/resume restarting track; expand scopes; dynamic country code

- Set Stream=false on Spotify tracks — they're seekable, not live streams.
  The TUI's togglePlayPause treats Stream=true as live (stop+replay on
  resume), which was restarting songs from the beginning.
- Expanded OAuth2 scopes to full standard Web API set (playlist modify,
  library modify, playback state, recently played, top tracks, follows).
  Internal Spotify scopes that cause 'Illegal scope' are documented and
  excluded.
- Fetch user's country from /v1/me for accurate media restriction checks
  instead of hardcoding 'US'.

* Dynamic playlist height, persist OAuth2 refresh token

- Playlist view now fills available terminal height instead of being
  capped at 5 items. Recalculates on window resize. 'x' key toggles
  between compact (5) and full height. Minimum 3 items.
- Persist OAuth2 refresh token in spotify_credentials.json. On subsequent
  launches, silently refresh the Web API token without opening a browser.
  Falls back to interactive auth if refresh fails.
- Extract spotifyOAuthConfig() helper shared by doWebAPIAuth and
  silentTokenRefresh.

* dynamic height

* Fix UTF-8 encoding in OAuth callback page (✅ rendered as ✅)

Add <meta charset="utf-8"> to both callback HTML pages so the
checkmark emoji renders correctly in all browsers.

* Fix dynamic playlist height: account for frame padding + 2-line controls

Previous calculation used 12 + vis.Rows for fixed UI lines, but missed:
- Frame padding (2 lines from Padding(1,3))
- Controls render as 2 lines (VOL + EQ), not 1
Corrected to 17 + vis.Rows. Playlist no longer overflows the terminal.

* Fix playlist scroll: account for album separator lines in visible window

Album separators (── Album Name (Year) ──) are rendered between tracks
from different albums, taking extra lines. adjustScroll only counted
track items, so the cursor would move past the visible area before
scrolling kicked in. Now counts rendered lines (tracks + separators)
to determine when to scroll.

* Dynamic frame width: use full terminal width instead of fixed 80 chars

The frame and panelWidth were hardcoded to 80/74 chars, causing the
help bar to wrap to the next line on wider terminals. Now dynamically
sizes to the terminal width on WindowSizeMsg. Album separators, seek
bar, controls, and visualizer all scale to the available width.

* Fix scroll with mixed album separators: count actual rendered lines

Previous fix assumed every track had an album separator. Now uses
renderedLineCount() helper that accurately counts lines (tracks +
separators) for any range. adjustScroll walks backward from cursor
to find the right scroll offset when mixed separator/no-separator
tracks are present.

* Measure actual UI height instead of counting lines manually

The manual fixed-line count (17 + vis.Rows) was wrong — missed various
multi-line renders, frame padding, etc. Now renders all non-playlist
sections into a probe frame and uses lipgloss.Height() to measure the
actual pixel height. Guarantees plVisible matches the real available
space regardless of theme, controls layout, or status lines.

* Fix x key toggle: properly toggle between compact (5) and dynamic max

Previous code had dynMax == plVisible always (broken comparison).
Now toggles between 5 and full dynamic height using same probe
measurement as WindowSizeMsg.

* Add 1-line buffer to dynamic playlist height

Probe measurement was consistently 1-2 lines optimistic, causing
the bottom of the playlist to clip. Add a 1-line safety margin.

* Bump playlist height buffer to -2 lines

* Fix playlist height: plVisible is rendered lines, not track count

Root cause: renderPlaylist() did `visible := min(m.plVisible, len(tracks))`
which conflated rendered line count with track count. When plVisible=15 and
len(tracks)=8, visible was capped to 8 — but 8 tracks from different albums
render as 16 lines (8 separators + 8 tracks), overflowing.

Fix:
- Remove min(plVisible, len(tracks)) — plVisible is the rendered line budget
- Remove scroll clamping that used track count as line count
- Add budget check before separator+track pair: if only 1 line left but
  need 2 (separator + track), break instead of overflowing
- Remove -2 magic buffer from probe measurement — it's now exact
- plVisible = height - lipgloss.Height(probeFrame) + 1 (no fudge factors)

* Address PR review feedback from Gemini Code Assist

- Use strconv.Atoi instead of fmt.Sscanf for year parsing
- Add comment explaining Stream: false (pause/resume requires it)
- Handle io.ReadAll error on non-OK HTTP response bodies
- Log saveCreds errors instead of silently discarding
- Log http.Serve errors (filter net.ErrClosed for clean shutdown)
- Log country code fetch errors with stderr fallback
- Add TODO for configurable bitrate

* Add Spotify provider using go-librespot for native playback

Integrates Spotify streaming directly into cliamp's Beep audio pipeline,
giving full EQ, visualizer, and gapless playback support for Spotify
Premium accounts.

Architecture:
- external/spotify/streamer.go: Bridges go-librespot AudioSource
  (interleaved float32) to beep.StreamSeekCloser ([2]float64 pairs)
- external/spotify/session.go: OAuth2 authentication with credential
  persistence in ~/.config/cliamp/spotify_credentials.json
- external/spotify/provider.go: playlist.Provider using Spotify Web API
  for playlists/tracks, go-librespot player.NewStream for audio
- player/player.go: StreamerFactory hook for custom URI schemes
- player/pipeline.go: spotify:track:xxx URI detection and routing
- config/config.go: [spotify] section with enabled flag

Enable with `enabled = true` under `[spotify]` in config.toml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Improve Spotify OAuth flow: print auth URL, open browser, allow Enter to retry

* Add retry with backoff on 429 rate limits, improve auth UX messaging

* Add stderr logging for 429 retries, increase timeout to 5min, bump max retries to 8

* Bypass spclient for Web API calls — use direct HTTP with Bearer token to avoid Client-Token header rate limits

* Own OAuth2 flow: capture Web API token, auto-close browser tab, use SpotifyTokenCredentials

The spclient's internal login5 token gets aggressively rate-limited (429 with
Retry-After: 86400) when used against api.spotify.com. Root cause: it's not
a standard Web API token.

Fix: Run our own OAuth2 flow with the same client_id/scopes, capture the
access_token for Web API calls, and pass it to go-librespot via
SpotifyTokenCredentials for session auth.

Also: custom callback server serves HTML with window.close() script,
fixing the browser tab not auto-closing after auth.

* Use registered Spotify Developer app client_id for Web API

The go-librespot internal client_id (65b708073fc0480ea92a077233ca87bd) is
shared across all librespot users and gets aggressively rate-limited for
Web API calls (Retry-After: 86400).

Now requires a registered Spotify Developer app:
- client_id in config.toml [spotify] section
- Fixed callback port 19872 for redirect URI
- OAuth2 PKCE flow (no client_secret needed)

Config:
  [spotify]
  enabled = true
  client_id = "your-client-id"

Spotify Developer app redirect URI:
  http://127.0.0.1:19872/login

* Remove internal Spotify scopes that cause 'Illegal scope' with registered apps

* Minimal OAuth2 scopes — only playlist-read + streaming + user-read-private

* Fix TUI freeze: stop stdin reader goroutine after auth completes

The bufio.Scanner goroutine for Enter-to-retry kept reading stdin after
OAuth completed, stealing input from Bubbletea's raw terminal handler.
Replaced with raw os.Stdin.Read + authDone channel to stop cleanly.

* Fix stored credential sessions: do fresh OAuth2 for Web API token on each launch

The spclient's login5 token gets 429'd on Web API. On second launch
(stored credentials), we were trying refreshWebAPIToken which tested the
spclient token — always fails. Now does a quick OAuth2 PKCE flow on each
launch to get a fresh Web API token. Falls back to full interactive auth
if the token flow fails.

* Fix nil CountryCode panic when playing Spotify tracks

go-librespot's Player.getUnrestrictedTrack dereferences CountryCode
to check media restrictions. We never set it, causing a nil pointer
panic. Default to 'US'.

* Fix pause/resume restarting track; expand scopes; dynamic country code

- Set Stream=false on Spotify tracks — they're seekable, not live streams.
  The TUI's togglePlayPause treats Stream=true as live (stop+replay on
  resume), which was restarting songs from the beginning.
- Expanded OAuth2 scopes to full standard Web API set (playlist modify,
  library modify, playback state, recently played, top tracks, follows).
  Internal Spotify scopes that cause 'Illegal scope' are documented and
  excluded.
- Fetch user's country from /v1/me for accurate media restriction checks
  instead of hardcoding 'US'.

* Dynamic playlist height, persist OAuth2 refresh token

- Playlist view now fills available terminal height instead of being
  capped at 5 items. Recalculates on window resize. 'x' key toggles
  between compact (5) and full height. Minimum 3 items.
- Persist OAuth2 refresh token in spotify_credentials.json. On subsequent
  launches, silently refresh the Web API token without opening a browser.
  Falls back to interactive auth if refresh fails.
- Extract spotifyOAuthConfig() helper shared by doWebAPIAuth and
  silentTokenRefresh.

* dynamic height

* Fix UTF-8 encoding in OAuth callback page (✅ rendered as ✅)

Add <meta charset="utf-8"> to both callback HTML pages so the
checkmark emoji renders correctly in all browsers.

* Fix dynamic playlist height: account for frame padding + 2-line controls

Previous calculation used 12 + vis.Rows for fixed UI lines, but missed:
- Frame padding (2 lines from Padding(1,3))
- Controls render as 2 lines (VOL + EQ), not 1
Corrected to 17 + vis.Rows. Playlist no longer overflows the terminal.

* Fix playlist scroll: account for album separator lines in visible window

Album separators (── Album Name (Year) ──) are rendered between tracks
from different albums, taking extra lines. adjustScroll only counted
track items, so the cursor would move past the visible area before
scrolling kicked in. Now counts rendered lines (tracks + separators)
to determine when to scroll.

* Dynamic frame width: use full terminal width instead of fixed 80 chars

The frame and panelWidth were hardcoded to 80/74 chars, causing the
help bar to wrap to the next line on wider terminals. Now dynamically
sizes to the terminal width on WindowSizeMsg. Album separators, seek
bar, controls, and visualizer all scale to the available width.

* Fix scroll with mixed album separators: count actual rendered lines

Previous fix assumed every track had an album separator. Now uses
renderedLineCount() helper that accurately counts lines (tracks +
separators) for any range. adjustScroll walks backward from cursor
to find the right scroll offset when mixed separator/no-separator
tracks are present.

* Measure actual UI height instead of counting lines manually

The manual fixed-line count (17 + vis.Rows) was wrong — missed various
multi-line renders, frame padding, etc. Now renders all non-playlist
sections into a probe frame and uses lipgloss.Height() to measure the
actual pixel height. Guarantees plVisible matches the real available
space regardless of theme, controls layout, or status lines.

* Fix x key toggle: properly toggle between compact (5) and dynamic max

Previous code had dynMax == plVisible always (broken comparison).
Now toggles between 5 and full dynamic height using same probe
measurement as WindowSizeMsg.

* Add 1-line buffer to dynamic playlist height

Probe measurement was consistently 1-2 lines optimistic, causing
the bottom of the playlist to clip. Add a 1-line safety margin.

* Bump playlist height buffer to -2 lines

* Fix playlist height: plVisible is rendered lines, not track count

Root cause: renderPlaylist() did `visible := min(m.plVisible, len(tracks))`
which conflated rendered line count with track count. When plVisible=15 and
len(tracks)=8, visible was capped to 8 — but 8 tracks from different albums
render as 16 lines (8 separators + 8 tracks), overflowing.

Fix:
- Remove min(plVisible, len(tracks)) — plVisible is the rendered line budget
- Remove scroll clamping that used track count as line count
- Add budget check before separator+track pair: if only 1 line left but
  need 2 (separator + track), break instead of overflowing
- Remove -2 magic buffer from probe measurement — it's now exact
- plVisible = height - lipgloss.Height(probeFrame) + 1 (no fudge factors)

* Address PR review feedback from Gemini Code Assist

- Use strconv.Atoi instead of fmt.Sscanf for year parsing
- Add comment explaining Stream: false (pause/resume requires it)
- Handle io.ReadAll error on non-OK HTTP response bodies
- Log saveCreds errors instead of silently discarding
- Log http.Serve errors (filter net.ErrClosed for clean shutdown)
- Log country code fetch errors with stderr fallback
- Add TODO for configurable bitrate

* Add Spotify docs to README, update install instructions for fork

- Added Spotify section with setup instructions (Developer app, config, OAuth)
- Updated install methods: go install, pre-built binaries, build from source
- Removed Homebrew tap update from release workflow (fork-specific)

* Auto-refresh Web API token: replace static string with TokenSource

The webAPIToken was set once during init and never refreshed. Spotify
access tokens expire after 1 hour, causing all Web API calls to fail.

Now uses oauth2.TokenSource which automatically refreshes the token
using the stored refresh token when it expires. No more browser
re-authentication after 1 hour — tokens refresh transparently in
the background.

* Show shuffle (z) and repeat (r) keys in bottom help bar

They were only visible in the Ctrl+K keymap overlay but not in the
always-visible bottom controls line.

* Persist shuffle/repeat state to config on toggle

Saves shuffle and repeat preferences to config.toml when toggled via
z/r keys, so they survive restarts across all providers.

* Move shuffle/repeat to top of Ctrl+K keymap overlay

They were at positions 23-24, below the 12-line visible window.
Moved them right after volume controls so they're visible without
scrolling.

* Responsive help bar: drop low-priority hints when terminal is narrow

Each help hint has a priority. When the combined width exceeds the
terminal width, the lowest-priority hints are dropped first:

  100 Spc(⏯)  95 Q(Quit)  90 <>(Trk)  80 +-(Vol)  70 ←→(Seek)
   60 Ctrl+K(Keys)  50 Tab(Focus)  40 /(Search)  30 a(Queue)
   20 z(Shfl)  20 r(Rpt)

On a narrow terminal you still see play/pause, track nav, vol, and quit.
On wide terminals everything shows.

* Handle config.Save errors for shuffle/repeat

Show a status message via saveMsg when persisting fails instead of
silently ignoring the error.

Addresses Gemini Code Assist review feedback.

* fix: don't auto-play when selecting Spotify playlist during playback

When a track is already playing, selecting a new playlist now loads the
tracks without stopping playback or auto-playing. Users can browse
playlists freely while listening.

Auto-play only triggers when nothing is currently playing.

* fix: auto re-auth on Spotify AES key / session errors

When go-librespot fails to retrieve an audio key (e.g. code 2 from
Spotify's AP server due to expired/revoked session), the provider now:

1. Detects auth-related errors (KeyProviderError, DeadlineExceeded)
2. Tears down the dead session and clears stored credentials
3. Triggers a fresh OAuth2 interactive flow automatically
4. Retries the stream once with the new session

This prevents users from getting stuck in an error loop — no manual
credential deletion or CLI commands needed.

Adds Session.Reconnect() for hot-swapping the session/player, and
deleteCreds() to clear stale stored credentials.

* fix: avoid nil window in Reconnect (address review)

Create the new session before tearing down the old one so s.sess and
s.player are never nil while the mutex is unlocked. Old session/player
are closed after the atomic swap completes.

Addresses Gemini review comment on PR gopxl#4.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
gjermundgaraba pushed a commit to gjermundgaraba/beep that referenced this pull request Apr 11, 2026
* Add Spotify provider using go-librespot for native playback

Integrates Spotify streaming directly into cliamp's Beep audio pipeline,
giving full EQ, visualizer, and gapless playback support for Spotify
Premium accounts.

Architecture:
- external/spotify/streamer.go: Bridges go-librespot AudioSource
  (interleaved float32) to beep.StreamSeekCloser ([2]float64 pairs)
- external/spotify/session.go: OAuth2 authentication with credential
  persistence in ~/.config/cliamp/spotify_credentials.json
- external/spotify/provider.go: playlist.Provider using Spotify Web API
  for playlists/tracks, go-librespot player.NewStream for audio
- player/player.go: StreamerFactory hook for custom URI schemes
- player/pipeline.go: spotify:track:xxx URI detection and routing
- config/config.go: [spotify] section with enabled flag

Enable with `enabled = true` under `[spotify]` in config.toml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Improve Spotify OAuth flow: print auth URL, open browser, allow Enter to retry

* Add retry with backoff on 429 rate limits, improve auth UX messaging

* Add stderr logging for 429 retries, increase timeout to 5min, bump max retries to 8

* Bypass spclient for Web API calls — use direct HTTP with Bearer token to avoid Client-Token header rate limits

* Own OAuth2 flow: capture Web API token, auto-close browser tab, use SpotifyTokenCredentials

The spclient's internal login5 token gets aggressively rate-limited (429 with
Retry-After: 86400) when used against api.spotify.com. Root cause: it's not
a standard Web API token.

Fix: Run our own OAuth2 flow with the same client_id/scopes, capture the
access_token for Web API calls, and pass it to go-librespot via
SpotifyTokenCredentials for session auth.

Also: custom callback server serves HTML with window.close() script,
fixing the browser tab not auto-closing after auth.

* Use registered Spotify Developer app client_id for Web API

The go-librespot internal client_id (65b708073fc0480ea92a077233ca87bd) is
shared across all librespot users and gets aggressively rate-limited for
Web API calls (Retry-After: 86400).

Now requires a registered Spotify Developer app:
- client_id in config.toml [spotify] section
- Fixed callback port 19872 for redirect URI
- OAuth2 PKCE flow (no client_secret needed)

Config:
  [spotify]
  enabled = true
  client_id = "your-client-id"

Spotify Developer app redirect URI:
  http://127.0.0.1:19872/login

* Remove internal Spotify scopes that cause 'Illegal scope' with registered apps

* Minimal OAuth2 scopes — only playlist-read + streaming + user-read-private

* Fix TUI freeze: stop stdin reader goroutine after auth completes

The bufio.Scanner goroutine for Enter-to-retry kept reading stdin after
OAuth completed, stealing input from Bubbletea's raw terminal handler.
Replaced with raw os.Stdin.Read + authDone channel to stop cleanly.

* Fix stored credential sessions: do fresh OAuth2 for Web API token on each launch

The spclient's login5 token gets 429'd on Web API. On second launch
(stored credentials), we were trying refreshWebAPIToken which tested the
spclient token — always fails. Now does a quick OAuth2 PKCE flow on each
launch to get a fresh Web API token. Falls back to full interactive auth
if the token flow fails.

* Fix nil CountryCode panic when playing Spotify tracks

go-librespot's Player.getUnrestrictedTrack dereferences CountryCode
to check media restrictions. We never set it, causing a nil pointer
panic. Default to 'US'.

* Fix pause/resume restarting track; expand scopes; dynamic country code

- Set Stream=false on Spotify tracks — they're seekable, not live streams.
  The TUI's togglePlayPause treats Stream=true as live (stop+replay on
  resume), which was restarting songs from the beginning.
- Expanded OAuth2 scopes to full standard Web API set (playlist modify,
  library modify, playback state, recently played, top tracks, follows).
  Internal Spotify scopes that cause 'Illegal scope' are documented and
  excluded.
- Fetch user's country from /v1/me for accurate media restriction checks
  instead of hardcoding 'US'.

* Dynamic playlist height, persist OAuth2 refresh token

- Playlist view now fills available terminal height instead of being
  capped at 5 items. Recalculates on window resize. 'x' key toggles
  between compact (5) and full height. Minimum 3 items.
- Persist OAuth2 refresh token in spotify_credentials.json. On subsequent
  launches, silently refresh the Web API token without opening a browser.
  Falls back to interactive auth if refresh fails.
- Extract spotifyOAuthConfig() helper shared by doWebAPIAuth and
  silentTokenRefresh.

* dynamic height

* Fix UTF-8 encoding in OAuth callback page (✅ rendered as ✅)

Add <meta charset="utf-8"> to both callback HTML pages so the
checkmark emoji renders correctly in all browsers.

* Fix dynamic playlist height: account for frame padding + 2-line controls

Previous calculation used 12 + vis.Rows for fixed UI lines, but missed:
- Frame padding (2 lines from Padding(1,3))
- Controls render as 2 lines (VOL + EQ), not 1
Corrected to 17 + vis.Rows. Playlist no longer overflows the terminal.

* Fix playlist scroll: account for album separator lines in visible window

Album separators (── Album Name (Year) ──) are rendered between tracks
from different albums, taking extra lines. adjustScroll only counted
track items, so the cursor would move past the visible area before
scrolling kicked in. Now counts rendered lines (tracks + separators)
to determine when to scroll.

* Dynamic frame width: use full terminal width instead of fixed 80 chars

The frame and panelWidth were hardcoded to 80/74 chars, causing the
help bar to wrap to the next line on wider terminals. Now dynamically
sizes to the terminal width on WindowSizeMsg. Album separators, seek
bar, controls, and visualizer all scale to the available width.

* Fix scroll with mixed album separators: count actual rendered lines

Previous fix assumed every track had an album separator. Now uses
renderedLineCount() helper that accurately counts lines (tracks +
separators) for any range. adjustScroll walks backward from cursor
to find the right scroll offset when mixed separator/no-separator
tracks are present.

* Measure actual UI height instead of counting lines manually

The manual fixed-line count (17 + vis.Rows) was wrong — missed various
multi-line renders, frame padding, etc. Now renders all non-playlist
sections into a probe frame and uses lipgloss.Height() to measure the
actual pixel height. Guarantees plVisible matches the real available
space regardless of theme, controls layout, or status lines.

* Fix x key toggle: properly toggle between compact (5) and dynamic max

Previous code had dynMax == plVisible always (broken comparison).
Now toggles between 5 and full dynamic height using same probe
measurement as WindowSizeMsg.

* Add 1-line buffer to dynamic playlist height

Probe measurement was consistently 1-2 lines optimistic, causing
the bottom of the playlist to clip. Add a 1-line safety margin.

* Bump playlist height buffer to -2 lines

* Fix playlist height: plVisible is rendered lines, not track count

Root cause: renderPlaylist() did `visible := min(m.plVisible, len(tracks))`
which conflated rendered line count with track count. When plVisible=15 and
len(tracks)=8, visible was capped to 8 — but 8 tracks from different albums
render as 16 lines (8 separators + 8 tracks), overflowing.

Fix:
- Remove min(plVisible, len(tracks)) — plVisible is the rendered line budget
- Remove scroll clamping that used track count as line count
- Add budget check before separator+track pair: if only 1 line left but
  need 2 (separator + track), break instead of overflowing
- Remove -2 magic buffer from probe measurement — it's now exact
- plVisible = height - lipgloss.Height(probeFrame) + 1 (no fudge factors)

* Address PR review feedback from Gemini Code Assist

- Use strconv.Atoi instead of fmt.Sscanf for year parsing
- Add comment explaining Stream: false (pause/resume requires it)
- Handle io.ReadAll error on non-OK HTTP response bodies
- Log saveCreds errors instead of silently discarding
- Log http.Serve errors (filter net.ErrClosed for clean shutdown)
- Log country code fetch errors with stderr fallback
- Add TODO for configurable bitrate

* Add Spotify provider using go-librespot for native playback

Integrates Spotify streaming directly into cliamp's Beep audio pipeline,
giving full EQ, visualizer, and gapless playback support for Spotify
Premium accounts.

Architecture:
- external/spotify/streamer.go: Bridges go-librespot AudioSource
  (interleaved float32) to beep.StreamSeekCloser ([2]float64 pairs)
- external/spotify/session.go: OAuth2 authentication with credential
  persistence in ~/.config/cliamp/spotify_credentials.json
- external/spotify/provider.go: playlist.Provider using Spotify Web API
  for playlists/tracks, go-librespot player.NewStream for audio
- player/player.go: StreamerFactory hook for custom URI schemes
- player/pipeline.go: spotify:track:xxx URI detection and routing
- config/config.go: [spotify] section with enabled flag

Enable with `enabled = true` under `[spotify]` in config.toml.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Improve Spotify OAuth flow: print auth URL, open browser, allow Enter to retry

* Add retry with backoff on 429 rate limits, improve auth UX messaging

* Add stderr logging for 429 retries, increase timeout to 5min, bump max retries to 8

* Bypass spclient for Web API calls — use direct HTTP with Bearer token to avoid Client-Token header rate limits

* Own OAuth2 flow: capture Web API token, auto-close browser tab, use SpotifyTokenCredentials

The spclient's internal login5 token gets aggressively rate-limited (429 with
Retry-After: 86400) when used against api.spotify.com. Root cause: it's not
a standard Web API token.

Fix: Run our own OAuth2 flow with the same client_id/scopes, capture the
access_token for Web API calls, and pass it to go-librespot via
SpotifyTokenCredentials for session auth.

Also: custom callback server serves HTML with window.close() script,
fixing the browser tab not auto-closing after auth.

* Use registered Spotify Developer app client_id for Web API

The go-librespot internal client_id (65b708073fc0480ea92a077233ca87bd) is
shared across all librespot users and gets aggressively rate-limited for
Web API calls (Retry-After: 86400).

Now requires a registered Spotify Developer app:
- client_id in config.toml [spotify] section
- Fixed callback port 19872 for redirect URI
- OAuth2 PKCE flow (no client_secret needed)

Config:
  [spotify]
  enabled = true
  client_id = "your-client-id"

Spotify Developer app redirect URI:
  http://127.0.0.1:19872/login

* Remove internal Spotify scopes that cause 'Illegal scope' with registered apps

* Minimal OAuth2 scopes — only playlist-read + streaming + user-read-private

* Fix TUI freeze: stop stdin reader goroutine after auth completes

The bufio.Scanner goroutine for Enter-to-retry kept reading stdin after
OAuth completed, stealing input from Bubbletea's raw terminal handler.
Replaced with raw os.Stdin.Read + authDone channel to stop cleanly.

* Fix stored credential sessions: do fresh OAuth2 for Web API token on each launch

The spclient's login5 token gets 429'd on Web API. On second launch
(stored credentials), we were trying refreshWebAPIToken which tested the
spclient token — always fails. Now does a quick OAuth2 PKCE flow on each
launch to get a fresh Web API token. Falls back to full interactive auth
if the token flow fails.

* Fix nil CountryCode panic when playing Spotify tracks

go-librespot's Player.getUnrestrictedTrack dereferences CountryCode
to check media restrictions. We never set it, causing a nil pointer
panic. Default to 'US'.

* Fix pause/resume restarting track; expand scopes; dynamic country code

- Set Stream=false on Spotify tracks — they're seekable, not live streams.
  The TUI's togglePlayPause treats Stream=true as live (stop+replay on
  resume), which was restarting songs from the beginning.
- Expanded OAuth2 scopes to full standard Web API set (playlist modify,
  library modify, playback state, recently played, top tracks, follows).
  Internal Spotify scopes that cause 'Illegal scope' are documented and
  excluded.
- Fetch user's country from /v1/me for accurate media restriction checks
  instead of hardcoding 'US'.

* Dynamic playlist height, persist OAuth2 refresh token

- Playlist view now fills available terminal height instead of being
  capped at 5 items. Recalculates on window resize. 'x' key toggles
  between compact (5) and full height. Minimum 3 items.
- Persist OAuth2 refresh token in spotify_credentials.json. On subsequent
  launches, silently refresh the Web API token without opening a browser.
  Falls back to interactive auth if refresh fails.
- Extract spotifyOAuthConfig() helper shared by doWebAPIAuth and
  silentTokenRefresh.

* dynamic height

* Fix UTF-8 encoding in OAuth callback page (✅ rendered as ✅)

Add <meta charset="utf-8"> to both callback HTML pages so the
checkmark emoji renders correctly in all browsers.

* Fix dynamic playlist height: account for frame padding + 2-line controls

Previous calculation used 12 + vis.Rows for fixed UI lines, but missed:
- Frame padding (2 lines from Padding(1,3))
- Controls render as 2 lines (VOL + EQ), not 1
Corrected to 17 + vis.Rows. Playlist no longer overflows the terminal.

* Fix playlist scroll: account for album separator lines in visible window

Album separators (── Album Name (Year) ──) are rendered between tracks
from different albums, taking extra lines. adjustScroll only counted
track items, so the cursor would move past the visible area before
scrolling kicked in. Now counts rendered lines (tracks + separators)
to determine when to scroll.

* Dynamic frame width: use full terminal width instead of fixed 80 chars

The frame and panelWidth were hardcoded to 80/74 chars, causing the
help bar to wrap to the next line on wider terminals. Now dynamically
sizes to the terminal width on WindowSizeMsg. Album separators, seek
bar, controls, and visualizer all scale to the available width.

* Fix scroll with mixed album separators: count actual rendered lines

Previous fix assumed every track had an album separator. Now uses
renderedLineCount() helper that accurately counts lines (tracks +
separators) for any range. adjustScroll walks backward from cursor
to find the right scroll offset when mixed separator/no-separator
tracks are present.

* Measure actual UI height instead of counting lines manually

The manual fixed-line count (17 + vis.Rows) was wrong — missed various
multi-line renders, frame padding, etc. Now renders all non-playlist
sections into a probe frame and uses lipgloss.Height() to measure the
actual pixel height. Guarantees plVisible matches the real available
space regardless of theme, controls layout, or status lines.

* Fix x key toggle: properly toggle between compact (5) and dynamic max

Previous code had dynMax == plVisible always (broken comparison).
Now toggles between 5 and full dynamic height using same probe
measurement as WindowSizeMsg.

* Add 1-line buffer to dynamic playlist height

Probe measurement was consistently 1-2 lines optimistic, causing
the bottom of the playlist to clip. Add a 1-line safety margin.

* Bump playlist height buffer to -2 lines

* Fix playlist height: plVisible is rendered lines, not track count

Root cause: renderPlaylist() did `visible := min(m.plVisible, len(tracks))`
which conflated rendered line count with track count. When plVisible=15 and
len(tracks)=8, visible was capped to 8 — but 8 tracks from different albums
render as 16 lines (8 separators + 8 tracks), overflowing.

Fix:
- Remove min(plVisible, len(tracks)) — plVisible is the rendered line budget
- Remove scroll clamping that used track count as line count
- Add budget check before separator+track pair: if only 1 line left but
  need 2 (separator + track), break instead of overflowing
- Remove -2 magic buffer from probe measurement — it's now exact
- plVisible = height - lipgloss.Height(probeFrame) + 1 (no fudge factors)

* Address PR review feedback from Gemini Code Assist

- Use strconv.Atoi instead of fmt.Sscanf for year parsing
- Add comment explaining Stream: false (pause/resume requires it)
- Handle io.ReadAll error on non-OK HTTP response bodies
- Log saveCreds errors instead of silently discarding
- Log http.Serve errors (filter net.ErrClosed for clean shutdown)
- Log country code fetch errors with stderr fallback
- Add TODO for configurable bitrate

* Add Spotify docs to README, update install instructions for fork

- Added Spotify section with setup instructions (Developer app, config, OAuth)
- Updated install methods: go install, pre-built binaries, build from source
- Removed Homebrew tap update from release workflow (fork-specific)

* Auto-refresh Web API token: replace static string with TokenSource

The webAPIToken was set once during init and never refreshed. Spotify
access tokens expire after 1 hour, causing all Web API calls to fail.

Now uses oauth2.TokenSource which automatically refreshes the token
using the stored refresh token when it expires. No more browser
re-authentication after 1 hour — tokens refresh transparently in
the background.

* Show shuffle (z) and repeat (r) keys in bottom help bar

They were only visible in the Ctrl+K keymap overlay but not in the
always-visible bottom controls line.

* Persist shuffle/repeat state to config on toggle

Saves shuffle and repeat preferences to config.toml when toggled via
z/r keys, so they survive restarts across all providers.

* Move shuffle/repeat to top of Ctrl+K keymap overlay

They were at positions 23-24, below the 12-line visible window.
Moved them right after volume controls so they're visible without
scrolling.

* Responsive help bar: drop low-priority hints when terminal is narrow

Each help hint has a priority. When the combined width exceeds the
terminal width, the lowest-priority hints are dropped first:

  100 Spc(⏯)  95 Q(Quit)  90 <>(Trk)  80 +-(Vol)  70 ←→(Seek)
   60 Ctrl+K(Keys)  50 Tab(Focus)  40 /(Search)  30 a(Queue)
   20 z(Shfl)  20 r(Rpt)

On a narrow terminal you still see play/pause, track nav, vol, and quit.
On wide terminals everything shows.

* Handle config.Save errors for shuffle/repeat

Show a status message via saveMsg when persisting fails instead of
silently ignoring the error.

Addresses Gemini Code Assist review feedback.

* fix: don't auto-play when selecting Spotify playlist during playback

When a track is already playing, selecting a new playlist now loads the
tracks without stopping playback or auto-playing. Users can browse
playlists freely while listening.

Auto-play only triggers when nothing is currently playing.

* fix: auto re-auth on Spotify AES key / session errors

When go-librespot fails to retrieve an audio key (e.g. code 2 from
Spotify's AP server due to expired/revoked session), the provider now:

1. Detects auth-related errors (KeyProviderError, DeadlineExceeded)
2. Tears down the dead session and clears stored credentials
3. Triggers a fresh OAuth2 interactive flow automatically
4. Retries the stream once with the new session

This prevents users from getting stuck in an error loop — no manual
credential deletion or CLI commands needed.

Adds Session.Reconnect() for hot-swapping the session/player, and
deleteCreds() to clear stale stored credentials.

* fix: avoid nil window in Reconnect (address review)

Create the new session before tearing down the old one so s.sess and
s.player are never nil while the mutex is unlocked. Old session/player
are closed after the atomic swap completes.

Addresses Gemini review comment on PR gopxl#4.

* auto commit

* auto commit

* feat(spotify): fallback client ID pool for zero-config setup

Users no longer need to register a Spotify Developer app. When no
client_id is configured, a random ID is selected from a built-in
fallback pool to spread rate-limit load across apps.

- Add external/spotify/fallback.go with FallbackClientID()
- Add SpotifyConfig.ResolveClientID() with user > fallback priority
- SpotifyConfig.IsSet() now returns true when enabled (even without client_id)
- Update config.toml.example with Spotify section docs

* migrate: Spotify Feb 2026 API changes

Spotify deprecated several endpoints and renamed fields (effective Mar 9, 2026
for existing dev mode apps). See:
https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide

Changes:
- Playlist tracks endpoint: /playlists/{id}/tracks → /playlists/{id}/items
- Playlist response field: track → item (with backwards-compat fallback)
- Playlist metadata field: tracks.total → items.total (with fallback)
- Remove GET /v1/me call for country (field removed from API), default to US
- Remove user-read-email scope (email no longer returned by GET /me)
- Remove unused 'io' import from session.go
- Document removed fields in scope comments (popularity, available_markets,
  external_ids, country, followers, product)

* perf(spotify): reduce API calls with fields filtering and snapshot caching

Three optimizations to reduce rate limit pressure:

1. Use 'fields' parameter on both /me/playlists and /playlists/{id}/items
   to request only the fields we actually parse. Smaller payloads, lower
   API cost per call.

2. Cache playlist tracks by snapshot_id. When Playlists() is called, we
   store each playlist's snapshot_id. If the snapshot hasn't changed on
   the next Tracks() call, we return cached results without hitting the
   API at all.

3. Include snapshot_id in playlist list fields so cache invalidation is
   automatic — changed playlists get re-fetched, unchanged ones don't.

Ref: https://developer.spotify.com/documentation/web-api/concepts/rate-limits

* fix(spotify): skip session when no client ID is available

Don't attempt OAuth flow if both user config and fallback pool are
empty — prevents the broken authorize URL with empty client_id.

* auto commit

* chore: remove build/release changes — keep only Spotify API migration

Revert .github/workflows/release.yml and install.sh to upstream/main
and remove the added .goreleaser.yml so this branch contains only the
Spotify February 2026 API migration changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants