Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c647cac7 |
68 changed files with 4047 additions and 4858 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -1 +1,7 @@
|
|||
/mcg*
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
/build/
|
||||
/install/
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
# 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 project’s code into any AI tool, you
|
||||
will be banned from this project.
|
||||
22
CONTRIBUTING.textile
Normal file
22
CONTRIBUTING.textile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
h1. Contributing
|
||||
|
||||
There are several ways to participate in this project. This guide covers only the contribution of code (i. e. any change to any file in the repository).
|
||||
|
||||
There are basically two ways to contribute code to this project: Either via GitLab or via e‑mail. The first one is probably easier for most people. However
|
||||
it requires a account at GitLab.com. For people who don’t want to create an account—or have any other reason to not use GitLab—e‑mail is an alternative option.
|
||||
|
||||
h2. Contributing via GitLab
|
||||
|
||||
Create an account on GitLab.com or use your already existing account. Fork this repository inside GitLab, add changes to your fork and finally create a Merge Request to contribute your changes back to this repository.
|
||||
|
||||
h2. Contributing via E‑mail
|
||||
|
||||
To contribute via e‑mail, first clone this repository to your local harddrive. After adding changes to your local copy you have two options to send them back to this repository: as patch or as Pull Request
|
||||
|
||||
h3. E‑mail Patch
|
||||
|
||||
To send a patch with your changes, use git’s @send-email@ option as explained in "Public Project over Email":https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_project_over_email of section "5.2 Distributed Git - Contributing to a Project":https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project of the "Git Pro Book":https://git-scm.com/book/en/v2/. You will find the e‑mail address at "suruatoel.xyz":https://www.suruatoel.xyz.
|
||||
|
||||
h3. E‑mail Pull Request
|
||||
|
||||
Sending a Pull Request via e‑mail requires to publish your copy of the repository (that includes your changes) at a publicly available URL (it is your responsibility to choose a trusted host). Then use git’s @request-pull@ command to send an e‑mail with your pull request. The e‑mail will contain a link to the public repository which can be used to pull your changes from. You will find the e‑mail address at "suruatoel.xyz":https://www.suruatoel.xyz. For more details, please take a look at "Forked Public Project":https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_public_project of section "5.2 Distributed Git - Contributing to a Project":https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project of the "Git Pro Book":https://git-scm.com/book/en/v2.
|
||||
2
LICENSE
2
LICENSE
|
|
@ -1,5 +1,5 @@
|
|||
CoverGrid (mcg) is a client for the Music Player Daemon, focusing on albums instead of single tracks.
|
||||
Copyright ® 2013 – 2026 Olli <olli@suruatoel.xyz>
|
||||
Copyright ® 2013 – 2017 coderkun <olli@suruatoel.xyz>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -5,51 +5,60 @@ Daemon](http://www.musicpd.org) (MPD), focusing on albums instead of single
|
|||
tracks. It is not intended to be a replacement for your favorite MPD client but
|
||||
an addition to get a better album-experience.
|
||||
|
||||
Website: [https://www.suruatoel.xyz/codes/mcg](www.suruatoel.xyz/codes/mcg)
|
||||
Website: https://www.suruatoel.xyz/codes/mcg
|
||||
|
||||
License: [GPL](http://www.gnu.org/licenses/gpl.html) v3
|
||||
|
||||
Dependencies:
|
||||
|
||||
* [GTK](http://www.gtk.org) 4 (>= 4.18)
|
||||
* [Python](http://www.python.org) 3
|
||||
* [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)
|
||||
* [go](https://go.dev/) (build only)
|
||||
* [Avahi](http://www.avahi.org) (optional)
|
||||
* [python-keyring](http://pypi.python.org/pypi/keyring) (optional)
|
||||
* [meson](https://mesonbuild.com/) and [ninja](https://ninja-build.org/) (building)
|
||||
|
||||
Additionally, an [MPD](http://www.musicpd.org) server (version >= 0.21.0) is
|
||||
Additionally a [MPD](http://www.musicpd.org) server (version >= 0.21.0) is
|
||||
required at runtime.
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
Build the application with _go_:
|
||||
Build the application with _meson_ and _ninja_:
|
||||
|
||||
$ go build .
|
||||
$ meson build
|
||||
$ ninja -C build
|
||||
|
||||
## Running
|
||||
## Running/Testing
|
||||
|
||||
Run the application for testing purposes:
|
||||
For testing the application and running it without (system-wide) installation,
|
||||
donwload/clone the code, build it with the `--prefix` option and install it
|
||||
with `ninja`:
|
||||
|
||||
$ go run .
|
||||
$ meson setup --prefix $(pwd)/install build
|
||||
$ ninja -C build
|
||||
$ ninja -C build install
|
||||
|
||||
Run the application:
|
||||
After that you can run it with _meson’s_ `devenv` command:
|
||||
|
||||
$ ./mcg
|
||||
$ meson devenv -C build src/mcg
|
||||
|
||||
## Packaging
|
||||
## Installing
|
||||
|
||||
The suggested package name for this project is “mcg”. To build a production
|
||||
binary, please use the following command:
|
||||
Install the application system-wide with _meson_ (after building):
|
||||
|
||||
$ go build --ldflags="-s -w -X main.Version=$(git describe)" -trimpath ./...
|
||||
$ ninja -C build install
|
||||
|
||||
In the “data/” folder you will find a `.desktop` file and an SVG icon.
|
||||
Note: _On Linux using the distribution’s package manager is the preferred way
|
||||
of installing applications system-wide._
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
|
|
|||
43
data/data.go
43
data/data.go
|
|
@ -1,43 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#content_stack {
|
||||
box-shadow:inset 4px 4px 10px rgba(0,0,0,0.3);
|
||||
background-image:url("@NOISETEXTURE@");
|
||||
background-image:url('noise-texture.png');
|
||||
}
|
||||
|
||||
#port_spinner {
|
||||
|
|
@ -30,6 +30,10 @@
|
|||
font-weight:bold;
|
||||
}
|
||||
|
||||
window.fullscreen #cover_box {
|
||||
background: black;
|
||||
}
|
||||
|
||||
#cover_info_revealer {
|
||||
background-color:alpha(@theme_bg_color, 0.8);
|
||||
box-shadow:0 0 10px @theme_bg_color;
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 7 KiB |
7
data/icons/meson.build
Normal file
7
data/icons/meson.build
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
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)
|
||||
)
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<interface>
|
||||
<requires lib="gtk+" version="4.8"/>
|
||||
<requires lib="adw" version="1.2" />
|
||||
<object id="McgAlbumHeaderbar" class="AdwBin">
|
||||
<template class="McgAlbumHeaderbar" parent="AdwBin">
|
||||
<child>
|
||||
<object class="GtkHeaderBar">
|
||||
<child type="title">
|
||||
|
|
@ -25,8 +25,9 @@
|
|||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object id="close_button" class="GtkButton">
|
||||
<object class="GtkButton">
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_close_clicked" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">go-previous-symbolic</property>
|
||||
|
|
@ -36,5 +37,5 @@
|
|||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</template>
|
||||
</interface>
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<requires lib="gtk+" version="4.8"/>
|
||||
<requires lib="adw" version="1.2" />
|
||||
<object class="GtkAdjustment" id="server-port-adjustment">
|
||||
<property name="lower">1024</property>
|
||||
<property name="upper">9999</property>
|
||||
<property name="value">6600</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="page-increment">100</property>
|
||||
</object>
|
||||
<object id="McgConnectionPanel" class="AdwBin">
|
||||
<child>
|
||||
<object class="AdwStatusPage">
|
||||
<property name="title">Connect to MPD</property>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="hexpand">false</property>
|
||||
<property name="halign">center</property>
|
||||
<property name="selection-mode">none</property>
|
||||
<style>
|
||||
<class name="boxed-list"/>
|
||||
</style>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="host_row">
|
||||
<property name="title" translatable="yes">Host</property>
|
||||
<property name="show-apply-button">true</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="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>
|
||||
</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>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<object id="McgCoverPanel" class="GtkOverlay">
|
||||
<template class="McgCoverPanel" parent="GtkOverlay">
|
||||
<child>
|
||||
<object class="GtkStack" id="cover_stack">
|
||||
<property name="visible-child">cover_default</property>
|
||||
|
|
@ -157,5 +157,5 @@
|
|||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</template>
|
||||
</interface>
|
||||
|
|
|
|||
|
|
@ -26,11 +26,13 @@
|
|||
<property name="round-digits">0</property>
|
||||
<property name="digits">0</property>
|
||||
<property name="has-origin">False</property>
|
||||
<signal name="value-changed" handler="on_grid_scale_changed" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="library-toolbar-update">
|
||||
<property name="label" translatable="yes">update library</property>
|
||||
<signal name="clicked" handler="on_update_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
|
|
@ -51,6 +53,7 @@
|
|||
<property name="label" translatable="yes">sort by artist</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="group">sort_year</property>
|
||||
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
|
|
@ -58,12 +61,14 @@
|
|||
<property name="label" translatable="yes">sort by title</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="group">sort_year</property>
|
||||
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkCheckButton" id="sort_year">
|
||||
<property name="label" translatable="yes">sort by year</property>
|
||||
<property name="receives-default">False</property>
|
||||
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
|
|
@ -71,6 +76,7 @@
|
|||
<property name="label" translatable="yes">sort by modification</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="group">sort_year</property>
|
||||
<signal name="toggled" handler="on_sort_toggled" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
|
|
@ -80,6 +86,7 @@
|
|||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">False</property>
|
||||
<property name="active">True</property>
|
||||
<signal name="toggled" handler="on_sort_order_toggled" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
@ -101,6 +108,9 @@
|
|||
<property name="icon-name">system-search-symbolic</property>
|
||||
</object>
|
||||
</child>
|
||||
<!--
|
||||
<accelerator key="f" signal="activate" modifiers="GDK_CONTROL_MASK"/>
|
||||
-->
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
|
|
@ -109,6 +119,7 @@
|
|||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<property name="tooltip-text" translatable="yes">Select multiple albums</property>
|
||||
<signal name="toggled" handler="on_select_toggled" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
|
|
@ -134,7 +145,7 @@
|
|||
</child>
|
||||
</object>
|
||||
|
||||
<object id="McgLibraryPanel" class="AdwBin">
|
||||
<template class="McgLibraryPanel" parent="AdwBin">
|
||||
<child>
|
||||
<object class="GtkStack" id="library_stack">
|
||||
<property name="transition-type">slide-left-right</property>
|
||||
|
|
@ -147,6 +158,7 @@
|
|||
<child>
|
||||
<object class="GtkSearchEntry" id="filter_entry">
|
||||
<property name="placeholder-text" translatable="yes">search library</property>
|
||||
<signal name="search-changed" handler="on_filter_entry_changed" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
@ -194,9 +206,45 @@
|
|||
<object class="GtkGridView" id="library_grid">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="single_click_activate">true</property>
|
||||
<signal name="activate" handler="on_library_grid_clicked"/>
|
||||
<style>
|
||||
<class name="no-bg"/>
|
||||
</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>
|
||||
</child>
|
||||
<style>
|
||||
|
|
@ -212,15 +260,17 @@
|
|||
<child>
|
||||
<object class="GtkActionBar" id="actionbar">
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="selection_cancel_button">
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">cancel</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_selection_cancel_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="selection_queue_button">
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">queue</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_selection_add_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
@ -267,15 +317,17 @@
|
|||
<child>
|
||||
<object class="GtkActionBar" id="actionbar_standalone">
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="standalone_play_button">
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">play</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_standalone_play_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="standalone_queue_button">
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">queue</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_standalone_queue_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
@ -284,5 +336,5 @@
|
|||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</template>
|
||||
</interface>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<property name="tooltip-text" translatable="yes">Select multiple albums</property>
|
||||
<signal name="toggled" handler="on_select_toggled" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="icon-name">object-select-symbolic</property>
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
<property name="can-focus">True</property>
|
||||
<property name="receives-default">True</property>
|
||||
<property name="tooltip-text" translatable="yes">Clear the playlist</property>
|
||||
<signal name="clicked" handler="on_clear_clicked" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="visible">True</property>
|
||||
|
|
@ -35,7 +37,7 @@
|
|||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<object id="McgPlaylistPanel" class="AdwBin">
|
||||
<template class="McgPlaylistPanel" parent="AdwBin">
|
||||
<child>
|
||||
<object class="GtkStack" id="playlist_stack">
|
||||
<property name="transition-type">slide-left-right</property>
|
||||
|
|
@ -49,9 +51,45 @@
|
|||
<object class="GtkGridView" id="playlist_grid">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="single-click-activate">true</property>
|
||||
<signal name="activate" handler="on_playlist_grid_clicked"/>
|
||||
<style>
|
||||
<class name="no-bg"/>
|
||||
</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>
|
||||
</child>
|
||||
<style>
|
||||
|
|
@ -65,15 +103,17 @@
|
|||
<child>
|
||||
<object class="GtkActionBar" id="actionbar">
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="selection_cancel_button">
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">cancel</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_selection_cancel_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="selection_remove_button">
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">remove</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_selection_remove_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
@ -119,15 +159,17 @@
|
|||
<child>
|
||||
<object class="GtkActionBar" id="actionbar_standalone">
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="standalone_play_button">
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">play</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_standalone_play_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="standalone_remove_button">
|
||||
<object class="GtkButton">
|
||||
<property name="label" translatable="yes">remove</property>
|
||||
<property name="receives-default">True</property>
|
||||
<signal name="clicked" handler="on_standalone_remove_clicked" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
@ -136,5 +178,5 @@
|
|||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</template>
|
||||
</interface>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
<interface>
|
||||
<requires lib="gtk+" version="4.8"/>
|
||||
<requires lib="adw" version="1.2" />
|
||||
<object class="GtkAdjustment" id="server-port-adjustment">
|
||||
<property name="lower">1024</property>
|
||||
<property name="upper">9999</property>
|
||||
<property name="value">6600</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="page-increment">100</property>
|
||||
</object>
|
||||
<object class="GtkBox" id="toolbar">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="halign">end</property>
|
||||
|
|
@ -13,7 +20,7 @@
|
|||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<object id="McgServerPanel" class="AdwBin">
|
||||
<template class="McgServerPanel" parent="AdwBin">
|
||||
<child>
|
||||
<object class="AdwFlap" id="server_flap">
|
||||
<property name="flap-position">end</property>
|
||||
|
|
@ -31,8 +38,84 @@
|
|||
<object class="GtkStack" id="stack">
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<property name="name">connection</property>
|
||||
<property name="title" translatable="yes">Connection</property>
|
||||
<property name="child">
|
||||
<object class="AdwStatusPage" id="connection_status_page">
|
||||
<property name="icon-name">network-wired-symbolic</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="hexpand">false</property>
|
||||
<property name="halign">center</property>
|
||||
<child>
|
||||
<object class="GtkListBox" id="zeroconf_list">
|
||||
<property name="hexpand">true</property>
|
||||
<child type="placeholder">
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">No service found</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkListBox">
|
||||
<property name="hexpand">true</property>
|
||||
<property name="selection-mode">none</property>
|
||||
<style>
|
||||
<class name="boxed-list"/>
|
||||
</style>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="host_row">
|
||||
<property name="title" translatable="yes">Host</property>
|
||||
<property name="show-apply-button">true</property>
|
||||
<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>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage" id="status_page">
|
||||
<property name="name">status</property>
|
||||
<property name="title" translatable="yes">Status</property>
|
||||
<property name="visible">false</property>
|
||||
<property name="child">
|
||||
<object class="AdwStatusPage">
|
||||
<property name="icon-name">dialog-information-symbolic</property>
|
||||
|
|
@ -149,9 +232,10 @@
|
|||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<object class="GtkStackPage" id="stats_page">
|
||||
<property name="name">stats</property>
|
||||
<property name="title" translatable="yes">Statistics</property>
|
||||
<property name="visible">false</property>
|
||||
<property name="child">
|
||||
<object class="AdwStatusPage">
|
||||
<property name="icon-name">starred-symbolic</property>
|
||||
|
|
@ -298,9 +382,10 @@
|
|||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkStackPage">
|
||||
<object class="GtkStackPage" id="devices_page">
|
||||
<property name="name">devices</property>
|
||||
<property name="title" translatable="yes">Audio Devices</property>
|
||||
<property name="visible">false</property>
|
||||
<property name="child">
|
||||
<object class="AdwStatusPage">
|
||||
<property name="icon-name">audio-speakers-symbolic</property>
|
||||
|
|
@ -322,5 +407,5 @@
|
|||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</template>
|
||||
</interface>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<interface>
|
||||
<requires lib="gtk+" version="4.8"/>
|
||||
<requires lib="adw" version="1.2" />
|
||||
<object id="McgShortcutsDialog" class="GtkShortcutsWindow">
|
||||
<template class="McgShortcutsDialog" parent="GtkShortcutsWindow">
|
||||
<property name="modal">1</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
|
|
@ -103,5 +103,5 @@
|
|||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</template>
|
||||
</interface>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
<interface>
|
||||
<requires lib="gtk+" version="4.8" />
|
||||
<requires lib="adw" version="1.2" />
|
||||
<object id="McgAppWindow" class="AdwApplicationWindow">
|
||||
<property name="title">CoverGrid</property>
|
||||
<template class="McgAppWindow" parent="AdwApplicationWindow">
|
||||
<property name="content">
|
||||
<object class="AdwToolbarView" id="toolbar_view">
|
||||
<child type="top">
|
||||
|
|
@ -18,12 +17,13 @@
|
|||
</property>
|
||||
<child>
|
||||
<object class="GtkSwitch" id="headerbar_button_connect">
|
||||
<signal name="state-set" handler="on_headerbar_connection_state_set" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkToggleButton" id="headerbar_button_playpause">
|
||||
<property name="sensitive">false</property>
|
||||
<property name="tooltip-text" translatable="yes">Switch between play and pause</property>
|
||||
<signal name="toggled" handler="on_headerbar_playpause_toggled" swapped="no"/>
|
||||
<child>
|
||||
<object class="GtkImage">
|
||||
<property name="can-focus">False</property>
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
</child>
|
||||
<child>
|
||||
<object class="GtkVolumeButton" id="headerbar_button_volume">
|
||||
<property name="sensitive">false</property>
|
||||
<signal name="value-changed" handler="on_headerbar_volume_changed" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
|
|
@ -44,19 +44,14 @@
|
|||
</object>
|
||||
</child>
|
||||
<property name="content">
|
||||
<object class="GtkBox">
|
||||
<object class="GtkBox" id="content_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="AdwToastOverlay" id="info_toast">
|
||||
<child>
|
||||
<object class="GtkStack" id="content_stack">
|
||||
<property name="name">content_stack</property>
|
||||
<object class="AdwViewStack" id="panel_stack">
|
||||
<property name="vexpand">true</property>
|
||||
<child>
|
||||
<object class="AdwViewStack" id="panel_stack">
|
||||
<property name="vexpand">true</property>
|
||||
</object>
|
||||
</child>
|
||||
<signal name="notify::visible-child" handler="on_stack_switched" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
|
|
@ -69,13 +64,9 @@
|
|||
</binding>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkDrawingArea" id="resize_helper">
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</template>
|
||||
</interface>
|
||||
|
|
|
|||
14
data/xyz.suruatoel.mcg.gresource.xml
Normal file
14
data/xyz.suruatoel.mcg.gresource.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?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/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>
|
||||
63
data/xyz.suruatoel.mcg.gschema.xml
Normal file
63
data/xyz.suruatoel.mcg.gschema.xml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?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>
|
||||
12
go.mod
12
go.mod
|
|
@ -1,12 +0,0 @@
|
|||
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
6
go.sum
|
|
@ -1,6 +0,0 @@
|
|||
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=
|
||||
|
|
@ -1,947 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package client
|
||||
|
||||
type listCommand struct {
|
||||
Action string
|
||||
Args []string
|
||||
}
|
||||
|
||||
func newListCommand(action string, args ...string) listCommand {
|
||||
return listCommand{action, args}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
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))
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package common
|
||||
|
||||
type OutputDevice struct {
|
||||
ID string
|
||||
Name string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func NewOutputDevice(id string, name string) *OutputDevice {
|
||||
return &OutputDevice{
|
||||
ID: id,
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
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())
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,563 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
|
||||
"github.com/diamondburned/gotk4/pkg/gtk/v4"
|
||||
"suruatoel.xyz/mcg/data"
|
||||
"suruatoel.xyz/mcg/internal/common"
|
||||
)
|
||||
|
||||
type serverPanel struct {
|
||||
*adw.Bin
|
||||
|
||||
toolbar *gtk.Box
|
||||
statusFile *gtk.Label
|
||||
statusAudio *gtk.Label
|
||||
statusBitrate *gtk.Label
|
||||
statusErrer *gtk.Label
|
||||
statsArtist *gtk.Label
|
||||
statsAlbums *gtk.Label
|
||||
statsSongs *gtk.Label
|
||||
statsDBPlaytime *gtk.Label
|
||||
statsPlaytime *gtk.Label
|
||||
statsUptime *gtk.Label
|
||||
outputDevices *gtk.ListBox
|
||||
|
||||
outputDeviceChangeCB OutputDeviceChangeCB
|
||||
}
|
||||
|
||||
type OutputDeviceChangeCB func(device *common.OutputDevice, enable bool)
|
||||
|
||||
func newServerPanel() *serverPanel {
|
||||
panel := serverPanel{}
|
||||
panel.loadWidgets()
|
||||
|
||||
return &panel
|
||||
}
|
||||
|
||||
func (p *serverPanel) loadWidgets() {
|
||||
builder := gtk.NewBuilderFromString(data.ServerPanelXML)
|
||||
|
||||
p.Bin = builder.GetObject("McgServerPanel").Cast().(*adw.Bin)
|
||||
p.toolbar = builder.GetObject("toolbar").Cast().(*gtk.Box)
|
||||
p.statusFile = builder.GetObject("status_file").Cast().(*gtk.Label)
|
||||
p.statusAudio = builder.GetObject("status_audio").Cast().(*gtk.Label)
|
||||
p.statusBitrate = builder.GetObject("status_bitrate").Cast().(*gtk.Label)
|
||||
p.statusErrer = builder.GetObject("status_error").Cast().(*gtk.Label)
|
||||
p.statsArtist = builder.GetObject("stats_artists").Cast().(*gtk.Label)
|
||||
p.statsAlbums = builder.GetObject("stats_albums").Cast().(*gtk.Label)
|
||||
p.statsSongs = builder.GetObject("stats_songs").Cast().(*gtk.Label)
|
||||
p.statsDBPlaytime = builder.GetObject("stats_dbplaytime").Cast().(*gtk.Label)
|
||||
p.statsPlaytime = builder.GetObject("stats_playtime").Cast().(*gtk.Label)
|
||||
p.statsUptime = builder.GetObject("stats_uptime").Cast().(*gtk.Label)
|
||||
p.outputDevices = builder.GetObject("output_devices").Cast().(*gtk.ListBox)
|
||||
}
|
||||
|
||||
func (p *serverPanel) ReceiveOutputDeviceChange(callback OutputDeviceChangeCB) {
|
||||
p.outputDeviceChangeCB = callback
|
||||
}
|
||||
|
||||
func (p *serverPanel) Toolbar() gtk.Widgetter {
|
||||
return p.toolbar
|
||||
}
|
||||
|
||||
func (p *serverPanel) Status(file, audio, bitrate, errorMessage string) {
|
||||
p.statusFile.SetText(file)
|
||||
p.statusAudio.SetText(p.formatAudio(audio))
|
||||
p.statusBitrate.SetText(p.formatBitrate(bitrate))
|
||||
p.statusErrer.SetText(errorMessage)
|
||||
}
|
||||
|
||||
func (p *serverPanel) formatAudio(audio string) string {
|
||||
parts := strings.Split(audio, ":")
|
||||
if len(parts) == 3 {
|
||||
return fmt.Sprintf("%s Hz, %s bit, %s channels", parts[0], parts[1], parts[2])
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *serverPanel) formatBitrate(bitrate string) string {
|
||||
if bitrate != "" {
|
||||
return bitrate + " kb/s"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *serverPanel) Statistics(artists, albums, songs, dbplaytime, playtime, uptime string) {
|
||||
p.statsArtist.SetText(artists)
|
||||
p.statsAlbums.SetText(albums)
|
||||
p.statsSongs.SetText(songs)
|
||||
p.statsDBPlaytime.SetText(dbplaytime)
|
||||
p.statsPlaytime.SetText(playtime)
|
||||
p.statsUptime.SetText(uptime)
|
||||
}
|
||||
|
||||
func (p *serverPanel) OutputDevices(devices []*common.OutputDevice) {
|
||||
p.outputDevices.RemoveAll()
|
||||
for _, device := range devices {
|
||||
deviceButton := gtk.NewCheckButtonWithLabel(device.Name)
|
||||
if device.Enabled {
|
||||
deviceButton.SetActive(true)
|
||||
}
|
||||
deviceButton.ConnectToggled(func() {
|
||||
if p.outputDeviceChangeCB != nil {
|
||||
p.outputDeviceChangeCB(device, deviceButton.Active())
|
||||
}
|
||||
})
|
||||
|
||||
p.outputDevices.Insert(deviceButton, -1)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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.
|
|
@ -1,333 +0,0 @@
|
|||
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
18
main.go
|
|
@ -1,18 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
19
meson.build
Normal file
19
meson.build
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
project('mcg',
|
||||
version: '4.0.1',
|
||||
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,
|
||||
)
|
||||
|
|
@ -1 +1 @@
|
|||
de
|
||||
en de
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
data/ui/album-headerbar.ui
|
||||
data/ui/connection-panel.ui
|
||||
data/ui/cover-panel.ui
|
||||
data/ui/library-panel.ui
|
||||
data/ui/playlist-panel.ui
|
||||
|
|
@ -9,7 +8,6 @@ data/ui/window.ui
|
|||
src/albumheaderbar.py
|
||||
src/application.py
|
||||
src/client.py
|
||||
src/connectionpanel.py
|
||||
src/coverpanel.py
|
||||
src/librarypanel.py
|
||||
src/main.py
|
||||
|
|
|
|||
BIN
po/de.mo
BIN
po/de.mo
Binary file not shown.
98
po/de.po
98
po/de.po
|
|
@ -2,8 +2,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: CoverGrid (mcg)\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-05-22 14:39+0200\n"
|
||||
"PO-Revision-Date: 2024-05-22 14:39+0200\n"
|
||||
"POT-Creation-Date: 2024-05-24 16:44+0200\n"
|
||||
"PO-Revision-Date: 2024-05-24 16:45+0200\n"
|
||||
"Last-Translator: coderkun <olli@suruatoel.xyz>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: de_DE\n"
|
||||
|
|
@ -15,22 +15,6 @@ msgstr ""
|
|||
"X-Poedit-Basepath: ../../..\n"
|
||||
"X-Poedit-SourceCharset: UTF-8\n"
|
||||
|
||||
#: data/ui/connection-panel.ui:29
|
||||
msgid "No service found"
|
||||
msgstr "Keine Dienste gefunden"
|
||||
|
||||
#: data/ui/connection-panel.ui:43
|
||||
msgid "Host"
|
||||
msgstr "Host"
|
||||
|
||||
#: data/ui/connection-panel.ui:56
|
||||
msgid "Port"
|
||||
msgstr "Port"
|
||||
|
||||
#: data/ui/connection-panel.ui:76
|
||||
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"
|
||||
|
|
@ -79,15 +63,15 @@ msgstr "Einstellungen und Aktionen"
|
|||
msgid "search library"
|
||||
msgstr "Bibliothek durchsuchen"
|
||||
|
||||
#: data/ui/library-panel.ui:263 data/ui/playlist-panel.ui:107
|
||||
#: data/ui/library-panel.ui:264 data/ui/playlist-panel.ui:107
|
||||
msgid "cancel"
|
||||
msgstr "abbrechen"
|
||||
|
||||
#: data/ui/library-panel.ui:270 data/ui/library-panel.ui:327
|
||||
#: data/ui/library-panel.ui:271 data/ui/library-panel.ui:328
|
||||
msgid "queue"
|
||||
msgstr "einreihen"
|
||||
|
||||
#: data/ui/library-panel.ui:320 data/ui/playlist-panel.ui:163
|
||||
#: data/ui/library-panel.ui:321 data/ui/playlist-panel.ui:163
|
||||
msgid "play"
|
||||
msgstr "abspielen"
|
||||
|
||||
|
|
@ -99,60 +83,80 @@ msgstr "Die Wiedergabeliste leeren"
|
|||
msgid "remove"
|
||||
msgstr "entfernen"
|
||||
|
||||
#: data/ui/server-panel.ui:35
|
||||
#: data/ui/server-panel.ui:42
|
||||
msgid "Connection"
|
||||
msgstr "Verbindung"
|
||||
|
||||
#: data/ui/server-panel.ui:55
|
||||
msgid "No service found"
|
||||
msgstr "Keine Dienste gefunden"
|
||||
|
||||
#: data/ui/server-panel.ui:69
|
||||
msgid "Host"
|
||||
msgstr "Host"
|
||||
|
||||
#: data/ui/server-panel.ui:82
|
||||
msgid "Port"
|
||||
msgstr "Port"
|
||||
|
||||
#: data/ui/server-panel.ui:102
|
||||
msgid "Password"
|
||||
msgstr "Passwort"
|
||||
|
||||
#: data/ui/server-panel.ui:117
|
||||
msgid "Status"
|
||||
msgstr "Status"
|
||||
|
||||
#: data/ui/server-panel.ui:49
|
||||
#: data/ui/server-panel.ui:132
|
||||
msgid "File:"
|
||||
msgstr "Datei:"
|
||||
|
||||
#: data/ui/server-panel.ui:60
|
||||
#: data/ui/server-panel.ui:143
|
||||
msgid "Audio:"
|
||||
msgstr "Audio:"
|
||||
|
||||
#: data/ui/server-panel.ui:71
|
||||
#: data/ui/server-panel.ui:154
|
||||
msgid "Bitrate:"
|
||||
msgstr "Bitrate:"
|
||||
|
||||
#: data/ui/server-panel.ui:82
|
||||
#: data/ui/server-panel.ui:165
|
||||
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
|
||||
#: data/ui/server-panel.ui:175 data/ui/server-panel.ui:189
|
||||
#: data/ui/server-panel.ui:203 data/ui/server-panel.ui:217
|
||||
msgid "<i>none</i>"
|
||||
msgstr "<i>nichts</i>"
|
||||
|
||||
#: data/ui/server-panel.ui:154
|
||||
#: data/ui/server-panel.ui:237
|
||||
msgid "Statistics"
|
||||
msgstr "Statistiken"
|
||||
|
||||
#: data/ui/server-panel.ui:178
|
||||
#: data/ui/server-panel.ui:262
|
||||
msgid "Artists"
|
||||
msgstr "Künstler"
|
||||
|
||||
#: data/ui/server-panel.ui:198
|
||||
#: data/ui/server-panel.ui:282
|
||||
msgid "Albums"
|
||||
msgstr "Alben"
|
||||
|
||||
#: data/ui/server-panel.ui:218
|
||||
#: data/ui/server-panel.ui:302
|
||||
msgid "Songs"
|
||||
msgstr "Songs"
|
||||
|
||||
#: data/ui/server-panel.ui:238
|
||||
#: data/ui/server-panel.ui:322
|
||||
msgid "Seconds"
|
||||
msgstr "Sekunden"
|
||||
|
||||
#: data/ui/server-panel.ui:267
|
||||
#: data/ui/server-panel.ui:351
|
||||
msgid "Seconds played"
|
||||
msgstr "Sekunden gespielt"
|
||||
|
||||
#: data/ui/server-panel.ui:287
|
||||
#: data/ui/server-panel.ui:371
|
||||
msgid "Seconds running"
|
||||
msgstr "Sekunden laufend"
|
||||
|
||||
#: data/ui/server-panel.ui:303
|
||||
#: data/ui/server-panel.ui:387
|
||||
msgid "Audio Devices"
|
||||
msgstr "Audiogeräte"
|
||||
|
||||
|
|
@ -208,18 +212,18 @@ msgstr "Cover-Paneel"
|
|||
msgid "Library Panel"
|
||||
msgstr "Bibliothekspaneel"
|
||||
|
||||
#: src/connectionpanel.py:51
|
||||
msgid "use"
|
||||
msgstr "verwenden"
|
||||
|
||||
#: src/librarypanel.py:291
|
||||
msgid "Loading albums"
|
||||
msgstr "Alben werden geladen"
|
||||
|
||||
#: src/librarypanel.py:379
|
||||
#: src/librarypanel.py:370
|
||||
msgid "Loading images"
|
||||
msgstr "Bilder werden geladen"
|
||||
|
||||
#: src/serverpanel.py:77
|
||||
msgid "use"
|
||||
msgstr "verwenden"
|
||||
|
||||
#: src/utils.py:50 src/utils.py:67
|
||||
msgid "{} feat. {}"
|
||||
msgstr "{} mit {}"
|
||||
|
|
@ -228,19 +232,20 @@ msgstr "{} mit {}"
|
|||
msgid "{}:{} minutes"
|
||||
msgstr "{}:{} Minuten"
|
||||
|
||||
#: src/window.py:114
|
||||
#. Stack
|
||||
#: src/window.py:106
|
||||
msgid "Server"
|
||||
msgstr "Server"
|
||||
|
||||
#: src/window.py:115
|
||||
#: src/window.py:107
|
||||
msgid "Cover"
|
||||
msgstr "Cover"
|
||||
|
||||
#: src/window.py:116
|
||||
#: src/window.py:108
|
||||
msgid "Playlist"
|
||||
msgstr "Wiedergabeliste"
|
||||
|
||||
#: src/window.py:117
|
||||
#: src/window.py:109
|
||||
msgid "Library"
|
||||
msgstr "Bibliothek"
|
||||
|
||||
|
|
@ -275,9 +280,6 @@ msgstr "Bibliothek"
|
|||
#~ msgid "Search Library"
|
||||
#~ msgstr "Bibliothek durchsuchen"
|
||||
|
||||
#~ msgid "Connection"
|
||||
#~ msgstr "Verbindung"
|
||||
|
||||
#~ msgid "Keyboard Shortcuts"
|
||||
#~ msgstr "Tastenkombinationen"
|
||||
|
||||
|
|
|
|||
BIN
po/en.mo
BIN
po/en.mo
Binary file not shown.
98
po/en.po
98
po/en.po
|
|
@ -2,8 +2,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: CoverGrid (mcg)\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-05-22 14:39+0200\n"
|
||||
"PO-Revision-Date: 2024-05-22 14:39+0200\n"
|
||||
"POT-Creation-Date: 2024-05-24 16:44+0200\n"
|
||||
"PO-Revision-Date: 2024-05-24 16:45+0200\n"
|
||||
"Last-Translator: coderkun <olli@suruatoel.xyz>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: en\n"
|
||||
|
|
@ -16,22 +16,6 @@ msgstr ""
|
|||
"X-Poedit-SearchPath-0: mcg\n"
|
||||
"X-Poedit-SearchPath-1: data/ui\n"
|
||||
|
||||
#: data/ui/connection-panel.ui:29
|
||||
msgid "No service found"
|
||||
msgstr "No service found"
|
||||
|
||||
#: data/ui/connection-panel.ui:43
|
||||
msgid "Host"
|
||||
msgstr "Host"
|
||||
|
||||
#: data/ui/connection-panel.ui:56
|
||||
msgid "Port"
|
||||
msgstr "Port"
|
||||
|
||||
#: data/ui/connection-panel.ui:76
|
||||
msgid "Password"
|
||||
msgstr "Password"
|
||||
|
||||
#: data/ui/cover-panel.ui:12 data/ui/shortcuts-dialog.ui:88
|
||||
msgid "Show the cover in fullscreen mode"
|
||||
msgstr "Show the cover in fullscreen mode"
|
||||
|
|
@ -80,15 +64,15 @@ msgstr "Settings and actions"
|
|||
msgid "search library"
|
||||
msgstr "search library"
|
||||
|
||||
#: data/ui/library-panel.ui:263 data/ui/playlist-panel.ui:107
|
||||
#: data/ui/library-panel.ui:264 data/ui/playlist-panel.ui:107
|
||||
msgid "cancel"
|
||||
msgstr "cancel"
|
||||
|
||||
#: data/ui/library-panel.ui:270 data/ui/library-panel.ui:327
|
||||
#: data/ui/library-panel.ui:271 data/ui/library-panel.ui:328
|
||||
msgid "queue"
|
||||
msgstr "queue"
|
||||
|
||||
#: data/ui/library-panel.ui:320 data/ui/playlist-panel.ui:163
|
||||
#: data/ui/library-panel.ui:321 data/ui/playlist-panel.ui:163
|
||||
msgid "play"
|
||||
msgstr "play"
|
||||
|
||||
|
|
@ -100,60 +84,80 @@ msgstr "Clear the playlist"
|
|||
msgid "remove"
|
||||
msgstr "remove"
|
||||
|
||||
#: data/ui/server-panel.ui:35
|
||||
#: data/ui/server-panel.ui:42
|
||||
msgid "Connection"
|
||||
msgstr "Connection"
|
||||
|
||||
#: data/ui/server-panel.ui:55
|
||||
msgid "No service found"
|
||||
msgstr "No service found"
|
||||
|
||||
#: data/ui/server-panel.ui:69
|
||||
msgid "Host"
|
||||
msgstr "Host"
|
||||
|
||||
#: data/ui/server-panel.ui:82
|
||||
msgid "Port"
|
||||
msgstr "Port"
|
||||
|
||||
#: data/ui/server-panel.ui:102
|
||||
msgid "Password"
|
||||
msgstr "Password"
|
||||
|
||||
#: data/ui/server-panel.ui:117
|
||||
msgid "Status"
|
||||
msgstr "Status"
|
||||
|
||||
#: data/ui/server-panel.ui:49
|
||||
#: data/ui/server-panel.ui:132
|
||||
msgid "File:"
|
||||
msgstr "File:"
|
||||
|
||||
#: data/ui/server-panel.ui:60
|
||||
#: data/ui/server-panel.ui:143
|
||||
msgid "Audio:"
|
||||
msgstr "Audio:"
|
||||
|
||||
#: data/ui/server-panel.ui:71
|
||||
#: data/ui/server-panel.ui:154
|
||||
msgid "Bitrate:"
|
||||
msgstr "Bitrate:"
|
||||
|
||||
#: data/ui/server-panel.ui:82
|
||||
#: data/ui/server-panel.ui:165
|
||||
msgid "Error:"
|
||||
msgstr "Error:"
|
||||
|
||||
#: data/ui/server-panel.ui:92 data/ui/server-panel.ui:106
|
||||
#: data/ui/server-panel.ui:120 data/ui/server-panel.ui:134
|
||||
#: data/ui/server-panel.ui:175 data/ui/server-panel.ui:189
|
||||
#: data/ui/server-panel.ui:203 data/ui/server-panel.ui:217
|
||||
msgid "<i>none</i>"
|
||||
msgstr "<i>none</i>"
|
||||
|
||||
#: data/ui/server-panel.ui:154
|
||||
#: data/ui/server-panel.ui:237
|
||||
msgid "Statistics"
|
||||
msgstr "Statistics"
|
||||
|
||||
#: data/ui/server-panel.ui:178
|
||||
#: data/ui/server-panel.ui:262
|
||||
msgid "Artists"
|
||||
msgstr "Artists"
|
||||
|
||||
#: data/ui/server-panel.ui:198
|
||||
#: data/ui/server-panel.ui:282
|
||||
msgid "Albums"
|
||||
msgstr "Albums"
|
||||
|
||||
#: data/ui/server-panel.ui:218
|
||||
#: data/ui/server-panel.ui:302
|
||||
msgid "Songs"
|
||||
msgstr "Songs"
|
||||
|
||||
#: data/ui/server-panel.ui:238
|
||||
#: data/ui/server-panel.ui:322
|
||||
msgid "Seconds"
|
||||
msgstr "Seconds"
|
||||
|
||||
#: data/ui/server-panel.ui:267
|
||||
#: data/ui/server-panel.ui:351
|
||||
msgid "Seconds played"
|
||||
msgstr "Seconds"
|
||||
|
||||
#: data/ui/server-panel.ui:287
|
||||
#: data/ui/server-panel.ui:371
|
||||
msgid "Seconds running"
|
||||
msgstr "Seconds running"
|
||||
|
||||
#: data/ui/server-panel.ui:303
|
||||
#: data/ui/server-panel.ui:387
|
||||
msgid "Audio Devices"
|
||||
msgstr "Audio Devices"
|
||||
|
||||
|
|
@ -209,18 +213,18 @@ msgstr "Cover Panel"
|
|||
msgid "Library Panel"
|
||||
msgstr "Library Panel"
|
||||
|
||||
#: src/connectionpanel.py:51
|
||||
msgid "use"
|
||||
msgstr "use"
|
||||
|
||||
#: src/librarypanel.py:291
|
||||
msgid "Loading albums"
|
||||
msgstr "Loading albums"
|
||||
|
||||
#: src/librarypanel.py:379
|
||||
#: src/librarypanel.py:370
|
||||
msgid "Loading images"
|
||||
msgstr "Loading images"
|
||||
|
||||
#: src/serverpanel.py:77
|
||||
msgid "use"
|
||||
msgstr "use"
|
||||
|
||||
#: src/utils.py:50 src/utils.py:67
|
||||
msgid "{} feat. {}"
|
||||
msgstr "{} feat. {}"
|
||||
|
|
@ -229,19 +233,20 @@ msgstr "{} feat. {}"
|
|||
msgid "{}:{} minutes"
|
||||
msgstr "{}:{} minutes"
|
||||
|
||||
#: src/window.py:114
|
||||
#. Stack
|
||||
#: src/window.py:106
|
||||
msgid "Server"
|
||||
msgstr "Server"
|
||||
|
||||
#: src/window.py:115
|
||||
#: src/window.py:107
|
||||
msgid "Cover"
|
||||
msgstr "Cover"
|
||||
|
||||
#: src/window.py:116
|
||||
#: src/window.py:108
|
||||
msgid "Playlist"
|
||||
msgstr "Playlist"
|
||||
|
||||
#: src/window.py:117
|
||||
#: src/window.py:109
|
||||
msgid "Library"
|
||||
msgstr "Library"
|
||||
|
||||
|
|
@ -276,9 +281,6 @@ msgstr "Library"
|
|||
#~ msgid "Search Library"
|
||||
#~ msgstr "Search Library"
|
||||
|
||||
#~ msgid "Connection"
|
||||
#~ msgstr "Connection"
|
||||
|
||||
#~ msgid "Keyboard Shortcuts"
|
||||
#~ msgstr "Keyboard Shortcuts"
|
||||
|
||||
|
|
|
|||
179
po/mcg.pot
179
po/mcg.pot
|
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: mcg\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-09 19:13+0200\n"
|
||||
"POT-Creation-Date: 2024-05-24 16:44+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
|
@ -17,172 +17,148 @@ msgstr ""
|
|||
"Content-Type: text/plain; charset=CHARSET\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: internal/common/album.go:93 internal/gui/coverpanel.go:290
|
||||
#, go-format
|
||||
msgid "%d:%d minutes"
|
||||
msgstr ""
|
||||
|
||||
#: internal/gui/application.go:108
|
||||
msgid ""
|
||||
"CoverGrid is a client for the Music Player Daemon, focusing on albums "
|
||||
"instead of single tracks."
|
||||
msgstr ""
|
||||
|
||||
#: internal/gui/coverpanel.go:277
|
||||
#, go-format
|
||||
msgid "%s feat. %s"
|
||||
msgstr ""
|
||||
|
||||
#: internal/gui/window.go:117
|
||||
msgid "Server"
|
||||
msgstr ""
|
||||
|
||||
#: internal/gui/window.go:118
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: internal/gui/window.go:119
|
||||
msgid "Playlist"
|
||||
msgstr ""
|
||||
|
||||
#: internal/gui/window.go:120
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/connection-panel.ui:26
|
||||
msgid "Host"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/connection-panel.ui:38
|
||||
msgid "Port"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/connection-panel.ui:57
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/cover-panel.ui:12 data/ui/shortcuts-dialog.ui:88
|
||||
msgid "Show the cover in fullscreen mode"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:33
|
||||
#: data/ui/library-panel.ui:34
|
||||
msgid "update library"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:46
|
||||
#: data/ui/library-panel.ui:48
|
||||
msgid "Sort"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:51
|
||||
#: data/ui/library-panel.ui:53
|
||||
msgid "sort by artist"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:58
|
||||
#: data/ui/library-panel.ui:61
|
||||
msgid "sort by title"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:65
|
||||
#: data/ui/library-panel.ui:69
|
||||
msgid "sort by year"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:71
|
||||
#: data/ui/library-panel.ui:76
|
||||
msgid "sort by modification"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:78
|
||||
#: data/ui/library-panel.ui:84
|
||||
msgid "sort library descending"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:98 data/ui/shortcuts-dialog.ui:99
|
||||
#: data/ui/library-panel.ui:105 data/ui/shortcuts-dialog.ui:99
|
||||
msgid "Search the library"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:111 data/ui/playlist-panel.ui:14
|
||||
#: data/ui/library-panel.ui:121 data/ui/playlist-panel.ui:14
|
||||
msgid "Select multiple albums"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:124
|
||||
#: data/ui/library-panel.ui:135
|
||||
msgid "Settings and actions"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:149
|
||||
#: data/ui/library-panel.ui:160
|
||||
msgid "search library"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:216 data/ui/playlist-panel.ui:69
|
||||
#: data/ui/library-panel.ui:264 data/ui/playlist-panel.ui:107
|
||||
msgid "cancel"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:222 data/ui/library-panel.ui:277
|
||||
#: data/ui/library-panel.ui:271 data/ui/library-panel.ui:328
|
||||
msgid "queue"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/library-panel.ui:271 data/ui/playlist-panel.ui:123
|
||||
#: data/ui/library-panel.ui:321 data/ui/playlist-panel.ui:163
|
||||
msgid "play"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/playlist-panel.ui:27 data/ui/shortcuts-dialog.ui:77
|
||||
#: data/ui/playlist-panel.ui:28 data/ui/shortcuts-dialog.ui:77
|
||||
msgid "Clear the playlist"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/playlist-panel.ui:75 data/ui/playlist-panel.ui:129
|
||||
#: data/ui/playlist-panel.ui:114 data/ui/playlist-panel.ui:170
|
||||
msgid "remove"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:35
|
||||
msgid "Status"
|
||||
#: data/ui/server-panel.ui:42
|
||||
msgid "Connection"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:49
|
||||
msgid "File:"
|
||||
#: data/ui/server-panel.ui:55
|
||||
msgid "No service found"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:60
|
||||
msgid "Audio:"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:71
|
||||
msgid "Bitrate:"
|
||||
#: data/ui/server-panel.ui:69
|
||||
msgid "Host"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:82
|
||||
msgid "Error:"
|
||||
msgid "Port"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:92 data/ui/server-panel.ui:106
|
||||
#: data/ui/server-panel.ui:120 data/ui/server-panel.ui:134
|
||||
msgid "<i>none</i>"
|
||||
#: data/ui/server-panel.ui:102
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:117
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:132
|
||||
msgid "File:"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:143
|
||||
msgid "Audio:"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:154
|
||||
msgid "Bitrate:"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:165
|
||||
msgid "Error:"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:175 data/ui/server-panel.ui:189
|
||||
#: data/ui/server-panel.ui:203 data/ui/server-panel.ui:217
|
||||
msgid "<i>none</i>"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:237
|
||||
msgid "Statistics"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:178
|
||||
#: data/ui/server-panel.ui:262
|
||||
msgid "Artists"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:198
|
||||
#: data/ui/server-panel.ui:282
|
||||
msgid "Albums"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:218
|
||||
#: data/ui/server-panel.ui:302
|
||||
msgid "Songs"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:238
|
||||
#: data/ui/server-panel.ui:322
|
||||
msgid "Seconds"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:267
|
||||
#: data/ui/server-panel.ui:351
|
||||
msgid "Seconds played"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:287
|
||||
#: data/ui/server-panel.ui:371
|
||||
msgid "Seconds running"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/server-panel.ui:303
|
||||
#: data/ui/server-panel.ui:387
|
||||
msgid "Audio Devices"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -226,7 +202,7 @@ msgstr ""
|
|||
msgid "Connect or disconnect"
|
||||
msgstr ""
|
||||
|
||||
#: data/ui/shortcuts-dialog.ui:71 data/ui/window.ui:26
|
||||
#: data/ui/shortcuts-dialog.ui:71 data/ui/window.ui:25
|
||||
msgid "Switch between play and pause"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -237,3 +213,40 @@ msgstr ""
|
|||
#: data/ui/shortcuts-dialog.ui:95
|
||||
msgid "Library Panel"
|
||||
msgstr ""
|
||||
|
||||
#: src/librarypanel.py:291
|
||||
msgid "Loading albums"
|
||||
msgstr ""
|
||||
|
||||
#: src/librarypanel.py:370
|
||||
msgid "Loading images"
|
||||
msgstr ""
|
||||
|
||||
#: src/serverpanel.py:77
|
||||
msgid "use"
|
||||
msgstr ""
|
||||
|
||||
#: src/utils.py:50 src/utils.py:67
|
||||
msgid "{} feat. {}"
|
||||
msgstr ""
|
||||
|
||||
#: src/utils.py:61
|
||||
msgid "{}:{} minutes"
|
||||
msgstr ""
|
||||
|
||||
#. Stack
|
||||
#: src/window.py:106
|
||||
msgid "Server"
|
||||
msgstr ""
|
||||
|
||||
#: src/window.py:107
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/window.py:108
|
||||
msgid "Playlist"
|
||||
msgstr ""
|
||||
|
||||
#: src/window.py:109
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
|
|
|||
19
src/__init__.py
Normal file
19
src/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#!/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
|
||||
28
src/albumheaderbar.py
Normal file
28
src/albumheaderbar.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/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()))
|
||||
108
src/application.py
Normal file
108
src/application.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#!/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()
|
||||
1240
src/client.py
Normal file
1240
src/client.py
Normal file
File diff suppressed because it is too large
Load diff
254
src/coverpanel.py
Normal file
254
src/coverpanel.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
#!/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:
|
||||
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.show()
|
||||
472
src/librarypanel.py
Normal file
472
src/librarypanel.py
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
#!/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:
|
||||
if data:
|
||||
# Load image and draw it
|
||||
try:
|
||||
self._standalone_pixbuf = Utils.load_pixbuf(data)
|
||||
except Exception:
|
||||
self._logger.exception("Failed to set albumart")
|
||||
self._standalone_pixbuf = self._get_default_image()
|
||||
else:
|
||||
self._standalone_pixbuf = self._get_default_image()
|
||||
# Show image
|
||||
GObject.idle_add(self._show_image)
|
||||
|
||||
def _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]
|
||||
pixbuf = None
|
||||
try:
|
||||
pixbuf = Utils.load_thumbnail(cache, self._client, album, size)
|
||||
except client.CommandException:
|
||||
# Exception is handled by client
|
||||
pass
|
||||
except Exception as e:
|
||||
self._logger.exception("Failed to load albumart", e)
|
||||
if pixbuf is None:
|
||||
pixbuf = self._icon_theme.lookup_icon(
|
||||
Utils.STOCK_ICON_DEFAULT, None, self._item_size,
|
||||
self._item_size, Gtk.TextDirection.LTR,
|
||||
Gtk.IconLookupFlags.FORCE_SYMBOLIC)
|
||||
if pixbuf is not None:
|
||||
self._grid_pixbufs[album.get_id()] = pixbuf
|
||||
GObject.idle_add(self._library_grid_model.append,
|
||||
GridItem(album, pixbuf))
|
||||
|
||||
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:
|
||||
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
|
||||
# Pixelpuffer auf Oberfläche zeichnen
|
||||
self.standalone_image.set_from_pixbuf(
|
||||
pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER))
|
||||
self.standalone_image.show()
|
||||
|
||||
def _get_default_image(self):
|
||||
return self._icon_theme.lookup_icon(Utils.STOCK_ICON_DEFAULT, None,
|
||||
512, 512, 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
|
||||
8
src/main.py
Normal file
8
src/main.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import sys
|
||||
|
||||
from .application import Application
|
||||
|
||||
|
||||
def main(version):
|
||||
app = Application(version)
|
||||
return app.run(sys.argv)
|
||||
25
src/mcg.in
Executable file
25
src/mcg.in
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!@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))
|
||||
36
src/meson.build
Normal file
36
src/meson.build
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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',
|
||||
'coverpanel.py',
|
||||
'librarypanel.py',
|
||||
'playlistpanel.py',
|
||||
'serverpanel.py',
|
||||
'shortcutsdialog.py',
|
||||
'utils.py',
|
||||
'window.py',
|
||||
'zeroconf.py',
|
||||
]
|
||||
|
||||
install_data(mcg_sources, install_dir: moduledir)
|
||||
279
src/playlistpanel.py
Normal file
279
src/playlistpanel.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
#!/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:
|
||||
if data:
|
||||
# Load image and draw it
|
||||
try:
|
||||
self._standalone_pixbuf = Utils.load_pixbuf(data)
|
||||
except Exception:
|
||||
self._logger.exception("Failed to set albumart")
|
||||
self._cover_pixbuf = self._get_default_image()
|
||||
else:
|
||||
self._cover_pixbuf = self._get_default_image()
|
||||
# Show image
|
||||
GObject.idle_add(self._show_image)
|
||||
|
||||
def stop_threads(self):
|
||||
self._playlist_stop.set()
|
||||
|
||||
def _set_playlist(self, host, playlist, size):
|
||||
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:
|
||||
pixbuf = None
|
||||
# Load albumart thumbnail
|
||||
try:
|
||||
pixbuf = Utils.load_thumbnail(cache, self._client, album, size)
|
||||
except client.CommandException:
|
||||
# Exception is handled by client
|
||||
pass
|
||||
except Exception:
|
||||
self._logger.exception("Failed to load albumart")
|
||||
if pixbuf is None:
|
||||
pixbuf = self._icon_theme.lookup_icon(
|
||||
Utils.STOCK_ICON_DEFAULT, None, self._item_size,
|
||||
self._item_size, Gtk.TextDirection.LTR,
|
||||
Gtk.IconLookupFlags.FORCE_SYMBOLIC)
|
||||
if pixbuf is not None:
|
||||
self._playlist_grid_model.append(GridItem(album, pixbuf))
|
||||
|
||||
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:
|
||||
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
|
||||
# Pixelpuffer auf Oberfläche zeichnen
|
||||
self.standalone_image.set_from_pixbuf(
|
||||
pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER))
|
||||
self.standalone_image.show()
|
||||
|
||||
def _get_default_image(self):
|
||||
return self._icon_theme.lookup_icon(Utils.STOCK_ICON_DEFAULT, None,
|
||||
512, 512, 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
|
||||
199
src/serverpanel.py
Normal file
199
src/serverpanel.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version('Gtk', '4.0')
|
||||
gi.require_version('Adw', '1')
|
||||
import locale
|
||||
|
||||
from gi.repository import Gtk, Adw, GObject
|
||||
|
||||
from mcg.zeroconf import ZeroconfProvider
|
||||
|
||||
|
||||
@Gtk.Template(resource_path='/xyz/suruatoel/mcg/ui/server-panel.ui')
|
||||
class ServerPanel(Adw.Bin):
|
||||
__gtype_name__ = 'McgServerPanel'
|
||||
__gsignals__ = {
|
||||
'connection-changed':
|
||||
(GObject.SIGNAL_RUN_FIRST, None, (str, int, str)),
|
||||
'change-output-device': (GObject.SIGNAL_RUN_FIRST, None, (
|
||||
GObject.TYPE_PYOBJECT,
|
||||
bool,
|
||||
)),
|
||||
}
|
||||
|
||||
# Widgets
|
||||
toolbar = Gtk.Template.Child()
|
||||
connection_status_page = Gtk.Template.Child()
|
||||
status_page = Gtk.Template.Child()
|
||||
stats_page = Gtk.Template.Child()
|
||||
devices_page = Gtk.Template.Child()
|
||||
# Connection widgets
|
||||
zeroconf_list = Gtk.Template.Child()
|
||||
host_row = Gtk.Template.Child()
|
||||
port_spinner = Gtk.Template.Child()
|
||||
password_row = 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()
|
||||
|
||||
# Zeroconf provider
|
||||
self._zeroconf_provider = ZeroconfProvider()
|
||||
self._zeroconf_provider.connect_signal(
|
||||
ZeroconfProvider.SIGNAL_SERVICE_NEW, self.on_new_service)
|
||||
|
||||
def set_selected(self, selected):
|
||||
self._is_selected = selected
|
||||
|
||||
def get_toolbar(self):
|
||||
return self.toolbar
|
||||
|
||||
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_connection_sensitive(self, sensitive):
|
||||
self.connection_status_page.set_sensitive(sensitive)
|
||||
self.status_page.set_visible(not sensitive)
|
||||
self.stats_page.set_visible(not sensitive)
|
||||
self.devices_page.set_visible(not sensitive)
|
||||
|
||||
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 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())
|
||||
|
||||
def _call_back(self):
|
||||
self.emit(
|
||||
'connection-changed',
|
||||
self.get_host(),
|
||||
self.get_port(),
|
||||
self.get_password(),
|
||||
)
|
||||
|
||||
15
src/shortcutsdialog.py
Normal file
15
src/shortcutsdialog.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#!/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__()
|
||||
114
src/utils.py
Normal file
114
src/utils.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import gi
|
||||
import hashlib
|
||||
import locale
|
||||
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:
|
||||
pixbuf = pixbuf.scale_simple(size, size,
|
||||
GdkPixbuf.InterpType.HYPER)
|
||||
pixbuf.savev(cache_url, 'jpeg', [], [])
|
||||
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()
|
||||
|
||||
|
||||
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, cover):
|
||||
super().__init__()
|
||||
self._album = album
|
||||
if cover:
|
||||
self.cover = Gdk.Texture.new_for_pixbuf(cover)
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
609
src/window.py
Normal file
609
src/window.py
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
#!/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 .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()
|
||||
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())
|
||||
|
||||
# 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.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._server_panel.set_host(
|
||||
self._settings.get_string(Window.SETTING_HOST))
|
||||
self._server_panel.set_port(self._settings.get_int(
|
||||
Window.SETTING_PORT))
|
||||
if use_keyring:
|
||||
self._server_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._server_panel.connect('connection-changed',
|
||||
self.on_server_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.panel_stack.set_visible_child(self._server_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_server_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._server_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._server_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._server_panel.set_connection_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._server_panel.get_host()
|
||||
port = self._server_panel.get_port()
|
||||
password = self._server_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.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.panel_stack.set_visible_child(self._server_panel)
|
||||
self._server_panel.set_connection_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))
|
||||
72
src/zeroconf.py
Normal file
72
src/zeroconf.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#!/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)
|
||||
Loading…
Reference in a new issue