feat(qbz-qt): phase 8 — AlbumView and ArtistView 1:1

AlbumPageView replica: 224px cover, credits, meta, description modal,
action row, quality badge + track search, exact tracklist (playing-row
pill, disc/work headers, live hearts, row menus), label/awards
sidebar, same-artist + suggestions carousels. ArtistPageView replica:
circular portrait, bio modal, From catalog/In library toggle (both
arms live), JUMP TO tabs with jump-scroll, Popular Tracks with Load
more, server-bucketed release sections paged per section, Appears On,
Network sidebar (labels, similar artists). Card clicks navigate
everywhere via the nav stack; new playback paths (play from track,
shuffled album, track lists). Owner data live: album 654ms, artist
670ms loads.

km
This commit is contained in:
vicrodh committed 2026-07-27 17:49:08 -06:00
1 parent 4d196223cd
commit 86de977f3c
84 files changed
+4683 -19

No files matched your search

+3
View File
@@ -41,6 +41,9 @@ fn main() {
"qml/LoginScreen.qml",
"qml/AppShell.qml",
"qml/AlbumCard.qml",
"qml/AlbumView.qml",
"qml/ArtistView.qml",
"qml/SectionRail.qml",
"qml/Sidebar.qml",
"qml/HeaderBar.qml",
"qml/NowPlayingBarSmall.qml",
+9 -5
View File
@@ -113,7 +113,9 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.playAlbum(root.albumId)
// Phase 8: the card opens the album view (the overlay play
// button carries the play affordance).
onClicked: QbzBridge.openAlbum(root.albumId)
}
// Pin badge — top-right. Filled accent pin always visible when
@@ -285,12 +287,13 @@ Rectangle {
onClicked: {
albumMenu.close()
var a = modelData.action
if (a === "play") QbzBridge.playAlbum(root.albumId)
if (a === "open") QbzBridge.openAlbum(root.albumId)
else if (a === "play") QbzBridge.playAlbum(root.albumId)
else if (a === "next") QbzBridge.enqueueAlbum(root.albumId, "next")
else if (a === "queue") QbzBridge.enqueueAlbum(root.albumId, "later")
else if (a === "favorite") root.toggleFavorite()
// "open" (no album page) + "block" (no
// blacklist store): inert — POC-NOTE.
// "block" (no blacklist store): inert —
// POC-NOTE.
}
}
}
@@ -376,7 +379,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.playAlbum(root.albumId)
onClicked: QbzBridge.openAlbum(root.albumId)
}
}
Text {
@@ -393,6 +396,7 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: root.artistId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.artistId !== "") QbzBridge.openArtist(root.artistId)
}
}
}
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -100,7 +100,9 @@ Rectangle {
Loader {
anchors.fill: parent
source: QbzBridge.currentView === "home" ? "HomeView.qml"
: QbzBridge.currentView === "library" ? "LibraryView.qml" : ""
: QbzBridge.currentView === "library" ? "LibraryView.qml"
: QbzBridge.currentView === "album" ? "AlbumView.qml"
: QbzBridge.currentView === "artist" ? "ArtistView.qml" : ""
}
}
}
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -236,8 +236,8 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Phase 4: slim rows are albums — click plays (page out of scope).
onClicked: QbzBridge.playAlbum(card.id)
// Phase 8: slim rows are albums — click opens the album view.
onClicked: QbzBridge.openAlbum(card.id)
}
}
@@ -368,7 +368,7 @@ Rectangle {
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
// POC-NOTE: artist page out of scope — inert.
onClicked: QbzBridge.openArtist(card.id)
}
}
Text {
+4 -3
View File
@@ -476,9 +476,10 @@ Rectangle {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (item.kind === "album") QbzBridge.playAlbum(item.id)
if (item.kind === "album") QbzBridge.openAlbum(item.id)
else if (item.kind === "track") QbzBridge.playTrack(item.id)
// artist/playlist/label pages: out of scope (POC-NOTE).
else if (item.kind === "artist") QbzBridge.openArtist(item.id)
// playlist/label pages: out of scope (POC-NOTE).
}
}
}
@@ -530,7 +531,7 @@ Rectangle {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (item.kind === "album") QbzBridge.playAlbum(item.id)
if (item.kind === "album") QbzBridge.openAlbum(item.id)
else if (item.kind === "track") QbzBridge.playTrack(item.id)
}
}
+127
View File
@@ -0,0 +1,127 @@
// Horizontal album rail (Carousel.slint) — extracted for reuse by the
// detail views (AlbumView / ArtistView): section header + page chevrons +
// clipped ListView of the shared AlbumCard, with Cider-style edge fades.
import QtQuick
import com.blitzfc.qbz
Column {
id: root
property string title: ""
property var items: []
// The host's url-keyed cover map ({url: file://path}).
property var coverMap: ({})
QbzTheme { id: theme }
width: parent ? parent.width : 0
spacing: 12
readonly property int perPage: Math.max(1, Math.floor((rail.width + 32) / 232))
readonly property int step: perPage * 232
readonly property real maxScroll: Math.max(0, rail.contentWidth - rail.width)
component RailNavBtn: Rectangle {
property string name: ""
property bool btnEnabled: true
signal clicked()
width: 28
height: 28
radius: 14
opacity: btnEnabled ? 1.0 : 0.4
color: (nbArea.containsMouse && btnEnabled) ? theme.surfaceHover : theme.surfaceElevated
QbzIcon {
name: parent.name
width: 15
height: 15
anchors.centerIn: parent
tintName: parent.btnEnabled ? "primary" : "muted"
}
MouseArea {
id: nbArea
anchors.fill: parent
enabled: parent.btnEnabled
hoverEnabled: true
cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: parent.clicked()
}
}
Item {
width: parent.width
height: 28
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.title
color: theme.textPrimary
font.pixelSize: theme.fontSection
font.weight: theme.weightSemibold
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
RailNavBtn {
name: "chevron-left"
btnEnabled: rail.contentX > 1
onClicked: rail.contentX = Math.max(0, rail.contentX - root.step)
}
RailNavBtn {
name: "chevron-right"
btnEnabled: rail.contentX < root.maxScroll - 1
onClicked: rail.contentX = Math.min(root.maxScroll, rail.contentX + root.step)
}
}
}
Item {
width: parent.width
height: 246
ListView {
id: rail
anchors.fill: parent
orientation: ListView.Horizontal
spacing: 32
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.items
delegate: AlbumCard {
albumId: modelData.id
title: modelData.title
artist: modelData.artist
artistId: modelData.artistId
genre: modelData.genre
year: modelData.year
qualityTier: modelData.qualityTier
artSource: root.coverMap[modelData.artUrl] || ""
isFavorite: false
}
}
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
opacity: rail.contentX > 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: theme.surfaceMain }
GradientStop { position: 1.0; color: "transparent" }
}
}
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 56
opacity: rail.contentX < root.maxScroll - 1 ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 150 } }
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: "transparent" }
GradientStop { position: 1.0; color: theme.surfaceMain }
}
}
}
}
@@ -0,0 +1,5 @@
<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="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 337 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">
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M8 12h8" />
<circle cx="16" cy="10" r="2" />
<path d="m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,5 @@
<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="M12 13v8l-4-4" />
<path d="m12 21 4-4" />
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
</svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs2797"/>
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
<g id="g2211" transform="translate(3339.32,4267.43)">
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
</g>
<g id="g2215" transform="translate(3521.08,4502.59)">
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>

After

