Compare commits

...
Author SHA1 Message Date
d3d44695a7 Add .mailmap file to map old user names and eMail addresses 2026-09-29 13:31:48 +02:00
93c053c970 Update translations including FIXME annotations 2026-09-29 13:31:26 +02:00
8fbecebf8b Update go dependencies and toolchain 2026-09-26 16:41:06 +02:00
bc6c0f6b33 Reformat markdown files with line length of 120 characters 2026-09-26 16:40:33 +02:00
5a033221e2 Set Codeberg as primary contribution platform 2026-09-26 16:39:23 +02:00
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
bfb8eac62d Bump version to 3.2 2023-01-21 17:18:41 +01:00
44fb332c62 Read cover with1 “readpicture” command if “albumart” is not found (close #90) 2023-01-21 15:37:29 +01:00
e976e05efe Fix setting sort order on Library panel (close #91) 2023-01-18 17:27:27 +01:00
a1e87e8994 Add new dependency “python-dateutil” to README 2023-01-18 17:27:27 +01:00
8607bf15a9 Add option to sort by last modification date (close #88)
Store the metadate “last-modified” for a track and an album and add an
option sort by it.
2023-01-18 17:27:27 +01:00
5618c9016c Always return tuple by get_albumart_now() (close #89) 2023-01-15 15:18:35 +01:00
Jeremy Fleischman
ea71d96dd7
Don't assume that size file is valid
I don't know how my cache got into this state, but I had an empty size
file:

    $ cat ~/.cache/mcg/127.0.0.1/size

    $ stat ~/.cache/mcg/127.0.0.1/size
      File: /home/jeremy/.cache/mcg/127.0.0.1/size
      Size: 0         	Blocks: 0          IO Block: 4096   regular empty file
    Device: 254,0	Inode: 18493061    Links: 1
    Access: (0644/-rw-r--r--)  Uid: ( 1000/  jeremy)   Gid: (  100/   users)
    Access: 2022-09-14 00:18:32.942885525 -0700
    Modify: 2022-09-07 12:32:44.151734944 -0700
    Change: 2022-09-07 12:32:44.151734944 -0700
     Birth: 2022-08-25 10:01:01.729717504 -0700

This was causing mcg's Library view to crash like this:

    (.mcg-wrapped:879276): Gtk-CRITICAL **: 00:19:15.727: gtk_window_add_accel_group: assertion 'GTK_IS_WINDOW (window)' failed
    Exception in thread Thread-1 (_set_playlist):
    Traceback (most recent call last):
      File "/nix/store/c1vb2z3c64i0sd92iz7fv0lb720qcvhb-python3-3.10.6/lib/python3.10/threading.py", line 1016, in _bootstrap_inner
        self.run()
      File "/nix/store/c1vb2z3c64i0sd92iz7fv0lb720qcvhb-python3-3.10.6/lib/python3.10/threading.py", line 953, in run
        self._target(*self._args, **self._kwargs)
      File "/nix/store/l935dwmk93sq2chr4xxiipv9amyfcg43-CoverGrid-3.1/share/mcg/mcg/playlistpanel.py", line 256, in _set_playlist
        cache = client.MCGCache(host, size)
      File "/nix/store/l935dwmk93sq2chr4xxiipv9amyfcg43-CoverGrid-3.1/share/mcg/mcg/client.py", line 1279, in __init__
        self._read_size()
      File "/nix/store/l935dwmk93sq2chr4xxiipv9amyfcg43-CoverGrid-3.1/share/mcg/mcg/client.py", line 1293, in _read_size
        size = int(f.readline())
    ValueError: invalid literal for int() with base 10: ''

Maybe mcg crashed while writing the `size` file at some point? I see
that it writes directly to the size file, which seems potentially risky:
it would probably be safer to write to a temp file and then (atomically)
move it. Still, it seems like a good practice to be resilient here.

After this change, here's what I see get printed by mcg:

    (.mcg-wrapped:889856): Gtk-CRITICAL **: 00:37:00.045: gtk_window_add_accel_group: assertion 'GTK_IS_WINDOW (window)' failed
    2022-09-14 00:37:00,076 WARNING: invalid cache file: /home/jeremy/.cache/mcg/127.0.0.1/size, deleting file
    Traceback (most recent call last):
      File "/nix/store/vzgcfs00nq543hjk8hrk81k1rs8aqpqw-CoverGrid-3.1/share/mcg/mcg/client.py", line 1295, in _read_size
        size = int(f.readline())
    ValueError: invalid literal for int() with base 10: ''

And then the problem goes away =)
2022-09-14 00:37:52 -07:00
48b1d90fc8 Bump version to 3.1 2022-09-11 18:13:52 +02:00
37b8701361 Use Markdown for README file (close #86) 2022-09-11 12:48:55 +02:00
fac7a85566 Use the build system “meson” (close #32)
Replace the build system “setuptools” with “meson”.
2022-09-11 12:31:13 +02:00
ff0eee8380 Bump version to 3.0.2 2022-09-11 12:28:24 +02:00
e63a3f2d4d Fix volume button (close #84)
Fix both the icon of the volume button and setting the volume if the
mixer does not allow it by using -1 as default value instead of 0.
2022-09-11 12:24:56 +02:00
Jeremy Fleischman
54717a3491 Fix build
When doing a `python setup.py build` on my machine, I found that
`build/lib` would not end up with a compiled gresource file until the
second invocation of `python setup.py build`.

Before:

    $ python setup.py build
    running build
    running build_py
    creating build
    creating build/lib
    creating build/lib/mcg
    copying mcg/connectionpanel.py -> build/lib/mcg
    copying mcg/shortcutsdialog.py -> build/lib/mcg
    copying mcg/serverpanel.py -> build/lib/mcg
    copying mcg/application.py -> build/lib/mcg
    copying mcg/window.py -> build/lib/mcg
    copying mcg/playlistpanel.py -> build/lib/mcg
    copying mcg/utils.py -> build/lib/mcg
    copying mcg/coverpanel.py -> build/lib/mcg
    copying mcg/infodialog.py -> build/lib/mcg
    copying mcg/librarypanel.py -> build/lib/mcg
    copying mcg/client.py -> build/lib/mcg
    copying mcg/zeroconf.py -> build/lib/mcg
    copying mcg/mcg.py -> build/lib/mcg
    copying mcg/__init__.py -> build/lib/mcg
    copying mcg/albumheaderbar.py -> build/lib/mcg
    package init file 'data/__init__.py' not found (or not a regular file)
    compiling gresources
    compiling gschemas

    $ ls build/lib/mcg/data/
    ls: cannot access 'build/lib/mcg/data/': No such file or directory

Note how there is no data directory at all. Now check out what happens
on the second build:

    $ git status
    On branch main
    Your branch is up to date with 'origin/main'.

    Untracked files:
      (use "git add <file>..." to include in what will be committed)
            data/gschemas.compiled
            data/xyz.suruatoel.mcg.gresource

    nothing added to commit but untracked files present (use "git add" to track)

    $ python setup.py build
    running build
    running build_py
    package init file 'data/__init__.py' not found (or not a regular file)
    creating build/lib/mcg/data
    copying data/xyz.suruatoel.mcg.gresource -> build/lib/mcg/data
    compiling gresources
    compiling gschemas

    $ ls build/lib/mcg/data/
    xyz.suruatoel.mcg.gresource

That's because the first build generated the compiled schemas and
resources (you can see evidence of that in `git status`), and then the
second build was able to copy the gresource file over according to the
`package_data` rules. The fix I've introduced here is to just do the
compilations *before* we call `super(...).run(...)`. There might be
better ways of doing this, I'm not very familiar with packaging gtk
python applications.

Things were even worse for the gschemas.compiled file: in addition to
the ordering issue it's not even mentioned in `data_files`, so even if
it does exist, it doesn't have a chance to get copied over when
installed. So I've added it to the `data_files` section. I don't know if
that'll play nicely or not with the existing `--no-compile-schemas`
flag.
2022-09-11 12:12:20 +02:00
92737cd045 Update requirements in README
Add the minimum required version of GTK and MPD to the requirements in
the README.
2021-04-18 18:10:05 +02:00
79 changed files with 5956 additions and 6355 deletions

10
.gitignore vendored
View file

@ -1,9 +1 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Files created by setuptools
build/
dist/
*.egg-info/
/mcg*

4
.mailmap Normal file
View file

@ -0,0 +1,4 @@
Olli <olli@suruatoel.xyz> <olli@coderkun.de>
Olli <olli@suruatoel.xyz> coderkun <olli@coderkun.de>
Olli <olli@suruatoel.xyz> coderkun <olli@suruatoel.xyz>
Olli <olli@suruatoel.xyz> gotik <olli@coderkun.de>

22
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,22 @@
# Contributing
There are multiple ways to contribute code to this project:
* Submit a Pull Request on [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
project’s 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 e‑mail. The first one is probably easier for most people. However
it requires a account at GitLab.com. For people who don’t want to create an account—or have any other reason to not use GitLab—e‑mail 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 E‑mail
To contribute via e‑mail, 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. E‑mail Patch
To send a patch with your changes, use git’s @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 e‑mail address at "suruatoel.xyz":https://www.suruatoel.xyz.
h3. E‑mail Pull Request
Sending a Pull Request via e‑mail 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 git’s @request-pull@ command to send an e‑mail with your pull request. The e‑mail will contain a link to the public repository which can be used to pull your changes from. You will find the e‑mail 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

52
README.md Normal file
View file

@ -0,0 +1,52 @@
# CoverGrid
CoverGrid (mcg) is a client for the [Music Player 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](www.suruatoel.xyz/codes/mcg)
License: [GPL](http://www.gnu.org/licenses/gpl.html) v3
Dependencies:
* [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, an [MPD](http://www.musicpd.org) server (version >= 0.21.0) is required at runtime.
## Building
Build the application with _go_:
$ go build .
## Running
Run the application for testing purposes:
$ go run .
Run the application:
$ ./mcg
## Packaging
The suggested package name for this project is “mcg”. To build a production binary, please use the following command:
$ go build --ldflags="-s -w -X main.Version=$(git describe)" -trimpath ./...
In the “data/” folder you will find a `.desktop` file and an SVG icon.
## Screenshots
![CoverGrid’s cover panel with album details and track list.](https://www.suruatoel.xyz/images/mcg-cover-s.png "CoverGrid’s cover panel with album details and track list.")
![CoverGrid’s playlist panel with queued albums.](https://www.suruatoel.xyz/images/mcg-playlist.png "CoverGrid’s playlist panel with queued albums.")
![CoverGrid’s library panel showing the albums middle-sized.](https://www.suruatoel.xyz/images/mcg-library-m.png "CoverGrid’s library panel showing the albums middle-sized.")
![CoverGrid’s library panel showing the albums small-sized.](https://www.suruatoel.xyz/images/mcg-library-s.png "CoverGrid’s library panel showing the albums small-sized.")

View file

@ -1,80 +0,0 @@
h1. CoverGrid
CoverGrid (mcg) is a client for the "Music Player 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
License: "GPL":http://www.gnu.org/licenses/gpl.html v3
Dependencies:
* "Python":http://www.python.org 3
* "GTK":http://www.gtk.org 3 ("python-gobject":https://live.gnome.org/PyGObject)
* "Avahi":http://www.avahi.org (optional)
* "python-keyring":http://pypi.python.org/pypi/keyring (optional)
* "python-setuptools":https://pypi.python.org/pypi/setuptools (building)
h2. Building
Build the application with _setuptools_:
bc. $ python3 setup.py build
h2. Running/Testing
For testing the application and running it without (system-wide) installation, donwload/clone the code, build it as described above and then use _setuptools_ to install it for the current user:
bc. $ python3 setup.py develop --user
After that you can run it with
bc. $ ~/.local/bin/mcg
or if _~/.local/bin/_ is on your PATH
bc. $ mcg
h2. Installing
Install the application system-wide with _setuptools_:
bc. # python3 setup.py install
Note: _On Linux using the distribution’s package manager is the preferred way of installing applications system-wide._
h2. Packaging
Create a distribution package with _setuptools_:
bc. $ python3 setup.py sdist
h2. Cover/image configuration
Since MPD itself does not provide the cover/image/album art binaries, yet, _mcg_ has to look for them itself. In order to find the images a base folder has to be configured on the Connection tab as “Image Directory”. This value can either be a local (absolute) folder (e. g. /home/user/music/) or an http URL (e. g. http://localhost/music/). _mcg_ then adds the (relative) folder of the audio file to this paths and tries different names for the actual file:
# the album name
# “cover”
# “folder”
The following file extensions are used:
# png
# jpg
The first combination that results in an existing file is used as cover image. If no matching file exists, _mcg_ will try to traverse the directory for any image file as fallback—this is done for local paths only though, not for http URLs.
h2. Screenshots
!https://suruatoel.xyz/images/mcg-cover-s.png(CoverGrid\u2019s cover panel with album details and track list.)!
!https://suruatoel.xyz/images/mcg-playlist.png(CoverGrid\u2019s playlist panel with queued albums.)!
!https://suruatoel.xyz/images/mcg-library-m.png(CoverGrid\u2019s library panel showing the albums middle-sized.)!
!https://suruatoel.xyz/images/mcg-library-s.png(CoverGrid\u2019s 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

@ -4,7 +4,7 @@ Name=CoverGrid (mcg)
Comment=CoverGrid for the Music Player Daemon
Keywords=mpd;
Type=Application
Icon=mcg.svg
Icon=xyz.suruatoel.mcg
Exec=mcg
Categories=AudioVideo;
StartupNotify=true

34
data/meson.build Normal file
View file

@ -0,0 +1,34 @@
i18n = import('i18n')
desktop_file = i18n.merge_file(
input: 'mcg.desktop',
output: 'mcg.desktop',
type: 'desktop',
po_dir: '../po',
install: true,
install_dir: join_paths(get_option('datadir'), 'applications')
)
desktop_utils = find_program('desktop-file-validate', required: false)
if desktop_utils.found()
test('Validate desktop file', desktop_utils,
args: [desktop_file]
)
endif
gnome = import('gnome')
pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name())
gnome.compile_resources('mcg',
'xyz.suruatoel.mcg.gresource.xml',
gresource_bundle: true,
install: true,
install_dir: pkgdatadir,
)
install_data('xyz.suruatoel.mcg.gschema.xml',
install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas')
)
gnome.compile_schemas(depend_files: files('xyz.suruatoel.mcg.gschema.xml'))
subdir('icons')

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.0.1</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"/>
<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,232 +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="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">5</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,233 +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">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>
<property name="icons">audio-volume-muted
audio-volume-high
audio-volume-low
audio-volume-medium</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,22 +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>icons/mcg.svg</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,62 +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" />
</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.26.0
toolchain go1.27.1
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.23.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.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=

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"
"suruatoel.xyz/mcg/internal/i18n"
)
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))
p.progressBar.SetText(i18n.T("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("%s Hz, %s bit, %s channels", 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().SetOutputDeviceState(device, enable)
}
func (w *mainWindow) OnWindowResize(width int, _ int) {
if !w.hasWidthChanged(width) {
return
}
w.setWidth(width)
if width > 0 {
w.updateUIWidth()
}
}
func (w *mainWindow) hasWidthChanged(width int) bool {
return width != w.currentWidth
}
func (w *mainWindow) setWidth(width int) {
w.currentWidth = width
}
func (w *mainWindow) updateUIWidth() {
w.coverPanel.SetWidth(w.currentWidth)
w.playlistPanel.SetWidth(w.currentWidth)
w.libraryPanel.SetWidth(w.currentWidth)
}

38
internal/i18n/i18n.go Normal file
View file

@ -0,0 +1,38 @@
package i18n
/*
#include <locale.h>
#include <stdlib.h>
#include <libintl.h>
static void init_i18n(const char* domain, const char* localedir) {
setlocale(LC_ALL, "");
bindtextdomain(domain, localedir);
bind_textdomain_codeset(domain, "UTF-8");
textdomain(domain);
}
*/
import "C"
import (
"log"
"unsafe"
)
func Init(domain, localeDir string) {
cDomain := C.CString(domain)
defer C.free(unsafe.Pointer(cDomain))
cDir := C.CString(localeDir)
defer C.free(unsafe.Pointer(cDir))
log.Println("initialize localization for domain", domain, "with directory", localeDir)
C.init_i18n(cDomain, cDir)
}
func T(msgid string) string {
c := C.CString(msgid)
defer C.free(unsafe.Pointer(c))
return C.GoString(C.gettext(c))
}

Binary file not shown.

View file

@ -1,102 +1,26 @@
msgid ""
msgstr ""
"Project-Id-Version: CoverGrid (mcg)\n"
"POT-Creation-Date: 2020-10-24 14:41+0200\n"
"PO-Revision-Date: 2020-10-24 14:41+0200\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-09 19:13+0200\n"
"PO-Revision-Date: 2026-08-14 09:34+0200\n"
"Last-Translator: coderkun <olli@suruatoel.xyz>\n"
"Language-Team: \n"
"Language: de\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.1\n"
"X-Poedit-Basepath: ../../..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 3.9\n"
"X-Poedit-Basepath: ../../..\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-SearchPath-0: mcg\n"
"X-Poedit-SearchPath-1: data/ui\n"
#: data/ui/album-headerbar.ui:17
msgid "Title"
msgstr "Titel"
#: internal/common/album.go:93 internal/gui/coverpanel.go:290
#, go-format
msgid "%d:%d minutes"
msgstr "%d:%d Minuten"
#: data/ui/album-headerbar.ui:34
msgid "Artist"
msgstr "Künstler"
#: data/ui/connection-panel.ui:75
msgid "Enter hostname or IP address"
msgstr "Hostnamen oder IP-Adresse eingeben"
#: data/ui/connection-panel.ui:88
msgid "Enter password or leave blank"
msgstr "Passwort eingeben oder leer lassen"
#: data/ui/connection-panel.ui:117
msgid "Host:"
msgstr "Host:"
#: data/ui/connection-panel.ui:129
msgid "Port:"
msgstr "Port:"
#: data/ui/connection-panel.ui:141
msgid "Password:"
msgstr "Passwort:"
#: data/ui/cover-toolbar.ui:15 data/ui/shortcuts-dialog.ui:101
msgid "Show the cover in fullscreen mode"
msgstr "Das Cover im Vollbildmodus anzeigen"
#: data/ui/gtk.menu.ui:7
msgid "Connect"
msgstr "Verbinden"
#: data/ui/gtk.menu.ui:12
msgid "Play"
msgstr "Abspielen"
#: data/ui/gtk.menu.ui:17
msgid "Clear Playlist"
msgstr "Playlist leeren"
#: data/ui/gtk.menu.ui:22
msgid "Toggle Fullscreen"
msgstr "Vollbild wechseln"
#: data/ui/gtk.menu.ui:27
msgid "Search Library"
msgstr "Bibliothek durchsuchen"
#: data/ui/gtk.menu.ui:34
msgid "Connection"
msgstr "Verbindung"
#: data/ui/gtk.menu.ui:40
msgid "Cover"
msgstr "Cover"
#: data/ui/gtk.menu.ui:46
msgid "Playlist"
msgstr "Wiedergabeliste"
#: data/ui/gtk.menu.ui:52
msgid "Library"
msgstr "Bibliothek"
#: data/ui/gtk.menu.ui:60
msgid "Keyboard Shortcuts"
msgstr "Tastenkombinationen"
#: data/ui/gtk.menu.ui:65
msgid "Info"
msgstr "Info"
#: data/ui/gtk.menu.ui:70
msgid "Quit"
msgstr "Beenden"
#: data/ui/info-dialog.ui:14
#: internal/gui/application.go:108
msgid ""
"CoverGrid is a client for the Music Player Daemon, focusing on albums "
"instead of single tracks."
@ -104,112 +28,161 @@ msgstr ""
"CoverGrid ist ein ein Client für den Music Player Daemon, der sich auf Alben "
"anstellen von einzelnen Songs fokussiert."
#: data/ui/library-panel.ui:27
msgid "search library"
msgstr "Bibliothek durchsuchen"
#: internal/gui/coverpanel.go:277
#, go-format
msgid "%s feat. %s"
msgstr "%s mit %s"
#: data/ui/library-panel.ui:166 data/ui/playlist-panel.ui:57
msgid "cancel"
msgstr "abbrechen"
#: internal/gui/window.go:117
msgid "Server"
msgstr "Server"
#: data/ui/library-panel.ui:179 data/ui/library-panel.ui:264
msgid "queue"
msgstr "einreihen"
#: internal/gui/window.go:118
msgid "Cover"
msgstr "Cover"
#: data/ui/library-panel.ui:277 data/ui/playlist-panel.ui:168
msgid "play"
msgstr "abspielen"
#: internal/gui/window.go:119
msgid "Playlist"
msgstr "Wiedergabeliste"
#: data/ui/library-toolbar.ui:76
#: internal/gui/window.go:120
msgid "Library"
msgstr "Bibliothek"
#: data/ui/connection-panel.ui:26
msgid "Host"
msgstr "Host"
#: data/ui/connection-panel.ui:38
msgid "Port"
msgstr "Port"
#: data/ui/connection-panel.ui:57
msgid "Password"
msgstr "Passwort"
#: data/ui/cover-panel.ui:12 data/ui/shortcuts-dialog.ui:88
msgid "Show the cover in fullscreen mode"
msgstr "Das Cover im Vollbildmodus anzeigen"
#: data/ui/library-panel.ui:33
msgid "update library"
msgstr "Die Bibliothek aktualisieren"
#: data/ui/library-panel.ui:46
msgid "Sort"
msgstr "Sortierung"
#: data/ui/library-toolbar.ui:86
#: data/ui/library-panel.ui:51
msgid "sort by artist"
msgstr "nach Künstler"
#: data/ui/library-toolbar.ui:102
#: data/ui/library-panel.ui:58
msgid "sort by title"
msgstr "nach Titel"
#: data/ui/library-toolbar.ui:118
#: data/ui/library-panel.ui:65
msgid "sort by year"
msgstr "nach Jahr"
#: data/ui/library-toolbar.ui:169 data/ui/shortcuts-dialog.ui:114
#: data/ui/library-panel.ui:71
msgid "sort by modification"
msgstr "nach Änderungsdatum"
#: data/ui/library-panel.ui:78
msgid "sort library descending"
msgstr "absteigend sortieren"
#: data/ui/library-panel.ui:98 data/ui/shortcuts-dialog.ui:99
msgid "Search the library"
msgstr "Die Bibliothek durchsuchen"
#: data/ui/library-toolbar.ui:192 data/ui/playlist-toolbar.ui:15
#: data/ui/library-panel.ui:111 data/ui/playlist-panel.ui:14
msgid "Select multiple albums"
msgstr "Mehrere Alben auswählen"
#: data/ui/library-toolbar.ui:214
#: data/ui/library-panel.ui:124
msgid "Settings and actions"
msgstr "Einstellungen und Aktionen"
#: data/ui/playlist-panel.ui:70 data/ui/playlist-panel.ui:155
msgid "remove"
msgstr "entfernen"
#: data/ui/library-panel.ui:149
msgid "search library"
msgstr "Bibliothek durchsuchen"
#: data/ui/playlist-toolbar.ui:37 data/ui/shortcuts-dialog.ui:88
#: data/ui/library-panel.ui:216 data/ui/playlist-panel.ui:69
msgid "cancel"
msgstr "abbrechen"
#: data/ui/library-panel.ui:222 data/ui/library-panel.ui:277
msgid "queue"
msgstr "einreihen"
#: data/ui/library-panel.ui:271 data/ui/playlist-panel.ui:123
msgid "play"
msgstr "abspielen"
#: data/ui/playlist-panel.ui:27 data/ui/shortcuts-dialog.ui:77
msgid "Clear the playlist"
msgstr "Die Wiedergabeliste leeren"
#: data/ui/server-panel.ui:68
msgid "File:"
msgstr "Datei:"
#: data/ui/playlist-panel.ui:75 data/ui/playlist-panel.ui:129
msgid "remove"
msgstr "entfernen"
#: data/ui/server-panel.ui:81
msgid "Audio:"
msgstr "Audio:"
#: data/ui/server-panel.ui:94
msgid "Bitrate:"
msgstr "Bitrate:"
#: data/ui/server-panel.ui:107
msgid "Error:"
msgstr "Fehler:"
#: data/ui/server-panel.ui:119 data/ui/server-panel.ui:135
#: data/ui/server-panel.ui:151 data/ui/server-panel.ui:167
msgid "<i>none</i>"
msgstr "<i>nichts</i>"
#: data/ui/server-panel.ui:208
#: data/ui/server-panel.ui:35
msgid "Status"
msgstr "Status"
#: data/ui/server-panel.ui:287
msgid "Albums"
msgstr "Alben"
#: data/ui/server-panel.ui:49
msgid "File:"
msgstr "Datei:"
#: data/ui/server-panel.ui:299
msgid "Songs"
msgstr "Songs"
#: data/ui/server-panel.ui:60
msgid "Audio:"
msgstr "Audio:"
#: data/ui/server-panel.ui:311
msgid "Artists"
msgstr "Künstler"
#: data/ui/server-panel.ui:71
msgid "Bitrate:"
msgstr "Bitrate:"
#: data/ui/server-panel.ui:335
msgid "Seconds"
msgstr "Sekunden"
#: data/ui/server-panel.ui:82
msgid "Error:"
msgstr "Fehler:"
#: data/ui/server-panel.ui:382
msgid "Seconds played"
msgstr "Sekunden gespielt"
#: data/ui/server-panel.ui:92 data/ui/server-panel.ui:106
#: data/ui/server-panel.ui:120 data/ui/server-panel.ui:134
msgid "<i>none</i>"
msgstr "<i>nichts</i>"
#: data/ui/server-panel.ui:393
msgid "Seconds running"
msgstr "Sekunden laufend"
#: data/ui/server-panel.ui:430
#: data/ui/server-panel.ui:154
msgid "Statistics"
msgstr "Statistiken"
#: data/ui/server-panel.ui:500
#: data/ui/server-panel.ui:178
msgid "Artists"
msgstr "Künstler"
#: data/ui/server-panel.ui:198
msgid "Albums"
msgstr "Alben"
#: data/ui/server-panel.ui:218
msgid "Songs"
msgstr "Songs"
#: data/ui/server-panel.ui:238
msgid "Seconds"
msgstr "Sekunden"
#: data/ui/server-panel.ui:267
msgid "Seconds played"
msgstr "Sekunden gespielt"
#: data/ui/server-panel.ui:287
msgid "Seconds running"
msgstr "Sekunden laufend"
#: data/ui/server-panel.ui:303
msgid "Audio Devices"
msgstr "Audiogeräte"
@ -217,77 +190,114 @@ msgstr "Audiogeräte"
msgid "General"
msgstr "Allgemein"
#: data/ui/shortcuts-dialog.ui:19
#: data/ui/shortcuts-dialog.ui:18
msgid "Switch to the Connection panel"
msgstr "Zum Verbindungspaneel wechseln"
#: data/ui/shortcuts-dialog.ui:26
#: data/ui/shortcuts-dialog.ui:24
msgid "Switch to the Cover panel"
msgstr "Zum Cover-Paneel wechseln"
#: data/ui/shortcuts-dialog.ui:33
#: data/ui/shortcuts-dialog.ui:30
msgid "Switch to the Playlist panel"
msgstr "Zum Wiedergabelistenpaneel wechseln"
#: data/ui/shortcuts-dialog.ui:40
#: data/ui/shortcuts-dialog.ui:36
msgid "Switch to the Library panel"
msgstr "Zum Bibliothekspaneel wechseln"
#: data/ui/shortcuts-dialog.ui:47
#: data/ui/shortcuts-dialog.ui:42
msgid "Show the keyboard shortcuts"
msgstr "Die Tastenkombinationen anzeigen (dieser Dialog)"
#: data/ui/shortcuts-dialog.ui:54
#: data/ui/shortcuts-dialog.ui:48
msgid "Open the info dialog"
msgstr "Den Infodialog öffnen"
#: data/ui/shortcuts-dialog.ui:61
#: data/ui/shortcuts-dialog.ui:54
msgid "Quit the application"
msgstr "Die Anwendung beenden"
#: data/ui/shortcuts-dialog.ui:69
#: data/ui/shortcuts-dialog.ui:61
msgid "Player"
msgstr "Wiedergabeprogramm"
#: data/ui/shortcuts-dialog.ui:74 data/ui/window.ui:139
#: data/ui/shortcuts-dialog.ui:65
msgid "Connect or disconnect"
msgstr "Die Verbindung herstellen oder trennen"
#: data/ui/shortcuts-dialog.ui:81 data/ui/window.ui:161
#: data/ui/shortcuts-dialog.ui:71 data/ui/window.ui:26
msgid "Switch between play and pause"
msgstr "Zwischen Abspielen und Pause wechseln"
#: data/ui/shortcuts-dialog.ui:96
#: data/ui/shortcuts-dialog.ui:84
msgid "Cover Panel"
msgstr "Cover-Paneel"
#: data/ui/shortcuts-dialog.ui:109
#: data/ui/shortcuts-dialog.ui:95
msgid "Library Panel"
msgstr "Bibliothekspaneel"
#: data/ui/window.ui:108
msgid "Connect to MPD"
msgstr "Zu MPD verbinden"
#~ msgid "No service found"
#~ msgstr "Keine Dienste gefunden"
#: data/ui/window.ui:181
msgid "Adjust the volume"
msgstr "Die Lautstärke anpassen"
#~ msgid "use"
#~ msgstr "verwenden"
#: mcg/librarypanel.py:421
msgid "Loading albums"
msgstr "Alben werden geladen"
#~ msgid "Loading albums"
#~ msgstr "Alben werden geladen"
#: mcg/librarypanel.py:523
msgid "Loading images"
msgstr "Bilder werden geladen"
#~ msgid "Loading images"
#~ msgstr "Bilder werden geladen"
#: mcg/utils.py:50 mcg/utils.py:67
msgid "{} feat. {}"
msgstr "{} mit {}"
#, fuzzy
#~ msgid "Connection state"
#~ msgstr "Verbindung"
#: mcg/utils.py:61
msgid "{}:{} minutes"
msgstr "{}:{} Minuten"
#~ msgid "Title"
#~ msgstr "Titel"
#~ msgid "Artist"
#~ msgstr "Künstler"
#~ msgid "Enter hostname or IP address"
#~ msgstr "Hostnamen oder IP-Adresse eingeben"
#~ msgid "Enter password or leave blank"
#~ msgstr "Passwort eingeben oder leer lassen"
#~ msgid "Connect"
#~ msgstr "Verbinden"
#~ msgid "Play"
#~ msgstr "Abspielen"
#~ msgid "Clear Playlist"
#~ msgstr "Playlist leeren"
#~ msgid "Toggle Fullscreen"
#~ msgstr "Vollbild wechseln"
#~ msgid "Search Library"
#~ msgstr "Bibliothek durchsuchen"
#~ msgid "Connection"
#~ msgstr "Verbindung"
#~ msgid "Keyboard Shortcuts"
#~ msgstr "Tastenkombinationen"
#~ msgid "Info"
#~ msgstr "Info"
#~ msgid "Quit"
#~ msgstr "Beenden"
#~ msgid "Connect to MPD"
#~ msgstr "Zu MPD verbinden"
#~ msgid "Adjust the volume"
#~ msgstr "Die Lautstärke anpassen"
#~ msgid "Enter URL or local path"
#~ msgstr "URL oder lokalen Pfad eingeben"
@ -295,9 +305,6 @@ msgstr "{}:{} Minuten"
#~ msgid "Image Directory:"
#~ msgstr "Bildordner:"
#~ msgid "Server"
#~ msgstr "Server"
#~ msgid "{} of {} images loaded"
#~ msgstr "{} von {} Bildern geladen"

Binary file not shown.

18
main.go Normal file
View file

@ -0,0 +1,18 @@
package main
import (
"os"
"suruatoel.xyz/mcg/internal/gui"
"suruatoel.xyz/mcg/internal/i18n"
)
func main() {
i18n.Init("mcg", "locale")
app := gui.NewApplication()
exitCode := app.Run(os.Args)
if exitCode > 0 {
os.Exit(exitCode)
}
}

View file

@ -1,41 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
# Set environment
srcdir = os.path.abspath(os.path.dirname(__file__))
datadir = os.path.join(srcdir, 'data')
datadirdev = os.path.join(srcdir, '..', 'data')
if os.path.exists(datadirdev):
datadir = datadirdev
localedir = None
localedirdev = os.path.join(srcdir, '..', 'locale')
if os.path.exists(localedirdev):
localedir = localedirdev
# Set GSettings schema dir (if not set already)
if not os.environ.get('GSETTINGS_SCHEMA_DIR'):
os.environ['GSETTINGS_SCHEMA_DIR'] = datadirdev
class Environment:
"""Wrapper class to access environment settings."""
def get_srcdir():
return srcdir
def get_data(subdir):
return os.path.join(datadir, subdir)
def get_locale():
return localedir

View file

@ -1,35 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/album-headerbar.ui')
class AlbumHeaderbar(Gtk.HeaderBar):
__gtype_name__ = 'McgAlbumHeaderbar'
__gsignals__ = {
'close': (GObject.SIGNAL_RUN_FIRST, None, ())
}
# Widgets
standalone_title = Gtk.Template.Child()
standalone_artist = Gtk.Template.Child()
def __init__(self):
super().__init__()
@Gtk.Template.Callback()
def on_close_clicked(self, widget):
self.emit('close')
def set_album(self, album):
self.standalone_title.set_text(album.get_title())
self.standalone_artist.set_text(", ".join(album.get_albumartists()))

View file

@ -1,122 +0,0 @@
#!/usr/bin/env python3
import logging
import urllib
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk, Gdk, GLib
from mcg.window import Window
from mcg.infodialog import InfoDialog
class Application(Gtk.Application):
TITLE = "CoverGrid"
ID = 'xyz.suruatoel.mcg'
DOMAIN = 'mcg'
def _get_option(shortname, longname, description):
option = GLib.OptionEntry()
option.short_name = ord(shortname)
option.long_name = longname
option.description = description
return option
def __init__(self):
Gtk.Application.__init__(self, application_id=Application.ID, flags=Gio.ApplicationFlags.FLAGS_NONE)
self._window = None
self._info_dialog = None
self._verbosity = logging.WARNING
self.add_main_option_entries([
Application._get_option("v", "verbose", "Be verbose: show info messages"),
Application._get_option("d", "debug", "Enable debugging: show debug messages")
])
self.connect('handle-local-options', self.handle_local_options)
def handle_local_options(self, widget, options):
if options.contains("debug") and options.lookup_value('debug'):
self._verbosity = logging.DEBUG
elif options.contains("verbose") and options.lookup_value('verbose'):
self._verbosity = logging.INFO
return -1
def do_startup(self):
Gtk.Application.do_startup(self)
self._setup_logging()
self._load_settings()
self._set_default_settings()
self._load_css()
self._setup_actions()
self._load_appmenu()
def do_activate(self):
Gtk.Application.do_activate(self)
if not self._window:
self._window = Window(self, Application.TITLE, self._settings)
self._window.present()
def on_menu_info(self, action, value):
if not self._info_dialog:
self._info_dialog = InfoDialog()
self._info_dialog.run()
self._info_dialog.hide()
def on_menu_quit(self, action, value):
self.quit()
def _setup_logging(self):
logging.basicConfig(
level=self._verbosity,
format="%(asctime)s %(levelname)s: %(message)s"
)
def _load_settings(self):
self._settings = Gio.Settings.new(Application.ID)
def _set_default_settings(self):
settings = Gtk.Settings.get_default()
settings.set_property('gtk-application-prefer-dark-theme', True)
def _load_css(self):
styleProvider = Gtk.CssProvider()
styleProvider.load_from_resource(self._get_resource_path('gtk.css'))
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
styleProvider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
def _setup_actions(self):
action = Gio.SimpleAction.new("info", None)
action.connect('activate', self.on_menu_info)
self.add_action(action)
action = Gio.SimpleAction.new("quit", None)
action.connect('activate', self.on_menu_quit)
self.add_action(action)
def _load_appmenu(self):
builder = Gtk.Builder()
builder.set_translation_domain(Application.DOMAIN)
builder.add_from_resource(self._get_resource_path('ui/gtk.menu.ui'))
self.set_app_menu(builder.get_object('app-menu'))
def _get_resource_path(self, path):
return "/{}/{}".format(Application.ID.replace('.', '/'), path)

File diff suppressed because it is too large Load diff

View file

@ -1,108 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject
from mcg.zeroconf import ZeroconfProvider
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/connection-panel.ui')
class ConnectionPanel(Gtk.Box):
__gtype_name__ = 'McgConnectionPanel'
__gsignals__ = {
'connection-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, int, str))
}
# Widgets
zeroconf_list = Gtk.Template.Child()
host_entry = Gtk.Template.Child()
port_spinner = Gtk.Template.Child()
password_entry = Gtk.Template.Child()
def __init__(self):
super().__init__()
self._services = Gtk.ListStore(str, str, int)
self._profile = None
# Zeroconf
self.zeroconf_list.set_model(self._services)
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn("Zeroconf", renderer, text=0)
self.zeroconf_list.append_column(column)
# Zeroconf provider
self._zeroconf_provider = ZeroconfProvider()
self._zeroconf_provider.connect_signal(ZeroconfProvider.SIGNAL_SERVICE_NEW, self.on_new_service)
def on_new_service(self, service):
name, host, port = service
self._services.append([name, host, port])
@Gtk.Template.Callback()
def on_service_selected(self, selection):
model, treeiter = selection.get_selected()
if treeiter != None:
service = model[treeiter]
self.set_host(service[1])
self.set_port(service[2])
@Gtk.Template.Callback()
def on_zeroconf_list_outfocused(self, widget, event):
self.zeroconf_list.get_selection().unselect_all()
@Gtk.Template.Callback()
def on_host_entry_outfocused(self, widget, event):
self._call_back()
@Gtk.Template.Callback()
def on_port_spinner_value_changed(self, widget):
self._call_back()
@Gtk.Template.Callback()
def on_password_entry_outfocused(self, widget, event):
self._call_back()
def set_host(self, host):
self.host_entry.set_text(host)
def get_host(self):
return self.host_entry.get_text()
def set_port(self, port):
self.port_spinner.set_value(port)
def get_port(self):
return self.port_spinner.get_value_as_int()
def set_password(self, password):
if password is None:
password = ""
self.password_entry.set_text(password)
def get_password(self):
if self.password_entry.get_text() == "":
return None
else:
return self.password_entry.get_text()
def _call_back(self):
self.emit('connection-changed', self.get_host(), self.get_port(), self.get_password(),)

View file

@ -1,283 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
import logging
import math
from gi.repository import Gtk, Gdk, GObject, GdkPixbuf
from mcg.utils import Utils
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/cover-toolbar.ui')
class CoverToolbar(Gtk.ButtonBox):
__gtype_name__ = 'McgCoverToolbar'
__gsignals__ = {
'fullscreen': (GObject.SIGNAL_RUN_FIRST, None, ())
}
# Widgets
fullscreen_button = Gtk.Template.Child()
def __init__(self):
super().__init__()
def set_fullscreen_sensitive(self, sensitive):
self.fullscreen_button.set_sensitive(sensitive)
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/cover-panel.ui')
class CoverPanel(Gtk.Overlay):
__gtype_name__ = 'McgCoverPanel'
__gsignals__ = {
'toggle-fullscreen': (GObject.SIGNAL_RUN_FIRST, None, ()),
'set-song': (GObject.SIGNAL_RUN_FIRST, None, (int, int,)),
'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,))
}
# Widgets
# Cover
cover_stack = Gtk.Template.Child()
cover_spinner = Gtk.Template.Child()
cover_scroll = Gtk.Template.Child()
cover_box = Gtk.Template.Child()
cover_image = Gtk.Template.Child()
# Album Infos
cover_info_scroll = Gtk.Template.Child()
info_revealer = Gtk.Template.Child()
album_title_label = Gtk.Template.Child()
album_date_label = Gtk.Template.Child()
album_artist_label = Gtk.Template.Child()
# Songs
songs_scale = Gtk.Template.Child()
def __init__(self):
super().__init__()
self._current_album = None
self._current_cover_album = None
self._cover_pixbuf = None
self._timer = None
self._properties = {}
self._icon_theme = Gtk.IconTheme.get_default()
self._fullscreened = False
self._is_selected = False
self._current_size = None
self._cover_pixbuf = self._get_default_image()
# Widgets
self._toolbar = CoverToolbar()
self.cover_stack.set_visible_child(self.cover_scroll)
# Initial actions
GObject.idle_add(self._enable_tracklist)
def get_toolbar(self):
return self._toolbar
def set_selected(self, selected):
self._is_selected = selected
@Gtk.Template.Callback()
def on_cover_box_pressed(self, widget, event):
if self._current_album and event.type == Gdk.EventType._2BUTTON_PRESS:
self.emit('toggle-fullscreen')
@Gtk.Template.Callback()
def on_cover_size_allocate(self, widget, allocation):
GObject.idle_add(self._resize_image)
self.cover_info_scroll.set_max_content_width(allocation.width // 2)
@Gtk.Template.Callback()
def on_songs_start_change(self, widget, event):
if self._timer:
GObject.source_remove(self._timer)
self._timer = None
@Gtk.Template.Callback()
def on_songs_change(self, widget, event):
value = int(self.songs_scale.get_value())
time = self._current_album.get_length()
tracks = self._current_album.get_tracks()
pos = 0
for index in range(len(tracks)-1, -1, -1):
time = time - tracks[index].get_length()
pos = tracks[index].get_pos()
if time < value:
break
time = max(value - time - 1, 0)
self.emit('set-song', pos, time)
def set_album(self, album):
if album:
# Set labels
self.album_title_label.set_label(album.get_title())
self.album_date_label.set_label(', '.join(album.get_dates()))
self.album_artist_label.set_label(', '.join(album.get_albumartists()))
# Set tracks
self._set_tracks(album)
# Load cover
if album != self._current_cover_album:
self.cover_stack.set_visible_child(self.cover_spinner)
self.cover_spinner.start()
self.emit('albumart', album.get_id() if album else None)
# Set current album
self._current_album = album
self._enable_tracklist()
self._toolbar.set_fullscreen_sensitive(self._current_album is not None)
def set_play(self, pos, time):
if self._timer is not None:
GObject.source_remove(self._timer)
self._timer = None
tracks = self._current_album.get_tracks()
for index in range(0, pos):
time = time + tracks[index].get_length()
self.songs_scale.set_value(time+1)
self._timer = GObject.timeout_add(1000, self._playing)
def set_pause(self):
if self._timer is not None:
GObject.source_remove(self._timer)
self._timer = None
def set_fullscreen(self, active):
if active:
self.info_revealer.set_reveal_child(False)
self.cover_box.override_background_color(Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 1))
GObject.idle_add(self._resize_image)
self._fullscreened = True
else:
self._fullscreened = False
self.info_revealer.set_reveal_child(True)
self.cover_box.override_background_color(Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 0))
GObject.idle_add(self._resize_image)
def set_albumart(self, album, data):
if album == self._current_album:
if data:
# Load image and draw it
try:
self._cover_pixbuf = Utils.load_pixbuf(data)
except Exception as e:
self._logger.exception("Failed to set albumart")
self._cover_pixbuf = self._get_default_image()
else:
# Reset image
self._cover_pixbuf = self._get_default_image()
self._current_size = None
self._current_cover_album = album
# Show image
GObject.idle_add(self._show_image)
def _set_tracks(self, album):
self.songs_scale.clear_marks()
self.songs_scale.set_range(0, album.get_length())
length = 0
for track in album.get_tracks():
cur_length = length
if length > 0 and length < album.get_length():
cur_length = cur_length + 1
self.songs_scale.add_mark(
cur_length,
Gtk.PositionType.RIGHT,
GObject.markup_escape_text(
Utils.create_track_title(track)
)
)
length = length + track.get_length()
self.songs_scale.add_mark(
length,
Gtk.PositionType.RIGHT,
"{0[0]:02d}:{0[1]:02d} minutes".format(divmod(length, 60))
)
def _enable_tracklist(self):
if self._current_album:
# enable
self.info_revealer.set_reveal_child(True)
else:
# disable
self.info_revealer.set_reveal_child(False)
def _playing(self):
value = self.songs_scale.get_value() + 1
self.songs_scale.set_value(value)
return True
def _show_image(self):
self._resize_image()
self.cover_stack.set_visible_child(self.cover_scroll)
self.cover_spinner.stop()
def _resize_image(self):
"""Diese Methode skaliert das geladene Bild aus dem Pixelpuffer
auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse
"""
# Get size
size = self.cover_scroll.get_allocation()
# Abort if size is the same
if self._current_size and size.width == self._current_size.width and size.height == self._current_size.height:
return
self._current_size = size
# Get pixelbuffer
pixbuf = self._cover_pixbuf
# Check pixelbuffer
if pixbuf is None:
return
# Skalierungswert für Breite und Höhe ermitteln
ratioW = float(size.width) / float(pixbuf.get_width())
ratioH = float(size.height) / float(pixbuf.get_height())
# Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren
ratio = min(ratioW, ratioH)
ratio = min(ratio, 1)
# Neue Breite und Höhe berechnen
width = int(math.floor(pixbuf.get_width()*ratio))
height = int(math.floor(pixbuf.get_height()*ratio))
if width <= 0 or height <= 0:
return
# Pixelpuffer auf Oberfläche zeichnen
self.cover_image.set_allocation(self.cover_scroll.get_allocation())
self.cover_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER))
self.cover_image.show()
def _get_default_image(self):
return self._icon_theme.load_icon(
Utils.STOCK_ICON_DEFAULT,
512,
Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE
)

View file

@ -1,24 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
import logging
from gi.repository import Gtk, GdkPixbuf
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/info-dialog.ui')
class InfoDialog(Gtk.AboutDialog):
__gtype_name__ = 'McgInfoDialog'
def __init__(self):
super().__init__()
self._logger = logging.getLogger(__name__)
self.set_logo(
GdkPixbuf.Pixbuf.new_from_resource("/xyz/suruatoel/mcg/icons/mcg.svg")
)

View file

@ -1,643 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
import gettext
import logging
import math
import threading
from gi.repository import Gtk, GObject, GdkPixbuf
from mcg import client
from mcg.albumheaderbar import AlbumHeaderbar
from mcg.utils import SortOrder
from mcg.utils import Utils
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/library-toolbar.ui')
class LibraryToolbar(Gtk.ButtonBox):
__gtype_name__ = 'McgLibraryToolbar'
__gsignals__ = {
'select': (GObject.SIGNAL_RUN_FIRST, None, (bool,)),
'toggle-search': (GObject.SIGNAL_RUN_FIRST, None, (bool,)),
'update': (GObject.SIGNAL_RUN_FIRST, None, ()),
'start-scale': (GObject.SIGNAL_RUN_FIRST, None, (int,)),
'end-scale': (GObject.SIGNAL_RUN_FIRST, None, (int,)),
'sort': (GObject.SIGNAL_RUN_FIRST, None, (int,)),
'sort-type': (GObject.SIGNAL_RUN_FIRST, None, (Gtk.SortType,))
}
# Widgets
select_button = Gtk.Template.Child()
toolbar_search_bar = Gtk.Template.Child()
toolbar_popover = Gtk.Template.Child()
toolbar_sort_order_button = Gtk.Template.Child()
sort_artist = Gtk.Template.Child()
sort_title = Gtk.Template.Child()
sort_year = Gtk.Template.Child()
grid_scale = Gtk.Template.Child()
def __init__(self, item_size):
super().__init__()
# Toolbar menu
self.grid_scale.set_value(item_size)
self._toolbar_sort_buttons = {
SortOrder.ARTIST: self.sort_artist,
SortOrder.TITLE: self.sort_title,
SortOrder.YEAR: self.sort_year
}
@Gtk.Template.Callback()
def on_select_toggled(self, widget):
self.emit('select', widget.get_active())
@Gtk.Template.Callback()
def on_search_toggled(self, widget):
self.emit('toggle-search', widget.get_active())
@Gtk.Template.Callback()
def on_update_clicked(self, widget):
self.emit('update')
@Gtk.Template.Callback()
def on_grid_scale_change(self, widget, scroll, value):
self.emit('start-scale', value)
@Gtk.Template.Callback()
def on_grid_scale_changed(self, widget, event):
self.emit('end-scale', self.grid_scale.get_value())
self.toolbar_popover.popdown()
@Gtk.Template.Callback()
def on_sort_toggled(self, widget):
if widget.get_active():
sort = [key for key, value in self._toolbar_sort_buttons.items() if value is widget][0]
self.emit('sort', sort)
@Gtk.Template.Callback()
def on_sort_order_toggled(self, button):
if button.get_active():
sort_type = Gtk.SortType.DESCENDING
else:
sort_type = Gtk.SortType.ASCENDING
self.emit('sort-type', sort_type)
def get_grid_scale(self):
return self.grid_scale
def is_search_active(self):
return self.toolbar_search_bar.get_active()
def set_search_active(self, active):
self.toolbar_search_bar.set_active(active)
def exit_selection(self):
self.select_button.set_active(False)
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/library-panel.ui')
class LibraryPanel(Gtk.Stack):
__gtype_name__ = 'McgLibraryPanel'
__gsignals__ = {
'open-standalone': (GObject.SIGNAL_RUN_FIRST, None, ()),
'close-standalone': (GObject.SIGNAL_RUN_FIRST, None, ()),
'update': (GObject.SIGNAL_RUN_FIRST, None, ()),
'play': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
'queue': (GObject.SIGNAL_RUN_FIRST, None, (str,)),
'queue-multiple': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
'item-size-changed': (GObject.SIGNAL_RUN_FIRST, None, (int,)),
'sort-order-changed': (GObject.SIGNAL_RUN_FIRST, None, (int,)),
'sort-type-changed': (GObject.SIGNAL_RUN_FIRST, None, (Gtk.SortType,)),
'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,))
}
# Widgets
panel_standalone = Gtk.Template.Child()
actionbar_revealer = Gtk.Template.Child()
# Filter/search bar
filter_bar = Gtk.Template.Child()
filter_entry = Gtk.Template.Child()
# Progress Bar
stack = Gtk.Template.Child()
progress_box = Gtk.Template.Child()
progress_image = Gtk.Template.Child()
progress_bar = Gtk.Template.Child()
scroll = Gtk.Template.Child()
# Library Grid
library_grid = Gtk.Template.Child()
# Standalone Image
standalone_stack = Gtk.Template.Child()
standalone_spinner = Gtk.Template.Child()
standalone_scroll = Gtk.Template.Child()
standalone_image = Gtk.Template.Child()
def __init__(self, client):
super().__init__()
self._logger = logging.getLogger(__name__)
self._client = client
self._buttons = {}
self._albums = None
self._host = "localhost"
self._filter_string = ""
self._item_size = 150
self._sort_order = SortOrder.YEAR
self._sort_type = Gtk.SortType.DESCENDING
self._grid_pixbufs = {}
self._old_ranges = {}
self._library_lock = threading.Lock()
self._library_stop = threading.Event()
self._icon_theme = Gtk.IconTheme.get_default()
self._standalone_pixbuf = None
self._selected_albums = []
self._allocation = (0, 0)
self._is_selected = False
# Widgets
self._toolbar = LibraryToolbar(self._item_size)
self._toolbar.connect('select', self.on_toolbar_select)
self._toolbar.connect('toggle-search', self.on_toolbar_toggle_search)
self._toolbar.connect('update', self.on_toolbar_update)
self._toolbar.connect('start-scale', self.on_toolbar_scale)
self._toolbar.connect('end-scale', self.on_toolbar_scaled)
self._toolbar.connect('sort', self.on_toolbar_sort)
self._toolbar.connect('sort-type', self.on_toolbar_sort_type)
# Header bar
self._headerbar_standalone = AlbumHeaderbar()
self._headerbar_standalone.connect('close', self.on_standalone_close_clicked)
# Progress Bar
self.progress_image.set_from_pixbuf(self._get_default_image())
# Library Grid: Model
self._library_grid_model = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str)
self._library_grid_model.set_sort_func(2, self.compare_albums, self._sort_order)
self._library_grid_model.set_sort_column_id(2, self._sort_type)
self._library_grid_filter = self._library_grid_model.filter_new()
self._library_grid_filter.set_visible_func(self.on_filter_visible)
# Library Grid
self.library_grid.set_model(self._library_grid_filter)
self.library_grid.set_pixbuf_column(0)
self.library_grid.set_text_column(-1)
self.library_grid.set_tooltip_column(1)
def get_headerbar_standalone(self):
return self._headerbar_standalone
def get_toolbar(self):
return self._toolbar
def set_selected(self, selected):
self._is_selected = selected
@Gtk.Template.Callback()
def on_resize(self, widget, event):
new_allocation = (widget.get_allocation().width, widget.get_allocation().height)
if new_allocation == self._allocation:
return
self._allocation = new_allocation
self._toolbar.get_grid_scale().clear_marks()
width = widget.get_allocation().width
lower = int(self._toolbar.get_grid_scale().get_adjustment().get_lower())
upper = int(self._toolbar.get_grid_scale().get_adjustment().get_upper())
countMin = max(int(width / upper), 1)
countMax = max(int(width / lower), 1)
for index in range(countMin, countMax):
pixel = int(width / index)
pixel = pixel - (2 * int(pixel / 100))
self._toolbar.get_grid_scale().add_mark(
pixel,
Gtk.PositionType.BOTTOM,
None
)
def on_toolbar_toggle_search(self, widget, active):
self.filter_bar.set_search_mode(active)
def on_toolbar_select(self, widget, active):
if active:
self.actionbar_revealer.set_reveal_child(True)
self.library_grid.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
self.library_grid.get_style_context().add_class(Utils.CSS_SELECTION)
else:
self.actionbar_revealer.set_reveal_child(False)
self.library_grid.set_selection_mode(Gtk.SelectionMode.SINGLE)
self.library_grid.get_style_context().remove_class(Utils.CSS_SELECTION)
def on_toolbar_update(self, widget):
self.emit('update')
def on_toolbar_scale(self, widget, value):
size = math.floor(value)
range = self._toolbar.get_grid_scale().get_adjustment()
if size < range.get_lower() or size > range.get_upper():
return
self._item_size = size
GObject.idle_add(self.library_grid.set_item_padding, size / 100)
GObject.idle_add(self._set_widget_grid_size, self.library_grid, size, True)
def on_toolbar_scaled(self, widget, value):
size = round(value)
range = self._toolbar.get_grid_scale().get_adjustment()
if size < range.get_lower() or size > range.get_upper():
return False
self.emit('item-size-changed', size)
self._redraw()
return False
def on_toolbar_sort(self, widget, sort):
self._change_sort(sort)
def on_toolbar_sort_type(self, widget, sort_type):
self._sort_type = sort_type
self._library_grid_model.set_sort_column_id(2, sort_type)
self.emit('sort-type-changed', sort_type)
@Gtk.Template.Callback()
def on_filter_bar_notify(self, widget, value):
if self._toolbar.is_search_active() is not self.filter_bar.get_search_mode():
self._toolbar.set_search_active(self.filter_bar.get_search_mode())
@Gtk.Template.Callback()
def on_filter_entry_changed(self, widget):
self._filter_string = self.filter_entry.get_text()
GObject.idle_add(self._library_grid_filter.refilter)
@Gtk.Template.Callback()
def on_library_grid_clicked(self, widget, path):
# Get selected album
path = self._library_grid_filter.convert_path_to_child_path(path)
iter = self._library_grid_model.get_iter(path)
id = self._library_grid_model.get_value(iter, 2)
album = self._albums[id]
self._selected_albums = [album]
self.emit('albumart', id)
# Show standalone album
if widget.get_selection_mode() == Gtk.SelectionMode.SINGLE:
# Set labels
self._headerbar_standalone.set_album(album)
# Show panel
self._open_standalone()
# Set cover loading indicator
self.standalone_stack.set_visible_child(self.standalone_spinner)
self.standalone_spinner.start()
@Gtk.Template.Callback()
def on_library_grid_selection_changed(self, widget):
self._selected_albums = []
for path in widget.get_selected_items():
path = self._library_grid_filter.convert_path_to_child_path(path)
iter = self._library_grid_model.get_iter(path)
id = self._library_grid_model.get_value(iter, 2)
self._selected_albums.insert(0, self._albums[id])
def on_filter_visible(self, model, iter, data):
id = model.get_value(iter, 2)
if not id in self._albums.keys():
return
album = self._albums[id]
return album.filter(self._filter_string)
@Gtk.Template.Callback()
def on_selection_cancel_clicked(self, widget):
self._toolbar.exit_selection()
@Gtk.Template.Callback()
def on_selection_add_clicked(self, widget):
ids = [album.get_id() for album in self._selected_albums]
self.emit('queue-multiple', ids)
self._toolbar.exit_selection()
@Gtk.Template.Callback()
def on_standalone_scroll_size_allocate(self, widget, allocation):
self._resize_standalone_image()
@Gtk.Template.Callback()
def on_standalone_play_clicked(self, widget):
self.emit('play', self._selected_albums[0].get_id())
self._close_standalone()
@Gtk.Template.Callback()
def on_standalone_queue_clicked(self, widget):
self.emit('queue', self._selected_albums[0].get_id())
self._close_standalone()
def on_standalone_close_clicked(self, widget):
self._close_standalone()
def show_search(self):
self.filter_bar.set_search_mode(True)
def set_item_size(self, item_size):
if self._item_size != item_size:
self._item_size = item_size
self._toolbar.get_grid_scale().set_value(item_size)
self._redraw()
def get_item_size(self):
return self._item_size
def set_sort_order(self, sort):
if self._sort_order != sort:
button = self._toolbar_sort_buttons[sort]
if button and not button.get_active():
button.set_active(True)
self._sort_order = sort
self._library_grid_model.set_sort_func(2, self.compare_albums, self._sort_order)
def get_sort_order(self):
return self._sort_order
def set_sort_type(self, sort_type):
if self._sort_type != sort_type:
if sort_type:
sort_type_gtk = Gtk.SortType.DESCENDING
self.toolbar_sort_order_button.set_active(True)
else:
sort_type_gtk = Gtk.SortType.ASCENDING
self.toolbar_sort_order_button.set_active(False)
if self._sort_type != sort_type_gtk:
self._sort_type = sort_type_gtk
self._library_grid_model.set_sort_column_id(2, sort_type)
def get_sort_type(self):
return (self._sort_type != Gtk.SortType.ASCENDING)
def init_albums(self):
self.progress_bar.set_text(gettext.gettext("Loading albums"))
def load_albums(self):
self.progress_bar.pulse()
def set_albums(self, host, albums):
self._host = host
self._library_stop.set()
threading.Thread(target=self._set_albums, args=(host, albums, self._item_size,)).start()
def set_albumart(self, album, data):
if album in self._selected_albums:
if data:
# Load image and draw it
try:
self._standalone_pixbuf = Utils.load_pixbuf(data)
except Exception as e:
self._logger.exception("Failed to set albumart")
self._standalone_pixbuf = self._get_default_image()
else:
self._standalone_pixbuf = self._get_default_image()
# Show image
GObject.idle_add(self._show_image)
def compare_albums(self, model, row1, row2, criterion):
id1 = model.get_value(row1, 2)
id2 = model.get_value(row2, 2)
if not id1 or not id2:
return
return client.MCGAlbum.compare(self._albums[id1], self._albums[id2], criterion)
def stop_threads(self):
self._library_stop.set()
def _change_sort(self, sort):
self._sort_order = sort
self._library_grid_model.set_sort_func(2, self.compare_albums, sort)
self.emit('sort-order-changed', sort)
def _set_albums(self, host, albums, size):
if not self._is_selected and albums != self._albums:
GObject.idle_add(
self.get_parent().child_set_property,
self,
'needs-attention',
True
)
self._library_lock.acquire()
self._library_stop.clear()
self._albums = albums
GObject.idle_add(self.stack.set_visible_child, self.progress_box)
GObject.idle_add(self.progress_bar.set_fraction, 0.0)
GObject.idle_add(self.library_grid.set_item_padding, size / 100)
self.library_grid.set_model(None)
self.library_grid.freeze_child_notify()
self._library_grid_model.clear()
i = 0
n = len(albums)
cache = client.MCGCache(host, size)
self._grid_pixbufs.clear()
for album_id in albums.keys():
album = albums[album_id]
pixbuf = None
try:
pixbuf = Utils.load_thumbnail(cache, self._client, album, size)
except client.CommandException:
# Exception is handled by client
pass
except Exception as e:
self._logger.exception("Failed to load albumart")
if pixbuf is None:
pixbuf = self._icon_theme.load_icon(
Utils.STOCK_ICON_DEFAULT,
self._item_size,
Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE
)
if pixbuf is not None:
self._grid_pixbufs[album.get_id()] = pixbuf
self._library_grid_model.append([
pixbuf,
GObject.markup_escape_text("\n".join([
album.get_title(),
', '.join(album.get_dates()),
Utils.create_artists_label(album),
Utils.create_length_label(album)
])),
album_id
])
i += 1
GObject.idle_add(self.progress_bar.set_fraction, i/n)
GObject.idle_add(self.progress_bar.set_text, gettext.gettext("Loading images"))
if self._library_stop.is_set():
self._library_lock.release()
return
self.library_grid.set_model(self._library_grid_filter)
self.library_grid.thaw_child_notify()
self.library_grid.set_item_width(-1)
self._library_lock.release()
self.stack.set_visible_child(self.scroll)
def _set_widget_grid_size(self, grid_widget, size, vertical):
self._library_stop.set()
threading.Thread(target=self._set_widget_grid_size_thread, args=(grid_widget, size, vertical,)).start()
def _set_widget_grid_size_thread(self, grid_widget, size, vertical):
self._library_lock.acquire()
self._library_stop.clear()
grid_filter = grid_widget.get_model()
grid_model = grid_filter.get_model()
# get old_range
grid_widget_id = id(grid_widget)
if grid_widget_id not in self._old_ranges or self._old_ranges[grid_widget_id] is None:
self._old_ranges[grid_widget_id] = range(0, len(grid_filter))
old_range = self._old_ranges[grid_widget_id]
old_start = len(old_range) > 0 and old_range[0] or 0
old_end = len(old_range) > 0 and old_range[len(old_range)-1] + 1 or 0
# calculate visible range
w = (grid_widget.get_allocation().width // size) + (vertical and 0 or 1)
h = (grid_widget.get_allocation().height // size) + (vertical and 1 or 0)
c = w * h
vis_range = grid_widget.get_visible_range()
if vis_range is None:
self._library_lock.release()
return
(vis_start,), (vis_end,) = vis_range
vis_end = min(vis_start + c, len(grid_filter))
vis_range = range(vis_start, vis_end)
# set pixbuf
cur_start = min(old_start, vis_start)
cur_end = max(old_end, vis_end)
cur_range = range(cur_start, cur_end)
for index in cur_range:
iter = grid_filter.convert_iter_to_child_iter(grid_filter[index].iter)
if index in vis_range:
album_id = grid_model.get_value(iter, 2)
pixbuf = self._grid_pixbufs[album_id]
pixbuf = pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.NEAREST)
else:
pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, 1, 1)
grid_model.set_value(iter, 0, pixbuf)
if self._library_stop.is_set():
self._library_lock.release()
return
self._old_ranges[grid_widget_id] = vis_range
grid_widget.set_item_width(size)
self._library_lock.release()
def _show_image(self):
self._resize_standalone_image()
self.standalone_stack.set_visible_child(self.standalone_scroll)
self.standalone_spinner.stop()
def _redraw(self):
if self._albums is not None:
self.set_albums(self._host, self._albums)
def _open_standalone(self):
self.set_visible_child(self.panel_standalone)
self.emit('open-standalone')
def _close_standalone(self):
self.set_visible_child(self.get_children()[0])
self.emit('close-standalone')
def _resize_standalone_image(self):
"""Diese Methode skaliert das geladene Bild aus dem Pixelpuffer
auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse
"""
pixbuf = self._standalone_pixbuf
size = self.standalone_scroll.get_allocation()
# Check pixelbuffer
if pixbuf is None:
return
# Skalierungswert für Breite und Höhe ermitteln
ratioW = float(size.width) / float(pixbuf.get_width())
ratioH = float(size.height) / float(pixbuf.get_height())
# Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren
ratio = min(ratioW, ratioH)
ratio = min(ratio, 1)
# Neue Breite und Höhe berechnen
width = int(math.floor(pixbuf.get_width()*ratio))
height = int(math.floor(pixbuf.get_height()*ratio))
if width <= 0 or height <= 0:
return
# Pixelpuffer auf Oberfläche zeichnen
self.standalone_image.set_allocation(self.standalone_scroll.get_allocation())
self.standalone_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER))
self.standalone_image.show()
def _get_default_image(self):
return self._icon_theme.load_icon(
Utils.STOCK_ICON_DEFAULT,
512,
Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE
)

View file

@ -1,44 +0,0 @@
#!/usr/bin/env python3
import sys
from mcg import Environment
def setup_resources():
from gi.repository import Gio
resource = Gio.resource_load(
Environment.get_data('xyz.suruatoel.mcg.gresource')
)
Gio.Resource._register(resource)
def setup_locale():
import gettext
gettext.bindtextdomain('mcg', Environment.get_locale())
gettext.textdomain('mcg')
def run_application():
from mcg.application import Application
app = Application()
return app.run(sys.argv)
def main():
setup_resources()
setup_locale()
return run_application()
if __name__ == "__main__":
main()
sys.exit(main())

View file

@ -1,353 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
import logging
import math
import threading
from gi.repository import Gtk, GObject, GdkPixbuf
from mcg import client
from mcg.albumheaderbar import AlbumHeaderbar
from mcg.utils import Utils
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/playlist-toolbar.ui')
class PlaylistToolbar(Gtk.ButtonBox):
__gtype_name__ = 'McgPlaylistToolbar'
__gsignals__ = {
'select': (GObject.SIGNAL_RUN_FIRST, None, (bool,)),
'clear-playlist': (GObject.SIGNAL_RUN_FIRST, None, ())
}
# Widgets
playlist_clear_button = Gtk.Template.Child()
select_button = Gtk.Template.Child()
def __init__(self):
super().__init__()
@Gtk.Template.Callback()
def on_select_toggled(self, widget):
self.emit('select', widget.get_active())
@Gtk.Template.Callback()
def on_clear_clicked(self, widget):
if widget is self.playlist_clear_button:
self.emit('clear-playlist')
def exit_selection(self):
self.select_button.set_active(False)
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/playlist-panel.ui')
class PlaylistPanel(Gtk.Stack):
__gtype_name__ = 'McgPlaylistPanel'
__gsignals__ = {
'open-standalone': (GObject.SIGNAL_RUN_FIRST, None, ()),
'close-standalone': (GObject.SIGNAL_RUN_FIRST, None, ()),
'clear-playlist': (GObject.SIGNAL_RUN_FIRST, None, ()),
'remove-album': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
'remove-multiple-albums': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
'play': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)),
'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,))
}
# Widgets
panel_standalone = Gtk.Template.Child()
actionbar_revealer = Gtk.Template.Child()
# Playlist Grid
playlist_grid = Gtk.Template.Child()
# Action bar (normal)
actionbar = Gtk.Template.Child()
actionbar_standalone = Gtk.Template.Child()
# Standalone Image
standalone_stack = Gtk.Template.Child()
standalone_spinner = Gtk.Template.Child()
standalone_scroll = Gtk.Template.Child()
standalone_image = Gtk.Template.Child()
def __init__(self, client):
GObject.GObject.__init__(self)
self._client = client
self._host = None
self._item_size = 150
self._playlist = None
self._playlist_albums = None
self._playlist_lock = threading.Lock()
self._playlist_stop = threading.Event()
self._icon_theme = Gtk.IconTheme.get_default()
self._standalone_pixbuf = None
self._selected_albums = []
self._is_selected = False
# Widgets
self._toolbar = PlaylistToolbar()
self._toolbar.connect('select', self.on_toolbar_select)
self._toolbar.connect('clear-playlist', self.on_toolbar_clear)
# Header bar
self._headerbar_standalone = AlbumHeaderbar()
self._headerbar_standalone.connect('close', self.on_headerbar_close_clicked)
# Playlist Grid: Model
self._playlist_grid_model = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str)
# Playlist Grid
self.playlist_grid.set_model(self._playlist_grid_model)
self.playlist_grid.set_pixbuf_column(0)
self.playlist_grid.set_text_column(-1)
self.playlist_grid.set_tooltip_column(1)
def get_headerbar_standalone(self):
return self._headerbar_standalone
def get_toolbar(self):
return self._toolbar
def set_selected(self, selected):
self._is_selected = selected
def on_toolbar_select(self, widget, active):
if active:
self.actionbar_revealer.set_reveal_child(True)
self.playlist_grid.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
self.playlist_grid.get_style_context().add_class(Utils.CSS_SELECTION)
else:
self.actionbar_revealer.set_reveal_child(False)
self.playlist_grid.set_selection_mode(Gtk.SelectionMode.SINGLE)
self.playlist_grid.get_style_context().remove_class(Utils.CSS_SELECTION)
def on_toolbar_clear(self, widget):
self.emit('clear-playlist')
@Gtk.Template.Callback()
def on_playlist_grid_clicked(self, widget, path):
# Get selected album
iter = self._playlist_grid_model.get_iter(path)
hash = self._playlist_grid_model.get_value(iter, 2)
album = self._playlist_albums[hash]
self._selected_albums = [album]
self.emit('albumart', hash)
# Show standalone album
if widget.get_selection_mode() == Gtk.SelectionMode.SINGLE:
# Set labels
self._headerbar_standalone.set_album(album)
# Show panel
self._open_standalone()
# Set cover loading indicator
self.standalone_stack.set_visible_child(self.standalone_spinner)
self.standalone_spinner.start()
@Gtk.Template.Callback()
def on_playlist_grid_selection_changed(self, widget):
self._selected_albums = []
for path in widget.get_selected_items():
iter = self._playlist_grid_model.get_iter(path)
hash = self._playlist_grid_model.get_value(iter, 2)
self._selected_albums.append(self._playlist_albums[hash])
@Gtk.Template.Callback()
def on_selection_cancel_clicked(self, widget):
self._toolbar.exit_selection()
@Gtk.Template.Callback()
def on_selection_remove_clicked(self, widget):
self.emit('remove-multiple-albums', self._selected_albums)
self._toolbar.exit_selection()
@Gtk.Template.Callback()
def on_standalone_scroll_size_allocate(self, widget, allocation):
self._resize_standalone_image()
def on_headerbar_close_clicked(self, widget):
self._close_standalone()
@Gtk.Template.Callback()
def on_standalone_remove_clicked(self, widget):
self.emit('remove-album', self._selected_albums[0])
self._close_standalone()
@Gtk.Template.Callback()
def on_standalone_play_clicked(self, widget):
self.emit('play', self._selected_albums[0])
self._close_standalone()
def set_item_size(self, item_size):
if self._item_size != item_size:
self._item_size = item_size
self._redraw()
def get_item_size(self):
return self._item_size
def set_playlist(self, host, playlist):
self._host = host
self._playlist_stop.set()
threading.Thread(target=self._set_playlist, args=(host, playlist, self._item_size,)).start()
def set_albumart(self, album, data):
if album in self._selected_albums:
if data:
# Load image and draw it
try:
self._standalone_pixbuf = Utils.load_pixbuf(data)
except Exception as e:
self._logger.exception("Failed to set albumart")
self._cover_pixbuf = self._get_default_image()
else:
self._cover_pixbuf = self._get_default_image()
# Show image
GObject.idle_add(self._show_image)
def stop_threads(self):
self._playlist_stop.set()
def _set_playlist(self, host, playlist, size):
if not self._is_selected and self._playlist != playlist:
GObject.idle_add(
self.get_parent().child_set_property,
self,
'needs-attention',
True
)
self._playlist_lock.acquire()
self._playlist_stop.clear()
self._playlist = playlist
self._playlist_albums = {}
for album in playlist:
self._playlist_albums[album.get_id()] = album
self.playlist_grid.set_model(None)
self.playlist_grid.freeze_child_notify()
self._playlist_grid_model.clear()
GObject.idle_add(self.playlist_grid.set_item_padding, size / 100)
cache = client.MCGCache(host, size)
for album in playlist:
pixbuf = None
# Load albumart thumbnail
try:
pixbuf = Utils.load_thumbnail(cache, self._client, album, size)
except client.CommandException:
# Exception is handled by client
pass
except Exception:
self._logger.exception("Failed to load albumart")
if pixbuf is None:
pixbuf = self._icon_theme.load_icon(
Utils.STOCK_ICON_DEFAULT,
self._item_size,
Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE
)
if pixbuf is not None:
self._playlist_grid_model.append([
pixbuf,
GObject.markup_escape_text("\n".join([
album.get_title(),
', '.join(album.get_dates()),
Utils.create_artists_label(album),
Utils.create_length_label(album)
])),
album.get_id()
])
if self._playlist_stop.is_set():
self._playlist_lock.release()
return
self.playlist_grid.set_model(self._playlist_grid_model)
self.playlist_grid.thaw_child_notify()
# TODO why set_columns()?
#self.playlist_grid.set_columns(len(playlist))
self._playlist_lock.release()
def _show_image(self):
self._resize_standalone_image()
self.standalone_stack.set_visible_child(self.standalone_scroll)
self.standalone_spinner.stop()
def _redraw(self):
if self._playlist is not None:
self.set_playlist(self._host, self._playlist)
def _open_standalone(self):
self.set_visible_child(self.panel_standalone)
self.emit('open-standalone')
def _close_standalone(self):
self.set_visible_child(self.get_children()[0])
self.emit('close-standalone')
def _resize_standalone_image(self):
"""Diese Methode skaliert das geladene Bild aus dem Pixelpuffer
auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse
"""
pixbuf = self._standalone_pixbuf
size = self.standalone_scroll.get_allocation()
# Check pixelbuffer
if pixbuf is None:
return
# Skalierungswert für Breite und Höhe ermitteln
ratioW = float(size.width) / float(pixbuf.get_width())
ratioH = float(size.height) / float(pixbuf.get_height())
# Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren
ratio = min(ratioW, ratioH)
ratio = min(ratio, 1)
# Neue Breite und Höhe berechnen
width = int(math.floor(pixbuf.get_width()*ratio))
height = int(math.floor(pixbuf.get_height()*ratio))
if width <= 0 or height <= 0:
return
# Pixelpuffer auf Oberfläche zeichnen
self.standalone_image.set_allocation(self.standalone_scroll.get_allocation())
self.standalone_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER))
self.standalone_image.show()
def _get_default_image(self):
return self._icon_theme.load_icon(
Utils.STOCK_ICON_DEFAULT,
512,
Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE
)

View file

@ -1,129 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GObject
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/server-toolbar.ui')
class ServerToolbar(Gtk.ButtonBox):
__gtype_name__ = 'McgServerToolbar'
def __init__(self):
super().__init__()
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/server-panel.ui')
class ServerPanel(Gtk.Box):
__gtype_name__ = 'McgServerPanel'
__gsignals__ = {
'change-output-device': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,bool,)),
}
# Widgets
# Status widgets
status_file = Gtk.Template.Child()
status_audio = Gtk.Template.Child()
status_bitrate = Gtk.Template.Child()
status_error = Gtk.Template.Child()
# Stats widgets
stats_artists = Gtk.Template.Child()
stats_albums = Gtk.Template.Child()
stats_songs = Gtk.Template.Child()
stats_dbplaytime = Gtk.Template.Child()
stats_playtime = Gtk.Template.Child()
stats_uptime = Gtk.Template.Child()
# Audio ouptut devices widgets
output_devices = Gtk.Template.Child()
def __init__(self):
super().__init__()
self._none_label = ""
self._output_buttons = {}
self._is_selected = False
# Widgets
self._toolbar = ServerToolbar()
self._none_label = self.status_file.get_label()
def set_selected(self, selected):
self._is_selected = selected
def get_toolbar(self):
return self._toolbar
def on_output_device_toggled(self, widget, device):
self.emit('change-output-device', device, widget.get_active())
def set_status(self, file, audio, bitrate, error):
if file:
file = GObject.markup_escape_text(file)
else:
file = self._none_label
self.status_file.set_markup(file)
# Audio information
if audio:
parts = audio.split(":")
if len(parts) == 3:
audio = "{} Hz, {} bit, {} channels".format(parts[0], parts[1], parts[2])
else:
audio = self._none_label
self.status_audio.set_markup(audio)
# Bitrate
if bitrate:
bitrate = bitrate + " kb/s"
else:
bitrate = self._none_label
self.status_bitrate.set_markup(bitrate)
# Error
if error:
error = GObject.markup_escape_text(error)
else:
error = self._none_label
self.status_error.set_markup(error)
def set_stats(self, artists, albums, songs, dbplaytime, playtime, uptime):
self.stats_artists.set_text(str(artists))
self.stats_albums.set_text(str(albums))
self.stats_songs.set_text(str(songs))
self.stats_dbplaytime.set_text(str(dbplaytime))
self.stats_playtime.set_text(str(playtime))
self.stats_uptime.set_text(str(uptime))
def set_output_devices(self, devices):
device_ids = []
# Add devices
for device in devices:
device_ids.append(device.get_id())
if device.get_id() in self._output_buttons.keys():
self._output_buttons[device.get_id()].freeze_notify()
self._output_buttons[device.get_id()].set_active(device.is_enabled())
self._output_buttons[device.get_id()].thaw_notify()
else:
button = Gtk.CheckButton(device.get_name())
if device.is_enabled():
button.set_active(True)
handler = button.connect('toggled', self.on_output_device_toggled, device)
self.output_devices.insert(button, -1)
self._output_buttons[device.get_id()] = button
self.output_devices.show_all()
# Remove devices
for id in self._output_buttons.keys():
if id not in device_ids:
self.output_devices.remove(self._output_buttons[id].get_parent())

