Repository navigation
Expand file tree
/
Copy pathService.qml
More file actions
875 lines (757 loc) · 32.1 KB
/
Copy pathService.qml
File metadata and controls
875 lines (757 loc) · 32.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "Model.js" as Model
Item {
id: root
property var settings: ({})
// The panel writes this so nothing polls while the popup is shut.
property bool panelOpen: false
// Rate following has to notice a track change with nothing on screen, so it is the
// second consumer of cliamp's status and keeps the poll alive on its own.
readonly property bool wantsStatus: panelOpen || followNativeRate
property var status: Model.defaultStatus()
property string lastError: ""
readonly property string cliampPath: String(setting("cliampPath", "") || "cliamp")
readonly property int statusIntervalMs: intSetting("statusIntervalSec", 2, 1, 10) * 1000
// Bound by cliamp's own bus name, never to whichever player happens to be active,
// because Chromium and others register MPRIS too and would otherwise drive this panel.
// Re-resolved on every player list change. A function call binding does not reliably
// re-evaluate when the daemon restarts, which strands the panel on a dead object.
property var player: null
function refreshPlayer() { player = findCliampPlayer() }
Connections {
target: Mpris.players
ignoreUnknownSignals: true
function onValuesChanged() { root.refreshPlayer() }
}
Component.onCompleted: {
refreshPlayer()
rebuildNodes()
}
// cliamp's own status is the truth. MPRIS only fills gaps and provides seeking, so a
// stale or missing player object can no longer freeze the panel.
readonly property bool running: status.ok === true || player !== null
readonly property bool hasTrack: running && (title.length > 0 || artist.length > 0)
readonly property bool isPlaying: status.ok === true
? status.state === "playing"
: (player !== null && player.isPlaying === true)
readonly property string title: String(status.title || (player ? player.trackTitle : "") || "")
readonly property string artist: String(status.artist || (player ? player.trackArtist : "") || "")
readonly property string album: String(status.album || (player ? player.trackAlbum : "") || "")
// cliamp publishes no mpris:artUrl for anything, local or remote, so the cover is
// derived from the Subsonic stream URL it does publish. The MPRIS value is still
// preferred in case a future release starts sending one.
// Held rather than recomputed to empty. The cover is derived from the stream path, so
// any moment without a status, between tracks or while the socket changes owner,
// would otherwise blank the artwork and flash the placeholder.
property string artUrl: ""
property int artSizePx: 300
readonly property string resolvedArtUrl: {
if (!running) return ""
var fromMpris = player ? safeArtUrl(player.trackArtUrl) : ""
if (fromMpris.length > 0) return fromMpris
return safeArtUrl(Model.coverArtUrlFromStreamPath(status.path, artSizePx))
}
// Held only across the gap where cliamp is unreachable, which is the socket dropping
// on a daemon restart. While it is running an empty value is the honest answer, so
// radio and local files clear the cover instead of showing the last album played.
onResolvedArtUrlChanged: if (running) artUrl = resolvedArtUrl
readonly property real lengthSec: {
if (status.durationSec > 0) return status.durationSec
if (player && player.lengthSupported) return Number(player.length || 0)
return 0
}
// Measured on 1.63.2: seeking a Navidrome track advances the queue instead of moving
// within it, because these arrive as HTTP streams and cliamp cannot reposition one. A
// duration is still known, so the bar is drawn, but it must not be interactive.
readonly property bool hasProgress: running && lengthSec > 0
readonly property bool canSeek: hasProgress && !isStream
readonly property bool shuffle: status.shuffle === true
readonly property string repeat: String(status.repeat || "Off")
readonly property int total: Number(status.total || 0)
readonly property real volumeDb: Number(status.volumeDb || 0)
readonly property bool isStream: status.isStream === true
// Ticked locally between MPRIS updates, because polling Position over D-Bus four
// times a second is traffic for something the panel can count on its own.
property real positionSec: 0
property bool _askedAtEnd: false
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
// `omarchy bar set` stores a bare value as a string unless it is given --json, so the
// documented way to flip one of these settings would otherwise be silently ignored.
function boolSetting(name, fallback) {
return Model.asBool(setting(name, fallback), fallback)
}
// Re-clamped on read so a hand-edited shell.json cannot poison the timer.
function intSetting(name, fallback, min, max) {
var value = parseInt(setting(name, fallback), 10)
if (!isFinite(value)) value = fallback
return Math.max(min, Math.min(max, value))
}
function findCliampPlayer() {
var list = Mpris.players ? Mpris.players.values : []
for (var i = 0; i < list.length; i++) {
var p = list[i]
if (!p) continue
if (String(p.dbusName || "").indexOf("org.mpris.MediaPlayer2.cliamp") === 0) return p
}
return null
}
// Album art can be any string a tag supplies, so only these two schemes reach an Image.
function safeArtUrl(raw) {
var url = String(raw || "")
if (url.indexOf("file://") === 0) return url
if (url.indexOf("https://") === 0) return url
return ""
}
// Held optimistically so the button flips on press instead of waiting for the poll,
// then released as soon as cliamp reports the same thing.
property int pendingPlaying: -1
readonly property bool showPlaying: pendingPlaying === -1 ? isPlaying : pendingPlaying === 1
function playPause() {
pendingPlaying = isPlaying ? 0 : 1
playHold.restart()
if (send('{"cmd":"toggle"}')) { settleTimer.restart(); return }
if (running) player.togglePlaying()
}
// Never let an optimistic flip stick if cliamp disagrees.
Timer {
id: playHold
interval: 2500
repeat: false
onTriggered: root.pendingPlaying = -1
}
function next() {
if (send('{"cmd":"next"}')) { settleTimer.restart(); return }
if (running) player.next()
}
function previous() {
if (send('{"cmd":"prev"}')) { settleTimer.restart(); return }
if (running) player.previous()
}
// Measured on 1.63.2: the socket seek takes a delta, not a position, whatever
// `cliamp seek --help` says. The delta comes off the interpolated position rather than
// the last polled one, which is up to a whole poll interval stale. MPRIS is the fallback.
function seekTo(targetSec) {
if (!canSeek) return
var target = Math.max(0, Math.min(lengthSec, Number(targetSec) || 0))
var delta = Math.round(target - positionSec)
positionSec = target
if (send('{"cmd":"seek","value":' + delta + '}')) { settleTimer.restart(); return }
if (player) player.seek(target - Number(player.position || 0))
}
function seekBy(deltaSec) { seekTo(positionSec + Number(deltaSec || 0)) }
// cliamp speaks newline delimited JSON on its own socket, so status needs no
// subprocess at all. One connection replaces a spawn every couple of seconds.
readonly property string socketPath: (Quickshell.env("HOME") || "") + "/.config/cliamp/cliamp.sock"
function refreshStatus() {
if (!ipcConnected) return
ipcLoader.item.write('{"cmd":"status"}\n')
ipcLoader.item.flush()
}
// Every verb the docs define on the socket goes the same way.
function send(payload) {
if (!ipcConnected) return false
ipcLoader.item.write(payload + "\n")
ipcLoader.item.flush()
return true
}
// cliamp resolves lyrics itself, from embedded tags then LRCLIB then NetEase, and
// serves them on the same socket. Undocumented, measured: {"cmd":"lyrics"} answers
// {"ok":true,"lyrics":[{"start":30.23,"text":"..."}]}.
property var lyrics: []
property string lyricsTrackPath: ""
readonly property bool hasLyrics: lyrics.length > 0
// cliamp reports the position it has decoded to. Over A2DP the ears are about a
// sixth of a second behind that, which is plainly visible against lyrics, so the
// line is chosen from the position that has actually reached the sink.
property int outputLatencyMs: 0
readonly property int lyricTrimMs: intSetting("lyricTrimMs", 0, -1000, 1000)
readonly property string latencyHelper: String(Qt.resolvedUrl("cliamp-output-latency")).replace("file://", "")
function readOutputLatency() {
if (latencyProcess.running) return
latencyProcess.command = [latencyHelper]
latencyProcess.running = true
}
Process {
id: latencyProcess
command: []
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.outputLatencyMs = Model.latencyMs(text)
}
}
readonly property int activeLyricIndex: Model.activeLyricIndex(lyrics, positionSec - (outputLatencyMs + lyricTrimMs) / 1000)
readonly property string activeLyric: activeLyricIndex >= 0
? String(lyrics[activeLyricIndex].text || "")
: ""
property string lyricsPendingPath: ""
function refreshLyrics() {
var path = String(status.path || "")
if (path.length === 0) { lyrics = []; lyricsTrackPath = ""; return }
if (path === lyricsTrackPath) return
lyrics = []
// One request outstanding at a time. The reply carries no track, so the path the
// single outstanding request was sent for is the only thing that can attribute it.
if (lyricsPendingPath.length > 0) return
// Marked fetched only once the request is actually out, or one dropped write
// suppresses every retry for the rest of the track.
if (send('{"cmd":"lyrics"}')) { lyricsPendingPath = path; lyricsTrackPath = path; lyricsTimeout.restart() }
}
// cliamp answers every request, so this only fires when a reply arrives in a shape the
// router does not recognise. Only the slot is freed: the track keeps its fetched mark,
// so one lost reply costs that track its lyrics rather than a request every 5 seconds.
Timer {
id: lyricsTimeout
interval: 5000
repeat: false
onTriggered: root.lyricsPendingPath = ""
}
// A reply lost with the connection is worth asking for again, and only that case is.
function dropLyricsRequest() {
if (lyricsPendingPath.length === 0) return
lyricsPendingPath = ""
lyricsTrackPath = ""
}
function acceptLyrics(raw) {
var wanted = lyricsPendingPath
lyricsPendingPath = ""
lyricsTimeout.stop()
if (wanted === String(status.path || "")) { lyrics = Model.parseLyrics(raw); return }
// The track changed while this was in flight, so it answers a question nobody is
// asking now. Re-arm and ask again for whatever is playing.
lyricsTrackPath = ""
refreshLyrics()
}
function syncPosition() {
if (status.ok === true) positionSec = Number(status.positionSec || 0)
else if (player && player.positionSupported) positionSec = Number(player.position || 0)
if (panelOpen) refreshStatus()
}
// The socket changes owner whenever the daemon relaunches or hands over to an
// interactive session. Measured on Quickshell 0.3.0: one connect against a missing
// path (ServerNotFoundError) bricks a Socket for good, and no later write to
// connected or path ever tries again, so every retry has to be a brand new Socket.
readonly property bool ipcConnected: !!(ipcLoader.item && ipcLoader.item.connected)
Loader {
id: ipcLoader
active: true
sourceComponent: Socket {
path: root.socketPath
connected: true
// On connect ask for status at once; on drop abandon the lyrics request in flight.
onConnectionStateChanged: {
if (connected) { root.refreshStatus(); return }
root.dropLyricsRequest()
}
parser: SplitParser {
splitMarker: "\n"
onRead: function (line) {
var raw = String(line || "")
var kind = Model.messageKind(raw)
// Every command answers on this socket too, and an acknowledgement carries no
// track, so parsing one as a status blanked the panel until the next poll.
// An acknowledgement that failed is the only report a command ever gets.
if (kind === "lyrics") { root.acceptLyrics(raw); return }
if (kind === "ack") { root.lastError = Model.ackError(raw); return }
if (kind !== "status") return
var parsed = Model.parseStatus(raw)
root.status = parsed
root.lastError = parsed.ok ? "" : parsed.lastError
// The feed carries position, so the local tick only fills the gaps between polls.
if (parsed.ok) root.positionSec = Number(parsed.positionSec || 0)
}
}
}
}
Timer {
id: reconnectTimer
interval: 700
repeat: true
// No assignment to running anywhere: the binding is the only thing that can stop
// this, so a later disconnect can always start it again.
running: !root.ipcConnected
onTriggered: {
if (root.ipcConnected) return
ipcLoader.active = false
ipcLoader.active = true
}
onRunningChanged: {
if (running) goneTimer.restart()
else goneTimer.stop()
}
}
// The socket drops briefly whenever the daemon restarts. Blanking the artwork and
// title for that window is worse than briefly showing the last known track, so the
// state is only cleared once cliamp has genuinely stayed away.
Timer {
id: goneTimer
interval: 6000
repeat: false
onTriggered: {
if (root.ipcConnected) return
root.status = Model.defaultStatus()
root.artUrl = ""
}
}
// cliamp's own 10 band spectrum, the same feed its first-party widget draws. visstream
// holds one IPC connection open and emits a frame per tick, which the docs give as the
// way to consume it from a UI toolkit. It runs only while the popup is open and
// playing, so a shut panel costs nothing.
property var bands: []
Process {
id: visProcess
command: [root.cliampPath, "visstream", "--fps", "20"]
running: root.panelOpen && root.isPlaying
stdout: SplitParser {
splitMarker: "\n"
onRead: function (line) {
var frame = Model.parseBands(line)
if (frame.length > 0) root.bands = frame
}
}
onRunningChanged: if (!running) root.bands = []
}
Timer {
id: statusTimer
interval: root.statusIntervalMs
repeat: true
running: root.wantsStatus && root.ipcConnected
triggeredOnStart: true
onTriggered: root.refreshStatus()
}
Timer {
id: positionTimer
interval: 250
repeat: true
running: root.panelOpen && root.isPlaying
onTriggered: {
var next = root.positionSec + interval / 1000
// A repeat restarts the track without MPRIS reporting a track change, so the
// position is asked for at the end rather than clamped and left stale. Asked
// once, because a duration shorter than the audio would poll four times a
// second for the rest of the track.
if (root.lengthSec > 0 && next >= root.lengthSec) {
if (!root._askedAtEnd) { root._askedAtEnd = true; root.refreshStatus() }
return
}
root._askedAtEnd = false
root.positionSec = next
}
}
Connections {
target: root.player
ignoreUnknownSignals: true
function onTrackChanged() { root.syncPosition() }
function onPlaybackStateChanged() { root.syncPosition() }
function onSeek() { root.syncPosition() }
}
onPanelOpenChanged: {
if (!panelOpen) return
syncPosition()
readSinkRate()
readOutputLatency()
readSinkAvailability()
readPlaylists()
readLibrary()
}
// ---- PipeWire routing and the signal verdict ----
// Rebuilt from an explicit signal rather than left as a binding on values: a list
// mutating in place does not re-evaluate the binding, so a device connected after
// the panel loaded never appeared. Same hazard as the MPRIS player lookup.
property var nodes: []
function rebuildNodes() {
nodes = Pipewire.nodes ? (Pipewire.nodes.values || []) : []
readSinkAvailability()
}
Connections {
target: Pipewire.nodes
ignoreUnknownSignals: true
function onValuesChanged() { root.rebuildNodes() }
}
property var sinkAvailability: ({})
// Filtered through the stock helper the first-party audio panel uses, so an output
// with nothing plugged into it is never offered as somewhere to send music.
readonly property var sinks: {
// Depends on nodes, which is now replaced wholesale on every change.
var list = []
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i]
if (!n || !n.isSink || n.isStream) continue
var known = sinkAvailability[String(n.name || "")]
if (known === false) continue
list.push(n)
}
return list
}
function readSinkAvailability() {
if (availabilityProcess.running) return
availabilityProcess.running = true
}
Process {
id: availabilityProcess
command: ["omarchy-audio-sink-availability"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.sinkAvailability = Model.parseSinkAvailability(text)
}
}
// cliamp reaches PipeWire through the ALSA compatibility layer, so its stream
// announces itself as "PipeWire ALSA [cliamp]" rather than as a native client.
readonly property var streamNode: {
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i]
if (!n || !n.isStream || !n.properties) continue
if (String(n.properties["application.name"] || "").indexOf("cliamp") >= 0) return n
}
return null
}
readonly property var peakNode: streamNode
// Read from the global link list rather than a PwNodeLinkTracker, which reports no
// groups at all for this stream. Taken live rather than cached, so a sink that
// disappears cannot leave a dead device name sitting in the panel.
readonly property var currentSink: {
if (!streamNode) return null
var groups = Pipewire.linkGroups ? (Pipewire.linkGroups.values || []) : []
for (var i = 0; i < groups.length; i++) {
var g = groups[i]
if (!g || !g.source || !g.target) continue
if (g.source.id !== streamNode.id) continue
// cliamp is also linked to quickshell itself for the peak meter, and that
// tap is not a sink, so the isSink test is what keeps the route honest.
if (g.target.isSink) return g.target
}
return null
}
readonly property string currentSinkLabel: currentSink
? String(currentSink.description || currentSink.nickname || currentSink.name || "")
: ""
readonly property int streamRate: streamNode ? Model.rateFromNodeProps(streamNode.properties) : 0
property int sinkRate: 0
readonly property string codec: codecFromPath(status.path)
// A Subsonic URL is judged by whether cliamp asked for format=raw. A local file is
// judged by its container, since nothing re-encoded it on the way in.
readonly property bool transcoded: status.path.indexOf("/rest/stream") >= 0
? Model.transcodedFromPath(status.path)
: (codec === "MP3" || codec === "OGG" || codec === "OPUS")
// Any attenuation alters samples, so only an exact 0 dB counts. Setting volume over
// MPRIS lands on -0.02 dB, which really is not unity and must not pass.
readonly property bool playerUnity: Math.abs(volumeDb) < 0.001
readonly property bool unityGain: playerUnity
&& (!hasStreamVolume || (!streamMuted && Math.abs(streamVolume - 1) < 0.001))
readonly property bool eqFlat: status.eqFlat !== false
// Empty for anything wired, so the lossy-link branch only fires on real Bluetooth.
readonly property string lossyLink: currentSink
? Model.bluetoothCodecLabel(currentSink.properties)
: ""
readonly property var signalVerdict: Model.verdict({
streamRate: streamRate,
sinkRate: sinkRate,
unityGain: unityGain,
playerUnity: playerUnity,
eqFlat: eqFlat,
transcoded: transcoded,
codec: codec,
requestedRate: forcedRate,
sourceRate: sourceRate,
lossyLink: lossyLink
})
function codecFromPath(path) {
var text = String(path || "")
var cut = text.indexOf("?")
if (cut >= 0) text = text.slice(0, cut)
var dot = text.lastIndexOf(".")
if (dot < 0) return ""
var ext = text.slice(dot + 1).toUpperCase()
if (ext === "MP3" || ext === "FLAC" || ext === "WAV" || ext === "ALAC"
|| ext === "OGG" || ext === "OPUS") return ext
return ""
}
PwObjectTracker { objects: root.sinks }
PwObjectTracker { objects: root.streamNode ? [root.streamNode] : [] }
// The verdict is only ever derived from what the sink actually adopted, never from
// the rate that was requested, because an unsupported rate silently lands on the
// nearest one the DAC does support. No PipeWire property reports this.
function readSinkRate() {
if (sinkRateProcess.running) return
sinkRateProcess.command = ["pactl", "list", "short", "sinks"]
sinkRateProcess.running = true
}
Process {
id: sinkRateProcess
command: []
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var wanted = root.currentSink ? String(root.currentSink.name || "") : ""
var lines = String(text || "").split("\n")
for (var i = 0; i < lines.length; i++) {
if (wanted.length > 0 && lines[i].indexOf(wanted) < 0) continue
var rate = Model.sinkRateFromPactl(lines[i])
if (rate > 0) { root.sinkRate = rate; return }
}
root.sinkRate = 0
}
}
}
// The graph takes a moment to settle after a forced rate, so the readback waits.
Timer {
id: rateSettleTimer
interval: 2000
repeat: false
onTriggered: { root.readSinkRate(); root.readOutputLatency() }
}
// ---- rate following ----
readonly property bool followSourceRate: boolSetting("followSourceRate", true)
property int forcedRate: 0
function matchRate() {
if (streamRate <= 0 || rateProcess.running) return
if (forcedRate === streamRate) return
rateProcess.command = ["pw-metadata", "-n", "settings", "0", "clock.force-rate", String(streamRate)]
rateProcess.running = true
forcedRate = streamRate
rateSettleTimer.restart()
}
function releaseRate() {
if (forcedRate === 0 || rateProcess.running) return
rateProcess.command = ["pw-metadata", "-n", "settings", "0", "clock.force-rate", "0"]
rateProcess.running = true
forcedRate = 0
rateSettleTimer.restart()
}
Process { id: rateProcess; command: [] }
// Following is deliberately scoped to actual playback: a forced rate reaches every
// application on the box, so it is released the moment the music stops.
// One handler only: QML rejects a second onIsPlayingChanged and the whole component
// then fails to load, which takes the widget out of the bar entirely.
onIsPlayingChanged: {
if (pendingPlaying !== -1 && isPlaying === (pendingPlaying === 1)) pendingPlaying = -1
if (!followSourceRate) return
if (isPlaying) matchRate()
else releaseRate()
}
// A sink change means the old rate reading describes a device no longer in the path,
// and the switch can happen outside this panel, so the readback is not tied to a click.
onCurrentSinkChanged: {
if (followSourceRate && isPlaying) matchRate()
rateSettleTimer.restart()
}
onStreamRateChanged: {
if (followSourceRate && isPlaying) matchRate()
else rateSettleTimer.restart()
// The node is destroyed and remade by a daemon relaunch, so this always fires
// after one. That re-check is what settles a track change that happened while
// the relaunch helper was still running and its change event was swallowed.
considerNativeRate()
}
Component.onDestruction: releaseRate()
// ---- actions ----
property var playlists: []
property var results: []
property string libraryQuery: ""
// Server rows on their own. Saved playlists are matched locally and merged in front,
// so a playlist list arriving late does not need a second server round trip.
property var _libraryRows: []
readonly property string libraryHelper: String(Qt.resolvedUrl("cliamp-library")).replace("file://", "")
function _recomputeResults() {
results = Model.matchPlaylists(playlists, libraryQuery).concat(_libraryRows)
}
// The query this helper run was dispatched for, so a keystroke that arrives while
// one is in flight is not silently dropped and does not leave stale rows on screen.
property string _dispatchedQuery: ""
// The library is browsed straight off the Subsonic server using the token cliamp
// already published, so picking something never has to stop the daemon.
function readLibrary() { _dispatchLibrary() }
function search(query) {
libraryQuery = String(query || "")
_recomputeResults()
_dispatchLibrary()
}
// One dispatcher, so a refresh cannot quietly replace an active search with the
// full album list while the playlist rows are still filtered by the query.
function _dispatchLibrary() {
if (albumProcess.running) return
_dispatchedQuery = libraryQuery
albumProcess.command = libraryQuery.length > 0
? [libraryHelper, "search", libraryQuery]
: [libraryHelper, "albums", "200"]
albumProcess.running = true
}
function playResult(item) {
if (!item) return
if (item.kind === "playlist") { loadPlaylist(String(item.name)); return }
if (albumPlayProcess.running || !item.id) return
albumPlayProcess.command = [libraryHelper,
item.kind === "song" ? "play-song" : "play", String(item.id)]
albumPlayProcess.running = true
}
Process {
id: albumProcess
command: []
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root._libraryRows = Model.parseResults(text)
root._recomputeResults()
}
}
onExited: if (root._dispatchedQuery !== root.libraryQuery) root._dispatchLibrary()
}
Process {
id: albumPlayProcess
command: []
onExited: settleTimer.restart()
}
function readPlaylists() {
if (playlistProcess.running) return
playlistProcess.command = [cliampPath, "playlist", "list"]
playlistProcess.running = true
}
Process {
id: playlistProcess
command: []
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.playlists = Model.parsePlaylists(text)
root._recomputeResults()
}
}
}
// Loading a saved playlist is the only way a headless daemon can reach a Navidrome
// library: the playlist keeps resolved stream URLs, and the browser is TUI only.
// Measured: load starts playing on its own, so nothing follows it here. The play that
// used to be sent 700 ms later was the only delayed action in the plugin.
function loadPlaylist(name) {
if (!name) return
if (send('{"cmd":"load","playlist":' + JSON.stringify(String(name)) + '}')) {
settleTimer.restart()
return
}
if (actionProcess.running) return
actionProcess.command = [cliampPath, "load", String(name)]
actionProcess.running = true
settleTimer.restart()
}
// Volume is cliamp's PipeWire stream volume, moved exactly the way the stock audio
// panel moves a sink: a property on a tracked node, pushed both ways, so a drag has
// no subprocess and no poll behind it to fight. cliamp's own gain stays at unity.
readonly property real streamVolume: streamNode && streamNode.audio ? streamNode.audio.volume : 0
readonly property bool streamMuted: !!(streamNode && streamNode.audio && streamNode.audio.muted)
readonly property bool hasStreamVolume: !!(streamNode && streamNode.audio)
// A level the operator sets is one they expect to hear, so setting one clears mute.
function setStreamVolume(value) {
if (!streamNode || !streamNode.audio) return
streamNode.audio.muted = false
streamNode.audio.volume = Math.max(0, Math.min(1, Number(value) || 0))
}
function setDevice(name) {
if (actionProcess.running || !name) return
actionProcess.command = [cliampPath, "device", String(name)]
actionProcess.running = true
}
function toggleShuffle() {
if (actionProcess.running) return
actionProcess.command = [cliampPath, "shuffle"]
actionProcess.running = true
}
function cycleRepeat() {
if (actionProcess.running) return
actionProcess.command = [cliampPath, "repeat"]
actionProcess.running = true
}
// cliamp cannot attach to a running instance, so the helper stops the daemon for
// the life of the terminal session and starts it again afterwards. Without that,
// opening the player spawns a second copy that the panel cannot see.
property int sourceRate: 0
readonly property bool followNativeRate: boolSetting("followNativeRate", false)
readonly property string nativeRateHelper: String(Qt.resolvedUrl("cliamp-daemon-rate-apply")).replace("file://", "")
// The rate already asked for, so an output the hardware or cliamp refuses is not
// requested again in a loop on every status poll.
property int attemptedNativeRate: 0
readonly property string sourceRateHelper: String(Qt.resolvedUrl("cliamp-source-rate")).replace("file://", "")
// Resolved per track, because cliamp exposes no source rate of its own.
property string sourceRateTrackPath: ""
function readSourceRate() {
if (sourceRateProcess.running) return
var path = String(status.path || "")
if (path.length === 0) { sourceRate = 0; sourceRateTrackPath = ""; return }
// Status is reassigned on every poll, so without this the helper queried the
// server every couple of seconds for a rate that only changes with the track.
if (path === sourceRateTrackPath) return
sourceRateTrackPath = path
sourceRateProcess.command = [sourceRateHelper, path]
sourceRateProcess.running = true
}
Process {
id: sourceRateProcess
command: []
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var value = parseInt(String(text || "").trim(), 10)
root.sourceRate = isFinite(value) && value > 0 ? value : 0
}
}
}
// One handler only. A second onStatusChanged in this object is "Property value set
// multiple times", which fails the whole Service and removes the widget from the bar.
onStatusChanged: {
if (!wantsStatus) return
readSourceRate()
// Nobody reads lyrics behind a shut panel, so that half stays panel only.
if (panelOpen) refreshLyrics()
}
// Relaunching cliamp is the only way to change its output rate, so this is gated
// hard: only while the daemon itself is what is running, only when the file really
// differs, and never twice for the same rate.
onSourceRateChanged: considerNativeRate()
function considerNativeRate() {
if (!followNativeRate) return
if (nativeRateProcess.running) return
if (sourceRate <= 0 || streamRate <= 0) return
if (Math.abs(sourceRate - streamRate) <= 1) { attemptedNativeRate = 0; return }
// 88.2 and 176.4 kHz are not accepted output rates, so relaunching would land on
// the default and gap the audio for nothing.
if (!Model.isSupportedOutputRate(sourceRate)) return
if (attemptedNativeRate === sourceRate) return
attemptedNativeRate = sourceRate
nativeRateProcess.command = [nativeRateHelper, String(sourceRate)]
nativeRateProcess.running = true
}
Process {
id: nativeRateProcess
command: []
// Re-check on exit as well: a track change during the relaunch is swallowed by
// the running guard, and the stream node can settle before or after this exit,
// so both this and onStreamRateChanged re-evaluate and the guards dedupe them.
onExited: { settleTimer.restart(); root.considerNativeRate() }
}
// No socket handover any more. cliamp allows one instance per user, so this is only
// ever offered when nothing owns the socket, and the in panel library removes the
// reason to open a terminal player at all.
function openPlayer() {
if (running) return
Quickshell.execDetached(["uwsm-app", "--", "foot", "--title=cliamp", cliampPath])
}
Process {
id: actionProcess
command: []
// A cliamp verb takes a moment to land, so the panel re-reads rather than guessing.
onExited: settleTimer.restart()
}
Timer {
id: settleTimer
interval: 400
repeat: false
onTriggered: {
root.refreshStatus()
root.readSinkRate()
}
}
}