websites/ewintr.nl/content/2024/an-invisible-media-player-w...

12 KiB

+++ title = 'An "invisible" media player with physical buttons' // alias date = 2024-12-14 +++

Two things I like are: minimalism and physical buttons. Two other things I like are Homeassistant and the Squeezebox streaming audio player ecosystem.

I combined these things to solve a problem that is not really a problem, but something I wanted anyway: play music on my computer while I work without having an audio player window, or confusing multimedia controls.

Something like this:

Home automation - foto's in foto - 720 pixels

When I work, I tend to play a random mix from own collection of flacs and mp3s on my headphones. These are songs that I all like and that are very familiar to me, so they become a mix of background radio and white noise. It helps me to focus on the work, but also puts me in a good mood. But for every interruption, walking away from my desk, or a video call, I pause the music and put down my headphones. When I come back, the reverse happens. Sit down, put on headphones, press play.

This is, of course, easy to arrange on any computer that can output audio. Just start up your favorite player (or streaming service) and go. But as I said, I really like the act of physically pressing a button for it. In my mind, listening to my personal radio station is separate from the rest of the computer experience. I set out to enhance this separation.

My wishes:

  • A physical button that can pause/play the music and skip to the next song.
  • No audio player window on my screen.
  • No confusion with multimedia keys. If I paused the music to watch a video and want to resume it afterwards, the computer should not misinterpret 'play' and start playing the video again.
  • Some feedback on the name of the song that is playing.

To make it work, I used the following:

The player

Squeezelite is a straightforward player. Just start it with two arguments:

squeezelite -n Toren -z

'Toren' is the name of the player here. It is Dutch for 'tower', this is my big computer. -z lets it run as a background daemon.

Of course, we would like to run this command automatically at startup. It is tempting to create a separate user for it together with systemd service that is started at boot. This might even be done automatically for you at install. But depending on your setup, this can can cause plenty of headaches. If you use PulseAudio, then chances are that it is configured so that only one user can 'own' the audio output. After you logged in, the daemon is not allowed to play audio anymore.

The simple solution is to just use whatever method your desktop environment supports to execute the command after you logged in. In Cinnamon it is called 'Startup Applications' in 'System Settings'.

Screenshot from 2024-11-07 10-21-28

Note: the Squeezebox system really cannot handle two players using the same name. If you experience sudden pauses or delays, check that you didn't accidentally start two instances with something like ps -aux | grep squeezelite

The streaming server

I have a separate Lyrion server running to manage all things music. Homeassistant has an official add-on called Music Assistant that is supposed to fully support the Squeezebox protocol. In theory one could drop Lyrion and let Homeassistant do everything. But I am nostalgic, and brief testing showed that Music Assistant does not understand my Squeezebox Controller, so I keep them separated and only use the Squeezebox integration.

This comes with a downside, though. Sometimes there is a lag in the communication between Homeassistant and Lyrion. Not really a problem with normal use, but if you are testing and quickly pressing buttons the results can be confusing.

The automations in Homeassistant

The automations in Homeassistant are about as basic as you can get. Once you got the button connected through Zigbee and the player through the Lyrion Add On, it is just a matter of linking them.

One special case needs to be added, though. If you start up the player for the first time after booting the computer, the play queue is empty. Fortunately, there is a command to continuously fill it with random tracks.

We can check for the current length of the queue and issue the command if it is zero. Otherwise, execute pause/play on the media player:

alias: Pause/Play on Squeezelite Toren
description: ""
triggers:
  - device_id: xxx
    domain: zha
    type: remote_button_short_press
    subtype: turn_on
    trigger: device
conditions: []
actions:
  - choose:
      - conditions:
          - condition: template
            value_template: >-
              {{ state_attr('media_player.squeezelite_toren', 'media_duration')
              == 0 }}              
        sequence:
          - data:
              command: randomplay
              parameters:
                - tracks
            action: squeezebox.call_method
            target:
              device_id: xxx
    default:
      - action: media_player.media_play_pause
        data: {}
        target:
          device_id: xxx
mode: single

The Cinnamon applet

Now, the applet is the most difficult part. There should not be a player window on my desktop, but I do like to have some feedback on what is playing. This should be easy to fix. Just write a small task bar applet in JavaScript that queries the state of the player in Homeassistant and display the band and song in a label.

Homeassistant has a REST API that provides all the information. Here is a curl command that fetches the information of my player:

curl -X GET -H "Authorization: Bearer ${HA_TOKEN}" https://homeassistant.local:8123/api/states/media_player.squeezelite_toren | jq .

Which returns something like this:

{
  "entity_id": "media_player.squeezelite_toren
  "state": "playing",
  "attributes": {
    "group_members": [],
    "volume_level": 0.98,
    "is_volume_muted": false,
    "media_content_id": // a bunch of links
    "media_content_type": "playlist",
    "media_duration": 254,
    "media_position": 39,
    "media_position_updated_at": "2024-11-19T11:19:49.056947+00:00",
    "media_title": "Devil's Dillema",
    "media_artist": "Super Preachers",
    "media_album_name": "The Underdog",
    "media_channel": "None",
    "shuffle": false,
    "repeat": "off",
    "query_result": {},
    "entity_picture": "/api/media_player_proxy/media_player.squeezelite_toren?token=3dd15241609284358c81ffb25bb19464ef2a0ed724489876c14766c389df9b51&cache=ff70377516f8f21c",
    "friendly_name": "Squeezelite Toren",
    "supported_features": 3077055
  },
  "last_changed": "2024-11-19T11:06:43.169101+00:00",
  "last_reported": "2024-11-19T11:19:49.057895+00:00",
  "last_updated": "2024-11-19T11:19:49.057895+00:00",
  "context": {
    "id": "01JD22CV212NMXMNZ3N4NGKSM1",
    "parent_id": null,
    "user_id": null
  }
}

As one can see, one needs to create an API token to access the REST API of Homeassistant. Our applet will use the same method.

The actual applet

As said, creating the applet was the hard part. The first reason for that is: I don't really know JavaScript. The second thing is: there isn't much documentation on how to write those applets.

Fortunately, I found two posts explaining the process. Also, in the source of a Cinnamon documentation is the first part of a tutorial in XML. Despite that it is quite readable:

Beyond that, the advice is to simply look at other applets and copy what you see. Here is mine:

First, create a folder ~/.local/share/cinnamon/applets/currentlyplaying@ewintr

This folder will need three files:

  • metadata.json - overall metadata for the applet
  • settings-schema.json - configuration
  • applet.js - the actual applet

metadata.json

This is the metadata that helps Cinnamon understand the applet.

// metadata.json
{
  "uuid": "currentlyplaying@ewintr",
  "name": "Currently playing",
  "version": "1.0.0",
  "description": "Shows currently playing media",
  "settings-schema": "settings-schema"
}

settings-schema.json

This enables a form where the user of the applet can enter their Homeassistant API key.

// settings-schema.json
{
  "ha_token": {
    "type": "entry",
    "default": "",
    "description": "Home Assistant API Token"
  }
}

applet.js

A Javascript snippets that polls the Homeassistant API about the status of the media player and displays the band and song when playing.

// applet.js
const Applet = imports.ui.applet;
const Util = imports.misc.util;
const Mainloop = imports.mainloop;
const Soup = imports.gi.Soup;
const Json = imports.gi.Json;
const Settings = imports.ui.settings;

class CurrentlyPlaying extends Applet.TextApplet {
  constructor (metadata, orientation, panelHeight, instance_id) {
    super(orientation, panelHeight, instance_id);
    this.set_applet_label("Loading...");
    this._httpSession = new Soup.Session();
    this._updateLoop();
    this.settings = new Settings.AppletSettings(this, metadata.uuid, instance_id);
    this.settings.bind("ha_token", "ha_token", this.on_settings_changed);  }
  
  _updateLoop() {
    this._updateText();
    this._timeoutId = Mainloop.timeout_add(1000, () => this._updateLoop());
  }

  _updateText() {
    let url = "https://ha.ewintr.nl:8123/api/states/media_player.squeezelite_toren";
    let message = Soup.Message.new('GET', url);
    
    message.request_headers.append('Authorization', 'Bearer ' + this.ha_token);
    this._httpSession.queue_message(message, (session, message) => {
      if (message.status_code === Soup.KnownStatusCode.OK) {
        let jsonData = JSON.parse(message.response_body.data);
        let mediaTitle = jsonData.attributes.media_title || "";
        let mediaArtist = jsonData.attributes.media_artist || "";
        let state = jsonData.state || "paused";
        let label = `${mediaArtist} - ${mediaTitle}` 
        if (label == " - " || state == "paused" || state == "idle") {
          label = ""
        }
        this.set_applet_label(label);
      } else {
        this.set_applet_label("Error fetching data");
      }
    });  }

  on_applet_removed_from_panel() {
    if (this._timeoutId) {
      Mainloop.source_remove(this._timeoutId);
    }
  }
}

function main(metadata, orientation, panelHeight, instance_id) {
  return new CurrentlyPlaying(metadata, orientation, panelHeight, instance_id);
}

And there you have it: plenty of moving parts, but on the surface it quietly just works.