View file

@ -1,18 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/shortcuts-dialog.ui')
class ShortcutsDialog(Gtk.ShortcutsWindow):
__gtype_name__ = 'McgShortcutsDialog'
def __init__(self):
super().__init__()

View file

@ -1,88 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
import hashlib
import gettext
import os
import urllib
from gi.repository import GdkPixbuf
class Utils:
CSS_SELECTION = 'selection'
STOCK_ICON_DEFAULT = 'image-x-generic-symbolic'
def load_pixbuf(data):
loader = GdkPixbuf.PixbufLoader()
try:
loader.write(data)
finally:
loader.close()
return loader.get_pixbuf()
def load_thumbnail(cache, client, album, size):
cache_url = cache.create_filename(album)
pixbuf = None
if os.path.isfile(cache_url):
pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_url)
else:
# Load cover from server
albumart = client.get_albumart_now(album.get_id())
if albumart:
pixbuf = Utils.load_pixbuf(albumart)
if pixbuf is not None:
pixbuf = pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.HYPER)
pixbuf.savev(cache_url, 'jpeg', [], [])
return pixbuf
def create_artists_label(album):
label = ', '.join(album.get_albumartists())
if album.get_artists():
label = gettext.gettext("{} feat. {}").format(
label,
", ".join(album.get_artists())
)
return label
def create_length_label(album):
minutes = album.get_length() // 60
seconds = album.get_length() - minutes * 60
return gettext.gettext("{}:{} minutes").format(minutes, seconds)
def create_track_title(track):
title = track.get_title()
if track.get_artists():
title = gettext.gettext("{} feat. {}").format(
title,
", ".join(track.get_artists())
)
return title
def generate_id(values):
if type(values) is not list:
values = [values]
m = hashlib.md5()
for value in values:
m.update(value.encode('utf-8'))
return m.hexdigest()
class SortOrder:
ARTIST = 0
TITLE = 1
YEAR = 2

