From 854c82e6d7f027e5f5cbacb6651938f11ae4340b Mon Sep 17 00:00:00 2001 From: aarontanx Date: Tue, 1 Sep 2026 17:50:29 +0800 Subject: [PATCH 1/4] feat: add YouTube search via yt-dlp (mirrors TUI Ctrl+F) - cliamp-library: add yt_search() using yt-dlp ytsearchN:query (same backend as cliamp's youtube provider strings ytsearch1:/ytsearch10:), merge Subsonic search3 rows with YouTube rows, filter channels (YoutubeTab) and require 11-char video IDs, add play-youtube to build a one-track scratch playlist from https://www.youtube.com/watch?v=ID so the daemon can play without Subsonic. Works without a Navidrome host (host absence no longer blanks search). - Model.js: parseResults now handles kind=youtube (with url), sample updated, row conditional url to keep existing tests green. - Service.qml: playResult routes youtube kind to play-youtube with title/artist for TOML metadata. - Library.qml: placeholder updated to reflect YouTube. Fixes panel showing only playlists (matchPlaylists) when [ytmusic] enabled but no Subsonic, and enables video/music search like TUI Ctrl+F. Tested: search rick astley -> 7 youtube rows, play-youtube loads cliampui.toml and cliamp plays (duration 213, stream true). --- Library.qml | 2 +- Model.js | 12 ++- Service.qml | 8 +- cliamp-library | 195 ++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 200 insertions(+), 17 deletions(-) diff --git a/Library.qml b/Library.qml index 973682c..1d001c8 100644 --- a/Library.qml +++ b/Library.qml @@ -133,7 +133,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 diff --git a/Model.js b/Model.js index d8e43d8..0d70e26 100644 --- a/Model.js +++ b/Model.js @@ -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() @@ -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 } diff --git a/Service.qml b/Service.qml index 017f34b..327444a 100644 --- a/Service.qml +++ b/Service.qml @@ -682,8 +682,12 @@ 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 } diff --git a/cliamp-library b/cliamp-library index c81fca3..05ddf0e 100755 --- a/cliamp-library +++ b/cliamp-library @@ -16,13 +16,19 @@ With nothing playing there is no URL to borrow, which used to leave the library unbrowsable after a daemon restart. The playlists on disk hold the same token in their resolved stream URLs, so they are read as a fallback. +Youtube: cliamp's TUI has Ctrl+F provider-search for YouTube (ytsearch) via yt-dlp. +This helper now mirrors it for the panel: `search` merges Subsonic rows with +yt-dlp `ytsearch` results, and `play-youtube` builds a one-track scratch playlist +from a YouTube URL. No Subsonic host required for YouTube. + Usage: cliamp-library albums [limit] list albums as JSON - cliamp-library search albums and songs matching a query + cliamp-library search albums, songs and YouTube videos matching a query cliamp-library play replace the queue with that album and play cliamp-library play-song replace the queue with that one song and play + cliamp-library play-youtube [title] [artist] play a YouTube video -Every row carries a "kind" so one list can mix albums and songs. +Every row carries a "kind" so one list can mix albums, songs, youtube and playlists. """ import json @@ -39,6 +45,10 @@ TIMEOUT_SEC = 8 # Reused and overwritten, so a play leaves no artifact in the user's playlist list. SCRATCH_PLAYLIST = "cliampui" +# YouTube: how many videos to return for a panel search. +YT_SEARCH_LIMIT = 8 +YT_SEARCH_TIMEOUT = 15 + def cliamp(*args): try: @@ -172,6 +182,95 @@ def search(host, auth, query, limit): return rows +def yt_search(query, limit=YT_SEARCH_LIMIT): + """Search YouTube via yt-dlp. Mirrors cliamp's TUI Ctrl+F provider search. + + Uses `ytsearchN:query` which is the same backend cliamp's youtube provider uses + (see strings: ytsearch1:, ytsearch10:). Returns rows with kind youtube so the + panel can play them via play-youtube. Never raises; empty list on failure so a + Subsonic outage or missing yt-dlp doesn't blank the panel. + """ + q = (query or "").strip() + if not q: + return [] + # Clamp limit to 3..15 to keep panel responsive and avoid huge yt-dlp call. + try: + n = int(limit) + except Exception: + n = YT_SEARCH_LIMIT + n = max(3, min(15, n)) + cmd = ["yt-dlp", "--flat-playlist", "--no-warnings", "--print-json", f"ytsearch{n}:{q}"] + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=YT_SEARCH_TIMEOUT) + except Exception: + return [] + # yt-dlp exits 0 even for no results; stdout is NDJSON. + rows = [] + for line in (proc.stdout or "").splitlines(): + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + except Exception: + continue + vid = data.get("id") + if not vid or not isinstance(vid, str): + url_tmp = str(data.get("url") or "") + if "watch?v=" in url_tmp: + vid = url_tmp.split("watch?v=")[-1].split("&")[0] + if not vid or not isinstance(vid, str): + continue + # Only YouTube videos, not channels/playlists/tabs. Channels show as + # ie_key YoutubeTab with url https://www.youtube.com/channel/... + if data.get("ie_key") not in (None, "Youtube"): + # Allow None (older yt-dlp) but reject YoutubeTab and others + if str(data.get("ie_key") or "") != "Youtube": + continue + url_raw = str(data.get("url") or "") + # Require a watch?v= URL for real videos + if "watch?v=" not in url_raw: + # Fallback still requires video-like id (11 chars) + if not (isinstance(vid, str) and len(vid) == 11): + continue + else: + # Extracted vid from url must match id for sanity + if "watch?v=" in url_raw: + url_vid = url_raw.split("watch?v=")[-1].split("&")[0] + if url_vid != vid: + # Channel id mismatch case + continue + # YouTube video IDs are always 11 chars + if not (isinstance(vid, str) and len(vid) == 11): + continue + title = str(data.get("title") or "").strip() + if not title: + continue + # Skip "No results" placeholder titles yt-dlp sometimes emits + if title.lower().startswith("no results"): + continue + uploader = str(data.get("uploader") or data.get("channel") or data.get("uploader_id") or "").strip() + duration = data.get("duration") + dur = 0 + try: + if duration is not None: + dur = int(float(duration)) + if dur < 0: + dur = 0 + except Exception: + dur = 0 + rows.append({ + "kind": "youtube", + "id": vid, + "name": title, + "artist": uploader, + "album": "", + "duration": dur, + "url": f"https://www.youtube.com/watch?v={vid}", + }) + return rows + + def play_album(host, auth, album_id): data = call(host, auth, "getAlbum", id=album_id) album = data.get("album") or {} @@ -217,6 +316,50 @@ def play_song(host, auth, song_id): return 0 +def play_youtube(video_id, title="", artist=""): + """Play a YouTube video via a one-track scratch playlist. + + Builds `~/.config/cliamp/playlists/cliampui.toml` with a single + `https://www.youtube.com/watch?v=ID` URL which cliamp's youtube provider + (yt-dlp) resolves natively. Title/artist are for the TOML metadata so the + panel shows something before MPRIS updates. + """ + vid = str(video_id or "").strip().split("?")[0].split("&")[0] + # accept full URL as well + if "watch?v=" in vid: + vid = vid.split("watch?v=")[-1].split("&")[0] + elif "/" in vid: + vid = vid.rsplit("/", 1)[-1] + vid = vid.strip() + if not vid: + return 1 + # Basic sanity: youtube IDs are 11 chars alnum_- ; allow a bit wider to avoid false reject + if len(vid) < 6 or len(vid) > 20: + return 1 + title = str(title or "").strip() or vid + artist = str(artist or "").strip() + url = f"https://www.youtube.com/watch?v={vid}" + + path = os.path.join(os.path.expanduser("~/.config/cliamp/playlists"), safe_name(SCRATCH_PLAYLIST) + ".toml") + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + os.unlink(path) + except FileNotFoundError: + pass + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write("[[track]]\n") + handle.write(f'path = "{toml_escape(url)}"\n') + handle.write(f'title = "{toml_escape(title)}"\n') + if artist: + handle.write(f'artist = "{toml_escape(artist)}"\n') + handle.write("\n") + cliamp("load", SCRATCH_PLAYLIST) + label = f"{artist} - {title}".strip(" -") if artist else title + print(label) + return 0 + + def toml_escape(value): return str(value).replace("\\", "\\\\").replace('"', '\\"') @@ -277,26 +420,58 @@ def main(): if not args: return 2 + # Youtube play does not need a Subsonic host. + if args[0] in ("play-youtube", "play-yt") and len(args) > 1: + title = args[2] if len(args) > 2 else "" + artist = args[3] if len(args) > 3 else "" + return play_youtube(args[1], title, artist) + host, auth = server_from(current_stream_url()) if not host: host, auth = server_from(playlist_stream_url()) - if not host: - # Neither a playing track nor a playlist on disk held a token to borrow. - if args[0] in ("albums", "search"): - print("[]") - return 0 + # Subsonic host missing is not fatal for YouTube search or for listing empty albums. if args[0] == "albums": limit = int(args[1]) if len(args) > 1 and args[1].isdigit() else 100 - print(json.dumps(albums(host, auth, limit))) + out = [] + if host: + try: + out = albums(host, auth, limit) + except Exception: + out = [] + print(json.dumps(out)) return 0 if args[0] == "search": query = " ".join(args[1:]).strip() if not query: - print(json.dumps(albums(host, auth, 200))) + out = [] + if host: + try: + out = albums(host, auth, 200) + except Exception: + out = [] + print(json.dumps(out)) return 0 - print(json.dumps(search(host, auth, query, 100))) + rows = [] + if host: + try: + rows = search(host, auth, query, 100) + except Exception: + rows = [] + # Merge YouTube results (yt-dlp ytsearch). Runs even without Subsonic. + try: + yt_rows = yt_search(query, YT_SEARCH_LIMIT) + except Exception: + yt_rows = [] + rows.extend(yt_rows) + print(json.dumps(rows)) + return 0 + + # Subsonic-only commands below require a host. + if not host: + if args[0] in ("albums", "search"): + print("[]") return 0 if args[0] == "play" and len(args) > 1: From 749d606968439132d9bd8b1333546cc60da817d3 Mon Sep 17 00:00:00 2001 From: aarontanx Date: Tue, 1 Sep 2026 17:57:18 +0800 Subject: [PATCH 2/4] feat: toggle for YouTube search - manifest: add enableYoutubeSearch boolean (default true) with schema, README settings table + notes updated - Service.qml: boolSetting enableYoutubeSearch, _dispatchLibrary adds --no-youtube when disabled, filters youtube rows post-parse - cliamp-library: search honors --no-youtube flag to skip yt_search - Library.qml: inline 'YouTube search ON/OFF' row + toggleYoutubeRequested signal - Panel.qml: handle toggle, persist via bar.shell.updateEntryInline and re-trigger current search Toggle via bar settings UI or library row, or CLI: omarchy bar set io.github.thisisgm.cliampui enableYoutubeSearch false --json omarchy bar set io.github.thisisgm.cliampui enableYoutubeSearch true --json --- Library.qml | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- Panel.qml | 8 ++++++++ README.md | 7 ++++--- Service.qml | 15 +++++++++++---- cliamp-library | 17 +++++++++++------ manifest.json | 10 +++++++++- 6 files changed, 92 insertions(+), 15 deletions(-) diff --git a/Library.qml b/Library.qml index 1d001c8..02d5510 100644 --- a/Library.qml +++ b/Library.qml @@ -15,10 +15,12 @@ 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() + + 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. @@ -154,6 +156,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. diff --git a/Panel.qml b/Panel.qml index cc1eee7..6366c1d 100644 --- a/Panel.qml +++ b/Panel.qml @@ -207,6 +207,14 @@ Panel { onMoveRequested: function (delta) { root.moveCursor(delta) } onActivateRequested: root.activateCursor() 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 { diff --git a/README.md b/README.md index 4b79c0a..7dc4ba5 100644 --- a/README.md +++ b/README.md @@ -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 | | @@ -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 diff --git a/Service.qml b/Service.qml index 327444a..7aff2a8 100644 --- a/Service.qml +++ b/Service.qml @@ -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. @@ -672,9 +673,11 @@ 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 } @@ -689,15 +692,19 @@ Item { 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) + 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() } } diff --git a/cliamp-library b/cliamp-library index 05ddf0e..899e535 100755 --- a/cliamp-library +++ b/cliamp-library @@ -443,7 +443,10 @@ def main(): return 0 if args[0] == "search": - query = " ".join(args[1:]).strip() + # Support --no-youtube flag from Service.qml toggle (enableYoutubeSearch) + raw_query_parts = [a for a in args[1:] if a != "--no-youtube"] + no_youtube = len(raw_query_parts) != len(args[1:]) + query = " ".join(raw_query_parts).strip() if not query: out = [] if host: @@ -460,11 +463,13 @@ def main(): except Exception: rows = [] # Merge YouTube results (yt-dlp ytsearch). Runs even without Subsonic. - try: - yt_rows = yt_search(query, YT_SEARCH_LIMIT) - except Exception: - yt_rows = [] - rows.extend(yt_rows) + # Skipped when panel sets enableYoutubeSearch=false → --no-youtube + if not no_youtube: + try: + yt_rows = yt_search(query, YT_SEARCH_LIMIT) + except Exception: + yt_rows = [] + rows.extend(yt_rows) print(json.dumps(rows)) return 0 diff --git a/manifest.json b/manifest.json index ad047f3..fba6c47 100644 --- a/manifest.json +++ b/manifest.json @@ -24,7 +24,8 @@ "hideWhenStopped": true, "cliampPath": "", "lyricTrimMs": 0, - "followNativeRate": false + "followNativeRate": false, + "enableYoutubeSearch": true }, "schema": [ { @@ -42,6 +43,13 @@ "description": "Retunes PipeWire to the track's sample rate while cliamp plays, and restores it afterwards. Affects every application on this machine while music is playing.", "defaultValue": true }, + { + "key": "enableYoutubeSearch", + "type": "boolean", + "label": "Search YouTube in library", + "description": "When enabled, searching the library also queries YouTube via yt-dlp (ytsearch) and shows videos alongside Subsonic results and playlists. Disable to search only your Navidrome library and saved playlists.", + "defaultValue": true + }, { "key": "followNativeRate", "type": "boolean", From 0dbd31434df7e9529aeeea139aaf02bfbd808773 Mon Sep 17 00:00:00 2001 From: aarontanx Date: Tue, 1 Sep 2026 18:00:42 +0800 Subject: [PATCH 3/4] fix: missing closing brace for playResult in Service.qml Fixes plugin widget failing to load (Service unavailable) after toggle feature - syntax error at 695:11 Expected token ',' --- Service.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Service.qml b/Service.qml index 7aff2a8..48dbc38 100644 --- a/Service.qml +++ b/Service.qml @@ -692,6 +692,8 @@ Item { item.kind === "song" ? "play-song" : "play", String(item.id)] } albumPlayProcess.running = true + } + Process { id: albumProcess command: [] From e27a6e0e6efa6240fb4397824b859b74d3f7e387 Mon Sep 17 00:00:00 2001 From: aarontanx Date: Tue, 1 Sep 2026 21:28:38 +0800 Subject: [PATCH 4/4] fix: hover follows mouse + restore Service volumeDb/shuffle/isYoutube Library/OutputSheet emit hovered onContainsMouseChanged -> Panel.cursorIndex follows mouse (matches CursorSurface contract, audio panel SinkRow pattern) Service: isYoutube fix dropped shuffle/repeat/total/volumeDb causing ReferenceError and missing button; restore them and keep isYoutube canSeek exception; fixes volumeDb not defined at playerUnity NowPlaying: keep draggable scrub (progressBar.scrubbing) already live --- Library.qml | 2 ++ NowPlaying.qml | 42 +++++++++++++++++++++++++++++++++++++----- OutputSheet.qml | 2 ++ Panel.qml | 2 ++ Service.qml | 10 ++++++++-- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/Library.qml b/Library.qml index 02d5510..433fdb5 100644 --- a/Library.qml +++ b/Library.qml @@ -19,6 +19,7 @@ Column { signal moveRequested(int delta) signal activateRequested() signal toggleYoutubeRequested() + signal hovered(int index) readonly property string searchText: search.text @@ -235,6 +236,7 @@ Column { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onContainsMouseChanged: if (containsMouse) root.hovered(index) onClicked: root.service.playResult(modelData) } diff --git a/NowPlaying.qml b/NowPlaying.qml index 7b10f7b..46b87df 100644 --- a/NowPlaying.qml +++ b/NowPlaying.qml @@ -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 @@ -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)) } @@ -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 } } } @@ -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 } } @@ -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 diff --git a/OutputSheet.qml b/OutputSheet.qml index eb559c2..29ccf68 100644 --- a/OutputSheet.qml +++ b/OutputSheet.qml @@ -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: "" }) @@ -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 || "")) } diff --git a/Panel.qml b/Panel.qml index 6366c1d..bcc4e3e 100644 --- a/Panel.qml +++ b/Panel.qml @@ -206,6 +206,7 @@ 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 @@ -224,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 } } } diff --git a/Service.qml b/Service.qml index 48dbc38..573c40f 100644 --- a/Service.qml +++ b/Service.qml @@ -83,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.