Width:  |  Height:  |  Size: 326 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 5h6" />
<path d="M3 12h13" />
<path d="M3 19h13" />
<path d="m16 8-3-3 3-3" />
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -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">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,5 @@
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 289 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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -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">
<path d="M21 10.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.5" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1,5 @@
<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="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 337 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">
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M8 12h8" />
<circle cx="16" cy="10" r="2" />
<path d="m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,5 @@
<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="M12 13v8l-4-4" />
<path d="m12 21 4-4" />
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
</svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs2797"/>
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
<g id="g2211" transform="translate(3339.32,4267.43)">
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
</g>
<g id="g2215" transform="translate(3521.08,4502.59)">
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>

After

Width:  |  Height:  |  Size: 326 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 5h6" />
<path d="M3 12h13" />
<path d="M3 19h13" />
<path d="m16 8-3-3 3-3" />
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -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">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,5 @@
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 289 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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -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">
<path d="M21 10.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.5" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1,5 @@
<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="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 337 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">
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M8 12h8" />
<circle cx="16" cy="10" r="2" />
<path d="m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,5 @@
<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="M12 13v8l-4-4" />
<path d="m12 21 4-4" />
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
</svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs2797"/>
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
<g id="g2211" transform="translate(3339.32,4267.43)">
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
</g>
<g id="g2215" transform="translate(3521.08,4502.59)">
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>

After

Width:  |  Height:  |  Size: 326 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 5h6" />
<path d="M3 12h13" />
<path d="M3 19h13" />
<path d="m16 8-3-3 3-3" />
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -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">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,5 @@
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 289 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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -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">
<path d="M21 10.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.5" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1,5 @@
<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="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 337 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">
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M8 12h8" />
<circle cx="16" cy="10" r="2" />
<path d="m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,5 @@
<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="M12 13v8l-4-4" />
<path d="m12 21 4-4" />
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
</svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs2797"/>
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
<g id="g2211" transform="translate(3339.32,4267.43)">
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
</g>
<g id="g2215" transform="translate(3521.08,4502.59)">
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>

After

Width:  |  Height:  |  Size: 326 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 5h6" />
<path d="M3 12h13" />
<path d="M3 19h13" />
<path d="m16 8-3-3 3-3" />
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -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">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,5 @@
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 289 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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -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">
<path d="M21 10.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.5" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1,5 @@
<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="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 337 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">
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M8 12h8" />
<circle cx="16" cy="10" r="2" />
<path d="m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,5 @@
<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="M12 13v8l-4-4" />
<path d="m12 21 4-4" />
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
</svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs2797"/>
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
<g id="g2211" transform="translate(3339.32,4267.43)">
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
</g>
<g id="g2215" transform="translate(3521.08,4502.59)">
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>

After

Width:  |  Height:  |  Size: 326 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 5h6" />
<path d="M3 12h13" />
<path d="M3 19h13" />
<path d="m16 8-3-3 3-3" />
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -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">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,5 @@
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 289 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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -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">
<path d="M21 10.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.5" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1,5 @@
<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="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 337 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">
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M8 12h8" />
<circle cx="16" cy="10" r="2" />
<path d="m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,5 @@
<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="M12 13v8l-4-4" />
<path d="m12 21 4-4" />
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
</svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs2797"/>
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
<g id="g2211" transform="translate(3339.32,4267.43)">
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
</g>
<g id="g2215" transform="translate(3521.08,4502.59)">
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>

After

Width:  |  Height:  |  Size: 326 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 5h6" />
<path d="M3 12h13" />
<path d="M3 19h13" />
<path d="m16 8-3-3 3-3" />
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -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">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,5 @@
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 289 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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -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">
<path d="M21 10.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.5" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1,5 @@
<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="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>

After

Width:  |  Height:  |  Size: 337 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">
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M8 12h8" />
<circle cx="16" cy="10" r="2" />
<path d="m6 20 .7-2.9A1.4 1.4 0 0 1 8.1 16h7.8a1.4 1.4 0 0 1 1.4 1l.7 3" />
</svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1,5 @@
<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="M12 13v8l-4-4" />
<path d="m12 21 4-4" />
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
</svg>

After

Width:  |  Height:  |  Size: 317 B

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs2797"/>
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
<g id="g2211" transform="translate(3339.32,4267.43)">
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
</g>
<g id="g2215" transform="translate(3521.08,4502.59)">
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>

After

Width:  |  Height:  |  Size: 326 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 5h6" />
<path d="M3 12h13" />
<path d="M3 19h13" />
<path d="m16 8-3-3 3-3" />
<path d="M21 19V7a2 2 0 0 0-2-2h-6" />
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -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">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 335 B

@@ -0,0 +1,5 @@
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d="m8 9 3 3-3 3" />
</svg>

After

Width:  |  Height:  |  Size: 289 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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -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">
<path d="M21 10.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.5" />
<path d="m9 11 3 3L22 4" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

