Merge go application port

This commit is contained in:
Olli 2026-08-14 11:57:42 +02:00
commit e49362256d
64 changed files with 4687 additions and 3917 deletions

8
.gitignore vendored
View file

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

26
CONTRIBUTING.md Normal file
View file

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

View file

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

View file

@ -1,5 +1,5 @@
CoverGrid (mcg) is a client for the Music Player Daemon, focusing on albums instead of single tracks. 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 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 it under the terms of the GNU General Public License as published by

View file

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

43
data/data.go Normal file
View file

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

View file

@ -1,6 +1,6 @@
#content_stack { #content_stack {
box-shadow:inset 4px 4px 10px rgba(0,0,0,0.3); box-shadow:inset 4px 4px 10px rgba(0,0,0,0.3);
background-image:url('noise-texture.png'); background-image:url("@NOISETEXTURE@");
} }
#port_spinner { #port_spinner {
@ -30,10 +30,6 @@
font-weight:bold; font-weight:bold;
} }
window.fullscreen #cover_box {
background: black;
}
#cover_info_revealer { #cover_info_revealer {
background-color:alpha(@theme_bg_color, 0.8); background-color:alpha(@theme_bg_color, 0.8);
box-shadow:0 0 10px @theme_bg_color; box-shadow:0 0 10px @theme_bg_color;

View file

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

View file

@ -2,7 +2,7 @@
<interface> <interface>
<requires lib="gtk+" version="4.8"/> <requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" /> <requires lib="adw" version="1.2" />
<template class="McgAlbumHeaderbar" parent="AdwBin"> <object id="McgAlbumHeaderbar" class="AdwBin">
<child> <child>
<object class="GtkHeaderBar"> <object class="GtkHeaderBar">
<child type="title"> <child type="title">
@ -25,9 +25,8 @@
</object> </object>
</child> </child>
<child> <child>
<object class="GtkButton"> <object id="close_button" class="GtkButton">
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_close_clicked" swapped="no"/>
<child> <child>
<object class="GtkImage"> <object class="GtkImage">
<property name="icon-name">go-previous-symbolic</property> <property name="icon-name">go-previous-symbolic</property>
@ -37,5 +36,5 @@
</child> </child>
</object> </object>
</child> </child>
</template> </object>
</interface> </interface>

View file

@ -9,79 +9,58 @@
<property name="step-increment">1</property> <property name="step-increment">1</property>
<property name="page-increment">100</property> <property name="page-increment">100</property>
</object> </object>
<object class="GtkBox" id="toolbar"> <object id="McgConnectionPanel" class="AdwBin">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
</object>
<template class="McgConnectionPanel" parent="AdwBin">
<child> <child>
<object class="AdwStatusPage"> <object class="AdwStatusPage">
<property name="title">Connect to MPD</property> <property name="title">Connect to MPD</property>
<child> <child>
<object class="GtkBox"> <object class="GtkListBox">
<property name="hexpand">false</property> <property name="hexpand">false</property>
<property name="halign">center</property> <property name="halign">center</property>
<property name="selection-mode">none</property>
<style>
<class name="boxed-list"/>
</style>
<child> <child>
<object class="GtkListBox" id="zeroconf_list"> <object class="AdwEntryRow" id="host_row">
<property name="hexpand">true</property> <property name="title" translatable="yes">Host</property>
<child type="placeholder"> <property name="show-apply-button">true</property>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<style>
<class name="header"/>
</style>
<child>
<object class="GtkLabel"> <object class="GtkLabel">
<property name="label" translatable="yes">No service found</property> <property name="label" translatable="yes">Port</property>
<property name="halign">start</property>
<property name="hexpand">false</property>
<style>
<class name="subtitle"/>
</style>
</object>
</child>
<child>
<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> </object>
</child> </child>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkListBox"> <object class="AdwPasswordEntryRow" id="password_row">
<property name="hexpand">true</property> <property name="title" translatable="yes">Password</property>
<property name="selection-mode">none</property> <property name="show-apply-button">true</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>
<signal name="apply" handler="on_host_entry_apply"/>
</object>
</child>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<style>
<class name="header"/>
</style>
<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>
</child>
<child>
<object class="GtkSpinButton" id="port_spinner">
<property name="name">port_spinner</property>
<property name="value">6600</property>
<property name="adjustment">server-port-adjustment</property>
<signal name="value-changed" handler="on_port_spinner_value_changed"/>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPasswordEntryRow" id="password_row">
<property name="title" translatable="yes">Password</property>
<property name="show-apply-button">true</property>
</object>
</child>
</object> </object>
</child> </child>
</object> </object>
</child> </child>
</object> </object>
</child> </child>
</template> </object>
</interface> </interface>

View file

@ -19,7 +19,7 @@
</object> </object>
</child> </child>
</object> </object>
<template class="McgCoverPanel" parent="GtkOverlay"> <object id="McgCoverPanel" class="GtkOverlay">
<child> <child>
<object class="GtkStack" id="cover_stack"> <object class="GtkStack" id="cover_stack">
<property name="visible-child">cover_default</property> <property name="visible-child">cover_default</property>
@ -157,5 +157,5 @@
</child> </child>
</object> </object>
</child> </child>
</template> </object>
</interface> </interface>

View file

