Eine Sencha Ext JS Web-App zum Setzen von Lesezeichen in Spotify Hörbüchern

In meinem Job als Ext JS Consultant bin ich häufig unterwegs. Während meiner Reisezeit höre ich gerne Hörbücher, von denen Spotify zum Glück, eine große Auswahl an ungekürzten Exemplaren anbietet. Ein Hörbuch-Album besteht in der Regel aus dutzenden oder sogar hunderten von Titeln.

Nach langen Arbeitstagen höre ich auch gerne mal gute Musik, um wieder runter zu kommen. Glücklicherweise gibt es bei Spotify auch davon mehr als genug und so wechsele ich einfach zu meiner Lieblings-Playlist.

Das Problem

Wenn ich nach dem Musikgenuss das nächste Mal in mein Hörbuch-Album wechsele, ist meine letzte Hörposition allerdings verloren und ich muss, basierend auf meiner Erinnerung, nach der richtigen Stelle suchen. Das ist so, weil Spotify keine Lesezeichen-Funktion zum Speichern der Position in den Titeln anbietet. Es ist nicht wirklich überraschend, das ich nicht der Einzige mit diesem Problem bin. Schaut man in die Spotify-Community-Foren, findet man viele Posts und Kommentare von Leuten die das gleiche Problem haben. Leider bleibt das Verlangen nach dieser Funktion, durch Spotify, schon seit über zwei Jahren ungehört.

alt

Die Lösung

Es ist sehr nervend und auch nicht ganz einfach jedes Mal händisch nach der richtigen Position zu suchen. Typischerweise benötigt man dafür drei Informationsbestandteile:

  • Name des Albums
  • Name des Titels – in der Regel enthält der Titel eine fortlaufende Nummer. Dies hilft die Titel zu sortieren und in der richtigen Reihenfolge abzuspielen.
  • Fortschrittszeitmarke im Titel

Im Moment sind mir drei Wege bekannt wie Spotify-Benutzer das Problem für sich lösen.

  1. Einen Screenshot machen, bevor man vom Hörbuch zur Musik-Playlist wechselt. Der Screenshot enthält dann alle Informationen die man benötigt, um das Hörbuch an der richtigen Stelle fortzusetzen. In der Vergangenheit habe ich das auch so gemacht.
  2. Meine Frau löschte alle Titel aus dem Hörbuch-Album, welche sie schon gehört hatte. Beim nächsten Öffnen, konnte sie einfach den ersten Eintrag aus der Titelliste starten. Dieser Lösungsansatz speichert aber nicht die Fortschrittszeitmarke im Titel.
  3. Ich bin Entwickler – ich habe eine App entwickelt, um mein Problem zu lösen. :-)

Die App

Meine Lösung basiert auf einer Ext JS Web App, welche die Spotify Web API verwendet, um auf die Informationen meiner gehörten Spotify-Titel zuzugreifen.

Name der App: «Bookmarks for Spotify»
URL der App: https://bookmarks-for-spotify.ws4.be

Login with Spotify Show currently playing and recently played tracks Bookmarked tracks Info

Features

Die App besitzt folgende Features:

  • Authentifizierung gegen Spotify
  • Anzeige des aktuell abspielenden Titels des Benutzers
  • Anzeige der fünfzig zuletzt gespielten Titel des Benutzers
  • Anzeige der Titel-Lesezeichen des Benutzers
  • Speichern des aktuell spielenden Titels als Lesezeichen inklusive Fortschrittszeitmarke
  • Speichern eines kürzlich gespielten Titels als Lesezeichen
  • In Spotify öffnen und das Abspielen starten für einen gerade aktiven, kürzlich abgespielten oder als Lesezeichen gespeicherten Titel. Falls die Fortschrittszeitmarke bekannt ist, wird der Titel an dieser Stelle gestartet.

Architektur

