diff --git a/README.md b/README.md index d8342af..7f0c2e7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/StationModel.js b/StationModel.js index 0fb93b6..4369c4f 100644 --- a/StationModel.js +++ b/StationModel.js @@ -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) } @@ -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 } diff --git a/StationPicker.qml b/StationPicker.qml index de30990..1c75d17 100644 --- a/StationPicker.qml +++ b/StationPicker.qml @@ -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 @@ -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 = "" @@ -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 @@ -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 @@ -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 } } } diff --git a/preview.png b/preview.png index 76aecd1..485bf0a 100644 Binary files a/preview.png and b/preview.png differ diff --git a/tests/model.test.mjs b/tests/model.test.mjs index 157ed79..ab1b112 100644 --- a/tests/model.test.mjs +++ b/tests/model.test.mjs @@ -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 }, @@ -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"]