← Back to Docs

Custom App Extension

This tutorial walks you through the full HPR Extension API by building a real extension from scratch — a Spotify song tracker that detects what you are listening to, stores the data in HPR's SQLite database, and pushes live statistics into HPR's UI. By the end you will know how to detect any running application, extract data from it, persist that data across sessions, and display it in a custom Slint UI panel.

Before you start

This tutorial assumes you have a working knowledge of Lua. If you are new to Lua, please read the Programming in Lua guide first. No C++ knowledge is required — you will only write Lua code.

About this example

The Spotify tracker built in this tutorial is a teaching example designed to familiarize you with the HPR Extension API. It detects the currently playing song by reading Spotify's window title, which means it only works when Spotify is the focused (active) window. For production-grade Spotify tracking that works in the background, you would need to use the Spotify Web API — but that is outside the scope of this tutorial. The goal here is to learn the API, not to build a perfect Spotify tracker.

Step 1 — Create your extension file

Create a new .lua file anywhere inside your HPR extensions folder. HPR scans this folder recursively, so you can organize files into subfolders however you like.

Linux
~/.config/HPR/extensions/main.lua
Windows
%APPDATA%\HPR\HPR_Config\extensions\main.lua

Step 2 — Detect when your target app is active

HPR exposes two functions for reading the currently focused window: HPR.getCurrentWindow_E() returns the application class (e.g. "Spotify", "firefox", "code"), and HPR.getCurrentTitle_E() returns the window title (e.g. "Artist - Song Name", "Google — Mozilla Firefox").

Both read directly from HPR's internal in-memory state via C++ lambdas — there is no HTTP overhead, no JSON parsing. The call is nanoseconds.

To detect whether Spotify is the currently focused window, you check if the window class string contains "spotify" (lowercase, so we compare case-insensitively using string.lower). You also want to make sure both the window class and the title actually exist and are not nil before trying to use them.

detection
function onTick(delta)
    local activeWindow = HPR.getCurrentWindow_E()
    local activeTitle  = HPR.getCurrentTitle_E()

    -- Make sure both values exist before using them
    local isWindowValid   = activeWindow and activeTitle
    local isSpotifyActive = isWindowValid and
        string.find(string.lower(activeWindow), "spotify")

    if isSpotifyActive then
        print("Spotify is active! Title: " .. activeTitle)
    end
end

Step 3 — Extract data from the window title

Spotify's window title follows the format "Artist - Song Name" when music is playing. When music is paused or on the home screen, the title is just "Spotify" or "Spotify Premium" without the " - " separator.

We can exploit this by using Lua's string.find to locate the " - " separator. If it exists, we split the string into the artist name (everything before the separator) and the song name (everything after). We also trim any leading/trailing whitespace using a :match pattern.

If the separator is not found, we return nil, nil — this signals to the caller that the title does not contain a valid song format (Spotify is paused or on the home screen).

parsing
local function parseSpotifyTitle(windowTitle)
    -- Look for the " - " separator that Spotify uses
    local hyphen_start, hyphen_end = string.find(windowTitle, " %- ")

    if not hyphen_start then
        return nil, nil  -- Title doesn't match "Artist - Song" format
    end

    -- Split the string and trim whitespace from both parts
    local artistName = string.sub(windowTitle, 1, hyphen_start - 1):match("^%s*(.-)%s*$")
    local songName   = string.sub(windowTitle, hyphen_end + 1):match("^%s*(.-)%s*$")

    return songName, artistName
end
Adapting for other apps

The same detection + parsing pattern applies to any application. For example, VS Code's window title is "filename.lua - ProjectFolder - Visual Studio Code". You would check for "code" in the window class and parse the title to extract the active file and project. The key idea is always the same: use getCurrentWindow_E() to identify the app, and getCurrentTitle_E() to extract meaningful data from it.

Step 4 — Track data in memory

Now we need somewhere to accumulate play times. Writing to the database every single tick (every 150ms) would be extremely wasteful. Instead, we keep an in-memory Lua table called spotify_history that maps each song name to its artist and total accumulated play time in milliseconds.

