Jukebox RSS player in SVG

Yes I'm back on my daftness.

With help from Tabs aka LadyOfCode and her chat/ Discord channel, I've been making adjustments to my home page. Some simple things like adding padding and changing the menu, to a large one that was the landing page was uninspiring. I'd minimised it to a quick blurb and links to things, but Tabs suggested I put more on the book and podcast on that page, as this is about me and those are things I'm very proud of. So I added more information on my book and Copperheart but wanted something needlessly complicated and unneccessary. Because being a Steampunk mad scientist means never stopping to contemplate Why Do This, as long as I can figure out How.

I'd wanted to make a Podcast player for a while. Why not jazz it up for the Copperheart section as a ye-olde radio?

HTML Components

Out of the box I wanted to use HTML components, as that's native and supported by the browser rather than me. For a podcast I needed (1) an audio object for the playing of audio, and (2) a select object for the selection of audio. Luckilly both those elements already exist.

<audio controls="true"><source src="..." type="..."></audio>
<select>...</select>

Audio is really easy to use, throw the URL of the audio into the src tag and the media type into the type tag, and away you go. Select is similarly easy to use, populate the select with the options you want, and huzzah. First trick, though - pull in the RSS feed

Javascript 1 - reading RSS feed.

A quick DuckDuckGo found How To Fetch And Parse RSS Feeds in Javascript which used all natural ingredients. The RSS feed for Copperheart contains the media URL and the media Type, both of which were easy to plumb into the select box. Adding an onchange event and I updated the Audio based on what was selected.

Quality of life changes: Defaulting the audio on first load to the latest podcast.

fetch("https://feeds.libsyn.com/174362/rss")
    // Get and parse the result
    .then(response => response.text())
    .then(str => new window.DOMParser().parseFromString(str, "text/xml"))
    .then(data => {
        // Get each <item> element, initialise target
        const items = data.querySelectorAll("item");
        const target = document.getElementById("podcast-episodes")
        target.innerHTML = "";
        // Create an add an option for each found episode
        items.forEach(el => { 
            let opt = document.createElement("OPTION")
            opt.innerText = el.querySelector("title").innerHTML;
            opt.value = el.querySelector("enclosure").getAttribute("url")
            opt.setAttribute("data-type", el.querySelector("enclosure").getAttribute("type"));
            // Using Prepend to reverse order, so first is at the top
            target.prepend(opt); 
        });
        // When someone changes the select box, update the audio object
        target.onchange = (e) => { 
            e.preventDefault();
            let selected_option = e.target.options[e.target.selectedIndex];
            let target_audio = document.querySelector("audio")
            let target_src = document.querySelector("audio source")
            target_src.setAttribute('src', e.target.value);
            target_src.setAttribute('type', selected_option.getAttribute("data-type"));
            // Gotcha: if you don't load() the audio, nothing happens
            target_audio.load(); 
        }
        // Default the audio on load to the latest episode
        target.value = target.querySelector("option:last-child").value;
        target.querySelector("option:last-child").selected = true;            
        document.querySelector("#podcast-player source").setAttribute("src", target.value);  
        document.querySelector("#podcast-player source").setAttribute("type",  target.querySelector("option:last-child").getAttribute("data-type"));
        document.querySelector("audio").load(); 
    });

This results in... well a functional if uninspiring podcast listener.

Basic

SVG

Maths. If you don't love it... you won't love this section.

I looked up a bunch of inspiration for olde style radioes. There are a lot. The one I found the best image for was a radio on Amazon. Nice wood, interesting patterns. Time to start svging.

Step 1 - Basic outlines.

I create basic objects for each shape. Arc for the top, two columns for the sides, arched top bit for the middle, and rectangles-with-wiggles for the bottom and decorative bits. I'm not looking at the speaker yet. Each object has a colour so I can tell them apart.

Colour Outlines

Gotcha: I used stroke-width: 3px when drawing outlines to make it easier to spot. This, however, threw all my calculations out by 3 once I removed the stroke. Relative units are your friend.

Step 2 - Side columns

Patterns in SVG are can be simple repeating drawings, like having a 5r circle filling your object. The columns were a repeating pattern right to left of light and dark wood colours. I figured using the alpha channel to provide different shading of the verticals would work, and it did. Once they were in place, I also added a highlight line and a shadow line to give it more depth.

I took that logic to add a shader bar to the edge of each column to give it more shape.

<pattern id="side-columns" patternUnits="userSpaceOnUse" x="0" y="0" width="25" height="30" viewBox="0 0 25 30">
    <rect x="0" y="0" stroke-width="0" height="30" width="25" fill="brown"></rect>
    <rect x="0" y="0" stroke-width="0" height="30" width="12.5" fill="rgba(0,0,0,0.0)"></rect>
    <rect stroke-width="0" y="0" height="30" x="12.5" width="12.5" fill="rgba(0,0,0,0.2)"></rect>
    <line x1="0" y1="0" x2="0" y2="30" stroke-width="1" stroke="rgba(255,255,255,0.3)"></line>
    <line y1="0" stroke-width="1" stroke="rgba(0,0,0,0.3)" y2="30" x1="12.5" x2="12.50"></line>
