├── .gitignore ├── resources ├── icons │ └── hicolor │ │ ├── 16x16 │ │ └── apps │ │ │ └── com.yktoo.ymuse.png │ │ ├── 24x24 │ │ └── apps │ │ │ └── com.yktoo.ymuse.png │ │ ├── 32x32 │ │ └── apps │ │ │ └── com.yktoo.ymuse.png │ │ ├── 48x48 │ │ └── apps │ │ │ └── com.yktoo.ymuse.png │ │ ├── 64x64 │ │ └── apps │ │ │ └── com.yktoo.ymuse.png │ │ ├── 128x128 │ │ └── apps │ │ │ └── com.yktoo.ymuse.png │ │ ├── 256x256 │ │ └── apps │ │ │ └── com.yktoo.ymuse.png │ │ ├── 512x512 │ │ └── apps │ │ │ └── com.yktoo.ymuse.png │ │ └── scalable │ │ ├── actions │ │ ├── ymuse-level-up-symbolic.svg │ │ ├── ymuse-stop-symbolic.svg │ │ ├── ymuse-play-symbolic.svg │ │ ├── ymuse-filter-symbolic.svg │ │ ├── ymuse-pause-symbolic.svg │ │ ├── ymuse-add-symbolic.svg │ │ ├── ymuse-next-symbolic.svg │ │ ├── ymuse-previous-symbolic.svg │ │ ├── ymuse-search-symbolic.svg │ │ ├── ymuse-repeat-symbolic.svg │ │ ├── ymuse-now-playing-symbolic.svg │ │ ├── ymuse-consume-symbolic.svg │ │ ├── ymuse-repeat-1-symbolic.svg │ │ ├── ymuse-clear-symbolic.svg │ │ ├── ymuse-save-symbolic.svg │ │ ├── ymuse-edit-symbolic.svg │ │ ├── ymuse-random-symbolic.svg │ │ ├── ymuse-delete-symbolic.svg │ │ ├── ymuse-sort-symbolic.svg │ │ ├── ymuse-delete-track-symbolic.svg │ │ ├── ymuse-update-db-symbolic.svg │ │ ├── ymuse-home-symbolic.svg │ │ └── ymuse-replace-queue-symbolic.svg │ │ └── mimetypes │ │ ├── ymuse-albums.svg │ │ ├── ymuse-artists.svg │ │ ├── ymuse-genres.svg │ │ ├── ymuse-playlists.svg │ │ ├── ymuse-artist.svg │ │ ├── ymuse-audio-file.svg │ │ ├── ymuse-album.svg │ │ ├── ymuse-genre.svg │ │ ├── ymuse-playlist.svg │ │ └── ymuse-stream.svg ├── scripts │ ├── postrm │ ├── update-pot │ ├── postinst │ └── generate-mos ├── ymuse-base-elements.svg ├── com.yktoo.ymuse.desktop ├── i18n │ ├── ymuse.pot │ └── ja.po └── metainfo │ └── com.yktoo.ymuse.metainfo.xml ├── go.mod ├── internal ├── player │ ├── resources.go │ ├── log.go │ ├── builder.go │ ├── outputs.go │ ├── glade │ │ ├── outputs.glade │ │ ├── shortcuts.glade │ │ └── mpd-info.glade │ ├── builder_test.go │ ├── connector.go │ └── prefs.go ├── util │ ├── log.go │ ├── log_test.go │ ├── util.go │ ├── util_test.go │ └── ui-util.go └── config │ ├── log.go │ ├── model.go │ └── config.go ├── go.sum ├── .github ├── FUNDING.yml └── workflows │ └── go.yml ├── snap └── snapcraft.yaml ├── .goreleaser.yml ├── ymuse.go ├── README.md └── COPYING /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | *.iml 3 | /ymuse 4 | *~ 5 | \#*.glade# 6 | 7 | # Generated content 8 | /resources/i18n/generated/ 9 | /dist/ 10 | *.snap 11 | -------------------------------------------------------------------------------- /resources/icons/hicolor/16x16/apps/com.yktoo.ymuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yktoo/ymuse/HEAD/resources/icons/hicolor/16x16/apps/com.yktoo.ymuse.png -------------------------------------------------------------------------------- /resources/icons/hicolor/24x24/apps/com.yktoo.ymuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yktoo/ymuse/HEAD/resources/icons/hicolor/24x24/apps/com.yktoo.ymuse.png -------------------------------------------------------------------------------- /resources/icons/hicolor/32x32/apps/com.yktoo.ymuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yktoo/ymuse/HEAD/resources/icons/hicolor/32x32/apps/com.yktoo.ymuse.png -------------------------------------------------------------------------------- /resources/icons/hicolor/48x48/apps/com.yktoo.ymuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yktoo/ymuse/HEAD/resources/icons/hicolor/48x48/apps/com.yktoo.ymuse.png -------------------------------------------------------------------------------- /resources/icons/hicolor/64x64/apps/com.yktoo.ymuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yktoo/ymuse/HEAD/resources/icons/hicolor/64x64/apps/com.yktoo.ymuse.png -------------------------------------------------------------------------------- /resources/icons/hicolor/128x128/apps/com.yktoo.ymuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yktoo/ymuse/HEAD/resources/icons/hicolor/128x128/apps/com.yktoo.ymuse.png -------------------------------------------------------------------------------- /resources/icons/hicolor/256x256/apps/com.yktoo.ymuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yktoo/ymuse/HEAD/resources/icons/hicolor/256x256/apps/com.yktoo.ymuse.png -------------------------------------------------------------------------------- /resources/icons/hicolor/512x512/apps/com.yktoo.ymuse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yktoo/ymuse/HEAD/resources/icons/hicolor/512x512/apps/com.yktoo.ymuse.png -------------------------------------------------------------------------------- /resources/scripts/postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Update icon caches 5 | if which update-icon-caches >/dev/null 2>&1 ; then 6 | update-icon-caches /usr/share/icons/hicolor/* 7 | fi 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yktoo/ymuse 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/fhs/gompd/v2 v2.3.0 7 | github.com/gotk3/gotk3 v0.6.5-0.20240618185848-ff349ae13f56 8 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 9 | ) 10 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-level-up-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-stop-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-play-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/scripts/update-pot: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Updates the .pot file from the available .glade files 3 | 4 | set -e 5 | 6 | root_dir="$(dirname "$(dirname "$(dirname "$(realpath "$0")")")")" 7 | 8 | find "$root_dir" -name '*.glade' | xargs xgettext --from-code=UTF-8 --join-existing --output="$root_dir/resources/i18n/ymuse.pot" 9 | -------------------------------------------------------------------------------- /resources/scripts/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Update icon caches 5 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 6 | if which update-icon-caches >/dev/null 2>&1 ; then 7 | update-icon-caches /usr/share/icons/hicolor/* 8 | fi 9 | fi 10 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-filter-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-pause-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-add-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/player/resources.go: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import _ "embed" 4 | 5 | //go:embed glade/mpd-info.glade 6 | var mpdInfoGlade string 7 | 8 | //go:embed glade/outputs.glade 9 | var outputsGlade string 10 | 11 | //go:embed glade/player.glade 12 | var playerGlade string 13 | 14 | //go:embed glade/prefs.glade 15 | var prefsGlade string 16 | 17 | //go:embed glade/shortcuts.glade 18 | var shortcutsGlade string 19 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-next-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-previous-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-search-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/ymuse-base-elements.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-repeat-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-now-playing-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fhs/gompd/v2 v2.3.0 h1:wuruUjmOODRlJhrYx73rJnzS7vTSXSU7pWmZtM3VPE0= 2 | github.com/fhs/gompd/v2 v2.3.0/go.mod h1:nNdZtcpD5VpmzZbRl5rV6RhxeMmAWTxEsSIMBkmMIy4= 3 | github.com/gotk3/gotk3 v0.6.5-0.20240618185848-ff349ae13f56 h1:eR+xxC8qqKuPMTucZqaklBxLIT7/4L7dzhlwKMrDbj8= 4 | github.com/gotk3/gotk3 v0.6.5-0.20240618185848-ff349ae13f56/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= 5 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= 6 | github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= 7 | -------------------------------------------------------------------------------- /resources/com.yktoo.ymuse.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Categories=AudioVideo; 3 | Comment=Music Player Daemon client application for GTK. 4 | Comment[nl]=Music Player Daemon cliënt applicatie voor GTK. 5 | Comment[ru]=Приложение-клиент для Music Player Daemon, использующее GTK. 6 | Comment[ja]=GTK製Music Player Daemonクライアント 7 | Exec=ymuse 8 | Hidden=false 9 | Icon=com.yktoo.ymuse 10 | Name=Ymuse 11 | GenericName=MPD client 12 | GenericName[nl]=MPD-cliënt 13 | GenericName[ru]=MPD-клиент 14 | GenericName[ja]=MPDクライアント 15 | StartupNotify=true 16 | Terminal=false 17 | Type=Application 18 | Keywords=sound;audio;MPD;GTK;Gnome; 19 | X-GNOME-Autostart-enabled=true 20 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [yktoo] 4 | patreon: yktoo 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-consume-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-repeat-1-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-clear-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-save-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-albums.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-artists.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-genres.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-playlists.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-edit-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/scripts/generate-mos: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Generates .mo compiled language files 3 | 4 | set -e 5 | 6 | app_id="ymuse" 7 | root_dir="$(dirname "$(dirname "$(dirname "$(realpath "$0")")")")" 8 | 9 | # Remove compiled .mo files, if any 10 | mo_dir="$root_dir/resources/i18n/generated" 11 | rm -rf "$mo_dir" 12 | 13 | # Iterate through all source .po files 14 | find "$root_dir" -type f -name '*.po' | 15 | while read file; do 16 | # Language is the filename without the extension 17 | lang="$(basename "$file")" 18 | lang="${lang%.*}" 19 | 20 | # Create the target dir if needed 21 | target_dir="$mo_dir/$lang/LC_MESSAGES" 22 | mkdir -p "$target_dir" 23 | 24 | # Compile the .po into a .mo 25 | echo "Compiling $file" into "$target_dir/$app_id.mo" 26 | msgfmt "$file" -o "$target_dir/$app_id.mo" 27 | done 28 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-artist.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-random-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/util/log.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package util 17 | 18 | import ( 19 | "fmt" 20 | "github.com/op/go-logging" 21 | ) 22 | 23 | // Package-wide Logger instance 24 | var log = logging.MustGetLogger("util") 25 | 26 | // errCheck logs a warning if the error is not nil. 27 | func errCheck(err error, message string) bool { 28 | if err != nil { 29 | log.Warning(fmt.Errorf("%v: %v", message, err)) 30 | return true 31 | } 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /internal/config/log.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package config 17 | 18 | import ( 19 | "fmt" 20 | "github.com/op/go-logging" 21 | ) 22 | 23 | // Package-wide Logger instance 24 | var log = logging.MustGetLogger("config") 25 | 26 | // errCheck logs a warning if the error is not nil. 27 | func errCheck(err error, message string) bool { 28 | if err != nil { 29 | log.Warning(fmt.Errorf("%v: %v", message, err)) 30 | return true 31 | } 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /internal/player/log.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package player 17 | 18 | import ( 19 | "fmt" 20 | "github.com/op/go-logging" 21 | ) 22 | 23 | // Package-wide Logger instance 24 | var log = logging.MustGetLogger("player") 25 | 26 | // errCheck logs a warning if the error is not nil. 27 | func errCheck(err error, message string) bool { 28 | if err != nil { 29 | log.Warning(fmt.Errorf("%v: %v", message, err)) 30 | return true 31 | } 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-audio-file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-album.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-delete-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-sort-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-delete-track-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-genre.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-update-db-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-home-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/actions/ymuse-replace-queue-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-playlist.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: ymuse 2 | base: core20 3 | adopt-info: metadata 4 | icon: resources/icons/hicolor/scalable/apps/com.yktoo.ymuse.svg 5 | confinement: strict 6 | 7 | architectures: 8 | - build-on: amd64 9 | 10 | apps: 11 | ymuse: 12 | desktop: com.yktoo.ymuse.desktop 13 | command: ymuse 14 | extensions: 15 | - gnome-3-38 16 | plugs: 17 | - network 18 | slots: 19 | - dbus-daemon 20 | 21 | parts: 22 | metadata: 23 | plugin: dump 24 | source: resources/metainfo 25 | parse-info: 26 | - com.yktoo.ymuse.metainfo.xml 27 | 28 | ymuse: 29 | plugin: go 30 | source: . 31 | build-packages: 32 | - git 33 | - gcc 34 | - gettext 35 | - libgtk-3-dev # gotk3 dependency 36 | 37 | override-pull: | 38 | snapcraftctl pull 39 | 40 | # Use version from git 41 | version="$(git describe --always --tags)" 42 | snapcraftctl set-version "$version" 43 | snapcraftctl set-grade "$(echo $version | grep -q '-' && echo devel || echo stable)" 44 | 45 | override-build: | 46 | set -eu 47 | go generate 48 | go build \ 49 | -tags "glib_2_64" \ 50 | -ldflags "-s -w -X main.version=$(git describe --always --tags) -X main.commit=$(git rev-parse HEAD) -X main.date=$(date --iso-8601=seconds)" \ 51 | -o "${SNAPCRAFT_PART_INSTALL}" 52 | 53 | resources: 54 | plugin: dump 55 | source: resources/ 56 | organize: 57 | icons: usr/share/icons 58 | i18n/generated: usr/share/locale 59 | metainfo: usr/share/metainfo 60 | prime: 61 | - usr/ 62 | - com.yktoo.ymuse.desktop 63 | 64 | override-pull: | 65 | snapcraftctl pull 66 | 67 | # Fix icon path in the .desktop 68 | sed -i -E 's!^Icon=.*!Icon=/usr/share/icons/hicolor/scalable/apps/com.yktoo.ymuse.svg!' com.yktoo.ymuse.desktop 69 | 70 | slots: 71 | dbus-daemon: 72 | interface: dbus 73 | bus: session 74 | name: com.yktoo.ymuse 75 | -------------------------------------------------------------------------------- /resources/icons/hicolor/scalable/mimetypes/ymuse-stream.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: ymuse 2 | 3 | before: 4 | hooks: 5 | - go generate 6 | - go mod download 7 | 8 | builds: 9 | - id: ymuse 10 | binary: ymuse 11 | env: 12 | - CGO_ENABLED=1 13 | goos: 14 | - linux 15 | goarch: 16 | - amd64 17 | 18 | archives: 19 | - id: ymuse-binary 20 | builds: 21 | - ymuse 22 | wrap_in_directory: 'true' 23 | files: 24 | - COPYING 25 | - README.md 26 | - resources/icons/** 27 | - resources/com.yktoo.ymuse.desktop 28 | 29 | checksum: 30 | name_template: 'checksums.txt' 31 | 32 | snapshot: 33 | name_template: "{{ .Tag }}-next" 34 | 35 | changelog: 36 | sort: asc 37 | filters: 38 | exclude: 39 | - '^ci:' 40 | - '^code:' 41 | - '^docs:' 42 | - '^snap:' 43 | - '^test:' 44 | - '^wip:' 45 | 46 | release: 47 | github: 48 | owner: yktoo 49 | name: ymuse 50 | 51 | nfpms: 52 | - id: ymuse 53 | package_name: ymuse 54 | 55 | vendor: Dmitry Kann 56 | homepage: https://yktoo.com/ 57 | maintainer: Dmitry Kann 58 | description: Easy, functional, and snappy GTK client for Music Player Daemon (MPD). 59 | license: Apache 2.0 60 | formats: 61 | - deb 62 | - rpm 63 | dependencies: 64 | - libc6 65 | - libgtk-3-0 66 | recommends: 67 | - mpd 68 | suggests: [] 69 | conflicts: [] 70 | bindir: /usr/bin 71 | contents: 72 | - src: "resources/icons/hicolor/**/*" 73 | dst: "/usr/share/icons/hicolor" 74 | - src: "resources/*.desktop" 75 | dst: "/usr/share/applications" 76 | - src: "resources/metainfo/*.metainfo.xml" 77 | dst: "/usr/share/metainfo" 78 | - src: "resources/i18n/generated/**/*" 79 | dst: "/usr/share/locale" 80 | scripts: 81 | postinstall: "resources/scripts/postinst" 82 | postremove: "resources/scripts/postrm" 83 | 84 | overrides: 85 | rpm: 86 | dependencies: 87 | - glibc 88 | - gtk3 89 | -------------------------------------------------------------------------------- /internal/util/log_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package util 17 | 18 | import ( 19 | "errors" 20 | "github.com/op/go-logging" 21 | "testing" 22 | ) 23 | 24 | type TestLogBackend struct { 25 | logging.Leveled 26 | level logging.Level 27 | record *logging.Record 28 | } 29 | 30 | func (t *TestLogBackend) Log(level logging.Level, _ int, record *logging.Record) error { 31 | t.level = level 32 | t.record = record 33 | return nil 34 | } 35 | 36 | func (t *TestLogBackend) reset() { 37 | t.level = 0 38 | t.record = nil 39 | } 40 | 41 | func Test_errCheck(t *testing.T) { 42 | type args struct { 43 | err error 44 | message string 45 | } 46 | tests := []struct { 47 | name string 48 | args args 49 | want bool 50 | wantLevel logging.Level 51 | wantMessage string 52 | }{ 53 | {"no error", args{err: nil, message: "foo"}, false, 0, ""}, 54 | {"error", args{err: errors.New("boom"), message: "foo failed"}, true, logging.WARNING, "foo failed: boom"}, 55 | } 56 | 57 | // Use a fake log backend for test 58 | backend := &TestLogBackend{} 59 | log.SetBackend(backend) 60 | 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | // Check the function 64 | backend.reset() 65 | if got := errCheck(tt.args.err, tt.args.message); got != tt.want { 66 | t.Errorf("errCheck() = %v, want %v", got, tt.want) 67 | } 68 | 69 | // Check the logging 70 | if backend.level != tt.wantLevel { 71 | t.Errorf("errCheck() log level = %v, want %v", backend.level, tt.wantLevel) 72 | } 73 | if tt.wantMessage == "" { 74 | if backend.record != nil { 75 | t.Errorf("errCheck() log message must be nil but it's %v", backend.record.Message()) 76 | } 77 | 78 | } else if backend.record.Message() != tt.wantMessage { 79 | t.Errorf("errCheck() log message = %v, want %v", backend.record.Message(), tt.wantMessage) 80 | } 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /ymuse.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | //go:generate resources/scripts/generate-mos 17 | 18 | package main 19 | 20 | import ( 21 | "flag" 22 | "github.com/gotk3/gotk3/glib" 23 | "github.com/gotk3/gotk3/gtk" 24 | "github.com/op/go-logging" 25 | "github.com/yktoo/ymuse/internal/config" 26 | "github.com/yktoo/ymuse/internal/player" 27 | "os" 28 | ) 29 | 30 | var log = logging.MustGetLogger("main") 31 | 32 | var ( 33 | version = "(dev)" 34 | commit = "(?)" 35 | date = "(?)" 36 | ) 37 | 38 | func main() { 39 | // Initialise the gettext engine 40 | glib.InitI18n("ymuse", "/usr/share/locale/") 41 | 42 | // Process command line 43 | verbInfo := flag.Bool("v", false, glib.Local("verbose logging")) 44 | verbDebug := flag.Bool("vv", false, glib.Local("more verbose logging")) 45 | flag.Parse() 46 | 47 | // Init logging 48 | logLevel := logging.WARNING 49 | switch { 50 | case *verbDebug: 51 | logLevel = logging.DEBUG 52 | case *verbInfo: 53 | logLevel = logging.INFO 54 | } 55 | logging.SetFormatter(logging.MustStringFormatter(`%{time:15:04:05.000} %{level:-5s} %{module} %{message}`)) 56 | logging.SetLevel(logLevel, "") 57 | 58 | // Init application metadata 59 | config.AppMetadata.Version = version 60 | config.AppMetadata.BuildDate = date 61 | 62 | // Start the app 63 | log.Infof(glib.Local("Ymuse version %s; %s; released %s"), version, commit, date) 64 | 65 | // Create Gtk Application, change appID to your application domain name reversed. 66 | application, err := gtk.ApplicationNew(config.AppMetadata.ID, glib.APPLICATION_FLAGS_NONE) 67 | if err != nil { 68 | log.Fatal("Could not create application", err) 69 | } 70 | 71 | // Setup the application 72 | application.Connect("activate", onActivate) 73 | 74 | // Run the application 75 | os.Exit(application.Run(nil)) 76 | } 77 | 78 | func onActivate(application *gtk.Application) { 79 | // Create the main window 80 | if window, err := player.NewMainWindow(application); err != nil { 81 | log.Fatal("Could not create application window", err) 82 | } else { 83 | window.Show() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/player/builder.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package player 17 | 18 | import ( 19 | "fmt" 20 | "github.com/gotk3/gotk3/gtk" 21 | "reflect" 22 | ) 23 | 24 | // Builder instance capable of finding specific types of widgets 25 | type Builder struct { 26 | *gtk.Builder 27 | } 28 | 29 | // NewBuilder creates and returns a new Builder instance 30 | func NewBuilder(content string) (*Builder, error) { 31 | builder, err := gtk.BuilderNew() 32 | if err != nil { 33 | return nil, err 34 | } 35 | if err := builder.AddFromString(content); err != nil { 36 | return nil, fmt.Errorf("builder.AddFromString() failed: %v", err) 37 | } 38 | return &Builder{Builder: builder}, nil 39 | } 40 | 41 | // BindWidgets binds the builder's widgets to same-named fields in the provided struct. Only exported fields are taken 42 | // into account 43 | func (b *Builder) BindWidgets(obj interface{}) error { 44 | // We're only dealing with structs 45 | vPtr := reflect.ValueOf(obj) 46 | if vPtr.Kind() != reflect.Ptr || vPtr.IsNil() || vPtr.Elem().Kind() != reflect.Struct { 47 | return fmt.Errorf("*struct expected, %T was given", obj) 48 | } 49 | 50 | // Fetch a value for the struct vPtr points to 51 | v := vPtr.Elem() 52 | 53 | // Iterate over struct's fields 54 | t := v.Type() 55 | for i := 0; i < t.NumField(); i++ { 56 | valField := v.Field(i) 57 | if valField.CanSet() { 58 | // Verify it's a pointer 59 | typeField := t.Field(i) 60 | if valField.Kind() != reflect.Ptr { 61 | return fmt.Errorf("struct's field %s is %v, but only pointers are supported", typeField.Name, valField.Kind()) 62 | } 63 | 64 | // Try to find a widget with the field's name 65 | widget, err := b.GetObject(typeField.Name) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | // Try to cast the value to the target type 71 | var targetVal reflect.Value 72 | func() { 73 | err = nil 74 | defer func() { 75 | if r := recover(); r != nil { 76 | err = fmt.Errorf("failed to cast IObject (ID=%s) to %s: %v", typeField.Name, typeField.Type, r) 77 | } 78 | }() 79 | targetVal = reflect.ValueOf(widget).Convert(typeField.Type) 80 | }() 81 | if err != nil { 82 | return err 83 | } 84 | 85 | // Set the value. Any possible panic won't be recovered 86 | valField.Set(targetVal) 87 | } 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version: 1.22 21 | 22 | - name: Install packages 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get install -y libgtk-3-dev xvfb gettext 26 | 27 | - name: Prepare 28 | run: | 29 | go generate 30 | go mod download 31 | 32 | - name: Verify format 33 | run: test `gofmt -l . | wc -l` = 0 34 | 35 | - name: Unit test 36 | run: | 37 | export DISPLAY=:99.0 38 | sudo /usr/bin/Xvfb $DISPLAY &>/dev/null & 39 | go test -v ./... 40 | 41 | deploy: 42 | needs: build 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v3 46 | with: 47 | fetch-depth: 0 48 | 49 | - name: Set up Go 50 | uses: actions/setup-go@v3 51 | with: 52 | go-version: 1.22 53 | 54 | - name: Install packages 55 | run: | 56 | sudo apt-get update 57 | sudo apt-get install -y libgtk-3-dev gettext 58 | 59 | - name: Verify GoReleaser config 60 | uses: goreleaser/goreleaser-action@v4 61 | with: 62 | distribution: goreleaser 63 | version: v1.21.2 64 | args: check 65 | 66 | - name: Make a release (tag only) 67 | if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') 68 | uses: goreleaser/goreleaser-action@v4 69 | with: 70 | distribution: goreleaser 71 | version: latest 72 | args: release 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | 76 | - name: Build snap 77 | # This must run AFTER goreleaser has built the app 78 | id: snap 79 | uses: snapcore/action-build@v1 80 | 81 | - name: Archive artifacts 82 | uses: actions/upload-artifact@v4 83 | with: 84 | name: dist 85 | path: | 86 | dist 87 | ${{ steps.snap.outputs.snap }} 88 | 89 | - name: Publish dev snap to edge channel 90 | if: github.ref_type == 'branch' && github.ref == 'refs/heads/dev' 91 | uses: snapcore/action-publish@v1 92 | env: 93 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} 94 | with: 95 | snap: ${{ steps.snap.outputs.snap }} 96 | release: edge 97 | 98 | - name: Publish snap to stable channel (tag only) 99 | if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') 100 | uses: snapcore/action-publish@v1 101 | env: 102 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} 103 | with: 104 | snap: ${{ steps.snap.outputs.snap }} 105 | release: stable 106 | -------------------------------------------------------------------------------- /internal/util/util.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package util 17 | 18 | import ( 19 | "fmt" 20 | "github.com/fhs/gompd/v2/mpd" 21 | "github.com/gotk3/gotk3/glib" 22 | "html/template" 23 | "strconv" 24 | "strings" 25 | "sync" 26 | ) 27 | 28 | var ( 29 | locDay string 30 | locDays string 31 | locOnce sync.Once 32 | ) 33 | 34 | // AtoiDef converts a string into an int, returning the given default value if conversion failed 35 | func AtoiDef(s string, def int) int { 36 | if i, err := strconv.Atoi(s); err == nil { 37 | return i 38 | } 39 | return def 40 | } 41 | 42 | // ParseFloatDef converts a string into a float64, returning the given default value if conversion failed 43 | func ParseFloatDef(s string, def float64) float64 { 44 | if f, err := strconv.ParseFloat(s, 32); err == nil { 45 | return f 46 | } 47 | return def 48 | } 49 | 50 | // FormatSeconds formats a number seconds as a string 51 | func FormatSeconds(seconds float64) string { 52 | // Make sure localised strings are fetched 53 | locOnce.Do(func() { 54 | locDay = glib.Local("one day") 55 | locDays = glib.Local("days") 56 | }) 57 | 58 | minutes, secs := int(seconds)/60, int(seconds)%60 59 | hours, mins := minutes/60, minutes%60 60 | days, hrs := hours/24, hours%24 61 | switch { 62 | case days > 1: 63 | return fmt.Sprintf("%d %s %d:%02d:%02d", days, locDays, hrs, mins, secs) 64 | case days == 1: 65 | return fmt.Sprintf("%s %d:%02d:%02d", locDay, hrs, mins, secs) 66 | case hours >= 1: 67 | return fmt.Sprintf("%d:%02d:%02d", hrs, mins, secs) 68 | default: 69 | return fmt.Sprintf("%d:%02d", mins, secs) 70 | } 71 | } 72 | 73 | // FormatSecondsStr formats a number seconds as a string given string input 74 | func FormatSecondsStr(seconds string) string { 75 | if f := ParseFloatDef(seconds, -1); f >= 0 { 76 | return FormatSeconds(f) 77 | } 78 | return "" 79 | } 80 | 81 | // Default returns a default value if no value is set 82 | func Default(def string, value interface{}) string { 83 | if set, ok := template.IsTrue(value); ok && set { 84 | return fmt.Sprint(value) 85 | } 86 | return def 87 | } 88 | 89 | // IsStreamURI returns whether the given URI refers to an Internet stream 90 | func IsStreamURI(uri string) bool { 91 | return strings.HasPrefix(uri, "http://") || strings.HasPrefix(uri, "https://") 92 | } 93 | 94 | // MapAttrsToSlice converts a list of Attrs into a string slice by extracting only the provided attribute 95 | func MapAttrsToSlice(attrs []mpd.Attrs, attr string) []string { 96 | r := make([]string, len(attrs)) 97 | for i, a := range attrs { 98 | r[i] = a[attr] 99 | } 100 | return r 101 | } 102 | 103 | func MaxInt(a, b int) int { 104 | if a < b { 105 | return b 106 | } 107 | return a 108 | } 109 | -------------------------------------------------------------------------------- /internal/player/outputs.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package player 17 | 18 | import ( 19 | "fmt" 20 | "github.com/fhs/gompd/v2/mpd" 21 | "github.com/gotk3/gotk3/glib" 22 | "github.com/gotk3/gotk3/gtk" 23 | "github.com/yktoo/ymuse/internal/util" 24 | "strconv" 25 | ) 26 | 27 | // OutputsDialog represents the output selection dialog 28 | type OutputsDialog struct { 29 | OutputsDialog *gtk.Dialog 30 | OutputsListBox *gtk.ListBox 31 | 32 | // Connector instance 33 | connector *Connector 34 | } 35 | 36 | // ShowOutputsDialog creates, shows and disposes of an Outputs dialog instance 37 | func ShowOutputsDialog(parent gtk.IWindow, c *Connector) { 38 | // Create the dialog 39 | d := &OutputsDialog{ 40 | connector: c, 41 | } 42 | 43 | // Load the dialog layout and map the widgets 44 | builder, err := NewBuilder(outputsGlade) 45 | if err == nil { 46 | err = builder.BindWidgets(d) 47 | } 48 | 49 | // Check for errors 50 | if errCheck(err, "OutputsDialog(): failed to initialise dialog") { 51 | util.ErrorDialog(parent, fmt.Sprint(glib.Local("Failed to load UI widgets"), err)) 52 | return 53 | } 54 | defer d.OutputsDialog.Destroy() 55 | 56 | // Set the dialog up 57 | d.OutputsDialog.SetTransientFor(parent) 58 | 59 | // Map the handlers to callback functions 60 | builder.ConnectSignals(map[string]interface{}{ 61 | "on_OutputsDialog_map": d.populateOutputs, 62 | }) 63 | 64 | // Run the dialog 65 | d.OutputsDialog.Run() 66 | } 67 | 68 | func (d *OutputsDialog) switchStateSet(id int, active bool) { 69 | log.Debugf("switchStateSet(%v, %v)", id, active) 70 | d.connector.IfConnected(func(client *mpd.Client) { 71 | if active { 72 | errCheck(client.EnableOutput(id), "EnableOutput() failed") 73 | } else { 74 | errCheck(client.DisableOutput(id), "DisableOutput() failed") 75 | } 76 | }) 77 | } 78 | 79 | // populateOutputs fills in the Outputs list box 80 | func (d *OutputsDialog) populateOutputs() { 81 | // Fetch the outputs 82 | var attrs []mpd.Attrs 83 | var err error 84 | d.connector.IfConnected(func(client *mpd.Client) { 85 | attrs, err = client.ListOutputs() 86 | }) 87 | if errCheck(err, "populateOutputs(): ListOutputs() failed") { 88 | return 89 | } 90 | 91 | // Add output rows to the list 92 | for _, a := range attrs { 93 | // Parse the output ID 94 | var id int 95 | if id, err = strconv.Atoi(a["outputid"]); errCheck(err, "Invalid output ID") { 96 | return 97 | } 98 | 99 | // Add a switch 100 | sw, err := gtk.SwitchNew() 101 | if errCheck(err, "SwitchNew() failed") { 102 | return 103 | } 104 | sw.SetActive(a["outputenabled"] != "0") 105 | sw.Connect("state-set", func(_ *gtk.Switch, state bool) { 106 | d.switchStateSet(id, state) 107 | }) 108 | 109 | // Add a new list box row 110 | text := fmt.Sprintf("%s (%s)", a["outputname"], a["plugin"]) 111 | if _, _, err := util.NewListBoxRow(d.OutputsListBox, true, text, "", "", sw); errCheck(err, "NewListBoxRow() failed") { 112 | return 113 | } 114 | } 115 | d.OutputsListBox.ShowAll() 116 | } 117 | -------------------------------------------------------------------------------- /internal/player/glade/outputs.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | False 7 | MPD Outputs 8 | True 9 | 500 10 | 300 11 | True 12 | dialog 13 | True 14 | 15 | 16 | 17 | False 18 | vertical 19 | 2 20 | 21 | 22 | False 23 | end 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | False 33 | False 34 | 0 35 | 36 | 37 | 38 | 39 | True 40 | False 41 | 12 42 | vertical 43 | 44 | 45 | True 46 | False 47 | 6 48 | Choose outputs MPD should use for playback. 49 | 0 50 | 51 | 52 | False 53 | True 54 | 0 55 | 56 | 57 | 58 | 59 | True 60 | True 61 | in 62 | 63 | 64 | True 65 | False 66 | 67 | 68 | True 69 | False 70 | browse 71 | 72 | 73 | 74 | 75 | 76 | 77 | True 78 | True 79 | 1 80 | 81 | 82 | 83 | 84 | True 85 | True 86 | 1 87 | 88 | 89 | 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest release](https://img.shields.io/github/v/release/yktoo/ymuse.svg)](https://github.com/yktoo/ymuse/releases/latest) 2 | [![Releases](https://img.shields.io/github/downloads/yktoo/ymuse/total.svg)](https://github.com/yktoo/ymuse/releases) 3 | [![License](https://img.shields.io/github/license/yktoo/ymuse.svg)](COPYING) 4 | [![Go](https://github.com/yktoo/ymuse/actions/workflows/go.yml/badge.svg)](https://github.com/yktoo/ymuse/actions/workflows/go.yml) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/yktoo/ymuse)](https://goreportcard.com/report/github.com/yktoo/ymuse) 6 | 7 | # ![Ymuse icon](resources/icons/hicolor/32x32/apps/com.yktoo.ymuse.png) Ymuse 8 | 9 | **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. 10 | 11 | [![Ymuse screenshot](https://res.cloudinary.com/yktoo/image/upload/blog/e6ecokfftenpwlwswon1.png)](https://res.cloudinary.com/yktoo/image/upload/blog/e6ecokfftenpwlwswon1.png) 12 | 13 | It supports library browsing and search, playlists, streams etc. 14 | 15 | [![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) 16 | [![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) 17 | 18 | Watch Ymuse feature tour video: 19 | 20 | [![Feature tour video](https://img.youtube.com/vi/h0g2gk5DM8s/0.jpg)](https://www.youtube.com/watch?v=h0g2gk5DM8s) 21 | 22 | ## Installing 23 | 24 | * If your distribution supports [snap packages](https://snapcraft.io/ymuse): `sudo snap install ymuse` 25 | * Ubuntu (as of 23.04) or Debian Testing: `sudo apt install ymuse` 26 | * A flatpak is available in the [Flathub repository](https://flathub.org/apps/details/com.yktoo.ymuse). 27 | * Otherwise, you can use a binary package from the [Releases](https://github.com/yktoo/ymuse/releases) section. 28 | 29 | ## Building from source 30 | 31 | ### Requirements 32 | 33 | * Go 1.22+ 34 | * GTK 3.24+ 35 | 36 | ### Getting started 37 | 38 | 1. [Install Go](https://golang.org/doc/install) 39 | 2. Make sure you have the following build dependencies installed: 40 | * `build-essential` 41 | * `libc6` 42 | * `libgtk-3-dev` 43 | * `libgdk-pixbuf2.0-dev` 44 | * `libglib2.0-dev` 45 | * `gettext` 46 | 3. Clone the source and compile: 47 | ```bash 48 | git clone https://github.com/yktoo/ymuse.git 49 | cd ymuse 50 | go generate 51 | go build 52 | ``` 53 | 4. Copy over the icons and localisations: 54 | ```bash 55 | sudo cp -r resources/icons/* /usr/share/icons/ 56 | sudo cp -r resources/i18n/generated/* /usr/share/locale/ 57 | sudo update-icon-caches /usr/share/icons/hicolor/* 58 | ``` 59 | 60 | This will create the application executable `ymuse` in the project root directory, which you can run straight away. 61 | 62 | ## Packaging 63 | 64 | ### DEB and RPM 65 | 66 | Requires `goreleaser` installed. 67 | 68 | ```bash 69 | goreleaser release --clean --skip=publish [--snapshot] 70 | ``` 71 | 72 | ### Flatpak 73 | 74 | 1. Install `flatpak` and `flatpack-builder` 75 | 2. `flatpak remote-add flathub https://flathub.org/repo/flathub.flatpakrepo` 76 | 3. `flatpak-builder dist /path/to/com.yktoo.ymuse.yml --force-clean --install-deps-from=flathub --repo=/path/to/repository` 77 | 4. Optional: make a `.flatpak` bundle: 78 | `flatpak build-bundle /path/to/repository ymuse.flatpak com.yktoo.ymuse` 79 | 80 | ### Snap 81 | 82 | Install and run `snapcraft` (it will also ask to install Multipass, which you'll have to confirm): 83 | 84 | ```bash 85 | snap install snapcraft 86 | snapcraft clean # Optional, when rebuilding the snap 87 | snapcraft 88 | ``` 89 | 90 | ## License 91 | 92 | See [COPYING](COPYING). 93 | 94 | ## Credits 95 | 96 | * Icon artwork: [Jeppe Zapp](https://github.com/mrzapp) 97 | * [gotk3](https://github.com/gotk3/gotk3) 98 | * [gompd](https://github.com/fhs/gompd) by Fazlul Shahriar 99 | * [go-logging](https://github.com/op/go-logging) by Örjan Fors 100 | * [goreleaser](https://goreleaser.com/) by Carlos Alexandro Becker et al. 101 | 102 | ## TODO 103 | 104 | * Automated UI testing. 105 | * Drag’n’drop of multiple tracks in the play queue. 106 | * More settings. 107 | * Multiple MPD connections support. 108 | -------------------------------------------------------------------------------- /internal/config/model.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package config 17 | 18 | import ( 19 | "github.com/yktoo/ymuse/internal/util" 20 | "path" 21 | "sort" 22 | ) 23 | 24 | // MPD's track attribute identifiers. These must precisely match the QueueListStore's columns declared in player.glade 25 | const ( 26 | MTAttrArtist = iota 27 | MTAttrArtistSort 28 | MTAttrAlbum 29 | MTAttrAlbumSort 30 | MTAttrAlbumArtist 31 | MTAttrAlbumArtistSort 32 | MTAttrDisc 33 | MTAttrTrack 34 | MTAttrNumber 35 | MTAttrLength 36 | MTAttrPath 37 | MTAttrDirectory 38 | MTAttrFile 39 | MTAttrYear 40 | MTAttrGenre 41 | MTAttrName 42 | MTAttrComposer 43 | MTAttrPerformer 44 | MTAttrConductor 45 | MTAttrWork 46 | MTAttrGrouping 47 | MTAttrComment 48 | MTAttrLabel 49 | MTAttrPos 50 | // List store's "artificial" columns used for rendering 51 | QueueColumnIcon 52 | QueueColumnFontWeight 53 | QueueColumnBgColor 54 | QueueColumnVisible 55 | ) 56 | 57 | // MpdTrackAttribute describes an MPD's track attribute 58 | type MpdTrackAttribute struct { 59 | Name string // Short display label for the attribute 60 | LongName string // Display label for the attribute 61 | AttrName string // Internal name of the corresponding MPD attribute 62 | Numeric bool // Whether the attribute's value is numeric 63 | Searchable bool // Whether the attribute is searchable 64 | Width int // Default width of the column displaying this attribute 65 | XAlign float64 // X alignment of the column displaying this attribute (0...1) 66 | Formatter func(v string) string // Optional function for formatting the value 67 | FallbackAttrIDs []int // Optional references to the fallback attributes to use when there's no value, in the order of preference 68 | } 69 | 70 | // MpdTrackAttributes contains all known MPD's track attributes 71 | var MpdTrackAttributes = map[int]MpdTrackAttribute{ 72 | MTAttrArtist: {"Artist", "Artist", "Artist", false, true, 200, 0, nil, nil}, 73 | MTAttrArtistSort: {"Artist", "Artist (for sorting)", "Artistsort", false, false, 200, 0, nil, nil}, 74 | MTAttrAlbum: {"Album", "Album", "Album", false, true, 200, 0, nil, nil}, 75 | MTAttrAlbumSort: {"Album", "Album (for sorting)", "Albumsort", false, false, 200, 0, nil, nil}, 76 | MTAttrAlbumArtist: {"Album artist", "Album artist", "Albumartist", false, true, 200, 0, nil, nil}, 77 | MTAttrAlbumArtistSort: {"Album artist", "Album artist (for sorting)", "Albumartistsort", false, false, 200, 0, nil, nil}, 78 | MTAttrDisc: {"Disc", "Disc", "Disc", false, true, 50, 1, nil, nil}, 79 | MTAttrTrack: {"Track", "Track title", "Title", false, true, 200, 0, nil, []int{MTAttrName, MTAttrPath}}, 80 | MTAttrNumber: {"#", "Track number", "Track", true, true, 50, 1, nil, nil}, 81 | MTAttrLength: {"Length", "Track length", "duration", true, false, 60, 1, util.FormatSecondsStr, nil}, 82 | MTAttrPath: {"Path", "Directory and file name", "file", false, true, 200, 0, nil, nil}, 83 | MTAttrDirectory: {"Directory", "File path", "file", false, false, 200, 0, path.Dir, nil}, 84 | MTAttrFile: {"File", "File name", "file", false, false, 200, 0, path.Base, nil}, 85 | MTAttrYear: {"Year", "Year", "Date", true, true, 50, 1, nil, nil}, 86 | MTAttrGenre: {"Genre", "Genre", "Genre", false, true, 200, 0, nil, nil}, 87 | MTAttrName: {"Name", "Stream name", "Name", false, true, 200, 0, nil, nil}, 88 | MTAttrComposer: {"Composer", "Composer", "Composer", false, true, 200, 0, nil, nil}, 89 | MTAttrPerformer: {"Performer", "Performer", "Performer", false, true, 200, 0, nil, nil}, 90 | MTAttrConductor: {"Conductor", "Conductor", "Conductor", false, false, 200, 0, nil, nil}, 91 | MTAttrWork: {"Work", "Work", "Work", false, false, 200, 0, nil, nil}, 92 | MTAttrGrouping: {"Grouping", "Grouping", "Grouping", false, false, 200, 0, nil, nil}, 93 | MTAttrComment: {"Comment", "Comment", "Comment", false, true, 200, 0, nil, nil}, 94 | MTAttrLabel: {"Label", "Label", "Label", false, true, 200, 0, nil, nil}, 95 | MTAttrPos: {"Pos", "Position", "Pos", true, false, 0, 1, nil, nil}, 96 | } 97 | 98 | // MpdTrackAttributeIds stores attribute IDs sorted in desired display order 99 | var MpdTrackAttributeIds []int 100 | 101 | func init() { 102 | // Fill in and sort MpdTrackAttributeIds 103 | MpdTrackAttributeIds = make([]int, len(MpdTrackAttributes)) 104 | i := 0 105 | for id := range MpdTrackAttributes { 106 | MpdTrackAttributeIds[i] = id 107 | i++ 108 | } 109 | sort.Ints(MpdTrackAttributeIds) 110 | } 111 | -------------------------------------------------------------------------------- /internal/player/builder_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package player 17 | 18 | import ( 19 | "fmt" 20 | "github.com/gotk3/gotk3/gtk" 21 | "testing" 22 | ) 23 | 24 | func TestBuilder_BindWidgets(t *testing.T) { 25 | vInt := 1 26 | tests := []struct { 27 | name string 28 | content string 29 | target interface{} 30 | wantErr bool 31 | }{ 32 | { 33 | name: "int instead of pointer", 34 | target: 1, 35 | wantErr: true, 36 | }, 37 | { 38 | name: "*int instead of pointer", 39 | target: &vInt, 40 | wantErr: true, 41 | }, 42 | { 43 | name: "struct with non-pointer field", 44 | target: &struct{ Value string }{}, 45 | wantErr: true, 46 | }, 47 | { 48 | name: "struct field of wrong name", 49 | content: ``, 50 | target: &struct{ Value *gtk.Button }{}, 51 | wantErr: true, 52 | }, 53 | { 54 | name: "struct field of wrong type", 55 | content: ``, 56 | target: &struct{ MyButton *gtk.Button }{}, 57 | wantErr: true, 58 | }, 59 | { 60 | name: "empty struct", 61 | target: &struct{}{}, 62 | }, 63 | { 64 | name: "struct with no exported fields", 65 | target: &struct { 66 | x int 67 | y string 68 | }{}, 69 | }, 70 | { 71 | name: "happy flow for MainWindow", 72 | content: playerGlade, 73 | target: &MainWindow{}, 74 | }, 75 | { 76 | name: "happy flow for Preferences", 77 | content: prefsGlade, 78 | target: &PrefsDialog{}, 79 | }, 80 | { 81 | name: "happy flow for Shortcuts", 82 | content: shortcutsGlade, 83 | target: &struct{ ShortcutsWindow *gtk.ShortcutsWindow }{}, 84 | }, 85 | } 86 | 87 | // Need to init GTK first 88 | gtk.Init(nil) 89 | 90 | // Run the tests 91 | for _, tt := range tests { 92 | t.Run(tt.name, func(t *testing.T) { 93 | // Instantiate a builder 94 | if b, err := NewBuilder(tt.content); err != nil { 95 | t.Errorf("BindWidgets() error in NewBuilder() = %v, wantErr %v", err, tt.wantErr) 96 | } else if err := b.BindWidgets(tt.target); (err != nil) != tt.wantErr { 97 | t.Errorf("BindWidgets() error = %v, wantErr %v", err, tt.wantErr) 98 | } else if err != nil { 99 | fmt.Println("Got error:", err) 100 | } 101 | }) 102 | } 103 | } 104 | 105 | func TestNewBuilder(t *testing.T) { 106 | tests := []struct { 107 | name string 108 | content string 109 | wantErr bool 110 | id string 111 | wantType string 112 | }{ 113 | {name: "empty file"}, 114 | { 115 | name: "bad XML", 116 | content: "", 117 | wantErr: true, 118 | }, 119 | { 120 | name: "bad GTK version", 121 | content: ` 122 | 123 | 124 | 125 | `, 126 | wantErr: true, 127 | }, 128 | { 129 | name: "unknown widget class", 130 | content: ``, 131 | wantErr: true, 132 | }, 133 | { 134 | name: "happy flow for GtkButton", 135 | content: ``, 136 | id: "btn", 137 | wantType: "*gtk.Button", 138 | }, 139 | { 140 | name: "happy flow for GtkApplicationWindow", 141 | content: ``, 142 | id: "win", 143 | wantType: "*gtk.ApplicationWindow", 144 | }, 145 | { 146 | name: "happy flow for GtkDialog", 147 | content: ``, 148 | id: "dlg", 149 | wantType: "*gtk.Dialog", 150 | }, 151 | { 152 | name: "happy flow for GtkEntry", 153 | content: ``, 154 | id: "ENTRY", 155 | wantType: "*gtk.Entry", 156 | }, 157 | { 158 | name: "happy flow for GtkMenu", 159 | content: ``, 160 | id: "mnu", 161 | wantType: "*gtk.Menu", 162 | }, 163 | { 164 | name: "happy flow for GtkToolbar", 165 | content: ``, 166 | id: "TB", 167 | wantType: "*gtk.Toolbar", 168 | }, 169 | { 170 | name: "happy flow for nested GtkButton", 171 | content: ` 172 | 173 | 174 | 175 | 176 | 177 | `, 178 | id: "btn", 179 | wantType: "*gtk.Button", 180 | }, 181 | } 182 | 183 | // Need to init GTK first 184 | gtk.Init(nil) 185 | 186 | // Run the tests 187 | for _, tt := range tests { 188 | t.Run(tt.name, func(t *testing.T) { 189 | got, err := NewBuilder(tt.content) 190 | if (err != nil) != tt.wantErr { 191 | t.Errorf("NewBuilder() error = %v, wantErr %v", err, tt.wantErr) 192 | return 193 | } 194 | 195 | // On an expected error or if there's nothing more to check: stop here 196 | if err != nil || tt.id == "" { 197 | if err != nil { 198 | fmt.Println("Got error:", err) 199 | } 200 | return 201 | } 202 | 203 | // Fetch and check the type of the object 204 | if obj, err := got.Builder.GetObject(tt.id); err != nil { 205 | t.Errorf("NewBuilder() failed to get object with ID=%v: %v", tt.id, err) 206 | } else if gotType := fmt.Sprintf("%T", obj); gotType != tt.wantType { 207 | t.Errorf("NewBuilder() object with ID=%v: got = %v, want %v", tt.id, gotType, tt.wantType) 208 | } 209 | }) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /internal/util/util_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package util 17 | 18 | import ( 19 | "fmt" 20 | "github.com/fhs/gompd/v2/mpd" 21 | "math" 22 | "reflect" 23 | "testing" 24 | ) 25 | 26 | func TestAtoiDef(t *testing.T) { 27 | type args struct { 28 | s string 29 | def int 30 | } 31 | tests := []struct { 32 | name string 33 | args args 34 | want int 35 | }{ 36 | {"empty string", args{"", 777}, 777}, 37 | {"positive numeric string", args{"42", -1}, 42}, 38 | {"negative numeric string", args{"-120", 0}, -120}, 39 | {"non-numeric string", args{"Zook", 16}, 16}, 40 | } 41 | for _, tt := range tests { 42 | t.Run(tt.name, func(t *testing.T) { 43 | if got := AtoiDef(tt.args.s, tt.args.def); got != tt.want { 44 | t.Errorf("AtoiDef() = %v, want %v", got, tt.want) 45 | } 46 | }) 47 | } 48 | } 49 | 50 | func TestFormatSeconds(t *testing.T) { 51 | type args struct { 52 | seconds float64 53 | } 54 | tests := []struct { 55 | name string 56 | args args 57 | want string 58 | }{ 59 | {"zero seconds", args{0}, "0:00"}, 60 | {"some seconds", args{42}, "0:42"}, 61 | {"fractional seconds", args{4.2234514}, "0:04"}, 62 | {"minute with seconds", args{218}, "3:38"}, 63 | {"many minutes", args{2722.7}, "45:22"}, 64 | {"an hour with minutes", args{3600 + 3*60 + 15}, "1:03:15"}, 65 | {"almost a day", args{23*3600 + 59*60 + 59}, "23:59:59"}, 66 | {"one day", args{1*24*3600 + 1*3600 + 8*60 + 47}, "one day 1:08:47"}, 67 | {"many days", args{66*24*3600 + 15*3600 + 12*60 + 33}, "66 days 15:12:33"}, 68 | } 69 | for _, tt := range tests { 70 | t.Run(tt.name, func(t *testing.T) { 71 | if got := FormatSeconds(tt.args.seconds); got != tt.want { 72 | t.Errorf("FormatSeconds() = %v, want %v", got, tt.want) 73 | } 74 | }) 75 | } 76 | } 77 | 78 | func TestFormatSecondsStr(t *testing.T) { 79 | type args struct { 80 | seconds string 81 | } 82 | tests := []struct { 83 | name string 84 | args args 85 | want string 86 | }{ 87 | {"empty value", args{""}, ""}, 88 | {"invalid value", args{"boo"}, ""}, 89 | {"zero seconds", args{"0"}, "0:00"}, 90 | {"some seconds", args{"42"}, "0:42"}, 91 | {"fractional seconds", args{"4.2234514"}, "0:04"}, 92 | {"minute with seconds", args{"218"}, "3:38"}, 93 | {"many minutes", args{"2722.7"}, "45:22"}, 94 | {"an hour with minutes", args{"3795"}, "1:03:15"}, 95 | {"almost a day", args{"86399"}, "23:59:59"}, 96 | {"one day", args{"90527"}, "one day 1:08:47"}, 97 | {"many days", args{"5757153"}, "66 days 15:12:33"}, 98 | } 99 | for _, tt := range tests { 100 | t.Run(tt.name, func(t *testing.T) { 101 | if got := FormatSecondsStr(tt.args.seconds); got != tt.want { 102 | t.Errorf("FormatSecondsStr() = %v, want %v", got, tt.want) 103 | } 104 | }) 105 | } 106 | } 107 | 108 | func TestParseFloatDef(t *testing.T) { 109 | type args struct { 110 | s string 111 | def float64 112 | } 113 | tests := []struct { 114 | name string 115 | args args 116 | want float64 117 | }{ 118 | {"empty string", args{"", 777.14}, 777.14}, 119 | {"positive numeric string", args{"42.52", -1.234}, 42.52}, 120 | {"negative numeric string", args{"-120.0001", 0}, -120.0001}, 121 | {"non-numeric string", args{"Zook", 16.8899}, 16.8899}, 122 | } 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | // Compare the numbers with 1/1e6 tolerance to ignore rounding errors 126 | if got := ParseFloatDef(tt.args.s, tt.args.def); math.Abs(got-tt.want) > 0.000001 { 127 | t.Errorf("ParseFloatDef() = %v, want %v", got, tt.want) 128 | } 129 | }) 130 | } 131 | } 132 | 133 | func TestDefault(t *testing.T) { 134 | type args struct { 135 | def string 136 | value interface{} 137 | } 138 | tests := []struct { 139 | name string 140 | args args 141 | want string 142 | }{ 143 | {"nil is no value", args{"Foo", nil}, "Foo"}, 144 | {"empty string is no value", args{"Foo", ""}, "Foo"}, 145 | {"non-empty string is value", args{"Foo", "barr"}, "barr"}, 146 | {"false is no value", args{"Foo", false}, "Foo"}, 147 | {"true is value", args{"Foo", true}, "true"}, 148 | {"struct is value", args{"Foo", struct{}{}}, "{}"}, 149 | {"int 0 is no value", args{"Foo", 0}, "Foo"}, 150 | {"positive int is value", args{"Foo", 14}, "14"}, 151 | {"negative int is value", args{"Foo", -2}, "-2"}, 152 | {"float 0 is no value", args{"Foo", 0.0}, "Foo"}, 153 | {"positive float is value", args{"Foo", 14.0}, "14"}, 154 | {"negative float is value", args{"Foo", -2.3}, "-2.3"}, 155 | {"complex is value", args{"Foo", 3 + 2i}, "(3+2i)"}, 156 | } 157 | for _, tt := range tests { 158 | t.Run(tt.name, func(t *testing.T) { 159 | if got := Default(tt.args.def, tt.args.value); got != tt.want { 160 | t.Errorf("Default() = %v, want %v", got, tt.want) 161 | } 162 | }) 163 | } 164 | } 165 | 166 | func TestIsStreamURI(t *testing.T) { 167 | tests := []struct { 168 | name string 169 | uri string 170 | want bool 171 | }{ 172 | {"empty is no stream", "", false}, 173 | {"Name is no stream", "Name", false}, 174 | {"http: is no stream", "http:", false}, 175 | {"https: is no stream", "https:", false}, 176 | {"http:// not at begin is no stream", "[http://whatev.er]", false}, 177 | {"http-URL is a stream", "http://example.com", true}, 178 | {"https-URL is a stream", "https://www.musicpd.org/", true}, 179 | } 180 | for _, tt := range tests { 181 | t.Run(tt.name, func(t *testing.T) { 182 | if got := IsStreamURI(tt.uri); got != tt.want { 183 | t.Errorf("IsStreamURI() = %v, want %v", got, tt.want) 184 | } 185 | }) 186 | } 187 | } 188 | 189 | func TestMapAttrsToSlice(t *testing.T) { 190 | type args struct { 191 | attrs []mpd.Attrs 192 | attr string 193 | } 194 | tests := []struct { 195 | name string 196 | args args 197 | want []string 198 | }{ 199 | {"empty list", args{[]mpd.Attrs{}, "file"}, []string{}}, 200 | {"single element", args{[]mpd.Attrs{{"file": "foo"}}, "file"}, []string{"foo"}}, 201 | {"missing attribute", args{[]mpd.Attrs{{"whoppa": "hippa"}}, "file"}, []string{""}}, 202 | { 203 | "multiple elements", 204 | args{ 205 | []mpd.Attrs{ 206 | {"artist": "bar", "album": "baz", "file": "foo"}, 207 | {"artist": "X-None", "file": "whoopsie"}, 208 | {"artist": "Blase", "file": "snap"}, 209 | {"album": "There"}, 210 | }, 211 | "file"}, 212 | []string{"foo", "whoopsie", "snap", ""}}, 213 | } 214 | for _, tt := range tests { 215 | t.Run(tt.name, func(t *testing.T) { 216 | if got := MapAttrsToSlice(tt.args.attrs, tt.args.attr); !reflect.DeepEqual(got, tt.want) { 217 | t.Errorf("MapAttrsToSlice() = %v, want %v", got, tt.want) 218 | } 219 | }) 220 | } 221 | } 222 | 223 | func TestMaxInt(t *testing.T) { 224 | const maxInt = int(^uint(0) >> 1) 225 | const minInt = -maxInt - 1 226 | tests := []struct { 227 | a int 228 | b int 229 | want int 230 | }{ 231 | {0, 0, 0}, 232 | {0, -1, 0}, 233 | {-1, 0, 0}, 234 | {1, 2, 2}, 235 | {-1, -2, -1}, 236 | {maxInt, 0, maxInt}, 237 | {0, maxInt, maxInt}, 238 | {maxInt - 1, maxInt, maxInt}, 239 | {minInt, maxInt, maxInt}, 240 | {maxInt, minInt, maxInt}, 241 | {minInt, 0, 0}, 242 | {0, minInt, 0}, 243 | {minInt + 1, minInt, minInt + 1}, 244 | } 245 | for _, tt := range tests { 246 | t.Run(fmt.Sprintf("Compare %d with %d", tt.a, tt.b), func(t *testing.T) { 247 | if got := MaxInt(tt.a, tt.b); got != tt.want { 248 | t.Errorf("MaxInt() = %v, want %v", got, tt.want) 249 | } 250 | }) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /internal/util/ui-util.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package util 17 | 18 | import ( 19 | "fmt" 20 | "github.com/gotk3/gotk3/gtk" 21 | "github.com/gotk3/gotk3/pango" 22 | "html" 23 | ) 24 | 25 | // ClearChildren removes all container's children 26 | func ClearChildren(container gtk.Container) { 27 | container.GetChildren().Foreach(func(item interface{}) { 28 | container.Remove(item.(gtk.IWidget)) 29 | }) 30 | } 31 | 32 | // NewButton creates and returns a new button 33 | func NewButton(label, tooltip, name, icon string, onClicked func()) *gtk.Button { 34 | btn, err := gtk.ButtonNewWithLabel(label) 35 | if errCheck(err, "ButtonNewWithLabel() failed") { 36 | return nil 37 | } 38 | btn.SetName(name) 39 | btn.SetTooltipText(tooltip) 40 | 41 | // Create an icon, if needed 42 | if icon != "" { 43 | // Icon is optional, do not fail entirely on an error 44 | if img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_BUTTON); !errCheck(err, "ImageNewFromIconName() failed") { 45 | btn.SetImage(img) 46 | btn.SetAlwaysShowImage(true) 47 | } 48 | } 49 | 50 | // Bind the clicked signal 51 | btn.Connect("clicked", onClicked) 52 | return btn 53 | } 54 | 55 | // NewBoxToggleButton creates, adds to a box and returns a new toggle button 56 | func NewBoxToggleButton(box *gtk.Box, label, name, icon string, active bool, onClicked func()) *gtk.ToggleButton { 57 | btn, err := gtk.ToggleButtonNewWithLabel(label) 58 | if errCheck(err, "ToggleButtonNewWithLabel() failed") { 59 | return nil 60 | } 61 | btn.SetName(name) 62 | btn.SetActive(active) 63 | 64 | // Create an icon, if needed 65 | if icon != "" { 66 | // Icon is optional, do not fail entirely on an error 67 | if img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_BUTTON); !errCheck(err, "ImageNewFromIconName() failed") { 68 | btn.SetImage(img) 69 | btn.SetAlwaysShowImage(true) 70 | } 71 | } 72 | 73 | // Bind the clicked signal 74 | btn.Connect("clicked", onClicked) 75 | 76 | // Add the button to the box 77 | box.PackStart(btn, false, false, 0) 78 | return btn 79 | } 80 | 81 | // NewLabel instantiates and returns a new label 82 | func NewLabel(label string) *gtk.Label { 83 | lbl, err := gtk.LabelNew(label) 84 | if errCheck(err, "LabelNew() failed") { 85 | return nil 86 | } 87 | lbl.SetXAlign(0) 88 | return lbl 89 | } 90 | 91 | // NewListBoxRow adds a new row to the list box, a horizontal box, an image and a label to it 92 | // listBox: list box instance 93 | // useMarkup: whether label is markup 94 | // label: text for the row 95 | // name: name of the row 96 | // icon: optional icon name for the row 97 | // widgets: extra widgets to insert into the beginning of the row 98 | func NewListBoxRow(listBox *gtk.ListBox, useMarkup bool, label, name, icon string, widgets ...gtk.IWidget) (*gtk.ListBoxRow, *gtk.Box, error) { 99 | // Add a new list box row 100 | row, err := gtk.ListBoxRowNew() 101 | if err != nil { 102 | return nil, nil, err 103 | } 104 | row.SetName(name) 105 | 106 | // Add horizontal box 107 | hbx, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) 108 | if err != nil { 109 | return nil, nil, err 110 | } 111 | hbx.SetMarginStart(6) 112 | hbx.SetMarginEnd(6) 113 | row.Add(hbx) 114 | 115 | // Add extra widgets, if any 116 | for _, w := range widgets { 117 | hbx.PackStart(w, false, false, 0) 118 | } 119 | 120 | // Insert icon, if needed 121 | if icon != "" { 122 | // Icon is optional, do not fail entirely on an error 123 | if img, err := gtk.ImageNewFromIconName(icon, gtk.ICON_SIZE_LARGE_TOOLBAR); !errCheck(err, "ImageNewFromIconName() failed") { 124 | hbx.PackStart(img, false, false, 0) 125 | } 126 | } 127 | 128 | // Insert label with directory/file name 129 | lbl, err := gtk.LabelNew("") 130 | if err != nil { 131 | return nil, nil, err 132 | } 133 | lbl.SetXAlign(0) 134 | lbl.SetEllipsize(pango.ELLIPSIZE_END) 135 | if useMarkup { 136 | lbl.SetMarkup(label) 137 | } else { 138 | lbl.SetText(label) 139 | } 140 | hbx.PackStart(lbl, true, true, 0) 141 | 142 | // Add the row to the list box 143 | listBox.Add(row) 144 | return row, hbx, nil 145 | } 146 | 147 | // ListBoxScrollToSelected scrolls the provided list box so that the selected row is centered in the window 148 | func ListBoxScrollToSelected(listBox *gtk.ListBox) { 149 | // If there's selection 150 | if row := listBox.GetSelectedRow(); row != nil { 151 | // Convert the row's Y coordinate into the list box's coordinate 152 | if _, y, _ := row.TranslateCoordinates(listBox, 0, 0); y >= 0 { 153 | // Scroll the vertical adjustment to center the row in the viewport 154 | if adj := listBox.GetAdjustment(); adj != nil { 155 | _, rowHeight := row.GetPreferredHeight() 156 | adj.SetValue(float64(y) - (adj.GetPageSize()-float64(rowHeight))/2) 157 | } 158 | } 159 | } 160 | } 161 | 162 | // ConfirmDialog shows a confirmation message dialog 163 | func ConfirmDialog(parent gtk.IWindow, title, text string) bool { 164 | dlg := gtk.MessageDialogNew(parent, gtk.DIALOG_MODAL, gtk.MESSAGE_QUESTION, gtk.BUTTONS_OK_CANCEL, "") 165 | dlg.SetMarkup(fmt.Sprintf("%v\n\n%v", html.EscapeString(title), html.EscapeString(text))) 166 | defer dlg.Destroy() 167 | return dlg.Run() == gtk.RESPONSE_OK 168 | } 169 | 170 | // GetTextBufferText returns the entire text stored in a text buffer 171 | func GetTextBufferText(buf *gtk.TextBuffer) (string, error) { 172 | start, end := buf.GetBounds() 173 | return buf.GetText(start, end, true) 174 | } 175 | 176 | // EditDialog show a dialog with a single text entry 177 | func EditDialog(parent gtk.IWindow, title, value, okButton string) (string, bool) { 178 | // Create a dialog 179 | dlg, err := gtk.DialogNewWithButtons( 180 | title, 181 | parent, 182 | gtk.DIALOG_MODAL, 183 | []interface{}{okButton, gtk.RESPONSE_OK}, 184 | []interface{}{"Cancel", gtk.RESPONSE_CANCEL}) 185 | if errCheck(err, "DialogNewWithButtons() failed") { 186 | return "", false 187 | } 188 | defer dlg.Destroy() 189 | 190 | // Obtain the dialog's content area 191 | bx, err := dlg.GetContentArea() 192 | if errCheck(err, "GetContentArea() failed") { 193 | return "", false 194 | } 195 | 196 | // Add a text entry to the dialog 197 | entry, err := gtk.EntryNew() 198 | if errCheck(err, "EntryNew() failed") { 199 | return "", false 200 | } 201 | entry.SetSizeRequest(400, -1) 202 | entry.SetText(value) 203 | entry.SetMarginStart(12) 204 | entry.SetMarginEnd(12) 205 | entry.SetMarginTop(12) 206 | entry.SetMarginBottom(12) 207 | entry.GrabFocus() 208 | bx.Add(entry) 209 | 210 | bx.ShowAll() 211 | 212 | // Enable or disable the OK button based on text presence 213 | validate := func() { 214 | if w, err := dlg.GetWidgetForResponse(gtk.RESPONSE_OK); err == nil { 215 | text, err := entry.GetText() 216 | w.ToWidget().SetSensitive(err == nil && text != "") 217 | } 218 | } 219 | entry.Connect("changed", validate) 220 | dlg.Connect("map", validate) 221 | dlg.SetDefaultResponse(gtk.RESPONSE_OK) 222 | 223 | // Run the dialog 224 | response := dlg.Run() 225 | value, err = entry.GetText() 226 | if errCheck(err, "entry.GetText() failed") { 227 | return "", false 228 | } 229 | 230 | // Check the response 231 | if response == gtk.RESPONSE_OK { 232 | return value, true 233 | } 234 | return "", false 235 | } 236 | 237 | // EntryText returns the text in an entry, or the default string if an error occurred 238 | func EntryText(entry *gtk.Entry, def string) string { 239 | s, err := entry.GetText() 240 | if errCheck(err, "EntryText(): GetText() failed") { 241 | return def 242 | } 243 | return s 244 | } 245 | 246 | // ErrorDialog shows an error message dialog 247 | func ErrorDialog(parent gtk.IWindow, text string) { 248 | dlg := gtk.MessageDialogNew(parent, gtk.DIALOG_MODAL, gtk.MESSAGE_ERROR, gtk.BUTTONS_OK, text) 249 | defer dlg.Destroy() 250 | dlg.Run() 251 | } 252 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package config 17 | 18 | import ( 19 | "encoding/json" 20 | "errors" 21 | "fmt" 22 | "github.com/gotk3/gotk3/glib" 23 | "github.com/yktoo/ymuse/internal/util" 24 | "os" 25 | "path" 26 | "sync" 27 | ) 28 | 29 | // AppMetadata stores application-wide metadata such as version, license etc. 30 | var AppMetadata = &struct { 31 | Version string 32 | BuildDate string 33 | Name string 34 | Icon string 35 | Copyright string 36 | URL string 37 | URLLabel string 38 | ID string 39 | License string 40 | }{ 41 | Name: "Ymuse", 42 | Icon: "com.yktoo.ymuse", 43 | Copyright: "Written by Dmitry Kann", 44 | URL: "https://yktoo.com", 45 | URLLabel: "yktoo.com", 46 | ID: "com.yktoo.ymuse", 47 | License: "Licensed under the Apache License, Version 2.0 (the \"License\");\n" + 48 | "you may not use this file except in compliance with the License.\n" + 49 | "You may obtain a copy of the License at\n" + 50 | " http://www.apache.org/licenses/LICENSE-2.0\n" + 51 | "\n" + 52 | "Unless required by applicable law or agreed to in writing, software\n" + 53 | "distributed under the License is distributed on an \"AS IS\" BASIS,\n" + 54 | "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n" + 55 | "See the License for the specific language governing permissions and\n" + 56 | "limitations under the License.\n", 57 | } 58 | 59 | // Dimensions represents window dimensions 60 | type Dimensions struct { 61 | X, Y, Width, Height int 62 | } 63 | 64 | // ColumnSpec describes settings for a queue column 65 | type ColumnSpec struct { 66 | ID int // Column ID 67 | Width int // Column width, if differs from the default, otherwise 0 68 | } 69 | 70 | // StreamSpec describes settings for an Internet stream 71 | type StreamSpec struct { 72 | Name string // Stream name 73 | URI string // Stream URI 74 | } 75 | 76 | // Config represents (storable) application configuration 77 | type Config struct { 78 | MpdNetwork string // Network to use to connect to MPD, either 'tcp' or 'unix' 79 | MpdSocketPath string // Path to the MPD's Unix socket (only if MpdNetwork == 'unix') 80 | MpdHost string // MPD's IP address or hostname (only if MpdNetwork == 'tcp') 81 | MpdPort int // MPD's port number (only if MpdNetwork == 'tcp') 82 | MpdPassword string // MPD's password (optional) 83 | MpdAutoConnect bool // Whether to automatically connect to MPD on startup 84 | MpdAutoReconnect bool // Whether to automatically reconnect to MPD after connection is lost 85 | QueueColumns []ColumnSpec // Displayed queue columns 86 | QueueToolbar bool // Whether the queue toolbar is visible 87 | DefaultSortAttrID int // ID of MPD attribute used as a default for queue sorting 88 | TrackDefaultReplace bool // Whether the default action for double-clicking a track is replace rather than append 89 | PlaylistDefaultReplace bool // Whether the default action for double-clicking a playlist is replace rather than append 90 | StreamDefaultReplace bool // Whether the default action for double-clicking a stream is replace rather than append 91 | PlayerSeekDuration int // Number of seconds to seek back/forward at a time, while playing 92 | PlayerTitleTemplate string // Track's title formatting template for the player 93 | PlayerAlbumArtTracks bool // Whether to display the current track's album art in the player 94 | PlayerAlbumArtStreams bool // Whether to display the current stream's album art in the player 95 | PlayerAlbumArtSize int // Size of the album art image in the player, in pixels 96 | SwitchToOnQueueReplace bool // Whether to switch to the Queue tab after the queue has been replaced 97 | PlayOnQueueReplace bool // Whether to start playback after the queue has been replaced 98 | MaxSearchResults int // Maximum number of displayed search results 99 | Streams []StreamSpec // Registered stream specifications 100 | LibraryPath string // Last selected library path 101 | 102 | MainWindowDimensions Dimensions // Main window dimensions 103 | } 104 | 105 | // Config singleton with all settings 106 | var config *Config 107 | var once sync.Once 108 | 109 | // GetConfig returns a global Config instance 110 | func GetConfig() *Config { 111 | // Load the config from the file 112 | once.Do(func() { 113 | // Instantiate a config 114 | config = newConfig() 115 | // Load the config from the default file, if any 116 | config.Load() 117 | }) 118 | return config 119 | } 120 | 121 | // newConfig initialises and returns a config instance with all the defaults 122 | func newConfig() *Config { 123 | return &Config{ 124 | MpdNetwork: "tcp", 125 | MpdSocketPath: os.Getenv("XDG_RUNTIME_DIR") + "/mpd/socket", 126 | MpdHost: os.Getenv("MPD_HOST"), 127 | MpdPort: util.AtoiDef(os.Getenv("MPD_PORT"), 6600), 128 | MpdPassword: "", 129 | MpdAutoConnect: true, 130 | MpdAutoReconnect: true, 131 | QueueColumns: []ColumnSpec{ 132 | {ID: MTAttrArtist}, 133 | {ID: MTAttrYear}, 134 | {ID: MTAttrAlbum}, 135 | {ID: MTAttrDisc}, 136 | {ID: MTAttrNumber}, 137 | {ID: MTAttrTrack}, 138 | {ID: MTAttrLength}, 139 | {ID: MTAttrGenre}, 140 | }, 141 | QueueToolbar: true, 142 | DefaultSortAttrID: MTAttrPath, 143 | TrackDefaultReplace: false, 144 | PlaylistDefaultReplace: true, 145 | StreamDefaultReplace: true, 146 | PlayerSeekDuration: 5, 147 | PlayerTitleTemplate: glib.Local( 148 | "{{- if or .Title .Album | or .Artist -}}\n" + 149 | "{{ .Title | default \"(unknown title)\" }}\n" + 150 | "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | default \"(unknown album)\" }}\n" + 151 | "{{- else if .Name -}}\n" + 152 | "{{ .Name }}\n" + 153 | "{{- else if .file -}}\n" + 154 | "File {{ .file | basename }}\n" + 155 | "from {{ .file | dirname }}\n" + 156 | "{{- else -}}\n" + 157 | "(no track)\n" + 158 | "{{- end -}}\n"), 159 | PlayerAlbumArtTracks: true, 160 | PlayerAlbumArtStreams: false, 161 | PlayerAlbumArtSize: 80, 162 | SwitchToOnQueueReplace: true, 163 | PlayOnQueueReplace: false, 164 | MaxSearchResults: 500, 165 | Streams: []StreamSpec{ 166 | {Name: "BBC World News", URI: "http://stream.live.vc.bbcmedia.co.uk/bbc_world_service"}, 167 | }, 168 | MainWindowDimensions: Dimensions{-1, -1, -1, -1}, 169 | } 170 | } 171 | 172 | // Load reads the config from the default file 173 | func (c *Config) Load() { 174 | // Try to read the file 175 | file := c.getConfigFile() 176 | data, err := os.ReadFile(file) 177 | 178 | // Ignore if the file isn't there 179 | if errors.Is(err, os.ErrNotExist) { 180 | return 181 | } 182 | 183 | // Warn on any other error 184 | if errCheck(err, "Couldn't read file") { 185 | return 186 | } 187 | 188 | // Unmarshal the config 189 | if errCheck(json.Unmarshal(data, &c), "json.Unmarshal() failed") { 190 | return 191 | } 192 | log.Debugf("Loaded configuration from %s", file) 193 | } 194 | 195 | // MpdNetworkAddress returns the MPD network and the address string 196 | func (c *Config) MpdNetworkAddress() (string, string) { 197 | if c.MpdNetwork == "unix" { 198 | return "unix", c.MpdSocketPath 199 | } 200 | return "tcp", fmt.Sprintf("%s:%d", c.MpdHost, c.MpdPort) 201 | } 202 | 203 | // Save writes out the config to the default file 204 | func (c *Config) Save() { 205 | // Create the config directory if it doesn't exist 206 | if errCheck(os.MkdirAll(c.getConfigDir(), 0755), "MkdirAll() failed") { 207 | return 208 | } 209 | 210 | // Serialise the config 211 | data, err := json.MarshalIndent(c, "", " ") 212 | if errCheck(err, "json.MarshalIndent() failed") { 213 | return 214 | } 215 | 216 | // Save the config 217 | file := c.getConfigFile() 218 | if !errCheck(os.WriteFile(file, data, 0600), "WriteFile() failed") { 219 | log.Debugf("Saved configuration to %s", file) 220 | } 221 | } 222 | 223 | // getConfigDir returns the full path to the config directory 224 | func (c *Config) getConfigDir() string { 225 | return path.Join(glib.GetUserConfigDir(), "ymuse") 226 | } 227 | 228 | // getConfigFile returns the full path of the config file 229 | func (c *Config) getConfigFile() string { 230 | return path.Join(c.getConfigDir(), "config.json") 231 | } 232 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /internal/player/connector.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package player 17 | 18 | import ( 19 | "fmt" 20 | 21 | "sort" 22 | "sync" 23 | "time" 24 | 25 | "github.com/fhs/gompd/v2/mpd" 26 | ) 27 | 28 | // Connector encapsulates functionality for connecting to MPD and watch for its changes 29 | type Connector struct { 30 | mpdNetwork string // MPD network 31 | mpdAddress string // MPD address 32 | mpdPassword string // MPD password 33 | stayConnected bool // Whether a connection is supposed to be kept alive 34 | 35 | mpdClient *mpd.Client // MPD client instance 36 | mpdClientConnecting bool // Whether MPD connection is being established 37 | mpdClientMutex sync.RWMutex 38 | 39 | mpdStatus mpd.Attrs // Last reported MPD status 40 | mpdStatusMutex sync.RWMutex 41 | 42 | chConnectorConnect chan bool // Connector's connect channel 43 | chConnectorQuit chan bool // Connector's quit channel 44 | 45 | chWatcherStart chan bool // Watcher's start channel 46 | chWatcherStop chan bool // Watcher's suspend/quit channel 47 | 48 | onStatusChange func() // Callback for connection status change notifications 49 | onHeartbeat func() // Callback for periodic message notifications 50 | onSubsystemChange func(subsystem string) // Callback for subsystem change notifications 51 | } 52 | 53 | // NewConnector creates and returns a new Connector instance 54 | func NewConnector(onStatusChange func(), onHeartbeat func(), onSubsystemChange func(subsystem string)) *Connector { 55 | return &Connector{ 56 | mpdStatus: mpd.Attrs{}, 57 | onStatusChange: onStatusChange, 58 | onHeartbeat: onHeartbeat, 59 | onSubsystemChange: onSubsystemChange, 60 | chConnectorConnect: make(chan bool), 61 | chConnectorQuit: make(chan bool), 62 | chWatcherStart: make(chan bool), 63 | chWatcherStop: make(chan bool), 64 | } 65 | } 66 | 67 | // Start initialises the connector 68 | // stayConnected: whether the connection must be automatically re-established when lost 69 | func (c *Connector) Start(mpdNetwork, mpdAddress, mpdPassword string, stayConnected bool) { 70 | c.mpdNetwork = mpdNetwork 71 | c.mpdAddress = mpdAddress 72 | c.mpdPassword = mpdPassword 73 | c.stayConnected = stayConnected 74 | 75 | // Start the connect goroutine 76 | go c.connect() 77 | 78 | // Start the watch goroutine 79 | go c.watch() 80 | 81 | c.startConnecting() 82 | } 83 | 84 | // Status returns the last known MPD status 85 | func (c *Connector) Status() mpd.Attrs { 86 | c.mpdStatusMutex.RLock() 87 | defer c.mpdStatusMutex.RUnlock() 88 | return c.mpdStatus 89 | } 90 | 91 | // Stop signals the connector to shut down 92 | func (c *Connector) Stop() { 93 | // Ignore if not connected/connecting 94 | if connected, connecting := c.ConnectStatus(); !connected && !connecting { 95 | return 96 | } 97 | 98 | // Quit connector and watcher 99 | c.stayConnected = false 100 | c.chConnectorQuit <- true 101 | c.chWatcherStop <- true 102 | 103 | // Close the connection to MPD, if any 104 | c.mpdClientMutex.Lock() 105 | c.mpdClientConnecting = false 106 | if c.mpdClient != nil { 107 | log.Debug("Disconnect from MPD") 108 | errCheck(c.mpdClient.Close(), "Close() failed") 109 | c.mpdClient = nil 110 | } 111 | c.mpdClientMutex.Unlock() 112 | 113 | // Reset the status 114 | c.setStatus(mpd.Attrs{}) 115 | 116 | // Notify the callback 117 | c.onStatusChange() 118 | } 119 | 120 | // GetPlaylists queries and returns a slice of playlist names available in MPD 121 | func (c *Connector) GetPlaylists() []string { 122 | // Fetch the list of playlists 123 | var attrs []mpd.Attrs 124 | var err error 125 | c.IfConnected(func(client *mpd.Client) { 126 | attrs, err = client.ListPlaylists() 127 | }) 128 | if errCheck(err, "ListPlaylists() failed") { 129 | return nil 130 | } 131 | 132 | // Convert attrs to a slice of strings 133 | names := make([]string, len(attrs)) 134 | for i, a := range attrs { 135 | names[i] = a["playlist"] 136 | } 137 | 138 | // Sort the list by name 139 | sort.Strings(names) 140 | return names 141 | } 142 | 143 | // IfConnected runs MPD client code if there's a connection with MPD 144 | func (c *Connector) IfConnected(funcIfConnected func(client *mpd.Client)) { 145 | c.mpdClientMutex.RLock() 146 | defer c.mpdClientMutex.RUnlock() 147 | if c.mpdClient != nil { 148 | funcIfConnected(c.mpdClient) 149 | } 150 | } 151 | 152 | // ConnectStatus returns whether there's a connection with MPD and whether it's being established 153 | func (c *Connector) ConnectStatus() (bool, bool) { 154 | c.mpdClientMutex.RLock() 155 | defer c.mpdClientMutex.RUnlock() 156 | return c.mpdClient != nil, c.mpdClientConnecting 157 | } 158 | 159 | // setStatus sets the current MPD status, thread-safely 160 | func (c *Connector) setStatus(attrs mpd.Attrs) { 161 | c.mpdStatusMutex.Lock() 162 | defer c.mpdStatusMutex.Unlock() 163 | c.mpdStatus = attrs 164 | } 165 | 166 | // startConnecting signals the connector to initiate connection process 167 | func (c *Connector) startConnecting() { 168 | go func() { c.chConnectorConnect <- true }() 169 | } 170 | 171 | // connect maintains MPD connection and invokes callbacks until something is sent via chConnectorQuit 172 | func (c *Connector) connect() { 173 | log.Debug("connect()") 174 | var heartbeatTicker = time.NewTicker(time.Second) 175 | for { 176 | select { 177 | // Request to connect 178 | case <-c.chConnectorConnect: 179 | c.doConnect(true, false) 180 | 181 | // Heartbeat tick 182 | case <-heartbeatTicker.C: 183 | c.doConnect(false, true) 184 | 185 | // Request to quit 186 | case <-c.chConnectorQuit: 187 | // Kill the heartbeat timer 188 | heartbeatTicker.Stop() 189 | return 190 | } 191 | } 192 | } 193 | 194 | // doConnect takes care of (re)establishing a connection to MPD and calling the status/heartbeat callbacks 195 | func (c *Connector) doConnect(connect, heartbeat bool) { 196 | var err error 197 | var client *mpd.Client 198 | var wasConnected bool 199 | connected, _ := c.ConnectStatus() 200 | 201 | // If there's a request to connect and not connected yet 202 | if connect && !connected { 203 | // Set the connecting flag 204 | c.mpdClientMutex.Lock() 205 | c.mpdClientConnecting = true 206 | c.mpdClientMutex.Unlock() 207 | 208 | // Notify the callback we're about to connect 209 | c.onStatusChange() 210 | 211 | // Try to connect 212 | log.Debugf("Connecting to MPD (network=%v, address=%v)", c.mpdNetwork, c.mpdAddress) 213 | if client, err = mpd.DialAuthenticated(c.mpdNetwork, c.mpdAddress, c.mpdPassword); err == nil { 214 | connected = true 215 | } else { 216 | err = fmt.Errorf("DialAuthenticated() failed: %v", err) 217 | } 218 | } 219 | 220 | // If there's a local client, we've just connected 221 | status := mpd.Attrs{} 222 | if connected && client != nil { 223 | // Validate the connection by requesting MPD status and, on success, save the client connection 224 | if status, err = client.Status(); err == nil { 225 | c.mpdClientMutex.Lock() 226 | c.mpdClientConnecting = false 227 | c.mpdClient = client 228 | c.mpdClientMutex.Unlock() 229 | log.Info("Successfully connected to MPD") 230 | 231 | // Start the watcher 232 | go func() { c.chWatcherStart <- true }() 233 | } else { 234 | connected = false 235 | err = fmt.Errorf("Status() after dial failed: %v", err) 236 | // Disconnect since we're not "fully connected" 237 | errCheck(client.Close(), "doConnect(): Close() failed") 238 | } 239 | 240 | } else { 241 | connected = false 242 | // We didn't connect. Validate the existing connection, if any 243 | c.IfConnected(func(client *mpd.Client) { 244 | wasConnected = true 245 | if status, err = client.Status(); err == nil { 246 | connected = true 247 | } else { 248 | err = fmt.Errorf("Status() failed: %v", err) 249 | } 250 | }) 251 | 252 | // Connection lost 253 | if wasConnected && !connected { 254 | log.Warning("Connection to MPD lost") 255 | 256 | // Remove client connection 257 | c.mpdClientMutex.Lock() 258 | c.mpdClientConnecting = false 259 | c.mpdClient = nil 260 | c.mpdClientMutex.Unlock() 261 | 262 | // Suspend the watcher 263 | go func() { c.chWatcherStop <- false }() 264 | } 265 | } 266 | 267 | // On error, replace status with the error info 268 | if errCheck(err, "Failed to connect to MPD") { 269 | status = mpd.Attrs{"error": err.Error()} 270 | } 271 | 272 | // Store the (updated) status 273 | c.setStatus(status) 274 | 275 | // Notify the status callback on status change 276 | if wasConnected != connected { 277 | c.onStatusChange() 278 | } 279 | 280 | if heartbeat { 281 | // No connection (anymore), re-attempt connection if needed, but not more frequently than once in a heartbeat 282 | if !connected && c.stayConnected { 283 | c.startConnecting() 284 | } 285 | 286 | // Notify the heartbeat callback 287 | c.onHeartbeat() 288 | } 289 | } 290 | 291 | // watch starts watching MPD subsystem changes 292 | func (c *Connector) watch() { 293 | log.Debug("watch()") 294 | var rewatchTimer *time.Timer 295 | var eventChannel chan string 296 | var errorChannel chan error 297 | var mpdWatcher *mpd.Watcher 298 | for { 299 | select { 300 | // Request to watch 301 | case <-c.chWatcherStart: 302 | log.Debug("Start watcher") 303 | 304 | // Remove the timer 305 | rewatchTimer = nil 306 | 307 | // If no watcher yet 308 | if mpdWatcher == nil { 309 | watcher, err := mpd.NewWatcher(c.mpdNetwork, c.mpdAddress, c.mpdPassword) 310 | // Failed to connect 311 | if err != nil { 312 | log.Warning("Failed to watch MPD", err) 313 | // Schedule a reconnection 314 | rewatchTimer = time.AfterFunc(3*time.Second, func() { 315 | c.chWatcherStart <- true 316 | }) 317 | 318 | } else { 319 | // Connection succeeded 320 | mpdWatcher = watcher 321 | eventChannel = watcher.Event 322 | errorChannel = watcher.Error 323 | } 324 | } 325 | 326 | // Watcher's event 327 | case subsystem := <-eventChannel: 328 | // Provide an empty map as fallback 329 | status := mpd.Attrs{} 330 | 331 | // Request player status if there's a connection 332 | c.IfConnected(func(client *mpd.Client) { 333 | st, err := client.Status() 334 | if errCheck(err, "watch(): Status() failed") { 335 | return 336 | } 337 | status = st 338 | }) 339 | 340 | // Update the MPD's status 341 | c.setStatus(status) 342 | 343 | // Notify the callback 344 | c.onSubsystemChange(subsystem) 345 | 346 | // Watcher's error 347 | case err := <-errorChannel: 348 | log.Debug("Watcher error", err) 349 | 350 | // Request to quit 351 | case doQuit := <-c.chWatcherStop: 352 | // Kill the reconnection timer, if any 353 | if rewatchTimer != nil { 354 | rewatchTimer.Stop() 355 | rewatchTimer = nil 356 | } 357 | 358 | // Close the connection to MPD, if any 359 | if mpdWatcher != nil { 360 | log.Debug("Stop watcher") 361 | errCheck(mpdWatcher.Close(), "mpdWatcher.Close() failed") 362 | mpdWatcher = nil 363 | } 364 | 365 | // If we need to quit 366 | if doQuit { 367 | return 368 | } 369 | } 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /internal/player/glade/shortcuts.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 6 | Ymuse shortcuts 7 | 1 8 | 800 9 | 600 10 | 11 | 12 | shortcuts 13 | Application shortcuts 14 | 15 | 16 | General 17 | 18 | 19 | (Re)connect to MPD 20 | <ctrl><shift>c 21 | 22 | 23 | 24 | 25 | Disconnect from MPD 26 | <ctrl><shift>d 27 | 28 | 29 | 30 | 31 | MPD Information 32 | <ctrl><shift>i 33 | 34 | 35 | 36 | 37 | MPD Outputs 38 | <ctrl>o 39 | 40 | 41 | 42 | 43 | Preferences 44 | <ctrl>comma 45 | 46 | 47 | 48 | 49 | Quit 50 | <ctrl>q 51 | 52 | 53 | 54 | 55 | About 56 | F1 57 | 58 | 59 | 60 | 61 | Keyboard Shortcuts 62 | <ctrl><shift>question 63 | 64 | 65 | 66 | 67 | Switch to Queue tab 68 | <ctrl>1 69 | 70 | 71 | 72 | 73 | Switch to Library tab 74 | <ctrl>2 75 | 76 | 77 | 78 | 79 | Switch to Streams tab 80 | <ctrl>3 81 | 82 | 83 | 84 | 85 | 86 | 87 | Player 88 | 89 | 90 | Previous track 91 | <ctrl>Left 92 | 93 | 94 | 95 | 96 | Next track 97 | <ctrl>Right 98 | 99 | 100 | 101 | 102 | Stop 103 | <ctrl>S 104 | 105 | 106 | 107 | 108 | Toggle play/pause 109 | <ctrl>P 110 | 111 | 112 | 113 | 114 | Toggle random mode 115 | <ctrl>U 116 | 117 | 118 | 119 | 120 | Toggle repeat mode 121 | <ctrl>R 122 | 123 | 124 | 125 | 126 | Toggle consume mode 127 | <ctrl>N 128 | 129 | 130 | 131 | 132 | Seek backward 133 | <ctrl><shift>Left 134 | 135 | 136 | 137 | 138 | Seek forward 139 | <ctrl><shift>Right 140 | 141 | 142 | 143 | 144 | 145 | 146 | Queue 147 | 148 | 149 | Play selection 150 | Return 151 | 152 | 153 | 154 | 155 | Toggle play/pause 156 | space 157 | 158 | 159 | 160 | 161 | Delete selected 162 | Delete 163 | 164 | 165 | 166 | 167 | Now playing 168 | <ctrl>J 169 | 170 | 171 | 172 | 173 | Open Filter bar 174 | <ctrl>F 175 | 176 | 177 | 178 | 179 | Shuffle the queue 180 | <ctrl><shift>R 181 | 182 | 183 | 184 | 185 | 186 | 187 | Library 188 | 189 | 190 | Default action (set in Preferences) 191 | Return 192 | 193 | 194 | 195 | 196 | Replace queue with selection 197 | <ctrl>Return 198 | 199 | 200 | 201 | 202 | Append selection to queue 203 | <shift>Return 204 | 205 | 206 | 207 | 208 | Go a level up 209 | BackSpace 210 | 211 | 212 | 213 | 214 | Open Search bar 215 | <ctrl>F 216 | 217 | 218 | 219 | 220 | 221 | 222 | Streams 223 | 224 | 225 | Default action (set in Preferences) 226 | Return 227 | 228 | 229 | 230 | 231 | Replace queue with selection 232 | <ctrl>Return 233 | 234 | 235 | 236 | 237 | Append selection to queue 238 | <shift>Return 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /internal/player/glade/mpd-info.glade: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | False 7 | True 8 | True 9 | dialog 10 | ok 11 | <b><big>MPD Information</big></b> 12 | True 13 | 14 | 15 | False 16 | vertical 17 | 2 18 | 19 | 20 | False 21 | 22 | 23 | False 24 | False 25 | 0 26 | 27 | 28 | 29 | 30 | 31 | True 32 | False 33 | 20 34 | 3 35 | 12 36 | 37 | 38 | True 39 | False 40 | Daemon version: 41 | 0 42 | 43 | 44 | 45 | 46 | 47 | 0 48 | 0 49 | 50 | 51 | 52 | 53 | True 54 | False 55 | Number of artists: 56 | 0 57 | 58 | 59 | 60 | 61 | 62 | 0 63 | 1 64 | 65 | 66 | 67 | 68 | True 69 | False 70 | Number of albums: 71 | 0 72 | 73 | 74 | 75 | 76 | 77 | 0 78 | 2 79 | 80 | 81 | 82 | 83 | True 84 | False 85 | Number of tracks: 86 | 0 87 | 88 | 89 | 90 | 91 | 92 | 0 93 | 3 94 | 95 | 96 | 97 | 98 | True 99 | False 100 | Total playing time: 101 | 0 102 | 103 | 104 | 105 | 106 | 107 | 0 108 | 4 109 | 110 | 111 | 112 | 113 | True 114 | False 115 | Last database update: 116 | 0 117 | 118 | 119 | 120 | 121 | 122 | 0 123 | 5 124 | 125 | 126 | 127 | 128 | True 129 | False 130 | Daemon uptime: 131 | 0 132 | 133 | 134 | 135 | 136 | 137 | 0 138 | 6 139 | 140 | 141 | 142 | 143 | True 144 | False 145 | Listening time: 146 | 0 147 | 148 | 149 | 150 | 151 | 152 | 0 153 | 7 154 | 155 | 156 | 157 | 158 | True 159 | False 160 | 1 161 | 162 | 163 | 1 164 | 0 165 | 166 | 167 | 168 | 169 | True 170 | False 171 | 1 172 | 173 | 174 | 1 175 | 1 176 | 177 | 178 | 179 | 180 | True 181 | False 182 | 1 183 | 184 | 185 | 1 186 | 2 187 | 188 | 189 | 190 | 191 | True 192 | False 193 | 1 194 | 195 | 196 | 1 197 | 3 198 | 199 | 200 | 201 | 202 | True 203 | False 204 | 1 205 | 206 | 207 | 1 208 | 4 209 | 210 | 211 | 212 | 213 | True 214 | False 215 | 1 216 | 217 | 218 | 1 219 | 5 220 | 221 | 222 | 223 | 224 | True 225 | False 226 | 1 227 | 228 | 229 | 1 230 | 6 231 | 232 | 233 | 234 | 235 | True 236 | False 237 | 1 238 | 239 | 240 | 1 241 | 7 242 | 243 | 244 | 245 | 246 | True 247 | True 248 | 6 249 | 6 250 | 251 | 252 | 253 | True 254 | False 255 | 12 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | True 288 | False 289 | Decoder plugins 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 0 298 | 8 299 | 2 300 | 301 | 302 | 303 | 304 | False 305 | True 306 | 2 307 | 308 | 309 | 310 | 311 | 312 | 313 | -------------------------------------------------------------------------------- /resources/i18n/ymuse.pot: -------------------------------------------------------------------------------- 1 | # Ymuse MPD client 2 | # Copyright (C) 2020-2021 Dmitry Kann 3 | # This file is distributed under the same license as the Ymuse package. 4 | # 5 | #, fuzzy 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2020-05-25 12:11+0200\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | 19 | msgid "" 20 | "{{- if or .Title .Album | or .Artist -}}\n" 21 | "{{ .Title | default \"(unknown title)\" }}\n" 22 | "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | default \"(unknown album)\" }}\n" 23 | "{{- else if .Name -}}\n" 24 | "{{ .Name }}\n" 25 | "{{- else if .file -}}\n" 26 | "File {{ .file | basename }}\n" 27 | "from {{ .file | dirname }}\n" 28 | "{{- else -}}\n" 29 | "(no track)\n" 30 | "{{- end -}}\n" 31 | msgstr "" 32 | 33 | msgid "#" 34 | msgstr "" 35 | 36 | msgid "%d items" 37 | msgstr "" 38 | 39 | msgid "%d streams" 40 | msgstr "" 41 | 42 | msgid "%d track(s) displayed" 43 | msgstr "" 44 | 45 | msgid "%d tracks" 46 | msgstr "" 47 | 48 | msgid "(leave empty for localhost)" 49 | msgstr "" 50 | 51 | msgid "(limited selection of %d items)" 52 | msgstr "" 53 | 54 | msgid "(new playlist)" 55 | msgstr "" 56 | 57 | msgid "(Re)connect to MPD" 58 | msgstr "" 59 | 60 | msgid "(unknown)" 61 | msgstr "" 62 | 63 | msgid "Library" 64 | msgstr "" 65 | 66 | msgid "MPD connection" 67 | msgstr "" 68 | 69 | msgid "MPD Information" 70 | msgstr "" 71 | 72 | msgid "Player" 73 | msgstr "" 74 | 75 | msgid "Playlists" 76 | msgstr "" 77 | 78 | msgid "Queue" 79 | msgstr "" 80 | 81 | msgid "Streams" 82 | msgstr "" 83 | 84 | msgid "_About…" 85 | msgstr "" 86 | 87 | msgid "_Connect to MPD" 88 | msgstr "" 89 | 90 | msgid "_Disconnect from MPD" 91 | msgstr "" 92 | 93 | msgid "_Keyboard shortcuts…" 94 | msgstr "" 95 | 96 | msgid "_Preferences…" 97 | msgstr "" 98 | 99 | msgid "_Quit" 100 | msgstr "" 101 | 102 | msgid "About" 103 | msgstr "" 104 | 105 | msgid "Add a new stream" 106 | msgstr "" 107 | 108 | msgid "Add" 109 | msgstr "" 110 | 111 | msgid "Add the selected item to a playlist" 112 | msgstr "" 113 | 114 | msgid "Add to ▾" 115 | msgstr "" 116 | 117 | msgid "Add to playlist…" 118 | msgstr "" 119 | 120 | msgid "After the queue is replaced:" 121 | msgstr "" 122 | 123 | msgid "Album (for sorting)" 124 | msgstr "" 125 | 126 | msgid "Album art:" 127 | msgstr "" 128 | 129 | msgid "Album artist (for sorting)" 130 | msgstr "" 131 | 132 | msgid "Album artist" 133 | msgstr "" 134 | 135 | msgid "Album" 136 | msgstr "" 137 | 138 | msgid "Albums" 139 | msgstr "" 140 | 141 | msgid "and" 142 | msgstr "" 143 | 144 | msgid "Append selection to queue" 145 | msgstr "" 146 | 147 | msgid "Append to the queue" 148 | msgstr "" 149 | 150 | msgid "Append tracks" 151 | msgstr "" 152 | 153 | msgid "Application shortcuts" 154 | msgstr "" 155 | 156 | msgid "Apply" 157 | msgstr "" 158 | 159 | msgid "Are you sure you want to delete playlist \"%s\"?" 160 | msgstr "" 161 | 162 | msgid "Are you sure you want to delete stream \"%s\"?" 163 | msgstr "" 164 | 165 | msgid "Artist (for sorting)" 166 | msgstr "" 167 | 168 | msgid "Artist" 169 | msgstr "" 170 | 171 | msgid "Artists" 172 | msgstr "" 173 | 174 | msgid "Ascending" 175 | msgstr "" 176 | 177 | msgid "Automatically connect on startup" 178 | msgstr "" 179 | 180 | msgid "Automatically reconnect" 181 | msgstr "" 182 | 183 | msgid "Automation" 184 | msgstr "" 185 | 186 | msgid "Choose outputs MPD should use for playback." 187 | msgstr "" 188 | 189 | msgid "Clear the play queue" 190 | msgstr "" 191 | 192 | msgid "Clear" 193 | msgstr "" 194 | 195 | msgid "Columns" 196 | msgstr "" 197 | 198 | msgid "Comment" 199 | msgstr "" 200 | 201 | msgid "Composer" 202 | msgstr "" 203 | 204 | msgid "Conductor" 205 | msgstr "" 206 | 207 | msgid "Connecting to MPD…" 208 | msgstr "" 209 | 210 | msgid "Connect to MPD" 211 | msgstr "" 212 | 213 | msgid "Consume mode" 214 | msgstr "" 215 | 216 | msgid "Consume" 217 | msgstr "" 218 | 219 | msgid "Current track time" 220 | msgstr "" 221 | 222 | msgid "Daemon uptime:" 223 | msgstr "" 224 | 225 | msgid "Daemon version:" 226 | msgstr "" 227 | 228 | msgid "days" 229 | msgstr "" 230 | 231 | msgid "Decoder plugins" 232 | msgstr "" 233 | 234 | msgid "Default action (set in Preferences)" 235 | msgstr "" 236 | 237 | msgid "Delete playlist" 238 | msgstr "" 239 | 240 | msgid "Delete selected" 241 | msgstr "" 242 | 243 | msgid "Delete stream" 244 | msgstr "" 245 | 246 | msgid "Delete the selected item" 247 | msgstr "" 248 | 249 | msgid "Delete the selected stream" 250 | msgstr "" 251 | 252 | msgid "Delete" 253 | msgstr "" 254 | 255 | msgid "Descending" 256 | msgstr "" 257 | 258 | msgid "Directory and file name" 259 | msgstr "" 260 | 261 | msgid "Directory" 262 | msgstr "" 263 | 264 | msgid "Disc" 265 | msgstr "" 266 | 267 | msgid "Disconnect from MPD" 268 | msgstr "" 269 | 270 | msgid "Edit the selected stream" 271 | msgstr "" 272 | 273 | msgid "Edit" 274 | msgstr "" 275 | 276 | msgid "Everywhere" 277 | msgstr "" 278 | 279 | msgid "Failed to add item to the playlist" 280 | msgstr "" 281 | 282 | msgid "Failed to add item to the queue" 283 | msgstr "" 284 | 285 | msgid "Failed to add playlist to the queue" 286 | msgstr "" 287 | 288 | msgid "Failed to add stream to the queue" 289 | msgstr "" 290 | 291 | msgid "Failed to clear the queue" 292 | msgstr "" 293 | 294 | msgid "Failed to create a playlist" 295 | msgstr "" 296 | 297 | msgid "Failed to delete the playlist" 298 | msgstr "" 299 | 300 | msgid "Failed to delete tracks from the queue" 301 | msgstr "" 302 | 303 | msgid "Failed to get album information" 304 | msgstr "" 305 | 306 | msgid "Failed to get artist information" 307 | msgstr "" 308 | 309 | msgid "Failed to get genre information" 310 | msgstr "" 311 | 312 | msgid "Failed to load UI widgets" 313 | msgstr "" 314 | 315 | msgid "Failed to play the selected track" 316 | msgstr "" 317 | 318 | msgid "Failed to rename the playlist" 319 | msgstr "" 320 | 321 | msgid "Failed to retrieve information from MPD" 322 | msgstr "" 323 | 324 | msgid "Failed to shuffle the queue" 325 | msgstr "" 326 | 327 | msgid "Failed to skip to next track" 328 | msgstr "" 329 | 330 | msgid "Failed to skip to previous track" 331 | msgstr "" 332 | 333 | msgid "Failed to sort the queue" 334 | msgstr "" 335 | 336 | msgid "Failed to stop playback" 337 | msgstr "" 338 | 339 | msgid "Failed to toggle consume mode" 340 | msgstr "" 341 | 342 | msgid "Failed to toggle playback" 343 | msgstr "" 344 | 345 | msgid "Failed to toggle random mode" 346 | msgstr "" 347 | 348 | msgid "Failed to toggle repeat/single mode" 349 | msgstr "" 350 | 351 | msgid "Failed to update the library" 352 | msgstr "" 353 | 354 | msgid "File name" 355 | msgstr "" 356 | 357 | msgid "File path" 358 | msgstr "" 359 | 360 | msgid "File" 361 | msgstr "" 362 | 363 | msgid "Files" 364 | msgstr "" 365 | 366 | msgid "Filter the play queue" 367 | msgstr "" 368 | 369 | msgid "Filter…" 370 | msgstr "" 371 | 372 | msgid "General" 373 | msgstr "" 374 | 375 | msgid "Genre" 376 | msgstr "" 377 | 378 | msgid "Genres" 379 | msgstr "" 380 | 381 | msgid "Go a level up" 382 | msgstr "" 383 | 384 | msgid "Grouping" 385 | msgstr "" 386 | 387 | msgid "Host:" 388 | msgstr "" 389 | 390 | msgid "Image size:" 391 | msgstr "" 392 | 393 | msgid "Interface" 394 | msgstr "" 395 | 396 | msgid "Jump to the currently played track" 397 | msgstr "" 398 | 399 | msgid "Keyboard Shortcuts" 400 | msgstr "" 401 | 402 | msgid "Label" 403 | msgstr "" 404 | 405 | msgid "Last database update:" 406 | msgstr "" 407 | 408 | msgid "Length" 409 | msgstr "" 410 | 411 | msgid "Library" 412 | msgstr "" 413 | 414 | msgid "Listening time:" 415 | msgstr "" 416 | 417 | msgid "more verbose logging" 418 | msgstr "" 419 | 420 | msgid "Move down" 421 | msgstr "" 422 | 423 | msgid "Move the selected column down" 424 | msgstr "" 425 | 426 | msgid "Move the selected column up" 427 | msgstr "" 428 | 429 | msgid "Move up" 430 | msgstr "" 431 | 432 | msgid "MPD _information…" 433 | msgstr "" 434 | 435 | msgid "MPD _outputs…" 436 | msgstr "" 437 | 438 | msgid "MPD Information" 439 | msgstr "" 440 | 441 | msgid "MPD Outputs" 442 | msgstr "" 443 | 444 | msgid "Name" 445 | msgstr "" 446 | 447 | msgid "Network:" 448 | msgstr "" 449 | 450 | msgid "New playlist name" 451 | msgstr "" 452 | 453 | msgid "Next track" 454 | msgstr "" 455 | 456 | msgid "Next" 457 | msgstr "" 458 | 459 | msgid "No items" 460 | msgstr "" 461 | 462 | msgid "No streams" 463 | msgstr "" 464 | 465 | msgid "Not connected to MPD" 466 | msgstr "" 467 | 468 | msgid "Now playing" 469 | msgstr "" 470 | 471 | msgid "Number of albums:" 472 | msgstr "" 473 | 474 | msgid "Number of artists:" 475 | msgstr "" 476 | 477 | msgid "Number of tracks:" 478 | msgstr "" 479 | 480 | msgid "On double click / Enter on a playlist:" 481 | msgstr "" 482 | 483 | msgid "On double click / Enter on a stream:" 484 | msgstr "" 485 | 486 | msgid "On double click / Enter on a track:" 487 | msgstr "" 488 | 489 | msgid "one day" 490 | msgstr "" 491 | 492 | msgid "One track" 493 | msgstr "" 494 | 495 | msgid "Open Filter bar" 496 | msgstr "" 497 | 498 | msgid "Open Search bar" 499 | msgstr "" 500 | 501 | msgid "Password:" 502 | msgstr "" 503 | 504 | msgid "Path" 505 | msgstr "" 506 | 507 | msgid "Path:" 508 | msgstr "" 509 | 510 | msgid "Pause or resume playback" 511 | msgstr "" 512 | 513 | msgid "Performer" 514 | msgstr "" 515 | 516 | msgid "Play selection" 517 | msgstr "" 518 | 519 | msgid "Play/Pause" 520 | msgstr "" 521 | 522 | msgid "Player title template error, check log" 523 | msgstr "" 524 | 525 | msgid "Player" 526 | msgstr "" 527 | 528 | msgid "playing time %s" 529 | msgstr "" 530 | 531 | msgid "Playlists" 532 | msgstr "" 533 | 534 | msgid "Port:" 535 | msgstr "" 536 | 537 | msgid "Preferences" 538 | msgstr "" 539 | 540 | msgid "Previous track" 541 | msgstr "" 542 | 543 | msgid "Previous" 544 | msgstr "" 545 | 546 | msgid "Queue is empty" 547 | msgstr "" 548 | 549 | msgid "Queue" 550 | msgstr "" 551 | 552 | msgid "Quit" 553 | msgstr "" 554 | 555 | msgid "Random" 556 | msgstr "" 557 | 558 | msgid "Reconnect now" 559 | msgstr "" 560 | 561 | msgid "Release date: %s" 562 | msgstr "" 563 | 564 | msgid "Remove selected track(s) from the queue" 565 | msgstr "" 566 | 567 | msgid "Rename playlist" 568 | msgstr "" 569 | 570 | msgid "Rename the selected item" 571 | msgstr "" 572 | 573 | msgid "Rename" 574 | msgstr "" 575 | 576 | msgid "Repeat mode" 577 | msgstr "" 578 | 579 | msgid "Repeat" 580 | msgstr "" 581 | 582 | msgid "Replace playlist" 583 | msgstr "" 584 | 585 | msgid "Replace queue with selection" 586 | msgstr "" 587 | 588 | msgid "Replace the queue" 589 | msgstr "" 590 | 591 | msgid "Rescan all files" 592 | msgstr "" 593 | 594 | msgid "Rescan selected item" 595 | msgstr "" 596 | 597 | msgid "Save into playlist" 598 | msgstr "" 599 | 600 | msgid "Save selected tracks only" 601 | msgstr "" 602 | 603 | msgid "Save the play queue as a playlist" 604 | msgstr "" 605 | 606 | msgid "Save ▾" 607 | msgstr "" 608 | 609 | msgid "Search the library" 610 | msgstr "" 611 | 612 | msgid "Search" 613 | msgstr "" 614 | 615 | msgid "Search…" 616 | msgstr "" 617 | 618 | msgid "Seek backward" 619 | msgstr "" 620 | 621 | msgid "Seek forward" 622 | msgstr "" 623 | 624 | msgid "Select columns to display in the play queue, and their order." 625 | msgstr "" 626 | 627 | msgid "Show album in Library" 628 | msgstr "" 629 | 630 | msgid "Show artist in Library" 631 | msgstr "" 632 | 633 | msgid "Show for streams" 634 | msgstr "" 635 | 636 | msgid "Show for tracks" 637 | msgstr "" 638 | 639 | msgid "Show genre in Library" 640 | msgstr "" 641 | 642 | msgid "Show toolbar" 643 | msgstr "" 644 | 645 | msgid "Shuffle mode" 646 | msgstr "" 647 | 648 | msgid "Shuffle the queue" 649 | msgstr "" 650 | 651 | msgid "Shuffle" 652 | msgstr "" 653 | 654 | msgid "Sort queue by" 655 | msgstr "" 656 | 657 | msgid "Sort the play queue" 658 | msgstr "" 659 | 660 | msgid "Sort ▾" 661 | msgstr "" 662 | 663 | msgid "Start playback" 664 | msgstr "" 665 | 666 | msgid "Stop playback" 667 | msgstr "" 668 | 669 | msgid "Stop" 670 | msgstr "" 671 | 672 | msgid "Stream name" 673 | msgstr "" 674 | 675 | msgid "Stream name:" 676 | msgstr "" 677 | 678 | msgid "Stream URI:" 679 | msgstr "" 680 | 681 | msgid "Streams" 682 | msgstr "" 683 | 684 | msgid "Switch to Library tab" 685 | msgstr "" 686 | 687 | msgid "Switch to Queue tab" 688 | msgstr "" 689 | 690 | msgid "Switch to Streams tab" 691 | msgstr "" 692 | 693 | msgid "TCP" 694 | msgstr "" 695 | 696 | msgid "Template error" 697 | msgstr "" 698 | 699 | msgid "Title" 700 | msgstr "" 701 | 702 | msgid "Toggle consume mode" 703 | msgstr "" 704 | 705 | msgid "Toggle play/pause" 706 | msgstr "" 707 | 708 | msgid "Toggle random mode" 709 | msgstr "" 710 | 711 | msgid "Toggle repeat mode" 712 | msgstr "" 713 | 714 | msgid "Total playing time:" 715 | msgstr "" 716 | 717 | msgid "Track attribute(s) to search" 718 | msgstr "" 719 | 720 | msgid "Track length" 721 | msgstr "" 722 | 723 | msgid "Track number" 724 | msgstr "" 725 | 726 | msgid "Track title template:" 727 | msgstr "" 728 | 729 | msgid "Track title" 730 | msgstr "" 731 | 732 | msgid "Track" 733 | msgstr "" 734 | 735 | msgid "Unix socket" 736 | msgstr "" 737 | 738 | msgid "Unnamed" 739 | msgstr "" 740 | 741 | msgid "Update" 742 | msgstr "" 743 | 744 | msgid "Update entire library" 745 | msgstr "" 746 | 747 | msgid "Update selected item" 748 | msgstr "" 749 | 750 | msgid "Update the entire music database" 751 | msgstr "" 752 | 753 | msgid "Update the entire music database, including unmodified files" 754 | msgstr "" 755 | 756 | msgid "Update the music library" 757 | msgstr "" 758 | 759 | msgid "Update the selected item in music database" 760 | msgstr "" 761 | 762 | msgid "Update the selected item, including unmodified files" 763 | msgstr "" 764 | 765 | msgid "Update ▾" 766 | msgstr "" 767 | 768 | msgid "updating database…" 769 | msgstr "" 770 | 771 | msgid "verbose logging" 772 | msgstr "" 773 | 774 | msgid "Work" 775 | msgstr "" 776 | 777 | msgid "Written by Dmitry Kann" 778 | msgstr "" 779 | 780 | msgid "Year" 781 | msgstr "" 782 | 783 | msgid "Ymuse version %s; %s; released %s" 784 | msgstr "" 785 | -------------------------------------------------------------------------------- /resources/metainfo/com.yktoo.ymuse.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.yktoo.ymuse 4 | CC0-1.0 5 | Apache-2.0 6 | Ymuse 7 | Easy, functional, and snappy GTK client for Music Player Daemon 8 | Dmitry Kann 9 | 10 | https://raw.githubusercontent.com/yktoo/ymuse/master/resources/icons/hicolor/scalable/apps/com.yktoo.ymuse.svg 11 | 12 | 13 |

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

