Building a Now Playing Display

Today’s a bank holiday, so I decided to spend a little while having fun. As it happens, yesterday I came across this neat “Now Playing” desktop display build, and since I have an official 7” LCD on my desk for controlling my lights, I decided to do something similar, but to display what’s currently playing on (since I use PlexAmp for my music collection).

As usual, things got slightly out of hand, because:

  • I wanted to have both my Echo Listen or PlexAmp update the display
  • The display defaults to showing my office dashboard, and I can’t just take it over (or waste it on a single purpose)
  • The display turns off every few minutes automatically, and it needs to come back on to display the album art
  • I wanted it to be pretty

This meant talking to the Node-RED instance that runs the display, customizing the dashboard to include tailored HTML and CSS and delving a bit into the APIs.

The Pretty

The first part was laying out the UI, which I quickly mocked up in VS Code:

Yeah, I have a fair amount of Portuguese music CDs ripped on my Plex server...

Pro tips:

  • Rotating the cover art 180o before blurring it and sticking it as a background gives a very nice look (and saves you one HTTP request for another image, etc.).
  • Using the :before pseudo-element can be both friend and foe.
  • Set the cover art width to auto (unlike shown) and you will also be able to show movie posters and the like (if you want to)
  • For extra polish, I also designed this so that I could set the --grey CSS variable when playback was paused (which, alas, I had to give up on)

The Good

With the UI mocked up, I decided to get started on how to actually tap into Plex. Since I wanted to update Node-RED and everything else in the house already speaks MQTT, the way forward was obvious, and it took me all of 60 lines of Python to build a simple MQTT relay for playback events.

And since it’s a 12-factor app deployed on piku, you can even call it a microservice:

from functools import lru_cache
from json import dumps
from os import environ
from sys import exit
from plexapi.base import PlexObject
from plexapi.client import PlexClient
from plexapi.server import PlexServer
from time import sleep
import paho.mqtt.client as mqtt

MQTT_BROKER=environ.get("MQTT_BROKER")
PLEX_SERVER=environ.get("PLEX_SERVER")
PLEX_TOKEN=environ.get("PLEX_TOKEN")
BASE_URL=f"http://{PLEX_SERVER}:32400"

server = PlexServer(BASE_URL, PLEX_TOKEN)
broker = mqtt.Client()
broker.connect(MQTT_BROKER)

def fetchItem(key):
    global server
    i = server.fetchItem(key)
    return {
        "key": key,
        "type": i.TYPE,
        "art": i.art,
        "thumb": i.thumb,
        "thumbUrl": f"{BASE_URL}{i.thumb}?X-Plex-Token={PLEX_TOKEN}",
        "artist": i.grandparentTitle,
        "album": i.parentTitle,
        "track": i.title,
        "rating": i.userRating,
    }


def on_message(client, userdata, message):
    global server
    if "plex/rate/" in message.topic:
        key = message.topic.split("/")[-1]
        try:
            key = int(key)
            i = server.fetchItem(key)
            if i:
                if not(message.payload):
                    i.rate(None)
                else:
                    i.rate(float(message.payload))
        except Exception as e:
            print(e)
            exit(-1)
    elif "plex/player/" in message.topic:
        player = message.topic.split("/")[-1]
        try:
            for c in server.clients():
                if c.machineIdentifier in player:
                    cmd = str(message.payload)
                    if "skipPrev" in cmd:
                         c.skipPrevious("music")
                    elif "skipNext" in cmd:
                        c.skipNext("music")
                    elif "pause" in cmd:
                        if c.isPlayingMedia(False):
                            c.pause("music")
                        else:
                            c.play("music")
        except Exception as e:
            print(e)
            exit(-1)


@lru_cache(10)
def findClient(identifier):
    global server
    for s in server.sessions():
        for client in s.players:
            if client.machineIdentifier == identifier:
                return {
                    "client": client.title,
                    "product": client.product,
                    "device": client.device,
                    "platform": client.platform,
                }

def dump(alert):
    global server, broker
    if "playing" in alert["type"]:
        n = alert["PlaySessionStateNotification"][0]
        key = int(n["key"].split("/")[-1])
        state = n["state"]
        alert = {**alert, **fetchItem(key), "state": state}
        c = findClient(n["clientIdentifier"])
        if c: # Plexamp provides information, but Alexa doesn't
            alert.update(c)
        broker.publish("plex/playback", dumps(alert))

try:
    server.startAlertListener(dump)
    print("Listening for events.")
    broker.subscribe("plex/rate/#")
    broker.subscribe("plex/player/#")
    broker.on_message = on_message
    broker.loop_forever()
except Exception as e:
    print(e)
    exit(-1)

Update: I’ve since added touchscreen support for previous/next track and pausing.

There are a few bits of weirdness in the APIs, though, largely because not all Plex clients are well behaved:

  • While I can figure out when something is playing on PlexAmp and from which machine, playing music from my Amazon Echo Listen gave zero information on the client (even then, I cached those calls to save time).
  • PlexAmp also sends out “paused” events regularly, which the Echo Listen doesn’t – so I had to forego my nice greyscale look for when music was paused.

So your mileage may vary quite a lot here.

The Bad

Then I had to get my HTML to work inside the Node-RED dashboard, which is awesome if you want to build control panels but a hideous Angular nightmare full of sharp corners if you want to do something pretty (I really wish they switched to Vue).

