Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ MPRIS integration, and local MPV control socket. The plugin does not install or
modify system packages itself.

The community directory works immediately. To put your own self-hosted station
first in the list, configure its bare public origin:
first in the list, open the picker, choose **+ SELF-HOSTED**, and save its bare
public origin. You can configure the same setting from the terminal:

```bash
omarchy bar set getsubwave.radio stationUrl https://radio.example.com
Expand Down
26 changes: 21 additions & 5 deletions StationModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ function normalizeOrigin(value) {
return matched[1].toLowerCase() + "://" + matched[2]
}

function configuredStationUpdate(value) {
var raw = String(value || "").trim()
var url = raw ? normalizeOrigin(raw) : ""
if (raw && !url) return { ok: false, error: "Enter a bare HTTP(S) station origin" }
return {
ok: true,
url: url,
command: ["omarchy", "bar", "set", "getsubwave.radio", "stationUrl", url]
}
}

function fallbackSlug(name) {
return singleLine(name, 80).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 49)
}
Expand Down Expand Up @@ -60,13 +71,18 @@ function normalizeCatalog(value) {
}

function mergeConfigured(stations, stationUrl) {
var rows = normalizeCatalog(stations)
var normalized = normalizeCatalog(stations)
var rows = []
for (var i = 0; i < normalized.length; i++) {
if (normalized[i].slug === "__configured") continue
rows.push(Object.assign({}, normalized[i], { isConfigured: false }))
}
var origin = normalizeOrigin(stationUrl)
if (!origin) return rows
for (var i = 0; i < rows.length; i++) {
if (rows[i].url !== origin) continue
var matched = Object.assign({}, rows[i], { isConfigured: true })
rows.splice(i, 1)
for (var matchIndex = 0; matchIndex < rows.length; matchIndex++) {
if (rows[matchIndex].url !== origin) continue
var matched = Object.assign({}, rows[matchIndex], { isConfigured: true })
rows.splice(matchIndex, 1)
rows.unshift(matched)
return rows
}
Expand Down
214 changes: 198 additions & 16 deletions StationPicker.qml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Item {
property var manifest: null
property bool opened: false
property string configuredUrl: ""
property string configuredInput: ""
property bool editingConfigured: false
property bool savingConfigured: false
property string configuredError: ""
property var allStations: []
property string filterText: ""
property int selectedIndex: 0
Expand All @@ -37,6 +41,10 @@ Item {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (error) {}
configuredUrl = StationModel.normalizeOrigin(payload.stationUrl || "")
configuredInput = configuredUrl
editingConfigured = false
savingConfigured = false
configuredError = ""
opened = true
errorText = ""
filterText = ""
Expand Down Expand Up @@ -94,6 +102,44 @@ Item {
queueVisibleProbes()
}

function beginConfiguredEdit() {
configuredInput = configuredUrl
configuredError = ""
editingConfigured = true
Qt.callLater(function() { configuredField.forceActiveFocus() })
}

function cancelConfiguredEdit() {
configuredInput = configuredUrl
configuredError = ""
editingConfigured = false
Qt.callLater(function() { searchField.forceActiveFocus() })
}

function saveConfigured(value) {
if (settingProcess.running) return
var update = StationModel.configuredStationUpdate(value)
if (!update.ok) {
configuredError = update.error
return
}
configuredError = ""
savingConfigured = true
settingProcess.pendingUrl = update.url
settingProcess.command = update.command
settingProcess.running = true
}

function applyConfigured(url) {
configuredUrl = url
configuredInput = url
allStations = StationModel.mergeConfigured(allStations, configuredUrl)
rebuildVisible()
queueVisibleProbes()
editingConfigured = false
Qt.callLater(function() { searchField.forceActiveFocus() })
}

function moveSelection(delta) {
if (!visibleStations.count) return
selectedIndex = (selectedIndex + delta + visibleStations.count) % visibleStations.count
Expand Down Expand Up @@ -198,6 +244,19 @@ Item {
}
}

Process {
id: settingProcess
property string pendingUrl: ""
command: []
stdout: StdioCollector { id: settingOutput; waitForEnd: true }
stderr: StdioCollector { id: settingError; waitForEnd: true }
onExited: function(exitCode) {
root.savingConfigured = false
if (exitCode === 0) root.applyConfigured(pendingUrl)
else root.configuredError = StationModel.singleLine(settingError.text, 200) || "Could not save station setting"
}
}

Timer {
id: probeTimer
interval: 30000
Expand Down Expand Up @@ -257,24 +316,147 @@ Item {
horizontalAlignment: Text.AlignRight
}
}
Row {
width: parent.width
spacing: Style.spacing.sm

TextField {
id: searchField
width: parent.width - configuredButton.width - parent.spacing
placeholderText: "Search stations, places, genres…"
foreground: root.foreground
accent: root.accent
font.family: Style.font.menuFamily
font.pixelSize: Style.font.body
onTextChanged: root.setFilter(text)
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Down) { root.moveSelection(1); event.accepted = true }
else if (event.key === Qt.Key_Up) { root.moveSelection(-1); event.accepted = true }
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { root.playSelected(); event.accepted = true }
else if (event.key === Qt.Key_Escape) {
if (text) text = ""
else root.dismiss()
event.accepted = true
}
}
}

Button {
id: configuredButton
text: root.configuredUrl ? "EDIT" : "+ SELF-HOSTED"
active: root.configuredUrl !== ""
focusable: true
bordered: true
foreground: root.foreground
accent: root.accent
fontFamily: Style.font.menuFamily
fontSize: Style.font.caption
onClicked: root.editingConfigured ? root.cancelConfiguredEdit() : root.beginConfiguredEdit()
}
}

TextField {
id: searchField
BorderSurface {
visible: root.editingConfigured
width: parent.width
placeholderText: "Search stations, places, genres…"
foreground: root.foreground
accent: root.accent
font.family: Style.font.menuFamily
font.pixelSize: Style.font.body
onTextChanged: root.setFilter(text)
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Down) { root.moveSelection(1); event.accepted = true }
else if (event.key === Qt.Key_Up) { root.moveSelection(-1); event.accepted = true }
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { root.playSelected(); event.accepted = true }
else if (event.key === Qt.Key_Escape) {
if (text) text = ""
else root.dismiss()
event.accepted = true
implicitHeight: configuredEditor.implicitHeight + Style.spacing.md * 2
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.035)
radius: Style.cornerRadius
borderSpec: Border.flat(Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10), 1)

Column {
id: configuredEditor
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.spacing.md
anchors.rightMargin: Style.spacing.md
spacing: Style.spacing.sm

Text {
width: parent.width
text: "SELF-HOSTED STATION"
color: root.foreground
opacity: 0.62
font.family: Style.font.menuFamily
font.pixelSize: Style.font.caption
font.bold: true
}

Row {
width: parent.width
spacing: Style.spacing.sm

TextField {
id: configuredField
width: parent.width - saveConfiguredButton.width - cancelConfiguredButton.width
- (removeConfiguredButton.visible ? removeConfiguredButton.width + parent.spacing : 0)
- parent.spacing * 2
enabled: !root.savingConfigured
text: root.configuredInput
placeholderText: "https://radio.example.com"
foreground: root.foreground
accent: root.accent
font.family: Style.font.menuFamily
font.pixelSize: Style.font.body
onTextChanged: root.configuredInput = text
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.saveConfigured(text)
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
root.cancelConfiguredEdit()
event.accepted = true
}
}
}

Button {
id: saveConfiguredButton
text: root.savingConfigured ? "SAVING" : "SAVE"
enabled: !root.savingConfigured
focusable: true
bordered: true
foreground: root.foreground
accent: root.accent
fontFamily: Style.font.menuFamily
fontSize: Style.font.caption
onClicked: root.saveConfigured(configuredField.text)
}

Button {
id: removeConfiguredButton
visible: root.configuredUrl !== ""
text: "REMOVE"
enabled: !root.savingConfigured
focusable: true
foreground: Color.urgent
accent: Color.urgent
fontFamily: Style.font.menuFamily
fontSize: Style.font.caption
onClicked: root.saveConfigured("")
}

Button {
id: cancelConfiguredButton
text: "CANCEL"
enabled: !root.savingConfigured
focusable: true
foreground: root.foreground
accent: root.accent
fontFamily: Style.font.menuFamily
fontSize: Style.font.caption
onClicked: root.cancelConfiguredEdit()
}
}

Text {
visible: root.configuredError !== ""
width: parent.width
text: root.configuredError
color: Color.urgent
font.family: Style.font.menuFamily
font.pixelSize: Style.font.caption
wrapMode: Text.Wrap
}
}
}
Expand Down
Binary file modified preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions tests/model.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ assert.equal(model.normalizeOrigin("file:///tmp/stream"), "")
assert.equal(model.normalizeOrigin("https://radio.example.com/#secret"), "")
assert.equal(model.normalizeOrigin("https://radio.example.com/\nnext"), "")

assert.equal(typeof model.configuredStationUpdate, "function")
assert.deepEqual(
JSON.parse(JSON.stringify(model.configuredStationUpdate(" https://radio.example.com/listen "))),
{
ok: true,
url: "https://radio.example.com",
command: ["omarchy", "bar", "set", "getsubwave.radio", "stationUrl", "https://radio.example.com"]
}
)
assert.deepEqual(
JSON.parse(JSON.stringify(model.configuredStationUpdate(""))),
{
ok: true,
url: "",
command: ["omarchy", "bar", "set", "getsubwave.radio", "stationUrl", ""]
}
)
assert.equal(model.configuredStationUpdate("https://user:pass@radio.example.com").ok, false)
assert.equal(model.configuredStationUpdate("file:///tmp/stream").ok, false)

const rows = model.normalizeCatalog([
{ slug: "zeta", name: "Zeta", url: "https://zeta.example", genre: "Jazz" },
{ slug: "featured", name: "Featured", url: "https://featured.example", featured: true },
Expand All @@ -36,6 +56,14 @@ assert.equal(synthetic[0].slug, "__configured")
assert.equal(synthetic[0].name, "My station")
assert.equal(synthetic[0].url, "https://mine.example")

const reconfigured = model.mergeConfigured(synthetic, "https://featured.example")
assert.equal(reconfigured.length, 2)
assert.equal(reconfigured[0].url, "https://featured.example")
assert.equal(reconfigured.filter(row => row.isConfigured).length, 1)
const cleared = model.mergeConfigured(reconfigured, "")
assert.equal(cleared.length, 2)
assert.equal(cleared.filter(row => row.isConfigured).length, 0)

assert.deepEqual(
Array.from(model.searchStations(synthetic, "jazz"), row => row.slug),
["zeta"]
Expand Down
Loading