14 |

Application features:

15 |
    16 |
  • Connection to a local or remote MPD server via TCP or Unix domain socket, auto(re)connect function.
  • 17 |
  • Displaying, sorting, and shuffling the play queue. Track removal.
  • 18 |
  • Filtering the play queue on a substring.
  • 19 |
  • Saving the play queue as a playlist (new or existing).
  • 20 |
  • MPD library browse and search functions.
  • 21 |
  • Browsing, adding, and renaming playlists.
  • 22 |
  • Own stream (a.k.a. Internet radio) list, which can be edited.
  • 23 |
  • Visible queue columns selection.
  • 24 |
  • Player title setting using Go template syntax.
  • 25 |
  • Toggling various MPD modes (random, repeat, consume).
  • 26 |
  • Seeking the current track to an arbitrary location.
  • 27 |
  • Light and dark desktop theme support.
  • 28 |
  • Internationalisation support.
  • 29 |
30 |
31 | 32 | com.yktoo.ymuse.desktop 33 | 34 | 35 | 36 | Main app window in the light and the dark themes. 37 | https://res.cloudinary.com/yktoo/image/upload/blog/e6ecokfftenpwlwswon1.png 38 | 39 | 40 | 41 | https://yktoo.com/en/software/ymuse/ 42 | https://yktoo.com/en/software/ymuse/faq/ 43 | https://github.com/yktoo/ymuse/issues 44 | 45 | 46 | Audio 47 | 48 | 49 | 50 | MPD 51 | Music Player Daemon 52 | audio 53 | sound 54 | GTK 55 | 56 | 57 | 58 | 59 | 60 |

