J.S. Cruz

Kadio: decorative functionality

As of the last post, we have a bare-bones audio player that reads a playlist from a file.

However, it doesn’t look very good. Let’s add some UI elements that are expected to be present in any respectable program, i.e., menus, toolbars and a status bar. Since we need something to fill them with, let’s give the user the ability to add new audio files to the list. Let’s also implement a playlist import/export function.

Note on compilation

Up until now, I’ve been using KDevelop, and using its build/execute functionality to run kadio. The way I set it up was by starting a new project (Project > New from Template > Qt > Graphical > Cmake Qt5 - C++) and copying over the tutorial’s cmake CMakeLists.txt. After configuring launches (Run > Configure Launches > Add > kadio), this works pretty well.

Given that we’re going to start using the ui.rc file, some additional considerations are necessary.

To begin with, create the directories $HOME/kde/usr/bin/ and $HOME/kde/usr/share/kxmlgui/.

Using cmake

If you’ve been using cmake, just following KDE’s tutorial is enough to successfully launch the program. Note that, given the way we’ve hard-coded test-file in main.cpp, you need to run kadio in the directory where test-file is.

Remember to compile with:

1cmake -B build -DCMAKE_INSTALL_PREFIX=$HOME/kde/usr
2cmake --build build/
3cmake --install build/

Using KDevelop

To be able to build and execute the project with KDevelop, we need to make it aware of the new environment. In the instructions below, it is a good idea to expand the shell variables manually.

Install ui.rc file to the correct location:

To mimic what cmake does with -DCMAKE_INSTALL_PREFIX, we need to set it up in the project’s settings:

Project > Open Configuration:

Change run environment:

As per the documentation:

Since KF 5.1, the file will then be assumed to be installed in DATADIR/kxmlgui5/, under a directory named after the component name. You should use ${KDE_INSTALL_KXMLGUI5DIR}/componentname in your CMakeLists.txt file, to install the .rc file(s).

I’m not sure why they wrote it like this, but DATADIR is $XDG_DATA_DIRS. Changing this variable is actually what prefix.sh does. Although the documentation is for setXMLFile, it’s also applicable to setupGUI, which is what we’ll use.

Create a new environment (Project > Open Configuration > Show Advanced > Configure environment) and add everything in build/prefix.sh. This should be:

1PATH=$HOME/kde/usr/bin:$PATH
2QML2_IMPORT_PATH=$HOME/kde/usr/lib64/qml:$QML2_IMPORT_PATH
3QT_PLUGIN_PATH=$HOME/kde/usr/lib64/plugins:$QT_PLUGIN_PATH
4QT_QUICK_CONTROLS_STYLE_PATH=$HOME/kde/usr/lib64/qml/QtQuick/Controls.2/:$QT_QUICK_CONTROLS_STYLE_PATH
5XDG_CONFIG_DIRS=$HOME/kde/usr/etc/xdg:${XDG_CONFIG_DIRS:-/etc/xdg}
6XDG_DATA_DIRS=$HOME/kde/usr/share:${XDG_DATA_DIRS:-/usr/local/share/:/usr/share/}
7LD_LIBRARY_PATH=$HOME/kde/usr/lib64

Then, select the environment you’ve just created/edited when running: Run > Configure Launches > Environment.

Add ui.rc file as a project dependency:

We need to make the ui.rc file an explicit dependency in KDevelop, so every time we build/run the project it gets installed to the proper location.

Run > Configure Launches:

Setup

First, to specify the UI using kdexml, I think we need to call setApplicationDomain(), so the program knows its own name, and thus the proper path to its ui.rc file. While we’re at it, we can add some about-information to the program.