</pattern>

<path stroke-width="3px" d="M150 332 l 0 471 l -107 0 l0 -471 Z" stroke="blue" fill="url(#side-columns)"></path>
<path stroke-width="3px" d="M630 332 l 0 471 l 107 0 l0 -471 Z" stroke="blue" fill="url(#side-columns)"></path>

Sidebars

Step 3 - Wood grain

I found an excellent noise tutorial and used that as a shading layer to make a darker wood grain. I ended up using the wood grain for the top and middle sections, as well as replacing brown in the side bars.

<pattern id="top-section" patternUnits="userSpaceOnUse" x="0" y="0" width="900" height="500" viewBox="0 0 900 500">
    <filter id="filter">
        <feTurbulence type="fractalNoise" baseFrequency=".1 .01"></feTurbulence>
        <feColorMatrix values="0.2 0.2 0 .11 .69 0 0.2 0 .09 .38 0.2 0 0.2 .08 .14 0 0 0 0.2 1"></feColorMatrix>
    </filter>
    <rect width="100%" height="100%" fill="rgba(140,22,15)"></rect>
    <rect width="100%" height="100%" filter="rgb(0,0,0)" opacity="0.6"></rect>
    <rect width="100%" height="100%" filter="url(#filter)" opacity="0.3"></rect>
</pattern>

Wood Grain

Step 4 - Curved wood shading

Looking at the example picture, this area is the most complex. Somehow I had to turn the curved ends to make it look like light/ shadow interplay over concave and convex surfaces. I'm not sure if this was the best approach, but I went for a gradient.

A gradient is a fill-type where you can specify colours at points on a spectrum, and the browser tries to blend each colour transformation together smoothly. I started with a basic darker colour and played with gradient stop-points to get a lighter shading on the "top" of bulges and darker shading on the "inset" of the bulges. And, after an hour of playing, it did the job. Once again, I stacked the layer on top of the wood grain for consistency of colours.

<pattern id="vertical-gradient" patternUnits="userSpaceOnUse" x="0" y="0" width="2" height="32" viewBox="0 0 2 32">
    <linearGradient id="lG" x1="50%" y1="0" x2="50%" y2="100%">
        <stop stop-color="rgba(0,0,0,0.6)" offset="0%"></stop>
        <stop stop-color="rgba(255,255,255,0.8)" offset="8%"></stop>
        <stop stop-color="rgba(0,0,0,0.6)" offset="12%"></stop>
        <stop stop-color="rgba(0,0,0,0.3)" offset="38%"></stop>
        <stop stop-color="rgba(0,0,0,0.6)" offset="40%"></stop>
        <stop stop-color="rgba(255,255,255,0.5)" offset="40%"></stop>
        <stop stop-color="rgba(0,0,0,0.6)" offset="70%"></stop>
        <stop stop-color="rgba(255,255,255,0.2)" offset="90%"></stop>
    </linearGradient>
    <rect x="0" y="0" stroke-width="0" height="32" width="2" fill="url(#top-section)"></rect>
    <rect x="0" y="0" stroke-width="0" height="32" width="2" fill="url(#lG)"></rect>
</pattern>

Gotcha: The gradient seemed to start at ... odd places. I really had to fiddle to find out where the wrap occured. If you've got ideas, ping me.

Gradient

Step 5 - Speaker

I have the pattern for the speaker from Philip Rogers' SVGPatterns gallery. I hadn't sketched out the position for the speakers like I had the earlier code, but I just ended up winging it anyway by taking the arc for the front section, moving it down and shrinking it, and playing around with creating a decorative wavy section at the bottom. The idea of trying to make all the cutouts was far too tiring for me by this point.

Note that I added a opacity border to this to give it an inset effect that worked like a charm.

<pattern xmlns="http://www.w3.org/2000/svg" id="speakers" patternUnits="userSpaceOnUse" x="0" y="0" width="15" height="15" viewBox="0 0 15 15">
            <rect width="50" height="50" fill="BurlyWood"/>
            <circle cx="3" cy="4.3" r="1.8" fill="#393939"/>
            <circle cx="3" cy="3" r="1.8" fill="black"/>
            <circle cx="10.5" cy="12.5" r="1.8" fill="#393943"/>
            <circle cx="10.5" cy="11.3" r="1.8" fill="black"/>
        </pattern>

<path xmlns="http://www.w3.org/2000/svg" id="speakers" fill="url(#speakers)" stroke-width="5px" d="M191 318 A 200 150 1 0 1 599 318 l 0 200 A 50 45 1 0 1 491 518 A 50 50 1 0 0 291 519 A 50 45 1 0 1 191 519 Z" stroke="rgba(33,33,33,0.5)"/>

Speakers

Embedded image

