Skip to main content

How I Built My First Neovim Plugin spotify.nvim

· 10 min read
Om Athalye
Senior Front End Engineer @ Wealthy

Having recently ditched VSCode for NeoVim, I was very excited customizing my editor experience with different themes, treesitter, lsp, utility plugins and the works. The possibilities for customization were endless, and the code for each of them was open source and available on github. When I started building spotify.nvim, my goal was simple: Learn about plugin development in NeoVim using Lua, and control Spotify directly from Neovim. What began as a small experiment turned into an insightful journey into Lua scripting, Neovim's API, and integrating external bash scripts for Spotify control.

In this blog, I’ll walk you through the process: from the initial idea to overcoming challenges, debugging, and refining the plugin.


The Idea Behind spotify.nvim​

I spend most of my time working in Neovim, and switching to Spotify each time to interact with it felt inefficient. You might ask why not just use the F7-F12 Keys for controlling music playback, however there is no finegrained control. If I have to reduce volume by 50% I have to press F11 multiple times. If I have to go back by 3 songs, I have to press F7 multiple times again. Besides, these keys are far from the homerow and difficult to access. My goal was to make Spotify controls a seamless part of my workflow, accessible via simple keybindings within Neovim and to make them work with a count prefix.

Instead of relying on pre-existing tools like playerctl or using Spotify web app API key, I decided to use my existing bash scripts to interact with Spotify on macOS. These scripts used AppleScript to interact with Spotify and although there is limited functionality like not being able to add a song to Liked playlist, or set Repeat mode to Repeat-Single, it provided enough functionality for my simple use case of being able to control the music player from within without having to leave NeoVim.


Step 1: Using Bash Scripts to Control Spotify​

Spotify on macOS can be controlled using AppleScript commands executed from the terminal via osascript. These commands were the foundation of the plugin's functionality. Before making the plugin, I had developed several scripts for myself for basic functionality like:

  • Play/Pause To toggle Spotify playback, I wrote the following bash script:
~/scripts/playpause.sh
#!/bin/bash
osascript -e 'tell application "Spotify" to playpause'
  • Next Track To skip to the next track:
~/scripts/next-track.sh
#!/bin/bash
osascript -e 'tell application "Spotify" to next track'
  • Volume Control For incrementing volume by 10: Note that I had to use min function to cap volume at 100.
~/scripts/volume-up.sh
#!/bin/bash

osascript <<EOF
on min(x, y)
if x ≤ y then
return x
end if
return y
end min

tell application "Spotify" to set sound volume to (my min(( sound volume + 11 ), 100))
EOF

The Issue with these scripts was lacking the ability to provide a count, which was possible in NeoVim


Step 2: Setting Up the Plugin Structure​

I am using Lazy.nvim as my package manager, which provides an easy way to provide paths for local plugins you are developing using the dir parameter. There's also the option to pass dev = true to toggle between using local development version of a plugin vs it's online github version.

~/.config/nvim/init.lua
  -- Adding my local Spotify Plugin during development so I can test it within my NeoVim Client
{
dir = vim.fn.expand '~/.config/nvim/lua/plugins/spotify.nvim', -- Expanded local path
config = function()
require('spotify').setup {
keymaps = {
-- You can specify keymaps here if you want to override the defaults.
{ name = 'play_pause', mode = 'n', '<leader>0', ':SpotifyPlayPause<CR>', desc = 'Play/Pause Spotify' }, -- Add more custom keymaps if needed
},
}
end,
},

The plugin's structure revolves around Lua modules for extensibility. The main entry point, init.lua, includes:

  • Keymap Configuration
  • Command Registration
  • Setup Function for User Customization

Default Keymaps​

Default Keymaps were designed to cover basic Spotify functionality such that they do not conflict with my existing Plugins:

