diff --git a/Library.qml b/Library.qml index 973682c..433fdb5 100644 --- a/Library.qml +++ b/Library.qml @@ -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. @@ -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 @@ -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. @@ -187,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/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/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 cc1eee7..bcc4e3e 100644 --- a/Panel.qml +++ b/Panel.qml @@ -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 { @@ -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 } } } 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 017f34b..573c40f 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. @@ -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. @@ -672,9 +679,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 } @@ -682,8 +691,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 } @@ -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() } } diff --git a/cliamp-library b/cliamp-library index c81fca3..899e535 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,63 @@ 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() + # 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: - 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. + # 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 + + # 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: 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",