diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c88ab18..62ac51a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -22,6 +22,7 @@ jobs: and (.kinds | sort) == (["bar-widget", "overlay"] | sort) and .entryPoints.barWidget == "BarWidget.qml" and .entryPoints.overlay == "StationPicker.qml" + and .barWidget.defaultSection == "left" ' manifest.json test -x subwave-fetch test -x subwave-player diff --git a/BarWidget.qml b/BarWidget.qml index 746fcea..042283e 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -23,6 +23,17 @@ BarWidget { readonly property string label: trackTitle ? trackTitle + (trackArtist ? " · " + trackArtist : "") : stationName + readonly property bool tooltipHovered: interactionArea.containsMouse + readonly property string tooltipText: StationModel.barTooltip({ + running: playerRunning, + paused: playerPaused, + stationName: stationName, + trackTitle: trackTitle, + trackArtist: trackArtist, + volume: playerVolume + }) + + onTooltipTextChanged: if (tooltipHovered && bar) bar.showTooltip(root, tooltipText) function applyStatus(raw) { try { @@ -134,6 +145,7 @@ BarWidget { } MouseArea { + id: interactionArea anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton @@ -144,10 +156,7 @@ BarWidget { else root.toggleOverlay() } onWheel: function(wheel) { root.changeVolume(wheel.angleDelta.y) } - onEntered: if (root.bar) root.bar.showTooltip(root, - (root.playerRunning ? (root.playerPaused ? "Paused · " : "Playing · ") : "Open · ") - + root.stationName + (root.trackTitle ? "\n" + root.label : "") - + " · " + root.playerVolume + "%") + onEntered: if (root.bar) root.bar.showTooltip(root, root.tooltipText) onExited: if (root.bar) root.bar.hideTooltip(root) } } diff --git a/README.md b/README.md index 7f0c2e7..deefd58 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ that works with Omarchy's existing media controls. ![SUB/WAVE station picker in Omarchy](preview.png) +## What is SUB/WAVE? + +SUB/WAVE is a self-hosted shared internet radio station: every listener hears +the same live broadcast. An AI DJ selects music from the station's library and +speaks between tracks, while the operator controls its sound, schedule, +personas, and listener requests. This Omarchy plugin lets you tune into your own +station or discover other public SUB/WAVE stations. + ## Install ```bash @@ -54,6 +62,7 @@ fragment. The plugin derives the API and stream paths from the origin. | Type | Search names, places, genres, operators, and descriptions | | Up / Down | Move through stations | | Enter | Play the selected station | +| ↗ button | Open that station in its web player without interrupting playback | | Escape | Clear search, then close | Selecting the station that is already playing closes the picker. Playback uses diff --git a/StationModel.js b/StationModel.js index 4369c4f..a58013d 100644 --- a/StationModel.js +++ b/StationModel.js @@ -25,6 +25,22 @@ function configuredStationUpdate(value) { } } +function barTooltip(value) { + var state = value && typeof value === "object" ? value : ({}) + var station = singleLine(state.stationName, 160) || "SUB/WAVE" + var title = singleLine(state.trackTitle, MAX_FIELD) + var artist = singleLine(state.trackArtist, MAX_FIELD) + var volume = Math.max(0, Math.min(100, Math.round(Number(state.volume) || 0))) + var status = state.running === true ? (state.paused === true ? "Paused" : "Playing") : "Open" + var details = station + " · " + status + " · " + volume + "%" + return title ? title + (artist ? " — " + artist : "") + "\n" + details : station + "\n" + status + " · " + volume + "%" +} + +function webPlayerCommand(value) { + var origin = normalizeOrigin(value) + return origin ? ["omarchy", "launch", "browser", origin] : [] +} + function fallbackSlug(name) { return singleLine(name, 80).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 49) } diff --git a/StationPicker.qml b/StationPicker.qml index 1c75d17..97ed7f2 100644 --- a/StationPicker.qml +++ b/StationPicker.qml @@ -160,6 +160,11 @@ Item { playProcess.running = true } + function openWebPlayer(url) { + var command = StationModel.webPlayerCommand(url) + if (command.length) Quickshell.execDetached(command) + } + function queueVisibleProbes() { var next = [] var limit = Math.min(24, visibleStations.count) @@ -541,8 +546,8 @@ Item { Text { id: status - anchors.right: parent.right - anchors.rightMargin: Style.space(14) + anchors.right: webPlayerButton.left + anchors.rightMargin: Style.space(10) anchors.verticalCenter: parent.verticalCenter text: root.playingUrl === url ? "PLAYING" : (!live ? "CHECKING" : (live.online ? "ON AIR" : "OFFLINE")) color: live && live.online ? root.accent : root.foreground @@ -551,6 +556,24 @@ Item { font.pixelSize: Style.font.caption font.bold: true } + + Button { + id: webPlayerButton + z: 1 + anchors.right: parent.right + anchors.rightMargin: Style.space(8) + anchors.verticalCenter: parent.verticalCenter + text: "↗" + tooltipText: "Open web player" + focusable: true + foreground: root.foreground + accent: root.accent + fontFamily: Style.font.menuFamily + fontSize: Style.font.body + horizontalPadding: Style.spacing.controlGap + verticalPadding: Style.spacing.labelGap + onClicked: root.openWebPlayer(url) + } } Text { diff --git a/manifest.json b/manifest.json index b4eb562..418320d 100644 --- a/manifest.json +++ b/manifest.json @@ -5,10 +5,10 @@ "version": "0.1.0", "author": "SUB/WAVE", "license": "MIT", - "description": "Listen to your SUB/WAVE station and discover community stations from the Omarchy bar.", + "description": "Tune into self-hosted AI DJ radio and discover public SUB/WAVE community stations.", "homepage": "https://github.com/getsubwave/omarchy-subwave", "repository": "https://github.com/getsubwave/omarchy-subwave", - "keywords": ["radio", "subwave", "music", "mpris"], + "keywords": ["radio", "subwave", "music", "mpris", "ai", "self-hosted"], "kinds": ["overlay", "bar-widget"], "keepLoaded": true, "entryPoints": { diff --git a/preview.png b/preview.png index 485bf0a..5353e78 100644 Binary files a/preview.png and b/preview.png differ diff --git a/tests/model.test.mjs b/tests/model.test.mjs index ab1b112..3cf1252 100644 --- a/tests/model.test.mjs +++ b/tests/model.test.mjs @@ -38,6 +38,36 @@ assert.deepEqual( assert.equal(model.configuredStationUpdate("https://user:pass@radio.example.com").ok, false) assert.equal(model.configuredStationUpdate("file:///tmp/stream").ok, false) +assert.equal(typeof model.barTooltip, "function") +assert.equal(model.barTooltip({ + running: true, + paused: false, + stationName: "ChillWave", + trackTitle: "Wish We Had History", + trackArtist: "Bexy", + volume: 65 +}), "Wish We Had History — Bexy\nChillWave · Playing · 65%") +assert.equal(model.barTooltip({ + running: false, + stationName: "SUB/WAVE", + volume: 70 +}), "SUB/WAVE\nOpen · 70%") +assert.equal(model.barTooltip({ + running: true, + paused: true, + stationName: "Chill\nWave", + trackTitle: "Track\nName", + volume: 101 +}), "Track Name\nChill Wave · Paused · 100%") + +assert.equal(typeof model.webPlayerCommand, "function") +assert.deepEqual( + Array.from(model.webPlayerCommand("https://radio.example.com/listen")), + ["omarchy", "launch", "browser", "https://radio.example.com"] +) +assert.deepEqual(Array.from(model.webPlayerCommand("https://user:pass@radio.example.com")), []) +assert.deepEqual(Array.from(model.webPlayerCommand("javascript:alert(1)")), []) + const rows = model.normalizeCatalog([ { slug: "zeta", name: "Zeta", url: "https://zeta.example", genre: "Jazz" }, { slug: "featured", name: "Featured", url: "https://featured.example", featured: true },