View file

@ -1,659 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
try:
import keyring
use_keyring = True
except:
use_keyring = False
import logging
from gi.repository import Gtk, Gdk, GObject, GLib, Gio
from mcg import client
from mcg.shortcutsdialog import ShortcutsDialog
from mcg.connectionpanel import ConnectionPanel
from mcg.serverpanel import ServerPanel
from mcg.coverpanel import CoverPanel
from mcg.playlistpanel import PlaylistPanel
from mcg.librarypanel import LibraryPanel
from mcg.zeroconf import ZeroconfProvider
class WindowState(GObject.Object):
WIDTH = 'width'
HEIGHT = 'height'
IS_MAXIMIZED = 'is_maximized'
IS_FULLSCREENED = 'is_fullscreened'
width = GObject.Property(type=int, default=800)
height = GObject.Property(type=int, default=600)
is_maximized = GObject.Property(type=bool, default=False)
is_fullscreened = GObject.Property(type=bool, default=False)
def __init__(self):
super().__init__()
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/window.ui')
class Window(Gtk.ApplicationWindow):
__gtype_name__ = 'McgAppWindow'
SETTING_HOST = 'host'
SETTING_PORT = 'port'
SETTING_CONNECTED = 'connected'
SETTING_WINDOW_WIDTH = 'width'
SETTING_WINDOW_HEIGHT = 'height'
SETTING_WINDOW_MAXIMIZED = 'is-maximized'
SETTING_PANEL = 'panel'
SETTING_ITEM_SIZE = 'item-size'
SETTING_SORT_ORDER = 'sort-order'
SETTING_SORT_TYPE = 'sort-type'
_CUSTOM_STARTUP_COMPLETE = 'startup-complete'
# Widgets
content_stack = Gtk.Template.Child()
panel_stack = Gtk.Template.Child()
toolbar_stack = Gtk.Template.Child()
# Headerbar
headerbar = Gtk.Template.Child()
headerbar_title_stack = Gtk.Template.Child()
headerbar_panel_switcher = Gtk.Template.Child()
headerbar_connection_label = Gtk.Template.Child()
headerbar_button_connect = Gtk.Template.Child()
headerbar_button_playpause = Gtk.Template.Child()
headerbar_button_volume = Gtk.Template.Child()
# Infobar
info_revealer = Gtk.Template.Child()
info_bar = Gtk.Template.Child()
info_label = Gtk.Template.Child()
def __init__(self, app, title, settings):
super().__init__()
self.set_application(app)
self.set_title(title)
self._settings = settings
self._panels = []
self._mcg = client.Client()
self._state = WindowState()
self._changing_volume = False
self._setting_volume = False
# Help/Shortcuts dialog
self.set_help_overlay(ShortcutsDialog())
# Login screen
self._connection_panel = ConnectionPanel()
# Server panel
self._server_panel = ServerPanel()
self._panels.append(self._server_panel)
# Cover panel
self._cover_panel = CoverPanel()
self._panels.append(self._cover_panel)
# Playlist panel
self._playlist_panel = PlaylistPanel(self._mcg)
self._playlist_panel.connect('open-standalone', self.on_panel_open_standalone)
self._playlist_panel.connect('close-standalone', self.on_panel_close_standalone)
self._panels.append(self._playlist_panel)
# Library panel
self._library_panel = LibraryPanel(self._mcg)
self._library_panel.connect('open-standalone', self.on_panel_open_standalone)
self._library_panel.connect('close-standalone', self.on_panel_close_standalone)
self._panels.append(self._library_panel)
# Stack
self.content_stack.add(self._connection_panel)
self.panel_stack.add_titled(self._server_panel, 'server-panel', "Server")
self.panel_stack.add_titled(self._cover_panel, 'cover-panel', "Cover")
self.panel_stack.add_titled(self._playlist_panel, 'playlist-panel', "Playlist")
self.panel_stack.add_titled(self._library_panel, 'library-panel', "Library")
# Header
self._playlist_panel.get_headerbar_standalone().connect('close', self.on_panel_close_standalone)
self._library_panel.get_headerbar_standalone().connect('close', self.on_panel_close_standalone)
# Toolbar stack
self.toolbar_stack.add(self._server_panel.get_toolbar())
self.toolbar_stack.add(self._cover_panel.get_toolbar())
self.toolbar_stack.add(self._playlist_panel.get_toolbar())
self.toolbar_stack.add(self._library_panel.get_toolbar())
# Properties
self._set_headerbar_sensitive(False, False)
self._connection_panel.set_host(self._settings.get_string(Window.SETTING_HOST))
self._connection_panel.set_port(self._settings.get_int(Window.SETTING_PORT))
if use_keyring:
self._connection_panel.set_password(keyring.get_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME))
self._playlist_panel.set_item_size(self._settings.get_int(Window.SETTING_ITEM_SIZE))
self._library_panel.set_item_size(self._settings.get_int(Window.SETTING_ITEM_SIZE))
self._library_panel.set_sort_order(self._settings.get_enum(Window.SETTING_SORT_ORDER))
self._library_panel.set_sort_type(self._settings.get_boolean(Window.SETTING_SORT_TYPE))
# Signals
self._connection_panel.connect('connection-changed', self.on_connection_panel_connection_changed)
self.panel_stack.connect('notify::visible-child', self.on_stack_switched)
self._server_panel.connect('change-output-device', self.on_server_panel_output_device_changed)
self._cover_panel.connect('toggle-fullscreen', self.on_cover_panel_toggle_fullscreen)
self._cover_panel.connect('set-song', self.on_cover_panel_set_song)
self._cover_panel.connect('albumart', self.on_cover_panel_albumart)
self._playlist_panel.connect('clear-playlist', self.on_playlist_panel_clear_playlist)
self._playlist_panel.connect('remove-album', self.on_playlist_panel_remove)
self._playlist_panel.connect('remove-multiple-albums', self.on_playlist_panel_remove_multiple)
self._playlist_panel.connect('play', self.on_playlist_panel_play)
self._playlist_panel.connect('albumart', self.on_playlist_panel_albumart)
self._library_panel.connect('update', self.on_library_panel_update)
self._library_panel.connect('play', self.on_library_panel_play)
self._library_panel.connect('queue', self.on_library_panel_queue)
self._library_panel.connect('queue-multiple', self.on_library_panel_queue_multiple)
self._library_panel.connect('item-size-changed', self.on_library_panel_item_size_changed)
self._library_panel.connect('sort-order-changed', self.on_library_panel_sort_order_changed)
self._library_panel.connect('sort-type-changed', self.on_library_panel_sort_type_changed)
self._library_panel.connect('albumart', self.on_library_panel_albumart)
self._mcg.connect_signal(client.Client.SIGNAL_CONNECTION, self.on_mcg_connect)
self._mcg.connect_signal(client.Client.SIGNAL_STATUS, self.on_mcg_status)
self._mcg.connect_signal(client.Client.SIGNAL_STATS, self.on_mcg_stats)
self._mcg.connect_signal(client.Client.SIGNAL_LOAD_OUTPUT_DEVICES, self.on_mcg_load_output_devices)
self._mcg.connect_signal(client.Client.SIGNAL_LOAD_PLAYLIST, self.on_mcg_load_playlist)
self._mcg.connect_signal(client.Client.SIGNAL_PULSE_ALBUMS, self.on_mcg_pulse_albums)
self._mcg.connect_signal(client.Client.SIGNAL_INIT_ALBUMS, self.on_mcg_init_albums)
self._mcg.connect_signal(client.Client.SIGNAL_LOAD_ALBUMS, self.on_mcg_load_albums)
self._mcg.connect_signal(client.Client.SIGNAL_LOAD_ALBUMART, self.on_mcg_load_albumart)
self._mcg.connect_signal(client.Client.SIGNAL_CUSTOM, self.on_mcg_custom)
self._mcg.connect_signal(client.Client.SIGNAL_ERROR, self.on_mcg_error)
self._settings.connect('changed::'+Window.SETTING_PANEL, self.on_settings_panel_changed)
self._settings.connect('changed::'+Window.SETTING_ITEM_SIZE, self.on_settings_item_size_changed)
self._settings.connect('changed::'+Window.SETTING_SORT_ORDER, self.on_settings_sort_order_changed)
self._settings.connect('changed::'+Window.SETTING_SORT_TYPE, self.on_settings_sort_type_changed)
self._settings.bind(Window.SETTING_WINDOW_WIDTH, self._state, WindowState.WIDTH, Gio.SettingsBindFlags.DEFAULT)
self._settings.bind(Window.SETTING_WINDOW_HEIGHT, self._state, WindowState.HEIGHT, Gio.SettingsBindFlags.DEFAULT)
self._settings.bind(Window.SETTING_WINDOW_MAXIMIZED, self._state, WindowState.IS_MAXIMIZED, Gio.SettingsBindFlags.DEFAULT)
# Actions
self.set_default_size(self._state.width, self._state.height)
if self._state.get_property(WindowState.IS_MAXIMIZED):
self.maximize()
self.show_all()
self.content_stack.set_visible_child(self._connection_panel)
if self._settings.get_boolean(Window.SETTING_CONNECTED):
self._connect()
# Menu actions
self._connect_action = Gio.SimpleAction.new_stateful("connect", None, GLib.Variant.new_boolean(False))
self._connect_action.connect('change-state', self.on_menu_connect)
self.add_action(self._connect_action)
self._play_action = Gio.SimpleAction.new_stateful("play", None, GLib.Variant.new_boolean(False))
self._play_action.set_enabled(False)
self._play_action.connect('change-state', self.on_menu_play)
self.add_action(self._play_action)
self._clear_playlist_action = Gio.SimpleAction.new("clear-playlist", None)
self._clear_playlist_action.set_enabled(False)
self._clear_playlist_action.connect('activate', self.on_menu_clear_playlist)
self.add_action(self._clear_playlist_action)
panel_variant = GLib.Variant.new_string("0")
self._panel_action = Gio.SimpleAction.new_stateful("panel", panel_variant.get_type(), panel_variant)
self._panel_action.set_enabled(False)
self._panel_action.connect('change-state', self.on_menu_panel)
self.add_action(self._panel_action)
self._toggle_fullscreen_action = Gio.SimpleAction.new("toggle-fullscreen", None)
self._toggle_fullscreen_action.set_enabled(True)
self._toggle_fullscreen_action.connect('activate', self.on_menu_toggle_fullscreen)
self.add_action(self._toggle_fullscreen_action)
self._search_library_action = Gio.SimpleAction.new("search-library", None)
self._search_library_action.set_enabled(True)
self._search_library_action.connect('activate', self.on_menu_search_library)
self.add_action(self._search_library_action)
# Menu callbacks
def on_menu_connect(self, action, value):
self._connect()
def on_menu_play(self, action, value):
self._mcg.playpause()
def on_menu_clear_playlist(self, action, value):
self._mcg.clear_playlist()
def on_menu_panel(self, action, value):
action.set_state(value)
self.panel_stack.set_visible_child(self._panels[int(value.get_string())])
def on_menu_toggle_fullscreen(self, action, value):
self.panel_stack.set_visible_child(self._cover_panel)
if not self._state.get_property(WindowState.IS_FULLSCREENED):
self.fullscreen()
else:
self.unfullscreen()
def on_menu_search_library(self, action, value):
self.panel_stack.set_visible_child(self._library_panel)
self._library_panel.show_search()
# Window callbacks
@Gtk.Template.Callback()
def on_resize(self, widget, event):
if not self._state.get_property(WindowState.IS_MAXIMIZED):
size = self.get_size()
self._state.set_property(WindowState.WIDTH, size.width)
self._state.set_property(WindowState.HEIGHT, size.height)
@Gtk.Template.Callback()
def on_state(self, widget, state):
self._state.set_property(WindowState.IS_MAXIMIZED, (state.new_window_state & Gdk.WindowState.MAXIMIZED > 0))
self._fullscreen((state.new_window_state & Gdk.WindowState.FULLSCREEN > 0))
# HeaderBar callbacks
@Gtk.Template.Callback()
def on_headerbar_connection_active_notify(self, widget, status):
self._connect()
@Gtk.Template.Callback()
def on_headerbar_connection_state_set(self, widget, state):
return True
@Gtk.Template.Callback()
def on_headerbar_volume_press(self, widget, active):
self._changing_volume = active
@Gtk.Template.Callback()
def on_headerbar_volume_release(self, widget, active):
self._changing_volume = active
@Gtk.Template.Callback()
def on_headerbar_playpause_toggled(self, widget):
self._mcg.playpause()
self._mcg.get_status()
@Gtk.Template.Callback()
def on_headerbar_volume_changed(self, widget, value):
if not self._setting_volume:
self._mcg.set_volume(int(value*100))
# Infobar callback
@Gtk.Template.Callback()
def on_info_bar_close(self, *args):
self.info_revealer.set_reveal_child(False)
@Gtk.Template.Callback()
def on_info_bar_response(self, widget, response):
self.info_revealer.set_reveal_child(False)
# Panel callbacks
def on_stack_switched(self, widget, prop):
self._set_visible_toolbar()
self._save_visible_panel()
self._set_menu_visible_panel()
for panel in self._panels:
panel.set_selected(panel == self.panel_stack.get_visible_child())
GObject.idle_add(
self.panel_stack.child_set_property,
self.panel_stack.get_visible_child(),
'needs-attention',
False
)
def on_panel_open_standalone(self, panel):
self.set_titlebar(panel.get_headerbar_standalone())
def on_panel_close_standalone(self, headerbar):
self.set_titlebar(self.headerbar)
def on_connection_panel_connection_changed(self, widget, host, port, password):
self._settings.set_string(Window.SETTING_HOST, host)
self._settings.set_int(Window.SETTING_PORT, port)
if use_keyring:
if password:
keyring.set_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME, password)
else:
if keyring.get_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME):
keyring.delete_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME)
def on_playlist_panel_clear_playlist(self, widget):
self._mcg.clear_playlist()
def on_playlist_panel_remove(self, widget, album):
self._mcg.remove_album_from_playlist(album)
def on_playlist_panel_remove_multiple(self, widget, albums):
self._mcg.remove_albums_from_playlist(albums)
def on_playlist_panel_play(self, widget, album):
self._mcg.play_album_from_playlist(album)
def on_playlist_panel_albumart(self, widget, album):
self._mcg.get_albumart(album)
def on_server_panel_output_device_changed(self, widget, device, enabled):
self._mcg.enable_output_device(device, enabled)
def on_cover_panel_toggle_fullscreen(self, widget):
if not self._state.get_property(WindowState.IS_FULLSCREENED):
self.fullscreen()
else:
self.unfullscreen()
def on_cover_panel_set_song(self, widget, pos, time):
self._mcg.seek(pos, time)
def on_cover_panel_albumart(self, widget, album):
self._mcg.get_albumart(album)
def on_library_panel_update(self, widget):
self._mcg.update()
def on_library_panel_play(self, widget, album):
self._mcg.play_album(album)
def on_library_panel_queue(self, widget, album):
self._mcg.queue_album(album)
def on_library_panel_queue_multiple(self, widget, albums):
self._mcg.queue_albums(albums)
def on_library_panel_item_size_changed(self, widget, size):
self._playlist_panel.set_item_size(size)
self._settings.set_int(Window.SETTING_ITEM_SIZE, self._library_panel.get_item_size())
def on_library_panel_sort_order_changed(self, widget, sort_order):
self._settings.set_enum(Window.SETTING_SORT_ORDER, self._library_panel.get_sort_order())
def on_library_panel_sort_type_changed(self, widget, sort_type):
self._settings.set_boolean(Window.SETTING_SORT_TYPE, self._library_panel.get_sort_type())
def on_library_panel_albumart(self, widget, album):
self._mcg.get_albumart(album)
# MCG callbacks
def on_mcg_connect(self, connected):
if connected:
GObject.idle_add(self._connect_connected)
self._mcg.load_playlist()
self._mcg.load_albums()
self._mcg.get_custom(Window._CUSTOM_STARTUP_COMPLETE)
self._mcg.get_status()
self._mcg.get_stats()
self._mcg.get_output_devices()
self._connect_action.set_state(GLib.Variant.new_boolean(True))
self._play_action.set_enabled(True)
self._clear_playlist_action.set_enabled(True)
self._panel_action.set_enabled(True)
else:
GObject.idle_add(self._connect_disconnected)
self._connect_action.set_state(GLib.Variant.new_boolean(False))
self._play_action.set_enabled(False)
self._clear_playlist_action.set_enabled(False)
self._panel_action.set_enabled(False)
def on_mcg_status(self, state, album, pos, time, volume, file, audio, bitrate, error):
# Album
GObject.idle_add(self._cover_panel.set_album, album)
if not album and self._state.get_property(WindowState.IS_FULLSCREENED):
self._fullscreen(False)
# State
if state == 'play':
GObject.idle_add(self._set_play)
GObject.idle_add(self._cover_panel.set_play, pos, time)
self._play_action.set_state(GLib.Variant.new_boolean(True))
elif state == 'pause' or state == 'stop':
GObject.idle_add(self._set_pause)
GObject.idle_add(self._cover_panel.set_pause)
self._play_action.set_state(GLib.Variant.new_boolean(False))
# Volume
GObject.idle_add(self._set_volume, volume)
# Status
self._server_panel.set_status(file, audio, bitrate, error)
# Error
if error is None:
self.info_revealer.set_reveal_child(False)
else:
self._show_error(error)
def on_mcg_stats(self, artists, albums, songs, dbplaytime, playtime, uptime):
self._server_panel.set_stats(artists, albums, songs, dbplaytime, playtime, uptime)
def on_mcg_load_output_devices(self, devices):
self._server_panel.set_output_devices(devices)
def on_mcg_load_playlist(self, playlist):
self._playlist_panel.set_playlist(self._connection_panel.get_host(), playlist)
def on_mcg_init_albums(self):
GObject.idle_add(self._library_panel.init_albums)
def on_mcg_pulse_albums(self):
GObject.idle_add(self._library_panel.load_albums)
def on_mcg_load_albums(self, albums):
self._library_panel.set_albums(self._connection_panel.get_host(), albums)
def on_mcg_load_albumart(self, album, data):
self._cover_panel.set_albumart(album, data)
self._playlist_panel.set_albumart(album, data)
self._library_panel.set_albumart(album, data)
def on_mcg_custom(self, name):
if name == Window._CUSTOM_STARTUP_COMPLETE:
for panel in self._panels:
GObject.idle_add(
self.panel_stack.child_set_property,
panel,
'needs-attention',
False
)
def on_mcg_error(self, error):
GObject.idle_add(self._show_error, str(error))
# Settings callbacks
def on_settings_panel_changed(self, settings, key):
panel_index = settings.get_int(key)
self.panel_stack.set_visible_child(self._panels[panel_index])
def on_settings_item_size_changed(self, settings, key):
size = settings.get_int(key)
self._playlist_panel.set_item_size(size)
self._library_panel.set_item_size(size)
def on_settings_sort_order_changed(self, settings, key):
sort_order = settings.get_enum(key)
self._library_panel.set_sort_order(sort_order)
def on_settings_sort_type_changed(self, settings, key):
sort_type = settings.get_boolean(key)
self._library_panel.set_sort_type(sort_type)
# Private methods
def _connect(self):
self._connection_panel.set_sensitive(False)
self._set_headerbar_sensitive(False, True)
if self._mcg.is_connected():
self._mcg.disconnect()
self._settings.set_boolean(Window.SETTING_CONNECTED, False)
else:
host = self._connection_panel.get_host()
port = self._connection_panel.get_port()
password = self._connection_panel.get_password()
self._mcg.connect(host, port, password)
self._settings.set_boolean(Window.SETTING_CONNECTED, True)
def _connect_connected(self):
self._headerbar_connected()
self._set_headerbar_sensitive(True, False)
self.content_stack.set_visible_child(self.panel_stack)
self.panel_stack.set_visible_child(self._panels[self._settings.get_int(Window.SETTING_PANEL)])
def _connect_disconnected(self):
self._playlist_panel.stop_threads();
self._library_panel.stop_threads();
self._headerbar_disconnected()
self._set_headerbar_sensitive(False, False)
self._save_visible_panel()
self.content_stack.set_visible_child(self._connection_panel)
self._connection_panel.set_sensitive(True)
def _fullscreen(self, fullscreened_new):
if fullscreened_new != self._state.get_property(WindowState.IS_FULLSCREENED):
self._state.set_property(WindowState.IS_FULLSCREENED, fullscreened_new)
if self._state.get_property(WindowState.IS_FULLSCREENED):
self.headerbar.hide()
self._cover_panel.set_fullscreen(True)
# Hide cursor
self.get_window().set_cursor(
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none")
)
else:
self.headerbar.show()
self._cover_panel.set_fullscreen(False)
# Reset cursor
self.get_window().set_cursor(
Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "default")
)
def _save_visible_panel(self):
panel_index_selected = self._panels.index(self.panel_stack.get_visible_child())
self._settings.set_int(Window.SETTING_PANEL, panel_index_selected)
def _set_menu_visible_panel(self):
panel_index_selected = self._panels.index(self.panel_stack.get_visible_child())
self._panel_action.set_state(GLib.Variant.new_string(str(panel_index_selected)))
def _set_visible_toolbar(self):
panel_index_selected = self._panels.index(self.panel_stack.get_visible_child())
toolbar = self._panels[panel_index_selected].get_toolbar()
self.toolbar_stack.set_visible_child(toolbar)
def _set_play(self):
self.headerbar_button_playpause.handler_block_by_func(
self.on_headerbar_playpause_toggled
)
self.headerbar_button_playpause.set_active(True)
self.headerbar_button_playpause.handler_unblock_by_func(
self.on_headerbar_playpause_toggled
)
def _set_pause(self):
self.headerbar_button_playpause.handler_block_by_func(
self.on_headerbar_playpause_toggled
)
self.headerbar_button_playpause.set_active(False)
self.headerbar_button_playpause.handler_unblock_by_func(
self.on_headerbar_playpause_toggled
)
def _set_volume(self, volume):
if volume >= 0:
self.headerbar_button_volume.set_visible(True)
if not self._changing_volume:
self._setting_volume = True
self.headerbar_button_volume.set_value(volume / 100)
self._setting_volume = False
else:
self.headerbar_button_volume.set_visible(False)
def _headerbar_connected(self):
self.headerbar_button_connect.handler_block_by_func(
self.on_headerbar_connection_active_notify
)
self.headerbar_button_connect.set_active(True)
self.headerbar_button_connect.set_state(True)
self.headerbar_button_connect.handler_unblock_by_func(
self.on_headerbar_connection_active_notify
)
self.headerbar_title_stack.set_visible_child(self.headerbar_panel_switcher)
def _headerbar_disconnected(self):
self.headerbar_button_connect.handler_block_by_func(
self.on_headerbar_connection_active_notify
)
self.headerbar_button_connect.set_active(False)
self.headerbar_button_connect.set_state(False)
self.headerbar_button_connect.handler_unblock_by_func(
self.on_headerbar_connection_active_notify
)
self.headerbar_title_stack.set_visible_child(self.headerbar_connection_label)
def _set_headerbar_sensitive(self, sensitive, connecting):
self.headerbar_button_playpause.set_sensitive(sensitive)
self.headerbar_button_volume.set_sensitive(sensitive)
self.headerbar_panel_switcher.set_sensitive(sensitive)
self.headerbar_button_connect.set_sensitive(not connecting)
def _show_error(self, message):
self.info_bar.set_message_type(Gtk.MessageType.ERROR)
self.info_label.set_text(message)
self.info_revealer.set_reveal_child(True)

