pax_global_header00006660000000000000000000000064145072722550014523gustar00rootroot0000000000000052 comment=b7b01d9db61a7d99b9d1e5aa62580274790188da ymuse-0.22/000077500000000000000000000000001450727225500126105ustar00rootroot00000000000000ymuse-0.22/.github/000077500000000000000000000000001450727225500141505ustar00rootroot00000000000000ymuse-0.22/.github/FUNDING.yml000066400000000000000000000011361450727225500157660ustar00rootroot00000000000000# These are supported funding model platforms github: [yktoo] patreon: yktoo open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ymuse-0.22/.github/workflows/000077500000000000000000000000001450727225500162055ustar00rootroot00000000000000ymuse-0.22/.github/workflows/go.yml000066400000000000000000000047721450727225500173470ustar00rootroot00000000000000name: Go on: push: branches: - dev tags: - v* jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Go uses: actions/setup-go@v3 with: go-version: 1.21 - name: Install packages run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev xvfb gettext - name: Prepare run: | go generate go mod download - name: Verify format run: test `gofmt -l . | wc -l` = 0 - name: Unit test run: | export DISPLAY=:99.0 sudo /usr/bin/Xvfb $DISPLAY &>/dev/null & go test -v ./... deploy: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v3 with: go-version: 1.21 - name: Install packages run: | sudo apt-get update sudo apt-get install -y libgtk-3-dev gettext - name: Verify GoReleaser config uses: goreleaser/goreleaser-action@v4 with: distribution: goreleaser version: v1.21.2 args: check - name: Make a release (tag only) if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') uses: goreleaser/goreleaser-action@v4 with: distribution: goreleaser version: latest args: release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build snap # This must run AFTER goreleaser has built the app id: snap uses: snapcore/action-build@v1 - name: Archive artifacts uses: actions/upload-artifact@v3 with: name: dist path: | dist ${{ steps.snap.outputs.snap }} - name: Publish dev snap to edge channel if: github.ref_type == 'branch' && github.ref == 'refs/heads/dev' uses: snapcore/action-publish@v1 env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_STORE_LOGIN }} with: snap: ${{ steps.snap.outputs.snap }} release: edge - name: Publish snap to stable channel (tag only) if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') uses: snapcore/action-publish@v1 env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_STORE_LOGIN }} with: snap: ${{ steps.snap.outputs.snap }} release: stable ymuse-0.22/.gitignore000066400000000000000000000001411450727225500145740ustar00rootroot00000000000000/.idea/ *.iml /ymuse *~ \#*.glade# # Generated content /resources/i18n/generated/ /dist/ *.snap ymuse-0.22/.goreleaser.yml000066400000000000000000000034101450727225500155370ustar00rootroot00000000000000project_name: ymuse before: hooks: - go generate - go mod download builds: - id: ymuse binary: ymuse env: - CGO_ENABLED=1 goos: - linux goarch: - amd64 archives: - id: ymuse-binary builds: - ymuse wrap_in_directory: 'true' files: - COPYING - README.md - resources/icons/**/*.png - resources/com.yktoo.ymuse.desktop checksum: name_template: 'checksums.txt' snapshot: name_template: "{{ .Tag }}-next" changelog: sort: asc filters: exclude: - '^ci:' - '^code:' - '^docs:' - '^snap:' - '^test:' - '^wip:' release: github: owner: yktoo name: ymuse nfpms: - id: ymuse package_name: ymuse vendor: Dmitry Kann homepage: https://yktoo.com/ maintainer: Dmitry Kann description: Easy, functional, and snappy GTK client for Music Player Daemon (MPD). license: Apache 2.0 formats: - deb - rpm dependencies: - libc6 - libgtk-3-0 recommends: - mpd suggests: [] conflicts: [] bindir: /usr/bin contents: - src: "resources/icons/hicolor/**/*" dst: "/usr/share/icons/hicolor" - src: "resources/*.desktop" dst: "/usr/share/applications" - src: "resources/metainfo/*.metainfo.xml" dst: "/usr/share/metainfo" - src: "resources/i18n/generated/**/*" dst: "/usr/share/locale" scripts: postinstall: "resources/scripts/postinst" postremove: "resources/scripts/postrm" overrides: rpm: dependencies: - glibc - gtk3 ymuse-0.22/COPYING000066400000000000000000000236751450727225500136600ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ymuse-0.22/README.md000066400000000000000000000076341450727225500141010ustar00rootroot00000000000000[![Latest release](https://img.shields.io/github/v/release/yktoo/ymuse.svg)](https://github.com/yktoo/ymuse/releases/latest) [![Releases](https://img.shields.io/github/downloads/yktoo/ymuse/total.svg)](https://github.com/yktoo/ymuse/releases) [![License](https://img.shields.io/github/license/yktoo/ymuse.svg)](COPYING) [![Go](https://github.com/yktoo/ymuse/actions/workflows/go.yml/badge.svg)](https://github.com/yktoo/ymuse/actions/workflows/go.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/yktoo/ymuse)](https://goreportcard.com/report/github.com/yktoo/ymuse) # ![Ymuse icon](resources/icons/hicolor/32x32/apps/com.yktoo.ymuse.png) Ymuse **Ymuse** is an easy, functional, and snappy GTK front-end (client) for [Music Player Daemon](https://www.musicpd.org/) written in Go. It supports both light and dark desktop theme. [![Ymuse screenshot](https://res.cloudinary.com/yktoo/image/upload/blog/e6ecokfftenpwlwswon1.png)](https://res.cloudinary.com/yktoo/image/upload/blog/e6ecokfftenpwlwswon1.png) It supports library browsing and search, playlists, streams etc. [![Ymuse Library screenshot](https://res.cloudinary.com/yktoo/image/upload/t_s320/blog/wqud8spomcmuduvgar9d.png)](https://res.cloudinary.com/yktoo/image/upload/blog/wqud8spomcmuduvgar9d.png) [![Ymuse Streams screenshot](https://res.cloudinary.com/yktoo/image/upload/t_s320/blog/pnwj9nlucfuobw0vcv0l.png)](https://res.cloudinary.com/yktoo/image/upload/blog/pnwj9nlucfuobw0vcv0l.png) Watch Ymuse feature tour video: [![Feature tour video](https://img.youtube.com/vi/h0g2gk5DM8s/0.jpg)](https://www.youtube.com/watch?v=h0g2gk5DM8s) ## Installing * If your distribution supports [snap packages](https://snapcraft.io/ymuse): `sudo snap install ymuse` * Ubuntu (as of 23.04) or Debian Testing: `sudo apt install ymuse` * A flatpak is available in the [Flathub repository](https://flathub.org/apps/details/com.yktoo.ymuse). * Otherwise, you can use a binary package from the [Releases](https://github.com/yktoo/ymuse/releases) section. ## Building from source ### Requirements * Go 1.21+ * GTK 3.24+ ### Getting started 1. [Install Go](https://golang.org/doc/install) 2. Make sure you have the following build dependencies installed: * `build-essential` * `libc6` * `libgtk-3-dev` * `libgdk-pixbuf2.0-dev` * `libglib2.0-dev` * `gettext` 3. Clone the source and compile: ```bash git clone https://github.com/yktoo/ymuse.git cd ymuse go generate go build ``` 4. Copy over the icons and localisations: ```bash sudo cp -r resources/icons/* /usr/share/icons/ sudo cp -r resources/i18n/generated/* /usr/share/locale/ sudo update-icon-caches /usr/share/icons/hicolor/* ``` This will create the application executable `ymuse` in the project root directory, which you can run straight away. ## Packaging ### DEB and RPM Requires `goreleaser` installed. ```bash goreleaser release --clean --skip=publish [--snapshot] ``` ### Flatpak 1. Install `flatpak` and `flatpack-builder` 2. `flatpak remote-add flathub https://flathub.org/repo/flathub.flatpakrepo` 3. `flatpak-builder dist /path/to/com.yktoo.ymuse.yml --force-clean --install-deps-from=flathub --repo=/path/to/repository` 4. Optional: make a `.flatpak` bundle: `flatpak build-bundle /path/to/repository ymuse.flatpak com.yktoo.ymuse` ### Snap Install and run `snapcraft` (it will also ask to install Multipass, which you'll have to confirm): ```bash snap install snapcraft snapcraft clean # Optional, when rebuilding the snap snapcraft ``` ## License See [COPYING](COPYING). ## Credits * Icon artwork: [Jeppe Zapp](https://github.com/mrzapp) * [gotk3](https://github.com/gotk3/gotk3) * [gompd](https://github.com/fhs/gompd) by Fazlul Shahriar * [go-logging](https://github.com/op/go-logging) by Örjan Fors * [goreleaser](https://goreleaser.com/) by Carlos Alexandro Becker et al. ## TODO * Automated UI testing. * Drag’n’drop of multiple tracks in the play queue. * More settings. * Multiple MPD connections support. ymuse-0.22/go.mod000066400000000000000000000003161450727225500137160ustar00rootroot00000000000000module github.com/yktoo/ymuse go 1.21 require ( github.com/fhs/gompd/v2 v2.3.0 github.com/gotk3/gotk3 v0.6.2 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/pkg/errors v0.9.1 ) ymuse-0.22/go.sum000066400000000000000000000021141450727225500137410ustar00rootroot00000000000000github.com/fhs/gompd/v2 v2.2.1-0.20220620205817-bbf835995263 h1:4lMCzoduUccm3WQu1Ho1BHbjrg8t7tnFQEhHkhlZNN0= github.com/fhs/gompd/v2 v2.2.1-0.20220620205817-bbf835995263/go.mod h1:nNdZtcpD5VpmzZbRl5rV6RhxeMmAWTxEsSIMBkmMIy4= github.com/fhs/gompd/v2 v2.3.0 h1:wuruUjmOODRlJhrYx73rJnzS7vTSXSU7pWmZtM3VPE0= github.com/fhs/gompd/v2 v2.3.0/go.mod h1:nNdZtcpD5VpmzZbRl5rV6RhxeMmAWTxEsSIMBkmMIy4= github.com/gotk3/gotk3 v0.6.1 h1:GJ400a0ecEEWrzjBvzBzH+pB/esEMIGdB9zPSmBdoeo= github.com/gotk3/gotk3 v0.6.1/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= github.com/gotk3/gotk3 v0.6.2 h1:sx/PjaKfKULJPTPq8p2kn2ZbcNFxpOJqi4VLzMbEOO8= github.com/gotk3/gotk3 v0.6.2/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= ymuse-0.22/internal/000077500000000000000000000000001450727225500144245ustar00rootroot00000000000000ymuse-0.22/internal/config/000077500000000000000000000000001450727225500156715ustar00rootroot00000000000000ymuse-0.22/internal/config/config.go000066400000000000000000000200001450727225500174550ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package config import ( "encoding/json" "errors" "fmt" "github.com/gotk3/gotk3/glib" "github.com/yktoo/ymuse/internal/util" "os" "path" "sync" ) // AppMetadata stores application-wide metadata such as version, license etc. var AppMetadata = &struct { Version string BuildDate string Name string Icon string Copyright string URL string URLLabel string ID string License string }{ Name: "Ymuse", Icon: "com.yktoo.ymuse", Copyright: "Written by Dmitry Kann", URL: "https://yktoo.com", URLLabel: "yktoo.com", ID: "com.yktoo.ymuse", License: "Licensed under the Apache License, Version 2.0 (the \"License\");\n" + "you may not use this file except in compliance with the License.\n" + "You may obtain a copy of the License at\n" + " http://www.apache.org/licenses/LICENSE-2.0\n" + "\n" + "Unless required by applicable law or agreed to in writing, software\n" + "distributed under the License is distributed on an \"AS IS\" BASIS,\n" + "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n" + "See the License for the specific language governing permissions and\n" + "limitations under the License.\n", } // Dimensions represents window dimensions type Dimensions struct { X, Y, Width, Height int } // ColumnSpec describes settings for a queue column type ColumnSpec struct { ID int // Column ID Width int // Column width, if differs from the default, otherwise 0 } // StreamSpec describes settings for an Internet stream type StreamSpec struct { Name string // Stream name URI string // Stream URI } // Config represents (storable) application configuration type Config struct { MpdNetwork string // Network to use to connect to MPD, either 'tcp' or 'unix' MpdSocketPath string // Path to the MPD's Unix socket (only if MpdNetwork == 'unix') MpdHost string // MPD's IP address or hostname (only if MpdNetwork == 'tcp') MpdPort int // MPD's port number (only if MpdNetwork == 'tcp') MpdPassword string // MPD's password (optional) MpdAutoConnect bool // Whether to automatically connect to MPD on startup MpdAutoReconnect bool // Whether to automatically reconnect to MPD after connection is lost QueueColumns []ColumnSpec // Displayed queue columns QueueToolbar bool // Whether the queue toolbar is visible DefaultSortAttrID int // ID of MPD attribute used as a default for queue sorting TrackDefaultReplace bool // Whether the default action for double-clicking a track is replace rather than append PlaylistDefaultReplace bool // Whether the default action for double-clicking a playlist is replace rather than append StreamDefaultReplace bool // Whether the default action for double-clicking a stream is replace rather than append PlayerSeekDuration int // Number of seconds to seek back/forward at a time, while playing PlayerTitleTemplate string // Track's title formatting template for the player PlayerAlbumArtTracks bool // Whether to display the current track's album art in the player PlayerAlbumArtStreams bool // Whether to display the current stream's album art in the player PlayerAlbumArtSize int // Size of the album art image in the player, in pixels SwitchToOnQueueReplace bool // Whether to switch to the Queue tab after the queue has been replaced PlayOnQueueReplace bool // Whether to start playback after the queue has been replaced MaxSearchResults int // Maximum number of displayed search results Streams []StreamSpec // Registered stream specifications LibraryPath string // Last selected library path MainWindowDimensions Dimensions // Main window dimensions } // Config singleton with all settings var config *Config var once sync.Once // GetConfig returns a global Config instance func GetConfig() *Config { // Load the config from the file once.Do(func() { // Instantiate a config config = newConfig() // Load the config from the default file, if any config.Load() }) return config } // newConfig initialises and returns a config instance with all the defaults func newConfig() *Config { return &Config{ MpdNetwork: "tcp", MpdSocketPath: os.Getenv("XDG_RUNTIME_DIR") + "/mpd/socket", MpdHost: os.Getenv("MPD_HOST"), MpdPort: util.AtoiDef(os.Getenv("MPD_PORT"), 6600), MpdPassword: "", MpdAutoConnect: true, MpdAutoReconnect: true, QueueColumns: []ColumnSpec{ {ID: MTAttrArtist}, {ID: MTAttrYear}, {ID: MTAttrAlbum}, {ID: MTAttrDisc}, {ID: MTAttrNumber}, {ID: MTAttrTrack}, {ID: MTAttrLength}, {ID: MTAttrGenre}, }, QueueToolbar: true, DefaultSortAttrID: MTAttrPath, TrackDefaultReplace: false, PlaylistDefaultReplace: true, StreamDefaultReplace: true, PlayerSeekDuration: 5, PlayerTitleTemplate: glib.Local( "{{- if or .Title .Album | or .Artist -}}\n" + "{{ .Title | default \"(unknown title)\" }}\n" + "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | default \"(unknown album)\" }}\n" + "{{- else if .Name -}}\n" + "{{ .Name }}\n" + "{{- else if .file -}}\n" + "File {{ .file | basename }}\n" + "from {{ .file | dirname }}\n" + "{{- else -}}\n" + "(no track)\n" + "{{- end -}}\n"), PlayerAlbumArtTracks: true, PlayerAlbumArtStreams: false, PlayerAlbumArtSize: 80, SwitchToOnQueueReplace: true, PlayOnQueueReplace: false, MaxSearchResults: 500, Streams: []StreamSpec{ {Name: "BBC World News", URI: "http://stream.live.vc.bbcmedia.co.uk/bbc_world_service"}, }, MainWindowDimensions: Dimensions{-1, -1, -1, -1}, } } // Load reads the config from the default file func (c *Config) Load() { // Try to read the file file := c.getConfigFile() data, err := os.ReadFile(file) // Ignore if the file isn't there if errors.Is(err, os.ErrNotExist) { return } // Warn on any other error if errCheck(err, "Couldn't read file") { return } // Unmarshal the config if errCheck(json.Unmarshal(data, &c), "json.Unmarshal() failed") { return } log.Debugf("Loaded configuration from %s", file) } // MpdNetworkAddress returns the MPD network and the address string func (c *Config) MpdNetworkAddress() (string, string) { if c.MpdNetwork == "unix" { return "unix", c.MpdSocketPath } return "tcp", fmt.Sprintf("%s:%d", c.MpdHost, c.MpdPort) } // Save writes out the config to the default file func (c *Config) Save() { // Create the config directory if it doesn't exist if errCheck(os.MkdirAll(c.getConfigDir(), 0755), "MkdirAll() failed") { return } // Serialise the config data, err := json.MarshalIndent(c, "", " ") if errCheck(err, "json.MarshalIndent() failed") { return } // Save the config file := c.getConfigFile() if !errCheck(os.WriteFile(file, data, 0600), "WriteFile() failed") { log.Debugf("Saved configuration to %s", file) } } // getConfigDir returns the full path to the config directory func (c *Config) getConfigDir() string { return path.Join(glib.GetUserConfigDir(), "ymuse") } // getConfigFile returns the full path of the config file func (c *Config) getConfigFile() string { return path.Join(c.getConfigDir(), "config.json") } ymuse-0.22/internal/config/log.go000066400000000000000000000016451450727225500170070ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package config import ( "fmt" "github.com/op/go-logging" ) // Package-wide Logger instance var log = logging.MustGetLogger("config") // errCheck logs a warning if the error is not nil. func errCheck(err error, message string) bool { if err != nil { log.Warning(fmt.Errorf("%v: %v", message, err)) return true } return false } ymuse-0.22/internal/config/model.go000066400000000000000000000115331450727225500173230ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package config import ( "github.com/yktoo/ymuse/internal/util" "path" "sort" ) // MPD's track attribute identifiers. These must precisely match the QueueListStore's columns declared in player.glade const ( MTAttrArtist = iota MTAttrArtistSort MTAttrAlbum MTAttrAlbumSort MTAttrAlbumArtist MTAttrAlbumArtistSort MTAttrDisc MTAttrTrack MTAttrNumber MTAttrLength MTAttrPath MTAttrDirectory MTAttrFile MTAttrYear MTAttrGenre MTAttrName MTAttrComposer MTAttrPerformer MTAttrConductor MTAttrWork MTAttrGrouping MTAttrComment MTAttrLabel MTAttrPos // List store's "artificial" columns used for rendering QueueColumnIcon QueueColumnFontWeight QueueColumnBgColor QueueColumnVisible ) // MpdTrackAttribute describes an MPD's track attribute type MpdTrackAttribute struct { Name string // Short display label for the attribute LongName string // Display label for the attribute AttrName string // Internal name of the corresponding MPD attribute Numeric bool // Whether the attribute's value is numeric Searchable bool // Whether the attribute is searchable Width int // Default width of the column displaying this attribute XAlign float64 // X alignment of the column displaying this attribute (0...1) Formatter func(v string) string // Optional function for formatting the value FallbackAttrIDs []int // Optional references to the fallback attributes to use when there's no value, in the order of preference } // MpdTrackAttributes contains all known MPD's track attributes var MpdTrackAttributes = map[int]MpdTrackAttribute{ MTAttrArtist: {"Artist", "Artist", "Artist", false, true, 200, 0, nil, nil}, MTAttrArtistSort: {"Artist", "Artist (for sorting)", "Artistsort", false, false, 200, 0, nil, nil}, MTAttrAlbum: {"Album", "Album", "Album", false, true, 200, 0, nil, nil}, MTAttrAlbumSort: {"Album", "Album (for sorting)", "Albumsort", false, false, 200, 0, nil, nil}, MTAttrAlbumArtist: {"Album artist", "Album artist", "Albumartist", false, true, 200, 0, nil, nil}, MTAttrAlbumArtistSort: {"Album artist", "Album artist (for sorting)", "Albumartistsort", false, false, 200, 0, nil, nil}, MTAttrDisc: {"Disc", "Disc", "Disc", false, true, 50, 1, nil, nil}, MTAttrTrack: {"Track", "Track title", "Title", false, true, 200, 0, nil, []int{MTAttrName, MTAttrPath}}, MTAttrNumber: {"#", "Track number", "Track", true, true, 50, 1, nil, nil}, MTAttrLength: {"Length", "Track length", "duration", true, false, 60, 1, util.FormatSecondsStr, nil}, MTAttrPath: {"Path", "Directory and file name", "file", false, true, 200, 0, nil, nil}, MTAttrDirectory: {"Directory", "File path", "file", false, false, 200, 0, path.Dir, nil}, MTAttrFile: {"File", "File name", "file", false, false, 200, 0, path.Base, nil}, MTAttrYear: {"Year", "Year", "Date", true, true, 50, 1, nil, nil}, MTAttrGenre: {"Genre", "Genre", "Genre", false, true, 200, 0, nil, nil}, MTAttrName: {"Name", "Stream name", "Name", false, true, 200, 0, nil, nil}, MTAttrComposer: {"Composer", "Composer", "Composer", false, true, 200, 0, nil, nil}, MTAttrPerformer: {"Performer", "Performer", "Performer", false, true, 200, 0, nil, nil}, MTAttrConductor: {"Conductor", "Conductor", "Conductor", false, false, 200, 0, nil, nil}, MTAttrWork: {"Work", "Work", "Work", false, false, 200, 0, nil, nil}, MTAttrGrouping: {"Grouping", "Grouping", "Grouping", false, false, 200, 0, nil, nil}, MTAttrComment: {"Comment", "Comment", "Comment", false, true, 200, 0, nil, nil}, MTAttrLabel: {"Label", "Label", "Label", false, true, 200, 0, nil, nil}, MTAttrPos: {"Pos", "Position", "Pos", true, false, 0, 1, nil, nil}, } // MpdTrackAttributeIds stores attribute IDs sorted in desired display order var MpdTrackAttributeIds []int func init() { // Fill in and sort MpdTrackAttributeIds MpdTrackAttributeIds = make([]int, len(MpdTrackAttributes)) i := 0 for id := range MpdTrackAttributes { MpdTrackAttributeIds[i] = id i++ } sort.Ints(MpdTrackAttributeIds) } ymuse-0.22/internal/player/000077500000000000000000000000001450727225500157205ustar00rootroot00000000000000ymuse-0.22/internal/player/builder.go000066400000000000000000000050231450727225500176750ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package player import ( "fmt" "github.com/gotk3/gotk3/gtk" "reflect" ) // Builder instance capable of finding specific types of widgets type Builder struct { *gtk.Builder } // NewBuilder creates and returns a new Builder instance func NewBuilder(content string) (*Builder, error) { builder, err := gtk.BuilderNew() if err != nil { return nil, err } if err := builder.AddFromString(content); err != nil { return nil, fmt.Errorf("builder.AddFromString() failed: %v", err) } return &Builder{Builder: builder}, nil } // BindWidgets binds the builder's widgets to same-named fields in the provided struct. Only exported fields are taken // into account func (b *Builder) BindWidgets(obj interface{}) error { // We're only dealing with structs vPtr := reflect.ValueOf(obj) if vPtr.Kind() != reflect.Ptr || vPtr.IsNil() || vPtr.Elem().Kind() != reflect.Struct { return fmt.Errorf("*struct expected, %T was given", obj) } // Fetch a value for the struct vPtr points to v := vPtr.Elem() // Iterate over struct's fields t := v.Type() for i := 0; i < t.NumField(); i++ { valField := v.Field(i) if valField.CanSet() { // Verify it's a pointer typeField := t.Field(i) if valField.Kind() != reflect.Ptr { return fmt.Errorf("struct's field %s is %v, but only pointers are supported", typeField.Name, valField.Kind()) } // Try to find a widget with the field's name widget, err := b.GetObject(typeField.Name) if err != nil { return err } // Try to cast the value to the target type var targetVal reflect.Value func() { err = nil defer func() { if r := recover(); r != nil { err = fmt.Errorf("failed to cast IObject (ID=%s) to %s: %v", typeField.Name, typeField.Type, r) } }() targetVal = reflect.ValueOf(widget).Convert(typeField.Type) }() if err != nil { return err } // Set the value. Any possible panic won't be recovered valField.Set(targetVal) } } return nil } ymuse-0.22/internal/player/builder_test.go000066400000000000000000000123331450727225500207360ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package player import ( "fmt" "github.com/gotk3/gotk3/gtk" "testing" ) func TestBuilder_BindWidgets(t *testing.T) { vInt := 1 tests := []struct { name string content string target interface{} wantErr bool }{ { name: "int instead of pointer", target: 1, wantErr: true, }, { name: "*int instead of pointer", target: &vInt, wantErr: true, }, { name: "struct with non-pointer field", target: &struct{ Value string }{}, wantErr: true, }, { name: "struct field of wrong name", content: ``, target: &struct{ Value *gtk.Button }{}, wantErr: true, }, { name: "struct field of wrong type", content: ``, target: &struct{ MyButton *gtk.Button }{}, wantErr: true, }, { name: "empty struct", target: &struct{}{}, }, { name: "struct with no exported fields", target: &struct { x int y string }{}, }, { name: "happy flow for MainWindow", content: playerGlade, target: &MainWindow{}, }, { name: "happy flow for Preferences", content: prefsGlade, target: &PrefsDialog{}, }, { name: "happy flow for Shortcuts", content: shortcutsGlade, target: &struct{ ShortcutsWindow *gtk.ShortcutsWindow }{}, }, } // Need to init GTK first gtk.Init(nil) // Run the tests for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Instantiate a builder if b, err := NewBuilder(tt.content); err != nil { t.Errorf("BindWidgets() error in NewBuilder() = %v, wantErr %v", err, tt.wantErr) } else if err := b.BindWidgets(tt.target); (err != nil) != tt.wantErr { t.Errorf("BindWidgets() error = %v, wantErr %v", err, tt.wantErr) } else if err != nil { fmt.Println("Got error:", err) } }) } } func TestNewBuilder(t *testing.T) { tests := []struct { name string content string wantErr bool id string wantType string }{ {name: "empty file"}, { name: "bad XML", content: "", wantErr: true, }, { name: "bad GTK version", content: ` `, wantErr: true, }, { name: "unknown widget class", content: ``, wantErr: true, }, { name: "happy flow for GtkButton", content: ``, id: "btn", wantType: "*gtk.Button", }, { name: "happy flow for GtkApplicationWindow", content: ``, id: "win", wantType: "*gtk.ApplicationWindow", }, { name: "happy flow for GtkDialog", content: ``, id: "dlg", wantType: "*gtk.Dialog", }, { name: "happy flow for GtkEntry", content: ``, id: "ENTRY", wantType: "*gtk.Entry", }, { name: "happy flow for GtkMenu", content: ``, id: "mnu", wantType: "*gtk.Menu", }, { name: "happy flow for GtkToolbar", content: ``, id: "TB", wantType: "*gtk.Toolbar", }, { name: "happy flow for nested GtkButton", content: ` `, id: "btn", wantType: "*gtk.Button", }, } // Need to init GTK first gtk.Init(nil) // Run the tests for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := NewBuilder(tt.content) if (err != nil) != tt.wantErr { t.Errorf("NewBuilder() error = %v, wantErr %v", err, tt.wantErr) return } // On an expected error or if there's nothing more to check: stop here if err != nil || tt.id == "" { if err != nil { fmt.Println("Got error:", err) } return } // Fetch and check the type of the object if obj, err := got.Builder.GetObject(tt.id); err != nil { t.Errorf("NewBuilder() failed to get object with ID=%v: %v", tt.id, err) } else if gotType := fmt.Sprintf("%T", obj); gotType != tt.wantType { t.Errorf("NewBuilder() object with ID=%v: got = %v, want %v", tt.id, gotType, tt.wantType) } }) } } ymuse-0.22/internal/player/connector.go000066400000000000000000000233641450727225500202510ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package player import ( "github.com/fhs/gompd/v2/mpd" "github.com/pkg/errors" "sync" "time" ) // Connector encapsulates functionality for connecting to MPD and watch for its changes type Connector struct { mpdNetwork string // MPD network mpdAddress string // MPD address mpdPassword string // MPD password stayConnected bool // Whether a connection is supposed to be kept alive mpdClient *mpd.Client // MPD client instance mpdClientConnecting bool // Whether MPD connection is being established mpdClientMutex sync.RWMutex mpdStatus mpd.Attrs // Last reported MPD status mpdStatusMutex sync.RWMutex chConnectorConnect chan bool // Connector's connect channel chConnectorQuit chan bool // Connector's quit channel chWatcherStart chan bool // Watcher's start channel chWatcherStop chan bool // Watcher's suspend/quit channel onStatusChange func() // Callback for connection status change notifications onHeartbeat func() // Callback for periodic message notifications onSubsystemChange func(subsystem string) // Callback for subsystem change notifications } // NewConnector creates and returns a new Connector instance func NewConnector(onStatusChange func(), onHeartbeat func(), onSubsystemChange func(subsystem string)) *Connector { return &Connector{ mpdStatus: mpd.Attrs{}, onStatusChange: onStatusChange, onHeartbeat: onHeartbeat, onSubsystemChange: onSubsystemChange, chConnectorConnect: make(chan bool), chConnectorQuit: make(chan bool), chWatcherStart: make(chan bool), chWatcherStop: make(chan bool), } } // Start initialises the connector // stayConnected: whether the connection must be automatically re-established when lost func (c *Connector) Start(mpdNetwork, mpdAddress, mpdPassword string, stayConnected bool) { c.mpdNetwork = mpdNetwork c.mpdAddress = mpdAddress c.mpdPassword = mpdPassword c.stayConnected = stayConnected // Start the connect goroutine go c.connect() // Start the watch goroutine go c.watch() c.startConnecting() } // Status returns the last known MPD status func (c *Connector) Status() mpd.Attrs { c.mpdStatusMutex.RLock() defer c.mpdStatusMutex.RUnlock() return c.mpdStatus } // Stop signals the connector to shut down func (c *Connector) Stop() { // Ignore if not connected/connecting if connected, connecting := c.ConnectStatus(); !connected && !connecting { return } // Quit connector and watcher c.stayConnected = false c.chConnectorQuit <- true c.chWatcherStop <- true // Close the connection to MPD, if any c.mpdClientMutex.Lock() c.mpdClientConnecting = false if c.mpdClient != nil { log.Debug("Disconnect from MPD") errCheck(c.mpdClient.Close(), "Close() failed") c.mpdClient = nil } c.mpdClientMutex.Unlock() // Reset the status c.setStatus(mpd.Attrs{}) // Notify the callback c.onStatusChange() } // GetPlaylists queries and returns a slice of playlist names available in MPD func (c *Connector) GetPlaylists() []string { // Fetch the list of playlists var attrs []mpd.Attrs var err error c.IfConnected(func(client *mpd.Client) { attrs, err = client.ListPlaylists() }) if errCheck(err, "ListPlaylists() failed") { return nil } // Convert attrs to a slice of strings names := make([]string, len(attrs)) for i, a := range attrs { names[i] = a["playlist"] } return names } // IfConnected runs MPD client code if there's a connection with MPD func (c *Connector) IfConnected(funcIfConnected func(client *mpd.Client)) { c.mpdClientMutex.RLock() defer c.mpdClientMutex.RUnlock() if c.mpdClient != nil { funcIfConnected(c.mpdClient) } } // IsConnected returns whether there's a connection with MPD and whether it's being established func (c *Connector) ConnectStatus() (bool, bool) { c.mpdClientMutex.RLock() defer c.mpdClientMutex.RUnlock() return c.mpdClient != nil, c.mpdClientConnecting } // setStatus sets the current MPD status, thread-safely func (c *Connector) setStatus(attrs mpd.Attrs) { c.mpdStatusMutex.Lock() defer c.mpdStatusMutex.Unlock() c.mpdStatus = attrs } // startConnecting signals the connector to initiate connection process func (c *Connector) startConnecting() { go func() { c.chConnectorConnect <- true }() } // connect maintains MPD connection and invokes callbacks until something is sent via chConnectorQuit func (c *Connector) connect() { log.Debug("connect()") var heartbeatTicker = time.NewTicker(time.Second) for { select { // Request to connect case <-c.chConnectorConnect: c.doConnect(true, false) // Heartbeat tick case <-heartbeatTicker.C: c.doConnect(false, true) // Request to quit case <-c.chConnectorQuit: // Kill the heartbeat timer heartbeatTicker.Stop() return } } } // doConnect takes care of (re)establishing a connection to MPD and calling the status/heartbeat callbacks func (c *Connector) doConnect(connect, heartbeat bool) { var err error var client *mpd.Client var wasConnected bool connected, _ := c.ConnectStatus() // If there's a request to connect and not connected yet if connect && !connected { // Set the connecting flag c.mpdClientMutex.Lock() c.mpdClientConnecting = true c.mpdClientMutex.Unlock() // Notify the callback we're about to connect c.onStatusChange() // Try to connect log.Debugf("Connecting to MPD (network=%v, address=%v)", c.mpdNetwork, c.mpdAddress) if client, err = mpd.DialAuthenticated(c.mpdNetwork, c.mpdAddress, c.mpdPassword); err == nil { connected = true } else { err = errors.Errorf("DialAuthenticated() failed: %v", err) } } // If there's a local client, we've just connected status := mpd.Attrs{} if connected && client != nil { // Validate the connection by requesting MPD status and, on success, save the client connection if status, err = client.Status(); err == nil { c.mpdClientMutex.Lock() c.mpdClientConnecting = false c.mpdClient = client c.mpdClientMutex.Unlock() log.Info("Successfully connected to MPD") // Start the watcher go func() { c.chWatcherStart <- true }() } else { connected = false err = errors.Errorf("Status() after dial failed: %v", err) // Disconnect since we're not "fully connected" errCheck(client.Close(), "doConnect(): Close() failed") } } else { connected = false // We didn't connect. Validate the existing connection, if any c.IfConnected(func(client *mpd.Client) { wasConnected = true if status, err = client.Status(); err == nil { connected = true } else { err = errors.Errorf("Status() failed: %v", err) } }) // Connection lost if wasConnected && !connected { log.Warning("Connection to MPD lost") // Remove client connection c.mpdClientMutex.Lock() c.mpdClientConnecting = false c.mpdClient = nil c.mpdClientMutex.Unlock() // Suspend the watcher go func() { c.chWatcherStop <- false }() } } // On error, replace status with the error info if errCheck(err, "Failed to connect to MPD") { status = mpd.Attrs{"error": err.Error()} } // Store the (updated) status c.setStatus(status) // Notify the status callback on status change if wasConnected != connected { c.onStatusChange() } if heartbeat { // No connection (anymore), re-attempt connection if needed, but not more frequently than once in a heartbeat if !connected && c.stayConnected { c.startConnecting() } // Notify the heartbeat callback c.onHeartbeat() } } // watch starts watching MPD subsystem changes func (c *Connector) watch() { log.Debug("watch()") var rewatchTimer *time.Timer var eventChannel chan string var errorChannel chan error var mpdWatcher *mpd.Watcher for { select { // Request to watch case <-c.chWatcherStart: log.Debug("Start watcher") // Remove the timer rewatchTimer = nil // If no watcher yet if mpdWatcher == nil { watcher, err := mpd.NewWatcher(c.mpdNetwork, c.mpdAddress, c.mpdPassword) // Failed to connect if err != nil { log.Warning("Failed to watch MPD", err) // Schedule a reconnection rewatchTimer = time.AfterFunc(3*time.Second, func() { c.chWatcherStart <- true }) } else { // Connection succeeded mpdWatcher = watcher eventChannel = watcher.Event errorChannel = watcher.Error } } // Watcher's event case subsystem := <-eventChannel: // Provide an empty map as fallback status := mpd.Attrs{} // Request player status if there's a connection c.IfConnected(func(client *mpd.Client) { st, err := client.Status() if errCheck(err, "watch(): Status() failed") { return } status = st }) // Update the MPD's status c.setStatus(status) // Notify the callback c.onSubsystemChange(subsystem) // Watcher's error case err := <-errorChannel: log.Debug("Watcher error", err) // Request to quit case doQuit := <-c.chWatcherStop: // Kill the reconnection timer, if any if rewatchTimer != nil { rewatchTimer.Stop() rewatchTimer = nil } // Close the connection to MPD, if any if mpdWatcher != nil { log.Debug("Stop watcher") errCheck(mpdWatcher.Close(), "mpdWatcher.Close() failed") mpdWatcher = nil } // If we need to quit if doQuit { return } } } } ymuse-0.22/internal/player/glade/000077500000000000000000000000001450727225500167745ustar00rootroot00000000000000ymuse-0.22/internal/player/glade/mpd-info.glade000066400000000000000000000313101450727225500215010ustar00rootroot00000000000000 False True True dialog ok <b><big>MPD Information</big></b> True False vertical 2 False False False 0 True False 20 3 12 True False Daemon version: 0 0 0 True False Number of artists: 0 0 1 True False Number of albums: 0 0 2 True False Number of tracks: 0 0 3 True False Total playing time: 0 0 4 True False Last database update: 0 0 5 True False Daemon uptime: 0 0 6 True False Listening time: 0 0 7 True False 1 1 0 True False 1 1 1 True False 1 1 2 True False 1 1 3 True False 1 1 4 True False 1 1 5 True False 1 1 6 True False 1 1 7 True True 6 6 True False 12 True False Decoder plugins 0 8 2 False True 2 ymuse-0.22/internal/player/glade/outputs.glade000066400000000000000000000074071450727225500215250ustar00rootroot00000000000000 False MPD Outputs True 500 300 True dialog True False vertical 2 False end False False 0 True False 12 vertical True False 6 Choose outputs MPD should use for playback. 0 False True 0 True True in True False True False browse True True 1 True True 1 ymuse-0.22/internal/player/glade/player.glade000066400000000000000000002544571450727225500213070ustar00rootroot00000000000000 True False True False Append to the queue True True False Replace the queue True True False True False Rename True True False Delete True True False Update True True False True False Add to playlist… True 100 25 1 10 QueueListStore True False True False Now playing True True False True False Show album in Library True True False Show artist in Library True True False Show genre in Library True True False True False Clear True True False Delete selected True False True False 12 vertical 6 True False True 6 6 True False False Stream name: 1 0 0 True True True 60 1 1 True False False Stream URI: 1 0 1 True True True 60 1 0 False True 0 True True True app.stream.props.apply Apply True False True 1 main 1 True False True False Append to the queue True True False Replace the queue True True False True False Edit True True False Delete True 100 1 10 False AppMenuButton True False 12 vertical True True True app.mpd.connect _Connect to MPD False True 0 True True True app.mpd.disconnect _Disconnect from MPD False True 1 True True True app.mpd.info MPD _information… False True 2 True True True app.outputs MPD _outputs… False True 3 True False False True 4 True True True app.prefs _Preferences… False True 5 True False False True 6 True True True app.about _About… False True 7 True True True app.shortcuts _Keyboard shortcuts… False True 8 True True True app.quit _Quit False True 9 main 1 600 300 False 800 600 com.yktoo.ymuse True False 6 6 6 6 vertical True False True True slide-left-right True False vertical True False 2 True False Jump to the currently played track app.queue.now-playing Now playing True ymuse-now-playing-symbolic False True True False Clear the play queue app.queue.clear Clear True ymuse-clear-symbolic False True True False Sort the play queue True app.queue.sort Sort ▾ True ymuse-sort-symbolic False True True False Remove selected track(s) from the queue app.queue.delete Delete True ymuse-delete-track-symbolic False True True False Save the play queue as a playlist True app.queue.save Save ▾ True ymuse-save-symbolic False True True False Filter the play queue Search True ymuse-filter-symbolic False True False True 0 True False True True True 50 ymuse-filter-symbolic False False Filter… False True 1 True True True True etched-out True True True True QueueTreeModelFilter False True False multiple False True 2 True False 6 True False 3 3 end False True True 0 False 6 3 3 False True 1 False True 3 queue Queue True False vertical True False True False False 2 True False Update the music library True app.library.update Update ▾ ymuse-update-db-symbolic False True True False Rename the selected item app.library.rename Rename ymuse-edit-symbolic False True True False Delete the selected item app.library.delete Delete ymuse-delete-symbolic False True True False Add the selected item to a playlist True app.library.add-to-playlist Add to ▾ ymuse-add-symbolic False True True False Search the library app.library.search.toggle Search ymuse-search-symbolic False True False True 0 True False slide-up-down True False 6 path Path True False 6 6 True False Track attribute(s) to search False True end 1 True True ymuse-search-symbolic False False Search… True True 1 search Search 1 True True 1 False True 0 True True True True in True False True False browse False True 2 True False vertical True False 3 3 end False True True 0 False True 3 library Library 1 True False vertical True False 2 True False Add a new stream app.stream.add Add True ymuse-add-symbolic False True True False Edit the selected stream app.stream.edit Edit True ymuse-edit-symbolic False True True False Delete the selected stream app.stream.delete Delete True ymuse-delete-symbolic False True False True 0 True True True True in True False True False browse False True 1 True False vertical True False 3 3 end False True True 0 False True 2 streams Streams 2 True True 0 True False center 12 True False end dialog-question False False 0 True False end False 0 False True 1 False False 6 1 True False 6 True False 12 True False 4 True False Previous track app.player.previous Previous True ymuse-previous-symbolic False True True False Stop playback app.player.stop Stop True ymuse-stop-symbolic False True True False Pause or resume playback app.player.play-pause Play/Pause True ymuse-play-symbolic False True True False Next track app.player.next Next True ymuse-next-symbolic False True True False False True True False Shuffle mode app.player.toggle.random Random True ymuse-random-symbolic False True True False Repeat mode app.player.toggle.repeat Repeat True ymuse-repeat-symbolic False True True False Consume mode app.player.toggle.consume Consume True ymuse-consume-symbolic False True False True 0 True True False True none vertical VolumeAdjustment audio-volume-muted-symbolic audio-volume-high-symbolic audio-volume-low-symbolic audio-volume-medium-symbolic True True center center none True True center center none False True 1 100 True False True PlayPositionAdjustment False 0 0 0 False right True True 2 100 True False Current track time <big>0:00</big> / 0:00 True False False True 6 3 False True 2 True False True True True start MainStack True True True AppPopoverMenu True False open-menu-symbolic end 1 False LibraryAddToPlaylistToolButton True False 12 vertical main 1 False LibraryUpdateToolButton True False 12 vertical True True True Update the entire music database app.library.update.all Update entire library False True 0 True True True Update the selected item in music database app.library.update.selected Update selected item False True 1 True True True Update the entire music database, including unmodified files app.library.rescan.all Rescan all files False True 2 True True True Update the selected item, including unmodified files app.library.rescan.selected Rescan selected item False True 3 main 1 False QueueSaveToolButton True False 12 vertical 6 True False True 6 6 True False False Save into playlist 1 0 0 True False True 1 0 True True True 1 1 Save selected tracks only True True False True 1 2 True False False New playlist name 1 0 1 False True 0 True False True True True app.queue.save.replace Replace playlist True True True 0 True True True app.queue.save.append Append tracks True True True 1 False True 1 main 1 False QueueSortToolButton True False 12 vertical 6 True False 6 True False Sort queue by 0 False True 0 True False True False True 1 True True True app.queue.sort.asc Ascending True False True 2 True True True app.queue.sort.desc Descending True False True 3 False True 0 True False False True 1 True True True app.queue.sort.shuffle Shuffle False True 2 main 1 ymuse-0.22/internal/player/glade/prefs.glade000066400000000000000000001457241450727225500211260ustar00rootroot00000000000000 1 65535 6600 1 10 50 500 80 10 100 False Preferences True True dialog True False True True vertical False end False False 0 True True False True False 12 vertical True False 0 none True False 12 6 6 6 6 True False Host: right 1 0 2 True True 1 2 True False (leave empty for localhost) 0 2 2 True False Port: right 1 0 3 True True MpdPortAdjustment 1 3 True False Password: right 1 0 4 True True False 1 4 Automatically connect on startup True True False start True 1 5 Automatically reconnect True True False start True 1 6 Reconnect now True True True 1 7 True True 1 1 True False Path: right 1 0 1 True False Network: right 1 0 0 True False 1 Unix socket TCP 1 0 True False <b>MPD connection</b> True False True 0 True False General False True False 12 vertical 6 True False 0 none Show toolbar True True False 12 6 6 True True False <b>Queue</b> True False True 0 True False 0 none True False 12 6 6 vertical True False On double click / Enter on a track: 0 False True 6 0 Replace the queue True True False start True True False True 1 Append to the queue True True False start True LibraryDefaultReplaceRadioButton False True 2 True False <b>Library</b> True False True 1 True False 0 none True False 12 6 6 vertical True False On double click / Enter on a playlist: 0 False True 6 0 Replace the queue True True False start True True False True 1 Append to the queue True True False start True PlaylistsDefaultReplaceRadioButton False True 2 True False <b>Playlists</b> True False True 2 True False 0 none True False 12 6 6 vertical True False On double click / Enter on a stream: 0 False True 6 0 Replace the queue True True False start True True False True 1 Append to the queue True True False start True StreamsDefaultReplaceRadioButton False True 2 True False <b>Streams</b> True False True 3 1 True False Interface 1 False True False 12 vertical True False After the queue is replaced: 0 False True 0 True False 12 6 6 vertical Switch to Queue tab True True False True False True 0 Start playback True True False True False True 1 False True 1 2 True False Automation 2 False True False 12 vertical 6 True False 0 none True False 12 6 6 vertical Show for tracks True True False True False True 0 Show for streams True True False True False True 1 True False 6 Image size: 0 False True 6 2 True True 6 PlayerAlbumArtSizeAdjustment 0 0 False False True 3 True False Album art: True False True 0 True False Track title template: 0 False True 1 500 200 True True always in True True PlayerTitleTemplateTextBuffer True True True 2 3 True False Player 3 False True False 12 vertical True False 6 Select columns to display in the play queue, and their order. 0 False True 0 True True in True False True False browse True True 1 True False icons 2 True False Move the selected column up Move up True go-up False True True False Move the selected column down Move down True go-down False True False True 2 4 True False Columns 4 False True True 1 ymuse-0.22/internal/player/glade/shortcuts.glade000066400000000000000000000252211450727225500220320ustar00rootroot00000000000000 1 Ymuse shortcuts 1 800 600 shortcuts Application shortcuts General (Re)connect to MPD <ctrl><shift>c Disconnect from MPD <ctrl><shift>d MPD Information <ctrl><shift>i MPD Outputs <ctrl>o Preferences <ctrl>comma Quit <ctrl>q About F1 Keyboard Shortcuts <ctrl><shift>question Switch to Queue tab <ctrl>1 Switch to Library tab <ctrl>2 Switch to Streams tab <ctrl>3 Player Previous track <ctrl>Left Next track <ctrl>Right Stop <ctrl>S Toggle play/pause <ctrl>P Toggle random mode <ctrl>U Toggle repeat mode <ctrl>R Toggle consume mode <ctrl>N Seek backward <ctrl><shift>Left Seek forward <ctrl><shift>Right Queue Play selection Return Toggle play/pause space Delete selected Delete Now playing <ctrl>J Open Filter bar <ctrl>F Shuffle the queue <ctrl><shift>R Library Default action (set in Preferences) Return Replace queue with selection <ctrl>Return Append selection to queue <shift>Return Go a level up BackSpace Open Search bar <ctrl>F Streams Default action (set in Preferences) Return Replace queue with selection <ctrl>Return Append selection to queue <shift>Return ymuse-0.22/internal/player/libpath.go000066400000000000000000000566641450727225500177130ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package player import ( "fmt" "github.com/fhs/gompd/v2/mpd" "github.com/gotk3/gotk3/glib" "github.com/yktoo/ymuse/internal/config" "github.com/yktoo/ymuse/internal/util" "path" "strings" ) // LibraryPathElement represents one element in the library path type LibraryPathElement interface { Icon() string // Icon returns name of the icon for the element Label() string // Label returns display label text for the element IsFolder() bool // IsFolder denotes whether the element can be entered into (eg. by double-clicking it) IsPlayable() bool // IsPlayable denotes whether the element can be played (added to the queue) Marshal() string // Serialises the element into a string Unmarshal(data string) error // Deserialises the element from a string Prefix() string // Returns a string prefix for marshalling } // URIHolder represents an object that possesses a URI type URIHolder interface { URI() string } // AttributeHolder represents an object that possesses an MPD attribute and the corresponding value type AttributeHolder interface { AttributeID() int // Attribute's ID AttributeValue() string // Attribute's value } // AttributeHolderParent represents an object that can be a parent for AttributeHolder type AttributeHolderParent interface { ChildAttributeID() int // Child attribute's ID NewChild(value string) LibraryPathElement // Instantiates and returns a new child element with the given value } // DetailsHolder represents an object that can provide additional details type DetailsHolder interface { Details() string } // PlaylistHolder represents an object that references a playlist type PlaylistHolder interface { PlaylistName() string } var elementConstructors = map[string]func() LibraryPathElement{ "lvlup": NewLevelUpLibElement, "filesystem": NewFilesystemLibElement, "dir": NewDirLibElement, "file": NewFileLibElement, "playlists": NewPlaylistsLibElement, "playlist": NewPlaylistLibElement, "genres": NewGenresLibElement, "genre": NewGenreLibElement, "artists": NewArtistsLibElement, "artist": NewArtistLibElement, "albums": NewAlbumsLibElement, "album": NewAlbumLibElement, "track": NewTrackLibElement, } const ( pathFieldSeparator = "\u0001" pathElementSeparator = "\u0002" ) // UnmarshalLibPathElement instantiates and initialises a library path element from the given serialised string form func UnmarshalLibPathElement(data string) (LibraryPathElement, error) { // Extract type prefix i := strings.Index(data, pathFieldSeparator) if i < 0 { return nil, fmt.Errorf("failed to unmarshal library path element: missing prefix") } prefix := data[0:i] // If the prefix is known, instantiate and unmarshal the element if constructor, ok := elementConstructors[prefix]; ok { element := constructor() if err := element.Unmarshal(data[i+1:]); err != nil { return nil, err } return element, nil } // Prefix unknown return nil, fmt.Errorf("failed to unmarshal library path element: unknown prefix '%v'", prefix) } // MarshalLibPathElement serialises a library path element into string form func MarshalLibPathElement(e LibraryPathElement) string { return e.Prefix() + pathFieldSeparator + e.Marshal() } // AttrsToElements converts the provided list of MPD Attributes instances into a slice of elements, stripping the // (optional) URI prefix func AttrsToElements(attrs []mpd.Attrs, uriPrefix string) []LibraryPathElement { result := make([]LibraryPathElement, 0, len(attrs)) for _, a := range attrs { if dir, ok := a["directory"]; ok { result = append(result, &DirLibElement{ uri: dir, title: strings.TrimPrefix(dir, uriPrefix), }) } else if file, ok := a["file"]; ok { result = append(result, &FileLibElement{ uri: file, title: strings.TrimPrefix(file, uriPrefix), length: util.ParseFloatDef(a["duration"], 0.0), }) } else if playlist, ok := a["playlist"]; ok { result = append(result, &PlaylistLibElement{ name: strings.TrimPrefix(playlist, uriPrefix), }) } else { continue } } return result } //---------------------------------------------------------------------------------------------------------------------- // LibraryPath //---------------------------------------------------------------------------------------------------------------------- // LibraryPath holds a series of LibraryPathElement's type LibraryPath struct { elements []LibraryPathElement // Internal list of elements onChanged func() // On path change callback } func NewLibraryPath(onChanged func()) *LibraryPath { return &LibraryPath{onChanged: onChanged} } // Append extends the current path with the given element func (p *LibraryPath) Append(e LibraryPathElement) { p.elements = append(p.elements, e) p.onChanged() } // AsFilter converts the path (with optional additions) into a slice of arguments for MPD's filter function func (p *LibraryPath) AsFilter(extraElements ...LibraryPathElement) (result []string) { // Iterate all elements, including extras for _, e := range append(p.elements, extraElements...) { // Select only those associated with attributes if ah, oka := e.(AttributeHolder); oka { // For each element, add two elements to the slice: the name and the value result = append( result, config.MpdTrackAttributes[ah.AttributeID()].AttrName, ah.AttributeValue()) } } return } // ElementAt returns the element at the given index, or nil if no such element exists func (p *LibraryPath) ElementAt(index int) LibraryPathElement { if index >= 0 && index < len(p.elements) { return p.elements[index] } return nil } // Elements returns the elements slice func (p *LibraryPath) Elements() []LibraryPathElement { return p.elements } // IsRoot returns whether the current path represents root func (p *LibraryPath) IsRoot() bool { return len(p.elements) == 0 } // Last returns the last element in the current path, or nil if the path is empty func (p *LibraryPath) Last() LibraryPathElement { if i := len(p.elements); i > 0 { return p.elements[i-1] } return nil } // LevelUp shifts the current path one level up (by dropping the last element) func (p *LibraryPath) LevelUp() { if i := len(p.elements); i > 0 { p.elements = p.elements[:i-1] p.onChanged() } } // Marshal serialises the current path as a string func (p *LibraryPath) Marshal() string { s := "" for _, e := range p.elements { if s != "" { s += pathElementSeparator } s += MarshalLibPathElement(e) } return s } // SetElements updates the elements to the given slice func (p *LibraryPath) SetElements(elements []LibraryPathElement) { p.elements = elements p.onChanged() } // SetLength limits the length of the path at the given figure func (p *LibraryPath) SetLength(length int) { if length > len(p.elements) { return } p.elements = p.elements[:length] p.onChanged() } // Unmarshal deserialises the path from a string func (p *LibraryPath) Unmarshal(s string) error { // Iterate serialised elements var elements []LibraryPathElement for _, s := range strings.Split(s, pathElementSeparator) { // Skip over empty elements if s != "" { element, err := UnmarshalLibPathElement(s) if err != nil { return err } elements = append(elements, element) } } // Succeeded p.elements = elements p.onChanged() return nil } //---------------------------------------------------------------------------------------------------------------------- // BaseAttrHolder: base (incomplete) implementation of AttributeHolder lacking NewChild() //---------------------------------------------------------------------------------------------------------------------- type BaseAttrHolder struct { attrID int attrValue string } func (h *BaseAttrHolder) AttributeID() int { return h.attrID } func (h *BaseAttrHolder) AttributeValue() string { return h.attrValue } //---------------------------------------------------------------------------------------------------------------------- // LevelUpLibElement - a LibraryPathElement that looks as a ".." and is used to navigate to the parent //---------------------------------------------------------------------------------------------------------------------- type LevelUpLibElement struct{} func NewLevelUpLibElement() LibraryPathElement { return &LevelUpLibElement{} } func (e *LevelUpLibElement) Icon() string { return "ymuse-level-up-symbolic" } func (e *LevelUpLibElement) Label() string { return "" } func (e *LevelUpLibElement) IsFolder() bool { return false } func (e *LevelUpLibElement) IsPlayable() bool { return false } func (e *LevelUpLibElement) Prefix() string { return "lvlup" } func (e *LevelUpLibElement) Marshal() string { return "" } func (e *LevelUpLibElement) Unmarshal(string) error { return nil } //---------------------------------------------------------------------------------------------------------------------- // FilesystemLibElement //---------------------------------------------------------------------------------------------------------------------- type FilesystemLibElement struct{} func NewFilesystemLibElement() LibraryPathElement { return &FilesystemLibElement{} } func (e *FilesystemLibElement) Icon() string { return "drive-harddisk" } func (e *FilesystemLibElement) Label() string { return glib.Local("Files") } func (e *FilesystemLibElement) IsFolder() bool { return true } func (e *FilesystemLibElement) IsPlayable() bool { return true } func (e *FilesystemLibElement) Prefix() string { return "filesystem" } func (e *FilesystemLibElement) Marshal() string { return "" } func (e *FilesystemLibElement) Unmarshal(string) error { return nil } func (e *FilesystemLibElement) URI() string { return "" } //---------------------------------------------------------------------------------------------------------------------- // DirLibElement //---------------------------------------------------------------------------------------------------------------------- type DirLibElement struct { uri string // URI of the directory title string // Title of the directory } func NewDirLibElement() LibraryPathElement { return &DirLibElement{} } func (e *DirLibElement) Icon() string { return "folder" } func (e *DirLibElement) Label() string { return path.Base(e.uri) } func (e *DirLibElement) IsFolder() bool { return true } func (e *DirLibElement) IsPlayable() bool { return true } func (e *DirLibElement) Prefix() string { return "dir" } func (e *DirLibElement) Marshal() string { return e.uri } func (e *DirLibElement) Unmarshal(data string) error { fields := strings.Split(data, pathFieldSeparator) if len(fields) != 1 { return fmt.Errorf("failed to unmarshal DirLibElement: want 1 field, got %d", len(fields)) } e.uri = fields[0] return nil } func (e *DirLibElement) URI() string { return e.uri } //---------------------------------------------------------------------------------------------------------------------- // FileLibElement //---------------------------------------------------------------------------------------------------------------------- type FileLibElement struct { uri string // URI of the file title string // Title of the track length float64 // Length of the track in seconds } func NewFileLibElement() LibraryPathElement { return &FileLibElement{} } func (e *FileLibElement) Icon() string { return "ymuse-audio-file" } func (e *FileLibElement) Label() string { return e.title } func (e *FileLibElement) IsFolder() bool { return false } func (e *FileLibElement) IsPlayable() bool { return true } func (e *FileLibElement) Prefix() string { return "file" } func (e *FileLibElement) Marshal() string { return e.uri + pathFieldSeparator + e.title } func (e *FileLibElement) Unmarshal(data string) error { fields := strings.Split(data, pathFieldSeparator) if len(fields) != 2 { return fmt.Errorf("failed to unmarshal FileLibElement: want 2 fields, got %d", len(fields)) } e.uri = fields[0] e.title = fields[1] return nil } func (e *FileLibElement) URI() string { return e.uri } func (e *FileLibElement) Details() string { if e.length > 0 { return util.FormatSeconds(e.length) } return "" } //---------------------------------------------------------------------------------------------------------------------- // PlaylistsLibElement //---------------------------------------------------------------------------------------------------------------------- type PlaylistsLibElement struct{} func NewPlaylistsLibElement() LibraryPathElement { return &PlaylistsLibElement{} } func (e *PlaylistsLibElement) Icon() string { return "ymuse-playlists" } func (e *PlaylistsLibElement) Label() string { return glib.Local("Playlists") } func (e *PlaylistsLibElement) IsFolder() bool { return true } func (e *PlaylistsLibElement) IsPlayable() bool { return false } func (e *PlaylistsLibElement) Prefix() string { return "playlists" } func (e *PlaylistsLibElement) Marshal() string { return "" } func (e *PlaylistsLibElement) Unmarshal(string) error { return nil } func (e *PlaylistsLibElement) NewChild(name string) LibraryPathElement { return NewPlaylistLibElementName(name) } //---------------------------------------------------------------------------------------------------------------------- // PlaylistLibElement //---------------------------------------------------------------------------------------------------------------------- type PlaylistLibElement struct { name string // Playlist name } func NewPlaylistLibElement() LibraryPathElement { return NewPlaylistLibElementName("") } func NewPlaylistLibElementName(name string) LibraryPathElement { return &PlaylistLibElement{name: name} } func (e *PlaylistLibElement) Icon() string { return "ymuse-playlist" } func (e *PlaylistLibElement) Label() string { return e.name } func (e *PlaylistLibElement) IsFolder() bool { return false } func (e *PlaylistLibElement) IsPlayable() bool { return true } func (e *PlaylistLibElement) Prefix() string { return "playlist" } func (e *PlaylistLibElement) Marshal() string { return e.name } func (e *PlaylistLibElement) Unmarshal(data string) error { fields := strings.Split(data, pathFieldSeparator) if len(fields) != 1 { return fmt.Errorf("failed to unmarshal PlaylistLibElement: want 1 fields, got %d", len(fields)) } e.name = fields[0] return nil } func (e *PlaylistLibElement) PlaylistName() string { return e.name } //---------------------------------------------------------------------------------------------------------------------- // GenresLibElement //---------------------------------------------------------------------------------------------------------------------- type GenresLibElement struct{} func NewGenresLibElement() LibraryPathElement { return &GenresLibElement{} } func (e *GenresLibElement) Icon() string { return "ymuse-genres" } func (e *GenresLibElement) Label() string { return glib.Local("Genres") } func (e *GenresLibElement) IsFolder() bool { return true } func (e *GenresLibElement) IsPlayable() bool { return false } func (e *GenresLibElement) Prefix() string { return "genres" } func (e *GenresLibElement) Marshal() string { return "" } func (e *GenresLibElement) Unmarshal(string) error { return nil } func (e *GenresLibElement) ChildAttributeID() int { return config.MTAttrGenre } func (e *GenresLibElement) NewChild(value string) LibraryPathElement { return NewGenreLibElementVal(value) } //---------------------------------------------------------------------------------------------------------------------- // GenreLibElement //---------------------------------------------------------------------------------------------------------------------- type GenreLibElement struct { BaseAttrHolder } func NewGenreLibElement() LibraryPathElement { return NewGenreLibElementVal("") } func NewGenreLibElementVal(value string) LibraryPathElement { return &GenreLibElement{BaseAttrHolder{attrID: config.MTAttrGenre, attrValue: value}} } func (e *GenreLibElement) Icon() string { return "ymuse-genre" } func (e *GenreLibElement) Label() string { if e.attrValue == "" { return glib.Local("(unknown)") } return e.attrValue } func (e *GenreLibElement) IsFolder() bool { return true } func (e *GenreLibElement) IsPlayable() bool { return true } func (e *GenreLibElement) Prefix() string { return "genre" } func (e *GenreLibElement) Marshal() string { return e.attrValue } func (e *GenreLibElement) Unmarshal(data string) error { fields := strings.Split(data, pathFieldSeparator) if len(fields) != 1 { return fmt.Errorf("failed to unmarshal GenreLibElement: want 1 field, got %d", len(fields)) } e.attrValue = fields[0] return nil } func (e *GenreLibElement) ChildAttributeID() int { return config.MTAttrArtist } func (e *GenreLibElement) NewChild(value string) LibraryPathElement { return NewArtistLibElementVal(value) } //---------------------------------------------------------------------------------------------------------------------- // ArtistsLibElement //---------------------------------------------------------------------------------------------------------------------- type ArtistsLibElement struct{} func NewArtistsLibElement() LibraryPathElement { return &ArtistsLibElement{} } func (e *ArtistsLibElement) Icon() string { return "ymuse-artists" } func (e *ArtistsLibElement) Label() string { return glib.Local("Artists") } func (e *ArtistsLibElement) IsFolder() bool { return true } func (e *ArtistsLibElement) IsPlayable() bool { return false } func (e *ArtistsLibElement) Prefix() string { return "artists" } func (e *ArtistsLibElement) Marshal() string { return "" } func (e *ArtistsLibElement) Unmarshal(string) error { return nil } func (e *ArtistsLibElement) ChildAttributeID() int { return config.MTAttrArtist } func (e *ArtistsLibElement) NewChild(value string) LibraryPathElement { return NewArtistLibElementVal(value) } //---------------------------------------------------------------------------------------------------------------------- // ArtistLibElement //---------------------------------------------------------------------------------------------------------------------- type ArtistLibElement struct { BaseAttrHolder } func NewArtistLibElement() LibraryPathElement { return NewArtistLibElementVal("") } func NewArtistLibElementVal(value string) LibraryPathElement { return &ArtistLibElement{BaseAttrHolder{attrID: config.MTAttrArtist, attrValue: value}} } func (e *ArtistLibElement) Icon() string { return "ymuse-artist" } func (e *ArtistLibElement) Label() string { if e.attrValue == "" { return glib.Local("(unknown)") } return e.attrValue } func (e *ArtistLibElement) IsFolder() bool { return true } func (e *ArtistLibElement) IsPlayable() bool { return true } func (e *ArtistLibElement) Prefix() string { return "artist" } func (e *ArtistLibElement) Marshal() string { return e.attrValue } func (e *ArtistLibElement) Unmarshal(data string) error { fields := strings.Split(data, pathFieldSeparator) if len(fields) != 1 { return fmt.Errorf("failed to unmarshal ArtistLibElement: want 1 field, got %d", len(fields)) } e.attrValue = fields[0] return nil } func (e *ArtistLibElement) ChildAttributeID() int { return config.MTAttrAlbum } func (e *ArtistLibElement) NewChild(value string) LibraryPathElement { return NewAlbumLibElementVal(value) } //---------------------------------------------------------------------------------------------------------------------- // AlbumsLibElement //---------------------------------------------------------------------------------------------------------------------- type AlbumsLibElement struct{} func NewAlbumsLibElement() LibraryPathElement { return &AlbumsLibElement{} } func (e *AlbumsLibElement) Icon() string { return "ymuse-albums" } func (e *AlbumsLibElement) Label() string { return glib.Local("Albums") } func (e *AlbumsLibElement) IsFolder() bool { return true } func (e *AlbumsLibElement) IsPlayable() bool { return false } func (e *AlbumsLibElement) Prefix() string { return "albums" } func (e *AlbumsLibElement) Marshal() string { return "" } func (e *AlbumsLibElement) Unmarshal(string) error { return nil } func (e *AlbumsLibElement) ChildAttributeID() int { return config.MTAttrAlbum } func (e *AlbumsLibElement) NewChild(value string) LibraryPathElement { return NewAlbumLibElementVal(value) } //---------------------------------------------------------------------------------------------------------------------- // AlbumLibElement //---------------------------------------------------------------------------------------------------------------------- type AlbumLibElement struct { BaseAttrHolder } func NewAlbumLibElement() LibraryPathElement { return NewAlbumLibElementVal("") } func NewAlbumLibElementVal(value string) LibraryPathElement { return &AlbumLibElement{BaseAttrHolder{attrID: config.MTAttrAlbum, attrValue: value}} } func (e *AlbumLibElement) Icon() string { return "ymuse-album" } func (e *AlbumLibElement) Label() string { if e.attrValue == "" { return glib.Local("(unknown)") } return e.attrValue } func (e *AlbumLibElement) IsFolder() bool { return true } func (e *AlbumLibElement) IsPlayable() bool { return true } func (e *AlbumLibElement) Prefix() string { return "album" } func (e *AlbumLibElement) Marshal() string { return e.attrValue } func (e *AlbumLibElement) Unmarshal(data string) error { fields := strings.Split(data, pathFieldSeparator) if len(fields) != 1 { return fmt.Errorf("failed to unmarshal AlbumLibElement: want 1 field, got %d", len(fields)) } e.attrValue = fields[0] return nil } func (e *AlbumLibElement) ChildAttributeID() int { return config.MTAttrTrack } func (e *AlbumLibElement) NewChild(value string) LibraryPathElement { return NewTrackLibElementVal(value) } //---------------------------------------------------------------------------------------------------------------------- // TrackLibElement //---------------------------------------------------------------------------------------------------------------------- type TrackLibElement struct { BaseAttrHolder } func NewTrackLibElement() LibraryPathElement { return NewTrackLibElementVal("") } func NewTrackLibElementVal(value string) LibraryPathElement { return &TrackLibElement{BaseAttrHolder{attrID: config.MTAttrTrack, attrValue: value}} } func (e *TrackLibElement) Icon() string { return "ymuse-audio-file" } func (e *TrackLibElement) Label() string { if e.attrValue == "" { return glib.Local("(unknown)") } return e.attrValue } func (e *TrackLibElement) IsFolder() bool { return false } func (e *TrackLibElement) IsPlayable() bool { return true } func (e *TrackLibElement) Prefix() string { return "track" } func (e *TrackLibElement) Marshal() string { return e.attrValue } func (e *TrackLibElement) Unmarshal(data string) error { fields := strings.Split(data, pathFieldSeparator) if len(fields) != 1 { return fmt.Errorf("failed to unmarshal TrackLibElement: want 1 field, got %d", len(fields)) } e.attrValue = fields[0] return nil } ymuse-0.22/internal/player/log.go000066400000000000000000000016451450727225500170360ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package player import ( "fmt" "github.com/op/go-logging" ) // Package-wide Logger instance var log = logging.MustGetLogger("player") // errCheck logs a warning if the error is not nil. func errCheck(err error, message string) bool { if err != nil { log.Warning(fmt.Errorf("%v: %v", message, err)) return true } return false } ymuse-0.22/internal/player/main-window.go000066400000000000000000002543521450727225500205130ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package player import ( "C" "bytes" "fmt" "github.com/fhs/gompd/v2/mpd" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" "github.com/yktoo/ymuse/internal/config" "github.com/yktoo/ymuse/internal/util" "html" "html/template" "path" "sort" "strconv" "strings" "time" ) // MainWindow represents the main application window type MainWindow struct { app *gtk.Application // Application reference connector *Connector // Connector instance mapped bool // Whether the main window is mapped (~visible) // Control widgets AppWindow *gtk.ApplicationWindow // Main window MainStack *gtk.Stack StatusLabel *gtk.Label PositionLabel *gtk.Label PlayPauseButton *gtk.ToolButton RandomButton *gtk.ToggleToolButton RepeatButton *gtk.ToggleToolButton ConsumeButton *gtk.ToggleToolButton VolumeButton *gtk.VolumeButton VolumeAdjustment *gtk.Adjustment PlayPositionScale *gtk.Scale PlayPositionAdjustment *gtk.Adjustment AlbumArtworkImage *gtk.Image // Queue widgets QueueBox *gtk.Box QueueToolbar *gtk.Toolbar QueueInfoLabel *gtk.Label QueueTreeView *gtk.TreeView QueueSortPopoverMenu *gtk.PopoverMenu QueueSavePopoverMenu *gtk.PopoverMenu QueueMenu *gtk.Menu QueueNowPlayingMenuItem *gtk.MenuItem QueueShowAlbumInLibraryMenuItem *gtk.MenuItem QueueShowArtistInLibraryMenuItem *gtk.MenuItem QueueShowGenreInLibraryMenuItem *gtk.MenuItem QueueClearMenuItem *gtk.MenuItem QueueDeleteMenuItem *gtk.MenuItem QueueFilterToolButton *gtk.ToggleToolButton QueueSearchBar *gtk.SearchBar QueueSearchEntry *gtk.SearchEntry QueueFilterLabel *gtk.Label QueueListStore *gtk.ListStore QueueTreeModelFilter *gtk.TreeModelFilter // Queue sort popup QueueSortByComboBox *gtk.ComboBoxText // Queue save popup QueueSavePlaylistComboBox *gtk.ComboBoxText QueueSavePlaylistNameLabel *gtk.Label QueueSavePlaylistNameEntry *gtk.Entry QueueSaveSelectedOnlyCheckButton *gtk.CheckButton // Library widgets LibraryUpdatePopoverMenu *gtk.PopoverMenu LibraryAddToPlaylistPopoverMenu *gtk.PopoverMenu LibraryAddToPlaylistBox *gtk.Box LibraryBox *gtk.Box LibraryPathBox *gtk.Box LibrarySearchBox *gtk.Box LibrarySearchToolButton *gtk.ToggleToolButton LibraryToolStack *gtk.Stack LibrarySearchEntry *gtk.SearchEntry LibrarySearchAttrComboBox *gtk.ComboBoxText LibraryListBox *gtk.ListBox LibraryInfoLabel *gtk.Label LibraryMenu *gtk.Menu LibraryAppendMenuItem *gtk.MenuItem LibraryReplaceMenuItem *gtk.MenuItem LibraryRenameMenuItem *gtk.MenuItem LibraryDeleteMenuItem *gtk.MenuItem LibraryUpdateSelMenuItem *gtk.MenuItem LibraryAddToPlaylistMenuItem *gtk.MenuItem // Streams widgets StreamsBox *gtk.Box StreamsAddToolButton *gtk.ToolButton StreamsEditToolButton *gtk.ToolButton StreamsListBox *gtk.ListBox StreamsInfoLabel *gtk.Label StreamsMenu *gtk.Menu StreamsAppendMenuItem *gtk.MenuItem StreamsReplaceMenuItem *gtk.MenuItem StreamsEditMenuItem *gtk.MenuItem StreamsDeleteMenuItem *gtk.MenuItem // Streams props popup StreamPropsPopoverMenu *gtk.PopoverMenu StreamPropsNameEntry *gtk.Entry StreamPropsUriEntry *gtk.Entry // Actions aMPDDisconnect *glib.SimpleAction aMPDInfo *glib.SimpleAction aMPDOutputs *glib.SimpleAction aQueueNowPlaying *glib.SimpleAction aQueueClear *glib.SimpleAction aQueueSort *glib.SimpleAction aQueueSortAsc *glib.SimpleAction aQueueSortDesc *glib.SimpleAction aQueueSortShuffle *glib.SimpleAction aQueueDelete *glib.SimpleAction aQueueSave *glib.SimpleAction aQueueSaveReplace *glib.SimpleAction aQueueSaveAppend *glib.SimpleAction aLibraryUpdate *glib.SimpleAction aLibraryUpdateAll *glib.SimpleAction aLibraryUpdateSel *glib.SimpleAction aLibraryRescanAll *glib.SimpleAction aLibraryRescanSel *glib.SimpleAction aLibraryRename *glib.SimpleAction aLibraryDelete *glib.SimpleAction aLibraryAddToPlaylist *glib.SimpleAction aStreamAdd *glib.SimpleAction aStreamEdit *glib.SimpleAction aStreamDelete *glib.SimpleAction aStreamPropsApply *glib.SimpleAction aPlayerPrevious *glib.SimpleAction aPlayerStop *glib.SimpleAction aPlayerPlayPause *glib.SimpleAction aPlayerNext *glib.SimpleAction aPlayerSeekBackward *glib.SimpleAction aPlayerSeekForward *glib.SimpleAction aPlayerRandom *glib.SimpleAction aPlayerRepeat *glib.SimpleAction aPlayerConsume *glib.SimpleAction // Colours colourBgNormal string // Normal background colour colourBgActive string // Active background colour currentQueueSize int // Number of items in the play queue currentQueueIndex int // Queue's track index (last) marked as current libPath *LibraryPath // Current library path libPathElementToSelect string // Library path element to select after list load (serialised) playerTitleTemplate *template.Template // Compiled template for player's track title playerCurrentAlbumArtUri string // URI of the current player's album art volumeUpdating bool // Volume button update (initiated by an MPD event) flag playPosUpdating bool // Play position manual update flag optionsUpdating bool // Options update flag addingStream bool // Whether the property popover is open to add a stream (rather than edit an existing one) } const ( // Rendering properties for the Queue list fontWeightNormal = 400 fontWeightBold = 700 queueSaveNewPlaylistID = "\u0001new" librarySearchAllAttrID = "\u0001any" ) type triBool int const ( tbNone triBool = iota - 1 tbFalse tbTrue ) // NewMainWindow creates and returns a new MainWindow instance func NewMainWindow(application *gtk.Application) (*MainWindow, error) { // Set up the window builder, err := NewBuilder(playerGlade) if err != nil { log.Fatalf("NewBuilder() failed: %v", err) } // Instantiate a window and bind widgets w := &MainWindow{app: application} if err := builder.BindWidgets(w); err != nil { log.Fatalf("BindWidgets() failed: %v", err) } // Initialise queue filter model w.QueueTreeModelFilter.SetVisibleColumn(config.QueueColumnVisible) // Initialise player settings w.applyPlayerSettings() // Initialise widgets and actions w.initWidgets() // Map the handlers to callback functions builder.ConnectSignals(map[string]interface{}{ "on_MainWindow_delete": w.onDelete, "on_MainWindow_map": w.onMap, "on_MainWindow_styleUpdated": w.updateStyle, "on_MainStack_switched": w.focusMainList, "on_QueueListStore_rowChanged": w.onQueueReorder, "on_QueueTreeView_buttonPress": w.onQueueTreeViewButtonPress, "on_QueueTreeView_keyPress": w.onQueueTreeViewKeyPress, "on_QueueTreeSelection_changed": w.updateQueueActions, "on_QueueSearchBar_searchMode": w.onQueueSearchMode, "on_QueueSearchEntry_searchChanged": w.queueFilter, "on_LibraryListBox_buttonPress": w.onLibraryListBoxButtonPress, "on_LibraryListBox_keyPress": w.onLibraryListBoxKeyPress, "on_LibraryListBox_selectionChange": w.updateLibraryActions, "on_LibrarySearchChanged": w.updateLibrary, "on_LibrarySearchStop": w.onLibraryStopSearch, "on_StreamsListBox_buttonPress": w.onStreamListBoxButtonPress, "on_StreamsListBox_keyPress": w.onStreamListBoxKeyPress, "on_StreamsListBox_selectionChange": w.updateStreamsActions, "on_StreamPropsChanged": w.onStreamPropsChanged, "on_QueueSavePopoverMenu_validate": w.onQueueSavePopoverValidate, "on_VolumeButton_valueChanged": w.onVolumeValueChanged, "on_PlayPositionScale_buttonEvent": w.onPlayPositionButtonEvent, "on_PlayPositionScale_valueChanged": w.updatePlayerSeekBar, "on_QueueNowPlayingMenuItem_activate": w.updateQueueNowPlaying, "on_QueueShowAlbumInLibraryMenuItem_activate": w.libraryShowAlbumFromQueue, "on_QueueShowArtistInLibraryMenuItem_activate": w.libraryShowArtistFromQueue, "on_QueueShowGenreInLibraryMenuItem_activate": w.libraryShowGenreFromQueue, "on_QueueClearMenuItem_activate": w.queueClear, "on_QueueDeleteMenuItem_activate": w.queueDelete, "on_LibraryAddToPlaylistMenuItem_activate": w.libraryAddToPlaylist, "on_LibraryAppendMenuItem_activate": func() { w.applyLibrarySelection(tbFalse) }, "on_LibraryReplaceMenuItem_activate": func() { w.applyLibrarySelection(tbTrue) }, "on_LibraryRenameMenuItem_activate": w.libraryRename, "on_LibraryDeleteMenuItem_activate": w.libraryDelete, "on_LibraryUpdateSelMenuItem_activate": func() { w.libraryUpdate(false, true) }, "on_StreamsAppendMenuItem_activate": func() { w.applyStreamSelection(tbFalse) }, "on_StreamsReplaceMenuItem_activate": func() { w.applyStreamSelection(tbTrue) }, "on_StreamsEditMenuItem_activate": w.onStreamEdit, "on_StreamsDeleteMenuItem_activate": w.onStreamDelete, }) // Register the main window with the app application.AddWindow(w.AppWindow) // Restore library path cfg := config.GetConfig() errCheck(w.libPath.Unmarshal(cfg.LibraryPath), "Failed to restore library path") // Restore window dimensions dim := cfg.MainWindowDimensions if dim.Width > 0 && dim.Height > 0 { w.AppWindow.Resize(dim.Width, dim.Height) } if dim.X >= 0 && dim.Y >= 0 { w.AppWindow.Move(dim.X, dim.Y) } // Instantiate a connector w.connector = NewConnector(w.onConnectorStatusChange, w.onConnectorHeartbeat, w.onConnectorSubsystemChange) return w, nil } func (w *MainWindow) onConnectorStatusChange() { // Ignore when not mapped if w.mapped { glib.IdleAdd(w.updateAll) } } func (w *MainWindow) onConnectorHeartbeat() { // Ignore when not mapped if w.mapped { glib.IdleAdd(w.updatePlayerSeekBar) } } func (w *MainWindow) onConnectorSubsystemChange(subsystem string) { log.Debugf("onSubsystemChange(%v)", subsystem) // Ignore when not mapped if !w.mapped { return } switch subsystem { case "database", "update": glib.IdleAdd(w.updateLibrary) case "mixer": glib.IdleAdd(w.updateVolume) case "options": glib.IdleAdd(w.updateOptions) case "player": glib.IdleAdd(w.updatePlayer) case "playlist": glib.IdleAdd(func() { w.updateQueue() w.updatePlayer() }) case "stored_playlist": if _, ok := w.libPath.Last().(*PlaylistsLibElement); ok { glib.IdleAdd(w.updateLibrary) } } } func (w *MainWindow) onMap() { log.Debug("MainWindow.onMap()") // Update all lists w.updateAll() w.updateStreams() // Activate the Queue tree view w.focusMainList() // Start connecting if needed if config.GetConfig().MpdAutoConnect { w.connect() } w.mapped = true } func (w *MainWindow) onDelete() { log.Debug("MainWindow.onDelete()") w.mapped = false cfg := config.GetConfig() // Save the current library path cfg.LibraryPath = w.libPath.Marshal() // Save the current window dimensions in the config x, y := w.AppWindow.GetPosition() width, height := w.AppWindow.GetSize() cfg.MainWindowDimensions = config.Dimensions{X: x, Y: y, Width: width, Height: height} // Write out the config cfg.Save() // Disconnect from MPD w.disconnect() } func (w *MainWindow) onLibraryAddToPlaylist(playlist string) { log.Debugf("MainWindow.onLibraryAddToPlaylist(%s)", playlist) // Fetch the selected element, which must be playable element := w.getSelectedLibraryElement() if element == nil || !element.IsPlayable() { return } // If it's a URI-enabled element if uh, ok := element.(URIHolder); ok { w.libraryAppendPlaylist(playlist, uh.URI()) return } // Playlist-enabled element if ph, ok := element.(PlaylistHolder); ok { var attrs []mpd.Attrs var err error w.connector.IfConnected(func(client *mpd.Client) { attrs, err = client.PlaylistContents(ph.PlaylistName()) }) if w.errCheckDialog(err, glib.Local("Failed to add item to the playlist")) { return } // Extract the URIs and append them to the playlist w.libraryAppendPlaylist(playlist, util.MapAttrsToSlice(attrs, "file")...) return } // Attribute-enabled path: extend the current path filter with the element and query the tracks if filter := w.libPath.AsFilter(element); len(filter) > 0 { var attrs []mpd.Attrs var err error w.connector.IfConnected(func(client *mpd.Client) { attrs, err = client.Find(filter...) }) if w.errCheckDialog(err, glib.Local("Failed to add item to the playlist")) { return } // Extract the URIs and append them to the playlist w.libraryAppendPlaylist(playlist, util.MapAttrsToSlice(attrs, "file")...) return } // Oops log.Errorf("element %T cannot be added to a playlist", element) } func (w *MainWindow) onLibraryListBoxButtonPress(_ *gtk.ListBox, event *gdk.Event) { switch btn := gdk.EventButtonNewFromEvent(event); btn.Type() { // Mouse click case gdk.EVENT_BUTTON_PRESS: // Right click if btn.Button() == 3 { w.LibraryListBox.SelectRow(w.LibraryListBox.GetRowAtY(int(btn.Y()))) w.LibraryMenu.PopupAtPointer(event) } // Double click case gdk.EVENT_DOUBLE_BUTTON_PRESS: w.applyLibrarySelection(tbNone) } } func (w *MainWindow) onLibraryListBoxKeyPress(_ *gtk.ListBox, event *gdk.Event) { evt := gdk.EventKeyNewFromEvent(event) state := gdk.ModifierType(evt.State()) & gtk.AcceleratorGetDefaultModMask() switch evt.KeyVal() { // Enter: we need to go deeper case gdk.KEY_Return: switch state { // Enter: use default mode case 0: w.applyLibrarySelection(tbNone) // Ctrl+Enter: replace case gdk.CONTROL_MASK: w.applyLibrarySelection(tbTrue) // Shift+Enter: append case gdk.SHIFT_MASK: w.applyLibrarySelection(tbFalse) } // Backspace: go level up (not in search mode) case gdk.KEY_BackSpace: if state == 0 && !w.LibrarySearchToolButton.GetActive() { w.libraryLevelUp() } // Escape: deactivate search mode case gdk.KEY_Escape: if state == 0 { w.onLibraryStopSearch() } // Ctrl+F: activate search mode case gdk.KEY_f: if state == gdk.CONTROL_MASK { w.LibrarySearchToolButton.SetActive(true) } } } // onLibrarySearchToggle activates or deactivates library search mode func (w *MainWindow) onLibrarySearchToggle() { searchMode := w.LibrarySearchToolButton.GetActive() // Show the appropriate tool stack's page if searchMode { w.LibraryToolStack.SetVisibleChild(w.LibrarySearchBox) // Clear and shift focus to the search entry w.LibrarySearchEntry.SetText("") w.LibrarySearchEntry.GrabFocus() } else { w.LibraryToolStack.SetVisibleChild(w.LibraryPathBox) } // Run search or load library w.updateLibrary() // If search mode finished, move focus to the library list if !searchMode { w.focusMainList() } } // onLibraryStopSearch deactivates library search mode func (w *MainWindow) onLibraryStopSearch() { w.LibrarySearchToolButton.SetActive(false) } func (w *MainWindow) onLibraryPathChanged() { // Ignore when not mapped if w.mapped { w.updateLibraryPath() w.updateLibrary() w.focusMainList() } } func (w *MainWindow) onPlayPositionButtonEvent(_ interface{}, event *gdk.Event) { switch gdk.EventButtonNewFromEvent(event).Type() { case gdk.EVENT_BUTTON_PRESS: w.playPosUpdating = true case gdk.EVENT_BUTTON_RELEASE: w.playPosUpdating = false w.connector.IfConnected(func(client *mpd.Client) { d := time.Duration(w.PlayPositionAdjustment.GetValue()) errCheck(client.SeekCur(d*time.Second, false), "SeekCur() failed") }) } } func (w *MainWindow) onQueueReorder(self *gtk.ListStore, path *gtk.TreePath, iter *gtk.TreeIter) { // Make sure the list order is recovered should the reordering fail var err error defer func() { if errCheck(err, "Failed to reorder the queue") { w.updateQueue() } }() // Fetch the old position var oldPosVal *glib.Value var oldPosStr string var oldPos int if oldPosVal, err = self.GetValue(iter, config.MTAttrPos); err != nil { return } else if oldPosStr, err = oldPosVal.GetString(); err != nil { return } else if oldPos, err = strconv.Atoi(oldPosStr); err != nil { return } // Fetch the new position var newPos int if newIdx := path.GetIndices(); len(newIdx) == 0 { err = errors.New("invalid new position") return } else if newPos = newIdx[0]; newPos == oldPos { // No position change return } // When dragged beyond the last item, move to the end of the list if cnt := self.IterNChildren(nil); cnt > 0 && newPos >= cnt-1 { newPos = cnt - 2 // When dragging an item down, the new index must be decremented, because the item will first disappear at the // oldPos } else if newPos > oldPos { newPos-- } // Move the track to the new position w.connector.IfConnected(func(client *mpd.Client) { err = client.Move(oldPos, oldPos+1, newPos) }) } func (w *MainWindow) onQueueSavePopoverValidate() { // Only show new playlist widgets if (new playlist) is selected in the combo box selectedID := w.QueueSavePlaylistComboBox.GetActiveID() isNew := selectedID == queueSaveNewPlaylistID w.QueueSavePlaylistNameLabel.SetVisible(isNew) w.QueueSavePlaylistNameEntry.SetVisible(isNew) // Validate the actions valid := (!isNew && selectedID != "") || (isNew && util.EntryText(w.QueueSavePlaylistNameEntry, "") != "") w.aQueueSaveReplace.SetEnabled(valid && !isNew) w.aQueueSaveAppend.SetEnabled(valid) } func (w *MainWindow) onQueueSearchMode() { w.queueFilter() // Update the queue model w.updateQueueTreeViewModel() // Return focus to the queue on deactivating search if !w.QueueSearchBar.GetSearchMode() { w.focusMainList() } } func (w *MainWindow) onQueueTreeViewColClicked(col *gtk.TreeViewColumn, index int, attr *config.MpdTrackAttribute) { log.Debugf("onQueueTreeViewColClicked(col, %v, %v)", index, *attr) // Determine the sort order: on first click on a column ascending, on next descending descending := col.GetSortIndicator() && col.GetSortOrder() == gtk.SORT_ASCENDING sortType := gtk.SORT_ASCENDING if descending { sortType = gtk.SORT_DESCENDING } // Update sort indicators on all columns i := 0 for c := w.QueueTreeView.GetColumns(); c != nil; c = c.Next() { item := c.Data().(*gtk.TreeViewColumn) thisCol := i == index // Set sort direction on the clicked column if thisCol { item.SetSortOrder(sortType) } // Update every column's sort indicator item.SetSortIndicator(thisCol) i++ } // Sort the queue w.queueSort(attr, descending) } func (w *MainWindow) onQueueTreeViewButtonPress(_ *gtk.TreeView, event *gdk.Event) bool { switch btn := gdk.EventButtonNewFromEvent(event); btn.Type() { // Mouse click case gdk.EVENT_BUTTON_PRESS: // Right click if btn.Button() == 3 { w.QueueMenu.PopupAtPointer(event) // Stop event propagation return true } // Double click case gdk.EVENT_DOUBLE_BUTTON_PRESS: w.applyQueueSelection() } return false } func (w *MainWindow) onQueueTreeViewKeyPress(_ *gtk.TreeView, event *gdk.Event) { evt := gdk.EventKeyNewFromEvent(event) state := gdk.ModifierType(evt.State()) & gtk.AcceleratorGetDefaultModMask() switch evt.KeyVal() { // Enter: apply current selection case gdk.KEY_Return: if state == 0 { w.applyQueueSelection() } // Esc: exit filtering mode if it's active case gdk.KEY_Escape: if state == 0 { w.QueueSearchBar.SetSearchMode(false) } // Delete case gdk.KEY_Delete: switch state { // Delete: delete selection case 0: w.queueDelete() // Ctrl+Delete: clear queue case gdk.CONTROL_MASK: w.queueClear() } // Space case gdk.KEY_space: if state == 0 { w.playerPlayPause() } // Ctrl+F: activate search bar case gdk.KEY_f: if state == gdk.CONTROL_MASK { w.QueueSearchBar.SetSearchMode(true) } } } func (w *MainWindow) onStreamAdd() { // Reset property values w.StreamPropsNameEntry.SetText("") w.StreamPropsUriEntry.SetText("") // Disable the Apply action initially w.aStreamPropsApply.SetEnabled(false) // Show the popover w.addingStream = true w.StreamPropsPopoverMenu.SetRelativeTo(w.StreamsAddToolButton) w.StreamPropsPopoverMenu.Popup() } func (w *MainWindow) onStreamDelete() { // Fetch the selected stream idx := w.getSelectedStreamIndex() if idx < 0 { return } // Ask for a confirmation streams := &config.GetConfig().Streams if util.ConfirmDialog(w.AppWindow, glib.Local("Delete stream"), fmt.Sprintf(glib.Local("Are you sure you want to delete stream \"%s\"?"), (*streams)[idx].Name)) { // Delete the selected stream from the slice *streams = append((*streams)[:idx], (*streams)[idx+1:]...) // Update stream list w.updateStreams() w.focusMainList() } } func (w *MainWindow) onStreamEdit() { // Fetch the selected stream idx := w.getSelectedStreamIndex() if idx < 0 { return } stream := config.GetConfig().Streams[idx] // Reset property values w.StreamPropsNameEntry.SetText(stream.Name) w.StreamPropsUriEntry.SetText(stream.URI) // Disable the Apply action initially w.aStreamPropsApply.SetEnabled(false) // Show the popover w.addingStream = false w.StreamPropsPopoverMenu.SetRelativeTo(w.StreamsEditToolButton) w.StreamPropsPopoverMenu.Popup() } func (w *MainWindow) onStreamListBoxButtonPress(_ *gtk.ListBox, event *gdk.Event) { switch btn := gdk.EventButtonNewFromEvent(event); btn.Type() { // Mouse click case gdk.EVENT_BUTTON_PRESS: // Right click if btn.Button() == 3 { w.StreamsListBox.SelectRow(w.StreamsListBox.GetRowAtY(int(btn.Y()))) w.StreamsMenu.PopupAtPointer(event) } // Double click case gdk.EVENT_DOUBLE_BUTTON_PRESS: w.applyStreamSelection(tbNone) } } func (w *MainWindow) onStreamListBoxKeyPress(_ *gtk.ListBox, event *gdk.Event) { evt := gdk.EventKeyNewFromEvent(event) state := gdk.ModifierType(evt.State()) & gtk.AcceleratorGetDefaultModMask() switch evt.KeyVal() { // Enter: apply selection case gdk.KEY_Return: switch state { // Enter: use default mode case 0: w.applyStreamSelection(tbNone) // Ctrl+Enter: replace case gdk.CONTROL_MASK: w.applyStreamSelection(tbTrue) // Shift+Enter: append case gdk.SHIFT_MASK: w.applyStreamSelection(tbFalse) } } } func (w *MainWindow) onStreamPropsApply() { // Fetch entered data name, uri := util.EntryText(w.StreamPropsNameEntry, ""), util.EntryText(w.StreamPropsUriEntry, "") if name == "" || uri == "" { return } // Make a stream spec instance stream := config.StreamSpec{ Name: name, URI: uri, } // Adding a stream cfg := config.GetConfig() if w.addingStream { cfg.Streams = append(cfg.Streams, stream) } else if idx := w.getSelectedStreamIndex(); idx >= 0 { // Editing an existing stream cfg.Streams[idx] = stream } // Update stream list w.updateStreams() w.focusMainList() } func (w *MainWindow) onStreamPropsChanged() { // Validate the popover w.aStreamPropsApply.SetEnabled( util.EntryText(w.StreamPropsNameEntry, "") != "" && util.EntryText(w.StreamPropsUriEntry, "") != "") } func (w *MainWindow) onVolumeValueChanged() { if !w.volumeUpdating { vol := int(w.VolumeAdjustment.GetValue()) log.Debugf("Adjusting volume to %d", vol) w.connector.IfConnected(func(client *mpd.Client) { errCheck(client.SetVolume(vol), "SetVolume() failed") }) } } // addAction add a new application action, with an optional keyboard shortcut func (w *MainWindow) addAction(name, shortcut string, onActivate func()) *glib.SimpleAction { action := glib.SimpleActionNew(name, nil) action.Connect("activate", onActivate) w.app.AddAction(action) if shortcut != "" { w.app.SetAccelsForAction("app."+name, []string{shortcut}) } return action } // applyLibrarySelection navigates into the folder or adds or replaces the content of the queue with the currently // selected items in the library func (w *MainWindow) applyLibrarySelection(replace triBool) { // Get selected element e := w.getSelectedLibraryElement() if e == nil { return } // Level-up element if _, ok := e.(*LevelUpLibElement); ok { w.libraryLevelUp() } else if replace == tbNone && e.IsFolder() { // Default for folders is entering into w.libPath.Append(e) } else { // Queue the element up otherwise w.queueLibraryElement(replace, e) } } // applyPlayerSettings compiles the player title template and updates the player func (w *MainWindow) applyPlayerSettings() { // Apply toolbar setting cfg := config.GetConfig() w.QueueToolbar.SetVisible(cfg.QueueToolbar) // Compile and apply the track title template tmpl, err := template.New("playerTitle"). Funcs(template.FuncMap{ "default": util.Default, "dirname": path.Dir, "basename": path.Base, }). Parse(cfg.PlayerTitleTemplate) if errCheck(err, "Template parse error") { w.playerTitleTemplate = template.Must( template.New("error").Parse("[" + glib.Local("Player title template error, check log") + "]")) } else { w.playerTitleTemplate = tmpl } // Update the displayed title/artwork if the connector is initialised if w.connector != nil { w.updatePlayer() } } // applyQueueSelection starts playing from the currently selected track func (w *MainWindow) applyQueueSelection() { // Get the tree's selection var err error if indices := w.getQueueSelectedIndices(); len(indices) > 0 { // Start playback from the first selected index w.connector.IfConnected(func(client *mpd.Client) { err = client.Play(indices[0]) }) } // Check for error w.errCheckDialog(err, glib.Local("Failed to play the selected track")) } // applyStreamSelection adds or replaces the content of the queue with the currently selected stream func (w *MainWindow) applyStreamSelection(replace triBool) { if idx := w.getSelectedStreamIndex(); idx >= 0 { w.queueStream(replace, config.GetConfig().Streams[idx].URI) } } // connect starts connecting to MPD func (w *MainWindow) connect() { // First disconnect, if connected w.disconnect() // Start connecting cfg := config.GetConfig() network, addr := cfg.MpdNetworkAddress() w.connector.Start(network, addr, cfg.MpdPassword, cfg.MpdAutoReconnect) } // disconnect starts disconnecting from MPD func (w *MainWindow) disconnect() { w.connector.Stop() } // errCheckDialog checks for error, and if it isn't nil, shows an error dialog ti the given text and the error info func (w *MainWindow) errCheckDialog(err error, message string) bool { if err != nil { formatted := fmt.Sprintf("%v: %v", message, err) log.Warning(formatted) util.ErrorDialog(w.AppWindow, formatted) return true } return false } // focusMainList transfers the focus to the main list on the currently visible page func (w *MainWindow) focusMainList() { var widget *gtk.Widget switch w.MainStack.GetVisibleChildName() { case "queue": widget = &w.QueueTreeView.Widget // Library: move focus to the selected row, if any case "library": if row := w.LibraryListBox.GetSelectedRow(); row != nil { widget = &row.Widget } else { widget = &w.LibraryListBox.Widget } // Streams: move focus to the selected row, if any case "streams": if row := w.StreamsListBox.GetSelectedRow(); row != nil { widget = &row.Widget } else { widget = &w.StreamsListBox.Widget } } // Move focus if widget != nil { widget.GrabFocus() } } // getQueueHasSelection returns whether there's any selected rows in the queue func (w *MainWindow) getQueueSelectedCount() int { if sel, err := w.QueueTreeView.GetSelection(); !errCheck(err, "getQueueHasSelection(): QueueTreeView.GetSelection() failed") { return sel.CountSelectedRows() } return 0 } // getQueueSelectedIndices returns indices of the currently selected rows in the queue func (w *MainWindow) getQueueSelectedIndices() []int { // Get the tree's selection sel, err := w.QueueTreeView.GetSelection() if errCheck(err, "QueueTreeView.GetSelection() failed") { return nil } // Get selected nodes' indices var indices []int sel.SelectedForEach(func(model *gtk.TreeModel, path *gtk.TreePath, iter *gtk.TreeIter) { // Convert the provided tree (filtered) path into unfiltered one if queuePath := w.QueueTreeModelFilter.ConvertPathToChildPath(path); queuePath != nil { if ix := queuePath.GetIndices(); len(ix) > 0 { indices = append(indices, ix[0]) } } }) return indices } // getQueueSelectedTrackAttrs returns attributes of the first currently selected row in the queue func (w *MainWindow) getQueueSelectedTrackAttrs() (mpd.Attrs, error) { // Get the tree's selection if indices := w.getQueueSelectedIndices(); len(indices) > 0 { // Fetch the attrs of the first selected index var err error var attrs []mpd.Attrs w.connector.IfConnected(func(client *mpd.Client) { attrs, err = client.PlaylistInfo(indices[0], -1) }) // If there's an error if err != nil { return nil, err } // If no data returned if len(attrs) == 0 { return nil, errors.New("No data returned by MPD for the current selection") } // All OK return attrs[0], nil } return nil, errors.New("No selection in the queue") } // getSelectedLibraryElement returns the path element of the currently selected library item or nil if there's an error func (w *MainWindow) getSelectedLibraryElement() LibraryPathElement { // If there's selection row := w.LibraryListBox.GetSelectedRow() if row == nil { return nil } // Extract path, which is stored in the row's name name, err := row.GetName() if errCheck(err, "getSelectedLibraryPath(): row.GetName() failed") { return nil } // Unmarshal the element from name if element, err := UnmarshalLibPathElement(name); !errCheck(err, "Unmarshalling failed") { return element } return nil } // getSelectedStreamIndex returns the index of the currently selected stream, or -1 if there's an error func (w *MainWindow) getSelectedStreamIndex() int { // If there's selection row := w.StreamsListBox.GetSelectedRow() if row == nil { return -1 } return row.GetIndex() } // initLibraryWidgets initialises library widgets and actions func (w *MainWindow) initLibraryWidgets() { // Create actions w.aLibraryUpdate = w.addAction("library.update", "", w.LibraryUpdatePopoverMenu.Popup) w.aLibraryUpdateAll = w.addAction("library.update.all", "", func() { w.libraryUpdate(false, false) }) w.aLibraryUpdateSel = w.addAction("library.update.selected", "", func() { w.libraryUpdate(false, true) }) w.aLibraryRescanAll = w.addAction("library.rescan.all", "", func() { w.libraryUpdate(true, false) }) w.aLibraryRescanSel = w.addAction("library.rescan.selected", "", func() { w.libraryUpdate(true, true) }) w.aLibraryRename = w.addAction("library.rename", "", w.libraryRename) w.aLibraryDelete = w.addAction("library.delete", "", w.libraryDelete) w.aLibraryAddToPlaylist = w.addAction("library.add-to-playlist", "", w.libraryAddToPlaylist) w.addAction("library.search.toggle", "", w.onLibrarySearchToggle) // Create a library path instance w.libPath = NewLibraryPath(w.onLibraryPathChanged) // Populate search attribute combo box w.LibrarySearchAttrComboBox.Append(librarySearchAllAttrID, glib.Local("Everywhere")) for _, id := range config.MpdTrackAttributeIds { if config.MpdTrackAttributes[id].Searchable { w.LibrarySearchAttrComboBox.Append(strconv.Itoa(id), glib.Local(config.MpdTrackAttributes[id].LongName)) } } w.LibrarySearchAttrComboBox.SetActiveID(librarySearchAllAttrID) } // initPlayerWidgets initialises player widgets and actions func (w *MainWindow) initPlayerWidgets() { // Create actions w.aPlayerPrevious = w.addAction("player.previous", "Left", w.playerPrevious) w.aPlayerStop = w.addAction("player.stop", "S", w.playerStop) w.aPlayerPlayPause = w.addAction("player.play-pause", "P", w.playerPlayPause) w.aPlayerNext = w.addAction("player.next", "Right", w.playerNext) w.aPlayerSeekBackward = w.addAction("player.seek.backward", "Left", func() { w.playerSeekCurrent(-1) }) w.aPlayerSeekForward = w.addAction("player.seek.forward", "Right", func() { w.playerSeekCurrent(1) }) // NB convert to stateful actions once Gotk3 supporting GVariant is released w.aPlayerRandom = w.addAction("player.toggle.random", "U", w.playerToggleRandom) w.aPlayerRepeat = w.addAction("player.toggle.repeat", "R", w.playerToggleRepeat) w.aPlayerConsume = w.addAction("player.toggle.consume", "N", w.playerToggleConsume) } // initQueueWidgets initialises queue widgets and actions func (w *MainWindow) initQueueWidgets() { // Configure the search bar glib.BindProperty(w.QueueSearchBar.Object, "search-mode-enabled", w.QueueFilterToolButton.Object, "active", glib.BINDING_BIDIRECTIONAL) glib.BindProperty(w.QueueSearchBar.Object, "search-mode-enabled", w.QueueFilterLabel.Object, "visible", glib.BINDING_DEFAULT) // Forcefully disable tree search popup on Ctrl+F w.QueueTreeView.SetSearchColumn(-1) // Create actions w.aQueueNowPlaying = w.addAction("queue.now-playing", "J", w.updateQueueNowPlaying) w.aQueueClear = w.addAction("queue.clear", "", w.queueClear) w.aQueueSort = w.addAction("queue.sort", "", w.QueueSortPopoverMenu.Popup) w.aQueueSortAsc = w.addAction("queue.sort.asc", "", func() { w.queueSortApply(false) }) w.aQueueSortDesc = w.addAction("queue.sort.desc", "", func() { w.queueSortApply(true) }) w.aQueueSortShuffle = w.addAction("queue.sort.shuffle", "R", w.queueShuffle) w.aQueueDelete = w.addAction("queue.delete", "", w.queueDelete) w.aQueueSave = w.addAction("queue.save", "", w.queueSave) w.aQueueSaveReplace = w.addAction("queue.save.replace", "", func() { w.queueSaveApply(true) }) w.aQueueSaveAppend = w.addAction("queue.save.append", "", func() { w.queueSaveApply(false) }) // Populate "Queue sort by" combo box for _, id := range config.MpdTrackAttributeIds { w.QueueSortByComboBox.Append(strconv.Itoa(id), glib.Local(config.MpdTrackAttributes[id].LongName)) } w.QueueSortByComboBox.SetActiveID(strconv.Itoa(config.GetConfig().DefaultSortAttrID)) // Update Queue tree view columns w.updateQueueColumns() } // initStreamsWidgets initialises streams widgets and actions func (w *MainWindow) initStreamsWidgets() { // Create actions w.aStreamAdd = w.addAction("stream.add", "", w.onStreamAdd) w.aStreamEdit = w.addAction("stream.edit", "", w.onStreamEdit) w.aStreamDelete = w.addAction("stream.delete", "", w.onStreamDelete) w.aStreamPropsApply = w.addAction("stream.props.apply", "", w.onStreamPropsApply) } // initWidgets initialises all widgets and actions func (w *MainWindow) initWidgets() { // Determine base colours w.updateStyle() // Create global actions w.addAction("mpd.connect", "C", w.connect) w.aMPDDisconnect = w.addAction("mpd.disconnect", "D", w.disconnect) w.aMPDInfo = w.addAction("mpd.info", "I", w.showMPDInfo) w.addAction("prefs", "comma", w.showPreferences) w.aMPDOutputs = w.addAction("outputs", "O", w.showOutputs) w.addAction("about", "F1", w.showAbout) w.addAction("shortcuts", "question", w.showShortcuts) w.addAction("quit", "Q", w.AppWindow.Close) w.addAction("page.queue", "1", func() { w.MainStack.SetVisibleChild(w.QueueBox) }) w.addAction("page.library", "2", func() { w.MainStack.SetVisibleChild(w.LibraryBox) }) w.addAction("page.streams", "3", func() { w.MainStack.SetVisibleChild(w.StreamsBox) }) // Init other widgets and actions w.initQueueWidgets() w.initLibraryWidgets() w.initStreamsWidgets() w.initPlayerWidgets() } // libraryAddToPlaylist shows a popover menu that allows to add the selected library element to a playlist func (w *MainWindow) libraryAddToPlaylist() { // Clean up and repopulate the menu with playlists util.ClearChildren(w.LibraryAddToPlaylistBox.Container) for _, name := range w.connector.GetPlaylists() { name := name // Make an in-loop copy // Make a new button btn, err := gtk.ModelButtonNew() if errCheck(err, "ModelButtonNew() failed") { return } // Set the text using a generic setter (due to https://github.com/gotk3/gotk3/issues/742) errCheck(btn.Set("text", name), "Set(text) failed") // Cannot bind to "activate" here as it's not triggered for Actionable widgets btn.Connect("clicked", func() { w.onLibraryAddToPlaylist(name) }) // Add the button to the popover w.LibraryAddToPlaylistBox.PackStart(btn, false, true, 0) } // Show the popover w.LibraryAddToPlaylistBox.ShowAll() w.LibraryAddToPlaylistPopoverMenu.Popup() } // libraryAppendPlaylist appends the provided URIs to a playlist with the given name func (w *MainWindow) libraryAppendPlaylist(name string, uris ...string) { err := errors.New(glib.Local("Not connected to MPD")) w.connector.IfConnected(func(client *mpd.Client) { commands := client.BeginCommandList() for _, uri := range uris { commands.PlaylistAdd(name, uri) } err = commands.End() }) // Check for error w.errCheckDialog(err, glib.Local("Failed to add item to the playlist")) } // libraryDelete allows to delete the selected library element func (w *MainWindow) libraryDelete() { element := w.getSelectedLibraryElement() if ph, ok := element.(PlaylistHolder); ok { if util.ConfirmDialog(w.AppWindow, glib.Local("Delete playlist"), fmt.Sprintf(glib.Local("Are you sure you want to delete playlist \"%s\"?"), ph.PlaylistName())) { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.PlaylistRemove(ph.PlaylistName()) }) // Check for error (outside IfConnected() because it would keep the client locked) w.errCheckDialog(err, glib.Local("Failed to delete the playlist")) } } } // libraryLevelUp navigates to the library element at the upper level func (w *MainWindow) libraryLevelUp() { if e := w.libPath.Last(); e != nil { // Save the currently active path element for subsequent selection w.libPathElementToSelect = e.Marshal() // Move up a level w.libPath.LevelUp() } } // libraryRename allows to rename the selected library element func (w *MainWindow) libraryRename() { element := w.getSelectedLibraryElement() if ph, ok := element.(PlaylistHolder); ok { if newName, ok := util.EditDialog(w.AppWindow, glib.Local("Rename playlist"), ph.PlaylistName(), glib.Local("Rename")); ok { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.PlaylistRename(ph.PlaylistName(), newName) }) // Check for error (outside IfConnected() because it would keep the client locked) w.errCheckDialog(err, glib.Local("Failed to rename the playlist")) } } } // libraryShowAlbumFromQueue opens the currently selected queue album in the library func (w *MainWindow) libraryShowAlbumFromQueue() { if attrs, err := w.getQueueSelectedTrackAttrs(); !w.errCheckDialog(err, glib.Local("Failed to get album information")) { // Update the current library path w.libPath.SetElements([]LibraryPathElement{ NewArtistsLibElement(), NewArtistLibElementVal(attrs[config.MpdTrackAttributes[config.MTAttrArtist].AttrName]), NewAlbumLibElementVal(attrs[config.MpdTrackAttributes[config.MTAttrAlbum].AttrName]), }) // Switch to the library tab w.MainStack.SetVisibleChild(w.LibraryBox) } } // libraryShowArtistFromQueue opens the currently selected queue artist in the library func (w *MainWindow) libraryShowArtistFromQueue() { if attrs, err := w.getQueueSelectedTrackAttrs(); !w.errCheckDialog(err, glib.Local("Failed to get artist information")) { // Update the current library path w.libPath.SetElements([]LibraryPathElement{ NewArtistsLibElement(), NewArtistLibElementVal(attrs[config.MpdTrackAttributes[config.MTAttrArtist].AttrName]), }) // Switch to the library tab w.MainStack.SetVisibleChild(w.LibraryBox) } } // libraryShowGenreFromQueue opens the currently selected queue genre in the library func (w *MainWindow) libraryShowGenreFromQueue() { if attrs, err := w.getQueueSelectedTrackAttrs(); !w.errCheckDialog(err, glib.Local("Failed to get genre information")) { // Update the current library path w.libPath.SetElements([]LibraryPathElement{ NewGenresLibElement(), NewGenreLibElementVal(attrs[config.MpdTrackAttributes[config.MTAttrGenre].AttrName]), }) // Switch to the library tab w.MainStack.SetVisibleChild(w.LibraryBox) } } // libraryUpdate updates or rescans the library func (w *MainWindow) libraryUpdate(rescan, selectedOnly bool) { // Determine the update path libPath := "" if selectedOnly { // We only support updating file-based items uh, ok := w.getSelectedLibraryElement().(URIHolder) if !ok { return } libPath = uh.URI() } // Run the update var err error w.connector.IfConnected(func(client *mpd.Client) { if rescan { _, err = client.Rescan(libPath) } else { _, err = client.Update(libPath) } }) // Check for error w.errCheckDialog(err, glib.Local("Failed to update the library")) } // playerPrevious rewinds the player to the previous track func (w *MainWindow) playerPrevious() { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.Previous() }) // Check for error w.errCheckDialog(err, glib.Local("Failed to skip to previous track")) } // playerStop stops the playback func (w *MainWindow) playerStop() { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.Stop() }) // Check for error w.errCheckDialog(err, glib.Local("Failed to stop playback")) } // playerPlayPause pauses or resumes the playback func (w *MainWindow) playerPlayPause() { var err error w.connector.IfConnected(func(client *mpd.Client) { switch w.connector.Status()["state"] { case "pause": err = client.Pause(false) case "play": err = client.Pause(true) default: err = client.Play(-1) } }) // Check for error w.errCheckDialog(err, glib.Local("Failed to toggle playback")) } // playerNext advances the player to the next track func (w *MainWindow) playerNext() { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.Next() }) // Check for error w.errCheckDialog(err, glib.Local("Failed to skip to next track")) } // playerSeekCurrent rewinds (dir == -1) or fast-forwards (dir == 1) the currently played track the configured number of // seconds func (w *MainWindow) playerSeekCurrent(dir int) { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.SeekCur(time.Duration(dir*config.GetConfig().PlayerSeekDuration)*time.Second, true) }) // Check for error w.errCheckDialog(err, glib.Local("Failed to seek in the current track")) } // playerToggleConsume toggles player's consume mode func (w *MainWindow) playerToggleConsume() { // Ignore if the state of the button is being updated programmatically if w.optionsUpdating { return } var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.Consume(w.connector.Status()["consume"] == "0") }) // Check for error w.errCheckDialog(err, glib.Local("Failed to toggle consume mode")) } // playerToggleRandom toggles player's random mode func (w *MainWindow) playerToggleRandom() { // Ignore if the state of the button is being updated programmatically if w.optionsUpdating { return } var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.Random(w.connector.Status()["random"] == "0") }) // Check for error w.errCheckDialog(err, glib.Local("Failed to toggle random mode")) } // playerToggleRepeat toggles player's repeat/single modes func (w *MainWindow) playerToggleRepeat() { // Ignore if the state of the button is being updated programmatically if w.optionsUpdating { return } // Toggle Repeat and Single in the following pattern: No repeat → Repeat all → Repeat single var err error w.connector.IfConnected(func(client *mpd.Client) { status := w.connector.Status() repeat := status["repeat"] != "0" single := status["single"] != "0" if err = client.Repeat(!repeat || !single); err == nil { err = client.Single(repeat && !single) } }) // Check for error w.errCheckDialog(err, glib.Local("Failed to toggle repeat/single mode")) } // queueClear empties MPD's play queue func (w *MainWindow) queueClear() { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.Clear() }) // Check for error w.errCheckDialog(err, glib.Local("Failed to clear the queue")) } // queueDelete deletes the selected tracks from MPD's play queue func (w *MainWindow) queueDelete() { // Get selected nodes' indices indices := w.getQueueSelectedIndices() if len(indices) == 0 { return } // Sort indices in descending order sort.Slice(indices, func(i, j int) bool { return indices[j] < indices[i] }) // Remove the tracks from the queue (also in descending order) var err error w.connector.IfConnected(func(client *mpd.Client) { commands := client.BeginCommandList() for _, idx := range indices { errCheck(commands.Delete(idx, idx+1), "commands.Delete() failed") } err = commands.End() }) // Check for error w.errCheckDialog(err, glib.Local("Failed to delete tracks from the queue")) } // queueFilter applies the currently entered filter substring to the queue func (w *MainWindow) queueFilter() { substr := "" // Only use filter pattern if the search bar is visible if w.QueueSearchBar.GetSearchMode() { substr = util.EntryText(&w.QueueSearchEntry.Entry, "") } // Iterate all rows in the list store count := 0 w.QueueListStore.ForEach(func(model *gtk.TreeModel, path *gtk.TreePath, iter *gtk.TreeIter) bool { // Show all rows if no search pattern given visible := substr == "" if !visible { // We're going to compare case-insensitively substr := strings.ToLower(substr) // Scan all known columns in the row for _, id := range config.MpdTrackAttributeIds { // Get column's value v, err := model.GetValue(iter, id) if errCheck(err, "queueFilter(): QueueListStore.GetValue() failed") { continue } // Convert the value into a string (ignore any error caused by a missing value as we don't store them) s, _ := v.GetString() // Check for a match and stop checking if match has already been found visible = s != "" && strings.Contains(strings.ToLower(s), substr) if visible { break } } } // Modify the row's visibility if err := w.QueueListStore.SetValue(iter, config.QueueColumnVisible, visible); errCheck(err, "queueFilter(): QueueListStore.SetValue() failed") { return true } if visible { count++ } // Proceed to the next row return false }) w.QueueFilterLabel.SetText(fmt.Sprintf(glib.Local("%d track(s) displayed"), count)) } // queueLibraryElement adds or replaces the content of the queue with the specified library path element func (w *MainWindow) queueLibraryElement(replace triBool, element LibraryPathElement) { // Element must be playable if !element.IsPlayable() { return } // If it's a URI-enabled element if uh, ok := element.(URIHolder); ok { w.queueURIs(replace, uh.URI()) return } // Playlist-enabled element if ph, ok := element.(PlaylistHolder); ok { w.queuePlaylist(replace, ph.PlaylistName()) return } // Attribute-enabled path: extend the current path filter with the element if filter := w.libPath.AsFilter(element); len(filter) > 0 { var attrs []mpd.Attrs var err error w.connector.IfConnected(func(client *mpd.Client) { // For the lack of FindAdd() command in gompd, we need to query tracks first attrs, err = client.Find(filter...) }) // Check for error if w.errCheckDialog(err, glib.Local("Failed to add item to the queue")) { return } // Convert attrs to list of URIs and queue them w.queueURIs(replace, util.MapAttrsToSlice(attrs, "file")...) return } // Oops log.Errorf("Element %T cannot be queued", element) } // queuePlaylist adds or replaces the content of the queue with the specified playlist func (w *MainWindow) queuePlaylist(replace triBool, uri string) { log.Debugf("queuePlaylist(%v, %v)", replace, uri) var err error replaced := replace == tbTrue || replace == tbNone && config.GetConfig().PlaylistDefaultReplace w.connector.IfConnected(func(client *mpd.Client) { commands := client.BeginCommandList() // Clear the queue, if needed if replaced { commands.Clear() } // Add the content of the playlist // NB: extract only playlist name from the URI for now commands.PlaylistLoad(strings.TrimSuffix(path.Base(uri), ".m3u"), -1, -1) // Run the commands err = commands.End() }) // Check for error if w.errCheckDialog(err, glib.Local("Failed to add playlist to the queue")) { return } // Initiate post-replace actions, if necessary if replaced { w.queueReplaced() } } // queueReplaced runs necessary post-queue-replace actions func (w *MainWindow) queueReplaced() { // Switch to the queue tab if config.GetConfig().SwitchToOnQueueReplace { w.MainStack.SetVisibleChild(w.QueueBox) } // Initiate playback if config.GetConfig().PlayOnQueueReplace { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.Play(0) }) // Check for error if w.errCheckDialog(err, glib.Local("Failed to start playback")) { return } } } // queueSave shows a dialog for saving the play queue into a playlist and performs the operation if confirmed func (w *MainWindow) queueSave() { // Tweak widgets selection := w.getQueueSelectedCount() > 0 w.QueueSaveSelectedOnlyCheckButton.SetVisible(selection) w.QueueSaveSelectedOnlyCheckButton.SetActive(selection) w.QueueSavePlaylistNameEntry.SetText("") // Populate the playlists combo box w.QueueSavePlaylistComboBox.RemoveAll() w.QueueSavePlaylistComboBox.Append(queueSaveNewPlaylistID, glib.Local("(new playlist)")) for _, name := range w.connector.GetPlaylists() { w.QueueSavePlaylistComboBox.Append(name, name) } w.QueueSavePlaylistComboBox.SetActiveID(queueSaveNewPlaylistID) // Show the popover w.QueueSavePopoverMenu.Popup() } // queueSaveApply performs queue saving into a playlist func (w *MainWindow) queueSaveApply(replace bool) { // Collect current values from the UI selIndices := w.getQueueSelectedIndices() selOnly := len(selIndices) > 0 && w.QueueSaveSelectedOnlyCheckButton.GetActive() name := w.QueueSavePlaylistComboBox.GetActiveID() isNew := name == queueSaveNewPlaylistID if isNew { name = util.EntryText(w.QueueSavePlaylistNameEntry, glib.Local("Unnamed")) } err := errors.New(glib.Local("Not connected to MPD")) w.connector.IfConnected(func(client *mpd.Client) { // Fetch the queue var attrs []mpd.Attrs attrs, err = client.PlaylistInfo(-1, -1) if err != nil { return } // Begin a command list commands := client.BeginCommandList() // If replacing an existing playlist, remove it first if !isNew && replace { commands.PlaylistRemove(name) } // If adding selection only if selOnly { for _, idx := range selIndices { commands.PlaylistAdd(name, attrs[idx]["file"]) } } else if replace { // Save the entire queue commands.PlaylistSave(name) } else { // Append the entire queue for _, a := range attrs { commands.PlaylistAdd(name, a["file"]) } } // Execute the command list err = commands.End() }) // Check for error w.errCheckDialog(err, glib.Local("Failed to create a playlist")) } // queueShuffle randomises MPD's play queue func (w *MainWindow) queueShuffle() { var err error w.connector.IfConnected(func(client *mpd.Client) { err = client.Shuffle(-1, -1) }) // Check for error w.errCheckDialog(err, glib.Local("Failed to shuffle the queue")) } // queueSort orders MPD's play queue on the provided attribute func (w *MainWindow) queueSort(attr *config.MpdTrackAttribute, descending bool) { var err error w.connector.IfConnected(func(client *mpd.Client) { // Fetch the current playlist var attrs []mpd.Attrs if attrs, err = client.PlaylistInfo(-1, -1); err != nil { return } // Sort the list sort.SliceStable(attrs, func(i, j int) bool { a, b := attrs[i][attr.AttrName], attrs[j][attr.AttrName] if attr.Numeric { an, bn := util.ParseFloatDef(a, 0), util.ParseFloatDef(b, 0) if descending { return bn < an } return an < bn } if descending { return b < a } return a < b }) // Post the changes back to MPD commands := client.BeginCommandList() for index, a := range attrs { var id int if id, err = strconv.Atoi(a["Id"]); err != nil { return } commands.MoveID(id, index) } err = commands.End() }) // Check for error w.errCheckDialog(err, glib.Local("Failed to sort the queue")) } // queueSortApply performs MPD's play queue ordering based on the currently selected in popover mode func (w *MainWindow) queueSortApply(descending bool) { // Fetch the ID of the currently selected item in the Sort by combo box, and the corresponding attribute if attr, ok := config.MpdTrackAttributes[util.AtoiDef(w.QueueSortByComboBox.GetActiveID(), -1)]; ok { w.queueSort(&attr, descending) } } // queueStream adds or replaces the content of the queue with the specified stream func (w *MainWindow) queueStream(replace triBool, uri string) { log.Debugf("queueStream(%v, %v)", replace, uri) var err error replaced := replace == tbTrue || replace == tbNone && config.GetConfig().StreamDefaultReplace w.connector.IfConnected(func(client *mpd.Client) { commands := client.BeginCommandList() // Clear the queue, if needed if replaced { commands.Clear() } // Add the URI of the stream commands.Add(uri) // Run the commands err = commands.End() }) // Check for error if w.errCheckDialog(err, glib.Local("Failed to add stream to the queue")) { return } // Initiate post-replace actions, if necessary if replaced { w.queueReplaced() } } // queueURIs adds or replaces the content of the queue with the specified URIs func (w *MainWindow) queueURIs(replace triBool, uris ...string) { var err error replaced := replace == tbTrue || replace == tbNone && config.GetConfig().TrackDefaultReplace w.connector.IfConnected(func(client *mpd.Client) { commands := client.BeginCommandList() // Clear the queue, if needed if replaced { commands.Clear() } // Add the URIs for _, uri := range uris { commands.Add(uri) } // Run the commands err = commands.End() }) // Check for error if w.errCheckDialog(err, glib.Local("Failed to add track(s) to the queue")) { return } // Initiate post-replace actions, if necessary if replaced { w.queueReplaced() } } // Show displays the window and all its child widgets func (w *MainWindow) Show() { w.AppWindow.Show() } // showAbout shows the application's about dialog func (w *MainWindow) showAbout() { dlg, err := gtk.AboutDialogNew() if errCheck(err, "AboutDialogNew() failed") { return } dlg.SetLogoIconName(config.AppMetadata.Icon) dlg.SetProgramName(config.AppMetadata.Name) dlg.SetComments(fmt.Sprintf(glib.Local("Release date: %s"), config.AppMetadata.BuildDate)) dlg.SetCopyright(glib.Local(config.AppMetadata.Copyright)) dlg.SetLicense(config.AppMetadata.License) dlg.SetVersion(config.AppMetadata.Version) dlg.SetWebsite(config.AppMetadata.URL) dlg.SetWebsiteLabel(config.AppMetadata.URLLabel) dlg.SetTransientFor(w.AppWindow) dlg.Connect("response", dlg.Destroy) dlg.Run() } // showMPDInfo displays a dialog with MPD information func (w *MainWindow) showMPDInfo() { // Fetch information var version string var stats mpd.Attrs var decoders []mpd.Attrs var err error w.connector.IfConnected(func(client *mpd.Client) { // Fetch client version version = client.Version() // Fetch stats stats, err = client.Stats() if errCheck(err, "Stats() failed") { return } // Fetch decoder configuration decoders, err = client.Command("decoders").AttrsList("plugin") if errCheck(err, "Command(decoders) failed") { return } }) if w.errCheckDialog(err, glib.Local("Failed to retrieve information from MPD")) || stats == nil { return } // Parse DB update time updateTime := glib.Local("(unknown)") if i, err := strconv.ParseInt(stats["db_update"], 10, 64); err == nil { updateTime = time.Unix(i, 0).Format("2006-01-02 15:04:05") } // Load widgets from Glade file var dlg struct { MPDInfoDialog *gtk.MessageDialog PropertyGrid *gtk.Grid DaemonVersionLabel *gtk.Label NumberOfArtistsLabel *gtk.Label NumberOfAlbumsLabel *gtk.Label NumberOfTracksLabel *gtk.Label TotalPlayingTimeLabel *gtk.Label LastDatabaseUpdateLabel *gtk.Label DaemonUptimeLabel *gtk.Label ListeningTimeLabel *gtk.Label DecoderPluginsExpander *gtk.Expander DecoderPluginsGrid *gtk.Grid } builder, err := NewBuilder(mpdInfoGlade) if err == nil { err = builder.BindWidgets(&dlg) } if w.errCheckDialog(err, glib.Local("Failed to load UI widgets")) { return } defer dlg.MPDInfoDialog.Destroy() // Set info properties dlg.DaemonVersionLabel.SetLabel(version) dlg.NumberOfArtistsLabel.SetLabel(stats["artists"]) dlg.NumberOfAlbumsLabel.SetLabel(stats["albums"]) dlg.NumberOfTracksLabel.SetLabel(stats["songs"]) dlg.TotalPlayingTimeLabel.SetLabel(util.FormatSecondsStr(stats["db_playtime"])) dlg.LastDatabaseUpdateLabel.SetLabel(updateTime) dlg.DaemonUptimeLabel.SetLabel(util.FormatSecondsStr(stats["uptime"])) dlg.ListeningTimeLabel.SetLabel(util.FormatSecondsStr(stats["playtime"])) // Add decoder plugins for i, decoder := range decoders { dlg.DecoderPluginsGrid.Attach(util.NewLabel(decoder["plugin"]), 0, i, 1, 1) if s, ok := decoder["suffix"]; ok { dlg.DecoderPluginsGrid.Attach(util.NewLabel("."+s), 1, i, 1, 1) } if s, ok := decoder["mime_type"]; ok { dlg.DecoderPluginsGrid.Attach(util.NewLabel(s), 2, i, 1, 1) } } // Set up and show the dialog dlg.MPDInfoDialog.SetTransientFor(w.AppWindow) dlg.MPDInfoDialog.ShowAll() dlg.MPDInfoDialog.Run() } // showOutputs shows the Outputs dialog func (w *MainWindow) showOutputs() { ShowOutputsDialog(w.AppWindow, w.connector) } // showPreferences shows the Preferences dialog func (w *MainWindow) showPreferences() { ShowPreferencesDialog(w.AppWindow, w.connect, w.updateQueueColumns, w.applyPlayerSettings) } // showShortcuts displays a shortcut info window func (w *MainWindow) showShortcuts() { // Construct a window from the Glade resource builder, err := NewBuilder(shortcutsGlade) // Map the window's widgets win := struct { ShortcutsWindow *gtk.ShortcutsWindow }{} if err == nil { err = builder.BindWidgets(&win) } // Check for errors if w.errCheckDialog(err, "Failed to open the Shortcuts Window") { return } // Set up the window sw := win.ShortcutsWindow sw.SetTransientFor(w.AppWindow) sw.Connect("unmap", sw.Destroy) // Show the window sw.ShowAll() // For some reason, setting the active section name only works if the window is shown errCheck(sw.SetProperty("section-name", "shortcuts"), "Failed to set shortcut window's section name") } // setQueueHighlight selects or deselects an item in the Queue tree view at the given index func (w *MainWindow) setQueueHighlight(index int, selected bool) { if index >= 0 { if iter, err := w.QueueListStore.GetIterFromString(strconv.Itoa(index)); err == nil { weight := fontWeightNormal bgColor := w.colourBgNormal if selected { weight = fontWeightBold bgColor = w.colourBgActive } errCheck( w.QueueListStore.SetCols(iter, map[int]interface{}{ config.QueueColumnFontWeight: weight, config.QueueColumnBgColor: bgColor, }), "setQueueHighlight(): SetCols() failed") } } } // updateAll updates all window's widgets and lists func (w *MainWindow) updateAll() { // Update global actions connected, connecting := w.connector.ConnectStatus() w.aMPDDisconnect.SetEnabled(connected || connecting) w.aMPDInfo.SetEnabled(connected) w.aMPDOutputs.SetEnabled(connected) // Update the queue model w.updateQueueTreeViewModel() // Update other widgets w.updateQueue() w.updateLibraryPath() w.updateLibrary() w.updateLibraryActions() w.updateOptions() w.updatePlayer() w.updateVolume() } // updateLibrary updates the current library list contents func (w *MainWindow) updateLibrary() { // Clear the library list util.ClearChildren(w.LibraryListBox.Container) var ( elements []LibraryPathElement err error pattern string ) maxResultRows := -1 lastElement := w.libPath.Last() // If search mode activated if w.LibrarySearchToolButton.GetActive() { pattern = util.EntryText(&w.LibrarySearchEntry.Entry, "") } // Search mode: fetch selected attribute if pattern != "" { attrName := "any" if attr, ok := config.MpdTrackAttributes[util.AtoiDef(w.LibrarySearchAttrComboBox.GetActiveID(), -1)]; ok { attrName = attr.AttrName } // Run search var attrs []mpd.Attrs w.connector.IfConnected(func(client *mpd.Client) { attrs, err = client.Search(fmt.Sprintf("(%s contains \"%s\")", attrName, pattern)) }) if errCheck(err, "updateLibrary(): Search() failed") { return } maxResultRows = config.GetConfig().MaxSearchResults // Convert the list into elements elements = AttrsToElements(attrs, "") } else if lastElement == nil { // Root elements = []LibraryPathElement{ NewFilesystemLibElement(), NewGenresLibElement(), NewArtistsLibElement(), NewAlbumsLibElement(), NewPlaylistsLibElement(), } } else if uh, ok := lastElement.(URIHolder); ok { // URI-enabled element: load list of directories/files at the current path var attrs []mpd.Attrs w.connector.IfConnected(func(client *mpd.Client) { attrs, err = client.ListInfo(uh.URI()) }) if errCheck(err, "updateLibrary(): ListInfo() failed") { return } // Convert the list into elements elements = AttrsToElements(attrs, uh.URI()+"/") } else if browseBy, ok := lastElement.(AttributeHolderParent); ok { // Attribute-enabled path: determine the attribute we're browsing by args := append( // First element is the attribute we're browsing by []string{config.MpdTrackAttributes[browseBy.ChildAttributeID()].AttrName}, // Then the filter arguments follow w.libPath.AsFilter()...) // Load the list of tags var list []string w.connector.IfConnected(func(client *mpd.Client) { list, err = client.List(args...) }) if errCheck(err, "updateLibrary(): List() failed") { return } // Convert the string list into a list of elements elements = make([]LibraryPathElement, 0, len(list)) for _, s := range list { if c := browseBy.NewChild(s); c != nil { elements = append(elements, c) } } } else if pl, ok := lastElement.(*PlaylistsLibElement); ok { // Playlists list element: load list of playlists for _, name := range w.connector.GetPlaylists() { elements = append(elements, pl.NewChild(name)) } } else { log.Errorf("Unknown library path kind (last element is %T)", lastElement) return } // If no search mode and not root, insert a "level up" element if pattern == "" && lastElement != nil { elements = append([]LibraryPathElement{NewLevelUpLibElement()}, elements...) } // Repopulate the library list var rowToSelect *gtk.ListBoxRow countItems, limited := 0, false for _, element := range elements { element := element // Make an in-loop copy for closures label := element.Label() markup := false var buttons []gtk.IWidget // Make root elements bold if lastElement == nil { label = "" + label + "" markup = true // For non-root elements, add replace/append buttons if needed } else if element.IsPlayable() { buttons = []gtk.IWidget{ util.NewButton("", glib.Local("Append to the queue"), "", "ymuse-add-symbolic", func() { w.queueLibraryElement(tbFalse, element) }), util.NewButton("", glib.Local("Replace the queue"), "", "ymuse-replace-queue-symbolic", func() { w.queueLibraryElement(tbTrue, element) }), } } // Add a new list box row row, hbx, err := util.NewListBoxRow(w.LibraryListBox, markup, label, MarshalLibPathElement(element), element.Icon(), buttons...) if errCheck(err, "NewListBoxRow() failed") { return } // If no specific row to select, pick the first one. Otherwise, check for a matching marshalled form if rowToSelect == nil && (w.libPathElementToSelect == "" || w.libPathElementToSelect == element.Marshal()) { rowToSelect = row } // Add a label with details [track length], if any if dh, ok := element.(DetailsHolder); ok { if details := dh.Details(); details != "" { lbl, err := gtk.LabelNew(details) // Just ignore the error and proceed if !errCheck(err, "LabelNew() failed") { hbx.PackEnd(lbl, false, false, 0) } } } countItems++ if maxResultRows >= 0 && countItems >= maxResultRows { limited = true break } } // Show all rows w.LibraryListBox.ShowAll() // Select the required row and scroll to it (later) w.LibraryListBox.SelectRow(rowToSelect) glib.IdleAdd(func() { util.ListBoxScrollToSelected(w.LibraryListBox) }) w.libPathElementToSelect = "" // Compose info info := "" if countItems == 0 { info = glib.Local("No items") } else { // Compose info info += fmt.Sprintf(glib.Local("%d items"), countItems) // Add note about limited set, if applicable if limited { info += " " + fmt.Sprintf(glib.Local("(limited selection of %d items)"), len(elements)) } } if _, ok := w.connector.Status()["updating_db"]; ok { info += " — " + glib.Local("updating database…") } // Update info w.LibraryInfoLabel.SetText(info) } // updateLibraryActions updates the widgets for library list func (w *MainWindow) updateLibraryActions() { element := w.getSelectedLibraryElement() connected, _ := w.connector.ConnectStatus() selected := element != nil _, playlist := element.(PlaylistHolder) _, filesystem := element.(URIHolder) editable := playlist && connected && selected updatable := connected && selected && filesystem playable := connected && selected && element.IsPlayable() // Actions w.aLibraryUpdate.SetEnabled(connected) w.aLibraryUpdateAll.SetEnabled(connected) w.aLibraryUpdateSel.SetEnabled(updatable) w.aLibraryRescanAll.SetEnabled(connected) w.aLibraryRescanSel.SetEnabled(updatable) w.aLibraryRename.SetEnabled(editable) w.aLibraryDelete.SetEnabled(editable) w.aLibraryAddToPlaylist.SetEnabled(playable) // Menu items w.LibraryAppendMenuItem.SetSensitive(playable) w.LibraryReplaceMenuItem.SetSensitive(playable) w.LibraryRenameMenuItem.SetSensitive(editable) w.LibraryDeleteMenuItem.SetSensitive(editable) w.LibraryUpdateSelMenuItem.SetSensitive(updatable) w.LibraryAddToPlaylistMenuItem.SetSensitive(playable) } // updateLibraryPath updates the current library path selector func (w *MainWindow) updateLibraryPath() { // Remove all buttons from the box util.ClearChildren(w.LibraryPathBox.Container) // Create a button for "root" util.NewBoxToggleButton( w.LibraryPathBox, "", "", "ymuse-home-symbolic", w.libPath.IsRoot(), func() { w.libPath.SetLength(0) }) // Create buttons for path elements for i, element := range w.libPath.Elements() { // Create a button. The last button must be depressed i := i // Make an in-loop copy of i util.NewBoxToggleButton( w.LibraryPathBox, element.Label(), "", element.Icon(), element == w.libPath.Last(), func() { // Save the first path element from the chopped-off tail for subsequent selection if e := w.libPath.ElementAt(i + 1); e != nil { w.libPathElementToSelect = e.Marshal() } // Move to the selected level w.libPath.SetLength(i + 1) }) } // Show all buttons w.LibraryPathBox.ShowAll() } // updateOptions updates player options widgets func (w *MainWindow) updateOptions() { w.optionsUpdating = true status := w.connector.Status() repeat, single := status["repeat"] == "1", status["single"] == "1" w.RandomButton.SetActive(status["random"] == "1") w.RepeatButton.SetActive(repeat) if repeat && single { w.RepeatButton.SetIconName("ymuse-repeat-1-symbolic") } else { w.RepeatButton.SetIconName("ymuse-repeat-symbolic") } w.ConsumeButton.SetActive(status["consume"] == "1") w.optionsUpdating = false } // updatePlayer updates player control widgets func (w *MainWindow) updatePlayer() { connected, connecting := w.connector.ConnectStatus() status := w.connector.Status() playing := false stopped := false var statusHTML string var err error curURI := "" switch { // Still connecting case connecting: statusHTML = fmt.Sprintf("%s", html.EscapeString(glib.Local("Connecting to MPD…"))) // Already connected case connected: // Fetch the current track var curSong mpd.Attrs w.connector.IfConnected(func(client *mpd.Client) { curSong, err = client.CurrentSong() errCheck(err, "CurrentSong() failed") }) if err == nil { // Enrich the current track with the status info curSong["Bitrate"] = status["bitrate"] curSong["Format"] = status["audio"] // Dump the current track for debug purposes log.Debugf("Current track: %#v", curSong) // Apply track title template var buffer bytes.Buffer if err := w.playerTitleTemplate.Execute(&buffer, curSong); err != nil { statusHTML = html.EscapeString(fmt.Sprintf("%s: %v", glib.Local("Template error"), err)) } else { statusHTML = buffer.String() } // Get the current URI curURI = curSong["file"] } // Update play/pause button's appearance switch status["state"] { case "play": w.PlayPauseButton.SetIconName("ymuse-pause-symbolic") playing = true case "stop": stopped = true fallthrough default: w.PlayPauseButton.SetIconName("ymuse-play-symbolic") } // Not connected default: statusHTML = fmt.Sprintf("%s", html.EscapeString(glib.Local("Not connected to MPD"))) } // If there's an error if errMsg, ok := status["error"]; ok { statusHTML += fmt.Sprintf(" — %s", html.EscapeString(errMsg)) } // Update the album art w.updatePlayerAlbumArt(curURI) // Update status text w.StatusLabel.SetMarkup(statusHTML) // Highlight and scroll the tree to the currently played item w.updateQueueNowPlaying() // Enable or disable player actions based on the connection status w.aPlayerPrevious.SetEnabled(connected && !stopped) w.aPlayerStop.SetEnabled(connected) w.aPlayerPlayPause.SetEnabled(connected) w.aPlayerNext.SetEnabled(connected && !stopped) w.aPlayerSeekBackward.SetEnabled(playing) w.aPlayerSeekForward.SetEnabled(playing) w.aPlayerRandom.SetEnabled(connected) w.aPlayerRepeat.SetEnabled(connected) w.aPlayerConsume.SetEnabled(connected) // Update the seek bar w.updatePlayerSeekBar() } // updatePlayerAlbumArt updates player's album art image appearance and visibility func (w *MainWindow) updatePlayerAlbumArt(uri string) { // Check if the album art is to be shown show := false if uri != "" { isStream := util.IsStreamURI(uri) cfg := config.GetConfig() size := cfg.PlayerAlbumArtSize if (isStream && cfg.PlayerAlbumArtStreams || !isStream && cfg.PlayerAlbumArtTracks) && size > 0 { // Avoid updating album art if there's no change in the URI or size if curPx := w.AlbumArtworkImage.GetPixbuf(); curPx != nil && util.MaxInt(curPx.GetWidth(), curPx.GetHeight()) == size && w.playerCurrentAlbumArtUri == uri { show = true } else { // Try to fetch the album art var albumArt []byte log.Debugf("Fetching album art for %s", uri) w.connector.IfConnected(func(client *mpd.Client) { var err error // Try the embedded image first if albumArt, err = client.ReadPicture(uri); err == nil && len(albumArt) > 0 { log.Debugf("Fetched embedded album art: %d bytes", len(albumArt)) return } log.Debugf("Failed to obtain embedded album art: %v", err) // Then image from a cover file if albumArt, err = client.AlbumArt(uri); err == nil && len(albumArt) > 0 { log.Debugf("Fetched album art from cover file: %d bytes", len(albumArt)) return } log.Debugf("Failed to obtain album art from cover.* file: %v", err) albumArt = nil }) // If succeeded if len(albumArt) > 0 { // Make a pixbuf from the data bytes if px, err := gdk.PixbufNewFromBytesOnly(albumArt); !errCheck(err, "PixbufNewFromBytesOnly() failed") { // Determine the required dimensions, keeping the aspect ratio aspect, iw, ih := float64(px.GetWidth())/float64(px.GetHeight()), float64(size), float64(size) if aspect > 1 { ih /= aspect } else { iw *= aspect } // Rescale the image if px, err = px.ScaleSimple(int(iw), int(ih), gdk.INTERP_BILINEAR); !errCheck(err, "ScaleSimple() failed") { w.AlbumArtworkImage.SetFromPixbuf(px) show = true // Save the last used URI w.playerCurrentAlbumArtUri = uri } } } } } } // Show or hide the album art if !show { w.AlbumArtworkImage.Clear() w.playerCurrentAlbumArtUri = "" } w.AlbumArtworkImage.SetVisible(show) // If the image isn't visible, center-justify the title. Otherwise, use left justification justification := gtk.JUSTIFY_CENTER if show { justification = gtk.JUSTIFY_LEFT } w.StatusLabel.SetJustify(justification) } // updatePlayerSeekBar updates the seek bar position and status func (w *MainWindow) updatePlayerSeekBar() { seekPos := "" var trackLen, trackPos float64 // If the user is dragging the slider manually if w.playPosUpdating { trackLen, trackPos = w.PlayPositionAdjustment.GetUpper(), w.PlayPositionAdjustment.GetValue() } else { // The update comes from MPD: adjust the seek bar position if there's a connection trackStart := -1.0 trackLen, trackPos = -1.0, -1.0 if connected, _ := w.connector.ConnectStatus(); connected { // Fetch current player position and track length status := w.connector.Status() trackLen = util.ParseFloatDef(status["duration"], -1) trackPos = util.ParseFloatDef(status["elapsed"], -1) } // If not seekable, remove the slider if trackPos >= 0 && trackLen >= trackPos { trackStart = 0 } w.PlayPositionScale.SetSensitive(trackStart == 0) // Enable the seek bar based on status and position it w.PlayPositionAdjustment.SetLower(trackStart) w.PlayPositionAdjustment.SetUpper(trackLen) w.PlayPositionAdjustment.SetValue(trackPos) } // Update position text if trackPos >= 0 { seekPos = fmt.Sprintf("%s", util.FormatSeconds(trackPos)) if trackLen >= trackPos { seekPos += fmt.Sprintf(" / " + util.FormatSeconds(trackLen)) } } w.PositionLabel.SetMarkup(seekPos) } // updateQueue updates the current play queue contents func (w *MainWindow) updateQueue() { // Lock tree updates w.QueueTreeView.FreezeChildNotify() defer w.QueueTreeView.ThawChildNotify() // Detach the tree view from the list model to speed up processing w.QueueTreeView.SetModel(nil) // Clear the queue list store w.QueueListStore.Clear() w.currentQueueIndex = -1 w.currentQueueSize = 0 // Update the queue if there's a connection var attrs []mpd.Attrs var err error w.connector.IfConnected(func(client *mpd.Client) { attrs, err = client.PlaylistInfo(-1, -1) }) if errCheck(err, "PlaylistInfo() failed") { return } // Repopulate the queue list store totalSecs := 0.0 for _, a := range attrs { rowData := make(map[int]interface{}) // Iterate attributes for id, mpdAttr := range config.MpdTrackAttributes { // Fetch the raw attribute value, if any value, ok := a[mpdAttr.AttrName] if !ok { continue } // Format the value if needed if mpdAttr.Formatter != nil { value = mpdAttr.Formatter(value) } // Only store non-empty values if value != "" { rowData[id] = value } } // Check for possible fallbacks once all values are known for id, mpdAttr := range config.MpdTrackAttributes { // If no value for attribute and there are fallback attributes if _, ok := rowData[id]; !ok && mpdAttr.FallbackAttrIDs != nil { // Pick the first available value from fallback list for _, fbId := range mpdAttr.FallbackAttrIDs { if value, ok := rowData[fbId]; ok { rowData[id] = value break } } } } // Add the "artificial" column values iconName := "ymuse-audio-file" if uri, ok := a["file"]; ok && util.IsStreamURI(uri) { iconName = "ymuse-stream" } rowData[config.QueueColumnIcon] = iconName rowData[config.QueueColumnFontWeight] = fontWeightNormal rowData[config.QueueColumnBgColor] = w.colourBgNormal rowData[config.QueueColumnVisible] = true // Create arrays (indices and values) rowIndices, rowValues := make([]int, len(rowData)), make([]interface{}, len(rowData)) colIdx := 0 for key, value := range rowData { rowIndices[colIdx] = key rowValues[colIdx] = value colIdx++ } // Add a row to the list store errCheck( w.QueueListStore.InsertWithValues(nil, -1, rowIndices, rowValues), "QueueListStore.SetCols() failed") // Accumulate counters totalSecs += util.ParseFloatDef(a["duration"], 0) w.currentQueueSize++ } // Add number of tracks var status string switch w.currentQueueSize { case 0: status = glib.Local("Queue is empty") case 1: status = glib.Local("One track") default: status = fmt.Sprintf(glib.Local("%d tracks"), len(attrs)) } // Add playing time, if any if totalSecs > 0 { status += ", " + fmt.Sprintf(glib.Local("playing time %s"), util.FormatSeconds(totalSecs)) } // Update the queue info w.QueueInfoLabel.SetText(status) // Update queue actions w.updateQueueActions() // Restore the tree view model w.updateQueueTreeViewModel() // Highlight and scroll the tree to the currently played item w.updateQueueNowPlaying() } // updateQueueColumns updates the columns in the play queue tree view func (w *MainWindow) updateQueueColumns() { // Remove all columns w.QueueTreeView.GetColumns().Foreach(func(item interface{}) { w.QueueTreeView.RemoveColumn(item.(*gtk.TreeViewColumn)) }) // Add an icon renderer if renderer, err := gtk.CellRendererPixbufNew(); !errCheck(err, "CellRendererPixbufNew() failed") { // Add an icon column if col, err := gtk.TreeViewColumnNewWithAttribute("", renderer, "icon-name", config.QueueColumnIcon); !errCheck(err, "TreeViewColumnNewWithAttribute() failed") { col.SetSizing(gtk.TREE_VIEW_COLUMN_FIXED) col.SetFixedWidth(-1) col.AddAttribute(renderer, "cell-background", config.QueueColumnBgColor) w.QueueTreeView.AppendColumn(col) } } // Add selected columns for index, colSpec := range config.GetConfig().QueueColumns { index := index // Make an in-loop copy of index for the closures below // Fetch the attribute by its ID attr, ok := config.MpdTrackAttributes[colSpec.ID] if !ok { log.Errorf("Invalid column ID: %d", colSpec.ID) continue } // Add a text renderer renderer, err := gtk.CellRendererTextNew() if errCheck(err, "CellRendererTextNew() failed") { continue } errCheck(renderer.SetProperty("xalign", attr.XAlign), "renderer.SetProperty(xalign) failed") // Add a new tree column col, err := gtk.TreeViewColumnNewWithAttribute(glib.Local(attr.Name), renderer, "text", colSpec.ID) if errCheck(err, "TreeViewColumnNewWithAttribute() failed") { continue } col.SetSizing(gtk.TREE_VIEW_COLUMN_FIXED) width := colSpec.Width if width == 0 { width = attr.Width } col.SetFixedWidth(width) col.SetClickable(true) col.SetResizable(true) col.AddAttribute(renderer, "weight", config.QueueColumnFontWeight) col.AddAttribute(renderer, "cell-background", config.QueueColumnBgColor) // Bind the clicked signal col.Connect("clicked", func(c *gtk.TreeViewColumn) { w.onQueueTreeViewColClicked(c, index, &attr) }) // Bind the width property change signal: update QueueColumns on each change col.Connect("notify::fixed-width", func(c *gtk.TreeViewColumn) { config.GetConfig().QueueColumns[index].Width = c.GetFixedWidth() }) // Add the column to the tree view w.QueueTreeView.AppendColumn(col) } // Make all columns visible w.QueueTreeView.ShowAll() } // updateQueueActions updates the play queue actions func (w *MainWindow) updateQueueActions() { connected, _ := w.connector.ConnectStatus() notEmpty := connected && w.currentQueueSize > 0 selCount := w.getQueueSelectedCount() selection := notEmpty && selCount > 0 selOne := notEmpty && selCount == 1 // Actions w.aQueueNowPlaying.SetEnabled(notEmpty) w.aQueueClear.SetEnabled(notEmpty) w.aQueueSort.SetEnabled(notEmpty) w.aQueueSortAsc.SetEnabled(notEmpty) w.aQueueSortDesc.SetEnabled(notEmpty) w.aQueueSortShuffle.SetEnabled(notEmpty) w.aQueueDelete.SetEnabled(selection) w.aQueueSave.SetEnabled(notEmpty) // Menu items w.QueueNowPlayingMenuItem.SetSensitive(notEmpty) w.QueueShowAlbumInLibraryMenuItem.SetSensitive(selOne) w.QueueShowArtistInLibraryMenuItem.SetSensitive(selOne) w.QueueShowGenreInLibraryMenuItem.SetSensitive(selOne) w.QueueClearMenuItem.SetSensitive(notEmpty) w.QueueDeleteMenuItem.SetSensitive(selection) } // updateQueueNowPlaying scrolls the queue tree view to the currently played track func (w *MainWindow) updateQueueNowPlaying() { // Update queue highlight if curIdx := util.AtoiDef(w.connector.Status()["song"], -1); w.currentQueueIndex != curIdx { w.setQueueHighlight(w.currentQueueIndex, false) w.setQueueHighlight(curIdx, true) w.currentQueueIndex = curIdx } // Scroll to the currently playing if w.currentQueueIndex >= 0 { // Obtain a path in the unfiltered list treePath, err := gtk.TreePathNewFromIndicesv([]int{w.currentQueueIndex}) if errCheck(err, "updateQueueNowPlaying(): TreePathNewFromString() failed") { return } // Convert the path into one in the filtered list if treePath = w.QueueTreeModelFilter.ConvertChildPathToPath(treePath); treePath != nil { w.QueueTreeView.ScrollToCell(treePath, nil, true, 0.5, 0) } } } // updateQueueTreeViewModel updates the model (and related properties) of the queue tree view func (w *MainWindow) updateQueueTreeViewModel() { // Only use (non-reorderable) QueueTreeModelFilter when connected and searching connected, _ := w.connector.ConnectStatus() searching := w.QueueSearchBar.GetSearchMode() var model gtk.ITreeModel = w.QueueListStore if connected && searching { model = w.QueueTreeModelFilter } w.QueueTreeView.SetModel(model) w.QueueTreeView.SetReorderable(connected && !searching) } // updateStreams updates the current streams list contents func (w *MainWindow) updateStreams() { // Clear the streams list util.ClearChildren(w.StreamsListBox.Container) // Make sure the streams are sorted by name cfg := config.GetConfig() sort.Slice(cfg.Streams, func(i, j int) bool { return strings.ToUpper(cfg.Streams[i].Name) < strings.ToUpper(cfg.Streams[j].Name) }) // Repopulate the streams list var rowToSelect *gtk.ListBoxRow for _, stream := range config.GetConfig().Streams { stream := stream // Make an in-loop copy of the var row, _, err := util.NewListBoxRow( w.StreamsListBox, false, stream.Name, "", "ymuse-stream", // Add replace/append buttons util.NewButton("", glib.Local("Append to the queue"), "", "ymuse-add-symbolic", func() { w.queueStream(tbFalse, stream.URI) }), util.NewButton("", glib.Local("Replace the queue"), "", "ymuse-replace-queue-symbolic", func() { w.queueStream(tbTrue, stream.URI) })) if errCheck(err, "NewListBoxRow() failed") { return } // Select the first row in the list if rowToSelect == nil { rowToSelect = row } } // Show all rows w.StreamsListBox.ShowAll() // Select the required row w.StreamsListBox.SelectRow(rowToSelect) // Compose info var info string if cnt := len(config.GetConfig().Streams); cnt > 0 { info = fmt.Sprintf(glib.Local("%d streams"), cnt) } else { info = glib.Local("No streams") } // Update info w.StreamsInfoLabel.SetText(info) } // updateStreamsActions updates the widgets for streams list func (w *MainWindow) updateStreamsActions() { connected, _ := w.connector.ConnectStatus() selected := w.getSelectedStreamIndex() >= 0 // Actions w.aStreamAdd.SetEnabled(true) // Adding a stream is always possible w.aStreamEdit.SetEnabled(selected) w.aStreamDelete.SetEnabled(selected) // Menu items w.StreamsAppendMenuItem.SetSensitive(connected && selected) w.StreamsReplaceMenuItem.SetSensitive(connected && selected) w.StreamsEditMenuItem.SetSensitive(selected) w.StreamsDeleteMenuItem.SetSensitive(selected) } // updateStyle updates custom colours based on the current theme func (w *MainWindow) updateStyle() { // Fetch window's style context ctx, err := w.AppWindow.GetStyleContext() if errCheck(err, "updateStyle(): GetStyleContext() failed") { return } // Determine normal background colour var bgNormal, bgActive string if rgba, ok := ctx.LookupColor("theme_base_color"); ok { bgNormal = rgba.String() } else { log.Warning("Unknown colour: theme_base_color") bgNormal = "#ffffff" } // Determine active background colour: same as selected colour, but at 30% opacity if rgba, ok := ctx.LookupColor("theme_selected_bg_color"); ok { newRGBA := rgba.Floats() rgba.SetColors(newRGBA[0], newRGBA[1], newRGBA[2], newRGBA[3]*0.3) bgActive = rgba.String() } else { log.Warning("Unknown colour: theme_selected_bg_color") bgActive = "#ffffe0" } // If the colours changed, we need to update the queue list store if w.colourBgNormal != bgNormal || w.colourBgActive != bgActive { w.colourBgNormal = bgNormal w.colourBgActive = bgActive w.currentQueueIndex = -1 w.QueueListStore.ForEach(func(model *gtk.TreeModel, path *gtk.TreePath, iter *gtk.TreeIter) bool { // Update item's background color if err := w.QueueListStore.SetValue(iter, config.QueueColumnBgColor, w.colourBgNormal); errCheck(err, "updateStyle(): SetValue() failed") { return true } // Proceed to the next row return false }) // Update the active row, if the app has been initialised if w.connector != nil { w.updateQueueNowPlaying() } } } // updateVolume synchronises the volume scale position to the current MPD volume func (w *MainWindow) updateVolume() { // Update the volume button's state connected, _ := w.connector.ConnectStatus() w.VolumeButton.SetSensitive(connected) // The update comes from MPD: adjust the volume bar position if there's a connection if vol := util.AtoiDef(w.connector.Status()["volume"], -1); vol >= 0 && vol <= 100 { w.volumeUpdating = true w.VolumeAdjustment.SetValue(float64(vol)) w.volumeUpdating = false } } ymuse-0.22/internal/player/outputs.go000066400000000000000000000060661450727225500200020ustar00rootroot00000000000000/* * Copyright 2022 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package player import ( "fmt" "github.com/fhs/gompd/v2/mpd" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" "github.com/yktoo/ymuse/internal/util" "strconv" ) // OutputsDialog represents the output selection dialog type OutputsDialog struct { OutputsDialog *gtk.Dialog OutputsListBox *gtk.ListBox // Connector instance connector *Connector } // ShowOutputsDialog creates, shows and disposes of an Outputs dialog instance func ShowOutputsDialog(parent gtk.IWindow, c *Connector) { // Create the dialog d := &OutputsDialog{ connector: c, } // Load the dialog layout and map the widgets builder, err := NewBuilder(outputsGlade) if err == nil { err = builder.BindWidgets(d) } // Check for errors if errCheck(err, "OutputsDialog(): failed to initialise dialog") { util.ErrorDialog(parent, fmt.Sprint(glib.Local("Failed to load UI widgets"), err)) return } defer d.OutputsDialog.Destroy() // Set the dialog up d.OutputsDialog.SetTransientFor(parent) // Map the handlers to callback functions builder.ConnectSignals(map[string]interface{}{ "on_OutputsDialog_map": d.populateOutputs, }) // Run the dialog d.OutputsDialog.Run() } func (d *OutputsDialog) switchStateSet(id int, active bool) { log.Debugf("switchStateSet(%v, %v)", id, active) d.connector.IfConnected(func(client *mpd.Client) { if active { errCheck(client.EnableOutput(id), "EnableOutput() failed") } else { errCheck(client.DisableOutput(id), "DisableOutput() failed") } }) } // populateOutputs fills in the Outputs list box func (d *OutputsDialog) populateOutputs() { // Fetch the outputs var attrs []mpd.Attrs var err error d.connector.IfConnected(func(client *mpd.Client) { attrs, err = client.ListOutputs() }) if errCheck(err, "populateOutputs(): ListOutputs() failed") { return } // Add output rows to the list for _, a := range attrs { // Parse the output ID var id int if id, err = strconv.Atoi(a["outputid"]); errCheck(err, "Invalid output ID") { return } // Add a switch sw, err := gtk.SwitchNew() if errCheck(err, "SwitchNew() failed") { return } sw.SetActive(a["outputenabled"] != "0") sw.Connect("state-set", func(_ *gtk.Switch, state bool) { d.switchStateSet(id, state) }) // Add a new list box row text := fmt.Sprintf("%s (%s)", a["outputname"], a["plugin"]) if _, _, err := util.NewListBoxRow(d.OutputsListBox, true, text, "", "", sw); errCheck(err, "NewListBoxRow() failed") { return } } d.OutputsListBox.ShowAll() } ymuse-0.22/internal/player/prefs.go000066400000000000000000000313051450727225500173700ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package player import ( "fmt" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" "github.com/yktoo/ymuse/internal/config" "github.com/yktoo/ymuse/internal/util" "sync" "time" ) type queueCol struct { selected bool id int width int } // PrefsDialog represents the preferences dialog type PrefsDialog struct { PreferencesDialog *gtk.Dialog // General page widgets MpdNetworkComboBox *gtk.ComboBoxText MpdPathEntry *gtk.Entry MpdPathLabel *gtk.Label MpdHostEntry *gtk.Entry MpdHostLabel *gtk.Label MpdHostLabelRemark *gtk.Label MpdPortSpinButton *gtk.SpinButton MpdPortLabel *gtk.Label MpdPortAdjustment *gtk.Adjustment MpdPasswordEntry *gtk.Entry MpdAutoConnectCheckButton *gtk.CheckButton MpdAutoReconnectCheckButton *gtk.CheckButton // Interface page widgets QueueToolbarCheckButton *gtk.CheckButton LibraryDefaultReplaceRadioButton *gtk.RadioButton LibraryDefaultAppendRadioButton *gtk.RadioButton PlaylistsDefaultReplaceRadioButton *gtk.RadioButton PlaylistsDefaultAppendRadioButton *gtk.RadioButton StreamsDefaultReplaceRadioButton *gtk.RadioButton StreamsDefaultAppendRadioButton *gtk.RadioButton // Automation page widgets AutomationQueueReplaceSwitchToCheckButton *gtk.CheckButton AutomationQueueReplacePlayCheckButton *gtk.CheckButton // Player page widgets PlayerShowAlbumArtTracksCheckButton *gtk.CheckButton PlayerShowAlbumArtStreamsCheckButton *gtk.CheckButton PlayerAlbumArtSizeAdjustment *gtk.Adjustment PlayerTitleTemplateTextBuffer *gtk.TextBuffer // Columns page widgets ColumnsListBox *gtk.ListBox // Whether the dialog is initialised initialised bool // Columns, in the same order as in the ColumnsListBox queueColumns []queueCol // Timer for delayed player setting change callback invocation playerSettingChangeTimer *time.Timer playerSettingChangeMutex sync.Mutex // Callbacks onQueueColumnsChanged func() onPlayerSettingChanged func() } // ShowPreferencesDialog creates, shows and disposes of a Preferences dialog instance func ShowPreferencesDialog(parent gtk.IWindow, onMpdReconnect, onQueueColumnsChanged, onPlayerSettingChanged func()) { // Create the dialog d := &PrefsDialog{ onQueueColumnsChanged: onQueueColumnsChanged, onPlayerSettingChanged: onPlayerSettingChanged, } // Load the dialog layout and map the widgets builder, err := NewBuilder(prefsGlade) if err == nil { err = builder.BindWidgets(d) } // Check for errors if errCheck(err, "ShowPreferencesDialog(): failed to initialise dialog") { util.ErrorDialog(parent, fmt.Sprint(glib.Local("Failed to load UI widgets"), err)) return } defer d.PreferencesDialog.Destroy() // Set the dialog up d.PreferencesDialog.SetTransientFor(parent) // Remove the 2-pixel "aura" around the notebook if box, err := d.PreferencesDialog.GetContentArea(); err == nil { box.SetBorderWidth(0) } // Map the handlers to callback functions builder.ConnectSignals(map[string]interface{}{ "on_PreferencesDialog_map": d.onMap, "on_Setting_change": d.onSettingChange, "on_MpdReconnect": onMpdReconnect, "on_ColumnMoveUpToolButton_clicked": d.onColumnMoveUp, "on_ColumnMoveDownToolButton_clicked": d.onColumnMoveDown, }) // Run the dialog d.PreferencesDialog.Run() } func (d *PrefsDialog) onMap() { log.Debug("PrefsDialog.onMap()") // Initialise widgets cfg := config.GetConfig() // General page d.MpdNetworkComboBox.SetActiveID(cfg.MpdNetwork) d.MpdPathEntry.SetText(cfg.MpdSocketPath) d.MpdHostEntry.SetText(cfg.MpdHost) d.MpdPortAdjustment.SetValue(float64(cfg.MpdPort)) d.MpdPasswordEntry.SetText(cfg.MpdPassword) d.MpdAutoConnectCheckButton.SetActive(cfg.MpdAutoConnect) d.MpdAutoReconnectCheckButton.SetActive(cfg.MpdAutoReconnect) d.updateGeneralWidgets() // Interface page d.QueueToolbarCheckButton.SetActive(cfg.QueueToolbar) d.LibraryDefaultReplaceRadioButton.SetActive(cfg.TrackDefaultReplace) d.LibraryDefaultAppendRadioButton.SetActive(!cfg.TrackDefaultReplace) d.PlaylistsDefaultReplaceRadioButton.SetActive(cfg.PlaylistDefaultReplace) d.PlaylistsDefaultAppendRadioButton.SetActive(!cfg.PlaylistDefaultReplace) d.StreamsDefaultReplaceRadioButton.SetActive(cfg.StreamDefaultReplace) d.StreamsDefaultAppendRadioButton.SetActive(!cfg.StreamDefaultReplace) d.PlayerShowAlbumArtTracksCheckButton.SetActive(cfg.PlayerAlbumArtTracks) d.PlayerShowAlbumArtStreamsCheckButton.SetActive(cfg.PlayerAlbumArtStreams) d.PlayerAlbumArtSizeAdjustment.SetValue(float64(cfg.PlayerAlbumArtSize)) d.PlayerTitleTemplateTextBuffer.SetText(cfg.PlayerTitleTemplate) // Automation page d.AutomationQueueReplaceSwitchToCheckButton.SetActive(cfg.SwitchToOnQueueReplace) d.AutomationQueueReplacePlayCheckButton.SetActive(cfg.PlayOnQueueReplace) // Columns page d.populateColumns() d.initialised = true } // addQueueColumn adds a row with a check box to the Columns list box, and also registers a new item in d.queueColumns func (d *PrefsDialog) addQueueColumn(attrID, width int, selected bool) { // Add an entry to queue columns slice d.queueColumns = append(d.queueColumns, queueCol{selected: selected, id: attrID, width: width}) // Add a new list box row row, err := gtk.ListBoxRowNew() if errCheck(err, "ListBoxRowNew() failed") { return } d.ColumnsListBox.Add(row) // Add a container box hbx, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) if errCheck(err, "BoxNew() failed") { return } row.Add(hbx) // Add a checkbox cb, err := gtk.CheckButtonNew() if errCheck(err, "CheckButtonNew() failed") { return } cb.SetActive(selected) cb.Connect("toggled", func(c *gtk.CheckButton) { d.columnCheckboxToggled(attrID, c.GetActive(), row) }) hbx.PackStart(cb, false, false, 0) // Add a label lbl, err := gtk.LabelNew(glib.Local(config.MpdTrackAttributes[attrID].LongName)) if errCheck(err, "LabelNew() failed") { return } lbl.SetXAlign(0) hbx.PackStart(lbl, true, true, 0) } // columnCheckboxToggled is a handler of the toggled signal for queue column checkboxes func (d *PrefsDialog) columnCheckboxToggled(id int, selected bool, row *gtk.ListBoxRow) { // Find and toggle the column for the attribute if i := d.indexOfColumnWithAttrID(id); i >= 0 { d.queueColumns[i].selected = selected // Select the row d.ColumnsListBox.SelectRow(row) // Update the queue columns d.notifyColumnsChanged() } } // indexOfColumnWithAttrID returns the index of the queue column with given attribute ID, or -1 if not found func (d *PrefsDialog) indexOfColumnWithAttrID(id int) int { for i := range d.queueColumns { if id == d.queueColumns[i].id { return i } } return -1 } // moveSelectedColumnRow moves the row selected in the Columns listbox up or down func (d *PrefsDialog) moveSelectedColumnRow(up bool) { // Get and check the selection row := d.ColumnsListBox.GetSelectedRow() if row == nil { return } // Get the row's index in the list index := row.GetIndex() if index < 0 || (up && index == 0) || (!up && index >= len(d.queueColumns)-1) { return } // Reorder the elements in the queue columns slice prevIndex := index if up { index-- } else { index++ } d.queueColumns[index], d.queueColumns[prevIndex] = d.queueColumns[prevIndex], d.queueColumns[index] // Remove and re-insert the row d.ColumnsListBox.Remove(row) d.ColumnsListBox.Insert(row, index) // Re-select the row. NB: need to deselect all first, otherwise it wouldn't get selected d.ColumnsListBox.SelectRow(nil) d.ColumnsListBox.SelectRow(d.ColumnsListBox.GetRowAtIndex(index)) // Scroll the listbox to center the row glib.IdleAdd(func() { util.ListBoxScrollToSelected(d.ColumnsListBox) }) // Update the queue's columns d.notifyColumnsChanged() } // notifyColumnsChanged updates queue tree view columns from the currently selected ones in the Columns list box func (d *PrefsDialog) notifyColumnsChanged() { // Collect IDs of selected attributes var colSpecs []config.ColumnSpec for _, col := range d.queueColumns { if col.selected { colSpecs = append(colSpecs, config.ColumnSpec{ID: col.id, Width: col.width}) } } // Save the IDs in the config config.GetConfig().QueueColumns = colSpecs // Notify the callback d.onQueueColumnsChanged() } // onColumnMoveUp is a signal handler for the Move up button click func (d *PrefsDialog) onColumnMoveUp() { d.moveSelectedColumnRow(true) } // onColumnMoveDown is a signal handler for the Move down button click func (d *PrefsDialog) onColumnMoveDown() { d.moveSelectedColumnRow(false) } // onSettingChange is a signal handler for a change of a simple setting widget func (d *PrefsDialog) onSettingChange() { // Ignore if the dialog is not initialised yet if !d.initialised { return } log.Debug("onSettingChange()") // Collect settings cfg := config.GetConfig() // General page cfg.MpdNetwork = d.MpdNetworkComboBox.GetActiveID() cfg.MpdSocketPath = util.EntryText(d.MpdPathEntry, "") cfg.MpdHost = util.EntryText(d.MpdHostEntry, "") cfg.MpdPort = int(d.MpdPortAdjustment.GetValue()) if s, err := d.MpdPasswordEntry.GetText(); !errCheck(err, "MpdPasswordEntry.GetText() failed") { cfg.MpdPassword = s } cfg.MpdAutoConnect = d.MpdAutoConnectCheckButton.GetActive() cfg.MpdAutoReconnect = d.MpdAutoReconnectCheckButton.GetActive() d.updateGeneralWidgets() // Interface page if b := d.QueueToolbarCheckButton.GetActive(); b != cfg.QueueToolbar { cfg.QueueToolbar = b d.schedulePlayerSettingChange() } cfg.TrackDefaultReplace = d.LibraryDefaultReplaceRadioButton.GetActive() cfg.PlaylistDefaultReplace = d.PlaylistsDefaultReplaceRadioButton.GetActive() cfg.StreamDefaultReplace = d.StreamsDefaultReplaceRadioButton.GetActive() // Automation page cfg.SwitchToOnQueueReplace = d.AutomationQueueReplaceSwitchToCheckButton.GetActive() cfg.PlayOnQueueReplace = d.AutomationQueueReplacePlayCheckButton.GetActive() // Player page if b := d.PlayerShowAlbumArtTracksCheckButton.GetActive(); b != cfg.PlayerAlbumArtTracks { cfg.PlayerAlbumArtTracks = b d.schedulePlayerSettingChange() } if b := d.PlayerShowAlbumArtStreamsCheckButton.GetActive(); b != cfg.PlayerAlbumArtStreams { cfg.PlayerAlbumArtStreams = b d.schedulePlayerSettingChange() } if i := int(d.PlayerAlbumArtSizeAdjustment.GetValue()); i != cfg.PlayerAlbumArtSize { cfg.PlayerAlbumArtSize = i d.schedulePlayerSettingChange() } if s, err := util.GetTextBufferText(d.PlayerTitleTemplateTextBuffer); !errCheck(err, "util.GetTextBufferText() failed") { if s != cfg.PlayerTitleTemplate { cfg.PlayerTitleTemplate = s d.schedulePlayerSettingChange() } } } // populateColumns fills in the Columns list box func (d *PrefsDialog) populateColumns() { // First add selected columns selColSpecs := config.GetConfig().QueueColumns for _, colSpec := range selColSpecs { d.addQueueColumn(colSpec.ID, colSpec.Width, true) } // Add all unselected columns for _, id := range config.MpdTrackAttributeIds { // Check if the ID is already in the list of selected IDs isSelected := false for _, selSpec := range selColSpecs { if id == selSpec.ID { isSelected = true break } } // If not, add it if !isSelected { d.addQueueColumn(id, 0, false) } } d.ColumnsListBox.ShowAll() } func (d *PrefsDialog) schedulePlayerSettingChange() { // Cancel the currently scheduled callback, if any d.playerSettingChangeMutex.Lock() defer d.playerSettingChangeMutex.Unlock() if d.playerSettingChangeTimer != nil { d.playerSettingChangeTimer.Stop() } // Schedule a new callback d.playerSettingChangeTimer = time.AfterFunc(time.Second, func() { d.playerSettingChangeMutex.Lock() d.playerSettingChangeTimer = nil d.playerSettingChangeMutex.Unlock() glib.IdleAdd(d.onPlayerSettingChanged) }) } // updateGeneralWidgets updates widget states on the General tab func (d *PrefsDialog) updateGeneralWidgets() { network := d.MpdNetworkComboBox.GetActiveID() unix, tcp := network == "unix", network == "tcp" d.MpdPathEntry.SetVisible(unix) d.MpdPathLabel.SetVisible(unix) d.MpdHostEntry.SetVisible(tcp) d.MpdHostLabel.SetVisible(tcp) d.MpdHostLabelRemark.SetVisible(tcp) d.MpdPortSpinButton.SetVisible(tcp) d.MpdPortLabel.SetVisible(tcp) } ymuse-0.22/internal/player/resources.go000066400000000000000000000004701450727225500202620ustar00rootroot00000000000000package player import _ "embed" //go:embed glade/mpd-info.glade var mpdInfoGlade string //go:embed glade/outputs.glade var outputsGlade string //go:embed glade/player.glade var playerGlade string //go:embed glade/prefs.glade var prefsGlade string //go:embed glade/shortcuts.glade var shortcutsGlade string ymuse-0.22/internal/util/000077500000000000000000000000001450727225500154015ustar00rootroot00000000000000ymuse-0.22/internal/util/log.go000066400000000000000000000016411450727225500165130ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package util import ( "fmt" "github.com/op/go-logging" ) // Package-wide Logger instance var log = logging.MustGetLogger("util") // errCheck logs a warning if the error is not nil. func errCheck(err error, message string) bool { if err != nil { log.Warning(fmt.Errorf("%v: %v", message, err)) return true } return false } ymuse-0.22/internal/util/log_test.go000066400000000000000000000042011450727225500175450ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package util import ( "errors" "github.com/op/go-logging" "testing" ) type TestLogBackend struct { logging.Leveled level logging.Level record *logging.Record } func (t *TestLogBackend) Log(level logging.Level, _ int, record *logging.Record) error { t.level = level t.record = record return nil } func (t *TestLogBackend) reset() { t.level = 0 t.record = nil } func Test_errCheck(t *testing.T) { type args struct { err error message string } tests := []struct { name string args args want bool wantLevel logging.Level wantMessage string }{ {"no error", args{err: nil, message: "foo"}, false, 0, ""}, {"error", args{err: errors.New("boom"), message: "foo failed"}, true, logging.WARNING, "foo failed: boom"}, } // Use a fake log backend for test backend := &TestLogBackend{} log.SetBackend(backend) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Check the function backend.reset() if got := errCheck(tt.args.err, tt.args.message); got != tt.want { t.Errorf("errCheck() = %v, want %v", got, tt.want) } // Check the logging if backend.level != tt.wantLevel { t.Errorf("errCheck() log level = %v, want %v", backend.level, tt.wantLevel) } if tt.wantMessage == "" { if backend.record != nil { t.Errorf("errCheck() log message must be nil but it's %v", backend.record.Message()) } } else if backend.record.Message() != tt.wantMessage { t.Errorf("errCheck() log message = %v, want %v", backend.record.Message(), tt.wantMessage) } }) } } ymuse-0.22/internal/util/ui-util.go000066400000000000000000000161021450727225500173200ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package util import ( "fmt" "github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/pango" "html" ) // ClearChildren removes all container's children func ClearChildren(container gtk.Container) { container.GetChildren().Foreach(func(item interface{}) { container.Remove(item.(gtk.IWidget)) }) } // NewButton creates and returns a new button func NewButton(label, tooltip, name, icon string, onClicked func()) *gtk.Button { btn, err := gtk.ButtonNewWithLabel(label) if errCheck(err, "ButtonNewWithLabel() failed") { return nil } btn.SetName(name) btn.SetTooltipText(tooltip) // Create an icon, if needed if icon != "" { // Icon is optional, do not fail entirely on an error if img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_BUTTON); !errCheck(err, "ImageNewFromIconName() failed") { btn.SetImage(img) btn.SetAlwaysShowImage(true) } } // Bind the clicked signal btn.Connect("clicked", onClicked) return btn } // NewBoxToggleButton creates, adds to a box and returns a new toggle button func NewBoxToggleButton(box *gtk.Box, label, name, icon string, active bool, onClicked func()) *gtk.ToggleButton { btn, err := gtk.ToggleButtonNewWithLabel(label) if errCheck(err, "ToggleButtonNewWithLabel() failed") { return nil } btn.SetName(name) btn.SetActive(active) // Create an icon, if needed if icon != "" { // Icon is optional, do not fail entirely on an error if img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_BUTTON); !errCheck(err, "ImageNewFromIconName() failed") { btn.SetImage(img) btn.SetAlwaysShowImage(true) } } // Bind the clicked signal btn.Connect("clicked", onClicked) // Add the button to the box box.PackStart(btn, false, false, 0) return btn } // NewLabel instantiates and returns a new label func NewLabel(label string) *gtk.Label { lbl, err := gtk.LabelNew(label) if errCheck(err, "LabelNew() failed") { return nil } lbl.SetXAlign(0) return lbl } // NewListBoxRow adds a new row to the list box, a horizontal box, an image and a label to it // listBox: list box instance // useMarkup: whether label is markup // label: text for the row // name: name of the row // icon: optional icon name for the row // widgets: extra widgets to insert into the beginning of the row func NewListBoxRow(listBox *gtk.ListBox, useMarkup bool, label, name, icon string, widgets ...gtk.IWidget) (*gtk.ListBoxRow, *gtk.Box, error) { // Add a new list box row row, err := gtk.ListBoxRowNew() if err != nil { return nil, nil, err } row.SetName(name) // Add horizontal box hbx, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) if err != nil { return nil, nil, err } hbx.SetMarginStart(6) hbx.SetMarginEnd(6) row.Add(hbx) // Add extra widgets, if any for _, w := range widgets { hbx.PackStart(w, false, false, 0) } // Insert icon, if needed if icon != "" { // Icon is optional, do not fail entirely on an error if img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_LARGE_TOOLBAR); !errCheck(err, "ImageNewFromIconName() failed") { hbx.PackStart(img, false, false, 0) } } // Insert label with directory/file name lbl, err := gtk.LabelNew("") if err != nil { return nil, nil, err } lbl.SetXAlign(0) lbl.SetEllipsize(pango.ELLIPSIZE_END) if useMarkup { lbl.SetMarkup(label) } else { lbl.SetText(label) } hbx.PackStart(lbl, true, true, 0) // Add the row to the list box listBox.Add(row) return row, hbx, nil } // ListBoxScrollToSelected scrolls the provided list box so that the selected row is centered in the window func ListBoxScrollToSelected(listBox *gtk.ListBox) { // If there's selection if row := listBox.GetSelectedRow(); row != nil { // Convert the row's Y coordinate into the list box's coordinate if _, y, _ := row.TranslateCoordinates(listBox, 0, 0); y >= 0 { // Scroll the vertical adjustment to center the row in the viewport if adj := listBox.GetAdjustment(); adj != nil { _, rowHeight := row.GetPreferredHeight() adj.SetValue(float64(y) - (adj.GetPageSize()-float64(rowHeight))/2) } } } } // ConfirmDialog shows a confirmation message dialog func ConfirmDialog(parent gtk.IWindow, title, text string) bool { dlg := gtk.MessageDialogNew(parent, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_OK_CANCEL, "") dlg.SetMarkup(fmt.Sprintf("%v\n\n%v", html.EscapeString(title), html.EscapeString(text))) defer dlg.Destroy() return dlg.Run() == gtk.RESPONSE_OK } // GetTextBufferText returns the entire text stored in a text buffer func GetTextBufferText(buf *gtk.TextBuffer) (string, error) { start, end := buf.GetBounds() return buf.GetText(start, end, true) } // EditDialog show a dialog with a single text entry func EditDialog(parent gtk.IWindow, title, value, okButton string) (string, bool) { // Create a dialog dlg, err := gtk.DialogNewWithButtons( title, parent, gtk.DIALOG_MODAL, []interface{}{okButton, gtk.RESPONSE_OK}, []interface{}{"Cancel", gtk.RESPONSE_CANCEL}) if errCheck(err, "DialogNewWithButtons() failed") { return "", false } defer dlg.Destroy() // Obtain the dialog's content area bx, err := dlg.GetContentArea() if errCheck(err, "GetContentArea() failed") { return "", false } // Add a text entry to the dialog entry, err := gtk.EntryNew() if errCheck(err, "EntryNew() failed") { return "", false } entry.SetSizeRequest(400, -1) entry.SetText(value) entry.SetMarginStart(12) entry.SetMarginEnd(12) entry.SetMarginTop(12) entry.SetMarginBottom(12) entry.GrabFocus() bx.Add(entry) bx.ShowAll() // Enable or disable the OK button based on text presence validate := func() { if w, err := dlg.GetWidgetForResponse(gtk.RESPONSE_OK); err == nil { text, err := entry.GetText() w.ToWidget().SetSensitive(err == nil && text != "") } } entry.Connect("changed", validate) dlg.Connect("map", validate) dlg.SetDefaultResponse(gtk.RESPONSE_OK) // Run the dialog response := dlg.Run() value, err = entry.GetText() if errCheck(err, "entry.GetText() failed") { return "", false } // Check the response if response == gtk.RESPONSE_OK { return value, true } return "", false } // EntryText returns the text in an entry, or the default string if an error occurred func EntryText(entry *gtk.Entry, def string) string { s, err := entry.GetText() if errCheck(err, "EntryText(): GetText() failed") { return def } return s } // ErrorDialog shows an error message dialog func ErrorDialog(parent gtk.IWindow, text string) { dlg := gtk.MessageDialogNew(parent, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, gtk.BUTTONS_OK, text) defer dlg.Destroy() dlg.Run() } ymuse-0.22/internal/util/util.go000066400000000000000000000054461450727225500167160ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package util import ( "fmt" "github.com/fhs/gompd/v2/mpd" "github.com/gotk3/gotk3/glib" "html/template" "strconv" "strings" "sync" ) var ( locDay string locDays string locOnce sync.Once ) // AtoiDef converts a string into an int, returning the given default value if conversion failed func AtoiDef(s string, def int) int { if i, err := strconv.Atoi(s); err == nil { return i } return def } // ParseFloatDef converts a string into a float64, returning the given default value if conversion failed func ParseFloatDef(s string, def float64) float64 { if f, err := strconv.ParseFloat(s, 32); err == nil { return f } return def } // FormatSeconds formats a number seconds as a string func FormatSeconds(seconds float64) string { // Make sure localised strings are fetched locOnce.Do(func() { locDay = glib.Local("one day") locDays = glib.Local("days") }) minutes, secs := int(seconds)/60, int(seconds)%60 hours, mins := minutes/60, minutes%60 days, hrs := hours/24, hours%24 switch { case days > 1: return fmt.Sprintf("%d %s %d:%02d:%02d", days, locDays, hrs, mins, secs) case days == 1: return fmt.Sprintf("%s %d:%02d:%02d", locDay, hrs, mins, secs) case hours >= 1: return fmt.Sprintf("%d:%02d:%02d", hrs, mins, secs) default: return fmt.Sprintf("%d:%02d", mins, secs) } } // FormatSecondsStr formats a number seconds as a string given string input func FormatSecondsStr(seconds string) string { if f := ParseFloatDef(seconds, -1); f >= 0 { return FormatSeconds(f) } return "" } // Default returns a default value if no value is set func Default(def string, value interface{}) string { if set, ok := template.IsTrue(value); ok && set { return fmt.Sprint(value) } return def } // IsStreamURI returns whether the given URI refers to an Internet stream func IsStreamURI(uri string) bool { return strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://") } // MapAttrsToSlice converts a list of Attrs into a string slice by extracting only the provided attribute func MapAttrsToSlice(attrs []mpd.Attrs, attr string) []string { r := make([]string, len(attrs)) for i, a := range attrs { r[i] = a[attr] } return r } func MaxInt(a, b int) int { if a < b { return b } return a } ymuse-0.22/internal/util/util_test.go000066400000000000000000000153561450727225500177560ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package util import ( "fmt" "github.com/fhs/gompd/v2/mpd" "math" "reflect" "testing" ) func TestAtoiDef(t *testing.T) { type args struct { s string def int } tests := []struct { name string args args want int }{ {"empty string", args{"", 777}, 777}, {"positive numeric string", args{"42", -1}, 42}, {"negative numeric string", args{"-120", 0}, -120}, {"non-numeric string", args{"Zook", 16}, 16}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := AtoiDef(tt.args.s, tt.args.def); got != tt.want { t.Errorf("AtoiDef() = %v, want %v", got, tt.want) } }) } } func TestFormatSeconds(t *testing.T) { type args struct { seconds float64 } tests := []struct { name string args args want string }{ {"zero seconds", args{0}, "0:00"}, {"some seconds", args{42}, "0:42"}, {"fractional seconds", args{4.2234514}, "0:04"}, {"minute with seconds", args{218}, "3:38"}, {"many minutes", args{2722.7}, "45:22"}, {"an hour with minutes", args{3600 + 3*60 + 15}, "1:03:15"}, {"almost a day", args{23*3600 + 59*60 + 59}, "23:59:59"}, {"one day", args{1*24*3600 + 1*3600 + 8*60 + 47}, "one day 1:08:47"}, {"many days", args{66*24*3600 + 15*3600 + 12*60 + 33}, "66 days 15:12:33"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := FormatSeconds(tt.args.seconds); got != tt.want { t.Errorf("FormatSeconds() = %v, want %v", got, tt.want) } }) } } func TestFormatSecondsStr(t *testing.T) { type args struct { seconds string } tests := []struct { name string args args want string }{ {"empty value", args{""}, ""}, {"invalid value", args{"boo"}, ""}, {"zero seconds", args{"0"}, "0:00"}, {"some seconds", args{"42"}, "0:42"}, {"fractional seconds", args{"4.2234514"}, "0:04"}, {"minute with seconds", args{"218"}, "3:38"}, {"many minutes", args{"2722.7"}, "45:22"}, {"an hour with minutes", args{"3795"}, "1:03:15"}, {"almost a day", args{"86399"}, "23:59:59"}, {"one day", args{"90527"}, "one day 1:08:47"}, {"many days", args{"5757153"}, "66 days 15:12:33"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := FormatSecondsStr(tt.args.seconds); got != tt.want { t.Errorf("FormatSecondsStr() = %v, want %v", got, tt.want) } }) } } func TestParseFloatDef(t *testing.T) { type args struct { s string def float64 } tests := []struct { name string args args want float64 }{ {"empty string", args{"", 777.14}, 777.14}, {"positive numeric string", args{"42.52", -1.234}, 42.52}, {"negative numeric string", args{"-120.0001", 0}, -120.0001}, {"non-numeric string", args{"Zook", 16.8899}, 16.8899}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Compare the numbers with 1/1e6 tolerance to ignore rounding errors if got := ParseFloatDef(tt.args.s, tt.args.def); math.Abs(got-tt.want) > 0.000001 { t.Errorf("ParseFloatDef() = %v, want %v", got, tt.want) } }) } } func TestDefault(t *testing.T) { type args struct { def string value interface{} } tests := []struct { name string args args want string }{ {"nil is no value", args{"Foo", nil}, "Foo"}, {"empty string is no value", args{"Foo", ""}, "Foo"}, {"non-empty string is value", args{"Foo", "barr"}, "barr"}, {"false is no value", args{"Foo", false}, "Foo"}, {"true is value", args{"Foo", true}, "true"}, {"struct is value", args{"Foo", struct{}{}}, "{}"}, {"int 0 is no value", args{"Foo", 0}, "Foo"}, {"positive int is value", args{"Foo", 14}, "14"}, {"negative int is value", args{"Foo", -2}, "-2"}, {"float 0 is no value", args{"Foo", 0.0}, "Foo"}, {"positive float is value", args{"Foo", 14.0}, "14"}, {"negative float is value", args{"Foo", -2.3}, "-2.3"}, {"complex is value", args{"Foo", 3 + 2i}, "(3+2i)"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := Default(tt.args.def, tt.args.value); got != tt.want { t.Errorf("Default() = %v, want %v", got, tt.want) } }) } } func TestIsStreamURI(t *testing.T) { tests := []struct { name string uri string want bool }{ {"empty is no stream", "", false}, {"Name is no stream", "Name", false}, {"http: is no stream", "http:", false}, {"https: is no stream", "https:", false}, {"http:// not at begin is no stream", "[http://whatev.er]", false}, {"http-URL is a stream", "http://example.com", true}, {"https-URL is a stream", "https://www.musicpd.org/", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsStreamURI(tt.uri); got != tt.want { t.Errorf("IsStreamURI() = %v, want %v", got, tt.want) } }) } } func TestMapAttrsToSlice(t *testing.T) { type args struct { attrs []mpd.Attrs attr string } tests := []struct { name string args args want []string }{ {"empty list", args{[]mpd.Attrs{}, "file"}, []string{}}, {"single element", args{[]mpd.Attrs{{"file": "foo"}}, "file"}, []string{"foo"}}, {"missing attribute", args{[]mpd.Attrs{{"whoppa": "hippa"}}, "file"}, []string{""}}, { "multiple elements", args{ []mpd.Attrs{ {"artist": "bar", "album": "baz", "file": "foo"}, {"artist": "X-None", "file": "whoopsie"}, {"artist": "Blase", "file": "snap"}, {"album": "There"}, }, "file"}, []string{"foo", "whoopsie", "snap", ""}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := MapAttrsToSlice(tt.args.attrs, tt.args.attr); !reflect.DeepEqual(got, tt.want) { t.Errorf("MapAttrsToSlice() = %v, want %v", got, tt.want) } }) } } func TestMaxInt(t *testing.T) { const maxInt = int(^uint(0) >> 1) const minInt = -maxInt - 1 tests := []struct { a int b int want int }{ {0, 0, 0}, {0, -1, 0}, {-1, 0, 0}, {1, 2, 2}, {-1, -2, -1}, {maxInt, 0, maxInt}, {0, maxInt, maxInt}, {maxInt - 1, maxInt, maxInt}, {minInt, maxInt, maxInt}, {maxInt, minInt, maxInt}, {minInt, 0, 0}, {0, minInt, 0}, {minInt + 1, minInt, minInt + 1}, } for _, tt := range tests { t.Run(fmt.Sprintf("Compare %d with %d", tt.a, tt.b), func(t *testing.T) { if got := MaxInt(tt.a, tt.b); got != tt.want { t.Errorf("MaxInt() = %v, want %v", got, tt.want) } }) } } ymuse-0.22/resources/000077500000000000000000000000001450727225500146225ustar00rootroot00000000000000ymuse-0.22/resources/com.yktoo.ymuse.desktop000066400000000000000000000011441450727225500213000ustar00rootroot00000000000000[Desktop Entry] Categories=AudioVideo; Comment=Music Player Daemon client application for GTK. Comment[nl]=Music Player Daemon cliënt applicatie voor GTK. Comment[ru]=Приложение-клиент для Music Player Daemon, использующее GTK. Comment[ja]=GTK製Music Player Daemonクライアント Exec=ymuse Hidden=false Icon=com.yktoo.ymuse Name=Ymuse GenericName=MPD client GenericName[nl]=MPD-cliënt GenericName[ru]=MPD-клиент GenericName[ja]=MPDクライアント StartupNotify=true Terminal=false Type=Application Keywords=sound;audio;MPD;GTK;Gnome; X-GNOME-Autostart-enabled=true ymuse-0.22/resources/i18n/000077500000000000000000000000001450727225500154015ustar00rootroot00000000000000ymuse-0.22/resources/i18n/de.po000066400000000000000000000377471450727225500163530ustar00rootroot00000000000000# Ymuse MPD client # Copyright (C) 2020-2021 Dmitry Kann # This file is distributed under the same license as the Ymuse package. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-25 12:11+0200\n" "PO-Revision-Date: 2022-12-08 21:12+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: 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.2.2\n" msgid "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(unknown title)\" }}\n" "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | default \"(unknown album)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "File {{ .file | basename }}\n" "from {{ .file | dirname }}\n" "{{- else -}}\n" "(no track)\n" "{{- end -}}\n" msgstr "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(unbekanntes Stück)\" }}\n" "von {{ .Artist | default \"(unbekanntem Künstler)\" }} von {{ .Album | default \"(unbekanntem Album)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "File {{ .file | basename }}\n" "from {{ .file | dirname }}\n" "{{- else -}}\n" "(kein Stück)\n" "{{- end -}}\n" msgid "#" msgstr "#" msgid "%d items" msgstr "%d Stücke" msgid "%d streams" msgstr "%d Stream(s)" msgid "%d track(s) displayed" msgstr "%d Stück(e) angezeigt" msgid "%d tracks" msgstr "%d Stücke" msgid "(leave empty for localhost)" msgstr "(für localhost leer lassen)" msgid "(limited selection of %d items)" msgstr "(begrenzte Auswahl von %d Stücken)" msgid "(new playlist)" msgstr "(Neue Wiedergabeliste)" msgid "(Re)connect to MPD" msgstr "Zu MPD (wieder)verbinden" msgid "(unknown)" msgstr "(unbekannt)" msgid "Library" msgstr "Bibliothek" msgid "MPD connection" msgstr "MPD Verbindung" msgid "MPD Information" msgstr "MPD Information" msgid "Player" msgstr "Spieler" msgid "Playlists" msgstr "Wiedergabelisten" msgid "Queue" msgstr "Warteschlange" msgid "Streams" msgstr "Streams" msgid "_About…" msgstr "_Über…" msgid "_Connect to MPD" msgstr "_Mit MPD verbinden" msgid "_Disconnect from MPD" msgstr "_Von MPD trennen" msgid "_Keyboard shortcuts…" msgstr "_Tastaturkürzel…" msgid "_Preferences…" msgstr "_Einstellungen…" msgid "_Quit" msgstr "_Beenden" msgid "About" msgstr "Über" msgid "Add a new stream" msgstr "Neuen Stream hinzufügen" msgid "Add" msgstr "Hinzufügen" msgid "Add the selected item to a playlist" msgstr "Das markierte Stück zu einer Wiedergabeliste hinzufügen" msgid "Add to ▾" msgstr "Hinzufügen zu ▾" msgid "Add to playlist…" msgstr "Zu Warteschlange hinzufügen…" msgid "After the queue is replaced:" msgstr "Nachdem die Warteschlange ersetzt wurde:" msgid "Album (for sorting)" msgstr "Album (für Sortierung)" msgid "Album art:" msgstr "Album Cover:" msgid "Album artist (for sorting)" msgstr "Albumkünstler (für Sortierung)" msgid "Album artist" msgstr "Albumkünstler" msgid "Album" msgstr "Album" msgid "Albums" msgstr "Alben" msgid "and" msgstr "und" msgid "Append selection to queue" msgstr "Markierung an Warteschlange anhängen" msgid "Append to the queue" msgstr "An Warteschlange anhängen" msgid "Append tracks" msgstr "Stücke anhängen" msgid "Application shortcuts" msgstr "Anwendungskürzel" msgid "Apply" msgstr "Anwenden" msgid "Are you sure you want to delete playlist \"%s\"?" msgstr "Bist du dir sicher, dass du die Widergabeliste \"%s\" löschen möchtest?" msgid "Are you sure you want to delete stream \"%s\"?" msgstr "Bist du dir sicher, dass du den Stream \"%s\" löschen möchtest?" msgid "Artist (for sorting)" msgstr "Künstler (für Sortierung)" msgid "Artist" msgstr "Künstler" msgid "Artists" msgstr "Künstler" msgid "Ascending" msgstr "Aufsteigend" msgid "Automatically connect on startup" msgstr "Beim Starten automatisch verbinden" msgid "Automatically reconnect" msgstr "Automatisch wiederverbinden" msgid "Automation" msgstr "Automatisierung" msgid "Choose outputs MPD should use for playback." msgstr "Wähle das die Ausgabe, die MPD zum Abspielen nutzen soll." msgid "Clear the play queue" msgstr "Wiedergabeliste leeren" msgid "Clear" msgstr "Leeren" msgid "Columns" msgstr "Spalten" msgid "Comment" msgstr "Kommentar" msgid "Composer" msgstr "Komponist" msgid "Conductor" msgstr "Leiter" msgid "Connecting to MPD…" msgstr "Zu MPD verbinden…" msgid "Connect to MPD" msgstr "Zu MPD verbinden" msgid "Consume mode" msgstr "Verbrauchenmodus" msgid "Consume" msgstr "Verbrauchen" msgid "Current track time" msgstr "Aktuelle Spielzeit" msgid "Daemon uptime:" msgstr "Laufzeit daemon:" msgid "Daemon version:" msgstr "Version daemon:" msgid "days" msgstr "Tage" msgid "Decoder plugins" msgstr "Dekodiererweiterungen" msgid "Default action (set in Preferences)" msgstr "Standardaktion (in Einstellungen gesetzt)" msgid "Delete playlist" msgstr "Wiedergabeliste löschen" msgid "Delete selected" msgstr "Markiertes löschen" msgid "Delete stream" msgstr "Stream löschen" msgid "Delete the selected item" msgstr "Das ausgewählte Stück löschen" msgid "Delete the selected stream" msgstr "Den ausgewählten Stream löschen" msgid "Delete" msgstr "Löschen" msgid "Descending" msgstr "Absteigend" msgid "Directory and file name" msgstr "Ordner- und Dateiname" msgid "Directory" msgstr "Ordner" msgid "Disc" msgstr "CD" msgid "Disconnect from MPD" msgstr "Verbindung zu MPD trennen" msgid "Edit the selected stream" msgstr "Den ausgewählten Stream editieren" msgid "Edit" msgstr "Editieren" msgid "Everywhere" msgstr "Überall" msgid "Failed to add item to the playlist" msgstr "Stück zur Wiedergabeliste hinzufügen fehlgeschlagen" msgid "Failed to add item to the queue" msgstr "Stück zur Warteschlange hinzufügen fehlgeschlagen" msgid "Failed to add playlist to the queue" msgstr "Wiedergabeliste zur Warteschlange hinzufügen fehlgeschlagen" msgid "Failed to add stream to the queue" msgstr "Stream zur Warteschlange hinzufügen fehlgeschlagen" msgid "Failed to clear the queue" msgstr "Warteliste leeren fehlgeschlagen" msgid "Failed to create a playlist" msgstr "Wiedergabeliste erstellen fehlgeschlagen" msgid "Failed to delete the playlist" msgstr "Wiedergabeliste löschen fehlgeschlagen" msgid "Failed to delete tracks from the queue" msgstr "Stücke von Warteschlange löschen fehlgeschlagen" msgid "Failed to get album information" msgstr "Albuminformationen abrufen fehlgeschlagen" msgid "Failed to get artist information" msgstr "Künsterinformationen abrufen fehlgeschlagen" msgid "Failed to get genre information" msgstr "Genreinformationen abrufen fehlgeschlagen" msgid "Failed to load UI widgets" msgstr "UI widgets laden fehlgeschlagen" msgid "Failed to play the selected track" msgstr "Abspielen von gewähltem Stück fehlgeschlagen" msgid "Failed to rename the playlist" msgstr "Umbenennen der Wiedergabeliste fehlgeschlagen" msgid "Failed to retrieve information from MPD" msgstr "Informationen von MPD abrufen fehlgeschlagen" msgid "Failed to shuffle the queue" msgstr "Mischen der Wiedergabeliste fehlgeschlagen" msgid "Failed to skip to next track" msgstr "Springen zum nächsten Stück fehlgeschlagen" msgid "Failed to skip to previous track" msgstr "Überspringen von vorigem Stück fehlgeschlagen" msgid "Failed to sort the queue" msgstr "Sortierung der Warteschlange fehlgeschlagen" msgid "Failed to stop playback" msgstr "Wiedergabe anhalten fehlgeschlagen" msgid "Failed to toggle consume mode" msgstr "Umschaltung von Verbrauchenmodus fehlgeschlagen" msgid "Failed to toggle playback" msgstr "Umschalten der Wiedergabe fehlgeschlagen" msgid "Failed to toggle random mode" msgstr "Umschaltung des Zufallsmodus fehlgeschlagen" msgid "Failed to toggle repeat/single mode" msgstr "Umschalten des Wiederholungs- und Einzelmodus fehlgeschlagen" msgid "Failed to update the library" msgstr "Aktualisierung der Bibliothek fehlgeschlagen" msgid "File name" msgstr "Dateiname" msgid "File path" msgstr "Dateipfad" msgid "File" msgstr "Datei" msgid "Files" msgstr "Dateien" msgid "Filter the play queue" msgstr "Wiedergabeliste filtern" msgid "Filter…" msgstr "Filter…" msgid "General" msgstr "Allgemein" msgid "Genre" msgstr "Genre" msgid "Genres" msgstr "Genres" msgid "Go a level up" msgstr "Ein Level aufsteigen" msgid "Grouping" msgstr "Gruppierung" msgid "Host:" msgstr "Host:" msgid "Image size:" msgstr "Bildgröße:" msgid "Interface" msgstr "Benutzerschnittstelle" msgid "Jump to the currently played track" msgstr "Zum aktuell gespielten Stück springen" msgid "Keyboard Shortcuts" msgstr "Tastaturkürzel" msgid "Label" msgstr "Label" msgid "Last database update:" msgstr "Letzte Datenbankaktualisierung:" msgid "Length" msgstr "Länge" msgid "Library" msgstr "Bibliothek" msgid "Listening time:" msgstr "Spieldauer:" msgid "more verbose logging" msgstr "detaillierteres loggen" msgid "Move down" msgstr "Nach unten bewegen" msgid "Move the selected column down" msgstr "Die ausgewählte Spalte nach unten verschieben" msgid "Move the selected column up" msgstr "Die ausgewählte Spalte nach oben verschieben" msgid "Move up" msgstr "Nach oben bewegen" msgid "MPD _information…" msgstr "MPD_Information…" msgid "MPD _outputs…" msgstr "MPD_Ausgaben…" msgid "MPD Information" msgstr "MPD Information" msgid "MPD Outputs" msgstr "MPD Ausgaben" msgid "Name" msgstr "Name" msgid "Network:" msgstr "Netzwerk:" msgid "New playlist name" msgstr "Neuer Wiedergabelistenname" msgid "Next track" msgstr "Nächstes Stück" msgid "Next" msgstr "Weiter" msgid "No items" msgstr "Keine Stücke" msgid "No streams" msgstr "Keine Streams" msgid "Not connected to MPD" msgstr "Nicht zu MPD verbunden" msgid "Now playing" msgstr "Aktuell spielt" msgid "Number of albums:" msgstr "Anzahl an Alben:" msgid "Number of artists:" msgstr "Anzahl an Künstlern:" msgid "Number of tracks:" msgstr "Anzahl an Stücken:" msgid "On double click / Enter on a playlist:" msgstr "" "Bei Doppelklick / Bei Enter auf Wiedergabeliste\n" ":" msgid "On double click / Enter on a stream:" msgstr "Bei Doppelklick / Bei Enter auf Stream:" msgid "On double click / Enter on a track:" msgstr "Bei Doppelklick / Bei Enter auf Stück:" msgid "one day" msgstr "ein Tag" msgid "One track" msgstr "Ein Stück" msgid "Open Filter bar" msgstr "Filterleiste öffnen" msgid "Open Search bar" msgstr "Suchleiste öffnen" msgid "Password:" msgstr "Passwort:" msgid "Path" msgstr "Pfad" msgid "Path:" msgstr "Pfad:" msgid "Pause or resume playback" msgstr "Pausieren oder weiterspielen" msgid "Performer" msgstr "Ausgeführt von" msgid "Play selection" msgstr "Auswahl wiedergeben" msgid "Play/Pause" msgstr "Wiedergabe/Pause" msgid "Player title template error, check log" msgstr "Spieler Tietelvorlagefehler, überprüfe log" msgid "Player" msgstr "Spieler" msgid "playing time %s" msgstr "Spieldauer %s" msgid "Playlists" msgstr "Wiedergabelisten" msgid "Port:" msgstr "Port:" msgid "Preferences" msgstr "Einstellungen" msgid "Previous track" msgstr "Voriges Stück" msgid "Previous" msgstr "Voriges" msgid "Queue is empty" msgstr "Warteschlange ist leer" msgid "Queue" msgstr "Warteschlange" msgid "Quit" msgstr "Beenden" msgid "Random" msgstr "Zufällig" msgid "Reconnect now" msgstr "Jetzt wiederverbinden" msgid "Release date: %s" msgstr "Veröffentlichung: %s" msgid "Remove selected track(s) from the queue" msgstr "Augewählte(s) Stück(e) von Warteschlange entfernen" msgid "Rename playlist" msgstr "Wiedergabeliste umbenennen" msgid "Rename the selected item" msgstr "Ausgewähltes Stück umbenennen" msgid "Rename" msgstr "Umbenennen" msgid "Repeat mode" msgstr "Wiederholungsmodus" msgid "Repeat" msgstr "Wiederholen" msgid "Replace playlist" msgstr "Wiedergabeliste ersetzen" msgid "Replace queue with selection" msgstr "Warteschlange mit Auswahl ersetzen" msgid "Replace the queue" msgstr "Warteschlange ersetzen" msgid "Rescan all files" msgstr "Alle Dateien erneut scannen" msgid "Rescan selected item" msgstr "Ausgewählte Objekte erneut scannen" msgid "Save into playlist" msgstr "In Wiedergabeliste speichern" msgid "Save selected tracks only" msgstr "Nur ausgewählte Stücke speichern" msgid "Save the play queue as a playlist" msgstr "Warteschlange als Wiedergabeliste speichern" msgid "Save ▾" msgstr "Speichern ▾" msgid "Search the library" msgstr "Bibliothek durchsuchen" msgid "Search" msgstr "Suche" msgid "Search…" msgstr "Suche…" msgid "Seek backward" msgstr "Rückwärts springen" msgid "Seek forward" msgstr "Vorwärts springen" msgid "Select columns to display in the play queue, and their order." msgstr "Spalten zur Anzeige in der Wiedergabeliste und zum bestimmen der Reihenfolge auswählen." msgid "Show album in Library" msgstr "Album in Bibliothek anzeigen" msgid "Show artist in Library" msgstr "Künstler in Bibliothek anzeigen" msgid "Show for streams" msgstr "Für Streams anzeigen" msgid "Show for tracks" msgstr "Für Stücke anzeigen" msgid "Show genre in Library" msgstr "Genre in Bibliothek anzeigen" msgid "Show toolbar" msgstr "Werkzeugleiste anzeigen" msgid "Shuffle mode" msgstr "Zufallsmodus" msgid "Shuffle the queue" msgstr "Warteschlange zufällig anordnen" msgid "Shuffle" msgstr "Zufällig abspielen" msgid "Sort queue by" msgstr "Sortiere Warteschlange nach" msgid "Sort the play queue" msgstr "Wiedergabeliste sortieren" msgid "Sort ▾" msgstr "Sortieren ▾" msgid "Start playback" msgstr "Wiedergabe starten" msgid "Stop playback" msgstr "Wiedergabe stoppen" msgid "Stop" msgstr "Stop" msgid "Stream name" msgstr "Stream Name" msgid "Stream name:" msgstr "Stream Name:" msgid "Stream URI:" msgstr "Stream URI:" msgid "Streams" msgstr "Streams" msgid "Switch to Library tab" msgstr "Zum Bibliotheksreiter wechseln" msgid "Switch to Queue tab" msgstr "Zum Warteschlangenreiter wechseln" msgid "Switch to Streams tab" msgstr "Zum Streamreiter wechseln" msgid "TCP" msgstr "TCP" msgid "Template error" msgstr "Vorlagenfehler" msgid "Title" msgstr "Titel" msgid "Toggle consume mode" msgstr "Verbrauchenmodus umschalten" msgid "Toggle play/pause" msgstr "Wiedergabe/Pause umschalten" msgid "Toggle random mode" msgstr "Zufallsmodus umschalten" msgid "Toggle repeat mode" msgstr "Wiederholungsmodus umschalten" msgid "Total playing time:" msgstr "Gesamte Spieldauer:" msgid "Track attribute(s) to search" msgstr "Nach Stückeigenschaft(en) suchen" msgid "Track length" msgstr "Stückdauer" msgid "Track number" msgstr "Stücknummer" msgid "Track title template:" msgstr "Stücktitelvorlage:" msgid "Track title" msgstr "Stücktitel" msgid "Track" msgstr "Stück" msgid "Unix socket" msgstr "Unix socket" msgid "Unnamed" msgstr "Unbenannt" msgid "Update" msgstr "Aktualisierung" msgid "Update entire library" msgstr "Gesamte Bibliothek aktualisieren" msgid "Update selected item" msgstr "Ausgewähltes Objekt aktualisieren" msgid "Update the entire music database" msgstr "Die gesamte Musikdatenbank aktualisieren" msgid "Update the entire music database, including unmodified files" msgstr "Die gesamte Musikdatenbank, inklusive unveränderter Dateien" msgid "Update the music library" msgstr "Musikbibliothek aktualisieren" msgid "Update the selected item in music database" msgstr "Das ausgewählte Objekt in Musikdatenbank aktualisieren" msgid "Update the selected item, including unmodified files" msgstr "Das ausgewählte Objekt, inklusive unveränderter Dateien" msgid "Update ▾" msgstr "Aktualisieren ▾" msgid "updating database…" msgstr "Datenbank wird aktualisiert…" msgid "verbose logging" msgstr "Detailliertes logging" msgid "Work" msgstr "Werk" msgid "Written by Dmitry Kann" msgstr "Geschrieben von Dmitry Kann" msgid "Year" msgstr "Jahr" msgid "Ymuse version %s; %s; released %s" msgstr "Ymuse Version %s; %s; veröffentlicht %s" ymuse-0.22/resources/i18n/ja.po000066400000000000000000000366241450727225500163460ustar00rootroot00000000000000# Ymuse MPD client # Copyright (C) 2020 Dmitry Kann # This file is distributed under the same license as the Ymuse package. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-25 12:11+0200\n" "PO-Revision-Date: 2020-06-16 19:16+0900\n" "Language-Team: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 2.3\n" "Last-Translator: Nakaya \n" "Plural-Forms: nplurals=1; plural=0;\n" "Language: ja\n" msgid "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(unknown title)\" }}\n" "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | " "default \"(unknown album)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "File {{ .file | basename }}\n" "from {{ .file | dirname }}\n" "{{- else -}}\n" "(no track)\n" "{{- end -}}\n" msgstr "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(不明なタイトル)\" }}\n" "by {{ .Artist | default \"(不明なアーティスト)\" }} from {{ ." "Album | default \"(不明なアルバム)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "File {{ .file | basename }}\n" "from {{ .file | dirname }}\n" "{{- else -}}\n" "(トラックがありません)\n" "{{- end -}}\n" msgid "#" msgstr "トラック番号" msgid "%d items" msgstr "%d個のアイテム" msgid "%d streams" msgstr "%d個のラジオ配信" msgid "%d track(s) displayed" msgstr "%d個のトラックが表示されています" msgid "%d tracks" msgstr "%d個のトラック" msgid "(leave empty for localhost)" msgstr "(空欄の場合はlocalhostと見なされます)" #, fuzzy msgid "(limited selection of %d items)" msgstr "限定された%d個の選択されたアイテム" msgid "(new playlist)" msgstr "(新しいプレイリスト)" msgid "(Re)connect to MPD" msgstr "MPDサーバに(再)接続する" msgid "(unknown)" msgstr "(不明)" msgid "Library" msgstr "ライブラリ" msgid "MPD connection" msgstr "MPDサーバとの接続" msgid "Player" msgstr "プレーヤ" msgid "Playlists" msgstr "プレイリスト" msgid "Streams" msgstr "ラジオ配信" msgid "_About…" msgstr "ymuseについて" msgid "_Connect to MPD" msgstr "MPDサーバと接続する" msgid "_Disconnect from MPD" msgstr "MPDサーバから切断する" msgid "_Keyboard shortcuts…" msgstr "キーボードショートカット" msgid "_Preferences…" msgstr "設定" msgid "_Quit" msgstr "終了する" msgid "About" msgstr "このソフトウェアについて" msgid "Add a new stream" msgstr "新しいラジオ配信を追加" msgid "Add" msgstr "追加" #, fuzzy msgid "Album (for sorting)" msgstr "アルバム(並び換え用)" #, fuzzy msgid "Album artist (for sorting)" msgstr "アルバムアーティスト(並び換え用)" msgid "Album artist" msgstr "アルバムアーティスト" msgid "Album" msgstr "アルバム" msgid "Albums" msgstr "アルバム" msgid "and" msgstr "" msgid "Append to the queue" msgstr "キューの末尾に追加する" msgid "Append tracks" msgstr "末尾にトラックを追加する" msgid "Application shortcuts" msgstr "アプリケーションショートカット" msgid "Apply" msgstr "適用" msgid "Are you sure you want to delete playlist \"%s\"?" msgstr "プレイリスト\"%s\"を削除してもよろしいですか?" msgid "Are you sure you want to delete stream \"%s\"?" msgstr "配信\"%s\"を削除してもよろしいですか?" #, fuzzy msgid "Artist (for sorting)" msgstr "アーティスト(並び換え用)" msgid "Artist" msgstr "アーティスト" msgid "Artists" msgstr "アーティスト" msgid "Ascending" msgstr "昇順" msgid "Automatically connect on startup" msgstr "起動時に自動で接続する" msgid "Automatically reconnect" msgstr "自動的に再接続する" msgid "Clear the play queue" msgstr "キューを消去する" msgid "Clear" msgstr "キューを消去する" msgid "Columns" msgstr "カラム" msgid "Comment" msgstr "コメント" msgid "Composer" msgstr "作曲者" msgid "Conductor" msgstr "指揮者" msgid "Connecting to MPD…" msgstr "MPDサーバに接続しています……" msgid "Connect to MPD" msgstr "MPDサーバと接続する" msgid "Consume mode" msgstr "コンシュームモード(再生が終わったトラックを都度キューから削除する)" msgid "Consume" msgstr "コンシューム" msgid "Current track time" msgstr "再生中のトラックの再生時間" msgid "Daemon uptime:" msgstr "MPDサーバの起動時間:" msgid "Daemon version:" msgstr "MPDサーバのバージョン:" msgid "days" msgstr "日" msgid "Delete playlist" msgstr "プレイリストを削除する" msgid "Delete selected" msgstr "選択したトラックを削除する" msgid "Delete stream" msgstr "選択したラジオ配信を削除する" msgid "Delete the selected item" msgstr "選択したファイルを削除する" msgid "Delete the selected stream" msgstr "選択したラジオ配信を削除する" msgid "Delete" msgstr "削除" msgid "Descending" msgstr "降順" msgid "Directory and file name" msgstr "ディレクトリとファイル名" msgid "Directory" msgstr "ディレクトリ" msgid "Disc" msgstr "ディスク" msgid "Disconnect from MPD" msgstr "MPDサーバから切断する" msgid "Edit the selected stream" msgstr "選択されたラジオ配信の項目を編集する" msgid "Edit" msgstr "編集" msgid "Everywhere" msgstr "指定なし" msgid "Failed to add item to the queue" msgstr "アイテムをキューへ追加できませんでした" msgid "Failed to add playlist to the queue" msgstr "プレイリストをキューへ追加できませんでした" msgid "Failed to add stream to the queue" msgstr "ラジオ配信をキューへ追加できませんでした" msgid "Failed to clear the queue" msgstr "キューの消去に失敗しました" msgid "Failed to create a playlist" msgstr "プレイリストの作成に失敗しました" msgid "Failed to delete the playlist" msgstr "プレイリストの削除に失敗しました" msgid "Failed to delete tracks from the queue" msgstr "キューからトラックを削除できませんでした" msgid "Failed to play the selected track" msgstr "選択したトラックを再生できませんでした" msgid "Failed to rename the playlist" msgstr "プレイリストの名前を変更できませんでした" msgid "Failed to retrieve information from MPD" msgstr "MPDサーバから情報を取得できませんでした" msgid "Failed to shuffle the queue" msgstr "キューをシャッフルできませんでした" msgid "Failed to skip to next track" msgstr "次のトラックへスキップできませんでした" msgid "Failed to skip to previous track" msgstr "前のトラックへ移動できませんでした" msgid "Failed to sort the queue" msgstr "キューの整列に失敗しました" msgid "Failed to stop playback" msgstr "停止できませんでした" msgid "Failed to toggle consume mode" msgstr "コンシュームモードに切り替えられませんでした" msgid "Failed to toggle playback" msgstr "トラックを再生できませんでした" msgid "Failed to toggle random mode" msgstr "ランダムモードを切り替えられませんでした" msgid "Failed to toggle repeat/single mode" msgstr "リピート/シングルモードの切り替えに失敗" msgid "Failed to update the library" msgstr "音楽ライブラリを更新できませんでした" msgid "File name" msgstr "ファイル名" msgid "File path" msgstr "ファイルパス" msgid "File" msgstr "ファイル" msgid "Files" msgstr "ファイル" msgid "Filter the play queue" msgstr "キュー内を検索する" msgid "Filter…" msgstr "検索" #, fuzzy msgid "General" msgstr "MPDサーバ" msgid "Genre" msgstr "ジャンル" msgid "Genres" msgstr "ジャンル" msgid "Grouping" msgstr "グループ" msgid "Host:" msgstr "ホスト:" msgid "Interface" msgstr "インターフェース" msgid "Jump to the currently played track" msgstr "再生中のトラックに移動する" msgid "Keyboard Shortcuts" msgstr "キーボードショートカット" #, fuzzy msgid "Label" msgstr "ラベル" msgid "Last database update:" msgstr "データベースの最終更新日時:" msgid "Length" msgstr "長さ" msgid "Library" msgstr "ライブラリ" msgid "Listening time:" msgstr "聴いた時間:" msgid "more verbose logging" msgstr "もっと詳細なログ" msgid "Move down" msgstr "下へ移動する" msgid "Move the selected column down" msgstr "選択されたカラムを下へ移動する" msgid "Move the selected column up" msgstr "選択されたカラムを上へ移動する" msgid "Move up" msgstr "上へ移動する" msgid "MPD _information…" msgstr "MPDサーバの情報" msgid "MPD Information" msgstr "MPDサーバの情報" msgid "Name" msgstr "名前" msgid "New playlist name" msgstr "新しいプレイリストの名前" msgid "Next track" msgstr "次のトラック" msgid "Next" msgstr "次" msgid "No items" msgstr "アイテムがありません" msgid "No streams" msgstr "ラジオ配信がありません" msgid "Not connected to MPD" msgstr "MPDサーバと接続していません" msgid "Now playing" msgstr "再生中のトラックに移動する" msgid "Number of albums:" msgstr "アルバムの数:" msgid "Number of artists:" msgstr "アーティストの数:" msgid "Number of tracks:" msgstr "トラックの数:" msgid "On double click / Enter on a playlist:" msgstr "プレイリストが選択された状態でEnterキー/ダブルクリックを押したとき:" msgid "On double click / Enter on a stream:" msgstr "ラジオ配信が選択された状態でEnterキー/ダブルクリックを押したとき:" msgid "On double click / Enter on a track:" msgstr "トラックが選択された状態でEnterキー/ダブルクリックを押したとき:" msgid "one day" msgstr "1日" msgid "One track" msgstr "1個のトラック" msgid "Open Filter bar" msgstr "検索バーを表示する" msgid "Password:" msgstr "パスワード:" #, fuzzy msgid "Path" msgstr "パス" msgid "Pause or resume playback" msgstr "再生/一時停止" msgid "Performer" msgstr "演奏者" msgid "Play selection" msgstr "選択されているトラックを再生する" msgid "Play/Pause" msgstr "再生/一時停止" msgid "Player title template error, check log" msgstr "" "プレーヤタイトルのテンプレートに問題があります。ログを確認してください" msgid "Player" msgstr "プレーヤ" msgid "playing time %s" msgstr "再生時間 %s" msgid "Playlists" msgstr "プレイリスト" msgid "Port:" msgstr "ポート:" msgid "Preferences" msgstr "設定" msgid "Previous track" msgstr "前のトラック" msgid "Previous" msgstr "前" msgid "Queue is empty" msgstr "キューが空です" msgid "Queue" msgstr "キュー" msgid "Quit" msgstr "終了" msgid "Random" msgstr "ランダム" msgid "Reconnect now" msgstr "再接続する" msgid "Release date: %s" msgstr "公開日時: %s" msgid "Remove selected track(s) from the queue" msgstr "選択したトラックをキューから削除する" msgid "Rename playlist" msgstr "プレイリストの名前を変更する" msgid "Rename the selected item" msgstr "選択されたアイテムの名前を変更する" msgid "Rename" msgstr "名前を変更する" msgid "Repeat mode" msgstr "リピートモード" msgid "Repeat" msgstr "リピート" msgid "Replace playlist" msgstr "プレイリストを置き替える" msgid "Replace the queue" msgstr "キューを置き替える" msgid "Rescan all files" msgstr "全てのファイルを再スキャンする" msgid "Rescan selected item" msgstr "選択したファイルを再スキャンする" msgid "Save into playlist" msgstr "プレイリストとして保存する" msgid "Save selected tracks only" msgstr "選択されたトラックのみ保存する" msgid "Save the play queue as a playlist" msgstr "プレイリストとしてキューを保存する" msgid "Save ▾" msgstr "保存 ▾" msgid "Search the library" msgstr "ライブラリを検索する" msgid "Search" msgstr "検索" msgid "Search…" msgstr "検索する" msgid "Select columns to display in the play queue, and their order." msgstr "選択されたカラムがキューに表示され、整列させることができます。" msgid "Shuffle mode" msgstr "シャッフルモード" msgid "Shuffle the queue" msgstr "キューをシャッフルする" msgid "Shuffle" msgstr "シャッフル" msgid "Sort queue by" msgstr "整列する項目" msgid "Sort the play queue" msgstr "キューを整列する" msgid "Sort ▾" msgstr "整列 ▾" msgid "Stop playback" msgstr "停止" msgid "Stop" msgstr "停止する" msgid "Stream name" msgstr "ラジオ配信名" msgid "Stream name:" msgstr "ラジオ配信名:" msgid "Stream URI:" msgstr "配信URI:" msgid "Streams" msgstr "ラジオ配信" msgid "Switch to Library tab" msgstr "ライブラリタブへ切り替え" msgid "Switch to Queue tab" msgstr "キュータブへ切り替え" msgid "Switch to Streams tab" msgstr "ラジオ配信タブへ切り替え" msgid "Template error" msgstr "テンプレートエラー" msgid "Title" msgstr "タイトル" msgid "Toggle consume mode" msgstr "コンシュームモードに切り替える" msgid "Toggle play/pause" msgstr "再生/一時停止を切り替える" msgid "Toggle random mode" msgstr "ランダムモードを切り替える" msgid "Toggle repeat mode" msgstr "リピートモードを切り替える" msgid "Total playing time:" msgstr "合計再生時間:" msgid "Track attribute(s) to search" msgstr "要素を指定する" msgid "Track length" msgstr "トラックの長さ" msgid "Track number" msgstr "トラック番号" msgid "Track title template:" msgstr "トラックタイトルのテンプレート:" msgid "Track title" msgstr "トラックタイトル" msgid "Track" msgstr "トラック" msgid "Unnamed" msgstr "名前のない" msgid "Update" msgstr "更新" msgid "Update entire library" msgstr "音楽ライブラリの全体を更新する" msgid "Update selected item" msgstr "選択したファイルを更新する" msgid "Update the entire music database" msgstr "データベースの全体を更新する" msgid "Update the entire music database, including unmodified files" msgstr "編集されていないファイルを含む、データベースの全体を更新する" msgid "Update the music library" msgstr "音楽ライブラリを更新する" msgid "Update the selected item in music database" msgstr "データベース内の選択されたアイテムを更新する" msgid "Update the selected item, including unmodified files" msgstr "編集されていないファイルを含む、選択されたアイテムを更新する" msgid "Update ▾" msgstr "更新▾" msgid "updating database…" msgstr "データベースを更新しています……" msgid "verbose logging" msgstr "詳細なログ" msgid "Work" msgstr "作品" msgid "Year" msgstr "年代" msgid "Ymuse version %s; %s; released %s" msgstr "Ymuse バージョン %s; %s; %s にリリースされました" ymuse-0.22/resources/i18n/nl.po000066400000000000000000000377111450727225500163630ustar00rootroot00000000000000# Ymuse MPD client # Copyright (C) 2020 Dmitry Kann # This file is distributed under the same license as the Ymuse package. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-25 12:11+0200\n" "PO-Revision-Date: 2021-11-22 19:52+0100\n" "Last-Translator: Dmitry Kann \n" "Language-Team: \n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.0\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" msgid "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(unknown title)\" }}\n" "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | " "default \"(unknown album)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "File {{ .file | basename }}\n" "from {{ .file | dirname }}\n" "{{- else -}}\n" "(no track)\n" "{{- end -}}\n" msgstr "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(onbekende titel)\" }}\n" "door {{ .Artist | default \"(onbekende artiest)\" }} uit {{ .Album " "| default \"(onbekend album)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "Bestand {{ .file | basename }}\n" "in map {{ .file | dirname }}\n" "{{- else -}}\n" "(geen track)\n" "{{- end -}}\n" msgid "#" msgstr "Nr" msgid "%d items" msgstr "%d items" msgid "%d streams" msgstr "%d streams" msgid "%d track(s) displayed" msgstr "%d track(s) zichtbaar" msgid "%d tracks" msgstr "%d tracks" msgid "(leave empty for localhost)" msgstr "(leeglaten voor localhost)" msgid "(limited selection of %d items)" msgstr "(beperkte selectie uit %d items)" msgid "(new playlist)" msgstr "(nieuwe afspeellijst)" msgid "(Re)connect to MPD" msgstr "Verbinding met MPD (opnieuw) maken" msgid "(unknown)" msgstr "(onbekend)" msgid "Library" msgstr "Mediatheek" msgid "MPD connection" msgstr "MPD verbinding" msgid "MPD Information" msgstr "MPD Informatie" msgid "Player" msgstr "Speler" msgid "Playlists" msgstr "Afspeellijsten" msgid "Queue" msgstr "Wachtrij" msgid "Streams" msgstr "Streams" msgid "_About…" msgstr "_Over…" msgid "_Connect to MPD" msgstr "_Verbinden met MPD" msgid "_Disconnect from MPD" msgstr "Verbinding met MPD ver_breken" msgid "_Keyboard shortcuts…" msgstr "_Toetsenbord sneltoetsen…" msgid "_Preferences…" msgstr "I_nstellingen…" msgid "_Quit" msgstr "A_fsluiten" msgid "About" msgstr "Over" msgid "Add a new stream" msgstr "Nieuwe stream toevoegen" msgid "Add" msgstr "Toevoegen" msgid "Add the selected item to a playlist" msgstr "Geselecteerd item aan een afspeellijst toevoegen" msgid "Add to ▾" msgstr "Toevoegen aan ▾" msgid "Add to playlist…" msgstr "Toevoegen aan afspeellijst…" msgid "After the queue is replaced:" msgstr "Nadat de wachtrij vervangen is:" msgid "Album (for sorting)" msgstr "Album (voor het sorteren)" msgid "Album art:" msgstr "Albumhoes:" msgid "Album artist (for sorting)" msgstr "Album artiest (voor het sorteren)" msgid "Album artist" msgstr "Album artiest" msgid "Album" msgstr "Album" msgid "Albums" msgstr "Albums" msgid "and" msgstr "en" msgid "Append selection to queue" msgstr "Geselecteerde items aan wachtrij toevoegen" msgid "Append to the queue" msgstr "Aan de wachtrij toevoegen" msgid "Append tracks" msgstr "Tracks toevoegen" msgid "Application shortcuts" msgstr "Applicatie snelkoppelingen" msgid "Apply" msgstr "Opslaan" msgid "Are you sure you want to delete playlist \"%s\"?" msgstr "Weet je zeker dat je de afspeellijst \"%s\" wilt verwijderen?" msgid "Are you sure you want to delete stream \"%s\"?" msgstr "Weet je zeker dat je de stream \"%s\" wilt verwijderen?" msgid "Artist (for sorting)" msgstr "Artiest (voor het sorteren)" msgid "Artist" msgstr "Artiest" msgid "Artists" msgstr "Artiesten" msgid "Ascending" msgstr "Oplopend" msgid "Automatically connect on startup" msgstr "Automatisch verbinden bij opstarten" msgid "Automatically reconnect" msgstr "Automatisch verbinding herstellen" msgid "Automation" msgstr "Automatisering" msgid "Choose outputs MPD should use for playback." msgstr "Kies de geluidsuitgangen die MPD moet gebruiken voor het afspelen." msgid "Clear the play queue" msgstr "De wachtrij legen" msgid "Clear" msgstr "Legen" msgid "Columns" msgstr "Kolommen" msgid "Comment" msgstr "Commentaar" msgid "Composer" msgstr "Componist" msgid "Conductor" msgstr "Dirigent" msgid "Connecting to MPD…" msgstr "Verbinden met MPD…" msgid "Connect to MPD" msgstr "Verbinden met MPD" msgid "Consume mode" msgstr "Wegwerpmodus" msgid "Consume" msgstr "Wegwerpen" msgid "Current track time" msgstr "Huidige track tijd" msgid "Daemon uptime:" msgstr "Daemon werktijd:" msgid "Daemon version:" msgstr "Daemon versie:" msgid "days" msgstr "dagen" msgid "Decoder plugins" msgstr "Decoder-plug-ins" msgid "Default action (set in Preferences)" msgstr "Default actie (gezet in Instellingen)" msgid "Delete playlist" msgstr "Afspeellijst verwijderen" msgid "Delete selected" msgstr "Geselecteerde items verwijderen" msgid "Delete stream" msgstr "Stream verwijderen" msgid "Delete the selected item" msgstr "Geselecteerd item verwijderen" msgid "Delete the selected stream" msgstr "Geselecteerde stream verwijderen" msgid "Delete" msgstr "Verwijderen" msgid "Descending" msgstr "Aflopend" msgid "Directory and file name" msgstr "Map- en bestandsnaam" msgid "Directory" msgstr "Map" msgid "Disc" msgstr "Disc" msgid "Disconnect from MPD" msgstr "Verbinding met MPD verbreken" msgid "Edit the selected stream" msgstr "Geselecteerde stream bijwerken" msgid "Edit" msgstr "Bijwerken" msgid "Everywhere" msgstr "Overal" msgid "Failed to add item to the playlist" msgstr "Het is niet gelukt om het item aan de afspeellijst toe te voegen" msgid "Failed to add item to the queue" msgstr "Het is niet gelukt om het item aan de wachtrij toe te voegen" msgid "Failed to add playlist to the queue" msgstr "Het is niet gelukt om de afspeellijst aan de wachtrij toe te voegen" msgid "Failed to add stream to the queue" msgstr "Het is niet gelukt om de stream aan de wachtrij toe te voegen" msgid "Failed to clear the queue" msgstr "Het is niet gelukt om de wachtrij te legen" msgid "Failed to create a playlist" msgstr "Het is niet gelukt om een afspeellijst aan te maken" msgid "Failed to delete the playlist" msgstr "Het is niet gelukt om de afspeellijst te verwijderen" msgid "Failed to delete tracks from the queue" msgstr "Het is niet gelukt om tracks uit de wachtrij te halen" msgid "Failed to get album information" msgstr "Het is niet gelukt om album gegevens op te halen" msgid "Failed to get artist information" msgstr "Het is niet gelukt om artiest gegevens op te halen" msgid "Failed to get genre information" msgstr "Het is niet gelukt om genre gegevens op te halen" msgid "Failed to load UI widgets" msgstr "Het is niet gelukt om UI-widgets te laden" msgid "Failed to play the selected track" msgstr "Het is niet gelukt om de geselecteerde track af te spelen" msgid "Failed to rename the playlist" msgstr "Het is niet gelukt om de afspeellijst te hernoemen" msgid "Failed to retrieve information from MPD" msgstr "Het is niet gelukt om gegevens bij MPD op te halen" msgid "Failed to shuffle the queue" msgstr "Het is niet gelukt om de wachtrij te schudden" msgid "Failed to skip to next track" msgstr "Het is niet gelukt om naar de volgende track te schakelen" msgid "Failed to skip to previous track" msgstr "Het is niet gelukt om naar de vorige track te schakelen" msgid "Failed to sort the queue" msgstr "Het is niet gelukt om de wachtrij te sorteren" msgid "Failed to stop playback" msgstr "Het is niet gelukt om het afspelen te stoppen" msgid "Failed to toggle consume mode" msgstr "Het is niet gelukt om de wegwerpmodus om te schakelen" msgid "Failed to toggle playback" msgstr "Het is niet gelukt om het afspelen om te schakelen" msgid "Failed to toggle random mode" msgstr "Het is niet gelukt om de willekeurigmodus om te schakelen" msgid "Failed to toggle repeat/single mode" msgstr "Het is niet gelukt om de herhaal-/single-modus om te schakelen" msgid "Failed to update the library" msgstr "Het is niet gelukt om de mediatheek bij te werken" msgid "File name" msgstr "Bestandsnaam" msgid "File path" msgstr "Bestandspad" msgid "File" msgstr "Bestand" msgid "Files" msgstr "Bestanden" msgid "Filter the play queue" msgstr "De wachtrij filteren" msgid "Filter…" msgstr "Filter…" msgid "General" msgstr "Algemeen" msgid "Genre" msgstr "Genre" msgid "Genres" msgstr "Genres" msgid "Go a level up" msgstr "Naar niveau omhoog" msgid "Grouping" msgstr "Groepering" msgid "Host:" msgstr "Host:" msgid "Image size:" msgstr "Beeldgrootte:" msgid "Interface" msgstr "Interface" msgid "Jump to the currently played track" msgstr "Naar het momenteel afgespeelde item gaan" msgid "Keyboard Shortcuts" msgstr "Toetsenbord sneltoetsen" msgid "Label" msgstr "Label" msgid "Last database update:" msgstr "Laatste update van database:" msgid "Length" msgstr "Lengte" msgid "Library" msgstr "Mediatheek" msgid "Listening time:" msgstr "Luisterduur:" msgid "more verbose logging" msgstr "gedetailleerdere logging" msgid "Move down" msgstr "Omlaag" msgid "Move the selected column down" msgstr "Geselecteerde kolom omlaag verschuiven" msgid "Move the selected column up" msgstr "Geselecteerde kolom omhoog verschuiven" msgid "Move up" msgstr "Omhoog" msgid "MPD _information…" msgstr "MPD _informatie…" msgid "MPD _outputs…" msgstr "MPD _uitgangen…" msgid "MPD Information" msgstr "MPD Informatie" msgid "MPD Outputs" msgstr "MPD Geluidsuitgangen" msgid "Name" msgstr "Naam" msgid "Network:" msgstr "Netwerk:" msgid "New playlist name" msgstr "Nieuwe afspeellijst naam" msgid "Next track" msgstr "Volgende track" msgid "Next" msgstr "Volgend item" msgid "No items" msgstr "Geen items" msgid "No streams" msgstr "Geen streams" msgid "Not connected to MPD" msgstr "Geen verbinding met MPD" msgid "Now playing" msgstr "Nu aan het afspelen" msgid "Number of albums:" msgstr "Aantal albums:" msgid "Number of artists:" msgstr "Aantal artiesten:" msgid "Number of tracks:" msgstr "Aantal tracks:" msgid "On double click / Enter on a playlist:" msgstr "Bij dubbelklik / Enter op een afspeellijst:" msgid "On double click / Enter on a stream:" msgstr "Bij dubbelklik / Enter op een stream:" msgid "On double click / Enter on a track:" msgstr "Bij dubbelklik / Enter op een track:" msgid "one day" msgstr "één dag" msgid "One track" msgstr "Eén track" msgid "Open Filter bar" msgstr "Filterbalk openen" msgid "Open Search bar" msgstr "Zoekbalk openen" msgid "Password:" msgstr "Wachtwoord:" msgid "Path" msgstr "Pad" msgid "Path:" msgstr "Pad:" msgid "Pause or resume playback" msgstr "Afspelen pauzeren of hervatten" msgid "Performer" msgstr "Uitvoerder" msgid "Play selection" msgstr "Geselecteerde items afspelen" msgid "Play/Pause" msgstr "Afspelen/Pauzeren" msgid "Player title template error, check log" msgstr "Tracktitel template fout, check de log" msgid "Player" msgstr "Speler" msgid "playing time %s" msgstr "afspeeltijd %s" msgid "Playlists" msgstr "Afspeellijsten" msgid "Port:" msgstr "Port:" msgid "Preferences" msgstr "Instellingen" msgid "Previous track" msgstr "Vorige track" msgid "Previous" msgstr "Vorig item" msgid "Queue is empty" msgstr "Wachtrij is leeg" msgid "Queue" msgstr "Wachtrij" msgid "Quit" msgstr "Afsluiten" msgid "Random" msgstr "Willekeurig" msgid "Reconnect now" msgstr "Nu opnieuw verbinden" msgid "Release date: %s" msgstr "Releasedatum: %s" msgid "Remove selected track(s) from the queue" msgstr "Geselecteerde track(s) uit de wachtrij verwijderen" msgid "Rename playlist" msgstr "Afspeellijst hernoemen" msgid "Rename the selected item" msgstr "Geselecteerd item hernoemen" msgid "Rename" msgstr "Hernoemen" msgid "Repeat mode" msgstr "Herhaalmodus" msgid "Repeat" msgstr "Herhalen" msgid "Replace playlist" msgstr "Afspeellijst vervangen" msgid "Replace queue with selection" msgstr "Wachtrij door geselecteerde items vervangen" msgid "Replace the queue" msgstr "De wachtrij vervangen" msgid "Rescan all files" msgstr "Alles opnieuw scannen" msgid "Rescan selected item" msgstr "Geselecteerd item opnieuw scannen" msgid "Save into playlist" msgstr "Opslaan in afspeellijst" msgid "Save selected tracks only" msgstr "Alleen geselecteerde tracks" msgid "Save the play queue as a playlist" msgstr "De wachtrij als een afspeellijst opslaan" msgid "Save ▾" msgstr "Opslaan ▾" msgid "Search the library" msgstr "De mediatheek doorzoeken" msgid "Search" msgstr "Zoeken" msgid "Search…" msgstr "Doorzoeken…" msgid "Seek backward" msgstr "Terugspoelen" msgid "Seek forward" msgstr "Vooruitspoelen" msgid "Select columns to display in the play queue, and their order." msgstr "Selecteer kolommen om weer te geven in de wachtrij, en hun volgorde." msgid "Show album in Library" msgstr "Album in Mediatheek tonen" msgid "Show artist in Library" msgstr "Artiest in Mediatheek tonen" msgid "Show for streams" msgstr "Tonen voor streams" msgid "Show for tracks" msgstr "Tonen voor tracks" msgid "Show genre in Library" msgstr "Genre in Mediatheek tonen" msgid "Show toolbar" msgstr "Werkbalk tonen" msgid "Shuffle mode" msgstr "Willekeurigmodus" msgid "Shuffle the queue" msgstr "Wachtrij schudden" msgid "Shuffle" msgstr "Schudden" msgid "Sort queue by" msgstr "Wachtrij sorteren op" msgid "Sort the play queue" msgstr "De wachtrij sorteren" msgid "Sort ▾" msgstr "Sorteren ▾" msgid "Start playback" msgstr "Afspelen starten" msgid "Stop playback" msgstr "Afspelen stoppen" msgid "Stop" msgstr "Stoppen" msgid "Stream name" msgstr "Streamnaam" msgid "Stream name:" msgstr "Streamnaam:" msgid "Stream URI:" msgstr "Stream URI:" msgid "Streams" msgstr "Streams" msgid "Switch to Library tab" msgstr "Naar het Mediatheek tabblad" msgid "Switch to Queue tab" msgstr "Naar het Wachtrij tabblad" msgid "Switch to Streams tab" msgstr "Naar het Streams tabblad" msgid "TCP" msgstr "TCP" msgid "Template error" msgstr "Sjabloonfout" msgid "Title" msgstr "Titel" msgid "Toggle consume mode" msgstr "Schakel de wegwerpmodus aan/uit" msgid "Toggle play/pause" msgstr "Afspelen/pauzeren schakelen" msgid "Toggle random mode" msgstr "Schakel de willekeurigmodus aan/uit" msgid "Toggle repeat mode" msgstr "Schakel de herhaalmodus aan/uit" msgid "Total playing time:" msgstr "Totale afspeelduur:" msgid "Track attribute(s) to search" msgstr "Trackeigenschappen om te zoeken" msgid "Track length" msgstr "Tracklengte" msgid "Track number" msgstr "Tracknummer" msgid "Track title template:" msgstr "Tracktitel sjabloon:" msgid "Track title" msgstr "Tracktitel" msgid "Track" msgstr "Track" msgid "Unix socket" msgstr "Unix socket" msgid "Unnamed" msgstr "Zonder naam" msgid "Update" msgstr "Bijwerken" msgid "Update entire library" msgstr "Gehele mediatheek bijwerken" msgid "Update selected item" msgstr "Geselecteerd item bijwerken" msgid "Update the entire music database" msgstr "De gehele mediatheek bijwerken" msgid "Update the entire music database, including unmodified files" msgstr "De gehele mediatheek bijwerken, inclusief ongewijzigde bestanden" msgid "Update the music library" msgstr "De mediatheek bijwerken" msgid "Update the selected item in music database" msgstr "De geselecteerde items in de mediatheek bijwerken" msgid "Update the selected item, including unmodified files" msgstr "De geselecteerde item bijwerken, inclusief ongewijzigde bestanden" msgid "Update ▾" msgstr "Bijwerken ▾" msgid "updating database…" msgstr "database aan het bijwerken…" msgid "verbose logging" msgstr "gedetailleerde logging" msgid "Work" msgstr "Kunstwerk" msgid "Written by Dmitry Kann" msgstr "Geschreven door Dmitry Kann" msgid "Year" msgstr "Jaar" msgid "Ymuse version %s; %s; released %s" msgstr "Ymuse versie %s; %s; gereleased %s" ymuse-0.22/resources/i18n/ru.po000066400000000000000000000506231450727225500163750ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: Ymuse\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-25 12:11+0200\n" "PO-Revision-Date: 2021-11-22 19:50+0100\n" "Last-Translator: Dmitry Kann \n" "Language-Team: \n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.0\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" "%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" "X-Poedit-Basepath: ..\n" "X-Poedit-SearchPath-0: .\n" msgid "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(unknown title)\" }}\n" "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | " "default \"(unknown album)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "File {{ .file | basename }}\n" "from {{ .file | dirname }}\n" "{{- else -}}\n" "(no track)\n" "{{- end -}}\n" msgstr "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(неизвестное название)\" }}\n" "Исполняет {{ .Artist | default \"(неизвестно)\" }} из альбома {{ ." "Album | default \"(неизвестно)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "Файл {{ .file | basename }}\n" "из папки {{ .file | dirname }}\n" "{{- else -}}\n" "(нет трека)\n" "{{- end -}}\n" msgid "#" msgstr "№" msgid "%d items" msgstr "%d элементов" msgid "%d streams" msgstr "%d потоков" msgid "%d track(s) displayed" msgstr "Отображается %d треков" msgid "%d tracks" msgstr "%d треков" msgid "(leave empty for localhost)" msgstr "(оставить пустым для localhost)" msgid "(limited selection of %d items)" msgstr "(ограниченная выборка из %d элементов)" msgid "(new playlist)" msgstr "(новый плейлист)" msgid "(Re)connect to MPD" msgstr "(Пере)подключиться к MPD" msgid "(unknown)" msgstr "(неизвестно)" msgid "Library" msgstr "Библиотека" msgid "MPD connection" msgstr "Подключение к MPD" msgid "MPD Information" msgstr "Информация об MPD" msgid "Player" msgstr "Плеер" msgid "Playlists" msgstr "Плейлисты" msgid "Queue" msgstr "Очередь" msgid "Streams" msgstr "Потоки" msgid "_About…" msgstr "_О программе…" msgid "_Connect to MPD" msgstr "_Подключиться к MPD" msgid "_Disconnect from MPD" msgstr "Откл_ючиться от MPD" msgid "_Keyboard shortcuts…" msgstr "Горячие _клавиши…" msgid "_Preferences…" msgstr "_Настройки…" msgid "_Quit" msgstr "В_ыход" msgid "About" msgstr "О программе" msgid "Add a new stream" msgstr "Добавить новый поток" msgid "Add" msgstr "Добавить" msgid "Add the selected item to a playlist" msgstr "Добавить выделенный элемент к плейлисту" msgid "Add to ▾" msgstr "Добавить к ▾" msgid "Add to playlist…" msgstr "Добавить к плейлисту…" msgid "After the queue is replaced:" msgstr "После замены очереди:" msgid "Album (for sorting)" msgstr "Альбом (для сортировки)" msgid "Album art:" msgstr "Обложка альбома:" msgid "Album artist (for sorting)" msgstr "Исполнитель альбома (для сортировки)" msgid "Album artist" msgstr "Исполнитель альбома" msgid "Album" msgstr "Альбом" msgid "Albums" msgstr "Альбомы" msgid "and" msgstr "и" msgid "Append selection to queue" msgstr "Добавить выделенное к очереди" msgid "Append to the queue" msgstr "Добавить в конец очереди" msgid "Append tracks" msgstr "Добавить треки" msgid "Application shortcuts" msgstr "Горячие клавиши" msgid "Apply" msgstr "Применить" msgid "Are you sure you want to delete playlist \"%s\"?" msgstr "Вы уверены, что хотите удалить плейлист «%s»?" msgid "Are you sure you want to delete stream \"%s\"?" msgstr "Вы уверены, что хотите удалить поток «%s»?" msgid "Artist (for sorting)" msgstr "Исполнитель (для сортировки)" msgid "Artist" msgstr "Исполнитель" msgid "Artists" msgstr "Исполнители" msgid "Ascending" msgstr "По возрастанию" msgid "Automatically connect on startup" msgstr "Автоматически подключаться при запуске" msgid "Automatically reconnect" msgstr "Автоматически восстанавливать подключение" msgid "Automation" msgstr "Автоматизация" msgid "Choose outputs MPD should use for playback." msgstr "Выберите выходы, через которые MPD должен воспроизводить звук." msgid "Clear the play queue" msgstr "Стереть очередь" msgid "Clear" msgstr "Очистить" msgid "Columns" msgstr "Столбцы" msgid "Comment" msgstr "Комментарий" msgid "Composer" msgstr "Композитор" msgid "Conductor" msgstr "Дирижёр" msgid "Connecting to MPD…" msgstr "Подключение к MPD…" msgid "Connect to MPD" msgstr "Подключиться к MPD" msgid "Consume mode" msgstr "Режим удаления проигранного" msgid "Consume" msgstr "Удалять проигранное" msgid "Current track time" msgstr "Время текущего трека" msgid "Daemon uptime:" msgstr "Демон работает:" msgid "Daemon version:" msgstr "Версия демона:" msgid "days" msgstr "дней" msgid "Decoder plugins" msgstr "Плагины для декодирования" msgid "Default action (set in Preferences)" msgstr "Действие по умолчанию (см. в Настройках)" msgid "Delete playlist" msgstr "Удалить плейлист" msgid "Delete selected" msgstr "Удалить выделенное" msgid "Delete stream" msgstr "Удалить поток" msgid "Delete the selected item" msgstr "Удалить выделенный элемент" msgid "Delete the selected stream" msgstr "Удалить выделенный поток" msgid "Delete" msgstr "Удалить" msgid "Descending" msgstr "По убыванию" msgid "Directory and file name" msgstr "Папка и имя файла" msgid "Directory" msgstr "Папка" msgid "Disc" msgstr "Диск" msgid "Disconnect from MPD" msgstr "Отключиться от MPD" msgid "Edit the selected stream" msgstr "Редактировать выделенный поток" msgid "Edit" msgstr "Редактировать" msgid "Everywhere" msgstr "Везде" msgid "Failed to add item to the playlist" msgstr "Не удалось добавить элемент к плейлисту" msgid "Failed to add item to the queue" msgstr "Не удалось добавить элемент к очереди" msgid "Failed to add playlist to the queue" msgstr "Не удалось добавить плейлист к очереди" msgid "Failed to add stream to the queue" msgstr "Не удалось добавить поток к очереди" msgid "Failed to clear the queue" msgstr "Не удалось очистить очередь" msgid "Failed to create a playlist" msgstr "Не удалось создать плейлист" msgid "Failed to delete the playlist" msgstr "Не удалось удалить плейлист" msgid "Failed to delete tracks from the queue" msgstr "Не удалось удалить треки из очереди" msgid "Failed to get album information" msgstr "Не удалось получить информацию об альбоме" msgid "Failed to get artist information" msgstr "Не удалось получить информацию об исполнителе" msgid "Failed to get genre information" msgstr "Не удалось получить информацию о жанре" msgid "Failed to load UI widgets" msgstr "Не удалось загрузить виджеты графического интерфейса" msgid "Failed to play the selected track" msgstr "Не удалось воспроизвести выделенный трек" msgid "Failed to rename the playlist" msgstr "Не удалось переименовать плейлист" msgid "Failed to retrieve information from MPD" msgstr "Не удалось получить информацию от MPD" msgid "Failed to shuffle the queue" msgstr "Не удалось перемешать очередь" msgid "Failed to skip to next track" msgstr "Не удалось перейти к следующему треку" msgid "Failed to skip to previous track" msgstr "Не удалось перейти к предыдущему треку" msgid "Failed to sort the queue" msgstr "Не удалось сортировать очередь" msgid "Failed to stop playback" msgstr "Не удалось остановить воспроизведение" msgid "Failed to toggle consume mode" msgstr "Не удалось переключить режим удаления проигранного" msgid "Failed to toggle playback" msgstr "Не удалось приостановить или возобновить воспроизведение" msgid "Failed to toggle random mode" msgstr "Не удалось переключить режим случайного порядка" msgid "Failed to toggle repeat/single mode" msgstr "Не удалось переключить режим повтора/одного трека" msgid "Failed to update the library" msgstr "Не удалось обновить библиотеку" msgid "File name" msgstr "Имя файла" msgid "File path" msgstr "Путь к файлу" msgid "File" msgstr "Файл" msgid "Files" msgstr "Файлы" msgid "Filter the play queue" msgstr "Фильтрация очереди" msgid "Filter…" msgstr "Фильтр…" msgid "General" msgstr "Общие" msgid "Genre" msgstr "Жанр" msgid "Genres" msgstr "Жанры" msgid "Go a level up" msgstr "Перейти на уровень выше" msgid "Grouping" msgstr "Группировка" msgid "Host:" msgstr "Хост:" msgid "Image size:" msgstr "Размер изображения:" msgid "Interface" msgstr "Интерфейс" msgid "Jump to the currently played track" msgstr "Перейти к текущему воспроизводимому треку" msgid "Keyboard Shortcuts" msgstr "Сочетания клавиш" msgid "Label" msgstr "Лейбл" msgid "Last database update:" msgstr "Последнее обновление БД:" msgid "Length" msgstr "Длина" msgid "Library" msgstr "Библиотека" msgid "Listening time:" msgstr "Время прослушивания:" msgid "more verbose logging" msgstr "ещё более подробное журналирование" msgid "Move down" msgstr "Передвинуть вниз" msgid "Move the selected column down" msgstr "Передвинуть выделенный столбец вниз" msgid "Move the selected column up" msgstr "Передвинуть выделенный столбец вверх" msgid "Move up" msgstr "Передвинуть вверх" msgid "MPD _information…" msgstr "Информация об _MPD…" msgid "MPD _outputs…" msgstr "Выходы _звука MPD…" msgid "MPD Information" msgstr "Информация об MPD" msgid "MPD Outputs" msgstr "Звуковые выходы MPD" msgid "Name" msgstr "Наименование" msgid "Network:" msgstr "Сеть:" msgid "New playlist name" msgstr "Название нового плейлиста" msgid "Next track" msgstr "Следующий трек" msgid "Next" msgstr "Следующий" msgid "No items" msgstr "Элементы отсутствуют" msgid "No streams" msgstr "Нет потоков" msgid "Not connected to MPD" msgstr "" "Нет подключения к M\n" "PD" msgid "Now playing" msgstr "Сейчас играет" msgid "Number of albums:" msgstr "Количество альбомов:" msgid "Number of artists:" msgstr "Количество исполнителей:" msgid "Number of tracks:" msgstr "Количество треков:" msgid "On double click / Enter on a playlist:" msgstr "По двойному клику или Enter по плейлисту:" msgid "On double click / Enter on a stream:" msgstr "По двойному клику или Enter по потоку:" msgid "On double click / Enter on a track:" msgstr "По двойному клику или Enter по треку:" msgid "one day" msgstr "один день" msgid "One track" msgstr "Один трек" msgid "Open Filter bar" msgstr "Открыть панель фильтра" msgid "Open Search bar" msgstr "Открыть панель поиска" msgid "Password:" msgstr "Пароль:" msgid "Path" msgstr "Путь" msgid "Path:" msgstr "Путь:" msgid "Pause or resume playback" msgstr "Приостановить или возобновить воспроизведение" msgid "Performer" msgstr "Исполнитель произведения" msgid "Play selection" msgstr "Воспроизвести выделенное" msgid "Play/Pause" msgstr "Играть/Пауза" msgid "Player title template error, check log" msgstr "Ошибка в шаблоне заголовка, проверьте журнал" msgid "Player" msgstr "Плеер" msgid "playing time %s" msgstr "время воспроизведения %s" msgid "Playlists" msgstr "Плейлисты" msgid "Port:" msgstr "Порт:" msgid "Preferences" msgstr "Настройки" msgid "Previous track" msgstr "Предыдущий трек" msgid "Previous" msgstr "Предыдущий" msgid "Queue is empty" msgstr "Очередь пуста" msgid "Queue" msgstr "Очередь" msgid "Quit" msgstr "Выход" msgid "Random" msgstr "Вразброс" msgid "Reconnect now" msgstr "Переподключиться сейчас" msgid "Release date: %s" msgstr "Дата выпуска: %s" msgid "Remove selected track(s) from the queue" msgstr "Удалить отмеченное из очереди" msgid "Rename playlist" msgstr "Переименовать плейлист" msgid "Rename the selected item" msgstr "Переименовать выделенный элемент" msgid "Rename" msgstr "Переименовать" msgid "Repeat mode" msgstr "Режим повтора" msgid "Repeat" msgstr "Повтор" msgid "Replace playlist" msgstr "Заменить плейлист" msgid "Replace queue with selection" msgstr "Заменить очередь на выделенное" msgid "Replace the queue" msgstr "Заменить очередь" msgid "Rescan all files" msgstr "Принудительно обновить всё" msgid "Rescan selected item" msgstr "Принудительно обновить выделенное" msgid "Save into playlist" msgstr "Сохранить в плейлист" msgid "Save selected tracks only" msgstr "Только выделенные треки" msgid "Save the play queue as a playlist" msgstr "Сохранить очередь в плейлист" msgid "Save ▾" msgstr "Сохранить ▾" msgid "Search the library" msgstr "Поиск по библиотеке" msgid "Search" msgstr "Поиск" msgid "Search…" msgstr "Поиск…" msgid "Seek backward" msgstr "Перемотать назад" msgid "Seek forward" msgstr "Перемотать вперёд" msgid "Select columns to display in the play queue, and their order." msgstr "Выберите столбцы для отображения и их порядок." msgid "Show album in Library" msgstr "Показать альбом в библиотеке" msgid "Show artist in Library" msgstr "Показать исполнителя в библиотеке" msgid "Show for streams" msgstr "Показывать для потоков" msgid "Show for tracks" msgstr "Показывать для треков" msgid "Show genre in Library" msgstr "Показать жанр в библиотеке" msgid "Show toolbar" msgstr "Отображать панель инструментов" msgid "Shuffle mode" msgstr "Режим случайного порядка" msgid "Shuffle the queue" msgstr "Перемешать очередь" msgid "Shuffle" msgstr "Перемешать" msgid "Sort queue by" msgstr "Сортировать очередь по" msgid "Sort the play queue" msgstr "Сортировка очереди" msgid "Sort ▾" msgstr "Сортировка ▾" msgid "Start playback" msgstr "Запустить воспроизведение" msgid "Stop playback" msgstr "Остановить воспроизведение" msgid "Stop" msgstr "Стоп" msgid "Stream name" msgstr "Название потока" msgid "Stream name:" msgstr "Название потока:" msgid "Stream URI:" msgstr "URI потока:" msgid "Streams" msgstr "Потоки" msgid "Switch to Library tab" msgstr "Переключиться во вкладку «Библиотека»" msgid "Switch to Queue tab" msgstr "Переключиться во вкладку «Очередь»" msgid "Switch to Streams tab" msgstr "Переключиться во вкладку «Потоки»" msgid "TCP" msgstr "TCP" msgid "Template error" msgstr "Ошибка в шаблоне" msgid "Title" msgstr "Название" msgid "Toggle consume mode" msgstr "Переключить режим удаления проигранного" msgid "Toggle play/pause" msgstr "Воспроизведение или пауза" msgid "Toggle random mode" msgstr "Переключить режим случайного порядка" msgid "Toggle repeat mode" msgstr "Переключить режим повтора" msgid "Total playing time:" msgstr "Общее время воспроизведения:" msgid "Track attribute(s) to search" msgstr "Искать в атрибутах трека" msgid "Track length" msgstr "Длительность трека" msgid "Track number" msgstr "Номер трека" msgid "Track title template:" msgstr "Шаблон заголовка трека:" msgid "Track title" msgstr "Название трека" msgid "Track" msgstr "Трек" msgid "Unix socket" msgstr "Unix-сокет" msgid "Unnamed" msgstr "Безымянный" msgid "Update" msgstr "Обновить" msgid "Update entire library" msgstr "Обновить всю библиотеку" msgid "Update selected item" msgstr "Обновить выделенное в библиотеке" msgid "Update the entire music database" msgstr "Обновить всю музыкальную библиотеку" msgid "Update the entire music database, including unmodified files" msgstr "Обновить всю музыкальную библиотеку, включая неизменённые файлы" msgid "Update the music library" msgstr "Обновить библиотеку" msgid "Update the selected item in music database" msgstr "Обновить выделенный элемент в музыкальной библиотеке" msgid "Update the selected item, including unmodified files" msgstr "" "Обновить выделенный элемент в музыкальной библиотеке, включая неизменённые " "файлы" msgid "Update ▾" msgstr "Обновить ▾" msgid "updating database…" msgstr "обновление базы данных…" msgid "verbose logging" msgstr "подробное журналирование" msgid "Work" msgstr "Произведение искусства" msgid "Written by Dmitry Kann" msgstr "Автор: Дмитрий Канн" msgid "Year" msgstr "Год" msgid "Ymuse version %s; %s; released %s" msgstr "Ymuse версии %s; %s; выпущен %s" ymuse-0.22/resources/i18n/ymuse.pot000066400000000000000000000240421450727225500172710ustar00rootroot00000000000000# Ymuse MPD client # Copyright (C) 2020-2021 Dmitry Kann # This file is distributed under the same license as the Ymuse package. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-05-25 12:11+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" msgid "" "{{- if or .Title .Album | or .Artist -}}\n" "{{ .Title | default \"(unknown title)\" }}\n" "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | default \"(unknown album)\" }}\n" "{{- else if .Name -}}\n" "{{ .Name }}\n" "{{- else if .file -}}\n" "File {{ .file | basename }}\n" "from {{ .file | dirname }}\n" "{{- else -}}\n" "(no track)\n" "{{- end -}}\n" msgstr "" msgid "#" msgstr "" msgid "%d items" msgstr "" msgid "%d streams" msgstr "" msgid "%d track(s) displayed" msgstr "" msgid "%d tracks" msgstr "" msgid "(leave empty for localhost)" msgstr "" msgid "(limited selection of %d items)" msgstr "" msgid "(new playlist)" msgstr "" msgid "(Re)connect to MPD" msgstr "" msgid "(unknown)" msgstr "" msgid "Library" msgstr "" msgid "MPD connection" msgstr "" msgid "MPD Information" msgstr "" msgid "Player" msgstr "" msgid "Playlists" msgstr "" msgid "Queue" msgstr "" msgid "Streams" msgstr "" msgid "_About…" msgstr "" msgid "_Connect to MPD" msgstr "" msgid "_Disconnect from MPD" msgstr "" msgid "_Keyboard shortcuts…" msgstr "" msgid "_Preferences…" msgstr "" msgid "_Quit" msgstr "" msgid "About" msgstr "" msgid "Add a new stream" msgstr "" msgid "Add" msgstr "" msgid "Add the selected item to a playlist" msgstr "" msgid "Add to ▾" msgstr "" msgid "Add to playlist…" msgstr "" msgid "After the queue is replaced:" msgstr "" msgid "Album (for sorting)" msgstr "" msgid "Album art:" msgstr "" msgid "Album artist (for sorting)" msgstr "" msgid "Album artist" msgstr "" msgid "Album" msgstr "" msgid "Albums" msgstr "" msgid "and" msgstr "" msgid "Append selection to queue" msgstr "" msgid "Append to the queue" msgstr "" msgid "Append tracks" msgstr "" msgid "Application shortcuts" msgstr "" msgid "Apply" msgstr "" msgid "Are you sure you want to delete playlist \"%s\"?" msgstr "" msgid "Are you sure you want to delete stream \"%s\"?" msgstr "" msgid "Artist (for sorting)" msgstr "" msgid "Artist" msgstr "" msgid "Artists" msgstr "" msgid "Ascending" msgstr "" msgid "Automatically connect on startup" msgstr "" msgid "Automatically reconnect" msgstr "" msgid "Automation" msgstr "" msgid "Choose outputs MPD should use for playback." msgstr "" msgid "Clear the play queue" msgstr "" msgid "Clear" msgstr "" msgid "Columns" msgstr "" msgid "Comment" msgstr "" msgid "Composer" msgstr "" msgid "Conductor" msgstr "" msgid "Connecting to MPD…" msgstr "" msgid "Connect to MPD" msgstr "" msgid "Consume mode" msgstr "" msgid "Consume" msgstr "" msgid "Current track time" msgstr "" msgid "Daemon uptime:" msgstr "" msgid "Daemon version:" msgstr "" msgid "days" msgstr "" msgid "Decoder plugins" msgstr "" msgid "Default action (set in Preferences)" msgstr "" msgid "Delete playlist" msgstr "" msgid "Delete selected" msgstr "" msgid "Delete stream" msgstr "" msgid "Delete the selected item" msgstr "" msgid "Delete the selected stream" msgstr "" msgid "Delete" msgstr "" msgid "Descending" msgstr "" msgid "Directory and file name" msgstr "" msgid "Directory" msgstr "" msgid "Disc" msgstr "" msgid "Disconnect from MPD" msgstr "" msgid "Edit the selected stream" msgstr "" msgid "Edit" msgstr "" msgid "Everywhere" msgstr "" msgid "Failed to add item to the playlist" msgstr "" msgid "Failed to add item to the queue" msgstr "" msgid "Failed to add playlist to the queue" msgstr "" msgid "Failed to add stream to the queue" msgstr "" msgid "Failed to clear the queue" msgstr "" msgid "Failed to create a playlist" msgstr "" msgid "Failed to delete the playlist" msgstr "" msgid "Failed to delete tracks from the queue" msgstr "" msgid "Failed to get album information" msgstr "" msgid "Failed to get artist information" msgstr "" msgid "Failed to get genre information" msgstr "" msgid "Failed to load UI widgets" msgstr "" msgid "Failed to play the selected track" msgstr "" msgid "Failed to rename the playlist" msgstr "" msgid "Failed to retrieve information from MPD" msgstr "" msgid "Failed to shuffle the queue" msgstr "" msgid "Failed to skip to next track" msgstr "" msgid "Failed to skip to previous track" msgstr "" msgid "Failed to sort the queue" msgstr "" msgid "Failed to stop playback" msgstr "" msgid "Failed to toggle consume mode" msgstr "" msgid "Failed to toggle playback" msgstr "" msgid "Failed to toggle random mode" msgstr "" msgid "Failed to toggle repeat/single mode" msgstr "" msgid "Failed to update the library" msgstr "" msgid "File name" msgstr "" msgid "File path" msgstr "" msgid "File" msgstr "" msgid "Files" msgstr "" msgid "Filter the play queue" msgstr "" msgid "Filter…" msgstr "" msgid "General" msgstr "" msgid "Genre" msgstr "" msgid "Genres" msgstr "" msgid "Go a level up" msgstr "" msgid "Grouping" msgstr "" msgid "Host:" msgstr "" msgid "Image size:" msgstr "" msgid "Interface" msgstr "" msgid "Jump to the currently played track" msgstr "" msgid "Keyboard Shortcuts" msgstr "" msgid "Label" msgstr "" msgid "Last database update:" msgstr "" msgid "Length" msgstr "" msgid "Library" msgstr "" msgid "Listening time:" msgstr "" msgid "more verbose logging" msgstr "" msgid "Move down" msgstr "" msgid "Move the selected column down" msgstr "" msgid "Move the selected column up" msgstr "" msgid "Move up" msgstr "" msgid "MPD _information…" msgstr "" msgid "MPD _outputs…" msgstr "" msgid "MPD Information" msgstr "" msgid "MPD Outputs" msgstr "" msgid "Name" msgstr "" msgid "Network:" msgstr "" msgid "New playlist name" msgstr "" msgid "Next track" msgstr "" msgid "Next" msgstr "" msgid "No items" msgstr "" msgid "No streams" msgstr "" msgid "Not connected to MPD" msgstr "" msgid "Now playing" msgstr "" msgid "Number of albums:" msgstr "" msgid "Number of artists:" msgstr "" msgid "Number of tracks:" msgstr "" msgid "On double click / Enter on a playlist:" msgstr "" msgid "On double click / Enter on a stream:" msgstr "" msgid "On double click / Enter on a track:" msgstr "" msgid "one day" msgstr "" msgid "One track" msgstr "" msgid "Open Filter bar" msgstr "" msgid "Open Search bar" msgstr "" msgid "Password:" msgstr "" msgid "Path" msgstr "" msgid "Path:" msgstr "" msgid "Pause or resume playback" msgstr "" msgid "Performer" msgstr "" msgid "Play selection" msgstr "" msgid "Play/Pause" msgstr "" msgid "Player title template error, check log" msgstr "" msgid "Player" msgstr "" msgid "playing time %s" msgstr "" msgid "Playlists" msgstr "" msgid "Port:" msgstr "" msgid "Preferences" msgstr "" msgid "Previous track" msgstr "" msgid "Previous" msgstr "" msgid "Queue is empty" msgstr "" msgid "Queue" msgstr "" msgid "Quit" msgstr "" msgid "Random" msgstr "" msgid "Reconnect now" msgstr "" msgid "Release date: %s" msgstr "" msgid "Remove selected track(s) from the queue" msgstr "" msgid "Rename playlist" msgstr "" msgid "Rename the selected item" msgstr "" msgid "Rename" msgstr "" msgid "Repeat mode" msgstr "" msgid "Repeat" msgstr "" msgid "Replace playlist" msgstr "" msgid "Replace queue with selection" msgstr "" msgid "Replace the queue" msgstr "" msgid "Rescan all files" msgstr "" msgid "Rescan selected item" msgstr "" msgid "Save into playlist" msgstr "" msgid "Save selected tracks only" msgstr "" msgid "Save the play queue as a playlist" msgstr "" msgid "Save ▾" msgstr "" msgid "Search the library" msgstr "" msgid "Search" msgstr "" msgid "Search…" msgstr "" msgid "Seek backward" msgstr "" msgid "Seek forward" msgstr "" msgid "Select columns to display in the play queue, and their order." msgstr "" msgid "Show album in Library" msgstr "" msgid "Show artist in Library" msgstr "" msgid "Show for streams" msgstr "" msgid "Show for tracks" msgstr "" msgid "Show genre in Library" msgstr "" msgid "Show toolbar" msgstr "" msgid "Shuffle mode" msgstr "" msgid "Shuffle the queue" msgstr "" msgid "Shuffle" msgstr "" msgid "Sort queue by" msgstr "" msgid "Sort the play queue" msgstr "" msgid "Sort ▾" msgstr "" msgid "Start playback" msgstr "" msgid "Stop playback" msgstr "" msgid "Stop" msgstr "" msgid "Stream name" msgstr "" msgid "Stream name:" msgstr "" msgid "Stream URI:" msgstr "" msgid "Streams" msgstr "" msgid "Switch to Library tab" msgstr "" msgid "Switch to Queue tab" msgstr "" msgid "Switch to Streams tab" msgstr "" msgid "TCP" msgstr "" msgid "Template error" msgstr "" msgid "Title" msgstr "" msgid "Toggle consume mode" msgstr "" msgid "Toggle play/pause" msgstr "" msgid "Toggle random mode" msgstr "" msgid "Toggle repeat mode" msgstr "" msgid "Total playing time:" msgstr "" msgid "Track attribute(s) to search" msgstr "" msgid "Track length" msgstr "" msgid "Track number" msgstr "" msgid "Track title template:" msgstr "" msgid "Track title" msgstr "" msgid "Track" msgstr "" msgid "Unix socket" msgstr "" msgid "Unnamed" msgstr "" msgid "Update" msgstr "" msgid "Update entire library" msgstr "" msgid "Update selected item" msgstr "" msgid "Update the entire music database" msgstr "" msgid "Update the entire music database, including unmodified files" msgstr "" msgid "Update the music library" msgstr "" msgid "Update the selected item in music database" msgstr "" msgid "Update the selected item, including unmodified files" msgstr "" msgid "Update ▾" msgstr "" msgid "updating database…" msgstr "" msgid "verbose logging" msgstr "" msgid "Work" msgstr "" msgid "Written by Dmitry Kann" msgstr "" msgid "Year" msgstr "" msgid "Ymuse version %s; %s; released %s" msgstr "" ymuse-0.22/resources/icons/000077500000000000000000000000001450727225500157355ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/000077500000000000000000000000001450727225500173745ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/128x128/000077500000000000000000000000001450727225500203315ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/128x128/apps/000077500000000000000000000000001450727225500212745ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/128x128/apps/com.yktoo.ymuse.png000066400000000000000000000311541450727225500250710ustar00rootroot00000000000000PNG  IHDR>a pHYs'tEXtSoftwarewww.inkscape.org<1IDATxxTǗlB6!zO#$@JQ@A&D((XO}""z'R;3{n*![o攙34߫  ,ˡIҼZL K+%/@QW-H0b4/9hWڵ(h|+R*dG 9#{L*ei9; fe~Bɠ6}?ZRy+(3Bror=-\\>YPQ(b%Y Rzbk˨GyylV{I3/, ?( $!#!MܥO F6dm(V#[-I]T3Ja:٨;i ]Y< dYCWg+hkD-iTw~^0`= A8 =amtV 8MBZ@-ĈnC/4nm2@G^3t4zFJHvTB'uVA{g3os[<5C'|a8jRPag)z"}.X2Y$A ?Ou||ujUs4hAÇgDtT@+5C:BEtB<Cy" ( Hm[.*^qsrہje䶛Ѡ35*;zRu^:D ^qrH@Zd'p@r'b< gÁXvr?y8(poFY+K[9)uGT|\%  F]IﯕAm-!X %J^4D!D1 riN`Kaџ"<<$m(+ZxFOkMyrzu/E^r +^$<T^\:crk)Y[@f+xUC  ^x N9qyītsH<2~};Vc|#l<`aRW_>c P@<8#r0!X}q$!`M2cKR\ża#@u;57uz|q#`o\rȁy $< T]Qιy29W^̈gG}<7F &0 s >LG/گgffK {69#8ȈlÖ KԤG :4O)CWo|7`AnF9"Yȇm , \ #RDך3qSqDzq>7uZIsB~S/<tYrXW[‚#%&0rxzH`q X e; 7cy#=75lcKZV$`Z)Qo5>)A@oTJVzX| /aʒ*ĆmF "%a 79 .rQi ~Jid:e8l2E#*.k'%36৑C*P+ Bِ\DC2+5r$  V,::Z)NVelCY.+mIjpYD>FA:iX+YQCbUGZ)RA[UZ|o!#nڗW%T*So}2}~ުk^ry0xmkdʜ;3ǩqOX8eF z!@N Hb!2Φ2'n<>C)}HxQw81Je 7"VgWSuaaI02>*_+ol+X>Try;]]0MTńJG#( ,f@!᪃ {|f~*eeZYG~Lm`QN8V$>$.`CV If{ ,ZյtZۑG~7[3ӞNBͬguX:a hku9)-\jrNp gͪ9k%ҽm#3I I lhFO֏ҩ8jh[E$BH0@&s[Lv/FX KGJ.ȊZ:S юSlUŢHW-D;9A'Dz@WC![6-m7zǺ_UiZ |[YU$sRne|ᔩ٩SD. Rb&u4ޯD= ;m7;LNlȀµoQg ߳+SQQv25ytfN8._ X;9D!J!ZưBIe!(f#3hi6vZhutu6rި\O;l =cGipp=48Uo(Mo©/܌*@m 7~Fn+`|5 3&K rF uZ.p27WmF|X"mdf-\B~@vhUvNPvq̍d܆v9Ʈ^M:y¶{[XGݏ1VF?  #z@ko;Ȋs`FĺcaA!'i "h)\Kvہ)\3` !@rcMQN!)1SNqսy3jF>.޺ۑ^ú[m־`d :[/ m=k p7`!@X$OZXhQ)؏[k+T$BWd1opi<0b93Kе{`He_7 ;ے -}PD?\1_& pu=!8:eXQg#JJo8e4bY[B67 nEr4cor/ю:s}*mgK }(9w<ٰpT,tss3p\ ;I-FZO.hV*|9y϶bL"҉C0|re$cͯ܆,&ckLugF-(!M+wr~7êZМ$W :Cn"UE$%s)dF JNI:n|YhdDKrNдt6, d D8粂^9="qs Bppl?NPɸA*vh~cR4v^NaL8 SA2»1$Z!R%n#r=n y3%#@ @'M" ;mD3y$O/ 7*aVcb 78H$1o|  En@< 81 :&rCQB+4k4x))esd-nm^@~o t LN&\G+>(r]k>lGF=_P@#|t6VJG3@3wF^vٰ|]?w`hOAVVfB5*A֘&z8%9C \\0 gbh;YbN+ܜ } GB 6Mt3ts RpC HZ\ޓxZZthɻ[, _@D+`OZ]_Z6x@;Wcu&gs}b;@ǠU;q_?3G{[SúC`>cZ~0`rb 7CW<7nO )~^9 h|%S$O@; -O0_}aHtTCl` Gt'/aB*iҤFOܴO'yr 7K[%|&c~MɐR{Bٺ-s0-o*Nw1܁ ϴ.ӫ`@[=5\f֥ <[9&F,8ox4k_w>u2HWq3ܟƲ6=d?X-'E i{ 0h,opUZq5{,kksQ 24}72+ppn0$ϹPYie^~PD6Uzy}>Hm0wRӸm>l/1 y`NP5Yd قw34ډu7pVp󶘓 ӑ:BR!a(?8nWҡ&0pt[6]԰SIv[MsbEfpTYc:Lq`S% k#<jpNÌ9!K3(zVX?- A`P\8ϛTE6f`H3.h!%9'. Yɞ U) IC0[ ԰D!8"a榁񵊶0| ?1A}]߯ծ$e\0MC!FBs@XhDͬ| &yE 'J$ 49ߋӑ#`d?v9V(1LG/&lp~u0 Z[®| E•OEO'pnUFsEg@.J;8pýՂ$NW jaX3 ge aWS(VpI\ d,8^܊ c.[ffkv '{®9 rUV` Z 6y~狡B9/{v7?5 d~;Pje@tr0[Ync;. \3S`2xŌ߮pf+fp6 `͡IX9 ^; ah2Ag5&twpLŭNG/=T(0*`wphx0T7 0T[.MdXn VsadX\O[p1]661dAA"]@o.gA諕 An"xr+ΉX!ٶ^YtJv;4BV \`X 0SC| zZĖ.tpt8\5p=B^V2Ncvq5(s]1=. ;8/Q_ .@ʤ? uAY(Ӕooq*3uZ0*I?nm"L(ξgW^ 31./@+^( W>g{1KxGp0hq " &yg4P,F : b/rqWO'*O6J褆$͏wv@$ P!m#g@ 8?4_m#  o{\ O@ K76°EhpD1S͗?@_g J=([:| =38qsVBqXfQbZ`L`g0 LX8<dϬpp|T8vȾR1錱9Nǰ=_BIq+5P ^e^UfT/a 8m qÐ!әi3:{s(wu||%c8rt|85`Iw8<.x6.syɹkoA˕D~/UvmA|(W5>ѻǎգAN 7 TXt` '6l.V';Ӎ6'}7ػ6>5U &¶EȂ`TX{f[3FCϩ{! w/d;67@+(27Q\mxj@ڤsm>fa0DoŔ},|/ P5ΕB06+v B&Aؼ7wV\)PnqȜ+3U,dys#gIeCn,g3 @I mUv[5afڶW8$ Yk-|3J q^@ۢ.Kށ ;B?7@.@ȶ8 # 'yb5\㼴QelFʬ> zZK_U0D9Y_Tx & sCNyD$ހjV:Qj#n•I:"y|'aq[ZW̭`_AM ~XC$Cjj 6<&}u漀 gg`piC3So@ivG~$H.=S^tl R4<ԍ^wFqw |-}7;b-w]Cd`(ad9;pdE~>a/MtFi3_;}k\bĵKvlfq?n7Q>B05N eo&986N}ͤ 4L|N-z <L|'=RyrU}{m4*\ڲ^7x_g Zå{ !`A8vΓrڀH \r'z~ ~O!;K'm5}o^L /ԝoĻ2!=ÁY,>(.bRV:|&TK]k*wWA{!" Ynz^+ocZ7fktK[ܽ񫂀M ~%f~B+Q/#w׳nzxPYoi&3;Š&\:ޞ,7ʯ\`{]Ao+ ζ{g|1 @ohSiP0m&@>w81"&^=.8b|<>A^Hx/slw}lG\-YD1'ǜ,Zl'ة J!vsdpvYI,󕵒J7RLkEx%2axwѰo3bJ`)}5^ ԡ|;>P:^xw!,g5NH@twxpVO z<^9v\[b'( q{5_a(=zҜcѦ%1hԆ1@@8*G0 uR icJr#lǒaY9 O: S_= rMcIw1ȲX5JλOMX.S/ l3i=AH8yǯwp~9&/=nkyʾƦF ;3>"Qzy}7CކBK\J$<:I΂8#ӷ+;r?==~)G>whd=HQEk=P-,߉;{9{Ab=MLI੉KEӷ1 -Gp,CAd~.Q58̹AexՍVœ(J6/cav:# j-AL۱H$#$ڨ%z D$ K= [Al>fωQ{r}$(09,1wΞmC*w_t-&E7  ҌmM?|׋v}% x?i @IJaܨkJ [- c0xDetm|߾;{F%YC-fX=lYH2ا-^oEfjtb|[jY8do&wsdm>4rgzی!`c=>,ٍy(vHX82^ k:=Uo ͸ PA|#㰜zt9۞$,`>{ 9lB8!zlec`Iy!AFBM(mR5@)j$$Y%vhӨ,//b }h m4,~:vZJҾwMƼD&u?DRA9/ϡG1)w@4i?'a7 ^:-f/MݴCSj D;1:qw>6&y4'?=> <ꇦ 21μg 9/`^ը?-MT7xΒHt#.q9B<׼3ULǺ> a{5vxVe"M3%?j3142?Bm1& ?zz&ЮC gFČ[`e{}Y7K IOMhġ(_|y|z|k8`Hǖ)N:i:}[ŨMs^H4,91CΫ+7j`CP#R{8 !tM}w٠Q[6pe![јPGѿ V, cRI4z=0ZN /N]eu  !,7װbّχ`1PKfW@iGwq<8^&{AhRp?X/pXU|iHzf7s&|'"@U/C-t+p|._NKuF`|;TF3<3emiCh˵S cKq~bx_C%`7C @ ּLLyu>qz@]uZhҴ9aBF._^jC S0;n%:A&Ym1[ПelAZȪpB#\ŲY3M yph2-Y} #v˴ݫM5_"qE#$.W{6 KvhtDVozl◰Toۊ~%*&נeMjHNVZ7ugVMx&y \wƬ; #V|y$a}3~D75?>k@;oRzoe{?_۔#V/|vy+Nw; oXq0`߻L+Rs{2gW9ܦФ&@hRMj\?*\ZbA-IENDB`ymuse-0.22/resources/icons/hicolor/16x16/000077500000000000000000000000001450727225500201615ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/16x16/apps/000077500000000000000000000000001450727225500211245ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/16x16/apps/com.yktoo.ymuse.png000066400000000000000000000015671450727225500247260ustar00rootroot00000000000000PNG  IHDRa pHYsvvN{&tEXtSoftwarewww.inkscape.org<IDAT8}[h\e9^Nv7沽ni۔`-F(`ZT!X т>_|^@*hk^l4͚d=o|)m-a`f~0GDxS~t樔N M\;tRR`q 'k9VߞRb{ƙ~o)mK-ݎv Obǀo4ֽL8ŝ4MBLD04(&4`q;uBGWWt$"mQkb֜E53"Ŭ77VXND"՚b@ٜMazb$Gs u!YJ1>w㴮tj=ͲW|/]EpuV!RLc Jf0*Q,a iD˲ޏQQ)j"lY̙sE"|ȷ.>'|̜!t=~z#)&[)v?O[*P4x5OO˅4aXnpww# JDX8wX0> |y~;x 4Xwav2'"cϛ0=X( na<}pay; OP/_G.޻ptjQl@V-Ŋ<)G6 5>V {J9@4wLCZN4{۷Rjh~pᨈ\lmu"k IENDB`ymuse-0.22/resources/icons/hicolor/24x24/000077500000000000000000000000001450727225500201575ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/24x24/apps/000077500000000000000000000000001450727225500211225ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/24x24/apps/com.yktoo.ymuse.png000066400000000000000000000026651450727225500247240ustar00rootroot00000000000000PNG  IHDRw= pHYs-ItEXtSoftwarewww.inkscape.org<BIDATHmlW~ednJ&1? tɂeK~@?~`]2`83A[d&30aZiK osKeWOuuι(a>Wְ,z**BTӗ8vѽ66vMù /iˢ*‘7˵d.lh+HMf IeS`KRFㄨ#G׵358|2?"+M%6{`XFO)o ;Gyz,q[ o/G'+\-P6ߋh\ glAWb2dQ+"L]Ѯ(VVljr$r4N[2)Lrbf5!Ҕ|jebK^Kv+3ƟPv sCcfSS|~sç3м$?L5T,oKm:~C ^mAKb=w6䍡+ˬʜ{Tx];ňǙ~|fd'E֯hctB[}rbH{^QoՕR"6{KyÓoɱv6ȯ2O<3urh%ϯ4^؟`N~tãr4ow/~`"`A/(Ung|<~l/Կ֯=)X& s;K,?Cp{P}#"/GSݰJ"OsQwӻSr9靅<DR_h`.7뇇}b¾ł< nU|;, HIENDB`ymuse-0.22/resources/icons/hicolor/256x256/000077500000000000000000000000001450727225500203355ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/256x256/apps/000077500000000000000000000000001450727225500213005ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/256x256/apps/com.yktoo.ymuse.png000066400000000000000000000643011450727225500250750ustar00rootroot00000000000000PNG  IHDR\rf pHYsaaøtEXtSoftwarewww.inkscape.org<hNIDATxxTE7en6{!t&@QR(M@QWD "JQ{ 9vܻw7$lnsΙ3gd $IRݔ$I I$ $I I$ $IPþ7^~%ZxO o߬^H+~c[-< w \d BPi}H"-GFh"-E&SHP(7 so y] n~$+L&TS&T*E:h2)TF*!PLD=L-F@@̬;v.߃w҂#n/ }LMhn1*B]8ݐ {뽲 ՚{3Ip`y08hE]<됡*HҪ K >h⫅ZhzP=E 3ý77t3@t zh櫃tEߎ׃Ӑϑƅk4$T7O͙=L\Q!. ݞo6z}8P(z=NTr<@킵)DCu+2z70= yAwd| O}oԯ& A0iRpl<=4@`HQ43!V %R{ِ8w͹#y!P1Fٰ%g k1|^|*Pmo!D 93 `"I7 aHÑFDCI?@[:zA#o-Īl;P.LdwD^ FC/͎G>)Q,o4GRCR &s:?՟3m4HRQ1F# V~zH؄d2d6 N(BfuZ>2A9<4[ >QVx2 *h⯂*hb C Y/466@1Aw/CPR]h̥ Vx7:Z+{Y0Fye;e.hYP+DT5>K ~Jh*h A0Q !hॡ^(:k(M;{ -\T(dk!z sZo\×@o^+2  "k^Ω `NJ2[In?_4 "?::s[n qݓ$إICBhpnV-2) ? J#l@# gD#z1plqsկ/$DToDjvN BǢg1UTQ5Ik|;O_C 6e HU@hGDtzD@]87p}:H P3QZqs᥂ƾƝ?KL8W ):%;=)ǭj p[3U5ho6D뼄C}z!23 v T p|Vpa`tZ 0"5ӑ @\r?ˀm|3='0>c1ts|97c j;H O4~FOH4d~#<'  ؆3:Iln6<&5}-5Qr5T*]aɼMo6D=LxiMRwr:l~yW~!Fj_s AaK!Z7bnBc*[AR46 @YY*=yLOJׇ!饱UOXy~̏(=4j2*[]_< ]lrҹA42~ R,R JlEB EB@`Ag|L_[WJz*1YB#ǵr;_@ @2=j'hx@^F) Q #cj` t @W߷sq 0z]M2m5B K1>ߪOs  FKQ@@ޔ}yV}Dj,wAPJ0>,&dC + e}| a9o 77|~?l7m~k$dY&JM/Bl~'Dd р# ؑAG+oV@W7ʑivṦoG+>4*c/`|ҪO28*љZ|dn!u{dwUߝ0- O Z& a &̻ CB26άr{RokbV}M` R\"ۆ됞[HIW#D k4nw4@ ؊Bee H@C. īI+y=jiPrr|[o1q7Ӌ{ꛍ;~#UVj:^/kw!I1yO|!@J l^# H0` ` e3JiAM29 iȔ`e#4|?H8oo>w 5YO`0i}P76pj_e/t[M'iI -P'΢N"TaQPMaaKbb{u1jut7`nh[ǹkdVs|,H`Q`fYm+ B.ώR(~=P3IXTh',j - be_ xS*'RW1 _369 b@!zpx#]S*L_Ix-IiZV#vÂލ *2Ũ,V&b0+=~ izcO@#~MEG 6a{8sHҪ, peV2 r{ Ԋ- u 2rV?W$=h1?$E@+i{f1QTQT,*,ڢ12oNE ?YV e.nE6)U#՜0UhU]`\aNXt1^Dī5m#_KfR#VN@\UN(1ΝS?NEE[h O:tyEQޖJw"pC>KM?_oHeB2XAXBs 4US6p8V{P#Ʀ5P#Vܭ˽NGsn' !E`Ԩh^[k5j( @'2tsz7@i^ ^~(KXE3px~ M}Ga+P 9M.H ,!5WJ hQLEMd#e]y*0-T= џheMc(D6 fũ,RUB eC[FC3Z?{zåO xHuT2V2800ps3(!DPT",ͫ,d /%yJʰجVR'hRg&n)lL6| {||.c}.t< gknIfzYAkV+o[}>ɲLn Goog]&2v:25^pn3ACJJ΅sێпL 􀏖 >puK_1fQ*t[J"Nl^ ii5;8654Uol^w^Hx]co4A{Ad@/5؛Ra7tG* | 8~)km'R'n;~X'?E7}4kiFV~(;!prE$=|o$nSGs AcQx骆Ed,dr 8o<#'iPJ`]歅m/(QV0ixE#aDQ%4R(F#K csMj6.22![0 f`lfA +NMxb%ߍŸS-<9ޤryGWH> HEQJ޸Kr'`S[6jT oNKS=!QX5 35??12u_)X9( &tA-Cᔆ$SeHA솆~`:rҵd~RblisW^ Δw.,p-"Krr5ޙa;<x46{*Jaeſs<5, sC 0 |"<~ ޙ&"(9olXtl!C[--蓤#B?Fce2d~;ɤqwI"ED2Z1j > a\i I0^`6"kYa)!HWkn8 Fq`M[BaLtT FW|Z(:d|Qfqj]Dy[e6, ~Vswp5 /ߑ@DãùTg :yQg)W}> ~ˬ=<2; peG?xlhubbХ;Y% Ok'QWod2-}/Z ^nX'x}=US[rϲK0> דxj`L WxO2背Kh@ w!6h5 ^odl-Q"ND9_&; c1:O]"LijuϮ!0VXo.z6Vk~ f%"  G<Z7ː k(}&*k6wO\P2R(z1~2yA!"7C312 #QY|DfXX2h@# ~p0 c)c8==k d2d|{#ƇLV[(JRExE  |3/7 =a'G J2,M>2>Xb 6I:ni 7 av6DC뷹Qrd|;yJIUnqnpv. wF+<oЎhDK:'I'=}~R^IƧ䷓@ AKw`A$qܦN>G-d%w$R:ŃQZU}|盼S`9XVw-h 1]ۡqZ5L ?r:2?#̧;&N3ATdkk<s"3Twn]ϒdKց\/ٙCSW`1Dor4 y9嘦ÓuQT:4?&N@QH\ Y[h=B:OB?Lι=QV k,)֍i~એlyGs ?[j)ヂ!9 aS=hooOFS{悫%ﷆƜl0kL- Vpf QsL=?b —qn=_eOEQ/ o2?M>s}uDC  d譯t;Cd|xx{$;]ӂ= YqO[Ǻg:iT eܾ|uV3Q1 'Smn)Z4-=Mn>"sۣEe7I0 1S6u xnld{WApHKh q1su7բ\]ru_q\6 aa RC3빃<{]q~naC֑p0gͦFa0$[ ~ k@_Mn9 I@YOrV(hy`V2Ў7k=L'V$oOh*AfH$GeBO9hEz׷u W 7 QPM S,IAs>|f|AHc;‰2 m'00BX4ty0{<,D߼#\7M yg$DJνk<--m|~Z/ș._!5Yv(m[_+FI'ǽN ^q_ L?-Ąe@nD:XEˆ%QH 8r\qnXxwFsj>5&;zOa?e7A@AP;Bi.}qYLwf~`;I A;?pizSF$@_y*l =0 g{{Z3jRˈ(\|j:0h T-՟a~d73pmp`# ]?HCV0=o a`x&xxDd^ZOY Ɵ*E7GQA=-ح-@p/BaHr9xz/q:(=hE!0Y}Vk̅KÅc~i=PO I7L. (:Yu3,Q6/^N.ճ!a=<&ۋSkZR<\5r`vEWcV>ֿt:B\o-J3lsf2 _gn)y9^g@lη*&P3x1~F* \ِS  /#.\1@P}I)[-?a HRT^nȲ^|Y`yA%G%2.m;mœ@]mL0w@+XDEL:w@㛾c/ @ۃ 4p]!۠5 /[~O,.Em]Q4[V%JV@~ܠϧץ2\Jz ǎWձc D 3KhXIaQ`iwoZP@̫ߗa\x#` $dܳo@ ?]Q@%BZ QZ=P3:";">D̟̙ͻa@G~[*+G{s}Ua?I,uSH*5ǫ~ju?x^Yx0TRp_Qa@;*ٞg/ @p>a!!ba} = g= %`n:Z! kF p”{y~`q24]r ╶(u~<( ܼ>#>vL &N04w;ah+v`3c_?݆t55 P" WOT]rmؖ߳^k*̮iU&VDLRc_)FIq9*"|gMI:SC.U9̽%a8101"" 8JLK&oЁFF˰vL:zxZOeà0h/dJɶ8 ]O3Y_N[?h>b=E3/ -+[.^@Asl_pmF]QdOͯyah,ŨIB<#6zs~ a>֝Ow7gCw`mWa Q ZJ C{-xwϑ;: :_ -ft Y{zXNA~^ GKijг7Cǁ88& `~NOnHsEl ii=_[4 Fe=sqVjq@#DpA91ale*xg : S5px~U;KR##3AXi](0 rzM,e 5\EF3'qow /Z6 L nz5G6@տ/slDԬJX1?A' ``[g_+ݢv 4O 0n@o2 ^#Z? [ٴ pjeYO4<]o,^+~fiCRIs 23XY_żFoi^d ZA'`=J X^J pwɷ{6>6^~ j:g(]CAo(շ:ȅ_A6 p4`cd5[|tJ!7抩tw`b[( f^7{@PF)/0 : р,G}=}u@O^3=/fr_A rg€':HbM(DO( ZSZjh ;D3%4sQ}:N@V` hD`ɅseOBCS}g?=©@rI&{T ִxd: `$dyn`%w& 5Pӆ46V8+@8*2Ǫx@(`tTt5b|?9yDzy^KU/Dף:Z\^QaZπ{֊ B.϶7 `t AC;`˺+Z#罣0X:!Vp|;Ѻ񘰟Wv2\(?=oxnҖxgI}xiCdU&CepYQUT!X `&0r"wҽQ@%VԁcP2p8MN 'Ճ+_dER _>w}BL`^͂gCƼrIHiOap1$2N@Jž7Rp-|ӯ{€`Hq{dyŸ NƘhlc Sn;y#(J"!>G/ ׿^g1zE(E]J?{U>8:?uY8W\e-w*L;Du&{Zp텁@IKKALu%[Z9!V8vd0 "N.79v n02 1/`~f՟6 oc0zGuQOr@2_7lhzmwd2-26[9`JI:Zn0 x.vόdGz4g7಩):KBw,EAXt(B yOU}l~[Vpt˳;-SJU{C') }O>: ̓ # ݁Qf 89 B*!fvq#0f/\8Jwk:3 |P+pni}Hȗ( xUvry8z{>@=^28f6y3.n g_ܽ 2}HC+t5kr18wh>!p*#XgB9j *4nKArPSk rӀ7i@̫gM4qphGz[N@N=|ϬUbN?~SĘ!Sy_؛g1mntR?<SD/OFŜ T'@̎B hLL&li(~rY(_ =DFrQQp".v؈,h'*~-ߴ xߖ^ O-X OzHA'q묢0h1Ka`-g\'W5Pp7q9 oeWpBƾ:!Y7#>A@gsW~=_xP5ț{ :֞[8<Pdn02w܉}q:ON"b 1?`Y=cɵ]KFHpg`= f@( B.~ЅqWNC.ljMiwV ܞ> ӳW}cG?HS^+|y),鏟K+zlT4@t?mOE"[V2s,"y Af'м@Z{ZW`= x@۬ __șwvC6\&h@.>B~-?3^y_:&%,Ä!=㘱Ýv3#V3J9PZ7gÀ].6R{`^P:uh/~Ÿv#@Xi= ϼDo GTK"=(59jV[A@N>óBC~K"޾qށddw%W jAh4'h^yMw:\T( QW( k|M4`DQHA|C]6(:Ý1,d;vϋj)<\ ~[F˰e".dzO oY^ ^ u^G!a.Ȝ.~6I]r;p R Qg/ \ꭀ5!0?/n0\ :QvQ(STz#4$?2 [ҢH0w$W}qOoBXkm P |}~ A.̿5yߙu*LGǑ `4ϐ`'L$E()mZqn~0ڃ>'."znb[?6.,9~:۫.n'AUS ԽmM v=46\bv-i?7 z.νф|W] D8٫E75:-Yk=_eFDz>r֑K` :v/ 0*"xyb+ Dvړy(JSmh x οo>`(Jso2ͽThr^UJ#nbQPb~_pF51Ϋ |wyx/"< o Ep:2b|L |02 o@`LmTpBdLo7oƿ>6T8>`UAg㘮STE_Z=F r]Hj>Q,mEU ׇ{3M,F/W`@`\K*[lE)ꋙчqzU,Gݛ۠|Q>_1\}^Fӣc {=NI"B+}jm~C/f@ө{~~ƕeʈ9 V(QP F;0{0;0W^/d+j0|6F3.OGttQ8LzuHQ^-:|y?@cV1n?wl2z)0pBN'<ݟHm~;3NbC -c(W |ч@N9_N}%!.1E୧Cه )Wo,b~帑?̉DYUw)(@iBс`ȎšVg](zO7؆[k _4pxI"=R貍0{l92](z\[mhu2~8)H!t8%o/+ Sko~Q_*XdNA6!&|{| Δ_SGtױW8Wx {dm&@m5Ef;rLVTl9Odr7# @ES[ Y=NjӍ7.Bs5 ]0DOJ|qt=Ϟ?gVE\Y]Uwh4p°\now:TG2WP7=9?> @@Gֵ|s.8lZ'+bApW߇*$bᒯ'  S7K,TE[,֬`"|ӕiwZGy¡y zprM_l*@@qT|"GEu9΂']̍yI}}MBBhq< ݷ"pd3R@E*՛ p @6sA; nUο !_4` @ڦw).5E˷4}@cc?cqp=e|D;; vk+JeGW8qn8BйKX.ASQiͨn'7&@'% 2 "R^Y#9oЩ_#0N|PCӶT={LoE\j Mt 0M$8~^J8@Hg_ɵ|,]Go*9 nTS>{q t}V]h*5¯RGnj{6 Nz~)Y;7 s, `C+z%P1/I:9ætw{RA;5?-@ܐxHg*CSimK[ W @'sR|;&+ 6ξn-U1?{Gz6x]'rЎ>+9a%{!lNvoi Aqnbkl>Q\α ࡢiŁ{-hnxy# f|/xވE};<7?5!G(e< }%ߑJi?g/Hw6[ 4`L 51 ** Cī0ޗaP?5Z1 0ΌiJVx>pW0m 2Aޏ ~H|u{ c~ yjZmY8`IwDzU`hc Y9?ZNl^5wH{Wt i7NxhKLV ə w4JN0/l<>pR& {~>"Fa{? %wPH4U? {+USpIHe(lܙi91@x?0"$=t2bn +:HdHXByʦR7":B0]_Z+׎ G~)n3FG{<%x5[N+=` DolE`L;#H `E5d-_irĽhc ?$<~r;`/Nl-;b`+ ⊁-"UKPrU-u}@ u ξ֖|fy> <3@=w.@ִ#Ah8{p3hBKTm-0m d V}6l |9' a!,0XȸFl3hY?Q񬊟W&q n Q\ )?  $@]u> =>Khʀ)%@zW'|3:Uh4B1kFA@^*0oc|/J0ȞM$s.ys)@)bWyΑ^|5v 2Sg~!.`N#7 Y22Ih g(`+c.B e'2<<^Ҹ Y_J LTƠ1w?wUGz |)5o}4KgnnꁆV``y!bjS-;1W 1rg gk@8m'"0vI1hN LWy«Rzm@tsf`Í-Dly'z>mS7> ȝ~ؘ3j:x( m'oʄ=} nVHdJάQ@gg+8oC@6@َrOwxkjSGr`7z ]|#oBӐ!cX6]x{цf?w^eܦXQ@pk 0xBvy!gCA0.Y(zxl3rTryϘ:<; >+pT#=ZJC)2{p?Kde p&V] `my5xk 4rrvuѴujU&߂iߋ6F)B;/j6tgUެ'58Ts ub >t*a-{6cP*D@f@`LH传':{B8i  Y3::WfT2X- vL&G91H8 PKyM2H|8htMė;R)GF z TF~zșG:w*t)A?Jp=؞(@ o>܄2 k54ES/qxb8.ڒs97H|gHG<Yء Eo3 VQp.Ip|[pZW%=*ߣK-#^2 @?/"F 9^H1t(.ߪX.kV`|QQ*[b ΁@sv``8b>iS{HpP*U2m[݁nAZo:u=੹g\YPw>,}wLzLҜY2<#A<Ɇ-2+] b3cw;/|~ i+6#}E2ЀJpq".) ][-yZ$Zcu<>dk&^@*M@p5k *n_=3@kRn5E0U-Ծ; x(VS醶n~ٷDxXL ygHQ0C^1sMPR a,@܋;b|(غ[&yw/l21M;ֳvS ^F% =$u3u%p1v93w[5]n,cAzUeؼd8SP_fnpFc <1(g+`n]JJlp><1M­ d;"Ptl ,cn1vFQ@piR2oS.VFʖb:k{y!`Y^==v(w+#Y( vx)_iyOתj .vÌ y!t1>Y֖=# ytkܺoEѕ,,PKwύGDy(#Mo\=| xC|E+GRVؗ>*@sMUT}랿u+~V&kH7->_$f*":x'f` .9i\@ym_wu)ݴLm? _}+L{6UH>$TX=; >@ؾ[(U?nZ L~/2֥J3,t a8kyۀP?|v KtGo $8tLwܶy(S= ̟Z 3n?g.2Yx%3"$H}7pLW931]y~-n3e:Ơ&@JoWDjZ6LKUz aGqnu=-";v|V=pPPMX5jUn11]n ͔mQU2YKd7Y@iȧbnQ9ގq`JLt .C-ضQ~MYcȬ/;s?+- +$H ji8Ou:pkӚ3]VSB"rJں,L +FCCǜqU dRLp?OVjE{I7" vj2g@JW .汌z(M.rr8 Uk.=1be O]ȶ}[Y-nN?~CCs+,H"(JnPE9get1P~C-;Dm `c:hXgXk!N1+ӕrȓ6'xL{d _/.MĀA`FMVE3*|9kgQ!joS itlt3"18@*@);*tL7ia黸:'H]@yO-%ú䜀_a|o25*-?amk|OdO2'l/Nֶcմ0"O|g49;x>&Ks6//'HtƝS-;wzIaxnZKAxH-e4_GPR{gY1U}ᩧVs}2>v?.xɘa7' g6X;VX5ṋrsp1/7p`Q!ah;c8)v-;D5};WL&k`"&D,x3{9s^ouHHHFiHy?iľj/XW//; ⥇ I@ ՈǷ !/}%Ӌ7K I@Mn^r\=͓G`Kz->cG$IXQ]{iO+_?wy`C$?;gdž+~Ӓ%I$II$I$II$I$II$I$II$HA$ $I I7KIENDB`ymuse-0.22/resources/icons/hicolor/32x32/000077500000000000000000000000001450727225500201555ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/32x32/apps/000077500000000000000000000000001450727225500211205ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/32x32/apps/com.yktoo.ymuse.png000066400000000000000000000041501450727225500247110ustar00rootroot00000000000000PNG  IHDR szz pHYsy(qtEXtSoftwarewww.inkscape.org<IDATXýyW??TJ5KmҨUKJlؿ\[R[Z㈑MkDAEe_f_`oy{1P@4$'y7w>w^QUer]qQ&)9fURqoWJ}5lvTjvk*նX$ȱ2݁ 3mi;|5z?̕킄'33WOx&Bq$w.Zx\ءyE~0Ic7f%i ϐ4 c!tJo9ڝ$ƒb-m7GX%i bQBEIJZYGn |dRג>\X81xF`!8\5b@ ޮ+3I'g5 dzU0^-!SD >aYV|*/&kjKnpXyiB {21oe.!"$( CP@+E8^"3ZP/h9قR  $DЛR$8gFdOg(LD+c 45r 1)ˎ]?8DҠVe-[{m;7>7ePk+#c7>= :9KOB/g\b s4,r3RO#o䔺<ւO,ho zc|tzMBc6sBһE8S*pBK{ݓmaGx"a5#0c*`l%zTׅ|uTܸ[URt.L\B-hP-ETPXΈY9ڝĿD1]Â<.NF- :G̃Jek27k=(.t_84ؤ:vypa!*S*jv?+oURu1&'cԜe$݃Et! pV,5\{[cv[4le5i4ZيoF#s)x1E>/cOo%(F{[95R;:Cȍl[r' ~ \xY|{y+ӹO '>55WEKm%om/Xl=o[,9(R(=#ʜlg6f]'˒2@ '/w?Z}ܗLνծ;9p7!I֯Zl=.,z\_=7pl{+g@xsB[tɩu|lZ=i U_.2,kac|FCTqY^-l/wԭ;waikaoDo'fAx[KR2M*ʗ/cɃc70d ;:SLfpAy5f$۷>_o8fа+6u?]^!Lʗ0I`|!uԍN=\U׾,aB#5:e؏oQ? ]T#!4B` 䒱\WPfgw> k@y*@'_cjn4Wv{V3$QԵTۺQp6}ԏgTQΡTP1#D@à h4,-b vJV9 ߓA*8U`E WFɱPS VIUIRJqx{Mh+zg%S  #`PD3" ad2TIN'\M4^iDxE4.E. $k@3$iT &aH:u)q!vY@TFL*Zg܌id֦xvyEU_J.+R 2Ygv q+|==K7"W5VPbˆJtaKVL8SJvniJ,~e5H+)S:SPWJN ֋PN@5^qVAHɞSM-f؁uڼ6wG bKPA* Cj9 P)Ch*&f5q[xu r˩fEg<'re邈$vz" ^6lKՑ*h^}Q_$Ov0!LzG1:!CSH-gk.aR_ae$Rb )H#1/9EoBēPP|8 Y0e?{џxD!%6sڪ_X|fT4Y9Y{Okko 2&kqI*0h(*UUAD<ҚD..b\Lm2ASayC 0 B–Qf7W .-%0 mr(|D/K9>8'q&d\ʔm[ӆqGzi @R҄㬓kxB>p]/11~p-K-Y\\ÖB5u MM Ȯ^3u߬ӥ{Ł/K.GOgyM m- R }߇Cx~g)Gp39&f6g0M0Ai;t%r1&4)"N?]Z/{]5bRFj]/7D.58ξw3 Rc8_y})ttv2Y . L*Ƥw,LB(U#$ 5׀S ΃OlA41MGB' baZ9jS82q͛?#?7\'ҵ'MiD[ vS l*w}-}|N@7 B## fP ^k:Dimk`sq7m07} $ܭę1 чAP=dzM|>6Vsgyjl!/HJ;"vF?u_,nwKaEj;D|Juw)?GOs182(:ܕ>fc^?2 pBʥM1&lfO,{e 籭op7Uժ="O çUsXFs Hyqb!x3k_`142%c~+O^u@yiqKŧFgbWR5Ĝ;_Vm=г^ W3~EN>'~;_<?X3_e?}OEjO!p0G޽ވћjpMk%/ßcUf IENDB`ymuse-0.22/resources/icons/hicolor/512x512/000077500000000000000000000000001450727225500203235ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/512x512/apps/000077500000000000000000000000001450727225500212665ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/512x512/apps/com.yktoo.ymuse.png000066400000000000000000001572621450727225500250740ustar00rootroot00000000000000PNG  IHDRx pHYsodtEXtSoftwarewww.inkscape.org<?IDATx|dUg7;wL&=M2o6dKe5ۗ핢 4WE(*-9wf){n$y|eK)w{s,`AAdz/       (       (       ( :N^ƁaRGx/cMCk BLh!?|FAP {$+I hC؅; /„GnOQGX (@0~ ppV)Gr@A@R9? h^ypt  8hs^@d#fAIde,Gﱜlv/:fYvObIwANFIqif1L!W0A+r[A@R(MNGswbq"20%JS$ Gj({- BlsCMH|~8O1~`%vґo8GqB_1r7BĨ̹xj? tlhZnJW5 SeW0ͫ ¹kXGSR-}B\(X#¡c϶XLn!d Q {rqR< ?UaAPPYuѱ h}턴Ӡ 8A޲HDdHN|‰tr"4C=Y\,s@Vau{`}WbeVa6*"O"+=4 9.rA ^4PMb^:A@' @:@wsq*_FޘypT;\bK&$$kI7p8`Q`% s*J 0k$<6 |PVo<;Ė0[lQ⇓l/ DV{aIz.:B05ՅAN"j'UX,7  J滣P`=d3HF7Gj5$[<"!#,w z J ì,ta|mqx@OG$);J$v+ E>X番nT&j0,O ; D (PDXҁCЈ`q S*ՋЕe$엒,+ 4x3F ͳ{|1,(΂^XpCs*t_ND (S̃GS Gځ ݝwcwnCw#T:l܀!pk?G_Of_>_ !9hs;߇]mjw  #S F!RFq!x!!&P, B- kDd>mk r(/D?%VܿȖ/:r0CaAT4f?K*BŁALS9UVx恑dڜgfP7$۠l; }B^h0:?9#,Z?~ !sPy)a]nKZh{&=ˠn/ L* >rmБdd-'\CN?  ntQ~o}${[̙Oܿ?3eBr kȀI #D+0)(;GDw |Hض> 9BӟЯ'IB~onqX0`oO){?"7G~0\07@+DЃ (iͬ@Yw(\~\z>7rkvBizy)@9FN( ҟ&^  LϦ`f[ER>Q]ҦJɠ _W m (tq+7@h756h٠+@?DDIND$ v?/Y"pjE?Cq,@uqj ԱnG+|$kp-!] ƯX ^ U/ (е㆗{Ve@(iU`\Y5`@4ATY |XDB?1`  MX-'{vm#F^=􅨆  H{v{ޛQE?q/TU ~.+ԑP X% 0%@ʀLe@N'1B?z ׂ. s\R=ǵ:J`3[ag}M\KZ;?q1?~I kny3\tZƛV iSɗmSjɀ@ J71C%;y.}tI-rM:s7ß0\ mA? -M~q22W ?j|3%$Y:$@OD в:@o5@U"/ʕ)5AP {):ёzSS_f~.p<.GŸ~g+xF@P`ݷG{gT3?& 0 P_)&J"Se1E?Qf+_{͜1{mRgy{if9αL 1(] gh(VG (kIO$ hPI¾*LhPLGȀIffQy/7ڗ F_Õh[GޛQ?O?/; h1={W!ۭP PIQ~ s P%Fe@NƥQr #>8^-rW1;|~gLuHm_2ÿ#T LV/;d`*\PNBR;y$@iN`.Y9%)PX9PM`W xVĄג+]j@wCuUYꧩOcS<(4/j+  $ÿYϔm"#XH@(JHpߠ04yNJ/0F^羀*zr*6/k h[SxxF@&JnM {TPH=Y K@U%'@f)}:j"65 [P!%1Pb4+zΠRxс+~tG/wI%VE`Q~%-j(5gBZ%D `7/@C5@MB LJ=(_C诈{:|sjj˕nUb-kLSj\} XC: ~hh]{9s,)&$Q2P.jB 'b5A0Z zW+L$N 1P~ s~Vwp?J|W SL].W!Fl3%Ad6=+ D1HEIY̠W~|$~bG5qfM ~lRKjm{5 2`Ywm~#`1g@Vo1iR`O C Ā%lQ zW Q>OǗWP_nQ|BI#f[jd";ӽvo%>;y{0/@k5,P[5S4I\@Vd$A0P=C?-eMq h9~=觱f[jd owdj~Y pJ@b5@}@UD |X)UԄW ĀU9HDz#7EN^33R n mhޠ5P+I@PL 0 PPdɀ6!P11P5A0 yl:>>~|Gcs_kwlIWPn0xP_׫ϪWn[ :-&jU U^^#R." +8]-Cޕ𸵅Bs2+\a7 ~ur?kq3W P?)m S_ksj_W5@m ߴ(.(<̓l!`K| y7#B?~ ?m lGZJgFg© UO P_,T$?Sjidϐ2&r%15xc\Zrs=wܻ? aQ>O %Ų UeßHIk[ 0 j̔-U!`HRŀWEA/Z_i]{_WW VoV PFLO H\Tfh'ϻTPS5@Z)0ITD@*++Bu!%1`Ɂ(8ᬇX9>~}>Onѫ25 ~yƸ>r( *ZXro"PVW4/Ϊ ̓e!e1Q=3]-䕂'yGkc9?.\ dK~qo Hȯ2=UP&PR%r1!5L_|FFFj]j~VSj<C =  #¤713T+MD zC Ā%lAPmh !ׅ]W |Vy_wsߛvqmXf=H֨*K/ Tfk;7wZ@kP0OUx@] ĸPTYI`a~|^!(_K oZ^-Y>#F*ŌrU>A'~6jm{4 fе)*0YX=Jl-,9" 쬀W z }f*]-~cM\&ޣQo0#?Ktl_V T̔- F)PQ N`6M.YAkNXK/\ k },mnǟx4 Sg4+Zd:WO d)U0Gϯ+F7f9y_ -}hej{粄zRP93BUB#S^(ˉ" xƠHѦ!.yPJ:VJDF'<ZЛ%ynAL"oe_HĆ0) 2(}OǢ\y^sس_yȧ'Pd/K ;K৬/)} $OqfH@$#.h8mHS:,D4C*2•)ͱ*O&!ܐCɀQ!U1J < D yޠ~d_nj+Da7 =Qf#xF@&P]Fu,إ0"ڤYn[pPP A`&Q΄Z"uL-zY!@Vd$!Q*,ʡ.m*ϻ@؛j_ u!BGIx_H4 2б< R 4DġAAm%`%`z" >1`$JDio9xsMy A/j%}ӗvc2_ca Kdl7"&4 6~ŷ@,(,ʜYGFF!$ N4ԺCih Hɧ$|`TȆ{8B^)z2/w{f ^$pQ]$6/;q/$ /2TjJVpLb ӽNUЪCEǁ +TysID]TCuQ{01A/$)CGPɞ6xҲ}0|Lqm+<$Kj蓥tR?K Pk^b2?$ [Y)MeI[Apt% `C?1$ ]3X,x_G@&RQ,a*d+fCw ktLF;tx0~tS1? 9.XbY?eX%WJ?~ s #?B~OC>.;cmڥ}M.;s!ϭ7 b"iL5FB/$#;; "CKJmg' yS$A N_(((` YÅGz)e V>X^ *T^'r1^sR LH fRO^2_SB_/_p#bb 3[Ċ?C> LE=B-`Gj Ź4IB]H&1UeaHu,D"\XD.齡UZAi'A+TjWErÞ;ʧrIwN~6<@9w (ӞBABgtב|[$^t>@Bfe6V񂲐tYcyזɝwQ zzrBiB&V6BzBB aH,MP/Cj3a }4[u>O=(rcm ܟ>m#76ђ}9^ɠI,FYH, ZHaiXyC{T偊*miTE9k7l Ci^.#OG^]~>PReE<@Y@Y0SYW:v /$@蒵HJ-{zCUHDVdִ>ZZABGsHґ< yD B#Mh*0[F䀾Wr>Q0QUlfFors(I3`"( ( Ax5D2Atj#*qih%Q,ɥK=HX]H_gɳI&! U{AQ܋!0m?hF.$g3ԒM}d uv&M,T.M%P&%"Ҙs"R7,,FYt&Q ۏnG)ey#tP"e&y9?>.PPPP`wR?ө>)]!vȤRA liݺ3ʂK]Н]<*}6;`%y=^m!^vȤM8&;47gjiuy}Ҟ5>b.盟* =S"B ;dR@۝uF${dg( DQPơsAS: .#R.B]\?TL%KvH ]W&OiiiHd_+( ( ( [湠762¯MR!}ZY U2p@$}ZtdȟtT8:'+$+bR>:Ȝ1X)vHZ *?7;mB_PP&,?io:vhP =Y$J} SCVJaP/Lke:B.B P&^/ |C -0Ƕw٤`|;j} rI; k%@ N]FYHEUe!uUtd? 9pK½{}ݖyz-M|++p83(6q r.CJ,'8$de"( ( ( day}"??7B_6 !"E㷓0+BG$#v]"8lPi2[zRAߪ|:>९sq*p/Tw5JI+Sx۳dAY@YhY裯y94CSv` ء+$$ɯ$ [!Zh & Q:2PE . 1<8SAc tE;G/\ l?~/F~\,>z%qVQWL1K. Ȑ$W,LѲ@v"!MZù ƨqBa-ݙmduusoo7/Dq5!]\+5<:a!i!\:+@G Nd,LgY_םM#khx΍wN\US:o< o] o=zp Iy9pm0Fnߛej0!Eq'@./p-q'VP,Ha.J>0\6 #Gnz^MEѵpipEwdT* J apE%prsG<A)V؃4 4̪fiŰr l]N߷.:k+pVMm#Ol#'c'\O›|/%8I`*ɂ}er@ #ǐ 7yGԃ_-fzd@UDx;^S} Nti* V?z<:&Z@Y. tc2o$:w\_۪&uN١ Hp7{H{a}~]&0ƞD*O:T*a>O\ 8 Ag'5y͵u'DgJį, xmGɝ5Aa |ImyYb$i2" >+ӦcJ4x}Mnr1gW0>T\x? 59”ã{:g4_n&Л9.n8+i _ # $Y2:]QԞk? .W77Hڜ[(xjl_<./А v|.;P lb!)2?6Bl aF,4oXFtħ'KvGB|'7 ٨r@o(wk(c?zN@d=exHg W!Z,H@$+SΧ C4? A/ǐ(-t$k%b&a'Qow}NXZgm]~9+o?X/>I?~= g)'/+ 0CT@O{gڌNycH+Qo zo&TXHxR3zП낕.XScm2xj8 }/UE,/F8d7yәNl3o $ Yt:)C.Ԕw' auQ"kb@J2@ß +#XY?&ןGIȳxWNwϝ H<yWG  }.=]SṎZ, <$%@mU/'Zԙ@_Ʉ*<)ӛ|0dtOU4E.2.8M20U:ѐ˄L>rx:RkCWHIKҚJfAJU,JNCNQZ% ;$eof I,dJ͋_&N^JNXQ"#4@2@O6๎٨:oZM2߻pǻᅇ7@a  =v28e\b[,8~ `w]BSV8 |<9rI]@nfm[ɇOTB7SY(Oq]XG)fP%feAu]mx鶹p$|_}l3{e?|.iǷ H@D *q@ xߗA_ր%< z\7=Il/*PM](ʂ>9f@;OklyN(p% QP y8>7(L_J^+U Њi_/}CYxrk{l;s) A ITB=&JCx1SݙRBE4ђ~$@2y=k5 Ơ ~tˀF?Lax#U &w>[;]a=^ðCR"J2eړA>֔m[gdyPPdEn""5BW *k ~A" ;-"^ 1gȞ1}Cg/t1GjC (vHjfU$+GIhJW& }kJok(?)RPPd`,Xk&M+ßw_ aF5C^!mu^} |9 @Vk'N2̤̐B%W8l/"#!ee%'(:욯E4W  ( ZE t'^P&KkE P xYf'6,:  Ńa$]f*<3 3dChA0 }N'B_xǃ22&[\DZ?ξF\S'2/<&V5[o*/&V' Oy_'`5-'MId•Cv8`Q^~9PP8e`mW:BgM~. otzٟw@葀H< v/([?=pղ: |424{0萔@ lQ KGfhA83,IM}5DioޑeeG6&еIK?a v# ~_ (I@H`g#FK^g]vB:B :$UpБ8 g%̀+Dm\eeG诃:TFW٬\GY~-{pɀBE @;֎ c*四Oj}  :$%ٮSIpv[Bx"Ѕl;2]M-V[سDC@&/s*MJ~F3}f!`ҔDCR)O^v+0K} Ǝ?K(rAnt,c[C*_ D@ ?P7_#xhPSZUd48tƲ `$eNT YXh[MފWЉ\q2t d`UdQ~eN\V ="#R\38?/32#e?*2 xRD ỗ r3[u}]ypd9ThX[Q:gR (6݃[xN9"wC_~T]KOlJO?_1!`@toR_ P 8"3w{?AGWˮ[dsezHRV c ~6AGMNxXG<B.&}9^fBbJS:rqWGgROSP, `eB ~88& IcS}F.g%20e`97rb>"MnZwPP0'D8eF[l3oV_ 'N 3<U\kԸ{{njT#"p#(U6rM{8=@Ho_7?AFvh:녠ݑ^ar@y^<+K D+s=@_/ ~O-"`Ht P|mWw1v]H|=O1 G7rO~s}NX5^:;4WB2}2/G@}6f+yD x'J@ԔF/ltMxCT8+0s>ȴqi[C E= mNu!( !ap@k~!B7;m;l7'Y~M} ɒ%YZCL٣y Oz__^6%pioieؚpto9WPYWe{T+Q׺FG]+U9)D൙Gc[,v]u34FH #ߑMPWJˉ_cO鱻tҀĞ2e> 24\K?Ag?wOy[ه6hGFcwWgÆ"p_u^'\|ISQߟWxп/̓@jG2îҬ1vB:'Ϥ̈́Jϫ5(Z ')qcA ra/M2?q\ط,9nѪU8x{S \O AF_۾oh.G VJmgKZ?5.kv>(l:r8D&f] յѠ'#edD?T腓H$a.&ZN%W JW;Ėm.SVWG6ysS[mppE9 uJ_klaE^xtyU(8|Y!rɆV vP}G1pB:odkح3Q@W@")+#2ځ\hBlm$ORA l&MwB_/(2p@Cq9TNNy4i-uP]SSS2q'e@kY<n&IyB?O_ffGC<ƀ ͆Omo\ Oܺ=Ek2[WΙKkrc[Lix&*@wQjȗ+Ta,xci\hj<?ފ|_ѿ 1 E!iyP4BK_,L x?ut_Zr{Py  !m+|>db8O(r|S fx/lzؑ,(uLΗN#:H Q&^!!%t> Img}NFᭇP3*w3?n }PT*k@K>ڕ OL к (q4t_ b@ m1Ğ2@_!3iZ4/ԡ;J$Sh2X= t\}J{CC[BH/#ZgŸbH3:%4G?0?& ƤhZ/pci(md(}=dg J nC7ܡhB,R!khǼS`Sg7Ûp>0c߬ҿZK#2"*FJ+;' ]JJţ ov @.G22.+NJa U*VBE@|"4w:n(!_MYpަ:›Ч@hxx\6,5q>?蟳D5]7 )\y^KV.ǮoIAc_cDXJ{aOu 2b$Ivj2_G>O@PLmCgBvf&22CpΜ ":c$`9TEoanfg|=>>> 8~zx!8MpDŽ{)0 .ت]̘7ZW+G5?1ߋ"Q8%*i }o?VVraTg( 3swCe)t^1 EsFM^ RA͚Cgfh ̱- ׺Pnn,sڡ')5-(X!+RaJ-Y.[Bl:8~3PBse5 7wl⍻ &dP(-ˍB2@tE@Qxd~+˦X:B' CJJF!L8vR  @x?3v̱ 42#l¨v۽vX5$D_P-2@mN'z'3 ް^i-t3ᖵPH_kSYol1. @w_eG9I8 hx%J~x_&-PͳrV3t~S:hwcOy\wi+ ?(5w٥y4qxbH .B؈)B rpFGH Xr6PJkP^58W(PpW@E<\ҪfQ:]1m?]6XIN8b=7Hs- ~jr蒁"2@ z6 C`l',w "64䗽9R^aɘL 2,Gu7VȘgyKJ$_* @~ +(,{}߰3k} MW\ӕF!lz\ $ @ |+vJ]LdBЮ* /##U$ԣY 2oCԾB%BOAB ܲ=KYsN[F~4k(UG($@C5 Pa%V^|.{Z0:Ӵx>gx[cofF3e*2P̈́ k;.C AnU<)!>7 rH8Hu1@5*e ۲MwUDŽlAYS2+N4}?_j]0)(WU#ұç ouoaSHh>~h9Ȁ|7TkKv)GI!6[ @oCS0K` 3yҿ?*ߏˊT 7 0ȹop5e%H߈2W>np[&@(^ȵFnepdjO-k~ ]1,t)ʀ$C8l0R"5}pMmҥ`pN'L0UdY8B_ . hnLT*{_ug19 ~ 5`<c7 2Pj? c'jzTadB7jְ/TͰ|,!aar2><`|:h5$\[/?{#f @Mq/<3  Z6w)`D^W: X|u{'uM8vTKY[]N2K\4Md@k@j5",sHa2ޭ(]Q m1$|#_[ ^WN #m"R[${_y%:0x(S\*]JzM=e`qKs07$C,W Ks%ज़Vs7|_^~,!O`#VIOWǯ~&q(Og@Pkٮ nHgpX55µxDX/n  hu"'a8F(@6n{&ݡMR!wԿ s @r qSs)Axa͟3 r)ZɀT44)/}S5!H f;wk x-;X{t'XWo3 wL#0mߥZY /7 `6o/ @yV*@f~j04-pl:;-IvLS`dZ9o9M D Ao/iu`Na ||!&Y|vp2Ÿƒ[|H6:HI8 *xJZx *W\!nt`٧NdC"BƁCa& jm;3LT@yQ˖_ 7GŸw;@Fv2lX_TnDQ^:Ka@^c= !(7nا]>@olT,0!Q(<dZ68GM{(ɜh:ÿ)P[+S tΟU9a~n$A~xʷ$)lEк 3I<T?w5&Qi~-Klgr @L H_m[:b",4{rЗ;.$C._+sUٛ}pM mQLK7^zN՟5Ҷ{@~2702`<Y&6P xdnDTű{!'G$>F2e`K {BUAUxk+)R OTg~;0DAy'@8k(~G]i!2Pƹ̏fU;'Z`@g¿77TV"K@y`25 pW#:x^a{_QjdCGF!,;<]*N ȿZ8_dɀM>W_W@y9!zeD 2^ƹժ9 Wq Wo[B=.4 =2#{?hI.&;|ʑܩj`:[Dsοw2$20?G}]יϓ!U2!o.'6Mv4a#?F@O+^^yEqaxe TE ذDh{。&aM1{,QT-3K[9gvf[uƬK+|QV8u+'"uQpWΫ楝܌K#h '6 !mmUH^P*3ʰ.`.be@PM59*@yo1*z%~p{S6 '? 4D,ZC@Af [K@ꪚp<"< gSs&$/ [h*/>BM7ĭ3/Z:=3C[g[Ԑ P妿A_ w{ipf1(V#S_r]Da>_Ha90dERVVu D~7/hڼR_nR3$<8:8IpE ;o7= pL ptd~৖xy'2"U™k@mkϿ7_:9 4*``aCn_, \X0 i&qhC < hNW@D! Qr@H9 8QX@pw ?*+PXW z{ _ pyj ςN5d5Tu:np#G,!Z`Hef`ܳ$i &LpD@_L_Pu O9/?jE3UTV *1DNN0 pzu˼À93X,[ IO"i!yq)Zn-|KMFTy(1Z{?cZ gs8x aV'[H.`A =LnG' w]ѵ$ [HR|pq/ȞۑGAp,~>D'L*RvyQ#eZ_?[ )" lY ٖ8$/cY:4XG~my 8_$tykG,@|_I7 e*Thp4VS?aHcŸͲ€B1"=P& nY͟,"g%aqppT;@Th@iR[`! 70?w畍BK{\\l!}\~Ssjv*s2 K 8uՋ.Ia@(UDX3A_UV5MM.R@O^`˭; HNsĤHB`k=.8T\5Ya t=;r y=- ZO^+y_yڙ*|#^ZZ}6mϗZgּ(EM4P auBd"jV #LYlq,k"|\9 (*Z  _P`ڤ20׵щ<zf! w7EA ^*jp̋NsPôc\JxYLh 20 Xpl"$\p*` 3[^uNY> PB,:d ` p p/!ot=:4ۼ곤h]j@U~c_I]2X@FSj  B9eA€Ea.?-TF&-[1 ]s$Z ] dÀ, gAx- @HW3+nGj@ @U<嵤ê^Oʽ`2 @eu_phY x+PDH +z9B%#uvn uU|Dx?i%m'tȱ:3~c̟3o'^tZKhϼF0b>_jX!SHIGE + - $pg&`{7ejƹ<LYS,% EtH _?g҃BBW`sR/É?jjTPz1QqƉmQr. y ?NklU=PkI'|= tihV@K̭ I7r.lbcf?߸UMH^\Z韞_6%!ssRã'UuLCh4f_yr- €REu1"8[omF EpkV@% 9'R 8%lXp7HGt5T iB M矶:n w7׀?4C˛S"R7Ҋ{X( .[<ZzIϭ5Ƹ@e0=OV-Yw7Apo(_I[(T 4@Lb@Rפx>%qUsB/O95ZpyQtGEE"R( A:lk k q"/2#k*"M0{]`_^Ȇޏ;.lG6P@49 [I7Ϸg;+%!j+2?**JE/eP@;+/]'S&k:Ŝ^jda~ENn<o*5ŠJ<)Ƭ:isB-9g"BN|MݳEisSs"Roؤ(XKb:E; pEsn]1XB@}ºV C!`oW@/ `( 6~q'^u3OQK#%eNAw\\<0PJ`[H6Tyw`(Չ;)66uI0ih`(\F0JL_ԏ5K~|'sY56*b = &VV[K~(e]Izq0PJaWD9*x@@C?}Jy n[JB! ~"཭!}[:baB\Q`R+ӯ3п?o_~[9PkSjnTDr,+@V`;~9xq䐕%f@K&Va Sm䍭ɑ/yskIBiA YC̄$2K7\ *ʠJb'0+Zm[|@kѿl?N`;]˛@-.w7C%-P# 4Lz;ŀBQT_:4% fH0c8OVw7Fy.M͍HpАat[b@̒0`Ɏ> f* y"yv 3JYi'{Dʒ 07l`k<7?6{qSl-<Pt@n Irr H KS?/dW@c Jy- ?$@ٹ2WJ╿ 9 _`drp"mm&-a- ;> PM a`c0Pn&/5*5p@z߽+> !P( .TF`,>{Sokk7u-!}KmH[Uh꜕H {<U̮ .SqcRaڋk50-wZ7f@kB? 骅-q2BO3jD@DQ g9=';AܑЋsIy&5i~NfsOOvG@pO$/(!j+` **NyHlS`g%P߄o # @M܆`@J@~:4o{kN j $B }_}/p*W/W5s&zFΆp{]HE_six"u`mȩj# Z8`Àe+y.0ޞ@! G8 [:4@&C5s|ꊐi?HH"0 N8>2Zn]1QT-%m`.O-o;Y>( X3HzT@aK:\@QՍ1K|R%uP|`a0@ވ 4fO̫ R5}e @A0; ``E/-QoTd0F@0!K*AZ7۔MUB0 U{(rQ}[iŀbZ 8@_0M:>!uuhrќP{cjtTbt}_H38duJ`:peV2 TRSDX ݐ;d``[.o+2@Ш`PB1FCة9>l~-YEB@с>2y-/L{[@ښ05b./EsQkH T3YgPKE:~rkKwTp@^!e0'pz9@RpXpGW4'j4' py^oR" ; )i~=x> zfByy~~ Cq`ښrBhxs˜h|QUK|#׿]P4* + 0RPgIX& T T2^<`0g\1nXۄx(@Q*Ixk삜2 3 `.'y@@ {'hv?<^xǻ<ѽhqku؜{yc^Ih5<5=*">ϡ$ct8kEF 4n5cz@F0즂`d `-0POP6o7Uô ҷʝȅ;L &B(`\ & xgBFwsS9m.<DqȊ$Ⱦ#a.Z>r3a aF̉(`,ko9sfga>.Mb}3MV @ 1A '1_G0q r \W-} '\6} Jt@Wt*bS+~PYW-@)s6 TA/@GBd0( lΝ7 iQm @Lm?4"ŀlKຊ&#}n.* H - 4r@bu"6~;\6n3?ύFX߀@iS\>€C8fuՖCB8 1aHY 0@pz7w \8P98έ}W|O16Gʼn 0x@@N7|N?}M_pįߛ7y sA3  _ 5GÎ0u|` `9W 00PF; q"`k2u6V崼٩kBkV0hGHJZMk ?x> &ti4d ?N`$~"7li86Pٿ y> z?wSPkqsiS8h-0anr@WDȦHRl%[g)kĖIKN id5< eFRITY b m45))j+xp/wCx{?$T8j`hj` &r;DFPn0%krX?.3%>:4@*;_/ |=3Ȣsrm Dm& A `\>揩-ɟӨAڱABi? [.ت keBl*WHp՘LQ@@9¢@>j68wY%@:|`X\ YŀQG?M,1O`BFoU?==s'U>FiB_|_r -.+wZ=3+2K  , e[+"*PgRQw Q3b@˙ b@@(R P`5Щ47[ J>3wRC4}B?ew! TCJxOZR @>" H `˾^ete f 9Yj O8pg0 tGAe/Q#-Upew?yQ0}i8l`ڂ_Ċ/$'y; T D@~>8?G?zX_4;0>Taz^ S1`&U-y~5"C/@0 x`@谈SFRRٓiڑ|FI߫*O}܃. ,cw͗Ve d,E |sIaFzvY 0KI^ jF׊T~/) B4\:L`B~^{n"`jv"o@+~߆kn _H`$d F5`PhɌ55%tג`!ɼ baNiG.6Si|1P|1Z3z !B<ǁ@aOR=?ڼ̯qځʰ'tv t\ V*2 [6 Y G#z@s<)# R""8,dJ"XT,.%T ^ ldQ'oj̟tR$DȠaU@򒸦ץ36% -LR[ފdOD1T52JFzhk 5A B<82?jb9ibC >m&G'@PAHY> %90IV9!gŗqN[j6񽡣@̜*W v]fbveڼCɩ 2ۤRV`ldaE+K)00A0t7RF#Ő6XUBn 4; %…hɋJՂ: p`$3}ܩ g颦l=‘Pٖ&]?m}CA!nfeE1 w-Z_P*ɩ2&Kmx@9+."1@YJAuOE1iLo6Bp`I3y$s,$/% ˺y@i48 q;b??0Gy㨡ڐ¦eA @C @>]}E  Ez}g,S;Y`; *yj 4`HӨd1:eېq&0jE!SFL!1 '=h[! c uH@_;2|oEVx)XmDEI_64M<-fU ?|گ޷Ð ~&.Pw5v99`V2 Q(w?.4lS ȚQdQNh'*`bB@ )a7-fj*5KJJOC)'YiEƨcbAZCtoyO:' U2W̿bq\Y,( er~BrmCK@.OٕmbA0$_qƏ??佮fTVIoKh ?_?]ۜh0-JaCu\;N{T׽@좔N8Z=֪"#y-]U{0`"Bk((f~`X8-0RVLQQ/D큮<  X{r>⅙gJnQIuf,! #lm)A0TheR}8rK8cT :Yo+--B'g@ikz{<]Se"0BFiu=2E$X0V& 0 2u3XۆŜCěQC !K#~G?5CޢK[Ǜ1|~g).|g-d@:x N#ߝcJ@ig5t=^ߙd"); XsG\FmyBQ;JA'K|/P 7FX##71,r2`k:[5|>ßc}I3O 5C9d`To5.@f_44zA m|o$;jf p${<{gASZ~*ލf g /", P [8\QfAιAP=iQ(H pFAvYQP˃=K z*.u|Stj_2`b Z:ҽ0|݀vT@bppb17y@(@e@VEM@x  puE{ps~ 6s+gV1͟G+- >ppjT#f6 NN5DA)Ў< $ŻKgrF0pjE p9 B[y@PJ7.zP+)- nzR)X:sބ;yX=% 4d z`n%nUV `xFJ^x wBvmXI _?ω_a+H>hlOj 旲9g'5 LD\BK!ϯm- -wOl6 YuߓD yGAdQ59(~̊9Q3on΂)_< pkM<9 !"_H}6Vfn|FJh:W}jv:Pd,"Q0 AQY˘?a 0)䁀ukbޫ rAh oܡ5!lJ(L o.g?洀I^( s ?M/T1:vIMۃE&onSv> COMN X C`Q(tE*\¿f%RYаbqAjTp`I3  oGFf$S!~A7[RXD88;viV cscª0k UP#hԙN8<5r34]Rbh2s Ї2T|;ZK TQ)Ul v'51vvaO hYƚlAO5A>U(*pJF_*y y) 8v4w7 s]CMNU^> Q cGnS!uM+eQ, X3ʑ ÂHSng\Yd֬"&`CSkg0 kJ@d]ؠ5x;@}K dGAZzp/l L{m+(4z!F@'?6,o218^ݬr2=SW^u۠i{8w)ϑ0 ;\`Od/j{K_>&UHt`5W༩A4:*;i{4EB x=jF $Nv׏ pX%zat蟤-PT*n -h<g5 Epi4¡ [vXpj e'ɒ*2Ķ'78Bک8y1 7 䂁 a`yҡf fӨ' 7u.@(٤6ch{)|=@ɅKW&85, N{ҝ$lQS/P|d H; 2a_]'uK+GB63myA!Ҕ pc^~lJu ]k̽pm*t? 8Iw `+]K?Rɠߟiٲ0v׶k̩ {oNot^6Ц?m+yǠjT1;b{&nw%jGr= `upmNqio[Цni 80OJ $E:m=% m+>5e߫c0 }]!SP@S@, 2qsx:.y1cA?h@"S\.Ȃ彿ͦd4 / H X6saMk*h4u[w=ENc5(E\@* 1TLEW @T4~/lǦU臠-m%`5)pnW՘OԄ)&gc` U/jz5.MsCiJ4 a>=L̰y jW_` KU u\\{@p58Hj ;6i!{2/ uus3z \^ 0]>[ҿ2sҭ%9XqTZW 9]|5=KPAeVP"+&׸5b ]N zߝ}!e۶kTP=e|pp6^C&/-&0BV~ZSQ(LRcހ>w (>(q2Cqt滾l@E͘, c7dOH Cӣ]̟ cO z6 Noe0{G Xh :__ pW!qJv;+q;dBokfL@Ζq4T n7?eY}@@~!ʕrΤ`i ,o)ٛ[ x@T^iy~MgPuCT ㎭w71YSjљԧnMVS (4jvaOlk`6 Y0}3]" AD5Ȣw5SLcqP!uakzG̵m@ ŀL2uks QmV[(PK(5RŜҺV 6łik*sJC7'ݲ[UN.ec!h+Yo pJ?]'Pe(;?cwDjkT2y Lh[Y W ?y?2o@!PM5W[o'H>nc@C%.h}݄rN@GM쮋Ѣ̆ac\jhh1 "pyVq^ , @_ŷ?L{#109v  AWf}ހF^~w l| :oM\(U~@K_q~bCu|B3Pi$2'kd3 zI͙,*0΁Zm:|2 'ߐvh l=^3@@pBJ: *~yJvڑ"T}*:M@n8RZD؇򓺺15uLKE"IUA@FL` uy9f:XyOe=7H~qos @atHO}:[E)|, Ai4mXa#O b@@|~NP!F7@U}z}AᷡKPc@|:쏶a0-jdSh_Gl%0؃%﹋0toNM @ phoA+ꮃ#eA8 3ь}#e)8"U U&_S_ ~Aɮ0 wXՉ}\Ԥ)=h(QrM<QiI"`f@4̲W_q_0`8:sB7vozS5WԘvj$^V)X0@v4tweK`4ċGߊvCQJ(FUcfXQ!&Ā |N c3Re\Iv +>U* UVOZ1̊`H@Z%0 v*Jl)K*!fm XG0)UL-?Hۄ!@n3vi^l_DQ`{\OhI`W1 s͢QSU~nnE?@ b )kRj hFTx6(:ٺk. | >[דJB }sUͿ7ݛ= w$)eZޡu b02x`Ąk=toA@* HCjU$^)w0 l&e3mK׭i2h{`6ׁR6i@ 5zFM@vH:[`.5tsBw![kрkZ(~}ZGʅ>O;6iHn}oCiW]=8:wKN;͛V/YS? ծ(&~h@ŠJ4#`˸y!@$ 5ZPJy=c͙!%?;İ5*ԘvjL)Teq'O.7`90,((`.lIPFJ^췴25^[1р,88)slTwdX<h}Ư`b4ԿÎ [0{- 45KE>G nC~Z_bIxc c>59l=àq+PfT}fS <1֣^Es(@ Q(]Sȣ\ лY0T ͅ@J` 댿e+RHS'4$n8Kºl02BZPbفԧ(XL!Zm8Y-jo53+˪ӑ@ -.T )W>n 1Rc!85<k*Mg7$ 5]]H˔B 벚n ?V)ZJ%7  eUcj"% aֺhh\чj- W״ʳs Z%0f E[<w!bC}Ըja $BH!Q J(_|ܣ)gO cZGA !S̝{ rUհp%b_rf}BK>{Β~Nn 1d}!&ϐTԧ(X`5~<D@s)P(A@q"(5ϝ1^ַ[ `!Y- p+bwRR_) )'_6l,ᵢQdNgf¼*mmc|kc3 $ e~l#@K"_ܜF֬{Y@R_2#2sP*E͡Yw~T Xiކ21~z=^ !sX1<¡EH4_1`q!UkQz:6@|T / YK p֩a`5ocR#3]b_)x {^y:^&k  ֖ #wU \\zAQ+U>k­f%{o#P("Pm'c͒{M/8 t\fԓ((pr'w-*kK[@T0`FHvCS}syZCwhe?z@xMg B7s-$sPOPQP&]hQXunH]- 'O>{>.X}P}@Vz)fI[!H̳7"!\ @e0pcU+h]ÏX`]#mL GP^긳"Fm\1`># .x\Q T)Ͼ͂!B)w8Ԍw/GtMvlW:ӊO'$WbB5D@AJ# @ʚ5MbSDί}&7nM8NmvKF1d̥Rx0 gH`&coV"M5ds џn \ ^{<Uq#N)7TmKo,a@d؇9L_~0~vԇ(é  ^C.iX-EHC^?u@T1;'oG 9>ԒYj7}[`;zI9M}5f(PمMm%S[KwNUg0asĢvсAK 6 uCPPmZQ-*͈ '0yj{2[Nd5P%'1L;?[V`/ *?7!YEntqI x79zC 3'{yPn;e$Jd FXyL P,jDL )PٌRš HE)58L/@×zdzA^g';sԚQBF@/ @Ps q=CHGr`SŪ@<8Iآd`\NR 1O';mO@7(#*Չ]˄@cPuP}4_p)">N.F=-@FCp8*ҍ0フg@ly]h@fjhڢ7d;z?;y/yfPϡ`u jÑѿ_Hja4tMҚ^u9{s]M0ξ"x틳d7S3[<Ty^R`u$Ѻ` TKCQ%ߛG;![ ]:tۡ!g__ҎFcak(XJ232KuϤo@Ưzm;ԙoh puk=$ &tZ^"tjY32z  TÁ(P)AG y'{cv~m*Pvֿvn,bBKf@ H} E(P)M C.J]kؔ@~f[nkȔ ^ 0}aD1Z> ]_(PYL{ر;dfj)0b! i\K`0L:1,eFc!X dGEwXjGqчyz ^}'캲a|SJ8)8HCb~? Hц$|+`ֆ؄)D?֎9FVQ?%3$O+m̻xf!g\DJ02$oǬ.&,Bd f}"`3QS(ؔE TR ?uˠݭw\6f@ߛ2LLVqȬHf pI9Ա3@'RAM2EC޿ 5ПC;E&X$2 BW$1L1;H=Lv@E+;j{?07m*}QLCa"5(g{w"mir27wnU`%lvmpN*5k⤯nW<mLHoZ>ls=MVobCn"U{G@7%l5%O T2o"|Y5%u_ 4G\Ж  wpb[ h!yl|Z`- 6-Q2֞%}]k=EJdZS Iq`̼6H 1f  !?,NCH;C(ش?@eЅU2vm)mbwm`b`ml$~V!Oj'WZpױ!@]!(PŚoKKX+Aw7ìfeu*jM-l`*Q@f 1 kz{K zpߣ LI2t: & ;Bi>vIU!*v,&a5!g$iD'eW ,C(P5}Idm#~ѼH 2#}m&|#r0"Xg!(PYRd-1 Uښ+G:UZ0LG\Q @! 3H_끝JB_2v=(P3h8~R&lQ["I2_,l7U0\wN`ܢAkzȚWkzY?…63 ՄpH1N q(PYYְף} &ngy[B؟ldS)LS2sv BP@eukz^5]PliC . GNe- 3=JAz%S@*m3ɠۯ*zX&֖ eN% 0߳{u6 v^?(P*P[Qn7s|o]{L s{fE=+pڌxh?(P*5^}%T@v&fWE{]Z0 _Ji]I50a b  dMo!d߫]-J .['ǬUYEǭ~G0 @k(P)KB{mwMo;0*1AO*遭 uHX TAuq{0dfy"a|}P=g-w߯ `PN`CG %1ŨP@e1%9קmIm}7f!_XNT@kd?K ,ɚͯ$L\dg}{Fi ,C6緮5(tG!vK `y 8Oc de {U+aMo7{E:C'#R@`yj3 ~ev7R@e}kz hMo@$iPt+sA tM7ZZs>tDh3-j A" J'բHTbP$D"!S$( *Ϲ9韵~˲V|79{RVoƍCs'b,TӻxLj⛫(<* ^rkL{SKz1iYXcepx2Z@@e^u P6ѣcz0)w@6_\`W F!`4h={]0ߘ^_gG]Pi1"N_2F r,QP־7}1sLo3c>Џ3fP5o.F9k@ P6ȓ f^%=|V,pjϹȠZKi%?[3yC"au[Qpߘ^˜^0$ݶBjI6y @؎@AFxL3|cz{jχU4ă\ Ti9@z TzW{+ ƂQ;u\~ߘ^-ѵ1ݝjg-W}M@l\nqEQ;V8_u47{w~jʤz&R~i&\ⓙs\g$Њ{P8VEpX֛IR X8~iLT^z]\vi%g0)-788# @MF齯|j8 =q5\׍.)#ㆂy  5Z,[jV ?C@V$tK2 UdtD ƃ4ݘ^m-QBݝǫ e<- صl?"6S8T5GMRVsu,E^C -%% 3kAq0U`>sU齗z^gW K=h#瀚-wk_*9)ufP9~sQ!\`(W)l+D~(c&G9%x5!_$JQm1w(&gS鵉ӫk%1^N|)?oOӰ!>W˕o#?O/:}B8!ޚ|ޘtH#H(9ssBlCAXXA/JyK '"s2A7 `W++7W1yO.$;I?TjL#>,z#eawO-j }( nJ#_O\a/u\̝xM+-h͙AqGؾw1fΧtk:z.(MKRtOx[-Dsrq9O([r `:9h>2 =A5~Uj1u~zHS:;09PG\N?4\['A\o/-jhtuE.gi%()!qQ󍿳 \,7c7>?D 3.pI9K %5:4ހD=&S##8C9 ^{DX 4ͻ 7r/UFm'a',]CHl e` -0}Ƌ4yE]D)m3غ7 uY@Kh\[L ]vEB>ܣw XG;MWL:O='q1'cPA&@~8OߝߞO9K֝_1k4.%؄h#jvuOYtzk cz=x3!@~kΦ['KȡU/&uE5翜27sH ALF1czL! `3n x.?+,[l3rsG=g u0ÖnRGeVڢ7}S Tq](4,>y= x1&SKy_S*ʽ9 *+4ҧ ^LH-0ksXzgw 6܌dMfpl1'<]anSO @&@@@@ ցIENDB`ymuse-0.22/resources/icons/hicolor/64x64/000077500000000000000000000000001450727225500201675ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/64x64/apps/000077500000000000000000000000001450727225500211325ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/64x64/apps/com.yktoo.ymuse.png000066400000000000000000000125501450727225500247260ustar00rootroot00000000000000PNG  IHDR@@iq pHYs\rtEXtSoftwarewww.inkscape.org<IDATxy]U?}ιC[s*I*1!B}Nm yOjc Bn_#F2'dN;qT |o {aGT r)5UJ9L}S; o{'H\}v\t*7;F""NݩfjVDr3"oW>ߩ%[<&j6%/>"a,CC"}+D,ߓ B1&E'\:Wtu*?rpO.' XlmsPv-O[_ZcKͩ$L F!)@1*-OD.XwjQRV"2\S(l٪Zzݜ`:C9̾ʴIo_&9Bx-Z~k[ޱ_ %; [Z %G>嵵vnQ@~4O() ,"W!X'x"@\EY[^臧mMI"3תTh!pF*2-{qv1NܦSN4-A0 ^PZaSƊy4pT0%X7j'IʞVbe$nyI S*p % ruZ&2 } }gtq3!XU$`VZ۔eoD&MCIp )2JE)<`^z]}tSp 15snzCD' "1`Ph"ġPVDd:; \4G N(+N֑ԐYJxy #1#g"5XsCUu֩GLBJ\q(x5oJz)d|K@WrQ:0#ل4e"LUt[Cb}]ͩTpL @>t ;ū^K9#3OL ^}J<BxۉB^]ŒuڻU'fVfҟqSSx /Ё UdUݯ Q:h)#-ֵ2М/5Pљt5j0"03 zJs9&@A\5iER A$ Z3 K uSᛝ Hj3uV n2IyeVRPVJҞG€o %lw^;%/'GVgZͪUzw9P V4 *8[UZز_9&ڐD1ҁ2>WNk TB\%T}_lRf (YY•9 NQYZg'l$ KIxq&'b-hLϔ95t|+؇Vĩpٜ.sJG8A.`!baG3kX);s~&@%|rrb!ZIk˭]}N!è?:4ዟ]Dzeu'L-E ͯA- :8RMmv{ f2* R $8s%rZn Ph,\JΣX,Rِ/l`6&A&qP=E³/HxxzzsVr0LBnYL[yQW . 荸"@x.I'Vد.р[J8v _i(ظS\m=0_ $f SDxBsso޽dpY-N,0d3|7X |4h뾮\gqcp&'cRƒ~"M9U+ {.rsy' i "0Zr"@4 팹,iMs<?e0A "VHqaQ;=/8 Ljhy:^I{U,M~ƞ/^4*(N_Lג ̤Tsb)e8ken;xc'VV/T &(o}A$U.R4TlɲwV6pxGZ=QEt)l$і9" K@Ck[T$){FBDM51ۂMNz,keJShӆiĹǨՈ6cDVnN%"3'MΦ=lKC)-uIDb]پEEI%PC9s{ mi^ޱ3L Z@TRP.,4y / NJ%;H!J$VG$ db |e{׏'x#$Ay!?׎IJ8:4e5'H-ZJvdל@9<:c~;^[X1Б>[(<獢qP՞q@U3jiHol7Ts {:cc1)U]%S~sS-:Kt7GzjBm)tuU)!a_Å`{-n-o[%T]Y>Fݕ)`jA}]Ι8 T9jR[&H"eN+?@$ ~:.?PƕT2 D70bYbl@c5{HV OwWֹ_7DG^JmBܭ/U7#>3{U+$Rq't6|_Sum1G4Aid [W[SHᡈ+BQD;?_즡ưpn//~UOT՘3FUO$~<X>s\qX^?ݘbG0tPd<;~ OL^3G9 ɗ,8?CKݴkg&FS0nTZ kW73$VL|d'?yoOۻӁH#Ӟ/`G~Be72s##;0ZD8wKaXo(Rh4G^qތk~k8b.RjV}]DWoUizA[D\V\$p7dzB@Uw̯O/!ۂI{+*p&6co5ŢI2M̨CVPF5≍lU5JDfmX{24= c=%+Oo.llD?sk6`|2,iHyt'W};n;#I|ج? _\#7֭0iq4H%֔[~Eh2[RR%&B41êpG?2yY;Ub5+hPKHY-偐/ښR\_3aVP*E &/17 :okok+#G{e`ɬ.1qHfV=q "5Ã) x^!"T=g*;ŭů'-vNކ+~EЕzKVTB玔p 毚_sx}=?;/f(w+U0Mޢ|\xx1@|?x|L#|Y'u\98a޾4[KNKCo\{a=kg <5[ `%:W*Ҿys0|+A+[z FHw0NH<3O~ebOeSî"޸x91FC<+;V7Cd]7Oe,r?p- =#<P]{z抆&B3O|ePD²>s\Xu)oEix܍[_STT&xڿ]g׌_տ5೦ ?n(ײIENDB`ymuse-0.22/resources/icons/hicolor/scalable/000077500000000000000000000000001450727225500211425ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/000077500000000000000000000000001450727225500226025ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-add-symbolic.svg000066400000000000000000000005211450727225500270300ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-clear-symbolic.svg000066400000000000000000000013551450727225500273740ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-consume-symbolic.svg000066400000000000000000000011761450727225500277600ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-delete-symbolic.svg000066400000000000000000000021751450727225500275510ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-delete-track-symbolic.svg000066400000000000000000000025121450727225500306460ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-edit-symbolic.svg000066400000000000000000000015121450727225500272260ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-filter-symbolic.svg000066400000000000000000000004541450727225500275720ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-home-symbolic.svg000066400000000000000000000033741450727225500272410ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-level-up-symbolic.svg000066400000000000000000000002751450727225500300370ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-next-symbolic.svg000066400000000000000000000006231450727225500272610ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-now-playing-symbolic.svg000066400000000000000000000010171450727225500305450ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-pause-symbolic.svg000066400000000000000000000004701450727225500274200ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-play-symbolic.svg000066400000000000000000000004041450727225500272450ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-previous-symbolic.svg000066400000000000000000000006221450727225500301560ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-random-symbolic.svg000066400000000000000000000017761450727225500275750ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-repeat-1-symbolic.svg000066400000000000000000000013051450727225500277170ustar00rootroot00000000000000 ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-repeat-symbolic.svg000066400000000000000000000010141450727225500275560ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-replace-queue-symbolic.svg000066400000000000000000000033701450727225500310420ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-save-symbolic.svg000066400000000000000000000013741450727225500272450ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-search-symbolic.svg000066400000000000000000000006701450727225500275520ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-sort-symbolic.svg000066400000000000000000000022611450727225500272720ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-stop-symbolic.svg000066400000000000000000000003151450727225500272660ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/actions/ymuse-update-db-symbolic.svg000066400000000000000000000031741450727225500301540ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/apps/000077500000000000000000000000001450727225500221055ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/apps/com.yktoo.ymuse.svg000066400000000000000000000554301450727225500257200ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/000077500000000000000000000000001450727225500231565ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-album.svg000066400000000000000000000021631450727225500261410ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-albums.svg000066400000000000000000000014021450727225500263170ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-artist.svg000066400000000000000000000016251450727225500263510ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-artists.svg000066400000000000000000000014021450727225500265250ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-audio-file.svg000066400000000000000000000020501450727225500270520ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-genre.svg000066400000000000000000000026751450727225500261510ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-genres.svg000066400000000000000000000014161450727225500263240ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-playlist.svg000066400000000000000000000034651450727225500267100ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-playlists.svg000066400000000000000000000014131450727225500270620ustar00rootroot00000000000000ymuse-0.22/resources/icons/hicolor/scalable/mimetypes/ymuse-stream.svg000066400000000000000000000040251450727225500263330ustar00rootroot00000000000000ymuse-0.22/resources/metainfo/000077500000000000000000000000001450727225500164245ustar00rootroot00000000000000ymuse-0.22/resources/metainfo/com.yktoo.ymuse.metainfo.xml000066400000000000000000000322231450727225500240340ustar00rootroot00000000000000 com.yktoo.ymuse CC0-1.0 Apache-2.0 Ymuse Easy, functional, and snappy GTK client for Music Player Daemon Dmitry Kann https://raw.githubusercontent.com/yktoo/ymuse/master/resources/icons/hicolor/scalable/apps/com.yktoo.ymuse.svg

Ymuse is an easy, functional, and snappy GTK front-end (client) application for Music Player Daemon.

Application features:

  • Connection to a local or remote MPD server via TCP or Unix domain socket, auto(re)connect function.
  • Displaying, sorting, and shuffling the play queue. Track removal.
  • Filtering the play queue on a substring.
  • Saving the play queue as a playlist (new or existing).
  • MPD library browse and search functions.
  • Browsing, adding, and renaming playlists.
  • Own stream (a.k.a. Internet radio) list, which can be edited.
  • Visible queue columns selection.
  • Player title setting using Go template syntax.
  • Toggling various MPD modes (random, repeat, consume).
  • Seeking the current track to an arbitrary location.
  • Light and dark desktop theme support.
  • Internationalisation support.
com.yktoo.ymuse.desktop Main app window in the light and the dark themes. https://res.cloudinary.com/yktoo/image/upload/blog/e6ecokfftenpwlwswon1.png https://yktoo.com/en/software/ymuse/ https://yktoo.com/en/software/ymuse/faq/ https://github.com/yktoo/ymuse/issues Audio MPD Music Player Daemon audio sound GTK

Changelog:

  • Updated app icon (#79)
  • Add drag-and-drop queue reordering (#34)
  • Support for single-track repeat (#76)
  • Allow adding/replacing of all tracks in Library by Files context menu (#69)
  • Remove warnings about non-existent/empty Ymuse config (#70)
  • German translation (#68)

Changelog:

  • Add Ctrl+Shift+Left/Right shortcuts for seeking through current track (#56)
  • Scale album art proportionally (#59)
  • Add embedded album art image support (#52)
  • Add image size setting for album artwork

Changelog:

  • Add MPD Outputs dialog (#44)
  • Fix Add to playlist appending to wrong playlist (#51)

Changelog:

  • Sort streams ignoring case (resolves #45)
  • Update feature tour link in README
  • fix: rpm dependencies (#46)

Changelog:

  • Add queue post-replace actions: switch to Queue tab, start playback
  • Make copyright localisable
  • Replace the discontinued BBC WS stream URL
  • Require GTK 3.22+; replace deprecated GTK properties
  • Upgrade to Go 1.16.2 and latest gotk3/master

Changelog:

  • Add missing items to Keyboard shortcuts info window
  • Add to playlist command in the Library (resolves #17)
  • Separate album art display settings for tracks and streams (resolves #30)

Changelog:

  • Add volume button/slider (resolves #20)
  • Fix cleanup when connection lost (resolves #26, #28)
  • Fix queue selection being reset on right click (resolves #21)
  • Option for showing/hiding library toolbar (resolves #23)
  • Update to latest gompd (2.2.0), gotk3 (0.5.2) (hopefully resolves #27); go 1.15+

Changelog:

  • Upgrade to latest gompd master (resolves #11)

Changelog:

  • Add album art display to the player
  • Fix possible race in schedulePlayerSettingChange

Changelog:

  • Add "Show album/artist/genre in Library" function to Queue
  • Scroll to the selected row in library and prefs/columns
  • Select top item in Streams by default

Changelog:

  • Add Unix domain socket connectivity (resolves #10)

Changelog:

  • Expose Bitrate and Format attributes to current track (resolves #6)
  • Fix: too frequent reconnection attempts (resolves #9)
  • Redesign MPD Info dialog; add Decoder Plugins info
  • Save and restore selected library path
  • Translation updates: RU, NL
  • Use MPD_HOST and MPD_PORT environment vars for connection defaults (resolves #8)

Changelog:

  • Add tests for Builder; improve error handling
  • Add the ".." (level up) element to Library
  • Select element being left when going back in Library
  • Support Japanese for .desktop file
  • add .po file for Japanese

Changelog:

  • Add snapcraft config
  • Add util/log tests
  • Implement forced file rescanning On my gompd's fork for now
  • Improve connection management; Localise player template
  • Library: don't collapse toolbar to avoid hiding search button
  • Speed up display update on MPD connect
  • Use Go reflection to bind widgets in Builder (also resolves #3)

Changelog:

  • Add Dutch translation; translation update
  • Add browsing by genre, artist, album function
  • Add context menu/items, numerous UI fixes
  • Add localisation support; Add Russian translation
  • Add missing translation
  • Improve handling when not connected
  • Localise durations, too
  • Optimise queue loading with large lists
  • Properly colour symbolic icons
  • Recover SVGO-broken icons
  • Refactor library path and browsing
  • Refactor library path; Add Update popup menu item
  • Remove Playlists tab in favour of section in Library
  • Translation update: RU, NL

Changelog:

  • Add MPD information and stats dialog
  • Add shortcut Ctrl+Shift+R for Queue shuffle
  • Add support for dark theme
  • Also show playlists in the library (#2)
  • Cleaner library update icon
  • Icon update
  • Queue: add icon, fix colors, add fallback for track title
  • Revert to system folder icon
  • doc: add reference icon SVG

Changelog:

  • Add own SVG icons
  • Add support for internet streams (#1)
  • Make all used icons symbolic

Changelog:

  • Add horizontal alignment per MPD attribute
  • Add library search function
  • Improve focus management on page switching
  • Minor UI changes; labels in Preferences | columns
  • Suspend watcher on lost connection; remove possible race

Changelog:

  • add queue filter function, UI tweaks

Changelog:

  • Add queue column reordering, store widths
  • Interface style tweaks
  • packaging: optimise icon cache update

Changelog:

  • Fixes wrong playlist being loaded (using buttons in Playlists)
  • First public release
ymuse-0.22/resources/scripts/000077500000000000000000000000001450727225500163115ustar00rootroot00000000000000ymuse-0.22/resources/scripts/generate-mos000077500000000000000000000013571450727225500206330ustar00rootroot00000000000000#!/usr/bin/env bash # Generates .mo compiled language files set -e app_id="ymuse" root_dir="$(dirname "$(dirname "$(dirname "$(realpath "$0")")")")" # Remove compiled .mo files, if any mo_dir="$root_dir/resources/i18n/generated" rm -rf "$mo_dir" # Iterate through all source .po files find "$root_dir" -type f -name '*.po' | while read file; do # Language is the filename without the extension lang="$(basename "$file")" lang="${lang%.*}" # Create the target dir if needed target_dir="$mo_dir/$lang/LC_MESSAGES" mkdir -p "$target_dir" # Compile the .po into a .mo echo "Compiling $file" into "$target_dir/$app_id.mo" msgfmt "$file" -o "$target_dir/$app_id.mo" done ymuse-0.22/resources/scripts/postinst000077500000000000000000000004201450727225500201160ustar00rootroot00000000000000#!/bin/sh set -e # Update icon caches if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then if which update-icon-caches >/dev/null 2>&1 ; then update-icon-caches /usr/share/icons/hicolor/* fi fi ymuse-0.22/resources/scripts/postrm000077500000000000000000000002141450727225500175600ustar00rootroot00000000000000#!/bin/sh set -e # Update icon caches if which update-icon-caches >/dev/null 2>&1 ; then update-icon-caches /usr/share/icons/hicolor/* fi ymuse-0.22/resources/scripts/update-pot000077500000000000000000000004331450727225500203210ustar00rootroot00000000000000#!/usr/bin/env bash # Updates the .pot file from the available .glade files set -e root_dir="$(dirname "$(dirname "$(dirname "$(realpath "$0")")")")" find "$root_dir" -name '*.glade' | xargs xgettext --from-code=UTF-8 --join-existing --output="$root_dir/resources/i18n/ymuse.pot" ymuse-0.22/resources/ymuse-base-elements.svg000066400000000000000000000007501450727225500212310ustar00rootroot00000000000000ymuse-0.22/snap/000077500000000000000000000000001450727225500135515ustar00rootroot00000000000000ymuse-0.22/snap/snapcraft.yaml000066400000000000000000000031671450727225500164250ustar00rootroot00000000000000name: ymuse base: core20 adopt-info: metadata icon: resources/icons/hicolor/scalable/apps/com.yktoo.ymuse.svg confinement: strict architectures: - build-on: amd64 apps: ymuse: desktop: com.yktoo.ymuse.desktop command: ymuse extensions: - gnome-3-38 plugs: - network slots: - dbus-daemon parts: metadata: plugin: dump source: resources/metainfo parse-info: - com.yktoo.ymuse.metainfo.xml ymuse: plugin: go source: . build-packages: - git - gcc - gettext - libgtk-3-dev # gotk3 dependency override-pull: | snapcraftctl pull # Use version from git version="$(git describe --always --tags)" snapcraftctl set-version "$version" snapcraftctl set-grade "$(echo $version | grep -q '-' && echo devel || echo stable)" override-build: | set -eu go generate go build \ -tags "glib_2_64" \ -ldflags "-s -w -X main.version=$(git describe --always --tags) -X main.commit=$(git rev-parse HEAD) -X main.date=$(date --iso-8601=seconds)" \ -o "${SNAPCRAFT_PART_INSTALL}" resources: plugin: dump source: resources/ organize: icons: usr/share/icons i18n/generated: usr/share/locale metainfo: usr/share/metainfo prime: - usr/ - com.yktoo.ymuse.desktop override-pull: | snapcraftctl pull # Fix icon path in the .desktop sed -i -E 's!^Icon=.*!Icon=/usr/share/icons/hicolor/scalable/apps/com.yktoo.ymuse.svg!' com.yktoo.ymuse.desktop slots: dbus-daemon: interface: dbus bus: session name: com.yktoo.ymuse ymuse-0.22/ymuse.go000066400000000000000000000044341450727225500143060ustar00rootroot00000000000000/* * Copyright 2020 Dmitry Kann * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ //go:generate resources/scripts/generate-mos package main import ( "flag" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" "github.com/op/go-logging" "github.com/yktoo/ymuse/internal/config" "github.com/yktoo/ymuse/internal/player" "os" ) var log = logging.MustGetLogger("main") var ( version = "(dev)" commit = "(?)" date = "(?)" ) func main() { // Initialise the gettext engine glib.InitI18n("ymuse", "/usr/share/locale/") // Process command line verbInfo := flag.Bool("v", false, glib.Local("verbose logging")) verbDebug := flag.Bool("vv", false, glib.Local("more verbose logging")) flag.Parse() // Init logging logLevel := logging.WARNING switch { case *verbDebug: logLevel = logging.DEBUG case *verbInfo: logLevel = logging.INFO } logging.SetFormatter(logging.MustStringFormatter(`%{time:15:04:05.000} %{level:-5s} %{module} %{message}`)) logging.SetLevel(logLevel, "") // Init application metadata config.AppMetadata.Version = version config.AppMetadata.BuildDate = date // Start the app log.Infof(glib.Local("Ymuse version %s; %s; released %s"), version, commit, date) // Create Gtk Application, change appID to your application domain name reversed. application, err := gtk.ApplicationNew(config.AppMetadata.ID, glib.APPLICATION_FLAGS_NONE) if err != nil { log.Fatal("Could not create application", err) } // Setup the application application.Connect("activate", onActivate) // Run the application os.Exit(application.Run(nil)) } func onActivate(application *gtk.Application) { // Create the main window if window, err := player.NewMainWindow(application); err != nil { log.Fatal("Could not create application window", err) } else { window.Show() } }