Meine App läuft auf einem Node.js Server. Die serverseitige Applikation liefert den Ext JS App Client aus und stellt für diesen Schnittstellen bereit, um mit der Spotify API zu kommunizieren. Die Spotify API REST Requests werden von der Node.js App ausgeführt.
Die Client App basiert mit Ext JS 6.5 auf der momentan aktuellen Version des Frameworks. Es verwendet das «modern Toolkit» und wurde generiert und gebuildet mit Sencha Cmd. Das Theme wurde mit dem Sencha Themer erstellt. Ich verwende therootcause.io für das Error Tracking. Für das Hosting habe ich ein Docker Image erstellt und lasse den Container auf sloppy.io laufen. Für den Build- und Deploy-Prozess habe ich ein Set an NPM Scripts geschrieben.

Technologie Stack:

  • Ext JS 6.5.0
  • Node JS 7.8
  • Spotify Web Api
  • Sencha Cmd 6.5.0.180
  • Sencha Themer 1.2
  • therootcause.io
  • sloppy.io
  • GIT
  • Docker
  • Docker Hub
  • NPM Scripts

Sencha Cmd

Zu Beginn der Entwicklung habe ich mit Hilfe von Sencha Cmd einen Sencha Workspace und das Grundgerüst meiner Spotify App generiert.

sencha generate app -modern Spotify client/

Während der Entwicklung verwende ich Sencha Cmd um die Dateiabhängigkeiten aufzulösen und die Bootstrap- sowie die Styling-Dateien zu generieren.

sencha app watch

Meine build.xml habe ich erweitert, so dass die app.json Versionsnummer basierend auf der aus der package.json gesetzt wird. Des Weiteren werden entwicklerspezifische Werte in der index.html gesetzt. Dies erlaubt mir, die App-Versionsnummer an einer Stelle via npm zu setzen und dann an mehren Stellen, beispielsweise zur Anzeige in der App oder für das Error Tracking, zu verwenden.

Hierfür sind in der app.json und der index.html Datei-Platzhalter definiert, welche während des Builds ersetzt werden.

app.json:

{
    ...
    /**
     * The version of the application.
     */
    "version": "@@@version@@@",
    ...
}

Ich verwende das «-after-init» Target in der build.xml, um die Platzhalter durch Werte der package.json und config.json zu ersetzen.

<target name="-after-init">
    <!-- Script to get properties from JSON file -->
    <x-script-def name="get-prop">
        <attribute name="file"/>
        <attribute name="query"/>
        <attribute name="property"/>

        <script src="${cmd.dir}/ant/JSON.js"/>
        <script src="${cmd.dir}/ant/ant-util.js"/>
        <![CDATA[
        var fileName = attributes.get('file') + '';
        var query = attributes.get('query') + '';
        var property = attributes.get('property') + '';
        var object = readJson(fileName);
        var s;
        query = query.split('.');
        while (query.length) {
            s = query.shift();
            object = object[s];
        }
        project.setNewProperty(property, object);
        ]]>
    </x-script-def>

    <!-- Read values and save in vars -->
    <get-prop file="../package.json" query="version" property="package_version"/>
    <get-prop file="../config.json" query="therootcauseapplicationid" property="therootcauseapplicationid"/>
    <get-prop file="../config.json" query="googleanalytics" property="googleanalytics"/>
    <get-prop file="../config.json" query="frameworkversion" property="frameworkversion"/>

        <!-- Replace placeholders -->
    <replace file="app.json" token="@@@version@@@" value="${package_version}"/>
    <replace file="index.html" token="@@@version@@@" value="${package_version}"/>
    <replace file="index.html" token="@@@therootcauseapplicationid@@@" value="${therootcauseapplicationid}"/>
    <replace file="index.html" token="@@@googleanalytics@@@" value="${googleanalytics}"/>
    <replace file="index.html" token="@@@frameworkversion@@@" value="${frameworkversion}"/>
</target>

Ext JS

Die Ext JS App verwendet das «Modern Toolkit» und liegt in der von Sencha Cmd generierten Ordnerstruktur. Es werden das MVC und MVVM Pattern, so wie ES6 Code Style verwendet. ES6 wird ab Sencha Cmd Version 6.5 unterstützt.