local default_keymaps = {
{ name = 'play_pause', mode = 'n', '<leader>pp', ':SpotifyPlayPause<CR>', desc = 'Play/Pause Spotify' },
{ name = 'next', mode = 'n', '<leader>pn', ':<C-U>SpotifyNext<CR>', desc = 'Next Spotify Track' },
{ name = 'previous', mode = 'n', '<leader>pb', ':<C-U>SpotifyPrev<CR>', desc = 'Previous Spotify Track' },
{ name = 'volume_up', mode = 'n', '<leader>+', ':<C-U>SpotifyVolUp<CR>', desc = 'Increase Spotify Volume' },
{ name = 'volume_down', mode = 'n', '<leader>-', ':<C-U>SpotifyVolDown<CR>', desc = 'Decrease Spotify Volume' },
{ name = 'shuffle_toggle', mode = 'n', '<leader>ps', ':SpotifyToggleShuffle<CR>', desc = 'Toggle Spotify Shuffle' },
{ name = 'repeat_toggle', mode = 'n', '<leader>pr', ':SpotifyToggleRepeat<CR>', desc = 'Toggle Spotify Repeat' },
{ name = 'sound_volume', mode = 'n', '<leader>pv', ':SpotifyVolume<CR>', desc = 'Show Spotify Volume' },
{ name = 'info', mode = 'n', '<leader>pi', ':SpotifyInfo<CR>', desc = 'Show Spotify Info' },
{ name = 'mute_toggle', mode = 'n', '<leader>pm', ':SpotifyToggleMute<CR>', desc = 'Toggle Spotify Mute' },
}

Note that mode = 'n' is used to tell NeoVim to apply this keymap in normal mode. The description parameter helps plugins like which-key.nvim to display possible key combinations and the description of what they do as soon as I press the <leader key. The third column is the actual command that gets executed. : puts vim in command mode, SpotifyPlayPause is the name of the Command I've defined for my plugin, while <CR> character stands for Carriage Return similar to pressing Enter and running a command.

Setup Function​

To allow user customization, the setup function merges default keymaps with user-specified configurations:

function M.setup(user_opts)
local opts = vim.tbl_deep_extend('force', { keymaps = default_keymaps }, user_opts or {})
apply_keymaps(opts.keymaps)
end

Command Registration​

I registered Neovim commands that invoke the bash scripts which I've defined in another file called commands.lua:

  local spotify_commands = require 'spotify.commands'
-- Register commands
vim.api.nvim_create_user_command('SpotifyPlayPause', spotify_commands.play_pause, {})
vim.api.nvim_create_user_command('SpotifyNext', function()
spotify_commands.next(vim.v.count1)
end, {})
vim.api.nvim_create_user_command('SpotifyPrev', function()
spotify_commands.previous(vim.v.count1)
end, {})
vim.api.nvim_create_user_command('SpotifyVolUp', function()
spotify_commands.volume_up(vim.v.count1)
end, {})
vim.api.nvim_create_user_command('SpotifyVolDown', function()
spotify_commands.volume_down(vim.v.count1)
end, {})
vim.api.nvim_create_user_command('SpotifyToggleMute', spotify_commands.mute_toggle, {})
vim.api.nvim_create_user_command('SpotifyToggleShuffle', spotify_commands.shuffle_toggle, {})
vim.api.nvim_create_user_command('SpotifyToggleRepeat', spotify_commands.repeat_toggle, {})
vim.api.nvim_create_user_command('SpotifyVolume', spotify_commands.sound_volume, {})
vim.api.nvim_create_user_command('SpotifyInfo', function()
spotify_commands.info(true)
end, {})

Step 3: Overcoming Challenges​

1. Managing Errors​

E481: No Range allowed Prepending a vim command with a number will be interpreted as providing a range to the command. However, we want to be able to use this vim.v.count1 to run a command multiple times for which we should be able to pass it in as an argument.

Solution: I prepended the commands where I should be able to provide a count with <C-U> that tells NeoVim not to treat the prefixed count as a range. Without this we would not be able to pass the command vim.v.count1.

Another common issue is Spotify might not be running when a command is run.

Solution: To not cause any issues, I added the clause if Spotify is running in some of my scripts to ensure that there are no silent failures during testing.

2. Allowing Keymap Customization​

To make my plugin be customizable by others, I need to provide them the option to setup their own custom Keymaps that override mine.

