feat: initial qobuz-qt source
Lightweight Qt6 desktop client for Qobuz with a Rust audio backend (Symphonia/CPAL via staticlib FFI). Mirrors the spotify-qt layout: toolbar with playback controls, library/context docks on the left, tabbed search side panel on the right, queue panel, now-playing dock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
build/
|
||||
target/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
Cargo.lock
|
||||
.cache/
|
||||
*.user
|
||||
*.autosave
|
||||
|
||||
24
BUILD.md
@@ -20,6 +20,30 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source "$HOME/.cargo/env"
|
||||
```
|
||||
|
||||
### Alpine Linux
|
||||
```bash
|
||||
sudo apk add \
|
||||
cmake \
|
||||
ninja \
|
||||
qt6-qtbase-dev \
|
||||
qt6-qtsvg-dev \
|
||||
openssl-dev \
|
||||
alsa-lib-dev \
|
||||
dbus-dev \
|
||||
pkgconf \
|
||||
curl \
|
||||
musl-dev \
|
||||
gcc \
|
||||
g++
|
||||
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
source "$HOME/.cargo/env"
|
||||
```
|
||||
|
||||
> **Note:** CPAL uses ALSA on Alpine. If you prefer PipeWire/PulseAudio,
|
||||
> install `pipewire-alsa` so ALSA routes through it automatically.
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
sudo pacman -S cmake ninja qt6-base qt6-svg openssl alsa-lib rust
|
||||
|
||||
@@ -25,16 +25,35 @@ if (USE_DBUS)
|
||||
find_package(Qt6 OPTIONAL_COMPONENTS DBus QUIET)
|
||||
endif ()
|
||||
|
||||
# ----- Rust backend via corrosion -----
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
Corrosion
|
||||
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
|
||||
GIT_TAG v0.5
|
||||
)
|
||||
FetchContent_MakeAvailable(Corrosion)
|
||||
# ----- Rust backend (cargo) -----
|
||||
# Don't use find_program — it caches a full path that may differ across machines.
|
||||
# 'cargo' is resolved via PATH at build time.
|
||||
set(CARGO_CMD cargo)
|
||||
|
||||
corrosion_import_crate(MANIFEST_PATH rust/Cargo.toml CRATES qobuz-backend)
|
||||
if(CMAKE_BUILD_TYPE STREQUAL "Release")
|
||||
set(CARGO_PROFILE release)
|
||||
set(CARGO_PROFILE_FLAG --release)
|
||||
else()
|
||||
set(CARGO_PROFILE debug)
|
||||
set(CARGO_PROFILE_FLAG "")
|
||||
endif()
|
||||
|
||||
set(RUST_LIB "${CMAKE_SOURCE_DIR}/target/${CARGO_PROFILE}/libqobuz_backend.a")
|
||||
|
||||
add_custom_target(rust_backend ALL
|
||||
COMMAND ${CARGO_CMD} build ${CARGO_PROFILE_FLAG}
|
||||
--manifest-path "${CMAKE_SOURCE_DIR}/Cargo.toml"
|
||||
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||
COMMENT "Building Rust backend (cargo ${CARGO_PROFILE})"
|
||||
BYPRODUCTS "${RUST_LIB}"
|
||||
)
|
||||
|
||||
# Imported static library so CMake knows it's a link input
|
||||
add_library(qobuz_backend_lib STATIC IMPORTED GLOBAL)
|
||||
set_target_properties(qobuz_backend_lib PROPERTIES
|
||||
IMPORTED_LOCATION "${RUST_LIB}"
|
||||
)
|
||||
add_dependencies(qobuz_backend_lib rust_backend)
|
||||
|
||||
# Create main executable
|
||||
add_executable(qobuz-qt res.qrc)
|
||||
@@ -42,6 +61,8 @@ add_executable(qobuz-qt res.qrc)
|
||||
# Source files
|
||||
add_subdirectory(src)
|
||||
|
||||
add_dependencies(qobuz-qt rust_backend)
|
||||
|
||||
# Include paths
|
||||
target_include_directories(qobuz-qt PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||
@@ -59,7 +80,7 @@ target_link_libraries(qobuz-qt PRIVATE
|
||||
Qt6::Network
|
||||
Qt6::Gui
|
||||
Qt6::Svg
|
||||
qobuz-backend
|
||||
qobuz_backend_lib
|
||||
)
|
||||
|
||||
# Platform-specific system libs needed by the Rust audio stack (cpal/ALSA)
|
||||
|
||||
2602
Cargo.lock
generated
Normal file
66
res.qrc
@@ -1,6 +1,68 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<!-- Icons will be served from the system theme; this is a fallback stub. -->
|
||||
<!-- Add .svg files to res/icons/ and list them here if needed. -->
|
||||
<!-- Icons from spotify-qt (dark variant) -->
|
||||
<file>res/icons/application-exit.svg</file>
|
||||
<file>res/icons/application-menu.svg</file>
|
||||
<file>res/icons/audio-volume-high.svg</file>
|
||||
<file>res/icons/audio-volume-low.svg</file>
|
||||
<file>res/icons/audio-volume-medium.svg</file>
|
||||
<file>res/icons/configure.svg</file>
|
||||
<file>res/icons/data-error.svg</file>
|
||||
<file>res/icons/data-information.svg</file>
|
||||
<file>res/icons/data-warning.svg</file>
|
||||
<file>res/icons/description.svg</file>
|
||||
<file>res/icons/dialog-cancel.svg</file>
|
||||
<file>res/icons/dialog-ok.svg</file>
|
||||
<file>res/icons/document-edit.svg</file>
|
||||
<file>res/icons/document-preview.svg</file>
|
||||
<file>res/icons/document-properties.svg</file>
|
||||
<file>res/icons/document-save.svg</file>
|
||||
<file>res/icons/document-share.svg</file>
|
||||
<file>res/icons/download.svg</file>
|
||||
<file>res/icons/draw-brush.svg</file>
|
||||
<file>res/icons/draw-donut.svg</file>
|
||||
<file>res/icons/edit-copy.svg</file>
|
||||
<file>res/icons/edit-find.svg</file>
|
||||
<file>res/icons/edit-undo.svg</file>
|
||||
<file>res/icons/exception.svg</file>
|
||||
<file>res/icons/folder-temp.svg</file>
|
||||
<file>res/icons/folder-txt.svg</file>
|
||||
<file>res/icons/go-down.svg</file>
|
||||
<file>res/icons/go-previous.svg</file>
|
||||
<file>res/icons/go-up.svg</file>
|
||||
<file>res/icons/headphones.svg</file>
|
||||
<file>res/icons/help-about.svg</file>
|
||||
<file>res/icons/hint.svg</file>
|
||||
<file>res/icons/im-user-away.svg</file>
|
||||
<file>res/icons/list-add.svg</file>
|
||||
<file>res/icons/list-remove.svg</file>
|
||||
<file>res/icons/media-optical-audio.svg</file>
|
||||
<file>res/icons/media-playback-pause.svg</file>
|
||||
<file>res/icons/media-playback-start.svg</file>
|
||||
<file>res/icons/media-playlist-append.svg</file>
|
||||
<file>res/icons/media-playlist-repeat-song.svg</file>
|
||||
<file>res/icons/media-playlist-repeat.svg</file>
|
||||
<file>res/icons/media-playlist-shuffle.svg</file>
|
||||
<file>res/icons/media-skip-backward.svg</file>
|
||||
<file>res/icons/media-skip-forward.svg</file>
|
||||
<file>res/icons/media-track-show-active.svg</file>
|
||||
<file>res/icons/non-starred-symbolic.svg</file>
|
||||
<file>res/icons/speaker.svg</file>
|
||||
<file>res/icons/starred-symbolic.svg</file>
|
||||
<file>res/icons/view-calendar.svg</file>
|
||||
<file>res/icons/view-media-album-cover.svg</file>
|
||||
<file>res/icons/view-media-artist.svg</file>
|
||||
<file>res/icons/view-media-lyrics.svg</file>
|
||||
<file>res/icons/view-media-playlist.svg</file>
|
||||
<file>res/icons/view-media-track.svg</file>
|
||||
<file>res/icons/view-refresh.svg</file>
|
||||
<file>res/icons/view-sort-ascending.svg</file>
|
||||
<file>res/icons/view-statistics.svg</file>
|
||||
<file>res/icons/visibility.svg</file>
|
||||
<file>res/icons/window-close.svg</file>
|
||||
<file>res/icons/window-maximize.svg</file>
|
||||
<file>res/icons/window-minimize.svg</file>
|
||||
<file>res/icons/window-restore.svg</file>
|
||||
<file>res/icons/window.svg</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
||||
13
res/icons/application-exit.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-NegativeText {
|
||||
color:#da4453;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
class="ColorScheme-NegativeText"
|
||||
d="m3 3v4 12h1 14 1v-1-12-3h-15-1m1 4h14v11h-14v-11m4 4v2h6v-2h-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
14
res/icons/application-menu.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m3 5v2h16v-2h-16m0 5v2h16v-2h-16m0 5v2h16v-2h-16"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 397 B |
13
res/icons/audio-volume-high.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<g class="ColorScheme-Text" fill="currentColor">
|
||||
<path d="m14.324219 7.28125-.539063.8613281a4 4 0 0 1 1.214844 2.8574219 4 4 0 0 1 -1.210938 2.861328l.539063.863281a5 5 0 0 0 1.671875-3.724609 5 5 0 0 0 -1.675781-3.71875z"/>
|
||||
<path d="m13.865234 3.5371094-.24414.9765625a7 7 0 0 1 4.378906 6.4863281 7 7 0 0 1 -4.380859 6.478516l.24414.974609a8 8 0 0 0 5.136719-7.453125 8 8 0 0 0 -5.134766-7.4628906z"/>
|
||||
<path d="m3 8h2v6h-2z" fill-rule="evenodd"/>
|
||||
<path d="m6 14 5 5h1v-16h-1l-5 5z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
12
res/icons/audio-volume-low.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<g class="ColorScheme-Text" fill="currentColor">
|
||||
<path d="m13.865234 3.5371094-.24414.9765625a7 7 0 0 1 4.378906 6.4863281 7 7 0 0 1 -4.380859 6.478516l.24414.974609a8 8 0 0 0 5.136719-7.453125 8 8 0 0 0 -5.134766-7.4628906zm.458985 3.7441406-.539063.8613281a4 4 0 0 1 1.214844 2.8574219 4 4 0 0 1 -1.210938 2.861328l.539063.863281a5 5 0 0 0 1.671875-3.724609 5 5 0 0 0 -1.675781-3.71875z" fill-opacity=".35"/>
|
||||
<path d="m3 8h2v6h-2z" fill-rule="evenodd"/>
|
||||
<path d="m6 14 5 5h1v-16h-1l-5 5z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
13
res/icons/audio-volume-medium.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<g class="ColorScheme-Text" fill="currentColor">
|
||||
<path d="m14.324219 7.28125-.539063.8613281a4 4 0 0 1 1.214844 2.8574219 4 4 0 0 1 -1.210938 2.861328l.539063.863281a5 5 0 0 0 1.671875-3.724609 5 5 0 0 0 -1.675781-3.71875z"/>
|
||||
<path d="m13.865234 3.5371094-.24414.9765625a7 7 0 0 1 4.378906 6.4863281 7 7 0 0 1 -4.380859 6.478516l.24414.974609a8 8 0 0 0 5.136719-7.453125 8 8 0 0 0 -5.134766-7.4628906z" fill-opacity=".35"/>
|
||||
<path d="m3.0000005 8h2v6h-2z" fill-rule="evenodd"/>
|
||||
<path d="m6.0000005 14 5.0000005 5h.999999v-16h-1l-4.9999995 5z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 789 B |
13
res/icons/configure.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 11.5 3 C 10.286139 3 9.2809778 3.8559279 9.0507812 5 L 3 5 L 3 6 L 9.0507812 6 C 9.2809778 7.1440721 10.286139 8 11.5 8 C 12.713861 8 13.719022 7.1440721 13.949219 6 L 19 6 L 19 5 L 13.949219 5 C 13.719022 3.8559279 12.713861 3 11.5 3 z M 5.5 14 C 4.1149999 14 3 15.115 3 16.5 C 3 17.885 4.1149999 19 5.5 19 C 6.7138604 19 7.7190223 18.144072 7.9492188 17 L 19 17 L 19 16 L 7.9492188 16 C 7.7190223 14.855928 6.7138604 14 5.5 14 z M 5.5 15 C 6.3310001 15 7 15.669 7 16.5 C 7 17.331 6.3310001 18 5.5 18 C 4.6689999 18 4 17.331 4 16.5 C 4 15.669 4.6689999 15 5.5 15 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 904 B |
9
res/icons/data-error.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-NegativeText {
|
||||
color:#da4453;
|
||||
}
|
||||
</style>
|
||||
<rect class="ColorScheme-NegativeText" x="3" y="3" width="16" height="16" rx="2" fill="currentColor"/>
|
||||
<path d="M 6.414,5 5,6.414 9.586,11 5,15.586 6.414,17 11,12.414 15.586,17 17,15.586 12.414,11 17,6.414 15.586,5 11,9.586 Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
9
res/icons/data-information.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Highlight {
|
||||
color:#3daee9;
|
||||
}
|
||||
</style>
|
||||
<rect class="ColorScheme-Highlight" x="3" y="3" width="16" height="16" rx="2" fill="currentColor"/>
|
||||
<path d="m10 6v2h2v-2zm0 4v6h2v-6z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
9
res/icons/data-warning.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-NeutralText {
|
||||
color:#f67400;
|
||||
}
|
||||
</style>
|
||||
<path class="ColorScheme-NeutralText" d="m11.006318 3.0000261a0.72728737 0.72727154 0 0 0-0.65674 0.4021811l-7.2728738 14.545431a0.72728737 0.72727154 0 0 0 0.6509222 1.052362h14.545748a0.72728737 0.72727154 0 0 0 0.650922-1.052362l-7.272874-14.545431a0.72728737 0.72727154 0 0 0-0.645104-0.4021811z" fill="currentColor"/>
|
||||
<path d="m10 7v6h2v-6zm0 8v2h2v-2z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 602 B |
14
res/icons/description.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 3 3 L 3 5 L 8 5 L 8 3 L 3 3 z M 10 3 L 10 4 L 19 4 L 19 3 L 10 3 z M 5 6 L 5 7 L 19 7 L 19 6 L 5 6 z M 3 9 L 3 11 L 8 11 L 8 9 L 3 9 z M 10 9 L 10 10 L 19 10 L 19 9 L 10 9 z M 5 12 L 5 13 L 19 13 L 19 12 L 5 12 z M 5 15 L 5 16 L 19 16 L 19 15 L 5 15 z M 17 18 L 17 19 L 19 19 L 19 18 L 17 18 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 636 B |
14
res/icons/dialog-cancel.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
transform="translate(-1-1)" d="m12 4c-2.027598 0-3.87132.756694-5.28125 2-.126239.11132-.25603.22478-.375.34375l-.34375.375c-1.243306 1.40993-2 3.253652-2 5.28125 0 4.41828 3.58172 8 8 8 2.027598 0 3.87132-.756694 5.28125-2l.375-.34375c.11897-.11897.23243-.248761.34375-.375 1.243306-1.40993 2-3.253652 2-5.28125 0-4.41828-3.58172-8-8-8m0 1c3.86599 0 7 3.13401 7 7 0 1.75366-.653215 3.334268-1.71875 4.5625l-9.84375-9.84375c1.228231-1.065535 2.80884-1.71875 4.5625-1.71875m-5.28125 2.4375l9.84375 9.84375c-1.228232 1.065535-2.80884 1.71875-4.5625 1.71875-3.86599 0-7-3.13401-7-7 0-1.75366.653215-3.334269 1.71875-4.5625"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 962 B |
14
res/icons/dialog-ok.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m382.8643 530.79077l-10.43876 10.56644-4.14699-4.19772-.70712.71578 4.14699 4.1977-.002.002.70713.71577.002-.002.002.002.70711-.71577-.002-.002 10.43877-10.56645-.70712-.71576z" transform="translate(-364.57143-525.79075)"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 567 B |
14
res/icons/document-edit.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m14.996094 3l-11.992188 11.992188h-.003906v4.00781h1 2 1.00781v-.003906l11.992188-11.992188-.001953-.001953.001953-.001953-4-4-.001953.001953-.001953-.001953m-1.998047 3.412109l2.589844 2.589844-7.587891 7.587891v-1.589844h-1-1v-1-.589844l6.998047-6.998047m-7.998047 7.998047v1.589844h1 1v1 .589844l-.410156.410156h-1.589844l-1-1v-1.589844l1-1"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 695 B |
11
res/icons/document-preview.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE svg>
|
||||
<svg viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="ColorScheme-Text" style="fill:currentColor; fill-opacity:1; stroke:none" d="M 4 3 L 4 19 L 15 19 L 15 18 L 5 18 L 5 4 L 13 4 L 13 8 L 17 8 L 17 16 L 18 16 L 18 7 L 14 3 L 4 3 Z M 13 11 C 11.338 11 10 12.338 10 14 C 10 15.662 11.338 17 13 17 C 13.6494 17 14.2464 16.7914 14.7363 16.4434 L 17.293 19 L 18 18.293 L 15.4434 15.7363 C 15.7914 15.2464 16 14.6494 16 14 C 16 12.338 14.662 11 13 11 Z M 13 12 C 14.108 12 15 12.892 15 14 C 15 15.108 14.108 16 13 16 C 11.892 16 11 15.108 11 14 C 11 12.892 11.892 12 13 12 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
14
res/icons/document-properties.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m3 3v16h1 15v-1-15h-1-14-1m1 4h14v11h-14v-11m1 1v9h5v-9h-5m6 1v1h6v-1h-6m0 3v1h4v-1h-4m0 3v1h2v-1h-2"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
14
res/icons/document-save.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 3 2.9980469 L 3 3 L 3 4 L 3 19 L 4 19 L 19 19 L 19 18 L 19 7 L 19 6.3007812 L 18.992188 6.3007812 L 19 6.2910156 L 15.707031 2.9980469 L 15.699219 3.0078125 L 15.699219 2.9980469 L 15 2.9980469 L 3 2.9980469 z M 4 4 L 7 4 L 7 8 L 7 9 L 15 9 L 15 8 L 15 4 L 15.292969 4 L 18 6.7070312 L 18 7 L 18 18 L 16 18 L 16 11 L 15 11 L 7 11 L 6 11 L 6 18 L 4 18 L 4 4 z M 8 4 L 11.900391 4 L 11.900391 8 L 8 8 L 8 4 z M 7 12 L 15 12 L 15 18 L 7 18 L 7 12 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
14
res/icons/document-share.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M16 3A2.00001 2 0 0 0 14 5 2.00001 2 0 0 0 16 7 2.00001 2 0 0 0 18 5 2.00001 2 0 0 0 16 3M11.5 7A.5 .5 0 0 0 11 7.5 .5 .5 0 0 0 11.5 8 .5 .5 0 0 0 12 7.5 .5 .5 0 0 0 11.5 7M9.5 8A.5 .499991 0 0 0 9 8.5 .5 .499991 0 0 0 9.5 9 .5 .499991 0 0 0 10 8.5 .5 .499991 0 0 0 9.5 8M6 9A2 2.00001 0 0 0 4 11 2 2.00001 0 0 0 6 13 2 2.00001 0 0 0 8 11 2 2.00001 0 0 0 6 9M9.5 13A.500009 .5 0 0 0 9 13.5 .500009 .5 0 0 0 9.5 14 .500009 .5 0 0 0 10 13.5 .500009 .5 0 0 0 9.5 13M11.5 14A.5 .5 0 0 0 11 14.5 .5 .5 0 0 0 11.5 15 .5 .5 0 0 0 12 14.5 .5 .5 0 0 0 11.5 14M16 15A2.00001 2 0 0 0 14 17 2.00001 2 0 0 0 16 19 2.00001 2 0 0 0 18 17 2.00001 2 0 0 0 16 15"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 993 B |
14
res/icons/download.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 8 3 L 8 9 L 9 9 L 9 4 L 13 4 L 13 9 L 14 9 L 14 3 L 13 3 L 9 3 L 8 3 z M 5.7929688 10 L 5 10.816406 L 11 17 L 17 10.816406 L 16.207031 10 L 11 15.367188 L 5.7929688 10 z M 4 17 L 4 19 L 5 19 L 17 19 L 18 19 L 18 17 L 17 17 L 17 18 L 5 18 L 5 17 L 4 17 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
14
res/icons/draw-brush.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M19.513672 4.00781A2 9 45 0 0 12.06836 9.105469 2 9 45 0 0 8.525391 13.21875c1.07606.34264 1.915172 1.181743 2.257812 2.257812A2 9 45 0 0 14.896484 11.933594 2 9 45 0 0 19.845703 4.15625 2 9 45 0 0 19.513672 4.00781M8 14.664062C3.99999 15.735863 7 18.26795 4 20c4.00339 0 6-2.00082 6-3.335938 0-.66488.0676-1.88944-2-2z" transform="translate(-.99999-.99999)"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 707 B |
9
res/icons/draw-donut.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}</style>
|
||||
</defs>
|
||||
<path class="ColorScheme-Text" d="m11 3c-4.432 0-8 3.568-8 8s3.568 8 8 8 8-3.568 8-8-3.568-8-8-8m0 1c3.878 0 7 3.122 7 7s-3.122 7-7 7-7-3.122-7-7 3.122-7 7-7m0 4a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3m0 1a2 2 0 0 1 2 2 2 2 0 0 1-2 2 2 2 0 0 1-2-2 2 2 0 0 1 2-2" fill="currentColor"/>
|
||||
<path d="m10.4001 18.729319c-1.8088243-0.184648-3.4219302-0.939811-4.6781255-2.190027-1.0569773-1.051949-1.7758787-2.354791-2.1191545-3.840477-0.1328775-0.575094-0.1882528-2.048606-0.1004652-2.673459 0.4524189-3.2203656 2.804316-5.7657685 5.9906921-6.4835852 0.7394791-0.1665873 2.0107771-0.191715 2.7989201-0.0553184 2.771679 0.4796625 5.035602 2.4074378 5.954313 5.0702107 1.469833 4.2601289-1.031941 8.8932879-5.384164 9.9712129-0.798339 0.197726-1.742032 0.274939-2.462012 0.201444zm3.333948-7.816054c-0.842155-1.8969241-1.75986-3.2733857-2.848132-2.5541034-1.6444159 1.0868599-2.2759172 1.2584314-2.1975416 2.9941244 0.058357 1.292463 0.8438046 1.803392 2.2762786 2.213226 1.4061-0.568649 2.101043-1.336538 2.769395-2.653247z" class="ColorScheme-Text" fill="currentColor" fill-opacity=".50196078"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
11
res/icons/edit-copy.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE svg>
|
||||
<svg viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="ColorScheme-Text" style="fill:currentColor; fill-opacity:1; stroke:none" d="M 3 3 L 3 17 L 7 17 L 7 19 L 17 19 L 17 10 L 13 6 L 12 6 L 9 3 L 3 3 Z M 4 4 L 8 4 L 8 6 L 7 6 L 7 16 L 4 16 L 4 4 Z M 8 7 L 12 7 L 12 11 L 16 11 L 16 18 L 8 18 L 8 7 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 537 B |
14
res/icons/edit-find.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 9 3 C 5.6759952 3 3 5.6759952 3 9 C 3 12.324005 5.6759952 15 9 15 C 10.481205 15 11.830584 14.465318 12.875 13.582031 L 18.292969 19 L 19 18.292969 L 13.582031 12.875 C 14.465318 11.830584 15 10.481205 15 9 C 15 5.6759952 12.324005 3 9 3 z M 9 4 C 11.770005 4 14 6.2299952 14 9 C 14 11.770005 11.770005 14 9 14 C 6.2299952 14 4 11.770005 4 9 C 4 6.2299952 6.2299952 4 9 4 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
14
res/icons/edit-undo.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m8.300781 3l-3.292969 3.292969-.207031.207031.207031.207031 3.292969 3.292969.707031-.707031-2.292969-2.292969h2.285156 1.00781.492188c3.047 0 5.5 2.453 5.5 5.5 0 3.047-2.453 5.5-5.5 5.5h-1.5v1h1.5c3.601 0 6.5-2.899 6.5-6.5 0-3.601-2.899-6.5-6.5-6.5h-.492188-1.00781-2.285156l2.292969-2.292969-.707031-.707031"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
14
res/icons/exception.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m12 3l-7 10h5v6l7-9h-5zm-1 3.302734v4.697266h4l-4 4.697266v-3.697266h-4"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
13
res/icons/folder-temp.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor"
|
||||
d="m 11,3 c -4.4,0 -8,3.6 -8,8 0,4.4 3.6,8 8,8 4.4,0 8,-3.6 8,-8 0,-4.4 -3.6,-8 -8,-8 z m 0,1 c 3.9,0 7,3.1 7,7 0,3.9 -3.1,7 -7,7 C 7.1,18 4,14.9 4,11 4,7.1 7.1,4 11,4 Z m -1,1 0,7 1,0 5,0 0,-1 -5,0 0,-6 -1,0 z"
|
||||
class="ColorScheme-Text" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 523 B |
14
res/icons/folder-txt.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor"
|
||||
d="M 3 3 L 3 4 L 11 4 L 11 3 L 3 3 z M 3 7 L 3 8 L 19 8 L 19 7 L 3 7 z M 3 11 L 3 12 L 11 12 L 11 11 L 3 11 z M 3 14 L 3 15 L 19 15 L 19 14 L 3 14 z M 3 18 L 3 19 L 15 19 L 15 18 L 3 18 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
14
res/icons/go-down.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 502 B |
14
res/icons/go-previous.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m14.292969 3l-6.125 6.125-1.875 1.875 1.875 1.875 6.125 6.125.707031-.707031-6.125-6.125-1.167969-1.167969 1.167969-1.167969 6.125-6.125-.707031-.707031"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
10
res/icons/go-up.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style
|
||||
type="text/css"
|
||||
id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path d="M3.707 15L3 14.293l6.125-6.125L11 6.293l1.875 1.875L19 14.293l-.707.707-6.125-6.125L11 7.707 9.832 8.875 3.707 15" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
14
res/icons/headphones.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 11 3 A 8 8 0 0 0 3 11 L 3 19 L 4 19 L 4 17 L 6 19 L 7 19 L 7 13 L 6 13 L 4 15 L 4 11 A 7 7 0 0 1 11 4 A 7 7 0 0 1 18 11 L 18 15 L 16 13 L 15 13 L 15 19 L 16 19 L 18 17 L 18 19 L 19 19 L 19 11 A 8 8 0 0 0 11 3 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 555 B |
14
res/icons/help-about.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 11 3 C 6.568 3 3 6.568 3 11 C 3 15.432 6.568 19 11 19 C 15.432 19 19 15.432 19 11 C 19 6.568 15.432 3 11 3 z M 11 4 C 14.878 4 18 7.122 18 11 C 18 14.878 14.878 18 11 18 C 7.122 18 4 14.878 4 11 C 4 7.122 7.122 4 11 4 z M 10 6 L 10 8 L 12 8 L 12 6 L 10 6 z M 10 9 L 10 16 L 12 16 L 12 9 L 10 9 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 640 B |
14
res/icons/hint.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 18.292969 3 L 3 18.292969 L 3.7070312 19 L 19 3.7070312 L 18.292969 3 z M 11 6 A 10 9.9999781 0 0 0 2.2871094 11.119141 C 2.4663699 11.420241 2.7209984 11.668644 3.0273438 11.839844 A 9 8.99998 0 0 1 11 7 A 4 4 0 0 0 7 11 A 4 4 0 0 0 7.3574219 12.642578 L 8.1308594 11.869141 A 3 3 0 0 1 8 11 A 3 3 0 0 1 11 8 A 3 3 0 0 1 11.869141 8.1308594 L 12.640625 7.359375 A 4 4 0 0 0 11.34375 7.0175781 A 9 8.99998 0 0 1 12.796875 7.203125 L 13.640625 6.359375 A 10 9.9999781 0 0 0 11 6 z M 16.404297 7.5957031 L 15.675781 8.3242188 A 9 8.99998 0 0 1 18.974609 11.837891 C 19.282742 11.665091 19.539718 11.415428 19.71875 11.111328 A 10 9.9999781 0 0 0 16.404297 7.5957031 z M 11 9 A 2 2 0 0 0 9 11 L 11 9 z M 14.642578 9.3574219 L 13.869141 10.130859 A 3 3 0 0 1 14 11 A 3 3 0 0 1 11 14 A 3 3 0 0 1 10.130859 13.869141 L 9.3574219 14.642578 A 4 4 0 0 0 11 15 A 4 4 0 0 0 15 11 A 4 4 0 0 0 14.642578 9.3574219 z M 13 11 L 11 13 A 2 2 0 0 0 13 11 z M 1 13 C 0.33333333 19 0.66666667 16 1 13 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
7
res/icons/im-user-away.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<g transform="translate(-421.71429-525.79071)" style="fill:#fdbc4b">
|
||||
<path d="m432.71429 528.79032a3.99999 4.00003 0 0 0 -4 4 3.99999 4.00003 0 0 0 4 4 3.99999 4.00003 0 0 0 4 -4 3.99999 4.00003 0 0 0 -4 -4m0 1a3 3.00003 0 0 1 3 3 3 3.00003 0 0 1 -3 3 3 3.00003 0 0 1 -3 -3 3 3.00003 0 0 1 3 -3"/>
|
||||
<path d="m12 13a7.99995 8.00003 0 0 0 -7.927734 7h1.00781A6.99996 7.00003 0 0 1 12 14a6.99996 7.00003 0 0 1 1 .08008v-1.00977A7.99995 8.00003 0 0 0 12 13" transform="translate(420.71429 524.79071)"/>
|
||||
<path d="m440.71429 536.79071l-8 4 2 1 1 3 1-2 2 1z" style="fill-rule:evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
14
res/icons/list-add.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 10 4 L 10 11 L 3 11 L 3 12 L 10 12 L 10 19 L 11 19 L 11 12 L 18 12 L 18 11 L 11 11 L 11 4 L 10 4 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 441 B |
10
res/icons/list-remove.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path style="fill:currentColor" class="ColorScheme-Text" d="M 3 7 L 3 9 L 13 9 L 13 7 L 3 7 z" transform="translate(3 3)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 355 B |
14
res/icons/media-optical-audio.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 11 3 C 6.568 3 3 6.568 3 11 C 3 15.432 6.568 19 11 19 L 11 18 C 7.122 18 4 14.878 4 11 C 4 7.122 7.122 4 11 4 C 14.878 4 18 7.122 18 11 L 19 11 C 19 6.568 15.432 3 11 3 z M 11 5 C 7.676 5 5 7.676 5 11 L 8 11 C 8 9.338 9.338 8 11 8 L 11 5 z M 11 9 C 9.892 9 9 9.892 9 11 C 9 12.108 9.892 13 11 13 L 11 12 C 10.446 12 10 11.554 10 11 C 10 10.446 10.446 10 11 10 C 11.554 10 12 10.446 12 11 L 13 11 C 13 9.892 12.108 9 11 9 z M 15 10 L 15 15.277344 C 14.704935 15.106143 14.367047 15 14 15 C 12.892 15 12 15.892 12 17 C 12 18.108 12.892 19 14 19 C 15.108 19 16 18.108 16 17 L 16 12 L 16 11 L 17 11 L 16 10 L 15 10 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 955 B |
8
res/icons/media-playback-pause.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path d="m3 3v16h6v-16zm10 0v16h6v-16z" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 291 B |
8
res/icons/media-playback-start.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path d="m3 3v16l16-8z" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 275 B |
14
res/icons/media-playlist-append.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 4.5,3 C 3.8,3 3,3.7 3,4.5 3,5.3 3.8,6 4.5,6 5.33,6 6,5.3 6,4.5 6,3.7 5.33,3 4.5,3 Z M 8,3 V 4 H 19 V 3 Z m 0,2 v 1 h 4 V 5 Z M 4.5,9 C 3.732,9 3,9.71 3,10.5 3,11.3 3.732,12 4.5,12 5.3,12 6,11.3 6,10.5 6,9.71 5.3,9 4.5,9 Z M 8,9 v 1 H 19 V 9 Z m 7,3 v 3 h -3 v 1 h 3 v 3 h 1 v -3 h 3 V 15 H 16 V 12 Z M 4.5,15 C 3.732,15 3,15.7 3,16.5 3,17.3 3.732,18 4.5,18 5.3,18 6,17.3 6,16.5 6,15.7 5.3,15 4.5,15 Z"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 739 B |
12
res/icons/media-playlist-repeat-song.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<g class="ColorScheme-Text" fill="currentColor">
|
||||
<path d="m6 12-3 2.5 3 2.5v-2h9v-1h-9z"/>
|
||||
<path d="m16 5v2h-10c-1.662 0-3 1.338-3 3v1h1v-1c0-1.108.892-2 2-2h10v2l3-2.5z"/>
|
||||
<path d="m17.29296875 11-1.5 1.5.70703125.70703125.5-.5v3.29296875h-1v1h3v-1h-1v-5z" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 B |
10
res/icons/media-playlist-repeat.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style id="current-color-scheme" type="text/css">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="ColorScheme-Text" d="m16 5v2h-10c-1.662 0-3 1.338-3 3v1h1v-1c0-1.108 0.892-2 2-2h10v2l3-2.5zm2 6v1c0 1.108-0.892 2-2 2h-10v-2l-3 2.5 3 2.5v-2h10c1.662 0 3-1.338 3-3v-1z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 462 B |
14
res/icons/media-playlist-shuffle.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 16 5 L 16 7 L 13 7 C 10.784 7 9 8.784 9 11 C 9 12.662 7.662 14 6 14 L 3 14 L 3 15 L 6 15 C 8.216 15 10 13.216 10 11 C 10 9.338 11.338 8 13 8 L 16 8 L 16 10 L 19 7.5 L 16 5 z M 3 7 L 3 8 L 6 8 C 6.8536128 8 7.6168144 8.3572581 8.1621094 8.9257812 C 8.2578781 8.5814037 8.3956215 8.2568349 8.5742188 7.9570312 C 7.8779048 7.3680394 6.9875937 7 6 7 L 3 7 z M 16 12 L 16 14 L 13 14 C 12.146387 14 11.383186 13.642742 10.837891 13.074219 C 10.742121 13.418597 10.604379 13.743165 10.425781 14.042969 C 11.122095 14.631961 12.012406 15 13 15 L 16 15 L 16 17 L 19 14.5 L 16 12 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 914 B |
8
res/icons/media-skip-backward.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" id="svg6">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path d="m0 3v16h2v-16zm2 8 10 8v-16zm10 0 10 8v-16z" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 315 B |
8
res/icons/media-skip-forward.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22" id="svg6">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path d="m0 3v16l10-8zm10 8v8l10-8-10-8zm10 0v8h2v-16h-2z" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 320 B |
22
res/icons/media-track-show-active.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
.ColorScheme-Highlight {
|
||||
color:#3daee9;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 4.5,3 C 3.787,3 3,3.704 3,4.5 3,5.3 3.787,6 4.5,6 5.33,6 6,5.3 6,4.5 6,3.704 5.33,3 4.5,3 Z M 8,3 V 4 H 19 V 3 Z m 0,2 v 1 h 4 V 5 Z M 4.5,9 C 3.732,9 3,9.71 3,10.5 3,11.3 3.732,12 4.5,12 5.3,12 6,11.3 6,10.5 6,9.71 5.3,9 4.5,9 Z M 8,9 v 1 h 6 V 9 Z m 0,2 v 1 h 5 V 11 Z M 4.5,15 C 3.732,15 3,15.7 3,16.5 3,17.3 3.732,18 4.5,18 5.3,18 6,17.3 6,16.5 6,15.7 5.3,15 4.5,15 Z M 8,15 v 1 h 9 v -1 z m 0,2 v 1 h 7 v -1 z"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 19 8 L 15 11 L 19 14 L 19 8 z "
|
||||
class="ColorScheme-Highlight"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 972 B |
10
res/icons/non-starred-symbolic.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<style
|
||||
type="text/css"
|
||||
id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path style="fill:currentColor;stroke:none" class="ColorScheme-Text" d="M8 .188l-2.473 5.01L0 6l4 3.898-.943 5.506L8 12.804l4.943 2.598L12 9.898 16 6l-5.527-.803zm0 2.261l1.809 3.662 4.043.588-2.926 2.852.69 4.025L8 11.676l-3.615 1.9.69-4.025-2.927-2.852 4.043-.588z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 490 B |
14
res/icons/speaker.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 11 3 C 9.0836958 3 7.3317076 3.6696447 5.9570312 4.7851562 C 5.8567573 4.3384591 5.477825 4 5 4 C 4.446 4 4 4.446 4 5 C 4 5.477825 4.3384591 5.8567573 4.7851562 5.9570312 C 3.6696447 7.3317076 3 9.0836958 3 11 C 3 12.916304 3.6696447 14.668292 4.7851562 16.042969 C 4.3384591 16.143243 4 16.522175 4 17 C 4 17.554 4.446 18 5 18 C 5.477825 18 5.8567573 17.661541 5.9570312 17.214844 C 7.3317076 18.330355 9.0836958 19 11 19 C 12.916304 19 14.668292 18.330355 16.042969 17.214844 C 16.143243 17.661541 16.522175 18 17 18 C 17.554 18 18 17.554 18 17 C 18 16.522175 17.661541 16.143243 17.214844 16.042969 C 18.330355 14.668292 19 12.916304 19 11 C 19 9.0836958 18.330355 7.3317076 17.214844 5.9570312 C 17.661541 5.8567573 18 5.477825 18 5 C 18 4.446 17.554 4 17 4 C 16.522175 4 16.143243 4.3384591 16.042969 4.7851562 C 14.668292 3.6696447 12.916304 3 11 3 z M 10 4 L 10.046875 4.0722656 C 10.359458 4.0299165 10.675439 4 11 4 C 14.878002 4 18 7.1219982 18 11 C 18 14.878002 14.878002 18 11 18 C 7.1219982 18 4 14.878002 4 11 C 4 7.5163997 6.5222216 4.6500238 9.8457031 4.1015625 L 10 4 z M 11 9 C 9.892 9 9 9.892 9 11 C 9 12.108 9.892 13 11 13 C 12.108 13 13 12.108 13 11 C 13 9.892 12.108 9 11 9 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
10
res/icons/starred-symbolic.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<style
|
||||
type="text/css"
|
||||
id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path style="fill:currentColor;fill-opacity:1;stroke:none" class="ColorScheme-Text" d="M8 .188l2.472 5.009L16 6l-4 3.899.944 5.506L8 12.805l-4.944 2.6L4 9.899 0 6l5.528-.803z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 398 B |
14
res/icons/view-calendar.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 6 3 C 6 3 5 2.99997 5 4 L 3 4 L 3 7 L 3 18 L 3 19 L 19 19 L 19 18 L 19 7 L 19 4 L 17 4 C 17 2.99997 16 3 16 3 L 13 3 C 13 3 12 2.99997 12 4 L 10 4 C 10 2.99997 9 3 9 3 L 6 3 z M 6 4 L 9 4 L 9 5 L 6 5 L 6 4 z M 13 4 L 16 4 L 16 5 L 13 5 L 13 4 z M 4 7 L 18 7 L 18 18 L 4 18 L 4 7 z M 6 8 L 6 10 L 8 10 L 8 8 L 6 8 z M 10 8 L 10 10 L 12 10 L 12 8 L 10 8 z M 14 8 L 14 10 L 16 10 L 16 8 L 14 8 z M 6 11 L 6 13 L 8 13 L 8 11 L 6 11 z M 10 11 L 10 13 L 12 13 L 12 11 L 10 11 z M 14 11 L 14 13 L 16 13 L 16 11 L 14 11 z M 14 14 L 14 16 L 16 16 L 16 14 L 14 14 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 895 B |
14
res/icons/view-media-album-cover.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 2 3 L 2 19 L 3 19 L 20 19 L 20 3 L 3 3 L 2 3 z M 3 4 L 4 4 L 4 18 L 3 18 L 3 4 z M 5 4 L 6 4 A 1 1 0 0 0 7 5 A 1 1 0 0 0 8 4 L 12 4 L 16 4 A 1 1 0 0 0 17 5 A 1 1 0 0 0 18 4 L 19 4 L 19 11 L 19 18 L 18 18 A 1 1 0 0 0 17 17 A 1 1 0 0 0 16 18 L 12 18 L 8 18 A 1 1 0 0 0 7 17 A 1 1 0 0 0 6 18 L 5 18 L 5 11 L 5 4 z M 5 11 A 7 7 0 0 0 12 18 A 7 7 0 0 0 19 11 A 7 7 0 0 0 12 4 A 7 7 0 0 0 5 11 z M 12 5 A 6 6 0 0 1 18 11 A 6 6 0 0 1 12 17 A 6 6 0 0 1 6 11 A 6 6 0 0 1 12 5 z M 12 10 A 1 1 0 0 0 11 11 A 1 1 0 0 0 12 12 A 1 1 0 0 0 13 11 A 1 1 0 0 0 12 10 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 895 B |
14
res/icons/view-media-artist.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 11 3 A 3.9999902 4.0000296 0 0 0 7 7 A 3.9999902 4.0000296 0 0 0 11 11 A 3.9999902 4.0000296 0 0 0 15 7 A 3.9999902 4.0000296 0 0 0 11 3 z M 11 4 A 3 3.0000296 0 0 1 14 7 A 3 3.0000296 0 0 1 11 10 A 3 3.0000296 0 0 1 8 7 A 3 3.0000296 0 0 1 11 4 z M 11 12 A 7.9999504 8.0000296 0 0 0 3.0722656 19 L 4.0800781 19 A 6.9999604 7.0000296 0 0 1 11 13 A 6.9999604 7.0000296 0 0 1 17.921875 19 L 18.929688 19 A 7.9999504 8.0000296 0 0 0 11 12 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 782 B |
17
res/icons/view-media-lyrics.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
.ColorScheme-Highlight {
|
||||
color:#3daee9;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 3 3 L 3 4 L 19 4 L 19 3 L 3 3 z M 3 6 L 3 7 L 12 7 L 12 6 L 3 6 z M 16 8 L 16 9 L 16 14.5 C 15.5818 14.1852 15.066 14 14.5 14 C 13.115 14 12 15.115 12 16.5 C 12 17.885 13.115 19 14.5 19 C 15.885 19 17 17.885 17 16.5 L 17 10 L 19 10 C 19 8.892 18.108 8 17 8 L 16 8 z M 3 9 L 3 10 L 14 10 L 14 9 L 3 9 z M 3 12 L 3 13 L 12 13 L 12 12 L 3 12 z M 3 15 L 3 16 L 10 16 L 10 15 L 3 15 z M 3 18 L 3 19 L 10 19 L 10 18 L 3 18 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 825 B |
14
res/icons/view-media-playlist.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 4.5 3 C 3.8 3 3 3.7 3 4.5 C 3 5.3 3.8 6 4.5 6 C 5.33 6 6 5.3 6 4.5 C 6 3.7 5.33 3 4.5 3 z M 8 3 L 8 4 L 19 4 L 19 3 L 8 3 z M 8 5 L 8 6 L 12 6 L 12 5 L 8 5 z M 4.5 9 C 3.7 9 3 9.71 3 10.5 C 3 11.3 3.7 12 4.5 12 C 5.3 12 6 11.3 6 10.5 C 6 9.71 5.3 9 4.5 9 z M 8 9 L 8 10 L 15 10 L 15 9 L 8 9 z M 17 10 L 17 15.3 C 16.7 15.1 16.4 15 16 15 C 14.9 15 14 15.9 14 17 C 14 18.1 14.9 19 16 19 C 17.1 19 18 18.1 18 17 L 18 11 L 19 11 L 18 10 L 17 10 z M 8 11 L 8 12 L 13 12 L 13 11 L 8 11 z M 4.5 15 C 3.7 15 3 15.7 3 16.5 C 3 17.3 3.7 18 4.5 18 C 5.3 18 6 17.3 6 16.5 C 6 15.7 5.3 15 4.5 15 z M 8 15 L 8 16 L 13 16 L 13 15 L 8 15 z M 8 17 L 8 18 L 12 18 L 12 17 L 8 17 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1002 B |
14
res/icons/view-media-track.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m333 537.3622v1 10.50391a2.5 2.5 0 0 0 -1.5 -.50391 2.5 2.5 0 0 0 -2.5 2.5 2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5 -2.5v-9.5h10v5.50391a2.5 2.5 0 0 0 -1.5 -.50391 2.5 2.5 0 0 0 -2.5 2.5 2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5 -2.5v-10.5-.5-.5h-11-1m1 1h10v2h-10v-2" transform="translate(-326-534.3622)"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 655 B |
8
res/icons/view-refresh.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style type="text/css" id="current-color-scheme">.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}</style>
|
||||
</defs>
|
||||
<path class="ColorScheme-Text" fill="currentColor" d="m 19,11 c 0,1.441714 -0.382922,2.789289 -1.044922,3.955078 l -0.738281,-0.738281 c 0,0 0.002,-0.002 0.002,-0.002 l -2.777341,-2.77925 0.707032,-0.707031 2.480468,2.482422 C 17.861583,12.515315 18,11.776088 18,11 18,7.12203 14.878,4 11,4 9.8375,4 8.746103,4.285828 7.783203,4.783203 L 7.044922,4.044922 C 8.210722,3.382871 9.5583,3 11,3 c 4.432,0 8,3.568034 8,8 z m -4.044922,6.955078 C 13.789278,18.617129 12.4417,19 11,19 6.568,19 3,15.431966 3,11 3,9.558286 3.382922,8.210711 4.044922,7.044922 l 0.683594,0.683594 0.002,-0.00195 2.828125,2.828126 L 6.851609,11.261723 4.373094,8.783207 C 4.139126,9.480503 4,10.221736 4,11 c 0,3.87797 3.122,7 7,7 1.1625,0 2.253897,-0.285829 3.216797,-0.783203 z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 980 B |
12
res/icons/view-sort-ascending.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg id="svg19" version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style id="current-color-scheme" type="text/css">.ColorScheme-Text{color:#eff0f1;}</style>
|
||||
<g id="g17" class="ColorScheme-Text" transform="matrix(3.7795305,0,0,3.7795305,-2.4855562e-6,-1100.5205)" fill="currentColor">
|
||||
<path id="path3" d="m3.96875 294.35415v-1.87482l-0.6066814 0.60668-0.1870686-0.18707 0.9260416-0.92604 0.9260417 0.92604-0.1870686 0.18707-0.6066814-0.60668v1.87482z"/>
|
||||
<path id="path5" d="m0.79374999 296.20623v-0.26458h3.7041667v0.26458z"/>
|
||||
<path id="path7" d="m0.79374999 295.41248v-0.26458h3.175v0.26458z"/>
|
||||
<path id="path9" d="m0.79374999 294.61873v-0.26458h2.6458333v0.26458z"/>
|
||||
<path id="path11" d="m0.79375004 291.9729v0.26458h1.0583333v-0.26458z"/>
|
||||
<path id="path13" d="m0.79375 292.76665v0.26458h1.5875v-0.26458z"/>
|
||||
<path id="path15" d="m0.79375001 293.5604v0.26458h2.1166667v-0.26458z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 982 B |
14
res/icons/view-statistics.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 15 3 L 15 19 L 19 19 L 19 3 L 15 3 z M 9 10 L 9 19 L 13 19 L 13 10 L 9 10 z M 3 14 L 3 19 L 7 19 L 7 14 L 3 14 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
14
res/icons/visibility.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 11 6 A 10 9.999975 0 0 0 2.3144531 11.070312 C 2.4995621 11.361743 2.7600802 11.597238 3.0664062 11.759766 A 9 8.999978 0 0 1 11 7 A 4 4 0 0 0 7 11 A 4 4 0 0 0 11 15 A 4 4 0 0 0 15 11 A 4 4 0 0 0 11.34375 7.0175781 A 9 8.999978 0 0 1 18.931641 11.761719 C 19.241063 11.598077 19.503624 11.359298 19.689453 11.064453 A 10 9.999975 0 0 0 11 6 z M 11 8 A 3 3 0 0 1 14 11 A 3 3 0 0 1 11 14 A 3 3 0 0 1 8 11 A 3 3 0 0 1 11 8 z M 11 9 C 9.892 9 9 9.892 9 11 C 9 12.108 9.892 13 11 13 C 12.108 13 13 12.108 13 11 C 13 10.79519 12.960983 10.601795 12.904297 10.416016 C 12.746415 10.759733 12.404317 11 12 11 C 11.446 11 11 10.554 11 10 C 11 9.595683 11.240267 9.2535881 11.583984 9.0957031 C 11.398205 9.0390231 11.20481 9 11 9 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
10
res/icons/window-close.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style
|
||||
type="text/css"
|
||||
id="current-color-scheme">
|
||||
.ColorScheme-NegativeText {
|
||||
color:#da4453;
|
||||
}
|
||||
</style>
|
||||
<path d="M11 3a8 8 0 0 0-8 8 8 8 0 0 0 8 8 8 8 0 0 0 8-8 8 8 0 0 0-8-8M7.707 7L11 10.294l3.293-3.293.707.707-3.293 3.293L15 14.294l-.707.707L11 11.708l-3.293 3.293L7 14.294l3.293-3.293L7 7.708l.707-.707" class="ColorScheme-NegativeText" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
10
res/icons/window-maximize.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style
|
||||
type="text/css"
|
||||
id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path d="M3.707 15L3 14.293l6.125-6.125L11 6.293l1.875 1.875L19 14.293l-.707.707-6.125-6.125L11 7.707 9.832 8.875 3.707 15" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
14
res/icons/window-minimize.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125"
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 502 B |
10
res/icons/window-restore.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg height="22" width="22" xmlns="http://www.w3.org/2000/svg">
|
||||
<style
|
||||
type="text/css"
|
||||
id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
<path d="M11 3.293L3.293 11 11 18.707 18.707 11zm0 1.414L17.293 11 11 17.293 4.707 11z" class="ColorScheme-Text" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
14
res/icons/window.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#eff0f1;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 3 3 L 3 7 L 3 18 L 3 19 L 4 19 L 13 19 L 18 19 L 19 19 L 19 18 L 19 13 L 19 7 L 19 3 L 3 3 z M 4 7 L 18 7 L 18 13 L 18 18 L 13 18 L 4 18 L 4 7 z "
|
||||
class="ColorScheme-Text"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -6,6 +6,9 @@ target_sources(qobuz-qt PRIVATE
|
||||
mainwindow.hpp
|
||||
mainwindow.cpp
|
||||
|
||||
# Queue (header-only)
|
||||
playqueue.hpp
|
||||
|
||||
# Backend (Qt wrapper around Rust FFI)
|
||||
backend/qobuzbackend.hpp
|
||||
backend/qobuzbackend.cpp
|
||||
@@ -15,6 +18,10 @@ target_sources(qobuz-qt PRIVATE
|
||||
view/maintoolbar.cpp
|
||||
view/maincontent.hpp
|
||||
view/maincontent.cpp
|
||||
view/context/view.hpp
|
||||
view/context/view.cpp
|
||||
view/queuepanel.hpp
|
||||
view/queuepanel.cpp
|
||||
view/sidepanel/view.hpp
|
||||
view/sidepanel/view.cpp
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
#include <QHeaderView>
|
||||
#include <QMenu>
|
||||
#include <QAction>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace List
|
||||
{
|
||||
|
||||
Tracks::Tracks(QobuzBackend *backend, QWidget *parent)
|
||||
Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
|
||||
: QTreeView(parent)
|
||||
, m_backend(backend)
|
||||
, m_queue(queue)
|
||||
{
|
||||
m_model = new TrackListModel(this);
|
||||
setModel(m_model);
|
||||
@@ -22,6 +21,7 @@ Tracks::Tracks(QobuzBackend *backend, QWidget *parent)
|
||||
setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
setSortingEnabled(true);
|
||||
setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
sortByColumn(TrackListModel::ColNumber, Qt::AscendingOrder);
|
||||
|
||||
header()->setStretchLastSection(false);
|
||||
header()->setSectionResizeMode(TrackListModel::ColTitle, QHeaderView::Stretch);
|
||||
@@ -30,46 +30,65 @@ Tracks::Tracks(QobuzBackend *backend, QWidget *parent)
|
||||
header()->setSectionResizeMode(TrackListModel::ColNumber, QHeaderView::ResizeToContents);
|
||||
header()->setSectionResizeMode(TrackListModel::ColDuration, QHeaderView::ResizeToContents);
|
||||
|
||||
connect(this, &QTreeView::doubleClicked, this, &Tracks::onDoubleClicked);
|
||||
connect(this, &QTreeView::customContextMenuRequested, this, &Tracks::onContextMenu);
|
||||
connect(this, &QTreeView::doubleClicked,
|
||||
this, &Tracks::onDoubleClicked);
|
||||
connect(this, &QTreeView::customContextMenuRequested,
|
||||
this, &Tracks::onContextMenu);
|
||||
|
||||
// Re-sync queue order whenever the model is re-sorted (user clicked header
|
||||
// or setTracks re-applied an existing sort).
|
||||
connect(m_model, &TrackListModel::sortApplied,
|
||||
this, &Tracks::syncQueueToModel);
|
||||
}
|
||||
|
||||
void Tracks::loadTracks(const QJsonArray &tracks)
|
||||
{
|
||||
m_model->setTracks(tracks);
|
||||
m_model->setTracks(tracks, false, /*useSequential=*/true);
|
||||
m_queue->setContext(tracks, 0);
|
||||
syncQueueToModel();
|
||||
}
|
||||
|
||||
void Tracks::loadAlbum(const QJsonObject &album)
|
||||
{
|
||||
const QJsonObject tracksWrapper = album["tracks"].toObject();
|
||||
const QJsonArray items = tracksWrapper["items"].toArray();
|
||||
m_model->setTracks(items);
|
||||
const QJsonArray items = album["tracks"].toObject()["items"].toArray();
|
||||
m_model->setTracks(items); // album: use track_number
|
||||
m_queue->setContext(items, 0);
|
||||
syncQueueToModel();
|
||||
}
|
||||
|
||||
void Tracks::loadPlaylist(const QJsonObject &playlist)
|
||||
{
|
||||
const QJsonObject tracksWrapper = playlist["tracks"].toObject();
|
||||
const QJsonArray items = tracksWrapper["items"].toArray();
|
||||
m_model->setTracks(items);
|
||||
const QJsonArray items = playlist["tracks"].toObject()["items"].toArray();
|
||||
m_model->setTracks(items, /*usePosition=*/true);
|
||||
m_queue->setContext(items, 0);
|
||||
syncQueueToModel();
|
||||
}
|
||||
|
||||
void Tracks::loadSearchTracks(const QJsonArray &tracks)
|
||||
{
|
||||
m_model->setTracks(tracks);
|
||||
m_model->setTracks(tracks, false, /*useSequential=*/true);
|
||||
m_queue->setContext(tracks, 0);
|
||||
syncQueueToModel();
|
||||
}
|
||||
|
||||
void Tracks::setCurrentTrackId(qint64 id)
|
||||
void Tracks::setPlayingTrackId(qint64 id)
|
||||
{
|
||||
m_currentTrackId = id;
|
||||
// Trigger a repaint for the now-playing indicator
|
||||
viewport()->update();
|
||||
m_playingId = id;
|
||||
m_model->setPlayingId(id);
|
||||
}
|
||||
|
||||
void Tracks::syncQueueToModel()
|
||||
{
|
||||
m_queue->reorderContext(m_model->currentTracksJson(), m_playingId);
|
||||
}
|
||||
|
||||
void Tracks::onDoubleClicked(const QModelIndex &index)
|
||||
{
|
||||
const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
|
||||
if (trackId > 0)
|
||||
emit playTrackRequested(trackId);
|
||||
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
|
||||
if (id > 0) {
|
||||
m_queue->setCurrentById(id);
|
||||
emit playTrackRequested(id);
|
||||
}
|
||||
}
|
||||
|
||||
void Tracks::onContextMenu(const QPoint &pos)
|
||||
@@ -77,33 +96,55 @@ void Tracks::onContextMenu(const QPoint &pos)
|
||||
const QModelIndex index = indexAt(pos);
|
||||
if (!index.isValid()) return;
|
||||
|
||||
const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
|
||||
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
|
||||
const QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject();
|
||||
|
||||
QMenu menu(this);
|
||||
|
||||
QAction *playNow = menu.addAction(tr("Play now"));
|
||||
auto *playNow = menu.addAction(QIcon(":/res/icons/media-playback-start.svg"), tr("Play now"));
|
||||
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
|
||||
auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
|
||||
menu.addSeparator();
|
||||
QAction *addFav = menu.addAction(tr("Add to favorites"));
|
||||
QAction *remFav = menu.addAction(tr("Remove from favorites"));
|
||||
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
|
||||
auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites"));
|
||||
|
||||
connect(playNow, &QAction::triggered, this, [this, trackId] {
|
||||
emit playTrackRequested(trackId);
|
||||
connect(playNow, &QAction::triggered, this, [this, id] {
|
||||
m_queue->setCurrentById(id);
|
||||
emit playTrackRequested(id);
|
||||
});
|
||||
connect(addFav, &QAction::triggered, this, [this, trackId] {
|
||||
m_backend->addFavTrack(trackId);
|
||||
connect(playNext, &QAction::triggered, this, [this, trackJson] {
|
||||
m_queue->playNext(trackJson);
|
||||
});
|
||||
connect(remFav, &QAction::triggered, this, [this, trackId] {
|
||||
m_backend->removeFavTrack(trackId);
|
||||
connect(addQueue, &QAction::triggered, this, [this, trackJson] {
|
||||
m_queue->addToQueue(trackJson);
|
||||
});
|
||||
connect(addFav, &QAction::triggered, this, [this, id] {
|
||||
m_backend->addFavTrack(id);
|
||||
});
|
||||
connect(remFav, &QAction::triggered, this, [this, id] {
|
||||
m_backend->removeFavTrack(id);
|
||||
});
|
||||
|
||||
// Open artist page
|
||||
const QJsonObject performer = trackJson["performer"].toObject();
|
||||
const qint64 artistId = static_cast<qint64>(performer["id"].toDouble());
|
||||
// Open album
|
||||
const QString albumId = m_model->trackAt(index.row()).albumId;
|
||||
if (!albumId.isEmpty()) {
|
||||
menu.addSeparator();
|
||||
auto *openAlbum = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-album-cover.svg"),
|
||||
tr("Open album: %1").arg(m_model->trackAt(index.row()).album));
|
||||
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
|
||||
m_backend->getAlbum(albumId);
|
||||
});
|
||||
}
|
||||
|
||||
// Open artist
|
||||
const qint64 artistId = static_cast<qint64>(
|
||||
trackJson["performer"].toObject()["id"].toDouble());
|
||||
if (artistId > 0) {
|
||||
menu.addSeparator();
|
||||
QAction *openArtist = menu.addAction(
|
||||
tr("Open artist: %1").arg(performer["name"].toString()));
|
||||
const QString artistName = trackJson["performer"].toObject()["name"].toString();
|
||||
auto *openArtist = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-artist.svg"),
|
||||
tr("Open artist: %1").arg(artistName));
|
||||
connect(openArtist, &QAction::triggered, this, [this, artistId] {
|
||||
m_backend->getArtist(artistId);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "../model/tracklistmodel.hpp"
|
||||
#include "../backend/qobuzbackend.hpp"
|
||||
#include "../playqueue.hpp"
|
||||
|
||||
#include <QTreeView>
|
||||
#include <QJsonArray>
|
||||
@@ -9,21 +10,20 @@
|
||||
|
||||
namespace List
|
||||
{
|
||||
/// Track list view — mirrors the spotify-qt List::Tracks widget.
|
||||
/// Displays a flat list of tracks for a playlist, album, search result, etc.
|
||||
class Tracks : public QTreeView
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit Tracks(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
explicit Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
|
||||
|
||||
void loadTracks(const QJsonArray &tracks);
|
||||
void loadAlbum(const QJsonObject &album);
|
||||
void loadPlaylist(const QJsonObject &playlist);
|
||||
void loadSearchTracks(const QJsonArray &tracks);
|
||||
|
||||
void setCurrentTrackId(qint64 id);
|
||||
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
|
||||
void setPlayingTrackId(qint64 id);
|
||||
|
||||
signals:
|
||||
void playTrackRequested(qint64 trackId);
|
||||
@@ -31,10 +31,11 @@ namespace List
|
||||
private:
|
||||
TrackListModel *m_model = nullptr;
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
qint64 m_currentTrackId = 0;
|
||||
PlayQueue *m_queue = nullptr;
|
||||
qint64 m_playingId = 0;
|
||||
|
||||
void onDoubleClicked(const QModelIndex &index);
|
||||
void onContextMenu(const QPoint &pos);
|
||||
void resizeColumnsToContent();
|
||||
void syncQueueToModel();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#include "mainwindow.hpp"
|
||||
|
||||
#include "dialog/login.hpp"
|
||||
#include "dialog/settings.hpp"
|
||||
#include "util/settings.hpp"
|
||||
#include "util/icon.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QMenuBar>
|
||||
@@ -23,12 +23,15 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
setMinimumSize(800, 500);
|
||||
resize(defaultSize());
|
||||
|
||||
// ---- Queue (owned here, shared with toolbar and track list) ----
|
||||
m_queue = new PlayQueue(this);
|
||||
|
||||
// ---- Toolbar ----
|
||||
m_toolBar = new MainToolBar(m_backend, this);
|
||||
m_toolBar = new MainToolBar(m_backend, m_queue, this);
|
||||
addToolBar(Qt::TopToolBarArea, m_toolBar);
|
||||
|
||||
// ---- Central content ----
|
||||
m_content = new MainContent(m_backend, this);
|
||||
m_content = new MainContent(m_backend, m_queue, this);
|
||||
setCentralWidget(m_content);
|
||||
|
||||
// ---- Library dock (left) ----
|
||||
@@ -40,18 +43,24 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
m_libraryDock->setMinimumWidth(200);
|
||||
addDockWidget(Qt::LeftDockWidgetArea, m_libraryDock);
|
||||
|
||||
// ---- Now-playing context dock (left, below library) ----
|
||||
m_contextView = new Context::View(m_backend, this);
|
||||
addDockWidget(Qt::LeftDockWidgetArea, m_contextView);
|
||||
|
||||
// ---- Queue panel (right) ----
|
||||
m_queuePanel = new QueuePanel(m_queue, this);
|
||||
m_queuePanel->hide();
|
||||
addDockWidget(Qt::RightDockWidgetArea, m_queuePanel);
|
||||
|
||||
// ---- Search side panel (right) ----
|
||||
m_sidePanel = new SidePanel::View(m_backend, this);
|
||||
m_sidePanel->hide();
|
||||
addDockWidget(Qt::RightDockWidgetArea, m_sidePanel);
|
||||
|
||||
// ---- Menu bar ----
|
||||
setupMenuBar();
|
||||
|
||||
// ---- Status bar ----
|
||||
statusBar()->showMessage(tr("Ready"));
|
||||
|
||||
// ---- Wire signals ----
|
||||
// ---- Backend signals ----
|
||||
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
|
||||
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
|
||||
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
|
||||
@@ -59,20 +68,21 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded);
|
||||
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
|
||||
connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded);
|
||||
connect(m_backend, &QobuzBackend::trackChanged, this, &MainWindow::onTrackChanged);
|
||||
connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) {
|
||||
statusBar()->showMessage(tr("Error: %1").arg(msg), 5000);
|
||||
statusBar()->showMessage(tr("Error: %1").arg(msg), 6000);
|
||||
});
|
||||
|
||||
// Library → backend
|
||||
connect(m_library, &List::Library::favTracksRequested, m_backend, [this] {
|
||||
// ---- Library → backend ----
|
||||
connect(m_library, &List::Library::favTracksRequested, this, [this] {
|
||||
m_backend->getFavTracks();
|
||||
statusBar()->showMessage(tr("Loading favorite tracks…"));
|
||||
});
|
||||
connect(m_library, &List::Library::favAlbumsRequested, m_backend, [this] {
|
||||
connect(m_library, &List::Library::favAlbumsRequested, this, [this] {
|
||||
m_backend->getFavAlbums();
|
||||
statusBar()->showMessage(tr("Loading favorite albums…"));
|
||||
});
|
||||
connect(m_library, &List::Library::favArtistsRequested, m_backend, [this] {
|
||||
connect(m_library, &List::Library::favArtistsRequested, this, [this] {
|
||||
m_backend->getFavArtists();
|
||||
statusBar()->showMessage(tr("Loading favorite artists…"));
|
||||
});
|
||||
@@ -82,11 +92,11 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
statusBar()->showMessage(tr("Loading playlist: %1…").arg(name));
|
||||
});
|
||||
|
||||
// Track list → playback
|
||||
// ---- Track list → playback ----
|
||||
connect(m_content->tracksList(), &List::Tracks::playTrackRequested,
|
||||
this, &MainWindow::onPlayTrackRequested);
|
||||
|
||||
// Search panel → backend
|
||||
// ---- Search panel ----
|
||||
connect(m_sidePanel, &SidePanel::View::albumSelected,
|
||||
this, &MainWindow::onSearchAlbumSelected);
|
||||
connect(m_sidePanel, &SidePanel::View::artistSelected,
|
||||
@@ -94,32 +104,40 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
connect(m_sidePanel, &SidePanel::View::trackPlayRequested,
|
||||
this, &MainWindow::onPlayTrackRequested);
|
||||
|
||||
// Toolbar search toggle → side panel visibility
|
||||
// ---- Toolbar toggles ----
|
||||
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
|
||||
connect(m_toolBar, &MainToolBar::queueToggled,
|
||||
this, [this](bool v) { m_queuePanel->setVisible(v); });
|
||||
|
||||
// Restore or prompt login
|
||||
tryRestoreSession();
|
||||
}
|
||||
|
||||
void MainWindow::setupMenuBar()
|
||||
{
|
||||
auto *fileMenu = menuBar()->addMenu(tr("&File"));
|
||||
fileMenu->addAction(tr("&Sign in…"), this, &MainWindow::showLoginDialog);
|
||||
fileMenu->addAction(Icon::get("im-user-away"), tr("&Sign in…"),
|
||||
this, &MainWindow::showLoginDialog);
|
||||
fileMenu->addSeparator();
|
||||
fileMenu->addAction(tr("&Settings…"), this, &MainWindow::showSettingsDialog);
|
||||
fileMenu->addAction(Icon::settings(), tr("&Settings…"),
|
||||
this, &MainWindow::showSettingsDialog);
|
||||
fileMenu->addSeparator();
|
||||
fileMenu->addAction(tr("&Quit"), qApp, &QApplication::quit, QKeySequence::Quit);
|
||||
auto *quitAction = fileMenu->addAction(Icon::get("application-exit"), tr("&Quit"),
|
||||
qApp, &QApplication::quit);
|
||||
quitAction->setShortcut(QKeySequence::Quit);
|
||||
|
||||
auto *viewMenu = menuBar()->addMenu(tr("&View"));
|
||||
viewMenu->addAction(m_libraryDock->toggleViewAction());
|
||||
viewMenu->addAction(m_contextView->toggleViewAction());
|
||||
viewMenu->addAction(m_queuePanel->toggleViewAction());
|
||||
viewMenu->addAction(m_sidePanel->toggleViewAction());
|
||||
|
||||
auto *helpMenu = menuBar()->addMenu(tr("&Help"));
|
||||
helpMenu->addAction(tr("&About"), this, [this] {
|
||||
helpMenu->addAction(Icon::get("help-about"), tr("&About"), this, [this] {
|
||||
QMessageBox::about(this, tr("About Qobuz"),
|
||||
tr("<h3>qobuz-qt</h3>"
|
||||
"<p>A lightweight Qt client for the Qobuz streaming service.</p>"
|
||||
"<p>Audio decoding powered by Symphonia (Rust).</p>"));
|
||||
"<p>Audio engine: <b>Symphonia</b> (Rust) via CPAL/ALSA.<br>"
|
||||
"Icons: <b>spotify-qt</b> (dark variant).</p>"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,7 +151,6 @@ void MainWindow::tryRestoreSession()
|
||||
statusBar()->showMessage(tr("Signed in as %1").arg(
|
||||
name.isEmpty() ? AppSettings::instance().userEmail() : name));
|
||||
} else {
|
||||
// No saved session — prompt login after a short delay so the window is visible first
|
||||
QTimer::singleShot(200, this, &MainWindow::showLoginDialog);
|
||||
}
|
||||
}
|
||||
@@ -145,16 +162,11 @@ void MainWindow::showLoginDialog()
|
||||
auto *dlg = new LoginDialog(this);
|
||||
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
// Pre-fill if remembered
|
||||
if (AppSettings::instance().rememberLogin())
|
||||
dlg->findChild<QLineEdit *>(); // just show with empty fields
|
||||
|
||||
connect(dlg, &LoginDialog::loginRequested,
|
||||
this, [this, dlg](const QString &email, const QString &password) {
|
||||
dlg->setBusy(true);
|
||||
m_backend->login(email, password);
|
||||
});
|
||||
|
||||
connect(m_backend, &QobuzBackend::loginSuccess, dlg, [dlg](const QString &, const QJsonObject &) {
|
||||
dlg->accept();
|
||||
});
|
||||
@@ -178,10 +190,8 @@ void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user)
|
||||
const QString email = user["email"].toString();
|
||||
AppSettings::instance().setDisplayName(displayName);
|
||||
AppSettings::instance().setUserEmail(email);
|
||||
|
||||
statusBar()->showMessage(tr("Signed in as %1").arg(
|
||||
displayName.isEmpty() ? email : displayName));
|
||||
|
||||
m_library->refresh();
|
||||
}
|
||||
|
||||
@@ -190,47 +200,59 @@ void MainWindow::onLoginError(const QString &error)
|
||||
statusBar()->showMessage(tr("Login failed: %1").arg(error), 6000);
|
||||
}
|
||||
|
||||
void MainWindow::onTrackChanged(const QJsonObject &track)
|
||||
{
|
||||
// Update playing row highlight in the track list
|
||||
const qint64 id = static_cast<qint64>(track["id"].toDouble());
|
||||
m_content->tracksList()->setPlayingTrackId(id);
|
||||
|
||||
// Update status bar with track name
|
||||
const QString title = track["title"].toString();
|
||||
const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
|
||||
? track["album"].toObject()["artist"].toObject()["name"].toString()
|
||||
: track["performer"].toObject()["name"].toString();
|
||||
statusBar()->showMessage(
|
||||
artist.isEmpty() ? title : QStringLiteral("▶ %1 — %2").arg(artist, title));
|
||||
}
|
||||
|
||||
void MainWindow::onFavTracksLoaded(const QJsonObject &result)
|
||||
{
|
||||
m_content->showFavTracks(result);
|
||||
statusBar()->showMessage(tr("Favorite tracks loaded."), 3000);
|
||||
statusBar()->showMessage(
|
||||
tr("%1 favorite tracks").arg(result["total"].toInt()), 4000);
|
||||
}
|
||||
|
||||
void MainWindow::onFavAlbumsLoaded(const QJsonObject &result)
|
||||
{
|
||||
// Show albums as a track list (each album as a row)
|
||||
// For now just show the raw tracks from each album
|
||||
// A proper album grid view would go here in a future iteration
|
||||
const QJsonArray albums = result["items"].toArray();
|
||||
statusBar()->showMessage(tr("%1 favorite albums").arg(albums.size()), 3000);
|
||||
// TODO: show album grid
|
||||
const int total = result["total"].toInt();
|
||||
statusBar()->showMessage(tr("%1 favorite albums").arg(total), 4000);
|
||||
// TODO: album grid view
|
||||
}
|
||||
|
||||
void MainWindow::onAlbumLoaded(const QJsonObject &album)
|
||||
{
|
||||
m_content->showAlbum(album);
|
||||
const QString title = album["title"].toString();
|
||||
statusBar()->showMessage(tr("Album: %1").arg(title), 3000);
|
||||
statusBar()->showMessage(
|
||||
tr("Album: %1").arg(album["title"].toString()), 4000);
|
||||
}
|
||||
|
||||
void MainWindow::onArtistLoaded(const QJsonObject &artist)
|
||||
{
|
||||
// TODO: show artist view with top tracks + discography
|
||||
statusBar()->showMessage(tr("Artist: %1").arg(artist["name"].toString()), 3000);
|
||||
statusBar()->showMessage(
|
||||
tr("Artist: %1").arg(artist["name"].toString()), 4000);
|
||||
// TODO: artist view
|
||||
}
|
||||
|
||||
void MainWindow::onPlaylistLoaded(const QJsonObject &playlist)
|
||||
{
|
||||
m_content->showPlaylist(playlist);
|
||||
const QString name = playlist["name"].toString();
|
||||
statusBar()->showMessage(tr("Playlist: %1").arg(name), 3000);
|
||||
statusBar()->showMessage(
|
||||
tr("Playlist: %1").arg(playlist["name"].toString()), 4000);
|
||||
}
|
||||
|
||||
void MainWindow::onPlayTrackRequested(qint64 trackId)
|
||||
{
|
||||
const int format = AppSettings::instance().preferredFormat();
|
||||
m_backend->playTrack(trackId, format);
|
||||
statusBar()->showMessage(tr("Loading track…"));
|
||||
m_backend->playTrack(trackId, AppSettings::instance().preferredFormat());
|
||||
}
|
||||
|
||||
void MainWindow::onSearchAlbumSelected(const QString &albumId)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "backend/qobuzbackend.hpp"
|
||||
#include "playqueue.hpp"
|
||||
#include "view/maintoolbar.hpp"
|
||||
#include "view/maincontent.hpp"
|
||||
#include "view/context/view.hpp"
|
||||
#include "view/queuepanel.hpp"
|
||||
#include "view/sidepanel/view.hpp"
|
||||
#include "list/library.hpp"
|
||||
|
||||
@@ -16,7 +19,6 @@ class MainWindow : public QMainWindow
|
||||
|
||||
public:
|
||||
explicit MainWindow(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
|
||||
static QSize defaultSize() { return {1100, 700}; }
|
||||
|
||||
private slots:
|
||||
@@ -29,10 +31,10 @@ private slots:
|
||||
void onArtistLoaded(const QJsonObject &artist);
|
||||
void onPlaylistLoaded(const QJsonObject &playlist);
|
||||
|
||||
void onTrackChanged(const QJsonObject &track);
|
||||
void onPlayTrackRequested(qint64 trackId);
|
||||
void onSearchAlbumSelected(const QString &albumId);
|
||||
void onSearchArtistSelected(qint64 artistId);
|
||||
|
||||
void onSearchToggled(bool visible);
|
||||
|
||||
void showLoginDialog();
|
||||
@@ -40,11 +42,13 @@ private slots:
|
||||
|
||||
private:
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
PlayQueue *m_queue = nullptr;
|
||||
MainToolBar *m_toolBar = nullptr;
|
||||
MainContent *m_content = nullptr;
|
||||
List::Library *m_library = nullptr;
|
||||
Context::View *m_contextView = nullptr;
|
||||
QueuePanel *m_queuePanel = nullptr;
|
||||
SidePanel::View *m_sidePanel = nullptr;
|
||||
|
||||
QDockWidget *m_libraryDock = nullptr;
|
||||
|
||||
void setupMenuBar();
|
||||
|
||||
@@ -2,45 +2,63 @@
|
||||
|
||||
#include <QJsonValue>
|
||||
#include <QColor>
|
||||
#include <QIcon>
|
||||
#include <algorithm>
|
||||
|
||||
TrackListModel::TrackListModel(QObject *parent)
|
||||
: QAbstractTableModel(parent)
|
||||
{}
|
||||
|
||||
void TrackListModel::setTracks(const QJsonArray &tracks)
|
||||
void TrackListModel::setTracks(const QJsonArray &tracks,
|
||||
bool usePosition,
|
||||
bool useSequential)
|
||||
{
|
||||
beginResetModel();
|
||||
m_tracks.clear();
|
||||
m_tracks.reserve(tracks.size());
|
||||
|
||||
int seq = 1;
|
||||
for (const QJsonValue &v : tracks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
TrackItem item;
|
||||
item.id = static_cast<qint64>(t["id"].toDouble());
|
||||
item.number = t["track_number"].toInt();
|
||||
item.title = t["title"].toString();
|
||||
item.duration = static_cast<qint64>(t["duration"].toDouble());
|
||||
item.hiRes = t["hires_streamable"].toBool();
|
||||
item.streamable = t["streamable"].toBool();
|
||||
item.streamable = t["streamable"].toBool(true);
|
||||
item.raw = t;
|
||||
|
||||
// Performer / artist
|
||||
const QJsonObject performer = t["performer"].toObject();
|
||||
item.artist = performer["name"].toString();
|
||||
if (item.artist.isEmpty()) {
|
||||
const QJsonObject album = t["album"].toObject();
|
||||
const QJsonObject artist = album["artist"].toObject();
|
||||
item.artist = artist["name"].toString();
|
||||
if (useSequential) {
|
||||
item.number = seq++;
|
||||
} else if (usePosition) {
|
||||
const int pos = t["position"].toInt();
|
||||
item.number = pos > 0 ? pos : seq;
|
||||
++seq;
|
||||
} else {
|
||||
item.number = t["track_number"].toInt();
|
||||
}
|
||||
|
||||
// Album
|
||||
const QJsonObject performer = t["performer"].toObject();
|
||||
item.artist = performer["name"].toString();
|
||||
if (item.artist.isEmpty())
|
||||
item.artist = t["album"].toObject()["artist"].toObject()["name"].toString();
|
||||
|
||||
const QJsonObject album = t["album"].toObject();
|
||||
item.album = album["title"].toString();
|
||||
item.albumId = album["id"].toString();
|
||||
|
||||
m_tracks.append(item);
|
||||
}
|
||||
|
||||
// Re-apply sort silently inside the reset (no layout signals needed here)
|
||||
if (m_sortColumn >= 0)
|
||||
sortData(m_sortColumn, m_sortOrder);
|
||||
|
||||
endResetModel();
|
||||
|
||||
// Tell external listeners the sorted order is ready (e.g. PlayQueue sync)
|
||||
if (m_sortColumn >= 0)
|
||||
emit sortApplied();
|
||||
}
|
||||
|
||||
void TrackListModel::clear()
|
||||
@@ -50,16 +68,22 @@ void TrackListModel::clear()
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void TrackListModel::setPlayingId(qint64 id)
|
||||
{
|
||||
m_playingId = id;
|
||||
if (!m_tracks.isEmpty())
|
||||
emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1),
|
||||
{Qt::FontRole, Qt::DecorationRole});
|
||||
}
|
||||
|
||||
int TrackListModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid()) return 0;
|
||||
return m_tracks.size();
|
||||
return parent.isValid() ? 0 : m_tracks.size();
|
||||
}
|
||||
|
||||
int TrackListModel::columnCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid()) return 0;
|
||||
return ColCount;
|
||||
return parent.isValid() ? 0 : ColCount;
|
||||
}
|
||||
|
||||
QVariant TrackListModel::data(const QModelIndex &index, int role) const
|
||||
@@ -68,6 +92,7 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
|
||||
return {};
|
||||
|
||||
const TrackItem &t = m_tracks.at(index.row());
|
||||
const bool isPlaying = (t.id == m_playingId && m_playingId != 0);
|
||||
|
||||
if (role == Qt::DisplayRole) {
|
||||
switch (index.column()) {
|
||||
@@ -79,8 +104,19 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
|
||||
}
|
||||
}
|
||||
|
||||
if (role == Qt::ForegroundRole && !t.streamable) {
|
||||
return QColor(Qt::gray);
|
||||
if (role == Qt::FontRole && isPlaying) {
|
||||
QFont f;
|
||||
f.setBold(true);
|
||||
return f;
|
||||
}
|
||||
|
||||
if (role == Qt::ForegroundRole) {
|
||||
if (!t.streamable) return QColor(Qt::gray);
|
||||
if (isPlaying) return QColor(0x1d, 0xb9, 0x54); // Qobuz green
|
||||
}
|
||||
|
||||
if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) {
|
||||
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
|
||||
}
|
||||
|
||||
if (role == TrackIdRole) return t.id;
|
||||
@@ -92,9 +128,9 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
|
||||
|
||||
QVariant TrackListModel::headerData(int section, Qt::Orientation orientation, int role) const
|
||||
{
|
||||
if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
|
||||
return {};
|
||||
if (orientation != Qt::Horizontal) return {};
|
||||
|
||||
if (role == Qt::DisplayRole) {
|
||||
switch (section) {
|
||||
case ColNumber: return tr("#");
|
||||
case ColTitle: return tr("Title");
|
||||
@@ -102,9 +138,46 @@ QVariant TrackListModel::headerData(int section, Qt::Orientation orientation, in
|
||||
case ColAlbum: return tr("Album");
|
||||
case ColDuration: return tr("Duration");
|
||||
}
|
||||
}
|
||||
|
||||
if (role == Qt::DecorationRole && section == m_sortColumn) {
|
||||
return QIcon(QStringLiteral(":/res/icons/view-sort-ascending.svg"));
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void TrackListModel::sortData(int column, Qt::SortOrder order)
|
||||
{
|
||||
auto cmp = [&](const TrackItem &a, const TrackItem &b) -> bool {
|
||||
bool less = false;
|
||||
switch (column) {
|
||||
case ColNumber: less = a.number < b.number; break;
|
||||
case ColTitle: less = a.title < b.title; break;
|
||||
case ColArtist: less = a.artist < b.artist; break;
|
||||
case ColAlbum: less = a.album < b.album; break;
|
||||
case ColDuration: less = a.duration < b.duration; break;
|
||||
default: less = false;
|
||||
}
|
||||
return order == Qt::AscendingOrder ? less : !less;
|
||||
};
|
||||
std::stable_sort(m_tracks.begin(), m_tracks.end(), cmp);
|
||||
}
|
||||
|
||||
void TrackListModel::sort(int column, Qt::SortOrder order)
|
||||
{
|
||||
m_sortColumn = column;
|
||||
m_sortOrder = order;
|
||||
|
||||
if (m_tracks.isEmpty()) return;
|
||||
|
||||
emit layoutAboutToBeChanged();
|
||||
sortData(column, order);
|
||||
emit layoutChanged();
|
||||
|
||||
emit sortApplied();
|
||||
}
|
||||
|
||||
QString TrackListModel::formatDuration(qint64 secs)
|
||||
{
|
||||
const int m = static_cast<int>(secs / 60);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QVector>
|
||||
#include <QFont>
|
||||
|
||||
/// Flat data stored for each row in the track list.
|
||||
struct TrackItem {
|
||||
qint64 id = 0;
|
||||
int number = 0;
|
||||
@@ -16,7 +16,7 @@ struct TrackItem {
|
||||
qint64 duration = 0; // seconds
|
||||
bool hiRes = false;
|
||||
bool streamable = false;
|
||||
QJsonObject raw; // full JSON for context menus / playback
|
||||
QJsonObject raw;
|
||||
};
|
||||
|
||||
class TrackListModel : public QAbstractTableModel
|
||||
@@ -41,17 +41,45 @@ public:
|
||||
|
||||
explicit TrackListModel(QObject *parent = nullptr);
|
||||
|
||||
void setTracks(const QJsonArray &tracks);
|
||||
// usePosition: use tracks[i]["position"] for the # column (playlists)
|
||||
// useSequential: use 1..n sequential numbering (favourites)
|
||||
void setTracks(const QJsonArray &tracks,
|
||||
bool usePosition = false,
|
||||
bool useSequential = false);
|
||||
void clear();
|
||||
void setPlayingId(qint64 id);
|
||||
qint64 playingId() const { return m_playingId; }
|
||||
|
||||
const TrackItem &trackAt(int row) const { return m_tracks.at(row); }
|
||||
|
||||
// Returns the current (possibly sorted) raw JSON objects in display order.
|
||||
QJsonArray currentTracksJson() const
|
||||
{
|
||||
QJsonArray out;
|
||||
for (const auto &t : m_tracks)
|
||||
out.append(t.raw);
|
||||
return out;
|
||||
}
|
||||
|
||||
int rowCount(const QModelIndex &parent = {}) const override;
|
||||
int columnCount(const QModelIndex &parent = {}) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||
void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override;
|
||||
|
||||
static QString formatDuration(qint64 secs);
|
||||
|
||||
signals:
|
||||
// Emitted after a sort is applied (including the initial sort after setTracks).
|
||||
// Lets external observers (e.g. PlayQueue) re-sync their order.
|
||||
void sortApplied();
|
||||
|
||||
private:
|
||||
QVector<TrackItem> m_tracks;
|
||||
qint64 m_playingId = 0;
|
||||
int m_sortColumn = -1;
|
||||
Qt::SortOrder m_sortOrder = Qt::AscendingOrder;
|
||||
|
||||
// Sort m_tracks in-place without emitting any signals.
|
||||
void sortData(int column, Qt::SortOrder order);
|
||||
};
|
||||
|
||||
225
src/playqueue.hpp
Normal file
@@ -0,0 +1,225 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QVector>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <algorithm>
|
||||
#include <random>
|
||||
|
||||
/// Local playback queue. Holds the ordered list of tracks for the current
|
||||
/// context (album / playlist / search result / favourites) plus a separate
|
||||
/// "play-next" prepend queue that mirrors the spotify-qt pattern.
|
||||
class PlayQueue : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit PlayQueue(QObject *parent = nullptr) : QObject(parent) {}
|
||||
|
||||
// ---- Loading a new context ----
|
||||
|
||||
/// Replace the queue with all tracks from an album/playlist JSON context.
|
||||
/// @param startIndex Index of the track to start playing (-1 = first).
|
||||
void setContext(const QJsonArray &tracks, int startIndex = 0)
|
||||
{
|
||||
m_queue.clear();
|
||||
m_playNext.clear();
|
||||
for (const auto &v : tracks)
|
||||
m_queue.append(v.toObject());
|
||||
if (m_shuffle) {
|
||||
shuffleQueue(startIndex);
|
||||
// shuffleQueue moves the start track to index 0 and sets m_index = 0
|
||||
} else {
|
||||
m_index = qBound(0, startIndex, m_queue.size() - 1);
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
// ---- Re-order after a sort (keeps m_playNext, updates m_index) ----
|
||||
|
||||
void reorderContext(const QJsonArray &tracks, qint64 currentId)
|
||||
{
|
||||
m_queue.clear();
|
||||
for (const auto &v : tracks)
|
||||
m_queue.append(v.toObject());
|
||||
|
||||
m_index = 0;
|
||||
for (int i = 0; i < m_queue.size(); ++i) {
|
||||
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == currentId) {
|
||||
m_index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
// ---- Clear / remove upcoming ----
|
||||
|
||||
/// Remove all "up next" entries (playNext + remaining main queue after current).
|
||||
void clearUpcoming()
|
||||
{
|
||||
m_playNext.clear();
|
||||
if (m_index < m_queue.size())
|
||||
m_queue.resize(m_index + 1); // keep up to and including current
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
/// Remove one upcoming track by its index in upcomingTracks().
|
||||
void removeUpcoming(int upcomingIndex)
|
||||
{
|
||||
if (upcomingIndex < m_playNext.size()) {
|
||||
m_playNext.removeAt(upcomingIndex);
|
||||
} else {
|
||||
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
|
||||
if (queueIdx < m_queue.size())
|
||||
m_queue.removeAt(queueIdx);
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
// ---- Shuffle ----
|
||||
|
||||
bool shuffleEnabled() const { return m_shuffle; }
|
||||
|
||||
void setShuffle(bool enabled)
|
||||
{
|
||||
if (m_shuffle == enabled) return;
|
||||
m_shuffle = enabled;
|
||||
if (enabled && !m_queue.isEmpty())
|
||||
shuffleQueue(m_index);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
// ---- Play-next prepend queue (like "Add to queue" ----
|
||||
|
||||
void addToQueue(const QJsonObject &track)
|
||||
{
|
||||
m_playNext.append(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void playNext(const QJsonObject &track)
|
||||
{
|
||||
m_playNext.prepend(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
// ---- Navigation ----
|
||||
|
||||
bool hasCurrent() const
|
||||
{
|
||||
return (!m_playNext.isEmpty()) || (!m_queue.isEmpty());
|
||||
}
|
||||
|
||||
QJsonObject current() const
|
||||
{
|
||||
if (!m_playNext.isEmpty()) return m_playNext.first();
|
||||
if (m_index < m_queue.size()) return m_queue.at(m_index);
|
||||
return {};
|
||||
}
|
||||
|
||||
qint64 currentId() const
|
||||
{
|
||||
return static_cast<qint64>(current()["id"].toDouble());
|
||||
}
|
||||
|
||||
/// Advance and return the new current track. Returns {} at end of queue.
|
||||
QJsonObject advance()
|
||||
{
|
||||
if (!m_playNext.isEmpty()) {
|
||||
m_playNext.removeFirst();
|
||||
} else {
|
||||
++m_index;
|
||||
}
|
||||
emit queueChanged();
|
||||
return current();
|
||||
}
|
||||
|
||||
/// Step backwards in the main queue (play-next is not affected).
|
||||
QJsonObject stepBack()
|
||||
{
|
||||
if (m_index > 0) --m_index;
|
||||
emit queueChanged();
|
||||
return current();
|
||||
}
|
||||
|
||||
bool canGoNext() const
|
||||
{
|
||||
return !m_playNext.isEmpty() || (m_index + 1 < m_queue.size());
|
||||
}
|
||||
|
||||
bool canGoPrev() const { return m_index > 0; }
|
||||
|
||||
// ---- Index lookup ----
|
||||
|
||||
/// Set the current position by track id (after user double-clicks a row).
|
||||
void setCurrentById(qint64 id)
|
||||
{
|
||||
m_playNext.clear();
|
||||
for (int i = 0; i < m_queue.size(); ++i) {
|
||||
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == id) {
|
||||
m_index = i;
|
||||
emit queueChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Accessors for queue panel ----
|
||||
|
||||
QVector<QJsonObject> upcomingTracks(int maxCount = 200) const
|
||||
{
|
||||
QVector<QJsonObject> result;
|
||||
result.append(m_playNext);
|
||||
for (int i = m_index + 1; i < m_queue.size() && result.size() < maxCount; ++i)
|
||||
result.append(m_queue.at(i));
|
||||
return result;
|
||||
}
|
||||
|
||||
int playNextCount() const { return m_playNext.size(); }
|
||||
int totalSize() const { return m_playNext.size() + m_queue.size(); }
|
||||
int currentIndex() const { return m_index; }
|
||||
|
||||
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
|
||||
void moveUpcomingToTop(int upcomingIndex)
|
||||
{
|
||||
if (upcomingIndex < 0) return;
|
||||
QJsonObject track;
|
||||
if (upcomingIndex < m_playNext.size()) {
|
||||
if (upcomingIndex == 0) return; // already at top
|
||||
track = m_playNext.takeAt(upcomingIndex);
|
||||
} else {
|
||||
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
|
||||
if (queueIdx >= m_queue.size()) return;
|
||||
track = m_queue.takeAt(queueIdx);
|
||||
}
|
||||
m_playNext.prepend(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
signals:
|
||||
void queueChanged();
|
||||
|
||||
private:
|
||||
QVector<QJsonObject> m_queue; // main context (album / playlist)
|
||||
QVector<QJsonObject> m_playNext; // prepended "play next" tracks
|
||||
int m_index = 0;
|
||||
bool m_shuffle = false;
|
||||
|
||||
void shuffleQueue(int keepAtFront)
|
||||
{
|
||||
if (m_queue.isEmpty()) return;
|
||||
// Keep the current track at index 0 of the remaining queue
|
||||
if (keepAtFront >= 0 && keepAtFront < m_queue.size()) {
|
||||
QJsonObject current = m_queue.takeAt(keepAtFront);
|
||||
std::mt19937 rng(std::random_device{}());
|
||||
std::shuffle(m_queue.begin(), m_queue.end(), rng);
|
||||
m_queue.prepend(current);
|
||||
} else {
|
||||
std::mt19937 rng(std::random_device{}());
|
||||
std::shuffle(m_queue.begin(), m_queue.end(), rng);
|
||||
}
|
||||
m_index = 0;
|
||||
}
|
||||
};
|
||||
@@ -1,32 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <QFile>
|
||||
#include <QIcon>
|
||||
#include <QString>
|
||||
|
||||
namespace Icon
|
||||
{
|
||||
/// Load an icon by name. Checks the embedded :/res/icons/ first, then
|
||||
/// falls back to the system theme. The dark SVGs from spotify-qt are
|
||||
/// bundled so this always succeeds for known names.
|
||||
inline QIcon get(const QString &name)
|
||||
{
|
||||
// Try theme icon first, fall back to resource
|
||||
if (QIcon::hasThemeIcon(name))
|
||||
const QString path = QStringLiteral(":/res/icons/%1.svg").arg(name);
|
||||
if (QFile::exists(path))
|
||||
return QIcon(path);
|
||||
return QIcon::fromTheme(name);
|
||||
return QIcon(QStringLiteral(":/icons/%1.svg").arg(name));
|
||||
}
|
||||
|
||||
// Convenient aliases for common icons used throughout the app
|
||||
// Playback
|
||||
inline QIcon play() { return get("media-playback-start"); }
|
||||
inline QIcon pause() { return get("media-playback-pause"); }
|
||||
inline QIcon stop() { return get("media-playback-stop"); }
|
||||
inline QIcon next() { return get("media-skip-forward"); }
|
||||
inline QIcon previous() { return get("media-skip-backward"); }
|
||||
inline QIcon shuffle() { return get("media-playlist-shuffle"); }
|
||||
inline QIcon repeat() { return get("media-playlist-repeat"); }
|
||||
|
||||
// Volume
|
||||
inline QIcon volumeHigh() { return get("audio-volume-high"); }
|
||||
inline QIcon volumeMid() { return get("audio-volume-medium"); }
|
||||
inline QIcon volumeMute() { return get("audio-volume-muted"); }
|
||||
inline QIcon volumeLow() { return get("audio-volume-low"); }
|
||||
inline QIcon volumeMute() { return get("audio-volume-low"); }
|
||||
|
||||
// UI
|
||||
inline QIcon search() { return get("edit-find"); }
|
||||
inline QIcon heart() { return get("emblem-favorite"); }
|
||||
inline QIcon album() { return get("media-optical"); }
|
||||
inline QIcon artist() { return get("system-users"); }
|
||||
inline QIcon playlist() { return get("view-list-symbolic"); }
|
||||
inline QIcon heart() { return get("starred-symbolic"); }
|
||||
inline QIcon heartOff() { return get("non-starred-symbolic"); }
|
||||
inline QIcon album() { return get("view-media-album-cover"); }
|
||||
inline QIcon artist() { return get("view-media-artist"); }
|
||||
inline QIcon playlist() { return get("view-media-playlist"); }
|
||||
inline QIcon track() { return get("view-media-track"); }
|
||||
inline QIcon queue() { return get("media-playlist-append"); }
|
||||
inline QIcon refresh() { return get("view-refresh"); }
|
||||
inline QIcon settings() { return get("configure"); }
|
||||
inline QIcon sortAsc() { return get("view-sort-ascending"); }
|
||||
}
|
||||
|
||||
88
src/view/context/view.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
#include "view.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QNetworkRequest>
|
||||
|
||||
namespace Context
|
||||
{
|
||||
|
||||
static constexpr int ArtSize = 96;
|
||||
|
||||
View::View(QobuzBackend *backend, QWidget *parent)
|
||||
: QDockWidget(tr("Now Playing"), parent)
|
||||
, m_backend(backend)
|
||||
{
|
||||
setObjectName(QStringLiteral("contextDock"));
|
||||
setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable);
|
||||
|
||||
m_nam = new QNetworkAccessManager(this);
|
||||
connect(m_nam, &QNetworkAccessManager::finished, this, &View::onArtReady);
|
||||
|
||||
auto *container = new QWidget(this);
|
||||
auto *layout = new QVBoxLayout(container);
|
||||
layout->setContentsMargins(8, 8, 8, 8);
|
||||
layout->setSpacing(6);
|
||||
|
||||
m_albumArt = new QLabel(container);
|
||||
m_albumArt->setFixedSize(ArtSize, ArtSize);
|
||||
m_albumArt->setScaledContents(true);
|
||||
m_albumArt->setAlignment(Qt::AlignCenter);
|
||||
m_albumArt->setStyleSheet(QStringLiteral(
|
||||
"background: #1a1a1a; border-radius: 4px;"));
|
||||
layout->addWidget(m_albumArt, 0, Qt::AlignCenter);
|
||||
|
||||
m_title = new QLabel(tr("Not playing"), container);
|
||||
m_title->setAlignment(Qt::AlignCenter);
|
||||
m_title->setWordWrap(true);
|
||||
QFont titleFont = m_title->font();
|
||||
titleFont.setPointSizeF(titleFont.pointSizeF() * 1.05);
|
||||
titleFont.setBold(true);
|
||||
m_title->setFont(titleFont);
|
||||
layout->addWidget(m_title);
|
||||
|
||||
m_artist = new QLabel(QString(), container);
|
||||
m_artist->setAlignment(Qt::AlignCenter);
|
||||
m_artist->setWordWrap(true);
|
||||
layout->addWidget(m_artist);
|
||||
|
||||
layout->addStretch();
|
||||
setWidget(container);
|
||||
setMinimumWidth(160);
|
||||
|
||||
connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged);
|
||||
}
|
||||
|
||||
void View::onTrackChanged(const QJsonObject &track)
|
||||
{
|
||||
const QString title = track["title"].toString();
|
||||
const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
|
||||
? track["album"].toObject()["artist"].toObject()["name"].toString()
|
||||
: track["performer"].toObject()["name"].toString();
|
||||
|
||||
m_title->setText(title.isEmpty() ? tr("Not playing") : title);
|
||||
m_artist->setText(artist);
|
||||
|
||||
// Prefer "large" image, fall back to "small"
|
||||
const QJsonObject img = track["album"].toObject()["image"].toObject();
|
||||
QString artUrl = img["large"].toString();
|
||||
if (artUrl.isEmpty())
|
||||
artUrl = img["small"].toString();
|
||||
|
||||
if (!artUrl.isEmpty() && artUrl != m_currentArtUrl) {
|
||||
m_currentArtUrl = artUrl;
|
||||
m_nam->get(QNetworkRequest(QUrl(artUrl)));
|
||||
}
|
||||
}
|
||||
|
||||
void View::onArtReady(QNetworkReply *reply)
|
||||
{
|
||||
reply->deleteLater();
|
||||
if (reply->error() != QNetworkReply::NoError)
|
||||
return;
|
||||
QPixmap pix;
|
||||
if (pix.loadFromData(reply->readAll()))
|
||||
m_albumArt->setPixmap(pix);
|
||||
}
|
||||
|
||||
} // namespace Context
|
||||
32
src/view/context/view.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../backend/qobuzbackend.hpp"
|
||||
|
||||
#include <QDockWidget>
|
||||
#include <QLabel>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace Context
|
||||
{
|
||||
class View : public QDockWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit View(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void onTrackChanged(const QJsonObject &track);
|
||||
void onArtReady(QNetworkReply *reply);
|
||||
|
||||
private:
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
QLabel *m_albumArt = nullptr;
|
||||
QLabel *m_title = nullptr;
|
||||
QLabel *m_artist = nullptr;
|
||||
QNetworkAccessManager *m_nam = nullptr;
|
||||
QString m_currentArtUrl;
|
||||
};
|
||||
} // namespace Context
|
||||
@@ -1,9 +1,8 @@
|
||||
#include "maincontent.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QJsonArray>
|
||||
|
||||
MainContent::MainContent(QobuzBackend *backend, QWidget *parent)
|
||||
MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_backend(backend)
|
||||
{
|
||||
@@ -15,22 +14,20 @@ MainContent::MainContent(QobuzBackend *backend, QWidget *parent)
|
||||
|
||||
m_welcome = new QLabel(
|
||||
tr("<h2>Welcome to Qobuz</h2>"
|
||||
"<p>Select something from the library on the left to get started.</p>"),
|
||||
"<p>Select something from the library on the left to get started,<br>"
|
||||
"or use the search panel (🔍) to find music.</p>"),
|
||||
this);
|
||||
m_welcome->setAlignment(Qt::AlignCenter);
|
||||
|
||||
m_tracks = new List::Tracks(m_backend, this);
|
||||
m_tracks = new List::Tracks(m_backend, queue, this);
|
||||
|
||||
m_stack->addWidget(m_welcome); // index 0
|
||||
m_stack->addWidget(m_tracks); // index 1
|
||||
m_stack->addWidget(m_welcome); // 0
|
||||
m_stack->addWidget(m_tracks); // 1
|
||||
|
||||
m_stack->setCurrentIndex(0);
|
||||
}
|
||||
|
||||
void MainContent::showWelcome()
|
||||
{
|
||||
m_stack->setCurrentIndex(0);
|
||||
}
|
||||
void MainContent::showWelcome() { m_stack->setCurrentIndex(0); }
|
||||
|
||||
void MainContent::showAlbum(const QJsonObject &album)
|
||||
{
|
||||
@@ -46,8 +43,7 @@ void MainContent::showPlaylist(const QJsonObject &playlist)
|
||||
|
||||
void MainContent::showFavTracks(const QJsonObject &result)
|
||||
{
|
||||
const QJsonArray items = result["items"].toArray();
|
||||
m_tracks->loadTracks(items);
|
||||
m_tracks->loadTracks(result["items"].toArray());
|
||||
m_stack->setCurrentIndex(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,20 @@
|
||||
|
||||
#include "../list/tracks.hpp"
|
||||
#include "../backend/qobuzbackend.hpp"
|
||||
#include "../playqueue.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QLabel>
|
||||
#include <QStackedWidget>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
|
||||
/// Central content widget — mirrors MainContent from spotify-qt.
|
||||
/// Displays either a track list, an album cover + tracks, or a search result.
|
||||
class MainContent : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainContent(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
explicit MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
|
||||
|
||||
List::Tracks *tracksList() const { return m_tracks; }
|
||||
|
||||
|
||||
@@ -4,56 +4,69 @@
|
||||
|
||||
#include <QWidget>
|
||||
#include <QHBoxLayout>
|
||||
#include <QNetworkRequest>
|
||||
|
||||
MainToolBar::MainToolBar(QobuzBackend *backend, QWidget *parent)
|
||||
MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
|
||||
: QToolBar(parent)
|
||||
, m_backend(backend)
|
||||
, m_queue(queue)
|
||||
{
|
||||
setMovable(false);
|
||||
setFloatable(false);
|
||||
setContextMenuPolicy(Qt::PreventContextMenu);
|
||||
setIconSize(QSize(22, 22));
|
||||
|
||||
// Previous
|
||||
m_nam = new QNetworkAccessManager(this);
|
||||
connect(m_nam, &QNetworkAccessManager::finished, this, &MainToolBar::onAlbumArtReady);
|
||||
|
||||
// --- Album art thumbnail ---
|
||||
m_artLabel = new QLabel(this);
|
||||
m_artLabel->setFixedSize(36, 36);
|
||||
m_artLabel->setScaledContents(true);
|
||||
m_artLabel->setStyleSheet("border: 1px solid #444; background: #1a1a1a;");
|
||||
m_artLabel->setPixmap(QIcon(":/res/icons/view-media-album-cover.svg")
|
||||
.pixmap(32, 32));
|
||||
addWidget(m_artLabel);
|
||||
addSeparator();
|
||||
|
||||
// --- Playback controls ---
|
||||
m_previous = addAction(Icon::previous(), tr("Previous"));
|
||||
connect(m_previous, &QAction::triggered, this, &MainToolBar::onPrevious);
|
||||
|
||||
// Play/Pause
|
||||
m_playPause = addAction(Icon::play(), tr("Play"));
|
||||
m_playPause->setCheckable(false);
|
||||
connect(m_playPause, &QAction::triggered, this, [this](bool) { onPlayPause(false); });
|
||||
connect(m_playPause, &QAction::triggered, this, &MainToolBar::onPlayPause);
|
||||
|
||||
// Next
|
||||
m_next = addAction(Icon::next(), tr("Next"));
|
||||
connect(m_next, &QAction::triggered, this, &MainToolBar::onNext);
|
||||
|
||||
addSeparator();
|
||||
|
||||
// Track label
|
||||
// --- Track info label ---
|
||||
m_trackLabel = new QLabel(tr("Not playing"), this);
|
||||
m_trackLabel->setMinimumWidth(200);
|
||||
m_trackLabel->setMaximumWidth(320);
|
||||
m_trackLabel->setMinimumWidth(180);
|
||||
m_trackLabel->setMaximumWidth(340);
|
||||
m_trackLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
|
||||
addWidget(m_trackLabel);
|
||||
|
||||
addSeparator();
|
||||
|
||||
// Progress slider
|
||||
// --- Progress slider ---
|
||||
m_progress = new ClickableSlider(Qt::Horizontal, this);
|
||||
m_progress->setRange(0, 1000);
|
||||
m_progress->setValue(0);
|
||||
m_progress->setMinimumWidth(180);
|
||||
m_progress->setMaximumWidth(400);
|
||||
m_progress->setMinimumWidth(160);
|
||||
m_progress->setMaximumWidth(380);
|
||||
addWidget(m_progress);
|
||||
|
||||
connect(m_progress, &QSlider::sliderPressed, this, [this] { m_seeking = true; });
|
||||
connect(m_progress, &QSlider::sliderReleased, this, &MainToolBar::onProgressReleased);
|
||||
|
||||
// Time label
|
||||
m_timeLabel = new QLabel("0:00 / 0:00", this);
|
||||
addWidget(m_timeLabel);
|
||||
|
||||
addSeparator();
|
||||
|
||||
// Volume button
|
||||
// --- Volume ---
|
||||
m_volume = new VolumeButton(this);
|
||||
m_volume->setValue(AppSettings::instance().volume());
|
||||
addWidget(m_volume);
|
||||
@@ -61,18 +74,36 @@ MainToolBar::MainToolBar(QobuzBackend *backend, QWidget *parent)
|
||||
|
||||
addSeparator();
|
||||
|
||||
// Search toggle
|
||||
// --- Shuffle ---
|
||||
m_shuffle = addAction(Icon::get(QStringLiteral("media-playlist-shuffle")), tr("Shuffle"));
|
||||
m_shuffle->setCheckable(true);
|
||||
connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled);
|
||||
|
||||
addSeparator();
|
||||
|
||||
// --- Queue toggle ---
|
||||
m_queueBtn = addAction(Icon::queue(), tr("Queue"));
|
||||
m_queueBtn->setCheckable(true);
|
||||
connect(m_queueBtn, &QAction::toggled, this, &MainToolBar::queueToggled);
|
||||
|
||||
// --- Search toggle ---
|
||||
m_search = addAction(Icon::search(), tr("Search"));
|
||||
m_search->setCheckable(true);
|
||||
connect(m_search, &QAction::toggled, this, &MainToolBar::searchToggled);
|
||||
|
||||
// Connect to backend signals
|
||||
// --- Backend signals ---
|
||||
connect(m_backend, &QobuzBackend::stateChanged, this, &MainToolBar::onBackendStateChanged);
|
||||
connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged);
|
||||
connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged);
|
||||
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
|
||||
|
||||
// --- Queue signals ---
|
||||
connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged);
|
||||
onQueueChanged(); // initialise button states
|
||||
}
|
||||
|
||||
// ---- public ----
|
||||
|
||||
void MainToolBar::setPlaying(bool playing)
|
||||
{
|
||||
m_playing = playing;
|
||||
@@ -83,8 +114,9 @@ void MainToolBar::setPlaying(bool playing)
|
||||
void MainToolBar::setCurrentTrack(const QJsonObject &track)
|
||||
{
|
||||
const QString title = track["title"].toString();
|
||||
const QJsonObject performer = track["performer"].toObject();
|
||||
const QString artist = performer["name"].toString();
|
||||
const QString artist = track["performer"].toObject()["name"].toString().isEmpty()
|
||||
? track["album"].toObject()["artist"].toObject()["name"].toString()
|
||||
: track["performer"].toObject()["name"].toString();
|
||||
|
||||
if (title.isEmpty()) {
|
||||
m_trackLabel->setText(tr("Not playing"));
|
||||
@@ -93,55 +125,58 @@ void MainToolBar::setCurrentTrack(const QJsonObject &track)
|
||||
} else {
|
||||
m_trackLabel->setText(QStringLiteral("%1 — %2").arg(artist, title));
|
||||
}
|
||||
|
||||
// Album art
|
||||
const QString artUrl = track["album"].toObject()["image"].toObject()["small"].toString();
|
||||
if (!artUrl.isEmpty() && artUrl != m_currentArtUrl) {
|
||||
m_currentArtUrl = artUrl;
|
||||
fetchAlbumArt(artUrl);
|
||||
}
|
||||
}
|
||||
|
||||
void MainToolBar::updateProgress(quint64 position, quint64 duration)
|
||||
{
|
||||
if (m_seeking) return;
|
||||
|
||||
const int sliderPos = duration > 0
|
||||
? static_cast<int>(position * 1000 / duration)
|
||||
: 0;
|
||||
? static_cast<int>(position * 1000 / duration) : 0;
|
||||
m_progress->blockSignals(true);
|
||||
m_progress->setValue(sliderPos);
|
||||
m_progress->blockSignals(false);
|
||||
|
||||
const QString pos = TrackListModel::formatDuration(static_cast<qint64>(position));
|
||||
const QString dur = TrackListModel::formatDuration(static_cast<qint64>(duration));
|
||||
m_timeLabel->setText(QStringLiteral("%1 / %2").arg(pos, dur));
|
||||
m_timeLabel->setText(
|
||||
QStringLiteral("%1 / %2")
|
||||
.arg(TrackListModel::formatDuration(static_cast<qint64>(position)),
|
||||
TrackListModel::formatDuration(static_cast<qint64>(duration))));
|
||||
}
|
||||
|
||||
void MainToolBar::setVolume(int volume)
|
||||
// ---- private slots ----
|
||||
|
||||
void MainToolBar::onPlayPause()
|
||||
{
|
||||
m_volume->setValue(volume);
|
||||
}
|
||||
|
||||
// --- private slots ---
|
||||
|
||||
void MainToolBar::onPlayPause(bool)
|
||||
{
|
||||
if (m_playing) {
|
||||
m_backend->pause();
|
||||
} else {
|
||||
m_backend->resume();
|
||||
}
|
||||
if (m_playing) m_backend->pause();
|
||||
else m_backend->resume();
|
||||
}
|
||||
|
||||
void MainToolBar::onPrevious()
|
||||
{
|
||||
// TODO: wire up to a queue / playlist
|
||||
if (!m_queue->canGoPrev()) return;
|
||||
const QJsonObject track = m_queue->stepBack();
|
||||
const qint64 id = static_cast<qint64>(track["id"].toDouble());
|
||||
if (id > 0)
|
||||
m_backend->playTrack(id, AppSettings::instance().preferredFormat());
|
||||
}
|
||||
|
||||
void MainToolBar::onNext()
|
||||
{
|
||||
// TODO: wire up to a queue / playlist
|
||||
if (!m_queue->canGoNext()) return;
|
||||
const QJsonObject track = m_queue->advance();
|
||||
const qint64 id = static_cast<qint64>(track["id"].toDouble());
|
||||
if (id > 0)
|
||||
m_backend->playTrack(id, AppSettings::instance().preferredFormat());
|
||||
}
|
||||
|
||||
void MainToolBar::onProgressReleased()
|
||||
{
|
||||
m_seeking = false;
|
||||
// Seek is not yet implemented in the Rust player, but we can at least
|
||||
// update the displayed position optimistically.
|
||||
}
|
||||
|
||||
void MainToolBar::onVolumeChanged(int volume)
|
||||
@@ -167,7 +202,37 @@ void MainToolBar::onPositionChanged(quint64 position, quint64 duration)
|
||||
|
||||
void MainToolBar::onTrackFinished()
|
||||
{
|
||||
// Auto-advance queue
|
||||
if (m_queue->canGoNext()) {
|
||||
onNext();
|
||||
} else {
|
||||
setPlaying(false);
|
||||
m_progress->setValue(0);
|
||||
m_timeLabel->setText("0:00 / 0:00");
|
||||
}
|
||||
}
|
||||
|
||||
void MainToolBar::onQueueChanged()
|
||||
{
|
||||
m_previous->setEnabled(m_queue->canGoPrev());
|
||||
m_next->setEnabled(m_queue->canGoNext());
|
||||
}
|
||||
|
||||
void MainToolBar::onShuffleToggled(bool checked)
|
||||
{
|
||||
m_queue->setShuffle(checked);
|
||||
}
|
||||
|
||||
void MainToolBar::fetchAlbumArt(const QString &url)
|
||||
{
|
||||
m_nam->get(QNetworkRequest(QUrl(url)));
|
||||
}
|
||||
|
||||
void MainToolBar::onAlbumArtReady(QNetworkReply *reply)
|
||||
{
|
||||
reply->deleteLater();
|
||||
if (reply->error() != QNetworkReply::NoError) return;
|
||||
QPixmap pix;
|
||||
if (pix.loadFromData(reply->readAll()))
|
||||
m_artLabel->setPixmap(pix);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "../backend/qobuzbackend.hpp"
|
||||
#include "../playqueue.hpp"
|
||||
#include "../widget/volumebutton.hpp"
|
||||
#include "../widget/clickableslider.hpp"
|
||||
#include "../util/icon.hpp"
|
||||
@@ -9,26 +10,27 @@
|
||||
#include <QToolButton>
|
||||
#include <QLabel>
|
||||
#include <QAction>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonObject>
|
||||
|
||||
/// Main playback toolbar — mirrors MainToolBar from spotify-qt.
|
||||
class MainToolBar : public QToolBar
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainToolBar(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
explicit MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
|
||||
|
||||
void setPlaying(bool playing);
|
||||
void setCurrentTrack(const QJsonObject &track);
|
||||
void updateProgress(quint64 position, quint64 duration);
|
||||
void setVolume(int volume);
|
||||
|
||||
signals:
|
||||
void searchToggled(bool visible);
|
||||
void queueToggled(bool visible);
|
||||
|
||||
private slots:
|
||||
void onPlayPause(bool checked);
|
||||
void onPlayPause();
|
||||
void onPrevious();
|
||||
void onNext();
|
||||
void onProgressReleased();
|
||||
@@ -38,26 +40,31 @@ private slots:
|
||||
void onTrackChanged(const QJsonObject &track);
|
||||
void onPositionChanged(quint64 position, quint64 duration);
|
||||
void onTrackFinished();
|
||||
void onQueueChanged();
|
||||
void onShuffleToggled(bool checked);
|
||||
|
||||
void fetchAlbumArt(const QString &url);
|
||||
void onAlbumArtReady(QNetworkReply *reply);
|
||||
|
||||
private:
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
PlayQueue *m_queue = nullptr;
|
||||
|
||||
QLabel *m_artLabel = nullptr;
|
||||
QLabel *m_trackLabel = nullptr;
|
||||
ClickableSlider *m_progress = nullptr;
|
||||
QLabel *m_timeLabel = nullptr;
|
||||
VolumeButton *m_volume = nullptr;
|
||||
|
||||
QAction *m_previous = nullptr;
|
||||
QAction *m_playPause = nullptr;
|
||||
QAction *m_next = nullptr;
|
||||
QAction *m_shuffle = nullptr;
|
||||
QAction *m_queueBtn = nullptr;
|
||||
QAction *m_search = nullptr;
|
||||
|
||||
ClickableSlider *m_progress = nullptr;
|
||||
QLabel *m_timeLabel = nullptr;
|
||||
QLabel *m_trackLabel = nullptr;
|
||||
VolumeButton *m_volume = nullptr;
|
||||
|
||||
QNetworkAccessManager *m_nam = nullptr;
|
||||
QString m_currentArtUrl;
|
||||
bool m_playing = false;
|
||||
bool m_seeking = false;
|
||||
|
||||
// Playback queue (track IDs) for next/prev
|
||||
QVector<qint64> m_queue;
|
||||
int m_queueIdx = -1;
|
||||
|
||||
void addSpacerWidget();
|
||||
};
|
||||
|
||||
111
src/view/queuepanel.cpp
Normal file
@@ -0,0 +1,111 @@
|
||||
#include "queuepanel.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMenu>
|
||||
#include <QAction>
|
||||
|
||||
static constexpr int UpcomingIndexRole = Qt::UserRole + 1;
|
||||
static constexpr int IsPlayNextRole = Qt::UserRole + 2;
|
||||
|
||||
QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
|
||||
: QDockWidget(tr("Queue"), parent)
|
||||
, m_queue(queue)
|
||||
{
|
||||
setObjectName(QStringLiteral("queuePanel"));
|
||||
setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable);
|
||||
|
||||
auto *container = new QWidget(this);
|
||||
auto *layout = new QVBoxLayout(container);
|
||||
layout->setContentsMargins(4, 4, 4, 4);
|
||||
layout->setSpacing(4);
|
||||
|
||||
// Header row: count label + Clear button
|
||||
auto *headerRow = new QHBoxLayout;
|
||||
m_countLabel = new QLabel(tr("Up next: 0 tracks"), container);
|
||||
m_clearBtn = new QPushButton(tr("Clear"), container);
|
||||
m_clearBtn->setMaximumWidth(64);
|
||||
headerRow->addWidget(m_countLabel, 1);
|
||||
headerRow->addWidget(m_clearBtn);
|
||||
layout->addLayout(headerRow);
|
||||
|
||||
m_list = new QListWidget(container);
|
||||
m_list->setAlternatingRowColors(true);
|
||||
m_list->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
layout->addWidget(m_list, 1);
|
||||
|
||||
setWidget(container);
|
||||
setMinimumWidth(200);
|
||||
|
||||
connect(m_queue, &PlayQueue::queueChanged, this, &QueuePanel::refresh);
|
||||
connect(m_clearBtn, &QPushButton::clicked, this, [this] {
|
||||
m_queue->clearUpcoming();
|
||||
});
|
||||
connect(m_list, &QListWidget::itemDoubleClicked,
|
||||
this, &QueuePanel::onItemDoubleClicked);
|
||||
connect(m_list, &QListWidget::customContextMenuRequested,
|
||||
this, &QueuePanel::onContextMenu);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
void QueuePanel::refresh()
|
||||
{
|
||||
m_list->clear();
|
||||
|
||||
const QVector<QJsonObject> upcoming = m_queue->upcomingTracks();
|
||||
const int playNextCount = m_queue->playNextCount();
|
||||
|
||||
m_countLabel->setText(tr("Up next: %1 track(s)").arg(upcoming.size()));
|
||||
m_clearBtn->setEnabled(!upcoming.isEmpty());
|
||||
|
||||
for (int i = 0; i < upcoming.size(); ++i) {
|
||||
const QJsonObject &t = upcoming.at(i);
|
||||
const QString title = t["title"].toString();
|
||||
const QString artist = t["performer"].toObject()["name"].toString().isEmpty()
|
||||
? t["album"].toObject()["artist"].toObject()["name"].toString()
|
||||
: t["performer"].toObject()["name"].toString();
|
||||
|
||||
const QString text = artist.isEmpty()
|
||||
? title
|
||||
: QStringLiteral("%1 — %2").arg(artist, title);
|
||||
|
||||
auto *item = new QListWidgetItem(text, m_list);
|
||||
item->setData(UpcomingIndexRole, i);
|
||||
item->setData(IsPlayNextRole, i < playNextCount);
|
||||
|
||||
// "Play Next" tracks shown slightly differently
|
||||
if (i < playNextCount) {
|
||||
QFont f = item->font();
|
||||
f.setItalic(true);
|
||||
item->setFont(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void QueuePanel::onItemDoubleClicked(QListWidgetItem *item)
|
||||
{
|
||||
// Double-clicking an upcoming item is not needed for now (could skip to it later)
|
||||
Q_UNUSED(item)
|
||||
}
|
||||
|
||||
void QueuePanel::onContextMenu(const QPoint &pos)
|
||||
{
|
||||
auto *item = m_list->itemAt(pos);
|
||||
if (!item) return;
|
||||
|
||||
const int idx = item->data(UpcomingIndexRole).toInt();
|
||||
|
||||
QMenu menu(this);
|
||||
auto *removeAct = menu.addAction(tr("Remove from queue"));
|
||||
auto *toTopAct = menu.addAction(tr("Move to top (play next)"));
|
||||
|
||||
connect(removeAct, &QAction::triggered, this, [this, idx] {
|
||||
m_queue->removeUpcoming(idx);
|
||||
});
|
||||
connect(toTopAct, &QAction::triggered, this, [this, idx] {
|
||||
m_queue->moveUpcomingToTop(idx);
|
||||
});
|
||||
|
||||
menu.exec(m_list->viewport()->mapToGlobal(pos));
|
||||
}
|
||||
27
src/view/queuepanel.hpp
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "../playqueue.hpp"
|
||||
|
||||
#include <QDockWidget>
|
||||
#include <QListWidget>
|
||||
#include <QPushButton>
|
||||
#include <QLabel>
|
||||
|
||||
class QueuePanel : public QDockWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit QueuePanel(PlayQueue *queue, QWidget *parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void refresh();
|
||||
void onItemDoubleClicked(QListWidgetItem *item);
|
||||
void onContextMenu(const QPoint &pos);
|
||||
|
||||
private:
|
||||
PlayQueue *m_queue = nullptr;
|
||||
QLabel *m_countLabel = nullptr;
|
||||
QListWidget *m_list = nullptr;
|
||||
QPushButton *m_clearBtn = nullptr;
|
||||
};
|
||||