Name: vibe.lua
Purpose: A Neovim plugin to display synchronized lyrics for the currently playing Spotify track in a non-blocking, floating window.
Core Stack: Lua (Neovim runtime), plenary.nvim (for async curl), vim.uv (LibUV for timers).
- Required: nvim-lua/plenary.nvim (Standard library for async HTTP requests in Neovim).
- External: curl (System dependency).
- Spotify Auth: The user must provide a mechanism to get a Spotify Access Token or use a local player integration (e.g., spotify-tui config or raw OAuth token in setup). For this implementation, we assume the user provides a valid Bearer token in the setup or a command to fetch it.
lua/
vibe/
init.lua # Public setup() and command definitions
config.lua # Default configuration and validation
spotify.lua # Async Spotify API interaction
lyrics.lua # LRCLIB API interaction and Parsing logic
ui.lua # Window and Buffer management (UI Rendering)
poller.lua # The main event loop / heartbeat manager
Defines the default behavior.
defaults = {
enabled = true,
update_interval = 100, -- UI refresh rate in ms
spotify_api_poll_interval = 5000, -- How often to check Spotify for song change/drift (ms)
auth_token = "", -- Spotify Web API Token
window = {
width = 40,
height = 10,
relative = "editor",
anchor = "NE", -- Top Right
row = 1,
col = 1, -- Will calculate 'vim.o.columns' - 1
border = "rounded",
style = "minimal",
},
styles = {
highlight = "String", -- Highlight group for the active line
dim = "Comment" -- Highlight group for inactive lines
}
}
Responsibility: Fetch current state without blocking.
Endpoint: GET https://api.spotify.com/v1/me/player/currently-playing
Key Function: get_current_state(callback)
- Use plenary.curl.get.
- Headers: Authorization: Bearer <token>
- Parse JSON:
- item.name (Track Name)
- item.artists[0].name (Artist Name)
- item.duration_ms (Total Length)
- progress_ms (Current position)
- is_playing (Boolean)
- item.id (Spotify ID - useful for caching lyrics)
Optimization:
The specific timestamp (progress_ms) returned by the API is the time when the request was processed. We must capture vim.uv.hrtime() immediately upon receiving the response to calculate "drift" for the local timer.
Responsibility: Fetch from LRCLib and parse.
Endpoint: GET https://lrclib.net/api/get
Query Params: artist_name, track_name, album_name, duration (optional but helps accuracy).
Key Function: fetch_lyrics(artist, track, duration, callback)
- Use plenary.curl.get.
- Handle 404 (Instrumental or not found).
Key Function: parse_lrc(lrc_string)
Input:
[00:12.00]Line one
[00:15.30]Line two
Output (Table):
{
{ time_ms = 12000, text = "Line one" },
{ time_ms = 15300, text = "Line two" },
...
}
Algorithm:
- Iterate lines.
- Regex match ^%[(%d+):(%d+)%.(%d+)%](.*) (Minutes, Seconds, Hundredths).
- Convert to total milliseconds.
- Store in an ordered table.
Responsibility: Draw the window and scroll the text.
Key Function: create_window()
- vim.api.nvim_create_buf(false, true)
- Calculate position: col = vim.o.columns.
- vim.api.nvim_open_win with focusable = false.
Key Function: update_view(lyrics_table, current_time_ms)
- Find the index of the lyric line where lyric.time_ms <= current_time_ms (and the next one is >).
- Clear buffer.
- Set lines (centered around the current index).
- Apply ns_id (namespace) highlighting.
- Current line: config.styles.highlight
- Other lines: config.styles.dim
- Auto-Scroll Logic: Ensure the "current line" is always in the middle of the window height.
Responsibility: The heartbeat.
Architecture:
Two separate timers using vim.uv.new_timer().
- Network Timer (Slow): Runs every config.spotify_api_poll_interval (e.g., 5s).
- Calls spotify.get_current_state.
- If Song changed -> Call lyrics.fetch_lyrics.
- Updates global state: current_song_start_system_time, song_progress_at_fetch.
- Render Timer (Fast): Runs every config.update_interval (e.g., 100ms).
- Logic:
- now = vim.uv.hrtime()
- elapsed_since_fetch = now - last_fetch_time
- estimated_progress = spotify_progress_ms + elapsed_since_fetch
- Calls ui.update_view(lyrics, estimated_progress).
- Logic:
Why this approach?
If we polled Spotify every 100ms, we would hit API rate limits immediately. By interpolating time locally, we get smooth 60fps-style scrolling while only hitting the network every few seconds.
- Step 1: Create config.lua and ui.lua. Implement the window creation and closing logic. Test by manually injecting text lines.
- Step 2: Create lyrics.lua. Write the LRC parser. Test with a hardcoded string.
- Step 3: Create spotify.lua using plenary.curl. Ensure it can read the token from config and return a JSON object.
- Step 4: Create poller.lua. Implement the "Dual Timer" strategy.
- Connect the Spotify data to the Lyrics fetcher.
- Connect the interpolated time to the UI renderer.
- Step 5: Create init.lua. Expose setup() and user commands :VibeToggle, :VibeStart, :VibeStop.
- Instrumental Songs: lrclib returns instrumental: true. The UI should display "♪ Instrumental ♪".
- No Lyrics Found: UI displays "No lyrics found".
- Spotify Paused: Detect is_playing: false from Spotify response. Pause the Render Timer to save CPU resources. Resume when is_playing becomes true.
- Buffer Closed: If the user closes the float manually (:q), the code must detect nvim_buf_is_valid returns false and recreate the window or stop the timer.
-- This logic is critical for the "100ms sync" requirement
-- without spamming the API.
local state = {
progress_at_last_fetch = 0,
time_of_last_fetch = 0, -- system time
is_playing = false
}
-- Called by Network Timer (every 5s)
local function on_spotify_response(data)
state.progress_at_last_fetch = data.progress_ms
state.time_of_last_fetch = vim.uv.hrtime() / 1000000 -- convert ns to ms
state.is_playing = data.is_playing
end
-- Called by Render Timer (every 100ms)
local function on_render_tick()
if not state.is_playing then return end
local now \= vim.uv.hrtime() / 1000000
local delta \= now \- state.time\_of\_last\_fetch
local current\_estimated\_ms \= state.progress\_at\_last\_fetch \+ delta
ui.render(current\_estimated\_ms)
end