From ad23c7e466df1b52725d8890bffd9987881cfa22 Mon Sep 17 00:00:00 2001
From: Bye <bye@byecorps.com>
Date: Sat, 5 Apr 2025 12:08:36 +0100
Subject: [PATCH] A somewhat working player!

---
 .idea/workspace.xml                 |  63 +++++----
 waves/examples/hls.test.html        |  10 ++
 waves/examples/player_basic.html    |  92 ++++++++++++-
 waves/src/embed.html                |   2 -
 waves/src/misc/sorting.js           |  44 ++++++
 waves/src/sources/radio-browser.js  |  52 +++++++
 waves/src/sources/rteProvider.js    |   1 +
 waves/src/sources/searchProvider.js |  16 +++
 waves/src/station.js                |  30 +++--
 waves/src/waves.js                  | 202 +++++++++++++++++++++-------
 10 files changed, 422 insertions(+), 90 deletions(-)
 create mode 100644 waves/examples/hls.test.html
 create mode 100644 waves/src/misc/sorting.js
 create mode 100644 waves/src/sources/radio-browser.js
 create mode 100644 waves/src/sources/rteProvider.js
 create mode 100644 waves/src/sources/searchProvider.js

diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index f725a49..1886620 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,14 +4,17 @@
     <option name="autoReloadType" value="SELECTIVE" />
   </component>
   <component name="ChangeListManager">
-    <list default="true" id="dc2eda89-ce72-4bfb-9c82-f9fb4ab5dde0" name="Changes" comment="Add NPM boilerplate">
-      <change afterPath="$PROJECT_DIR$/waves/examples/assets/noise.wav" afterDir="false" />
-      <change afterPath="$PROJECT_DIR$/waves/examples/embed_basic.html" afterDir="false" />
-      <change afterPath="$PROJECT_DIR$/waves/examples/player_basic.html" afterDir="false" />
-      <change afterPath="$PROJECT_DIR$/waves/src/embed.html" afterDir="false" />
-      <change afterPath="$PROJECT_DIR$/waves/src/station.js" afterDir="false" />
-      <change afterPath="$PROJECT_DIR$/waves/src/waves.js" afterDir="false" />
+    <list default="true" id="dc2eda89-ce72-4bfb-9c82-f9fb4ab5dde0" name="Changes" comment="We're playing audio!!!">
+      <change afterPath="$PROJECT_DIR$/waves/examples/hls.test.html" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/waves/src/misc/sorting.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/waves/src/sources/radio-browser.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/waves/src/sources/rteProvider.js" afterDir="false" />
+      <change afterPath="$PROJECT_DIR$/waves/src/sources/searchProvider.js" afterDir="false" />
       <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
+      <change beforePath="$PROJECT_DIR$/waves/examples/player_basic.html" beforeDir="false" afterPath="$PROJECT_DIR$/waves/examples/player_basic.html" afterDir="false" />
+      <change beforePath="$PROJECT_DIR$/waves/src/embed.html" beforeDir="false" afterPath="$PROJECT_DIR$/waves/src/embed.html" afterDir="false" />
+      <change beforePath="$PROJECT_DIR$/waves/src/station.js" beforeDir="false" afterPath="$PROJECT_DIR$/waves/src/station.js" afterDir="false" />
+      <change beforePath="$PROJECT_DIR$/waves/src/waves.js" beforeDir="false" afterPath="$PROJECT_DIR$/waves/src/waves.js" afterDir="false" />
     </list>
     <option name="SHOW_DIALOG" value="false" />
     <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -36,22 +39,22 @@
     </option>
     <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
   </component>
