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
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||