@ -26,13 +26,11 @@
<property name="round-digits">0</property> <property name="round-digits">0</property>
<property name="digits">0</property> <property name="digits">0</property>
<property name="has-origin">False</property> <property name="has-origin">False</property>
<signal name="value-changed" handler="on_grid_scale_changed" swapped="no"/>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkButton" id="library-toolbar-update"> <object class="GtkButton" id="library-toolbar-update">
<property name="label" translatable="yes">update library</property> <property name="label" translatable="yes">update library</property>
<signal name="clicked" handler="on_update_clicked" swapped="no"/>
</object> </object>
</child> </child>
<child> <child>
@ -53,7 +51,6 @@
<property name="label" translatable="yes">sort by artist</property> <property name="label" translatable="yes">sort by artist</property>
<property name="receives-default">False</property> <property name="receives-default">False</property>
<property name="group">sort_year</property> <property name="group">sort_year</property>
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
</object> </object>
</child> </child>
<child> <child>
@ -61,14 +58,12 @@
<property name="label" translatable="yes">sort by title</property> <property name="label" translatable="yes">sort by title</property>
<property name="receives-default">False</property> <property name="receives-default">False</property>
<property name="group">sort_year</property> <property name="group">sort_year</property>
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkCheckButton" id="sort_year"> <object class="GtkCheckButton" id="sort_year">
<property name="label" translatable="yes">sort by year</property> <property name="label" translatable="yes">sort by year</property>
<property name="receives-default">False</property> <property name="receives-default">False</property>
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
</object> </object>
</child> </child>
<child> <child>
@ -76,7 +71,6 @@
<property name="label" translatable="yes">sort by modification</property> <property name="label" translatable="yes">sort by modification</property>
<property name="receives-default">False</property> <property name="receives-default">False</property>
<property name="group">sort_year</property> <property name="group">sort_year</property>
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
</object> </object>
</child> </child>
<child> <child>
@ -86,7 +80,6 @@
<property name="can-focus">True</property> <property name="can-focus">True</property>
<property name="receives-default">False</property> <property name="receives-default">False</property>
<property name="active">True</property> <property name="active">True</property>
<signal name="toggled" handler="on_sort_order_toggled" swapped="no"/>
</object> </object>
</child> </child>
</object> </object>
@ -108,9 +101,6 @@
<property name="icon-name">system-search-symbolic</property> <property name="icon-name">system-search-symbolic</property>
</object> </object>
</child> </child>
<!--
<accelerator key="f" signal="activate" modifiers="GDK_CONTROL_MASK"/>
-->
</object> </object>
</child> </child>
<child> <child>
@ -119,7 +109,6 @@
<property name="can-focus">True</property> <property name="can-focus">True</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Select multiple albums</property> <property name="tooltip-text" translatable="yes">Select multiple albums</property>
<signal name="toggled" handler="on_select_toggled" swapped="no"/>
<child> <child>
<object class="GtkImage"> <object class="GtkImage">
<property name="icon-name">object-select-symbolic</property> <property name="icon-name">object-select-symbolic</property>
@ -145,7 +134,7 @@
</child> </child>
</object> </object>
<template class="McgLibraryPanel" parent="AdwBin"> <object id="McgLibraryPanel" class="AdwBin">
<child> <child>
<object class="GtkStack" id="library_stack"> <object class="GtkStack" id="library_stack">
<property name="transition-type">slide-left-right</property> <property name="transition-type">slide-left-right</property>
@ -158,7 +147,6 @@
<child> <child>
<object class="GtkSearchEntry" id="filter_entry"> <object class="GtkSearchEntry" id="filter_entry">
<property name="placeholder-text" translatable="yes">search library</property> <property name="placeholder-text" translatable="yes">search library</property>
<signal name="search-changed" handler="on_filter_entry_changed" swapped="no"/>
</object> </object>
</child> </child>
</object> </object>
@ -206,49 +194,9 @@
<object class="GtkGridView" id="library_grid"> <object class="GtkGridView" id="library_grid">
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<property name="single_click_activate">true</property> <property name="single_click_activate">true</property>
<signal name="activate" handler="on_library_grid_clicked"/>
<style> <style>
<class name="no-bg"/> <class name="no-bg"/>
</style> </style>
<property name="factory">
<object class="GtkBuilderListItemFactory">
<property name="bytes">
<![CDATA[
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="GtkListItem">
<property name="activatable">true</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="vexpand">true</property>
<property name="hexpand">true</property>
<child>
<object class="GtkPicture">
<property name="content-fit">contain</property>
<property name="can-shrink">false</property>
<property name="vexpand">true</property>
<property name="hexpand">true</property>
<binding name="tooltip-markup">
<lookup name="tooltip" type="GridItem">
<lookup name="item">GtkListItem</lookup>
</lookup>
</binding>
<binding name="paintable">
<lookup name="cover" type="GridItem">
<lookup name="item">GtkListItem</lookup>
</lookup>
</binding>
</object>
</child>
</object>
</property>
</template>
</interface>
]]>
</property>
</object>
</property>
</object> </object>
</child> </child>
<style> <style>
@ -264,17 +212,15 @@
<child> <child>
<object class="GtkActionBar" id="actionbar"> <object class="GtkActionBar" id="actionbar">
<child type="end"> <child type="end">
<object class="GtkButton"> <object class="GtkButton" id="selection_cancel_button">
<property name="label" translatable="yes">cancel</property> <property name="label" translatable="yes">cancel</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_selection_cancel_clicked" swapped="no"/>
</object> </object>
</child> </child>
<child type="end"> <child type="end">
<object class="GtkButton"> <object class="GtkButton" id="selection_queue_button">
<property name="label" translatable="yes">queue</property> <property name="label" translatable="yes">queue</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_selection_add_clicked" swapped="no"/>
</object> </object>
</child> </child>
</object> </object>
@ -321,17 +267,15 @@
<child> <child>
<object class="GtkActionBar" id="actionbar_standalone"> <object class="GtkActionBar" id="actionbar_standalone">
<child type="end"> <child type="end">
<object class="GtkButton"> <object class="GtkButton" id="standalone_play_button">
<property name="label" translatable="yes">play</property> <property name="label" translatable="yes">play</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_standalone_play_clicked" swapped="no"/>
</object> </object>
</child> </child>
<child type="end"> <child type="end">
<object class="GtkButton"> <object class="GtkButton" id="standalone_queue_button">
<property name="label" translatable="yes">queue</property> <property name="label" translatable="yes">queue</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_standalone_queue_clicked" swapped="no"/>
</object> </object>
</child> </child>
</object> </object>
@ -340,5 +284,5 @@
</child> </child>
</object> </object>
</child> </child>
</template> </object>
</interface> </interface>

View file

@ -12,7 +12,6 @@
<property name="can-focus">True</property> <property name="can-focus">True</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Select multiple albums</property> <property name="tooltip-text" translatable="yes">Select multiple albums</property>
<signal name="toggled" handler="on_select_toggled" swapped="no"/>
<child> <child>
<object class="GtkImage"> <object class="GtkImage">
<property name="icon-name">object-select-symbolic</property> <property name="icon-name">object-select-symbolic</property>
@ -26,7 +25,6 @@
<property name="can-focus">True</property> <property name="can-focus">True</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Clear the playlist</property> <property name="tooltip-text" translatable="yes">Clear the playlist</property>
<signal name="clicked" handler="on_clear_clicked" swapped="no"/>
<child> <child>
<object class="GtkImage"> <object class="GtkImage">
<property name="visible">True</property> <property name="visible">True</property>
@ -37,7 +35,7 @@
</object> </object>
</child> </child>
</object> </object>
<template class="McgPlaylistPanel" parent="AdwBin"> <object id="McgPlaylistPanel" class="AdwBin">
<child> <child>
<object class="GtkStack" id="playlist_stack"> <object class="GtkStack" id="playlist_stack">
<property name="transition-type">slide-left-right</property> <property name="transition-type">slide-left-right</property>
@ -51,45 +49,9 @@
<object class="GtkGridView" id="playlist_grid"> <object class="GtkGridView" id="playlist_grid">
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<property name="single-click-activate">true</property> <property name="single-click-activate">true</property>
<signal name="activate" handler="on_playlist_grid_clicked"/>
<style> <style>
<class name="no-bg"/> <class name="no-bg"/>
</style> </style>
<property name="factory">
<object class="GtkBuilderListItemFactory">
<property name="bytes">
<![CDATA[
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="GtkListItem">
<property name="activatable">true</property>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkPicture">
<property name="content-fit">contain</property>
<property name="can-shrink">false</property>
<binding name="tooltip-markup">
<lookup name="tooltip" type="GridItem">
<lookup name="item">GtkListItem</lookup>
</lookup>
</binding>
<binding name="paintable">
<lookup name="cover" type="GridItem">
<lookup name="item">GtkListItem</lookup>
</lookup>
</binding>
</object>
</child>
</object>
</property>
</template>
</interface>
]]>
</property>
</object>
</property>
</object> </object>
</child> </child>
<style> <style>
@ -103,17 +65,15 @@
<child> <child>
<object class="GtkActionBar" id="actionbar"> <object class="GtkActionBar" id="actionbar">
<child type="end"> <child type="end">
<object class="GtkButton"> <object class="GtkButton" id="selection_cancel_button">
<property name="label" translatable="yes">cancel</property> <property name="label" translatable="yes">cancel</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_selection_cancel_clicked" swapped="no"/>
</object> </object>
</child> </child>
<child type="end"> <child type="end">
<object class="GtkButton"> <object class="GtkButton" id="selection_remove_button">
<property name="label" translatable="yes">remove</property> <property name="label" translatable="yes">remove</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_selection_remove_clicked" swapped="no"/>
</object> </object>
</child> </child>
</object> </object>
@ -159,17 +119,15 @@
<child> <child>
<object class="GtkActionBar" id="actionbar_standalone"> <object class="GtkActionBar" id="actionbar_standalone">
<child type="end"> <child type="end">
<object class="GtkButton"> <object class="GtkButton" id="standalone_play_button">
<property name="label" translatable="yes">play</property> <property name="label" translatable="yes">play</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_standalone_play_clicked" swapped="no"/>
</object> </object>
</child> </child>
<child type="end"> <child type="end">
<object class="GtkButton"> <object class="GtkButton" id="standalone_remove_button">
<property name="label" translatable="yes">remove</property> <property name="label" translatable="yes">remove</property>
<property name="receives-default">True</property> <property name="receives-default">True</property>
<signal name="clicked" handler="on_standalone_remove_clicked" swapped="no"/>
</object> </object>
</child> </child>
</object> </object>
@ -178,5 +136,5 @@
</child> </child>
</object> </object>
</child> </child>
</template> </object>
</interface> </interface>