+525
View File
@@ -0,0 +1,525 @@
//! Album detail data layer — Slint-free port of `crates/qbz/src/album.rs`
//! (map_album: credits/meta/description/tracks) plus the two bottom
//! carousels ("From the same artist" via get_releases_grid, "Listening
//! suggestions" via get_album_suggest). Publishes ONE JSON document.
//!
//! POC-NOTEs:
//! - Booklet download/rasterize, external links (Last.fm/Discogs/MB),
//! multi-select + bulk bar, DiscHeaderMenu, album-info modal, custom
//! covers: out of scope (inert stubs where visible).
//! - Blacklist filtering on carousels: skipped (store not open).
//! - The album-header-gradient atmosphere and Last.fm suggestions row:
//! not wired (appearance pref + Last.fm subsystem).
use std::sync::Arc;
use std::time::Instant;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_models::{Album, Track};
use serde::Serialize;
use crate::home_qt;
const MORE_FROM_ARTIST_MAX: usize = 16;
const RELEASE_PAGE_SIZE: u32 = 20;
#[derive(Clone, Default, Serialize)]
pub struct TrackRow {
pub id: String,
pub number: String,
pub title: String,
pub artist: String,
#[serde(rename = "artistId")]
pub artist_id: String,
pub album: String,
#[serde(rename = "albumId")]
pub album_id: String,
pub duration: String,
#[serde(rename = "qualityTier")]
pub quality_tier: String,
#[serde(rename = "qualityDetail")]
pub quality_detail: String,
pub explicit: bool,
pub disc: u32,
/// Work-section header text ("" = none; album view only).
#[serde(rename = "workHeader")]
pub work_header: String,
#[serde(rename = "workComposerName")]
pub work_composer_name: String,
#[serde(rename = "workComposerId")]
pub work_composer_id: String,
/// Artwork url ("" on album view; artist top-tracks carry it).
#[serde(rename = "artUrl")]
pub artwork_url: String,
}
#[derive(Clone, Serialize)]
pub struct AlbumCardData {
pub id: String,
pub title: String,
pub artist: String,
#[serde(rename = "artistId")]
pub artist_id: String,
pub genre: String,
pub year: String,
#[serde(rename = "qualityTier")]
pub quality_tier: String,
#[serde(rename = "qualityDetail")]
pub quality_detail: String,
#[serde(rename = "artUrl")]
pub art_url: String,
}
#[derive(Clone, Default, Serialize)]
pub struct AlbumHeader {
pub id: String,
pub title: String,
pub artist: String,
#[serde(rename = "artistId")]
pub artist_id: String,
/// "Name|id|role" triples for the credits line.
pub credits: Vec<(String, String, String)>,
#[serde(rename = "infoLine")]
pub info_line: String,
#[serde(rename = "metaPre")]
pub meta_pre: String,
#[serde(rename = "metaPost")]
pub meta_post: String,
#[serde(rename = "qualityTier")]
pub quality_tier: String,
#[serde(rename = "qualityDetail")]
pub quality_detail: String,
pub description: String,
#[serde(rename = "descriptionShort")]
pub description_short: String,
#[serde(rename = "artUrl")]
pub artwork_url: String,
pub label: String,
#[serde(rename = "labelId")]
pub label_id: String,
/// (id, name) award pairs for the sidebar.
pub awards: Vec<(String, String)>,
#[serde(rename = "isFavorite")]
pub is_favorite: bool,
#[serde(rename = "isPinned")]
pub is_pinned: bool,
}
#[derive(Default, Serialize)]
pub struct AlbumViewData {
pub header: AlbumHeader,
pub tracks: Vec<TrackRow>,
#[serde(rename = "moreFromArtist")]
pub more_from_artist: Vec<AlbumCardData>,
pub suggestions: Vec<AlbumCardData>,
}
fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn format_duration(secs: u32) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
if hours > 0 {
format!("{hours}h {minutes}m")
} else {
format!("{minutes}m")
}
}
/// Word-boundary truncation (album.rs `truncate_words`).
pub(crate) fn truncate_words(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let truncated: String = text.chars().take(max).collect();
let cut = truncated.rfind(' ').unwrap_or(truncated.len());
format!("{}…", truncated[..cut].trim_end())
}
/// Localized readable release date ("Feb 19, 2026"); empty when absent.
fn format_release_date(iso: Option<&str>) -> String {
let Some(raw) = iso.map(str::trim).filter(|s| !s.is_empty()) else {
return String::new();
};
let head = raw.get(0..10).unwrap_or(raw);
qbz_text_utils::dates::release_label(Some(head))
}
/// album.rs `credit_role`: "" for the main artist; the first non-main role
/// (localized via the same format_role_label path).
fn credit_role(roles: Option<&Vec<String>>) -> String {
let Some(roles) = roles else {
return String::new();
};
roles
.iter()
.find(|r| r.as_str() != "main-artist")
.map(|r| qbz_i18n::t(&qbz_qobuz::performers::format_role_label(r)))
.unwrap_or_default()
}
/// album.rs `build_credits` (releaseArtistsMapper parity, minus the
/// "VARIOUS"-composer drop it also applies).
fn build_credits(album: &Album) -> Vec<(String, String, String)> {
let mut credits: Vec<(String, String, String)> = match album.artists.as_ref().filter(|v| !v.is_empty())
{
Some(list) => list
.iter()
.map(|a| {
(
a.name.clone(),
a.id.to_string(),
credit_role(a.roles.as_ref()),
)
})
.collect(),
None => vec![(
album.artist.name.clone(),
album.artist.id.to_string(),
String::new(),
)],
};
// Album-level composer appended last, unless it's the localized
// "Various Composers" placeholder (releaseArtistsMapper's VARIOUS drop).
if let Some(composer) = album.composer.as_ref().filter(|c| !c.name.is_empty()) {
if !composer.name.to_uppercase().contains("VARIOUS") {
credits.push((
composer.name.clone(),
composer.id.to_string(),
String::new(),
));
}
}
credits
}
/// album.rs `map_track` (with work headers for classical albums).
fn map_track(track: &Track) -> TrackRow {
let work = track
.work
.as_ref()
.filter(|w| !w.is_empty())
.cloned()
.unwrap_or_default();
let (work_composer_name, work_composer_id) = if work.is_empty() {
(String::new(), String::new())
} else {
track
.composer
.as_ref()
.filter(|c| !c.name.is_empty())
.map(|c| (c.name.clone(), c.id.to_string()))
.unwrap_or_default()
};
let mut title = track.title.clone();
if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({version})");
}
let (artist, artist_id) = track
.performer
.as_ref()
.map(|p| (p.name.clone(), p.id.to_string()))
.unwrap_or_default();
TrackRow {
id: track.id.to_string(),
number: track.track_number.to_string(),
title,
artist,
artist_id,
album: String::new(),
album_id: String::new(),
duration: mmss(track.duration),
quality_tier: home_qt::quality_tier_from_depth(track.maximum_bit_depth).to_string(),
quality_detail: home_qt::quality_detail_from_parts(
track.maximum_bit_depth,
track.maximum_sampling_rate,
),
explicit: track.parental_warning,
disc: track.media_number.unwrap_or(1),
work_header: work,
work_composer_name,
work_composer_id,
artwork_url: String::new(),
}
}
/// Fetch + map the album detail (album.rs `map_album` port).
pub async fn load_album(runtime: &Arc<AppRuntime<LoggingAdapter>>, album_id: &str) -> Result<AlbumViewData, String> {
let album = runtime
.core()
.get_album(album_id)
.await
.map_err(|e| e.to_string())?;
let artist = album.artist.name.clone();
let artist_id = album.artist.id.to_string();
let credits = build_credits(&album);
let date_display = format_release_date(
album
.release_date_original
.as_deref()
.or_else(|| album.dates.as_ref().and_then(|d| d.original.as_deref())),
);
let label_name = album
.label
.as_ref()
.filter(|l| !l.name.is_empty())
.map(|l| l.name.clone());
let genre_str = album
.genre
.as_ref()
.filter(|g| !g.name.is_empty())
.map(|g| g.name.clone());
let tracks_str = album.tracks_count.map(|count| {
qbz_i18n::tf("{} track", "{} tracks", count as i64, &[&count.to_string()])
});
let duration_str = album.duration.map(format_duration);
let mut pre_parts: Vec<String> = Vec::new();
if !date_display.is_empty() {
pre_parts.push(date_display);
}
let mut post_parts: Vec<String> = Vec::new();
if let Some(g) = &genre_str {
post_parts.push(g.clone());
}
if let Some(tc) = &tracks_str {
post_parts.push(tc.clone());
}
if let Some(d) = &duration_str {
post_parts.push(d.clone());
}
let meta_pre = pre_parts.join(" • ");
let meta_post = post_parts.join(" • ");
let mut all_parts = pre_parts.clone();
if let Some(l) = &label_name {
all_parts.push(l.clone());
}
all_parts.extend(post_parts.clone());
let info_line = all_parts.join(" • ");
let description = album
.description
.as_deref()
.map(qbz_text_utils::strip_html::strip_html)
.unwrap_or_default();
let description_short = truncate_words(&description, 360);
let awards = album
.awards
.as_deref()
.unwrap_or_default()
.iter()
.map(|a| (a.id.clone().unwrap_or_default(), a.name.clone()))
.filter(|(_, n)| !n.is_empty())
.collect();
let title = crate::album_qt::format_album_title(&album.title, album.version.as_deref());
let tracks: Vec<TrackRow> = album
.tracks
.as_ref()
.map(|container| container.items.iter().map(map_track).collect())
.unwrap_or_default();
let header = AlbumHeader {
is_favorite: crate::library_qt::with_library(|d| {
d.feed
.iter()
.any(|i| i.kind == "album" && i.id == album.id && i.is_favorite)
})
.unwrap_or(false),
is_pinned: crate::sidebar_qt::is_pinned("album", &album.id),
id: album.id,
title,
artist,
artist_id,
credits,
info_line,
meta_pre,
meta_post,
quality_tier: home_qt::quality_tier_from_depth(album.maximum_bit_depth).to_string(),
quality_detail: home_qt::quality_detail_from_parts(
album.maximum_bit_depth,
album.maximum_sampling_rate,
),
description,
description_short,
artwork_url: album.image.best().cloned().unwrap_or_default(),
label: album
.label
.as_ref()
.map(|l| l.name.clone())
.unwrap_or_default(),
label_id: album
.label
.as_ref()
.map(|l| l.id.to_string())
.unwrap_or_default(),
awards,
};
Ok(AlbumViewData {
header,
tracks,
..Default::default()
})
}
pub(crate) fn format_album_title(title: &str, version: Option<&str>) -> String {
match version.map(str::trim).filter(|v| !v.is_empty()) {
Some(v) => format!("{title} ({v})"),
None => title.to_string(),
}
}
/// artist.rs `map_release` for carousel cards (deduped vs the open album).
fn map_release_card(release: &qbz_models::PageArtistRelease) -> AlbumCardData {
let artist = release
.artist
.as_ref()
.map(|a| a.name.display.clone())
.or_else(|| {
release
.artists
.as_ref()
.and_then(|list| list.first().map(|a| a.name.clone()))
})
.unwrap_or_default();
let bit_depth = release.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
let sample_rate = release
.audio_info
.as_ref()
.and_then(|a| a.maximum_sampling_rate);
let artist_id = release
.artist
.as_ref()
.map(|a| a.id.to_string())
.unwrap_or_default();
AlbumCardData {
id: release.id.clone(),
title: release.title.clone(),
artist,
artist_id,
genre: release
.genre
.as_ref()
.map(|g| g.name.clone())
.unwrap_or_default(),
year: release
.dates
.as_ref()
.and_then(|d| d.original.as_deref())
.and_then(|s| s.get(..4).map(|y| y.to_string()))
.unwrap_or_default(),
quality_tier: home_qt::quality_tier_from_depth(bit_depth).to_string(),
quality_detail: home_qt::quality_detail_from_parts(bit_depth, sample_rate),
art_url: release
.image
.as_ref()
.and_then(|img| img.best().cloned())
.unwrap_or_default(),
}
}
/// "From the same artist" — get_releases_grid(artist, "album"), capped 16,
/// minus the open album.
pub async fn load_more_from_artist(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str,
current_album_id: &str,
) -> Vec<AlbumCardData> {
let id: u64 = match artist_id.parse() {
Ok(id) => id,
Err(_) => return Vec::new(),
};
let resp = match runtime
.core()
.get_releases_grid(id, "album", RELEASE_PAGE_SIZE, 0, Some("release_date"))
.await
{
Ok(resp) => resp,
Err(e) => {
log::warn!("[qbz-qt] more-from-artist load failed: {e}");
return Vec::new();
}
};
resp.items
.iter()
.map(map_release_card)
.filter(|c| c.id != current_album_id)
.take(MORE_FROM_ARTIST_MAX)
.collect()
}
/// "Listening suggestions" — /album/suggest, minus the open album.
pub async fn load_suggestions(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Vec<AlbumCardData> {
let resp = match runtime.core().get_album_suggest(album_id).await {
Ok(r) => r,
Err(e) => {
log::warn!("[qbz-qt] album suggestions load failed: {e}");
return Vec::new();
}
};
resp.albums
.map(|page| page.items)
.unwrap_or_default()
.iter()
.map(|a| {
let bit_depth = a
.audio_info
.as_ref()
.and_then(|ai| ai.maximum_bit_depth)
.or(a.maximum_bit_depth);
let sample_rate = a
.audio_info
.as_ref()
.and_then(|ai| ai.maximum_sampling_rate)
.or(a.maximum_sampling_rate);
let date = a
.dates
.as_ref()
.and_then(|d| d.original.clone().or(d.download.clone()).or(d.stream.clone()))
.or(a.release_date_original.clone());
AlbumCardData {
id: a.id.clone(),
title: a.title.clone(),
artist: a.artist.name.clone(),
artist_id: a.artist.id.to_string(),
genre: a.genre.as_ref().map(|g| g.name.clone()).unwrap_or_default(),
year: qbz_text_utils::dates::release_label(date.as_deref()),
quality_tier: home_qt::quality_tier_from_depth(bit_depth).to_string(),
quality_detail: home_qt::quality_detail_from_parts(bit_depth, sample_rate),
art_url: a.image.best().cloned().unwrap_or_default(),
}
})
.filter(|c| c.id != album_id)
.collect()
}
/// Fetch + publish everything (perf-marked like phase 5).
pub async fn load_album_view(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<String, String> {
let t = Instant::now();
let mut data = load_album(runtime, album_id).await?;
let artist_id = data.header.artist_id.clone();
let (more, suggestions) = tokio::join!(
load_more_from_artist(runtime, &artist_id, album_id),
load_suggestions(runtime, album_id),
);
data.more_from_artist = more;
data.suggestions = suggestions;
let json = serde_json::to_string(&data).map_err(|e| e.to_string())?;
log::info!(
"[qbz-qt][perf] album load: {:?} ({} tracks, {} more, {} suggestions)",
t.elapsed(),
data.tracks.len(),
data.more_from_artist.len(),
data.suggestions.len(),
);
Ok(json)
}
+504
View File
@@ -0,0 +1,504 @@
//! Artist detail data layer — Slint-free port of `crates/qbz/src/artist.rs`
//! (`load_artist`/`map_artist`, `load_release_page`, the release-section
//! bucketing in the official order). Publishes ONE JSON document.
//!
//! POC-NOTEs:
//! - MusicBrainz sidebar sections (Origin / Relationships / Discovery),
//! the Magazine/Stories tab, blacklist banner/filter, "In library"
//! PLAYLISTS sub-lists, jump-tab scroll-tracking: out of scope (the
//! Network sidebar carries LABELS + SIMILAR ARTISTS, both wired).
//! - `is_following` seeds from the phase-5 library feed (the Slint app
//! resolves it from the favorites cache — same truth, different path).
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use std::time::Instant;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use qbz_models::{PageArtistRelease, PageArtistResponse, PageArtistTrack, QueueTrack};
use serde::Serialize;
use crate::album_qt::{truncate_words, AlbumCardData, TrackRow};
use crate::home_qt;
pub const RELEASE_PAGE_SIZE: u32 = 20;
/// Official on-screen order of release buckets (artist.rs
/// RELEASE_SECTION_ORDER; titles are the msgids translated at build time).
const RELEASE_SECTION_ORDER: &[(&str, &str)] = &[
("album", "Albums"),
("epSingle", "EPs & Singles"),
("ep", "EPs & Singles"),
("single", "EPs & Singles"),
("live", "Live"),
("compilation", "Compilations"),
("download", "Purchase Only"),
("composer", "Composer"),
("other", "Other"),
("awardedRelease", "Critics' Picks"),
("next", "Upcoming"),
];
#[derive(Clone, Serialize)]
pub struct ArtistReleaseSection {
#[serde(rename = "releaseType")]
pub release_type: String,
pub title: String,
#[serde(rename = "hasMore")]
pub has_more: bool,
pub cards: Vec<AlbumCardData>,
}
#[derive(Clone, Serialize)]
pub struct ArtistLabel {
pub id: String,
pub name: String,
}
#[derive(Clone, Serialize)]
pub struct ArtistSimilar {
pub id: String,
pub name: String,
}
#[derive(Clone, Serialize)]
pub struct ArtistPlaylist {
pub id: String,
pub title: String,
pub subtitle: String,
#[serde(rename = "artUrl")]
pub art_url: String,
}
#[derive(Default, Serialize)]
pub struct ArtistViewData {
pub id: String,
pub name: String,
pub bio: String,
#[serde(rename = "bioShort")]
pub bio_short: String,
#[serde(rename = "bioTruncated")]
pub bio_truncated: bool,
#[serde(rename = "artUrl")]
pub artwork_url: String,
#[serde(rename = "isFollowing")]
pub is_following: bool,
#[serde(rename = "isPinned")]
pub is_pinned: bool,
#[serde(rename = "libraryCount")]
pub library_count: i64,
#[serde(rename = "topTracks")]
pub top_tracks: Vec<TrackRow>,
#[serde(rename = "appearsOn")]
pub appears_on: Vec<TrackRow>,
#[serde(rename = "lastRelease")]
pub last_release: Option<AlbumCardData>,
#[serde(rename = "releaseSections")]
pub release_sections: Vec<ArtistReleaseSection>,
pub labels: Vec<ArtistLabel>,
#[serde(rename = "similarArtists")]
pub similar_artists: Vec<ArtistSimilar>,
pub playlists: Vec<ArtistPlaylist>,
}
fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
/// artist.rs `map_release`.
pub(crate) fn map_release(release: &PageArtistRelease) -> AlbumCardData {
let artist = release
.artist
.as_ref()
.map(|a| a.name.display.clone())
.or_else(|| {
release
.artists
.as_ref()
.and_then(|list| list.first().map(|a| a.name.clone()))
})
.unwrap_or_default();
let artist_id = release
.artist
.as_ref()
.map(|a| a.id.to_string())
.unwrap_or_default();
let bit_depth = release.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
let sample_rate = release
.audio_info
.as_ref()
.and_then(|a| a.maximum_sampling_rate);
AlbumCardData {
id: release.id.clone(),
title: release.title.clone(),
artist,
artist_id,
genre: release
.genre
.as_ref()
.map(|g| g.name.clone())
.unwrap_or_default(),
year: release
.dates
.as_ref()
.and_then(|d| d.original.as_deref())
.and_then(|s| s.get(..4).map(|y| y.to_string()))
.unwrap_or_default(),
quality_tier: home_qt::quality_tier_from_depth(bit_depth).to_string(),
quality_detail: home_qt::quality_detail_from_parts(bit_depth, sample_rate),
art_url: release
.image
.as_ref()
.and_then(|img| img.best().cloned())
.unwrap_or_default(),
}
}
/// artist.rs `map_track` (Popular Tracks / Appears On rows — album title +
/// cover ride the nested album object).
fn map_track(index: usize, track: PageArtistTrack) -> TrackRow {
let mut title = track.title;
if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({version})");
}
let (artist, artist_id) = track
.artist
.map(|a| (a.name.display, a.id.to_string()))
.unwrap_or_default();
let (album_id, album, artwork_url) = track
.album
.map(|a| {
let url = a.image.and_then(|img| img.smallest().cloned()).unwrap_or_default();
(a.id, a.title, url)
})
.unwrap_or_default();
let bit_depth = track.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
let sample_rate = track.audio_info.as_ref().and_then(|a| a.maximum_sampling_rate);
TrackRow {
id: track.id.to_string(),
number: (index + 1).to_string(),
title,
artist,
artist_id,
album,
album_id,
duration: mmss(track.duration.unwrap_or(0)),
quality_tier: home_qt::quality_tier_from_depth(bit_depth).to_string(),
quality_detail: home_qt::quality_detail_from_parts(bit_depth, sample_rate),
explicit: track.parental_warning.unwrap_or(false),
disc: 1,
work_header: String::new(),
work_composer_name: String::new(),
work_composer_id: String::new(),
artwork_url,
}
}
/// Fetch and map an artist page by id (artist.rs `load_artist`/`map_artist`).
pub async fn load_artist(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str,
) -> Result<ArtistViewData, String> {
let id: u64 = artist_id
.parse()
.map_err(|_| format!("invalid artist id: {artist_id}"))?;
let page = runtime
.core()
.get_artist_page(id, None)
.await
.map_err(|e| e.to_string())?;
Ok(map_artist(page))
}
fn map_artist(page: PageArtistResponse) -> ArtistViewData {
let name = page.name.display;
let bio = page
.biography
.and_then(|b| b.content)
.map(|c| qbz_text_utils::strip_html::strip_html(&c))
.unwrap_or_default();
let bio_short = truncate_words(&bio, 360);
let bio_truncated = bio_short != bio;
let artwork_url = page
.images
.and_then(|images| images.portrait)
.map(|portrait| {
format!(
"https://static.qobuz.com/images/artists/covers/large/{}.{}",
portrait.hash, portrait.format
)
})
.unwrap_or_default();
let top_tracks = page
.top_tracks
.unwrap_or_default()
.into_iter()
.enumerate()
.map(|(index, track)| map_track(index, track))
.collect();
// Server-driven bucketing (webplayer source of truth): every non-empty
// bucket in the official order; ids deduped across groups; labels from
// the artist's own "album" group.
let mut bucket_cards: HashMap<String, Vec<AlbumCardData>> = HashMap::new();
let mut bucket_has_more: HashMap<String, bool> = HashMap::new();
let mut seen_release_ids: HashSet<String> = HashSet::new();
let mut labels_by_id: BTreeMap<u64, String> = BTreeMap::new();
for group in page.releases.into_iter().flatten() {
let release_type = group.release_type.clone();
let is_album_group = release_type == "album";
*bucket_has_more.entry(release_type.clone()).or_insert(false) |= group.has_more;
for release in group.items.into_iter() {
if seen_release_ids.contains(&release.id) {
continue;
}
seen_release_ids.insert(release.id.clone());
if is_album_group {
if let Some(label) = release.label.as_ref() {
labels_by_id
.entry(label.id)
.or_insert_with(|| label.name.clone());
}
}
bucket_cards
.entry(release_type.clone())
.or_default()
.push(map_release(&release));
}
}
let mut labels: Vec<ArtistLabel> = labels_by_id
.into_iter()
.map(|(id, name)| ArtistLabel {
id: id.to_string(),
name,
})
.collect();
labels.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
let similar_artists: Vec<ArtistSimilar> = page
.similar_artists
.map(|s| {
s.items
.into_iter()
.map(|item| ArtistSimilar {
id: item.id.to_string(),
name: item.name.display,
})
.collect()
})
.unwrap_or_default();
let playlists: Vec<ArtistPlaylist> = page
.playlists
.map(|p| {
p.items
.into_iter()
.map(|pl| {
let owner = pl
.owner
.and_then(|o| o.name)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Qobuz".to_string());
let track_count = pl.tracks_count.unwrap_or(0);
let art_url = pl
.images
.and_then(|imgs| imgs.rectangle)
.and_then(|rects| rects.into_iter().find(|s| !s.is_empty()))
.unwrap_or_default();
ArtistPlaylist {
id: pl.id.to_string(),
title: pl.title.unwrap_or_default(),
subtitle: format!("{owner} · {track_count}"),
art_url,
}
})
.collect()
})
.unwrap_or_default();
let last_release = page.last_release.as_ref().map(map_release);
let appears_on: Vec<TrackRow> = page
.tracks_appears_on
.into_iter()
.flatten()
.enumerate()
.map(|(index, track)| map_track(index, track))
.collect();
// One section per non-empty bucket in the official order; "download"
// drains but never renders (Purchase Only is hidden upstream too).
let mut release_sections: Vec<ArtistReleaseSection> = Vec::new();
for &(rt, title) in RELEASE_SECTION_ORDER {
if rt == "download" {
bucket_cards.remove(rt);
continue;
}
if let Some(cards) = bucket_cards.remove(rt) {
if cards.is_empty() {
continue;
}
release_sections.push(ArtistReleaseSection {
release_type: rt.to_string(),
title: qbz_i18n::t(title),
has_more: bucket_has_more.get(rt).copied().unwrap_or(false),
cards,
});
}
}
// Leftovers: server buckets unknown to the table, appended (title-cased).
for (rt, cards) in bucket_cards {
if cards.is_empty() {
continue;
}
let title = {
let mut chars = rt.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => rt.clone(),
}
};
release_sections.push(ArtistReleaseSection {
has_more: false,
release_type: rt,
title,
cards,
});
}
let (library_count, is_following) = crate::library_qt::with_library(|d| {
let count = d
.feed
.iter()
.filter(|i| {
(i.kind == "album" || i.kind == "track") && i.artist_id == page.id.to_string()
})
.count() as i64;
let following = d
.feed
.iter()
.any(|i| i.kind == "artist" && i.id == page.id.to_string() && i.is_favorite);
(count, following)
})
.unwrap_or((0, false));
ArtistViewData {
id: page.id.to_string(),
name,
bio,
bio_short,
bio_truncated,
artwork_url,
is_following,
is_pinned: crate::sidebar_qt::is_pinned("artist", &page.id.to_string()),
library_count,
top_tracks,
appears_on,
last_release,
release_sections,
labels,
similar_artists,
playlists,
}
}
/// The last loaded artist's Popular Tracks as a playable queue (row play
/// + Play-all + Shuffle-all + Play-next/queue on rows).
static TOP_QUEUE: std::sync::Mutex<Vec<QueueTrack>> = std::sync::Mutex::new(Vec::new());
fn track_row_to_queue(row: &TrackRow) -> QueueTrack {
let duration_secs = {
let mut parts = row.duration.split(':');
parts.next().and_then(|m| m.parse::<u64>().ok()).unwrap_or(0) * 60
+ parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)
};
QueueTrack {
id: row.id.parse().unwrap_or(0),
title: row.title.clone(),
version: None,
artist: row.artist.clone(),
album: row.album.clone(),
album_version: None,
duration_secs,
artwork_url: if row.artwork_url.is_empty() {
None
} else {
Some(row.artwork_url.clone())
},
hires: row.quality_tier == "hires",
bit_depth: None,
sample_rate: None,
is_local: false,
album_id: if row.album_id.is_empty() {
None
} else {
Some(row.album_id.clone())
},
artist_id: row.artist_id.parse::<u64>().ok(),
streamable: true,
source: Some("qobuz".to_string()),
parental_warning: row.explicit,
source_item_id_hint: None,
context_kind: None,
context_id: None,
}
}
/// Stash the queue at publish time (called from load_artist_view).
fn stash_top_queue(data: &ArtistViewData) {
let queue: Vec<QueueTrack> = data.top_tracks.iter().map(track_row_to_queue).collect();
*TOP_QUEUE.lock().unwrap() = queue;
}
/// The stashed queue (cloned) + the start index for `track_id` (0 when
/// unknown — e.g. Play-all).
pub fn top_queue(track_id: Option<u64>) -> (Vec<QueueTrack>, usize) {
let queue = TOP_QUEUE.lock().unwrap().clone();
let start = track_id
.and_then(|id| queue.iter().position(|t| t.id == id))
.unwrap_or(0);
(queue, start)
}
/// One more page of a releases bucket (artist.rs `load_release_page`).
pub async fn load_release_page(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str,
release_type: &str,
offset: u32,
) -> Result<(Vec<AlbumCardData>, bool), String> {
let id: u64 = artist_id
.parse()
.map_err(|_| format!("invalid artist id: {artist_id}"))?;
let resp = runtime
.core()
.get_releases_grid(id, release_type, RELEASE_PAGE_SIZE, offset, Some("release_date"))
.await
.map_err(|e| e.to_string())?;
let has_more = resp.has_more;
let cards = resp.items.iter().map(map_release).collect();
Ok((cards, has_more))
}
/// Fetch + publish (perf-marked like phase 5).
pub async fn load_artist_view(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
artist_id: &str,
) -> Result<String, String> {
let t = Instant::now();
let data = load_artist(runtime, artist_id).await?;
let sections: usize = data.release_sections.iter().map(|s| s.cards.len()).sum();
stash_top_queue(&data);
let json = serde_json::to_string(&data).map_err(|e| e.to_string())?;
log::info!(
"[qbz-qt][perf] artist load: {:?} ({} top tracks, {} releases in {} sections)",
t.elapsed(),
data.top_tracks.len(),
sections,
data.release_sections.len(),
);
Ok(json)
}
+96
View File
@@ -113,6 +113,16 @@ pub mod qbz_bridge {
// {tracks, albums, artists, playlists, labels, all} — tab badges.
#[qproperty(QString, library_counts_json)]
// --- Album / Artist detail views (phase 8) -------------------------
#[qproperty(bool, album_loading)]
#[qproperty(QString, album_json)]
#[qproperty(bool, artist_loading)]
#[qproperty(QString, artist_json)]
// The id backing the current "album"/"artist" view ("" = none).
#[qproperty(QString, view_param_id)]
// Now-playing track id (playing-row indicator in track lists).
#[qproperty(QString, np_track_id)]
// --- Sidebar playlist tree ---------------------------------------
// One JSON document: the flattened entries (folders + playlists,
// expand/sort/search applied Rust-side — sidebar_qt.rs).
@@ -201,6 +211,25 @@ pub mod qbz_bridge {
#[qinvokable]
fn play_album(self: Pin<&mut QbzBridge>, album_id: QString);
/// AlbumView header Shuffle.
#[qinvokable]
fn play_album_shuffled(self: Pin<&mut QbzBridge>, album_id: QString);
/// AlbumView row play: the album starting AT this track.
#[qinvokable]
fn play_album_from(self: Pin<&mut QbzBridge>, album_id: QString, track_id: QString);
/// AlbumView row "Play next" ("next") / "Add to queue" ("later").
#[qinvokable]
fn enqueue_album_track(self: Pin<&mut QbzBridge>, album_id: QString, track_id: QString, mode: QString);
/// ArtistView Popular Tracks row play (whole list as the queue).
#[qinvokable]
fn play_artist_track(self: Pin<&mut QbzBridge>, track_id: QString);
/// ArtistView "Play all" (shuffle=false) / "Shuffle all" (true).
#[qinvokable]
fn play_artist_top(self: Pin<&mut QbzBridge>, shuffle: bool);
/// ArtistView ⋯ "Add all to queue" — appends the top-tracks queue.
#[qinvokable]
fn enqueue_artist_top(self: Pin<&mut QbzBridge>);
/// Sidebar navigation: record a content view ("home" | "library")
/// and lazy-load its data on first visit.
#[qinvokable]
@@ -265,6 +294,19 @@ pub mod qbz_bridge {
/// collage is url-keyed, unlike the feed's artKey).
#[qinvokable]
fn sidebar_artwork_window(self: Pin<&mut QbzBridge>, urls_json: QString);
/// Open the album detail view (pushes "album" on the nav stack).
#[qinvokable]
fn open_album(self: Pin<&mut QbzBridge>, album_id: QString);
/// Open the artist detail view (pushes "artist" on the nav stack).
#[qinvokable]
fn open_artist(self: Pin<&mut QbzBridge>, artist_id: QString);
/// ArtistView per-section "Load more" — the next releases page.
#[qinvokable]
fn load_release_section(self: Pin<&mut QbzBridge>, artist_id: QString, release_type: QString, offset: i32);
/// Emitted with the next page of a releases bucket.
#[qsignal]
fn release_section_ready(self: Pin<&mut QbzBridge>, release_type: QString, cards_json: QString, has_more: bool);
}
impl cxx_qt::Threading for QbzBridge {}
@@ -326,6 +368,12 @@ pub struct QbzBridgeRust {
sidebar_json: QString,
sidebar_sort_by: QString,
sidebar_sort_asc: bool,
album_loading: bool,
album_json: QString,
artist_loading: bool,
artist_json: QString,
view_param_id: QString,
np_track_id: QString,
}
impl Default for QbzBridgeRust {
@@ -377,6 +425,12 @@ impl Default for QbzBridgeRust {
sidebar_json: QString::from("[]"),
sidebar_sort_by: QString::from("name"),
sidebar_sort_asc: true,
album_loading: false,
album_json: QString::from("{}"),
artist_loading: false,
artist_json: QString::from("{}"),
view_param_id: QString::default(),
np_track_id: QString::default(),
}
}
}
@@ -480,6 +534,36 @@ impl qbz_bridge::QbzBridge {
crate::play_album(album_id.to_string());
}
pub fn play_album_shuffled(self: Pin<&mut Self>, album_id: QString) {
crate::play_album_shuffled(album_id.to_string());
}
pub fn play_album_from(self: Pin<&mut Self>, album_id: QString, track_id: QString) {
if let Ok(tid) = track_id.to_string().parse::<u64>() {
crate::play_album_from_track(album_id.to_string(), tid);
}
}
pub fn enqueue_album_track(self: Pin<&mut Self>, album_id: QString, track_id: QString, mode: QString) {
if let Ok(tid) = track_id.to_string().parse::<u64>() {
crate::enqueue_album_track(album_id.to_string(), tid, mode.to_string());
}
}
pub fn play_artist_track(self: Pin<&mut Self>, track_id: QString) {
if let Ok(tid) = track_id.to_string().parse::<u64>() {
crate::play_artist_track(tid);
}
}
pub fn play_artist_top(self: Pin<&mut Self>, shuffle: bool) {
crate::play_artist_top(shuffle);
}
pub fn enqueue_artist_top(self: Pin<&mut Self>) {
crate::enqueue_artist_top();
}
pub fn navigate_to(self: Pin<&mut Self>, view: QString) {
crate::navigate_to(&view.to_string());
}
@@ -539,4 +623,16 @@ impl qbz_bridge::QbzBridge {
pub fn sidebar_artwork_window(self: Pin<&mut Self>, urls_json: QString) {
crate::sidebar_artwork_window(urls_json.to_string());
}
pub fn open_album(self: Pin<&mut Self>, album_id: QString) {
crate::open_album(album_id.to_string());
}
pub fn open_artist(self: Pin<&mut Self>, artist_id: QString) {
crate::open_artist(artist_id.to_string());
}
pub fn load_release_section(self: Pin<&mut Self>, artist_id: QString, release_type: QString, offset: i32) {
crate::load_release_section(artist_id.to_string(), release_type.to_string(), offset);
}
}
+12 -2
View File
@@ -218,6 +218,14 @@ 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.
@@ -238,12 +246,14 @@ where
release_watch.len(),
top_artists.len(),
);
Ok(build_sections(
let built = build_sections(
response.containers,
favorite_albums,
release_watch,
top_artists,
))
);
*LAST_SECTIONS.lock().unwrap() = built.clone();
Ok(built)
}
// ---------------------------------------------------------------------------
+145 -2
View File
@@ -15,6 +15,8 @@ mod library_qt;
mod nav_qt;
mod now_playing;
mod offline_fwd;
mod album_qt;
mod artist_qt;
mod playback_qt;
mod sidebar_qt;
@@ -126,7 +128,6 @@ fn enter_shell(session: auth_qt::SessionInfo) {
playback_qt::start_poll_loop(app());
// Phase 7: the sidebar playlist tree.
load_sidebar_once();
ui(move |mut b| {
b.as_mut().set_session_user_name(QString::from(session.display_name.as_str()));
b.as_mut().set_session_subscription(QString::from(session.subscription.as_str()));
@@ -322,6 +323,7 @@ pub(crate) fn sidebar_toggle_folder(id: &str) {
/// url-keyed). Disk hits emit immediately; misses download then emit.
pub(crate) fn sidebar_artwork_window(urls_json: String) {
let urls: Vec<String> = serde_json::from_str(&urls_json).unwrap_or_default();
log::debug!("[qbz-qt] sidebar artwork window: {} urls", urls.len());
let mut missing: Vec<String> = Vec::new();
for url in urls {
let path = artwork_qt::cached_path(&url);
@@ -346,6 +348,84 @@ pub(crate) fn sidebar_artwork_window(urls_json: String) {
});
}
// ============================ Album / Artist views (phase 8) ==============
/// Open the album detail view: push the nav entry, then fetch + publish.
pub(crate) fn open_album(album_id: String) {
if offline_fwd::engine().status().is_offline() {
return;
}
nav_qt::record("album");
let runtime = app();
let param = album_id.clone();
ui(move |mut b| {
b.as_mut().set_album_loading(true);
b.as_mut().set_view_param_id(QString::from(param.as_str()));
});
spawn(async move {
match album_qt::load_album_view(&runtime, &album_id).await {
Ok(json) => {
ui(move |mut b| {
b.as_mut().set_album_json(QString::from(json.as_str()));
b.as_mut().set_album_loading(false);
})
},
Err(e) => {
log::warn!("[qbz-qt] album view load failed: {e}");
ui(move |mut b| b.as_mut().set_album_loading(false));
}
}
});
}
/// Open the artist detail view: push the nav entry, then fetch + publish.
pub(crate) fn open_artist(artist_id: String) {
if offline_fwd::engine().status().is_offline() {
return;
}
nav_qt::record("artist");
let runtime = app();
let param = artist_id.clone();
ui(move |mut b| {
b.as_mut().set_artist_loading(true);
b.as_mut().set_view_param_id(QString::from(param.as_str()));
});
spawn(async move {
match artist_qt::load_artist_view(&runtime, &artist_id).await {
Ok(json) => ui(move |mut b| {
b.as_mut().set_artist_json(QString::from(json.as_str()));
b.as_mut().set_artist_loading(false);
}),
Err(e) => {
log::warn!("[qbz-qt] artist view load failed: {e}");
ui(move |mut b| b.as_mut().set_artist_loading(false));
}
}
});
}
/// ArtistView "Load more" for one releases bucket (page 20, has_more).
pub(crate) fn load_release_section(artist_id: String, release_type: String, offset: i32) {
let runtime = app();
spawn(async move {
match artist_qt::load_release_page(&runtime, &artist_id, &release_type, offset.max(0) as u32)
.await
{
Ok((cards, has_more)) => {
let json = serde_json::to_string(&cards).unwrap_or_else(|_| "[]".into());
ui(move |mut b| {
b.as_mut().release_section_ready(
QString::from(release_type.as_str()),
QString::from(json.as_str()),
has_more,
);
});
}
Err(e) => log::warn!("[qbz-qt] release page load failed: {e}"),
}
});
}
/// Sidebar "+" — create an empty playlist with the default name, then
/// reload the tree (the Slint flow opens a naming modal — POC-NOTE).
pub(crate) fn create_playlist() {
@@ -386,6 +466,69 @@ pub(crate) fn toggle_pin(kind: String, id: String, title: String, subtitle: Stri
}
}
/// AlbumView header Shuffle.
pub(crate) fn play_album_shuffled(album_id: String) {
let runtime = app();
spawn(async move {
if let Err(e) = playback_qt::play_album_shuffled(&runtime, &album_id).await {
log::error!("[qbz-qt] play_album_shuffled failed: {e}");
}
});
}
/// AlbumView row play (album from the clicked track).
pub(crate) fn play_album_from_track(album_id: String, track_id: u64) {
let runtime = app();
spawn(async move {
if let Err(e) = playback_qt::play_album_from_track(&runtime, &album_id, track_id).await {
log::error!("[qbz-qt] play_album_from_track failed: {e}");
}
});
}
/// AlbumView row "Play next" / "Add to queue".
pub(crate) fn enqueue_album_track(album_id: String, track_id: u64, mode: String) {
let runtime = app();
spawn(async move {
if let Err(e) = playback_qt::enqueue_album_track(&runtime, &album_id, track_id, &mode).await {
log::error!("[qbz-qt] enqueue_album_track failed: {e}");
}
});
}
/// ArtistView Popular Tracks row play (the whole list as the queue).
pub(crate) fn play_artist_track(track_id: u64) {
let runtime = app();
spawn(async move {
let (queue, start) = artist_qt::top_queue(Some(track_id));
if let Err(e) = playback_qt::play_track_list(&runtime, queue, start, false).await {
log::error!("[qbz-qt] play_artist_track failed: {e}");
}
});
}
/// ArtistView "Play all" / "Shuffle all".
pub(crate) fn play_artist_top(shuffle: bool) {
let runtime = app();
spawn(async move {
let (queue, start) = artist_qt::top_queue(None);
if let Err(e) = playback_qt::play_track_list(&runtime, queue, start, shuffle).await {
log::error!("[qbz-qt] play_artist_top failed: {e}");
}
});
}
/// ArtistView ⋯ "Add all to queue".
pub(crate) fn enqueue_artist_top() {
let runtime = app();
spawn(async move {
let (queue, _) = artist_qt::top_queue(None);
if let Err(e) = playback_qt::enqueue_track_list(&runtime, queue).await {
log::error!("[qbz-qt] enqueue_artist_top failed: {e}");
}
});
}
/// Track-row click (Library tracks): one-track queue through the core.
pub(crate) fn play_track(track_id: u64) {
let runtime = app();
@@ -540,7 +683,7 @@ fn log_rss(mark: &str) {
/// from the Slint round). Disk hits emit immediately.
pub(crate) fn library_artwork_window(keys_json: String) {
let keys: Vec<String> = serde_json::from_str(&keys_json).unwrap_or_default();
log::debug!("[qbz-qt] library_artwork_window: {} keys", keys.len());
log::debug!("[qbz-qt] artwork window: {} keys", keys.len());
if keys.is_empty() {
return;
}
+117 -3
View File
@@ -23,6 +23,7 @@ use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use cxx_qt_lib::QString;
use qbz_models::{Quality, QueueTrack, RepeatMode};
/// The request tier for every play: the ui_prefs default ("hires_plus").
@@ -130,11 +131,23 @@ pub async fn play_album(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<(), String> {
log::info!("[qbz-qt] play_album: resolving {album_id}");
play_album_from(runtime, album_id, 0).await
}
/// Play an album starting at track `start_index` (AlbumView row play —
/// Slint's play_album_from semantics: the queue keeps the whole album,
/// the cursor starts at the clicked track).
pub async fn play_album_from(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
start_index: usize,
) -> Result<(), String> {
log::info!("[qbz-qt] play_album: resolving {album_id} (start {start_index})");
let tracks = fetch_album_queue(runtime, album_id).await?;
let first_id = tracks[0].id;
let start = start_index.min(tracks.len() - 1);
let first_id = tracks[start].id;
let count = tracks.len();
runtime.core().set_queue(tracks, Some(0)).await;
runtime.core().set_queue(tracks, Some(start)).await;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}");
runtime
@@ -147,6 +160,100 @@ pub async fn play_album(
Ok(())
}
/// AlbumView row play: play the album starting AT the clicked track
/// (Slint play_album_from: the queue keeps the whole album).
pub async fn play_album_from_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
track_id: u64,
) -> Result<(), String> {
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = tracks.iter().position(|t| t.id == track_id).unwrap_or(0);
let first_id = tracks[start].id;
runtime.core().set_queue(tracks, Some(start)).await;
publish_queue(runtime).await;
runtime
.core()
.play_track_resolved(first_id, POC_QUALITY, None, None, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
/// Play a pre-built queue starting at `start` (ArtistView Popular Tracks:
/// the visible list becomes the queue, anchored at the clicked track).
pub async fn play_track_list(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
start: usize,
shuffle: bool,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("empty track list".to_string());
}
if shuffle {
runtime.core().set_shuffle(true).await;
crate::now_playing::set_shuffle(true);
}
let start = start.min(tracks.len() - 1);
let first_id = tracks[start].id;
runtime.core().set_queue(tracks, Some(start)).await;
publish_queue(runtime).await;
runtime
.core()
.play_track_resolved(first_id, POC_QUALITY, None, None, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
/// Append a pre-built queue to the current one ("Add all to queue").
pub async fn enqueue_track_list(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("empty track list".to_string());
}
runtime.core().add_tracks(tracks).await;
publish_queue(runtime).await;
Ok(())
}
/// One track from an album context into the queue ("Play next" / "Add to
/// queue" on an AlbumView row).
pub async fn enqueue_album_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
track_id: u64,
mode: &str,
) -> Result<(), String> {
let tracks = fetch_album_queue(runtime, album_id).await?;
let Some(track) = tracks.into_iter().find(|t| t.id == track_id) else {
return Err(format!("track {track_id} not in album {album_id}"));
};
if mode == "next" {
runtime.core().add_track_next(track).await;
} else {
runtime.core().add_track(track).await;
}
publish_queue(runtime).await;
Ok(())
}
/// Shuffle-play an album (AlbumView header Shuffle): enable shuffle, then
/// play — the core queue owns the shuffled order.
pub async fn play_album_shuffled(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<(), String> {
runtime.core().set_shuffle(true).await;
crate::now_playing::set_shuffle(true);
play_album(runtime, album_id).await
}
/// Play a single track as a one-element queue (Library track rows). The
/// queue meta is rebuilt from the Library feed row; the audio resolves by
/// id through the same `play_track_resolved` path.
@@ -316,12 +423,19 @@ pub async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let state = runtime.core().get_queue_state().await;
let Some(track) = state.current_track else {
crate::now_playing::clear_track();
crate::ui(move |mut b| {
b.as_mut().set_np_track_id(QString::from(""));
});
return;
};
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
};
let track_id_str = track.id.to_string();
crate::ui(move |mut b| {
b.as_mut().set_np_track_id(QString::from(track_id_str.as_str()));
});
log::info!(
"[qbz-qt] now playing: '{title}' — {} ({}s)",
track.artist,