DEV Community

Cover image for I Built a Chrome Extension to Bypass Spotify's Mini-Player Paywall (Because I'm a Pirate πŸ΄β€β˜ οΈ)
Arnab Datta
Arnab Datta

Posted on

I Built a Chrome Extension to Bypass Spotify's Mini-Player Paywall (Because I'm a Pirate πŸ΄β€β˜ οΈ)

I love listening to music while coding.

Not background noise. Not lo-fi beats. Actual music β€” I want to see what's playing, change tracks without breaking flow, and keep the player somewhere visible so I can glance at it without switching tabs.

Spotify Web has a mini-player. You probably know this. What you might also know is that it's locked behind Premium. Free tier users get the full-page player or nothing. You can't make it small. You can't tuck it into a corner. Spotify decided that's a Premium feature.

I disagreed.

So I built my own.


The Problem (In Case You've Never Hit This)

Here's the workflow I wanted:

  • Spotify Web open in one tab, playing music
  • A tiny floating player sitting in the corner of my screen
  • Full controls β€” play, pause, skip, seek, volume β€” without switching tabs
  • A Picture-in-Picture window I can drag to another monitor while I code

Spotify's answer: pay for Premium.

My answer: build a Chrome Extension that injects its own mini-player directly onto the page.

Will I submit it to the Chrome Web Store? Probably not β€” it's kind of piracy and I don't think they'd approve it. But who cares. I'm a pirate. πŸ΄β€β˜ οΈ


What I Built

Spotify Float is a Chrome Extension (Manifest V3) that:

  • Injects a floating, draggable, resizable mini-player into open.spotify.com
  • Supports three modes: Full (art + info + controls), Compact, and Mini pill
  • Has a real Document Picture-in-Picture window β€” a separate always-on-top OS window showing the album art with controls on hover
  • Works entirely on the free tier β€” no Spotify API, no login, no data collection

Here's what it looks like:

Spotify Float Mini Player

And How it works:


How It Actually Works

The interesting part: this extension doesn't use the Spotify API at all.

Spotify's Web Player is a React app. All the playback state lives in the DOM β€” track titles, artist names, play state, progress β€” all exposed through data-testid attributes and aria-label values. So instead of going through any API, I just... read the DOM directly and simulate clicks.

// Finding the play button β€” no API needed
var SELECTORS = {
  playPauseButton: [
    '[data-testid="control-button-playpause"]',
    'button[aria-label="Play"]',
    'button[aria-label="Pause"]',
    // ...more fallbacks
  ],
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Every control has a priority-ordered list of selectors. Primary is data-testid (most stable), with CSS class fallbacks for when Spotify updates their DOM.

The Sync Loop

Every 500ms (or 2000ms when paused), syncNow() reads the current state from the DOM and updates the floating player:

MutationObserver (DOM changes on the now-playing widget)
    ↓ debounce 200ms
    ↓
syncNow()
  β”œβ”€β”€ readText('trackTitle')
  β”œβ”€β”€ readText('artistName')
  β”œβ”€β”€ cachedResolve('albumArt')
  β”œβ”€β”€ calcProgress()  β€” 3-strategy fallback
  β”œβ”€β”€ playBtn aria-label β†’ play state
  β”œβ”€β”€ shuffleBtn aria-label β†’ shuffle state
  └── repeatBtn aria-label β†’ repeat mode
Enter fullscreen mode Exit fullscreen mode

The sync loop completely stops when the mini-player is hidden β€” no background polling, no CPU waste.

Seeking Without an API

Seeking was the trickiest part. Spotify's progress bar is a range input, but you can't just set .value β€” React controls it and ignores direct assignment. The trick is using the native property setter:

function handleSeek(pct) {
  var sl = resolveSelector(SELECTORS.seekSlider);
  if (sl) {
    var setter = Object.getOwnPropertyDescriptor(
      window.HTMLInputElement.prototype, 'value'
    ).set;
    setter.call(sl, pct * parseFloat(sl.max || '100'));
    sl.dispatchEvent(new Event('input', { bubbles: true }));
    sl.dispatchEvent(new Event('change', { bubbles: true }));
  }
}
Enter fullscreen mode Exit fullscreen mode

This bypasses React's synthetic event system and fires a real native event that Spotify's player actually listens to.

Shadow DOM β€” Fully Isolated

The floating player is built inside a Shadow DOM. This means Spotify's CSS can't bleed in and break the player's styles, and the player's styles can't accidentally affect Spotify's page. Complete isolation.

this.host = document.createElement('div');
this.host.id = 'spotify-float-host';
document.documentElement.appendChild(this.host);
this.shadow = this.host.attachShadow({ mode: 'open' });
// Everything inside is fully encapsulated
Enter fullscreen mode Exit fullscreen mode

The PiP Bug That Took Me A While

The Document Picture-in-Picture API is relatively new (window.documentPictureInPicture) and it does something unexpected: CSS :hover doesn't work inside a PiP window.

The PiP window is a separate OS-level window with its own document. When your mouse is inside it, the main page's document doesn't receive hover events. So all the CSS I had like:

#player:hover #ctrl { opacity: 1; }
#player:hover #pw   { opacity: 1; }
Enter fullscreen mode Exit fullscreen mode

...never fired. The controls were stuck invisible and unclickable. Forever.

The fix was to stop relying on CSS hover entirely and switch to JS events registered directly on the PiP window's document:

pipWindow.document.addEventListener('mouseenter', function () {
  if (pipPlayer) pipPlayer.classList.add('pip-hovered');
});
pipWindow.document.addEventListener('mouseleave', function () {
  if (pipPlayer) pipPlayer.classList.remove('pip-hovered');
});
Enter fullscreen mode Exit fullscreen mode

Then all the hover styles reference .pip-hovered instead of :hover. Simple fix once you know the root cause, but CSS :hover silently not working across window contexts is not an obvious thing to debug.


The Stack

No frameworks. No build tools. No npm packages at runtime.

  • Manifest V3 Chrome Extension
  • Vanilla JS β€” single bundled IIFE in content.js
  • Shadow DOM for style encapsulation
  • Document Picture-in-Picture API for the PiP window
  • chrome.storage.local for persisting position, size, mode
  • Node.js (built-in zlib only) for icon generation

The whole thing is five files that Chrome loads directly. No webpack, no transpilation, no dependencies.

One thing worth noting: Chrome MV3 content scripts cannot use ES module import/export without some manifest workarounds that introduce their own issues. So the entire UI, selector system, and logic are bundled into one self-contained IIFE. If you're building a Chrome Extension and hitting Uncaught SyntaxError: Cannot use import statement outside a module β€” that's why.


Features at a Glance

What How
Play / Pause / Skip / Shuffle / Repeat DOM click simulation with retry backoff
Seek Native range input setter + bubbling events
Volume Same native setter technique
Drag mousedown on handle β†’ mousemove on document, viewport-clamped
Resize SE corner handle, 200–500px Γ— 120–700px
PiP window.documentPictureInPicture.requestWindow()
Persistence chrome.storage.local β€” position, size, mode, visibility
Keyboard Space, Ctrl+β†’, Ctrl+←

Get It

The extension is open source on GitHub:

github.com/Arnab500th/Spotify-miniplayer-chrome-extension-By-pass-premium-pay-walls

Download the ZIP from the Releases page, unzip it, go to chrome://extensions, enable Developer Mode, click Load unpacked, select the folder. Done.


What's Next

  • Better selector recovery when Spotify does a major redesign
  • Maybe a volume keyboard shortcut
  • Possibly a lyrics overlay if I can figure out a non-API approach

And yes, I know the repo name is a bit on the nose. But it's accurate. πŸ΄β€β˜ οΈ


Not affiliated with Spotify AB. This is a personal project built for fun and learning. Use it responsibly.

Top comments (0)