View file

@ -1,66 +0,0 @@
#!/usr/bin/env python3
import gi
try:
gi.require_version('Avahi', '0.6')
from gi.repository import Avahi
use_avahi = True
except:
use_avahi = False
import logging
from mcg import client
class ZeroconfProvider(client.Base):
KEYRING_SYSTEM = 'mcg'
KEYRING_USERNAME = 'mpd'
SIGNAL_SERVICE_NEW = 'service-new'
TYPE = '_mpd._tcp'
def __init__(self):
client.Base.__init__(self)
self._service_resolvers = []
self._services = {}
self._logger = logging.getLogger(__name__)
# Client
if use_avahi:
self._start_client()
def on_new_service(self, browser, interface, protocol, name, type, domain, flags):
#if not (flags & Avahi.LookupResultFlags.GA_LOOKUP_RESULT_LOCAL):
service_resolver = Avahi.ServiceResolver(interface=interface, protocol=protocol, name=name, type=type, domain=domain, aprotocol=Avahi.Protocol.GA_PROTOCOL_UNSPEC, flags=0,)
service_resolver.connect('found', self.on_found)
service_resolver.connect('failure', self.on_failure)
service_resolver.attach(self._client)
self._service_resolvers.append(service_resolver)
def on_found(self, resolver, interface, protocol, name, type, domain, host, date, port, *args):
if (host, port) not in self._services.keys():
service = (name,host,port)
self._services[(host,port)] = service
self._callback(ZeroconfProvider.SIGNAL_SERVICE_NEW, service)
def on_failure(self, resolver, date):
if resolver in self._service_resolvers:
self._service_resolvers.remove(resolver)
def _start_client(self):
self._logger.info("Starting Avahi client")
self._client = Avahi.Client(flags=0,)
try:
self._client.start()
# Browser
self._service_browser = Avahi.ServiceBrowser(domain='local', flags=0, interface=-1, protocol=Avahi.Protocol.GA_PROTOCOL_UNSPEC, type=ZeroconfProvider.TYPE)
self._service_browser.connect('new_service', self.on_new_service)
self._service_browser.attach(self._client)
except Exception as e:
self._logger.info(e)