View file

@ -13,7 +13,7 @@
</object> </object>
</child> </child>
</object> </object>
<template class="McgServerPanel" parent="AdwBin"> <object id="McgServerPanel" class="AdwBin">
<child> <child>
<object class="AdwFlap" id="server_flap"> <object class="AdwFlap" id="server_flap">
<property name="flap-position">end</property> <property name="flap-position">end</property>
@ -322,5 +322,5 @@
</property> </property>
</object> </object>
</child> </child>
</template> </object>
</interface> </interface>

View file

@ -2,7 +2,7 @@
<interface> <interface>
<requires lib="gtk+" version="4.8"/> <requires lib="gtk+" version="4.8"/>
<requires lib="adw" version="1.2" /> <requires lib="adw" version="1.2" />
<template class="McgShortcutsDialog" parent="GtkShortcutsWindow"> <object id="McgShortcutsDialog" class="GtkShortcutsWindow">
<property name="modal">1</property> <property name="modal">1</property>
<child> <child>
<object class="GtkShortcutsSection"> <object class="GtkShortcutsSection">
@ -103,5 +103,5 @@
</child> </child>
</object> </object>
</child> </child>
</template> </object>
</interface> </interface>

View file

@ -2,7 +2,8 @@
<interface> <interface>
<requires lib="gtk+" version="4.8" /> <requires lib="gtk+" version="4.8" />
<requires lib="adw" version="1.2" /> <requires lib="adw" version="1.2" />
<template class="McgAppWindow" parent="AdwApplicationWindow"> <object id="McgAppWindow" class="AdwApplicationWindow">
<property name="title">CoverGrid</property>
<property name="content"> <property name="content">
<object class="AdwToolbarView" id="toolbar_view"> <object class="AdwToolbarView" id="toolbar_view">
<child type="top"> <child type="top">
@ -17,13 +18,12 @@
</property> </property>
<child> <child>
<object class="GtkSwitch" id="headerbar_button_connect"> <object class="GtkSwitch" id="headerbar_button_connect">
<signal name="state-set" handler="on_headerbar_connection_state_set" swapped="no"/>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkToggleButton" id="headerbar_button_playpause"> <object class="GtkToggleButton" id="headerbar_button_playpause">
<property name="sensitive">false</property>
<property name="tooltip-text" translatable="yes">Switch between play and pause</property> <property name="tooltip-text" translatable="yes">Switch between play and pause</property>
<signal name="toggled" handler="on_headerbar_playpause_toggled" swapped="no"/>
<child> <child>
<object class="GtkImage"> <object class="GtkImage">
<property name="can-focus">False</property> <property name="can-focus">False</property>
@ -34,7 +34,7 @@
</child> </child>
<child> <child>
<object class="GtkVolumeButton" id="headerbar_button_volume"> <object class="GtkVolumeButton" id="headerbar_button_volume">
<signal name="value-changed" handler="on_headerbar_volume_changed" swapped="no"/> <property name="sensitive">false</property>
</object> </object>
</child> </child>
<child type="end"> <child type="end">
@ -44,7 +44,7 @@
</object> </object>
</child> </child>
<property name="content"> <property name="content">
<object class="GtkBox" id="content_box"> <object class="GtkBox">
<property name="orientation">vertical</property> <property name="orientation">vertical</property>
<child> <child>
<object class="AdwToastOverlay" id="info_toast"> <object class="AdwToastOverlay" id="info_toast">
@ -55,7 +55,6 @@
<child> <child>
<object class="AdwViewStack" id="panel_stack"> <object class="AdwViewStack" id="panel_stack">
<property name="vexpand">true</property> <property name="vexpand">true</property>
<signal name="notify::visible-child" handler="on_stack_switched" swapped="no"/>
</object> </object>
</child> </child>
</object> </object>
@ -70,9 +69,13 @@
</binding> </binding>
</object> </object>
</child> </child>
<child>
<object class="GtkDrawingArea" id="resize_helper">
</object>
</child>
</object> </object>
</property> </property>
</object> </object>
</property> </property>
</template> </object>
</interface> </interface>

View file

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

View file

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

View file

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

12
go.mod Normal file
View file

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

6
go.sum Normal file
View file

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

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

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

View file

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

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

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

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

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

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

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

View file

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

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

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

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

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

View file

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

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

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

@ -0,0 +1,333 @@
msgid ""
msgstr ""
"Project-Id-Version: CoverGrid (mcg)\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_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/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"
#~ msgid "Loading images"
#~ msgstr "Bilder 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)"

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,19 +0,0 @@
project('mcg',
version: '4.0.2',
meson_version: '>= 0.59.0',
default_options: [
'warning_level=2',
'werror=false',
],
)
subdir('data')
subdir('src')
subdir('po')
gnome = import('gnome')
gnome.post_install(
glib_compile_schemas: true,
gtk_update_icon_cache: true,
update_desktop_database: true,
)

View file

@ -1 +1 @@
en de de

View file

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: mcg\n" "Project-Id-Version: mcg\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-05-22 14:39+0200\n" "POT-Creation-Date: 2026-08-09 19:13+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,19 +17,47 @@ msgstr ""
"Content-Type: text/plain; charset=CHARSET\n" "Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
#: data/ui/connection-panel.ui:29 #: internal/common/album.go:93 internal/gui/coverpanel.go:290
msgid "No service found" #, go-format
msgid "%d:%d minutes"
msgstr "" msgstr ""
#: data/ui/connection-panel.ui:43 #: 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/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" msgid "Host"
msgstr "" msgstr ""
#: data/ui/connection-panel.ui:56 #: data/ui/connection-panel.ui:38
msgid "Port" msgid "Port"
msgstr "" msgstr ""
#: data/ui/connection-panel.ui:76 #: data/ui/connection-panel.ui:57
msgid "Password" msgid "Password"
msgstr "" msgstr ""
@ -37,67 +65,67 @@ msgstr ""
msgid "Show the cover in fullscreen mode" msgid "Show the cover in fullscreen mode"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:34 #: data/ui/library-panel.ui:33
msgid "update library" msgid "update library"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:48 #: data/ui/library-panel.ui:46
msgid "Sort" msgid "Sort"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:53 #: data/ui/library-panel.ui:51
msgid "sort by artist" msgid "sort by artist"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:61 #: data/ui/library-panel.ui:58
msgid "sort by title" msgid "sort by title"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:69 #: data/ui/library-panel.ui:65
msgid "sort by year" msgid "sort by year"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:76 #: data/ui/library-panel.ui:71
msgid "sort by modification" msgid "sort by modification"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:84 #: data/ui/library-panel.ui:78
msgid "sort library descending" msgid "sort library descending"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:105 data/ui/shortcuts-dialog.ui:99 #: data/ui/library-panel.ui:98 data/ui/shortcuts-dialog.ui:99
msgid "Search the library" msgid "Search the library"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:121 data/ui/playlist-panel.ui:14 #: data/ui/library-panel.ui:111 data/ui/playlist-panel.ui:14
msgid "Select multiple albums" msgid "Select multiple albums"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:135 #: data/ui/library-panel.ui:124
msgid "Settings and actions" msgid "Settings and actions"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:160 #: data/ui/library-panel.ui:149
msgid "search library" msgid "search library"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:263 data/ui/playlist-panel.ui:107 #: data/ui/library-panel.ui:216 data/ui/playlist-panel.ui:69
msgid "cancel" msgid "cancel"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:270 data/ui/library-panel.ui:327 #: data/ui/library-panel.ui:222 data/ui/library-panel.ui:277
msgid "queue" msgid "queue"
msgstr "" msgstr ""
#: data/ui/library-panel.ui:320 data/ui/playlist-panel.ui:163 #: data/ui/library-panel.ui:271 data/ui/playlist-panel.ui:123
msgid "play" msgid "play"
msgstr "" msgstr ""
#: data/ui/playlist-panel.ui:28 data/ui/shortcuts-dialog.ui:77 #: data/ui/playlist-panel.ui:27 data/ui/shortcuts-dialog.ui:77
msgid "Clear the playlist" msgid "Clear the playlist"
msgstr "" msgstr ""
#: data/ui/playlist-panel.ui:114 data/ui/playlist-panel.ui:170 #: data/ui/playlist-panel.ui:75 data/ui/playlist-panel.ui:129
msgid "remove" msgid "remove"
msgstr "" msgstr ""
@ -198,7 +226,7 @@ msgstr ""
msgid "Connect or disconnect" msgid "Connect or disconnect"
msgstr "" msgstr ""
#: data/ui/shortcuts-dialog.ui:71 data/ui/window.ui:25 #: data/ui/shortcuts-dialog.ui:71 data/ui/window.ui:26
msgid "Switch between play and pause" msgid "Switch between play and pause"
msgstr "" msgstr ""
@ -209,39 +237,3 @@ msgstr ""
#: data/ui/shortcuts-dialog.ui:95 #: data/ui/shortcuts-dialog.ui:95
msgid "Library Panel" msgid "Library Panel"
msgstr "" msgstr ""
#: src/connectionpanel.py:51
msgid "use"
msgstr ""
#: src/librarypanel.py:291
msgid "Loading albums"
msgstr ""
#: src/librarypanel.py:379
msgid "Loading images"
msgstr ""
#: src/utils.py:50 src/utils.py:67
msgid "{} feat. {}"
msgstr ""
#: src/utils.py:61
msgid "{}:{} minutes"
msgstr ""
#: src/window.py:114
msgid "Server"
msgstr ""
#: src/window.py:115
msgid "Cover"
msgstr ""
#: src/window.py:116
msgid "Playlist"
msgstr ""
#: src/window.py:117
msgid "Library"
msgstr ""