Injecting something into the dashboard layout controller is… hard, especially if you’d like to overlay it across most of the screen and customize navigation. I spent the most time fighting with the built-in CSS and Angular’s DOM handling, but it sort of worked:

Those default margins are really going to have to go.

I had to compromise a bit in terms of layout (including preserving the tab bar background color), at least for the moment, but at least I can hop back and forth to my dashboard if needed:

My regular dashboard.

The Ugly

However, the really hard bit to figure out (and which was also a bit of a nostalgia trip back to when we hacked X terminals in college labs) was how to get Node-RED to turn on the LCD backlight again.

The overall flow logic is pretty simple, really:

This took 5 minutes to build and 45 to get xset to work.
  • Listen to the plex/playback topic and de-duplicate it, which gives us only the new events we are interested in
  • Render the HTML template (the CSS is injected in a similar node elsewhere since I have other customizations)
  • Tell the dashboard to temporarily switch to the Now Playing tab (the trigger node switches it back to the default one after a timeout)
  • If it’s a music track, turn on the display using xset, since the display is DPMS enabled and blanks out on its own.

The really tricky thing was to remember how to get xset to work for Node-RED to turn the display on, since Chrome and the X Server runs as the pi user and Node-RED runs under the piku user, and I didn’t want to do the crass thing and just xhost + so anything could access the X display.

I eventually got it to work by explicitly allowing piku to access the display in the autostart script, by using +SI:localuser:piku as arguments to xhost:

$ cat .config/lxsession/LXDE-pi/autostart
@unclutter -idle 0 -root
@xsetroot -cursor_name dotbox
@xhost +SI:localuser:piku
@sed -i 's/"exited_cleanly": false/"exited_cleanly": true/' ~/.config/chromium/Default/Preferences
@sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' ~/.config/chromium/Default/Preferences
@chromium-browser --noerrdialogs --kiosk http://127.0.0.1:1880 --incognito --disable-translate --disable-infobars

That man page diving session took me a lot longer than I expected, since these things are just not done on a daily basis anymore.

Update: A Few Days Later

I ended up needing to inject an insane amount of CSS into Node-RED to get things to look the way I wanted (not just doing away with the navigation bar, but getting the album art to look right and to add transparent controls for rating tracks).

It took a lot of finagling with transparency, position: fixed and z-index to get everything to look just right:

#album_art {
    margin-top: 50px;
    margin-bottom: 20px;
    height: 320px;
    aspect-ratio: 0.92/1; /* allow for slight LCD distortion */
    width: auto;
    filter: drop-shadow(0px 10px 10px #000) grayscale(var(--grey));
    border-radius: 10px;
    z-index: 0;
}
#album_blur {
    padding: 10px 100px;
    text-align: center;
    position: relative;
    margin: 0;
    z-index: -1; /* make sure it's back */
}
#album_blur:before {
    content:'';
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background-color: transparent;
    background-image: var(--thumbUrl);
    background-repeat: no-repeat;
    background-position: center;
    background-attachment: scroll;
    filter: blur(75px) grayscale(var(--grey));
    transform: rotate(0deg);
    background-size: 100vw auto;
    z-index: -1; /* make sure it's back */
}
/* Slight tint to ensure text is readable  */
#album_tint {
    margin: 0;
    padding: 0;
}
#album_tint:before {
    content:'';
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    right: 0;
    background: linear-gradient(0deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0.8) 70%, rgba(255,255,255,0) 100%);
}
#album_info {
    position: relative;
}
#album_info p {
    mix-blend-mode: hard-light;
    background: none !important;
}
/* Try to get rid of dropdown background and dashboard colors */
md-card.album_art_holder {
    top: 0;
    background: none;
    background-color: transparent !important;
}
md-card.album_rating {
    z-index: 9;
    background: none;
    background-color: transparent !important;
}
#Now_Playing_Holder_cards {
    position: fixed;
    left: 0;
    top: 0;
    right: 0;
    height: 64;
    z-index: 2;
}
#Tab_Now_Playing {
    position: absolute;
    top: 0;
    bottom: 0;
    z-index: 2;
}
#Tab_Now_Playing::parent::parent::parent > md-toolbar {
    background: transparent;
}
/* Ratings dropdown tweaks */
.md-select-value, #Now_Playing_Holder_cards,
#Now_Playing_Holder_cards > md-select-value {
    background: none !important;
    border: none !important;
    border-color: transparent !important;
    border-bottom-color: transparent !important;
    text-align: center;
    margin-left: auto;
    margin-right: auto;
}
md-card.album_rating > md-input-container > md-select > md-select-value > span.md-select-icon {
    display: none;
}
md-option > .md-text {
    margin: auto;
}
/* Get the toolbar out of the way */
#toolbar  {
    z-index: 3;
    width: 30%;
}
/* Make tap overlays transparent */
md-card.invisible, md-card.invisible > button {
    opacity: 0;
    background: none;
    background-color: transparent !important;
}
md-card.clear, md-card.clear > button {
    background: none;
    background-color: transparent !important;
}

But it was totally worth it:

The final result. Emojis don't render like this on the Pi, though.

I also changed the logic quite a bit, including an option to set my ambient RGB lighting according to the highlight colors in the album art:

This is what I ended up with.

All that is missing is still some animations (the Pi running the dashboard is a 3B, so it’s not the sharpest CPU on my desk, but it should be able to do fade-ins in Chrome, but…).

But for a couple of hours of fun during a holiday and a couple of evenings, I think that’s OK.

Thanks to Jason Tate for the inspiration.

This page is referenced in: