diff --git a/.gitignore b/.gitignore index 8a28ddc..f42fdfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ -/mcg* +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +/build/ +/install/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index bacb769..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -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. diff --git a/CONTRIBUTING.textile b/CONTRIBUTING.textile new file mode 100644 index 0000000..f5b11fb --- /dev/null +++ b/CONTRIBUTING.textile @@ -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. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 1f5cef4..bb13eb5 100644 --- a/LICENSE +++ b/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 +Copyright ® 2013 – 2017 coderkun 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 diff --git a/README.md b/README.md index c65ad22..f37f2db 100644 --- a/README.md +++ b/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 -![CoverGrid’s cover panel with album details and track list.](https://www.suruatoel.xyz/images/mcg-cover-s.png "CoverGrid’s cover panel with album details and track list.") +![CoverGrid’s cover panel with album details and track list.](https://suruatoel.xyz/images/mcg-cover-s.png "CoverGrid’s cover panel with album details and track list.") -![CoverGrid’s playlist panel with queued albums.](https://www.suruatoel.xyz/images/mcg-playlist.png "CoverGrid’s playlist panel with queued albums.") +![CoverGrid’s playlist panel with queued albums.](https://suruatoel.xyz/images/mcg-playlist.png "CoverGrid’s playlist panel with queued albums.") -![CoverGrid’s library panel showing the albums middle-sized.](https://www.suruatoel.xyz/images/mcg-library-m.png "CoverGrid’s library panel showing the albums middle-sized.") +![CoverGrid’s library panel showing the albums middle-sized.](https://suruatoel.xyz/images/mcg-library-m.png "CoverGrid’s library panel showing the albums middle-sized.") -![CoverGrid’s library panel showing the albums small-sized.](https://www.suruatoel.xyz/images/mcg-library-s.png "CoverGrid’s library panel showing the albums small-sized.") +![CoverGrid’s library panel showing the albums small-sized.](https://suruatoel.xyz/images/mcg-library-s.png "CoverGrid’s library panel showing the albums small-sized.") diff --git a/data/data.go b/data/data.go deleted file mode 100644 index 3383a7d..0000000 --- a/data/data.go +++ /dev/null @@ -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) -} diff --git a/data/gtk.css b/data/gtk.css index 9e74f64..1b361de 100644 --- a/data/gtk.css +++ b/data/gtk.css @@ -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; diff --git a/data/xyz.suruatoel.mcg.svg b/data/icons/hicolor/scalable/apps/xyz.suruatoel.mcg.svg similarity index 100% rename from data/xyz.suruatoel.mcg.svg rename to data/icons/hicolor/scalable/apps/xyz.suruatoel.mcg.svg diff --git a/data/icons/meson.build b/data/icons/meson.build new file mode 100644 index 0000000..20bdb7c --- /dev/null +++ b/data/icons/meson.build @@ -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) +) diff --git a/data/ui/album-headerbar.ui b/data/ui/album-headerbar.ui index 8a1be95..bda26a8 100644 --- a/data/ui/album-headerbar.ui +++ b/data/ui/album-headerbar.ui @@ -2,7 +2,7 @@ - + diff --git a/data/ui/connection-panel.ui b/data/ui/connection-panel.ui deleted file mode 100644 index 42a280c..0000000 --- a/data/ui/connection-panel.ui +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - 1024 - 9999 - 6600 - 1 - 100 - - - - - Connect to MPD - - - false - center - none - - - - Host - true - - - - - vertical - - - - Port - start - false - - - - - - port_spinner - 6600 - server-port-adjustment - - - - - - - Password - true - - - - - - - - diff --git a/data/ui/cover-panel.ui b/data/ui/cover-panel.ui index f42cfe7..d869e76 100644 --- a/data/ui/cover-panel.ui +++ b/data/ui/cover-panel.ui @@ -19,7 +19,7 @@ - + diff --git a/data/ui/library-panel.ui b/data/ui/library-panel.ui index 2ef262d..b5512e3 100644 --- a/data/ui/library-panel.ui +++ b/data/ui/library-panel.ui @@ -26,11 +26,13 @@ 0 0 False + update library + @@ -51,6 +53,7 @@ sort by artist False sort_year + @@ -58,12 +61,14 @@ sort by title False sort_year + sort by year False + @@ -71,6 +76,7 @@ sort by modification False sort_year + @@ -80,6 +86,7 @@ True False True + @@ -101,6 +108,9 @@ system-search-symbolic + @@ -109,6 +119,7 @@ True True Select multiple albums + object-select-symbolic @@ -134,7 +145,7 @@ - + diff --git a/data/ui/shortcuts-dialog.ui b/data/ui/shortcuts-dialog.ui index f4c9e66..e3dc1d9 100644 --- a/data/ui/shortcuts-dialog.ui +++ b/data/ui/shortcuts-dialog.ui @@ -2,7 +2,7 @@ - + diff --git a/data/ui/window.ui b/data/ui/window.ui index f9717fb..801e6a4 100644 --- a/data/ui/window.ui +++ b/data/ui/window.ui @@ -2,8 +2,7 @@ - - CoverGrid + diff --git a/data/xyz.suruatoel.mcg.gresource.xml b/data/xyz.suruatoel.mcg.gresource.xml new file mode 100644 index 0000000..45476d0 --- /dev/null +++ b/data/xyz.suruatoel.mcg.gresource.xml @@ -0,0 +1,14 @@ + + + + gtk.css + noise-texture.png + ui/window.ui + ui/shortcuts-dialog.ui + ui/album-headerbar.ui + ui/server-panel.ui + ui/cover-panel.ui + ui/playlist-panel.ui + ui/library-panel.ui + + diff --git a/data/xyz.suruatoel.mcg.gschema.xml b/data/xyz.suruatoel.mcg.gschema.xml new file mode 100644 index 0000000..9f50878 --- /dev/null +++ b/data/xyz.suruatoel.mcg.gschema.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + 'localhost' + MPD host + MPD host to connect to + + + 6600 + MPD port + MPD port to connect to + + + false + Connection state + State of last connection + + + 800 + Window width + The window width in pixels. + + + 600 + Window height + The window height in pixels. + + + false + Window maximized + Whether or not the window is in maximized state. + + + + 1 + Last selected panel + The index of the last selected panel. + + + + 150 + Size of library items + The size of items displayed in the library. + + + 'year' + Sort criterium for library items + The sort criterium of items displayed in the library. + + + true + Sort type for library items + The sort type of items displayed in the library. + + + diff --git a/go.mod b/go.mod deleted file mode 100644 index fe1746e..0000000 --- a/go.mod +++ /dev/null @@ -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 diff --git a/go.sum b/go.sum deleted file mode 100644 index d02c405..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/internal/client/client.go b/internal/client/client.go deleted file mode 100644 index 26fb49b..0000000 --- a/internal/client/client.go +++ /dev/null @@ -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 -} diff --git a/internal/client/command.go b/internal/client/command.go deleted file mode 100644 index a584e9f..0000000 --- a/internal/client/command.go +++ /dev/null @@ -1,10 +0,0 @@ -package client - -type listCommand struct { - Action string - Args []string -} - -func newListCommand(action string, args ...string) listCommand { - return listCommand{action, args} -} diff --git a/internal/client/errors.go b/internal/client/errors.go deleted file mode 100644 index 345dc11..0000000 --- a/internal/client/errors.go +++ /dev/null @@ -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) -} diff --git a/internal/client/reader.go b/internal/client/reader.go deleted file mode 100644 index 27fa821..0000000 --- a/internal/client/reader.go +++ /dev/null @@ -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 -} diff --git a/internal/common/album.go b/internal/common/album.go deleted file mode 100644 index 1ce2ea7..0000000 --- a/internal/common/album.go +++ /dev/null @@ -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)) -} diff --git a/internal/common/outputdevice.go b/internal/common/outputdevice.go deleted file mode 100644 index df6aa49..0000000 --- a/internal/common/outputdevice.go +++ /dev/null @@ -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, - } -} diff --git a/internal/common/song.go b/internal/common/song.go deleted file mode 100644 index 1b81806..0000000 --- a/internal/common/song.go +++ /dev/null @@ -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 -} diff --git a/internal/common/sort.go b/internal/common/sort.go deleted file mode 100644 index 7c62117..0000000 --- a/internal/common/sort.go +++ /dev/null @@ -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()) -} diff --git a/internal/gui/albumheaderbar.go b/internal/gui/albumheaderbar.go deleted file mode 100644 index 6b96ec9..0000000 --- a/internal/gui/albumheaderbar.go +++ /dev/null @@ -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) -} diff --git a/internal/gui/application.go b/internal/gui/application.go deleted file mode 100644 index fe64d79..0000000 --- a/internal/gui/application.go +++ /dev/null @@ -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{"q"}) - a.SetAccelsForAction("win.show-help-overlay", []string{"k"}) - a.SetAccelsForAction("app.info", []string{"i"}) - a.SetAccelsForAction("win.connect", []string{"c"}) - a.SetAccelsForAction("win.play", []string{"p"}) - a.SetAccelsForAction("win.clear-playlist", []string{"r"}) - a.SetAccelsForAction("win.toggle-fullscreen", []string{"F11"}) - a.SetAccelsForAction("win.search-library", []string{"f"}) - a.SetAccelsForAction("win.panel(\"server\")", []string{"KP_1"}) - a.SetAccelsForAction("win.panel(\"cover\")", []string{"KP_2"}) - a.SetAccelsForAction("win.panel(\"playlist\")", []string{"KP_3"}) - a.SetAccelsForAction("win.panel(\"library\")", []string{"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() - } -} diff --git a/internal/gui/config.go b/internal/gui/config.go deleted file mode 100644 index 33f47e6..0000000 --- a/internal/gui/config.go +++ /dev/null @@ -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 -} diff --git a/internal/gui/connectionpanel.go b/internal/gui/connectionpanel.go deleted file mode 100644 index 679eb4d..0000000 --- a/internal/gui/connectionpanel.go +++ /dev/null @@ -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() -} diff --git a/internal/gui/coverloader.go b/internal/gui/coverloader.go deleted file mode 100644 index 02d1cd6..0000000 --- a/internal/gui/coverloader.go +++ /dev/null @@ -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() -} diff --git a/internal/gui/coverpanel.go b/internal/gui/coverpanel.go deleted file mode 100644 index b2298af..0000000 --- a/internal/gui/coverpanel.go +++ /dev/null @@ -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 -} diff --git a/internal/gui/librarypanel.go b/internal/gui/librarypanel.go deleted file mode 100644 index 7f749c9..0000000 --- a/internal/gui/librarypanel.go +++ /dev/null @@ -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 -} diff --git a/internal/gui/pictureitemfactory.go b/internal/gui/pictureitemfactory.go deleted file mode 100644 index a1097d1..0000000 --- a/internal/gui/pictureitemfactory.go +++ /dev/null @@ -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 -} diff --git a/internal/gui/playlistpanel.go b/internal/gui/playlistpanel.go deleted file mode 100644 index 0bbd2cb..0000000 --- a/internal/gui/playlistpanel.go +++ /dev/null @@ -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 -} diff --git a/internal/gui/serverpanel.go b/internal/gui/serverpanel.go deleted file mode 100644 index f83fe55..0000000 --- a/internal/gui/serverpanel.go +++ /dev/null @@ -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) - } -} diff --git a/internal/gui/shortcutsdialog.go b/internal/gui/shortcutsdialog.go deleted file mode 100644 index b5b615b..0000000 --- a/internal/gui/shortcutsdialog.go +++ /dev/null @@ -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) -} diff --git a/internal/gui/window.go b/internal/gui/window.go deleted file mode 100644 index d1d295e..0000000 --- a/internal/gui/window.go +++ /dev/null @@ -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) -} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go deleted file mode 100644 index 40183e1..0000000 --- a/internal/i18n/i18n.go +++ /dev/null @@ -1,38 +0,0 @@ -package i18n - -/* -#include -#include -#include - -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)) -} diff --git a/locale/de/LC_MESSAGES/mcg.mo b/locale/de/LC_MESSAGES/mcg.mo deleted file mode 100644 index 79cb779..0000000 Binary files a/locale/de/LC_MESSAGES/mcg.mo and /dev/null differ diff --git a/locale/de/LC_MESSAGES/mcg.po b/locale/de/LC_MESSAGES/mcg.po deleted file mode 100644 index 8819521..0000000 --- a/locale/de/LC_MESSAGES/mcg.po +++ /dev/null @@ -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 \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 "none" -msgstr "nichts" - -#: 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)" diff --git a/main.go b/main.go deleted file mode 100644 index 4268eea..0000000 --- a/main.go +++ /dev/null @@ -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) - } -} diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..a8e88cc --- /dev/null +++ b/meson.build @@ -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, +) diff --git a/po/LINGUAS b/po/LINGUAS index 7673daa..3ae2b72 100644 --- a/po/LINGUAS +++ b/po/LINGUAS @@ -1 +1 @@ -de +en de diff --git a/po/POTFILES b/po/POTFILES index 0263b63..7245744 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -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 diff --git a/po/de.mo b/po/de.mo index 0977fc7..a3e8ff0 100644 Binary files a/po/de.mo and b/po/de.mo differ diff --git a/po/de.po b/po/de.po index 753eb6b..c169b36 100644 --- a/po/de.po +++ b/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 \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 "none" msgstr "nichts" -#: 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" diff --git a/po/en.mo b/po/en.mo index 96e0ea4..3edbb64 100644 Binary files a/po/en.mo and b/po/en.mo differ diff --git a/po/en.po b/po/en.po index 00593af..e30d4d4 100644 --- a/po/en.po +++ b/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 \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 "none" msgstr "none" -#: 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" diff --git a/po/mcg.pot b/po/mcg.pot index f813837..e2cffcb 100644 --- a/po/mcg.pot +++ b/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 \n" "Language-Team: LANGUAGE \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 "none" +#: 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 "none" +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 "" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..c498e0f --- /dev/null +++ b/src/__init__.py @@ -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 diff --git a/src/albumheaderbar.py b/src/albumheaderbar.py new file mode 100644 index 0000000..2f0b40b --- /dev/null +++ b/src/albumheaderbar.py @@ -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())) diff --git a/src/application.py b/src/application.py new file mode 100644 index 0000000..c39b929 --- /dev/null +++ b/src/application.py @@ -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', ['q']) + self.set_accels_for_action('win.show-help-overlay', ['k']) + self.set_accels_for_action('app.info', ['i']) + self.set_accels_for_action('win.connect', ['c']) + self.set_accels_for_action('win.play', ['p']) + self.set_accels_for_action('win.clear-playlist', ['r']) + self.set_accels_for_action('win.toggle-fullscreen', ['F11']) + self.set_accels_for_action('win.search-library', ['f']) + self.set_accels_for_action('win.panel("0")', ['KP_1']) + self.set_accels_for_action('win.panel("1")', ['KP_2']) + self.set_accels_for_action('win.panel("2")', ['KP_3']) + self.set_accels_for_action('win.panel("3")', ['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() diff --git a/src/client.py b/src/client.py new file mode 100644 index 0000000..17b7522 --- /dev/null +++ b/src/client.py @@ -0,0 +1,1240 @@ +#!/usr/bin/env python3 + +import concurrent.futures +import configparser +import dateutil.parser +import logging +import os +import queue +import re +import socket +import threading + +from mcg.utils import SortOrder +from mcg.utils import Utils + + +class MPDException(Exception): + + def __init__(self, error): + super(MPDException, self).__init__(self._parse_error(error)) + self._error = error + + def _parse_error(self, error): + if error: + parts = re.match(r"\[(\d+)@(\d+)\]\s\{(\w+)\}\s(.*)", error) + if parts: + self._error_number = int(parts.group(1)) + self._command_number = int(parts.group(2)) + self._command_name = parts.group(3) + return parts.group(4) + return error + + def get_error(self): + return self._error + + def get_error_number(self): + return self._error_number + + def get_command_number(self): + return self._command_number + + def get_command_name(self): + return self._command_name + + +class ConnectionException(MPDException): + pass + + +class ProtocolException(MPDException): + pass + + +class CommandException(MPDException): + pass + + +class Future(concurrent.futures.Future): + + def __init__(self, signal): + concurrent.futures.Future.__init__(self) + self._signal = signal + + def get_signal(self): + return self._signal + + +class Base(): + + def __init__(self): + self._callbacks = {} + + def connect_signal(self, signal, callback): + """Connect a callback function to a signal (event).""" + self._callbacks[signal] = callback + + def disconnect_signal(self, signal): + """Disconnect a callback function from a signal (event).""" + if self._has_callback(signal): + del self._callbacks[signal] + + def _has_callback(self, signal): + """Check if there is a registered callback function for a signal.""" + return signal in self._callbacks + + def _callback(self, signal, *data): + if signal in self._callbacks: + callback = self._callbacks[signal] + callback(*data) + + def _callback_future(self, future): + self._callback(future.get_signal(), *future.result()) + + +class Client(Base): + """Client library for handling the connection to the Music Player Daemon. + + This class implements an album-based MPD client. It offers a non-blocking + threaded worker model for use in graphical environments. + """ + # Protocol: greeting mark + PROTOCOL_GREETING = 'OK MPD ' + # Protocol: completion mark + PROTOCOL_COMPLETION = 'OK' + # Protocol: error mark + PROTOCOL_ERROR = 'ACK ' + # Protocol: error: permission + PROTOCOL_ERROR_PERMISSION = 4 + # Protocol: error: no exists + PROTOCOL_ERROR_NOEXISTS = 50 + # Signal: connection status + SIGNAL_CONNECTION = 'connection' + # Signal: status + SIGNAL_STATUS = 'status' + # Signal: stats + SIGNAL_STATS = 'stats' + # Signal: init loading of albums + SIGNAL_INIT_ALBUMS = 'init-albums' + # Signal: pulse loading of albums + SIGNAL_PULSE_ALBUMS = 'pulse-albums' + # Signal: load albums + SIGNAL_LOAD_ALBUMS = 'load-albums' + # Signal: load playlist + SIGNAL_LOAD_PLAYLIST = 'load-playlist' + # Signal: load audio output devices + SIGNAL_LOAD_OUTPUT_DEVICES = 'load-output-devices' + # Signal: load albumart + SIGNAL_LOAD_ALBUMART = 'albumart' + # Signal: error + SIGNAL_ERROR = 'error' + # Buffer size for reading from socket + SOCKET_BUFSIZE = 4096 + + def __init__(self): + """Set class variables and instantiates the Client.""" + Base.__init__(self) + self._logger = logging.getLogger(__name__) + self._sock = None + self._buffer = bytearray() + self._sock_write = None + self._stop = threading.Event() + self._actions = queue.Queue() + self._worker = None + self._idling = False + self._host = None + self._albums = {} + self._playlist = [] + self._state = None + + def get_logger(self): + return self._logger + + # Client commands + + def connect(self, host, port, password=None): + """Connect to MPD with the given host, port and password or with + standard values. + """ + self._logger.info("connect") + self._host = host + self._add_action(self._connect, host, port, password) + self._stop.clear() + self._start_worker() + + def is_connected(self): + """Return the connection status.""" + return self._worker is not None and self._worker.is_alive() + + def disconnect(self): + """Disconnect from the connected MPD.""" + self._logger.info("disconnect") + self._stop.set() + self._add_action(self._disconnect) + + def join(self): + self._actions.join() + + def get_status(self): + """Determine the current status.""" + self._logger.info("get status") + self._add_action_signal(Client.SIGNAL_STATUS, self._get_status) + + def get_stats(self): + """Load statistics.""" + self._logger.info("get stats") + self._add_action_signal(Client.SIGNAL_STATS, self._get_stats) + + def get_output_devices(self): + """Determine the list of audio output devices.""" + self._logger.info("get output devices") + self._add_action_signal(Client.SIGNAL_LOAD_OUTPUT_DEVICES, + self._get_output_devices) + + def enable_output_device(self, device, enabled): + """Enable/disable an audio output device.""" + self._logger.info("enable output device") + self._add_action(self._enable_output_device, device, enabled) + + def load_albums(self): + self._logger.info("load albums") + self._add_action_signal(Client.SIGNAL_LOAD_ALBUMS, self._load_albums) + + def update(self): + self._logger.info("update") + self._add_action(self._update) + + def load_playlist(self): + self._logger.info("load playlist") + self._add_action_signal(Client.SIGNAL_LOAD_PLAYLIST, + self._load_playlist) + + def clear_playlist(self): + """Clear the current playlist""" + self._logger.info("clear playlist") + self._add_action(self._clear_playlist) + + def remove_album_from_playlist(self, album): + """Remove the given album from the playlist.""" + self._logger.info("remove album from playlist") + self._add_action(self._remove_album_from_playlist, album) + + def remove_albums_from_playlist(self, albums): + """Remove multiple albums from the playlist in one step.""" + self._logger.info("remove multiple albums from playlist") + self._add_action(self._remove_albums_from_playlist, albums) + + def play_album_from_playlist(self, album): + """Play the given album from the playlist.""" + self._logger.info("play album from playlist") + self._add_action(self._play_album_from_playlist, album) + + def playpause(self): + """Play or pauses the current state.""" + self._logger.info("playpause") + self._add_action(self._playpause) + + def play_album(self, album): + """Add the given album to the queue and play it immediately.""" + self._logger.info("play album") + self._add_action(self._play_album, album) + + def queue_album(self, album): + """Add the given album to the queue.""" + self._logger.info("play album") + self._add_action(self._queue_album, album) + + def queue_albums(self, albums): + """Add the given albums to the queue.""" + self._logger.info("play albums") + self._add_action(self._queue_albums, albums) + + def seek(self, pos, time): + """Seeks to a song at a position""" + self._logger.info("seek") + self._add_action(self._seek, pos, time) + + def stop(self): + self._logger.info("stop") + self._add_action(self._stop) + + def set_volume(self, volume): + self._logger.info("set volume") + self._add_action(self._set_volume, volume) + + def get_albumart(self, album): + self._logger.info("get albumart") + self._add_action_signal(Client.SIGNAL_LOAD_ALBUMART, + self._get_albumart, album) + + def get_albumart_now(self, album): + self._logger.info("get albumart now") + future = concurrent.futures.Future() + self._add_action_future(future, self._get_albumart, album) + (_, albumart) = future.result() + return albumart + + # Private methods + + def _connect(self, host, port, password): + self._logger.info("connecting to host %r, port %r", host, port) + if self._sock is not None: + return + try: + self._sock = self._connect_socket(host, port) + self._sock_write = self._sock.makefile("w", encoding="utf-8") + self._greet() + self._logger.info("connected") + if password: + self._logger.info("setting password") + self._call("password", password) + self._set_connection_status(True) + except OSError as e: + raise ConnectionException("connection failed: {}".format(e)) + + def _connect_socket(self, host, port): + sock = None + error = None + resources = socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM, socket.IPPROTO_TCP) + for res in resources: + af, socktype, proto, _, sa = res + try: + sock = socket.socket(af, socktype, proto) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.connect(sa) + return sock + except Exception as e: + error = e + if sock is not None: + sock.close() + break + if error is not None: + raise ConnectionException("connection failed: {}".format(error)) + else: + raise ConnectionException("no suitable socket") + + def _greet(self): + greeting = self._read_line() + self._logger.debug("greeting: %s", greeting.strip()) + if not greeting.startswith(Client.PROTOCOL_GREETING): + self._disconnect_socket() + raise ProtocolException("invalid greeting: {}".format(greeting)) + self._protocol_version = greeting[len(Client.PROTOCOL_GREETING + ):].strip() + self._logger.debug("protocol version: %s", self._protocol_version) + + def _disconnect(self): + self._logger.info("disconnecting") + self._disconnect_socket() + + def _disconnect_socket(self): + if self._sock_write is not None: + self._sock_write.close() + self._sock_write = None + if self._sock is not None: + self._sock.close() + self._sock = None + self._logger.info("disconnected") + self._set_connection_status(False) + + def _idle(self): + """React to idle events from MPD.""" + self._logger.info("idle") + self._idling = True + subsystems = self._parse_dict(self._call("idle")) + self._idling = False + self._logger.info("idle subsystems: %r", subsystems) + if subsystems: + if subsystems['changed'] == 'player': + self.load_playlist() + self.get_status() + if subsystems['changed'] == 'mixer': + self.get_status() + if subsystems['changed'] == 'playlist': + self.load_playlist() + if subsystems['changed'] == 'database': + self.load_albums() + self.load_playlist() + self.get_status() + if subsystems['changed'] == 'update': + self.load_albums() + self.load_playlist() + self.get_status() + if subsystems['changed'] == 'output': + self.get_output_devices() + self.get_status() + + def _noidle(self): + if self._idling: + self._logger.debug("noidle") + self._write("noidle") + + def _get_status(self): + """Action: Perform the real status determination.""" + self._logger.info("getting status") + status = self._parse_dict(self._call("status")) + self._logger.debug("status: %r", status) + + # State + state = None + if 'state' in status: + state = status['state'] + self._state = state + # Time + time = 0 + if 'time' in status: + time = int(status['time'].split(':')[0]) + # Volume + volume = -1 + if 'volume' in status: + volume = int(status['volume']) + # Error + error = None + if 'error' in status: + error = status['error'] + # Album + file = None + album = None + pos = 0 + song = self._parse_dict(self._call("currentsong")) + if song: + # File + if 'file' in song: + file = song['file'] + # Track + track = self._extract_playlist_track(song) + if track: + # Album + album = self._extract_album(song) + # Position + pos = track.get_pos() + for palbum in self._playlist: + if palbum == album and len(palbum.get_tracks()) >= pos: + album = palbum + break + pos = pos - len(palbum.get_tracks()) + # Audio + audio = None + if 'audio' in status: + audio = status['audio'] + # Bitrate + bitrate = None + if 'bitrate' in status: + bitrate = status['bitrate'] + return (state, album, pos, time, volume, file, audio, bitrate, error) + + def _get_stats(self): + """Action: Perform the real statistics gathering.""" + self._logger.info("getting statistics") + stats = self._parse_dict(self._call("stats")) + self._logger.debug("stats: %r", stats) + + # Artists + artists = 0 + if 'artists' in stats: + artists = int(stats['artists']) + # Albums + albums = 0 + if 'albums' in stats: + albums = int(stats['albums']) + # Songs + songs = 0 + if 'songs' in stats: + songs = int(stats['songs']) + # Database playtime + dbplaytime = 0 + if 'db_playtime' in stats: + dbplaytime = stats['db_playtime'] + # Playtime + playtime = 0 + if 'playtime' in stats: + playtime = stats['playtime'] + # Uptime + uptime = 0 + if 'uptime' in stats: + uptime = stats['uptime'] + return (artists, albums, songs, dbplaytime, playtime, uptime) + + def _get_output_devices(self): + """Action: Perform the real loading of output devices.""" + devices = [] + for output in self._parse_list(self._call('outputs'), ['outputid']): + device = OutputDevice(output['outputid'], output['outputname']) + device.set_enabled(int(output['outputenabled']) == 1) + devices.append(device) + return (devices, ) + + def _enable_output_device(self, device, enabled): + """Action: Perform the real enabling/disabling of an output device.""" + if enabled: + self._call('enableoutput ', device.get_id()) + else: + self._call('disableoutput ', device.get_id()) + + def _load_albums(self): + """Action: Perform the real update.""" + self._callback(Client.SIGNAL_INIT_ALBUMS) + self._albums = {} + # Albums + for album in self._parse_list(self._call('list album'), ['album']): + self._callback(Client.SIGNAL_PULSE_ALBUMS) + + # Album + album = self._extract_album(album) + self._logger.debug("album: %r", album) + # Tracks + songs = self._parse_list( + self._call('find album ', album.get_title()), ['file']) + for song in songs: + track = self._extract_track(song) + if track: + self._logger.debug("track: %r", track) + album.add_track(track) + return (self._albums, ) + + def _update(self): + self._call('update') + + def _load_playlist(self): + self._playlist = [] + songs = self._parse_list(self._call('playlistinfo'), + ['file', 'playlist']) + for song in songs: + self._logger.debug("song: %r", song) + # Track + track = self._extract_playlist_track(song) + self._logger.debug("track: %r", track) + # Album + album = self._extract_album(song, lookup=False) + if ( + len(self._playlist) == 0 + or self._playlist[len(self._playlist) - 1] != album + ): + self._playlist.append(album) + else: + album = self._playlist[len(self._playlist) - 1] + self._logger.debug("album: %r", album) + if track: + album.add_track(track) + return (self._playlist, ) + + def _clear_playlist(self): + """Action: Perform the real clearing of the current playlist.""" + self._call('clear') + + def _remove_album_from_playlist(self, album): + self._call_list('command_list_begin') + for track in album.get_tracks(): + self._call_list('deleteid', track.get_id()) + self._call('command_list_end') + + def _remove_albums_from_playlist(self, albums): + self._call_list('command_list_begin') + for album in albums: + for track in album.get_tracks(): + self._call_list('deleteid', track.get_id()) + self._call('command_list_end') + + def _play_album_from_playlist(self, album): + if album.get_tracks(): + self._call('playid', album.get_tracks()[0].get_id()) + + def _playpause(self): + """Action: Perform the real play/pause command.""" + if self._state == 'play': + self._call('pause') + else: + self._call('play') + + def _play_album(self, album): + track_ids = self._queue_album(album) + if track_ids: + self._logger.info("play track %d", track_ids[0]) + self._call('playid', track_ids[0]) + + def _queue_album(self, album): + track_ids = [] + if album in self._albums: + self._logger.info("add album %s", album) + for track in self._albums[album].get_tracks(): + self._logger.info("addid: %r", track.get_file()) + track_id = None + track_id_response = self._parse_dict( + self._call('addid', track.get_file())) + if 'id' in track_id_response: + track_id = track_id_response['id'] + self._logger.debug("track id: %r", track_id) + if track_id is not None: + track_ids.append(track_id) + return track_ids + + def _queue_albums(self, albums): + track_ids = [] + for album in albums: + track_ids.extend(self._queue_album(album)) + + def _seek(self, pos, time): + self._call('seek', pos, time) + + def _stop(self): + self._call('stop') + + def _set_volume(self, volume): + self._call('setvol', volume) + + def _get_albumart(self, album): + if album in self._albums: + album = self._albums[album] + self._logger.debug("get albumart for album \"%s\"", + album.get_title()) + + # Use "albumart" command + if album.get_tracks(): + try: + return (album, + self._read_binary('albumart', + album.get_tracks()[0].get_file(), + False)) + except CommandException as e: + # The "albumart" command throws an exception if not found + if e.get_error_number() != Client.PROTOCOL_ERROR_NOEXISTS: + raise e + # If no albumart can be found, use "readpicture" command + for track in album.get_tracks(): + data = self._read_binary('readpicture', + track.get_file(), True) + if data: + return (album, data) + + return (album, None) + + def _start_worker(self): + """Start the worker thread which waits for action to be performed.""" + self._logger.debug("start worker") + self._worker = threading.Thread(target=self._run, + name='mcg-worker', + args=()) + self._worker.setDaemon(True) + self._worker.start() + self._logger.debug("worker started") + + def _run(self): + while not self._stop.is_set() or not self._actions.empty(): + if self._sock is not None and self._actions.empty(): + self._add_action(self._idle) + action = self._actions.get() + self._logger.debug("next action: %r", action) + self._work(action) + self._actions.task_done() + self._logger.debug("action done") + self._logger.debug("worker finished") + + def _add_action(self, method, *args): + """Add an action to the action list.""" + self._logger.debug("add action %r (%r)", method.__name__, args) + future = concurrent.futures.Future() + action = (future, method, args) + self._actions.put(action) + self._noidle() + + return future + + def _add_action_signal(self, signal, method, *args): + """Add an action to the action list that triggers a callback.""" + self._logger.debug("add action signal %r: %r (%r)", signal, + method.__name__, args) + future = Future(signal) + future.add_done_callback(self._callback_future) + self._add_action_future(future, method, *args) + + return future + + def _add_action_future(self, future, method, *args): + """Add an action to the action list based on a future.""" + self._logger.debug("add action future %r (%r)", method.__name__, args) + action = (future, method, args) + self._actions.put(action) + self._noidle() + + def _work(self, action): + (future, method, args) = action + self._logger.debug("work: %r", method.__name__) + try: + result = method(*args) + future.set_result(result) + except ConnectionException as e: + self._logger.exception(e) + future.set_exception(e) + self._callback(Client.SIGNAL_ERROR, e) + self._disconnect_socket() + except Exception as e: + self._logger.exception(e) + future.set_exception(e) + self._callback(Client.SIGNAL_ERROR, e) + + def _call(self, command, *args): + try: + self._write(command, args) + return self._read() + except MPDException as e: + if ( + command == 'idle' + and e.get_error_number() == Client.PROTOCOL_ERROR_PERMISSION + ): + self.disconnect() + self._callback(Client.SIGNAL_ERROR, e) + + def _call_list(self, command, *args): + try: + self._write(command, args) + except MPDException as e: + if ( + command == 'idle' + and e.get_error_number() == Client.PROTOCOL_ERROR_PERMISSION + ): + self.disconnect() + self._callback(Client.SIGNAL_ERROR, e) + + def _write(self, command, args=None): + if args is not None and len(args) > 0: + line = '{} "{}"\n'.format( + command, '" "'.join(str(x).replace('"', '\\\"') for x in args)) + else: + line = '{}\n'.format(command) + self._logger.debug("write: %r", line) + self._sock_write.write(line) + self._sock_write.flush() + + def _read(self): + self._logger.debug("reading response") + response = [] + line = self._read_line() + while ( + not line.startswith(Client.PROTOCOL_COMPLETION) + and not line.startswith(Client.PROTOCOL_ERROR) + ): + response.append(line.strip()) + line = self._read_line() + if line.startswith(Client.PROTOCOL_COMPLETION): + self._logger.debug("response complete") + if line.startswith(Client.PROTOCOL_ERROR): + error = line[len(Client.PROTOCOL_ERROR):].strip() + self._logger.debug("command failed: %r", error) + raise CommandException(error) + self._logger.debug("response: %r", response) + return response + + def _read_line(self): + self._logger.debug("reading line") + + # Read from the buffer + data = self._buffer_get_char(b'\x0A') + if not data.endswith(b'\x0A'): + # Read more from socket until next line break + while b'\x0A' not in data: + buf = self._sock.recv(Client.SOCKET_BUFSIZE) + if buf: + data += buf + else: + break + + # Get first line from data, add rest to buffer + if data: + lines = data.split(b'\x0A', 1) + data = lines[0] + self._buffer_set(lines[1]) + if data: + return data.decode('utf-8') + return None + + def _read_binary(self, command, filename, has_mimetype): + data = None + size = 1 + offset = 0 + + # Read data until size is reached + while offset < size: + self._write(command, args=[filename, offset]) + + # Read first line + line = self._read_line() + # Check first line for error + if line.startswith(Client.PROTOCOL_ERROR): + error = line[len(Client.PROTOCOL_ERROR):].strip() + self._logger.debug("command failed: %r", error) + raise CommandException(error) + # Check first line for completion + if line.startswith(Client.PROTOCOL_COMPLETION): + break + # First line is the file size + size = int(self._parse_dict([line])['size']) + self._logger.debug("size: %d", size) + # For some commands the second line is the mimetype + if has_mimetype: + self._parse_dict([self._read_line()])['type'] + # Next line is the count of bytes read + binary = int(self._parse_dict([self._read_line()])['binary']) + self._logger.debug("binary: %d", binary) + + # Create new data array on the first iteration + if not data: + data = bytearray(size) + # Create a view for the current chunk of data + data_view = memoryview(data)[offset:offset + binary] + # Read actual bytes + self._read_bytes(data_view, binary) + offset += binary + # Read line break to complete previous response + self._read_line() + # Read command completion + end = self._read_line() + if not end.startswith(Client.PROTOCOL_COMPLETION): + self._logger.debug("albumart not completed") + data = None + break + return data + + def _read_bytes(self, buf, nbytes): + self._logger.debug("reading bytes") + # Use already buffered data + buf_read = self._buffer_get_size(nbytes) + nbytes_read = len(buf_read) + buf[0:nbytes_read] = buf_read + # Read additional data from socket + nbytes = nbytes - nbytes_read + if nbytes > 0: + buf_view = memoryview(buf)[nbytes_read:] + nbytes_read += self._sock.recv_into(buf_view, nbytes) + return nbytes_read + + def _buffer_get_char(self, char): + pos = self._buffer.find(char) + if pos < 0: + pos = len(self._buffer) - 1 + buf = self._buffer[0:pos + 1] + self._buffer = self._buffer[pos + 1:] + return buf + + def _buffer_get_size(self, size): + buf = self._buffer[0:size] + self._logger.debug("get %d bytes from buffer", len(buf)) + self._buffer = self._buffer[size:] + self._logger.debug("leaving %d in the buffer", len(self._buffer)) + return buf + + def _buffer_set(self, buf): + self._logger.debug("set %d %s as buffer", len(buf), type(buf)) + self._buffer = buf + + def _parse_dict(self, response): + dictionary = {} + if response: + for line in response: + key, value = self._split_line(line) + dictionary[key] = value + return dictionary + + def _parse_list(self, response, delimiters): + entry = {} + if response: + for line in response: + key, value = self._split_line(line) + if entry and key in delimiters: + yield entry + entry = {} + entry[key] = value + if entry: + yield entry + + def _split_line(self, line): + parts = line.split(':') + return parts[0].lower(), ':'.join(parts[1:]).lstrip() + + def _extract_album(self, song, lookup=True): + album = None + if 'album' not in song: + song['album'] = MCGAlbum.DEFAULT_ALBUM + album_id = Utils.generate_id(song['album']) + if lookup and album_id in self._albums.keys(): + album = self._albums[album_id] + else: + album = MCGAlbum(song['album'], self._host) + if lookup: + self._albums[album_id] = album + return album + + def _extract_track(self, song): + track = None + if 'artist' in song and 'title' in song and 'file' in song: + track = MCGTrack(song['artist'], song['title'], song['file']) + if 'track' in song: + track.set_track(song['track']) + if 'time' in song: + track.set_length(song['time']) + if 'date' in song: + track.set_date(song['date']) + if 'albumartist' in song: + track.set_albumartists(song['albumartist']) + if 'last-modified' in song: + track.set_last_modified(song['last-modified']) + return track + + def _extract_playlist_track(self, song): + track = self._extract_track(song) + if track and 'id' in song and 'pos' in song: + track = MCGPlaylistTrack(track, song['id'], song['pos']) + return track + + def _set_connection_status(self, status): + self._callback(Client.SIGNAL_CONNECTION, status) + + +class OutputDevice: + + def __init__(self, id, name): + self._id = id + self._name = name + self._enabled = None + + def get_id(self): + return self._id + + def get_name(self): + return self._name + + def set_enabled(self, enabled): + self._enabled = enabled + + def is_enabled(self): + return self._enabled + + +class MCGAlbum: + DEFAULT_ALBUM = 'Various' + _FILE_NAMES = ['cover', 'folder'] + _FILE_EXTS = ['jpg', 'png', 'jpeg'] + _FILTER_DELIMITER = ' ' + + def __init__(self, title, host): + self._artists = [] + self._albumartists = [] + self._paths = [] + if type(title) is list: + title = title[0] + self._title = title + self._dates = [] + self._host = host + self._tracks = [] + self._length = 0 + self._last_modified = None + self._id = Utils.generate_id(title) + + def __eq__(self, other): + return (other and self.get_id() == other.get_id()) + + def __hash__(self): + return hash(self._title) + + def get_id(self): + return self._id + + def get_artists(self): + if self._albumartists: + return [ + artist for artist in self._artists + if artist not in self._albumartists + ] + return self._artists + + def get_albumartists(self): + if self._albumartists: + return self._albumartists + return self._artists + + def get_title(self): + return self._title + + def get_dates(self): + return self._dates + + def get_date(self): + if len(self._dates) == 0: + return None + return self._dates[0] + + def get_path(self): + return self._path + + def add_track(self, track): + self._tracks.append(track) + self._length = self._length + track.get_length() + for artist in track.get_artists(): + if artist not in self._artists: + self._artists.append(artist) + for artist in track.get_albumartists(): + if artist not in self._albumartists: + self._albumartists.append(artist) + if ( + track.get_date() is not None + and track.get_date() not in self._dates + ): + self._dates.append(track.get_date()) + path = os.path.dirname(track.get_file()) + if path not in self._paths: + self._paths.append(path) + if track.get_last_modified(): + if ( + not self._last_modified + or track.get_last_modified() > self._last_modified + ): + self._last_modified = track.get_last_modified() + + def get_tracks(self): + return self._tracks + + def get_length(self): + return self._length + + def get_last_modified(self): + return self._last_modified + + def filter(self, filter_string): + if len(filter_string) == 0: + return True + keywords = filter_string.split(MCGAlbum._FILTER_DELIMITER) + for keyword in keywords: + if len(keyword) == 0: + continue + result = False + keyword = keyword.lower() + # Search in album data + for value in self._artists + [self._title] + self._dates: + if keyword in value.lower(): + result = True + break + if result: + continue + # Search in track data + for track in self._tracks: + if ( + keyword in track.get_title().lower() + or keyword in track.get_file().lower() + ): + result = True + break + if not result: + return False + return True + + @staticmethod + def compare(album1, album2, criterion=None, reverse=False): + if criterion is None: + criterion = SortOrder.TITLE + if criterion == SortOrder.ARTIST: + value_function = "get_artists" + elif criterion == SortOrder.TITLE: + value_function = "get_title" + elif criterion == SortOrder.YEAR: + value_function = "get_date" + elif criterion == SortOrder.MODIFIED: + value_function = "get_last_modified" + + reverse_multiplier = -1 if reverse else 1 + + value1 = getattr(album1, value_function)() + value2 = getattr(album2, value_function)() + if value1 is None and value2 is None: + return 0 + elif value1 is None: + return -1 * reverse_multiplier + elif value2 is None: + return 1 * reverse_multiplier + if value1 < value2: + return -1 * reverse_multiplier + elif value1 == value2: + return 0 + else: + return 1 * reverse_multiplier + + +class MCGTrack: + + def __init__(self, artists, title, file): + if type(artists) is not list: + artists = [artists] + self._artists = artists + if type(title) is list: + title = title[0] + self._title = title + if type(file) is list: + file = file[0] + self._file = file + + self._albumartists = [] + self._track = None + self._length = 0 + self._date = None + self._last_modified = None + + def __eq__(self, other): + return self._file == other.get_file() + + def __hash__(self): + return hash(self._file) + + def get_artists(self): + if self._albumartists: + return [ + artist for artist in self._artists + if artist not in self._albumartists + ] + return self._artists + + def set_albumartists(self, artists): + if type(artists) is not list: + artists = [artists] + self._albumartists = artists + + def get_albumartists(self): + if self._albumartists: + return self._albumartists + return self._artists + + def get_title(self): + return self._title + + def get_track(self): + return self._track + + def set_track(self, track): + if type(track) is list: + track = track[0] + if type(track) is str and '/' in track: + track = track[0:track.index('/')] + if track is not None: + try: + track = int(track) + except ValueError: + track = 0 + self._track = track + + def get_length(self): + return self._length + + def set_length(self, length): + self._length = int(length) + + def get_date(self): + return self._date + + def set_date(self, date): + if type(date) is list: + date = date[0] + self._date = date + + def get_file(self): + return self._file + + def set_last_modified(self, date_string): + if date_string: + try: + self._last_modified = dateutil.parser.isoparse(date_string) + except ValueError: + self._logger.debug("Invalid date format: %s", date_string) + + def get_last_modified(self): + return self._last_modified + + +class MCGPlaylistTrack(MCGTrack): + + def __init__(self, track, id, pos): + MCGTrack.__init__(self, track.get_artists(), track.get_title(), + track.get_file()) + self.set_albumartists(track.get_albumartists()) + self.set_track(track.get_track()) + self.set_length(track.get_length()) + self.set_date(track.get_date()) + self._id = int(id) + self._pos = int(pos) + + def get_id(self): + return self._id + + def get_pos(self): + return self._pos + + +class MCGConfig(configparser.ConfigParser): + CONFIG_DIR = '~/.config/mcg/' + + def __init__(self, filename): + configparser.ConfigParser.__init__(self) + self._filename = os.path.expanduser( + os.path.join(MCGConfig.CONFIG_DIR, filename)) + self._create_dir() + + def load(self): + if os.path.isfile(self._filename): + self.read(self._filename) + + def save(self): + with open(self._filename, 'w') as configfile: + self.write(configfile) + + def _create_dir(self): + dirname = os.path.dirname(self._filename) + if not os.path.exists(dirname): + os.makedirs(dirname) + + +class MCGCache(): + DIRNAME = '~/.cache/mcg/' + SIZE_FILENAME = 'size' + _lock = threading.Lock() + + def __init__(self, host, size): + self._logger = logging.getLogger(__name__) + self._host = host + self._size = size + self._dirname = os.path.expanduser(os.path.join( + MCGCache.DIRNAME, host)) + if not os.path.exists(self._dirname): + os.makedirs(self._dirname) + self._read_size() + + def create_filename(self, album): + return os.path.join(self._dirname, '-'.join([album.get_id()])) + + def _read_size(self): + size = 100 + MCGCache._lock.acquire() + # Read old size + filename = os.path.join(self._dirname, MCGCache.SIZE_FILENAME) + if os.path.exists(filename): + with open(filename, 'r') as f: + try: + size = int(f.readline()) + except: + self._logger.warning( + "invalid cache file: %s, deleting file", + filename, + exc_info=True) + size = None + # Clear cache if size has changed + if size != self._size: + self._clear() + # Write new size + with open(filename, 'w') as f: + f.write(str(self._size)) + MCGCache._lock.release() + + def _clear(self): + for filename in os.listdir(self._dirname): + path = os.path.join(self._dirname, filename) + if os.path.isfile(path): + try: + os.unlink(path) + except Exception as e: + print("clear:", e) diff --git a/src/coverpanel.py b/src/coverpanel.py new file mode 100644 index 0000000..d9c5d31 --- /dev/null +++ b/src/coverpanel.py @@ -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() diff --git a/src/librarypanel.py b/src/librarypanel.py new file mode 100644 index 0000000..e2ae1fb --- /dev/null +++ b/src/librarypanel.py @@ -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 diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..319beff --- /dev/null +++ b/src/main.py @@ -0,0 +1,8 @@ +import sys + +from .application import Application + + +def main(version): + app = Application(version) + return app.run(sys.argv) diff --git a/src/mcg.in b/src/mcg.in new file mode 100755 index 0000000..5166fe0 --- /dev/null +++ b/src/mcg.in @@ -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)) diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 0000000..e2db64e --- /dev/null +++ b/src/meson.build @@ -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) diff --git a/src/playlistpanel.py b/src/playlistpanel.py new file mode 100644 index 0000000..34abfee --- /dev/null +++ b/src/playlistpanel.py @@ -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 diff --git a/src/serverpanel.py b/src/serverpanel.py new file mode 100644 index 0000000..cc765bc --- /dev/null +++ b/src/serverpanel.py @@ -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(), + ) + diff --git a/src/shortcutsdialog.py b/src/shortcutsdialog.py new file mode 100644 index 0000000..2db83e3 --- /dev/null +++ b/src/shortcutsdialog.py @@ -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__() diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..ed8a04f --- /dev/null +++ b/src/utils.py @@ -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) diff --git a/src/window.py b/src/window.py new file mode 100644 index 0000000..ed00409 --- /dev/null +++ b/src/window.py @@ -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)) diff --git a/src/zeroconf.py b/src/zeroconf.py new file mode 100644 index 0000000..6dc1f00 --- /dev/null +++ b/src/zeroconf.py @@ -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)