feat(qbz-qt): phase 9 — Queue homologation + Lyrics side panel
Queue panel 1:1: History tab (click replays per queue.rs semantics), #442 'Next in queue'/'Next up' section markers from manual_next_count, 40-row pagination + live search + page clamp, row actions (play, remove, remove-all-after, heart), press-drag reorder via move_track, clear queue, plural-aware footer count, exact empty states. New Lyrics panel: qbz-lyrics service + per-user cache wired, Qobuz-first chain with LRCLIB fallback, synced line view with line-level highlight + centered auto-follow + click-to-seek, exact status bodies. Both panels stack Feishin-style in the right column; NPB lyrics button wired. km
No files matched your search
@@ -6626,6 +6626,7 @@ dependencies = [
|
||||
"qbz-i18n",
|
||||
"qbz-library",
|
||||
"qbz-log",
|
||||
"qbz-lyrics",
|
||||
"qbz-models",
|
||||
"qbz-offline-cache",
|
||||
"qbz-qobuz",
|
||||
|
||||
@@ -31,6 +31,7 @@ qbz-models = { path = "../qbz-models" }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
qbz-offline-cache = { path = "../qbz-offline-cache" }
|
||||
qbz-library = { path = "../qbz-library" }
|
||||
qbz-lyrics = { path = "../qbz-lyrics" }
|
||||
qbz-text-utils = { path = "../qbz-text-utils" }
|
||||
qbz-credentials = { path = "../qbz-credentials" }
|
||||
qbz-i18n = { path = "../qbz-i18n" }
|
||||
|
||||
@@ -48,6 +48,7 @@ fn main() {
|
||||
"qml/HeaderBar.qml",
|
||||
"qml/NowPlayingBarSmall.qml",
|
||||
"qml/QueuePanel.qml",
|
||||
"qml/LyricsPanel.qml",
|
||||
"qml/HomeView.qml",
|
||||
"qml/LibraryView.qml",
|
||||
"qml/QbzTheme.qml",
|
||||
|
||||
@@ -59,15 +59,16 @@ Rectangle {
|
||||
// Animated 3-state width lives inside the component.
|
||||
}
|
||||
|
||||
// Right-side queue column — a layout sibling of the content (it shrinks
|
||||
// the content, no overlay), animated 0 <-> 300 like the Slint AppShell
|
||||
// column (160ms ease-in-out).
|
||||
// Right-side panel column — Queue and/or Lyrics, stacked vertically in
|
||||
// a shared 300px column (Feishin-style, AppShell.slint:684-707). Each
|
||||
// is toggled from its bar button and closed from its own X; the column
|
||||
// is visible when either is open, animated 0 <-> 300 (160ms).
|
||||
Rectangle {
|
||||
id: queueColumn
|
||||
anchors.right: parent.right
|
||||
anchors.top: header.bottom
|
||||
anchors.bottom: npb.top
|
||||
width: QbzBridge.queueOpen ? theme.queuePanelWidth : 0
|
||||
width: (QbzBridge.queueOpen || QbzBridge.lyricsOpen) ? theme.queuePanelWidth : 0
|
||||
clip: true
|
||||
color: theme.surfaceCard
|
||||
|
||||
@@ -76,9 +77,31 @@ Rectangle {
|
||||
}
|
||||
|
||||
QueuePanel {
|
||||
anchors.fill: parent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
height: QbzBridge.lyricsOpen
|
||||
? (QbzBridge.queueOpen ? parent.height / 2 : 0)
|
||||
: parent.height
|
||||
visible: QbzBridge.queueOpen
|
||||
}
|
||||
Rectangle {
|
||||
visible: QbzBridge.queueOpen && QbzBridge.lyricsOpen
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
y: QbzBridge.lyricsOpen && QbzBridge.queueOpen ? parent.height / 2 : 0
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
LyricsPanel {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: QbzBridge.lyricsOpen
|
||||
? (QbzBridge.queueOpen ? parent.height / 2 - 1 : parent.height)
|
||||
: 0
|
||||
visible: QbzBridge.lyricsOpen
|
||||
}
|
||||
}
|
||||
|
||||
// Content frame — the rounded, inset surface-main panel (Radius.md,
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// Right-side Lyrics panel — QML port of
|
||||
// crates/qbz-ui/ui/shell/LyricsSidebar.slint.
|
||||
//
|
||||
// Header (LYRICS overline + "{title} - {artist}" meta + display-settings
|
||||
// trigger + close X), body by status (loading / no-lyrics / offline-miss
|
||||
// / error / the lines view), source attribution line. Synced rendering:
|
||||
// LINE-level highlight + auto-follow driven by the 1 Hz position feed
|
||||
// (QbzBridge.npElapsedSecs; the Slint engine ticks ~30 Hz and can fill
|
||||
// word-level karaoke — both noted POC-NOTEs). Lines with timestamps are
|
||||
// click-to-seek (LyricsLinesView behavior).
|
||||
//
|
||||
// POC-NOTEs: display settings flyout (size/dimming/uppercase/font/active
|
||||
// color — Slint defaults hardcoded here: 13/15px, accent, auto-follow
|
||||
// ON, no dimming of surrounding lines), the translate toggle, word-level
|
||||
// karaoke, the LRCLIB contribute link is rendered but opens via the
|
||||
// system browser like upstream.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
color: "transparent"
|
||||
clip: true
|
||||
|
||||
readonly property var doc: JSON.parse(QbzBridge.lyricsJson)
|
||||
readonly property var lines: doc.lines || []
|
||||
readonly property bool synced: doc.synced === true
|
||||
|
||||
// Active line (line-level sync, 1 Hz cadence).
|
||||
readonly property int activeIndex: {
|
||||
if (!synced) return -1
|
||||
var nowMs = (QbzBridge.npElapsedSecs || 0) * 1000
|
||||
var idx = -1
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
if (lines[i].timeMs !== null && lines[i].timeMs <= nowMs) idx = i
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
|
||||
onActiveIndexChanged: followActive()
|
||||
function followActive() {
|
||||
if (root.activeIndex < 0 || linesColumn.children.length === 0) return
|
||||
var row = linesColumn.children[Math.min(root.activeIndex, linesColumn.children.length - 1)]
|
||||
var target = row.y + row.height / 2 - linesFlick.height / 2
|
||||
target = Math.max(0, Math.min(target, linesFlick.contentHeight - linesFlick.height))
|
||||
if (Math.abs(linesFlick.contentY - target) > 30) {
|
||||
followAnim2.to = target
|
||||
followAnim2.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// --- Header: overline/meta + controls trigger + close ----------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 56
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: theme.spacingSm
|
||||
anchors.right: controlsRow.left
|
||||
anchors.rightMargin: theme.spacingSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
text: QbzBridge.tr("LYRICS")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 1.5
|
||||
}
|
||||
Text {
|
||||
visible: QbzBridge.npTitle !== ""
|
||||
width: parent.width
|
||||
text: QbzBridge.npTitle + " - " + QbzBridge.npArtist
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
Row {
|
||||
id: controlsRow
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: theme.spacingSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
// Display settings (IconButton) — opens the flyout stub.
|
||||
Rectangle {
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
color: dsArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "sliders-horizontal"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: dsArea.containsMouse ? "primary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: dsArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: dsFlyout.open()
|
||||
}
|
||||
}
|
||||
// Close X (Slint-convention, homologated with Queue).
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: theme.radiusSm
|
||||
color: closeArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "x"
|
||||
width: 15
|
||||
height: 15
|
||||
tintName: closeArea.containsMouse ? "primary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: closeArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: QbzBridge.toggleLyrics()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: theme.borderSubtle
|
||||
}
|
||||
|
||||
// --- Body by status ----------------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - 57 - sourceLine.height
|
||||
|
||||
// Loading.
|
||||
Column {
|
||||
visible: doc.status === 1
|
||||
anchors.centerIn: parent
|
||||
spacing: 10
|
||||
QbzSpinner { size: 24; anchors.horizontalCenter: parent.horizontalCenter }
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: QbzBridge.tr("Fetching lyrics...")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
}
|
||||
}
|
||||
|
||||
// No lyrics (idle / not-found / ready-with-zero-lines).
|
||||
Column {
|
||||
visible: doc.status === 0 || doc.status === 3 || (doc.status === 2 && root.lines.length === 0)
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 32
|
||||
spacing: 12
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzBridge.tr("No lyrics available")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: QbzBridge.tr("Missing lyrics? You can contribute them to LRCLIB:")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 10
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: "lrclibup.boidu.dev"
|
||||
color: lrcArea.containsMouse ? theme.accentHover : theme.accent
|
||||
font.pixelSize: 10
|
||||
MouseArea {
|
||||
id: lrcArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Qt.openUrlExternally("https://lrclibup.boidu.dev/")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Offline with nothing cached.
|
||||
Text {
|
||||
visible: doc.status === 5
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 32
|
||||
text: QbzBridge.tr("Lyrics unavailable offline")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// Error.
|
||||
Text {
|
||||
visible: doc.status === 4
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 32
|
||||
text: doc.error || ""
|
||||
color: "#e0564f"
|
||||
font.pixelSize: theme.fontLegal
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// Lyrics lines (status 2, lines > 0).
|
||||
Flickable {
|
||||
id: linesFlick
|
||||
visible: doc.status === 2 && root.lines.length > 0
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: linesColumn.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: linesColumn
|
||||
width: parent.width
|
||||
leftPadding: theme.spacingMd
|
||||
rightPadding: theme.spacingMd
|
||||
topPadding: theme.spacingMd
|
||||
bottomPadding: 48
|
||||
spacing: 10
|
||||
|
||||
Repeater {
|
||||
model: root.lines
|
||||
delegate: Text {
|
||||
required property var modelData
|
||||
required property int index
|
||||
property bool isActive: index === root.activeIndex
|
||||
|
||||
width: linesColumn.width
|
||||
text: modelData.text
|
||||
// Synced: active line = accent, 15px; the rest
|
||||
// secondary 13px. Unsynced: uniform secondary.
|
||||
color: !root.synced ? theme.textSecondary
|
||||
: isActive ? theme.accent : theme.textSecondary
|
||||
font.pixelSize: root.synced && isActive ? 15 : 13
|
||||
font.weight: root.synced && isActive ? theme.weightSemibold : theme.weightRegular
|
||||
wrapMode: Text.WordWrap
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: modelData.timeMs !== null
|
||||
cursorShape: modelData.timeMs !== null ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (modelData.timeMs !== null && QbzBridge.npDurationSecs > 0)
|
||||
QbzBridge.seek(modelData.timeMs / 1000 / QbzBridge.npDurationSecs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-follow hook (the poll updates npElapsedSecs 1 Hz).
|
||||
NumberAnimation {
|
||||
id: followAnim2
|
||||
target: linesFlick
|
||||
property: "contentY"
|
||||
duration: 250
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
|
||||
// --- Source attribution ------------------------------------------
|
||||
Row {
|
||||
id: sourceLine
|
||||
visible: doc.status === 2 && root.lines.length > 0
|
||||
width: parent.width
|
||||
leftPadding: theme.spacingSm
|
||||
rightPadding: theme.spacingSm
|
||||
topPadding: 3
|
||||
bottomPadding: 5
|
||||
spacing: 3
|
||||
Text {
|
||||
text: QbzBridge.tr("Lyrics source") + ":"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
text: doc.provider === "lrclib" ? "LRCLIB"
|
||||
: doc.provider === "ovh" ? "lyrics.ovh" : "Qobuz"
|
||||
color: theme.accent
|
||||
font.pixelSize: 9
|
||||
font.weight: theme.weightSemibold
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Display-settings flyout (visual stub — POC-NOTE: the prefs) ----
|
||||
Popup {
|
||||
id: dsFlyout
|
||||
x: parent.width - 260
|
||||
y: 64
|
||||
width: 260
|
||||
padding: 14
|
||||
closePolicy: Popup.CloseOnPressOutside
|
||||
background: Rectangle {
|
||||
color: theme.surfaceCard
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
}
|
||||
contentItem: Text {
|
||||
width: parent ? parent.width : 0
|
||||
text: "QBZ Qt POC — display settings are not wired yet (Slint defaults apply)"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,7 +404,11 @@ Rectangle {
|
||||
BarIconBtn { name: "cast" }
|
||||
BarIconBtn { name: "settings-2" }
|
||||
BarIconBtn { name: "layout-grid" }
|
||||
BarIconBtn { name: "mic-vocal" }
|
||||
BarIconBtn {
|
||||
name: "mic-vocal"
|
||||
active: QbzBridge.lyricsOpen
|
||||
onClicked: QbzBridge.toggleLyrics()
|
||||
}
|
||||
|
||||
Item { width: 4; height: 1 }
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
// Right-side Queue panel — QML port of
|
||||
// crates/qbz-ui/ui/shell/QueueSidebar.slint.
|
||||
//
|
||||
// Header with the two tabs ("Queue" / "History"), the NOW PLAYING card,
|
||||
// the UP NEXT list, and the empty states. Phase 4: the Queue tab renders
|
||||
// the REAL queue (QbzBridge.queueModel — one JSON-encoded row per QVariant,
|
||||
// see the nesting POC-NOTE in playback_qt.rs `publish_queue`).
|
||||
// Tabs (Queue / History), NOW PLAYING card (live heart), UP NEXT with the
|
||||
// #442 "Next in queue" / "Next up" section markers, 40-row pagination,
|
||||
// live search filter, row actions (play / remove / remove-all-after /
|
||||
// heart), drag reorder (basic press-drag), footer (count line + Clear /
|
||||
// stubs + search field), History tab (thumbnail rows, click replays as a
|
||||
// fresh single-track queue), exact empty states. Data: QbzBridge.queueJson
|
||||
// (queue_qt.rs QueueDoc).
|
||||
//
|
||||
// POC-NOTEs:
|
||||
// - The footer (count line + clear / save-as-playlist / infinite /
|
||||
// sleep-timer actions + inline queue search) is omitted — phase 4 brings
|
||||
// no queue mutations beyond play/next/previous.
|
||||
// - The History tab stays empty (no local play-history store in the POC).
|
||||
// - Row context menus, drag-reorder, pagination and the now-playing
|
||||
// favorite toggle are out of scope.
|
||||
// POC-NOTEs: save-as-playlist (opens a picker modal upstream — inert),
|
||||
// infinite-play + sleep timer engines, stop-after marker, row menu's
|
||||
// "Add to playlist" / "Track info" entries, ephemeral rows, the ghost
|
||||
// drag pill (rows move with a plain displaced animation instead).
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Rectangle {
|
||||
@@ -24,22 +25,39 @@ Rectangle {
|
||||
|
||||
// Queue (0) / History (1) — Slint QueueState.tab.
|
||||
property int tab: 0
|
||||
// Parsed queue rows: [{id, title, artist, duration, artPath, current}].
|
||||
readonly property var rows: {
|
||||
var out = []
|
||||
for (var i = 0; i < QbzBridge.queueModel.length; i++) {
|
||||
try { out.push(JSON.parse(QbzBridge.queueModel[i])) } catch (e) {}
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property var currentRow: rows.length > 0 && rows[0].current ? rows[0] : null
|
||||
readonly property var upcomingRows: currentRow ? rows.slice(1) : rows
|
||||
readonly property bool queueEmpty: rows.length === 0
|
||||
readonly property var doc: JSON.parse(QbzBridge.queueJson)
|
||||
readonly property var currentRow: doc.current || null
|
||||
readonly property var upcoming: doc.upcoming || []
|
||||
readonly property var historyRows: doc.history || []
|
||||
readonly property bool queueEmpty: currentRow === null && (doc.upcomingTotal || 0) === 0
|
||||
|
||||
// url-keyed cover map (shared artwork pipeline).
|
||||
property var coverMap: ({})
|
||||
|
||||
// Drag-reorder state (basic press-drag; ghost is the row's own y).
|
||||
property int dragFrom: -1
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
// One tab — plain text, no pill (QBZ design rule): active brightest,
|
||||
// inactive dim, brighten on hover.
|
||||
Connections {
|
||||
target: QbzBridge
|
||||
function onLibraryArtworkReady(key, path) {
|
||||
var m = root.coverMap
|
||||
m[key] = path
|
||||
root.coverMap = Object.assign({}, m)
|
||||
}
|
||||
}
|
||||
Component.onCompleted: dispatchCovers()
|
||||
onDocChanged: dispatchCovers()
|
||||
function dispatchCovers() {
|
||||
var urls = []
|
||||
if (currentRow && currentRow.artUrl) urls.push(currentRow.artUrl)
|
||||
var i
|
||||
for (i = 0; i < upcoming.length; i++) if (upcoming[i].artUrl) urls.push(upcoming[i].artUrl)
|
||||
for (i = 0; i < historyRows.length; i++) if (historyRows[i].artUrl) urls.push(historyRows[i].artUrl)
|
||||
if (urls.length > 0) QbzBridge.sidebarArtworkWindow(JSON.stringify(urls))
|
||||
}
|
||||
|
||||
component QueueTab: Text {
|
||||
property bool active: false
|
||||
signal clicked()
|
||||
@@ -60,17 +78,54 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// One queue row (QueueRow.slint): index or thumbnail, title, artist,
|
||||
// duration.
|
||||
// Row action icon button (the panel's small IconButton).
|
||||
component PanelIconBtn: Rectangle {
|
||||
property string name: ""
|
||||
property int iconSize: 17
|
||||
property bool active: false
|
||||
property bool btnEnabled: true
|
||||
signal clicked()
|
||||
|
||||
width: 30
|
||||
height: 30
|
||||
radius: theme.radiusSm
|
||||
color: (piArea.containsMouse && btnEnabled) ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
name: parent.name
|
||||
width: parent.iconSize
|
||||
height: parent.iconSize
|
||||
anchors.centerIn: parent
|
||||
tintName: !parent.btnEnabled ? "muted"
|
||||
: parent.active ? "accent"
|
||||
: piArea.containsMouse ? "primary" : "secondary"
|
||||
}
|
||||
MouseArea {
|
||||
id: piArea
|
||||
anchors.fill: parent
|
||||
enabled: parent.btnEnabled
|
||||
hoverEnabled: true
|
||||
cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: parent.clicked()
|
||||
}
|
||||
}
|
||||
|
||||
// One UP NEXT / History row (QueueRow.slint).
|
||||
component QueueRow: Rectangle {
|
||||
property var row: ({})
|
||||
property int rowIndex: 0
|
||||
property bool showNumber: true
|
||||
property bool inQueue: true
|
||||
|
||||
readonly property bool hovered: qrArea.containsMouse || menuArea.containsMouse
|
||||
readonly property bool isActive: inQueue && QbzBridge.npTrackId !== "" && QbzBridge.npTrackId === row.id
|
||||
|
||||
height: 44
|
||||
radius: theme.radiusSm
|
||||
color: qrArea.containsMouse ? theme.surfaceHover
|
||||
color: hovered ? theme.surfaceHover
|
||||
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
|
||||
border.width: root.dragFrom === rowIndex ? 1 : 0
|
||||
border.color: theme.accent
|
||||
opacity: root.dragFrom === rowIndex ? 0.4 : 1.0
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
@@ -80,11 +135,12 @@ Rectangle {
|
||||
anchors.bottomMargin: 4
|
||||
spacing: 9
|
||||
|
||||
// Leading: track number (UP NEXT) or thumbnail (History).
|
||||
Text {
|
||||
visible: showNumber
|
||||
width: 22
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: rowIndex + 1
|
||||
text: rowIndex + 1 + (doc.page || 0) * 40
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
@@ -99,22 +155,42 @@ Rectangle {
|
||||
clip: true
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: row.artPath
|
||||
source: root.coverMap[row.artUrl] || ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
}
|
||||
}
|
||||
|
||||
// Title + artist.
|
||||
Column {
|
||||
width: parent.width - (showNumber ? 22 : 34) - 9 * 2 - durationText.implicitWidth
|
||||
width: parent.width - (showNumber ? 22 : 34) - durText.width - 32 - 3 * 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: row.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
Row {
|
||||
spacing: 5
|
||||
Text {
|
||||
text: row.title
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
font.weight: theme.weightMedium
|
||||
elide: Text.ElideRight
|
||||
width: Math.min(implicitWidth, parent.parent.width - (row.explicit ? 21 : 0))
|
||||
}
|
||||
Rectangle {
|
||||
visible: row.explicit
|
||||
width: 16
|
||||
height: 16
|
||||
radius: 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: theme.surfaceElevated
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "E"
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 9
|
||||
font.weight: theme.weightSemibold
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
@@ -125,19 +201,107 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: durationText
|
||||
id: durText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: row.duration
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
}
|
||||
// ⋯ menu trigger (UP NEXT rows only; always visible, 32px hit).
|
||||
Rectangle {
|
||||
visible: inQueue
|
||||
width: 32
|
||||
height: 32
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: menuArea.containsMouse ? theme.surfaceElevated : "transparent"
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: "ellipsis"
|
||||
width: 16
|
||||
height: 16
|
||||
tintName: menuArea.containsMouse ? "primary" : "muted"
|
||||
}
|
||||
MouseArea {
|
||||
id: menuArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: rowMenu.open()
|
||||
}
|
||||
Popup {
|
||||
id: rowMenu
|
||||
x: width - 196
|
||||
y: 44
|
||||
width: 196
|
||||
padding: 5
|
||||
closePolicy: Popup.CloseOnPressOutside
|
||||
background: Rectangle {
|
||||
color: theme.surfaceMain
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderMuted
|
||||
}
|
||||
contentItem: Column {
|
||||
Repeater {
|
||||
model: [
|
||||
{ "label": QbzBridge.tr("Remove from queue"), "icon": "trash-2", "action": "remove" },
|
||||
{ "label": QbzBridge.tr("Remove all after"), "icon": "list-x", "action": "remove-after" },
|
||||
{ "label": row.isFavorite ? QbzBridge.tr("Remove from Library") : QbzBridge.tr("Add to Library"), "icon": row.isFavorite ? "heart-filled" : "heart", "action": "favorite" },
|
||||
]
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
height: 33
|
||||
radius: 5
|
||||
color: rmiArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
spacing: 8
|
||||
QbzIcon { name: modelData.icon; width: 15; height: 15; anchors.verticalCenter: parent.verticalCenter; tintName: "secondary" }
|
||||
Text {
|
||||
height: parent.height
|
||||
width: parent.width - 23
|
||||
text: modelData.label
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: 13
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
MouseArea {
|
||||
id: rmiArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
rowMenu.close()
|
||||
var a = modelData.action
|
||||
if (a === "remove") QbzBridge.queueRemoveUpcoming(rowIndex)
|
||||
else if (a === "remove-after") QbzBridge.queueRemoveAllAfter(rowIndex)
|
||||
else if (a === "favorite") {
|
||||
row.isFavorite = !row.isFavorite
|
||||
QbzBridge.queueToggleFavorite("track", row.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: qrArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
// POC-NOTE: click-to-play / context menu out of scope.
|
||||
onClicked: {
|
||||
if (inQueue) QbzBridge.queuePlayUpcoming(rowIndex)
|
||||
else QbzBridge.queuePlayHistory(rowIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +341,6 @@ Rectangle {
|
||||
onClicked: root.tab = 1
|
||||
}
|
||||
}
|
||||
// Close button (mirrors the LyricsSidebar X).
|
||||
Rectangle {
|
||||
id: closeBtn
|
||||
anchors.right: parent.right
|
||||
@@ -212,7 +375,7 @@ Rectangle {
|
||||
// --- Body --------------------------------------------------------
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - 45
|
||||
height: parent.height - 45 - footerBlock.height
|
||||
|
||||
// ===== Queue tab =====
|
||||
Flickable {
|
||||
@@ -241,7 +404,6 @@ Rectangle {
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
// Highlighted current-track card.
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 44
|
||||
@@ -263,13 +425,13 @@ Rectangle {
|
||||
clip: true
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: root.currentRow ? root.currentRow.artPath : ""
|
||||
source: root.currentRow ? (root.coverMap[root.currentRow.artUrl] || "") : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
}
|
||||
}
|
||||
Column {
|
||||
width: parent.width - 34 - 9
|
||||
width: parent.width - 34 - 28 - 2 * 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
@@ -288,13 +450,40 @@ Rectangle {
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
// Favorite toggle (wired).
|
||||
Rectangle {
|
||||
width: 28
|
||||
height: 28
|
||||
radius: theme.radiusSm
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: npFavArea.containsMouse ? theme.surfaceHover : "transparent"
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
name: root.currentRow && root.currentRow.isFavorite ? "heart-filled" : "heart"
|
||||
width: 17
|
||||
height: 17
|
||||
tintName: root.currentRow && root.currentRow.isFavorite ? "favorite" : (npFavArea.containsMouse ? "primary" : "muted")
|
||||
}
|
||||
MouseArea {
|
||||
id: npFavArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.currentRow) {
|
||||
root.currentRow.isFavorite = !root.currentRow.isFavorite
|
||||
QbzBridge.queueToggleFavorite("track", root.currentRow.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UP NEXT section.
|
||||
Column {
|
||||
visible: root.upcomingRows.length > 0
|
||||
visible: (doc.upcomingTotal || 0) > 0
|
||||
width: parent.width - 20
|
||||
spacing: 8
|
||||
Text {
|
||||
@@ -304,15 +493,90 @@ Rectangle {
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.upcomingRows
|
||||
delegate: QueueRow {
|
||||
model: root.upcoming
|
||||
delegate: Column {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent ? parent.width : 0
|
||||
row: modelData
|
||||
rowIndex: index
|
||||
showNumber: true
|
||||
// #442 section header above the row.
|
||||
Row {
|
||||
visible: modelData.section !== ""
|
||||
height: 22
|
||||
spacing: 6
|
||||
QbzIcon {
|
||||
visible: modelData.section === "next-in-queue"
|
||||
name: "list-start"
|
||||
width: 13
|
||||
height: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "accent"
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelData.section === "next-in-queue"
|
||||
? QbzBridge.tr("Next in queue") : QbzBridge.tr("Next up")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
QueueRow {
|
||||
width: parent.width
|
||||
row: modelData
|
||||
rowIndex: index
|
||||
showNumber: true
|
||||
inQueue: true
|
||||
// Basic press-drag reorder (POC: no ghost pill).
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressAndHold: root.dragFrom = index
|
||||
onReleased: {
|
||||
if (root.dragFrom >= 0) {
|
||||
var target = Math.max(0, Math.min(root.upcoming.length - 1,
|
||||
Math.floor((parent.mapToItem(null, mouseX, mouseY).y - queueBody.mapToItem(null, 0, 0).y - 40) / 44)))
|
||||
QbzBridge.queueMoveTrack((doc.page || 0) * 40 + root.dragFrom, (doc.page || 0) * 40 + target)
|
||||
root.dragFrom = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paginator (plain text controls, no pills).
|
||||
Row {
|
||||
visible: (doc.pageCount || 1) > 1
|
||||
width: parent.width
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: theme.spacingMd
|
||||
Item { width: (parent.width - paginator.width) / 2; height: 1 }
|
||||
Row {
|
||||
id: paginator
|
||||
spacing: theme.spacingMd
|
||||
PanelIconBtn {
|
||||
name: "chevron-left"
|
||||
iconSize: 15
|
||||
btnEnabled: (doc.page || 0) > 0
|
||||
onClicked: QbzBridge.queueSetPage((doc.page || 0) - 1)
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: QbzBridge.tr("Page {} of {}").replace("{}", (doc.page || 0) + 1).replace("{}", doc.pageCount || 1)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
}
|
||||
PanelIconBtn {
|
||||
name: "chevron-right"
|
||||
iconSize: 15
|
||||
btnEnabled: (doc.page || 0) < (doc.pageCount || 1) - 1
|
||||
onClicked: QbzBridge.queueSetPage((doc.page || 0) + 1)
|
||||
}
|
||||
}
|
||||
Item { width: (parent.width - paginator.width) / 2; height: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state — no current track and no upcoming.
|
||||
@@ -342,28 +606,127 @@ Rectangle {
|
||||
}
|
||||
|
||||
// ===== History tab =====
|
||||
Column {
|
||||
Flickable {
|
||||
visible: root.tab === 1
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: historyBody.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Text {
|
||||
text: QbzBridge.tr("RECENTLY PLAYED")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
// POC-NOTE: history is always empty until the local
|
||||
// play-history store is wired.
|
||||
Text {
|
||||
Column {
|
||||
id: historyBody
|
||||
width: parent.width
|
||||
topPadding: 48 - 10
|
||||
text: QbzBridge.tr("Nothing played yet")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
padding: 10
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: QbzBridge.tr("RECENTLY PLAYED")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
font.weight: theme.weightSemibold
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
Repeater {
|
||||
model: root.historyRows
|
||||
delegate: QueueRow {
|
||||
width: parent.width
|
||||
row: modelData
|
||||
rowIndex: index
|
||||
showNumber: false
|
||||
inQueue: false
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: root.historyRows.length === 0
|
||||
width: parent.width
|
||||
topPadding: 48 - 10
|
||||
text: QbzBridge.tr("Nothing played yet")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Footer: count + actions + inline search (Queue tab) ---------
|
||||
Column {
|
||||
id: footerBlock
|
||||
visible: root.tab === 0 && !root.queueEmpty
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
Rectangle { width: parent.width; height: 1; color: theme.borderSubtle }
|
||||
Text {
|
||||
visible: (doc.upcomingTotal || 0) > 0
|
||||
width: parent.width
|
||||
leftPadding: theme.spacingMd
|
||||
rightPadding: theme.spacingMd
|
||||
topPadding: 8
|
||||
text: (doc.upcomingTotal === 1
|
||||
? QbzBridge.tr("Showing {} of {} track.")
|
||||
: QbzBridge.tr("Showing {} of {} tracks."))
|
||||
.replace("{}", doc.pageEnd || 0).replace("{}", doc.upcomingTotal || 0)
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 11
|
||||
}
|
||||
Row {
|
||||
width: parent.width
|
||||
leftPadding: theme.spacingMd
|
||||
rightPadding: theme.spacingMd
|
||||
topPadding: 10
|
||||
bottomPadding: 10
|
||||
spacing: theme.spacingXs
|
||||
|
||||
// Action 1 — Clear queue (wired).
|
||||
PanelIconBtn {
|
||||
name: "trash-list"
|
||||
onClicked: QbzBridge.queueClear()
|
||||
}
|
||||
// Action 2 — Save as playlist (INERT: opens a picker modal
|
||||
// upstream — POC-NOTE).
|
||||
PanelIconBtn { name: "add-to-list" }
|
||||
// Action 3 — infinite play (INERT engine — POC-NOTE).
|
||||
PanelIconBtn { name: "infinity" }
|
||||
// Action 4 — sleep timer (INERT engine — POC-NOTE).
|
||||
PanelIconBtn { name: "clock" }
|
||||
|
||||
// Inline queue search field (wired).
|
||||
Rectangle {
|
||||
width: parent.width - 4 * 30 - 4 * theme.spacingXs
|
||||
height: 30
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 9
|
||||
anchors.rightMargin: 7
|
||||
spacing: 6
|
||||
QbzIcon {
|
||||
name: "search"
|
||||
width: 13
|
||||
height: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
tintName: "muted"
|
||||
}
|
||||
TextInput {
|
||||
width: parent.width - 20
|
||||
height: parent.height
|
||||
color: theme.textPrimary
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
clip: true
|
||||
onTextEdited: QbzBridge.queueSetSearch(text)
|
||||
Text {
|
||||
visible: parent.text === ""
|
||||
anchors.fill: parent
|
||||
text: QbzBridge.tr("Search queue")
|
||||
color: theme.textMuted
|
||||
font.pixelSize: 12
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#4285f4" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#4285f4" stroke-width="0.4800000000000001">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
|
||||
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M16 18H3" />
|
||||
<path d="m19 10 4 4" />
|
||||
<path d="m23 10-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sliders-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#4285f4"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#000000" stroke-width="0.4800000000000001">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
|
||||
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M16 18H3" />
|
||||
<path d="m19 10 4 4" />
|
||||
<path d="m23 10-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sliders-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#ef4444" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#ef4444" stroke-width="0.4800000000000001">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
|
||||
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M16 18H3" />
|
||||
<path d="m19 10 4 4" />
|
||||
<path d="m23 10-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-mic-vocal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m11 7.601-5.994 8.19a1 1 0 0 0 .1 1.298l.817.818a1 1 0 0 0 1.314.087L15.09 12" />
|
||||
<path d="M16.5 21.174C15.5 20.5 14.372 20 13 20c-2.058 0-3.928 2.356-6 2-2.072-.356-2.775-3.369-1.5-4.5" />
|
||||
<circle cx="16" cy="7" r="5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sliders-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#888888" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#888888" stroke-width="0.4800000000000001">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
|
||||
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M16 18H3" />
|
||||
<path d="m19 10 4 4" />
|
||||
<path d="m23 10-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sliders-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#888888"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#888888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#ffffff" stroke-width="0.4800000000000001">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
|
||||
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M16 18H3" />
|
||||
<path d="m19 10 4 4" />
|
||||
<path d="m23 10-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sliders-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#cccccc" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#cccccc" stroke-width="0.4800000000000001">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
|
||||
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M16 18H3" />
|
||||
<path d="m19 10 4 4" />
|
||||
<path d="m23 10-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sliders-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#cccccc"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#cccccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#fbbf24" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="#fbbf24" stroke-width="0.4800000000000001">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="0.048"/>
|
||||
|
||||
<g id="SVGRepo_iconCarrier"> <path d="M18,7 L20.5,7 C20.7761424,7 21,7.22385763 21,7.5 C21,7.77614237 20.7761424,8 20.5,8 L18,8 L18,10.5 C18,10.7761424 17.7761424,11 17.5,11 C17.2238576,11 17,10.7761424 17,10.5 L17,8 L14.5,8 C14.2238576,8 14,7.77614237 14,7.5 C14,7.22385763 14.2238576,7 14.5,7 L17,7 L17,4.5 C17,4.22385763 17.2238576,4 17.5,4 C17.7761424,4 18,4.22385763 18,4.5 L18,7 Z M11.5,7 C11.7761424,7 12,7.22385763 12,7.5 C12,7.77614237 11.7761424,8 11.5,8 L3.5,8 C3.22385763,8 3,7.77614237 3,7.5 C3,7.22385763 3.22385763,7 3.5,7 L11.5,7 Z M14.5,12 C14.7761424,12 15,12.2238576 15,12.5 C15,12.7761424 14.7761424,13 14.5,13 L3.5,13 C3.22385763,13 3,12.7761424 3,12.5 C3,12.2238576 3.22385763,12 3.5,12 L14.5,12 Z M20.5,17 C20.7761424,17 21,17.2238576 21,17.5 C21,17.7761424 20.7761424,18 20.5,18 L3.5,18 C3.22385763,18 3,17.7761424 3,17.5 C3,17.2238576 3.22385763,17 3.5,17 L20.5,17 Z"/> </g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.781 0-4.781 8 0 8 5.606 0 7.644-8 12.739-8z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M16 18H3" />
|
||||
<path d="m19 10 4 4" />
|
||||
<path d="m23 10-4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,23 @@
|
||||
<!-- @license lucide-static v1.17.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-sliders-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fbbf24"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="21" x2="14" y1="4" y2="4" />
|
||||
<line x1="10" x2="3" y1="4" y2="4" />
|
||||
<line x1="21" x2="12" y1="12" y2="12" />
|
||||
<line x1="8" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="16" y1="20" y2="20" />
|
||||
<line x1="12" x2="3" y1="20" y2="20" />
|
||||
<line x1="14" x2="14" y1="2" y2="6" />
|
||||
<line x1="8" x2="8" y1="10" y2="14" />
|
||||
<line x1="16" x2="16" y1="18" y2="22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10V17M8 10V17M19 9H22M19 14H22M19 19H21M16 6V16.2C16 17.8802 16 18.7202 15.673 19.362C15.3854 19.9265 14.9265 20.3854 14.362 20.673C13.7202 21 12.8802 21 11.2 21H8.8C7.11984 21 6.27976 21 5.63803 20.673C5.07354 20.3854 4.6146 19.9265 4.32698 19.362C4 18.7202 4 17.8802 4 16.2V6M2 6H18M14 6L13.7294 5.18807C13.4671 4.40125 13.3359 4.00784 13.0927 3.71698C12.8779 3.46013 12.6021 3.26132 12.2905 3.13878C11.9376 3 11.523 3 10.6936 3H9.30643C8.47705 3 8.06236 3 7.70951 3.13878C7.39792 3.26132 7.12208 3.46013 6.90729 3.71698C6.66405 4.00784 6.53292 4.40125 6.27064 5.18807L6 6" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 899 B |
@@ -123,6 +123,17 @@ pub mod qbz_bridge {
|
||||
// Now-playing track id (playing-row indicator in track lists).
|
||||
#[qproperty(QString, np_track_id)]
|
||||
|
||||
// --- Lyrics panel (phase 9) ----------------------------------------
|
||||
#[qproperty(bool, lyrics_open)]
|
||||
// One JSON document (lyrics_qt.rs LyricsDoc: status/lines/synced/
|
||||
// provider/error).
|
||||
#[qproperty(QString, lyrics_json)]
|
||||
|
||||
// --- Queue panel (phase 9) -----------------------------------------
|
||||
// One JSON document (queue_qt.rs QueueDoc: current/upcoming/history
|
||||
// + pagination + #442 section markers). Supersedes `queueModel`.
|
||||
#[qproperty(QString, queue_json)]
|
||||
|
||||
// --- Sidebar playlist tree ---------------------------------------
|
||||
// One JSON document: the flattened entries (folders + playlists,
|
||||
// expand/sort/search applied Rust-side — sidebar_qt.rs).
|
||||
@@ -230,6 +241,32 @@ pub mod qbz_bridge {
|
||||
#[qinvokable]
|
||||
fn enqueue_artist_top(self: Pin<&mut QbzBridge>);
|
||||
|
||||
/// NPB lyrics button / lyrics panel close X.
|
||||
#[qinvokable]
|
||||
fn toggle_lyrics(self: Pin<&mut QbzBridge>);
|
||||
|
||||
/// Queue panel: tabs/pagination/search.
|
||||
#[qinvokable]
|
||||
fn queue_set_page(self: Pin<&mut QbzBridge>, page: i32);
|
||||
#[qinvokable]
|
||||
fn queue_set_search(self: Pin<&mut QbzBridge>, query: QString);
|
||||
/// Row actions.
|
||||
#[qinvokable]
|
||||
fn queue_play_upcoming(self: Pin<&mut QbzBridge>, index: i32);
|
||||
#[qinvokable]
|
||||
fn queue_remove_upcoming(self: Pin<&mut QbzBridge>, index: i32);
|
||||
#[qinvokable]
|
||||
fn queue_remove_all_after(self: Pin<&mut QbzBridge>, index: i32);
|
||||
#[qinvokable]
|
||||
fn queue_move_track(self: Pin<&mut QbzBridge>, from: i32, to: i32);
|
||||
#[qinvokable]
|
||||
fn queue_play_history(self: Pin<&mut QbzBridge>, index: i32);
|
||||
#[qinvokable]
|
||||
fn queue_toggle_favorite(self: Pin<&mut QbzBridge>, kind: QString, id: QString);
|
||||
/// Footer: Clear queue.
|
||||
#[qinvokable]
|
||||
fn queue_clear(self: Pin<&mut QbzBridge>);
|
||||
|
||||
/// Sidebar navigation: record a content view ("home" | "library")
|
||||
/// and lazy-load its data on first visit.
|
||||
#[qinvokable]
|
||||
@@ -374,6 +411,9 @@ pub struct QbzBridgeRust {
|
||||
artist_json: QString,
|
||||
view_param_id: QString,
|
||||
np_track_id: QString,
|
||||
queue_json: QString,
|
||||
lyrics_open: bool,
|
||||
lyrics_json: QString,
|
||||
}
|
||||
|
||||
impl Default for QbzBridgeRust {
|
||||
@@ -431,6 +471,9 @@ impl Default for QbzBridgeRust {
|
||||
artist_json: QString::from("{}"),
|
||||
view_param_id: QString::default(),
|
||||
np_track_id: QString::default(),
|
||||
queue_json: QString::from("{}"),
|
||||
lyrics_open: false,
|
||||
lyrics_json: QString::from("{}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,6 +577,46 @@ impl qbz_bridge::QbzBridge {
|
||||
crate::play_album(album_id.to_string());
|
||||
}
|
||||
|
||||
pub fn queue_set_page(self: Pin<&mut Self>, page: i32) {
|
||||
crate::queue_set_page(page);
|
||||
}
|
||||
|
||||
pub fn queue_set_search(self: Pin<&mut Self>, query: QString) {
|
||||
crate::queue_set_search(query.to_string());
|
||||
}
|
||||
|
||||
pub fn queue_play_upcoming(self: Pin<&mut Self>, index: i32) {
|
||||
crate::queue_play_upcoming(index);
|
||||
}
|
||||
|
||||
pub fn queue_remove_upcoming(self: Pin<&mut Self>, index: i32) {
|
||||
crate::queue_remove_upcoming(index);
|
||||
}
|
||||
|
||||
pub fn queue_remove_all_after(self: Pin<&mut Self>, index: i32) {
|
||||
crate::queue_remove_all_after(index);
|
||||
}
|
||||
|
||||
pub fn queue_move_track(self: Pin<&mut Self>, from: i32, to: i32) {
|
||||
crate::queue_move_track(from, to);
|
||||
}
|
||||
|
||||
pub fn queue_play_history(self: Pin<&mut Self>, index: i32) {
|
||||
crate::queue_play_history(index);
|
||||
}
|
||||
|
||||
pub fn queue_toggle_favorite(self: Pin<&mut Self>, kind: QString, id: QString) {
|
||||
crate::queue_toggle_favorite(kind.to_string(), id.to_string());
|
||||
}
|
||||
|
||||
pub fn queue_clear(self: Pin<&mut Self>) {
|
||||
crate::queue_clear();
|
||||
}
|
||||
|
||||
pub fn toggle_lyrics(self: Pin<&mut Self>) {
|
||||
crate::toggle_lyrics();
|
||||
}
|
||||
|
||||
pub fn play_album_shuffled(self: Pin<&mut Self>, album_id: QString) {
|
||||
crate::play_album_shuffled(album_id.to_string());
|
||||
}
|
||||
|
||||
@@ -218,14 +218,6 @@ fn build_sections(
|
||||
out
|
||||
}
|
||||
|
||||
/// The last built sections (clone), for debug/probe entry points.
|
||||
static LAST_SECTIONS: std::sync::Mutex<Vec<HomeSection>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn with_sections<R>(f: impl FnOnce(&[HomeSection]) -> R) -> R {
|
||||
let guard = LAST_SECTIONS.lock().unwrap();
|
||||
f(&guard)
|
||||
}
|
||||
|
||||
/// Fetch the discover index (UNFILTERED — genre filter out of scope) and
|
||||
/// the personalized rails concurrently (mirrors home.rs's `join!`), then
|
||||
/// map everything into plain rows.
|
||||
@@ -246,14 +238,12 @@ where
|
||||
release_watch.len(),
|
||||
top_artists.len(),
|
||||
);
|
||||
let built = build_sections(
|
||||
Ok(build_sections(
|
||||
response.containers,
|
||||
favorite_albums,
|
||||
release_watch,
|
||||
top_artists,
|
||||
);
|
||||
*LAST_SECTIONS.lock().unwrap() = built.clone();
|
||||
Ok(built)
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Lyrics side panel data layer — drives the `qbz-lyrics` crate
|
||||
//! (LyricsService: Qobuz primary + LRCLIB/OVH fallback chain + per-user
|
||||
//! SQLite cache, all self-contained) from the playback track-change hook.
|
||||
//!
|
||||
//! Lifecycle (mirrors `lyrics::init_for_user`): the service is built
|
||||
//! LAZILY on first use from the core's QobuzClient clone, and the per-user
|
||||
//! cache (`~/.cache/qbz/users/<uid>/lyrics`) binds on every session
|
||||
//! activation. Fetches dedupe in-flight per track inside the crate.
|
||||
//!
|
||||
//! POC-NOTEs:
|
||||
//! - Word-level karaoke fill (the wsync word engine), translations
|
||||
//! (Qobuz v10 language targets + the translate toggle), the display
|
||||
//! settings flyout (size/dimming/uppercase/font/color prefs — the
|
||||
//! Slint defaults are hardcoded), MusicBrainz/AI providers: out of scope.
|
||||
//! - Highlight cadence is the 1 Hz playback poll (vs the Slint engine's
|
||||
//! ~30 Hz) — line-level highlight only.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_app::user_data::UserDataPaths;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use qbz_lyrics::service::{LyricsOutcome, LyricsRequest, LyricsService};
|
||||
use qbz_models::QueueTrack;
|
||||
use cxx_qt_lib::QString;
|
||||
use serde::Serialize;
|
||||
|
||||
/// LyricsState.status semantics (LyricsSidebar renders by these).
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct LyricsLine {
|
||||
#[serde(rename = "timeMs")]
|
||||
pub time_ms: Option<i64>,
|
||||
#[serde(rename = "endMs")]
|
||||
pub end_ms: Option<i64>,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct LyricsDoc {
|
||||
/// 0 idle / 1 loading / 2 ready / 3 not-found / 4 error / 5 offline-miss.
|
||||
pub status: i32,
|
||||
pub lines: Vec<LyricsLine>,
|
||||
pub synced: bool,
|
||||
pub provider: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
static SERVICE: OnceLock<Arc<LyricsService>> = OnceLock::new();
|
||||
/// The user id the cache is currently bound for ("" = unbound).
|
||||
static CACHE_USER: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
|
||||
|
||||
/// Build (once) the service from the core's client clone, and bind the
|
||||
/// per-user cache on first use / user change.
|
||||
async fn service(runtime: &Arc<AppRuntime<LoggingAdapter>>) -> Option<Arc<LyricsService>> {
|
||||
let service = match SERVICE.get() {
|
||||
Some(s) => Arc::clone(s),
|
||||
None => {
|
||||
let client_lock = runtime.core().client();
|
||||
let client = {
|
||||
let guard = client_lock.read().await;
|
||||
guard.as_ref().cloned()
|
||||
}?;
|
||||
let service = Arc::new(LyricsService::with_qobuz_client(Arc::new(client)));
|
||||
let _ = SERVICE.set(Arc::clone(&service));
|
||||
service
|
||||
}
|
||||
};
|
||||
// Bind the per-user cache (idempotent per user). The std MutexGuard
|
||||
// must NOT be held across the await (it would make the future !Send).
|
||||
let needs_init = UserDataPaths::load_last_user_id()
|
||||
.map(|uid| {
|
||||
let bound = CACHE_USER.lock().unwrap();
|
||||
*bound != uid.to_string()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if needs_init {
|
||||
if let Some(uid) = UserDataPaths::load_last_user_id() {
|
||||
if let Some(dir) = dirs::cache_dir().map(|d| d.join("qbz").join("users").join(uid.to_string())) {
|
||||
match service.init_at(&dir).await {
|
||||
Ok(()) => {
|
||||
log::info!("[qbz-qt] lyrics cache bound for user {uid}");
|
||||
*CACHE_USER.lock().unwrap() = uid.to_string();
|
||||
}
|
||||
Err(e) => log::error!("[qbz-qt] lyrics cache init failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(service)
|
||||
}
|
||||
|
||||
fn publish(doc: LyricsDoc) {
|
||||
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
|
||||
crate::ui(move |mut b| {
|
||||
b.as_mut().set_lyrics_json(QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Publish the loading state (status 1) — called on every track change.
|
||||
pub fn publish_loading() {
|
||||
publish(LyricsDoc {
|
||||
status: 1,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// Publish the idle/empty state (track cleared).
|
||||
pub fn publish_idle() {
|
||||
publish(LyricsDoc::default());
|
||||
}
|
||||
|
||||
/// Fetch lyrics for the new current track (called from
|
||||
/// `refresh_now_playing` on every track change; the panel re-requests on
|
||||
/// open via the same path — a cached doc returns immediately).
|
||||
pub async fn load_for_track(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
track: &QueueTrack,
|
||||
) {
|
||||
publish_loading();
|
||||
let Some(service) = service(runtime).await else {
|
||||
publish(LyricsDoc {
|
||||
status: 4,
|
||||
error: "lyrics service unavailable".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
return;
|
||||
};
|
||||
let request = LyricsRequest {
|
||||
track_id: Some(track.id),
|
||||
source: qbz_lyrics::service::LyricsSourceKind::Qobuz,
|
||||
title: track.title.clone(),
|
||||
artist: track.artist.clone(),
|
||||
album: Some(track.album.clone()),
|
||||
duration_secs: Some(track.duration_secs),
|
||||
offline: crate::offline_fwd::engine().is_offline(),
|
||||
language: None,
|
||||
};
|
||||
let track_id = track.id;
|
||||
match service.get(request).await {
|
||||
Ok(response) => {
|
||||
// Stale-response guard (F2 parity): a slow fetch for a track
|
||||
// that is no longer current is discarded by the caller's own
|
||||
// generation check — see playback_qt (it awaits sequentially
|
||||
// per track change, so this mostly covers rapid skips).
|
||||
let _ = track_id;
|
||||
match response.outcome {
|
||||
LyricsOutcome::Found(result) => {
|
||||
let doc = result.doc;
|
||||
let n = doc.lines.len();
|
||||
let provider = doc.provider.as_str().to_string();
|
||||
let synced = doc.synced;
|
||||
let lines: Vec<LyricsLine> = doc
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| LyricsLine {
|
||||
time_ms: l.time_ms,
|
||||
end_ms: l.end_ms,
|
||||
text: l.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
publish(LyricsDoc {
|
||||
status: if lines.is_empty() { 3 } else { 2 },
|
||||
synced,
|
||||
provider: provider.clone(),
|
||||
lines,
|
||||
..Default::default()
|
||||
});
|
||||
log::info!("[qbz-qt] lyrics: {n} lines ({provider}, synced={synced}) for track {track_id}");
|
||||
}
|
||||
LyricsOutcome::NotFound => publish(LyricsDoc {
|
||||
status: 3,
|
||||
..Default::default()
|
||||
}),
|
||||
LyricsOutcome::NotAvailableOffline => publish(LyricsDoc {
|
||||
status: 5,
|
||||
..Default::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(e) => publish(LyricsDoc {
|
||||
status: 4,
|
||||
error: e,
|
||||
..Default::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@ mod now_playing;
|
||||
mod offline_fwd;
|
||||
mod album_qt;
|
||||
mod artist_qt;
|
||||
mod lyrics_qt;
|
||||
mod playback_qt;
|
||||
mod queue_qt;
|
||||
mod sidebar_qt;
|
||||
|
||||
use std::pin::Pin;
|
||||
@@ -466,6 +468,78 @@ pub(crate) fn toggle_pin(kind: String, id: String, title: String, subtitle: Stri
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Lyrics panel (phase 9) ======================
|
||||
|
||||
/// NPB lyrics button / panel close: toggle the column section and — like
|
||||
/// LyricsState.panel-opened — re-request the current track's lyrics on
|
||||
/// open (cached docs return immediately). The static is the source of
|
||||
/// truth (the ui() hop is queued, not synchronous).
|
||||
static LYRICS_OPEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub(crate) fn toggle_lyrics() {
|
||||
let open = !LYRICS_OPEN.swap(!LYRICS_OPEN.load(std::sync::atomic::Ordering::SeqCst), std::sync::atomic::Ordering::SeqCst);
|
||||
ui(move |mut b| {
|
||||
b.as_mut().set_lyrics_open(open);
|
||||
});
|
||||
if open {
|
||||
let runtime = app();
|
||||
spawn(async move {
|
||||
if let Some(track) = runtime.core().current_track().await {
|
||||
lyrics_qt::load_for_track(&runtime, &track).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Queue panel (phase 9) =======================
|
||||
|
||||
pub(crate) fn queue_set_page(page: i32) {
|
||||
queue_qt::set_page(page);
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::publish(&runtime).await });
|
||||
}
|
||||
|
||||
pub(crate) fn queue_set_search(query: String) {
|
||||
queue_qt::set_search(&query);
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::publish(&runtime).await });
|
||||
}
|
||||
|
||||
pub(crate) fn queue_play_upcoming(index: i32) {
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::play_upcoming(&runtime, index.max(0) as usize).await });
|
||||
}
|
||||
|
||||
pub(crate) fn queue_remove_upcoming(index: i32) {
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::remove_upcoming(&runtime, index.max(0) as usize).await });
|
||||
}
|
||||
|
||||
pub(crate) fn queue_remove_all_after(index: i32) {
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::remove_all_after(&runtime, index.max(0) as usize).await });
|
||||
}
|
||||
|
||||
pub(crate) fn queue_move_track(from: i32, to: i32) {
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::move_track(&runtime, from.max(0) as usize, to.max(0) as usize).await });
|
||||
}
|
||||
|
||||
pub(crate) fn queue_play_history(index: i32) {
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::play_history(&runtime, index.max(0) as usize).await });
|
||||
}
|
||||
|
||||
pub(crate) fn queue_clear() {
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::clear_queue(&runtime).await });
|
||||
}
|
||||
|
||||
pub(crate) fn queue_toggle_favorite(kind: String, id: String) {
|
||||
let runtime = app();
|
||||
spawn(async move { queue_qt::toggle_favorite(&runtime, &kind, &id).await });
|
||||
}
|
||||
|
||||
/// AlbumView header Shuffle.
|
||||
pub(crate) fn play_album_shuffled(album_id: String) {
|
||||
let runtime = app();
|
||||
@@ -802,20 +876,6 @@ fn publish_home_sections(sections: &[home_qt::HomeSection]) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the bridge's `queueModel` QVariantList from per-row JSON strings
|
||||
/// (each QVariant carries one QString — see the nesting POC-NOTE in
|
||||
/// playback_qt.rs `publish_queue`).
|
||||
pub(crate) fn json_rows_to_qvariant_list(
|
||||
rows: Vec<String>,
|
||||
) -> cxx_qt_lib::QList<cxx_qt_lib::QVariant> {
|
||||
let mut list = cxx_qt_lib::QList::<cxx_qt_lib::QVariant>::default();
|
||||
for row in rows {
|
||||
list.append(<QString as cxx_qt_lib::QVariantValue>::construct(&QString::from(
|
||||
row.as_str(),
|
||||
)));
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
fn main() {
|
||||
qbz_log::install("info");
|
||||
|
||||
@@ -345,6 +345,10 @@ pub async fn previous(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn play_queue_track_public(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
|
||||
play_queue_track(runtime, track_id).await;
|
||||
}
|
||||
|
||||
async fn play_queue_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
|
||||
if let Err(e) = runtime
|
||||
.core()
|
||||
@@ -426,6 +430,7 @@ pub async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
crate::ui(move |mut b| {
|
||||
b.as_mut().set_np_track_id(QString::from(""));
|
||||
});
|
||||
crate::lyrics_qt::publish_idle();
|
||||
return;
|
||||
};
|
||||
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
@@ -459,6 +464,13 @@ pub async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// Artwork through the same cache pipeline as Home (attach + background
|
||||
// download + republish — single url here).
|
||||
crate::artwork_qt::attach_now_playing(&track.artwork_url.clone().unwrap_or_default());
|
||||
// Lyrics for the new track (loading state, then the doc).
|
||||
crate::lyrics_qt::publish_loading();
|
||||
let runtime = runtime.clone();
|
||||
let track = track.clone();
|
||||
crate::spawn(async move {
|
||||
crate::lyrics_qt::load_for_track(&runtime, &track).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Quality tier + exact label from a queue track's bit depth / sample rate
|
||||
@@ -478,55 +490,10 @@ fn quality_badge(track: &QueueTrack) -> (String, String) {
|
||||
(tier, label)
|
||||
}
|
||||
|
||||
/// Publish the real queue into `queueModel` (now-playing row first, then
|
||||
/// up-next rows) as one QVariant per row.
|
||||
///
|
||||
/// POC-NOTE: rows are JSON-encoded strings inside the QVariants — the same
|
||||
/// cxx-qt-lib nesting limitation as homeSectionsJson (a QVariant cannot
|
||||
/// hold a QVariantMap in 0.7.3); QML parses each row.
|
||||
/// Publish the queue panel document (queue_qt.rs — the full QueueState
|
||||
/// port: sections, history, pagination, search).
|
||||
pub async fn publish_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
#[derive(serde::Serialize)]
|
||||
struct QueueRow {
|
||||
id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
duration: String,
|
||||
#[serde(rename = "artPath")]
|
||||
art_path: String,
|
||||
current: bool,
|
||||
}
|
||||
let mut rows: Vec<QueueRow> = Vec::new();
|
||||
let mut push = |track: &QueueTrack, current: bool| {
|
||||
rows.push(QueueRow {
|
||||
id: track.id.to_string(),
|
||||
title: track.title.clone(),
|
||||
artist: track.artist.clone(),
|
||||
duration: fmt_mmss(track.duration_secs),
|
||||
art_path: crate::artwork_qt::cached_path(
|
||||
&track.artwork_url.clone().unwrap_or_default(),
|
||||
),
|
||||
current,
|
||||
});
|
||||
};
|
||||
if let Some(current) = state.current_track.as_ref() {
|
||||
push(current, true);
|
||||
}
|
||||
for track in &state.upcoming {
|
||||
push(track, false);
|
||||
}
|
||||
let json_rows: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|r| serde_json::to_string(r).unwrap_or_default())
|
||||
.collect();
|
||||
crate::ui(move |mut b| {
|
||||
b.as_mut()
|
||||
.set_queue_model(crate::json_rows_to_qvariant_list(json_rows));
|
||||
});
|
||||
}
|
||||
|
||||
fn fmt_mmss(secs: u64) -> String {
|
||||
format!("{}:{:02}", secs / 60, secs % 60)
|
||||
crate::queue_qt::publish(runtime).await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Queue panel controller — Slint-free port of the `QueueState` assembly
|
||||
//! side of `crates/qbz/src/queue.rs`: NOW PLAYING card, UP NEXT with the
|
||||
//! #442 "Next in queue" / "Next up" section markers (from the core's
|
||||
//! `manual_next_count`), 40-row pagination, live search filter, and the
|
||||
//! History tab. Mutations go straight to the core queue API; every one
|
||||
//! ends with a republish.
|
||||
//!
|
||||
//! POC-NOTEs:
|
||||
//! - QConnect remote reorder, the playlist picker (save-as-playlist opens
|
||||
//! a modal picker upstream), infinite-play engine, sleep timer, stop-after
|
||||
//! marker, ephemeral rows, coverflow: out of scope.
|
||||
//! - Row favorite seeds from the phase-5 library feed (Slint uses
|
||||
//! fav_cache — same truth).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::LoggingAdapter;
|
||||
use cxx_qt_lib::QString;
|
||||
use qbz_models::QueueTrack;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// queue.rs PAGE_SIZE.
|
||||
pub const PAGE_SIZE: usize = 40;
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
pub struct QueueRow {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
#[serde(rename = "artistId")]
|
||||
pub artist_id: String,
|
||||
pub album: String,
|
||||
pub duration: String,
|
||||
#[serde(rename = "qualityTier")]
|
||||
pub quality_tier: String,
|
||||
#[serde(rename = "qualityDetail")]
|
||||
pub quality_detail: String,
|
||||
pub explicit: bool,
|
||||
#[serde(rename = "artUrl")]
|
||||
pub art_url: String,
|
||||
#[serde(rename = "isFavorite")]
|
||||
pub is_favorite: bool,
|
||||
/// #442 section header drawn ABOVE this row: "" | "next-in-queue" |
|
||||
/// "next-up" (only on the unfiltered list).
|
||||
pub section: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct QueueDoc {
|
||||
#[serde(rename = "hasCurrent")]
|
||||
pub has_current: bool,
|
||||
pub current: Option<QueueRow>,
|
||||
pub upcoming: Vec<QueueRow>,
|
||||
#[serde(rename = "upcomingTotal")]
|
||||
pub upcoming_total: usize,
|
||||
#[serde(rename = "upcomingRemaining")]
|
||||
pub upcoming_remaining: usize,
|
||||
pub history: Vec<QueueRow>,
|
||||
pub page: usize,
|
||||
#[serde(rename = "pageCount")]
|
||||
pub page_count: usize,
|
||||
#[serde(rename = "pageStart")]
|
||||
pub page_start: usize,
|
||||
#[serde(rename = "pageEnd")]
|
||||
pub page_end: usize,
|
||||
pub shuffle: bool,
|
||||
#[serde(rename = "repeatMode")]
|
||||
pub repeat_mode: i32,
|
||||
}
|
||||
|
||||
/// Panel view state (search + page), session-scope like the Slint view.
|
||||
#[derive(Default)]
|
||||
struct ViewState {
|
||||
search: String,
|
||||
page: usize,
|
||||
}
|
||||
|
||||
static VIEW: Mutex<ViewState> = Mutex::new(ViewState {
|
||||
search: String::new(),
|
||||
page: 0,
|
||||
});
|
||||
|
||||
/// queue.rs `display_title` (version suffix).
|
||||
fn display_title(track: &QueueTrack) -> String {
|
||||
match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
Some(version) => format!("{} ({version})", track.title),
|
||||
None => track.title.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_duration(secs: u64) -> String {
|
||||
format!("{}:{:02}", secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
fn row_from(track: &QueueTrack) -> QueueRow {
|
||||
let is_favorite = crate::library_qt::with_library(|d| {
|
||||
d.feed
|
||||
.iter()
|
||||
.any(|i| i.kind == "track" && i.id == track.id.to_string() && i.is_favorite)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let tier = match track.bit_depth {
|
||||
Some(d) if d >= 24 => "hires",
|
||||
Some(_) => "cd",
|
||||
None if track.hires => "hires",
|
||||
None => "",
|
||||
}
|
||||
.to_string();
|
||||
QueueRow {
|
||||
id: track.id.to_string(),
|
||||
title: display_title(track),
|
||||
artist: track.artist.clone(),
|
||||
artist_id: track.artist_id.map(|id| id.to_string()).unwrap_or_default(),
|
||||
album: track.album.clone(),
|
||||
duration: fmt_duration(track.duration_secs),
|
||||
quality_tier: tier,
|
||||
quality_detail: crate::home_qt::quality_detail_from_parts(
|
||||
track.bit_depth,
|
||||
track.sample_rate,
|
||||
),
|
||||
explicit: track.parental_warning,
|
||||
art_url: track.artwork_url.clone().unwrap_or_default(),
|
||||
is_favorite,
|
||||
section: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// queue.rs `paginate` (unit-tested upstream; port).
|
||||
fn paginate(total: usize, requested_page: usize) -> (usize, usize, usize, usize) {
|
||||
let page_count = total.div_ceil(PAGE_SIZE).max(1);
|
||||
let page = requested_page.min(page_count - 1);
|
||||
let start = page * PAGE_SIZE;
|
||||
let end = (start + PAGE_SIZE).min(total);
|
||||
(page, page_count, start, end)
|
||||
}
|
||||
|
||||
/// Build + publish the whole panel document.
|
||||
pub async fn publish(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
let state = runtime.core().get_queue_state_full().await;
|
||||
let (search, requested_page) = {
|
||||
let view = VIEW.lock().unwrap();
|
||||
(view.search.clone(), view.page)
|
||||
};
|
||||
let query = search.trim().to_lowercase();
|
||||
|
||||
// --- UP NEXT (search-filtered, paged, #442 markers) ------------------
|
||||
let filtered: Vec<&QueueTrack> = if query.is_empty() {
|
||||
state.upcoming.iter().collect()
|
||||
} else {
|
||||
state
|
||||
.upcoming
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
display_title(t).to_lowercase().contains(&query)
|
||||
|| t.artist.to_lowercase().contains(&query)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let upcoming_total = filtered.len();
|
||||
let (page, page_count, start, end) = paginate(upcoming_total, requested_page);
|
||||
VIEW.lock().unwrap().page = page;
|
||||
|
||||
let page_rows: Vec<QueueRow> = filtered[start..end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let mut r = row_from(t);
|
||||
if query.is_empty() {
|
||||
let g = start + i;
|
||||
if state.manual_next_count > 0 && g == 0 {
|
||||
r.section = "next-in-queue".into();
|
||||
} else if g == state.manual_next_count {
|
||||
r.section = "next-up".into();
|
||||
}
|
||||
}
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
|
||||
let remaining = state
|
||||
.current_index
|
||||
.map(|idx| state.total_tracks.saturating_sub(idx + 1))
|
||||
.unwrap_or(state.total_tracks);
|
||||
|
||||
let history: Vec<QueueRow> = state.history.iter().map(row_from).collect();
|
||||
let current = state.current_track.as_ref().map(row_from);
|
||||
|
||||
let doc = QueueDoc {
|
||||
has_current: current.is_some(),
|
||||
current,
|
||||
upcoming: page_rows,
|
||||
upcoming_total,
|
||||
upcoming_remaining: remaining,
|
||||
history,
|
||||
page,
|
||||
page_count,
|
||||
page_start: if upcoming_total == 0 { 0 } else { start + 1 },
|
||||
page_end: end,
|
||||
shuffle: state.shuffle,
|
||||
repeat_mode: match state.repeat {
|
||||
qbz_models::RepeatMode::Off => 0,
|
||||
qbz_models::RepeatMode::All => 1,
|
||||
qbz_models::RepeatMode::One => 2,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".into());
|
||||
crate::ui(move |mut b| {
|
||||
b.as_mut().set_queue_json(QString::from(json.as_str()));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View state + mutations (every mutation republishes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn set_search(query: &str) {
|
||||
{
|
||||
let mut view = VIEW.lock().unwrap();
|
||||
view.search = query.to_string();
|
||||
view.page = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_page(page: i32) {
|
||||
VIEW.lock().unwrap().page = page.max(0) as usize;
|
||||
}
|
||||
|
||||
pub async fn play_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
|
||||
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
|
||||
let upcoming_index = start + page_index;
|
||||
if let Some(track) = runtime.core().play_upcoming_at(upcoming_index).await {
|
||||
crate::playback_qt::play_queue_track_public(runtime, track.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_upcoming(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
|
||||
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
|
||||
let upcoming_index = start + page_index;
|
||||
runtime.core().remove_upcoming_track(upcoming_index).await;
|
||||
publish(runtime).await;
|
||||
}
|
||||
|
||||
pub async fn remove_all_after(runtime: &Arc<AppRuntime<LoggingAdapter>>, page_index: usize) {
|
||||
let start = VIEW.lock().unwrap().page * PAGE_SIZE;
|
||||
let upcoming_index = start + page_index;
|
||||
runtime.core().remove_upcoming_after(upcoming_index).await;
|
||||
publish(runtime).await;
|
||||
}
|
||||
|
||||
/// Drag reorder with QUEUE-WIDE indices (the QML list is unfiltered when
|
||||
/// drag is enabled, so page-local == page*40 + row).
|
||||
pub async fn move_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, from: usize, to: usize) {
|
||||
runtime.core().move_track(from, to).await;
|
||||
publish(runtime).await;
|
||||
}
|
||||
|
||||
/// History replay (queue.rs play_history: a fresh single-track queue).
|
||||
pub async fn play_history(runtime: &Arc<AppRuntime<LoggingAdapter>>, index: usize) {
|
||||
let state = runtime.core().get_queue_state_full().await;
|
||||
let Some(track) = state.history.get(index).cloned() else {
|
||||
log::warn!("[qbz-qt] queue: play_history {index} out of range");
|
||||
return;
|
||||
};
|
||||
runtime.core().set_queue(vec![track.clone()], Some(0)).await;
|
||||
crate::playback_qt::play_queue_track_public(runtime, track.id).await;
|
||||
}
|
||||
|
||||
pub async fn clear_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
|
||||
// queue.rs clear_queue: drops everything INCLUDING the current track
|
||||
// (keep_current: false) and stops playback.
|
||||
runtime.core().clear_queue(false).await;
|
||||
let _ = runtime.core().stop();
|
||||
publish(runtime).await;
|
||||
crate::playback_qt::refresh_now_playing(runtime).await;
|
||||
}
|
||||
|
||||
/// Toggle a heart for a queue row (Qobuz favorite), then republish.
|
||||
pub async fn toggle_favorite(
|
||||
runtime: &Arc<AppRuntime<LoggingAdapter>>,
|
||||
kind: &str,
|
||||
id: &str,
|
||||
) {
|
||||
let _ = crate::library_qt::toggle_favorite(runtime, kind, id).await;
|
||||
publish(runtime).await;
|
||||
}
|
||||