Skip to content
Open
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
54 changes: 52 additions & 2 deletions Library.qml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ Column {
property string fontFamily: Style.font.family
property bool expanded: false
property int cursorIndex: -1

signal toggleRequested()
signal moveRequested(int delta)
signal activateRequested()
signal toggleYoutubeRequested()
signal hovered(int index)

readonly property string searchText: search.text

// PanelKeyCatcher runs at Keys.BeforeItem, so the panel must stand down while this
// field has the keyboard or every letter typed also fires a panel action.
Expand Down Expand Up @@ -133,7 +136,7 @@ Column {
TextField {
id: search
width: parent.width
placeholderText: "Search songs, albums and playlists"
placeholderText: "Search songs, albums, playlists and YouTube"
foreground: root.foreground
font.family: root.fontFamily

Expand All @@ -154,6 +157,52 @@ Column {
onTriggered: if (root.service) root.service.search(search.text)
}

CursorSurface {
width: parent.width
foreground: root.foreground
implicitHeight: ytToggleLabel.implicitHeight + Style.spacing.rowPaddingX
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.toggleYoutubeRequested()
}
RowLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(8)
Text {
id: ytToggleLabel
textFormat: Text.PlainText
Layout.fillWidth: true
text: "YouTube search"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
textFormat: Text.PlainText
text: root.service && root.service.enableYoutubeSearch ? "ON" : "OFF"
color: root.service && root.service.enableYoutubeSearch ? root.foreground : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
}
Text {
textFormat: Text.PlainText
text: "›"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
}

// The list scrolls inside its own bounds, so the wheel over it moves rows rather
// than the whole panel. ListView is used over a Repeater for positionViewAtIndex,
// which is what keeps the j/k cursor on screen in a list this long.
Expand Down Expand Up @@ -187,6 +236,7 @@ Column {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) root.hovered(index)
onClicked: root.service.playResult(modelData)
}

Expand Down
12 changes: 8 additions & 4 deletions Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,8 @@ function parseBands(raw) {

// Sample input, one JSON array from `cliamp-library albums` or `search`, mixing kinds:
// [{"kind":"album","id":"7tO..","name":"Discovery","artist":"Daft Punk","songCount":14},
// {"kind":"song","id":"a9F..","name":"One More Time","artist":"Daft Punk","album":"Discovery","duration":320}]
// {"kind":"song","id":"a9F..","name":"One More Time","artist":"Daft Punk","album":"Discovery","duration":320},
// {"kind":"youtube","id":"dQw4w9WgXcQ","name":"Never Gonna Give You Up","artist":"Rick Astley","duration":213}]
function parseResults(raw) {
var out = []
var text = String(raw || "").trim()
Expand All @@ -358,15 +359,18 @@ function parseResults(raw) {
for (var i = 0; i < data.length; i++) {
var a = data[i]
if (!a || !a.id) continue
out.push({
kind: a.kind === "song" ? "song" : "album",
var kind = a.kind === "song" ? "song" : a.kind === "youtube" ? "youtube" : "album"
var row = {
kind: kind,
id: String(a.id),
name: String(a.name || ""),
artist: String(a.artist || ""),
album: String(a.album || ""),
songCount: numberOr(a.songCount, 0),
duration: numberOr(a.duration, 0)
})
}
if (a.url) row.url = String(a.url)
out.push(row)
}
return out
}
Expand Down
42 changes: 37 additions & 5 deletions NowPlaying.qml
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,26 @@ Column {
}
}
}

Column {
width: parent.width
spacing: Style.space(6)
visible: root.showProgress

Item {
id: progressBar
width: parent.width
height: Style.space(4)

// While the thumb is being dragged the fill and handle track the pointer
// instead of the ticking positionSec, so the bar does not fight the finger.
property bool scrubbing: false
property real scrubFraction: 0

function fractionForX(x) {
if (width <= 0) return 0
return Math.max(0, Math.min(1, x / width))
}

Rectangle {
anchors.fill: parent
radius: height / 2
Expand All @@ -213,6 +223,7 @@ Column {
radius: height / 2
color: Color.accent
width: {
if (progressBar.scrubbing) return parent.width * progressBar.scrubFraction
if (!root.service || root.service.lengthSec <= 0) return 0
return parent.width * Math.max(0, Math.min(1, root.service.positionSec / root.service.lengthSec))
}
Expand All @@ -225,7 +236,8 @@ Column {
color: Color.accent
x: Math.max(0, Math.min(parent.width - width, progress.width - width / 2))
anchors.verticalCenter: parent.verticalCenter
opacity: scrubArea.containsMouse ? 1 : 0
// Stay visible while dragging even if the pointer has left the hit area.
opacity: (scrubArea.containsMouse || progressBar.scrubbing) ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 120 } }
}

Expand All @@ -237,10 +249,24 @@ Column {
hoverEnabled: true
enabled: root.seekable
cursorShape: Qt.PointingHandCursor
onClicked: function (mouse) {
preventStealing: true
onPressed: function (mouse) {
progressBar.scrubbing = true
progressBar.scrubFraction = progressBar.fractionForX(mouse.x)
}
onPositionChanged: function (mouse) {
if (pressed && progressBar.scrubbing) {
progressBar.scrubFraction = progressBar.fractionForX(mouse.x)
}
}
onReleased: function (mouse) {
if (!progressBar.scrubbing) return
var frac = progressBar.fractionForX(mouse.x)
progressBar.scrubbing = false
if (!root.service || root.service.lengthSec <= 0) return
root.service.seekTo(root.service.lengthSec * (mouse.x / width))
root.service.seekTo(root.service.lengthSec * frac)
}
onCanceled: progressBar.scrubbing = false
}
}

Expand All @@ -252,7 +278,13 @@ Column {
id: elapsed
textFormat: Text.PlainText
anchors.left: parent.left
text: root.service ? Model.formatTime(root.service.positionSec) : "0:00"
text: {
if (!root.service) return "0:00"
if (progressBar.scrubbing) {
return Model.formatTime(progressBar.scrubFraction * root.service.lengthSec)
}
return Model.formatTime(root.service.positionSec)
}
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
Expand Down
2 changes: 2 additions & 0 deletions OutputSheet.qml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Column {
property int cursorIndex: -1

signal toggleRequested()
signal hovered(int index)

readonly property color dim: Qt.darker(foreground, 1.4)
readonly property var verdict: service ? service.signalVerdict : ({ ok: false, text: "" })
Expand Down Expand Up @@ -98,6 +99,7 @@ Column {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: if (containsMouse) root.hovered(index)
onClicked: root.service.setDevice(String(modelData.name || ""))
}

Expand Down
10 changes: 10 additions & 0 deletions Panel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,16 @@ Panel {
cursorIndex: root.libraryOpen ? root.cursorIndex : -1
onMoveRequested: function (delta) { root.moveCursor(delta) }
onActivateRequested: root.activateCursor()
onHovered: function(idx) { root.cursorIndex = idx }
onToggleRequested: { root.libraryOpen = !root.libraryOpen; root.sheetOpen = false; root.cursorIndex = 0 }
onToggleYoutubeRequested: {
var next = !cliamp.enableYoutubeSearch
var updated = Object.assign({}, root.settings, { enableYoutubeSearch: next })
root.settings = updated
if (root.bar && root.bar.shell) root.bar.shell.updateEntryInline(root.moduleName, updated)
// Refresh current search with new toggle
if (root.libraryOpen) cliamp.search(library.searchText)
}
}

OutputSheet {
Expand All @@ -216,6 +225,7 @@ Panel {
fontFamily: root.fontFamily
expanded: root.sheetOpen
cursorIndex: root.sheetOpen ? root.cursorIndex : -1
onHovered: function(idx) { root.cursorIndex = idx }
onToggleRequested: { root.sheetOpen = !root.sheetOpen; root.libraryOpen = false; root.cursorIndex = 0 }
}
}
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Left click opens the panel, right click plays or pauses without opening it.
| --- | --- | --- |
| Seconds between status refreshes | 2 | polls while the panel is open, and always while native rate following is on |
| Match the audio graph rate to the track | on | see the warning below |
| Search YouTube in library | on | when enabled, library search also queries YouTube via `yt-dlp` (`ytsearch`) and shows videos alongside Subsonic results; turn off to search only your library and playlists |
| Relaunch cliamp at the track's native rate | off | see the warning below |
| Lyric timing trim in milliseconds | 0 | added on top of the measured output latency |
| Hide the icon when cliamp is not running | on | |
Expand Down Expand Up @@ -136,11 +137,11 @@ cliamp is streaming, so a daemon that just started has none to lend. The playlis
disk carry the same token in their stream URLs, and those are read instead, which means
the library is browsable from a cold start without anything being stored anywhere.

**One field searches songs, albums and saved playlists.** Rows are tagged with what
they are. Artists are not a row of their own, because an artist name already brings
**One field searches songs, albums, saved playlists and YouTube.** Rows are tagged with what
they are. When “Search YouTube in library” is on, the field also queries YouTube via `yt-dlp` (`ytsearch`) and mixes videos (`YOUTUBE`) into the same list; toggle it off in settings or via the “YouTube search ON/OFF” row in the library to search only your Navidrome library and playlists. Artists are not a row of their own, because an artist name already brings
up their albums and there would be nothing to play on an artist by itself. Choosing a
song plays that one song: cliamp has no jump-to-track command, so starting its album
from the right place is not something this can offer.
from the right place is not something this can offer. Choosing a YouTube row builds a one-track scratch playlist (`cliampui`) from `https://www.youtube.com/watch?v=ID` and loads it, just like the TUI `Ctrl+F` provider search.

**cliamp is only launched when nothing is running.** It allows one instance per user,
so starting it while the daemon holds the socket would create a second, IPC-less copy
Expand Down
31 changes: 25 additions & 6 deletions Service.qml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Item {

readonly property string cliampPath: String(setting("cliampPath", "") || "cliamp")
readonly property int statusIntervalMs: intSetting("statusIntervalSec", 2, 1, 10) * 1000
readonly property bool enableYoutubeSearch: boolSetting("enableYoutubeSearch", true)

// 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.
Expand Down Expand Up @@ -82,14 +83,20 @@ Item {
// 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.
// YouTube is the exception: it reports stream:true but is backed by mpv/yt-dlp and
// seeks normally, verified with {"cmd":"seek"} on a live video.
readonly property bool hasProgress: running && lengthSec > 0
readonly property bool canSeek: hasProgress && !isStream
readonly property bool isStream: status.isStream === true
readonly property bool isYoutube: {
var p = String(status.path || "")
return p.indexOf("youtube.com") >= 0 || p.indexOf("youtu.be") >= 0
}
readonly property bool canSeek: hasProgress && (!isStream || isYoutube)

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.
Expand Down Expand Up @@ -672,18 +679,24 @@ Item {
function _dispatchLibrary() {
if (albumProcess.running) return
_dispatchedQuery = libraryQuery
albumProcess.command = libraryQuery.length > 0
var cmd = libraryQuery.length > 0
? [libraryHelper, "search", libraryQuery]
: [libraryHelper, "albums", "200"]
if (!enableYoutubeSearch && libraryQuery.length > 0) cmd.push("--no-youtube")
albumProcess.command = cmd
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)]
if (item.kind === "youtube") {
albumPlayProcess.command = [libraryHelper, "play-youtube", String(item.id), String(item.name || ""), String(item.artist || "")]
} else {
albumPlayProcess.command = [libraryHelper,
item.kind === "song" ? "play-song" : "play", String(item.id)]
}
albumPlayProcess.running = true
}

Expand All @@ -693,7 +706,13 @@ Item {
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root._libraryRows = Model.parseResults(text)
var rows = Model.parseResults(text)
if (!root.enableYoutubeSearch) {
var filtered = []
for (var i = 0; i < rows.length; i++) if (rows[i].kind !== "youtube") filtered.push(rows[i])
rows = filtered
}
root._libraryRows = rows
root._recomputeResults()
}
}
Expand Down
Loading