Changelog:

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

Changelog:

74 |
    75 |
  • Add Ctrl+Shift+Left/Right shortcuts for seeking through current track (#56)
  • 76 |
  • Scale album art proportionally (#59)
  • 77 |
  • Add embedded album art image support (#52)
  • 78 |
  • Add image size setting for album artwork
  • 79 |
80 |
81 |
82 | 83 | 84 |

Changelog:

85 |
    86 |
  • Add MPD Outputs dialog (#44)
  • 87 |
  • Fix Add to playlist appending to wrong playlist (#51)
  • 88 |
89 |
90 |
91 | 92 | 93 |

Changelog:

94 |
    95 |
  • Sort streams ignoring case (resolves #45)
  • 96 |
  • Update feature tour link in README
  • 97 |
  • fix: rpm dependencies (#46)
  • 98 |
99 |
100 |
101 | 102 | 103 |

Changelog:

104 |
    105 |
  • Add queue post-replace actions: switch to Queue tab, start playback
  • 106 |
  • Make copyright localisable
  • 107 |
  • Replace the discontinued BBC WS stream URL
  • 108 |
  • Require GTK 3.22+; replace deprecated GTK properties
  • 109 |
  • Upgrade to Go 1.16.2 and latest gotk3/master
  • 110 |
111 |
112 |
113 | 114 | 115 |

Changelog:

116 |
    117 |
  • Add missing items to Keyboard shortcuts info window
  • 118 |
  • Add to playlist command in the Library (resolves #17)
  • 119 |
  • Separate album art display settings for tracks and streams (resolves #30)
  • 120 |
121 |
122 |
123 | 124 | 125 |

Changelog:

126 |
    127 |
  • Add volume button/slider (resolves #20)
  • 128 |
  • Fix cleanup when connection lost (resolves #26, #28)
  • 129 |
  • Fix queue selection being reset on right click (resolves #21)
  • 130 |
  • Option for showing/hiding library toolbar (resolves #23)
  • 131 |
  • Update to latest gompd (2.2.0), gotk3 (0.5.2) (hopefully resolves #27); go 1.15+
  • 132 |
133 |
134 |
135 | 136 | 137 |

Changelog:

138 |
    139 |
  • Upgrade to latest gompd master (resolves #11)
  • 140 |
141 |
142 |
143 | 144 | 145 |

Changelog:

146 |
    147 |
  • Add album art display to the player
  • 148 |
  • Fix possible race in schedulePlayerSettingChange
  • 149 |
150 |
151 |
152 | 153 | 154 |

Changelog:

155 |
    156 |
  • Add "Show album/artist/genre in Library" function to Queue
  • 157 |
  • Scroll to the selected row in library and prefs/columns
  • 158 |
  • Select top item in Streams by default
  • 159 |
160 |
161 |
162 | 163 | 164 |

Changelog:

165 |
    166 |
  • Add Unix domain socket connectivity (resolves #10)
  • 167 |
168 |
169 |
170 | 171 | 172 |

Changelog:

173 |
    174 |
  • Expose Bitrate and Format attributes to current track (resolves #6)
  • 175 |
  • Fix: too frequent reconnection attempts (resolves #9)
  • 176 |
  • Redesign MPD Info dialog; add Decoder Plugins info
  • 177 |
  • Save and restore selected library path
  • 178 |
  • Translation updates: RU, NL
  • 179 |
  • Use MPD_HOST and MPD_PORT environment vars for connection defaults (resolves #8)
  • 180 |
181 |
182 |
183 | 184 | 185 |

Changelog:

186 |
    187 |
  • Add tests for Builder; improve error handling
  • 188 |
  • Add the ".." (level up) element to Library
  • 189 |
  • Select element being left when going back in Library
  • 190 |
  • Support Japanese for .desktop file
  • 191 |
  • add .po file for Japanese
  • 192 |
193 |
194 |
195 | 196 | 197 |

Changelog:

198 |
    199 |
  • Add snapcraft config
  • 200 |
  • Add util/log tests
  • 201 |
  • Implement forced file rescanning On my gompd's fork for now
  • 202 |
  • Improve connection management; Localise player template
  • 203 |
  • Library: don't collapse toolbar to avoid hiding search button
  • 204 |
  • Speed up display update on MPD connect
  • 205 |
  • Use Go reflection to bind widgets in Builder (also resolves #3)
  • 206 |
207 |
208 |
209 | 210 | 211 |

Changelog:

212 |
    213 |
  • Add Dutch translation; translation update
  • 214 |
  • Add browsing by genre, artist, album function
  • 215 |
  • Add context menu/items, numerous UI fixes
  • 216 |
  • Add localisation support; Add Russian translation
  • 217 |
  • Add missing translation
  • 218 |
  • Improve handling when not connected
  • 219 |
  • Localise durations, too
  • 220 |
  • Optimise queue loading with large lists
  • 221 |
  • Properly colour symbolic icons
  • 222 |
  • Recover SVGO-broken icons
  • 223 |
  • Refactor library path and browsing
  • 224 |
  • Refactor library path; Add Update popup menu item
  • 225 |
  • Remove Playlists tab in favour of section in Library
  • 226 |
  • Translation update: RU, NL
  • 227 |
228 |
229 |
230 | 231 | 232 |

Changelog:

233 |
    234 |
  • Add MPD information and stats dialog
  • 235 |
  • Add shortcut Ctrl+Shift+R for Queue shuffle
  • 236 |
  • Add support for dark theme
  • 237 |
  • Also show playlists in the library (#2)
  • 238 |
  • Cleaner library update icon
  • 239 |
  • Icon update
  • 240 |
  • Queue: add icon, fix colors, add fallback for track title
  • 241 |
  • Revert to system folder icon
  • 242 |
  • doc: add reference icon SVG
  • 243 |
244 |
245 |
246 | 247 | 248 |

Changelog:

249 |
    250 |
  • Add own SVG icons
  • 251 |
  • Add support for internet streams (#1)
  • 252 |
  • Make all used icons symbolic
  • 253 |
254 |
255 |
256 | 257 | 258 |

Changelog:

259 |
    260 |
  • Add horizontal alignment per MPD attribute
  • 261 |
  • Add library search function
  • 262 |
  • Improve focus management on page switching
  • 263 |
  • Minor UI changes; labels in Preferences | columns
  • 264 |
  • Suspend watcher on lost connection; remove possible race
  • 265 |
266 |
267 |
268 | 269 | 270 |

Changelog:

271 |
    272 |
  • add queue filter function, UI tweaks
  • 273 |
274 |
275 |
276 | 277 | 278 |

Changelog:

279 |
    280 |
  • Add queue column reordering, store widths
  • 281 |
  • Interface style tweaks
  • 282 |
  • packaging: optimise icon cache update
  • 283 |
284 |
285 |
286 | 287 | 288 |

Changelog:

289 |
    290 |
  • Fixes wrong playlist being loaded (using buttons in Playlists)
  • 291 |
292 |
293 |
294 | 295 | 296 |
    297 |
  • First public release
  • 298 |
299 |
300 |
301 |
302 |
303 | -------------------------------------------------------------------------------- /internal/player/prefs.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Dmitry Kann 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * http://www.apache.org/licenses/LICENSE-2.0 8 | * 9 | * Unless required by applicable law or agreed to in writing, software 10 | * distributed under the License is distributed on an "AS IS" BASIS, 11 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | * See the License for the specific language governing permissions and 13 | * limitations under the License. 14 | */ 15 | 16 | package player 17 | 18 | import ( 19 | "fmt" 20 | "github.com/gotk3/gotk3/glib" 21 | "github.com/gotk3/gotk3/gtk" 22 | "github.com/yktoo/ymuse/internal/config" 23 | "github.com/yktoo/ymuse/internal/util" 24 | "sync" 25 | "time" 26 | ) 27 | 28 | type queueCol struct { 29 | selected bool 30 | id int 31 | width int 32 | } 33 | 34 | // PrefsDialog represents the preferences dialog 35 | type PrefsDialog struct { 36 | PreferencesDialog *gtk.Dialog 37 | // General page widgets 38 | MpdNetworkComboBox *gtk.ComboBoxText 39 | MpdPathEntry *gtk.Entry 40 | MpdPathLabel *gtk.Label 41 | MpdHostEntry *gtk.Entry 42 | MpdHostLabel *gtk.Label 43 | MpdHostLabelRemark *gtk.Label 44 | MpdPortSpinButton *gtk.SpinButton 45 | MpdPortLabel *gtk.Label 46 | MpdPortAdjustment *gtk.Adjustment 47 | MpdPasswordEntry *gtk.Entry 48 | MpdAutoConnectCheckButton *gtk.CheckButton 49 | MpdAutoReconnectCheckButton *gtk.CheckButton 50 | // Interface page widgets 51 | QueueToolbarCheckButton *gtk.CheckButton 52 | LibraryDefaultReplaceRadioButton *gtk.RadioButton 53 | LibraryDefaultAppendRadioButton *gtk.RadioButton 54 | PlaylistsDefaultReplaceRadioButton *gtk.RadioButton 55 | PlaylistsDefaultAppendRadioButton *gtk.RadioButton 56 | StreamsDefaultReplaceRadioButton *gtk.RadioButton 57 | StreamsDefaultAppendRadioButton *gtk.RadioButton 58 | // Automation page widgets 59 | AutomationQueueReplaceSwitchToCheckButton *gtk.CheckButton 60 | AutomationQueueReplacePlayCheckButton *gtk.CheckButton 61 | // Player page widgets 62 | PlayerShowAlbumArtTracksCheckButton *gtk.CheckButton 63 | PlayerShowAlbumArtStreamsCheckButton *gtk.CheckButton 64 | PlayerAlbumArtSizeAdjustment *gtk.Adjustment 65 | PlayerTitleTemplateTextBuffer *gtk.TextBuffer 66 | // Columns page widgets 67 | ColumnsListBox *gtk.ListBox 68 | 69 | // Whether the dialog is initialised 70 | initialised bool 71 | // Columns, in the same order as in the ColumnsListBox 72 | queueColumns []queueCol 73 | // Timer for delayed player setting change callback invocation 74 | playerSettingChangeTimer *time.Timer 75 | playerSettingChangeMutex sync.Mutex 76 | // Callbacks 77 | onQueueColumnsChanged func() 78 | onPlayerSettingChanged func() 79 | } 80 | 81 | // ShowPreferencesDialog creates, shows and disposes of a Preferences dialog instance 82 | func ShowPreferencesDialog(parent gtk.IWindow, onMpdReconnect, onQueueColumnsChanged, onPlayerSettingChanged func()) { 83 | // Create the dialog 84 | d := &PrefsDialog{ 85 | onQueueColumnsChanged: onQueueColumnsChanged, 86 | onPlayerSettingChanged: onPlayerSettingChanged, 87 | } 88 | 89 | // Load the dialog layout and map the widgets 90 | builder, err := NewBuilder(prefsGlade) 91 | if err == nil { 92 | err = builder.BindWidgets(d) 93 | } 94 | 95 | // Check for errors 96 | if errCheck(err, "ShowPreferencesDialog(): failed to initialise dialog") { 97 | util.ErrorDialog(parent, fmt.Sprint(glib.Local("Failed to load UI widgets"), err)) 98 | return 99 | } 100 | defer d.PreferencesDialog.Destroy() 101 | 102 | // Set the dialog up 103 | d.PreferencesDialog.SetTransientFor(parent) 104 | 105 | // Remove the 2-pixel "aura" around the notebook 106 | if box, err := d.PreferencesDialog.GetContentArea(); err == nil { 107 | box.SetBorderWidth(0) 108 | } 109 | 110 | // Map the handlers to callback functions 111 | builder.ConnectSignals(map[string]interface{}{ 112 | "on_PreferencesDialog_map": d.onMap, 113 | "on_Setting_change": d.onSettingChange, 114 | "on_MpdReconnect": onMpdReconnect, 115 | "on_ColumnMoveUpToolButton_clicked": d.onColumnMoveUp, 116 | "on_ColumnMoveDownToolButton_clicked": d.onColumnMoveDown, 117 | }) 118 | 119 | // Run the dialog 120 | d.PreferencesDialog.Run() 121 | } 122 | 123 | func (d *PrefsDialog) onMap() { 124 | log.Debug("PrefsDialog.onMap()") 125 | 126 | // Initialise widgets 127 | cfg := config.GetConfig() 128 | // General page 129 | d.MpdNetworkComboBox.SetActiveID(cfg.MpdNetwork) 130 | d.MpdPathEntry.SetText(cfg.MpdSocketPath) 131 | d.MpdHostEntry.SetText(cfg.MpdHost) 132 | d.MpdPortAdjustment.SetValue(float64(cfg.MpdPort)) 133 | d.MpdPasswordEntry.SetText(cfg.MpdPassword) 134 | d.MpdAutoConnectCheckButton.SetActive(cfg.MpdAutoConnect) 135 | d.MpdAutoReconnectCheckButton.SetActive(cfg.MpdAutoReconnect) 136 | d.updateGeneralWidgets() 137 | // Interface page 138 | d.QueueToolbarCheckButton.SetActive(cfg.QueueToolbar) 139 | d.LibraryDefaultReplaceRadioButton.SetActive(cfg.TrackDefaultReplace) 140 | d.LibraryDefaultAppendRadioButton.SetActive(!cfg.TrackDefaultReplace) 141 | d.PlaylistsDefaultReplaceRadioButton.SetActive(cfg.PlaylistDefaultReplace) 142 | d.PlaylistsDefaultAppendRadioButton.SetActive(!cfg.PlaylistDefaultReplace) 143 | d.StreamsDefaultReplaceRadioButton.SetActive(cfg.StreamDefaultReplace) 144 | d.StreamsDefaultAppendRadioButton.SetActive(!cfg.StreamDefaultReplace) 145 | d.PlayerShowAlbumArtTracksCheckButton.SetActive(cfg.PlayerAlbumArtTracks) 146 | d.PlayerShowAlbumArtStreamsCheckButton.SetActive(cfg.PlayerAlbumArtStreams) 147 | d.PlayerAlbumArtSizeAdjustment.SetValue(float64(cfg.PlayerAlbumArtSize)) 148 | d.PlayerTitleTemplateTextBuffer.SetText(cfg.PlayerTitleTemplate) 149 | // Automation page 150 | d.AutomationQueueReplaceSwitchToCheckButton.SetActive(cfg.SwitchToOnQueueReplace) 151 | d.AutomationQueueReplacePlayCheckButton.SetActive(cfg.PlayOnQueueReplace) 152 | // Columns page 153 | d.populateColumns() 154 | d.initialised = true 155 | } 156 | 157 | // addQueueColumn adds a row with a check box to the Columns list box, and also registers a new item in d.queueColumns 158 | func (d *PrefsDialog) addQueueColumn(attrID, width int, selected bool) { 159 | // Add an entry to queue columns slice 160 | d.queueColumns = append(d.queueColumns, queueCol{selected: selected, id: attrID, width: width}) 161 | 162 | // Add a new list box row 163 | row, err := gtk.ListBoxRowNew() 164 | if errCheck(err, "ListBoxRowNew() failed") { 165 | return 166 | } 167 | d.ColumnsListBox.Add(row) 168 | 169 | // Add a container box 170 | hbx, err := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 6) 171 | if errCheck(err, "BoxNew() failed") { 172 | return 173 | } 174 | row.Add(hbx) 175 | 176 | // Add a checkbox 177 | cb, err := gtk.CheckButtonNew() 178 | if errCheck(err, "CheckButtonNew() failed") { 179 | return 180 | } 181 | cb.SetActive(selected) 182 | cb.Connect("toggled", func(c *gtk.CheckButton) { 183 | d.columnCheckboxToggled(attrID, c.GetActive(), row) 184 | }) 185 | hbx.PackStart(cb, false, false, 0) 186 | 187 | // Add a label 188 | lbl, err := gtk.LabelNew(glib.Local(config.MpdTrackAttributes[attrID].LongName)) 189 | if errCheck(err, "LabelNew() failed") { 190 | return 191 | } 192 | lbl.SetXAlign(0) 193 | hbx.PackStart(lbl, true, true, 0) 194 | } 195 | 196 | // columnCheckboxToggled is a handler of the toggled signal for queue column checkboxes 197 | func (d *PrefsDialog) columnCheckboxToggled(id int, selected bool, row *gtk.ListBoxRow) { 198 | // Find and toggle the column for the attribute 199 | if i := d.indexOfColumnWithAttrID(id); i >= 0 { 200 | d.queueColumns[i].selected = selected 201 | 202 | // Select the row 203 | d.ColumnsListBox.SelectRow(row) 204 | 205 | // Update the queue columns 206 | d.notifyColumnsChanged() 207 | } 208 | } 209 | 210 | // indexOfColumnWithAttrID returns the index of the queue column with given attribute ID, or -1 if not found 211 | func (d *PrefsDialog) indexOfColumnWithAttrID(id int) int { 212 | for i := range d.queueColumns { 213 | if id == d.queueColumns[i].id { 214 | return i 215 | } 216 | } 217 | return -1 218 | } 219 | 220 | // moveSelectedColumnRow moves the row selected in the Columns listbox up or down 221 | func (d *PrefsDialog) moveSelectedColumnRow(up bool) { 222 | // Get and check the selection 223 | row := d.ColumnsListBox.GetSelectedRow() 224 | if row == nil { 225 | return 226 | } 227 | 228 | // Get the row's index in the list 229 | index := row.GetIndex() 230 | if index < 0 || (up && index == 0) || (!up && index >= len(d.queueColumns)-1) { 231 | return 232 | } 233 | 234 | // Reorder the elements in the queue columns slice 235 | prevIndex := index 236 | if up { 237 | index-- 238 | } else { 239 | index++ 240 | } 241 | d.queueColumns[index], d.queueColumns[prevIndex] = d.queueColumns[prevIndex], d.queueColumns[index] 242 | 243 | // Remove and re-insert the row 244 | d.ColumnsListBox.Remove(row) 245 | d.ColumnsListBox.Insert(row, index) 246 | 247 | // Re-select the row. NB: need to deselect all first, otherwise it wouldn't get selected 248 | d.ColumnsListBox.SelectRow(nil) 249 | d.ColumnsListBox.SelectRow(d.ColumnsListBox.GetRowAtIndex(index)) 250 | 251 | // Scroll the listbox to center the row 252 | glib.IdleAdd(func() { util.ListBoxScrollToSelected(d.ColumnsListBox) }) 253 | 254 | // Update the queue's columns 255 | d.notifyColumnsChanged() 256 | } 257 | 258 | // notifyColumnsChanged updates queue tree view columns from the currently selected ones in the Columns list box 259 | func (d *PrefsDialog) notifyColumnsChanged() { 260 | // Collect IDs of selected attributes 261 | var colSpecs []config.ColumnSpec 262 | for _, col := range d.queueColumns { 263 | if col.selected { 264 | colSpecs = append(colSpecs, config.ColumnSpec{ID: col.id, Width: col.width}) 265 | } 266 | } 267 | 268 | // Save the IDs in the config 269 | config.GetConfig().QueueColumns = colSpecs 270 | 271 | // Notify the callback 272 | d.onQueueColumnsChanged() 273 | } 274 | 275 | // onColumnMoveUp is a signal handler for the Move up button click 276 | func (d *PrefsDialog) onColumnMoveUp() { 277 | d.moveSelectedColumnRow(true) 278 | } 279 | 280 | // onColumnMoveDown is a signal handler for the Move down button click 281 | func (d *PrefsDialog) onColumnMoveDown() { 282 | d.moveSelectedColumnRow(false) 283 | } 284 | 285 | // onSettingChange is a signal handler for a change of a simple setting widget 286 | func (d *PrefsDialog) onSettingChange() { 287 | // Ignore if the dialog is not initialised yet 288 | if !d.initialised { 289 | return 290 | } 291 | log.Debug("onSettingChange()") 292 | 293 | // Collect settings 294 | cfg := config.GetConfig() 295 | // General page 296 | cfg.MpdNetwork = d.MpdNetworkComboBox.GetActiveID() 297 | cfg.MpdSocketPath = util.EntryText(d.MpdPathEntry, "") 298 | cfg.MpdHost = util.EntryText(d.MpdHostEntry, "") 299 | cfg.MpdPort = int(d.MpdPortAdjustment.GetValue()) 300 | if s, err := d.MpdPasswordEntry.GetText(); !errCheck(err, "MpdPasswordEntry.GetText() failed") { 301 | cfg.MpdPassword = s 302 | } 303 | cfg.MpdAutoConnect = d.MpdAutoConnectCheckButton.GetActive() 304 | cfg.MpdAutoReconnect = d.MpdAutoReconnectCheckButton.GetActive() 305 | d.updateGeneralWidgets() 306 | 307 | // Interface page 308 | if b := d.QueueToolbarCheckButton.GetActive(); b != cfg.QueueToolbar { 309 | cfg.QueueToolbar = b 310 | d.schedulePlayerSettingChange() 311 | } 312 | cfg.TrackDefaultReplace = d.LibraryDefaultReplaceRadioButton.GetActive() 313 | cfg.PlaylistDefaultReplace = d.PlaylistsDefaultReplaceRadioButton.GetActive() 314 | cfg.StreamDefaultReplace = d.StreamsDefaultReplaceRadioButton.GetActive() 315 | 316 | // Automation page 317 | cfg.SwitchToOnQueueReplace = d.AutomationQueueReplaceSwitchToCheckButton.GetActive() 318 | cfg.PlayOnQueueReplace = d.AutomationQueueReplacePlayCheckButton.GetActive() 319 | 320 | // Player page 321 | if b := d.PlayerShowAlbumArtTracksCheckButton.GetActive(); b != cfg.PlayerAlbumArtTracks { 322 | cfg.PlayerAlbumArtTracks = b 323 | d.schedulePlayerSettingChange() 324 | } 325 | if b := d.PlayerShowAlbumArtStreamsCheckButton.GetActive(); b != cfg.PlayerAlbumArtStreams { 326 | cfg.PlayerAlbumArtStreams = b 327 | d.schedulePlayerSettingChange() 328 | } 329 | if i := int(d.PlayerAlbumArtSizeAdjustment.GetValue()); i != cfg.PlayerAlbumArtSize { 330 | cfg.PlayerAlbumArtSize = i 331 | d.schedulePlayerSettingChange() 332 | } 333 | if s, err := util.GetTextBufferText(d.PlayerTitleTemplateTextBuffer); !errCheck(err, "util.GetTextBufferText() failed") { 334 | if s != cfg.PlayerTitleTemplate { 335 | cfg.PlayerTitleTemplate = s 336 | d.schedulePlayerSettingChange() 337 | } 338 | } 339 | } 340 | 341 | // populateColumns fills in the Columns list box 342 | func (d *PrefsDialog) populateColumns() { 343 | // First add selected columns 344 | selColSpecs := config.GetConfig().QueueColumns 345 | for _, colSpec := range selColSpecs { 346 | d.addQueueColumn(colSpec.ID, colSpec.Width, true) 347 | } 348 | 349 | // Add all unselected columns 350 | for _, id := range config.MpdTrackAttributeIds { 351 | // Check if the ID is already in the list of selected IDs 352 | isSelected := false 353 | for _, selSpec := range selColSpecs { 354 | if id == selSpec.ID { 355 | isSelected = true 356 | break 357 | } 358 | } 359 | 360 | // If not, add it 361 | if !isSelected { 362 | d.addQueueColumn(id, 0, false) 363 | } 364 | } 365 | d.ColumnsListBox.ShowAll() 366 | } 367 | 368 | func (d *PrefsDialog) schedulePlayerSettingChange() { 369 | // Cancel the currently scheduled callback, if any 370 | d.playerSettingChangeMutex.Lock() 371 | defer d.playerSettingChangeMutex.Unlock() 372 | if d.playerSettingChangeTimer != nil { 373 | d.playerSettingChangeTimer.Stop() 374 | } 375 | 376 | // Schedule a new callback 377 | d.playerSettingChangeTimer = time.AfterFunc(time.Second, func() { 378 | d.playerSettingChangeMutex.Lock() 379 | d.playerSettingChangeTimer = nil 380 | d.playerSettingChangeMutex.Unlock() 381 | glib.IdleAdd(d.onPlayerSettingChanged) 382 | }) 383 | } 384 | 385 | // updateGeneralWidgets updates widget states on the General tab 386 | func (d *PrefsDialog) updateGeneralWidgets() { 387 | network := d.MpdNetworkComboBox.GetActiveID() 388 | unix, tcp := network == "unix", network == "tcp" 389 | d.MpdPathEntry.SetVisible(unix) 390 | d.MpdPathLabel.SetVisible(unix) 391 | d.MpdHostEntry.SetVisible(tcp) 392 | d.MpdHostLabel.SetVisible(tcp) 393 | d.MpdHostLabelRemark.SetVisible(tcp) 394 | d.MpdPortSpinButton.SetVisible(tcp) 395 | d.MpdPortLabel.SetVisible(tcp) 396 | } 397 | -------------------------------------------------------------------------------- /resources/i18n/ja.po: -------------------------------------------------------------------------------- 1 | # Ymuse MPD client 2 | # Copyright (C) 2020 Dmitry Kann 3 | # This file is distributed under the same license as the Ymuse package. 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: \n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2020-05-25 12:11+0200\n" 10 | "PO-Revision-Date: 2020-06-16 19:16+0900\n" 11 | "Language-Team: \n" 12 | "MIME-Version: 1.0\n" 13 | "Content-Type: text/plain; charset=UTF-8\n" 14 | "Content-Transfer-Encoding: 8bit\n" 15 | "X-Generator: Poedit 2.3\n" 16 | "Last-Translator: Nakaya \n" 17 | "Plural-Forms: nplurals=1; plural=0;\n" 18 | "Language: ja\n" 19 | 20 | msgid "" 21 | "{{- if or .Title .Album | or .Artist -}}\n" 22 | "{{ .Title | default \"(unknown title)\" }}\n" 23 | "by {{ .Artist | default \"(unknown artist)\" }} from {{ .Album | " 24 | "default \"(unknown album)\" }}\n" 25 | "{{- else if .Name -}}\n" 26 | "{{ .Name }}\n" 27 | "{{- else if .file -}}\n" 28 | "File {{ .file | basename }}\n" 29 | "from {{ .file | dirname }}\n" 30 | "{{- else -}}\n" 31 | "(no track)\n" 32 | "{{- end -}}\n" 33 | msgstr "" 34 | "{{- if or .Title .Album | or .Artist -}}\n" 35 | "{{ .Title | default \"(不明なタイトル)\" }}\n" 36 | "by {{ .Artist | default \"(不明なアーティスト)\" }} from {{ ." 37 | "Album | default \"(不明なアルバム)\" }}\n" 38 | "{{- else if .Name -}}\n" 39 | "{{ .Name }}\n" 40 | "{{- else if .file -}}\n" 41 | "File {{ .file | basename }}\n" 42 | "from {{ .file | dirname }}\n" 43 | "{{- else -}}\n" 44 | "(トラックがありません)\n" 45 | "{{- end -}}\n" 46 | 47 | msgid "#" 48 | msgstr "トラック番号" 49 | 50 | msgid "%d items" 51 | msgstr "%d個のアイテム" 52 | 53 | msgid "%d streams" 54 | msgstr "%d個のラジオ配信" 55 | 56 | msgid "%d track(s) displayed" 57 | msgstr "%d個のトラックが表示されています" 58 | 59 | msgid "%d tracks" 60 | msgstr "%d個のトラック" 61 | 62 | msgid "(leave empty for localhost)" 63 | msgstr "(空欄の場合はlocalhostと見なされます)" 64 | 65 | #, fuzzy 66 | msgid "(limited selection of %d items)" 67 | msgstr "限定された%d個の選択されたアイテム" 68 | 69 | msgid "(new playlist)" 70 | msgstr "(新しいプレイリスト)" 71 | 72 | msgid "(Re)connect to MPD" 73 | msgstr "MPDサーバに(再)接続する" 74 | 75 | msgid "(unknown)" 76 | msgstr "(不明)" 77 | 78 | msgid "Library" 79 | msgstr "ライブラリ" 80 | 81 | msgid "MPD connection" 82 | msgstr "MPDサーバとの接続" 83 | 84 | msgid "Player" 85 | msgstr "プレーヤ" 86 | 87 | msgid "Playlists" 88 | msgstr "プレイリスト" 89 | 90 | msgid "Streams" 91 | msgstr "ラジオ配信" 92 | 93 | msgid "_About…" 94 | msgstr "ymuseについて" 95 | 96 | msgid "_Connect to MPD" 97 | msgstr "MPDサーバと接続する" 98 | 99 | msgid "_Disconnect from MPD" 100 | msgstr "MPDサーバから切断する" 101 | 102 | msgid "_Keyboard shortcuts…" 103 | msgstr "キーボードショートカット" 104 | 105 | msgid "_Preferences…" 106 | msgstr "設定" 107 | 108 | msgid "_Quit" 109 | msgstr "終了する" 110 | 111 | msgid "About" 112 | msgstr "このソフトウェアについて" 113 | 114 | msgid "Add a new stream" 115 | msgstr "新しいラジオ配信を追加" 116 | 117 | msgid "Add" 118 | msgstr "追加" 119 | 120 | #, fuzzy 121 | msgid "Album (for sorting)" 122 | msgstr "アルバム(並び換え用)" 123 | 124 | #, fuzzy 125 | msgid "Album artist (for sorting)" 126 | msgstr "アルバムアーティスト(並び換え用)" 127 | 128 | msgid "Album artist" 129 | msgstr "アルバムアーティスト" 130 | 131 | msgid "Album" 132 | msgstr "アルバム" 133 | 134 | msgid "Albums" 135 | msgstr "アルバム" 136 | 137 | msgid "and" 138 | msgstr "" 139 | 140 | msgid "Append to the queue" 141 | msgstr "キューの末尾に追加する" 142 | 143 | msgid "Append tracks" 144 | msgstr "末尾にトラックを追加する" 145 | 146 | msgid "Application shortcuts" 147 | msgstr "アプリケーションショートカット" 148 | 149 | msgid "Apply" 150 | msgstr "適用" 151 | 152 | msgid "Are you sure you want to delete playlist \"%s\"?" 153 | msgstr "プレイリスト\"%s\"を削除してもよろしいですか?" 154 | 155 | msgid "Are you sure you want to delete stream \"%s\"?" 156 | msgstr "配信\"%s\"を削除してもよろしいですか?" 157 | 158 | #, fuzzy 159 | msgid "Artist (for sorting)" 160 | msgstr "アーティスト(並び換え用)" 161 | 162 | msgid "Artist" 163 | msgstr "アーティスト" 164 | 165 | msgid "Artists" 166 | msgstr "アーティスト" 167 | 168 | msgid "Ascending" 169 | msgstr "昇順" 170 | 171 | msgid "Automatically connect on startup" 172 | msgstr "起動時に自動で接続する" 173 | 174 | msgid "Automatically reconnect" 175 | msgstr "自動的に再接続する" 176 | 177 | msgid "Clear the play queue" 178 | msgstr "キューを消去する" 179 | 180 | msgid "Clear" 181 | msgstr "キューを消去する" 182 | 183 | msgid "Columns" 184 | msgstr "カラム" 185 | 186 | msgid "Comment" 187 | msgstr "コメント" 188 | 189 | msgid "Composer" 190 | msgstr "作曲者" 191 | 192 | msgid "Conductor" 193 | msgstr "指揮者" 194 | 195 | msgid "Connecting to MPD…" 196 | msgstr "MPDサーバに接続しています……" 197 | 198 | msgid "Connect to MPD" 199 | msgstr "MPDサーバと接続する" 200 | 201 | msgid "Consume mode" 202 | msgstr "コンシュームモード(再生が終わったトラックを都度キューから削除する)" 203 | 204 | msgid "Consume" 205 | msgstr "コンシューム" 206 | 207 | msgid "Current track time" 208 | msgstr "再生中のトラックの再生時間" 209 | 210 | msgid "Daemon uptime:" 211 | msgstr "MPDサーバの起動時間:" 212 | 213 | msgid "Daemon version:" 214 | msgstr "MPDサーバのバージョン:" 215 | 216 | msgid "days" 217 | msgstr "日" 218 | 219 | msgid "Delete playlist" 220 | msgstr "プレイリストを削除する" 221 | 222 | msgid "Delete selected" 223 | msgstr "選択したトラックを削除する" 224 | 225 | msgid "Delete stream" 226 | msgstr "選択したラジオ配信を削除する" 227 | 228 | msgid "Delete the selected item" 229 | msgstr "選択したファイルを削除する" 230 | 231 | msgid "Delete the selected stream" 232 | msgstr "選択したラジオ配信を削除する" 233 | 234 | msgid "Delete" 235 | msgstr "削除" 236 | 237 | msgid "Descending" 238 | msgstr "降順" 239 | 240 | msgid "Directory and file name" 241 | msgstr "ディレクトリとファイル名" 242 | 243 | msgid "Directory" 244 | msgstr "ディレクトリ" 245 | 246 | msgid "Disc" 247 | msgstr "ディスク" 248 | 249 | msgid "Disconnect from MPD" 250 | msgstr "MPDサーバから切断する" 251 | 252 | msgid "Edit the selected stream" 253 | msgstr "選択されたラジオ配信の項目を編集する" 254 | 255 | msgid "Edit" 256 | msgstr "編集" 257 | 258 | msgid "Everywhere" 259 | msgstr "指定なし" 260 | 261 | msgid "Failed to add item to the queue" 262 | msgstr "アイテムをキューへ追加できませんでした" 263 | 264 | msgid "Failed to add playlist to the queue" 265 | msgstr "プレイリストをキューへ追加できませんでした" 266 | 267 | msgid "Failed to add stream to the queue" 268 | msgstr "ラジオ配信をキューへ追加できませんでした" 269 | 270 | msgid "Failed to clear the queue" 271 | msgstr "キューの消去に失敗しました" 272 | 273 | msgid "Failed to create a playlist" 274 | msgstr "プレイリストの作成に失敗しました" 275 | 276 | msgid "Failed to delete the playlist" 277 | msgstr "プレイリストの削除に失敗しました" 278 | 279 | msgid "Failed to delete tracks from the queue" 280 | msgstr "キューからトラックを削除できませんでした" 281 | 282 | msgid "Failed to play the selected track" 283 | msgstr "選択したトラックを再生できませんでした" 284 | 285 | msgid "Failed to rename the playlist" 286 | msgstr "プレイリストの名前を変更できませんでした" 287 | 288 | msgid "Failed to retrieve information from MPD" 289 | msgstr "MPDサーバから情報を取得できませんでした" 290 | 291 | msgid "Failed to shuffle the queue" 292 | msgstr "キューをシャッフルできませんでした" 293 | 294 | msgid "Failed to skip to next track" 295 | msgstr "次のトラックへスキップできませんでした" 296 | 297 | msgid "Failed to skip to previous track" 298 | msgstr "前のトラックへ移動できませんでした" 299 | 300 | msgid "Failed to sort the queue" 301 | msgstr "キューの整列に失敗しました" 302 | 303 | msgid "Failed to stop playback" 304 | msgstr "停止できませんでした" 305 | 306 | msgid "Failed to toggle consume mode" 307 | msgstr "コンシュームモードに切り替えられませんでした" 308 | 309 | msgid "Failed to toggle playback" 310 | msgstr "トラックを再生できませんでした" 311 | 312 | msgid "Failed to toggle random mode" 313 | msgstr "ランダムモードを切り替えられませんでした" 314 | 315 | msgid "Failed to toggle repeat/single mode" 316 | msgstr "リピート/シングルモードの切り替えに失敗" 317 | 318 | msgid "Failed to update the library" 319 | msgstr "音楽ライブラリを更新できませんでした" 320 | 321 | msgid "File name" 322 | msgstr "ファイル名" 323 | 324 | msgid "File path" 325 | msgstr "ファイルパス" 326 | 327 | msgid "File" 328 | msgstr "ファイル" 329 | 330 | msgid "Files" 331 | msgstr "ファイル" 332 | 333 | msgid "Filter the play queue" 334 | msgstr "キュー内を検索する" 335 | 336 | msgid "Filter…" 337 | msgstr "検索" 338 | 339 | #, fuzzy 340 | msgid "General" 341 | msgstr "MPDサーバ" 342 | 343 | msgid "Genre" 344 | msgstr "ジャンル" 345 | 346 | msgid "Genres" 347 | msgstr "ジャンル" 348 | 349 | msgid "Grouping" 350 | msgstr "グループ" 351 | 352 | msgid "Host:" 353 | msgstr "ホスト:" 354 | 355 | msgid "Interface" 356 | msgstr "インターフェース" 357 | 358 | msgid "Jump to the currently played track" 359 | msgstr "再生中のトラックに移動する" 360 | 361 | msgid "Keyboard Shortcuts" 362 | msgstr "キーボードショートカット" 363 | 364 | #, fuzzy 365 | msgid "Label" 366 | msgstr "ラベル" 367 | 368 | msgid "Last database update:" 369 | msgstr "データベースの最終更新日時:" 370 | 371 | msgid "Length" 372 | msgstr "長さ" 373 | 374 | msgid "Library" 375 | msgstr "ライブラリ" 376 | 377 | msgid "Listening time:" 378 | msgstr "聴いた時間:" 379 | 380 | msgid "more verbose logging" 381 | msgstr "もっと詳細なログ" 382 | 383 | msgid "Move down" 384 | msgstr "下へ移動する" 385 | 386 | msgid "Move the selected column down" 387 | msgstr "選択されたカラムを下へ移動する" 388 | 389 | msgid "Move the selected column up" 390 | msgstr "選択されたカラムを上へ移動する" 391 | 392 | msgid "Move up" 393 | msgstr "上へ移動する" 394 | 395 | msgid "MPD _information…" 396 | msgstr "MPDサーバの情報" 397 | 398 | msgid "MPD Information" 399 | msgstr "MPDサーバの情報" 400 | 401 | msgid "Name" 402 | msgstr "名前" 403 | 404 | msgid "New playlist name" 405 | msgstr "新しいプレイリストの名前" 406 | 407 | msgid "Next track" 408 | msgstr "次のトラック" 409 | 410 | msgid "Next" 411 | msgstr "次" 412 | 413 | msgid "No items" 414 | msgstr "アイテムがありません" 415 | 416 | msgid "No streams" 417 | msgstr "ラジオ配信がありません" 418 | 419 | msgid "Not connected to MPD" 420 | msgstr "MPDサーバと接続していません" 421 | 422 | msgid "Now playing" 423 | msgstr "再生中のトラックに移動する" 424 | 425 | msgid "Number of albums:" 426 | msgstr "アルバムの数:" 427 | 428 | msgid "Number of artists:" 429 | msgstr "アーティストの数:" 430 | 431 | msgid "Number of tracks:" 432 | msgstr "トラックの数:" 433 | 434 | msgid "On double click / Enter on a playlist:" 435 | msgstr "プレイリストが選択された状態でEnterキー/ダブルクリックを押したとき:" 436 | 437 | msgid "On double click / Enter on a stream:" 438 | msgstr "ラジオ配信が選択された状態でEnterキー/ダブルクリックを押したとき:" 439 | 440 | msgid "On double click / Enter on a track:" 441 | msgstr "トラックが選択された状態でEnterキー/ダブルクリックを押したとき:" 442 | 443 | msgid "one day" 444 | msgstr "1日" 445 | 446 | msgid "One track" 447 | msgstr "1個のトラック" 448 | 449 | msgid "Open Filter bar" 450 | msgstr "検索バーを表示する" 451 | 452 | msgid "Password:" 453 | msgstr "パスワード:" 454 | 455 | #, fuzzy 456 | msgid "Path" 457 | msgstr "パス" 458 | 459 | msgid "Pause or resume playback" 460 | msgstr "再生/一時停止" 461 | 462 | msgid "Performer" 463 | msgstr "演奏者" 464 | 465 | msgid "Play selection" 466 | msgstr "選択されているトラックを再生する" 467 | 468 | msgid "Play/Pause" 469 | msgstr "再生/一時停止" 470 | 471 | msgid "Player title template error, check log" 472 | msgstr "" 473 | "プレーヤタイトルのテンプレートに問題があります。ログを確認してください" 474 | 475 | msgid "Player" 476 | msgstr "プレーヤ" 477 | 478 | msgid "playing time %s" 479 | msgstr "再生時間 %s" 480 | 481 | msgid "Playlists" 482 | msgstr "プレイリスト" 483 | 484 | msgid "Port:" 485 | msgstr "ポート:" 486 | 487 | msgid "Preferences" 488 | msgstr "設定" 489 | 490 | msgid "Previous track" 491 | msgstr "前のトラック" 492 | 493 | msgid "Previous" 494 | msgstr "前" 495 | 496 | msgid "Queue is empty" 497 | msgstr "キューが空です" 498 | 499 | msgid "Queue" 500 | msgstr "キュー" 501 | 502 | msgid "Quit" 503 | msgstr "終了" 504 | 505 | msgid "Random" 506 | msgstr "ランダム" 507 | 508 | msgid "Reconnect now" 509 | msgstr "再接続する" 510 | 511 | msgid "Release date: %s" 512 | msgstr "公開日時: %s" 513 | 514 | msgid "Remove selected track(s) from the queue" 515 | msgstr "選択したトラックをキューから削除する" 516 | 517 | msgid "Rename playlist" 518 | msgstr "プレイリストの名前を変更する" 519 | 520 | msgid "Rename the selected item" 521 | msgstr "選択されたアイテムの名前を変更する" 522 | 523 | msgid "Rename" 524 | msgstr "名前を変更する" 525 | 526 | msgid "Repeat mode" 527 | msgstr "リピートモード" 528 | 529 | msgid "Repeat" 530 | msgstr "リピート" 531 | 532 | msgid "Replace playlist" 533 | msgstr "プレイリストを置き替える" 534 | 535 | msgid "Replace the queue" 536 | msgstr "キューを置き替える" 537 | 538 | msgid "Rescan all files" 539 | msgstr "全てのファイルを再スキャンする" 540 | 541 | msgid "Rescan selected item" 542 | msgstr "選択したファイルを再スキャンする" 543 | 544 | msgid "Save into playlist" 545 | msgstr "プレイリストとして保存する" 546 | 547 | msgid "Save selected tracks only" 548 | msgstr "選択されたトラックのみ保存する" 549 | 550 | msgid "Save the play queue as a playlist" 551 | msgstr "プレイリストとしてキューを保存する" 552 | 553 | msgid "Save ▾" 554 | msgstr "保存 ▾" 555 | 556 | msgid "Search the library" 557 | msgstr "ライブラリを検索する" 558 | 559 | msgid "Search" 560 | msgstr "検索" 561 | 562 | msgid "Search…" 563 | msgstr "検索する" 564 | 565 | msgid "Select columns to display in the play queue, and their order." 566 | msgstr "選択されたカラムがキューに表示され、整列させることができます。" 567 | 568 | msgid "Shuffle mode" 569 | msgstr "シャッフルモード" 570 | 571 | msgid "Shuffle the queue" 572 | msgstr "キューをシャッフルする" 573 | 574 | msgid "Shuffle" 575 | msgstr "シャッフル" 576 | 577 | msgid "Sort queue by" 578 | msgstr "整列する項目" 579 | 580 | msgid "Sort the play queue" 581 | msgstr "キューを整列する" 582 | 583 | msgid "Sort ▾" 584 | msgstr "整列 ▾" 585 | 586 | msgid "Stop playback" 587 | msgstr "停止" 588 | 589 | msgid "Stop" 590 | msgstr "停止する" 591 | 592 | msgid "Stream name" 593 | msgstr "ラジオ配信名" 594 | 595 | msgid "Stream name:" 596 | msgstr "ラジオ配信名:" 597 | 598 | msgid "Stream URI:" 599 | msgstr "配信URI:" 600 | 601 | msgid "Streams" 602 | msgstr "ラジオ配信" 603 | 604 | msgid "Switch to Library tab" 605 | msgstr "ライブラリタブへ切り替え" 606 | 607 | msgid "Switch to Queue tab" 608 | msgstr "キュータブへ切り替え" 609 | 610 | msgid "Switch to Streams tab" 611 | msgstr "ラジオ配信タブへ切り替え" 612 | 613 | msgid "Template error" 614 | msgstr "テンプレートエラー" 615 | 616 | msgid "Title" 617 | msgstr "タイトル" 618 | 619 | msgid "Toggle consume mode" 620 | msgstr "コンシュームモードに切り替える" 621 | 622 | msgid "Toggle play/pause" 623 | msgstr "再生/一時停止を切り替える" 624 | 625 | msgid "Toggle random mode" 626 | msgstr "ランダムモードを切り替える" 627 | 628 | msgid "Toggle repeat mode" 629 | msgstr "リピートモードを切り替える" 630 | 631 | msgid "Total playing time:" 632 | msgstr "合計再生時間:" 633 | 634 | msgid "Track attribute(s) to search" 635 | msgstr "要素を指定する" 636 | 637 | msgid "Track length" 638 | msgstr "トラックの長さ" 639 | 640 | msgid "Track number" 641 | msgstr "トラック番号" 642 | 643 | msgid "Track title template:" 644 | msgstr "トラックタイトルのテンプレート:" 645 | 646 | msgid "Track title" 647 | msgstr "トラックタイトル" 648 | 649 | msgid "Track" 650 | msgstr "トラック" 651 | 652 | msgid "Unnamed" 653 | msgstr "名前のない" 654 | 655 | msgid "Update" 656 | msgstr "更新" 657 | 658 | msgid "Update entire library" 659 | msgstr "音楽ライブラリの全体を更新する" 660 | 661 | msgid "Update selected item" 662 | msgstr "選択したファイルを更新する" 663 | 664 | msgid "Update the entire music database" 665 | msgstr "データベースの全体を更新する" 666 | 667 | msgid "Update the entire music database, including unmodified files" 668 | msgstr "編集されていないファイルを含む、データベースの全体を更新する" 669 | 670 | msgid "Update the music library" 671 | msgstr "音楽ライブラリを更新する" 672 | 673 | msgid "Update the selected item in music database" 674 | msgstr "データベース内の選択されたアイテムを更新する" 675 | 676 | msgid "Update the selected item, including unmodified files" 677 | msgstr "編集されていないファイルを含む、選択されたアイテムを更新する" 678 | 679 | msgid "Update ▾" 680 | msgstr "更新▾" 681 | 682 | msgid "updating database…" 683 | msgstr "データベースを更新しています……" 684 | 685 | msgid "verbose logging" 686 | msgstr "詳細なログ" 687 | 688 | msgid "Work" 689 | msgstr "作品" 690 | 691 | msgid "Year" 692 | msgstr "年代" 693 | 694 | msgid "Ymuse version %s; %s; released %s" 695 | msgstr "Ymuse バージョン %s; %s; %s にリリースされました" 696 | --------------------------------------------------------------------------------