1
po/LINGUAS Normal file
View file

@ -0,0 +1 @@
de

21
po/POTFILES Normal file
View file

@ -0,0 +1,21 @@
data/ui/album-headerbar.ui
data/ui/connection-panel.ui
data/ui/cover-panel.ui
data/ui/library-panel.ui
data/ui/playlist-panel.ui
data/ui/server-panel.ui
data/ui/shortcuts-dialog.ui
data/ui/window.ui
src/albumheaderbar.py
src/application.py
src/client.py
src/connectionpanel.py
src/coverpanel.py
src/librarypanel.py
src/main.py
src/playlistpanel.py
src/serverpanel.py
src/shortcutsdialog.py
src/utils.py
src/window.py
src/zeroconf.py

BIN
po/de.mo Normal file

Binary file not shown.

334
po/de.po Normal file
View file

@ -0,0 +1,334 @@
msgid ""
msgstr ""
"Project-Id-Version: CoverGrid (mcg)\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-26 16:51+0200\n"
"PO-Revision-Date: 2026-09-26 16:56+0200\n"
"Last-Translator: coderkun <olli@suruatoel.xyz>\n"
"Language-Team: \n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 3.9\n"
"X-Poedit-Basepath: ../../..\n"
"X-Poedit-SourceCharset: UTF-8\n"
#: internal/common/album.go:93 internal/gui/coverpanel.go:290
#, go-format
msgid "%d:%d minutes"
msgstr "%d:%d Minuten"
#: internal/gui/application.go:108
msgid ""
"CoverGrid is a client for the Music Player Daemon, focusing on albums "
"instead of single tracks."
msgstr ""
"CoverGrid ist ein ein Client für den Music Player Daemon, der sich auf Alben "
"anstellen von einzelnen Songs fokussiert."
#: internal/gui/coverpanel.go:277
#, go-format
msgid "%s feat. %s"
msgstr "%s mit %s"
#: internal/gui/librarypanel.go:551
msgid "Loading images"
msgstr "Bilder werden geladen"
#: internal/gui/window.go:117
msgid "Server"
msgstr "Server"
#: internal/gui/window.go:118
msgid "Cover"
msgstr "Cover"
#: internal/gui/window.go:119
msgid "Playlist"
msgstr "Wiedergabeliste"
#: internal/gui/window.go:120
msgid "Library"
msgstr "Bibliothek"
#: data/ui/connection-panel.ui:26
msgid "Host"
msgstr "Host"
#: data/ui/connection-panel.ui:38
msgid "Port"
msgstr "Port"
#: data/ui/connection-panel.ui:57
msgid "Password"
msgstr "Passwort"
#: data/ui/cover-panel.ui:12 data/ui/shortcuts-dialog.ui:88
msgid "Show the cover in fullscreen mode"
msgstr "Das Cover im Vollbildmodus anzeigen"
#: data/ui/library-panel.ui:33
msgid "update library"
msgstr "Die Bibliothek aktualisieren"
#: data/ui/library-panel.ui:46
msgid "Sort"
msgstr "Sortierung"
#: data/ui/library-panel.ui:51
msgid "sort by artist"
msgstr "nach Künstler"
#: data/ui/library-panel.ui:58
msgid "sort by title"
msgstr "nach Titel"
#: data/ui/library-panel.ui:65
msgid "sort by year"
msgstr "nach Jahr"
#: data/ui/library-panel.ui:71
msgid "sort by modification"
msgstr "nach Änderungsdatum"
#: data/ui/library-panel.ui:78
msgid "sort library descending"
msgstr "absteigend sortieren"
#: data/ui/library-panel.ui:98 data/ui/shortcuts-dialog.ui:99
msgid "Search the library"
msgstr "Die Bibliothek durchsuchen"
#: data/ui/library-panel.ui:111 data/ui/playlist-panel.ui:14
msgid "Select multiple albums"
msgstr "Mehrere Alben auswählen"
#: data/ui/library-panel.ui:124
msgid "Settings and actions"
msgstr "Einstellungen und Aktionen"
#: data/ui/library-panel.ui:149
msgid "search library"
msgstr "Bibliothek durchsuchen"
#: data/ui/library-panel.ui:216 data/ui/playlist-panel.ui:69
msgid "cancel"
msgstr "abbrechen"
#: data/ui/library-panel.ui:222 data/ui/library-panel.ui:277
msgid "queue"
msgstr "einreihen"
#: data/ui/library-panel.ui:271 data/ui/playlist-panel.ui:123
msgid "play"
msgstr "abspielen"
#: data/ui/playlist-panel.ui:27 data/ui/shortcuts-dialog.ui:77
msgid "Clear the playlist"
msgstr "Die Wiedergabeliste leeren"
#: data/ui/playlist-panel.ui:75 data/ui/playlist-panel.ui:129
msgid "remove"
msgstr "entfernen"
#: data/ui/server-panel.ui:35
msgid "Status"
msgstr "Status"
#: data/ui/server-panel.ui:49
msgid "File:"
msgstr "Datei:"
#: data/ui/server-panel.ui:60
msgid "Audio:"
msgstr "Audio:"
#: data/ui/server-panel.ui:71
msgid "Bitrate:"
msgstr "Bitrate:"
#: data/ui/server-panel.ui:82
msgid "Error:"
msgstr "Fehler:"
#: data/ui/server-panel.ui:92 data/ui/server-panel.ui:106
#: data/ui/server-panel.ui:120 data/ui/server-panel.ui:134
msgid "<i>none</i>"
msgstr "<i>nichts</i>"
#: data/ui/server-panel.ui:154
msgid "Statistics"
msgstr "Statistiken"
#: data/ui/server-panel.ui:178
msgid "Artists"
msgstr "Künstler"
#: data/ui/server-panel.ui:198
msgid "Albums"
msgstr "Alben"
#: data/ui/server-panel.ui:218
msgid "Songs"
msgstr "Songs"
#: data/ui/server-panel.ui:238
msgid "Seconds"
msgstr "Sekunden"
#: data/ui/server-panel.ui:267
msgid "Seconds played"
msgstr "Sekunden gespielt"
#: data/ui/server-panel.ui:287
msgid "Seconds running"
msgstr "Sekunden laufend"
#: data/ui/server-panel.ui:303
msgid "Audio Devices"
msgstr "Audiogeräte"
#: data/ui/shortcuts-dialog.ui:14
msgid "General"
msgstr "Allgemein"
#: data/ui/shortcuts-dialog.ui:18
msgid "Switch to the Connection panel"
msgstr "Zum Verbindungspaneel wechseln"
#: data/ui/shortcuts-dialog.ui:24
msgid "Switch to the Cover panel"
msgstr "Zum Cover-Paneel wechseln"
#: data/ui/shortcuts-dialog.ui:30
msgid "Switch to the Playlist panel"
msgstr "Zum Wiedergabelistenpaneel wechseln"
#: data/ui/shortcuts-dialog.ui:36
msgid "Switch to the Library panel"
msgstr "Zum Bibliothekspaneel wechseln"
#: data/ui/shortcuts-dialog.ui:42
msgid "Show the keyboard shortcuts"
msgstr "Die Tastenkombinationen anzeigen (dieser Dialog)"
#: data/ui/shortcuts-dialog.ui:48
msgid "Open the info dialog"
msgstr "Den Infodialog öffnen"
#: data/ui/shortcuts-dialog.ui:54
msgid "Quit the application"
msgstr "Die Anwendung beenden"
#: data/ui/shortcuts-dialog.ui:61
msgid "Player"
msgstr "Wiedergabeprogramm"
#: data/ui/shortcuts-dialog.ui:65
msgid "Connect or disconnect"
msgstr "Die Verbindung herstellen oder trennen"
#: data/ui/shortcuts-dialog.ui:71 data/ui/window.ui:26
msgid "Switch between play and pause"
msgstr "Zwischen Abspielen und Pause wechseln"
#: data/ui/shortcuts-dialog.ui:84
msgid "Cover Panel"
msgstr "Cover-Paneel"
#: data/ui/shortcuts-dialog.ui:95
msgid "Library Panel"
msgstr "Bibliothekspaneel"
#~ msgid "No service found"
#~ msgstr "Keine Dienste gefunden"
#~ msgid "use"
#~ msgstr "verwenden"
#~ msgid "Loading albums"
#~ msgstr "Alben werden geladen"
#, fuzzy
#~ msgid "Connection state"
#~ msgstr "Verbindung"
#~ msgid "Title"
#~ msgstr "Titel"
#~ msgid "Artist"
#~ msgstr "Künstler"
#~ msgid "Enter hostname or IP address"
#~ msgstr "Hostnamen oder IP-Adresse eingeben"
#~ msgid "Enter password or leave blank"
#~ msgstr "Passwort eingeben oder leer lassen"
#~ msgid "Connect"
#~ msgstr "Verbinden"
#~ msgid "Play"
#~ msgstr "Abspielen"
#~ msgid "Clear Playlist"
#~ msgstr "Playlist leeren"
#~ msgid "Toggle Fullscreen"
#~ msgstr "Vollbild wechseln"
#~ msgid "Search Library"
#~ msgstr "Bibliothek durchsuchen"
#~ msgid "Connection"
#~ msgstr "Verbindung"
#~ msgid "Keyboard Shortcuts"
#~ msgstr "Tastenkombinationen"
#~ msgid "Info"
#~ msgstr "Info"
#~ msgid "Quit"
#~ msgstr "Beenden"
#~ msgid "Connect to MPD"
#~ msgstr "Zu MPD verbinden"
#~ msgid "Adjust the volume"
#~ msgstr "Die Lautstärke anpassen"
#~ msgid "Enter URL or local path"
#~ msgstr "URL oder lokalen Pfad eingeben"
#~ msgid "Image Directory:"
#~ msgstr "Bildordner:"
#~ msgid "{} of {} images loaded"
#~ msgstr "{} von {} Bildern geladen"
#~ msgid "Tracklist"
#~ msgstr "Titelliste"
#~ msgid "large tracklist"
#~ msgstr "große Titelliste"
#~ msgid "small tracklist"
#~ msgstr "kleine Titelliste"
#~ msgid "hide tracklist"
#~ msgstr "gar keine Titelliste"
#~ msgid "_Play"
#~ msgstr "_Abspielen"
#~ msgid "_Keyboard Shortcuts"
#~ msgstr "Tastenkombinationen"
#~ msgid "_Quit"
#~ msgstr "_Beenden"
#~ msgid "Show the keyboard shortcuts (this dialog)"
#~ msgstr "Die Tastenkombinationen anzeigen (dieser Dialog)"