Formulas und Binding

Binding wird an zahlreichen Stellen im Code verwendet. Ein üblicher Anwendungsfall sind Stores. Des Weiteren wird es in Kombination mit ViewModel Formulas benutzt. Im unten stehenden Beispiel prüft eine Formula im ViewModel ob das Authentifizierungstoken gesetzt ist. Basierend auf der Information {hasToken} oder {!hasToken} werden der Login Button und die Titelliste ein oder ausgeblendet.

View: view/main/Main.js

...
items: [
    {
        xtype: 'spotify-login',
        bind : {
            hidden: '{hasToken}'
        }
    },
    {
        xtype    : 'spotify-recentlyplayed',
        bind     : {
            hidden: '{!hasToken}',
            store : '{playedTracks}'
        },
        ...

ViewModel: view/main/MainModel.js

...
data: {
    token: ''
},

formulas: {
    // check if token is set
    hasToken: function (get) {
        return !(get('token') === '');
    }
},
...

Im CurrentTrackModel formatiert eine Formula die Millisekunden-Werte der bereits gespielten Zeit und der Gesamtzeit in das Format "mm:ss".

ViewModel: view/tracks/currenttrack/CurrentTrackModel.js

...
data    : {
    currentPlayback: null
},
formulas: {
    // format progress ms to "00:00"
    progress_ms: function (get) {
        const ms = get('currentPlayback.progress_ms');
        return parseInt(ms / 1000 / 60) + ":" + parseInt(ms / 1000 % 60);
    },
...

Mit Ext JS 6.5 kann man im View Controller einfach auf Datenveränderungen des View Models reagieren. Durch die Verwendung der bindings Konfiguration wird im folgendem Beispiel die onChangeToken Methode aufgerufen. Ich verwende diese, um den aktuell spielenden Titel zu laden, sobald das Authentifizierungstoken gesetzt wurde.

ViewController: view/tracks/currenttrack/CurrentTrackController.js

...
bindings: {
    onChangeToken: {
        token: '{token}'
    }
},

/**
 * when token changes, trigger load of current playback
 * 
 * @param data
 */
onChangeToken(data){
    if (data.token) {
        this.loadCurrentPlayback()
    }
},
...

Es ist sogar möglich mathematische Berechnungen mit Bindings zu tätigen. In der «aktuelle Spielender Titel»-Komponente verwende ich dieses Feature, um den Fortschritt des Fortschrittsbalken zu berechnen.

View: view/tracks/currenttrack/CurrentTrack.js

{
    xtype: 'progress',
    bind: {
        value: '{currentPlayback.progress_ms / currentPlayback.item.duration_ms}',
    }
}

Benutzerdefinierte Listen und Event Targets

Die App enthält zwei Titel-Listen. Eine für die Darstellung der zuletzt gespielten Titel und eine der gemerkten Titel (Lesezeichen). Das Aussehen beider ist gleich, aber die Aktionen bei Events unterscheiden sich. Aus diesem Grund gibt es eine abstrakte Basis-Klasse, welche das XTemplate enthält. Die «zuletzt gespielte Titel Liste» und «gemerkte Titel Liste» erweitern die abstrakte Liste. Die «zuletzt gespielte Titel Liste» erweitert die Funktionalität um das «Pull to Refresh»-Plugin, während die «gemerkte Titel Liste» nur einen Text ergänzt, für den Fall, dass die Liste leer bleibt. Beide Komponenten werden im Main View eingebunden und mit unterschiedlichen Event Listener verknüpft.

View: view/tracks/List.js

...
itemTpl:
'<div class="track hbox ">' +

    // bookmark/ed icon -> trigger to bookmark or remove bookmark
    '<div class="track-bookmark hbox cross-center main-center ">' +

        // conditional bookmark icon rendering based on the bookmarked flag of the record
        '<span class="icon x-fa  {[values.bookmarked ? "fa-bookmark" : "fa-bookmark-o"]}" />' +
    '</div>' +

    // track infos
    '<div class="track-info flex">' +

        // date formatting
        '<span class="track-played-at">{played_at:date("d.m.Y - H:i")}</span>' +
        '<br /> {name} - {artist} ({progress_ms_display}/{duration_ms_display})' +
    '</div>' +

    // play icon -> trigger for playback
    '<div class="track-play hbox cross-center main-center ">' +
        '<span class="icon x-fa fa-play-circle-o" />' +
    '</div>' +

'</div>'
...

Für jeden Titel, in den Listen, gibt es zwei mögliche Aktionen «als Lesezeichen merken» oder «Titel abspielen». Für beide wird der itemtap Event benutzt. Um die Aktionen zu unterscheiden, verwenden wir die getTarget() Methode des itemtap Listener Event Objekts. Sie überprüft, ob ein DOM Element mit einer bestimmten CSS Klasse getappt wurde.

ViewController: view/main/MainController.js

...
onItemTap(grid, index, target, record, e) {

    if (e.getTarget('.track-bookmark')) {
        // bookmark track
    }

    if (e.getTarget('.track-play')) {
        // start playback track
    }
},
...

Man kann sehen, dass dies die CSS-Klassen des Listen XTemplates sind.

Benutzerspezifische Events

In der App gibt es drei Komponenten, welche das Abspielen eines Titels anstoßen. Ich verwende ein eigenes Event, um die Information des zu startenden Titels über den View Controller zu verbreiten.

ViewController: view/tracks/currenttrack/CurrentTrackController.js

...
playCurrentTrack() {
    const vm = this.getViewModel();
    this.fireEvent('playCurrentTrack', vm.get('currentPlayback'));
}
...

Im Main View Controller sind Event Listener definiert, welche auf das Event reagieren und das Abspielen des Titels anstoßen.

ViewController: view/main/MainController.js

...
listen: {
    controller: {
        '*': {
            bookmarkCurrentTrack: 'onBookmarkCurrentTrack',
            playCurrentTrack    : 'onPlayCurrentTrack'
        }
    }
},
...

Localstorage

Manche Daten der App werden persistiert, so dass sie sessionübergreifend zur Verfügung stehen. Die persönlich gespeicherten Lesezeichen der Titel gehören hierzu, denn diese möchte der Benutzer natürlich beim nächsten Öffnen oder nach einem Refresh der App wieder vorfinden. Der bookmarked Store benutzt den Localstorage Proxy, um die Daten im Localstorage des Browsers zu persistieren.

ViewModel: view/main/MainModel.js

...
bookmarked  : {
    autoLoad: true,
    storeId : 'bookmarked',
    model   : 'Spotify.model.BookmarkedTrack',
    proxy   : {
        type: 'localstorage',
        id  : 'bookmarked-tracks'
    }
}
...

Um ein Login nach jedem Refresh zu verhindern, wird das Authentifizierungstoken von Spotify auch im Localstorage persistiert.

Anzeige der App Version

In meinen Ext-JS-Applikationen zeige ich gerne die App-Versionsnummer aus der app.json-Datei an. Ich verwende diese, um herauszufinden welche Version auf welcher Stage läuft, als Info beim Error Tracking und damit Benutzer gezieltes Feedback zu einer Version geben können. In der Bookmarks for Spotify App findet man sie im Info View. Hierfür muss man nur die Ext.manifest.version an einer beliebigen Stelle in seiner App anzeigen.

{
    xtype  : 'container',
    html   : 'App v' + Ext.manifest.version
}

Sencha Themer

Mit Sencha Themer habe ich, auf Basis des Material Design Themes, ein Ext JS Theme Package erstellt und in meiner App eingebunden.

Publish > Apply Theme to App(s)...

Spotify Theme

Ausgehend von der $base-color, wurden Farben und Größen angepasst, um ein an Spotify angelehntes Aussehen, zu erreichen. Über das Interface des Sencha Themer lassen sich die Theme-Variablen leicht anpassen, um das Theme zu individualisieren.

Spotify UI Button

Für spezielles Styling, habe ich benutzerspezifische UIs angelegt. Zum Beispiel das Spotify UI, welches vom Login-Button verwendet wird. Manchmal ist es nötig, im Sencha Themer zur SASS Variable Anzeige zu wechseln, um Variablen zu ändern. Im folgenden Bild, kann man sehen, dass ich dies getan habe, um den Wert von $ui-spotify-button-padding-big zu ändern.

Spotify UI Button

Im Code können diese UIs über die ui-Konfiguration verwendet werden.

{
    xtype  : 'button',
    ui     : 'spotify',
    iconCls: 'x-fa fa-spotify',
    text   : 'Login with Spotify',
    handler: 'onSpotifyLogin',
    width  : 280
}

Font Awesome

Für den Spotify-Login-Button und an anderen Stellen habe ich Font-Icons verwendet. Im oben stehenden Code sieht man das iconCls
Font Awesome verwendet, um das Spotify Icon anzuzeigen. Um Font Awesome in einer App zu verwenden, muss das "font-awesome" Package in der app.json Datei „required“ werden. Auf http://fontawesome.io/icons/ kann man nach benötigten Icons suchen und dann den angezeigten CSS-Klassennamen mit x-fa im Code kombinieren. Beispiel:

iconCls: 'x-fa fa-spotify'

Styling der eigenen Komponenten

Für die von mir erstellten Komponenten gibt es extra SCSS im Theme Package. Wenn kein namespace in der package.json gesetzt wurde, kann das SCSS anlog zu Ext JS Komponenten Struktur abgelegt werden. Siehe Bild:

Package Files

Das Styling der Ext-JS-Komponente für die Anzeige des aktuellen Titels mit dem Klassennamen Spotify.view.tracks.currenttrack.CurrentTrack und dem Pfad:

/bookmarks-for-spotify/client/app/view/tracks/currenttrack/CurrentTrack.js

kann unter folgendem Pfad gefunden werden:

/bookmarks-for-spotify/client/packages/local/spotify/modern/sass/src/Spotify/view/tracks/currenttrack/CurrentTrack.scss

man sieht das

.../view/tracks/currenttrack/CurrentTrack.js

zu

 .../view/tracks/currenttrack/CurrentTrack.scss

passt.

Build / Deploy / Run

Hier bei der dkd haben wir komplexe Continuous Integration und Continuous Deployment Prozesse um Apps und Webseiten bis zur Produktions-Stage zu bringen. Wir verwenden hierfür Tools wie Phabricator, Jenkins und Platform.sh. Inspiriert durch diese Prozesse, war es mein Ziel einen einfachen automatisierten Weg zu haben, um meine App zu Builden, Deployen und laufen zu lassen.

Mein Prozess sieht wie folgt aus:

  1. App-Version setzen - package.json via npm
  2. Ext JS App Production Build - via Sencha Cmd
  3. Git Tag erstellen und Version pushen - via Git
  4. Docker Image erstellen - via docker build
  5. Push Docker Image zu Docker Hub - via docker push
  6. App auf sloppy.io starten - via sloppy.io cli

Zu diesem Zweck habe ich ein Set an npm Skripten erstellt, welche all dies machen und durch einen einfachen Befehl auf dem Terminal ausgeführt werden können:

APP_VERSION=2.3.0 DOMAIN=bookmarks-for-spotify.ws4.be DOCKERHUB_REPOSITORY=mrsunshine/spotify-recently-played-tracks npm run deploy:prod

Fazit

Durch die Verwendung mir bekannter Frameworks und Tools konnte ich das Problem einfach und elegant lösen. Den Code der App habe ich auf Github veröffentlicht.

Ich freue mich die Bookmarks for Spotify - https://bookmarks-for-spotify.ws4.be App mit dir zu teilen. Ich verwende sie jeden Tag, um zwischen meinen Hörbüchern und Musik-Playlisten zu wechseln und hoffe, dass sie auch dir hilft!

von Nils Dehl

Nils works as Senior Developer and Trainer at dkd Internet Service GmbH. As an expert consultant he helps his customers with concepting, architecting and developing of JS web applications.