On every tick where Spotify is active and we successfully parse the title, we check if the song already exists in the cache. If not, we create a new entry with a play time of 0. Then we add delta (the time elapsed since the last tick, passed by HPR) to the song's play time. This is how we build up accurate per-song statistics in real time.

We declare these at the top of the file, outside any function — they are module-level variables that persist for the lifetime of the extension.

cache
-- Module-level state: persists across ticks for the extension's lifetime
local spotify_history = {}
local time_since_last_flush = 0

function onTick(delta)
    local activeWindow = HPR.getCurrentWindow_E()
    local activeTitle  = HPR.getCurrentTitle_E()

    local isWindowValid   = activeWindow and activeTitle
    local isSpotifyActive = isWindowValid and
        string.find(string.lower(activeWindow), "spotify")

    if isSpotifyActive then
        local songName, artistName = parseSpotifyTitle(activeTitle)

        if songName and artistName then
            -- Create a new entry if this is the first time we've seen this song
            if not spotify_history[songName] then
                spotify_history[songName] = { artist = artistName, play_time = 0 }
            end

            -- Accumulate: add the actual elapsed time (delta) to the song's total
            spotify_history[songName].play_time = spotify_history[songName].play_time + delta
        end
    end
end

Step 5 — Persist data with the SQLite database

In-memory data is lost when HPR exits. To persist data across sessions, HPR provides two database functions: HPR.dbExecute_E(sql, params?) for write operations (CREATE, INSERT, UPDATE, DELETE) and HPR.dbQuery_E(sql, params?) for read operations (SELECT). Both accept an optional second argument — a Lua table of strings used as parameterized bind values to prevent SQL injection.

We do three things with the database:

1. Create the table in init()

We use CREATE TABLE IF NOT EXISTS so it only runs the first time. The table stores song name (unique key), artist name, and accumulated play duration in milliseconds.

function init()
    HPR.dbExecute_E([[
        create table if not exists spotify_songs (
            song_name text unique,
            artist_name text,
            play_duration_ms int
        );
    ]])
end

2. Load history on startup in init()

After creating the table, we use HPR.dbQuery_E() to read all previously saved songs back into our in-memory cache. This means your play history carries over across HPR restarts. dbQuery_E returns a Lua table of rows, where each row is a table with column names as keys.

-- Still inside init(), after the CREATE TABLE
local dbRows = HPR.dbQuery_E(
    "select song_name, artist_name, play_duration_ms from spotify_songs;"
)

for rowIndex, row in ipairs(dbRows) do
    spotify_history[row.song_name] = {
        artist    = row.artist_name,
        play_time = tonumber(row.play_duration_ms) or 0
    }
end

3. Flush to database periodically