BIN
po/en.mo Normal file

Binary file not shown.

View file

@ -1,101 +1,27 @@
msgid ""
msgstr ""
"Project-Id-Version: CoverGrid (mcg)\n"
"POT-Creation-Date: 2020-10-24 14:39+0200\n"
"PO-Revision-Date: 2020-10-24 14:40+0200\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-26 16:51+0200\n"
"PO-Revision-Date: 2026-09-26 16:55+0200\n"
"Last-Translator: coderkun <olli@suruatoel.xyz>\n"
"Language-Team: \n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.1\n"
"X-Poedit-Basepath: ../../..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 3.9\n"
"X-Poedit-Basepath: ../../..\n"
"X-Poedit-SearchPath-0: mcg\n"
"X-Poedit-SearchPath-1: data/ui\n"
#: data/ui/album-headerbar.ui:17
msgid "Title"
msgstr "Title"
#: internal/common/album.go:93 internal/gui/coverpanel.go:290
#, go-format
msgid "%d:%d minutes"
msgstr "%d:%d minutes"
#: data/ui/album-headerbar.ui:34
msgid "Artist"
msgstr "Artist"
#: data/ui/connection-panel.ui:75
msgid "Enter hostname or IP address"
msgstr "Enter hostname or IP address"
#: data/ui/connection-panel.ui:88
msgid "Enter password or leave blank"
msgstr "Enter password or leave blank"
#: data/ui/connection-panel.ui:117
msgid "Host:"
msgstr "Host:"
#: data/ui/connection-panel.ui:129
msgid "Port:"
msgstr "Port:"
#: data/ui/connection-panel.ui:141
msgid "Password:"
msgstr "Password:"
#: data/ui/cover-toolbar.ui:15 data/ui/shortcuts-dialog.ui:101
msgid "Show the cover in fullscreen mode"
msgstr "Show the cover in fullscreen mode"
#: data/ui/gtk.menu.ui:7
msgid "Connect"
msgstr "Connect"
#: data/ui/gtk.menu.ui:12
msgid "Play"
msgstr "Play"
#: data/ui/gtk.menu.ui:17
msgid "Clear Playlist"
msgstr "Clear Playlist"
#: data/ui/gtk.menu.ui:22
msgid "Toggle Fullscreen"
msgstr "Toggle fullscreen"
#: data/ui/gtk.menu.ui:27
msgid "Search Library"
msgstr "Search Library"
#: data/ui/gtk.menu.ui:34
msgid "Connection"
msgstr "Connection"
#: data/ui/gtk.menu.ui:40
msgid "Cover"
msgstr "Cover"
#: data/ui/gtk.menu.ui:46
msgid "Playlist"
msgstr "Playlist"
#: data/ui/gtk.menu.ui:52
msgid "Library"
msgstr "Library"
#: data/ui/gtk.menu.ui:60
msgid "Keyboard Shortcuts"
msgstr "Keyboard Shortcuts"
#: data/ui/gtk.menu.ui:65
msgid "Info"
msgstr "Info"
#: data/ui/gtk.menu.ui:70
msgid "Quit"
msgstr "Quit"
#: data/ui/info-dialog.ui:14
#: internal/gui/application.go:108
msgid ""
"CoverGrid is a client for the Music Player Daemon, focusing on albums "
"instead of single tracks."
@ -103,112 +29,165 @@ msgstr ""
"CoverGrid is a client for the Music Player Daemon, focusing on albums "
"instead of single tracks."
#: data/ui/library-panel.ui:27
msgid "search library"
msgstr "search library"
#: internal/gui/coverpanel.go:277
#, go-format
msgid "%s feat. %s"
msgstr "%s feat. %s"
#: data/ui/library-panel.ui:166 data/ui/playlist-panel.ui:57
msgid "cancel"
msgstr "cancel"
#: internal/gui/librarypanel.go:551
msgid "Loading images"
msgstr "Loading images"
#: data/ui/library-panel.ui:179 data/ui/library-panel.ui:264
msgid "queue"
msgstr "queue"
#: internal/gui/window.go:117
msgid "Server"
msgstr "Server"
#: data/ui/library-panel.ui:277 data/ui/playlist-panel.ui:168
msgid "play"
msgstr "play"
#: internal/gui/window.go:118
msgid "Cover"
msgstr "Cover"
#: data/ui/library-toolbar.ui:76
#: internal/gui/window.go:119
msgid "Playlist"
msgstr "Playlist"
#: internal/gui/window.go:120
msgid "Library"
msgstr "Library"
#: data/ui/connection-panel.ui:26
msgid "Host"
msgstr "Host"
#: data/ui/connection-panel.ui:38
msgid "Port"
msgstr "Port"
#: data/ui/connection-panel.ui:57
msgid "Password"
msgstr "Password"
#: data/ui/cover-panel.ui:12 data/ui/shortcuts-dialog.ui:88
msgid "Show the cover in fullscreen mode"
msgstr "Show the cover in fullscreen mode"
#: data/ui/library-panel.ui:33
msgid "update library"
msgstr "update the library"
#: data/ui/library-panel.ui:46
msgid "Sort"
msgstr "Sort order"
#: data/ui/library-toolbar.ui:86
#: data/ui/library-panel.ui:51
msgid "sort by artist"
msgstr "by Artist"
#: data/ui/library-toolbar.ui:102
#: data/ui/library-panel.ui:58
msgid "sort by title"
msgstr "by Title"
#: data/ui/library-toolbar.ui:118
#: data/ui/library-panel.ui:65
msgid "sort by year"
msgstr "by Year"
#: data/ui/library-toolbar.ui:169 data/ui/shortcuts-dialog.ui:114
#: data/ui/library-panel.ui:71
msgid "sort by modification"
msgstr "by Modification Date"
#: data/ui/library-panel.ui:78
msgid "sort library descending"
msgstr "sort descending"
#: data/ui/library-panel.ui:98 data/ui/shortcuts-dialog.ui:99
msgid "Search the library"
msgstr "Search the library"
#: data/ui/library-toolbar.ui:192 data/ui/playlist-toolbar.ui:15
#: data/ui/library-panel.ui:111 data/ui/playlist-panel.ui:14
msgid "Select multiple albums"
msgstr "Select multiple albums"
#: data/ui/library-toolbar.ui:214
#: data/ui/library-panel.ui:124
msgid "Settings and actions"
msgstr "Settings and actions"
#: data/ui/playlist-panel.ui:70 data/ui/playlist-panel.ui:155
msgid "remove"
msgstr "remove"
#: data/ui/library-panel.ui:149
msgid "search library"
msgstr "search library"
#: data/ui/playlist-toolbar.ui:37 data/ui/shortcuts-dialog.ui:88
#: data/ui/library-panel.ui:216 data/ui/playlist-panel.ui:69
msgid "cancel"
msgstr "cancel"
#: data/ui/library-panel.ui:222 data/ui/library-panel.ui:277
msgid "queue"
msgstr "queue"
#: data/ui/library-panel.ui:271 data/ui/playlist-panel.ui:123
msgid "play"
msgstr "play"
#: data/ui/playlist-panel.ui:27 data/ui/shortcuts-dialog.ui:77
msgid "Clear the playlist"
msgstr "Clear the playlist"
#: data/ui/server-panel.ui:68
msgid "File:"
msgstr "File:"
#: data/ui/playlist-panel.ui:75 data/ui/playlist-panel.ui:129
msgid "remove"
msgstr "remove"
#: data/ui/server-panel.ui:81
msgid "Audio:"
msgstr "Audio:"
#: data/ui/server-panel.ui:94
msgid "Bitrate:"
msgstr "Bitrate:"
#: data/ui/server-panel.ui:107
msgid "Error:"
msgstr "Error:"
#: data/ui/server-panel.ui:119 data/ui/server-panel.ui:135
#: data/ui/server-panel.ui:151 data/ui/server-panel.ui:167
msgid "<i>none</i>"
msgstr "<i>none</i>"
#: data/ui/server-panel.ui:208
#: data/ui/server-panel.ui:35
msgid "Status"
msgstr "Status"
#: data/ui/server-panel.ui:287
msgid "Albums"
msgstr "Albums"
#: data/ui/server-panel.ui:49
msgid "File:"
msgstr "File:"
#: data/ui/server-panel.ui:299
msgid "Songs"
msgstr "Songs"
#: data/ui/server-panel.ui:60
msgid "Audio:"
msgstr "Audio:"
#: data/ui/server-panel.ui:311
msgid "Artists"
msgstr "Artists"
#: data/ui/server-panel.ui:71
msgid "Bitrate:"
msgstr "Bitrate:"
#: data/ui/server-panel.ui:335
msgid "Seconds"
msgstr "Seconds"
#: data/ui/server-panel.ui:82
msgid "Error:"
msgstr "Error:"
#: data/ui/server-panel.ui:382
msgid "Seconds played"
msgstr "Seconds"
#: data/ui/server-panel.ui:92 data/ui/server-panel.ui:106
#: data/ui/server-panel.ui:120 data/ui/server-panel.ui:134
msgid "<i>none</i>"
msgstr "<i>none</i>"
#: data/ui/server-panel.ui:393
msgid "Seconds running"
msgstr "Seconds running"
#: data/ui/server-panel.ui:430
#: data/ui/server-panel.ui:154
msgid "Statistics"
msgstr "Statistics"
#: data/ui/server-panel.ui:500
#: data/ui/server-panel.ui:178
msgid "Artists"
msgstr "Artists"
#: data/ui/server-panel.ui:198
msgid "Albums"
msgstr "Albums"
#: data/ui/server-panel.ui:218
msgid "Songs"
msgstr "Songs"
#: data/ui/server-panel.ui:238
msgid "Seconds"
msgstr "Seconds"
#: data/ui/server-panel.ui:267
msgid "Seconds played"
msgstr "Seconds"
#: data/ui/server-panel.ui:287
msgid "Seconds running"
msgstr "Seconds running"
#: data/ui/server-panel.ui:303
msgid "Audio Devices"
msgstr "Audio Devices"
@ -216,92 +195,118 @@ msgstr "Audio Devices"
msgid "General"
msgstr "General"
#: data/ui/shortcuts-dialog.ui:19
#: data/ui/shortcuts-dialog.ui:18
msgid "Switch to the Connection panel"
msgstr "Switch to the Connection panel"
#: data/ui/shortcuts-dialog.ui:26
#: data/ui/shortcuts-dialog.ui:24
msgid "Switch to the Cover panel"
msgstr "Switch to the Cover panel"
#: data/ui/shortcuts-dialog.ui:33
#: data/ui/shortcuts-dialog.ui:30
msgid "Switch to the Playlist panel"
msgstr "Switch to the Playlist panel"
#: data/ui/shortcuts-dialog.ui:40
#: data/ui/shortcuts-dialog.ui:36
msgid "Switch to the Library panel"
msgstr "Switch to the Cover panel"
#: data/ui/shortcuts-dialog.ui:47
#: data/ui/shortcuts-dialog.ui:42
msgid "Show the keyboard shortcuts"
msgstr "Show the keyboard shortcuts (this dialog)"
#: data/ui/shortcuts-dialog.ui:54
#: data/ui/shortcuts-dialog.ui:48
msgid "Open the info dialog"
msgstr "Open the info dialog"
#: data/ui/shortcuts-dialog.ui:61
#: data/ui/shortcuts-dialog.ui:54
msgid "Quit the application"
msgstr "Quit the application"
#: data/ui/shortcuts-dialog.ui:69
#: data/ui/shortcuts-dialog.ui:61
msgid "Player"
msgstr "Player"
#: data/ui/shortcuts-dialog.ui:74 data/ui/window.ui:139
#: data/ui/shortcuts-dialog.ui:65
msgid "Connect or disconnect"
msgstr "Connect or disconnect"
#: data/ui/shortcuts-dialog.ui:81 data/ui/window.ui:161
#: data/ui/shortcuts-dialog.ui:71 data/ui/window.ui:26
msgid "Switch between play and pause"
msgstr "Switch between play and pause"
#: data/ui/shortcuts-dialog.ui:96
#: data/ui/shortcuts-dialog.ui:84
msgid "Cover Panel"
msgstr "Cover Panel"
#: data/ui/shortcuts-dialog.ui:109
#: data/ui/shortcuts-dialog.ui:95
msgid "Library Panel"
msgstr "Library Panel"
#: data/ui/window.ui:108
msgid "Connect to MPD"
msgstr "Connect to MPD"
#~ msgid "No service found"
#~ msgstr "No service found"
#: data/ui/window.ui:181
msgid "Adjust the volume"
msgstr "Adjust the volume"
#~ msgid "use"
#~ msgstr "use"
#: mcg/librarypanel.py:421
msgid "Loading albums"
msgstr "Loading albums"
#: mcg/librarypanel.py:523
msgid "Loading images"
msgstr "Loading images"
#: mcg/utils.py:50 mcg/utils.py:67
msgid "{} feat. {}"
msgstr "{} feat. {}"
#: mcg/utils.py:61
msgid "{}:{} minutes"
msgstr "{}:{} minutes"
#~ msgid "Loading albums"
#~ msgstr "Loading albums"
#, fuzzy
#~| msgid "Connection"
#~ msgid "Connection state"
#~ msgstr "Connection"
#~ msgid "Title"
#~ msgstr "Title"
#~ msgid "Artist"
#~ msgstr "Artist"
#~ msgid "Enter hostname or IP address"
#~ msgstr "Enter hostname or IP address"
#~ msgid "Enter password or leave blank"
#~ msgstr "Enter password or leave blank"
#~ msgid "Connect"
#~ msgstr "Connect"
#~ msgid "Play"
#~ msgstr "Play"
#~ msgid "Clear Playlist"
#~ msgstr "Clear Playlist"
#~ msgid "Toggle Fullscreen"
#~ msgstr "Toggle fullscreen"
#~ msgid "Search Library"
#~ msgstr "Search Library"
#~ msgid "Connection"
#~ msgstr "Connection"
#~ msgid "Keyboard Shortcuts"
#~ msgstr "Keyboard Shortcuts"
#~ msgid "Info"
#~ msgstr "Info"
#~ msgid "Quit"
#~ msgstr "Quit"
#~ msgid "Connect to MPD"
#~ msgstr "Connect to MPD"
#~ msgid "Adjust the volume"
#~ msgstr "Adjust the volume"
#~ msgid "Enter URL or local path"
#~ msgstr "Enter URL or local path"
#~ msgid "Image Directory:"
#~ msgstr "Image Directory:"
#~ msgid "Server"
#~ msgstr "Server"
#~ msgid "{} of {} images loaded"
#~ msgstr "{} of {} images loaded"