Solution: This is achievable using the options table a user can pass to my setup function while installing my plugin.

  {
'spinalshock/spotify.nvim',
-- event = 'VeryLazy',
keys = {
{ mode = 'n', '<leader>pp', ':SpotifyPlayPause<cr>', desc = 'Play/Pause Spotify' },
},
dependencies = {
{ 'folke/noice.nvim' }, -- optional
{ 'folke/which-key.nvim' }, -- optional
{ 'nvim-lualine/lualine.nvim' }, -- optional
},
opts = { -- Default configuration options (optional for user overrides)
keymaps = {
-- Example: Default keymaps can be replaced by user-defined keymaps here.
-- { name = 'play_pause', mode = 'n', '<leader>0', ':SpotifyPlayPause<cr>', desc = 'Play/Pause Spotify' },
-- { name = 'next', mode = 'n', '<leader>9', ':<C-U>SpotifyNext<CR>', desc = 'Next Spotify Track' },
-- add more custom keymaps as needed.
},
},
config = function(_, opts)
require('spotify').setup(opts) -- pass user options to the plugin setup
end,
},

My setup function then accepts the users' options as a parameter and merges the default keymapping lua table with the passed options.


3: Updating Now Playing when song changes​

Whenever the song ends and the next one in queue plays, we don't want to manually query every time. Our plugin should check periodically and automatically update the user when the song changes.

Solution: Polling now-playing track periodically, and comparing it to the last played song. Only when the two do not match, we inform the user of song change. For this purpose, a simple string comparison suffices.

local last_song_info = nil -- To store the last known song info
-- Display Information
function M.info(force)
-- Run the now-playing script and capture the output
local song_info = run_script 'now-playing.sh'

-- Trim whitespace or newlines from the output
song_info = vim.trim(song_info)

if force then
notify_user(song_info, 'info')
return
end
-- If the song info has changed, notify the user
if song_info ~= last_song_info then
notify_user(song_info, 'info')
last_song_info = song_info -- Update the last known song info
end
end

-- Polling setup to check for song info changes every 3 seconds
local function start_polling()
vim.loop.new_timer():start(0, 3000, function()
-- Schedule the info check to run in the main thread
vim.schedule(function()
M.info() -- Check if the song info has changed and display it if necessary
end)
end)
end

-- Start polling when the plugin is set up
start_polling()

4: Displaying current song status in NeoVim's statusline​

Multiple statusline extensions exist where things like current buffer name, path, position inside buffer, fileType, Vim mode, etc can be configured to be shown. They also accept anything else you might want to pass to them. They efficiently poll to check if passed values have had any changes before updating. For this purpose I wrote the below function that simply returns the song playing as a string. The statusline plugin will periodically keep checking if the song has changed and this suits our purpose very well.

function M.statusline()
return last_song_info
end

5: Testing and Debugging​

Thorough testing involved:

  • Edge Cases: Ensuring commands didn't fail when Spotify was closed.
  • Volume Bounds: Preventing volume adjustments outside the range of 0–100.
  • Notification Messages: Using vim.notify to inform users of command results:
vim.notify('Spotify command executed successfully!', vim.log.levels.INFO)

Debugging was a mix of Lua print statements and terminal outputs, which helped ensure smooth execution.


Final Plugin Structure​

~/.config/nvim/lua/plugins/spotify.nvim/
spotify.nvim/
├── lua/
│ ├── spotify/
│ │ ├── commands.lua
│ │ ├── init.lua
│ │ └── scripts/
│ │ ├── now-playing.sh
│ │ ├── playpause.sh
│ │ ├── volume-up.sh
│ │ └── next-track.sh
└── README.md

Lessons Learned​

  1. Mastering Lua: Writing the plugin improved my understanding of Neovim’s Lua API.
  2. User-Centric Design: Adding error messages and customizable keymaps made the plugin more accessible.
  3. Iterative Refinement: Incremental improvements made the code more robust and user-friendly.

Final Thoughts​

Building spotify.nvim was a rewarding journey that taught me the power of integrating external tools with Neovim. By combining bash scripts, Lua, and Neovim APIs, I created a plugin that seamlessly controls Spotify from the editor.

If you’re considering building your first Neovim plugin, I encourage you to start small, embrace the learning curve, and enjoy the process. It’s a journey worth taking!