-  <component name="GitLabMergeRequestFiltersHistory"><![CDATA[{
-  "lastFilter": {
-    "state": "OPENED",
-    "assignee": {
-      "type": "org.jetbrains.plugins.gitlab.mergerequest.ui.filters.GitLabMergeRequestsFiltersValue.MergeRequestsMemberFilterValue.MergeRequestsAssigneeFilterValue",
-      "username": "bye",
-      "fullname": "Bye"
+  <component name="GitLabMergeRequestFiltersHistory">{
+  &quot;lastFilter&quot;: {
+    &quot;state&quot;: &quot;OPENED&quot;,
+    &quot;assignee&quot;: {
+      &quot;type&quot;: &quot;org.jetbrains.plugins.gitlab.mergerequest.ui.filters.GitLabMergeRequestsFiltersValue.MergeRequestsMemberFilterValue.MergeRequestsAssigneeFilterValue&quot;,
+      &quot;username&quot;: &quot;bye&quot;,
+      &quot;fullname&quot;: &quot;Bye&quot;
     }
   }
-}]]></component>
-  <component name="GitLabMergeRequestsSettings"><![CDATA[{
-  "selectedUrlAndAccountId": {
-    "first": "https://shinonome.rocks/bye/waves.git",
-    "second": "348e169e-b102-4043-9c6f-b59fa6d1c8d2"
+}</component>
+  <component name="GitLabMergeRequestsSettings">{
+  &quot;selectedUrlAndAccountId&quot;: {
+    &quot;first&quot;: &quot;https://shinonome.rocks/bye/waves.git&quot;,
+    &quot;second&quot;: &quot;348e169e-b102-4043-9c6f-b59fa6d1c8d2&quot;
   }
-}]]></component>
+}</component>
   <component name="ProjectColorInfo">{
   &quot;associatedIndex&quot;: 0
 }</component>
@@ -87,11 +90,11 @@
       <recent name="$PROJECT_DIR$" />
     </key>
     <key name="MoveFile.RECENT_KEYS">
+      <recent name="$PROJECT_DIR$/waves/src/sources" />
       <recent name="$PROJECT_DIR$/waves/examples" />
       <recent name="$PROJECT_DIR$/public" />
       <recent name="$PROJECT_DIR$/src" />
       <recent name="$PROJECT_DIR$" />
-      <recent name="$PROJECT_DIR$/dist" />
     </key>
   </component>
   <component name="RunManager" selected="npm.start">
@@ -149,7 +152,10 @@
       <workItem from="1741975972036" duration="5487000" />
       <workItem from="1742126058949" duration="567000" />
       <workItem from="1742499282237" duration="6271000" />
-      <workItem from="1742575186970" duration="7655000" />
+      <workItem from="1742575186970" duration="8123000" />
+      <workItem from="1743364249485" duration="16000" />
+      <workItem from="1743776325534" duration="22032000" />
+      <workItem from="1743849081632" duration="1848000" />
     </task>
     <task id="LOCAL-00001" summary="Lets start over. (Not bumping version because thats stupid)">
       <option name="closed" value="true" />
@@ -167,7 +173,15 @@
       <option name="project" value="LOCAL" />
       <updated>1742499870306</updated>
     </task>
-    <option name="localTasksCounter" value="3" />
+    <task id="LOCAL-00003" summary="We're playing audio!!!">
+      <option name="closed" value="true" />
+      <created>1742594326850</created>
+      <option name="number" value="00003" />
+      <option name="presentableId" value="LOCAL-00003" />
+      <option name="project" value="LOCAL" />
+      <updated>1742594326850</updated>
+    </task>
+    <option name="localTasksCounter" value="4" />
     <servers />
   </component>
   <component name="TypeScriptGeneratedFilesManager">
@@ -187,6 +201,7 @@
   <component name="VcsManagerConfiguration">
     <MESSAGE value="Lets start over. (Not bumping version because thats stupid)" />
     <MESSAGE value="Add NPM boilerplate" />
-    <option name="LAST_COMMIT_MESSAGE" value="Add NPM boilerplate" />
+    <MESSAGE value="We're playing audio!!!" />
+    <option name="LAST_COMMIT_MESSAGE" value="We're playing audio!!!" />
   </component>
 </project>
\ No newline at end of file
diff --git a/waves/examples/hls.test.html b/waves/examples/hls.test.html
new file mode 100644
index 0000000..29a53e8
--- /dev/null
+++ b/waves/examples/hls.test.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>Title</title>
+</head>
+<body>
+<audio controls src="https://www.rte.ie/manifests/pulse.m3u8"></audio>
+</body>
+</html>
\ No newline at end of file
diff --git a/waves/examples/player_basic.html b/waves/examples/player_basic.html
index 4361383..45b5ea7 100644
--- a/waves/examples/player_basic.html
+++ b/waves/examples/player_basic.html
@@ -5,13 +5,101 @@
     <title>waves ~ basic player</title>
 </head>
 <body>
+
+    <p>This page showcases the features of Waves.</p>
+    <hr>
+    <p>Stations:</p>
+    <ul id="stationlist"></ul>
+    <hr>
+    <p><button id="playpause-button">Play</button></p>
+    <p>State: <span id="waves-state">unknown</span></p>
+    <p>Now playing: <span id="waves-np">unknown</span></p>
+    <hr>
+    <p>Add a station:</p>
+    <p>
+        <label for="newstation_name">Name</label> <input type="text" id="newstation_name" />
+        <label for="newstation_url">URL</label> <input type="text" id="newstation_url" />
+        <button id="addandtune">Add & Tune</button>
+    </p>
+    <hr>
+    <p><label for="search">Search</label> <input type="search" name="search" id="search" /><button id="waves-search">Search</button> <span id="loading"></span></p>
+    <ul id="results"></ul>
+
     <script type="module">
         import Waves from "../src/waves.js";
         import {URLStation} from "../src/station.js";
 
+        import RadioBrowser from "../src/sources/radio-browser.js";
+
         const waves = new Waves();
-        waves.loadingSoundElem.src = "./assets/noise.wav";
-        waves.loadingSoundElem.load();
+        const radioBrowser = new RadioBrowser();
+
+        waves.addEventListener("stateChanged", ev => {
+            let label = "";
+
+            switch (ev.detail.state) {
+                case "playing":
+                    label = "Stop";
+                    break;
+                case "stopped":
+                    label = "Play";
+                    break;
+                case "loading":
+                    label = "Loading...";
+            }
+
+            document.getElementById("playpause-button").innerText = label;
+            document.getElementById("waves-state").innerText = ev.detail.state;
+            document.getElementById("waves-np").innerText = ev.detail.station.name;
+
+        });
+
+        waves.addEventListener("stationChanged", ev => {
+            document.getElementById("waves-np").innerText = ev.detail.station.name;
+        })
+
+        waves.addEventListener("stationAdded", ev => {
+            const li = document.createElement("li");
+            li.innerHTML = `<img style="vertical-align: middle" height="32" src="${ev.detail.station.icon}" /> ${ev.detail.station.name}: ${ev.detail.station.url} <button onclick="waves.setStation('${ev.detail.station.name}')">Tune</button>`;
+            document.getElementById("stationlist").appendChild(li);
+        });
+
+        document.getElementById("addandtune").addEventListener("click", _=>{
+            waves.addStation(
+                new URLStation(
+                    document.getElementById("newstation_name").value,
+                    document.getElementById("newstation_url").value,
+                )
+            )
+            waves.setStation(document.getElementById("newstation_name").value)
+            document.getElementById("newstation_name").value    = ""
+            document.getElementById("newstation_url").value     = ""
+        })
+
+        document.getElementById("playpause-button").addEventListener("click", _ => { waves.playpause() })
+
+        document.getElementById("waves-search").addEventListener("click", async _=> {
+            document.getElementById("loading").innerText = "Loading...";
+            const stations = await waves.searchStations(document.getElementById("search").value, [radioBrowser]);
+            const results = document.getElementById("results");
+            results.innerHTML = "";
+            stations.map(station => {
+                const li = document.createElement("li");
+                li.innerHTML = `<img style="vertical-align: middle" height="32" src="${station.icon}" /> ${station.name}: ${station.url} — ${station.lev}`;
+                const button = document.createElement("button");
+                button.innerText = "Tune";
+                button.addEventListener('click', _ => {
+                    console.log(station);
+                    waves.addStation(station);
+                    waves.setStation(station.name)
+                })
+                li.appendChild(button);
+                results.appendChild(li)
+            })
+            document.getElementById("loading").innerText = "";
+        })
+
+        waves.loadingSound = "./assets/noise.wav";
 
         const testStation = new URLStation("tilde", "https://tilde.radikan.byecorps.net/listen/tilde/radio.mp3");
         waves.addStation(testStation);
diff --git a/waves/src/embed.html b/waves/src/embed.html
index 30f86f2..ca39c38 100644
--- a/waves/src/embed.html
+++ b/waves/src/embed.html
@@ -6,5 +6,3 @@
     Code for quickly embedding waves on another website.
 
 -->
-
-
diff --git a/waves/src/misc/sorting.js b/waves/src/misc/sorting.js
new file mode 100644
index 0000000..ed09dba
--- /dev/null
+++ b/waves/src/misc/sorting.js
@@ -0,0 +1,44 @@
+
+const levDistance = (a, b) => {
+    // Calculates the Levenshtein distance between `a` and `b`
+    // Adapted from the pseudocode on https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm
+
+    a = a.toLowerCase()
+    b = b.toLowerCase()
+
+    const lenA = a.length;  // |a|
+    const lenB = b.length;  // |b|
+
+    if (lenA === 0) return lenB;
+    if (lenB === 0) return lenA;
+
+    let d = new Array(lenA+1).fill(Array(lenB+1).fill(0));
+
+    for (let i = 1; i < lenA; i++) {
+        d[i][0] = i
+    }
+
+    for (let j = 1; j < lenB; j++) {
+        d[0][j] = j
+    }
+
+    let substitutionCost;
+    for (let j = 1; j <= lenB; j++) {
+        for (let i = 1; i <= lenA; i++) {
+            if (a.substring(i, i+1) === b.substring(j, j+1)) {
+                substitutionCost = 0
+            } else {
+                substitutionCost = 1
+            }
+
+            d[i][j] = Math.min(
+                d[i-1][j] + 1,              // Deletion
+                d[i][j-1] + 1,                  // Insertion
+                d[i-1][j-1] + substitutionCost) // Substitution
+        }
+    }
+
+    return d[lenA][lenB]
+}
+
+export {levDistance};
diff --git a/waves/src/sources/radio-browser.js b/waves/src/sources/radio-browser.js
new file mode 100644
index 0000000..1114eb8
--- /dev/null
+++ b/waves/src/sources/radio-browser.js
@@ -0,0 +1,52 @@
+import SearchProvider from "./searchProvider.js";
+import {URLStation} from "../station.js";
+
+export default class RadioBrowser extends SearchProvider {
+    // Some of these functions are based off https://api.radio-browser.info/examples/serverlist-browser.js
+
+    constructor(props) {
+        super(props);
+
+        this.baseURL = "http://all.api.radio-browser.info";
+        this.getRandomAPIServer().then(data=>{this.baseURL = data})
+    }
+
+    async getAPIBaseURLs() {
+        const request = await this.fetch("http://all.api.radio-browser.info/json/servers");
+        if (request.status >= 200 && request.status < 300) {
+            const items = (await request.json()).map(x=>"https://"+x.name);
+            return items;
+        }
+        throw Error(`HTTP Error ${request.status} — ${request.statusText}`)
+    }
+
+    async getAPISettings() {
+        const request = await this.fetch("http://all.api.radio-browser.info/json/config");
+        if (request.status >= 200 && request.status < 300) {
+            return await request.json();
+        }
+        throw Error(`HTTP Error ${request.status} — ${request.statusText}`)
+    }
+
+    async getRandomAPIServer() {
+        const hosts = await this.getAPIBaseURLs()
+        return hosts[Math.floor(Math.random() * hosts.length)];
+    }
+
+    async search(query) {
+        const params = new URLSearchParams();
+        params.set("name", query);
+        params.set("hidebroken", true);
+        const url = new URL(`${this.baseURL}/json/stations/search`);
+        url.search = params.toString()
+        const results = await this.fetch(url.toString());
+        const output = [];
+        for (const station of await results.json()) {
+            const newStation = new URLStation(station.name, station.url);
+            newStation.icon = station.favicon || newStation.icon;
+            output.push(newStation)
+        }
+
+        return output;
+    }
+}
diff --git a/waves/src/sources/rteProvider.js b/waves/src/sources/rteProvider.js
new file mode 100644
index 0000000..293511a
--- /dev/null
+++ b/waves/src/sources/rteProvider.js
@@ -0,0 +1 @@
+// https://www.rte.ie/manifests/pulse.m3u8
diff --git a/waves/src/sources/searchProvider.js b/waves/src/sources/searchProvider.js
new file mode 100644
index 0000000..3a0788e
--- /dev/null
+++ b/waves/src/sources/searchProvider.js
@@ -0,0 +1,16 @@
+
+export default class SearchProvider {
+    constructor() {
+        this.userAgent = "waves js library/5.0.0";
+    }
+
+    async fetch(resource, options={}) {
+        options.userAgent = this.userAgent;
+        return fetch(resource,
+                    options)
+    }
+
+    search(query) {
+        return [];
+    }
+}
diff --git a/waves/src/station.js b/waves/src/station.js
index 183c78a..d24acbc 100644
--- a/waves/src/station.js
+++ b/waves/src/station.js
@@ -2,16 +2,9 @@
 class Station {
     constructor(name) {
         this.name = name;
+        this.icon = "data:image/png;base64,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";
         this.player = new Audio();
 
-        this.player.addEventListener("seeking", _ => {
-            console.log(`${this.name}: Started seeking... (${this.player.duration}, ${this.player.currentTime})`);
-        });
-
-        this.player.addEventListener("seeked", _ => {
-            console.log(`${this.name}: Seeked... (${this.player.duration}, ${this.player.currentTime})`);
-        });
-
         this.metadataSource = undefined;
     }
 
@@ -21,7 +14,7 @@ class Station {
         this.player.addEventListener("canplay", _=>{
             this.player.play();
 
-            console.log(`${this.name}: Started playing... (${this.player.duration}, ${this.player.currentTime}, ${this.player.src})`);
+            // console.log(`${this.name}: Started playing... (${this.player.duration}, ${this.player.currentTime}, ${this.player.src})`);
         }, {once: true});
 
     }
@@ -42,9 +35,9 @@ class URLStation extends Station {
     constructor(props, url) {
         super(props);
         this.url = url;
-        this.doCaching = false; // Append a random number to the filename to avoid caching issues.
+        this.doCaching = false;                     // Append a random number to the filename to avoid caching issues.
 
-        this.player.src = this.url;
+        // this.player.src = this.url; // Prevent loading every time, only on first play.
         this.player.crossOrigin = "crossorigin";
     }
 
@@ -57,5 +50,20 @@ class URLStation extends Station {
     }
 }
 
+class HLSStation extends Station {
+    /**
+     * Defines a station similar to a URLStation but using HLS as the streaming format.
+     * Uses HLS.js to handle playback
+     *
+     * @param props
+     * @param url
+     * @param fallbackStream
+     */
+
+    constructor(props, url, fallbackStream=null) {
+        super(props);
+    }
+}
+
 export default Station;
 export {URLStation};
diff --git a/waves/src/waves.js b/waves/src/waves.js
index 03b5791..c724c48 100644
--- a/waves/src/waves.js
+++ b/waves/src/waves.js
@@ -1,13 +1,30 @@
 import Station  from "./station.js";
+import {levDistance} from "./misc/sorting.js";
 
-class Waves {
+function supportsMSA() {
+    // Returns a boolean signalling if the browser supports the Media Source API (and therefore HLS, admittedly not natively)
+    return !!(window['MediaSource'] || window['WebKitMediaSource']); // Gets the objects, inverts it (converting it to bool), inverts it again (shows if initial value was truthy or falsy)
+}
+
+function supportsHLS() {
+    // Returns a boolean signalling if the browser supports HLS natively (Safari + mobile browsers)
+    // https://stackoverflow.com/questions/40039076/how-can-i-precisely-detect-hls-support-on-different-browsers-and-different-os
+    var video = document.createElement('video');
+    return Boolean(video.canPlayType('application/vnd.apple.mpegURL') || video.canPlayType('audio/mpegurl'))
+}
+
+class Waves extends EventTarget {
     constructor() {
-        this.stations = {};
+        super();
+
+        window.waves = this;                                // Register self to window. This won't cause issues at all!
 
-        this.audioContext = new AudioContext();
-        this.gainNode = this.audioContext.createGain();
+        this.stations       = {};
 
-        this.loadingSoundElem = new Audio(); // Can be set by client.
+        this.audioContext   = new AudioContext();
+        this.gainNode       = this.audioContext.createGain();
+
+        this.loadingSoundElem = new Audio();                // Can be set by client.
         this.loadingSoundElem.loop = true;
 
         this.currentPlayer  = new Audio();
@@ -15,77 +32,160 @@ class Waves {
         this.currentTrack   = this.audioContext.createMediaElementSource(this.currentPlayer);
         this.currentTrack.connect(this.gainNode).connect(this.audioContext.destination);
 
+        this.realState    = "unknown";
+
         const loadingTrack = this.audioContext.createMediaElementSource(this.loadingSoundElem);
         loadingTrack.connect(this.gainNode).connect(this.audioContext.destination);
+
+        // Check if the client supports HLS
+        this.supportsHLS = supportsHLS();
+        this.supportsMSA = supportsMSA();
+
+        this.addEventListener("playing",  this.setStatePlaying)
+        // this.addEventListener("play",     this.setStatePlaying)
+        this.addEventListener("seeked",   this.setStatePlaying)
+        this.addEventListener("seeking",  this.setStateLoading)
+        this.addEventListener("waiting",  this.setStateLoading)
+        this.addEventListener("stalled",  this.setStateLoading)
+        this.addEventListener("ended",    this.setStateStopped)
+        this.addEventListener("pause",    this.setStateStopped)
+        this.addEventListener("error",    this.setStateLoading)
+    }
+
+    set loadingSound(url) {
+        this.loadingSound.src = url;
+        this.loadingSound.load()
+    }
+
+    get loadingSound() {
+        return this.loadingSoundElem
+    }
+
+    set state(state) {
+        this.realState = state;
+
+        const event = new CustomEvent("stateChanged", {
+            detail: {state: this.realState, station: this.currentStation}
+        });
+
+        this.dispatchEvent(event);
+    }
+
+    get state() {
+        return this.realState
+    }
+
+    setStatePlaying(ev=undefined) {
+        console.debug(`${this.currentStation.name}:`, ev ? ev.type : "Playing (generic)")
+        this.state = "playing";
+        this.loadingSound.pause();
+    }
+
+    setStateLoading(ev=undefined) {
+        console.debug(`${this.currentStation.name}:`, ev ? ev.type : "Loading (generic)")
+        this.state = "loading";
+        this.loadingSound.play();
+    }
+
+    setStateStopped(ev=undefined) {
+        console.debug(`${this.currentStation.name}:`, ev ? ev.type : "Stopped (generic)")
+        this.state = "stopped";
+        this.loadingSound.pause();
     }
 
-    setStation(station) {
+    raiseEvent(ev) {
+        // Forwards the event to Waves
+        // This is because `this` in this context is actually `this.currentPlayer`
+        const event = new Event(ev.type);
+        console.debug(`Raising ${ev.type} event from ${ev.target} to Waves...`)
+        waves.dispatchEvent(event);
+    }
+
+    setStation(station, preventAutoPlay=false) {
         this.currentStation.stop();
+        this.loadingSound.play();
 
         if (!this.stations[station]) {
             throw ReferenceError("Station not found.");
         }
 
+        // Disconnect the previous track
+        this.currentTrack.disconnect();
+        delete this.currentTrack;
+        // Remove event listeners
+        this.currentPlayer.removeEventListener("playing",  this.raiseEvent);
+        this.currentPlayer.removeEventListener("seeked",   this.raiseEvent);
+        this.currentPlayer.removeEventListener("seeking",  this.raiseEvent);
+        this.currentPlayer.removeEventListener("waiting",  this.raiseEvent);
+        this.currentPlayer.removeEventListener("stalled",  this.raiseEvent);
+        this.currentPlayer.removeEventListener("ended",    this.raiseEvent);
+        this.currentPlayer.removeEventListener("pause",    this.raiseEvent);
+        this.currentPlayer.removeEventListener("error",    this.raiseEvent);
+
+        // Switch the station over
         this.currentStation = this.stations[station];
         this.currentPlayer  = this.currentStation.player;
-        // this.currentPlayer.controls = "controls";
-        delete this.currentTrack;
+        // Make new audio track for the context
         this.currentTrack   = this.audioContext.createMediaElementSource(this.currentPlayer);
         this.currentTrack.connect(this.gainNode).connect(this.audioContext.destination);
 
-        this.currentPlayer.addEventListener("playing", ev=>{
-            console.debug(`${station}:`, ev)
-            this.loadingSoundElem.pause();
-        })
-
-        this.currentPlayer.addEventListener("play", ev=>{
-            console.debug(`${station}:`, ev)
-            this.loadingSoundElem.pause();
-        })
-
-        this.currentPlayer.addEventListener("seeked", ev=>{
-            console.debug(`${station}:`, ev)
-            this.loadingSoundElem.pause();
-        })
-
-        this.currentPlayer.addEventListener("seeking", ev=>{
-            console.debug(`${station}:`, ev)
-            this.loadingSoundElem.play();
-        })
-
-        this.currentPlayer.addEventListener("waiting", ev=>{
-            console.debug(`${station}:`, ev)
-            this.loadingSoundElem.play();
-        })
-
-        this.currentPlayer.addEventListener("stalled", ev=>{
-            console.debug(`${station}:`, ev)
-            this.loadingSoundElem.play();
-        })
-
-        this.currentPlayer.addEventListener("ended", ev=>{
-            console.debug(`${station}:`, ev)
-            this.loadingSoundElem.play();
-        })
-
-        this.currentPlayer.addEventListener("error", ev=>{
-            console.debug(`${station}:`, ev)
-            this.loadingSoundElem.play();
-        })
+        // Forward event listeners
+        this.currentPlayer.addEventListener("playing",  this.raiseEvent);
+        this.currentPlayer.addEventListener("seeked",   this.raiseEvent);
+        this.currentPlayer.addEventListener("seeking",  this.raiseEvent);
+        this.currentPlayer.addEventListener("waiting",  this.raiseEvent);
+        this.currentPlayer.addEventListener("stalled",  this.raiseEvent);
+        this.currentPlayer.addEventListener("ended",    this.raiseEvent);
+        this.currentPlayer.addEventListener("pause",    this.raiseEvent);
+        this.currentPlayer.addEventListener("error",    this.raiseEvent);
+
+        const event = new CustomEvent("stationChanged", {detail: {station}})
+        this.dispatchEvent(event)
+
+        if (!preventAutoPlay) this.currentStation.play();
     }
 
     addStation(station) {
         this.stations[station.name] = station;
-        console.log(`Added station ${station.name}`)
+        this.dispatchEvent(
+            new CustomEvent("stationAdded", {detail: {station}})
+        )
     }
 
     play() {
+        this.audioContext.resume().then(r => {console.log("Resumed audio context.")});
         this.currentStation.play();
     }
 
-    // Code that deals with embedding waves
-    embed() {
-        document.currentScript.insertAdjacentHTML("beforebegin", EMBED)
+    stop() {
+        this.currentStation.stop();
+    }
+
+    async searchStations(query, providers=[]) {
+        let output = [];
+        if (providers) {
+            for (const provider of providers) {
+                let results = await provider.search(query);
+                results.map(value => {value.lev = levDistance(value.name, query); output.push(value)});
+            }
+            output.sort((a, b) => {
+                if (a.lev < b.lev) return -1;
+                if (b.lev < a.lev) return 1;
+                return 0;
+            })
+        }
+        return output;
+    }
+
+    // Helper functions to speed up development
+    playpause() {
+        // Handles automatically choosing to play or pause.
+        if (this.state === "playing" || this.state === "loading") {
+            this.stop();
+        }
+        else {
+            this.play();
+        }
     }
 }
 
-- 
GitLab