View file

@ -1,19 +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

View file

@ -1,28 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, GObject, Adw
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/album-headerbar.ui')
class AlbumHeaderbar(Adw.Bin):
__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,108 +0,0 @@
#!/usr/bin/env python3
import logging
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gio, Gtk, Gdk, Adw
from .window import Window
class Application(Gtk.Application):
TITLE = "CoverGrid"
ID = 'xyz.suruatoel.mcg'
DOMAIN = 'mcg'
def __init__(self, version):
super().__init__(application_id=Application.ID,
flags=Gio.ApplicationFlags.FLAGS_NONE)
self._window = None
self._info_dialog = None
self._version = version
self._verbosity = logging.WARNING
self.set_accels_for_action('window.close', ['<primary>q'])
self.set_accels_for_action('win.show-help-overlay', ['<primary>k'])
self.set_accels_for_action('app.info', ['<primary>i'])
self.set_accels_for_action('win.connect', ['<primary>c'])
self.set_accels_for_action('win.play', ['<primary>p'])
self.set_accels_for_action('win.clear-playlist', ['<primary>r'])
self.set_accels_for_action('win.toggle-fullscreen', ['F11'])
self.set_accels_for_action('win.search-library', ['<primary>f'])
self.set_accels_for_action('win.panel("0")', ['<primary>KP_1'])
self.set_accels_for_action('win.panel("1")', ['<primary>KP_2'])
self.set_accels_for_action('win.panel("2")', ['<primary>KP_3'])
self.set_accels_for_action('win.panel("3")', ['<primary>KP_4'])
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._setup_adw()
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):
self._info_dialog = Adw.AboutDialog()
self._info_dialog.set_application_icon("xyz.suruatoel.mcg")
self._info_dialog.set_application_name("CoverGrid")
self._info_dialog.set_version(self._version)
self._info_dialog.set_comments(
"""CoverGrid is a client for the Music Player Daemon, focusing on \
albums instead of single tracks.""")
self._info_dialog.set_website("https://www.suruatoel.xyz/codes/mcg")
self._info_dialog.set_license_type(Gtk.License.GPL_3_0)
self._info_dialog.set_issue_url(
"https://git.suruatoel.xyz/coderkun/mcg")
self._info_dialog.present()
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):
style_manager = Adw.StyleManager.get_default()
style_manager.set_color_scheme(Adw.ColorScheme.PREFER_DARK)
def _load_css(self):
style_provider = Gtk.CssProvider()
style_provider.load_from_resource(self._get_resource_path('gtk.css'))
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(), style_provider,
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 _get_resource_path(self, path):
return "/{}/{}".format(Application.ID.replace('.', '/'), path)
def _setup_adw(self):
Adw.HeaderBar()
Adw.ToolbarView()
Adw.ViewSwitcherTitle()
Adw.ViewSwitcherBar()
Adw.ViewStackPage()
Adw.ToastOverlay()
Adw.StatusPage()
Adw.Flap()
Adw.EntryRow()
Adw.PasswordEntryRow()

File diff suppressed because it is too large Load diff

View file

@ -1,89 +0,0 @@
#!/usr/bin/env python3
import gi
import locale
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, GObject, Adw
from mcg.zeroconf import ZeroconfProvider
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/connection-panel.ui')
class ConnectionPanel(Adw.Bin):
__gtype_name__ = 'McgConnectionPanel'
__gsignals__ = {
'connection-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, int, str))
}
# Widgets
toolbar = Gtk.Template.Child()
zeroconf_list = Gtk.Template.Child()
host_row = Gtk.Template.Child()
port_spinner = Gtk.Template.Child()
password_row = Gtk.Template.Child()
def __init__(self, **kwargs):
super().__init__(**kwargs)
# 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
row_button = Gtk.Button()
row_button.set_label(locale.gettext("use"))
row_button.connect("clicked", self.on_service_selected, host, port)
row = Adw.ActionRow()
row.set_title(name)
row.set_subtitle("{} ({})".format(host, port))
row.add_suffix(row_button)
self.zeroconf_list.insert(row, -1)
def on_service_selected(self, widget, host, port):
self.set_host(host)
self.set_port(port)
@Gtk.Template.Callback()
def on_host_entry_apply(self, widget):
self._call_back()
@Gtk.Template.Callback()
def on_port_spinner_value_changed(self, widget):
self._call_back()
def set_host(self, host):
self.host_row.set_text(host)
def get_host(self):
return self.host_row.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_row.set_text(password)
def get_password(self):
if self.password_row.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,256 +0,0 @@
#!/usr/bin/env python3
import gi
import math
gi.require_version('Gtk', '4.0')
from gi.repository import Gtk, Gdk, GObject, GdkPixbuf
from mcg.utils import Utils
@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
# Toolbar
toolbar = Gtk.Template.Child()
fullscreen_button = Gtk.Template.Child()
# Cover
cover_stack = Gtk.Template.Child()
cover_spinner = Gtk.Template.Child()
cover_default = 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, **kwargs):
super().__init__(**kwargs)
self._current_album = None
self._current_cover_album = None
self._cover_pixbuf = None
self._timer = None
self._properties = {}
self._icon_theme = Gtk.IconTheme.get_for_display(
Gdk.Display.get_default())
self._fullscreened = False
self._current_size = None
# Initial actions
GObject.idle_add(self._enable_tracklist)
# Click handler for image
click_controller = Gtk.GestureClick()
click_controller.connect('pressed', self.on_cover_box_pressed)
self.cover_box.add_controller(click_controller)
# Button controller for songs scale
button_controller = Gtk.GestureClick()
button_controller.connect('pressed', self.on_songs_scale_pressed)
button_controller.connect('unpaired-release',
self.on_songs_scale_released)
self.songs_scale.add_controller(button_controller)
def get_toolbar(self):
return self.toolbar
def set_selected(self, selected):
"""The cover panel does not use selections"""
pass
def on_cover_box_pressed(self, widget, npress, x, y):
if self._current_album and npress == 2:
self.emit('toggle-fullscreen')
def set_width(self, width):
GObject.idle_add(self._resize_image)
self.cover_info_scroll.set_max_content_width(width // 2)
def on_songs_scale_pressed(self, widget, npress, x, y):
if self._timer:
GObject.source_remove(self._timer)
self._timer = None
def on_songs_scale_released(self, widget, x, y, npress, sequence):
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.fullscreen_button.set_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)
GObject.idle_add(self._resize_image)
self._fullscreened = True
else:
self._fullscreened = False
if self._current_album:
self.info_revealer.set_reveal_child(True)
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:
self._logger.exception("Failed to set albumart")
self._cover_pixbuf = None
else:
# Reset image
self._cover_pixbuf = None
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)))
# Align marks
self._align_songs_scale_marks()
def _align_songs_scale_marks(self):
self._align_songs_scale_mark(self.songs_scale)
def _align_songs_scale_mark(self, widget):
child = widget.get_first_child()
while child:
if type(child) is Gtk.Label:
child.set_halign(Gtk.Align.START)
else:
self._align_songs_scale_mark(child)
child = child.get_next_sibling()
def _enable_tracklist(self):
if self._current_album:
# enable
if not self._fullscreened:
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):
if self._cover_pixbuf:
self._resize_image()
self.cover_stack.set_visible_child(self.cover_scroll)
else:
self.cover_stack.set_visible_child(self.cover_default)
self.cover_spinner.stop()
def _resize_image(self):
# Get size
size_width = self.cover_stack.get_size(Gtk.Orientation.HORIZONTAL)
size_height = self.cover_stack.get_size(Gtk.Orientation.HORIZONTAL)
# Abort if size is the same
if self._current_size:
current_width, current_height = self._current_size
if size_width == current_width and size_height == current_height:
return
self._current_size = (
size_width,
size_height,
)
# Get pixelbuffer
pixbuf = self._cover_pixbuf
# Check pixelbuffer
if pixbuf is None:
self.cover_default.set_pixel_size(min(size_width, size_height)/2)
return
# Skalierungswert für Breite und Höhe ermitteln
ratio_w = float(size_width) / float(pixbuf.get_width())
ratio_h = float(size_height) / float(pixbuf.get_height())
# Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren
ratio = min(ratio_w, ratio_h)
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
self.cover_image.set_from_pixbuf(
pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER))
self.cover_image.set_pixel_size(min(width, height))
self.cover_image.show()

