Skip to content

Latest commit

 

History

History
183 lines (143 loc) · 7.31 KB

File metadata and controls

183 lines (143 loc) · 7.31 KB

Implementation Design Document: vibe.lua

1. Project Overview

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).

2. Dependencies

  • 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.

3. Directory Structure

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

4. Module Specifications

4.1. Config Module (lua/vibe/config.lua)

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
}
}

4.2. Spotify Module (lua/vibe/spotify.lua)

Responsibility: Fetch current state without blocking.
Endpoint: GET https://api.spotify.com/v1/me/player/currently-playing
Key Function: get_current_state(callback)

  1. Use plenary.curl.get.
  2. Headers: Authorization: Bearer <token>
  3. 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.

4.3. Lyrics Module (lua/vibe/lyrics.lua)

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)

  1. Use plenary.curl.get.
  2. 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:

  1. Iterate lines.
  2. Regex match ^%[(%d+):(%d+)%.(%d+)%](.*) (Minutes, Seconds, Hundredths).
  3. Convert to total milliseconds.
  4. Store in an ordered table.

4.4. UI Module (lua/vibe/ui.lua)

Responsibility: Draw the window and scroll the text.
Key Function: create_window()

  1. vim.api.nvim_create_buf(false, true)
  2. Calculate position: col = vim.o.columns.
  3. vim.api.nvim_open_win with focusable = false.

Key Function: update_view(lyrics_table, current_time_ms)

  1. Find the index of the lyric line where lyric.time_ms <= current_time_ms (and the next one is >).
  2. Clear buffer.
  3. Set lines (centered around the current index).
  4. Apply ns_id (namespace) highlighting.
    • Current line: config.styles.highlight
    • Other lines: config.styles.dim
  5. Auto-Scroll Logic: Ensure the "current line" is always in the middle of the window height.

4.5. Poller / Main Logic (lua/vibe/poller.lua)

Responsibility: The heartbeat.
Architecture:
Two separate timers using vim.uv.new_timer().

  1. 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.
  2. 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).

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.

5. Implementation Steps for AI Assistant

  1. Step 1: Create config.lua and ui.lua. Implement the window creation and closing logic. Test by manually injecting text lines.
  2. Step 2: Create lyrics.lua. Write the LRC parser. Test with a hardcoded string.
  3. Step 3: Create spotify.lua using plenary.curl. Ensure it can read the token from config and return a JSON object.
  4. 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.
  5. Step 5: Create init.lua. Expose setup() and user commands :VibeToggle, :VibeStart, :VibeStop.

6. Edge Case Handling

  1. Instrumental Songs: lrclib returns instrumental: true. The UI should display "♪ Instrumental ♪".
  2. No Lyrics Found: UI displays "No lyrics found".
  3. Spotify Paused: Detect is_playing: false from Spotify response. Pause the Render Timer to save CPU resources. Resume when is_playing becomes true.
  4. 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.

7. Sample Code Snippet: The Interpolation Logic (Poller)

-- 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