I had my speaker, I had my controls. Combining the two was the factor of adding a div and setting the SVG as the background image, then position:absolute positioning the controls where I wanted them.

<section id="podcast-player" style="width: 400px; height: 440px; background-image: url(&quot;/theme/images/radio.svg&quot;); background-size: 400px 620px; background-position: 0px -90px; background-repeat: repeat; position: relative;"><div style="position: absolute; bottom: 80px; text-align: center; width: 100%;"><audio controls="true"><source></audio></div><div style="position: absolute; bottom: 60px; text-align: center; width: 100%;"><select style="text-align: center;" id="podcast-episodes">...</select></div></section>

Version 1

Javascript

Since this only works with Javascript enabled (to pull down the RSS feed), I converted all the HTML into a Javascript build that I won't post in here. Suffice to say, it only shows on the page if you've got JavaScript enabled so it'll work

Bonus round - Volume control.

There was a space in the middle of the speaker I knew I wanted to do something with. The basic control already had a scroble. So.. volume control?

I then went down a fruitless rabbit warren of trying to figure out how to curve an element. You can curve text on a textPath in svg, but not a foriegn element or similar. Since this whole thing is JavaScript dependant anyway, I felt less bad about making volume control a purely graphical element - especially given there are other controls that let you change volume at your disposal.

Circle and visual control

First I made a circle for the dial using the basic circle element. This I then used as the baseline for an arc that wasn't a full circle, miraculously guessing the start and end points on the circle. I added a knob to show where on the arc it was, and a knob in the centre for aesthetics.

Javascript - where is the interaction?

Then I had to add the events to capture the interaction on the knob. It's not dragging it, so its not an ondrag, it's a combination of mousein/mousedown/mouseout touchstart/touchmove/touchcancel. I set a flag to record if the interaction has started/ is still going and only calculate move events if that's the case. Move is all relative to the middle dot, so high school calculus and arctangents to the rescue.

That and a LOT of fiddling of numbers to figure out where the arcs are coming from. Like a lot. What's the deal with some JS angles being degrees and some radians?

It also sets the volume on load, and tracks the other volume knob to set them to equal values.

// Maximum rotation is 310 degrees
const mainbox = document.getElementById("volume-knob"),
    volumeDot = document.getElementById("volume-dot");
var volumeAdjusting = false,
    volumeMousePlace = [0,0];

// On interact, record we're adjusting and update slider
mainbox.addEventListener('mousedown', (e) => {
    volumeAdjusting = true;
    volumeMousePlace = getRelativeMousePosition(e.clientX, e.clientY);
    adjustSlider();
});
// Stop updating slider
mainbox.addEventListener('mouseup', (e) => {
    volumeAdjusting = false;
});
mainbox.addEventListener('mouseout', (e) => {
    volumeAdjusting = false;
});
// If we're adjusting, update slider
mainbox.addEventListener('mousemove', (e) => {
    if (volumeAdjusting) {
        volumeMousePlace = getRelativeMousePosition(e.clientX, e.clientY);
        window.console.log("Base place " + volumeMousePlace[0] + "," + volumeMousePlace[1]);
        adjustSlider();
    }
});

function getRelativeMousePosition(x, y) {
    let masterPosition = document.getElementById("volume-knob").getBoundingClientRect(),
        masterX = masterPosition.x,
        masterY = masterPosition.y;
    return [x - masterX, y - masterY];
}

function adjustSlider() {
    let dangle = getAngle();
    volumeDot.setAttribute(
        "transform",
        `rotate(${dangle},50,50)`);
    document.querySelector("audio").volume = dangle / 310;
}

// This spins the knob selector around its centre point based on where the
// interaction is relative to the knob centre, based on the `<audio>` volume
function adjustKnob() {
    volumeDot.setAttribute(
        "transform",
        `rotate(${document.querySelector("audio").volume}, 50, 50)`
    )
}
adjustKnob();
document.querySelector("audio").addEventListener("volumechange", adjustKnob, false);

// Here's that ArcTan to convert the x,y interaction position to an angle
// that then can be applied elsewhere
function getAngle() {
    window.console.log(volumeMousePlace);
    // Adjust X/Y for correct angle calculation
    let opposite = (48 - volumeMousePlace[1]),
        adjacent = (48 - volumeMousePlace[0]),
        rangle = Math.atan(opposite/adjacent),
        dangle = radiansToDegrees(rangle) + 53 // Start angle;
    if (volumeMousePlace[0] > 48) {
        dangle = dangle + 180;
    }
    if (dangle < 0) {
        dangle = 0;
    }
    if (dangle > 305) {
        dangle = 310
    }
    return dangle;
}

function radiansToDegrees(radians) {
    return radians * (180 / Math.PI);
}

Conclusion

It was a brilliantly fun day and a bit of hacking while very, VERY sick. Four rapid antigen tests over a week to ensure I wasn't going to covid the family but... man. That could be why I enjoyed the distraction on such a patently ridiculous thing, but it actually works and I really like it.