View file

@ -1,470 +0,0 @@
#!/usr/bin/env python3
import gi
import locale
import logging
import math
import threading
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, Gio, Adw
from mcg import client
from mcg.albumheaderbar import AlbumHeaderbar
from mcg.utils import SortOrder
from mcg.utils import Utils
from mcg.utils import GridItem
from mcg.utils import SearchFilter
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/library-panel.ui')
class LibraryPanel(Adw.Bin):
__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, (bool, )),
'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str, )),
}
# Widgets
library_stack = Gtk.Template.Child()
panel_normal = Gtk.Template.Child()
panel_standalone = Gtk.Template.Child()
actionbar_revealer = Gtk.Template.Child()
# Toolbar
toolbar = Gtk.Template.Child()
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()
sort_modified = Gtk.Template.Child()
grid_scale = 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()
# 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, **kwargs):
super().__init__(**kwargs)
self._logger = logging.getLogger(__name__)
self._client = client
self._buttons = {}
self._albums = None
self._host = "localhost"
self._item_size = 150
self._sort_order = SortOrder.YEAR
self._sort_type = Gtk.SortType.DESCENDING
self._grid_pixbufs = {}
self._grid_width = 0
self._old_ranges = {}
self._library_lock = threading.Lock()
self._library_stop = threading.Event()
self._icon_theme = Gtk.IconTheme.get_for_display(
Gdk.Display.get_default())
self._standalone_pixbuf = None
self._selected_albums = []
self._is_selected = False
# Widgets
# Header bar
self._headerbar_standalone = AlbumHeaderbar()
self._headerbar_standalone.connect('close',
self.on_standalone_close_clicked)
# Library Grid: Model
self._library_grid_model = Gio.ListStore()
self._library_grid_filter = Gtk.FilterListModel()
self._library_grid_filter.set_model(self._library_grid_model)
self._library_grid_selection_multi = Gtk.MultiSelection.new(
self._library_grid_filter)
self._library_grid_selection_single = Gtk.SingleSelection.new(
self._library_grid_filter)
# Library Grid
self.library_grid.set_model(self._library_grid_selection_single)
# Toolbar menu
self.grid_scale.set_value(self._item_size)
self._toolbar_sort_buttons = {
SortOrder.ARTIST: self.sort_artist,
SortOrder.TITLE: self.sort_title,
SortOrder.YEAR: self.sort_year,
SortOrder.MODIFIED: self.sort_modified
}
# Button controller for grid scale
button_controller = Gtk.GestureClick()
button_controller.connect('unpaired-release',
self.on_grid_scale_released)
self.grid_scale.add_controller(button_controller)
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_select_toggled(self, widget):
if self.select_button.get_active():
self.actionbar_revealer.set_reveal_child(True)
self.library_grid.set_model(self._library_grid_selection_multi)
self.library_grid.set_single_click_activate(False)
self.library_grid.get_style_context().add_class(
Utils.CSS_SELECTION)
else:
self.actionbar_revealer.set_reveal_child(False)
self.library_grid.set_model(self._library_grid_selection_single)
self.library_grid.set_single_click_activate(True)
self.library_grid.get_style_context().remove_class(
Utils.CSS_SELECTION)
@Gtk.Template.Callback()
def on_update_clicked(self, widget):
self.emit('update')
def on_grid_scale_released(self, widget, x, y, npress, sequence):
size = math.floor(self.grid_scale.get_value())
grid_range = self.grid_scale.get_adjustment()
if size < grid_range.get_lower() or size > grid_range.get_upper():
return
self._item_size = size
self.emit('item-size-changed', size)
self._redraw()
GObject.idle_add(self.toolbar_popover.popdown)
@Gtk.Template.Callback()
def on_grid_scale_changed(self, widget):
size = math.floor(self.grid_scale.get_value())
grid_range = widget.get_adjustment()
if size < grid_range.get_lower() or size > grid_range.get_upper():
return
self._set_widget_grid_size(self.library_grid, size, True)
@Gtk.Template.Callback()
def on_sort_toggled(self, widget):
if widget.get_active():
self._sort_order = [
key for key, value in self._toolbar_sort_buttons.items()
if value is widget
][0]
self._sort_grid_model()
self.emit('sort-order-changed', self._sort_order)
@Gtk.Template.Callback()
def on_sort_order_toggled(self, button):
if button.get_active():
self._sort_type = Gtk.SortType.DESCENDING
else:
self._sort_type = Gtk.SortType.ASCENDING
self._sort_grid_model()
self.emit('sort-type-changed', button.get_active())
def set_size(self, width, height):
self._set_marks()
self._resize_standalone_image()
@Gtk.Template.Callback()
def on_filter_entry_changed(self, widget):
self._library_grid_filter.set_filter(
SearchFilter(self.filter_entry.get_text()))
@Gtk.Template.Callback()
def on_library_grid_clicked(self, widget, position):
# Get selected album
item = self._library_grid_filter.get_item(position)
album = item.get_album()
album_id = album.get_id()
self._selected_albums = [album]
self.emit('albumart', album_id)
# Show standalone album
if widget.get_model() == self._library_grid_selection_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_selection_cancel_clicked(self, widget):
self.select_button.set_active(False)
@Gtk.Template.Callback()
def on_selection_add_clicked(self, widget):
self.emit('queue-multiple', self._get_selected_albums())
self.select_button.set_active(False)
@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.grid_scale.set_value(item_size)
self._redraw()
def get_item_size(self):
return self._item_size
def set_sort_order(self, sort):
button = self._toolbar_sort_buttons[sort]
if button:
self._sort_order = [
key for key, value in self._toolbar_sort_buttons.items()
if value is button
][0]
if not button.get_active():
button.set_active(True)
self._sort_grid_model()
def set_sort_type(self, sort_type):
if sort_type:
sort_type_gtk = Gtk.SortType.DESCENDING
else:
sort_type_gtk = Gtk.SortType.ASCENDING
if sort_type_gtk != self._sort_type:
self._sort_type = sort_type_gtk
self.toolbar_sort_order_button.set_active(sort_type)
self._sort_grid_model()
def get_sort_type(self):
return (self._sort_type != Gtk.SortType.ASCENDING)
def init_albums(self):
self.progress_bar.set_text(locale.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:
self._standalone_pixbuf = None
if data:
# Load image and draw it
try:
self._standalone_pixbuf = Utils.load_pixbuf(data)
except Exception:
self._logger.exception("Failed to set albumart")
# Show image
GObject.idle_add(self._show_image)
def _sort_grid_model(self):
GObject.idle_add(self._library_grid_model.sort,
self._grid_model_compare_func, self._sort_order,
self._sort_type)
def _grid_model_compare_func(self, item1, item2, criterion, order):
return client.MCGAlbum.compare(item1.get_album(), item2.get_album(),
criterion,
(order == Gtk.SortType.DESCENDING))
def stop_threads(self):
self._library_stop.set()
def _set_albums(self, host, albums, size):
self._library_lock.acquire()
self._albums = albums
stack_transition_type = self.stack.get_transition_type()
GObject.idle_add(self.stack.set_transition_type,
Gtk.StackTransitionType.NONE)
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.stack.set_transition_type, stack_transition_type)
GObject.idle_add(self._library_grid_model.remove_all)
i = 0
n = len(albums)
cache = client.MCGCache(host, size)
self._grid_pixbufs.clear()
for album_id in albums.keys():
album = albums[album_id]
grid_item = GridItem(album)
pixbuf = None
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:
icon = self._get_default_icon(self._item_size, self._item_size)
grid_item.set_icon(icon)
else:
self._grid_pixbufs[album.get_id()] = pixbuf
grid_item.set_cover(pixbuf)
GObject.idle_add(self._library_grid_model.append, grid_item)
i += 1
GObject.idle_add(self.progress_bar.set_fraction, i / n)
GObject.idle_add(self.progress_bar.set_text,
locale.gettext("Loading images"))
self._library_lock.release()
GObject.idle_add(self.stack.set_visible_child, self.scroll)
self._sort_grid_model()
GObject.idle_add(self.library_grid.scroll_to, 0, Gtk.ListScrollFlags.NONE, None)
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()
if size == self._item_size:
self._library_lock.release()
return
for i in range(self._library_grid_model.get_n_items()):
grid_item = self._library_grid_model.get_item(i)
album_id = grid_item.get_album().get_id()
pixbuf = self._grid_pixbufs[album_id]
if pixbuf is not None:
pixbuf = pixbuf.scale_simple(size, size,
GdkPixbuf.InterpType.NEAREST)
else:
pixbuf = self._icon_theme.lookup_icon(
Utils.STOCK_ICON_DEFAULT, None, size, size,
Gtk.TextDirection.LTR, Gtk.IconLookupFlags.FORCE_SYMBOLIC)
GObject.idle_add(grid_item.set_cover, pixbuf)
if self._library_stop.is_set():
self._library_lock.release()
return
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 _set_marks(self):
width = self.scroll.get_width()
if width == self._grid_width:
return
self._grid_width = width
self.grid_scale.clear_marks()
lower = int(self.grid_scale.get_adjustment().get_lower())
upper = int(self.grid_scale.get_adjustment().get_upper())
count_min = max(int(width / upper), 1)
count_max = max(int(width / lower), 1)
for index in range(count_min, count_max):
pixel = int(width / index)
pixel = pixel - (2 * int(pixel / 100))
self.grid_scale.add_mark(pixel, Gtk.PositionType.BOTTOM, None)
def _open_standalone(self):
self.library_stack.set_visible_child(self.panel_standalone)
self.emit('open-standalone')
def _close_standalone(self):
self.library_stack.set_visible_child(self.panel_normal)
self.emit('close-standalone')
def _resize_standalone_image(self):
# Get size
size_width = self.standalone_stack.get_width()
size_height = self.standalone_stack.get_height()
# Get pixelbuffer
pixbuf = self._standalone_pixbuf
# Check pixelbuffer
if pixbuf is None:
icon = self._get_default_icon(size_width, size_height)
self.standalone_image.set_from_paintable(icon)
self.standalone_image.set_pixel_size(min(size_width, size_height)/2)
return
(width, height) = Utils.calculate_size(pixbuf.get_width(),
pixbuf.get_height(), size_width,
size_height)
if width <= 0 or height <= 0:
return
# Pixelpuffer auf Oberfläche zeichnen
self.standalone_image.set_from_pixbuf(
pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER))
self.standalone_image.set_pixel_size(min(width, height))
self.standalone_image.show()
def _get_default_icon(self, width, height):
return self._icon_theme.lookup_icon(Utils.STOCK_ICON_DEFAULT, None,
width, height,
Gtk.TextDirection.LTR,
Gtk.IconLookupFlags.FORCE_SYMBOLIC)
def _get_selected_albums(self):
albums = []
for i in range(self.library_grid.get_model().get_n_items()):
if self.library_grid.get_model().is_selected(i):
albums.append(self.library_grid.get_model().get_item(
i).get_album().get_id())
return albums