243
po/mcg.pot Normal file
View file

@ -0,0 +1,243 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the mcg package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: mcg\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-26 16:51+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#: internal/common/album.go:93 internal/gui/coverpanel.go:290
#, go-format
msgid "%d:%d minutes"
msgstr ""
#: internal/gui/application.go:108
msgid ""
"CoverGrid is a client for the Music Player Daemon, focusing on albums "
"instead of single tracks."
msgstr ""
#: internal/gui/coverpanel.go:277
#, go-format
msgid "%s feat. %s"
msgstr ""
#: internal/gui/librarypanel.go:551
msgid "Loading images"
msgstr ""
#: internal/gui/window.go:117
msgid "Server"
msgstr ""
#: internal/gui/window.go:118
msgid "Cover"
msgstr ""
#: internal/gui/window.go:119
msgid "Playlist"
msgstr ""
#: internal/gui/window.go:120
msgid "Library"
msgstr ""
#: data/ui/connection-panel.ui:26
msgid "Host"
msgstr ""
#: data/ui/connection-panel.ui:38
msgid "Port"
msgstr ""
#: data/ui/connection-panel.ui:57
msgid "Password"
msgstr ""
#: data/ui/cover-panel.ui:12 data/ui/shortcuts-dialog.ui:88
msgid "Show the cover in fullscreen mode"
msgstr ""
#: data/ui/library-panel.ui:33
msgid "update library"
msgstr ""
#: data/ui/library-panel.ui:46
msgid "Sort"
msgstr ""
#: data/ui/library-panel.ui:51
msgid "sort by artist"
msgstr ""
#: data/ui/library-panel.ui:58
msgid "sort by title"
msgstr ""
#: data/ui/library-panel.ui:65
msgid "sort by year"
msgstr ""
#: data/ui/library-panel.ui:71
msgid "sort by modification"
msgstr ""
#: data/ui/library-panel.ui:78
msgid "sort library descending"
msgstr ""
#: data/ui/library-panel.ui:98 data/ui/shortcuts-dialog.ui:99
msgid "Search the library"
msgstr ""
#: data/ui/library-panel.ui:111 data/ui/playlist-panel.ui:14
msgid "Select multiple albums"
msgstr ""
#: data/ui/library-panel.ui:124
msgid "Settings and actions"
msgstr ""
#: data/ui/library-panel.ui:149
msgid "search library"
msgstr ""
#: data/ui/library-panel.ui:216 data/ui/playlist-panel.ui:69
msgid "cancel"
msgstr ""
#: data/ui/library-panel.ui:222 data/ui/library-panel.ui:277
msgid "queue"
msgstr ""
#: data/ui/library-panel.ui:271 data/ui/playlist-panel.ui:123
msgid "play"
msgstr ""
#: data/ui/playlist-panel.ui:27 data/ui/shortcuts-dialog.ui:77
msgid "Clear the playlist"
msgstr ""
#: data/ui/playlist-panel.ui:75 data/ui/playlist-panel.ui:129
msgid "remove"
msgstr ""
#: data/ui/server-panel.ui:35
msgid "Status"
msgstr ""
#: data/ui/server-panel.ui:49
msgid "File:"
msgstr ""
#: data/ui/server-panel.ui:60
msgid "Audio:"
msgstr ""
#: data/ui/server-panel.ui:71
msgid "Bitrate:"
msgstr ""
#: data/ui/server-panel.ui:82
msgid "Error:"
msgstr ""
#: data/ui/server-panel.ui:92 data/ui/server-panel.ui:106
#: data/ui/server-panel.ui:120 data/ui/server-panel.ui:134
msgid "<i>none</i>"
msgstr ""
#: data/ui/server-panel.ui:154
msgid "Statistics"
msgstr ""
#: data/ui/server-panel.ui:178
msgid "Artists"
msgstr ""
#: data/ui/server-panel.ui:198
msgid "Albums"
msgstr ""
#: data/ui/server-panel.ui:218
msgid "Songs"
msgstr ""
#: data/ui/server-panel.ui:238
msgid "Seconds"
msgstr ""
#: data/ui/server-panel.ui:267
msgid "Seconds played"
msgstr ""
#: data/ui/server-panel.ui:287
msgid "Seconds running"
msgstr ""
#: data/ui/server-panel.ui:303
msgid "Audio Devices"
msgstr ""
#: data/ui/shortcuts-dialog.ui:14
msgid "General"
msgstr ""
#: data/ui/shortcuts-dialog.ui:18
msgid "Switch to the Connection panel"
msgstr ""
#: data/ui/shortcuts-dialog.ui:24
msgid "Switch to the Cover panel"
msgstr ""
#: data/ui/shortcuts-dialog.ui:30
msgid "Switch to the Playlist panel"
msgstr ""
#: data/ui/shortcuts-dialog.ui:36
msgid "Switch to the Library panel"
msgstr ""
#: data/ui/shortcuts-dialog.ui:42
msgid "Show the keyboard shortcuts"
msgstr ""
#: data/ui/shortcuts-dialog.ui:48
msgid "Open the info dialog"
msgstr ""
#: data/ui/shortcuts-dialog.ui:54
msgid "Quit the application"
msgstr ""
#: data/ui/shortcuts-dialog.ui:61
msgid "Player"
msgstr ""
#: data/ui/shortcuts-dialog.ui:65
msgid "Connect or disconnect"
msgstr ""
#: data/ui/shortcuts-dialog.ui:71 data/ui/window.ui:26
msgid "Switch between play and pause"
msgstr ""
#: data/ui/shortcuts-dialog.ui:84
msgid "Cover Panel"
msgstr ""
#: data/ui/shortcuts-dialog.ui:95
msgid "Library Panel"
msgstr ""

