// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import QtQuick import "../.." import "../appearance" import "../sound" import "Sonix.js" as S // The card's folders: browse, play a folder or a file, add either to a // playlist (an existing one, a draft from Playlists, or a new name). Column { id: fo required property var mod property string root: "" property string path: "" property var entries: [] property string error: "" property string notice: "" // What + was pressed on: { label, folder: bool, path }; null when closed. property var adding: null spacing: 8 function glyph(cp) { return String.fromCodePoint(cp); } function report(ok, d, err) { fo.error = ok ? "" : err; } function go(p) { fo.notice = ""; Sonix.get("/api/browse?" + S.query({ path: p }), (ok, d, err) => { fo.report(ok, d, err); if (!ok) return; if (typeof d !== "object" || d === null) { fo.error = String(d).trim() || "unexpected answer"; return; } fo.root = d.root; fo.path = d.path; fo.entries = d.entries || []; fo.adding = null; }); } Component.onCompleted: go("") // The files directly inside a folder, for playing or adding it whole. function filesIn(folder, cb) { Sonix.get("/api/browse?" + S.query({ path: folder }), (ok, d, err) => { if (!ok) { fo.report(ok, d, err); return; } if (typeof d !== "object" || d === null) { fo.error = String(d).trim() || "unexpected answer"; return; } cb((d.entries || []).filter(e => !e.dir).map(e => e.path)); }); } function playFolder(folder) { fo.notice = ""; fo.filesIn(folder, files => { if (files.length === 0) { fo.error = "no tracks directly in this folder"; return; } Sonix.command({ do: "play_path", path: files[0], list: "folder", value: folder }, null, fo.report); }); } function addTo(name) { const a = fo.adding; if (!a || name === "") return; newName.value = ""; const send = paths => { if (paths.length === 0) { fo.error = "no tracks directly in this folder"; return; } Sonix.command({ do: "playlist_add", value: name }, paths.join("\n"), (ok, d, err) => { fo.report(ok, d, err); if (ok) fo.notice = `Added ${paths.length} to ${name}`; }); }; fo.adding = null; if (a.folder) fo.filesIn(a.path, send); else send([a.path]); } property var playlists: [] function loadPlaylists() { Sonix.get("/api/playlists", (ok, d, err) => { if (ok) fo.playlists = (d.playlists || []).map(l => l.name); }); } // --- breadcrumbs ------------------------------------------------------ Flow { width: fo.width spacing: 6 Repeater { id: crumbList model: S.crumbs(fo.path, fo.root) delegate: Text { textFormat: Text.PlainText required property var modelData required property int index // A path: "SD card / A / B", the root carrying its own slash. text: modelData.name + (index > 0 && index < crumbList.count - 1 ? " /" : "") font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: modelData.path === fo.path ? Theme.text : Theme.accent MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: fo.go(modelData.path) } } } } Button { text: "Up" icon: "\uf062" enabled: fo.path !== fo.root onClicked: fo.go(S.parentOf(fo.path, fo.root)) } // --- the add-to chooser ----------------------------------------------- Rectangle { visible: fo.adding !== null width: fo.width height: chooser.implicitHeight + 16 radius: 8 color: Qt.alpha(Theme.surface, 0.8) border.width: 1 border.color: Qt.alpha(Theme.accent, 0.5) Column { id: chooser x: 8; y: 8 width: parent.width - 16 spacing: 6 Text { textFormat: Text.PlainText text: fo.adding ? `Add “${fo.adding.label}” to:` : "" font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.text } Flow { width: parent.width spacing: 6 Repeater { model: fo.playlists.concat(fo.mod.draftPlaylists.filter(n => fo.playlists.indexOf(n) < 0)) delegate: Button { required property var modelData text: modelData onClicked: fo.addTo(modelData) } } } Row { spacing: 6 Field { id: newName; width: 200; placeholder: "New playlist name" } Button { text: "Add"; onClicked: fo.addTo(newName.value.trim()) } Button { text: "Cancel"; onClicked: fo.adding = null } } } } // --- entries ---------------------------------------------------------- Repeater { model: fo.entries delegate: SonixRow { required property var modelData width: fo.width text: modelData.name sub: modelData.dir ? "folder" : "" onClicked: if (modelData.dir) fo.go(modelData.path) TransportButton { glyph: fo.glyph(0xF040A) // md-play onClicked: modelData.dir ? fo.playFolder(modelData.path) : (fo.notice = "", Sonix.command({ do: "play_path", path: modelData.path, list: "folder", value: fo.path }, null, fo.report)) } TransportButton { glyph: fo.glyph(0xF0415) // md-plus onClicked: { fo.notice = ""; fo.loadPlaylists(); fo.adding = { label: modelData.name, folder: modelData.dir, path: modelData.path }; } } } } // SonixLink lists only folders and what the player can play, so a folder // of pictures or books reads as empty. Wi-Fi transfer shows everything. Column { visible: fo.root !== "" && fo.entries.length === 0 && fo.error === "" width: fo.width spacing: 6 Text { width: parent.width wrapMode: Text.Wrap text: "Only music files are listed here. Other files: Open Wi-Fi transfer (its page must be open on the player)." font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } color: Theme.subtext } Button { text: "Open Wi-Fi transfer" onClicked: Qt.openUrlExternally(`http://${Sonix.host}/`) } } Text { textFormat: Text.PlainText visible: fo.notice !== "" && fo.error === "" text: fo.notice font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.green } Text { textFormat: Text.PlainText visible: fo.error !== "" width: fo.width wrapMode: Text.Wrap text: fo.error font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } color: Theme.red } }