View file

@ -1,8 +0,0 @@
import sys
from .application import Application
def main(version):
app = Application(version)
return app.run(sys.argv)

View file

@ -1,25 +0,0 @@
#!@PYTHON@
import os
import sys
import signal
import locale
VERSION = '@VERSION@'
pkgdatadir = '@pkgdatadir@'
localedir = '@localedir@'
sys.path.insert(1, pkgdatadir)
signal.signal(signal.SIGINT, signal.SIG_DFL)
locale.bindtextdomain('mcg', localedir)
locale.textdomain('mcg')
if __name__ == '__main__':
import gi
from gi.repository import Gio
resource = Gio.Resource.load(os.path.join(pkgdatadir, 'mcg.gresource'))
resource._register()
from mcg import main
sys.exit(main.main(VERSION))

View file

@ -1,37 +0,0 @@
pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name())
moduledir = join_paths(pkgdatadir, 'mcg')
python = import('python')
conf = configuration_data()
conf.set('PYTHON', python.find_installation('python3').full_path())
conf.set('VERSION', meson.project_version())
conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir')))
conf.set('pkgdatadir', pkgdatadir)
configure_file(
input: 'mcg.in',
output: 'mcg',
configuration: conf,
install: true,
install_dir: get_option('bindir')
)
mcg_sources = [
'__init__.py',
'main.py',
'albumheaderbar.py',
'application.py',
'client.py',
'connectionpanel.py',
'coverpanel.py',
'librarypanel.py',
'playlistpanel.py',
'serverpanel.py',
'shortcutsdialog.py',
'utils.py',
'window.py',
'zeroconf.py',
]
install_data(mcg_sources, install_dir: moduledir)

View file