3
po/meson.build Normal file
View file

@ -0,0 +1,3 @@
i18n = import('i18n')
i18n.gettext(meson.project_name(), preset: 'glib')

115
setup.py
View file

@ -1,115 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import subprocess
from setuptools import setup
from setuptools.command.build_py import build_py
from setuptools.dist import Distribution
class MCGDistribution(Distribution):
global_options = Distribution.global_options + [
("no-compile-schemas", None, "Don't compile gsettings schemas")
]
def __init__(self, *args, **kwargs):
self.no_compile_schemas = False
super(self.__class__, self).__init__(*args, **kwargs)
class build_mcg(build_py):
def run(self, *args, **kwargs):
super(self.__class__, self).run(*args, **kwargs)
self._build_gresources()
if not self.distribution.no_compile_schemas:
self._build_gschemas()
def _build_gresources(self):
print("compiling gresources")
subprocess.run(['glib-compile-resources', 'xyz.suruatoel.mcg.gresource.xml'], cwd='data')
def _build_gschemas(self):
print("compiling gschemas")
subprocess.run(['glib-compile-schemas', 'data'])
setup(
distclass = MCGDistribution,
cmdclass = {
'build_py': build_mcg
},
name = "mcg",
version = '3.0.1',
description = "CoverGrid (mcg) is a client for the Music Player Daemon, focusing on albums instead of single tracks.",
url = "http://www.suruatoel.xyz/codes/mcg",
author = "coderkun",
author_email = "olli@suruatoel.xyz",
license = "GPL",
packages = [
'mcg',
'mcg/data'
],
package_dir = {
'mcg': 'mcg',
'mcg/data': 'data'
},
package_data = {
'mcg': [
'LICENSE',
'README.textile'
],
'mcg/data': [
'xyz.suruatoel.mcg.gresource'
]
},
extras_require = {
'keyring support': ["python-keyring"]
},
entry_points = {
"gui_scripts": [
"mcg = mcg.mcg:main"
]
},
data_files = [
(os.path.join('share', 'applications'), [
"data/mcg.desktop"
]),
(os.path.join('share', 'icons'), [
"data/icons/mcg.svg"
]),
(os.path.join('share', 'glib-2.0', 'schemas'), [
"data/xyz.suruatoel.mcg.gschema.xml"
]),
(os.path.join('share', 'locale', 'en', 'LC_MESSAGES'), [
'locale/en/LC_MESSAGES/mcg.mo'
]),
(os.path.join('share', 'locale', 'de', 'LC_MESSAGES'), [
'locale/de/LC_MESSAGES/mcg.mo'
])
],
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: X11 Applications :: GTK"
"Intended Audience :: End Users/Desktop"
"License :: OSI Approved :: GNU General Public License (GPL)"
"Operating System :: OS Independent"
"Programming Language :: Python :: 3"
"Topic :: Desktop Environment :: Gnome"
"Topic :: Multimedia :: Sound/Audio"
"Topic :: Multimedia :: Sound/Audio :: Players"
]
)