main.cpp:

 1int main(int argc, char *argv[])
 2{
 3    QApplication app(argc, argv);
 4    KLocalizedString::setApplicationDomain("kadio");
 5
 6    KAboutData about_data(
 7        QStringLiteral("kadio"), // Component name
 8        i18n("Kadio"), // Display name
 9        QStringLiteral("1.0"), // Version
10        i18n("Qt-based web radio"), // Short description
11        KAboutLicense::GPL_V3, // License type
12        i18n("Copyright 2023, João Cruz"), // Copyright statement
13        QStringLiteral(""), // Other text
14        QStringLiteral("https://jcruz.eu/tags/kadio/") // Home page address
15    );
16    about_data.addAuthor(
17        i18n("João Cruz"), // Name
18        i18n("Main Developer"), // Task
19        QStringLiteral("jcruz@posteo.net"), // E-mail address
20        QStringLiteral("https://jcruz.eu/")); // Web address
21    KAboutData::setApplicationData(about_data);
22
23    // [... rest of setup ...]

Might as well start writing the ui.rc file: Let’s put it under src/.

kadioui.rc:

1<?xml version="1.0" encoding="UTF-8"?>
2<gui name="kadio"
3     version="1"
4     xmlns="http://www.kde.org/standards/kxmlgui/1.0"
5     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
6     xsi:schemaLocation="http://www.kde.org/standards/kxmlgui/1.0
7                         http://www.kde.org/standards/kxmlgui/1.0/kxmlgui.xsd">
8
9</gui>

As per the tutorial, we need to install this file to a predefined location in the system. KDE expects ui.rc files to be under $XDG_DATA_DIRS/kxmlgui/APP_NAME/.

$XDG_DATA_DIRS is /usr/share by default. Since we need permissions to install files here (and since we don’t want to install the program system-wide just yet), we can change $XDG_DATA_DIRS to point to ~/kde/usr/share, which is what running the prefix.sh script does, and what we need to change to be able to run the program directly via KDevelop.

In any case, we need to change CMakeLists.txt. Add this at the end of the file, next to install(TARGETS kadio [...]).

CMakeLists.txt:

1install(FILES src/kadioui.rc DESTINATION ${KDE_INSTALL_KXMLGUI5DIR}/kadio)

This creates the above-mentioned APP_NAME directory automatically.

New station in menu bar

Here we can start adding back some functionality we removed from the tutorial.

A QAction is essentially a button which can be placed in menus (e.g., in menu bars, or in right-click menus), toolbars and in the status bar.

Although we can add actions directly to their place in the UI, since we’re using kdexml, we would do better to add all actions to KXmlGuiWindow::actionCollection(), which is the set of all actions in the program. Adding actions to this particular KActionCollection allows us to define program-wide shortcuts for them, using KDE’s standard shortcut configuration UI.

To add an action we must give it a name; we can then use this name to place the action directly in a menu bar or a toolbar in the ui.rc file.

KDE also defines, in the KStandardAction namespace, a couple of standard actions which can present in most programs. Apart from an action to quit the program, we don’t really need any of those.

Let’s define a new action, new-station, and a new function to go with it. The function can be empty for now, but it should add a new station to the list. Let’s also add the default action to exit the program.

kadio.h:

1class kadio : public KXmlGuiWindow
2{
3    // [...]
4public slots:
5    // [...]
6    void addNewStation();
7}

kadio.cpp:

 1kadio::kadio(const QVector<QString>& words, QWidget *parent) :
 2    KXmlGuiWindow(parent)
 3{
 4    // [... window and button setup ...]
 5    QAction* new_station = new QAction(QIcon::fromTheme("document-new"), i18n("&New station"), this);
 6    this->actionCollection()->addAction("new-station", new_station);
 7    this->actionCollection()->setDefaultShortcut(new_station, Qt::CTRL + Qt::Key_N);
 8    connect(new_station, &QAction::triggered, this, &kadio::addNewStation);
 9
10    KStandardAction::quit(qApp, &QCoreApplication::quit, actionCollection());
11    // [... media setup ...]
12}
13
14void kadio::addNewStation() { }

We’ve defined the action, but we need to actually place them in the UI. KDE does this automatically for KStandardActions (e.g., quit goes in File menu), but we need to add our actions manually. We can do this in the ui.rc file.

kadioui.rc:

1<gui [...]>
2    <MenuBar>
3        <Menu name="file">
4            <Action name="new-station" />
5        </Menu>
6    </MenuBar>
7</gui>

To load the ui.rc file and place the default actions, it’s just a matter of calling setupGUI().

kadio.cpp:

1kadio::kadio(const QVector<QString>& words, QWidget *parent) :
2    KXmlGuiWindow(parent)
3{
4    // [... previous setup ...]
5    this->setupGUI(Default, "kadioui.rc");
6}
Kadio with menu shown

Tool bar

Using the ui.rc file, we can decouple actions from their position in the UI. In this case, we’ve already defined the new station action and plugged it in the File menu, so to make it appear in a toolbar, we can just specify that same action in the toolbar in the ui.rc file.

kadioui.rc:

1<gui [...]>
2    <MenuBar>
3        [...]
4    </MenuBar>
5    <ToolBar name="mainToolBar">
6        <Action name="new-station" />
7    </ToolBar>
8</gui>

Status bar

Although we can also add actions to the status bar, we have to do this manually in-code, since there’s no support for it using the ui.rc file.

For our case, we don’t really have any need to add actions here though. For now, we can use the status bar to display the currently playing media. In the future, we’ll use it to show web station metadata.

Adding one to our program is simple. We create a QStatusBar, put a QLabel in it and add it to the main window using setStatusBar().

kadio.cpp:

 1kadio::kadio(const QVector<QString>& words, QWidget *parent) :
 2    KXmlGuiWindow(parent)
 3{
 4    // [... previous setup ...]
 5    QStatusBar* status_bar = new QStatusBar(window);
 6    status_bar->addPermanentWidget(new QLabel(mediaplayer->media().request().url().url()));
 7    this->setStatusBar(status_bar);
 8
 9    this->setupGUI(Default, "kadioui.rc");
10}

We’re getting the QString to pass to QLabel directly from the QMediaPlayer object. Of course, we could just read it from the place where we first set the media player’s media content (mediaplayer->setMedia(words.first())), but we want to do away with reading files from test-file, so what will change. The text shown will be prepended with file:// for local files, but that’s not too important.

To change the text when changing track, we can access the status bar via QMainWindow::statusBar(), but we don’t have a direct method — apart from QObject::findChild() — to access its permanent widget, if we set one:

kadio.cpp:

1void kadio::changeTrack(const QString& new_track)
2{
3    // [... button and media control ...]
4    this->statusBar()->findChild<QLabel*>()->setText(new_track);
5}

Since the status bar only has one QLabel, this isn’t too bad. Of course, as before, this would be better done by storing a pointer to the QLabel we’re storing in the widget, but since it’s going to be a while since we again touch this, I think we can leave this be.

setupGUI creates the Settings and Help menus in the menu bar, and in the Settings menu it creates a number of default actions, and one of which is the status bar display toggler. By default the status bar is not shown; to change this, we can add <StatusBar /> to the ui.rc file.

kadioui.rc:

1<gui [...]>
2    [...]
3    </ToolBar>
4    <StatusBar />
5</gui>
Kadio with toolbar and status bar

Export/import actions

We already know how to define the actions, so this is pretty easy. We don’t really need to define shortcuts for these actions, since it’s reasonable to expect that they’re not going to be accessed all that often. Still, by adding them to actionCollection(), if the user wants to, he can define a custom shortcut.

Not shown are the declaration and empty definitions of importStations and exportStations.

kadio.cpp:

 1kadio::kadio(const QVector<QString>& words, QWidget *parent) :
 2    KXmlGuiWindow(parent)
 3{
 4    // [... previous QAction setup ...]
 5    QAction* import_stations = new QAction(QIcon::fromTheme("document-import"), i18n("&Import stations"), this);
 6    this->actionCollection()->addAction("import-stations", import_stations);
 7    connect(import_stations, &QAction::triggered, this, &kadio::importStations);
 8
 9    QAction* export_stations = new QAction(QIcon::fromTheme("document-export"), i18n("&Export stations"), this);
10    this->actionCollection()->addAction("export-stations", export_stations);
11    connect(export_stations, &QAction::triggered, this, &kadio::exportStations);
12    // [... media and status bar setup ...]
13}

Let’s put them in the File menu.

kadioui.rc:

 1<gui [...]>
 2    <MenuBar>
 3        <Menu name="file" >
 4            <Action name="new-station" />
 5            <Action name="import-stations" />
 6            <Action name="export-stations" />
 7        </Menu>
 8    </MenuBar>
 9    [...]
10</gui>

Functionality

We defined and added actions to the UI, but they’re not doing anything at the moment. Let’s change that.

New station

For now, we’re going to have a very crude approach to adding stations: ask a user for a URL and add it to the left pane.

Since we’re going to be accessing the left pane more frequently from now on, we’ll store its pointer as a member variable.

kadio.h:

 1class kadio : public KXmlGuiWindow
 2{
 3    Q_OBJECT
 4
 5private:
 6    QMediaPlayer* mediaplayer;
 7    QPushButton* play_button;
 8    QWidget* left_pane;
 9
10public slots:
11    // [...]
12};

kadio.cpp:

 1kadio::kadio(const QVector<QString>& words, QWidget *parent) :
 2    KXmlGuiWindow(parent)
 3{
 4    QWidget* window = new QWidget(this);
 5    QHBoxLayout* main_layout = new QHBoxLayout(window);
 6
 7    left_pane = new QWidget;
 8    main_layout->addWidget(left_pane);
 9    QVBoxLayout* left_pane_layout = new QVBoxLayout(left_pane);
10    // [... rest of setup ...]
11}

QDialog is the main Qt class responsible for modal interaction with the user. Qt defines convenience classes to get simple input from the user (look at “Inherited by:” line). Since we want the user to input a URL, we should use QInputDialog.

Further in the series we’re going to have to define our own modal dialogue, when we want to ask the user to input more complicated information (e.g., station title, tags, image, etc.).

We need to check if the user actually pressed the Ok button, and that a URL was actually written; we can do this by checking whether the scheme is http or https.

If we indeed have a valid URL, we’re going to construct a StationListItem out of it, connect it to changeTrack, and add it to the left pane’s layout.

We’re not going to worry at all about permanence — in the next post in the series we’re going to change from using a file to using a database, so anything we did here would be lost.

Note that we’re not doing any error checking in the media player; if the user inputs a URL which is not an audio endpoint, nothing gets played.

kadio.cpp:

 1void kadio::addNewStation()
 2{
 3    bool ok = false;
 4    QString url_string = QInputDialog::getText(this, "Add station", "Web radio URL:", QLineEdit::Normal, "https://", &ok);
 5    QUrl url(url_string);
 6    if (ok && url.isValid() && (url.scheme() == "http" || url.scheme() == "https")) {
 7        auto list_item = new StationListItem(url_string, url);
 8        left_pane->layout()->addWidget(list_item);
 9        connect(list_item, &StationListItem::labelClicked, this, &kadio::changeTrack);
10        changeTrack(list_item);
11    }
12}

Whereas before we passed a QString to changeTrack (StationListItem’s url), it is more convenient now that we pass the StationListItem itself (as a pointer, of course), since we’re beginning to detach the station’s URL from its title, as we begin to drop the use of local audio files. Of course, for now, the station’s URL and title are the same, but they’ll be different when we start asking for the title separately.

This involves some superficial changes, e.g., in the signal emitted from StationListItem::mousePressEvent, or in mediaplayer->setMedia([...]); in changeTrack(). The commit dealing with all this is here.

Export stations to file

Exporting is just a matter of writing all the stations we have to a file. Of course, since we will have metadata that we want to export too, we might as well use JSON from the get go. It is actually pretty easy to, thanks Qt’s JSON support.

First, we need to get all of the left pane’s children. There seems to exist a function tailor-made for this, QObject::children(), but, if we use it:

kadio.cpp:

1void kadio::exportStations()
2{
3    for (auto label_object: left_pane->children()) {
4        QLabel* label = static_cast<QLabel*>(label_object);
5        std::cout << label->text().toStdString() << std::endl;
6    }
7}

I get:

1/home/cruz/kde/usr/share/locale/en_GB/LC_MESSAGES/kauth5_qt.qm
2/home/cruz/kadio/Cantique de Noël.mp3
3/home/cruz/kadio/AKMV-18 - Et In Arcadia Ego.mp3
4/home/cruz/kadio/Sara Afonso - what you've done.mp3

It got the three audio files that are shown, but there’s also some locale information as a child of the left pane’s main widget! I’m not too sure what that’s for or how it got there, so I’d rather not touch it, which means using children() is a no-go.

Fortunately we always have findChildren().

Let’s start with a simple export schema:

 1{
 2    "export_date": "2023-04-02T01:02:03",
 3    "items": [
 4        {
 5            "title": "Station 1",
 6            "url": "https://example.org/1"
 7        },
 8        {
 9            "title": "Station 2",
10            "url": "https://example.org/2"
11        },
12        // [...]
13    ]
14}

Qt defines QJsonDocument as the interface between Qt’s C++ JSON values and the filesystem. We can load the QJsonDocument up with a JSON value (the top level, in our case, object) and then call toJson() to get a QByteArray, which we can write directly to a file.

We start with a QJsonObject as the top-level object, as per the schema, and add to it. Notice how cleanly building the JSON hierarchy corresponds to the actual C++ code.

kadio.cpp:

 1void kadio::exportStations()
 2{
 3    QJsonObject top_level;
 4
 5    QString current_time = QDateTime::currentDateTimeUtc().toString("yyyy-MM-dd'T'HH:mm:ss");
 6    top_level.insert("export_date", current_time);
 7    QJsonArray item_array;
 8
 9    auto labels = left_pane->findChildren<StationListItem*>();
10    for (auto label : labels) {
11        QJsonObject station_entry;
12        station_entry.insert("title", label->text());
13        station_entry.insert("url", label->text());
14        item_array.append(station_entry);
15    }
16    top_level.insert("entries", item_array);
17
18    QJsonDocument json_document(top_level);
19    // [... select and write to output file ...]
20}

Instead of hard-coding the export file path, let’s suggest “~/export_kadio_DATETIME.json”, but give the user the ability to change it.

QFileDialog has a couple of static functions to handle file selection. In our case, getSaveFileName() is the most appropriate one.

To build the export file path suggestion, we use QTextStream to build up the QString.

Instead of using a regular QFile, we’ll write the export file using QSaveFile, a Qt convenience class to save files atomically.

Let’s also show the user a small message for three seconds saying that the export was successful.

kadio.cpp:

 1void kadio::exportStations()
 2{
 3    // [... build JSON object ...]
 4    QString default_export_file_name;
 5    QTextStream(&default_export_file_name) << getenv("HOME") << "/export_kadio_" << current_time << ".json";
 6    QString export_file_path = QFileDialog::getSaveFileName(this, i18n("Export file"), default_export_file_name, "JSON files (*.json)");
 7    if (export_file_path.isEmpty()) {
 8        return;
 9    }
10
11    QSaveFile out_file(export_file_path);
12    out_file.open(QIODevice::WriteOnly);
13    out_file.write(json_document.toJson());
14    out_file.commit();
15    this->statusBar()->showMessage("Successfully exported station list", 3000);
16}

Import stations from file

Having defined the file’s schema above, importing from it is very easy. We need to select and read the file to import, remove the current text labels in the left pane, and then create new ones from the data we’ve read.

As per Qt’s documentation:

You can also delete child objects yourself, and they will remove themselves from their parents. […]

Which means we can simply delete the StationListItem pointers stored in left_pane.

kadio.cpp:

 1
 2void kadio::importStations()
 3{
 4    QString export_file_path = QFileDialog::getOpenFileName(this, i18n("Select Kadio export file"), getenv("HOME"), i18n("Json files (*.json)"));
 5    if (export_file_path.isEmpty()) {
 6        return;
 7    }
 8
 9    QFile export_file(export_file_path);
10    export_file.open(QIODevice::ReadOnly);
11    auto json_document = QJsonDocument::fromJson(export_file.readAll());
12    export_file.close();
13
14    // Clear current list.
15    auto labels = left_pane->findChildren<StationListItem*>();
16    for (auto label_pointer : labels) {
17        delete label_pointer;
18    }
19    // [...]
20}

Now it’s just a matter of reading what we wrote.

Iterating over items gives us QJsonValueRef, to avoid extraneous copies. Note the way we have to call toArray, toObject, and toString to get the proper types.

As with export, let’s show the user a small message for three seconds saying that the import was successful.

kadio.cpp:

 1void kadio::importStations()
 2{
 3    // [...]
 4    auto items = json_document["entries"].toArray();
 5    for (auto entry_value : items) {
 6        QJsonObject entry = entry_value.toObject();
 7        QString title = entry["title"].toString();
 8        QString url = entry["url"].toString();
 9
10        auto list_item = new StationListItem(title, url);
11        left_pane->layout()->addWidget(list_item);
12        connect(list_item, &StationListItem::labelClicked, this, &kadio::changeTrack);
13    }
14    this->statusBar()->showMessage(QStringLiteral("Successfully imported %1 stations").arg(items.size()), 3000);
15}

Conclusion

We can new add web URLs to audio files and play them. I suppose this makes it a fully-functioning web radio player… Of course, it is pretty inconvenient without persistence, so that’s what we’ll add in the next post, getting rid of the test-file.

Note that, in the import/export functions, we did no error checking on file opening/writing, nor on whether the URL given by the user is a valid web radio station in addNewStation. We can leave this alone for now, but we’ll revisit this once we define our own modal dialogs. We’ll probably use KDE’s standard dialog boxes.

As of now, our git repository looks like this.

Tags: #kde #kadio