@ -1,278 +0,0 @@
#!/usr/bin/env python3
import gi
import math
import threading
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Gdk, Gio, GObject, GdkPixbuf, Adw
from mcg import client
from mcg.albumheaderbar import AlbumHeaderbar
from mcg.utils import Utils
from mcg.utils import GridItem
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/playlist-panel.ui')
class PlaylistPanel(Adw.Bin):
__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
playlist_stack = Gtk.Template.Child()
panel_normal = Gtk.Template.Child()
panel_standalone = Gtk.Template.Child()
actionbar_revealer = Gtk.Template.Child()
# Toolbar
toolbar = Gtk.Template.Child()
playlist_clear_button = Gtk.Template.Child()
select_button = 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, **kwargs):
super().__init__(**kwargs)
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_for_display(
Gdk.Display.get_default())
self._standalone_pixbuf = None
self._selected_albums = []
self._is_selected = False
# Widgets
# Header bar
self._headerbar_standalone = AlbumHeaderbar()
self._headerbar_standalone.connect('close',
self.on_headerbar_close_clicked)
# Playlist Grid: Model
self._playlist_grid_model = Gio.ListStore()
self._playlist_grid_selection_multi = Gtk.MultiSelection.new(
self._playlist_grid_model)
self._playlist_grid_selection_single = Gtk.SingleSelection.new(
self._playlist_grid_model)
# Playlist Grid
self.playlist_grid.set_model(self._playlist_grid_selection_single)
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_select_toggled(self, widget):
if self.select_button.get_active():
self.actionbar_revealer.set_reveal_child(True)
self.playlist_grid.set_model(self._playlist_grid_selection_multi)
self.playlist_grid.set_single_click_activate(False)
self.playlist_grid.get_style_context().add_class(
Utils.CSS_SELECTION)
else:
self.actionbar_revealer.set_reveal_child(False)
self.playlist_grid.set_model(self._playlist_grid_selection_single)
self.playlist_grid.set_single_click_activate(True)
self.playlist_grid.get_style_context().remove_class(
Utils.CSS_SELECTION)
@Gtk.Template.Callback()
def on_clear_clicked(self, widget):
self.emit('clear-playlist')
@Gtk.Template.Callback()
def on_playlist_grid_clicked(self, widget, position):
# Get selected album
item = self._playlist_grid_model.get_item(position)
album = item.get_album()
album_id = album.get_id()
self._selected_albums = [album]
self.emit('albumart', album_id)
# Show standalone album
if widget.get_model() == self._playlist_grid_selection_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_selection_cancel_clicked(self, widget):
self.select_button.set_active(False)
@Gtk.Template.Callback()
def on_selection_remove_clicked(self, widget):
self.emit('remove-multiple-albums', self._get_selected_albums())
self.select_button.set_active(False)
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._get_selected_albums()[0])
self._close_standalone()
@Gtk.Template.Callback()
def on_standalone_play_clicked(self, widget):
self.emit('play', self._get_selected_albums()[0])
self._close_standalone()
def set_size(self, width, height):
self._resize_standalone_image()
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:
self._standalone_pixbuf = None
if data:
# Load image and draw it
try:
self._standalone_pixbuf = Utils.load_pixbuf(data)
except Exception:
self._logger.exception("Failed to set albumart")
# Show image
GObject.idle_add(self._show_image)
def stop_threads(self):
self._playlist_stop.set()
def _set_playlist(self, host, playlist, size):
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_model.remove_all()
cache = client.MCGCache(host, size)
for album in playlist:
grid_item = GridItem(album)
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:
icon = self._get_default_icon(self._item_size, self._item_size)
grid_item.set_icon(icon)
else:
grid_item.set_cover(pixbuf)
GObject.idle_add(self._playlist_grid_model.append, grid_item)
if self._playlist_stop.is_set():
self._playlist_lock.release()
return
self.playlist_grid.set_model(self._playlist_grid_selection_single)
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.playlist_stack.set_visible_child(self.panel_standalone)
self.emit('open-standalone')
def _close_standalone(self):
self.playlist_stack.set_visible_child(self.panel_normal)
self.emit('close-standalone')
def _resize_standalone_image(self):
# Get size
size_width = self.standalone_stack.get_width()
size_height = self.standalone_stack.get_height()
# Get pixelbuffer
pixbuf = self._standalone_pixbuf
# Check pixelbuffer
if pixbuf is None:
icon = self._get_default_icon(size_width, size_height)
self.standalone_image.set_from_paintable(icon)
self.standalone_image.set_pixel_size(min(size_width, size_height)/2)
return
(width, height) = Utils.calculate_size(pixbuf.get_width(),
pixbuf.get_height(), size_width,
size_height)
if width <= 0 or height <= 0:
return
# Pixelpuffer auf Oberfläche zeichnen
self.standalone_image.set_from_pixbuf(
pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER))
self.standalone_image.set_pixel_size(min(width, height))
self.standalone_image.show()
def _get_default_icon(self, width, height):
return self._icon_theme.lookup_icon(Utils.STOCK_ICON_DEFAULT, None,
width, height,
Gtk.TextDirection.LTR,
Gtk.IconLookupFlags.FORCE_SYMBOLIC)
def _get_selected_albums(self):
albums = []
for i in range(self.playlist_grid.get_model().get_n_items()):
if self.playlist_grid.get_model().is_selected(i):
albums.append(
self.playlist_grid.get_model().get_item(i).get_album())
return albums

View file

@ -1,115 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Adw, GObject
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/server-panel.ui')
class ServerPanel(Adw.Bin):
__gtype_name__ = 'McgServerPanel'
__gsignals__ = {
'change-output-device': (GObject.SIGNAL_RUN_FIRST, None, (
GObject.TYPE_PYOBJECT,
bool,
)),
}
# Widgets
toolbar = Gtk.Template.Child()
# 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 output devices widgets
output_devices = Gtk.Template.Child()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._none_label = ""
self._output_buttons = {}
self._is_selected = False
# Widgets
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.new_with_label(device.get_name())
if device.is_enabled():
button.set_active(True)
button.connect('toggled', self.on_output_device_toggled,
device)
self.output_devices.insert(button, -1)
self._output_buttons[device.get_id()] = button
# 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,15 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
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,138 +0,0 @@
#!/usr/bin/env python3
import gi
import hashlib
import math
import locale
import logging
import os
gi.require_version('Gtk', '4.0')
from gi.repository import Gdk, GdkPixbuf, GObject, Gtk
class Utils:
CSS_SELECTION = 'selection'
STOCK_ICON_DEFAULT = 'image-x-generic-symbolic'
@staticmethod
def load_pixbuf(data):
loader = GdkPixbuf.PixbufLoader()
try:
loader.write(data)
finally:
loader.close()
return loader.get_pixbuf()
@staticmethod
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:
(width, height) = Utils.calculate_size(pixbuf.get_width(),
pixbuf.get_height(),
size, size)
pixbuf = pixbuf.scale_simple(width, height,
GdkPixbuf.InterpType.HYPER)
try:
pixbuf.savev(cache_url, 'jpeg', [], [])
except Exception as e:
logger = logging.getLogger(__name__)
logger.warning("Failed to save thumbnail for album\"%s\": "
"%s", album.get_title(), e)
return pixbuf
@staticmethod
def create_artists_label(album):
label = ', '.join(album.get_albumartists())
if album.get_artists():
label = locale.gettext("{} feat. {}").format(
label, ", ".join(album.get_artists()))
return label
@staticmethod
def create_length_label(album):
minutes = album.get_length() // 60
seconds = album.get_length() - minutes * 60
return locale.gettext("{}:{} minutes").format(minutes, seconds)
@staticmethod
def create_track_title(track):
title = track.get_title()
if track.get_artists():
title = locale.gettext("{} feat. {}").format(
title, ", ".join(track.get_artists()))
return title
@staticmethod
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()
@staticmethod
def calculate_size(src_width, src_height, dest_width, dest_height):
ratio_w = float(dest_width) / float(src_width)
ratio_h = float(dest_height) / float(src_height)
ratio = min(min(ratio_w, ratio_h), 1)
if ratio == 1:
return (src_width, src_height)
width = int(math.floor(src_width * ratio))
height = int(math.floor(src_height * ratio))
return (width, height)
class SortOrder:
ARTIST = 0
TITLE = 1
YEAR = 2
MODIFIED = 3
class GridItem(GObject.GObject):
__gtype_name__ = "GridItem"
tooltip = GObject.Property(type=str, default=None)
cover = GObject.Property(type=Gdk.Paintable, default=None)
def __init__(self, album):
super().__init__()
self._album = album
self.tooltip = GObject.markup_escape_text("\n".join([
album.get_title(), ', '.join(album.get_dates()),
Utils.create_artists_label(album),
Utils.create_length_label(album)
]))
def get_album(self):
return self._album
def set_cover(self, cover):
self.cover = Gdk.Texture.new_for_pixbuf(cover)
def set_icon(self, icon):
self.cover = icon
class SearchFilter(Gtk.Filter):
def __init__(self, search_string):
super().__init__()
self._search_string = search_string
def do_match(self, grid_item):
return grid_item.get_album().filter(self._search_string)

