soundcloud music player
hi!
i figured somebody else might find this useful, so i decided to write up a short (or not) guide on the music player my site uses.
so, to go ahead and get this out of the way, this music player uses soundcloud's embed widget (if that wasn't already obvious by the guide title and all). this won't work for other music platforms like spotify, youtube music, apple music, deezer, tidal, etc; those have their own implementations and quirks. i chose soundcloud because they allow for full track streaming on embed (unlike spotify, which does 30-second snippets).
html
the html for this is rather simple.
<div class="mini-player"
data-sc="ENTER_URL_HERE"> <!-- Can be either a SoundCloud track, playlist, or stream -->
<button class="mp-btn mp-prev">⏮</button>
<button class="mp-btn mp-play">▶</button>
<button class="mp-btn mp-next">⏭</button>
<div class="mp-title">
<span class="mp-text">—</span>
</div>
<button class="mp-btn mp-open">☰</button>
<!-- SoundCloud embed iFrame -->
<iframe class="mp-hidden" title="SoundCloud player" allow="autoplay"></iframe>
</div>
<!-- We call the actual SoundCloud Widget API here -->
<script src="https://w.soundcloud.com/player/api.js"></script>
all this does is set up the structure for the player, and gives you an easy-to-access place to drop the url so you're not scouring for it in the js functions.
css
the css for this player is entirely up to you. i'll paste my css section below, but you'll want to customize it to fit your own site.
/* ---------- MUSIC PLAYER ---------- */
/* Main Player */
.mini-player{
position: absolute;
inset: auto 0 0 0;
width: 100%;
box-sizing: border-box;
display: grid;
grid-auto-flow: column;
grid-template-columns: auto auto auto minmax(120px, 1fr) auto;
gap: 4px;
align-items: center;
padding: 4px 6px;
background: rgba(22,22,22,.55);
border: 2px solid var(--stroke);
border-left: 0;
border-right: 0;
border-bottom: 0;
color: var(--text);
font-size: 12px;
line-height: 1;
}
/* Buttons */
.mini-player .mp-btn{
all:unset; cursor:pointer;
padding:4px 6px;
border:1px solid var(--stroke);
border-radius:6px;
color:var(--text);
}
.mini-player .mp-title{
min-width: 0; /* This ensures that the title bar will shrink if necessary to avoid clipping */
overflow: hidden;
border: 1px solid var(--stroke);
border-radius: 6px;
padding: 4px 8px;
opacity: .95;
}
/* Marquee */
.mini-player .mp-text{
display:inline-block;
white-space:nowrap;
transform:translateX(0);
}
/*
There's a class here that prevents the title from scrolling
unless it's active (which is set via JS depending on the length
of the title)
*/
.mini-player.mp-scroll .mp-text{
animation: mp-marquee var(--mq, 10s) linear infinite;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce){
.mini-player.mp-scroll .mp-text{ animation:none; }
}
/* Marquee definition */
@keyframes mp-marquee{
0% { transform: translateX(0); }
100% { transform: translateX(calc(-100% + 1ch)); }
}
/* Make damn sure that the widget ain't popping up anywhere */
.mini-player .mp-hidden{
position:absolute; /* stays in one place */
width:0; height:0; border:0; padding:0; margin:0; /*hide it and ensure that it doesn't have any extraneous styles */
overflow:hidden; /* really hide it */
opacity:0; pointer-events:none;
}
/* ---------- END MUSIC PLAYER ---------- */
i've annotated this to highlight things i think are important, but as i said before, the style is something you'll have to work with to fit your site.
note: the soundcloud iframe must exist for the player to work. it can be hidden, but it must actually be loaded into the DOM (so no setting it to display:none).
js
some things to note before we get into the javascript:
- we use this script as IIFE (immediately-invoked function expression). this makes the script run once as soon as the page is accessed, and keeps all variables out of the global scope.
- this script should be loaded on the webpage where the music player is located (generally at the end of the
<body>tag).
for the code:
(() => {
const box = document.querySelector('.mini-player');
if (!box) return;
const iframe = box.querySelector('.mp-hidden');
const btnPrev = box.querySelector('.mp-prev');
const btnPlay = box.querySelector('.mp-play');
const btnNext = box.querySelector('.mp-next');
const btnOpen = box.querySelector('.mp-open');
const titleEl = box.querySelector('.mp-title');
const textEl = box.querySelector('.mp-text');
const playlistUrl = box.getAttribute('data-sc');
const widgetSrc =
'https://w.soundcloud.com/player/?url=' +
encodeURIComponent(playlistUrl) +
'&auto_play=false&hide_related=true&visual=false' +
'&buying=false&liking=false&download=false&sharing=false' +
'&show_comments=false&show_playcount=false&show_user=false';
iframe.src = widgetSrc;
const widget = window.SC && window.SC.Widget ? SC.Widget(iframe) : null;
if (!widget) return;
function updateMarquee() {
const over = textEl.scrollWidth - titleEl.clientWidth;
const needsScroll = over > 2;
box.classList.toggle('mp-scroll', needsScroll);
if (needsScroll) {
const ratio = textEl.scrollWidth / Math.max(1, titleEl.clientWidth);
const dur = Math.max(7, Math.min(18, ratio * 8));
box.style.setProperty('--mq', `${dur}s`);
}
}
function setTrackInfo(sound){
if (!sound) return;
const artist = sound.user && sound.user.username ? sound.user.username : '';
const title = sound.title || '';
const pretty = artist ? `${artist} — ${title}` : title || '—';
textEl.textContent = pretty;
requestAnimationFrame(updateMarquee);
}
btnPrev.addEventListener('click', () => widget.prev());
btnNext.addEventListener('click', () => widget.next());
btnPlay.addEventListener('click', () => widget.toggle());
btnOpen.addEventListener('click', () => {
window.open(playlistUrl, '_blank', 'noopener,noreferrer');
});
widget.bind(SC.Widget.Events.READY, () => {
widget.getCurrentSound(setTrackInfo);
});
widget.bind(SC.Widget.Events.PLAY, () => {
widget.getCurrentSound(setTrackInfo);
btnPlay.textContent = '⏸';
});
widget.bind(SC.Widget.Events.PAUSE, () => { btnPlay.textContent = '▶'; });
widget.bind(SC.Widget.Events.FINISH, () => { btnPlay.textContent = '▶'; });
widget.bind(SC.Widget.Events.PLAY_PROGRESS, () => {
requestAnimationFrame(updateMarquee);
});
window.addEventListener('resize', () => requestAnimationFrame(updateMarquee));
})();
let's break it down now:
const box = document.querySelector('.mini-player');
if (!box) return;
this is a check to see if the player is on the page. if it isn't, the script simply terminates. box is the root player element.
const iframe = box.querySelector('.mp-hidden'); // the hidden soundcloud widget
const btnPrev = box.querySelector('.mp-prev');
const btnPlay = box.querySelector('.mp-play');
const btnNext = box.querySelector('.mp-next');
const btnOpen = box.querySelector('.mp-open');
const titleEl = box.querySelector('.mp-title'); // clipping mask
const textEl = box.querySelector('.mp-text'); // the scrolling inner text
we cache things from the DOM now.
const playlistUrl = box.getAttribute('data-sc'); // see notes
const widgetSrc =
'https://w.soundcloud.com/player/?url=' +
encodeURIComponent(playlistUrl) +
'&auto_play=false&hide_related=true&visual=false' +
'&buying=false&liking=false&download=false&sharing=false' +
'&show_comments=false&show_playcount=false&show_user=false';
iframe.src = widgetSrc;
remember back in the html where we had that ENTER_URL_HERE thing? that data attribute value gets added to this widget URL, then passed on to the widget API. we don't need anything from the widget itself, so we mark everything as false (or true, in the case of hide_related); the custom player will do everything via the API.
const widget = window.SC && window.SC.Widget ? SC.Widget(iframe) : null;
if (!widget) return;
we call for the widget here. this will give us a controller which we can then use to call functions using our buttons. if the API doesn't load the widget, it'll immediately terminate the script to prevent any issues.
function updateMarquee() {
const over = textEl.scrollWidth - titleEl.clientWidth;
const needsScroll = over > 2;
box.classList.toggle('mp-scroll', needsScroll);
if (needsScroll) {
const ratio = textEl.scrollWidth / Math.max(1, titleEl.clientWidth);
const dur = Math.max(7, Math.min(18, ratio * 8));
box.style.setProperty('--mq', `${dur}s`);
}
}
this is all about the title box. scrollWidth is the width of the actual title text, while clientWidth is the container's visible mask. if there's an overflow within the title box, it adds the class mp-scroll, which enables the marquee effect in the stylesheet. the css variable --mq is set so that titles that are longer will scroll slower than titles that are shorter.
function setTrackInfo(sound){
if (!sound) return;
const artist = sound.user && sound.user.username ? sound.user.username : '';
const title = sound.title || '';
const pretty = artist ? `${artist} — ${title}` : title || '—';
textEl.textContent = pretty;
requestAnimationFrame(updateMarquee);
}
here we're gathering the artist and song names from the sound object that the soundcloud widget is sending. we then put it into an "Artist — Title" label, which is then fed to the title box. requestAnimationFrame(updateMarquee)[?] waits for the DOM to paint before measuring the new label's width.
btnPrev.addEventListener('click', () => widget.prev());
btnNext.addEventListener('click', () => widget.next());
btnPlay.addEventListener('click', () => widget.toggle());
btnOpen.addEventListener('click', () => {
window.open(playlistUrl, '_blank', 'noopener,noreferrer');
});
we use listener events to map the buttons we made to the corresponding button calls on the widget. the playlist button is currently set to open the track or playlist in a new tab. most modern browsers set rel="noopener" by default when target="_blank", but not in all cases. it's safest to explicitly specify it. the noreferrer keyword ensures that no referer data is leaked within the request header when visiting the target page.
widget.bind(SC.Widget.Events.READY, () => {
widget.getCurrentSound(setTrackInfo);
});
widget.bind(SC.Widget.Events.PLAY, () => {
widget.getCurrentSound(setTrackInfo);
btnPlay.textContent = '⏸';
});
widget.bind(SC.Widget.Events.PAUSE, () => { btnPlay.textContent = '▶'; });
widget.bind(SC.Widget.Events.FINISH, () => { btnPlay.textContent = '▶'; });
widget.bind(SC.Widget.Events.PLAY_PROGRESS, () => {
requestAnimationFrame(updateMarquee);
});
the READY event signifies that the widget has loaded. we immediately ask for the current sound so we can generate the label even before the track is played. PLAY, PAUSE, and FINISH events ensure that the play/pause button is accurate. PLAY_PROGRESS is a very active event, but we only need one updateMarquee per frame from requestAnimationFrame.
window.addEventListener('resize', () => requestAnimationFrame(updateMarquee));
not really needed for this to work outside of my website, but this listener checks to see if the sidebar width changes, and then recalculates the title box's overflow.
that's about it! i hope this all made sense; i've never been any good at explaining things. remember that you'll need to change the CSS code at a minimum to fit it for your own use!