Compare commits

...
Author SHA1 Message Date
e49362256d Merge go application port 2026-08-14 11:57:42 +02:00
690f0f8e9a Add back noise texture
Add back the noise texture background by embedding the image file and setting it
as base64-encoded data as CSS background image.
2026-08-14 11:54:59 +02:00
b7c0307f6b Add GTK application implementation 2026-08-14 11:54:59 +02:00
d1bb505e0a Add c-based localization support 2026-08-14 11:54:59 +02:00
4a11ea5ad0 Add MPD client implementation 2026-08-14 11:54:59 +02:00
af4fc2fb64 Add common go types 2026-08-14 11:54:59 +02:00
377e57ee43 Add basic go application and remove python code 2026-08-14 11:54:59 +02:00
345e7697ff Bump version to 4.0.2 2026-01-10 16:35:46 +01:00
7d474598e3 Center non-square album covers on grid views (close #112) 2026-01-10 16:34:13 +01:00
9b29f7b274 Preserve aspect ratio of album covers in grid views (close #111) 2026-01-10 16:23:30 +01:00
0a109bc886 Fix handling and logging of thumbnail save failures 2026-01-10 16:04:43 +01:00
9311f9974a Do not try to convert default icon to GDK pixbuf (close #110) 2026-01-10 15:42:24 +01:00
08cd9dbe65 Set pixel size for “standalone” images (close #109) 2026-01-10 15:41:12 +01:00
099adbab8c Bump version to 4.0.1 2025-04-06 17:36:02 +02:00
3d91ab1b35 Scroll library to the beginning after loading items (close #108) 2025-04-06 17:33:16 +02:00
dce1c441a0 Fix READMe to call meson’s “setup” command explicitly 2025-04-06 16:55:05 +02:00
cd4f32e7f2 Fix alignment of tracks on Cover panel (close #106)
GTK 4 centers marks of the Scale widget even when the scale has a vertical
orientation. Unfortunately, the Scale widget does not provide a way to set the
alignment or to access the internal Label widget in any way. To left-align the
labels this commit add a method that traverses the all children of the songs
scale recursively and adjusts the alignment if it is a Label widget.
2025-04-06 16:52:10 +02:00
79b3111fb0 Fix reacting to window resizing (close #107)
Replace the handlers for the “default-width” and “default-height” with an
override to the virtual “size_allocate” method to reliably react to Window
resizing.
2025-04-06 16:03:39 +02:00
f3e3f920a5 Bump version to 4.0 2024-11-24 14:35:40 +01:00
84103229f1 Fix conditions using None (see #103) 2024-11-23 16:35:44 +01:00
ccf68deff7 Remove unused (commented out) code (see #103) 2024-11-23 16:35:19 +01:00
2b1edc715f Mark strings as Regular Expressions (see #103) 2024-11-23 16:29:35 +01:00
4586494f8d Pass version to application (see #103) 2024-11-23 16:26:13 +01:00
188dcb4ef8 fixup! Improve variable names to match Code Style Guide (see #103) 2024-11-03 10:56:25 +01:00
99958eabc9 Fix some comments and remove German ones 2024-11-03 10:55:52 +01:00
bcfd17a8ae Remove unused variables (see #103) 2024-11-03 10:54:41 +01:00
420946d7d4 Declare static methods with annotation (see #103) 2024-11-03 10:42:29 +01:00
54fe825acf Improve variable names to match Code Style Guide (see #103) 2024-05-28 12:03:07 +02:00
4ebfc12f0e Fix import statements and order (see #103) 2024-05-28 11:47:30 +02:00
a1f8b73590 Adjust line length to match Code Style Guide (see #103) 2024-05-28 11:47:30 +02:00
d2e1f6f5d8 Adjust blank lines to match Code Style Guide (see #103) 2024-05-28 11:47:30 +02:00
75b99e5820 Port UI to GTK 4 (close #85) 2024-05-23 13:02:51 +02:00
6ba8bc550f Bump version to 3.2.1 2023-02-28 13:18:07 +01:00
21bd0f5832 Fix setting sort type on Library panel at startup (close #95) 2023-02-28 12:55:26 +01:00
76 changed files with 5689 additions and 6671 deletions

8
.gitignore vendored
View file

@ -1,7 +1 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
/build/
/install/
/mcg*

26
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,26 @@
# Contributing
There are multiple ways to contribute code to this project:
* Submit a Pull Request on
[https://git.suruatoel.xyz/coderkun/mcg](git.suruatoel.xyz/coderkun/mcg) or
[https://codeberg.org/suruatoel/mcg](codeberg.org/suruatoel/mcg)
* Send a git pull request via eMail
* Send a patch via eMail
## Internationalization
Update `.pot` files:
$ xgettext --language=Go --keyword=T --from-code=UTF-8 --package-name=mcg --output=po/mcg-go.pot $(git ls-files '*.go')
$ xgettext --language=Glade --from-code=UTF-8 --output=po/mcg-ui.pot $(git ls-files '*.ui')
$ msgcat --use-first -o po/mcg.pot po/mcg-go.pot po/mcg-ui.pot
Use a tool like [Poedit](https://poedit.net/) to create or update translations
in the `locale/` folder.
## AI Policy
This project uses a _No AI policy_. Any code you submit to this project has to
be written by yourself. If you put this projects code into any AI tool, you
will be banned from this project.

View file

@ -1,22 +0,0 @@
h1. Contributing
There are several ways to participate in this project. This guide covers only the contribution of code (i.e. any change to any file in the repository).
There are basically two ways to contribute code to this project: Either via GitLab or via email. The first one is probably easier for most people. However
it requires a account at GitLab.com. For people who dont want to create an account—or have any other reason to not use GitLab—email is an alternative option.
h2. Contributing via GitLab
Create an account on GitLab.com or use your already existing account. Fork this repository inside GitLab, add changes to your fork and finally create a Merge Request to contribute your changes back to this repository.
h2. Contributing via Email
To contribute via email, first clone this repository to your local harddrive. After adding changes to your local copy you have two options to send them back to this repository: as patch or as Pull Request
h3. Email Patch
To send a patch with your changes, use gits @send-email@ option as explained in "Public Project over Email":https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_project_over_email of section "5.2 Distributed Git - Contributing to a Project":https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project of the "Git Pro Book":https://git-scm.com/book/en/v2/. You will find the email address at "suruatoel.xyz":https://www.suruatoel.xyz.
h3. Email Pull Request
Sending a Pull Request via email requires to publish your copy of the repository (that includes your changes) at a publicly available URL (it is your responsibility to choose a trusted host). Then use gits @request-pull@ command to send an email with your pull request. The email will contain a link to the public repository which can be used to pull your changes from. You will find the email address at "suruatoel.xyz":https://www.suruatoel.xyz. For more details, please take a look at "Forked Public Project":https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_public_project of section "5.2 Distributed Git - Contributing to a Project":https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project of the "Git Pro Book":https://git-scm.com/book/en/v2.

View file

@ -1,5 +1,5 @@
CoverGrid (mcg) is a client for the Music Player Daemon, focusing on albums instead of single tracks.
Copyright ® 2013 2017 coderkun <olli@suruatoel.xyz>
Copyright ® 2013 2026 Olli <olli@suruatoel.xyz>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by

View file

@ -5,59 +5,51 @@ Daemon](http://www.musicpd.org) (MPD), focusing on albums instead of single
tracks. It is not intended to be a replacement for your favorite MPD client but
an addition to get a better album-experience.
Website: https://www.suruatoel.xyz/codes/mcg
Website: [https://www.suruatoel.xyz/codes/mcg](www.suruatoel.xyz/codes/mcg)
License: [GPL](http://www.gnu.org/licenses/gpl.html) v3
Dependencies:
* [Python](http://www.python.org) 3
* [python-dateutil](https://pypi.org/project/python-dateutil/)
* [GTK](http://www.gtk.org) 3 (>= 3.22) ([python-gobject](https://live.gnome.org/PyGObject))
* [Avahi](http://www.avahi.org) (optional)
* [python-keyring](http://pypi.python.org/pypi/keyring) (optional)
* [meson](https://mesonbuild.com/) and [ninja](https://ninja-build.org/) (building)
* [GTK](http://www.gtk.org) 4 (>= 4.18)
* [libadwaita](https://gnome.pages.gitlab.gnome.org/libadwaita/) (>= 1.2)
* [go](https://go.dev/) (build only)
Additionally a [MPD](http://www.musicpd.org) server (version >= 0.21.0) is
Additionally, an [MPD](http://www.musicpd.org) server (version >= 0.21.0) is
required at runtime.
## Building
Build the application with _meson_ and _ninja_:
Build the application with _go_:
$ meson build
$ ninja -C build
$ go build .
## Running/Testing
## Running
For testing the application and running it without (system-wide) installation,
donwload/clone the code, build it with the `--prefix` option and install it
with `ninja`:
Run the application for testing purposes:
$ meson --prefix $(pwd)/install build
$ ninja -C build
$ ninja -C build install
$ go run .
After that you can run it with _mesons_ `devenv` command:
Run the application:
$ meson devenv -C build src/mcg
$ ./mcg
## Installing
## Packaging
Install the application system-wide with _meson_ (after building):
The suggested package name for this project is “mcg”. To build a production
binary, please use the following command:
$ ninja -C build install
$ go build --ldflags="-s -w -X main.Version=$(git describe)" -trimpath ./...
Note: _On Linux using the distributions package manager is the preferred way
of installing applications system-wide._
In the “data/” folder you will find a `.desktop` file and an SVG icon.
## Screenshots
![CoverGrids cover panel with album details and track list.](https://suruatoel.xyz/images/mcg-cover-s.png "CoverGrids cover panel with album details and track list.")
![CoverGrids cover panel with album details and track list.](https://www.suruatoel.xyz/images/mcg-cover-s.png "CoverGrids cover panel with album details and track list.")
![CoverGrids playlist panel with queued albums.](https://suruatoel.xyz/images/mcg-playlist.png "CoverGrids playlist panel with queued albums.")
![CoverGrids playlist panel with queued albums.](https://www.suruatoel.xyz/images/mcg-playlist.png "CoverGrids playlist panel with queued albums.")
![CoverGrids library panel showing the albums middle-sized.](https://suruatoel.xyz/images/mcg-library-m.png "CoverGrids library panel showing the albums middle-sized.")
![CoverGrids library panel showing the albums middle-sized.](https://www.suruatoel.xyz/images/mcg-library-m.png "CoverGrids library panel showing the albums middle-sized.")
![CoverGrids library panel showing the albums small-sized.](https://suruatoel.xyz/images/mcg-library-s.png "CoverGrids library panel showing the albums small-sized.")
![CoverGrids library panel showing the albums small-sized.](https://www.suruatoel.xyz/images/mcg-library-s.png "CoverGrids library panel showing the albums small-sized.")

43
data/data.go Normal file
View file

@ -0,0 +1,43 @@
package data
import (
_ "embed"
"encoding/base64"
"strings"
)
//go:embed ui/window.ui
var WindowXML string
//go:embed ui/shortcuts-dialog.ui
var ShortcutsDialogXML string
//go:embed ui/connection-panel.ui
var ConnectionPanelXML string
//go:embed ui/server-panel.ui
var ServerPanelXML string
//go:embed ui/cover-panel.ui
var CoverPanelXML string
//go:embed ui/playlist-panel.ui
var PlaylistPanelXML string
//go:embed ui/album-headerbar.ui
var AlbumHeaderbarXML string
//go:embed ui/library-panel.ui
var LibraryPanelXML string
//go:embed gtk.css
var MainCSS string
//go:embed noise-texture.png
var NoiseTexture []byte
func BuildMainCSS() string {
noiseTextureEncoded := "data:image/png;base64," + base64.StdEncoding.EncodeToString(NoiseTexture)
return strings.ReplaceAll(MainCSS, "@NOISETEXTURE@", noiseTextureEncoded)
}

View file

@ -1,6 +1,18 @@
.bg-texture {
#content_stack {
box-shadow:inset 4px 4px 10px rgba(0,0,0,0.3);
background-image:url('noise-texture.png');
background-image:url("@NOISETEXTURE@");
}
#port_spinner {
background:none;
margin-top:-13px;
}
#port_spinner text {
padding-left: 0;
}
#server_stack_sidebar {
background-color:alpha(@theme_bg_color, 1);
}
.no-bg {
@ -18,13 +30,13 @@
font-weight:bold;
}
revealer.sidebar > * {
#cover_info_revealer {
background-color:alpha(@theme_bg_color, 0.8);
box-shadow:0 0 10px @theme_bg_color;
margin-left:20px
margin-left:20px;
}
revealer.sidebar scale mark indicator {
#cover_info_revealer scale mark indicator {
margin-right:5px;
}
@ -32,24 +44,13 @@ actionbar {
background-color:@theme_unfocused_bg_color;
}
/* Icon View in regular mode */
iconview.view:selected,
iconview.view:selected:focus {
background-color:@theme_selected_bg_color;
gridview child {
padding: 1px;
}
iconview.view:hover {
-gtk-icon-effect:highlight;
gridview.selection child {
opacity: 0.5;
}
/* Icon View in selection mode */
iconview.view.selection {
-gtk-icon-effect:dim;
}
iconview.view.selection:selected,
iconview.view.selection:selected:focus {
background-color:@theme_selected_bg_color;
-gtk-icon-effect:highlight;
}
iconview.view.selection:hover {
-gtk-icon-effect:none;
gridview.selection child:hover,
gridview.selection child:selected {
opacity: 1;
}

View file

@ -1,7 +0,0 @@
application_id = 'xyz.suruatoel.mcg'
scalable_dir = join_paths('hicolor', 'scalable', 'apps')
install_data(
join_paths(scalable_dir, ('@0@.svg').format(application_id)),
install_dir: join_paths(get_option('datadir'), 'icons', scalable_dir)
)

View file

@ -1,61 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.10"/>
<template class="McgAlbumHeaderbar" parent="GtkHeaderBar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child type="title">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="standalone_title">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Title</property>
<property name="selectable">True</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="standalone_artist">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Artist</property>
<property name="selectable">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
<requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" />
<object id="McgAlbumHeaderbar" class="AdwBin">
<child>
<object class="GtkButton">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_close_clicked" swapped="no"/>
<object class="GtkHeaderBar">
<child type="title">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="standalone_title">
<property name="selectable">True</property>
<attributes>
<attribute name="weight" value="bold"/>
<attribute name="scale" value="1"/>
</attributes>
</object>
</child>
<child>
<object class="GtkLabel" id="standalone_artist">
<property name="selectable">True</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">go-previous-symbolic</property>
<object id="close_button" class="GtkButton">
<property name="receives-default">True</property>
<child>
<object class="GtkImage">
<property name="icon-name">go-previous-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
</template>
</object>
</interface>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.12"/>
<requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" />
<object class="GtkAdjustment" id="server-port-adjustment">
<property name="lower">1024</property>
<property name="upper">9999</property>
@ -9,174 +9,58 @@
<property name="step-increment">1</property>
<property name="page-increment">100</property>
</object>
<template class="McgConnectionPanel" parent="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<object id="McgConnectionPanel" class="AdwBin">
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<object class="AdwStatusPage">
<property name="title">Connect to MPD</property>
<child>
<!-- n-columns=3 n-rows=6 -->
<object class="GtkGrid">
<property name="width-request">500</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="column-spacing">5</property>
<property name="row-homogeneous">True</property>
<property name="column-homogeneous">True</property>
<object class="GtkListBox">
<property name="hexpand">false</property>
<property name="halign">center</property>
<property name="selection-mode">none</property>
<style>
<class name="boxed-list"/>
</style>
<child>
<object class="AdwEntryRow" id="host_row">
<property name="title" translatable="yes">Host</property>
<property name="show-apply-button">true</property>
</object>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<style>
<class name="header"/>
</style>
<child>
<object class="GtkTreeView" id="zeroconf_list">
<property name="visible">True</property>
<property name="can-focus">True</property>
<signal name="focus-out-event" handler="on_zeroconf_list_outfocused" swapped="no"/>
<child internal-child="selection">
<object class="GtkTreeSelection">
<signal name="changed" handler="on_service_selected" swapped="no"/>
</object>
</child>
<object class="GtkLabel">
<property name="label" translatable="yes">Port</property>
<property name="halign">start</property>
<property name="hexpand">false</property>
<style>
<class name="subtitle"/>
</style>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="orientation">vertical</property>
<object class="GtkSpinButton" id="port_spinner">
<property name="name">port_spinner</property>
<property name="value">6600</property>
<property name="adjustment">server-port-adjustment</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">0</property>
<property name="height">6</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="host_entry">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="text">localhost</property>
<property name="placeholder-text" translatable="yes">Enter hostname or IP address</property>
<signal name="focus-out-event" handler="on_host_entry_outfocused" swapped="no"/>
<object class="AdwPasswordEntryRow" id="password_row">
<property name="title" translatable="yes">Password</property>
<property name="show-apply-button">true</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="password_entry">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="visibility">False</property>
<property name="placeholder-text" translatable="yes">Enter password or leave blank</property>
<property name="input-purpose">password</property>
<signal name="focus-out-event" handler="on_password_entry_outfocused" swapped="no"/>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">5</property>
</packing>
</child>
<child>
<object class="GtkSpinButton" id="port_spinner">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="text">6600</property>
<property name="input-purpose">number</property>
<property name="adjustment">server-port-adjustment</property>
<property name="value">6600</property>
<signal name="value-changed" handler="on_port_spinner_value_changed" swapped="no"/>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">3</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Host:</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Port:</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Password:</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">4</property>
</packing>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</template>
</object>
</interface>

View file

@ -1,187 +1,153 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.22"/>
<template class="McgCoverPanel" parent="GtkOverlay">
<property name="visible">True</property>
<property name="can-focus">False</property>
<requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" />
<object class="GtkBox" id="toolbar">
<property name="orientation">horizontal</property>
<property name="halign">end</property>
<property name="spacing">6</property>
<child>
<object class="GtkButton" id="fullscreen_button">
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Show the cover in fullscreen mode</property>
<property name="action-name">win.toggle-fullscreen</property>
<child>
<object class="GtkImage">
<property name="icon-name">view-fullscreen-symbolic</property>
</object>
</child>
</object>
</child>
</object>
<object id="McgCoverPanel" class="GtkOverlay">
<child>
<object class="GtkStack" id="cover_stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="visible-child">cover_default</property>
<child>
<object class="GtkSpinner" id="cover_spinner">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="name">cover-spinner</property>
</packing>
</child>
<child>
<object class="GtkImage" id="cover_default">
<property name="icon-name">image-x-generic-symbolic</property>
<property name="icon-size">large</property>
</object>
</child>
<child>
<object class="GtkScrolledWindow" id="cover_scroll">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="kinetic-scrolling">False</property>
<property name="overlay-scrolling">False</property>
<signal name="size-allocate" handler="on_cover_size_allocate" swapped="no"/>
<property name="hexpand">true</property>
<property name="halign">fill</property>
<property name="vexpand">true</property>
<property name="valign">fill</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<object class="GtkViewport" id="cover_box">
<property name="name">cover_box</property>
<property name="hexpand">true</property>
<property name="halign">fill</property>
<property name="vexpand">true</property>
<property name="valign">fill</property>
<child>
<object class="GtkEventBox" id="cover_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<signal name="button-press-event" handler="on_cover_box_pressed" swapped="no"/>
<child>
<object class="GtkImage" id="cover_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon_size">0</property>
</object>
</child>
<object class="GtkImage" id="cover_image">
<property name="hexpand">true</property>
<property name="halign">fill</property>
<property name="vexpand">true</property>
<property name="valign">fill</property>
<property name="icon-name">image-x-generic-symbolic</property>
<property name="icon-size">large</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="name">cover-scroll</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="index">-1</property>
</packing>
</child>
<child type="overlay">
<object class="GtkRevealer" id="info_revealer">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="transition-type">slide-right</property>
<property name="name">cover_info_revealer</property>
<style>
<class name="background"/>
</style>
<child>
<object class="GtkScrolledWindow" id="cover_info_scroll">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="halign">start</property>
<property name="vscrollbar-policy">never</property>
<property name="max-content-width">200</property>
<property name="propagate-natural-width">True</property>
<property name="name">cover_info_scroll</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="halign">start</property>
<property name="valign">fill</property>
<property name="vexpand">true</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="orientation">vertical</property>
<child>
<!-- n-columns=3 n-rows=3 -->
<object class="GtkGrid">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-bottom">5</property>
<property name="row-spacing">5</property>
<property name="column-homogeneous">True</property>
<style>
<class name="cover-labels"/>
</style>
<child>
<object class="GtkLabel" id="album_title_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label">Album</property>
<property name="wrap">True</property>
<property name="xalign">0</property>
<layout>
<property name="column">0</property>
<property name="row">0</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="album_date_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label">Date</property>
<property name="wrap">True</property>
<property name="xalign">0</property>
<layout>
<property name="column">0</property>
<property name="row">1</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="album_artist_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label">Artist</property>
<property name="wrap">True</property>
<property name="xalign">0</property>
<layout>
<property name="column">0</property>
<property name="row">2</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<style>
<class name="cover-labels"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="padding">10</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScale" id="songs_scale">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="orientation">vertical</property>
<property name="halign">start</property>
<property name="valign">fill</property>
<property name="vexpand">true</property>
<property name="restrict-to-fill-level">False</property>
<property name="digits">0</property>
<property name="draw-value">False</property>
<signal name="button-press-event" handler="on_songs_start_change" swapped="no"/>
<signal name="button-release-event" handler="on_songs_change" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
@ -189,11 +155,7 @@
</child>
</object>
</child>
<style>
<class name="sidebar"/>
<class name="background"/>
</style>
</object>
</child>
</template>
</object>
</interface>

View file

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.10"/>
<template class="McgCoverToolbar" parent="GtkButtonBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">6</property>
<property name="layout-style">end</property>
<child>
<object class="GtkButton" id="fullscreen_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Show the cover in fullscreen mode</property>
<property name="action-name">win.toggle-fullscreen</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">view-fullscreen-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
<property name="non-homogeneous">True</property>
</packing>
</child>
</template>
</interface>

View file

@ -1,75 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<menu id="app-menu">
<section>
<item>
<attribute name="action">win.connect</attribute>
<attribute name="label" translatable="yes">Connect</attribute>
<attribute name="accel">&lt;Primary&gt;c</attribute>
</item>
<item>
<attribute name="action">win.play</attribute>
<attribute name="label" translatable="yes">Play</attribute>
<attribute name="accel">&lt;Primary&gt;p</attribute>
</item>
<item>
<attribute name="action">win.clear-playlist</attribute>
<attribute name="label" translatable="yes">Clear Playlist</attribute>
<attribute name="accel">&lt;Primary&gt;r</attribute>
</item>
<item>
<attribute name="action">win.toggle-fullscreen</attribute>
<attribute name="label" translatable="yes">Toggle Fullscreen</attribute>
<attribute name="accel">F11</attribute>
</item>
<item>
<attribute name="action">win.search-library</attribute>
<attribute name="label" translatable="yes">Search Library</attribute>
<attribute name="accel">&lt;Primary&gt;f</attribute>
</item>
</section>
<section>
<item>
<attribute name="action">win.panel</attribute>
<attribute name="label" translatable="yes">Connection</attribute>
<attribute name="target">0</attribute>
<attribute name="accel">&lt;Primary&gt;KP_1</attribute>
</item>
<item>
<attribute name="action">win.panel</attribute>
<attribute name="label" translatable="yes">Cover</attribute>
<attribute name="target">1</attribute>
<attribute name="accel">&lt;Primary&gt;KP_2</attribute>
</item>
<item>
<attribute name="action">win.panel</attribute>
<attribute name="label" translatable="yes">Playlist</attribute>
<attribute name="target">2</attribute>
<attribute name="accel">&lt;Primary&gt;KP_3</attribute>
</item>
<item>
<attribute name="action">win.panel</attribute>
<attribute name="label" translatable="yes">Library</attribute>
<attribute name="target">3</attribute>
<attribute name="accel">&lt;Primary&gt;KP_4</attribute>
</item>
</section>
<section>
<item>
<attribute name="action">win.show-help-overlay</attribute>
<attribute name="label" translatable="yes">Keyboard Shortcuts</attribute>
<attribute name="accel">&lt;Primary&gt;k</attribute>
</item>
<item>
<attribute name="action">app.info</attribute>
<attribute name="label" translatable="yes">Info</attribute>
<attribute name="accel">&lt;Primary&gt;i</attribute>
</item>
<item>
<attribute name="action">app.quit</attribute>
<attribute name="label" translatable="yes">Quit</attribute>
<attribute name="accel">&lt;Primary&gt;q</attribute>
</item>
</section>
</menu>
</interface>

View file

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.10"/>
<template class="McgInfoDialog" parent="GtkAboutDialog">
<property name="can-focus">False</property>
<property name="resizable">False</property>
<property name="modal">True</property>
<property name="window-position">center</property>
<property name="type-hint">dialog</property>
<property name="gravity">center</property>
<property name="program-name">CoverGrid</property>
<property name="version">3.2</property>
<property name="comments" translatable="yes">CoverGrid is a client for the Music Player Daemon, focusing on albums instead of single tracks.</property>
<property name="website">http://www.suruatoel.xyz/codes/mcg</property>
<property name="logo-icon-name">xyz.suruatoel.mcg</property>
<property name="license-type">gpl-3-0</property>
<child internal-child="vbox">
<object class="GtkBox">
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">2</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can-focus">False</property>
<property name="layout-style">end</property>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
</child>
</template>
</interface>

View file

@ -1,303 +1,288 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.16"/>
<template class="McgLibraryPanel" parent="GtkStack">
<property name="visible">True</property>
<requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" />
<object class="GtkAdjustment" id="grid_adjustment">
<property name="lower">100</property>
<property name="upper">1000</property>
<property name="value">150</property>
<property name="step-increment">1</property>
<property name="page-increment">10</property>
</object>
<object class="GtkPopover" id="toolbar_popover">
<property name="can-focus">False</property>
<property name="transition-type">slide-left-right</property>
<child>
<object class="GtkBox" id="panel_normal">
<property name="visible">True</property>
<property name="can-focus">False</property>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="halign">end</property>
<child>
<object class="GtkSearchBar" id="filter_bar">
<object class="GtkScale" id="grid_scale">
<property name="width-request">350</property>
<property name="visible">True</property>
<property name="app-paintable">True</property>
<property name="can-focus">False</property>
<signal name="notify" handler="on_filter_bar_notify" swapped="no"/>
<child>
<object class="GtkSearchEntry" id="filter_entry">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="primary-icon-name">edit-find-symbolic</property>
<property name="primary-icon-activatable">False</property>
<property name="primary-icon-sensitive">False</property>
<property name="placeholder-text" translatable="yes">search library</property>
<signal name="search-changed" handler="on_filter_entry_changed" swapped="no"/>
</object>
</child>
<property name="can-focus">True</property>
<property name="adjustment">grid_adjustment</property>
<property name="restrict-to-fill-level">False</property>
<property name="fill-level">-1</property>
<property name="round-digits">0</property>
<property name="digits">0</property>
<property name="has-origin">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<placeholder/>
<object class="GtkButton" id="library-toolbar-update">
<property name="label" translatable="yes">update library</property>
</object>
</child>
<child>
<object class="GtkStack" id="stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-type">crossfade</property>
<object class="GtkBox" id="library-toolbar-sort">
<property name="orientation">vertical</property>
<child>
<object class="GtkBox" id="progress_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<object class="GtkSeparator">
<property name="orientation">vertical</property>
<child type="center">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child type="center">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">center</property>
<property name="orientation">vertical</property>
<property name="spacing">10</property>
<child>
<object class="GtkImage" id="progress_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon_size">6</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkProgressBar" id="progress_bar">
<property name="width-request">200</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="pulse-step">0</property>
<property name="show-text">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="name">page1</property>
<property name="title">page1</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="scroll">
<object class="GtkLabel">
<property name="label" translatable="yes">Sort</property>
</object>
</child>
<child>
<object class="GtkCheckButton" id="sort_artist">
<property name="label" translatable="yes">sort by artist</property>
<property name="receives-default">False</property>
<property name="group">sort_year</property>
</object>
</child>
<child>
<object class="GtkCheckButton" id="sort_title">
<property name="label" translatable="yes">sort by title</property>
<property name="receives-default">False</property>
<property name="group">sort_year</property>
</object>
</child>
<child>
<object class="GtkCheckButton" id="sort_year">
<property name="label" translatable="yes">sort by year</property>
<property name="receives-default">False</property>
</object>
</child>
<child>
<object class="GtkCheckButton" id="sort_modified">
<property name="label" translatable="yes">sort by modification</property>
<property name="receives-default">False</property>
<property name="group">sort_year</property>
</object>
</child>
<child>
<object class="GtkCheckButton" id="toolbar_sort_order_button">
<property name="label" translatable="yes">sort library descending</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<child>
<object class="GtkIconView" id="library_grid">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="margin">0</property>
<property name="item-orientation">horizontal</property>
<property name="row-spacing">0</property>
<property name="column-spacing">0</property>
<property name="tooltip-column">1</property>
<property name="item-padding">0</property>
<property name="activate-on-single-click">True</property>
<signal name="item-activated" handler="on_library_grid_clicked" swapped="no"/>
<signal name="selection-changed" handler="on_library_grid_selection_changed" swapped="no"/>
<signal name="size-allocate" handler="on_resize" swapped="no"/>
<style>
<class name="no-bg"/>
</style>
</object>
</child>
<style>
<class name="no-bg"/>
</style>
</object>
<packing>
<property name="name">page0</property>
<property name="title">page0</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkRevealer" id="actionbar_revealer">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-type">none</property>
<child>
<object class="GtkActionBar" id="library-actionbar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">cancel</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_selection_cancel_clicked" swapped="no"/>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">queue</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_selection_add_clicked" swapped="no"/>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">0</property>
</packing>
</child>
<property name="receives-default">False</property>
<property name="active">True</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="name">page0</property>
<property name="title">page0</property>
</packing>
</child>
</object>
<object class="GtkBox" id="toolbar">
<property name="orientation">horizontal</property>
<property name="halign">end</property>
<property name="spacing">6</property>
<child>
<object class="GtkToggleButton" id="toolbar_search_bar">
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Search the library</property>
<child>
<object class="GtkImage">
<property name="icon-name">system-search-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="panel_standalone">
<object class="GtkToggleButton" id="select_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Select multiple albums</property>
<child>
<object class="GtkStack" id="standalone_stack">
<object class="GtkImage">
<property name="icon-name">object-select-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkMenuButton">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Settings and actions</property>
<property name="popover">toolbar_popover</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-type">crossfade</property>
<property name="icon-name">open-menu-symbolic</property>
</object>
</child>
</object>
</child>
</object>
<object id="McgLibraryPanel" class="AdwBin">
<child>
<object class="GtkStack" id="library_stack">
<property name="transition-type">slide-left-right</property>
<child>
<object class="GtkBox" id="panel_normal">
<property name="orientation">vertical</property>
<child>
<object class="GtkSpinner" id="standalone_spinner">
<property name="visible">True</property>
<property name="can-focus">False</property>
<object class="GtkSearchBar" id="filter_bar">
<property name="search-mode-enabled" bind-source="toolbar_search_bar" bind-property="active" bind-flags="sync-create"/>
<child>
<object class="GtkSearchEntry" id="filter_entry">
<property name="placeholder-text" translatable="yes">search library</property>
</object>
</child>
</object>
<packing>
<property name="name">standalone-spinne</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="standalone_scroll">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="kinetic-scrolling">False</property>
<property name="overlay-scrolling">False</property>
<signal name="size-allocate" handler="on_standalone_scroll_size_allocate" swapped="no"/>
<object class="GtkStack" id="stack">
<property name="transition-type">crossfade</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<object class="GtkBox" id="progress_box">
<property name="orientation">vertical</property>
<property name="valign">center</property>
<child>
<object class="GtkImage" id="standalone_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">gtk-missing-image</property>
<property name="icon_size">6</property>
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkBox">
<property name="halign">center</property>
<property name="orientation">vertical</property>
<property name="spacing">10</property>
<child>
<object class="GtkImage" id="progress_image">
<property name="icon-size">large</property>
<property name="icon-name">image-x-generic-symbolic</property>
</object>
</child>
<child>
<object class="GtkProgressBar" id="progress_bar">
<property name="width-request">200</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="pulse-step">0</property>
<property name="show-text">True</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="name">standalone-scroll</property>
<property name="position">1</property>
</packing>
<child>
<object class="GtkScrolledWindow" id="scroll">
<property name="vexpand">true</property>
<child>
<object class="GtkGridView" id="library_grid">
<property name="orientation">vertical</property>
<property name="single_click_activate">true</property>
<style>
<class name="no-bg"/>
</style>
</object>
</child>
<style>
<class name="no-bg"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkRevealer" id="actionbar_revealer">
<property name="transition-type">slide-up</property>
<child>
<object class="GtkActionBar" id="actionbar">
<child type="end">
<object class="GtkButton" id="selection_cancel_button">
<property name="label" translatable="yes">cancel</property>
<property name="receives-default">True</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="selection_queue_button">
<property name="label" translatable="yes">queue</property>
<property name="receives-default">True</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="panel_standalone">
<property name="orientation">vertical</property>
<child>
<object class="GtkStack" id="standalone_stack">
<property name="vexpand">true</property>
<property name="transition-type">crossfade</property>
<child>
<object class="GtkSpinner" id="standalone_spinner">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
</child>
<child>
<object class="GtkScrolledWindow" id="standalone_scroll">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="kinetic-scrolling">False</property>
<property name="overlay-scrolling">False</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkImage" id="standalone_image">
<property name="icon-name">gtk-missing-image</property>
<property name="icon-size">large</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkActionBar" id="actionbar_standalone">
<child type="end">
<object class="GtkButton" id="standalone_play_button">
<property name="label" translatable="yes">play</property>
<property name="receives-default">True</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="standalone_queue_button">
<property name="label" translatable="yes">queue</property>
<property name="receives-default">True</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkActionBar" id="library-standalone-actionbar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">queue</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_standalone_queue_clicked" swapped="no"/>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">play</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_standalone_play_clicked" swapped="no"/>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="name">page1</property>
<property name="title">page1</property>
<property name="position">1</property>
</packing>
</child>
</template>
</object>
</interface>

View file

@ -1,248 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkAdjustment" id="grid_adjustment">
<property name="lower">100</property>
<property name="upper">1000</property>
<property name="value">150</property>
<property name="step-increment">1</property>
<property name="page-increment">10</property>
</object>
<object class="GtkPopover" id="toolbar_popover">
<property name="can-focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkScale" id="grid_scale">
<property name="width-request">350</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="adjustment">grid_adjustment</property>
<property name="restrict-to-fill-level">False</property>
<property name="fill-level">-1</property>
<property name="round-digits">0</property>
<property name="digits">0</property>
<property name="has-origin">False</property>
<signal name="button-release-event" handler="on_grid_scale_changed" swapped="no"/>
<signal name="change-value" handler="on_grid_scale_change" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="library-toolbar-update">
<property name="label">gtk-refresh</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="relief">none</property>
<property name="use-stock">True</property>
<signal name="clicked" handler="on_update_clicked" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="library-toolbar-sort">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Sort</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="sort_artist">
<property name="label" translatable="yes">sort by artist</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="draw-indicator">True</property>
<property name="group">sort_year</property>
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="sort_title">
<property name="label" translatable="yes">sort by title</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="draw-indicator">True</property>
<property name="group">sort_year</property>
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="sort_year">
<property name="label" translatable="yes">sort by year</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">True</property>
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="sort_modified">
<property name="label" translatable="yes">sort by modification</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="draw-indicator">True</property>
<property name="group">sort_year</property>
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">5</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="toolbar_sort_order_button">
<property name="label">gtk-sort-descending</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="use-stock">True</property>
<property name="active">True</property>
<property name="draw-indicator">True</property>
<signal name="toggled" handler="on_sort_order_toggled" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">6</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
<template class="McgLibraryToolbar" parent="GtkButtonBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">6</property>
<property name="layout-style">end</property>
<child>
<object class="GtkToggleButton" id="toolbar_search_bar">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Search the library</property>
<signal name="toggled" handler="on_search_toggled" swapped="no"/>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">system-search-symbolic</property>
</object>
</child>
<accelerator key="f" signal="activate" modifiers="GDK_CONTROL_MASK"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
<property name="non-homogeneous">True</property>
</packing>
</child>
<child>
<object class="GtkToggleButton" id="select_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Select multiple albums</property>
<signal name="toggled" handler="on_select_toggled" swapped="no"/>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">object-select-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
<property name="non-homogeneous">True</property>
</packing>
</child>
<child>
<object class="GtkMenuButton">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Settings and actions</property>
<property name="popover">toolbar_popover</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">open-menu-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
<property name="non-homogeneous">True</property>
</packing>
</child>
</template>
</interface>

View file

@ -1,194 +1,140 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.16"/>
<template class="McgPlaylistPanel" parent="GtkStack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-type">slide-left-right</property>
<requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" />
<object class="GtkBox" id="toolbar">
<property name="orientation">horizontal</property>
<property name="halign">end</property>
<property name="spacing">6</property>
<child>
<object class="GtkBox" id="panel_normal">
<object class="GtkToggleButton" id="select_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Select multiple albums</property>
<child>
<object class="GtkScrolledWindow">
<object class="GtkImage">
<property name="icon-name">object-select-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkButton" id="playlist_clear_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Clear the playlist</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-focus">False</property>
<property name="icon-name">edit-clear</property>
</object>
</child>
</object>
</child>
</object>
<object id="McgPlaylistPanel" class="AdwBin">
<child>
<object class="GtkStack" id="playlist_stack">
<property name="transition-type">slide-left-right</property>
<child>
<object class="GtkBox" id="panel_normal">
<property name="orientation">vertical</property>
<child>
<object class="GtkIconView" id="playlist_grid">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="margin">0</property>
<property name="item-orientation">horizontal</property>
<property name="row-spacing">0</property>
<property name="column-spacing">0</property>
<property name="tooltip-column">1</property>
<property name="item-padding">0</property>
<property name="activate-on-single-click">True</property>
<signal name="item-activated" handler="on_playlist_grid_clicked" swapped="no"/>
<signal name="selection-changed" handler="on_playlist_grid_selection_changed" swapped="no"/>
<object class="GtkScrolledWindow">
<property name="vexpand">true</property>
<child>
<object class="GtkGridView" id="playlist_grid">
<property name="orientation">vertical</property>
<property name="single-click-activate">true</property>
<style>
<class name="no-bg"/>
</style>
</object>
</child>
<style>
<class name="no-bg"/>
</style>
</object>
</child>
<style>
<class name="no-bg"/>
</style>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRevealer" id="actionbar_revealer">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-type">slide-up</property>
<child>
<object class="GtkActionBar" id="actionbar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<object class="GtkRevealer" id="actionbar_revealer">
<property name="transition-type">slide-up</property>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">cancel</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_selection_cancel_clicked" swapped="no"/>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">remove</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_selection_remove_clicked" swapped="no"/>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="name">page2</property>
<property name="title">page2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="panel_standalone">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkStack" id="standalone_stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkSpinner" id="standalone_spinner">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="name">standalone-spinne</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="standalone_scroll">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="kinetic-scrolling">False</property>
<property name="overlay-scrolling">False</property>
<signal name="size-allocate" handler="on_standalone_scroll_size_allocate" swapped="no"/>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkImage" id="standalone_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">gtk-missing-image</property>
<property name="icon_size">6</property>
<object class="GtkActionBar" id="actionbar">
<child type="end">
<object class="GtkButton" id="selection_cancel_button">
<property name="label" translatable="yes">cancel</property>
<property name="receives-default">True</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="selection_remove_button">
<property name="label" translatable="yes">remove</property>
<property name="receives-default">True</property>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="name">standalone-scroll</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkActionBar" id="actionbar_standalone">
<property name="visible">True</property>
<property name="can-focus">False</property>
<object class="GtkBox" id="panel_standalone">
<property name="orientation">vertical</property>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">remove</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_standalone_remove_clicked" swapped="no"/>
<object class="GtkStack" id="standalone_stack">
<property name="vexpand">true</property>
<child>
<object class="GtkSpinner" id="standalone_spinner">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
</child>
<child>
<object class="GtkScrolledWindow" id="standalone_scroll">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="kinetic-scrolling">False</property>
<property name="overlay-scrolling">False</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkImage" id="standalone_image">
<property name="icon-name">gtk-missing-image</property>
<property name="icon-size">large</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton">
<property name="label" translatable="yes">play</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_standalone_play_clicked" swapped="no"/>
<object class="GtkActionBar" id="actionbar_standalone">
<child type="end">
<object class="GtkButton" id="standalone_play_button">
<property name="label" translatable="yes">play</property>
<property name="receives-default">True</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="standalone_remove_button">
<property name="label" translatable="yes">remove</property>
<property name="receives-default">True</property>
</object>
</child>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="name">page1</property>
<property name="title">page1</property>
<property name="position">1</property>
</packing>
</child>
</template>
</object>
</interface>

View file

@ -1,55 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.10"/>
<template class="McgPlaylistToolbar" parent="GtkButtonBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">6</property>
<property name="layout-style">end</property>
<child>
<object class="GtkToggleButton" id="select_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Select multiple albums</property>
<signal name="toggled" handler="on_select_toggled" swapped="no"/>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">object-select-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
<property name="non-homogeneous">True</property>
</packing>
</child>
<child>
<object class="GtkButton" id="playlist_clear_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Clear the playlist</property>
<signal name="clicked" handler="on_clear_clicked" swapped="no"/>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">edit-clear</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
<property name="non-homogeneous">True</property>
</packing>
</child>
</template>
</interface>

View file

@ -1,547 +1,326 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.16"/>
<template class="McgServerPanel" parent="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" />
<object class="GtkBox" id="toolbar">
<property name="orientation">horizontal</property>
<property name="halign">end</property>
<child>
<object class="GtkStackSidebar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="stack">stack</property>
<object class="GtkToggleButton" id="sidebar_switcher">
<property name="icon-name">sidebar-show-symbolic</property>
<property name="active">true</property>
<property name="visible" bind-source="server_flap" bind-property="folded" bind-flags="sync-create"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<object id="McgServerPanel" class="AdwBin">
<child>
<object class="GtkStack" id="stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child type="center">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child type="center">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="orientation">vertical</property>
<property name="spacing">10</property>
<object class="AdwFlap" id="server_flap">
<property name="flap-position">end</property>
<property name="reveal-flap" bind-source="sidebar_switcher" bind-property="active" bind-flags="sync-create|bidirectional" />
<property name="flap">
<object class="GtkStackSidebar">
<property name="stack">stack</property>
<property name="name">server_stack_sidebar</property>
</object>
</property>
<property name="separator">
<object class="GtkSeparator"/>
</property>
<property name="content">
<object class="GtkStack" id="stack">
<child>
<object class="GtkStackPage">
<property name="name">status</property>
<property name="title" translatable="yes">Status</property>
<property name="child">
<object class="AdwStatusPage">
<property name="icon-name">dialog-information-symbolic</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">dialog-information-symbolic</property>
<property name="icon_size">6</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<!-- n-columns=3 n-rows=4 -->
<object class="GtkGrid">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="row-spacing">2</property>
<property name="column-spacing">5</property>
<property name="hexpand">false</property>
<property name="halign">center</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="valign">start</property>
<property name="label" translatable="yes">File:</property>
<layout>
<property name="column">0</property>
<property name="row">0</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="valign">start</property>
<property name="label" translatable="yes">Audio:</property>
<layout>
<property name="column">0</property>
<property name="row">1</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="valign">start</property>
<property name="label" translatable="yes">Bitrate:</property>
<layout>
<property name="column">0</property>
<property name="row">2</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="valign">start</property>
<property name="label" translatable="yes">Error:</property>
<layout>
<property name="column">0</property>
<property name="row">3</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">3</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="status_bitrate">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">&lt;i&gt;none&lt;/i&gt;</property>
<property name="use-markup">True</property>
<property name="wrap">True</property>
<property name="selectable">True</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="status_audio">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">&lt;i&gt;none&lt;/i&gt;</property>
<property name="use-markup">True</property>
<property name="wrap">True</property>
<property name="selectable">True</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="status_file">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">&lt;i&gt;none&lt;/i&gt;</property>
<property name="use-markup">True</property>
<property name="wrap">True</property>
<property name="selectable">True</property>
<property name="xalign">0</property>
<layout>
<property name="column">1</property>
<property name="row">0</property>
</layout>
</object>
</child>
<child>
<object class="GtkLabel" id="status_audio">
<property name="halign">start</property>
<property name="label" translatable="yes">&lt;i&gt;none&lt;/i&gt;</property>
<property name="use-markup">True</property>
<property name="wrap">True</property>
<property name="selectable">True</property>
<property name="xalign">0</property>
<layout>
<property name="column">1</property>
<property name="row">1</property>
</layout>
</object>
</child>
<child>
<object class="GtkLabel" id="status_bitrate">
<property name="halign">start</property>
<property name="label" translatable="yes">&lt;i&gt;none&lt;/i&gt;</property>
<property name="use-markup">True</property>
<property name="wrap">True</property>
<property name="selectable">True</property>
<property name="xalign">0</property>
<layout>
<property name="column">1</property>
<property name="row">2</property>
</layout>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="status_error">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">&lt;i&gt;none&lt;/i&gt;</property>
<property name="use-markup">True</property>
<property name="wrap">True</property>
<property name="selectable">True</property>
<property name="xalign">0</property>
<layout>
<property name="column">1</property>
<property name="row">3</property>
</layout>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">3</property>
</packing>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="name">page0</property>
<property name="title" translatable="yes">Status</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child type="center">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child type="center">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">10</property>
<object class="GtkStackPage">
<property name="name">stats</property>
<property name="title" translatable="yes">Statistics</property>
<property name="child">
<object class="AdwStatusPage">
<property name="icon-name">starred-symbolic</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">starred-symbolic</property>
<property name="icon_size">6</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<!-- n-columns=3 n-rows=7 -->
<object class="GtkGrid">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="row-spacing">2</property>
<property name="column-spacing">5</property>
<property name="column-homogeneous">true</property>
<property name="hexpand">false</property>
<property name="halign">center</property>
<child>
<object class="GtkLabel" id="stats_artists">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="justify">right</property>
<property name="selectable">True</property>
<layout>
<property name="column">0</property>
<property name="row">0</property>
</layout>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="label" translatable="yes">Artists</property>
<layout>
<property name="column">1</property>
<property name="row">0</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="stats_albums">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="justify">right</property>
<layout>
<property name="column">0</property>
<property name="row">1</property>
</layout>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="halign">start</property>
<property name="label" translatable="yes">Albums</property>
<layout>
<property name="column">1</property>
<property name="row">1</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="stats_songs">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="justify">right</property>
<layout>
<property name="column">0</property>
<property name="row">2</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Albums</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Songs</property>
<layout>
<property name="column">1</property>
<property name="row">2</property>
</layout>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Artists</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="stats_dbplaytime">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="justify">right</property>
<layout>
<property name="column">0</property>
<property name="row">3</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">3</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Seconds</property>
<layout>
<property name="column">1</property>
<property name="row">3</property>
</layout>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">3</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="stats_uptime">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="justify">right</property>
<object class="GtkLabel">
<layout>
<property name="column">0</property>
<property name="row">4</property>
<property name="column-span">2</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">6</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="stats_playtime">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="justify">right</property>
<layout>
<property name="column">0</property>
<property name="row">5</property>
</layout>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">5</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">4</property>
<property name="width">2</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Seconds played</property>
<layout>
<property name="column">1</property>
<property name="row">5</property>
</layout>
</object>
</child>
<child>
<object class="GtkLabel" id="stats_uptime">
<property name="halign">end</property>
<property name="justify">right</property>
<layout>
<property name="column">0</property>
<property name="row">6</property>
</layout>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">5</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Seconds running</property>
<layout>
<property name="column">1</property>
<property name="row">6</property>
</layout>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">6</property>
</packing>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="name">page1</property>
<property name="title" translatable="yes">Statistics</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child type="center">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child type="center">
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">10</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">audio-speakers-symbolic</property>
<property name="icon_size">6</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<object class="GtkStackPage">
<property name="name">devices</property>
<property name="title" translatable="yes">Audio Devices</property>
<property name="child">
<object class="AdwStatusPage">
<property name="icon-name">audio-speakers-symbolic</property>
<child>
<object class="GtkListBox" id="output_devices">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="hexpand">false</property>
<property name="halign">center</property>
<property name="selection-mode">none</property>
<style>
<class name="no-bg"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="name">page2</property>
<property name="title" translatable="yes">Audio Devices</property>
<property name="position">2</property>
</packing>
</child>
</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</template>
</object>
</interface>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.10"/>
<template class="McgServerToolbar" parent="GtkButtonBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="layout-style">start</property>
<child>
<placeholder/>
</child>
</template>
</interface>

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.18"/>
<template class="McgShortcutsDialog" parent="GtkShortcutsWindow">
<property name="modal">True</property>
<requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" />
<object id="McgShortcutsDialog" class="GtkShortcutsWindow">
<property name="modal">1</property>
<child>
<object class="GtkShortcutsSection">
<property name="visible">1</property>
@ -11,53 +11,45 @@
<property name="max-height">10</property>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<property name="title" translatable="yes">General</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;KP_1</property>
<property name="title" translatable="yes">Switch to the Connection panel</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;KP_2</property>
<property name="title" translatable="yes">Switch to the Cover panel</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;KP_3</property>
<property name="title" translatable="yes">Switch to the Playlist panel</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;KP_4</property>
<property name="title" translatable="yes">Switch to the Library panel</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;k</property>
<property name="title" translatable="yes">Show the keyboard shortcuts</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;i</property>
<property name="title" translatable="yes">Open the info dialog</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;q</property>
<property name="title" translatable="yes">Quit the application</property>
</object>
@ -66,25 +58,21 @@
</child>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<property name="title" translatable="yes">Player</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;c</property>
<property name="title" translatable="yes">Connect or disconnect</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;p</property>
<property name="title" translatable="yes">Switch between play and pause</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;r</property>
<property name="title" translatable="yes">Clear the playlist</property>
</object>
@ -93,11 +81,9 @@
</child>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<property name="title" translatable="yes">Cover Panel</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">F11</property>
<property name="title" translatable="yes">Show the cover in fullscreen mode</property>
</object>
@ -106,11 +92,9 @@
</child>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<property name="title" translatable="yes">Library Panel</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;primary&gt;f</property>
<property name="title" translatable="yes">Search the library</property>
</object>
@ -119,5 +103,5 @@
</child>
</object>
</child>
</template>
</object>
</interface>

View file

@ -1,229 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<template class="McgAppWindow" parent="GtkApplicationWindow">
<property name="can-focus">False</property>
<property name="icon-name">xyz.suruatoel.mcg</property>
<signal name="size-allocate" handler="on_resize" swapped="no"/>
<signal name="window-state-event" handler="on_state" swapped="no"/>
<child>
<object class="GtkOverlay">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkStack" id="content_stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-duration">100</property>
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStack" id="panel_stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="name">page0</property>
<property name="title">page0</property>
</packing>
</child>
</object>
<packing>
<property name="index">-1</property>
</packing>
</child>
<child type="overlay">
<object class="GtkRevealer" id="info_revealer">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="valign">start</property>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="can-focus">False</property>
<property name="valign">start</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="orientation">vertical</property>
<property name="baseline-position">top</property>
<property name="show-close-button">True</property>
<signal name="close" handler="on_info_bar_close" swapped="no"/>
<signal name="response" handler="on_info_bar_response" swapped="no"/>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can-focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child internal-child="content_area">
<object class="GtkBox">
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="info_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
<child type="titlebar">
<object class="GtkHeaderBar" id="headerbar">
<property name="name">headerbar</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="show-close-button">True</property>
<child type="title">
<object class="GtkStack" id="headerbar_title_stack">
<property name="name">headerbar-connection</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-duration">100</property>
<property name="transition-type">crossfade</property>
<child>
<object class="GtkLabel" id="headerbar_connection_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Connect to MPD</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
<packing>
<property name="name">page1</property>
<property name="title">page1</property>
</packing>
</child>
<child>
<object class="GtkStackSwitcher" id="headerbar_panel_switcher">
<property name="visible">True</property>
<property name="can-focus">False</property>
<requires lib="gtk+" version="4.8" />
<requires lib="adw" version="1.2" />
<object id="McgAppWindow" class="AdwApplicationWindow">
<property name="title">CoverGrid</property>
<property name="content">
<object class="AdwToolbarView" id="toolbar_view">
<child type="top">
<object class="AdwHeaderBar" id="headerbar">
<property name="centering-policy">strict</property>
<property name="show_end_title_buttons">true</property>
<property name="title-widget">
<object class="AdwViewSwitcherTitle" id="headerbar_panel_switcher">
<property name="title">CoverGrid</property>
<property name="stack">panel_stack</property>
</object>
<packing>
<property name="name">page0</property>
<property name="title">page0</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkSwitch" id="headerbar_button_connect">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="tooltip-text" translatable="yes">Connect or disconnect</property>
<signal name="notify::active" handler="on_headerbar_connection_active_notify" swapped="no"/>
<signal name="state-set" handler="on_headerbar_connection_state_set" swapped="no"/>
</object>
<packing>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkSeparator">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkToggleButton" id="headerbar_button_playpause">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Switch between play and pause</property>
<signal name="toggled" handler="on_headerbar_playpause_toggled" swapped="no"/>
</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">media-playback-start</property>
<object class="GtkSwitch" id="headerbar_button_connect">
</object>
</child>
<child>
<object class="GtkToggleButton" id="headerbar_button_playpause">
<property name="sensitive">false</property>
<property name="tooltip-text" translatable="yes">Switch between play and pause</property>
<child>
<object class="GtkImage">
<property name="can-focus">False</property>
<property name="icon-name">media-playback-start</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkVolumeButton" id="headerbar_button_volume">
<property name="sensitive">false</property>
</object>
</child>
<child type="end">
<object class="GtkStack" id="toolbar_stack">
</object>
</child>
</object>
<packing>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkVolumeButton" id="headerbar_button_volume">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="focus-on-click">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Adjust the volume</property>
<property name="relief">none</property>
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<signal name="button-press-event" handler="on_headerbar_volume_press" swapped="no"/>
<signal name="button-release-event" handler="on_headerbar_volume_release" swapped="no"/>
<signal name="value-changed" handler="on_headerbar_volume_changed" swapped="no"/>
<child internal-child="plus_button">
<object class="GtkButton">
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="relief">none</property>
</object>
</child>
<child internal-child="minus_button">
<object class="GtkButton">
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="relief">none</property>
</object>
</child>
</object>
<packing>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkStack" id="toolbar_stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<placeholder/>
<object class="AdwToastOverlay" id="info_toast">
<child>
<object class="GtkStack" id="content_stack">
<property name="name">content_stack</property>
<property name="vexpand">true</property>
<child>
<object class="AdwViewStack" id="panel_stack">
<property name="vexpand">true</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwViewSwitcherBar">
<property name="stack">panel_stack</property>
<binding name="reveal">
<lookup name="title-visible">headerbar_panel_switcher</lookup>
</binding>
</object>
</child>
<child>
<object class="GtkDrawingArea" id="resize_helper">
</object>
</child>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">4</property>
</packing>
</child>
</property>
</object>
</child>
<style>
<class name="bg-texture"/>
</style>
</template>
</property>
</object>
</interface>

View file

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/xyz/suruatoel/mcg">
<file>gtk.css</file>
<file>noise-texture.png</file>
<file>ui/window.ui</file>
<file>ui/gtk.menu.ui</file>
<file>ui/info-dialog.ui</file>
<file>ui/shortcuts-dialog.ui</file>
<file>ui/connection-panel.ui</file>
<file>ui/album-headerbar.ui</file>
<file>ui/server-toolbar.ui</file>
<file>ui/server-panel.ui</file>
<file>ui/cover-toolbar.ui</file>
<file>ui/cover-panel.ui</file>
<file>ui/playlist-toolbar.ui</file>
<file>ui/playlist-panel.ui</file>
<file>ui/library-toolbar.ui</file>
<file>ui/library-panel.ui</file>
</gresource>
</gresources>

View file

@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<enum id="xyz.suruatoel.mcg.SortOrder">
<value nick="artist" value="0" />
<value nick="title" value="1" />
<value nick="year" value="2" />
<value nick="modified" value="3" />
</enum>
<schema path="/xyz/suruatoel/mcg/" id="xyz.suruatoel.mcg" gettext-domain="mcg">
<key type="s" name="host">
<default>'localhost'</default>
<summary>MPD host</summary>
<description>MPD host to connect to</description>
</key>
<key type="i" name="port">
<default>6600</default>
<summary>MPD port</summary>
<description>MPD port to connect to</description>
</key>
<key type="b" name="connected">
<default>false</default>
<summary>Connection state</summary>
<description>State of last connection</description>
</key>
<key type="i" name="width">
<default>800</default>
<summary>Window width</summary>
<description>The window width in pixels.</description>
</key>
<key type="i" name="height">
<default>600</default>
<summary>Window height</summary>
<description>The window height in pixels.</description>
</key>
<key type="b" name="is-maximized">
<default>false</default>
<summary>Window maximized</summary>
<description>Whether or not the window is in maximized state.</description>
</key>
<key type="i" name="panel">
<range min="0" max="3"/>
<default>1</default>
<summary>Last selected panel</summary>
<description>The index of the last selected panel.</description>
</key>
<key type="i" name="item-size">
<range min="100" max="1000" />
<default>150</default>
<summary>Size of library items</summary>
<description>The size of items displayed in the library.</description>
</key>
<key enum="xyz.suruatoel.mcg.SortOrder" name="sort-order">
<default>'year'</default>
<summary>Sort criterium for library items</summary>
<description>The sort criterium of items displayed in the library.</description>
</key>
<key type="b" name="sort-type">
<default>true</default>
<summary>Sort type for library items</summary>
<description>The sort type of items displayed in the library.</description>
</key>
</schema>
</schemalist>

View file

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

12
go.mod Normal file
View file

@ -0,0 +1,12 @@
module suruatoel.xyz/mcg
go 1.25.0
toolchain go1.26.0
require (
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20260808200908-d4aecaa0ff32
github.com/diamondburned/gotk4/pkg v0.4.1
)
require golang.org/x/sync v0.22.0 // indirect

6
go.sum Normal file
View file

@ -0,0 +1,6 @@
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20260808200908-d4aecaa0ff32 h1:1EATAO7ZRrY7B7lt6djCzGnLPlKXPzuOuwPzDSF7jT0=
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20260808200908-d4aecaa0ff32/go.mod h1:ZzYiyPe0TqsukfPHi0sK/WwKzm0wIJdSRylLnuvAZNw=
github.com/diamondburned/gotk4/pkg v0.4.1 h1:w4BwVUUBkKyNLz5HKHN2Oz3kZCTLHfpfKgB/su1p6uo=
github.com/diamondburned/gotk4/pkg v0.4.1/go.mod h1:BAEEDXtj5srQ1S2++5HX2K1s9c6CtuWSa60dAywrkRM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=

947
internal/client/client.go Normal file
View file

@ -0,0 +1,947 @@
package client
import (
"fmt"
"io"
"log"
"net/textproto"
"strconv"
"strings"
"sync"
"time"
"suruatoel.xyz/mcg/internal/common"
)
type QueueItem struct {
Command func(*Client) error
}
type Client struct {
Host string
con *textproto.Conn
runGroup sync.WaitGroup
queue chan QueueItem
idling bool
version string
state string
playlist []*common.PlaylistAlbum
albums map[string]*common.Album
errorCB ErrorCB
connectionCB ConnectionCB
playlistCB PlaylistCB
albumsCB AlbumsCB
statusCB StatusCB
statisticsCB StatisticsCB
outputDevicesCB OutputDevicesCB
albumartCB AlbumartCB
albumartChan chan []byte
}
type ErrorCB func(string)
type ConnectionCB func(bool)
type PlaylistCB func([]*common.PlaylistAlbum)
type AlbumsCB func(map[string]*common.Album)
type StatusCB func(string, *common.PlaylistAlbum, uint64, uint64, int64, string, string, string, string)
type StatisticsCB func(string, string, string, string, string, string)
type OutputDevicesCB func(devices []*common.OutputDevice)
type AlbumartCB func(*common.Album, []byte)
var (
clientInstance *Client
clientInit sync.Once
)
func Instance() *Client {
clientInit.Do(func() {
clientInstance = &Client{}
})
return clientInstance
}
func (c *Client) IsConnected() bool {
return (c.con != nil)
}
func (c *Client) Connect(host string, port int, password string) {
c.Host = host
c.queue = make(chan QueueItem, 300)
c.albumartChan = make(chan []byte)
// Add connect action
c.addAction(
func(client *Client) error {
return client.connect(host, port, password)
},
)
go c.run()
}
func (c *Client) connect(host string, port int, password string) error {
// Notify listeners
defer func() {
if c.connectionCB != nil {
c.connectionCB(c.con != nil)
}
}()
// Create socket
con, err := textproto.Dial("tcp", fmt.Sprintf("%s:%d", host, port))
c.con = con
if err != nil {
return err
}
// Greeting
greeting, err := c.readLine()
if err != nil {
return err
}
if greeting[0:6] != "OK MPD" {
return textproto.ProtocolError(fmt.Sprintf("Unexpected greeting: %s", greeting))
}
// Parse and store server version
c.version = greeting[7:]
// Add post-connect actions
c.addPostConnectActions(password)
return nil
}
func (c *Client) addPostConnectActions(password string) {
c.addAction(
func(client *Client) error {
return client.password(password)
},
func(client *Client) error {
return client.loadAlbums()
},
func(client *Client) error {
return client.loadPlaylist()
},
func(client *Client) error {
return client.getStatus()
},
func(client *Client) error {
return client.getStatistics()
},
func(client *Client) error {
return client.getOutputDevices()
},
)
}
func (c *Client) password(password string) error {
if password != "" {
reader := &emptyReader{}
return c.cmd(reader, "password", password)
}
return nil
}
func (c *Client) Disconnect() {
c.addDisconnectActions()
close(c.queue)
close(c.albumartChan)
c.runGroup.Wait()
}
func (c *Client) addDisconnectActions() {
c.addAction(
func(client *Client) error {
return client.disconnect()
},
)
}
func (c *Client) disconnect() error {
// Stop current idle process
c.noIdle()
// Close connection
err := c.con.Close()
if err != nil {
return err
}
c.con = nil
// Notify callback
if c.connectionCB != nil {
c.connectionCB(false)
}
return nil
}
func (c *Client) run() {
c.runGroup.Add(1)
defer func() {
c.runGroup.Done()
}()
for action := range c.queue {
err := action.Command(c)
if err != nil && c.errorCB != nil {
c.errorCB(fmt.Sprintf("Error running action: %s\n", err))
}
}
}
func (c *Client) Version() string {
return c.version
}
func (c *Client) ReceiveError(callback ErrorCB) {
c.errorCB = callback
}
func (c *Client) RegisterConnection(callback ConnectionCB) {
c.connectionCB = callback
}
func (c *Client) ReceivePlaylist(callback PlaylistCB) {
c.playlistCB = callback
}
func (c *Client) ReceiveAlbums(callback AlbumsCB) {
c.albumsCB = callback
}
func (c *Client) ReceiveStatus(callback StatusCB) {
c.statusCB = callback
}
func (c *Client) ReceiveStatistics(callback StatisticsCB) {
c.statisticsCB = callback
}
func (c *Client) ReceiveOutputDevices(callback OutputDevicesCB) {
c.outputDevicesCB = callback
}
func (c *Client) ReceiveAlbumart(callback AlbumartCB) {
c.albumartCB = callback
}
func (c *Client) loadPlaylist() error {
c.playlist = []*common.PlaylistAlbum{}
playlistReader := &listMapReader{Delimiter: "file"}
playlistErr := c.cmd(playlistReader, "playlistinfo")
if playlistErr != nil {
return playlistErr
}
for _, songData := range playlistReader.DictList() {
song := c.extractPlaylistSong(songData)
album := c.extractPlaylistAlbum(songData)
if len(c.playlist) == 0 || c.playlist[len(c.playlist)-1].ID() != album.ID() {
c.playlist = append(c.playlist, album)
} else {
album = c.playlist[len(c.playlist)-1]
}
if song != nil {
album.AddSong(song)
}
}
if c.playlistCB != nil {
c.playlistCB(c.playlist)
}
return nil
}
func (c *Client) loadAlbums() error {
// self._callback(Client.SIGNAL_INIT_ALBUMS)
c.albums = make(map[string]*common.Album)
albumReader := &listMapReader{Delimiter: "Album"}
albumErr := c.cmd(albumReader, "list album")
if albumErr != nil {
return albumErr
}
for _, entry := range albumReader.DictList() {
// self._callback(Client.SIGNAL_PULSE_ALBUMS)
// Album
album := c.extractAlbum(entry)
if album != nil {
// Songs
songReader := &listMapReader{Delimiter: "file"}
songErr := c.cmd(songReader, "find album", album.Title())
if songErr != nil {
return songErr
}
for _, songData := range songReader.DictList() {
song := c.extractSong(songData)
if song != nil {
album.AddSong(song)
}
}
c.albums[album.ID()] = album
}
}
if c.albumsCB != nil {
c.albumsCB(c.albums)
}
return nil
}
func (c *Client) getStatus() error {
statusReader := &mapReader{}
statusErr := c.cmd(statusReader, "status")
if statusErr != nil {
return statusErr
}
status := statusReader.Dict()
// State
c.state = status["state"]
// Time
var parseErr error
time := uint64(0)
if timeStr, timeSet := status["time"]; timeSet {
time, parseErr = strconv.ParseUint(strings.Split(timeStr, ":")[0], 10, 64)
if parseErr != nil {
return newValueParseError("time", timeStr)
}
}
// Volume
volume := int64(-1)
if volumeStr, volumeSet := status["volume"]; volumeSet {
volume, parseErr = strconv.ParseInt(volumeStr, 10, 64)
if parseErr != nil {
return newValueParseError("volume", volumeStr)
}
}
// Error
errorMessage := status["error"]
// Album information
file := ""
var album *common.PlaylistAlbum
pos := uint64(0)
songReader := &mapReader{}
songErr := c.cmd(songReader, "currentsong")
if songErr != nil {
return songErr
}
songData := songReader.Dict()
if len(songData) > 0 {
// File
file = songData["file"]
// Song
song := c.extractPlaylistSong(songData)
if song != nil {
// Album
album = c.extractPlaylistAlbum(songData)
pos = song.Pos()
for _, palbum := range c.playlist {
if palbum.ID() == album.ID() && uint64(len(palbum.Songs())) >= pos {
album = palbum
break
}
pos -= uint64(len(palbum.Songs()))
}
}
}
// Audio
audio := status["audio"]
// Bitrate
bitrate := status["bitrate"]
if c.statusCB != nil {
c.statusCB(c.state, album, pos, time, volume, file, audio, bitrate, errorMessage)
}
return nil
}
func (c *Client) getStatistics() error {
statisticsReader := &mapReader{}
statisticsErr := c.cmd(statisticsReader, "stats")
if statisticsErr != nil {
return statisticsErr
}
stats := statisticsReader.Dict()
artists := stats["artists"]
albums := stats["albums"]
songs := stats["songs"]
dbPlaytime := stats["db_playtime"]
playtime := stats["playtime"]
uptime := stats["uptime"]
if c.statisticsCB != nil {
c.statisticsCB(artists, albums, songs, dbPlaytime, playtime, uptime)
}
return nil
}
func (c *Client) getOutputDevices() error {
devicesReader := &listMapReader{Delimiter: "outputid"}
devicesErr := c.cmd(devicesReader, "outputs")
if devicesErr != nil {
return devicesErr
}
devices := []*common.OutputDevice{}
for _, deviceMap := range devicesReader.DictList() {
device := common.NewOutputDevice(
deviceMap["outputid"],
deviceMap["outputname"],
)
enabledString := deviceMap["outputenabled"]
if enabledString != "" {
enabled, enabledErr := strconv.ParseBool(enabledString)
if enabledErr == nil {
device.Enabled = enabled
} else {
log.Println("failed to parse status of output device", device.ID, device.Name)
}
}
devices = append(devices, device)
}
if c.outputDevicesCB != nil {
c.outputDevicesCB(devices)
}
return nil
}
func (c *Client) SetOutputDeviceState(device *common.OutputDevice, enable bool) {
c.addAction(func(client *Client) error {
return client.setOutputDeviceState(device, enable)
})
}
func (c *Client) setOutputDeviceState(device *common.OutputDevice, enable bool) error {
reader := &emptyReader{}
if enable {
return c.cmd(reader, "enableoutput", device.ID)
}
return c.cmd(reader, "disableoutput", device.ID)
}
func (c *Client) PlayPause() {
c.addAction(func(client *Client) error {
return client.playPause()
})
}
func (c *Client) playPause() error {
reader := &emptyReader{}
switch c.state {
case "play":
return c.cmd(reader, "pause")
default:
return c.cmd(reader, "play")
}
}
func (c *Client) PlayAlbum(album *common.Album) {
c.addAction(func(client *Client) error {
return client.playAlbum(album)
})
}
func (c *Client) playAlbum(album *common.Album) error {
songIDs, err := c.queueAlbum(album)
if err != nil {
return err
}
if len(songIDs) > 0 {
reader := &emptyReader{}
return c.cmd(reader, "playid", songIDs[0])
}
return nil
}
func (c *Client) QueueAlbum(album *common.Album) {
c.addAction(func(client *Client) error {
_, err := client.queueAlbum(album)
return err
})
}
func (c *Client) queueAlbum(album *common.Album) ([]string, error) {
songIDs := []string{}
for _, song := range album.Songs() {
reader := &mapReader{}
addErr := c.cmd(reader, "addid", song.File())
if addErr != nil {
return songIDs, addErr
}
addData := reader.Dict()
songID, found := addData["Id"]
if found {
songIDs = append(songIDs, songID)
}
}
return songIDs, nil
}
func (c *Client) QueueAlbums(albums []*common.Album) {
c.addAction(func(client *Client) error {
return client.queueAlbums(albums)
})
}
func (c *Client) queueAlbums(albums []*common.Album) error {
for _, album := range albums {
_, queueErr := c.queueAlbum(album)
if queueErr != nil {
return queueErr
}
}
return nil
}
func (c *Client) PlayQueuedAlbum(album *common.PlaylistAlbum) {
c.addAction(func(client *Client) error {
return client.playQueuedAlbum(album)
})
}
func (c *Client) playQueuedAlbum(album *common.PlaylistAlbum) error {
if len(album.Songs()) <= 0 {
return nil
}
reader := &emptyReader{}
return c.cmd(reader, "playid", album.Songs()[0].ID())
}
func (c *Client) UnqueueAlbum(album *common.PlaylistAlbum) {
c.addAction(func(client *Client) error {
return client.unqueueAlbum(album)
})
}
func (c *Client) unqueueAlbum(album *common.PlaylistAlbum) error {
commands := []listCommand{}
for _, song := range album.Songs() {
command := newListCommand("deleteid", song.ID())
commands = append(commands, command)
}
return c.cmdList(commands)
}
func (c *Client) UnqueueAlbums(albums []*common.PlaylistAlbum) {
c.addAction(func(client *Client) error {
return client.unqueueAlbums(albums)
})
}
func (c *Client) unqueueAlbums(albums []*common.PlaylistAlbum) error {
commands := []listCommand{}
for _, album := range albums {
for _, song := range album.Songs() {
command := newListCommand("deleteid", song.ID())
commands = append(commands, command)
}
}
return c.cmdList(commands)
}
func (c *Client) Seek(pos int, time uint64) {
c.addAction(func(client *Client) error {
return client.seek(pos, time)
})
}
func (c *Client) seek(pos int, time uint64) error {
reader := &emptyReader{}
return c.cmd(reader, "seek", strconv.Itoa(pos), strconv.FormatUint(time, 10))
}
func (c *Client) SetVolume(volume int) {
c.addAction(func(client *Client) error {
return client.setVolume(volume)
})
}
func (c *Client) setVolume(volume int) error {
reader := &emptyReader{}
return c.cmd(reader, "setvol", strconv.Itoa(volume))
}
func (c *Client) LoadAlbumart(album *common.Album) {
c.addAction(func(client *Client) error {
return client.loadAlbumart(album)
})
}
func (c *Client) LoadAlbumartNow(album *common.Album) []byte {
c.addAction(func(client *Client) error {
return client.loadAlbumartNow(album)
})
return <-c.albumartChan
}
func (c *Client) loadAlbumart(album *common.Album) error {
reader := &binaryReader{}
reader.size = 1
reader.offset = 0
if c.albumartCB != nil {
// We need to wrap this in an anonymous function to make sure the call to reader.Binary() is deferred as well
defer func() {
c.albumartCB(album, reader.Binary())
}()
}
if len(album.Songs()) > 0 {
return c.cmd(reader, "albumart", album.Songs()[0].File(), "0")
}
return nil
}
func (c *Client) loadAlbumartNow(album *common.Album) error {
reader := &binaryReader{}
reader.size = 1
reader.offset = 0
defer func() {
c.albumartChan <- reader.Binary()
}()
if len(album.Songs()) > 0 {
return c.cmd(reader, "albumart", album.Songs()[0].File(), "0")
}
return nil
}
func (c *Client) ClearPlaylist() {
c.addAction(func(client *Client) error {
return client.clearPlaylist()
})
}
func (c *Client) clearPlaylist() error {
reader := &emptyReader{}
return c.cmd(reader, "clear")
}
func (c *Client) idle() error {
reader := &listReader{
key: "changed",
}
c.idling = true
err := c.cmd(reader, "idle")
c.idling = false
if err != nil {
return err
}
for _, subsystem := range reader.List() {
switch subsystem {
case "player":
c.addAction(
func(client *Client) error {
return client.loadPlaylist()
},
func(client *Client) error {
return client.getStatus()
},
)
case "mixer":
fallthrough
case "output":
c.addAction(func(client *Client) error {
return client.getStatus()
})
case "playlist":
c.addAction(func(client *Client) error {
return client.loadPlaylist()
})
case "database":
fallthrough
case "update":
c.addAction(
func(client *Client) error {
return client.loadAlbums()
},
func(client *Client) error {
return client.loadPlaylist()
},
func(client *Client) error {
return client.getStatus()
},
)
}
}
return nil
}
func (c *Client) noIdle() error {
id, err := c.write("noidle")
if err != nil {
return err
}
c.con.StartResponse(id)
defer c.con.EndResponse(id)
return nil
}
func (c *Client) addAction(commands ...func(*Client) error) {
for _, command := range commands {
c.queue <- QueueItem{
command,
}
}
c.queue <- QueueItem{
func(client *Client) error {
if client.con != nil && len(client.queue) == 0 {
return client.idle()
}
return nil
},
}
if c.idling {
c.noIdle()
}
}
func (c *Client) cmd(reader reader, action string, args ...string) error {
readingComplete := false
for !readingComplete {
command := c.constructCommand(action, args...)
id, err := c.write(command)
if err != nil {
return textproto.ProtocolError(fmt.Sprintf("failed to write command \"%s\": %s", action, err))
}
complete, newArgs, err := c.readData(id, reader, args)
if err != nil {
return textproto.ProtocolError(fmt.Sprintf("failed to read response for command \"%s\": %s", action, err))
}
readingComplete = complete
args = newArgs
}
return nil
}
func (c *Client) cmdList(commands []listCommand) error {
beginReader := &noopReader{}
beginErr := c.cmd(beginReader, "command_list_begin")
if beginErr != nil {
return beginErr
}
commandReader := &noopReader{}
for _, command := range commands {
commandErr := c.cmd(commandReader, command.Action, command.Args...)
if commandErr != nil {
return commandErr
}
}
endReader := &emptyReader{}
return c.cmd(endReader, "command_list_end")
}
func (c *Client) constructCommand(command string, args ...string) string {
if len(args) == 0 {
return command
}
cleanArgs := c.escapeCommandArguments(args...)
joinedArgs := strings.Join(cleanArgs, "\" \"")
return fmt.Sprintf("%s \"%s\"", command, joinedArgs)
}
func (c *Client) escapeCommandArguments(args ...string) []string {
for i, arg := range args {
args[i] = strings.ReplaceAll(arg, "\"", "\\\"")
}
return args
}
func (c *Client) write(command string) (uint, error) {
id := c.con.Next()
c.con.StartRequest(id)
defer c.con.EndRequest(id)
fmt.Fprintf(c.con.W, "%s", command)
c.con.W.WriteByte('\n')
err := c.con.W.Flush()
if err != nil {
return id, err
}
return id, nil
}
func (c *Client) readData(id uint, reader reader, args []string) (bool, []string, error) {
c.con.StartResponse(id)
defer c.con.EndResponse(id)
return reader.ReadData(c, args)
}
func (c *Client) read() ([]string, error) {
response := []string{}
for {
line, err := c.readLine()
if err != nil {
return nil, err
}
if line == "OK" {
break
}
response = append(response, line)
}
return response, nil
}
func (c *Client) readLine() (string, error) {
line, err := c.con.ReadLine()
if err != nil {
return "", err
}
if strings.HasPrefix(line, "ACK ") {
return "", textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line[4:]))
}
return line, nil
}
func (c *Client) readBytes(bytes []byte) error {
n, err := io.ReadFull(c.con.R, bytes)
if err != nil {
return err
}
if n != len(bytes) {
return textproto.ProtocolError(fmt.Sprintf("Wrong byte count: %d instead of %d", n, len(bytes)))
}
return nil
}
func (c *Client) extractAlbum(song map[string]string) *common.Album {
albumTitle := "Various"
if title, titleSet := song["Album"]; titleSet {
albumTitle = title
}
album := common.NewAlbum(albumTitle)
if albumLookup, albumExists := c.albums[album.ID()]; albumExists {
return albumLookup
}
return album
}
func (c *Client) extractPlaylistAlbum(song map[string]string) *common.PlaylistAlbum {
albumTitle := "Various"
if title, titleSet := song["Album"]; titleSet {
albumTitle = title
}
album := common.NewAlbum(albumTitle)
if albumLookup, albumExists := c.albums[album.ID()]; albumExists {
album = albumLookup
}
playlistAlbum := common.NewPlaylistAlbum(album)
return playlistAlbum
}
func (c *Client) extractSong(data map[string]string) *common.Song {
if artist, artistSet := data["Artist"]; artistSet {
if title, titleSet := data["Title"]; titleSet {
if file, fileSet := data["file"]; fileSet {
song := common.NewSong(artist, title, file)
if track, isSet := data["Track"]; isSet {
song.SetTrack(track)
}
if timeString, isSet := data["Time"]; isSet {
time, timeErr := strconv.ParseUint(timeString, 10, 64)
if timeErr == nil {
song.SetLength(time)
} else {
log.Println("failed to parse \"Time\" field of song file", file)
}
}
if date, isSet := data["Date"]; isSet {
song.SetDate(date)
}
if albumartist, isSet := data["Albumartist"]; isSet {
song.AddAlbumartist(albumartist)
}
if lastModifiedString, isSet := data["last-modified"]; isSet {
lastModified, dateErr := time.Parse("2006-01-02T15:04:05-0700", lastModifiedString)
if dateErr == nil {
song.SetLastModified(lastModified)
} else {
log.Println("failed to parse \"last-modified\" field of song file", file)
}
}
return song
}
}
}
return nil
}
func (c *Client) extractPlaylistSong(data map[string]string) *common.PlaylistSong {
song := c.extractSong(data)
if song != nil {
if id, idSet := data["Id"]; idSet {
if posString, posSet := data["Pos"]; posSet {
pos, posErr := strconv.ParseUint(posString, 10, 64)
if posErr != nil {
log.Println("failed to parse \"Pos\" field of playlist song")
}
return common.NewPlaylistSong(song, id, pos)
}
}
}
return nil
}

View file

@ -0,0 +1,10 @@
package client
type listCommand struct {
Action string
Args []string
}
func newListCommand(action string, args ...string) listCommand {
return listCommand{action, args}
}

19
internal/client/errors.go Normal file
View file

@ -0,0 +1,19 @@
package client
import "fmt"
type ValueParseErr struct {
field string
value string
}
func newValueParseError(field string, value string) ValueParseErr {
return ValueParseErr{
field: field,
value: value,
}
}
func (e ValueParseErr) Error() string {
return fmt.Sprintf("failed to parse %s value: %s", e.field, e.value)
}

236
internal/client/reader.go Normal file
View file

@ -0,0 +1,236 @@
package client
import (
"fmt"
"net/textproto"
"strconv"
"strings"
)
type reader interface {
ReadData(client *Client, args []string) (bool, []string, error)
}
type noopReader struct{}
func (r *noopReader) ReadData(client *Client, args []string) (bool, []string, error) {
return true, args, nil
}
type emptyReader struct{}
func (r *emptyReader) ReadData(client *Client, args []string) (bool, []string, error) {
_, err := client.read()
return true, args, err
}
type listReader struct {
key string
data []string
}
func (r *listReader) ReadData(client *Client, args []string) (bool, []string, error) {
data, err := client.read()
if err != nil {
return true, args, err
}
r.data = []string{}
for _, line := range data {
if !strings.HasPrefix(line, r.key+": ") {
return true, args, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line))
}
r.data = append(r.data, line[len(r.key)+2:])
}
return true, args, nil
}
func (r *listReader) List() []string {
return r.data
}
type mapReader struct {
data map[string]string
}
func (r *mapReader) ReadData(client *Client, args []string) (bool, []string, error) {
data, err := client.read()
if err != nil {
return true, args, err
}
r.data = make(map[string]string)
for _, line := range data {
parts := strings.Split(line, ": ")
if len(parts) < 2 {
return true, args, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line))
}
r.data[parts[0]] = strings.Join(parts[1:], ": ")
}
return true, args, nil
}
func (r *mapReader) Dict() map[string]string {
return r.data
}
type listMapReader struct {
Delimiter string
data []map[string]string
}
func (r *listMapReader) ReadData(client *Client, args []string) (bool, []string, error) {
data, err := client.read()
if err != nil {
return true, args, err
}
r.data = nil
var entry map[string]string
for _, line := range data {
parts := strings.Split(line, ": ")
if len(parts) < 2 {
return true, args, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line))
}
if parts[0] == r.Delimiter {
if entry != nil {
r.data = append(r.data, entry)
}
entry = make(map[string]string)
}
entry[parts[0]] = strings.Join(parts[1:], ": ")
}
if entry != nil {
r.data = append(r.data, entry)
}
return true, args, nil
}
func (r *listMapReader) DictList() []map[string]string {
return r.data
}
type binaryReader struct {
data []byte
size int
offset int
}
func (r *binaryReader) ReadData(client *Client, args []string) (bool, []string, error) {
firstLine, dataComplete, firstLineErr := r.readFirstLine(client)
if firstLineErr != nil {
return true, args, firstLineErr
}
if dataComplete {
return true, args, nil
}
totalSize, sizeErr := r.readSize(firstLine)
if sizeErr != nil {
return true, args, sizeErr
}
r.size = totalSize
currentSize, bytesCountErr := r.readBytesCount(client)
if bytesCountErr != nil {
return true, args, bytesCountErr
}
r.initData(totalSize)
bytesErr := r.readBytes(client, currentSize)
if bytesErr != nil {
return true, args, bytesErr
}
_, lineBreakErr := client.readLine()
if lineBreakErr != nil {
return true, args, lineBreakErr
}
_, lineCompletionErr := client.readLine()
if lineCompletionErr != nil {
return true, args, lineCompletionErr
}
complete := (r.offset >= r.size)
newArgs := r.updateOffsetArgument(args)
return complete, newArgs, nil
}
func (r *binaryReader) readFirstLine(client *Client) (line string, complete bool, err error) {
line, lineErr := client.readLine()
if lineErr != nil {
return "", true, lineErr
}
// OK here means the binary data is complete
if line == "OK" {
return "", true, nil
}
return line, false, nil
}
func (r *binaryReader) readSize(line string) (int, error) {
if !strings.HasPrefix(line, "size: ") {
return 0, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line))
}
parsedSize, err := strconv.Atoi(line[6:])
if err != nil {
return 0, textproto.ProtocolError(fmt.Sprintf("Invalid size: %s", line[6:]))
}
return parsedSize, nil
}
func (r *binaryReader) readBytesCount(client *Client) (int, error) {
line, lineErr := client.readLine()
if lineErr != nil {
return 0, lineErr
}
if !strings.HasPrefix(line, "binary: ") {
return 0, textproto.ProtocolError(fmt.Sprintf("Unexpected line: %s", line))
}
count, countErr := strconv.Atoi(line[8:])
if countErr != nil {
return 0, textproto.ProtocolError(fmt.Sprintf("Invalid binary: %s", line[8:]))
}
return count, nil
}
func (r *binaryReader) initData(size int) {
if r.data == nil {
r.data = make([]byte, size)
}
}
func (r *binaryReader) readBytes(client *Client, size int) error {
bytesError := client.readBytes(r.data[r.offset : r.offset+size])
if bytesError != nil {
return bytesError
}
r.offset += size
return nil
}
func (r *binaryReader) updateOffsetArgument(args []string) []string {
args[len(args)-1] = strconv.Itoa(r.offset)
return args
}
func (r *binaryReader) Binary() []byte {
return r.data
}

189
internal/common/album.go Normal file
View file

@ -0,0 +1,189 @@
package common
import (
"crypto/md5"
"fmt"
"io"
"path/filepath"
"slices"
"strings"
"time"
"suruatoel.xyz/mcg/internal/i18n"
)
type Album struct {
title string
songs []*Song
artists []string
pathes []string
dates []string
length uint64
lastModified time.Time
}
func NewAlbum(title string) *Album {
album := Album{
title: title,
}
return &album
}
func (a *Album) Title() string {
return a.title
}
func (a *Album) AddSong(song *Song) {
a.songs = append(a.songs, song)
a.length += song.Length()
for _, artist := range song.AlbumArtists() {
if !slices.Contains(a.artists, artist) {
a.artists = append(a.artists, artist)
}
}
if !slices.Contains(a.dates, song.Date()) {
a.dates = append(a.dates, song.Date())
}
path := filepath.Base(song.File())
if !slices.Contains(a.pathes, path) {
a.pathes = append(a.pathes, path)
}
if !song.LastModified().After(a.lastModified) {
a.lastModified = song.LastModified()
}
}
func (a *Album) Songs() []*Song {
return a.songs
}
func (a *Album) Artists() []string {
return a.artists
}
func (a *Album) Dates() []string {
return a.dates
}
func (a *Album) Length() uint64 {
return a.length
}
func (a *Album) LastModified() time.Time {
return a.lastModified
}
func (a *Album) ID() string {
return generateAlbumId(a.title)
}
func (a *Album) Tooltip() string {
artists := strings.Join(a.Artists(), ", ")
dates := strings.Join(a.dates, ", ")
minutes := a.length / 60
seconds := a.length - minutes*60
length := fmt.Sprintf(i18n.T("%d:%d minutes"), minutes, seconds)
return strings.Join(
[]string{
a.title,
dates,
artists,
length,
},
"\n",
)
}
type PlaylistAlbum struct {
*Album
songs []*PlaylistSong
length uint64
}
func NewPlaylistAlbum(album *Album) *PlaylistAlbum {
playlistAlbum := PlaylistAlbum{
Album: album,
}
return &playlistAlbum
}
func (a *PlaylistAlbum) AddSong(playlistSong *PlaylistSong) {
a.songs = append(a.songs, playlistSong)
song := playlistSong.Song
a.length += song.Length()
for _, artist := range song.AlbumArtists() {
if !slices.Contains(a.artists, artist) {
a.artists = append(a.artists, artist)
}
}
if !slices.Contains(a.dates, song.Date()) {
a.dates = append(a.dates, song.Date())
}
path := filepath.Base(song.File())
if !slices.Contains(a.pathes, path) {
a.pathes = append(a.pathes, path)
}
}
func (a *PlaylistAlbum) Songs() []*PlaylistSong {
return a.songs
}
func (a *PlaylistAlbum) Length() uint64 {
return a.length
}
func (a *Album) IsMatch(query string) bool {
if query == "" {
return true
}
for keyword := range strings.SplitSeq(query, " ") {
if !a.isKeywordMatch(keyword) {
return false
}
}
return true
}
func (a *Album) isKeywordMatch(keyword string) bool {
if strings.Contains(strings.ToLower(a.title), keyword) {
return true
}
for _, artist := range a.artists {
if strings.Contains(strings.ToLower(artist), keyword) {
return true
}
}
for _, song := range a.songs {
if strings.Contains(strings.ToLower(song.Title()), keyword) {
return true
}
if strings.Contains(strings.ToLower(song.File()), keyword) {
return true
}
}
return false
}
func generateAlbumId(values ...string) string {
h := md5.New()
for _, value := range values {
io.WriteString(h, value)
}
return fmt.Sprintf("%x", h.Sum(nil))
}

View file

@ -0,0 +1,14 @@
package common
type OutputDevice struct {
ID string
Name string
Enabled bool
}
func NewOutputDevice(id string, name string) *OutputDevice {
return &OutputDevice{
ID: id,
Name: name,
}
}

122
internal/common/song.go Normal file
View file

@ -0,0 +1,122 @@
package common
import (
"slices"
"time"
)
type Song struct {
artists []string
title string
file string
albumartists []string
track string
length uint64
date string
lastModified time.Time
}
func NewSong(artist string, title string, file string) *Song {
song := Song{
artists: []string{artist},
title: title,
file: file,
}
return &song
}
func (s *Song) AddArtist(artist string) {
s.artists = append(s.artists, artist)
}
func (s *Song) AddAlbumartist(artist string) {
s.albumartists = append(s.albumartists, artist)
}
func (s *Song) SongArtists() []string {
if len(s.albumartists) > 0 {
songArtists := []string{}
for _, artist := range s.artists {
if !slices.Contains(s.albumartists, artist) {
songArtists = append(songArtists, artist)
}
}
return songArtists
}
return []string{}
}
func (s *Song) AlbumArtists() []string {
if len(s.albumartists) > 0 {
return s.albumartists
}
return s.artists
}
func (s *Song) Title() string {
return s.title
}
func (s *Song) File() string {
return s.file
}
func (s *Song) SetTrack(track string) {
s.track = track
}
func (s *Song) Track() string {
return s.track
}
func (s *Song) SetLength(length uint64) {
s.length = length
}
func (s *Song) Length() uint64 {
return s.length
}
func (s *Song) SetDate(date string) {
s.date = date
}
func (s *Song) Date() string {
return s.date
}
func (s *Song) SetLastModified(lastModified time.Time) {
s.lastModified = lastModified
}
func (s *Song) LastModified() time.Time {
return s.lastModified
}
type PlaylistSong struct {
*Song
id string
pos uint64
}
func NewPlaylistSong(song *Song, id string, pos uint64) *PlaylistSong {
playlistSong := &PlaylistSong{
Song: song,
id: id,
pos: pos,
}
return playlistSong
}
func (t *PlaylistSong) ID() string {
return t.id
}
func (t *PlaylistSong) Pos() uint64 {
return t.pos
}

89
internal/common/sort.go Normal file
View file

@ -0,0 +1,89 @@
package common
import "sort"
type albumLessFunc func(p1, p2 *Album) bool
type albumSorter struct {
albums []*Album
less []albumLessFunc
}
func (s *albumSorter) Len() int {
return len(s.albums)
}
func (s *albumSorter) Swap(i, j int) {
s.albums[i], s.albums[j] = s.albums[j], s.albums[i]
}
func (s *albumSorter) Less(i, j int) bool {
p, q := s.albums[i], s.albums[j]
var k int
for k = 0; k < len(s.less)-1; k++ {
less := s.less[k]
switch {
case less(p, q):
return true
case less(q, p):
return false
}
}
return s.less[k](p, q)
}
func (s *albumSorter) Sort(albums []*Album) {
s.albums = albums
sort.Sort(s)
}
func OrderAlbumBy(less ...albumLessFunc) *albumSorter {
return &albumSorter{
less: less,
}
}
func SortAlbums(album []*Album, field string) {
title := orderAlbumByTitle
artists := orderAlbumByArtists
years := orderAlbumByYears
modified := orderAlbumByModified
var sorter *albumSorter
switch field {
case "artist":
sorter = OrderAlbumBy(artists, title, years, modified)
case "year":
sorter = OrderAlbumBy(years, title, artists, modified)
case "modified":
sorter = OrderAlbumBy(modified, title, artists, years)
default:
sorter = OrderAlbumBy(title, artists, years, modified)
}
sorter.Sort(album)
}
func orderAlbumByTitle(c1, c2 *Album) bool {
return c1.Title() < c2.Title()
}
func orderAlbumByArtists(left, right *Album) bool {
if len(left.Artists()) > 0 && len(right.Artists()) > 0 {
return left.Artists()[0] < right.Artists()[0]
}
return false
}
func orderAlbumByYears(c1, c2 *Album) bool {
if len(c1.Dates()) > 0 && len(c2.Dates()) > 0 {
return c1.Dates()[0] < c2.Dates()[0]
}
return false
}
func orderAlbumByModified(c1, c2 *Album) bool {
return c1.LastModified().Before(c2.LastModified())
}

View file

@ -0,0 +1,42 @@
package gui
import (
"strings"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
"suruatoel.xyz/mcg/internal/common"
)
type albumHeaderbar struct {
*adw.Bin
titleLabel *gtk.Label
artistLabel *gtk.Label
closeButton *gtk.Button
}
func newAlbumHeaderbar() *albumHeaderbar {
bar := albumHeaderbar{}
bar.loadWidgets()
return &bar
}
func (b *albumHeaderbar) loadWidgets() {
builder := gtk.NewBuilderFromString(data.AlbumHeaderbarXML)
b.Bin = builder.GetObject("McgAlbumHeaderbar").Cast().(*adw.Bin)
b.titleLabel = builder.GetObject("standalone_title").Cast().(*gtk.Label)
b.artistLabel = builder.GetObject("standalone_artist").Cast().(*gtk.Label)
b.closeButton = builder.GetObject("close_button").Cast().(*gtk.Button)
}
func (b *albumHeaderbar) SetAlbum(album *common.Album) {
b.titleLabel.SetText(album.Title())
b.artistLabel.SetText(strings.Join(album.Artists(), ", "))
}
func (b *albumHeaderbar) ConnectClose(callback func()) {
b.closeButton.ConnectClicked(callback)
}

151
internal/gui/application.go Normal file
View file

@ -0,0 +1,151 @@
package gui
import (
"log"
"os"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
"suruatoel.xyz/mcg/internal/client"
"suruatoel.xyz/mcg/internal/i18n"
)
type Application struct {
*gtk.Application
window *mainWindow
aboutDialog *adw.AboutDialog
config Configuration
configErr error
}
func NewApplication() *Application {
app := Application{
Application: gtk.NewApplication("xyz.suruatoel.mcg", gio.ApplicationFlagsNone),
}
app.ConnectStartup(app.onStartup)
app.ConnectActivate(app.onActivate)
app.ConnectShutdown(app.onShutdown)
return &app
}
func (a *Application) onStartup() {
a.setDefaultSettings()
a.loadCSS()
a.registerShortcuts()
a.registerActions()
a.loadConfig()
}
func (a *Application) loadConfig() {
a.config = Configuration{
Host: "localhost",
Port: 6600,
Panel: "library",
SortField: "year",
SortDesc: false,
}
a.configErr = readConfig(&a.config)
}
func (a *Application) setDefaultSettings() {
styleManager := adw.StyleManagerGetDefault()
styleManager.SetColorScheme(adw.ColorSchemePreferDark)
}
func (a *Application) loadCSS() {
styleProvider := gtk.NewCSSProvider()
styleProvider.LoadFromString(data.BuildMainCSS())
gtk.StyleContextAddProviderForDisplay(
gdk.DisplayGetDefault(),
styleProvider,
gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
}
func (a *Application) registerShortcuts() {
a.SetAccelsForAction("window.close", []string{"<primary>q"})
a.SetAccelsForAction("win.show-help-overlay", []string{"<primary>k"})
a.SetAccelsForAction("app.info", []string{"<primary>i"})
a.SetAccelsForAction("win.connect", []string{"<primary>c"})
a.SetAccelsForAction("win.play", []string{"<primary>p"})
a.SetAccelsForAction("win.clear-playlist", []string{"<primary>r"})
a.SetAccelsForAction("win.toggle-fullscreen", []string{"F11"})
a.SetAccelsForAction("win.search-library", []string{"<primary>f"})
a.SetAccelsForAction("win.panel(\"server\")", []string{"<primary>KP_1"})
a.SetAccelsForAction("win.panel(\"cover\")", []string{"<primary>KP_2"})
a.SetAccelsForAction("win.panel(\"playlist\")", []string{"<primary>KP_3"})
a.SetAccelsForAction("win.panel(\"library\")", []string{"<primary>KP_4"})
}
func (a *Application) registerActions() {
infoAction := gio.NewSimpleAction("info", nil)
infoAction.ConnectActivate(func(_ *glib.Variant) {
a.showAboutDialog()
})
a.AddAction(infoAction)
}
func (a *Application) showAboutDialog() {
if a.aboutDialog == nil {
a.aboutDialog = a.newAboutDialog()
}
a.aboutDialog.Present(a.window)
}
func (a *Application) newAboutDialog() *adw.AboutDialog {
dialog := adw.NewAboutDialog()
dialog.SetApplicationName("CoverGrid")
dialog.SetApplicationIcon("xyz.suruatoel.mcg")
dialog.SetVersion(a.Version())
dialog.SetComments(i18n.T("CoverGrid is a client for the Music Player Daemon, focusing on albums instead of single tracks."))
dialog.SetWebsite("https://www.suruatoel.xyz/codes/mcg")
dialog.SetLicenseType(gtk.LicenseGPL30)
dialog.SetIssueURL("https://git.suruatoel.xyz/coderkun/mcg")
return dialog
}
func (a *Application) onActivate() {
if a.window == nil {
a.window = newMainWindow(a.Application, a.config)
if a.configErr != nil && !os.IsNotExist(a.configErr) {
a.window.SetConfigError(a.configErr)
}
a.window.ConnectCloseRequest(a.onCloseRequest)
}
a.window.Present()
if a.config.IsConnected {
client.Instance().Connect(a.config.Host, int(a.config.Port), a.config.Password)
}
}
func (a *Application) onCloseRequest() bool {
a.window.UpdateConfig(&a.config)
a.config.IsConnected = client.Instance().IsConnected()
a.saveConfig()
return false
}
func (a *Application) saveConfig() {
err := writeConfig(&a.config)
if err != nil {
log.Println("failed to save configuration file:", err)
}
}
func (a *Application) onShutdown() {
if client.Instance().IsConnected() {
client.Instance().Disconnect()
}
}

78
internal/gui/config.go Normal file
View file

@ -0,0 +1,78 @@
package gui
import (
"encoding/json"
"os"
"path/filepath"
)
type Configuration struct {
Host string `json:"host"`
Port uint64 `json:"port"`
Password string `json:"password"`
IsConnected bool `json:"connected"`
Panel string `json:"panel"`
SortField string `json:"sortField"`
SortDesc bool `json:"sortDesc"`
ItemSize int `json:"itemSize"`
}
func readConfig(config *Configuration) error {
filename, filenameErr := getConfigFile()
if filenameErr != nil {
return filenameErr
}
file, fileErr := os.Open(filename)
if fileErr != nil {
return fileErr
}
defer file.Close()
return json.NewDecoder(file).Decode(config)
}
func writeConfig(config *Configuration) error {
filename, filenameErr := getConfigFile()
if filenameErr != nil {
return filenameErr
}
file, fileErr := os.Create(filename)
if fileErr != nil {
return fileErr
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", "\t")
return encoder.Encode(&config)
}
func getConfigFile() (string, error) {
configDir, configDirErr := getConfigDir()
if configDirErr != nil {
return "", configDirErr
}
createErr := ensureConfigDirExists(configDir)
if createErr != nil {
return "", createErr
}
return filepath.Join(configDir, "config.json"), nil
}
func ensureConfigDirExists(folder string) error {
return os.MkdirAll(folder, 0o750)
}
func getConfigDir() (string, error) {
configDir, configDirErr := os.UserConfigDir()
if configDirErr != nil {
return "", configDirErr
}
return filepath.Join(configDir, "mcg"), nil
}

View file

@ -0,0 +1,58 @@
package gui
import (
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
)
type connectionPanel struct {
*adw.Bin
hostEntry *adw.EntryRow
portSpinner *gtk.SpinButton
passwordEntry *adw.PasswordEntryRow
}
func newConnectionPanel(config Configuration) *connectionPanel {
panel := connectionPanel{}
panel.loadWidgets()
panel.loadDefaultValues(config)
panel.connectSignals()
return &panel
}
func (p *connectionPanel) loadWidgets() {
builder := gtk.NewBuilderFromString(data.ConnectionPanelXML)
p.Bin = builder.GetObject("McgConnectionPanel").Cast().(*adw.Bin)
p.hostEntry = builder.GetObject("host_row").Cast().(*adw.EntryRow)
p.portSpinner = builder.GetObject("port_spinner").Cast().(*gtk.SpinButton)
p.passwordEntry = builder.GetObject("password_row").Cast().(*adw.PasswordEntryRow)
}
func (p *connectionPanel) connectSignals() {
p.hostEntry.ConnectApply(p.callback)
p.portSpinner.ConnectValueChanged(p.callback)
p.passwordEntry.ConnectApply(p.callback)
}
func (p *connectionPanel) loadDefaultValues(config Configuration) {
p.hostEntry.SetText(config.Host)
p.portSpinner.SetValue(float64(config.Port))
p.passwordEntry.SetText(config.Password)
}
func (p *connectionPanel) UpdateConfig(config *Configuration) {
config.Host = p.hostEntry.Text()
config.Port = uint64(p.portSpinner.Value())
config.Password = p.passwordEntry.Text()
}
func (p *connectionPanel) callback() {
}
func (p *connectionPanel) ConnectionDetails() (string, int, string) {
return p.hostEntry.Text(), p.portSpinner.ValueAsInt(), p.passwordEntry.Text()
}

188
internal/gui/coverloader.go Normal file
View file

@ -0,0 +1,188 @@
package gui
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/diamondburned/gotk4/pkg/core/gerror"
"github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"suruatoel.xyz/mcg/internal/client"
"suruatoel.xyz/mcg/internal/common"
)
type CoverLoader struct {
hostDir string
size int
mutex sync.Mutex
}
func NewCoverLoader(host string) *CoverLoader {
loader := CoverLoader{}
loader.hostDir = loader.getHostDir(host)
loader.size = loader.readCacheSize()
return &loader
}
func (l *CoverLoader) SetSize(size int) {
if l.size != size {
log.Println("cache size has changed from", l.size, "to", size)
l.clearCache()
l.writeCacheSize(size)
l.size = size
}
}
func (l *CoverLoader) LoadThumbnail(album *common.Album) *gdkpixbuf.Pixbuf {
// Load cover from cache
pixbuf := l.getCached(album)
if pixbuf != nil {
return pixbuf
}
// Load cover from server
albumart := client.Instance().LoadAlbumartNow(album)
if albumart != nil {
pixbuf = l.loadImage(albumart)
}
if pixbuf != nil {
pixbuf = pixbuf.ScaleSimple(l.size, l.size, gdkpixbuf.InterpHyper)
if pixbuf != nil {
l.setCached(album, pixbuf)
}
}
return pixbuf
}
func (l *CoverLoader) getHostDir(host string) string {
cacheDir, cacheDirErr := os.UserCacheDir()
if cacheDirErr != nil {
log.Println("failed to detect user cache directory:", cacheDirErr)
return ""
}
hostDir := filepath.Join(cacheDir, "mcg", host)
return hostDir
}
func (l *CoverLoader) ensureHostDir() {
hostDirErr := os.MkdirAll(l.hostDir, 0o750)
if hostDirErr != nil {
log.Println("failed to create cache folder for host", l.hostDir, ":", hostDirErr)
}
}
func (l *CoverLoader) readCacheSize() int {
sizeFile := l.getCacheSizeFile()
if sizeFile == "" {
return 100
}
l.mutex.Lock()
defer l.mutex.Unlock()
content, readErr := os.ReadFile(sizeFile)
if readErr != nil {
log.Println("failed to read cache size file for host", l.hostDir, ":", readErr)
return 100
}
size, sizeErr := strconv.Atoi(string(content[:]))
if sizeErr != nil {
log.Println("invalid content of cache size file for host", l.hostDir, ":", readErr)
}
return size
}
func (l *CoverLoader) writeCacheSize(size int) {
l.ensureHostDir()
sizeFile := l.getCacheSizeFile()
if sizeFile == "" {
return
}
writeErr := os.WriteFile(sizeFile, []byte(fmt.Sprintf("%d", size)), 0o644)
if writeErr != nil {
log.Println("failed to write cache size file for host", l.hostDir, ":", writeErr)
}
}
func (l *CoverLoader) getCacheFile(album *common.Album) string {
if l.hostDir == "" {
return ""
}
return filepath.Join(l.hostDir, album.ID())
}
func (l *CoverLoader) getCacheSizeFile() string {
if l.hostDir == "" {
return ""
}
return filepath.Join(l.hostDir, "size")
}
func (l *CoverLoader) clearCache() {
if l.hostDir == "" {
return
}
removeErr := os.RemoveAll(l.hostDir)
if removeErr != nil {
log.Println("failed to clear cache folder for host", l.hostDir, ":", removeErr)
}
}
func (l *CoverLoader) getCached(album *common.Album) *gdkpixbuf.Pixbuf {
cacheFile := l.getCacheFile(album)
if cacheFile == "" {
return nil
}
pixbuf, pixbufErr := gdkpixbuf.NewPixbufFromFile(cacheFile)
if pixbufErr != nil {
if !isFileNotFoundError(pixbufErr) {
log.Println("failed to read cached thumbnail for host", l.hostDir, ":", pixbufErr)
}
return nil
}
return pixbuf
}
func isFileNotFoundError(err error) bool {
if gerr, isGerror := err.(*gerror.GError); isGerror {
return gerr.Quark() == uint32(glib.FileErrorQuark()) && gerr.ErrorCode() == int(glib.FileErrorNoent)
}
return false
}
func (l *CoverLoader) setCached(album *common.Album, pixbuf *gdkpixbuf.Pixbuf) {
cacheFile := l.getCacheFile(album)
if cacheFile == "" {
return
}
l.ensureHostDir()
pixbuf.Savev(cacheFile, "jpeg", []string{}, []string{})
}
func (l *CoverLoader) loadImage(file []byte) *gdkpixbuf.Pixbuf {
pixbufLoader, loaderErr := loadPixbuf(file)
if loaderErr != nil {
log.Println("failed to load image:", loaderErr)
return nil
}
return pixbufLoader.Pixbuf()
}

343
internal/gui/coverpanel.go Normal file
View file

@ -0,0 +1,343 @@
package gui
import (
"fmt"
"log"
"math"
"strings"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
"suruatoel.xyz/mcg/internal/client"
"suruatoel.xyz/mcg/internal/common"
"suruatoel.xyz/mcg/internal/i18n"
)
type coverPanel struct {
*gtk.Overlay
toolbar *gtk.Box
fullscreenButton *gtk.Button
coverInfoScroll *gtk.ScrolledWindow
coverStack *gtk.Stack
coverSpinner *gtk.Spinner
coverScroll *gtk.ScrolledWindow
coverBox *gtk.Viewport
coverDefault *gtk.Image
coverImage *gtk.Image
infoRevealer *gtk.Revealer
albumTitleLabel *gtk.Label
albumDateLabel *gtk.Label
albumArtistLabel *gtk.Label
songsScale *gtk.Scale
currentAlbum *common.PlaylistAlbum
currentPixbuf *gdkpixbuf.Pixbuf
currentWidth int
currentHeight int
timer *glib.SourceHandle
coverBoxController *gtk.GestureClick
isFullscreenActive bool
}
func newCoverPanel() *coverPanel {
panel := coverPanel{}
panel.loadWidgets()
panel.connectSignals()
return &panel
}
func (p *coverPanel) loadWidgets() {
builder := gtk.NewBuilderFromString(data.CoverPanelXML)
p.toolbar = builder.GetObject("toolbar").Cast().(*gtk.Box)
p.fullscreenButton = builder.GetObject("fullscreen_button").Cast().(*gtk.Button)
p.Overlay = builder.GetObject("McgCoverPanel").Cast().(*gtk.Overlay)
p.coverInfoScroll = builder.GetObject("cover_info_scroll").Cast().(*gtk.ScrolledWindow)
p.infoRevealer = builder.GetObject("info_revealer").Cast().(*gtk.Revealer)
p.albumTitleLabel = builder.GetObject("album_title_label").Cast().(*gtk.Label)
p.albumDateLabel = builder.GetObject("album_date_label").Cast().(*gtk.Label)
p.albumArtistLabel = builder.GetObject("album_artist_label").Cast().(*gtk.Label)
p.songsScale = builder.GetObject("songs_scale").Cast().(*gtk.Scale)
p.coverStack = builder.GetObject("cover_stack").Cast().(*gtk.Stack)
p.coverSpinner = builder.GetObject("cover_spinner").Cast().(*gtk.Spinner)
p.coverScroll = builder.GetObject("cover_scroll").Cast().(*gtk.ScrolledWindow)
p.coverBox = builder.GetObject("cover_box").Cast().(*gtk.Viewport)
p.coverDefault = builder.GetObject("cover_default").Cast().(*gtk.Image)
p.coverImage = builder.GetObject("cover_image").Cast().(*gtk.Image)
}
func (p *coverPanel) connectSignals() {
p.coverBoxController = gtk.NewGestureClick()
p.coverBox.AddController(p.coverBoxController)
buttonController := gtk.NewGestureClick()
buttonController.ConnectPressed(p.OnSongsScalePressed)
buttonController.ConnectUnpairedRelease(p.OnSongsScaleUnpairedReleased)
p.songsScale.AddController(buttonController)
}
func (p *coverPanel) ConnectFullscreen(callback func()) {
p.fullscreenButton.ConnectClicked(callback)
p.coverBoxController.ConnectPressed(func(nPress int, x float64, y float64) {
if nPress == 2 && p.currentAlbum != nil {
callback()
}
})
}
func (p *coverPanel) OnSongsScalePressed(nPress int, x float64, y float64) {
p.clearTimer()
}
func (p *coverPanel) OnSongsScaleUnpairedReleased(x float64, y float64, button uint, sequence *gdk.EventSequence) {
value := uint64(p.songsScale.Value())
time := p.currentAlbum.Length()
songs := p.currentAlbum.Songs()
pos := 0
for index := len(songs) - 1; index >= 0; index-- {
time = time - songs[index].Length()
pos = int(songs[index].Pos())
if time < value {
break
}
}
time = max(value-time-1, 0)
client.Instance().Seek(pos, time)
}
func (p *coverPanel) Toolbar() gtk.Widgetter {
return p.toolbar
}
func (p *coverPanel) SetPlay(pos uint64, time uint64) {
p.clearTimer()
defer p.startTimer()
dsongs := p.currentAlbum.Songs()
for index := range pos {
time += uint64(dsongs[index].Length())
}
p.songsScale.SetValue(float64(time + 1))
}
func (p *coverPanel) SetPause() {
p.clearTimer()
}
func (p *coverPanel) clearTimer() {
if p.timer != nil {
glib.SourceRemove(*p.timer)
p.timer = nil
}
}
func (p *coverPanel) startTimer() {
newTimer := glib.TimeoutAdd(1000, p.playing)
p.timer = &newTimer
}
func (p *coverPanel) playing() bool {
p.songsScale.SetValue(p.songsScale.Value() + 1)
return true
}
func (p *coverPanel) SetAlbum(state string, album *common.PlaylistAlbum) {
if album != nil {
// Set labels
p.albumTitleLabel.SetLabel(album.Title())
p.albumDateLabel.SetLabel(strings.Join(album.Dates(), ", "))
p.albumArtistLabel.SetLabel(strings.Join(album.Artists(), ","))
p.infoRevealer.SetRevealChild(true)
p.setSongs(album)
} else {
p.infoRevealer.SetRevealChild(false)
}
// Load cover
if album != nil {
p.coverSpinner.Start()
p.coverStack.SetVisibleChild(p.coverSpinner)
client.Instance().LoadAlbumart(album.Album)
}
// Set current album
p.currentAlbum = album
p.fullscreenButton.SetSensitive(album != nil)
p.enableSonglist()
}
func (p *coverPanel) Fullscreen(active bool) {
p.isFullscreenActive = active
p.enableSonglist()
glib.IdleAdd(p.resizeImage)
}
func (p *coverPanel) enableSonglist() {
enable := p.currentAlbum != nil && !p.isFullscreenActive
p.infoRevealer.SetRevealChild(enable)
}
func (p *coverPanel) SetAlbumart(album *common.Album, albumart []byte) {
if p.currentAlbum == nil || album != p.currentAlbum.Album {
return
}
defer glib.IdleAdd(func() {
p.coverStack.SetVisibleChild(p.coverScroll)
p.coverSpinner.Stop()
})
p.currentPixbuf = nil
if albumart == nil || len(albumart) == 0 {
return
}
loader, loaderErr := loadPixbuf(albumart)
if loaderErr != nil {
log.Println("failed to load albumart for ", album.Title(), ":", loaderErr)
return
}
p.currentPixbuf = loader.Pixbuf()
glib.IdleAdd(p.showImage)
}
func (p *coverPanel) showImage() {
if p.currentPixbuf != nil {
p.resizeImage()
p.coverStack.SetVisibleChild(p.coverScroll)
} else {
p.coverStack.SetVisibleChild(p.coverDefault)
}
}
func loadPixbuf(data []byte) (*gdkpixbuf.PixbufLoader, error) {
loader := gdkpixbuf.NewPixbufLoader()
defer loader.Close()
return loader, loader.Write(data)
}
func (p *coverPanel) setSongs(album *common.PlaylistAlbum) {
p.songsScale.ClearMarks()
p.songsScale.SetRange(0, float64(album.Length()))
length := uint64(0)
for _, song := range album.Songs() {
curLength := length
if length > 0 && length < album.Length() {
curLength += 1
}
songTitle := createSongTitle(song)
p.songsScale.AddMark(
float64(curLength),
gtk.PosRight,
glib.MarkupEscapeText(songTitle),
)
length += song.Length()
}
p.songsScale.AddMark(float64(length), gtk.PosRight, createAlbumLengthTitle(length))
p.alignSongsScaleMarks()
}
func (p *coverPanel) alignSongsScaleMarks() {
p.alignScaleMarks(&p.songsScale.Widget)
}
func (p *coverPanel) alignScaleMarks(widgetter gtk.Widgetter) {
widget := widgetter.(*gtk.Widget)
child := widget.FirstChild()
for child != nil {
if label, isLabel := child.(*gtk.Label); isLabel {
label.SetHAlign(gtk.AlignStart)
child = label.NextSibling()
} else {
p.alignScaleMarks(child)
child = child.(*gtk.Widget).NextSibling()
}
}
}
func createSongTitle(song *common.PlaylistSong) string {
songArtists := song.SongArtists()
if len(songArtists) > 0 {
return fmt.Sprintf(
i18n.T("%s feat. %s"),
song.Title(),
strings.Join(songArtists, ", "),
)
}
return song.Title()
}
func createAlbumLengthTitle(length uint64) string {
minutes := length / 60
seconds := length % 60
return fmt.Sprintf(i18n.T("%d:%d minutes"), minutes, seconds)
}
func (p *coverPanel) SetWidth(width int) {
glib.IdleAdd(p.resizeImage)
glib.IdleAdd(func() {
p.coverInfoScroll.SetMaxContentWidth(width / 2)
})
}
func (p *coverPanel) resizeImage() {
newWidth, newHeight := p.getSize()
if p.hasSizeChanged(newWidth, newHeight) {
return
}
p.setSize(newWidth, newHeight)
if p.currentPixbuf == nil {
p.coverImage.SetPixelSize(min(newWidth, newHeight))
return
}
width, height := p.calculateCoverSize()
if width <= 0 || height <= 0 {
return
}
scaledPixbuf := p.currentPixbuf.ScaleSimple(width, height, gdkpixbuf.InterpHyper)
p.coverImage.SetFromPaintable(gdk.NewTextureForPixbuf(scaledPixbuf))
p.coverImage.SetPixelSize(min(width, height))
}
func (p *coverPanel) getSize() (int, int) {
return p.coverStack.Size(gtk.OrientationHorizontal), p.coverStack.Size(gtk.OrientationVertical)
}
func (p *coverPanel) hasSizeChanged(newWidth, newHeight int) bool {
return newWidth == p.currentWidth && newHeight == p.currentHeight
}
func (p *coverPanel) setSize(newWidth, newHeight int) {
p.currentWidth = newWidth
p.currentHeight = newHeight
}
func (p *coverPanel) calculateCoverSize() (width, height int) {
ratioWidth := float64(p.currentWidth) / float64(p.currentPixbuf.Width())
ratioHeight := float64(p.currentHeight) / float64(p.currentPixbuf.Height())
ratio := min(min(ratioWidth, ratioHeight), 1)
width = int(math.Floor(float64(p.currentPixbuf.Width()) * ratio))
height = int(math.Floor(float64(p.currentPixbuf.Height()) * ratio))
return
}

View file

@ -0,0 +1,563 @@
package gui
import (
"log"
"math"
"slices"
"strings"
"sync"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
"suruatoel.xyz/mcg/internal/client"
"suruatoel.xyz/mcg/internal/common"
)
type libraryPanel struct {
*adw.Bin
toolbar *gtk.Box
selectButton *gtk.ToggleButton
actionbarRevealer *gtk.Revealer
toolbarPopover *gtk.Popover
searchBar *gtk.SearchBar
searchEntry *gtk.SearchEntry
sortButtons map[string]*gtk.CheckButton
sortDescButton *gtk.CheckButton
gridScale *gtk.Scale
stack *gtk.Stack
progressBox *gtk.Box
progressBar *gtk.ProgressBar
scroll *gtk.ScrolledWindow
libraryGrid *gtk.GridView
gridFactory *pictureItemFactory[string]
libraryModel *gtk.StringList
selectionMulti *gtk.MultiSelection
selectionSingle *gtk.SingleSelection
headerbarStandalone *albumHeaderbar
libraryStack *gtk.Stack
panelNormal *gtk.Box
panelStandalone *gtk.Box
standaloneStack *gtk.Stack
standaloneSpinner *gtk.Spinner
standaloneScroll *gtk.ScrolledWindow
standaloneImage *gtk.Image
standalonePlayButton *gtk.Button
standaloneQueueButton *gtk.Button
selectionQueueButton *gtk.Button
selectionCancelButton *gtk.Button
coverLoader *CoverLoader
albums map[string]*common.Album
pixbufs map[string]*gdkpixbuf.Pixbuf
sortField string
sortDesc bool
itemSize int
itemSizeCallback ItemSizeCallback
libraryLock sync.Mutex
libraryWait sync.WaitGroup
standaloneAlbum *common.Album
standalonePixbuf *gdkpixbuf.Pixbuf
standaloneCallback func(bool)
}
type ItemSizeCallback func(itemSize int)
func newLibraryPanel(config Configuration) *libraryPanel {
panel := libraryPanel{
albums: make(map[string]*common.Album),
pixbufs: make(map[string]*gdkpixbuf.Pixbuf),
sortField: "year",
sortDesc: false,
libraryLock: sync.Mutex{},
libraryWait: sync.WaitGroup{},
}
panel.loadWidgets(config)
panel.loadDefaultValues(config)
panel.connectSignals()
return &panel
}
func (p *libraryPanel) loadWidgets(config Configuration) {
builder := gtk.NewBuilderFromString(data.LibraryPanelXML)
p.toolbar = builder.GetObject("toolbar").Cast().(*gtk.Box)
p.selectButton = builder.GetObject("select_button").Cast().(*gtk.ToggleButton)
p.actionbarRevealer = builder.GetObject("actionbar_revealer").Cast().(*gtk.Revealer)
p.toolbarPopover = builder.GetObject("toolbar_popover").Cast().(*gtk.Popover)
p.searchBar = builder.GetObject("filter_bar").Cast().(*gtk.SearchBar)
p.searchEntry = builder.GetObject("filter_entry").Cast().(*gtk.SearchEntry)
p.sortButtons = make(map[string]*gtk.CheckButton)
p.sortButtons["artist"] = builder.GetObject("sort_artist").Cast().(*gtk.CheckButton)
p.sortButtons["title"] = builder.GetObject("sort_title").Cast().(*gtk.CheckButton)
p.sortButtons["year"] = builder.GetObject("sort_year").Cast().(*gtk.CheckButton)
p.sortButtons["modified"] = builder.GetObject("sort_modified").Cast().(*gtk.CheckButton)
p.sortDescButton = builder.GetObject("toolbar_sort_order_button").Cast().(*gtk.CheckButton)
p.gridScale = builder.GetObject("grid_scale").Cast().(*gtk.Scale)
p.Bin = builder.GetObject("McgLibraryPanel").Cast().(*adw.Bin)
p.stack = builder.GetObject("stack").Cast().(*gtk.Stack)
p.progressBox = builder.GetObject("progress_box").Cast().(*gtk.Box)
p.progressBar = builder.GetObject("progress_bar").Cast().(*gtk.ProgressBar)
p.scroll = builder.GetObject("scroll").Cast().(*gtk.ScrolledWindow)
p.libraryGrid = builder.GetObject("library_grid").Cast().(*gtk.GridView)
p.libraryModel = gtk.NewStringList(nil)
p.selectionMulti = gtk.NewMultiSelection(p.libraryModel)
p.selectionSingle = gtk.NewSingleSelection(p.libraryModel)
p.libraryGrid.SetModel(p.selectionSingle)
p.gridFactory = newPictureItemFactory[string](p.itemToModelID, p.modelIDToValue)
p.libraryGrid.SetFactory(p.gridFactory.ListItemFactory())
// Headerbar
p.headerbarStandalone = newAlbumHeaderbar()
p.libraryStack = builder.GetObject("library_stack").Cast().(*gtk.Stack)
p.panelNormal = builder.GetObject("panel_normal").Cast().(*gtk.Box)
p.panelStandalone = builder.GetObject("panel_standalone").Cast().(*gtk.Box)
p.standaloneStack = builder.GetObject("standalone_stack").Cast().(*gtk.Stack)
p.standaloneSpinner = builder.GetObject("standalone_spinner").Cast().(*gtk.Spinner)
p.standaloneScroll = builder.GetObject("standalone_scroll").Cast().(*gtk.ScrolledWindow)
p.standaloneImage = builder.GetObject("standalone_image").Cast().(*gtk.Image)
p.standalonePlayButton = builder.GetObject("standalone_play_button").Cast().(*gtk.Button)
p.standaloneQueueButton = builder.GetObject("standalone_queue_button").Cast().(*gtk.Button)
// Selection
p.selectionQueueButton = builder.GetObject("selection_queue_button").Cast().(*gtk.Button)
p.selectionCancelButton = builder.GetObject("selection_cancel_button").Cast().(*gtk.Button)
}
func (p *libraryPanel) itemToModelID(item *glib.Object) string {
return item.Cast().(*gtk.StringObject).String()
}
func (p *libraryPanel) modelIDToValue(itemID string) (tooltip string, pixbuf *gdkpixbuf.Pixbuf) {
if album, ok := p.albums[itemID]; ok {
tooltip = album.Tooltip()
}
pixbuf = p.pixbufs[itemID]
return
}
func (p *libraryPanel) loadDefaultValues(config Configuration) {
p.itemSize = config.ItemSize
p.gridScale.SetValue(float64(config.ItemSize))
_, found := p.sortButtons[config.SortField]
if found {
p.sortField = config.SortField
}
p.sortDesc = config.SortDesc
p.sortButtons[p.sortField].SetActive(true)
p.sortDescButton.SetActive(p.sortDesc)
}
func (p *libraryPanel) connectSignals() {
p.selectButton.ConnectToggled(p.onSelectToggled)
p.searchEntry.ConnectSearchChanged(p.onFilterEntryChanged)
for field, button := range p.sortButtons {
button.ConnectToggled(func() {
p.onSortToggled(field, button)
})
}
p.sortDescButton.ConnectToggled(p.onSortDescToggled)
p.libraryGrid.ConnectActivate(p.onGridClicked)
p.headerbarStandalone.ConnectClose(p.onHeaderbarStandaloneClose)
p.standalonePlayButton.ConnectClicked(p.onStandalonePlayClicked)
p.standaloneQueueButton.ConnectClicked(p.onStandaloneQueueClicked)
p.selectionQueueButton.ConnectClicked(p.onSelectionQueue)
p.selectionCancelButton.ConnectClicked(p.onSelectionCancel)
buttonController := gtk.NewGestureClick()
buttonController.ConnectUnpairedRelease(p.onGridScaleReleased)
p.gridScale.AddController(buttonController)
p.gridScale.ConnectValueChanged(p.onGridScaleChanged)
}
func (p *libraryPanel) ConnectItemSizeChanged(callback ItemSizeCallback) {
p.itemSizeCallback = callback
}
func (p *libraryPanel) ConnectStandalone(callback func(bool)) {
p.standaloneCallback = callback
}
func (p *libraryPanel) onSelectToggled() {
if p.selectButton.Active() {
p.actionbarRevealer.SetRevealChild(true)
p.libraryGrid.SetModel(p.selectionMulti)
p.libraryGrid.SetSingleClickActivate(false)
p.libraryGrid.StyleContext().AddClass("selection")
} else {
p.actionbarRevealer.SetRevealChild(false)
p.libraryGrid.SetModel(p.selectionSingle)
p.libraryGrid.SetSingleClickActivate(true)
p.libraryGrid.StyleContext().RemoveClass("selection")
}
}
func (p *libraryPanel) onFilterEntryChanged() {
p.updateGridModel()
}
func (p *libraryPanel) onSortToggled(field string, button *gtk.CheckButton) {
if !button.Active() {
return
}
p.sortField = field
p.updateGridModel()
}
func (p *libraryPanel) onSortDescToggled() {
p.sortDesc = p.sortDescButton.Active()
p.updateGridModel()
}
func (p *libraryPanel) onGridScaleChanged() {
size := math.Floor(p.gridScale.Value())
gridRange := p.gridScale.Adjustment()
if size < gridRange.Lower() || size > gridRange.Upper() {
return
}
go p.setGridSize(int(size))
}
func (p *libraryPanel) setGridSize(size int) {
if size == p.itemSize {
return
}
for i := range p.libraryGrid.Model().NItems() {
itemObj := p.libraryGrid.Model().Item(i)
if itemObj != nil {
itemStr := itemObj.Cast().(*gtk.StringObject)
albumID := itemStr.String()
picture, pictureFound := p.gridFactory.Picture(albumID)
if pictureFound {
pixbuf, pixbufFound := p.pixbufs[albumID]
if pixbufFound {
if pixbuf != nil {
pixbuf = pixbuf.ScaleSimple(size, size, gdkpixbuf.InterpNearest)
} else {
}
glib.IdleAdd(func() {
picture.SetPaintable(gdk.NewTextureForPixbuf(pixbuf))
})
}
}
}
}
}
func (p *libraryPanel) onGridScaleReleased(x float64, y float64, button uint, sequence *gdk.EventSequence) {
size := math.Floor(p.gridScale.Value())
gridRange := p.gridScale.Adjustment()
if size < gridRange.Lower() || size > gridRange.Upper() {
return
}
p.itemSize = int(size)
p.redraw()
glib.IdleAdd(p.toolbarPopover.Popdown)
if p.itemSizeCallback != nil {
p.itemSizeCallback(p.itemSize)
}
}
func (p *libraryPanel) redraw() {
if len(p.albums) > 0 {
p.SetAlbums(p.albums)
}
}
func (p *libraryPanel) onGridClicked(position uint) {
// Get selected album
itemObj := p.libraryModel.Item(position)
if itemObj == nil {
return
}
strObj := itemObj.Cast().(*gtk.StringObject)
id := strObj.String()
album, found := p.albums[id]
if !found {
return
}
// Show standalone album
if p.libraryGrid.Model().Native() == p.selectionSingle.Native() {
p.standaloneAlbum = album
p.headerbarStandalone.SetAlbum(album)
p.standaloneStack.SetVisibleChild(p.standaloneSpinner)
p.standaloneSpinner.Start()
p.openStandalone()
client.Instance().LoadAlbumart(album)
}
}
func (p *libraryPanel) onHeaderbarStandaloneClose() {
p.closeStandalone()
}
func (p *libraryPanel) openStandalone() {
p.libraryStack.SetVisibleChild(p.panelStandalone)
if p.standaloneCallback != nil {
p.standaloneCallback(true)
}
}
func (p *libraryPanel) closeStandalone() {
p.libraryStack.SetVisibleChild(p.panelNormal)
if p.standaloneCallback != nil {
p.standaloneCallback(false)
}
}
func (p *libraryPanel) setCoverLoader(loader *CoverLoader) {
p.coverLoader = loader
}
func (p *libraryPanel) SetAlbumart(album *common.Album, albumart []byte) {
if album != p.standaloneAlbum {
return
}
p.standalonePixbuf = nil
if albumart == nil || len(albumart) == 0 {
return
}
loader, loaderErr := loadPixbuf(albumart)
if loaderErr != nil {
log.Println("failed to load albumart for ", album.Title(), ":", loaderErr)
return
}
p.standalonePixbuf = loader.Pixbuf()
glib.IdleAdd(p.showStandaloneImage)
}
func (p *libraryPanel) showStandaloneImage() {
p.resizeStandaloneImage()
p.standaloneStack.SetVisibleChild(p.standaloneScroll)
p.standaloneSpinner.Stop()
}
func (p *libraryPanel) resizeStandaloneImage() {
newWidth, newHeight := p.getStandaloneSize()
if p.standalonePixbuf == nil {
p.standaloneImage.SetPixelSize(min(newWidth, newHeight))
return
}
width, height := p.calculateCoverSize(newWidth, newHeight)
if width <= 0 || height <= 0 {
return
}
scaledPixbuf := p.standalonePixbuf.ScaleSimple(width, height, gdkpixbuf.InterpHyper)
p.standaloneImage.SetFromPaintable(gdk.NewTextureForPixbuf(scaledPixbuf))
p.standaloneImage.SetPixelSize(min(width, height))
}
func (p *libraryPanel) getStandaloneSize() (int, int) {
return p.standaloneStack.Size(gtk.OrientationHorizontal), p.standaloneStack.Size(gtk.OrientationVertical)
}
func (p *libraryPanel) calculateCoverSize(currentWidth, currentHeight int) (width, height int) {
ratioWidth := float64(currentWidth) / float64(p.standalonePixbuf.Width())
ratioHeight := float64(currentHeight) / float64(p.standalonePixbuf.Height())
ratio := min(min(ratioWidth, ratioHeight), 1)
width = int(math.Floor(float64(p.standalonePixbuf.Width()) * ratio))
height = int(math.Floor(float64(p.standalonePixbuf.Height()) * ratio))
return
}
func (p *libraryPanel) onStandalonePlayClicked() {
client.Instance().PlayAlbum(p.standaloneAlbum)
p.closeStandalone()
}
func (p *libraryPanel) onStandaloneQueueClicked() {
client.Instance().QueueAlbum(p.standaloneAlbum)
p.closeStandalone()
}
func (p *libraryPanel) onSelectionQueue() {
albums := p.getSelectedAlbums()
if len(albums) > 0 {
client.Instance().QueueAlbums(albums)
}
p.selectButton.SetActive(false)
}
func (p *libraryPanel) onSelectionCancel() {
p.selectButton.SetActive(false)
}
func (p *libraryPanel) getSelectedAlbums() []*common.Album {
albums := []*common.Album{}
for i := range p.selectionMulti.NItems() {
if p.selectionMulti.IsSelected(i) {
itemObj := p.selectionMulti.Item(i)
if itemObj != nil {
itemStr := itemObj.Cast().(*gtk.StringObject)
albumID := itemStr.String()
album, found := p.albums[albumID]
if found {
albums = append(albums, album)
}
}
}
}
return albums
}
func (p *libraryPanel) UpdateConfig(config *Configuration) {
config.SortField = p.sortField
config.SortDesc = p.sortDesc
config.ItemSize = p.itemSize
}
func (p *libraryPanel) updateGridModel() {
albums := p.filterAlbums()
p.sortAlbums(albums)
p.updateGrid(albums)
}
func (p *libraryPanel) filterAlbums() []*common.Album {
var matchingAlbums []*common.Album
query := strings.TrimSpace(strings.ToLower(p.searchEntry.Text()))
for _, album := range p.albums {
if album.IsMatch(query) {
matchingAlbums = append(matchingAlbums, album)
}
}
return matchingAlbums
}
func (p *libraryPanel) sortAlbums(filtered []*common.Album) {
common.SortAlbums(filtered, p.sortField)
if p.sortDesc {
slices.Reverse(filtered)
}
}
func (p *libraryPanel) updateGrid(albums []*common.Album) {
glib.IdleAdd(func() {
n := p.libraryModel.NItems()
if n > 0 {
p.libraryModel.Splice(0, n, nil)
}
ids := make([]string, 0, len(albums))
for _, item := range albums {
ids = append(ids, item.ID())
}
if len(ids) > 0 {
p.libraryModel.Splice(0, 0, ids)
}
})
}
func (p *libraryPanel) Toolbar() gtk.Widgetter {
return p.toolbar
}
func (p *libraryPanel) Wait() {
p.libraryWait.Wait()
}
type LibraryFactory gtk.ListItemFactory
func (p *libraryPanel) SetAlbums(albums map[string]*common.Album) {
p.libraryWait.Add(1)
go p.setAlbums(albums)
}
func (p *libraryPanel) setAlbums(albums map[string]*common.Album) {
p.libraryLock.Lock()
defer p.libraryLock.Unlock()
defer p.libraryWait.Done()
p.albums = albums
glib.IdleAdd(func() {
p.stack.SetVisibleChild(p.progressBox)
p.progressBar.SetFraction(0.0)
})
// Create loader loader
i := 0
n := len(albums)
p.pixbufs = make(map[string]*gdkpixbuf.Pixbuf)
for _, album := range albums {
if p.coverLoader != nil {
p.pixbufs[album.ID()] = p.coverLoader.LoadThumbnail(album)
}
i++
glib.IdleAdd(func() {
p.progressBar.SetFraction(float64(i) / float64(n))
// FIXME: i18n
p.progressBar.SetText("Loading images")
})
}
p.updateGridModel()
glib.IdleAdd(func() {
p.stack.SetVisibleChild(p.scroll)
})
}
func (p *libraryPanel) SetWidth(width int) {
p.resizeStandaloneImage()
p.setMarks(width)
}
func (p *libraryPanel) setMarks(width int) {
glib.IdleAdd(func() {
p.gridScale.ClearMarks()
})
lower := p.gridScale.Adjustment().Lower()
upper := p.gridScale.Adjustment().Upper()
countMin := int(math.Max(float64(width)/upper, 1))
countMax := int(math.Max(float64(width)/lower, 1))
for i := countMin; i <= countMax; i++ {
pixel := int(float64(width) / float64(i))
pixel = pixel - (2 * pixel / 100)
glib.IdleAdd(func() {
p.gridScale.AddMark(float64(pixel), gtk.PosBottom, "")
})
}
}
func (p *libraryPanel) ShowSearch() {
p.searchBar.SetSearchMode(true)
}
func (p *libraryPanel) HeaderbarStandalone() *albumHeaderbar {
return p.headerbarStandalone
}

View file

@ -0,0 +1,78 @@
package gui
import (
"github.com/diamondburned/gotk4/pkg/core/glib"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
type pictureItemFactory[T comparable] struct {
model modelAccessor[T]
value valueAccessor[T]
listItemFactory *gtk.SignalListItemFactory
pictures map[T]*gtk.Picture
}
type (
modelAccessor[T any] func(item *glib.Object) T
valueAccessor[T any] func(T) (tooltip string, pixbuf *gdkpixbuf.Pixbuf)
)
func newPictureItemFactory[T comparable](model modelAccessor[T], value valueAccessor[T]) *pictureItemFactory[T] {
factory := pictureItemFactory[T]{
model: model,
value: value,
listItemFactory: gtk.NewSignalListItemFactory(),
pictures: make(map[T]*gtk.Picture),
}
factory.listItemFactory.Connect("setup", factory.setup)
factory.listItemFactory.Connect("bind", factory.bind)
factory.listItemFactory.Connect("unbind", factory.unbind)
return &factory
}
func (f *pictureItemFactory[T]) setup(listitem *gtk.ListItem) {
picture := gtk.NewPicture()
picture.SetContentFit(gtk.ContentFitContain)
picture.SetCanShrink(false)
listitem.SetChild(picture)
}
func (f *pictureItemFactory[T]) bind(listitem *gtk.ListItem) {
item := listitem.Item()
if item == nil {
return
}
itemID := f.model(item)
tooltip, pixbuf := f.value(itemID)
picture := listitem.Child().(*gtk.Picture)
picture.SetTooltipText(tooltip)
if pixbuf != nil {
picture.SetPaintable(gdk.NewTextureForPixbuf(pixbuf))
}
f.pictures[itemID] = picture
}
func (f *pictureItemFactory[T]) unbind(listitem *gtk.ListItem) {
itemObj := listitem.Item()
if itemObj == nil {
return
}
itemID := f.model(itemObj)
delete(f.pictures, itemID)
}
func (f *pictureItemFactory[T]) ListItemFactory() *gtk.ListItemFactory {
return &f.listItemFactory.ListItemFactory
}
func (f *pictureItemFactory[T]) Picture(itemID T) (*gtk.Picture, bool) {
picture, found := f.pictures[itemID]
return picture, found
}

View file

@ -0,0 +1,358 @@
package gui
import (
"log"
"math"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
"suruatoel.xyz/mcg/internal/client"
"suruatoel.xyz/mcg/internal/common"
)
type playlistPanel struct {
*adw.Bin
toolbar *gtk.Box
selectButton *gtk.ToggleButton
actionbarRevealer *gtk.Revealer
clearButton *gtk.Button
playlistGrid *gtk.GridView
gridFactory *pictureItemFactory[string]
playlistModel *gtk.StringList
selectionMulti *gtk.MultiSelection
selectionSingle *gtk.SingleSelection
headerbarStandalone *albumHeaderbar
playlistStack *gtk.Stack
panelNormal *gtk.Box
panelStandalone *gtk.Box
standaloneStack *gtk.Stack
standaloneSpinner *gtk.Spinner
standaloneScroll *gtk.ScrolledWindow
standaloneImage *gtk.Image
standalonePlayButton *gtk.Button
standaloneRemoveButton *gtk.Button
selectionRemoveButton *gtk.Button
selectionCancelButton *gtk.Button
coverLoader *CoverLoader
playlist []*common.PlaylistAlbum
albums map[string]*common.PlaylistAlbum
pixbufs map[string]*gdkpixbuf.Pixbuf
standaloneAlbum *common.PlaylistAlbum
standalonePixbuf *gdkpixbuf.Pixbuf
standaloneCallback func(bool)
}
func newPlaylistPanel() *playlistPanel {
panel := playlistPanel{
albums: make(map[string]*common.PlaylistAlbum),
pixbufs: make(map[string]*gdkpixbuf.Pixbuf),
}
panel.loadWidgets()
panel.connectSignals()
return &panel
}
func (p *playlistPanel) loadWidgets() {
builder := gtk.NewBuilderFromString(data.PlaylistPanelXML)
p.toolbar = builder.GetObject("toolbar").Cast().(*gtk.Box)
p.selectButton = builder.GetObject("select_button").Cast().(*gtk.ToggleButton)
p.actionbarRevealer = builder.GetObject("actionbar_revealer").Cast().(*gtk.Revealer)
p.clearButton = builder.GetObject("playlist_clear_button").Cast().(*gtk.Button)
p.Bin = builder.GetObject("McgPlaylistPanel").Cast().(*adw.Bin)
p.playlistGrid = builder.GetObject("playlist_grid").Cast().(*gtk.GridView)
p.playlistModel = gtk.NewStringList(nil)
p.selectionMulti = gtk.NewMultiSelection(p.playlistModel)
p.selectionSingle = gtk.NewSingleSelection(p.playlistModel)
p.playlistGrid.SetModel(p.selectionSingle)
p.gridFactory = newPictureItemFactory[string](p.itemToModelID, p.modelIDToValue)
p.playlistGrid.SetFactory(p.gridFactory.ListItemFactory())
// Headerbar
p.headerbarStandalone = newAlbumHeaderbar()
p.playlistStack = builder.GetObject("playlist_stack").Cast().(*gtk.Stack)
p.panelNormal = builder.GetObject("panel_normal").Cast().(*gtk.Box)
p.panelStandalone = builder.GetObject("panel_standalone").Cast().(*gtk.Box)
p.standaloneStack = builder.GetObject("standalone_stack").Cast().(*gtk.Stack)
p.standaloneSpinner = builder.GetObject("standalone_spinner").Cast().(*gtk.Spinner)
p.standaloneScroll = builder.GetObject("standalone_scroll").Cast().(*gtk.ScrolledWindow)
p.standaloneImage = builder.GetObject("standalone_image").Cast().(*gtk.Image)
p.standalonePlayButton = builder.GetObject("standalone_play_button").Cast().(*gtk.Button)
p.standaloneRemoveButton = builder.GetObject("standalone_remove_button").Cast().(*gtk.Button)
// Selection
p.selectionRemoveButton = builder.GetObject("selection_remove_button").Cast().(*gtk.Button)
p.selectionCancelButton = builder.GetObject("selection_cancel_button").Cast().(*gtk.Button)
}
func (p *playlistPanel) itemToModelID(item *glib.Object) string {
return item.Cast().(*gtk.StringObject).String()
}
func (p *playlistPanel) modelIDToValue(itemID string) (tooltip string, pixbuf *gdkpixbuf.Pixbuf) {
if album, ok := p.albums[itemID]; ok {
tooltip = album.Tooltip()
}
pixbuf = p.pixbufs[itemID]
return
}
func (p *playlistPanel) connectSignals() {
p.selectButton.ConnectToggled(p.onSelectToggled)
p.clearButton.ConnectClicked(p.onClearClicked)
p.playlistGrid.ConnectActivate(p.onGridClicked)
p.headerbarStandalone.ConnectClose(p.onHeaderbarStandaloneClose)
p.standalonePlayButton.ConnectClicked(p.onStandalonePlayClicked)
p.standaloneRemoveButton.ConnectClicked(p.onStandaloneRemoveClicked)
p.selectionRemoveButton.ConnectClicked(p.onSelectionRemove)
p.selectionCancelButton.ConnectClicked(p.onSelectionCancel)
}
func (p *playlistPanel) ConnectStandalone(callback func(bool)) {
p.standaloneCallback = callback
}
func (p *playlistPanel) onSelectToggled() {
if p.selectButton.Active() {
p.actionbarRevealer.SetRevealChild(true)
p.playlistGrid.SetModel(p.selectionMulti)
p.playlistGrid.SetSingleClickActivate(false)
p.playlistGrid.StyleContext().AddClass("selection")
} else {
p.actionbarRevealer.SetRevealChild(false)
p.playlistGrid.SetModel(p.selectionSingle)
p.playlistGrid.SetSingleClickActivate(true)
p.playlistGrid.StyleContext().AddClass("selection")
}
}
func (p *playlistPanel) onClearClicked() {
client.Instance().ClearPlaylist()
}
func (p *playlistPanel) onGridClicked(position uint) {
// Get selected album
itemObj := p.playlistModel.Item(position)
if itemObj == nil {
return
}
strObj := itemObj.Cast().(*gtk.StringObject)
id := strObj.String()
album, found := p.albums[id]
if !found {
return
}
// Show standalone album
if p.playlistGrid.Model().Native() == p.selectionSingle.Native() {
p.standaloneAlbum = album
p.headerbarStandalone.SetAlbum(album.Album)
p.standaloneStack.SetVisibleChild(p.standaloneSpinner)
p.standaloneSpinner.Start()
p.openStandalone()
client.Instance().LoadAlbumart(album.Album)
}
}
func (p *playlistPanel) onHeaderbarStandaloneClose() {
p.closeStandalone()
}
func (p *playlistPanel) openStandalone() {
p.playlistStack.SetVisibleChild(p.panelStandalone)
if p.standaloneCallback != nil {
p.standaloneCallback(true)
}
}
func (p *playlistPanel) closeStandalone() {
p.playlistStack.SetVisibleChild(p.panelNormal)
if p.standaloneCallback != nil {
p.standaloneCallback(false)
}
}
func (p *playlistPanel) setCoverLoader(loader *CoverLoader) {
p.coverLoader = loader
}
func (p *playlistPanel) SetAlbumart(album *common.Album, albumart []byte) {
if p.standaloneAlbum == nil || album != p.standaloneAlbum.Album {
return
}
p.standalonePixbuf = nil
if albumart == nil || len(albumart) == 0 {
return
}
loader, loaderErr := loadPixbuf(albumart)
if loaderErr != nil {
log.Println("failed to load albumart for ", album.Title(), ":", loaderErr)
return
}
p.standalonePixbuf = loader.Pixbuf()
glib.IdleAdd(p.showStandaloneImage)
}
func (p *playlistPanel) showStandaloneImage() {
p.resizeStandaloneImage()
p.standaloneStack.SetVisibleChild(p.standaloneScroll)
p.standaloneSpinner.Stop()
}
func (p *playlistPanel) resizeStandaloneImage() {
newWidth, newHeight := p.getStandaloneSize()
if p.standalonePixbuf == nil {
p.standaloneImage.SetPixelSize(min(newWidth, newHeight))
return
}
width, height := p.calculateCoverSize(newWidth, newHeight)
if width <= 0 || height <= 0 {
return
}
scaledPixbuf := p.standalonePixbuf.ScaleSimple(width, height, gdkpixbuf.InterpHyper)
p.standaloneImage.SetFromPaintable(gdk.NewTextureForPixbuf(scaledPixbuf))
p.standaloneImage.SetPixelSize(min(width, height))
}
func (p *playlistPanel) getStandaloneSize() (int, int) {
return p.standaloneStack.Size(gtk.OrientationHorizontal), p.standaloneStack.Size(gtk.OrientationVertical)
}
func (p *playlistPanel) calculateCoverSize(currentWidth, currentHeight int) (width, height int) {
ratioWidth := float64(currentWidth) / float64(p.standalonePixbuf.Width())
ratioHeight := float64(currentHeight) / float64(p.standalonePixbuf.Height())
ratio := min(min(ratioWidth, ratioHeight), 1)
width = int(math.Floor(float64(p.standalonePixbuf.Width()) * ratio))
height = int(math.Floor(float64(p.standalonePixbuf.Height()) * ratio))
return
}
func (p *playlistPanel) onStandalonePlayClicked() {
client.Instance().PlayQueuedAlbum(p.standaloneAlbum)
p.closeStandalone()
}
func (p *playlistPanel) onStandaloneRemoveClicked() {
client.Instance().UnqueueAlbum(p.standaloneAlbum)
p.closeStandalone()
}
func (p *playlistPanel) onSelectionRemove() {
albums := p.getSelectedAlbums()
if len(albums) > 0 {
client.Instance().UnqueueAlbums(albums)
}
p.selectButton.SetActive(false)
}
func (p *playlistPanel) getSelectedAlbums() []*common.PlaylistAlbum {
albums := []*common.PlaylistAlbum{}
for i := range p.selectionMulti.NItems() {
if p.selectionMulti.IsSelected(i) {
itemObj := p.selectionMulti.Item(i)
if itemObj != nil {
itemStr := itemObj.Cast().(*gtk.StringObject)
albumID := itemStr.String()
album, found := p.albums[albumID]
if found {
albums = append(albums, album)
}
}
}
}
return albums
}
func (p *playlistPanel) onSelectionCancel() {
p.selectButton.SetActive(false)
}
func (p *playlistPanel) Toolbar() gtk.Widgetter {
return p.toolbar
}
func (p *playlistPanel) ItemSizeChanged() {
p.redraw()
}
func (p *playlistPanel) redraw() {
if len(p.playlist) > 0 {
p.SetPlaylist(p.playlist)
}
}
func (p *playlistPanel) SetPlaylist(playlist []*common.PlaylistAlbum) {
go p.setPlaylist(playlist)
}
func (p *playlistPanel) setPlaylist(playlist []*common.PlaylistAlbum) {
p.playlist = playlist
for _, album := range playlist {
p.albums[album.ID()] = album
if p.coverLoader != nil {
p.pixbufs[album.ID()] = p.coverLoader.LoadThumbnail(album.Album)
}
}
p.updateGridModel()
}
func (p *playlistPanel) updateGridModel() {
p.updateGrid(p.playlist)
}
func (p *playlistPanel) updateGrid(albums []*common.PlaylistAlbum) {
glib.IdleAdd(func() {
n := p.playlistModel.NItems()
if n > 0 {
p.playlistModel.Splice(0, n, nil)
}
ids := make([]string, 0, len(albums))
for _, item := range albums {
ids = append(ids, item.ID())
}
if len(ids) > 0 {
p.playlistModel.Splice(0, 0, ids)
}
})
}
func (p *playlistPanel) SetWidth(width int) {
p.resizeStandaloneImage()
}
func (p *playlistPanel) ClearPlaylist() {
client.Instance().ClearPlaylist()
}
func (p *playlistPanel) HeaderbarStandalone() *albumHeaderbar {
return p.headerbarStandalone
}

115
internal/gui/serverpanel.go Normal file
View file

@ -0,0 +1,115 @@
package gui
import (
"fmt"
"strings"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
"suruatoel.xyz/mcg/internal/common"
)
type serverPanel struct {
*adw.Bin
toolbar *gtk.Box
statusFile *gtk.Label
statusAudio *gtk.Label
statusBitrate *gtk.Label
statusErrer *gtk.Label
statsArtist *gtk.Label
statsAlbums *gtk.Label
statsSongs *gtk.Label
statsDBPlaytime *gtk.Label
statsPlaytime *gtk.Label
statsUptime *gtk.Label
outputDevices *gtk.ListBox
outputDeviceChangeCB OutputDeviceChangeCB
}
type OutputDeviceChangeCB func(device *common.OutputDevice, enable bool)
func newServerPanel() *serverPanel {
panel := serverPanel{}
panel.loadWidgets()
return &panel
}
func (p *serverPanel) loadWidgets() {
builder := gtk.NewBuilderFromString(data.ServerPanelXML)
p.Bin = builder.GetObject("McgServerPanel").Cast().(*adw.Bin)
p.toolbar = builder.GetObject("toolbar").Cast().(*gtk.Box)
p.statusFile = builder.GetObject("status_file").Cast().(*gtk.Label)
p.statusAudio = builder.GetObject("status_audio").Cast().(*gtk.Label)
p.statusBitrate = builder.GetObject("status_bitrate").Cast().(*gtk.Label)
p.statusErrer = builder.GetObject("status_error").Cast().(*gtk.Label)
p.statsArtist = builder.GetObject("stats_artists").Cast().(*gtk.Label)
p.statsAlbums = builder.GetObject("stats_albums").Cast().(*gtk.Label)
p.statsSongs = builder.GetObject("stats_songs").Cast().(*gtk.Label)
p.statsDBPlaytime = builder.GetObject("stats_dbplaytime").Cast().(*gtk.Label)
p.statsPlaytime = builder.GetObject("stats_playtime").Cast().(*gtk.Label)
p.statsUptime = builder.GetObject("stats_uptime").Cast().(*gtk.Label)
p.outputDevices = builder.GetObject("output_devices").Cast().(*gtk.ListBox)
}
func (p *serverPanel) ReceiveOutputDeviceChange(callback OutputDeviceChangeCB) {
p.outputDeviceChangeCB = callback
}
func (p *serverPanel) Toolbar() gtk.Widgetter {
return p.toolbar
}
func (p *serverPanel) Status(file, audio, bitrate, errorMessage string) {
p.statusFile.SetText(file)
p.statusAudio.SetText(p.formatAudio(audio))
p.statusBitrate.SetText(p.formatBitrate(bitrate))
p.statusErrer.SetText(errorMessage)
}
func (p *serverPanel) formatAudio(audio string) string {
parts := strings.Split(audio, ":")
if len(parts) == 3 {
return fmt.Sprintf("%sHz, %sbit, %schannels", parts[0], parts[1], parts[2])
}
return ""
}
func (p *serverPanel) formatBitrate(bitrate string) string {
if bitrate != "" {
return bitrate + "kb/s"
}
return ""
}
func (p *serverPanel) Statistics(artists, albums, songs, dbplaytime, playtime, uptime string) {
p.statsArtist.SetText(artists)
p.statsAlbums.SetText(albums)
p.statsSongs.SetText(songs)
p.statsDBPlaytime.SetText(dbplaytime)
p.statsPlaytime.SetText(playtime)
p.statsUptime.SetText(uptime)
}
func (p *serverPanel) OutputDevices(devices []*common.OutputDevice) {
p.outputDevices.RemoveAll()
for _, device := range devices {
deviceButton := gtk.NewCheckButtonWithLabel(device.Name)
if device.Enabled {
deviceButton.SetActive(true)
}
deviceButton.ConnectToggled(func() {
if p.outputDeviceChangeCB != nil {
p.outputDeviceChangeCB(device, deviceButton.Active())
}
})
p.outputDevices.Insert(deviceButton, -1)
}
}

View file

@ -0,0 +1,23 @@
package gui
import (
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
)
type shortcutsDialog struct {
*gtk.ShortcutsWindow
}
func newShortcutsDialog() *shortcutsDialog {
dialog := shortcutsDialog{}
dialog.loadWidgets()
return &dialog
}
func (d *shortcutsDialog) loadWidgets() {
builder := gtk.NewBuilderFromString(data.ShortcutsDialogXML)
d.ShortcutsWindow = builder.GetObject("McgShortcutsDialog").Cast().(*gtk.ShortcutsWindow)
}

454
internal/gui/window.go Normal file
View file

@ -0,0 +1,454 @@
package gui
import (
"fmt"
"log"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gdk/v4"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
"suruatoel.xyz/mcg/data"
"suruatoel.xyz/mcg/internal/client"
"suruatoel.xyz/mcg/internal/common"
"suruatoel.xyz/mcg/internal/i18n"
)
type Panel interface {
Toolbar() gtk.Widgetter
}
type GridPanel interface {
HeaderbarStandalone() *albumHeaderbar
}
type mainWindow struct {
*adw.ApplicationWindow
toggleFullscreenAction *gio.SimpleAction
playAaction *gio.SimpleAction
panelAction *gio.SimpleAction
clearPlaylistAction *gio.SimpleAction
searchLibraryAction *gio.SimpleAction
toolbarView *adw.ToolbarView
headerBar *adw.HeaderBar
connectButton *gtk.Switch
connectButtonActive bool
playButton *gtk.ToggleButton
playButtonActive bool
volumeButton *gtk.VolumeButton
settingVolume bool
toolbarStack *gtk.Stack
infoToast *adw.ToastOverlay
contentStack *gtk.Stack
connectionPanel *connectionPanel
panelStack *adw.ViewStack
serverPanel *serverPanel
coverPanel *coverPanel
playlistPanel *playlistPanel
libraryPanel *libraryPanel
resizeHelper *gtk.DrawingArea
panels map[string]Panel
currentWidth int
coverLoader *CoverLoader
}
func newMainWindow(app *gtk.Application, config Configuration) *mainWindow {
window := mainWindow{
panels: make(map[string]Panel),
}
window.loadWidgets(app, config)
window.connectSignals()
window.registerActions()
window.SetHelpOverlay(newShortcutsDialog().ShortcutsWindow)
return &window
}
func (w *mainWindow) loadWidgets(app *gtk.Application, config Configuration) {
builder := gtk.NewBuilderFromString(data.WindowXML)
w.ApplicationWindow = builder.GetObject("McgAppWindow").Cast().(*adw.ApplicationWindow)
w.ApplicationWindow.SetApplication(app)
w.toolbarView = builder.GetObject("toolbar_view").Cast().(*adw.ToolbarView)
w.headerBar = builder.GetObject("headerbar").Cast().(*adw.HeaderBar)
w.connectButton = builder.GetObject("headerbar_button_connect").Cast().(*gtk.Switch)
w.connectButtonActive = true
w.playButton = builder.GetObject("headerbar_button_playpause").Cast().(*gtk.ToggleButton)
w.playButtonActive = true
w.volumeButton = builder.GetObject("headerbar_button_volume").Cast().(*gtk.VolumeButton)
w.volumeButton.ConnectValueChanged(w.OnVolumeChanged)
w.toolbarStack = builder.GetObject("toolbar_stack").Cast().(*gtk.Stack)
w.infoToast = builder.GetObject("info_toast").Cast().(*adw.ToastOverlay)
w.contentStack = builder.GetObject("content_stack").Cast().(*gtk.Stack)
w.connectionPanel = newConnectionPanel(config)
w.contentStack.AddChild(w.connectionPanel)
w.contentStack.SetVisibleChild(w.connectionPanel)
// Server panel
w.serverPanel = newServerPanel()
w.panels["server"] = w.serverPanel
w.serverPanel.ReceiveOutputDeviceChange(w.OnServerPanelOutputDeviceChange)
// Cover panel
w.coverPanel = newCoverPanel()
w.panels["cover"] = w.coverPanel
// Playlist panel
w.playlistPanel = newPlaylistPanel()
w.panels["playlist"] = w.playlistPanel
// Library panel
w.libraryPanel = newLibraryPanel(config)
w.panels["library"] = w.serverPanel
// Panel stack
w.panelStack = builder.GetObject("panel_stack").Cast().(*adw.ViewStack)
w.panelStack.AddTitledWithIcon(w.serverPanel, "server", i18n.T("Server"), "network-wired-symbolic")
w.panelStack.AddTitledWithIcon(w.coverPanel, "cover", i18n.T("Cover"), "image-x-generic-symbolic")
w.panelStack.AddTitledWithIcon(w.playlistPanel, "playlist", i18n.T("Playlist"), "view-list-symbolic")
w.panelStack.AddTitledWithIcon(w.libraryPanel, "library", i18n.T("Library"), "emblem-music-symbolic")
if _, found := w.panels[config.Panel]; found {
w.panelStack.SetVisibleChildName(config.Panel)
} else {
log.Println("configured initial panel name is unknown:", config.Panel)
}
// Toolbar stack
w.toolbarStack.AddNamed(w.serverPanel.Toolbar(), "server")
w.toolbarStack.AddNamed(w.coverPanel.Toolbar(), "cover")
w.toolbarStack.AddNamed(w.playlistPanel.Toolbar(), "playlist")
w.toolbarStack.AddNamed(w.libraryPanel.Toolbar(), "library")
w.toolbarStack.SetVisibleChildName(config.Panel)
w.resizeHelper = builder.GetObject("resize_helper").Cast().(*gtk.DrawingArea)
}
func (w *mainWindow) connectSignals() {
w.connectButton.Connect("notify::active", func() {
w.connect()
})
w.connectButton.ConnectStateSet(w.OnConnectStateSet)
w.playButton.ConnectToggled(w.OnPlayPauseToggled)
w.panelStack.Connect("notify::visible-child", w.OnPanelStackSwitched)
w.resizeHelper.ConnectResize(w.OnWindowResize)
w.coverPanel.ConnectFullscreen(w.OnCoverPanelFullscreen)
w.Connect("notify::fullscreened", w.OnWindowFullscreen)
w.playlistPanel.ConnectStandalone(w.OnPlaylistPanelStandalone)
w.libraryPanel.ConnectItemSizeChanged(w.onLibraryPanelItemSizeChanged)
w.libraryPanel.ConnectStandalone(w.OnLibraryPanelStandalone)
clientInstance := client.Instance()
clientInstance.ReceiveError(w.OnClientError)
clientInstance.RegisterConnection(w.OnClientConnection)
clientInstance.ReceiveStatus(w.OnClientStatus)
clientInstance.ReceiveStatistics(w.OnClientStatistics)
clientInstance.ReceiveOutputDevices(w.OnClientOutputDevces)
clientInstance.ReceivePlaylist(w.OnClientPlaylist)
clientInstance.ReceiveAlbums(w.OnClientAlbums)
clientInstance.ReceiveAlbumart(w.OnClientAlbumart)
}
func (w *mainWindow) registerActions() {
connectAction := gio.NewSimpleAction("connect", nil)
connectAction.ConnectActivate(func(_ *glib.Variant) {
w.connect()
})
w.AddAction(connectAction)
w.playAaction = gio.NewSimpleAction("play", nil)
w.playAaction.SetEnabled(false)
w.playAaction.ConnectActivate(func(_ *glib.Variant) {
w.OnPlayPauseToggled()
})
w.AddAction(w.playAaction)
w.clearPlaylistAction = gio.NewSimpleAction("clear-playlist", nil)
w.clearPlaylistAction.SetEnabled(false)
w.clearPlaylistAction.ConnectActivate(func(_ *glib.Variant) {
w.playlistPanel.ClearPlaylist()
})
w.AddAction(w.clearPlaylistAction)
panelVariant := glib.NewVariantString("server")
w.panelAction = gio.NewSimpleActionStateful("panel", panelVariant.Type(), panelVariant)
w.panelAction.SetEnabled(false)
w.panelAction.ConnectActivate(func(value *glib.Variant) {
panelName := value.String()
w.panelStack.SetVisibleChildName(panelName)
})
w.AddAction(w.panelAction)
w.toggleFullscreenAction = gio.NewSimpleAction("toggle-fullscreen", nil)
w.toggleFullscreenAction.SetEnabled(false)
w.toggleFullscreenAction.ConnectActivate(func(_ *glib.Variant) {
w.Fullscreen()
})
w.AddAction(w.toggleFullscreenAction)
w.searchLibraryAction = gio.NewSimpleAction("search-library", nil)
w.searchLibraryAction.SetEnabled(false)
w.searchLibraryAction.ConnectActivate(func(_ *glib.Variant) {
w.panelStack.SetVisibleChild(w.libraryPanel)
w.libraryPanel.ShowSearch()
})
w.AddAction(w.searchLibraryAction)
}
func (w *mainWindow) OnPanelStackSwitched() {
w.setVisibleToolbar()
}
func (w *mainWindow) setVisibleToolbar() {
name := w.panelStack.VisibleChildName()
w.toolbarStack.SetVisibleChildName(name)
}
func (w *mainWindow) OnWindowFullscreen() {
w.reactToFullscreen()
}
func (w *mainWindow) reactToFullscreen() {
var cursor *gdk.Cursor
if w.IsFullscreen() {
w.panelStack.SetVisibleChild(w.coverPanel)
w.headerBar.SetVisible(false)
w.coverPanel.Fullscreen(true)
cursor = gdk.NewCursorFromName("none", nil)
} else {
w.headerBar.SetVisible(true)
w.coverPanel.Fullscreen(false)
cursor = gdk.NewCursorFromName("default", nil)
}
w.SetCursor(cursor)
}
func (w *mainWindow) OnCoverPanelFullscreen() {
if w.IsFullscreen() {
w.Unfullscreen()
} else {
w.Fullscreen()
}
}
func (w *mainWindow) OnPlaylistPanelStandalone(open bool) {
w.onPanelStandalone(w.playlistPanel, open)
}
func (w *mainWindow) onLibraryPanelItemSizeChanged(itemSize int) {
w.coverLoader.SetSize(itemSize)
w.playlistPanel.ItemSizeChanged()
}
func (w *mainWindow) OnLibraryPanelStandalone(open bool) {
w.onPanelStandalone(w.libraryPanel, open)
}
func (w *mainWindow) onPanelStandalone(panel GridPanel, open bool) {
if open {
w.toolbarView.AddTopBar(panel.HeaderbarStandalone())
w.toolbarView.Remove(w.headerBar)
} else {
w.toolbarView.AddTopBar(w.headerBar)
w.toolbarView.Remove(panel.HeaderbarStandalone())
}
}
func (w *mainWindow) connect() {
if !w.connectButtonActive {
return
}
if client.Instance().IsConnected() {
w.libraryPanel.Wait()
client.Instance().Disconnect()
} else {
client.Instance().Connect(w.connectionPanel.ConnectionDetails())
}
}
func (w *mainWindow) OnClientConnection(connected bool) {
glib.IdleAdd(func() {
w.connectButtonActive = false
w.connectButton.SetActive(connected)
w.connectButton.SetState(connected)
w.connectButtonActive = true
w.playButton.SetSensitive(connected)
w.volumeButton.SetSensitive(connected)
w.playAaction.SetEnabled(connected)
w.panelAction.SetEnabled(connected)
if connected {
w.contentStack.SetVisibleChild(w.panelStack)
} else {
w.contentStack.SetVisibleChild(w.connectionPanel)
}
w.clearPlaylistAction.SetEnabled(connected)
w.searchLibraryAction.SetEnabled(connected)
})
host, _, _ := w.connectionPanel.ConnectionDetails()
w.coverLoader = NewCoverLoader(host)
w.playlistPanel.setCoverLoader(w.coverLoader)
w.libraryPanel.setCoverLoader(w.coverLoader)
}
func (w *mainWindow) SetConfigError(err error) {
glib.IdleAdd(func() {
w.infoToast.AddToast(adw.NewToast(fmt.Sprintf("Loading the configuration has failed: %s", err.Error())))
})
}
func (w *mainWindow) UpdateConfig(config *Configuration) {
config.Panel = w.panelStack.VisibleChildName()
w.connectionPanel.UpdateConfig(config)
w.libraryPanel.UpdateConfig(config)
}
func (w *mainWindow) OnClientError(message string) {
glib.IdleAdd(func() {
w.infoToast.AddToast(adw.NewToast(message))
})
}
func (w *mainWindow) OnClientStatus(state string, album *common.PlaylistAlbum, pos uint64, time uint64, volume int64, file string, audio string, bitrate string, errorMessage string) {
// Album
glib.IdleAdd(func() {
w.coverPanel.SetAlbum(state, album)
})
w.toggleFullscreenAction.SetEnabled(album != nil)
// Fullscreen
if w.IsFullscreen() && album == nil {
w.Unfullscreen()
}
// State
if state == "play" {
glib.IdleAdd(func() {
w.setPlayPause(true)
})
glib.IdleAdd(func() {
w.coverPanel.SetPlay(pos, time)
})
} else if state == "pause" || state == "stop" {
glib.IdleAdd(func() {
w.setPlayPause(false)
})
w.coverPanel.SetPause()
}
// Volume
glib.IdleAdd(func() {
w.setVolume(volume)
})
glib.IdleAdd(func() {
w.serverPanel.Status(file, audio, bitrate, errorMessage)
})
}
func (w *mainWindow) OnClientStatistics(artists, albums, songs, dbplaytime, playtime, uptime string) {
glib.IdleAdd(func() {
w.serverPanel.Statistics(artists, albums, songs, dbplaytime, playtime, uptime)
})
}
func (w *mainWindow) OnClientOutputDevces(devices []*common.OutputDevice) {
glib.IdleAdd(func() {
w.serverPanel.OutputDevices(devices)
})
}
func (w *mainWindow) OnClientPlaylist(playlist []*common.PlaylistAlbum) {
w.playlistPanel.SetPlaylist(playlist)
}
func (w *mainWindow) OnClientAlbums(albums map[string]*common.Album) {
w.libraryPanel.SetAlbums(albums)
}
func (w *mainWindow) OnClientAlbumart(album *common.Album, albumart []byte) {
w.coverPanel.SetAlbumart(album, albumart)
w.playlistPanel.SetAlbumart(album, albumart)
w.libraryPanel.SetAlbumart(album, albumart)
}
func (w *mainWindow) OnConnectStateSet(state bool) bool {
return true
}
func (w *mainWindow) OnPlayPauseToggled() {
if w.playButtonActive {
client.Instance().PlayPause()
}
}
func (w *mainWindow) OnVolumeChanged(value float64) {
if !w.settingVolume {
client.Instance().SetVolume(int(value * 100))
}
}
func (w *mainWindow) setPlayPause(play bool) {
w.playButtonActive = false
w.playButton.SetActive(play)
w.playButtonActive = true
}
func (w *mainWindow) setVolume(volume int64) {
if volume >= 0 {
w.volumeButton.SetVisible(true)
w.settingVolume = true
w.volumeButton.SetValue(float64(volume) / 100.0)
w.settingVolume = false
} else {
w.volumeButton.SetVisible(false)
}
}
func (w *mainWindow) OnServerPanelOutputDeviceChange(device *common.OutputDevice, enable bool) {
client.Instance().