View file

@ -1,615 +0,0 @@
#!/usr/bin/env python3
import gi
try:
import keyring
use_keyring = True
except ImportError:
use_keyring = False
use_keyring = False
import locale
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Adw, Gdk, GObject, GLib, Gio
from . import client
from .shortcutsdialog import ShortcutsDialog
from .connectionpanel import ConnectionPanel
from .serverpanel import ServerPanel
from .coverpanel import CoverPanel
from .playlistpanel import PlaylistPanel
from .librarypanel import LibraryPanel
from .zeroconf import ZeroconfProvider
class WindowState(GObject.Object):
PROP_WIDTH = 'width'
PROP_HEIGHT = 'height'
PROP_MAXIMIZED = 'is_maximized'
PROP_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(Adw.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'
# Widgets
toolbar_view = Gtk.Template.Child()
content_stack = Gtk.Template.Child()
panel_stack = Gtk.Template.Child()
toolbar_stack = Gtk.Template.Child()
# Headerbar
headerbar = Gtk.Template.Child()
headerbar_panel_switcher = Gtk.Template.Child()
headerbar_button_connect = Gtk.Template.Child()
headerbar_button_playpause = Gtk.Template.Child()
headerbar_button_volume = Gtk.Template.Child()
# Infobar
info_toast = Gtk.Template.Child()
def __init__(self, app, title, settings, **kwargs):
super().__init__(**kwargs)
self.set_application(app)
self.set_title(title)
self._settings = settings
self._panels = []
self._mcg = client.Client()
self._state = WindowState()
self._setting_volume = False
self._headerbar_connection_button_active = True
self._headerbar_playpause_button_active = True
self._width = 0
self._height = 0
# 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_child(self._connection_panel)
self.panel_stack.add_titled_with_icon(self._server_panel,
'server-panel',
locale.gettext("Server"),
"network-wired-symbolic")
self.panel_stack.add_titled_with_icon(self._cover_panel, 'cover-panel',
locale.gettext("Cover"),
"image-x-generic-symbolic")
self.panel_stack.add_titled_with_icon(self._playlist_panel,
'playlist-panel',
locale.gettext("Playlist"),
"view-list-symbolic")
self.panel_stack.add_titled_with_icon(self._library_panel,
'library-panel',
locale.gettext("Library"),
"emblem-music-symbolic")
# Toolbar stack
self.toolbar_stack.add_child(self._server_panel.get_toolbar())
self.toolbar_stack.add_child(self._cover_panel.get_toolbar())
self.toolbar_stack.add_child(self._playlist_panel.get_toolbar())
self.toolbar_stack.add_child(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.connect("notify::maximized", self.on_maximized)
self.connect("notify::fullscreened", self.on_fullscreened)
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_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.PROP_WIDTH,
Gio.SettingsBindFlags.DEFAULT)
self._settings.bind(Window.SETTING_WINDOW_HEIGHT, self._state,
WindowState.PROP_HEIGHT,
Gio.SettingsBindFlags.DEFAULT)
self._settings.bind(Window.SETTING_WINDOW_MAXIMIZED, self._state,
WindowState.PROP_MAXIMIZED,
Gio.SettingsBindFlags.DEFAULT)
# Actions
self.set_default_size(self._state.width, self._state.height)
if self._state.get_property(WindowState.PROP_MAXIMIZED):
self.maximize()
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)
def do_size_allocate(self, width, height, baseline):
Gtk.ApplicationWindow().do_size_allocate(self, width, height, baseline)
if self._width == width and self._height == height:
return
self._width = width
self._height = height
if width > 0:
self._cover_panel.set_width(width)
if not self._state.get_property(WindowState.PROP_MAXIMIZED):
self._state.set_property(WindowState.PROP_WIDTH, width)
self._state.set_property(WindowState.PROP_HEIGHT, height)
GObject.idle_add(self._playlist_panel.set_size, width, height)
GObject.idle_add(self._library_panel.set_size, width, height)
# 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.PROP_FULLSCREENED):
self.fullscreen()
else:
self.unfullscreen()
def on_menu_search_library(self, action, value):
self.panel_stack.set_visible_child(self.library_panel_page)
self._library_panel.show_search()
# Window callbacks
def on_maximized(self, widget, maximized):
self._state.set_property(WindowState.PROP_MAXIMIZED, maximized is True)
def on_fullscreened(self, widget, fullscreened):
self._fullscreen(self.is_fullscreen())
# HeaderBar callbacks
@Gtk.Template.Callback()
def on_headerbar_connection_state_set(self, widget, state):
if self._headerbar_connection_button_active:
self._connect()
@Gtk.Template.Callback()
def on_headerbar_volume_changed(self, widget, value):
if not self._setting_volume:
self._mcg.set_volume(int(value * 100))
@Gtk.Template.Callback()
def on_headerbar_playpause_toggled(self, widget):
if self._headerbar_playpause_button_active:
self._mcg.playpause()
self._mcg.get_status()
# 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())
def on_panel_open_standalone(self, panel):
self.toolbar_view.add_top_bar(panel.get_headerbar_standalone())
self.toolbar_view.remove(self.headerbar)
def on_panel_close_standalone(self, panel):
self.toolbar_view.add_top_bar(self.headerbar)
self.toolbar_view.remove(panel.get_headerbar_standalone())
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.PROP_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, sort_order)
def on_library_panel_sort_type_changed(self, widget, sort_type):
self._settings.set_boolean(Window.SETTING_SORT_TYPE, 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_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.PROP_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:
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_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.PROP_FULLSCREENED
):
self._state.set_property(WindowState.PROP_FULLSCREENED,
fullscreened_new)
if self._state.get_property(WindowState.PROP_FULLSCREENED):
self.headerbar.hide()
self._cover_panel.set_fullscreen(True)
self.set_cursor(Gdk.Cursor.new_from_name("none", None))
else:
self.headerbar.show()
self._cover_panel.set_fullscreen(False)
self.set_cursor(Gdk.Cursor.new_from_name("default", None))
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_playpause_button_active = False
self.headerbar_button_playpause.set_active(True)
self._headerbar_playpause_button_active = True
def _set_pause(self):
self._headerbar_playpause_button_active = False
self.headerbar_button_playpause.set_active(False)
self._headerbar_playpause_button_active = True
def _set_volume(self, volume):
if volume >= 0:
self.headerbar_button_volume.set_visible(True)
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_connection_button_active = False
self.headerbar_button_connect.set_active(True)
self.headerbar_button_connect.set_state(True)
self._headerbar_connection_button_active = True
def _headerbar_disconnected(self):
self._headerbar_connection_button_active = False
self.headerbar_button_connect.set_active(False)
self.headerbar_button_connect.set_state(False)
self._headerbar_connection_button_active = True
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_toast.add_toast(Adw.Toast.new(message))

View file

@ -1,72 +0,0 @@
#!/usr/bin/env python3
import gi
try:
gi.require_version('Avahi', '0.6')
from gi.repository import Avahi
use_avahi = True
except ValueError | ImportError:
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):
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)