Rather than writing to the database every tick, we accumulate time with a counter (time_since_last_flush) and only write to the database when 10 seconds (10,000ms) have elapsed. We loop over every song in the in-memory cache and use INSERT OR REPLACE to upsert the data. We also wrap the database call in pcall (Lua's protected call) so that if the write fails for any reason, the extension keeps running instead of crashing.

-- Standalone function that writes the entire cache to the database
function flushToDatabase()
    for songName, songData in pairs(spotify_history) do
        HPR.dbExecute_E(
            "insert or replace into spotify_songs (song_name, artist_name, play_duration_ms) values (?, ?, ?);",
            { songName, songData.artist, tostring(math.floor(songData.play_time)) }
        )
    end
end

-- Inside onTick(delta), at the end:
time_since_last_flush = time_since_last_flush + delta
if time_since_last_flush >= 10000 then
    local success, err = pcall(flushToDatabase)
    if not success then
        print("[DEBUG-ERROR] Database save failed: " .. tostring(err))
    end
    time_since_last_flush = 0
end
Parameterized Queries

Notice the ? placeholders in the SQL string and the second argument as a Lua table of values. HPR binds these values safely, preventing SQL injection. Always use parameterized queries when inserting user-derived data — never concatenate strings directly into SQL.

Step 6 — Configure the tick rate

By default, HPR ticks every 1000ms (1 second). For a Spotify tracker, we want faster updates so the play time stays accurate and the UI feels responsive. You set the tick rate by returning an integer from init() — this is the sleep time in milliseconds between ticks.

We use 150ms for this extension. That means HPR checks the active window roughly 6-7 times per second. This gives us play time accuracy within ~150ms, which is more than enough for tracking songs.

tick rate
function init()
    -- ... database setup code from Step 5 ...

    print("[Spotify Extension] Initialized successfully.")
    return 150 -- Tick every 150 milliseconds
end

Step 7 — Push data to HPR's UI in real time

HPR's UI is built with Slint and loaded dynamically at runtime using the Slint interpreter. The function HPR.setUiProperty_E(name, value) lets you set any property on the root Slint component by name. The value can be a string, number, boolean, or a Lua table (which maps to a Slint array of structs).

Behind the scenes, HPR converts your Lua value to a C++ intermediate representation on the extension's background thread, then dispatches the actual Slint property update to the main UI thread via slint::invoke_from_event_loop. You don't need to worry about any of this — just call the function and the UI updates.

For our Spotify tracker, we push two properties every tick:

spotifySongs_S — the track list

A Lua table (array) of structs, where each struct has name, artist, duration (formatted string), and duration_i (raw integer milliseconds). We sort this array descending by play time so the most-played song appears at the top.

HPR.formatTime_HHMMSS_E(ms) is a utility function that converts raw milliseconds into a human-readable "HH:MM:SS" string. This is what we display in the UI.

trackedTime_Spotify_S — total play time

A single integer: the sum of all song play times in milliseconds. The UI uses this to calculate percentage bars — each song's play time divided by the total gives its proportion.

A quick note on Lua "tables"

If you're coming from C++, Python, or JavaScript, the word table in Lua might be confusing. In Lua, arrays, dictionaries, lists, and hash maps are all just called tables (e.g., local my_dict = {}).

Additionally, table is the name of the built-in Lua module that contains utility functions for working with tables. When you see table.insert() or table.sort() below, it's calling functions from that standard library, much like std::vector::push_back or std::sort in C++.

UI pushing
-- Inside onTick(delta), after the detection and caching logic:

-- Format and sort the track list for the UI
local formattedSongs = {}
local totalPlayTime = 0

for songName, songData in pairs(spotify_history) do
    totalPlayTime = totalPlayTime + songData.play_time
    table.insert(formattedSongs, {
        name       = songName,
        artist     = songData.artist,
        duration   = HPR.formatTime_HHMMSS_E(math.floor(songData.play_time)),
        duration_i = math.floor(songData.play_time)
    })
end

-- Sort descending so the most-played song is at the top
table.sort(formattedSongs, function(songA, songB)
    return songA.duration_i > songB.duration_i
end)

-- Push to the Slint UI
HPR.setUiProperty_E("spotifySongs_S", formattedSongs)
HPR.setUiProperty_E("trackedTime_Spotify_S", math.floor(totalPlayTime))
Property name convention

Property names ending with _S is a convention used by HPR to indicate properties that are set from the extension system. The name you pass to setUiProperty_E must exactly match the in property name defined in the Slint UI file. If the names don't match, the property simply won't update — no error is thrown.

Step 8 — Add a custom UI panel in Slint

HPR's UI is defined in .slint files located in your HPR configuration folder. The Slint interpreter loads these files at runtime, which means you can modify the UI without recompiling HPR. You need to make changes to three files:

8a. Define your data struct in types.slint

The Slint UI needs to know the shape of the data you are pushing from Lua. Open types.slint in your HPR UI folder and add a new struct definition at the end of the file. The field names must exactly match the keys in the Lua tables you push via setUiProperty_E.

Linux
~/.config/HPR/ui/types.slint
Windows
%APPDATA%\HPR\HPR_Config\ui\types.slint

Add this struct at the end of the file:

types.slint
export struct SpotifySong {
    name: string,
    artist: string,
    duration: string,
    duration_i: int,
}

8b. Create the view component

Create a new file called spotify-view.slint in your HPR UI folder. This component receives the data you push from Lua and renders it as a scrollable list of song rows with progress bars and colored indicators. You do not need to understand the Slint syntax in detail — just paste this file as-is.

Linux
~/.config/HPR/ui/spotify-view.slint
Windows
%APPDATA%\HPR\HPR_Config\ui\spotify-view.slint

Paste this entire content into the file:

spotify-view.slint
import { Card } from "./card.slint";
import { SectionHeader } from "./section-header.slint";
import { SpotifySong } from "./types.slint";

component SpotifyRow inherits Rectangle {
    in property <string> song-title;
    in property <string> artist-name;
    in property <string> duration;
    in property <float>  ratio;
    in property <bool>   is-even;
    in property <int>    row-index;
    in property <color>  bar-color;

    height: 48px;
    border-radius: 5px;
    clip: true;

    background: ta.has-hover ? #ffffff0e : (is-even ? #ffffff03 : transparent);
    animate background { duration: 100ms; easing: ease-out; }

    HorizontalLayout {
        padding-left: 16px;
        padding-right: 14px;
        spacing: 12px;

        Text {
            text: (row-index + 1) + ".";
            color: #ffffff20;
            font-size: 10px;
            font-weight: 600;
            vertical-alignment: center;
            width: 18px;
        }

        Rectangle {
            width: 3px;
            height: 20px;
            border-radius: 2px;
            background: ta.has-hover ? bar-color : bar-color.mix(#00000000, 0.45);
            animate background { duration: 100ms; }
            y: (parent.height - self.height) / 2;
        }

        VerticalLayout {
            padding-top: 8px;
            padding-bottom: 8px;
            spacing: 5px;
            horizontal-stretch: 1;

            HorizontalLayout {
                Text {
                    text: song-title + " - " + artist-name;
                    color: ta.has-hover ? #e2e8f0 : #94a3b8;
                    font-size: 12px;
                    vertical-alignment: center;
                    animate color { duration: 100ms; }
                    overflow: elide;
                    horizontal-stretch: 1;
                }

                Text {
                    text: duration;
                    color: ta.has-hover ? bar-color : bar-color.mix(#00000000, 0.45);
                    font-size: 10px;
                    font-weight: 600;
                    vertical-alignment: center;
                    animate color { duration: 100ms; }
                }
            }

            Rectangle {
                height: 4px;
                border-radius: 2px;
                background: #ffffff08;

                Rectangle {
                    x: 0;
                    height: 4px;
                    border-radius: 2px;
                    width: parent.width * ratio;
                    background: ta.has-hover
                        ? @linear-gradient(90deg, bar-color 0%, bar-color.mix(#ffffff, 0.3) 100%)
                        : @linear-gradient(90deg, bar-color.mix(#00000000, 0.45) 0%, bar-color.mix(#ffffff, 0.15).mix(#00000000, 0.45) 100%);
                    animate background { duration: 100ms; }
                    animate width { duration: 400ms; easing: ease-out; }
                }
            }
        }
    }
    ta := TouchArea {}
}

export component SpotifyView {
    in property <[SpotifySong]> spotifySongs_S;
    in property <int>           trackedTime_Spotify_S;

    VerticalLayout {
        padding: 12px;
        padding-top: 0px;
        spacing: 10px;

        HorizontalLayout {
            vertical-stretch: 0;

            SectionHeader {
                label: "SPOTIFY TRACKS";
                horizontal-stretch: 1;
            }
        }

        Card {
            vertical-stretch: 0;
            max-height: 360px;

            Flickable {
                width: 100%;
                height: 100%;
                interactive: true;
                viewport-height: spotifyContent.preferred-height;

                spotifyContent := VerticalLayout {
                    width: 100%;
                    for entry[i] in spotifySongs_S: SpotifyRow {
                        song-title: entry.name;
                        artist-name: entry.artist;
                        ratio: trackedTime_Spotify_S > 0
                            ? (entry.duration_i * 1.0) / (trackedTime_Spotify_S * 1.0)
                            : 0.0;
                        duration: entry.duration
                            + " (" + Math.round(self.ratio * 100) + "%" + ")";
                        is-even:   mod(i, 2) == 0;
                        row-index: i;
                        bar-color: Colors.hsv(
                            mod(entry.name.character-count * 61, 360),
                            0.70, 0.88
                        );
                    }
                }
            }
        }
    }
}

8c. Wire it into app-window.slint

The main UI file app-window.slint needs three additions to integrate your new view. Open the file and make these changes:

1. Add the import at the top of the file

import { SpotifyView } from "./spotify-view.slint";
import { SpotifySong } from "./types.slint";

2. Declare the input properties inside the MainWindow component

in property <[SpotifySong]>   spotifySongs_S;
in property <int>             trackedTime_Spotify_S;

3. Add the conditional view alongside the other views (DATA, INSIGHTS, TAB, etc.)

if active-view == "SPOTIFY" : SpotifyView {
    vertical-stretch: 1;
    spotifySongs_S: root.spotifySongs_S;
    trackedTime_Spotify_S: root.trackedTime_Spotify_S;
}

4. Add a sidebar button to switch to your view

SidebarButton {
    icon: @image-url("./images/live.svg");
    tip: "View Spotify Songs";
    clicked => { root.active-view = "SPOTIFY"; }
}

Step 9 — Handle midnight rollovers and historical database views

A crucial aspect of long-running tracker extensions is aligning perfectly with HPR's calendar lifecycle events. When the clock strikes midnight or when the user loads a historical day, your extension needs to react appropriately to maintain data consistency.

This is made possible by HPR's built-in Event Hub.

💡 Understanding the Event Hub: The "Radio Station" Analogy

Think of HPR's Event Hub like a central radio station. Different components in HPR broadcast messages on specific frequencies (or "channels"), and anyone who is listening can react:

  • Channels (Event Names): Channels are named strings like "MIDNIGHT_ROLLOVER" or "LOAD_LIVE_DATA".
  • Broadcasting (Emit): When the C++ database manager detects midnight, it turns on the microphone and shouts: "It is midnight!" on the "MIDNIGHT_ROLLOVER" channel. This is called emitting a signal.
  • Listening (Connect): In your extension, you tune your radio receiver to that channel. You tell HPR: "Whenever you hear a broadcast on the 'MIDNIGHT_ROLLOVER' channel, run this specific callback function!" This is called connecting a listener.
  • Turning Off (Disconnect): When you turn off your radio, you stop receiving broadcasts. In your code, you must disconnect when your extension is stopping so that you don't waste system memory.

The Event Hub is what lets C++ and Lua speak to each other seamlessly in real time!

The Event Hub Functions

To interface with the Event Hub, HPR provides three simple Lua functions:

  • HPR.connect_E("eventName", callback_function)
    Tunes into an event. When that event is fired, HPR will run your callback_function. Returns: A unique subscription ID (a positive integer). You must store this ID so you can disconnect later!
  • HPR.emit_E("eventName", data_table?)
    Broadcasts an event to all active listeners along with an optional table containing event data.
  • HPR.disconnect_E("eventName", subscription_id)
    Stops listening to the event. You pass the same subscription_id returned by connect_E. Always do this inside your onExit() function to prevent memory leaks!
⚠️ CRITICAL: Always Disconnect Subscriptions to Avoid Memory Leaks!

Whenever you tune into an event using HPR.connect_E, HPR allocates a persistent listener hook in its C++ event registry. If your extension shuts down, gets reloaded, or is disabled, and you fail to call HPR.disconnect_E, this hook becomes a zombie reference.

Zombie hooks prevent the Lua garbage collector from freeing up memory, leading to major **application memory leaks, UI lag, and potential crashes**. Rule of thumb: Every single subscription created with HPR.connect_E must have a matching HPR.disconnect_E inside your onExit() handler.

To learn how to emit custom signals and establish two-way communication between multiple extensions, you can refer to the next tutorial: Communication between extensions.

For this tutorial, let's explore how to leverage the Event Hub to handle daily rollovers and calendar view switches:

1. Midnight Rollovers

When the day shifts, HPR automatically creates a new daily SQLite file and fires the "MIDNIGHT_ROLLOVER" signal. By connecting to this signal, you can reset today's active in-memory counters (spotify_history = {}) so your daily trackers always start fresh.

-- Inside init():
midnightId = HPR.connect_E("MIDNIGHT_ROLLOVER", function(data)
    print("Midnight detected! Clearing live daily table.")
    spotify_history = {}
end)

2. Historical Views

When the user clicks a previous calendar day in the UI, HPR loads that day's statistics and fires the "LOAD_DATABASE_SINGULAR" signal. To display your custom tracking data for that day, HPR provides a specialized historical querying function: HPR.dbQueryHistorical_E(sql, params?).

This automatically queries the database file of the loaded historical day directly, without any manual file-path manipulation or raw SQL attachments! We save the queried results into a separate past_spotify_history table and toggle a state variable is_showing_historical = true.

-- Inside init():
pastDbId = HPR.connect_E("LOAD_DATABASE_SINGULAR", function(data)
    local success, histRows = pcall(HPR.dbQueryHistorical_E, "SELECT song_name, artist_name, play_duration_ms FROM spotify_songs;")
    
    if success and histRows and #histRows > 0 then
        past_spotify_history = {}
        for _, row in ipairs(histRows) do
            past_spotify_history[row.song_name] = {
                artist = row.artist_name,
                play_time = tonumber(row.play_duration_ms) or 0
            }
        end
        is_showing_historical = true
    else
        -- If no past records exist or it's empty, fall back to today's active live data
        past_spotify_history = {}
        is_showing_historical = false
    end
end)

3. Returning to Live View

When the user exits historical mode and returns back to the active daily dashboard, HPR fires the "LOAD_LIVE_DATA" signal. We listen to this and immediately set is_showing_historical = false so our UI updates with today's real-time statistics.

-- Inside init():
liveDbId = HPR.connect_E("LOAD_LIVE_DATA", function(data)
    is_showing_historical = false
end)

Step 10 — Clean up resources on exit using onExit()

The final piece of the HPR lifecycle is the onExit() hook. This function is automatically invoked exactly once by the C++ engine when the HPR application is shutting down or when your extension is being stopped.

Use this hook to write any unsaved tracking data to the SQLite database (ensuring zero data loss on exit) and cleanly disconnect all active Event Hub listeners to keep the operating system's memory clean and leak-free.

cleanup
function onExit()
    -- Force a final database save to persist today's listening time
    flushToDatabase()
    
    -- Clean up event subscriptions to prevent memory leaks
    HPR.disconnect_E("MIDNIGHT_ROLLOVER", midnightId)
    HPR.disconnect_E("LOAD_DATABASE_SINGULAR", pastDbId)
    HPR.disconnect_E("LOAD_LIVE_DATA", liveDbId)
end

The Complete Extension

Putting everything together, here is the complete Spotify tracker extension. This is the full main.lua file — copy it into your extensions folder and it works out of the box.

main.lua
local spotify_history = {}          -- Live daily data
local past_spotify_history = {}     -- Loaded historical data
local is_showing_historical = false -- View state toggler
local time_since_last_flush = 0

local midnightId = 0
local pastDbId = 0
local liveDbId = 0

-- Parse: "Artist - Song" -> songName, artistName
local function parseSpotifyTitle(windowTitle)
    local hyphen_start, hyphen_end = string.find(windowTitle, " %- ")
    if not hyphen_start then
        return nil, nil
    end
    local artistName = string.sub(windowTitle, 1, hyphen_start - 1):match("^%s*(.-)%s*$")
    local songName   = string.sub(windowTitle, hyphen_end + 1):match("^%s*(.-)%s*$")
    return songName, artistName
end

function flushToDatabase()
    for songName, songData in pairs(spotify_history) do
        HPR.dbExecute_E(
            "insert or replace into spotify_songs (song_name, artist_name, play_duration_ms) values (?, ?, ?);",
            { songName, songData.artist, tostring(math.floor(songData.play_time)) }
        )
    end
end

function init()
    HPR.authorName    = "Plexescor"
    HPR.extensionName = "Spotify Watcher HPR Extension"

    -- Initialize database table
    HPR.dbExecute_E([[
        create table if not exists spotify_songs (
            song_name text unique,
            artist_name text,
            play_duration_ms int
        );
    ]])

    -- Load today's live songs on startup
    local dbRows = HPR.dbQuery_E("select song_name, artist_name, play_duration_ms from spotify_songs;")
    for rowIndex, row in ipairs(dbRows) do
        spotify_history[row.song_name] = {
            artist    = row.artist_name,
            play_time = tonumber(row.play_duration_ms) or 0
        }
    end

    -- Clock strikes midnight
    midnightId = HPR.connect_E("MIDNIGHT_ROLLOVER", function(data)
        spotify_history = {}
    end)

    -- Historical db loaded
    pastDbId = HPR.connect_E("LOAD_DATABASE_SINGULAR", function(data)
        local success, histRows = pcall(HPR.dbQueryHistorical_E, "SELECT song_name, artist_name, play_duration_ms FROM spotify_songs;")

        if success and histRows and #histRows > 0 then
            past_spotify_history = {}
            for _, row in ipairs(histRows) do
                past_spotify_history[row.song_name] = {
                    artist    = row.artist_name,
                    play_time = tonumber(row.play_duration_ms) or 0
                }
            end
            is_showing_historical = true
        else
            -- Fall back if empty
            past_spotify_history = {}
            is_showing_historical = false
        end
    end)

    -- Back to live view
    liveDbId = HPR.connect_E("LOAD_LIVE_DATA", function(data)
        is_showing_historical = false
    end)

    print("[Spotify Extension] Loaded successfully.")
    return 150 -- Tick rate
end

function onTick(delta)
    -- Always track active live songs in background
    local activeWindow = HPR.getCurrentWindow_E()
    local activeTitle  = HPR.getCurrentTitle_E()
    local isWindowValid   = activeWindow and activeTitle
    
    local isSpotifyActive = isWindowValid and string.find(string.lower(activeWindow), "spotify")
    if isSpotifyActive then
        local songName, artistName = parseSpotifyTitle(activeTitle)
        if songName and artistName then
            if not spotify_history[songName] then
                spotify_history[songName] = { artist = artistName, play_time = 0 }
            end
            spotify_history[songName].play_time = spotify_history[songName].play_time + delta
        end
    end

    -- Select active data source
    local active_source = is_showing_historical and past_spotify_history or spotify_history

    -- Format and send list to Slint UI
    local formattedSongs = {}
    local totalPlayTime = 0
    for songName, songData in pairs(active_source) do
        totalPlayTime = totalPlayTime + songData.play_time
        table.insert(formattedSongs, {
            name       = songName,
            artist     = songData.artist,
            duration   = HPR.formatTime_HHMMSS_E(math.floor(songData.play_time)),
            duration_i = math.floor(songData.play_time)
        })
    end
    
    table.sort(formattedSongs, function(songA, songB)
        return songA.duration_i > songB.duration_i
    end)

    HPR.setUiProperty_E("spotifySongs_S", formattedSongs)
    HPR.setUiProperty_E("trackedTime_Spotify_S", math.floor(totalPlayTime))

    -- Save to local db every 10s
    time_since_last_flush = time_since_last_flush + delta
    if time_since_last_flush >= 10000 then
        pcall(flushToDatabase)
        time_since_last_flush = 0
    end
end

function flushToDatabase()
    for songName, songData in pairs(spotify_history) do
        HPR.dbExecute_E(
            "insert or replace into spotify_songs (song_name, artist_name, play_duration_ms) values (?, ?, ?);",
            { songName, songData.artist, tostring(math.floor(songData.play_time)) }
        )
    end
end
What you learned

This tutorial covered every major part of the HPR Extension API:

Window detectiongetCurrentWindow_E() and getCurrentTitle_E() to identify and read active applications
Data extraction — Parsing structured information from window titles using Lua string manipulation
In-memory caching — Module-level Lua tables for accumulating live data between ticks
Database persistencedbExecute_E() and dbQuery_E() for saving and loading data across sessions
Tick rate configuration — Returning an integer from init() to control how often your extension runs
Live UI updatessetUiProperty_E() for pushing real-time data into HPR's Slint UI
Slint UI integration — Creating a view component, defining data structs, and wiring everything into the main window
Event Hub & Messaging — Subscribing to central system events like "MIDNIGHT_ROLLOVER" and "LOAD_LIVE_DATA" using HPR.connect_E()
Historical DB Queries — Safely querying loaded past database files directly using HPR.dbQueryHistorical_E()
Lifecycle Teardown & Safety — Implementing the onExit() hook to write final data and unregistering subscription IDs with HPR.disconnect_E() to avoid memory leaks

For a complete reference of all available API functions, see the Extension API Reference.