├── sonata ├── __init__.py ├── plugins │ ├── __init__.py │ ├── gajim_tune.py │ ├── test.py │ └── localmpd.py ├── pixmaps │ ├── sonata.png │ ├── sonatacd.png │ ├── sonata-album.png │ ├── sonata-case.png │ ├── sonata_large.png │ ├── sonata_pause.png │ ├── sonata_play.png │ ├── sonata-artist.png │ ├── sonatacd_large.png │ ├── sonata_disconnect.png │ ├── sonata-stock_volume-max.png │ ├── sonata-stock_volume-med.png │ ├── sonata-stock_volume-min.png │ └── sonata-stock_volume-mute.png ├── version.py ├── consts.py ├── rhapsodycovers.py ├── img.py ├── lyricwiki.py ├── mpdhelper.py ├── tray.py ├── dbus_plugin.py ├── formatting.py ├── pluginsystem.py ├── breadcrumbs.py ├── misc.py ├── streams.py ├── cli.py ├── scrobbler.py ├── ui.py ├── about.py ├── playlists.py ├── tagedit.py └── info.py ├── .gitignore ├── sonata.desktop ├── po └── POTFILES.in ├── scripts ├── pycheck ├── screenshot.py ├── release.sh ├── pycheck-svndiff └── update-po ├── mmkeys ├── mmkeys.override ├── mmkeys.defs ├── mmkeysmodule.c ├── Makefile ├── README ├── mmkeys.h ├── mmkeyspy.c ├── mmkeys.c └── COPYING ├── TRANSLATORS ├── sonata.1 ├── README ├── TODO ├── sonata.py └── setup.py /sonata/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sonata/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sonata/pixmaps/sonata.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonatacd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonatacd.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata-album.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata-album.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata-case.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata_large.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata_pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata_pause.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata_play.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata-artist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata-artist.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonatacd_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonatacd_large.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.swp 3 | mmkeys.so 4 | sonata/sonata 5 | mo/ 6 | tags 7 | TAGS 8 | cscope* 9 | *.un~ 10 | -------------------------------------------------------------------------------- /sonata/pixmaps/sonata_disconnect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata_disconnect.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata-stock_volume-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata-stock_volume-max.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata-stock_volume-med.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata-stock_volume-med.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata-stock_volume-min.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata-stock_volume-min.png -------------------------------------------------------------------------------- /sonata/pixmaps/sonata-stock_volume-mute.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vault/Sonata/master/sonata/pixmaps/sonata-stock_volume-mute.png -------------------------------------------------------------------------------- /sonata.desktop: -------------------------------------------------------------------------------- 1 | 2 | [Desktop Entry] 3 | Name=Sonata 4 | Comment=An elegant GTK+ MPD client 5 | Exec=sonata 6 | Terminal=false 7 | Type=Application 8 | Icon=sonata 9 | Categories=GTK;AudioVideo;Player; 10 | StartupNotify=true 11 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | sonata/about.py 2 | sonata/cli.py 3 | sonata/config.py 4 | sonata/current.py 5 | sonata/dbus_plugin.py 6 | sonata/info.py 7 | sonata/library.py 8 | sonata/main.py 9 | sonata/playlists.py 10 | sonata/pluginsystem.py 11 | sonata/preferences.py 12 | sonata/streams.py 13 | sonata/tagedit.py 14 | -------------------------------------------------------------------------------- /scripts/pycheck: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | scriptdir=$(dirname "$0") 3 | [ -z "$scriptdir" ] && scriptdir=$(which "$0") 4 | files="$scriptdir/../sonata/*.py" 5 | [ "$#" != 0 ] && files=$* 6 | pychecker --limit 999999 $files | 7 | egrep -v "\(_.*\) not used|No global \(_\) found|Imported module \(gettext\) not used" 8 | -------------------------------------------------------------------------------- /mmkeys/mmkeys.override: -------------------------------------------------------------------------------- 1 | /* Copyright 2004 Joe Wreschnig. Released under the terms of the GNU GPL. */ 2 | %% 3 | headers 4 | #include 5 | 6 | #include "pygobject.h" 7 | #include "mmkeys.h" 8 | %% 9 | modulename mmkeys 10 | %% 11 | import gtk.Plug as PyGtkPlug_Type 12 | %% 13 | ignore-glob 14 | *_get_type 15 | %% 16 | -------------------------------------------------------------------------------- /mmkeys/mmkeys.defs: -------------------------------------------------------------------------------- 1 | ; Copyright 2004 Joe Wreschnig. Released under the terms of the GNU GPL. 2 | 3 | (define-object MmKeys 4 | (in-module "MM") 5 | (parent "GtkPlug") 6 | (c-name "MmKeys") 7 | (gtype-id "TYPE_MMKEYS") 8 | ) 9 | 10 | (define-function mmkeys_get_type 11 | (c-name "mmkeys_get_type") 12 | (return-type "GType") 13 | ) 14 | 15 | (define-function mmkeys_new 16 | (c-name "mmkeys_new") 17 | (is-constructor-of "MmKeys") 18 | (return-type "MmKeys*") 19 | ) 20 | -------------------------------------------------------------------------------- /scripts/screenshot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from dogtail.procedural import * 4 | 5 | def shoot(window, name): 6 | import os 7 | 8 | os.system("import -window \"%s\" -frame %s" % (window, name)) 9 | 10 | 11 | # Start sonata 12 | run('sonata', appName='sonata') 13 | 14 | for tab in 'Current', 'Info', 'Library', 'Playlists', 'Streams': 15 | click(tab) 16 | shoot("Sonata", "%s-tab.png" % tab) 17 | 18 | # XXX take more screenshots 19 | 20 | # FIXME how to open the popup and quit? 21 | -------------------------------------------------------------------------------- /mmkeys/mmkeysmodule.c: -------------------------------------------------------------------------------- 1 | /* Copyright 2004 Joe Wreschnig. Released under the terms of the GNU GPL. */ 2 | 3 | #include 4 | 5 | void mmkeys_register_classes(PyObject *d); 6 | 7 | extern PyMethodDef mmkeys_functions[]; 8 | 9 | DL_EXPORT(void) initmmkeys(void) { 10 | PyObject *m, *d; 11 | 12 | init_pygobject(); 13 | 14 | m = Py_InitModule("mmkeys", mmkeys_functions); 15 | d = PyModule_GetDict(m); 16 | 17 | mmkeys_register_classes(d); 18 | 19 | if (PyErr_Occurred()) Py_FatalError("can't initialise module mmkeys"); 20 | } 21 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | rev=$1 4 | version=$2 5 | 6 | [ -n "$rev" ] 7 | [ -n "$version" ] 8 | 9 | # Tag the trunk revision as the released version 10 | 11 | svn copy -m "release trunk revision $rev as sonata $version" http://svn.berlios.de/svnroot/repos/sonata/trunk/"@$rev" http://svn.berlios.de/svnroot/repos/sonata/tags/$version 12 | 13 | # Create archive, removing: 14 | # - website directory 15 | 16 | svn export http://svn.berlios.de/svnroot/repos/sonata/tags/$version sonata-$version 17 | 18 | rm -R sonata-$version/website/ 19 | 20 | tar zcvf sonata-$version.tar.gz sonata-$version 21 | tar jcf sonata-$version.tar.bz2 sonata-$version 22 | 23 | rm -R sonata-$version/ 24 | -------------------------------------------------------------------------------- /scripts/pycheck-svndiff: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | scriptdir=$(dirname "$0") 3 | [ -z "$scriptdir" ] && scriptdir=$(which "$0") 4 | files="sonata/*.py" 5 | 6 | function do_pycheck() { 7 | pychecker --limit 999999 $1 | 8 | egrep -v "\(_.*\) not used|No global \(_\) found|Imported module \(gettext\) not used" 9 | } 10 | 11 | function setup() { 12 | src=$1 13 | dst=$2 14 | ( 15 | rm -Rf "$dst" 16 | mkdir -p "$dst/sonata" 17 | cd "$dst/sonata" 18 | for f in $src; do 19 | cp -a "$f" "$(basename "$f" .svn-base)" 20 | done 21 | ) 22 | } 23 | 24 | setup "../../../sonata/.svn/text-base/*.py.svn-base" "$scriptdir/pycheck-tmp" 25 | setup "../../../sonata/*.py" "$scriptdir/pycheck-tmp2" 26 | diff -u <(cd "$scriptdir/pycheck-tmp" && do_pycheck "$files") <(cd "$scriptdir/pycheck-tmp2" && do_pycheck "$files") 27 | -------------------------------------------------------------------------------- /mmkeys/Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2004 Joe Wreschnig. Released under the terms of the GNU GPL. 2 | 3 | PYTHON_DIR = /usr/include/python2.4/ 4 | 5 | CFLAGS += -fPIC -O2 `pkg-config --cflags gtk+-2.0 pygtk-2.0` -I$(PYTHON_DIR) 6 | LDFLAGS += `pkg-config --libs gtk+-2.0 pygtk-2.0` 7 | 8 | mmkeys.so: mmkeyspy.o mmkeys.o mmkeysmodule.o 9 | $(CC) $(LDFLAGS) -shared $^ -o $@ 10 | strip mmkeys.so 11 | 12 | DEFS=`pkg-config --variable=defsdir pygtk-2.0` 13 | 14 | mmkeyspy.c: mmkeys.defs mmkeys.override 15 | pygtk-codegen-2.0 --prefix mmkeys \ 16 | --register $(DEFS)/gdk-types.defs \ 17 | --register $(DEFS)/gtk-types.defs \ 18 | --override mmkeys.override \ 19 | mmkeys.defs > gen-tmp 20 | mv gen-tmp $@ 21 | 22 | clean: 23 | rm -f mmkeys.so *.o mmkeyspy.c 24 | 25 | distclean: clean 26 | rm -f *~ gen-tmp 27 | -------------------------------------------------------------------------------- /scripts/update-po: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | writelines() { 4 | for i in *.py 5 | do 6 | if [ ${i} != "__init__.py" ] 7 | then 8 | echo $2${i} >> $1 9 | fi 10 | done 11 | } 12 | 13 | cd $(dirname "$0") 14 | 15 | echo 16 | echo Updating POTFILES.in... 17 | 18 | rm ../po/POTFILES.in 19 | touch ../po/POTFILES.in 20 | cd .. 21 | writelines po/POTFILES.in ./ 22 | cd sonata 23 | writelines ../po/POTFILES.in ./sonata/ 24 | cd ../po 25 | 26 | echo Update messages.po... 27 | echo 28 | 29 | intltool-update -p 30 | mv untitled.pot messages.po 31 | 32 | for i in *.po 33 | do 34 | if [ "$i" = "messages.po" ] 35 | then 36 | continue 37 | fi 38 | echo Updating ${i}... 39 | intltool-update "${i%*.po}" 40 | echo 41 | done 42 | 43 | echo Cleaning up... 44 | echo 45 | 46 | rm untitled.pot 47 | -------------------------------------------------------------------------------- /sonata/version.py: -------------------------------------------------------------------------------- 1 | import os 2 | from subprocess import Popen, PIPE 3 | 4 | try: 5 | import genversion 6 | build_ver = genversion.VERSION 7 | except ImportError: 8 | build_ver = None 9 | 10 | # Should be the most recent release 11 | default_version = "v1.6.2.1" 12 | 13 | def _version(): 14 | '''Get the version number of the sources 15 | 16 | First check the build generated file, fallback to git describe if this is 17 | not a build, finally fallback to the default most recent release. 18 | ''' 19 | if build_ver: 20 | version = build_ver 21 | else: 22 | try: 23 | dir = os.path.dirname(__file__) 24 | version = Popen(["git", "describe", "--abbrev=4", "HEAD"], 25 | cwd=dir, stdout=PIPE, 26 | stderr=PIPE).communicate()[0] 27 | if not version: 28 | raise OSError 29 | except OSError: 30 | version = default_version 31 | return version.strip()[1:] 32 | 33 | version = _version() 34 | -------------------------------------------------------------------------------- /TRANSLATORS: -------------------------------------------------------------------------------- 1 | Sonata provides a quick and easy way of creating a translation for 2 | your language. If you find this software useful and are capable of 3 | adding a language that has not yet been translated, it would be 4 | greatly appreciated. 5 | 6 | Adding a new translation 7 | ------------------------ 8 | 9 | 1. Download the latest sources with git 10 | $ git clone git://git.berlios.de/sonata 11 | 12 | 2. Generate a template 13 | $ cd po 14 | $ intltool-update -p 15 | $ mv untiled.pot .po 16 | 17 | 3. Translate the strings 18 | 19 | 4. Join the sonata-translators mailing list: 20 | https://lists.berlios.de/mailman/listinfo/sonata-translations 21 | 22 | 5. Send your patch to sonata-translations@lists.berlios.de and Cc 23 | the maintainer Stephen Boyd 24 | 25 | 6. Keep the po file updated for future releases ;-) 26 | 27 | Updating an existing translation 28 | ---------------------- 29 | 30 | 1. Download the latest sources with git 31 | $ git clone git://git.berlios.de/sonata 32 | 33 | 2. Update the po file for the language you're updating 34 | $ intltool-update 35 | 36 | 3. Translate any strings that are new or fuzzy 37 | 38 | 4. Join the sonata-translators mailing list: 39 | https://lists.berlios.de/mailman/listinfo/sonata-translations 40 | 41 | 5. Send your patch to sonata-translations@lists.berlios.de and Cc 42 | the maintainer Stephen Boyd 43 | -------------------------------------------------------------------------------- /mmkeys/README: -------------------------------------------------------------------------------- 1 | Multimedia Key support as a PyGTK object 2 | ---------------------------------------- 3 | This module lets you access multimedia keys found on most new keyboards 4 | from Python; most important it grabs all input events so your program 5 | doesn't need to be in focus when the key is pressed (which is the 6 | usual behavior of the keys). You still need something like Acme or 7 | xmodmap to map the keys before using them. 8 | 9 | This code comes from the mmkeys object found in the Muine media player. 10 | 11 | Compiling: 12 | ---------- 13 | (Requires the PyGTK and Python development libraries.) 14 | $ make mmkeys.so 15 | 16 | Usage: 17 | ------ 18 | import mmkeys 19 | keys = mmkeys.Mmkeys() 20 | keys.connect("mm_prev", previous_cb) 21 | keys.connect("mm_next", next_cb) 22 | keys.connect("mm_playpause", playpause_cb) 23 | 24 | Make sure the reference to 'keys' sticks around; if it falls out of scope 25 | it can get GCd and cause segfaults. 26 | 27 | License: 28 | -------- 29 | Copyright (C) 2004 Lee Willis 30 | Borrowed heavily from code by Jan Arne Petersen 31 | Python bindings by Joe Wreschnig 32 | 33 | This program is free software; you can redistribute it and/or 34 | modify it under the terms of the GNU General Public License as 35 | published by the Free Software Foundation; either version 2 of the 36 | License, or (at your option) any later version. 37 | -------------------------------------------------------------------------------- /sonata.1: -------------------------------------------------------------------------------- 1 | .TH SONATA 1 "October 20, 2006" 2 | .SH NAME 3 | sonata \- GTK+ client for the Music Player Daemon 4 | .SH SYNOPSIS 5 | .B sonata 6 | [\fIOPTION\fR] 7 | .SH DESCRIPTION 8 | Sonata is an elegant GTK+ music client for the Music Player Daemon (MPD). 9 | .SH OPTIONS 10 | .TP 11 | .B \-h\fR, \fB\-\-help 12 | Show this help and exit 13 | .TP 14 | .B \-p\fR, \fB\-\-popup 15 | Popup song notification (requires D\-Bus) 16 | .TP 17 | .B \-t\fR, \fB\-\-toggle 18 | Toggles whether the app is minimized to tray or visible (requires D\-Bus) 19 | .TP 20 | .B \-v\fR, \fB\-\-version 21 | Show version information and exit 22 | .TP 23 | .B \-\-hidden 24 | Start app hidden (requires systray) 25 | .TP 26 | .B \-\-visible 27 | Start app visible (requires systray) 28 | .TP 29 | .B \-\-profile\=[NUM] 30 | Start with profile [NUM] 31 | .TP 32 | .B play 33 | Play song in playlist 34 | .TP 35 | .B pause 36 | Pause currently playing song 37 | .TP 38 | .B stop 39 | Stop currently playing song 40 | .TP 41 | .B next 42 | Play next song in playlist 43 | .TP 44 | .B prev 45 | Play previous song in playlist 46 | .TP 47 | .B pp 48 | Toggle play/pause; plays if stopped 49 | .TP 50 | .B repeat 51 | Toggle repeat mode 52 | .TP 53 | .B random 54 | Toggle random mode 55 | .TP 56 | .B info 57 | Display current song info 58 | .TP 59 | .B status 60 | Display MPD status 61 | .SH "SEE ALSO" 62 | .PP 63 | Website: http://sonata.berlios.de 64 | .PP 65 | MPD Website: http://www.musicpd.org 66 | .SH AUTHOR 67 | Sonata was written by Scott Horowitz . 68 | 69 | .PP 70 | This manual page is currently maintained by Scott Horowitz 71 | . It was originally written by Michal Cihar 72 | . 73 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Sonata, an elegant GTK+ client for the Music Player Daemon (MPD) 2 | Copyright 2006-2009 Scott Horowitz 3 | 4 | Thanks to Andrew Conkling et al, for all their hard work on Pygmy! 5 | Sonata started as a fork of the Pygmy project and is licensed under the GPLv3. 6 | 7 | DEVELOPERS: 8 | Scott Horowitz 9 | Tuukka Hastrup 10 | Stephen Boyd 11 | 12 | REQUIREMENTS: 13 | (Required) PyGTK 2.12 or newer 14 | (Required) GTK 2.12 or newer 15 | (Required) python-mpd 0.2 or newer 16 | (Required) Python 2.5 or newer 17 | (Required) MPD 0.12 or newer, possibly on another computer 18 | (Optional) Gnome-python-extras for enhanced system tray 19 | (Optional) taglib and tagpy for editing metadata 20 | (Optional) dbus-python for mmkeys, single instance support 21 | (Building) GCC 22 | (Building) python-dev (on some distros) 23 | 24 | RUNNING: 25 | Sonata can be run from source without installation. Simply 26 | run 'python sonata.py' as your user. 27 | 28 | INSTALLATION: 29 | Run 'python setup.py install' as root. 30 | 31 | FEATURES: 32 | + Expanded and collapsed views, fullscreen album art mode 33 | + Automatic remote and local album art 34 | + Library browsing by folders, or by genre/artist/album 35 | + User-configurable columns 36 | + Automatic fetching of lyrics 37 | + Playlist and stream support 38 | + Support for editing song tags 39 | + Drag-and-drop to copy files 40 | + Popup notification 41 | + Library and playlist searching, filter as you type 42 | + Audioscrobbler (last.fm) 1.2 support 43 | + Multiple MPD profiles 44 | + Keyboard friendly 45 | + Support for multimedia keys 46 | + Commandline control 47 | + Available in 24 languages 48 | 49 | DOCUMENTATION/FAQ: 50 | http://sonata.berlios.de/documentation.html 51 | -------------------------------------------------------------------------------- /mmkeys/mmkeys.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2004 Lee Willis 3 | * Borrowed heavily from code by Jan Arne Petersen 4 | * 5 | * This program is free software; you can redistribute it and/or 6 | * modify it under the terms of the GNU General Public License as 7 | * published by the Free Software Foundation; either version 2 of the 8 | * License, or (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | * General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public 16 | * License along with this program; if not, write to the 17 | * Free Software Foundation, Inc., 59 Temple Place - Suite 330, 18 | * Boston, MA 02111-1307, USA. 19 | */ 20 | 21 | #include 22 | #include 23 | #include 24 | #include 25 | #include 26 | #include 27 | 28 | #ifndef __MM_KEYS_H 29 | #define __MM_KEYS_H 30 | 31 | #define TYPE_MMKEYS (mmkeys_get_type ()) 32 | #define MMKEYS(obj) (G_TYPE_CHECK_INSTANCE_CAST ((obj), TYPE_MMKEYS, MmKeys)) 33 | #define MMKEYS_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), TYPE_MMKEYS, MmKeysClass)) 34 | #define IS_MMKEYS(obj) (G_TYPE_CHECK_INSTANCE_TYPE ((obj), TYPE_MMKEYS)) 35 | #define IS_MMKEYS_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), TYPE_MMKEYS)) 36 | #define MMKEYS_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), TYPE_MMKEYS, MmKeysClass)) 37 | 38 | typedef struct _MmKeys MmKeys; 39 | typedef struct _MmKeysClass MmKeysClass; 40 | 41 | struct _MmKeys 42 | { 43 | GObject parent; 44 | }; 45 | 46 | struct _MmKeysClass 47 | { 48 | GObjectClass parent_class; 49 | }; 50 | 51 | GType mmkeys_get_type (void); 52 | 53 | MmKeys *mmkeys_new (void); 54 | 55 | #endif /* __MM_KEYS_H */ 56 | -------------------------------------------------------------------------------- /sonata/consts.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | This module contains various constant definitions that any other 4 | module can import without risk of cyclic imports. 5 | 6 | Most of the constants are enum-like, that is, they provide symbolic 7 | names for a set of values. 8 | 9 | XXX Should some of these be moved to be private in some module, or 10 | into config? 11 | 12 | Example usage: 13 | from consts import consts 14 | ... 15 | if view == consts.VIEW_ALBUM: ... 16 | """ 17 | 18 | class Constants: 19 | """This class contains the constant definitions as attributes.""" 20 | def __init__(self): 21 | self.ART_LOCAL = 0 22 | self.ART_LOCAL_REMOTE = 1 23 | self.VIEW_FILESYSTEM = 0 24 | self.VIEW_ARTIST = 1 25 | self.VIEW_GENRE = 2 26 | self.VIEW_ALBUM = 3 27 | self.LYRIC_TIMEOUT = 10 28 | self.NOTIFICATION_WIDTH_MAX = 500 29 | self.NOTIFICATION_WIDTH_MIN = 350 30 | self.FULLSCREEN_COVER_SIZE = 500 31 | self.ART_LOCATION_HOMECOVERS = 0 # ~/.covers/[artist]-[album].jpg 32 | self.ART_LOCATION_COVER = 1 # file_dir/cover.jpg 33 | self.ART_LOCATION_ALBUM = 2 # file_dir/album.jpg 34 | self.ART_LOCATION_FOLDER = 3 # file_dir/folder.jpg 35 | self.ART_LOCATION_CUSTOM = 4 # file_dir/[custom] 36 | self.ART_LOCATION_SINGLE = 6 37 | self.ART_LOCATION_MISC = 7 38 | self.ART_LOCATIONS_MISC = ['front.jpg', '.folder.jpg', '.folder.png', 'AlbumArt.jpg', 'AlbumArtSmall.jpg'] 39 | self.LYRICS_LOCATION_HOME = 0 # ~/.lyrics/[artist]-[song].txt 40 | self.LYRICS_LOCATION_PATH = 1 # file_dir/[artist]-[song].txt 41 | self.LYRICS_LOCATION_HOME_ALT = 2 # ~/.lyrics/[artist] - [song].txt 42 | self.LYRICS_LOCATION_PATH_ALT = 3 # file_dir/[artist] - [song].txt 43 | self.LIB_COVER_SIZE = 32 44 | self.COVERS_TYPE_STANDARD = 0 45 | self.COVERS_TYPE_STYLIZED = 1 46 | self.LIB_LEVEL_GENRE = 0 47 | self.LIB_LEVEL_ARTIST = 1 48 | self.LIB_LEVEL_ALBUM = 2 49 | self.LIB_LEVEL_SONG = 3 50 | self.NUM_ARTISTS_FOR_VA = 2 51 | 52 | # the names of the plug-ins that will be enabled by default 53 | self.DEFAULT_PLUGINS = "playlists streams lyricwiki rhapsodycovers localmpd".split() 54 | 55 | consts = Constants() 56 | -------------------------------------------------------------------------------- /sonata/plugins/gajim_tune.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ### BEGIN PLUGIN INFO 4 | # [plugin] 5 | # plugin_format: 0, 0 6 | # name: Gajim tune 7 | # version: 0, 0, 1 8 | # description: Update the MPRIS (in Gajim etc.) tune information. 9 | # author: Fomin Denis 10 | # author_email: fominde@gmail.com 11 | # url: http://sonata.berlios.de 12 | # license: GPL v3 or later 13 | # [capabilities] 14 | # enablables: on_enable 15 | # playing_song_observers: on_song_change 16 | ### END PLUGIN INFO 17 | 18 | import gtk, pango 19 | import dbus.service 20 | from dbus.mainloop.glib import DBusGMainLoop 21 | 22 | songlabel = None 23 | lasttune = '' 24 | tune = None 25 | 26 | # this gets called when the plugin is loaded, enabled, or disabled: 27 | def on_enable(state): 28 | global tune 29 | if state and not tune: 30 | tune = mpdtune() 31 | dbus.SessionBus(mainloop = DBusGMainLoop()) 32 | else: 33 | if tune: 34 | title = artist = album = '' 35 | tune.TrackChange(dbus.Dictionary( 36 | {'title' : title, 'artist' : artist, 'album' : album} 37 | )) 38 | 39 | 40 | def on_song_change(songinfo): 41 | global tune, lasttune 42 | if lasttune == songinfo: 43 | return 44 | lasttune = songinfo 45 | title = artist = album = '' 46 | 47 | if not songinfo: 48 | # mpd stopped 49 | if tune: 50 | tune.TrackChange(dbus.Dictionary( 51 | {'title' : title, 'artist' : artist, 'album' : album})) 52 | return 53 | 54 | if 'title' in songinfo: 55 | title = songinfo['title'] 56 | if 'artist' in songinfo: 57 | artist = songinfo['artist'] 58 | if 'album' in songinfo: 59 | album = songinfo['album'] 60 | if 'name' in songinfo and artist =='': 61 | artist = songinfo['name'] 62 | if 'file' in songinfo and title =='': 63 | album = songinfo['file'] 64 | if not album.startswith('http:'): 65 | title = album.rpartition('/')[2] 66 | if tune: 67 | tune.TrackChange(dbus.Dictionary( 68 | {'title' : title, 'artist' : artist, 'album' : album} 69 | )) 70 | 71 | class mpdtune(dbus.service.Object): 72 | def __init__(self): 73 | dbus.service.Object.__init__(self, dbus.SessionBus(), '/Player') 74 | @dbus.service.signal(dbus_interface = 'org.freedesktop.MediaPlayer') 75 | def TrackChange(self, trackinfo): 76 | return 77 | 78 | -------------------------------------------------------------------------------- /sonata/rhapsodycovers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib 3 | import urllib2 4 | from xml.etree import ElementTree 5 | 6 | from pluginsystem import pluginsystem, BuiltinPlugin 7 | 8 | class RhapsodyCovers(object): 9 | def __init__(self): 10 | pluginsystem.plugin_infos.append(BuiltinPlugin( 11 | 'rhapsodycovers', "Rhapsody Covers", 12 | "Fetch album covers from Rhapsody.com.", 13 | {'cover_fetching': 'get_cover'}, self)) 14 | 15 | def _sanitize_query(self, str): 16 | return str.replace(" ", "").replace("'", "").replace("&","") 17 | 18 | def get_cover(self, progress_callback, artist, album, dest_filename, 19 | all_images=False): 20 | return self.artwork_download_img_to_file(progress_callback, artist, album, dest_filename, all_images) 21 | 22 | def artwork_download_img_to_file(self, progress_callback, artist, album, dest_filename, all_images=False): 23 | if not artist and not album: 24 | return False 25 | 26 | rhapsody_uri = "http://feeds.rhapsody.com" 27 | url = "%s/%s/%s/data.xml" % (rhapsody_uri, artist, album) 28 | url = self._sanitize_query(url) 29 | request = urllib2.Request(url) 30 | opener = urllib2.build_opener() 31 | try: 32 | body = opener.open(request).read() 33 | xml = ElementTree.fromstring(body) 34 | imgs = xml.getiterator("img") 35 | except: 36 | return False 37 | 38 | imglist = [img.attrib['src'] for img in imgs if img.attrib['src']] 39 | # Couldn't find any images 40 | if not imglist: 41 | return False 42 | 43 | if not all_images: 44 | try: 45 | urllib.urlretrieve(imglist[-1], dest_filename) 46 | except IOError: 47 | return False 48 | return True 49 | else: 50 | try: 51 | imgfound = False 52 | for i, image in enumerate(imglist): 53 | dest_filename_curr = dest_filename.replace("", str(i+1)) 54 | urllib.urlretrieve(image, dest_filename_curr) 55 | if not progress_callback( 56 | dest_filename_curr, i): 57 | return imgfound # cancelled 58 | if os.path.exists(dest_filename_curr): 59 | imgfound = True 60 | except: 61 | pass 62 | return imgfound 63 | -------------------------------------------------------------------------------- /sonata/plugins/test.py: -------------------------------------------------------------------------------- 1 | 2 | # this is the magic interpreted by Sonata, referring to on_enable etc. below: 3 | 4 | ### BEGIN PLUGIN INFO 5 | # [plugin] 6 | # plugin_format: 0, 0 7 | # name: Test plugin 8 | # version: 0, 0, 1 9 | # description: A simple test plugin. 10 | # author: Tuukka Hastrup 11 | # author_email: Tuukka.Hastrup@iki.fi 12 | # url: http://sonata.berlios.de 13 | # license: GPL v3 or later 14 | # [capabilities] 15 | # enablables: on_enable 16 | # tabs: construct_tab 17 | # playing_song_observers: on_song_change 18 | # lyrics_fetching: on_lyrics_fetch 19 | ### END PLUGIN INFO 20 | 21 | # nothing magical from here on 22 | 23 | import gobject, gtk, pango 24 | 25 | from sonata.misc import escape_html 26 | 27 | songlabel = None 28 | lyricslabel = None 29 | 30 | # this gets called when the plugin is loaded, enabled, or disabled: 31 | def on_enable(state): 32 | global songlabel, lyricslabel 33 | if state: 34 | songlabel = gtk.Label("No song info received yet.") 35 | songlabel.props.ellipsize = pango.ELLIPSIZE_END 36 | lyricslabel = gtk.Label("No lyrics requests yet.") 37 | lyricslabel.props.ellipsize = pango.ELLIPSIZE_END 38 | else: 39 | songlabel = None 40 | lyricslabel = None 41 | 42 | # this constructs the parts of the tab when called: 43 | def construct_tab(): 44 | vbox = gtk.VBox() 45 | vbox.pack_start(gtk.Label("Hello world!")) 46 | vbox.pack_start(songlabel) 47 | vbox.pack_start(lyricslabel) 48 | vbox.pack_start(gtk.Label("(You can modify me at %s)" % 49 | __file__.rstrip("c"))) 50 | vbox.show_all() 51 | 52 | # the return value goes off to Base.new_tab(page, stock, text, focus): 53 | # (tab content, icon name, tab name, the widget to focus on tab switch) 54 | return (vbox, None, "Test plugin", None) 55 | 56 | # this gets called when a new song is playing: 57 | def on_song_change(songinfo): 58 | if songinfo: 59 | songlabel.set_markup("Info for currently playing song:"+ 60 | "\n%s" % escape_html(repr(songinfo))) 61 | else: 62 | songlabel.set_text("Currently not playing any song.") 63 | songlabel.show() 64 | 65 | # this gets requests for lyrics: 66 | def on_lyrics_fetch(callback, artist, title): 67 | lyricslabel.set_markup( 68 | "Got request for lyrics for artist %r title %r." % 69 | (artist, title)) 70 | 71 | # callback(lyrics, error) 72 | gobject.timeout_add(0, callback, None, 73 | "%s doesn't have lyrics for %r." % 74 | (__name__, (artist, title))) 75 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | v1.6.3 2 | disconnect/connect/reconnect ui weirdness 3 | sonata freezes with gail/accessibility enabled during new library searching 4 | translations - bold, commas for arabic (ahmad farghal email) 5 | #3992: enabling/disabling outputs 6 | #4370: number in playlist 7 | contextual statusbar depending on tab opened? (michael email) 8 | save to playlist.. default to selected files? pref? 9 | tag editing - support for composer, disc 10 | song queue (mpd-git will be bringing it back) 11 | search results - show albums, artists, genres that match 12 | right-click on tab bar (michael email) 13 | 0.15 has input support for last.fm radio 14 | 15 | v1.7 16 | remove libegg? statusicon provides everything in gtk 2.16, not yet in pygtk 17 | albumartist tag for, e.g., VA albums; composer for classical 18 | plugin support 19 | - single instance, mmkeys (could remove dbus; faster on startup, less memory) 20 | - artwork 21 | - lyrics 22 | - system tray 23 | - audioscrobbler 24 | - popup notification 25 | - tag editing 26 | - #2419 dynamic playlist (patch) 27 | - #2454 Add as Next Track (patch) 28 | - #4007 stop after track ('single' command in 0.15) 29 | - #Zeroconf/avahi (patch) 30 | 31 | Future: 32 | support for new idle command (waiting on python-mpd) 33 | work with mpd's new "allow authenticated local users to add any local file to the playlist" 34 | - waiting on python-mpd to implement unix socket paths 35 | - dnd from a file manager (implemented and untested because of above) 36 | - new library browsing mode to open any file? 37 | remember: no tags and implications for remote mpd users. 38 | crop songs in current playlist? 39 | mpd statistics 40 | better playlist support (mpd 0.13+ only): 41 | ability to view songs, reorder songs, remove songs, etc 42 | lazy loading of the treeview 43 | http://log.emmanuelebassi.net/documentation/lazy-loading/ 44 | http://blogs.gnome.org/jamesh/2006/03/31/re-lazy-loading/ 45 | 46 | clean-up 47 | extract duplicate code into functions, classes etc. 48 | document interfaces and implementation 49 | modularity: 50 | - limit module size to 1000 lines 51 | - limit dependencies between modules 52 | style: 53 | - rewrite unpythonic or complicated parts 54 | - fix reasonable pychecker/pylint warnings 55 | tests: 56 | - write automated tests 57 | - refactor code into parts that can be tested 58 | use the logging module: 59 | - add verbose and debug logging modes 60 | - log to file if not running on console 61 | exceptions: 62 | - add class restrictions to most "except:" clauses 63 | - add debug logging to most except clauses 64 | threading: 65 | - design, document, and implement a bug-free use of threads 66 | -------------------------------------------------------------------------------- /sonata/img.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | import gtk, gobject 5 | 6 | def valid_image(filename): 7 | return bool(gtk.gdk.pixbuf_get_file_info(filename)) 8 | 9 | def get_pixbuf_of_size(pixbuf, size): 10 | # Creates a pixbuf that fits in the specified square of sizexsize 11 | # while preserving the aspect ratio 12 | # Returns tuple: (scaled_pixbuf, actual_width, actual_height) 13 | image_width = pixbuf.get_width() 14 | image_height = pixbuf.get_height() 15 | if image_width > image_height: 16 | if image_width > size: 17 | image_height = int(size/float(image_width)*image_height) 18 | image_width = size 19 | else: 20 | if image_height > size: 21 | image_width = int(size/float(image_height)*image_width) 22 | image_height = size 23 | crop_pixbuf = pixbuf.scale_simple(image_width, image_height, gtk.gdk.INTERP_HYPER) 24 | return (crop_pixbuf, image_width, image_height) 25 | 26 | def pixbuf_add_border(pix): 27 | # Add a gray outline to pix. This will increase the pixbuf size by 28 | # 2 pixels lengthwise and heightwise, 1 on each side. Returns pixbuf. 29 | width = pix.get_width() 30 | height = pix.get_height() 31 | newpix = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, True, 8, width+2, height+2) 32 | newpix.fill(0x858585ff) 33 | pix.copy_area(0, 0, width, height, newpix, 1, 1) 34 | return newpix 35 | 36 | def pixbuf_pad(pix, w, h): 37 | # Adds transparent canvas so that the pixbuf is of size (w,h). Also 38 | # centers the pixbuf in the canvas. 39 | width = pix.get_width() 40 | height = pix.get_height() 41 | transpbox = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, True, 8, w, h) 42 | transpbox.fill(0) 43 | x_pos = int((w - width)/2) 44 | y_pos = int((h - height)/2) 45 | pix.copy_area(0, 0, width, height, transpbox, x_pos, y_pos) 46 | return transpbox 47 | 48 | def extension_is_valid(extension): 49 | for imgformat in gtk.gdk.pixbuf_get_formats(): 50 | if extension.lower() in imgformat['extensions']: 51 | return True 52 | return False 53 | 54 | def is_imgfile(filename): 55 | ext = os.path.splitext(filename)[1][1:] 56 | return extension_is_valid(ext) 57 | 58 | def single_image_in_dir(dirname): 59 | # Returns None or a filename if there is exactly one image 60 | # in the dir. 61 | try: 62 | dirname = gobject.filename_from_utf8(dirname) 63 | except: 64 | pass 65 | 66 | try: 67 | files = os.listdir(dirname) 68 | except OSError: 69 | return None 70 | 71 | imgfiles = [f for f in files if is_imgfile(f)] 72 | if len(imgfiles) != 1: 73 | return None 74 | return os.path.join(dirname, imgfiles[0]) 75 | -------------------------------------------------------------------------------- /sonata/lyricwiki.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib 3 | import re 4 | import threading # get_lyrics_start starts a thread get_lyrics_thread 5 | 6 | import gobject 7 | 8 | import misc 9 | import mpdhelper as mpdh 10 | from consts import consts 11 | from pluginsystem import pluginsystem, BuiltinPlugin 12 | 13 | class LyricWiki(object): 14 | def __init__(self): 15 | self.lyricServer = None 16 | 17 | pluginsystem.plugin_infos.append(BuiltinPlugin( 18 | 'lyricwiki', "LyricWiki", 19 | "Fetch lyrics from LyricWiki.", 20 | {'lyrics_fetching': 'get_lyrics_start'}, self)) 21 | 22 | def get_lyrics_start(self, *args): 23 | lyricThread = threading.Thread(target=self.get_lyrics_thread, args=args) 24 | lyricThread.setDaemon(True) 25 | lyricThread.start() 26 | 27 | def lyricwiki_format(self, text): 28 | return urllib.quote(str(unicode(text).title())) 29 | 30 | def lyricwiki_editlink(self, songinfo): 31 | artist, title = [self.lyricwiki_format(mpdh.get(songinfo, key)) 32 | for key in ('artist', 'title')] 33 | return ("http://lyricwiki.org/index.php?title=%s:%s&action=edit" % 34 | (artist, title)) 35 | 36 | def get_lyrics_thread(self, callback, artist, title): 37 | try: 38 | lyricpage = urllib.urlopen("http://lyricwiki.org/index.php?title=%s:%s&action=edit" % (self.lyricwiki_format(artist), self.lyricwiki_format(title))).read() 39 | content = re.split("]*>", lyricpage)[1].split("")[0] 40 | content = content.strip() 41 | redir_tag = "#redirect" 42 | if content[:len(redir_tag)].lower() == redir_tag: 43 | addr = "http://lyricwiki.org/index.php?title=%s&action=edit" % urllib.quote(content.split("[[")[1].split("]]")[0]) 44 | lyricpage = urllib.urlopen(addr).read() 45 | content = re.split("]*>", lyricpage)[1].split("")[0] 46 | content = content.strip() 47 | lyrics = content.split("<lyrics>")[1].split("</lyrics>")[0] 48 | if lyrics.strip() != "<!-- PUT LYRICS HERE (and delete this entire line) -->": 49 | lyrics = misc.unescape_html(lyrics) 50 | lyrics = misc.wiki_to_html(lyrics) 51 | lyrics = lyrics.decode("utf-8") 52 | self.call_back(callback, lyrics=lyrics) 53 | else: 54 | error = _("Lyrics not found") 55 | self.call_back(callback, error=error) 56 | except: 57 | error = _("Fetching lyrics failed") 58 | self.call_back(callback, error=error) 59 | 60 | def call_back(self, callback, lyrics=None, error=None): 61 | gobject.timeout_add(0, callback, lyrics, error) 62 | -------------------------------------------------------------------------------- /sonata/mpdhelper.py: -------------------------------------------------------------------------------- 1 | 2 | import locale, sys, os 3 | from time import strftime 4 | from misc import remove_list_duplicates 5 | 6 | suppress_errors = False 7 | 8 | def suppress_mpd_errors(val): 9 | global suppress_errors 10 | suppress_errors = val 11 | 12 | def status(client): 13 | result = call(client, 'status') 14 | if result and 'state' in result: 15 | return result 16 | else: 17 | return {} 18 | 19 | def currsong(client): 20 | return call(client, 'currentsong') 21 | 22 | def get(mapping, key, alt='', *sanitize_args): 23 | """Get a value from a mpd song and sanitize appropriately. 24 | 25 | sanitize_args: Arguments to pass to sanitize 26 | 27 | If the value is a list, only the first element is returned. 28 | Examples: 29 | get({'baz':['foo', 'bar']}, 'baz', '') -> 'foo' 30 | get({'baz':34}, 'baz', '', True) -> 34 31 | """ 32 | 33 | value = mapping.get(key, alt) 34 | if isinstance(value, list): 35 | value = value[0] 36 | return _sanitize(value, *sanitize_args) if sanitize_args else value 37 | 38 | def _sanitize(tag, return_int=False, str_padding=0): 39 | # Sanitizes a mpd tag; used for numerical tags. Known forms 40 | # for the mpd tag can be "4", "4/10", and "4,10". 41 | if not tag: 42 | return tag 43 | tag = str(tag).replace(',', ' ', 1).replace('/', ' ', 1).split()[0] 44 | if return_int: 45 | return int(tag) if tag.isdigit() else 0 46 | 47 | return tag.zfill(str_padding) 48 | 49 | def conout(s): 50 | # A kind of 'print' which does not throw exceptions if the string 51 | # to print cannot be converted to console encoding; instead it 52 | # does a "readable" conversion 53 | print s.encode(locale.getpreferredencoding(), "replace") 54 | 55 | def call(mpdclient, mpd_cmd, *mpd_args): 56 | try: 57 | retval = getattr(mpdclient, mpd_cmd)(*mpd_args) 58 | except: 59 | if not mpd_cmd in ['disconnect', 'lsinfo', 'listplaylists']: 60 | if not suppress_errors: 61 | print strftime("%Y-%m-%d %H:%M:%S") + " " + str(sys.exc_info()[1]) 62 | if mpd_cmd in ['lsinfo', 'list']: 63 | return [] 64 | else: 65 | return None 66 | 67 | return retval 68 | 69 | def mpd_major_version(client): 70 | try: 71 | version = getattr(client, "mpd_version", 0.0) 72 | parts = version.split(".") 73 | return float(parts[0] + "." + parts[1]) 74 | except: 75 | return 0.0 76 | 77 | def mpd_is_updating(status): 78 | return status and status.get('updating_db', 0) 79 | 80 | def update(mpdclient, paths, status): 81 | # mpd 0.14.x limits the number of paths that can be 82 | # updated within a command_list at 32. If we have 83 | # >32 directories, we bail and update the entire library. 84 | # 85 | # If we want to get trickier in the future, we can find 86 | # the 32 most specific parents that cover the set of files. 87 | # This would lower the possibility of resorting to a full 88 | # library update. 89 | # 90 | # Note: If a future version of mpd relaxes this limit, 91 | # we should make the version check more specific to 0.14.x 92 | 93 | if mpd_is_updating(status): 94 | return 95 | 96 | # Updating paths seems to be faster than updating files for 97 | # some reason: 98 | dirs = [] 99 | for path in paths: 100 | dirs.append(os.path.dirname(path)) 101 | dirs = remove_list_duplicates(dirs, True) 102 | 103 | if len(dirs) > 32 and mpd_major_version(mpdclient) >= 0.14: 104 | call(mpdclient, 'update', '/') 105 | else: 106 | call(mpdclient, 'command_list_ok_begin') 107 | for directory in dirs: 108 | call(mpdclient, 'update', directory) 109 | call(mpdclient, 'command_list_end') 110 | -------------------------------------------------------------------------------- /mmkeys/mmkeyspy.c: -------------------------------------------------------------------------------- 1 | /* -- THIS FILE IS GENERATED - DO NOT EDIT *//* -*- Mode: C; c-basic-offset: 4 -*- */ 2 | 3 | #include 4 | 5 | 6 | 7 | #line 4 "mmkeys.override" 8 | #include 9 | 10 | #include "pygobject.h" 11 | #include "mmkeys.h" 12 | #line 13 "mmkeys.c" 13 | 14 | 15 | /* ---------- types from other modules ---------- */ 16 | static PyTypeObject *_PyGtkPlug_Type; 17 | #define PyGtkPlug_Type (*_PyGtkPlug_Type) 18 | 19 | 20 | /* ---------- forward type declarations ---------- */ 21 | PyTypeObject PyMmKeys_Type; 22 | 23 | #line 24 "mmkeys.c" 24 | 25 | 26 | 27 | /* ----------- MmKeys ----------- */ 28 | 29 | static int 30 | _wrap_mmkeys_new(PyGObject *self, PyObject *args, PyObject *kwargs) 31 | { 32 | static char* kwlist[] = { NULL }; 33 | 34 | if (!PyArg_ParseTupleAndKeywords(args, kwargs, ":mmkeys.MmKeys.__init__", kwlist)) 35 | return -1; 36 | 37 | pygobject_constructv(self, 0, NULL); 38 | 39 | if (!self->obj) { 40 | PyErr_SetString(PyExc_RuntimeError, "could not create %(typename)s object"); 41 | return -1; 42 | } 43 | 44 | return 0; 45 | } 46 | 47 | 48 | PyTypeObject PyMmKeys_Type = { 49 | PyObject_HEAD_INIT(NULL) 50 | 0, /* ob_size */ 51 | "mmkeys.MmKeys", /* tp_name */ 52 | sizeof(PyGObject), /* tp_basicsize */ 53 | 0, /* tp_itemsize */ 54 | /* methods */ 55 | (destructor)0, /* tp_dealloc */ 56 | (printfunc)0, /* tp_print */ 57 | (getattrfunc)0, /* tp_getattr */ 58 | (setattrfunc)0, /* tp_setattr */ 59 | (cmpfunc)0, /* tp_compare */ 60 | (reprfunc)0, /* tp_repr */ 61 | (PyNumberMethods*)0, /* tp_as_number */ 62 | (PySequenceMethods*)0, /* tp_as_sequence */ 63 | (PyMappingMethods*)0, /* tp_as_mapping */ 64 | (hashfunc)0, /* tp_hash */ 65 | (ternaryfunc)0, /* tp_call */ 66 | (reprfunc)0, /* tp_str */ 67 | (getattrofunc)0, /* tp_getattro */ 68 | (setattrofunc)0, /* tp_setattro */ 69 | (PyBufferProcs*)0, /* tp_as_buffer */ 70 | Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ 71 | NULL, /* Documentation string */ 72 | (traverseproc)0, /* tp_traverse */ 73 | (inquiry)0, /* tp_clear */ 74 | (richcmpfunc)0, /* tp_richcompare */ 75 | offsetof(PyGObject, weakreflist), /* tp_weaklistoffset */ 76 | (getiterfunc)0, /* tp_iter */ 77 | (iternextfunc)0, /* tp_iternext */ 78 | NULL, /* tp_methods */ 79 | 0, /* tp_members */ 80 | 0, /* tp_getset */ 81 | NULL, /* tp_base */ 82 | NULL, /* tp_dict */ 83 | (descrgetfunc)0, /* tp_descr_get */ 84 | (descrsetfunc)0, /* tp_descr_set */ 85 | offsetof(PyGObject, inst_dict), /* tp_dictoffset */ 86 | (initproc)_wrap_mmkeys_new, /* tp_init */ 87 | (allocfunc)0, /* tp_alloc */ 88 | (newfunc)0, /* tp_new */ 89 | (freefunc)0, /* tp_free */ 90 | (inquiry)0 /* tp_is_gc */ 91 | }; 92 | 93 | 94 | 95 | /* ----------- functions ----------- */ 96 | 97 | PyMethodDef mmkeys_functions[] = { 98 | { NULL, NULL, 0 } 99 | }; 100 | 101 | /* initialise stuff extension classes */ 102 | void 103 | mmkeys_register_classes(PyObject *d) 104 | { 105 | PyObject *module; 106 | 107 | if ((module = PyImport_ImportModule("gtk")) != NULL) { 108 | PyObject *moddict = PyModule_GetDict(module); 109 | 110 | _PyGtkPlug_Type = (PyTypeObject *)PyDict_GetItemString(moddict, "Plug"); 111 | if (_PyGtkPlug_Type == NULL) { 112 | PyErr_SetString(PyExc_ImportError, 113 | "cannot import name Plug from gtk"); 114 | return; 115 | } 116 | } else { 117 | PyErr_SetString(PyExc_ImportError, 118 | "could not import gtk"); 119 | return; 120 | } 121 | 122 | 123 | #line 124 "mmkeys.c" 124 | pygobject_register_class(d, "MmKeys", TYPE_MMKEYS, &PyMmKeys_Type, Py_BuildValue("(O)", &PyGtkPlug_Type)); 125 | pyg_set_object_has_new_constructor(TYPE_MMKEYS); 126 | } 127 | -------------------------------------------------------------------------------- /sonata.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | """Sonata is a simple GTK+ client for the Music Player Daemon. 3 | """ 4 | 5 | __author__ = "Scott Horowitz" 6 | __email__ = "stonecrest@gmail.com" 7 | __license__ = """ 8 | Sonata, an elegant GTK+ client for the Music Player Daemon 9 | Copyright 2006-2008 Scott Horowitz 10 | 11 | This file is part of Sonata. 12 | 13 | Sonata is free software; you can redistribute it and/or modify 14 | it under the terms of the GNU General Public License as published by 15 | the Free Software Foundation; either version 3 of the License, or 16 | (at your option) any later version. 17 | 18 | Sonata is distributed in the hope that it will be useful, 19 | but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | GNU General Public License for more details. 22 | 23 | You should have received a copy of the GNU General Public License 24 | along with this program. If not, see . 25 | """ 26 | 27 | import sys, platform, locale, gettext, os 28 | 29 | # XXX insert the correct sonata package dir in sys.path 30 | 31 | try: 32 | import sonata 33 | except ImportError: 34 | sys.stderr.write("Python failed to find the sonata modules.\n") 35 | sys.stderr.write("\nSearched in the following directories:\n" + 36 | "\n".join(sys.path) + "\n") 37 | sys.stderr.write("\nPerhaps Sonata is improperly installed?\n") 38 | sys.exit(1) 39 | 40 | try: 41 | from sonata.version import version 42 | except ImportError: 43 | sys.stderr.write("Python failed to find the sonata modules.\n") 44 | sys.stderr.write("\nAn old or incomplete installation was " + 45 | "found in the following directory:\n" + 46 | os.path.dirname(sonata.__file__) + "\n") 47 | sys.stderr.write("\nPerhaps you want to delete it?\n") 48 | sys.exit(1) 49 | 50 | # XXX check that version.VERSION is what this script was installed for 51 | 52 | 53 | ## Apply global fixes: 54 | 55 | # the following line is to fix python-zsi 2.0 and thus lyrics in ubuntu: 56 | # https://bugs.launchpad.net/ubuntu/+source/zsi/+bug/208855 57 | sys.path.append('/usr/lib/python2.5/site-packages/oldxml') 58 | 59 | # hint for gnome.init to set the process name to 'sonata' 60 | if platform.system() == 'Linux': 61 | sys.argv[0] = 'sonata' 62 | 63 | # apply as well: 64 | try: 65 | import ctypes 66 | libc = ctypes.CDLL('libc.so.6') 67 | PR_SET_NAME = 15 68 | libc.prctl(PR_SET_NAME, sys.argv[0], 0, 0, 0) 69 | except Exception: # if it fails, it fails 70 | pass 71 | 72 | 73 | ## Apply locale and translation: 74 | 75 | from sonata import misc 76 | misc.setlocale() 77 | 78 | # let gettext install _ as a built-in for all modules to see 79 | # XXX what's the correct way to find the localization? 80 | try: 81 | gettext.install('sonata', os.path.join(sonata.__file__.split('/lib')[0], 'share', 'locale'), unicode=1) 82 | except: 83 | print "Warning: trying to use an old translation" 84 | gettext.install('sonata', '/usr/share/locale', unicode=1) 85 | gettext.textdomain('sonata') 86 | 87 | 88 | ## Check initial dependencies: 89 | 90 | # Test python version: 91 | if sys.version_info < (2,5): 92 | sys.stderr.write("Sonata requires Python 2.5 or newer. Aborting...\n") 93 | sys.exit(1) 94 | 95 | try: 96 | import mpd 97 | except: 98 | sys.stderr.write("Sonata requires python-mpd. Aborting...\n") 99 | sys.exit(1) 100 | 101 | 102 | ## Initialize the plugin system: 103 | 104 | from sonata.pluginsystem import pluginsystem 105 | pluginsystem.find_plugins() 106 | pluginsystem.notify_of('enablables', 107 | lambda plugin, cb: cb(True), 108 | lambda plugin, cb: cb(False)) 109 | 110 | 111 | ## Load the command line interface: 112 | 113 | from sonata import cli 114 | args = cli.Args() 115 | args.parse(sys.argv) 116 | 117 | ## Deal with GTK: 118 | 119 | if not args.skip_gui: 120 | # importing gtk does sys.setdefaultencoding("utf-8"), sets locale etc. 121 | import gtk 122 | if gtk.pygtk_version < (2, 12, 0): 123 | sys.stderr.write("Sonata requires PyGTK 2.12.0 or newer. Aborting...\n") 124 | sys.exit(1) 125 | # fix locale 126 | misc.setlocale() 127 | else: 128 | class FakeModule(object): 129 | pass 130 | # make sure the ui modules aren't imported 131 | for m in 'gtk', 'pango', 'sonata.ui', 'sonata.breadcrumbs': 132 | if m in sys.modules: 133 | print "Warning: module %s imported in CLI mode" % m 134 | else: 135 | sys.modules[m] = FakeModule() 136 | # like gtk, set utf-8 encoding of str objects 137 | reload(sys) # hack access to setdefaultencoding 138 | sys.setdefaultencoding("utf-8") 139 | 140 | 141 | ## Global init: 142 | 143 | from socket import setdefaulttimeout as socketsettimeout 144 | socketsettimeout(5) 145 | 146 | if not args.skip_gui: 147 | gtk.gdk.threads_init() 148 | 149 | # we don't use gtk.LinkButton, but gtk.AboutDialog does; 150 | # in gtk 2.16.0 without this, the about uri opens doubly: 151 | gtk.link_button_set_uri_hook(lambda *args:None) 152 | 153 | ## CLI actions: 154 | 155 | args.execute_cmds() 156 | 157 | 158 | ## Load the main application: 159 | 160 | from sonata import main 161 | 162 | app = main.Base(args) 163 | try: 164 | app.main() 165 | except KeyboardInterrupt: 166 | pass 167 | -------------------------------------------------------------------------------- /sonata/tray.py: -------------------------------------------------------------------------------- 1 | 2 | import gtk, gobject 3 | 4 | class TrayIconTips(gtk.Window): 5 | """Custom tooltips derived from gtk.Window() that allow for markup text and multiple widgets, e.g. a progress bar. ;)""" 6 | MARGIN = 4 7 | 8 | def __init__(self): 9 | gtk.Window.__init__(self, gtk.WINDOW_POPUP) 10 | # from gtktooltips.c:gtk_tooltips_force_window 11 | self.set_app_paintable(True) 12 | self.set_resizable(False) 13 | self.set_name("gtk-tooltips") 14 | self.connect('expose-event', self._on__expose_event) 15 | 16 | self._show_timeout_id = -1 17 | self.timer_tag = None 18 | self.notif_handler = None 19 | self.use_notifications_location = False 20 | self.notifications_location = 0 21 | self.widget = None 22 | 23 | def _calculate_pos(self, widget): 24 | if widget is not None: 25 | try: 26 | x, y = widget.window.get_origin() 27 | if widget.flags() & gtk.NO_WINDOW: 28 | x += widget.allocation.x 29 | y += widget.allocation.y 30 | height = widget.allocation.height 31 | except: 32 | _icon_screen, icon_rect, _icon_orient = widget.get_geometry() 33 | x = icon_rect[0] 34 | y = icon_rect[1] 35 | height = icon_rect[3] 36 | w, h = self.size_request() 37 | 38 | screen = self.get_screen() 39 | pointer_screen, px, py, _ = screen.get_display().get_pointer() 40 | if pointer_screen != screen: 41 | px = x 42 | py = y 43 | try: 44 | # Use the monitor that the systemtray icon is on 45 | monitor_num = screen.get_monitor_at_point(x, y) 46 | except: 47 | # No systemtray icon, use the monitor that the pointer is on 48 | monitor_num = screen.get_monitor_at_point(px, py) 49 | monitor = screen.get_monitor_geometry(monitor_num) 50 | 51 | try: 52 | # If the tooltip goes off the screen horizontally, realign it so that 53 | # it all displays. 54 | if (x + w) > monitor.x + monitor.width: 55 | x = monitor.x + monitor.width - w 56 | # If the tooltip goes off the screen vertically (i.e. the system tray 57 | # icon is on the bottom of the screen), realign the icon so that it 58 | # shows above the icon. 59 | if ((y + h + height + self.MARGIN) > 60 | monitor.y + monitor.height): 61 | y = y - h - self.MARGIN 62 | else: 63 | y = y + height + self.MARGIN 64 | except: 65 | pass 66 | 67 | if not self.use_notifications_location: 68 | try: 69 | return x, y 70 | except: 71 | #Fallback to top-left: 72 | return monitor.x, monitor.y 73 | elif self.notifications_location == 0: 74 | try: 75 | return x, y 76 | except: 77 | #Fallback to top-left: 78 | return monitor.x, monitor.y 79 | elif self.notifications_location == 1: 80 | return monitor.x, monitor.y 81 | elif self.notifications_location == 2: 82 | return monitor.x + monitor.width - w, monitor.y 83 | elif self.notifications_location == 3: 84 | return monitor.x, monitor.y + monitor.height - h 85 | elif self.notifications_location == 4: 86 | return monitor.x + monitor.width - w, monitor.y + monitor.height - h 87 | elif self.notifications_location == 5: 88 | return monitor.x + (monitor.width - w)/2, monitor.y + (monitor.height - h)/2 89 | 90 | def _event_handler (self, widget): 91 | widget.connect_after("event-after", self._motion_cb) 92 | 93 | def _motion_cb (self, widget, event): 94 | if self.notif_handler != None: 95 | return 96 | if event.type == gtk.gdk.LEAVE_NOTIFY: 97 | self._remove_timer() 98 | if event.type == gtk.gdk.ENTER_NOTIFY: 99 | self._start_delay(widget) 100 | 101 | def _start_delay (self, widget): 102 | self.timer_tag = gobject.timeout_add(500, self._tips_timeout, widget) 103 | 104 | def _tips_timeout (self, widget): 105 | self.use_notifications_location = False 106 | self._real_display(widget) 107 | 108 | def _remove_timer(self): 109 | self.hide() 110 | if self.timer_tag: 111 | gobject.source_remove(self.timer_tag) 112 | self.timer_tag = None 113 | 114 | # from gtktooltips.c:gtk_tooltips_paint_window 115 | def _on__expose_event(self, window, _event): 116 | w, h = window.size_request() 117 | window.style.paint_flat_box(window.window, 118 | gtk.STATE_NORMAL, gtk.SHADOW_OUT, 119 | None, window, "tooltip", 120 | 0, 0, w, h) 121 | return False 122 | 123 | def _real_display(self, widget): 124 | x, y = self._calculate_pos(widget) 125 | self.move(x, y) 126 | self.show() 127 | 128 | # Public API 129 | 130 | def hide(self): 131 | gtk.Window.hide(self) 132 | gobject.source_remove(self._show_timeout_id) 133 | self._show_timeout_id = -1 134 | self.notif_handler = None 135 | 136 | def set_tip (self, widget): 137 | self.widget = widget 138 | self._event_handler (self.widget) 139 | 140 | def add_widget (self, widget_to_add): 141 | self.add(widget_to_add) 142 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os, glob, shutil 4 | 5 | from distutils.core import setup, Extension 6 | 7 | from sonata.version import version 8 | 9 | def capture(cmd): 10 | return os.popen(cmd).read().strip() 11 | 12 | def removeall(path): 13 | if not os.path.isdir(path): 14 | return 15 | 16 | files=os.listdir(path) 17 | 18 | for x in files: 19 | fullpath=os.path.join(path, x) 20 | if os.path.isfile(fullpath): 21 | f=os.remove 22 | rmgeneric(fullpath, f) 23 | elif os.path.isdir(fullpath): 24 | removeall(fullpath) 25 | f=os.rmdir 26 | rmgeneric(fullpath, f) 27 | 28 | def rmgeneric(path, __func__): 29 | try: 30 | __func__(path) 31 | except OSError, (errno, strerror): 32 | pass 33 | 34 | # Create mo files: 35 | if not os.path.exists("mo/"): 36 | os.mkdir("mo/") 37 | 38 | langs = (l[:-3] for l in os.listdir('po') if l.endswith('po') 39 | and l != "messages.po") 40 | for lang in langs: 41 | pofile = os.path.join("po", "%s.po" % lang) 42 | modir = os.path.join("mo", lang) 43 | mofile = os.path.join(modir, "sonata.mo") 44 | if not os.path.exists(modir): 45 | os.mkdir(modir) 46 | print "generating", mofile 47 | os.system("msgfmt %s -o %s" % (pofile, mofile)) 48 | 49 | # Copy script "sonata" file to sonata dir: 50 | shutil.copyfile("sonata.py", "sonata/sonata") 51 | 52 | versionfile = open("sonata/genversion.py","wt") 53 | versionfile.write(""" 54 | # generated by setup.py 55 | VERSION = 'v%s' 56 | """ % version) 57 | versionfile.close() 58 | 59 | setup(name='Sonata', 60 | version=version, 61 | description='GTK+ client for the Music Player Daemon (MPD).', 62 | author='Scott Horowitz', 63 | author_email='stonecrest@gmail.com', 64 | url='http://sonata.berlios.de/', 65 | classifiers=[ 66 | 'Development Status :: 4 - Beta', 67 | 'Environment :: X11 Applications', 68 | 'Intended Audience :: End Users/Desktop', 69 | 'License :: GNU General Public License (GPL)', 70 | 'Operating System :: Linux', 71 | 'Programming Language :: Python', 72 | 'Topic :: Multimedia :: Sound :: Players', 73 | ], 74 | packages=["sonata", "sonata.plugins"], 75 | package_dir={"sonata": "sonata/"}, 76 | ext_modules=[Extension( 77 | "mmkeys", ["mmkeys/mmkeyspy.c", "mmkeys/mmkeys.c", "mmkeys/mmkeysmodule.c"], 78 | extra_compile_args=capture("pkg-config --cflags gtk+-2.0 pygtk-2.0").split(), 79 | extra_link_args=capture("pkg-config --libs gtk+-2.0 pygtk-2.0").split() 80 | ),], 81 | scripts = ['sonata/sonata'], 82 | data_files=[('share/sonata', ['README', 'CHANGELOG', 'TODO', 'TRANSLATORS']), 83 | ('share/applications', ['sonata.desktop']), 84 | ('share/pixmaps', glob.glob('sonata/pixmaps/*')), 85 | ('share/man/man1', ['sonata.1']), 86 | ('share/locale/de/LC_MESSAGES', ['mo/de/sonata.mo']), 87 | ('share/locale/pl/LC_MESSAGES', ['mo/pl/sonata.mo']), 88 | ('share/locale/ru/LC_MESSAGES', ['mo/ru/sonata.mo']), 89 | ('share/locale/fr/LC_MESSAGES', ['mo/fr/sonata.mo']), 90 | ('share/locale/zh_CN/LC_MESSAGES', ['mo/zh_CN/sonata.mo']), 91 | ('share/locale/sv/LC_MESSAGES', ['mo/sv/sonata.mo']), 92 | ('share/locale/es/LC_MESSAGES', ['mo/es/sonata.mo']), 93 | ('share/locale/fi/LC_MESSAGES', ['mo/fi/sonata.mo']), 94 | ('share/locale/nl/LC_MESSAGES', ['mo/nl/sonata.mo']), 95 | ('share/locale/it/LC_MESSAGES', ['mo/it/sonata.mo']), 96 | ('share/locale/cs/LC_MESSAGES', ['mo/cs/sonata.mo']), 97 | ('share/locale/da/LC_MESSAGES', ['mo/da/sonata.mo']), 98 | ('share/locale/ca/LC_MESSAGES', ['mo/ca/sonata.mo']), 99 | ('share/locale/ar/LC_MESSAGES', ['mo/ar/sonata.mo']), 100 | ('share/locale/pt_BR/LC_MESSAGES', ['mo/pt_BR/sonata.mo']), 101 | ('share/locale/et/LC_MESSAGES', ['mo/et/sonata.mo']), 102 | ('share/locale/tr/LC_MESSAGES', ['mo/tr/sonata.mo']), 103 | ('share/locale/be@latin/LC_MESSAGES', ['mo/be@latin/sonata.mo']), 104 | ('share/locale/el_GR/LC_MESSAGES', ['mo/el_GR/sonata.mo']), 105 | ('share/locale/sk/LC_MESSAGES', ['mo/sk/sonata.mo']), 106 | ('share/locale/ja/LC_MESSAGES', ['mo/ja/sonata.mo']), 107 | ('share/locale/sl/LC_MESSAGES', ['mo/sl/sonata.mo']), 108 | ('share/locale/zh_TW/LC_MESSAGES', ['mo/zh_TW/sonata.mo']), 109 | ('share/locale/uk/LC_MESSAGES', ['mo/uk/sonata.mo'])], 110 | ) 111 | 112 | # Cleanup (remove /build, /mo, and *.pyc files: 113 | print "Cleaning up..." 114 | try: 115 | removeall("build/") 116 | os.rmdir("build/") 117 | except: 118 | pass 119 | try: 120 | removeall("mo/") 121 | os.rmdir("mo/") 122 | except: 123 | pass 124 | try: 125 | for f in os.listdir("."): 126 | if os.path.isfile(f): 127 | if os.path.splitext(os.path.basename(f))[1] == ".pyc": 128 | os.remove(f) 129 | except: 130 | pass 131 | try: 132 | os.remove("sonata/sonata") 133 | except: 134 | pass 135 | try: 136 | os.remove("sonata/genversion.py") 137 | os.remove("sonata/genversion.pyc") 138 | except: 139 | pass 140 | -------------------------------------------------------------------------------- /sonata/plugins/localmpd.py: -------------------------------------------------------------------------------- 1 | 2 | # this is the magic interpreted by Sonata, referring to construct_tab below: 3 | 4 | ### BEGIN PLUGIN INFO 5 | # [plugin] 6 | # plugin_format: 0, 0 7 | # name: Local MPD 8 | # version: 0, 0, 1 9 | # description: A tab for controlling local MPD. 10 | # author: Tuukka Hastrup 11 | # author_email: Tuukka.Hastrup@iki.fi 12 | # url: http://sonata.berlios.de 13 | # license: GPL v3 or later 14 | # [capabilities] 15 | # tabs: construct_tab 16 | ### END PLUGIN INFO 17 | 18 | import subprocess, locale 19 | from pwd import getpwuid 20 | 21 | import gobject, gtk 22 | 23 | from sonata.misc import escape_html 24 | 25 | class Netstat(object): 26 | TCP_STATE_NAMES = ("ESTABLISHED SYN_SENT SYN_RECV FIN_WAIT1 FIN_WAIT2 " 27 | "TIME_WAIT CLOSE CLOSE_WAIT LAST_ACK LISTEN CLOSING" 28 | .split()) 29 | def __init__(self): 30 | self.connections = None 31 | 32 | def _addr(self, part): 33 | host, port = part.split(':') 34 | port = str(int(port, 16)) 35 | if len(host) == 8: 36 | parts = [host[0:2], host[2:4], host[4:6], host[6:8]] 37 | parts = [str(int(x, 16)) for x in parts] 38 | host = '.'.join(reversed(parts)) 39 | else: 40 | host = "IPV6" # FIXME 41 | if host == '0.0.0.0': 42 | host = '*' 43 | elif host == '127.0.0.1': 44 | host = 'localhost' 45 | if port == '0': 46 | port = '*' 47 | return (host, port) 48 | 49 | def read_connections(self): 50 | def fromhex(x): 51 | return int(x, 16) 52 | self.connections = [] 53 | for name in '/proc/net/tcp', '/proc/net/tcp6': 54 | f = open(name,'rt') 55 | headings = f.readline() 56 | for line in f: 57 | parts = line.split() 58 | if len(parts) < 10: 59 | continue # broken line 60 | local = self._addr(parts[1]) 61 | remote = self._addr(parts[2]) 62 | state = self.TCP_STATE_NAMES[ 63 | fromhex(parts[3])-1] 64 | queueparts = parts[4].split(':') 65 | queues = tuple(map(fromhex,queueparts)) 66 | uid, _timeout, inode = map(int, parts[7:10]) 67 | if len(parts[1].split(":")[0]) == 8: 68 | proto = "tcp" 69 | else: 70 | proto = "tcp6" 71 | self.connections += [(proto, local, remote, state, queues, uid, inode)] 72 | 73 | def format_connections(self): 74 | t = "%-5s %6s %6s %15s:%-5s %15s:%-5s %-11s %s" 75 | headings = "Proto Send-Q Recv-Q Local Port Remote Port State User".split() 76 | return (t % tuple(headings) + '\n' + 77 | '\n'.join([t % (proto, rxq, txq, localh, localp, remoteh, remotep, state, getpwuid(uid)[0]) 78 | for proto, (localh, localp), (remoteh, remotep), state, (txq, rxq), uid, inode in self.connections 79 | if localp == '6600' or remotep == '6600' 80 | or getpwuid(uid)[0] == 'mpd'])) 81 | 82 | def update(label): 83 | # schedule next update 84 | gobject.timeout_add(1000, update, label) 85 | 86 | # don't update if not visible 87 | if not label.window or not label.window.is_viewable(): 88 | return 89 | 90 | netstat = Netstat() 91 | netstat.read_connections() 92 | netstats = netstat.format_connections() 93 | 94 | # XXX replace the shell commands with python code 95 | commands = [("Processes", "ps wwu -C mpd".split()), 96 | ("Files", ["sh", "-c", "ls -ldh /etc/mpd.conf /var/lib/mpd /var/lib/mpd/* /var/lib/mpd/*/*"]), 97 | ] 98 | outputs = [(title, subprocess.Popen(command, 99 | stdout=subprocess.PIPE, 100 | stderr=subprocess.PIPE 101 | ).communicate()) 102 | for title, command in commands] 103 | 104 | sections = [outputs[0], ("Networking", (netstats, "")), outputs[1]] 105 | text = '\n'.join(["%s\n%s%s\n" % 106 | (title, escape_html(stdout), escape_html(stderr)) 107 | for title, (stdout, stderr) in sections]) 108 | label.set_markup(text.decode(locale.getpreferredencoding(), 109 | 'replace')) 110 | 111 | # nothing magical here, this constructs the parts of the tab when called: 112 | def construct_tab(): 113 | vbox = gtk.VBox(spacing=2) 114 | vbox.props.border_width = 2 115 | buttonbox = gtk.HBox(spacing=2) 116 | editbutton = gtk.Button("Edit /etc/mpd.conf") 117 | editbutton.connect('clicked', lambda *args:subprocess.Popen( 118 | ["gksu", "gedit", "/etc/mpd.conf"])) 119 | buttonbox.pack_start(editbutton, False, False) 120 | restartbutton = gtk.Button("Restart the mpd service") 121 | restartbutton.connect('clicked', lambda *args:subprocess.Popen( 122 | ["gksu", "service", "mpd", "restart"])) 123 | buttonbox.pack_start(restartbutton, False, False) 124 | vbox.pack_start(buttonbox, False, False) 125 | label = gtk.Label() 126 | label.set_properties(xalign=0.0, xpad=5, yalign=0.0, ypad=5, 127 | selectable=True) 128 | vbox.pack_start(label, False, False) 129 | 130 | update(label) 131 | 132 | window = gtk.ScrolledWindow() 133 | window.set_properties(hscrollbar_policy=gtk.POLICY_AUTOMATIC, 134 | vscrollbar_policy=gtk.POLICY_AUTOMATIC) 135 | window.add_with_viewport(vbox) 136 | window.show_all() 137 | 138 | # (tab content, icon name, tab name, the widget to focus on tab switch) 139 | return (window, None, "Local MPD", None) 140 | -------------------------------------------------------------------------------- /sonata/dbus_plugin.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | This plugin implements D-Bus features for Sonata: 4 | 5 | * Check that only one instance of Sonata is running at a time 6 | * Allow other programs to request the info popup, and to show or to toggle 7 | the main window visibility 8 | * Listen to Gnome 2.18+ multimedia key events 9 | 10 | XXX Not a real plugin yet. 11 | 12 | Example usage: 13 | import dbus_plugin as dbus 14 | self.dbus_service = dbus.SonataDBus(self.dbus_show, self.dbus_toggle, self.dbus_popup) 15 | dbus.start_dbus_interface(toggle_arg, popup_arg) 16 | dbus.init_gnome_mediakeys(self.mpd_pp, self.mpd_stop, self.mpd_prev, self.mpd_next) 17 | if not dbus.using_gnome_mediakeys(): 18 | # do something else instead... 19 | """ 20 | 21 | import sys 22 | 23 | try: 24 | import dbus, dbus.service 25 | if getattr(dbus, "version", (0, 0, 0)) >= (0, 41, 0): 26 | import dbus.glib 27 | if getattr(dbus, "version", (0, 0, 0)) >= (0, 80, 0): 28 | import _dbus_bindings as dbus_bindings 29 | NEW_DBUS = True 30 | else: 31 | import dbus.dbus_bindings as dbus_bindings 32 | NEW_DBUS = False 33 | HAVE_DBUS = True 34 | except: 35 | HAVE_DBUS = False 36 | 37 | def using_dbus(): 38 | return HAVE_DBUS 39 | 40 | HAVE_GNOME_MMKEYS = False 41 | 42 | def using_gnome_mediakeys(): 43 | return HAVE_GNOME_MMKEYS 44 | 45 | def init_gnome_mediakeys(mpd_pp, mpd_stop, mpd_prev, mpd_next): 46 | global HAVE_GNOME_MMKEYS 47 | if HAVE_DBUS: 48 | try: 49 | bus = dbus.SessionBus() 50 | dbusObj = bus.get_object('org.freedesktop.DBus', '/org/freedesktop/DBus') 51 | dbusInterface = dbus.Interface(dbusObj, 'org.freedesktop.DBus') 52 | if dbusInterface.NameHasOwner('org.gnome.SettingsDaemon'): 53 | try: 54 | # mmkeys for gnome 2.22+ 55 | settingsDaemonObj = bus.get_object('org.gnome.SettingsDaemon', '/org/gnome/SettingsDaemon/MediaKeys') 56 | settingsDaemonInterface = dbus.Interface(settingsDaemonObj, 'org.gnome.SettingsDaemon.MediaKeys') 57 | settingsDaemonInterface.GrabMediaPlayerKeys('Sonata', 0) 58 | except: 59 | # mmkeys for gnome 2.18+ 60 | settingsDaemonObj = bus.get_object('org.gnome.SettingsDaemon', '/org/gnome/SettingsDaemon') 61 | settingsDaemonInterface = dbus.Interface(settingsDaemonObj, 'org.gnome.SettingsDaemon') 62 | settingsDaemonInterface.GrabMediaPlayerKeys('Sonata', 0) 63 | settingsDaemonInterface.connect_to_signal('MediaPlayerKeyPressed', lambda app, key:mediaPlayerKeysCallback(mpd_pp, mpd_stop, mpd_prev, mpd_next, app, key)) 64 | HAVE_GNOME_MMKEYS = True 65 | except: 66 | pass 67 | 68 | def mediaPlayerKeysCallback(mpd_pp, mpd_stop, mpd_prev, mpd_next, app, key): 69 | if app == 'Sonata': 70 | if key in ('Play', 'PlayPause', 'Pause'): 71 | mpd_pp(None) 72 | elif key == 'Stop': 73 | mpd_stop(None) 74 | elif key == 'Previous': 75 | mpd_prev(None) 76 | elif key == 'Next': 77 | mpd_next(None) 78 | 79 | def get_session_bus(): 80 | try: 81 | return dbus.SessionBus() 82 | except Exception: 83 | print _("Sonata failed to connect to the D-BUS session bus: Unable to determine the address of the message bus (try 'man dbus-launch' and 'man dbus-daemon' for help)") 84 | raise 85 | 86 | def execute_remote_commands(toggle=False, popup=False, start=False): 87 | try: 88 | bus = get_session_bus() 89 | obj = bus.get_object('org.MPD', '/org/MPD/Sonata') 90 | if toggle: 91 | obj.toggle(dbus_interface='org.MPD.SonataInterface') 92 | if popup: 93 | obj.popup(dbus_interface='org.MPD.SonataInterface') 94 | sys.exit() 95 | except Exception: 96 | print _("Failed to execute remote commands.") 97 | if start is None or start: 98 | print _("Starting Sonata instead...") 99 | else: 100 | print _("Maybe Sonata is not running?") 101 | sys.exit(1) 102 | 103 | def start_dbus_interface(): 104 | if HAVE_DBUS: 105 | try: 106 | bus = get_session_bus() 107 | if NEW_DBUS: 108 | retval = bus.request_name("org.MPD.Sonata", dbus_bindings.NAME_FLAG_DO_NOT_QUEUE) 109 | else: 110 | retval = dbus_bindings.bus_request_name(bus.get_connection(), "org.MPD.Sonata", dbus_bindings.NAME_FLAG_DO_NOT_QUEUE) 111 | if retval in (dbus_bindings.REQUEST_NAME_REPLY_PRIMARY_OWNER, dbus_bindings.REQUEST_NAME_REPLY_ALREADY_OWNER): 112 | pass 113 | elif retval in (dbus_bindings.REQUEST_NAME_REPLY_EXISTS, dbus_bindings.REQUEST_NAME_REPLY_IN_QUEUE): 114 | print _("An instance of Sonata is already running. Showing it...") 115 | try: 116 | obj = bus.get_object('org.MPD', '/org/MPD/Sonata') 117 | obj.show(dbus_interface='org.MPD.SonataInterface') 118 | sys.exit() 119 | except Exception: 120 | print _("Failed to execute remote command.") 121 | sys.exit(1) 122 | except Exception: 123 | pass 124 | except SystemExit: 125 | raise 126 | 127 | if HAVE_DBUS: 128 | class SonataDBus(dbus.service.Object): 129 | def __init__(self, dbus_show, dbus_toggle, dbus_popup): 130 | self.dbus_show = dbus_show 131 | self.dbus_toggle = dbus_toggle 132 | self.dbus_popup = dbus_popup 133 | session_bus = get_session_bus() 134 | bus_name = dbus.service.BusName('org.MPD', bus=session_bus) 135 | object_path = '/org/MPD/Sonata' 136 | dbus.service.Object.__init__(self, bus_name, object_path) 137 | 138 | @dbus.service.method('org.MPD.SonataInterface') 139 | def show(self): 140 | self.dbus_show() 141 | 142 | @dbus.service.method('org.MPD.SonataInterface') 143 | def toggle(self): 144 | self.dbus_toggle() 145 | 146 | @dbus.service.method('org.MPD.SonataInterface') 147 | def popup(self): 148 | self.dbus_popup() 149 | -------------------------------------------------------------------------------- /sonata/formatting.py: -------------------------------------------------------------------------------- 1 | 2 | """This module implements the format strings used to display song info. 3 | 4 | Example usage: 5 | import formatting 6 | colnames = formatting.parse_colnames(self.config.currentformat) 7 | ... 8 | newtitle = formatting.parse(self.config.titleformat, self.songinfo, False, True) 9 | ... 10 | formatcodes = formatting.formatcodes 11 | """ 12 | 13 | import mpdhelper as mpdh 14 | import misc 15 | import re 16 | import os 17 | 18 | class FormatCode(object): 19 | """Implements deafult format code behavior. 20 | 21 | Replaces all instances of %code with the value of key or default if the 22 | key doesn't exist. 23 | """ 24 | def __init__(self, code, description, column, key, 25 | default=_("Unknown")): 26 | self.code = code 27 | self.description = description 28 | self.column = column 29 | self.key = key 30 | self.default = default 31 | 32 | def format(self, item, wintitle, songpos): 33 | """Returns the value used in place of the format code""" 34 | return mpdh.get(item, self.key, self.default) 35 | 36 | class NumFormatCode(FormatCode): 37 | """Implements format code behavior for numeric values. 38 | 39 | Used for numbers which need special padding. 40 | """ 41 | def __init__(self, code, description, column, key, default, padding): 42 | FormatCode.__init__(self, code, description, column, key, 43 | default) 44 | self.padding = padding 45 | 46 | def format(self, item, wintitle, songpos): 47 | return mpdh.get(item, self.key, self.default, False, 48 | self.padding) 49 | 50 | class PathFormatCode(FormatCode): 51 | """Implements format code behavior for path values.""" 52 | def __init__(self, code, description, column, key, path_func): 53 | """ 54 | 55 | path_func: os.path function to apply 56 | """ 57 | FormatCode.__init__(self, code, description, column, key) 58 | self.func = getattr(os.path, path_func) 59 | 60 | def format(self, item, wintitle, songpos): 61 | return self.func(FormatCode.format(self, item, wintitle, 62 | songpos)) 63 | 64 | 65 | class TitleFormatCode(FormatCode): 66 | """Implements format code behavior for track titles.""" 67 | def format(self, item, wintitle, songpos): 68 | path = item['file'] 69 | full_path = re.match(r"^(http://|ftp://)", path) 70 | self.default = path if full_path else os.path.basename(path) 71 | self.default = misc.escape_html(self.default) 72 | return FormatCode.format(self, item, wintitle, songpos) 73 | 74 | class LenFormatCode(FormatCode): 75 | """Implements format code behavior for song length.""" 76 | def format(self, item, wintitle, songpos): 77 | time = FormatCode.format(self, item, wintitle, songpos) 78 | if time.isdigit(): 79 | time = misc.convert_time(int(time)) 80 | return time 81 | 82 | class ElapsedFormatCode(FormatCode): 83 | """Implements format code behavior for elapsed time.""" 84 | def format(self, item, wintitle, songpos): 85 | if not wintitle: 86 | return "%E" 87 | elapsed_time = songpos.split(':')[0] if songpos else self.default 88 | if elapsed_time.isdigit(): 89 | elapsed_time = misc.convert_time(int(elapsed_time)) 90 | return elapsed_time 91 | 92 | formatcodes = [FormatCode('A', _('Artist name'), _("Artist"), 'artist'), 93 | FormatCode('B', _('Album name'), _("Album"), 'album'), 94 | TitleFormatCode('T', _('Track name'), _("Track"), 'title'), 95 | NumFormatCode('N', _('Track number'), _("#"), 'track', '00', 2), 96 | NumFormatCode('D', _('Disc number'), _("#"), 'disc', '0', 0), 97 | FormatCode('Y', _('Year'), _("Year"), 'date', '?'), 98 | FormatCode('G', _('Genre'), _("Genre"), 'genre'), 99 | PathFormatCode('P', _('File path'), _("Path"), 'file', 100 | 'dirname'), 101 | PathFormatCode('F', _('File name'), _("File"), 'file', 102 | 'basename'), 103 | FormatCode('S', _('Stream name'), _("Stream"), 'name'), 104 | LenFormatCode('L', _('Song length'), _("Len"), 'time', '?'), 105 | ElapsedFormatCode('E', _('Elapsed time (title only)'), None, 106 | 'songpos', '?') 107 | ] 108 | 109 | replace_map = dict((code.code, code) for code in formatcodes) 110 | replace_expr = r"%%[%s]" % "".join(k for k in replace_map.keys()) 111 | 112 | def _return_substrings(format): 113 | """Split format along the { and } characters. 114 | 115 | For example: %A{-%T} {%L} -> ['%A', '{-%T} ', '{%L}']""" 116 | substrings = [] 117 | end = format 118 | while len(end) > 0: 119 | begin, sep1, end = end.partition('{') 120 | substrings.append(begin) 121 | if len(end) == 0: 122 | substrings.append(sep1) 123 | break 124 | begin, sep2, end = end.partition('}') 125 | substrings.append(sep1 + begin + sep2) 126 | return substrings 127 | 128 | def parse_colnames(format): 129 | def replace_format(m): 130 | format_code = replace_map.get(m.group(0)[1:]) 131 | return format_code.column 132 | 133 | cols = [re.sub(replace_expr, replace_format, s). 134 | replace("{", ""). 135 | replace("}", ""). 136 | # If the user wants the format of, e.g., "#%N", we'll 137 | # ensure the # doesn't show up twice in a row. 138 | replace("##", "#") 139 | for s in format.split('|')] 140 | return cols 141 | 142 | class EmptyBrackets(Exception): 143 | pass 144 | 145 | def _format_substrings(text, item, wintitle, songpos): 146 | has_brackets = text.startswith("{") and text.endswith("}") 147 | 148 | def formatter(m): 149 | format_code = replace_map[m.group(0)[1:]] 150 | if has_brackets and not item.has_key(format_code.key): 151 | raise EmptyBrackets 152 | return format_code.format(item, wintitle, songpos) 153 | 154 | try: 155 | text = re.sub(replace_expr, formatter, text) 156 | except EmptyBrackets: 157 | return "" 158 | 159 | return text[1:-1] if has_brackets else text 160 | 161 | def parse(format, item, use_escape_html, wintitle=False, songpos=None): 162 | substrings = _return_substrings(format) 163 | text = "".join(_format_substrings(sub, item, wintitle, songpos) 164 | for sub in substrings) 165 | return misc.escape_html(text) if use_escape_html else text 166 | -------------------------------------------------------------------------------- /sonata/pluginsystem.py: -------------------------------------------------------------------------------- 1 | 2 | import os, re, StringIO 3 | import ConfigParser, pkgutil 4 | 5 | import sonata.plugins 6 | 7 | def find_plugin_dirs(): 8 | return [os.path.expanduser('~/.sonata/plugins'), 9 | '/usr/local/lib/sonata/plugins'] 10 | 11 | # add dirs from sys.path: 12 | sonata.plugins.__path__ = pkgutil.extend_path(sonata.plugins.__path__, 13 | sonata.plugins.__name__) 14 | # add dirs specific to sonata: 15 | sonata.plugins.__path__ = find_plugin_dirs() + sonata.plugins.__path__ 16 | 17 | 18 | class Plugin(object): 19 | def __init__(self, path, name, info, load): 20 | self.path = path 21 | self.name = name 22 | self._info = info 23 | self._load = load 24 | # obligatory plugin info: 25 | format_value = info.get('plugin', 'plugin_format') 26 | self.plugin_format = tuple(map(int, format_value.split(','))) 27 | self.longname = info.get('plugin', 'name') 28 | versionvalue = info.get('plugin', 'version') 29 | self.version = tuple(map(int, versionvalue.split(','))) 30 | self.version_string = '.'.join(map(str, self.version)) 31 | self._capabilities = dict(info.items('capabilities')) 32 | # optional plugin info: 33 | try: 34 | self.description = info.get('plugin', 'description') 35 | except ConfigParser.NoOptionError: 36 | self.description = "" 37 | try: 38 | self.author = info.get('plugin', 'author') 39 | except: 40 | self.author = "" 41 | try: 42 | self.author_email = info.get('plugin', 'author_email') 43 | except: 44 | self.author_email = "" 45 | try: 46 | self.iconurl = info.get('plugin', 'icon') 47 | except ConfigParser.NoOptionError: 48 | self.iconurl = None 49 | try: 50 | self.url = info.get('plugin', 'url') 51 | except: 52 | self.url = "" 53 | # state: 54 | self._module = None # lazy loading 55 | self._enabled = False 56 | 57 | def get_enabled(self): 58 | return self._enabled 59 | 60 | def get_features(self, capability): 61 | if not self._enabled or not capability in self._capabilities: 62 | return [] 63 | 64 | module = self._get_module() 65 | if not module: 66 | return [] 67 | 68 | features = self._capabilities[capability] 69 | try: 70 | return [self.get_feature(module, f) 71 | for f in features.split(', ')] 72 | except KeyboardInterrupt: 73 | raise 74 | except "Exception": 75 | print ("Failed to access features in plugin %s." % 76 | self.name) 77 | return [] 78 | 79 | def get_feature(self, module, feature): 80 | obj = module 81 | for name in feature.split('.'): 82 | obj = getattr(obj, name) 83 | return obj 84 | 85 | def _get_module(self): 86 | if not self._module: 87 | try: 88 | self._module = self._load() 89 | except Exception: 90 | print "Failed to load plugin %s." % self.name 91 | return None 92 | return self._module 93 | 94 | def force_loaded(self): 95 | return bool(self._get_module()) 96 | 97 | 98 | class BuiltinPlugin(Plugin): 99 | def __init__(self, name, longname, description, capabilities, object): 100 | self.name = name 101 | self.longname = longname 102 | self.description = description 103 | self._capabilities = capabilities 104 | self._module = object 105 | self.version_string = "Built-in" 106 | self.author = self.author_email = self.url = "" 107 | self.iconurl = None 108 | self._enabled = False 109 | 110 | def _get_module(self): 111 | return self._module 112 | 113 | 114 | class PluginSystem(object): 115 | def __init__(self): 116 | self.plugin_infos = [] 117 | self.notifys = [] 118 | 119 | def get_info(self): 120 | return self.plugin_infos 121 | 122 | def get(self, capability): 123 | return [(plugin, feature) 124 | for plugin in self.plugin_infos 125 | for feature in plugin.get_features(capability)] 126 | 127 | def notify_of(self, capability, enable_cb, disable_cb): 128 | self.notifys.append((capability, enable_cb, disable_cb)) 129 | for plugin, feature in self.get(capability): 130 | enable_cb(plugin, feature) 131 | 132 | def set_enabled(self, plugin, state): 133 | if plugin._enabled != state: 134 | # make notify callbacks for each feature of the plugin: 135 | plugin._enabled = True # XXX for plugin.get_features 136 | 137 | # process the notifys in the order they were registered: 138 | order = (lambda x:x) if state else reversed 139 | for capability, enable, disable in order(self.notifys): 140 | callback = enable if state else disable 141 | for feature in plugin.get_features(capability): 142 | callback(plugin, feature) 143 | plugin._enabled = state 144 | 145 | def find_plugins(self): 146 | for path in sonata.plugins.__path__: 147 | if not os.path.isdir(path): 148 | continue 149 | for entry in os.listdir(path): 150 | if entry.startswith('_'): 151 | continue # __init__.py etc. 152 | if entry.endswith('.py'): 153 | try: 154 | self.load_info(path, entry[:-3]) 155 | except Exception: 156 | print "Failed to load info:", 157 | print os.path.join(path, entry) 158 | 159 | def load_info(self, path, name): 160 | f = open(os.path.join(path, name+".py"), "rt") 161 | text = f.read() 162 | f.close() 163 | 164 | pat = r'^### BEGIN PLUGIN INFO.*((\n#.*)*)\n### END PLUGIN INFO' 165 | infotext = re.search(pat, text, re.MULTILINE).group(1) 166 | uncommented = '\n'.join(line[1:].strip() 167 | for line in infotext.split('\n')) 168 | info = ConfigParser.SafeConfigParser() 169 | info.readfp(StringIO.StringIO(uncommented)) 170 | 171 | # XXX add only newest version of each name 172 | plugin = Plugin(path, name, info, 173 | lambda:self.import_plugin(name)) 174 | 175 | self.plugin_infos.append(plugin) 176 | 177 | if not info.options('capabilities'): 178 | print "Warning: No capabilities in plugin %s." % name 179 | 180 | def import_plugin(self, name): 181 | # XXX load from a .py file - no .pyc etc. 182 | __import__('sonata.plugins', {}, {}, [name], 0) 183 | plugin = getattr(sonata.plugins, name) 184 | return plugin 185 | 186 | pluginsystem = PluginSystem() 187 | -------------------------------------------------------------------------------- /sonata/breadcrumbs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import gtk 4 | 5 | class CrumbButton(gtk.ToggleButton): 6 | """A ToggleButton tailored for use as a breadcrumb.""" 7 | def __init__(self, image, label): 8 | gtk.ToggleButton.__init__(self) 9 | 10 | self.set_properties(can_focus=False, relief=gtk.RELIEF_NONE) 11 | 12 | # adapt gtk.Button internal layout code: 13 | hbox = gtk.HBox(spacing=2) 14 | align = gtk.Alignment(xalign=0.5, yalign=0.5) 15 | hbox.pack_start(image, False, False) 16 | hbox.pack_start(label, True, True) 17 | align.add(hbox) 18 | 19 | self.add(align) 20 | 21 | class CrumbBox(gtk.Box): 22 | """A box layout similar to gtk.HBox, but specifically for breadcrumbs. 23 | 24 | * Crumbs in the middle are replaced with an ellipsis if necessary. 25 | * The root, parent, and current element are always kept visible. 26 | * The root and parent are put in a condensed form if necessary. 27 | * The current element is truncated if necessary. 28 | """ 29 | __gtype_name__ = 'CrumbBox' 30 | def __init__(self, *args, **kwargs): 31 | gtk.Box.__init__(self, *args, **kwargs) 32 | 33 | # FIXME i can't get an internal child ellipsis to render... 34 | # gtk.widget_push_composite_child() 35 | # self.ellipsis = gtk.Label("...") 36 | # self.ellipsis.props.visible = True 37 | # gtk.widget_pop_composite_child() 38 | # self.ellipsis.set_parent(self) 39 | 40 | def do_size_request(self, requisition): 41 | """This gets called to determine the size we request""" 42 | # ellipsis_req = self.ellipsis.size_request() 43 | reqs = [w.size_request() for w in self] 44 | 45 | # This would request "natural" size: 46 | # pad = 0 if not reqs else (len(reqs)-1)*self.props.spacing 47 | # requisition.width = sum( [r[0] for r in reqs]) + pad 48 | # requisition.height = max([0]+[r[1] for r in reqs]) 49 | # return 50 | 51 | # Request "minimum" size: 52 | 53 | height = max([0]+[r[1] for r in reqs]) 54 | 55 | if len(reqs) == 0: # empty 56 | width = 0 57 | elif len(reqs) < 3: # current crumb 58 | width = height 59 | elif len(reqs) == 3: # parent and current 60 | width = height + height + self.props.spacing 61 | elif len(reqs) == 4: # root, parent and current 62 | width = height + height + height + 2*self.props.spacing 63 | elif len(reqs) > 4: # root, ellipsis, parent, current 64 | pad = 3*self.props.spacing 65 | width = height + reqs[1][0] + height + height + pad 66 | 67 | requisition.width = width 68 | requisition.height = height 69 | 70 | def _req_sum(self, reqs): 71 | pad = 0 if not reqs else (len(reqs)-1)*self.props.spacing 72 | return pad+sum([req[0] for req in reqs]) 73 | 74 | def _condense(self, req, w): 75 | # FIXME show and hide cause a fight in an infinite loop 76 | # try: 77 | # w.get_child().get_child().get_children()[1].hide() 78 | # except (AttributeError, IndexError): 79 | # pass 80 | wr, hr = req 81 | return (hr, hr) # XXX simplistic: set square size for now 82 | 83 | def _uncondense(self, w): 84 | # try: 85 | # w.get_child().get_child().get_children()[1].show() 86 | # except (AttributeError, IndexError): 87 | pass 88 | 89 | def _truncate(self, req, amount): 90 | wr, hr = req 91 | return (wr-amount, hr) # XXX this can be less than hr, even <0 92 | 93 | def do_size_allocate(self, allocation): 94 | """This gets called to layout our child widgets""" 95 | x0, y0 = allocation.x, allocation.y 96 | w0, h0 = allocation.width, allocation.height 97 | 98 | crumbs = self.get_children() 99 | 100 | if len(crumbs) < 2: 101 | return 102 | 103 | # FIXME: 104 | self.ellipsis = crumbs.pop(1) 105 | hidden = [self.ellipsis] 106 | 107 | # Undo any earlier condensing 108 | if len(crumbs) > 0: 109 | self._uncondense(crumbs[0]) 110 | if len(crumbs) > 1: 111 | self._uncondense(crumbs[-2]) 112 | 113 | reqs = [w.get_child_requisition() for w in crumbs] 114 | 115 | # If necessary, condense the root crumb 116 | if self._req_sum(reqs) > w0 and len(crumbs) > 2: 117 | reqs[0] = self._condense(reqs[0], crumbs[0]) 118 | 119 | # If necessary, replace an increasing amount of the 120 | # crumbs after the root with the ellipsis 121 | while self._req_sum(reqs) > w0: 122 | if self.ellipsis in hidden and len(crumbs) > 3: 123 | hidden = [crumbs.pop(1)] 124 | reqs.pop(1) 125 | crumbs.insert(1, self.ellipsis) 126 | req = self.ellipsis.get_child_requisition() 127 | reqs.insert(1, req) 128 | elif self.ellipsis in crumbs and len(crumbs) > 4: 129 | hidden.append(crumbs.pop(2)) 130 | reqs.pop(2) 131 | else: 132 | break # don't remove the parent 133 | 134 | # If necessary, condense the parent crumb 135 | if self._req_sum(reqs) > w0 and len(crumbs) > 1: 136 | reqs[-2] = self._condense(reqs[-2], crumbs[-2]) 137 | 138 | # If necessary, truncate the current crumb 139 | if self._req_sum(reqs) > w0: 140 | reqs[-1] = self._truncate(reqs[-1], 141 | self._req_sum(reqs)-w0) 142 | # Now we are at minimum width 143 | 144 | x = 0 145 | for w, req in zip(crumbs, reqs): 146 | wr, _hr = req 147 | w.size_allocate(gtk.gdk.Rectangle(x0+x, y0, wr, h0)) 148 | w.show() 149 | x += wr + self.props.spacing 150 | 151 | for w in hidden: 152 | w.size_allocate(gtk.gdk.Rectangle(-1, -1, 0, 0)) 153 | w.hide() 154 | 155 | # def do_forall(self, internal, callback, data): 156 | # callback(self.ellipsis, data) 157 | # for w in self.get_children(): 158 | # callback(w, data) 159 | 160 | # this file can be run as a simple test program: 161 | if __name__ == '__main__': 162 | w = gtk.Window() 163 | crumbs = CrumbBox(spacing=2) 164 | 165 | items = [ 166 | (gtk.STOCK_HARDDISK, "Filesystem"), 167 | (None, None), # XXX for ellipsis 168 | (gtk.STOCK_OPEN, "home"), 169 | (gtk.STOCK_OPEN, "user"), 170 | (gtk.STOCK_OPEN, "music"), 171 | (gtk.STOCK_OPEN, "genre"), 172 | (gtk.STOCK_OPEN, "artist"), 173 | (gtk.STOCK_OPEN, "album"), 174 | ] 175 | for stock, text in items: 176 | if stock: 177 | image = gtk.image_new_from_stock(stock, 178 | gtk.ICON_SIZE_MENU) 179 | crumbs.pack_start(CrumbButton(image, gtk.Label(text))) 180 | else: 181 | crumbs.pack_start(gtk.Label("...")) 182 | 183 | w.add(crumbs) 184 | w.connect('hide', gtk.main_quit) 185 | w.show_all() 186 | 187 | gtk.main() 188 | -------------------------------------------------------------------------------- /sonata/misc.py: -------------------------------------------------------------------------------- 1 | 2 | import os, subprocess, re, locale, sys 3 | 4 | 5 | def convert_time(raw): 6 | # Converts raw time to 'hh:mm:ss' with leading zeros as appropriate 7 | h, m, s = ['%02d' % c for c in (raw/3600, (raw%3600)/60, raw%60)] 8 | if h == '00': 9 | if m.startswith('0'): 10 | m = m[1:] 11 | return m + ':' + s 12 | else: 13 | if h.startswith('0'): 14 | h = h[1:] 15 | return h + ':' + m + ':' + s 16 | 17 | def bold(s): 18 | if not (str(s).startswith('') and str(s).endswith('')): 19 | return '%s' % s 20 | else: 21 | return s 22 | 23 | def unbold(s): 24 | if str(s).startswith('') and str(s).endswith(''): 25 | return s[3:-4] 26 | else: 27 | return s 28 | 29 | def escape_html(s): 30 | # & needs to be escaped first, before more are introduced: 31 | s = s.replace('&', '&') 32 | s = s.replace('<', '<') 33 | s = s.replace('>', '>') 34 | s = s.replace('"', '"') 35 | return s 36 | 37 | def unescape_html(s): 38 | s = s.replace('<', '<') 39 | s = s.replace('>', '>') 40 | s = s.replace('"', '"') 41 | s = s.replace(' ', ' ') 42 | # & needs to be unescaped last, so it can't get unescaped twice 43 | s = s.replace('&', '&') 44 | # FIXME why did we have this too? s = s.replace('amp;', '&') 45 | return s 46 | 47 | # XXX Should we depend on a library to do this or get html from the services? 48 | def wiki_to_html(s): 49 | s = re.sub(r"'''(.*?)'''", r"\1", s) 50 | s = re.sub(r"''(.*?)''", r"\1", s) 51 | return s 52 | 53 | def strip_all_slashes(s): 54 | s = s.replace("\\", "") 55 | s = s.replace("/", "") 56 | s = s.replace("\"", "") 57 | return s 58 | 59 | def _rmgeneric(path, __func__): 60 | try: 61 | __func__(path) 62 | except OSError: 63 | pass 64 | 65 | def is_binary(f): 66 | if '\0' in f: # found null byte 67 | return True 68 | return False 69 | 70 | def link_markup(s, enclose_in_parentheses, small, linkcolor): 71 | if enclose_in_parentheses: 72 | s = "(%s)" % s 73 | if small: 74 | s = "%s" % s 75 | if linkcolor: 76 | color = linkcolor 77 | else: 78 | color = "blue" #no theme color, default to blue.. 79 | s = "%s" % (color, s) 80 | return s 81 | 82 | def iunique(iterable, key=id): 83 | seen = set() 84 | for i in iterable: 85 | if key(i) not in seen: 86 | seen.add(key(i)) 87 | yield i 88 | 89 | def remove_list_duplicates(inputlist, case=True): 90 | # Note that we can't use list(set(inputlist)) 91 | # because we want the inputlist order preserved. 92 | if case: 93 | key = lambda x:x 94 | else: 95 | # repr() allows inputlist to be a list of tuples 96 | # FIXME: Doesn't correctly compare uppercase and 97 | # lowercase unicode 98 | key = lambda x:repr(x).lower() 99 | return list(iunique(inputlist, key)) 100 | 101 | the_re = re.compile('^the ') 102 | def lower_no_the(s): 103 | s = unicode(s) 104 | s = the_re.sub('', s.lower()) 105 | s = str(s) 106 | return s 107 | 108 | def create_dir(dirname): 109 | if not os.path.exists(os.path.expanduser(dirname)): 110 | try: 111 | os.makedirs(os.path.expanduser(dirname)) 112 | except (IOError, OSError): 113 | pass 114 | 115 | def remove_file(filename): 116 | if os.path.exists(filename): 117 | try: 118 | os.remove(filename) 119 | except: 120 | pass 121 | 122 | def remove_dir_recursive(path): 123 | if not os.path.isdir(path): 124 | return 125 | 126 | files = os.listdir(path) 127 | 128 | for x in files: 129 | fullpath = os.path.join(path, x) 130 | if os.path.isfile(fullpath): 131 | f = os.remove 132 | _rmgeneric(fullpath, f) 133 | elif os.path.isdir(fullpath): 134 | remove_dir_recursive(fullpath) 135 | f = os.rmdir 136 | _rmgeneric(fullpath, f) 137 | 138 | def file_exists_insensitive(filename): 139 | # Returns an updated filename that exists on the 140 | # user's filesystem; checks all possible combinations 141 | # of case. 142 | if os.path.exists(filename): 143 | return filename 144 | 145 | regexp = re.compile(re.escape(filename), re.IGNORECASE) 146 | 147 | path = os.path.dirname(filename) 148 | 149 | try: 150 | files = os.listdir(path) 151 | except OSError: 152 | return filename 153 | 154 | for x in files: 155 | fullpath = os.path.join(path, x) 156 | if regexp.match(fullpath): 157 | return fullpath 158 | 159 | return filename 160 | 161 | def browser_load(docslink, browser, window): 162 | if browser and browser.strip(): 163 | browsers = [browser.strip()] 164 | else: 165 | browsers = ["gnome-open", # default, we are a "gnome" app 166 | "x-www-browser", # default on Debian-based systems 167 | "exo-open", 168 | "kfmclient openURL", 169 | "firefox", 170 | "mozilla", 171 | "opera", 172 | "chromium-browser"] 173 | for browser in browsers: 174 | try: 175 | subprocess.Popen(browser.split()+[docslink]) 176 | break # done 177 | except OSError: 178 | pass # try next 179 | else: # none worked 180 | return False 181 | return True 182 | 183 | def file_from_utf8(filename): 184 | import gobject 185 | try: 186 | return gobject.filename_from_utf8(filename) 187 | except: 188 | return filename 189 | 190 | def is_lang_rtl(window): 191 | import pango 192 | # Check if a RTL (right-to-left) language: 193 | return window.get_pango_context().get_base_dir() == pango.DIRECTION_RTL 194 | 195 | def sanitize_musicdir(mdir): 196 | return os.path.expanduser(mdir) if mdir else '' 197 | 198 | def mpd_env_vars(): 199 | host = None 200 | port = None 201 | password = None 202 | if 'MPD_HOST' in os.environ: 203 | if '@' in os.environ['MPD_HOST']: 204 | password, host = os.environ['MPD_HOST'].split('@') 205 | else: 206 | host = os.environ['MPD_HOST'] 207 | if 'MPD_PORT' in os.environ: 208 | port = int(os.environ['MPD_PORT']) 209 | return (host, port, password) 210 | 211 | def get_files_recursively(dirname): 212 | filenames = [] 213 | os.path.walk(dirname, _get_files_recursively, filenames) 214 | return filenames 215 | 216 | def _get_files_recursively(filenames, dirname, files): 217 | filenames.extend([os.path.join(dirname, f) for f in files]) 218 | 219 | def setlocale(): 220 | try: 221 | locale.setlocale(locale.LC_ALL, "") 222 | # XXX this makes python-mpd correctly return lowercase 223 | # keys for, e.g., playlistinfo() with a turkish locale: 224 | locale.setlocale(locale.LC_CTYPE, "C") 225 | except: 226 | print "Failed to set locale" 227 | sys.exit(1) 228 | -------------------------------------------------------------------------------- /sonata/streams.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | This module implements a user interface for bookmarking remote music 4 | streams. 5 | 6 | Example usage: 7 | import streams 8 | self.streams = streams.Streams(self.config, self.window, self.on_streams_button_press, self.on_add_item, self.settings_save, self.TAB_STREAMS) 9 | streamswindow, streamsevbox = self.streams.get_widgets() 10 | ... 11 | self.streams.populate() 12 | ... 13 | """ 14 | 15 | import gtk, pango 16 | 17 | import misc, ui 18 | 19 | from pluginsystem import pluginsystem, BuiltinPlugin 20 | 21 | class Streams(object): 22 | def __init__(self, config, window, on_streams_button_press, on_add_item, settings_save, TAB_STREAMS): 23 | self.config = config 24 | self.window = window 25 | self.on_streams_button_press = on_streams_button_press 26 | self.on_add_item = on_add_item 27 | self.settings_save = settings_save 28 | 29 | # Streams tab 30 | self.streams = ui.treeview() 31 | self.streams_selection = self.streams.get_selection() 32 | self.streamswindow = ui.scrollwindow(add=self.streams) 33 | 34 | self.tab = (self.streamswindow, gtk.STOCK_NETWORK, TAB_STREAMS, self.streams) 35 | 36 | self.streams.connect('button_press_event', self.on_streams_button_press) 37 | self.streams.connect('row_activated', self.on_streams_activated) 38 | self.streams.connect('key-press-event', self.on_streams_key_press) 39 | 40 | # Initialize streams data and widget 41 | self.streamsdata = gtk.ListStore(str, str, str) 42 | self.streams.set_model(self.streamsdata) 43 | self.streams.set_search_column(1) 44 | self.streamsimg = gtk.CellRendererPixbuf() 45 | self.streamscell = gtk.CellRendererText() 46 | self.streamscell.set_property("ellipsize", pango.ELLIPSIZE_END) 47 | self.streamscolumn = gtk.TreeViewColumn() 48 | self.streamscolumn.pack_start(self.streamsimg, False) 49 | self.streamscolumn.pack_start(self.streamscell, True) 50 | self.streamscolumn.set_attributes(self.streamsimg, stock_id=0) 51 | self.streamscolumn.set_attributes(self.streamscell, markup=1) 52 | self.streams.append_column(self.streamscolumn) 53 | self.streams_selection.set_mode(gtk.SELECTION_MULTIPLE) 54 | 55 | pluginsystem.plugin_infos.append(BuiltinPlugin( 56 | 'streams', "Streams", "A tab for streams.", 57 | {'tabs': 'construct_tab'}, self)) 58 | 59 | def construct_tab(self): 60 | self.streamswindow.show_all() 61 | return self.tab 62 | 63 | def get_model(self): 64 | return self.streamsdata 65 | 66 | def get_widgets(self): 67 | return self.streamswindow 68 | 69 | def get_treeview(self): 70 | return self.streams 71 | 72 | def get_selection(self): 73 | return self.streams_selection 74 | 75 | def populate(self): 76 | self.streamsdata.clear() 77 | streamsinfo = [{'name' : misc.escape_html(name), 78 | 'uri' : misc.escape_html(uri)} 79 | for name, uri in zip(self.config.stream_names, 80 | self.config.stream_uris)] 81 | streamsinfo.sort(key=lambda x: x["name"].lower()) # Remove case sensitivity 82 | for item in streamsinfo: 83 | self.streamsdata.append([gtk.STOCK_NETWORK, item["name"], item["uri"]]) 84 | 85 | def on_streams_key_press(self, widget, event): 86 | if event.keyval == gtk.gdk.keyval_from_name('Return'): 87 | self.on_streams_activated(widget, widget.get_cursor()[0]) 88 | return True 89 | 90 | def on_streams_activated(self, _treeview, _path, _column=0): 91 | self.on_add_item(None) 92 | 93 | def on_streams_edit(self, action): 94 | model, selected = self.streams_selection.get_selected_rows() 95 | try: 96 | streamname = misc.unescape_html(model.get_value(model.get_iter(selected[0]), 1)) 97 | for i, name in enumerate(self.config.stream_names): 98 | if name == streamname: 99 | self.on_streams_new(action, i) 100 | return 101 | except: 102 | pass 103 | 104 | def on_streams_new(self, _action, stream_num=-1): 105 | if stream_num > -1: 106 | edit_mode = True 107 | else: 108 | edit_mode = False 109 | # Prompt user for playlist name: 110 | dialog = ui.dialog(title=None, parent=self.window, flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_OK, gtk.RESPONSE_ACCEPT), role="streamsNew") 111 | if edit_mode: 112 | dialog.set_title(_("Edit Stream")) 113 | else: 114 | dialog.set_title(_("New Stream")) 115 | hbox = gtk.HBox() 116 | namelabel = ui.label(text=_('Stream name:')) 117 | hbox.pack_start(namelabel, False, False, 5) 118 | nameentry = ui.entry() 119 | if edit_mode: 120 | nameentry.set_text(self.config.stream_names[stream_num]) 121 | hbox.pack_start(nameentry, True, True, 5) 122 | hbox2 = gtk.HBox() 123 | urllabel = ui.label(text=_('Stream URL:')) 124 | hbox2.pack_start(urllabel, False, False, 5) 125 | urlentry = ui.entry() 126 | if edit_mode: 127 | urlentry.set_text(self.config.stream_uris[stream_num]) 128 | hbox2.pack_start(urlentry, True, True, 5) 129 | ui.set_widths_equal([namelabel, urllabel]) 130 | dialog.vbox.pack_start(hbox) 131 | dialog.vbox.pack_start(hbox2) 132 | ui.show(dialog.vbox) 133 | response = dialog.run() 134 | if response == gtk.RESPONSE_ACCEPT: 135 | name = nameentry.get_text() 136 | uri = urlentry.get_text() 137 | if len(name.decode('utf-8')) > 0 and len(uri.decode('utf-8')) > 0: 138 | # Make sure this stream name doesn't already exit: 139 | i = 0 140 | for item in self.config.stream_names: 141 | # Prevent a name collision in edit_mode.. 142 | if not edit_mode or (edit_mode and i != stream_num): 143 | if item == name: 144 | dialog.destroy() 145 | if ui.show_msg(self.window, _("A stream with this name already exists. Would you like to replace it?"), _("New Stream"), 'newStreamError', gtk.BUTTONS_YES_NO) == gtk.RESPONSE_YES: 146 | # Pop existing stream: 147 | self.config.stream_names.pop(i) 148 | self.config.stream_uris.pop(i) 149 | else: 150 | return 151 | i = i + 1 152 | if edit_mode: 153 | self.config.stream_names.pop(stream_num) 154 | self.config.stream_uris.pop(stream_num) 155 | self.config.stream_names.append(name) 156 | self.config.stream_uris.append(uri) 157 | self.populate() 158 | self.settings_save() 159 | dialog.destroy() 160 | -------------------------------------------------------------------------------- /mmkeys/mmkeys.c: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2004 Lee Willis 3 | * Borrowed heavily from code by Jan Arne Petersen 4 | * 5 | * This program is free software; you can redistribute it and/or 6 | * modify it under the terms of the GNU General Public License as 7 | * published by the Free Software Foundation; either version 2 of the 8 | * License, or (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 13 | * General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public 16 | * License along with this program; if not, write to the 17 | * Free Software Foundation, Inc., 59 Temple Place - Suite 330, 18 | * Boston, MA 02111-1307, USA. 19 | */ 20 | 21 | #include 22 | 23 | #include "mmkeys.h" 24 | 25 | static void mmkeys_class_init (MmKeysClass *klass); 26 | static void mmkeys_init (MmKeys *object); 27 | static void mmkeys_finalize (GObject *object); 28 | 29 | static void grab_mmkey (int key_code, GdkWindow *root); 30 | 31 | static GdkFilterReturn filter_mmkeys (GdkXEvent *xevent, 32 | GdkEvent *event, 33 | gpointer data); 34 | 35 | enum { 36 | MM_PLAYPAUSE, 37 | MM_NEXT, 38 | MM_PREV, 39 | MM_STOP, 40 | LAST_SIGNAL 41 | }; 42 | 43 | static GObjectClass *parent_class; 44 | static guint signals[LAST_SIGNAL]; 45 | 46 | static GType type = 0; 47 | 48 | GType 49 | mmkeys_get_type (void) 50 | { 51 | if (!type) { 52 | static const GTypeInfo info = { 53 | sizeof (MmKeysClass), 54 | NULL, /* base_init */ 55 | NULL, /* base_finalize */ 56 | (GClassInitFunc) mmkeys_class_init, 57 | NULL, /* class_finalize */ 58 | NULL, /* class_data */ 59 | sizeof (MmKeys), 60 | 0, 61 | (GInstanceInitFunc) mmkeys_init, 62 | }; 63 | 64 | type = g_type_register_static (G_TYPE_OBJECT, "MmKeys", 65 | &info, 0); 66 | } 67 | 68 | return type; 69 | } 70 | 71 | static void 72 | mmkeys_class_init (MmKeysClass *klass) 73 | { 74 | GObjectClass *object_class; 75 | 76 | parent_class = g_type_class_peek_parent (klass); 77 | object_class = (GObjectClass*) klass; 78 | 79 | object_class->finalize = mmkeys_finalize; 80 | 81 | signals[MM_PLAYPAUSE] = 82 | g_signal_new ("mm_playpause", 83 | G_TYPE_FROM_CLASS (klass), 84 | G_SIGNAL_RUN_LAST, 85 | 0, 86 | NULL, NULL, 87 | g_cclosure_marshal_VOID__INT, 88 | G_TYPE_NONE, 1, G_TYPE_INT); 89 | signals[MM_PREV] = 90 | g_signal_new ("mm_prev", 91 | G_TYPE_FROM_CLASS (klass), 92 | G_SIGNAL_RUN_LAST, 93 | 0, 94 | NULL, NULL, 95 | g_cclosure_marshal_VOID__INT, 96 | G_TYPE_NONE, 1, G_TYPE_INT); 97 | signals[MM_NEXT] = 98 | g_signal_new ("mm_next", 99 | G_TYPE_FROM_CLASS (klass), 100 | G_SIGNAL_RUN_LAST, 101 | 0, 102 | NULL, NULL, 103 | g_cclosure_marshal_VOID__INT, 104 | G_TYPE_NONE, 1, G_TYPE_INT); 105 | signals[MM_STOP] = 106 | g_signal_new ("mm_stop", 107 | G_TYPE_FROM_CLASS (klass), 108 | G_SIGNAL_RUN_LAST, 109 | 0, 110 | NULL, NULL, 111 | g_cclosure_marshal_VOID__INT, 112 | G_TYPE_NONE, 1, G_TYPE_INT); 113 | } 114 | 115 | static void 116 | mmkeys_finalize (GObject *object) 117 | { 118 | parent_class->finalize (G_OBJECT(object)); 119 | } 120 | 121 | #define N_KEYCODES 5 122 | 123 | static void 124 | mmkeys_init (MmKeys *object) 125 | { 126 | int keycodes[N_KEYCODES]; 127 | GdkDisplay *display; 128 | GdkScreen *screen; 129 | GdkWindow *root; 130 | guint i, j; 131 | 132 | display = gdk_display_get_default (); 133 | 134 | keycodes[0] = XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioPrev); 135 | keycodes[1] = XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioNext); 136 | keycodes[2] = XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioPlay); 137 | keycodes[3] = XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioPause); 138 | keycodes[4] = XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioStop); 139 | 140 | for (i = 0; i < gdk_display_get_n_screens (display); i++) { 141 | screen = gdk_display_get_screen (display, i); 142 | 143 | if (screen != NULL) { 144 | root = gdk_screen_get_root_window (screen); 145 | 146 | for (j = 0; j < N_KEYCODES; j++) { 147 | if (keycodes[j] > 0) 148 | grab_mmkey (keycodes[j], root); 149 | } 150 | 151 | gdk_window_add_filter (root, filter_mmkeys, object); 152 | } 153 | } 154 | } 155 | 156 | MmKeys * 157 | mmkeys_new (void) 158 | { 159 | return MMKEYS (g_object_new (TYPE_MMKEYS, NULL)); 160 | } 161 | 162 | static void 163 | grab_mmkey (int key_code, GdkWindow *root) 164 | { 165 | gdk_error_trap_push (); 166 | 167 | XGrabKey (GDK_DISPLAY (), key_code, 168 | 0, 169 | GDK_WINDOW_XID (root), True, 170 | GrabModeAsync, GrabModeAsync); 171 | XGrabKey (GDK_DISPLAY (), key_code, 172 | Mod2Mask, 173 | GDK_WINDOW_XID (root), True, 174 | GrabModeAsync, GrabModeAsync); 175 | XGrabKey (GDK_DISPLAY (), key_code, 176 | Mod5Mask, 177 | GDK_WINDOW_XID (root), True, 178 | GrabModeAsync, GrabModeAsync); 179 | XGrabKey (GDK_DISPLAY (), key_code, 180 | LockMask, 181 | GDK_WINDOW_XID (root), True, 182 | GrabModeAsync, GrabModeAsync); 183 | XGrabKey (GDK_DISPLAY (), key_code, 184 | Mod2Mask | Mod5Mask, 185 | GDK_WINDOW_XID (root), True, 186 | GrabModeAsync, GrabModeAsync); 187 | XGrabKey (GDK_DISPLAY (), key_code, 188 | Mod2Mask | LockMask, 189 | GDK_WINDOW_XID (root), True, 190 | GrabModeAsync, GrabModeAsync); 191 | XGrabKey (GDK_DISPLAY (), key_code, 192 | Mod5Mask | LockMask, 193 | GDK_WINDOW_XID (root), True, 194 | GrabModeAsync, GrabModeAsync); 195 | XGrabKey (GDK_DISPLAY (), key_code, 196 | Mod2Mask | Mod5Mask | LockMask, 197 | GDK_WINDOW_XID (root), True, 198 | GrabModeAsync, GrabModeAsync); 199 | 200 | gdk_flush (); 201 | if (gdk_error_trap_pop ()) { 202 | fprintf (stderr, "Error grabbing key %d, %p\n", key_code, root); 203 | } 204 | } 205 | 206 | static GdkFilterReturn 207 | filter_mmkeys (GdkXEvent *xevent, GdkEvent *event, gpointer data) 208 | { 209 | XEvent *xev; 210 | XKeyEvent *key; 211 | 212 | xev = (XEvent *) xevent; 213 | if (xev->type != KeyPress) { 214 | return GDK_FILTER_CONTINUE; 215 | } 216 | 217 | key = (XKeyEvent *) xevent; 218 | 219 | if (XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioPlay) == key->keycode) { 220 | g_signal_emit (data, signals[MM_PLAYPAUSE], 0, 0); 221 | return GDK_FILTER_REMOVE; 222 | } else if (XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioPause) == key->keycode) { 223 | g_signal_emit (data, signals[MM_PLAYPAUSE], 0, 0); 224 | return GDK_FILTER_REMOVE; 225 | } else if (XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioPrev) == key->keycode) { 226 | g_signal_emit (data, signals[MM_PREV], 0, 0); 227 | return GDK_FILTER_REMOVE; 228 | } else if (XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioNext) == key->keycode) { 229 | g_signal_emit (data, signals[MM_NEXT], 0, 0); 230 | return GDK_FILTER_REMOVE; 231 | } else if (XKeysymToKeycode (GDK_DISPLAY (), XF86XK_AudioStop) == key->keycode) { 232 | g_signal_emit (data, signals[MM_STOP], 0, 0); 233 | return GDK_FILTER_REMOVE; 234 | } else { 235 | return GDK_FILTER_CONTINUE; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /sonata/cli.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import gettext 4 | from optparse import OptionParser 5 | 6 | from version import version 7 | 8 | # the mpd commands need a connection to server and exit without gui 9 | mpd_cmds = ["play", "pause", "stop", "next", "prev", "pp", "info", 10 | "status", "repeat", "random"] 11 | 12 | class Args(object): 13 | def __init__(self): 14 | self.skip_gui = False 15 | self.start_visibility = None 16 | 17 | def parse(self, argv): 18 | """Parse the command line arguments. 19 | 20 | Separates options and arguments from the given argument list, 21 | checks their validity.""" 22 | 23 | # toggle and popup need d-bus and don't always need gui 24 | # version and help don't need anything and exit without gui 25 | # hidden and visible are only applicable when gui is launched 26 | # profile and no-start don't need anything 27 | _usage = "\n".join((_("%prog [OPTION]... [COMMAND]...")+"\n", 28 | _("Commands:"), 29 | " play %s" % _("play song in playlist"), 30 | " pause %s" % _("pause currently playing song"), 31 | " stop %s" % _("stop currently playing song"), 32 | " next %s" % _("play next song in playlist"), 33 | " prev %s" % _("play previous song in playlist"), 34 | " pp %s" % _("toggle play/pause; plays if stopped"), 35 | " repeat %s" % _("toggle repeat mode"), 36 | " random %s" % _("toggle random mode"), 37 | " info %s" % _("display current song info"), 38 | " status %s" % _("display MPD status"), 39 | )) 40 | _version = "%prog " + version 41 | 42 | parser = OptionParser(usage=_usage, version=_version) 43 | parser.add_option("-p", "--popup", dest="popup", 44 | action="store_true", 45 | help=_("popup song notification (requires D-Bus)")) 46 | parser.add_option("-t", "--toggle", dest="toggle", 47 | action="store_true", 48 | help=_("toggles whether the app is minimized to the tray or visible (requires D-Bus)")) 49 | parser.add_option("-n", "--no-start", dest="start", 50 | action="store_false", 51 | help=_("don't start app if D-Bus commands fail")) 52 | parser.add_option("--hidden", dest="start_visibility", 53 | action="store_false", 54 | help=_("start app hidden (requires systray)")) 55 | parser.add_option("--visible", dest="start_visibility", 56 | action="store_true", 57 | help=_("start app visible (requires systray)")) 58 | parser.add_option("--profile", dest="profile", metavar="NUM", 59 | help=_("start with profile NUM"), type=int) 60 | 61 | options, self.cmds = parser.parse_args(argv[1:]) 62 | 63 | if options.toggle: 64 | options.start_visibility = True 65 | if options.popup and options.start_visibility is None: 66 | options.start_visibility = False 67 | self.start_visibility = options.start_visibility 68 | self.arg_profile = options.profile 69 | 70 | for cmd in self.cmds: 71 | if cmd in mpd_cmds: 72 | self.skip_gui = True 73 | else: 74 | parser.error(_("unknown command %s") % cmd) 75 | 76 | if options.toggle or options.popup: 77 | import dbus_plugin as dbus 78 | if not dbus.using_dbus(): 79 | print _("toggle and popup options require D-Bus. Aborting.") 80 | sys.exit(1) 81 | 82 | dbus.execute_remote_commands(options.toggle, 83 | options.popup, 84 | options.start) 85 | 86 | 87 | def execute_cmds(self): 88 | """If arguments were passed, perform action on them.""" 89 | if self.cmds: 90 | main = CliMain(self) 91 | mpdh.suppress_mpd_errors(True) 92 | main.mpd_connect() 93 | for cmd in self.cmds: 94 | main.execute_cmd(cmd) 95 | sys.exit() 96 | 97 | def apply_profile_arg(self, config): 98 | if self.arg_profile: 99 | a = self.arg_profile 100 | if a > 0 and a <= len(config.profile_names): 101 | config.profile_num = a-1 102 | print _("Starting Sonata with profile %s...") % config.profile_names[config.profile_num] 103 | else: 104 | print _("%d is not an available profile number.") % a 105 | print _("Profile numbers must be between 1 and %d.") % len(config.profile_names) 106 | sys.exit(1) 107 | 108 | class CliMain(object): 109 | def __init__(self, args): 110 | global os, mpd, config, library, mpdh, misc 111 | import os 112 | import mpd 113 | import config 114 | import library 115 | import mpdhelper as mpdh 116 | import misc 117 | 118 | self.config = config.Config(_('Default Profile'), _("by") + " %A " + _("from") + " %B", library.library_set_data) 119 | self.config.settings_load_real(library.library_set_data) 120 | args.apply_profile_arg(self.config) 121 | 122 | self.client = mpd.MPDClient() 123 | 124 | def mpd_connect(self): 125 | host, port, password = misc.mpd_env_vars() 126 | if not host: 127 | host = self.config.host[self.config.profile_num] 128 | if not port: 129 | port = self.config.port[self.config.profile_num] 130 | if not password: 131 | password = self.config.password[self.config.profile_num] 132 | 133 | mpdh.call(self.client, 'connect', host, port) 134 | if password: 135 | mpdh.call(self.client, 'password', password) 136 | 137 | def execute_cmd(self, cmd): 138 | self.status = mpdh.status(self.client) 139 | if not self.status: 140 | print _("Unable to connect to MPD.\nPlease check your Sonata preferences or MPD_HOST/MPD_PORT environment variables.") 141 | sys.exit(1) 142 | 143 | self.songinfo = mpdh.currsong(self.client) 144 | getattr(self, "_execute_%s" % cmd)() 145 | 146 | def _execute_play(self): 147 | mpdh.call(self.client, 'play') 148 | 149 | def _execute_pause(self): 150 | mpdh.call(self.client, 'pause', 1) 151 | 152 | def _execute_stop(self): 153 | mpdh.call(self.client, 'stop') 154 | 155 | def _execute_next(self): 156 | mpdh.call(self.client, 'next') 157 | 158 | def _execute_prev(self): 159 | mpdh.call(self.client, 'previous') 160 | 161 | def _execute_bool(self, cmd): 162 | """Set the reverse the value of cmd""" 163 | mpdh.call(self.client, cmd, int(not int(self.status[cmd]))) 164 | 165 | def _execute_random(self): 166 | self._execute_bool('random') 167 | 168 | def _execute_repeat(self): 169 | self._execute_bool('repeat') 170 | 171 | def _execute_pp(self): 172 | if self.status['state'] in ['play']: 173 | mpdh.call(self.client, 'pause', 1) 174 | elif self.status['state'] in ['pause', 'stop']: 175 | mpdh.call(self.client, 'play') 176 | 177 | def _execute_info(self): 178 | if self.status['state'] in ['play', 'pause']: 179 | cmds = [(_("Title"), ('title',)), 180 | (_("Artist"), ('artist',)), 181 | (_("Album"), ('album',)), 182 | (_("Date"), ('date',)), 183 | (_("Track"), ('track', '0', False, 2)), 184 | (_("Genre"), ('genre',)), 185 | (_("File"), ('file',)), 186 | ] 187 | for pretty, cmd in cmds: 188 | mpdh.conout("%s: %s" % (pretty, 189 | mpdh.get(self.songinfo, *cmd))) 190 | at, _length = [int(c) for c in self.status['time'].split(':')] 191 | at_time = misc.convert_time(at) 192 | try: 193 | time = misc.convert_time(mpdh.get(self.songinfo, 'time', '', True)) 194 | print "%s: %s/%s" % (_("Time"), at_time, time) 195 | except: 196 | print "%s: %s" % (_("Time"), at_time) 197 | print "%s: %s" % (_("Bitrate"), 198 | self.status.get('bitrate', '')) 199 | else: 200 | print _("MPD stopped") 201 | 202 | def _execute_status(self): 203 | state_map = { 204 | 'play': _("Playing"), 205 | 'pause': _("Paused"), 206 | 'stop': _("Stopped") 207 | } 208 | print "%s: %s" % (_("State"), 209 | state_map[self.status['state']]) 210 | 211 | print "%s %s" % (_("Repeat:"), _("On") if self.status['repeat'] == '1' else _("Off")) 212 | print "%s %s" % (_("Random:"), _("On") if self.status['random'] == '1' else _("Off")) 213 | print "%s: %s/100" % (_("Volume"), self.status['volume']) 214 | print "%s: %s %s" % (_('Crossfade'), self.status['xfade'], 215 | gettext.ngettext('second', 'seconds', 216 | int(self.status['xfade']))) 217 | -------------------------------------------------------------------------------- /sonata/scrobbler.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | This module makes Sonata submit the songs played to a Last.fm account. 4 | 5 | Example usage: 6 | import scrobbler 7 | self.scrobbler = scrobbler.Scrobbler(self.config) 8 | self.scrobbler.import_module() 9 | self.scrobbler.init() 10 | ... 11 | self.scrobbler.handle_change_status(False, self.prevsonginfo) 12 | """ 13 | 14 | import os, time, sys 15 | import threading # init, np, post start threads init_thread, do_np, do_post 16 | 17 | audioscrobbler = None # imported when first needed 18 | 19 | import mpdhelper as mpdh 20 | 21 | class Scrobbler(object): 22 | def __init__(self, config): 23 | self.config = config 24 | 25 | self.scrob = None 26 | self.scrob_post = None 27 | self.scrob_start_time = "" 28 | self.scrob_playing_duration = 0 29 | self.scrob_last_prepared = "" 30 | 31 | self.elapsed_now = None 32 | 33 | def import_module(self, _show_error=False): 34 | """Import the audioscrobbler module""" 35 | # We need to try to import audioscrobbler either when the app starts (if 36 | # as_enabled=True) or if the user enables it in prefs. 37 | global audioscrobbler 38 | if audioscrobbler is None: 39 | import audioscrobbler 40 | 41 | def imported(self): 42 | """Return True if the audioscrobbler module has been imported""" 43 | return audioscrobbler is not None 44 | 45 | def init(self): 46 | """Initialize the Audioscrobbler support if enabled and configured""" 47 | if audioscrobbler is not None and self.config.as_enabled and len(self.config.as_username) > 0 and len(self.config.as_password_md5) > 0: 48 | thread = threading.Thread(target=self.init_thread) 49 | thread.setDaemon(True) 50 | thread.start() 51 | 52 | def init_thread(self): 53 | if self.scrob is None: 54 | self.scrob = audioscrobbler.AudioScrobbler() 55 | if self.scrob_post is None: 56 | self.scrob_post = self.scrob.post(self.config.as_username, self.config.as_password_md5, verbose=True) 57 | else: 58 | if self.scrob_post.authenticated: 59 | return # We are authenticated 60 | else: 61 | self.scrob_post = self.scrob.post(self.config.as_username, self.config.as_password_md5, verbose=True) 62 | try: 63 | self.scrob_post.auth() 64 | except Exception, e: 65 | print "Error authenticating audioscrobbler", e 66 | self.scrob_post = None 67 | if self.scrob_post: 68 | self.retrieve_cache() 69 | 70 | def handle_change_status(self, state, prevstate, prevsonginfo, songinfo=None, mpd_time_now=None): 71 | """Handle changes to play status, submitting info as appropriate""" 72 | if prevsonginfo and 'time' in prevsonginfo: 73 | prevsong_time = mpdh.get(prevsonginfo, 'time') 74 | else: 75 | prevsong_time = None 76 | 77 | if state in ('play', 'pause'): 78 | elapsed_prev = self.elapsed_now 79 | self.elapsed_now, length = [float(c) for c in mpd_time_now.split(':')] 80 | current_file = mpdh.get(songinfo, 'file') 81 | if prevstate == 'stop': 82 | # Switched from stop to play, prepare current track: 83 | self.prepare(songinfo) 84 | elif (prevsong_time and 85 | (self.scrob_last_prepared != current_file or 86 | (self.scrob_last_prepared == current_file and elapsed_prev and 87 | abs(elapsed_prev-length)<=2 and self.elapsed_now<=2 and length>0))): 88 | # New song is playing, post previous track if time criteria is met. 89 | # In order to account for the situation where the same song is played twice in 90 | # a row, we will check if the previous time was the end of the song and we're 91 | # now at the beginning of the same song.. this technically isn't right in 92 | # the case where a user seeks back to the beginning, but that's an edge case. 93 | if self.scrob_playing_duration > 4 * 60 or self.scrob_playing_duration > int(prevsong_time)/2: 94 | if self.scrob_start_time != "": 95 | self.post(prevsonginfo) 96 | # Prepare current track: 97 | self.prepare(songinfo) 98 | # Keep track of the total amount of time that the current song 99 | # has been playing: 100 | now = time.time() 101 | if prevstate != 'pause': 102 | self.scrob_playing_duration += now - self.scrob_prev_time 103 | self.scrob_prev_time = now 104 | else: # stopped: 105 | self.elapsed_now = 0 106 | if prevsong_time: 107 | if self.scrob_playing_duration > 4 * 60 or self.scrob_playing_duration > int(prevsong_time)/2: 108 | # User stopped the client, post previous track if time 109 | # criteria is met: 110 | if self.scrob_start_time != "": 111 | self.post(prevsonginfo) 112 | 113 | def auth_changed(self): 114 | """Try to re-authenticate""" 115 | if self.scrob_post: 116 | if self.scrob_post.authenticated: 117 | self.scrob_post = None 118 | 119 | def prepare(self, songinfo): 120 | if audioscrobbler is not None: 121 | self.scrob_start_time = "" 122 | self.scrob_last_prepared = "" 123 | self.scrob_playing_duration = 0 124 | self.scrob_prev_time = time.time() 125 | 126 | if self.config.as_enabled and songinfo: 127 | # No need to check if the song is 30 seconds or longer, 128 | # audioscrobbler.py takes care of that. 129 | if 'time' in songinfo: 130 | self.np(songinfo) 131 | 132 | self.scrob_start_time = str(int(time.time())) 133 | self.scrob_last_prepared = mpdh.get(songinfo, 'file') 134 | 135 | def np(self, songinfo): 136 | thread = threading.Thread(target=self.do_np, args=(songinfo,)) 137 | thread.setDaemon(True) 138 | thread.start() 139 | 140 | def do_np(self, songinfo): 141 | self.init() 142 | if self.config.as_enabled and self.scrob_post and songinfo: 143 | if 'artist' in songinfo and \ 144 | 'title' in songinfo and \ 145 | 'time' in songinfo: 146 | if not 'album' in songinfo: 147 | album = u'' 148 | else: 149 | album = mpdh.get(songinfo, 'album') 150 | if not 'track' in songinfo: 151 | tracknumber = u'' 152 | else: 153 | tracknumber = mpdh.get(songinfo, 'track') 154 | try: 155 | self.scrob_post.nowplaying(mpdh.get(songinfo, 'artist'), 156 | mpdh.get(songinfo, 'title'), 157 | mpdh.get(songinfo, 'time'), 158 | tracknumber, 159 | album) 160 | except: 161 | print sys.exc_info()[1] 162 | time.sleep(10) 163 | 164 | def post(self, prevsonginfo): 165 | self.init() 166 | if self.config.as_enabled and self.scrob_post and prevsonginfo: 167 | if 'artist' in prevsonginfo and \ 168 | 'title' in prevsonginfo and \ 169 | 'time' in prevsonginfo: 170 | if not 'album' in prevsonginfo: 171 | album = u'' 172 | else: 173 | album = mpdh.get(prevsonginfo, 'album') 174 | if not 'track' in prevsonginfo: 175 | tracknumber = u'' 176 | else: 177 | tracknumber = mpdh.get(prevsonginfo, 'track') 178 | try: 179 | self.scrob_post.addtrack(mpdh.get(prevsonginfo, 'artist'), 180 | mpdh.get(prevsonginfo, 'title'), 181 | mpdh.get(prevsonginfo, 'time'), 182 | self.scrob_start_time, 183 | tracknumber, 184 | album) 185 | except: 186 | print sys.exc_info()[1] 187 | 188 | thread = threading.Thread(target=self.do_post) 189 | thread.setDaemon(True) 190 | thread.start() 191 | self.scrob_start_time = "" 192 | 193 | def do_post(self): 194 | for _i in range(0, 3): 195 | if not self.scrob_post: 196 | return 197 | if len(self.scrob_post.cache) == 0: 198 | return 199 | try: 200 | self.scrob_post.post() 201 | except audioscrobbler.AudioScrobblerConnectionError, e: 202 | print e 203 | time.sleep(10) 204 | 205 | def save_cache(self): 206 | """Save the cache in a file""" 207 | filename = os.path.expanduser('~/.config/sonata/ascache') 208 | if self.scrob_post: 209 | self.scrob_post.savecache(filename) 210 | 211 | def retrieve_cache(self): 212 | filename = os.path.expanduser('~/.config/sonata/ascache') 213 | if self.scrob_post: 214 | self.scrob_post.retrievecache(filename) 215 | 216 | -------------------------------------------------------------------------------- /sonata/ui.py: -------------------------------------------------------------------------------- 1 | 2 | import gtk, sys, pango 3 | 4 | def label(text=None, textmn=None, markup=None, x=0, y=0.5, \ 5 | wrap=False, select=False, w=-1, h=-1): 6 | # Defaults to left-aligned, vertically centered 7 | tmplabel = gtk.Label() 8 | if text: 9 | tmplabel.set_text(text) 10 | elif markup: 11 | tmplabel.set_markup(markup) 12 | elif textmn: 13 | tmplabel.set_text_with_mnemonic(textmn) 14 | tmplabel.set_alignment(x, y) 15 | tmplabel.set_size_request(w, h) 16 | tmplabel.set_line_wrap(wrap) 17 | try: # Only recent versions of pygtk/gtk have this 18 | tmplabel.set_line_wrap_mode(pango.WRAP_WORD_CHAR) 19 | except: 20 | pass 21 | tmplabel.set_selectable(select) 22 | return tmplabel 23 | 24 | def expander(text=None, markup=None, expand=False, can_focus=True): 25 | tmpexp = gtk.Expander() 26 | if text: 27 | tmpexp.set_label(text) 28 | elif markup: 29 | tmpexp.set_label(markup) 30 | tmpexp.set_use_markup(True) 31 | tmpexp.set_expanded(expand) 32 | tmpexp.set_property('can-focus', can_focus) 33 | return tmpexp 34 | 35 | def eventbox(visible=False, add=None, w=-1, h=-1, state=None): 36 | tmpevbox = gtk.EventBox() 37 | tmpevbox.set_visible_window(visible) 38 | tmpevbox.set_size_request(w, h) 39 | if state: 40 | tmpevbox.set_state(state) 41 | if add: 42 | tmpevbox.add(add) 43 | return tmpevbox 44 | 45 | def button(text=None, stock=None, relief=None, can_focus=True, \ 46 | hidetxt=False, img=None, w=-1, h=-1): 47 | tmpbut = gtk.Button() 48 | if text: 49 | tmpbut.set_label(text) 50 | elif stock: 51 | tmpbut.set_label(stock) 52 | tmpbut.set_use_stock(True) 53 | tmpbut.set_use_underline(True) 54 | if img: 55 | tmpbut.set_image(img) 56 | if relief: 57 | tmpbut.set_relief(relief) 58 | tmpbut.set_property('can-focus', can_focus) 59 | if hidetxt: 60 | tmpbut.get_child().get_child().get_children()[1].set_text('') 61 | tmpbut.set_size_request(w, h) 62 | return tmpbut 63 | 64 | def combo(items=None, active=None, changed_cb=None, wrap=1): 65 | tmpcb = gtk.combo_box_new_text() 66 | tmpcb = _combo_common(tmpcb, items, active, changed_cb, wrap) 67 | return tmpcb 68 | 69 | def comboentry(items=None, active=None, changed_cb=None, wrap=1): 70 | tmpcbe = gtk.combo_box_entry_new_text() 71 | tmpcbe = _combo_common(tmpcbe, items, active, changed_cb, wrap) 72 | return tmpcbe 73 | 74 | def _combo_common(combobox, items, active, changed_cb, wrap): 75 | if items: 76 | for item in items: 77 | combobox.append_text(item) 78 | if active is not None: 79 | combobox.set_active(active) 80 | if changed_cb: 81 | combobox.connect('changed', changed_cb) 82 | combobox.set_wrap_width(wrap) 83 | return combobox 84 | 85 | def togglebutton(text=None, underline=False, relief=gtk.RELIEF_NORMAL, \ 86 | can_focus=True): 87 | tmptbut = gtk.ToggleButton() 88 | if text: 89 | tmptbut.set_label(text) 90 | tmptbut.set_use_underline(underline) 91 | tmptbut.set_relief(relief) 92 | tmptbut.set_property('can-focus', can_focus) 93 | return tmptbut 94 | 95 | def image(stock=None, stocksize=gtk.ICON_SIZE_MENU, w=-1, h=-1, \ 96 | x=0.5, y=0.5, pb=None): 97 | if stock: 98 | tmpimg = gtk.image_new_from_stock(stock, stocksize) 99 | elif pb: 100 | tmpimg = gtk.image_new_from_pixbuf(pb) 101 | else: 102 | tmpimg = gtk.Image() 103 | tmpimg.set_size_request(w, h) 104 | tmpimg.set_alignment(x, y) 105 | return tmpimg 106 | 107 | def progressbar(orient=None, frac=None, step=None, ellipsize=None): 108 | tmpprog = gtk.ProgressBar() 109 | if orient: 110 | tmpprog.set_orientation(orient) 111 | if frac: 112 | tmpprog.set_fraction(frac) 113 | if step: 114 | tmpprog.set_pulse_step(step) 115 | if ellipsize: 116 | tmpprog.set_ellipsize(ellipsize) 117 | return tmpprog 118 | 119 | def scrollwindow(policy_x=gtk.POLICY_AUTOMATIC, policy_y=gtk.POLICY_AUTOMATIC, \ 120 | shadow=gtk.SHADOW_IN, w=-1, h=-1, add=None, addvp=None): 121 | tmpsw = gtk.ScrolledWindow() 122 | tmpsw.set_policy(policy_x, policy_y) 123 | tmpsw.set_shadow_type(shadow) 124 | tmpsw.set_size_request(w, h) 125 | if add: 126 | tmpsw.add(add) 127 | elif addvp: 128 | tmpsw.add_with_viewport(addvp) 129 | return tmpsw 130 | 131 | def dialog(title=None, parent=None, flags=0, buttons=None, default=None, \ 132 | separator=True, resizable=True, w=-1, h=-1, role=None): 133 | tmpdialog = gtk.Dialog(title, parent, flags, buttons) 134 | if default is not None: 135 | tmpdialog.set_default_response(default) 136 | tmpdialog.set_has_separator(separator) 137 | tmpdialog.set_resizable(resizable) 138 | tmpdialog.set_size_request(w, h) 139 | if role: 140 | tmpdialog.set_role(role) 141 | return tmpdialog 142 | 143 | def entry(text=None, password=False, w=-1, h=-1, changed_cb=None): 144 | tmpentry = UnicodeEntry() 145 | if text: 146 | tmpentry.set_text(text) 147 | if password: 148 | tmpentry.set_visibility(False) 149 | tmpentry.set_size_request(w, h) 150 | if changed_cb: 151 | tmpentry.connect('changed', changed_cb) 152 | return tmpentry 153 | 154 | class UnicodeEntry(gtk.Entry): 155 | def get_text(self): 156 | try: 157 | return gtk.Entry.get_text(self).decode('utf-8') 158 | except: 159 | print sys.exc_info()[1] 160 | return gtk.Entry.get_text(self).decode('utf-8', 'replace') 161 | 162 | def treeview(hint=True, reorder=False, search=True, headers=False): 163 | tmptv = gtk.TreeView() 164 | tmptv.set_rules_hint(hint) 165 | tmptv.set_reorderable(reorder) 166 | tmptv.set_enable_search(search) 167 | tmptv.set_headers_visible(headers) 168 | return tmptv 169 | 170 | def iconview(col=None, space=None, margin=None, itemw=None, selmode=None): 171 | tmpiv = gtk.IconView() 172 | if col: 173 | tmpiv.set_columns(col) 174 | if space: 175 | tmpiv.set_spacing(space) 176 | if margin: 177 | tmpiv.set_margin(margin) 178 | if itemw: 179 | tmpiv.set_item_width(itemw) 180 | if selmode: 181 | tmpiv.set_selection_mode(selmode) 182 | return tmpiv 183 | 184 | def show_msg(owner, message, title, role, buttons, default=None, response_cb=None): 185 | is_button_list = hasattr(buttons, '__getitem__') 186 | if not is_button_list: 187 | messagedialog = gtk.MessageDialog(owner, gtk.DIALOG_MODAL|gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING, buttons, message) 188 | else: 189 | messagedialog = gtk.MessageDialog(owner, gtk.DIALOG_MODAL|gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_WARNING, message_format=message) 190 | i = 0 191 | while i < len(buttons): 192 | messagedialog.add_button(buttons[i], buttons[i+1]) 193 | i += 2 194 | messagedialog.set_title(title) 195 | messagedialog.set_role(role) 196 | if default is not None: 197 | messagedialog.set_default_response(default) 198 | if response_cb: 199 | messagedialog.connect("response", response_cb) 200 | response = messagedialog.run() 201 | value = response 202 | messagedialog.destroy() 203 | return value 204 | 205 | def dialog_destroy(dialog_widget, _response_id): 206 | dialog_widget.destroy() 207 | 208 | def show(widget): 209 | widget.set_no_show_all(False) 210 | widget.show_all() 211 | 212 | def hide(widget): 213 | widget.hide_all() 214 | widget.set_no_show_all(True) 215 | 216 | def focus(widget): 217 | widget.grab_focus() 218 | 219 | def set_widths_equal(widgets): 220 | # Assigns the same width to all passed widgets in the list, where 221 | # the width is the maximum width across widgets. 222 | max_width = 0 223 | for widget in widgets: 224 | if widget.size_request()[0] > max_width: 225 | max_width = widget.size_request()[0] 226 | for widget in widgets: 227 | widget.set_size_request(max_width, -1) 228 | 229 | def icon(factory, icon_name, path): 230 | # Either the file or fullpath must be supplied, but not both: 231 | sonataset = gtk.IconSet() 232 | filename = [path] 233 | icons = [gtk.IconSource() for i in filename] 234 | for i, iconsource in enumerate(icons): 235 | iconsource.set_filename(filename[i]) 236 | sonataset.add_source(iconsource) 237 | factory.add(icon_name, sonataset) 238 | factory.add_default() 239 | 240 | def change_cursor(cursortype): 241 | for i in gtk.gdk.window_get_toplevels(): 242 | i.set_cursor(cursortype) 243 | 244 | class CellRendererTextWrap(gtk.CellRendererText): 245 | """A CellRendererText which sets its wrap-width to its width.""" 246 | 247 | __gtype_name__ = 'CellRendererTextWrap' 248 | 249 | def __init__(self): 250 | self.column = None 251 | gtk.CellRendererText.__init__(self) 252 | 253 | def set_column(self, column): 254 | """Set the containing gtk.TreeViewColumn to queue resizes.""" 255 | 256 | self.column = column 257 | 258 | def do_render(self, window, widget, background_area, cell_area, 259 | expose_area, flags): 260 | if (self.props.wrap_width == -1 or 261 | cell_area.width < self.props.wrap_width): 262 | self.props.wrap_width = cell_area.width 263 | self.column.queue_resize() 264 | 265 | gtk.CellRendererText.do_render( 266 | self, window, widget, background_area, cell_area, 267 | expose_area, flags) 268 | -------------------------------------------------------------------------------- /sonata/about.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | import gettext 4 | 5 | import gtk 6 | 7 | import misc, ui 8 | 9 | translators = '''\ 10 | ar - Ahmad Farghal 11 | be@latin - Ihar Hrachyshka 12 | ca - Franc Rodriguez 13 | cs - Jakub Adler 14 | da - Martin Dybdal 15 | de - Paul Johnson 16 | el_GR - Lazaros Koromilas 17 | es - Xoan Sampaiño 18 | et - Mihkel 19 | fi - Ilkka Tuohela 20 | fr - Floreal M 21 | it - Gianni Vialetto 22 | ja - Masato Hashimoto 23 | ko - Jaesung BANG 24 | nl - Olivier Keun 25 | pl - Tomasz Dominikowski 26 | pt_BR - Alex Tercete Matos 27 | ru - Ivan 28 | sk - Robert Hartl 29 | sl - Alan Pepelko 30 | sv - Daniel Nylander 31 | tr - Gökmen Görgen 32 | uk - Господарисько Тарас 33 | zh_CN - Desmond Chang 34 | zh_TW - Ian-Xue Li 35 | ''' 36 | 37 | class About(object): 38 | def __init__(self, parent_window, config, version, licensetext, icon_file): 39 | self.parent_window = parent_window 40 | self.config = config 41 | self.version = version 42 | self.license = licensetext 43 | self.icon_file = icon_file 44 | 45 | self.about_dialog = None 46 | 47 | def about_close(self, _event, _data=None): 48 | self.about_dialog.hide() 49 | return True 50 | 51 | def about_shortcuts(self, _button): 52 | # define the shortcuts and their descriptions 53 | # these are all gettextable 54 | mainshortcuts = \ 55 | [[ "F1", _("About Sonata") ], 56 | [ "F5", _("Preferences") ], 57 | [ "F11", _("Fullscreen Artwork Mode") ], 58 | [ "Alt-[1-5]", _("Switch to [1st-5th] tab") ], 59 | [ "Alt-C", _("Connect to MPD") ], 60 | [ "Alt-D", _("Disconnect from MPD") ], 61 | [ "Alt-R", _("Randomize current playlist") ], 62 | [ "Alt-Down", _("Expand player") ], 63 | [ "Alt-Left", _("Switch to previous tab") ], 64 | [ "Alt-Right", _("Switch to next tab") ], 65 | [ "Alt-Up", _("Collapse player") ], 66 | [ "Ctrl-H", _("Search library") ], 67 | [ "Ctrl-Q", _("Quit") ], 68 | [ "Ctrl-Shift-U", _("Update entire library") ], 69 | [ "Menu", _("Display popup menu") ], 70 | [ "Escape", _("Minimize to system tray (if enabled)") ]] 71 | playbackshortcuts = \ 72 | [[ "Ctrl-Left", _("Previous track") ], 73 | [ "Ctrl-Right", _("Next track") ], 74 | [ "Ctrl-P", _("Play/Pause") ], 75 | [ "Ctrl-S", _("Stop") ], 76 | [ "Ctrl-Minus", _("Lower the volume") ], 77 | [ "Ctrl-Plus", _("Raise the volume") ]] 78 | currentshortcuts = \ 79 | [[ "Enter/Space", _("Play selected song") ], 80 | [ "Delete", _("Remove selected song(s)") ], 81 | [ "Ctrl-I", _("Center currently playing song") ], 82 | [ "Ctrl-T", _("Edit selected song's tags") ], 83 | [ "Ctrl-Shift-S", _("Save to new playlist") ], 84 | [ "Ctrl-Delete", _("Clear list") ], 85 | [ "Alt-R", _("Randomize list") ]] 86 | libraryshortcuts = \ 87 | [[ "Enter/Space", _("Add selected song(s) or enter directory") ], 88 | [ "Backspace", _("Go to parent directory") ], 89 | [ "Ctrl-D", _("Add selected item(s)") ], 90 | [ "Ctrl-R", _("Replace with selected item(s)") ], 91 | [ "Ctrl-T", _("Edit selected song's tags") ], 92 | [ "Ctrl-Shift-D", _("Add selected item(s) and play") ], 93 | [ "Ctrl-Shift-R", _("Replace with selected item(s) and play") ], 94 | [ "Ctrl-U", _("Update selected item(s)/path(s)") ]] 95 | playlistshortcuts = \ 96 | [[ "Enter/Space", _("Add selected playlist(s)") ], 97 | [ "Delete", _("Remove selected playlist(s)") ], 98 | [ "Ctrl-D", _("Add selected playlist(s)") ], 99 | [ "Ctrl-R", _("Replace with selected playlist(s)") ], 100 | [ "Ctrl-Shift-D", _("Add selected playlist(s) and play") ], 101 | [ "Ctrl-Shift-R", _("Replace with selected playlist(s) and play") ]] 102 | streamshortcuts = \ 103 | [[ "Enter/Space", _("Add selected stream(s)") ], 104 | [ "Delete", _("Remove selected stream(s)") ], 105 | [ "Ctrl-D", _("Add selected stream(s)") ], 106 | [ "Ctrl-R", _("Replace with selected stream(s)") ], 107 | [ "Ctrl-Shift-D", _("Add selected stream(s) and play") ], 108 | [ "Ctrl-Shift-R", _("Replace with selected stream(s) and play") ]] 109 | infoshortcuts = \ 110 | [[ "Ctrl-T", _("Edit playing song's tags") ]] 111 | # define the main array- this adds headings to each section of 112 | # shortcuts that will be displayed 113 | shortcuts = [[ _("Main Shortcuts"), mainshortcuts ], 114 | [ _("Playback Shortcuts"), playbackshortcuts ], 115 | [ _("Current Shortcuts"), currentshortcuts ], 116 | [ _("Library Shortcuts"), libraryshortcuts ], 117 | [ _("Playlist Shortcuts"), playlistshortcuts ], 118 | [ _("Stream Shortcuts"), streamshortcuts ], 119 | [ _("Info Shortcuts"), infoshortcuts ]] 120 | dialog = ui.dialog(title=_("Shortcuts"), parent=self.about_dialog, flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, buttons=(gtk.STOCK_CLOSE, gtk.RESPONSE_CLOSE), role='shortcuts', default=gtk.RESPONSE_CLOSE, h=320) 121 | 122 | # each pair is a [ heading, shortcutlist ] 123 | vbox = gtk.VBox() 124 | for pair in shortcuts: 125 | titlelabel = ui.label(markup="%s" % pair[0]) 126 | vbox.pack_start(titlelabel, False, False, 2) 127 | 128 | # print the items of [ shortcut, desc ] 129 | for item in pair[1]: 130 | tmphbox = gtk.HBox() 131 | 132 | tmplabel = ui.label(markup="%s:" % item[0], y=0) 133 | tmpdesc = ui.label(text=item[1], wrap=True, y=0) 134 | 135 | tmphbox.pack_start(tmplabel, False, False, 2) 136 | tmphbox.pack_start(tmpdesc, True, True, 2) 137 | 138 | vbox.pack_start(tmphbox, False, False, 2) 139 | vbox.pack_start(ui.label(text=" "), False, False, 2) 140 | scrollbox = ui.scrollwindow(policy_x=gtk.POLICY_NEVER, addvp=vbox) 141 | dialog.vbox.pack_start(scrollbox, True, True, 2) 142 | dialog.show_all() 143 | dialog.run() 144 | dialog.destroy() 145 | 146 | def statstext(self, stats): 147 | # XXX translate expressions, not words 148 | statslabel = stats['songs'] + ' ' + gettext.ngettext('song', 'songs', int(stats['songs'])) + '.\n' 149 | statslabel = statslabel + stats['albums'] + ' ' + gettext.ngettext('album', 'albums', int(stats['albums'])) + '.\n' 150 | statslabel = statslabel + stats['artists'] + ' ' + gettext.ngettext('artist', 'artists', int(stats['artists'])) + '.\n' 151 | try: 152 | hours_of_playtime = misc.convert_time(float(stats['db_playtime'])).split(':')[-3] 153 | except: 154 | hours_of_playtime = '0' 155 | if int(hours_of_playtime) >= 24: 156 | days_of_playtime = str(int(hours_of_playtime)/24) 157 | statslabel = statslabel + days_of_playtime + ' ' + gettext.ngettext('day of bliss', 'days of bliss', int(days_of_playtime)) + '.' 158 | else: 159 | statslabel = statslabel + hours_of_playtime + ' ' + gettext.ngettext('hour of bliss', 'hours of bliss', int(hours_of_playtime)) + '.' 160 | 161 | return statslabel 162 | 163 | def about_load(self, stats): 164 | self.about_dialog = gtk.AboutDialog() 165 | try: 166 | self.about_dialog.set_transient_for(self.parent_window) 167 | self.about_dialog.set_modal(True) 168 | except: 169 | pass 170 | self.about_dialog.set_name('Sonata') 171 | self.about_dialog.set_role('about') 172 | self.about_dialog.set_version(self.version) 173 | commentlabel = _('An elegant music client for MPD.') 174 | self.about_dialog.set_comments(commentlabel) 175 | if stats: 176 | self.about_dialog.set_copyright(self.statstext(stats)) 177 | self.about_dialog.set_license(self.license) 178 | self.about_dialog.set_authors(['Scott Horowitz ', 'Tuukka Hastrup ', 'Stephen Boyd ']) 179 | self.about_dialog.set_artists(['Adrian Chromenko \nhttp://oss.rest0re.org/']) 180 | self.about_dialog.set_translator_credits(translators) 181 | gtk.about_dialog_set_url_hook(self.show_website) 182 | self.about_dialog.set_website("http://sonata.berlios.de/") 183 | large_icon = gtk.gdk.pixbuf_new_from_file(self.icon_file) 184 | self.about_dialog.set_logo(large_icon) 185 | # Add button to show keybindings: 186 | shortcut_button = ui.button(text=_("_Shortcuts")) 187 | self.about_dialog.action_area.pack_start(shortcut_button) 188 | self.about_dialog.action_area.reorder_child(self.about_dialog.action_area.get_children()[-1], -2) 189 | # Connect to callbacks 190 | self.about_dialog.connect('response', self.about_close) 191 | self.about_dialog.connect('delete_event', self.about_close) 192 | shortcut_button.connect('clicked', self.about_shortcuts) 193 | self.about_dialog.show_all() 194 | 195 | def show_website(self, _dialog, link): 196 | if not misc.browser_load(link, self.config.url_browser, self.parent_window): 197 | ui.show_msg(self.about_dialog, _('Unable to launch a suitable browser.'), _('Launch Browser'), 'browserLoadError', gtk.BUTTONS_CLOSE) 198 | -------------------------------------------------------------------------------- /sonata/playlists.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | This module implements a user interface for mpd playlists. 4 | 5 | Example usage: 6 | import playlists 7 | self.playlists = playlists.Playlists(self.config, self.window, self.client, lambda:self.UIManager, self.update_menu_visibility, self.iterate_now, self.on_add_item, self.on_playlists_button_press, self.get_current_songs, self.connected, self.TAB_PLAYLISTS) 8 | playlistswindow, playlistsevbox = self.playlists.get_widgets() 9 | ... 10 | self.playlists.populate() 11 | ... 12 | """ 13 | 14 | import gtk, pango 15 | 16 | import ui, misc 17 | import mpdhelper as mpdh 18 | 19 | from pluginsystem import pluginsystem, BuiltinPlugin 20 | 21 | class Playlists(object): 22 | def __init__(self, config, window, client, UIManager, update_menu_visibility, iterate_now, on_add_item, on_playlists_button_press, get_current_songs, connected, add_selected_to_playlist, TAB_PLAYLISTS): 23 | self.config = config 24 | self.window = window 25 | self.client = client 26 | self.UIManager = UIManager 27 | self.update_menu_visibility = update_menu_visibility 28 | self.iterate_now = iterate_now # XXX Do we really need this? 29 | self.on_add_item = on_add_item 30 | self.on_playlists_button_press = on_playlists_button_press 31 | self.get_current_songs = get_current_songs 32 | self.add_selected_to_playlist = add_selected_to_playlist 33 | self.connected = connected 34 | 35 | self.mergepl_id = None 36 | self.actionGroupPlaylists = None 37 | 38 | # Playlists tab 39 | self.playlists = ui.treeview() 40 | self.playlists_selection = self.playlists.get_selection() 41 | self.playlistswindow = ui.scrollwindow(add=self.playlists) 42 | 43 | self.tab = (self.playlistswindow, gtk.STOCK_JUSTIFY_CENTER, TAB_PLAYLISTS, self.playlists) 44 | 45 | self.playlists.connect('button_press_event', self.on_playlists_button_press) 46 | self.playlists.connect('row_activated', self.playlists_activated) 47 | self.playlists.connect('key-press-event', self.playlists_key_press) 48 | 49 | # Initialize playlist data and widget 50 | self.playlistsdata = gtk.ListStore(str, str) 51 | self.playlists.set_model(self.playlistsdata) 52 | self.playlists.set_search_column(1) 53 | self.playlistsimg = gtk.CellRendererPixbuf() 54 | self.playlistscell = gtk.CellRendererText() 55 | self.playlistscell.set_property("ellipsize", pango.ELLIPSIZE_END) 56 | self.playlistscolumn = gtk.TreeViewColumn() 57 | self.playlistscolumn.pack_start(self.playlistsimg, False) 58 | self.playlistscolumn.pack_start(self.playlistscell, True) 59 | self.playlistscolumn.set_attributes(self.playlistsimg, stock_id=0) 60 | self.playlistscolumn.set_attributes(self.playlistscell, markup=1) 61 | self.playlists.append_column(self.playlistscolumn) 62 | self.playlists_selection.set_mode(gtk.SELECTION_MULTIPLE) 63 | 64 | pluginsystem.plugin_infos.append(BuiltinPlugin( 65 | 'playlists', "Playlists", "A tab for playlists.", 66 | {'tabs': 'construct_tab'}, self)) 67 | 68 | def construct_tab(self): 69 | self.playlistswindow.show_all() 70 | return self.tab 71 | 72 | def get_model(self): 73 | return self.playlistsdata 74 | 75 | def get_widgets(self): 76 | return self.playlistswindow 77 | 78 | def get_treeview(self): 79 | return self.playlists 80 | 81 | def get_selection(self): 82 | return self.playlists_selection 83 | 84 | def populate_playlists_for_menu(self, playlistinfo): 85 | if self.mergepl_id: 86 | self.UIManager().remove_ui(self.mergepl_id) 87 | if self.actionGroupPlaylists: 88 | self.UIManager().remove_action_group(self.actionGroupPlaylists) 89 | self.actionGroupPlaylists = None 90 | self.actionGroupPlaylists = gtk.ActionGroup('MPDPlaylists') 91 | self.UIManager().ensure_update() 92 | actions = [("Playlist: %s" % playlist.replace("&", ""), 93 | gtk.STOCK_JUSTIFY_CENTER, 94 | misc.unescape_html(playlist), None, None, 95 | self.on_playlist_menu_click) 96 | for playlist in playlistinfo] 97 | self.actionGroupPlaylists.add_actions(actions) 98 | uiDescription = """ 99 | 100 | 101 | 102 | """ 103 | uiDescription += "".join('' % action[0] 104 | for action in actions) 105 | uiDescription += '' 106 | self.mergepl_id = self.UIManager().add_ui_from_string(uiDescription) 107 | self.UIManager().insert_action_group(self.actionGroupPlaylists, 0) 108 | self.UIManager().get_widget('/hidden').set_property('visible', False) 109 | # If we're not on the Current tab, prevent additional menu items 110 | # from displaying: 111 | self.update_menu_visibility() 112 | 113 | def on_playlist_save(self, _action): 114 | plname = self.prompt_for_playlist_name(_("Save Playlist"), 'savePlaylist') 115 | if plname: 116 | if self.playlist_name_exists(_("Save Playlist"), 'savePlaylistError', plname): 117 | return 118 | self.playlist_create(plname) 119 | mpdh.call(self.client, 'playlistclear', plname) 120 | self.add_selected_to_playlist(plname) 121 | 122 | def playlist_create(self, playlistname, oldname=None): 123 | mpdh.call(self.client, 'rm', playlistname) 124 | if oldname is not None: 125 | mpdh.call(self.client, 'rename', oldname, playlistname) 126 | else: 127 | mpdh.call(self.client, 'save', playlistname) 128 | self.populate() 129 | self.iterate_now() 130 | 131 | def on_playlist_menu_click(self, action): 132 | plname = misc.unescape_html(action.get_name().replace("Playlist: ", "")) 133 | response = ui.show_msg(self.window, _("Would you like to replace the existing playlist or append these songs?"), _("Existing Playlist"), "existingPlaylist", (_("Replace playlist"), 1, _("Append songs"), 2), default=self.config.existing_playlist_option) 134 | if response == 1: # Overwrite 135 | self.config.existing_playlist_option = response 136 | mpdh.call(self.client, 'playlistclear', plname) 137 | self.add_selected_to_playlist(plname) 138 | elif response == 2: # Append songs: 139 | self.config.existing_playlist_option = response 140 | self.add_selected_to_playlist(plname) 141 | 142 | def playlist_name_exists(self, title, role, plname, skip_plname=""): 143 | # If the playlist already exists, and the user does not want to replace it, return True; In 144 | # all other cases, return False 145 | playlists = mpdh.call(self.client, 'listplaylists') 146 | if playlists is None: 147 | playlists = mpdh.call(self.client, 'lsinfo') 148 | for item in playlists: 149 | if 'playlist' in item: 150 | if mpdh.get(item, 'playlist') == plname and plname != skip_plname: 151 | if ui.show_msg(self.window, _("A playlist with this name already exists. Would you like to replace it?"), title, role, gtk.BUTTONS_YES_NO) == gtk.RESPONSE_YES: 152 | return False 153 | else: 154 | return True 155 | return False 156 | 157 | def prompt_for_playlist_name(self, title, role): 158 | plname = None 159 | if self.connected(): 160 | # Prompt user for playlist name: 161 | dialog = ui.dialog(title=title, parent=self.window, flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT, gtk.STOCK_SAVE, gtk.RESPONSE_ACCEPT), role=role, default=gtk.RESPONSE_ACCEPT) 162 | hbox = gtk.HBox() 163 | hbox.pack_start(ui.label(text=_('Playlist name:')), False, False, 5) 164 | entry = ui.entry() 165 | entry.set_activates_default(True) 166 | hbox.pack_start(entry, True, True, 5) 167 | dialog.vbox.pack_start(hbox) 168 | ui.show(dialog.vbox) 169 | response = dialog.run() 170 | if response == gtk.RESPONSE_ACCEPT: 171 | plname = misc.strip_all_slashes(entry.get_text()) 172 | dialog.destroy() 173 | return plname 174 | 175 | def populate(self): 176 | if self.connected(): 177 | self.playlistsdata.clear() 178 | playlistinfo = [] 179 | playlists = mpdh.call(self.client, 'listplaylists') 180 | if playlists is None: 181 | playlists = mpdh.call(self.client, 'lsinfo') 182 | for item in playlists: 183 | if 'playlist' in item: 184 | playlistinfo.append(misc.escape_html(mpdh.get(item, 'playlist'))) 185 | playlistinfo.sort(key=lambda x: x.lower()) # Remove case sensitivity 186 | for item in playlistinfo: 187 | self.playlistsdata.append([gtk.STOCK_JUSTIFY_FILL, item]) 188 | if mpdh.mpd_major_version(self.client) >= 0.13: 189 | self.populate_playlists_for_menu(playlistinfo) 190 | 191 | def on_playlist_rename(self, _action): 192 | plname = self.prompt_for_playlist_name(_("Rename Playlist"), 'renamePlaylist') 193 | if plname: 194 | model, selected = self.playlists_selection.get_selected_rows() 195 | oldname = misc.unescape_html(model.get_value(model.get_iter(selected[0]), 1)) 196 | if self.playlist_name_exists(_("Rename Playlist"), 'renamePlaylistError', plname, oldname): 197 | return 198 | self.playlist_create(plname, oldname) 199 | # Re-select item: 200 | row = 0 201 | for pl in self.playlistsdata: 202 | if pl[1] == plname: 203 | self.playlists_selection.select_path((row,)) 204 | return 205 | row = row + 1 206 | 207 | def playlists_key_press(self, widget, event): 208 | if event.keyval == gtk.gdk.keyval_from_name('Return'): 209 | self.playlists_activated(widget, widget.get_cursor()[0]) 210 | return True 211 | 212 | def playlists_activated(self, _treeview, _path, _column=0): 213 | self.on_add_item(None) 214 | 215 | -------------------------------------------------------------------------------- /mmkeys/COPYING: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 5 | 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Library General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License 307 | along with this program; if not, write to the Free Software 308 | Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 309 | 310 | 311 | Also add information on how to contact you by electronic and paper mail. 312 | 313 | If the program is interactive, make it output a short notice like this 314 | when it starts in an interactive mode: 315 | 316 | Gnomovision version 69, Copyright (C) year name of author 317 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 318 | This is free software, and you are welcome to redistribute it 319 | under certain conditions; type `show c' for details. 320 | 321 | The hypothetical commands `show w' and `show c' should show the appropriate 322 | parts of the General Public License. Of course, the commands you use may 323 | be called something other than `show w' and `show c'; they could even be 324 | mouse-clicks or menu items--whatever suits your program. 325 | 326 | You should also get your employer (if you work as a programmer) or your 327 | school, if any, to sign a "copyright disclaimer" for the program, if 328 | necessary. Here is a sample; alter the names: 329 | 330 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 331 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 332 | 333 | , 1 April 1989 334 | Ty Coon, President of Vice 335 | 336 | This General Public License does not permit incorporating your program into 337 | proprietary programs. If your program is a subroutine library, you may 338 | consider it more useful to permit linking proprietary applications with the 339 | library. If this is what you want to do, use the GNU Library General 340 | Public License instead of this License. 341 | -------------------------------------------------------------------------------- /sonata/tagedit.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | This module provides a user interface for editing the metadata tags of 4 | local music files. 5 | 6 | Example usage: 7 | import tagedit 8 | tageditor = tagedit.TagEditor(self.window, self.tags_mpd_update) 9 | tageditor.on_tags_edit(files, temp_mpdpaths, self.musicdir[self.profile_num]) 10 | """ 11 | 12 | import os 13 | import re 14 | 15 | import gtk, gobject 16 | tagpy = None # module loaded when needed 17 | 18 | import ui, misc 19 | 20 | 21 | class TagEditor(): 22 | """This class implements a dialog for editing music metadata tags. 23 | 24 | When the dialog closes, the callback gets the list of updates made. 25 | """ 26 | def __init__(self, window, tags_mpd_update, tags_set_use_mpdpath): 27 | self.window = window 28 | self.tags_mpd_update = tags_mpd_update 29 | self.tags_set_use_mpdpath = tags_set_use_mpdpath 30 | 31 | self.filelabel = None 32 | self.curr_mpdpath = None 33 | self.tagnum = None 34 | self.use_mpdpaths = None 35 | 36 | def _create_label_entry_button_hbox(self, label_name, track=False): 37 | """Creates a label, entry, apply all button, packing them into an hbox. 38 | 39 | This is usually one row in the tagedit dialog, for example the title. 40 | """ 41 | entry = ui.entry() 42 | button = ui.button() 43 | buttonvbox = self.tags_win_create_apply_all_button(button, entry, track) 44 | 45 | label = ui.label(text=label_name, x=1) 46 | hbox = gtk.HBox() 47 | hbox.pack_start(label, False, False, 2) 48 | hbox.pack_start(entry, True, True, 2) 49 | hbox.pack_start(buttonvbox, False, False, 2) 50 | 51 | return (label, entry, button, hbox) 52 | 53 | def on_tags_edit(self, files, temp_mpdpaths, music_dir): 54 | """Display the editing dialog""" 55 | # Try loading module 56 | global tagpy 57 | if tagpy is None: 58 | try: 59 | import tagpy 60 | except ImportError: 61 | ui.show_msg(self.window, _("Taglib and/or tagpy not found, tag editing support disabled."), _("Edit Tags"), 'editTagsError', gtk.BUTTONS_CLOSE, response_cb=ui.dialog_destroy) 62 | ui.change_cursor(None) 63 | return 64 | # Set default tag encoding to utf8.. fixes some reported bugs. 65 | import tagpy.id3v2 as id3v2 66 | id3v2.FrameFactory.instance().setDefaultTextEncoding(tagpy.StringType.UTF8) 67 | 68 | # Make sure tagpy is at least 0.91 69 | if hasattr(tagpy.Tag.title, '__call__'): 70 | ui.show_msg(self.window, _("Tagpy version < 0.91. Please upgrade to a newer version, tag editing support disabled."), _("Edit Tags"), 'editTagsError', gtk.BUTTONS_CLOSE, response_cb=ui.dialog_destroy) 71 | ui.change_cursor(None) 72 | return 73 | 74 | if not os.path.isdir(misc.file_from_utf8(music_dir)): 75 | ui.show_msg(self.window, _("The path %s does not exist. Please specify a valid music directory in preferences.") % music_dir, _("Edit Tags"), 'editTagsError', gtk.BUTTONS_CLOSE, response_cb=ui.dialog_destroy) 76 | ui.change_cursor(None) 77 | return 78 | 79 | # XXX file list was created here 80 | 81 | if len(files) == 0: 82 | ui.change_cursor(None) 83 | return 84 | 85 | # Initialize: 86 | self.tagnum = -1 87 | 88 | tags = [{'title':'', 'artist':'', 'album':'', 'year':'', 'track':'', 89 | 'genre':'', 'comment':'', 'title-changed':False, 90 | 'artist-changed':False, 'album-changed':False, 91 | 'year-changed':False, 'track-changed':False, 92 | 'genre-changed':False, 'comment-changed':False, 93 | 'fullpath':misc.file_from_utf8(filename), 94 | 'mpdpath':path} 95 | for filename, path in zip(files, temp_mpdpaths)] 96 | 97 | if not os.path.exists(tags[0]['fullpath']): 98 | ui.change_cursor(None) 99 | ui.show_msg(self.window, _("File '%s' not found. Please specify a valid music directory in preferences.") % tags[0]['fullpath'], _("Edit Tags"), 'editTagsError', gtk.BUTTONS_CLOSE, response_cb=ui.dialog_destroy) 100 | return 101 | if not self.tags_next_tag(tags): 102 | ui.change_cursor(None) 103 | ui.show_msg(self.window, _("No music files with editable tags found."), _("Edit Tags"), 'editTagsError', gtk.BUTTONS_CLOSE, response_cb=ui.dialog_destroy) 104 | return 105 | editwindow = ui.dialog(parent=self.window, flags=gtk.DIALOG_MODAL, role='editTags', resizable=False, separator=False) 106 | editwindow.set_size_request(375, -1) 107 | table = gtk.Table(9, 2, False) 108 | table.set_row_spacings(2) 109 | self.filelabel = ui.label(select=True, wrap=True) 110 | filehbox = gtk.HBox() 111 | sonataicon = ui.image(stock='sonata', stocksize=gtk.ICON_SIZE_DND, x=1) 112 | expandbutton = ui.button(" ") 113 | self.set_expandbutton_state(expandbutton) 114 | expandvbox = gtk.VBox() 115 | expandvbox.pack_start(ui.label(), True, True) 116 | expandvbox.pack_start(expandbutton, False, False) 117 | expandvbox.pack_start(ui.label(), True, True) 118 | expandbutton.connect('clicked', self.toggle_path) 119 | blanklabel = ui.label(w=5, h=12) 120 | filehbox.pack_start(sonataicon, False, False, 2) 121 | filehbox.pack_start(self.filelabel, True, True, 2) 122 | filehbox.pack_start(expandvbox, False, False, 2) 123 | filehbox.pack_start(blanklabel, False, False, 2) 124 | 125 | titlelabel, titleentry, titlebutton, titlehbox = self._create_label_entry_button_hbox(_("Title:")) 126 | artistlabel, artistentry, artistbutton, artisthbox = self._create_label_entry_button_hbox(_("Artist:")) 127 | albumlabel, albumentry, albumbutton, albumhbox = self._create_label_entry_button_hbox(_("Album:")) 128 | yearlabel, yearentry, yearbutton, yearhbox = self._create_label_entry_button_hbox(_("Year:")) 129 | yearentry.set_size_request(50,-1) 130 | tracklabel, trackentry, trackbutton, trackhbox = self._create_label_entry_button_hbox(" " + _("Track:"), True) 131 | trackentry.set_size_request(50,-1) 132 | yearandtrackhbox = gtk.HBox() 133 | yearandtrackhbox.pack_start(yearhbox, True, True, 0) 134 | yearandtrackhbox.pack_start(trackhbox, True, True, 0) 135 | 136 | yearentry.connect("insert_text", self.tags_win_entry_constraint, True) 137 | trackentry.connect("insert_text", self.tags_win_entry_constraint, False) 138 | 139 | genrelabel = ui.label(text=_("Genre:"), x=1) 140 | genrecombo = ui.comboentry(items=self.tags_win_genres(), wrap=2) 141 | genreentry = genrecombo.get_child() 142 | genrehbox = gtk.HBox() 143 | genrebutton = ui.button() 144 | genrebuttonvbox = self.tags_win_create_apply_all_button(genrebutton, 145 | genreentry) 146 | genrehbox.pack_start(genrelabel, False, False, 2) 147 | genrehbox.pack_start(genrecombo, True, True, 2) 148 | genrehbox.pack_start(genrebuttonvbox, False, False, 2) 149 | 150 | commentlabel, commententry, commentbutton, commenthbox = self._create_label_entry_button_hbox(_("Comment:")) 151 | 152 | ui.set_widths_equal([titlelabel, artistlabel, albumlabel, yearlabel, genrelabel, commentlabel, sonataicon]) 153 | genrecombo.set_size_request(-1, titleentry.size_request()[1]) 154 | tablewidgets = [ui.label(), filehbox, ui.label(), titlehbox, artisthbox, albumhbox, yearandtrackhbox, genrehbox, commenthbox, ui.label()] 155 | for i, widget in enumerate(tablewidgets): 156 | table.attach(widget, 1, 2, i+1, i+2, gtk.FILL|gtk.EXPAND, gtk.FILL|gtk.EXPAND, 2, 0) 157 | editwindow.vbox.pack_start(table) 158 | saveall_button = None 159 | if len(files) > 1: 160 | # Only show save all button if more than one song being edited. 161 | saveall_button = ui.button(text=_("Save _All")) 162 | editwindow.action_area.pack_start(saveall_button) 163 | editwindow.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT) 164 | editwindow.add_button(gtk.STOCK_SAVE, gtk.RESPONSE_ACCEPT) 165 | editwindow.connect('delete_event', self.tags_win_hide, tags) 166 | entries = [titleentry, artistentry, albumentry, yearentry, trackentry, genreentry, commententry] 167 | buttons = [titlebutton, artistbutton, albumbutton, yearbutton, trackbutton, genrebutton, commentbutton] 168 | entries_names = ["title", "artist", "album", "year", "track", "genre", "comment"] 169 | editwindow.connect('response', self.tags_win_response, tags, entries, entries_names) 170 | if saveall_button: 171 | saveall_button.connect('clicked', self.tags_win_save_all, editwindow, tags, entries, entries_names) 172 | 173 | for button, name, entry in zip(buttons, entries_names, entries): 174 | entry.connect('changed', self.tags_win_entry_changed) 175 | button.connect('clicked', self.tags_win_apply_all, name, tags, entry) 176 | 177 | self.tags_win_update(editwindow, tags, entries, entries_names) 178 | ui.change_cursor(None) 179 | self.filelabel.set_size_request(editwindow.size_request()[0] - titlelabel.size_request()[0] - 70, -1) 180 | editwindow.show_all() 181 | 182 | def tags_next_tag(self, tags): 183 | # Returns true if next tag found (and self.tagnum is updated). 184 | # If no next tag found, returns False. 185 | while self.tagnum < len(tags)-1: 186 | self.tagnum = self.tagnum + 1 187 | if os.path.exists(tags[self.tagnum]['fullpath']): 188 | try: 189 | fileref = tagpy.FileRef(tags[self.tagnum]['fullpath']) 190 | if not fileref.isNull(): 191 | return True 192 | except: 193 | pass 194 | return False 195 | 196 | def tags_win_entry_changed(self, editable): 197 | style = editable.get_style().copy() 198 | style.text[gtk.STATE_NORMAL] = editable.get_colormap().alloc_color("red") 199 | editable.set_style(style) 200 | 201 | def tags_win_entry_revert_color(self, editable): 202 | editable.set_style(None) 203 | 204 | def tags_win_create_apply_all_button(self, button, entry, autotrack=False): 205 | button.set_size_request(12, 12) 206 | if autotrack: 207 | button.set_tooltip_text(_("Increment each selected music file, starting at track 1 for this file.")) 208 | else: 209 | button.set_tooltip_text(_("Apply to all selected music files.")) 210 | padding = int((entry.size_request()[1] - button.size_request()[1])/2)+1 211 | vbox = gtk.VBox(); 212 | vbox.pack_start(button, False, False, padding) 213 | return vbox 214 | 215 | def tags_win_apply_all(self, _button, item, tags, entry): 216 | for tagnum, tag in enumerate(tags): 217 | tagnum = tagnum + 1 218 | if item in ("title", "album", "artist", "genre", "comment"): 219 | tag[item] = entry.get_text() 220 | tag[item + '-changed'] = True 221 | elif item == "year": 222 | if len(entry.get_text()) > 0: 223 | tag['year'] = int(entry.get_text()) 224 | else: 225 | tag['year'] = 0 226 | tag['year-changed'] = True 227 | elif item == "track": 228 | if tagnum >= self.tagnum-1: 229 | # Start the current song at track 1, as opposed to the first 230 | # song in the list. 231 | tag['track'] = tagnum - self.tagnum 232 | tag['track-changed'] = True 233 | if item == "track": 234 | # Update the entry for the current song: 235 | entry.set_text(str(tags[self.tagnum]['track'])) 236 | 237 | def tags_win_update(self, window, tags, entries, entries_names): 238 | current_tag = tags[self.tagnum] 239 | tag = tagpy.FileRef(current_tag['fullpath']).tag() 240 | # Update interface: 241 | for entry, entry_name in zip(entries, entries_names): 242 | # Only retrieve info from the file if the info hasn't changed 243 | if not current_tag[entry_name + "-changed"]: 244 | current_tag[entry_name] = getattr(tag, entry_name, '') 245 | tag_value = current_tag[entry_name] 246 | if tag_value == 0: 247 | tag_value = '' 248 | entry.set_text(str(tag_value).strip()) 249 | 250 | # Revert text color if this tag wasn't changed by the user 251 | if not current_tag[entry_name + "-changed"]: 252 | self.tags_win_entry_revert_color(entry) 253 | 254 | self.curr_mpdpath = gobject.filename_display_name(current_tag['mpdpath']) 255 | filename = self.curr_mpdpath 256 | if not self.use_mpdpaths: 257 | filename = os.path.basename(filename) 258 | self.filelabel.set_text(filename) 259 | entries[0].grab_focus() 260 | window.set_title(_("Edit Tags - %s of %s") % 261 | (self.tagnum+1, len(tags))) 262 | self.tags_win_set_sensitive(window.action_area) 263 | 264 | def tags_win_set_sensitive(self, action_area): 265 | # Hacky workaround to allow the user to click the save button again when the 266 | # mouse stays over the button (see http://bugzilla.gnome.org/show_bug.cgi?id=56070) 267 | action_area.set_sensitive(True) 268 | action_area.hide() 269 | action_area.show_all() 270 | 271 | def tags_win_save_all(self, _button, window, tags, entries, entries_names): 272 | for entry in entries: 273 | entry.set_property('editable', False) 274 | while window.get_property('visible'): 275 | self.tags_win_response(window, gtk.RESPONSE_ACCEPT, tags, entries, entries_names) 276 | 277 | def tags_win_response(self, window, response, tags, entries, entries_names): 278 | if response == gtk.RESPONSE_REJECT: 279 | self.tags_win_hide(window, None, tags) 280 | elif response == gtk.RESPONSE_ACCEPT: 281 | window.action_area.set_sensitive(False) 282 | while window.action_area.get_property("sensitive") or gtk.events_pending(): 283 | gtk.main_iteration() 284 | filetag = tagpy.FileRef(tags[self.tagnum]['fullpath']) 285 | tag = filetag.tag() 286 | # Set tag fields according to entry text 287 | for entry, field in zip(entries, entries_names): 288 | tag_value = entry.get_text().strip() 289 | if field in ('year', 'track'): 290 | if len(tag_value) == 0: 291 | tag_value = '0' 292 | tag_value = int(tag_value) 293 | if field is 'comment': 294 | if len(tag_value) == 0: 295 | tag_value = ' ' 296 | setattr(tag, field, tag_value) 297 | 298 | save_success = filetag.save() 299 | if not (save_success): # FIXME: was (save_success and self.conn and self.status): 300 | ui.show_msg(self.window, _("Unable to save tag to music file."), _("Edit Tags"), 'editTagsError', gtk.BUTTONS_CLOSE, response_cb=ui.dialog_destroy) 301 | if self.tags_next_tag(tags): 302 | # Next file: 303 | self.tags_win_update(window, tags, entries, entries_names) 304 | else: 305 | # No more (valid) files: 306 | self.tagnum = self.tagnum + 1 # To ensure we update the last file in tags_mpd_update 307 | self.tags_win_hide(window, None, tags) 308 | 309 | def tags_win_hide(self, window, _data, tags): 310 | tag_paths = (tag['mpdpath'] for tag in tags[:self.tagnum]) 311 | gobject.idle_add(self.tags_mpd_update, tag_paths) 312 | window.destroy() 313 | self.tags_set_use_mpdpath(self.use_mpdpaths) 314 | 315 | def tags_win_genres(self): 316 | return ["", "A Cappella", "Acid", "Acid Jazz", "Acid Punk", "Acoustic", 317 | "Alt. Rock", "Alternative", "Ambient", "Anime", "Avantgarde", "Ballad", 318 | "Bass", "Beat", "Bebob", "Big Band", "Black Metal", "Bluegrass", 319 | "Blues", "Booty Bass", "BritPop", "Cabaret", "Celtic", "Chamber music", 320 | "Chanson", "Chorus", "Christian Gangsta Rap", "Christian Rap", 321 | "Christian Rock", "Classic Rock", "Classical", "Club", "Club-House", 322 | "Comedy", "Contemporary Christian", "Country", "Crossover", "Cult", 323 | "Dance", "Dance Hall", "Darkwave", "Death Metal", "Disco", "Dream", 324 | "Drum & Bass", "Drum Solo", "Duet", "Easy Listening", "Electronic", 325 | "Ethnic", "Euro-House", "Euro-Techno", "Eurodance", "Fast Fusion", 326 | "Folk", "Folk-Rock", "Folklore", "Freestyle", "Funk", "Fusion", "Game", 327 | "Gangsta", "Goa", "Gospel", "Gothic", "Gothic Rock", "Grunge", 328 | "Hard Rock", "Hardcore", "Heavy Metal", "Hip-Hop", "House", "Humour", 329 | "Indie", "Industrial", "Instrumental", "Instrumental pop", 330 | "Instrumental rock", "JPop", "Jazz", "Jazz+Funk", "Jungle", "Latin", 331 | "Lo-Fi", "Meditative", "Merengue", "Metal", "Musical", "National Folk", 332 | "Native American", "Negerpunk", "New Age", "New Wave", "Noise", 333 | "Oldies", "Opera", "Other", "Polka", "Polsk Punk", "Pop", "Pop-Folk", 334 | "Pop/Funk", "Porn Groove", "Power Ballad", "Pranks", "Primus", 335 | "Progressive Rock", "Psychedelic", "Psychedelic Rock", "Punk", 336 | "Punk Rock", "R&B", "Rap", "Rave", "Reggae", "Retro", "Revival", 337 | "Rhythmic soul", "Rock", "Rock & Roll", "Salsa", "Samba", "Satire", 338 | "Showtunes", "Ska", "Slow Jam", "Slow Rock", "Sonata", "Soul", 339 | "Sound Clip", "Soundtrack", "Southern Rock", "Space", "Speech", 340 | "Swing", "Symphonic Rock", "Symphony", "Synthpop", "Tango", "Techno", 341 | "Techno-Industrial", "Terror", "Thrash Metal", "Top 40", "Trailer"] 342 | 343 | def tags_win_entry_constraint(self, entry, new_text, _new_text_length, _broken_position, isyearlabel): 344 | entry_chars = entry.get_text() 345 | pos = entry.get_position() 346 | proposed_text = entry_chars[:pos] + new_text + entry_chars[pos:] 347 | 348 | # get the correct regular expression 349 | expr = r'(0|[1-9][0-9]{0,3})$' if isyearlabel else r'(0|[1-9][0-9]*)$' 350 | expr = re.compile(expr) 351 | 352 | if not expr.match(proposed_text): 353 | # deny 354 | entry.stop_emission("insert-text") 355 | 356 | def toggle_path(self, button): 357 | self.use_mpdpaths = not self.use_mpdpaths 358 | if self.use_mpdpaths: 359 | self.filelabel.set_text(self.curr_mpdpath) 360 | else: 361 | self.filelabel.set_text(os.path.basename(self.curr_mpdpath)) 362 | self.set_expandbutton_state(button) 363 | 364 | def set_expandbutton_state(self, button): 365 | if self.use_mpdpaths: 366 | button.get_child().set_markup('<') 367 | button.set_tooltip_text(_("Hide file path")) 368 | else: 369 | button.get_child().set_markup('>') 370 | button.set_tooltip_text(_("Show file path")) 371 | 372 | def set_use_mpdpaths(self, use_mpdpaths): 373 | self.use_mpdpaths = use_mpdpaths 374 | -------------------------------------------------------------------------------- /sonata/info.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import sys, os, locale 4 | 5 | import gtk 6 | 7 | import ui, misc 8 | import mpdhelper as mpdh 9 | from consts import consts 10 | from pluginsystem import pluginsystem 11 | 12 | class Info(object): 13 | def __init__(self, config, info_image, linkcolor, on_link_click_cb, get_playing_song, TAB_INFO, on_image_activate, on_image_motion_cb, on_image_drop_cb, album_return_artist_and_tracks, new_tab): 14 | self.config = config 15 | self.linkcolor = linkcolor 16 | self.on_link_click_cb = on_link_click_cb 17 | self.get_playing_song = get_playing_song 18 | self.album_return_artist_and_tracks = album_return_artist_and_tracks 19 | 20 | try: 21 | self.enc = locale.getpreferredencoding() 22 | except: 23 | print "Locale cannot be found; please set your system's locale. Aborting..." 24 | sys.exit(1) 25 | 26 | self.last_bitrate = None 27 | 28 | self.info_boxes_in_more = None 29 | self._editlabel = None 30 | self._editlyricslabel = None 31 | self.info_left_label = None 32 | self.info_lyrics = None 33 | self._morelabel = None 34 | self._searchlabel = None 35 | 36 | self.lyricsText = None 37 | self.albumText = None 38 | 39 | self.info_area = ui.scrollwindow(shadow=gtk.SHADOW_NONE) 40 | self.tab = new_tab(self.info_area, gtk.STOCK_JUSTIFY_FILL, TAB_INFO, self.info_area) 41 | 42 | image_width = -1 if self.config.info_art_enlarged else 152 43 | imagebox = ui.eventbox(w=image_width, add=info_image) 44 | imagebox.drag_dest_set(gtk.DEST_DEFAULT_HIGHLIGHT | 45 | gtk.DEST_DEFAULT_DROP, 46 | [("text/uri-list", 0, 80), 47 | ("text/plain", 0, 80)], gtk.gdk.ACTION_DEFAULT) 48 | imagebox.connect('button_press_event', on_image_activate) 49 | imagebox.connect('drag_motion', on_image_motion_cb) 50 | imagebox.connect('drag_data_received', on_image_drop_cb) 51 | self._imagebox = imagebox 52 | 53 | self._widgets_initialize() 54 | 55 | def _widgets_initialize(self): 56 | margin = 5 57 | outter_vbox = gtk.VBox() 58 | setupfuncs = (getattr(self, "_widgets_%s" % func) 59 | for func in ['song', 'lyrics', 'album']) 60 | for setup in setupfuncs: 61 | widget = setup() 62 | outter_vbox.pack_start(widget, False, False, margin) 63 | 64 | # Finish.. 65 | if not self.config.show_lyrics: 66 | ui.hide(self.info_lyrics) 67 | if not self.config.show_covers: 68 | ui.hide(self._imagebox) 69 | self.info_area.add_with_viewport(outter_vbox) 70 | 71 | def _widgets_song(self): 72 | info_song = ui.expander(markup="%s" % _("Song Info"), 73 | expand=self.config.info_song_expanded, 74 | can_focus=False) 75 | info_song.connect("activate", self._expanded, "song") 76 | 77 | self.info_labels = {} 78 | self.info_boxes_in_more = [] 79 | labels = [(_("Title"), 'title', False, "", False), 80 | (_("Artist"), 'artist', True, 81 | _("Launch artist in Wikipedia"), False), 82 | (_("Album"), 'album', True, 83 | _("Launch album in Wikipedia"), False), 84 | (_("Date"), 'date', False, "", False), 85 | (_("Track"), 'track', False, "", False), 86 | (_("Genre"), 'genre', False, "", False), 87 | (_("File"), 'file', False, "", True), 88 | (_("Bitrate"), 'bitrate', False, "", True)] 89 | 90 | tagtable = gtk.Table(len(labels), 2) 91 | tagtable.set_col_spacings(12) 92 | for i,(text, name, link, tooltip, in_more) in enumerate(labels): 93 | label = ui.label(markup="%s:" % text, y=0) 94 | tagtable.attach(label, 0, 1, i, i+1, yoptions=gtk.SHRINK) 95 | if i == 0: 96 | self.info_left_label = label 97 | # Using set_selectable overrides the hover cursor that 98 | # sonata tries to set for the links, and I can't figure 99 | # out how to stop that. So we'll disable set_selectable 100 | # for those labels until it's figured out. 101 | tmplabel2 = ui.label(wrap=True, y=0, select=not link) 102 | if link: 103 | tmpevbox = ui.eventbox(add=tmplabel2) 104 | self._apply_link_signals(tmpevbox, name, tooltip) 105 | to_pack = tmpevbox if link else tmplabel2 106 | tagtable.attach(to_pack, 1, 2, i, i+1, yoptions=gtk.SHRINK) 107 | self.info_labels[name] = tmplabel2 108 | if in_more: 109 | self.info_boxes_in_more.append(label) 110 | self.info_boxes_in_more.append(to_pack) 111 | 112 | self._morelabel = ui.label(y=0) 113 | self.toggle_more() 114 | moreevbox = ui.eventbox(add=self._morelabel) 115 | self._apply_link_signals(moreevbox, 'more', _("Toggle extra tags")) 116 | self._editlabel = ui.label(y=0) 117 | editevbox = ui.eventbox(add=self._editlabel) 118 | self._apply_link_signals(editevbox, 'edit', _("Edit song tags")) 119 | mischbox = gtk.HBox() 120 | mischbox.pack_start(moreevbox, False, False, 3) 121 | mischbox.pack_start(editevbox, False, False, 3) 122 | 123 | tagtable.attach(mischbox, 0, 2, len(labels), len(labels) + 1) 124 | inner_hbox = gtk.HBox() 125 | inner_hbox.pack_start(self._imagebox, False, False, 6) 126 | inner_hbox.pack_start(tagtable, False, False, 6) 127 | info_song.add(inner_hbox) 128 | return info_song 129 | 130 | def _widgets_lyrics(self): 131 | horiz_spacing = 2 132 | vert_spacing = 1 133 | self.info_lyrics = ui.expander(markup="%s" % _("Lyrics"), 134 | expand=self.config.info_lyrics_expanded, 135 | can_focus=False) 136 | self.info_lyrics.connect("activate", self._expanded, "lyrics") 137 | lyricsbox = gtk.VBox() 138 | self.lyricsText = ui.label(markup=" ", y=0, select=True, wrap=True) 139 | lyricsbox.pack_start(self.lyricsText, True, True, vert_spacing) 140 | lyricsbox_bottom = gtk.HBox() 141 | self._searchlabel = ui.label(y=0) 142 | self._editlyricslabel = ui.label(y=0) 143 | searchevbox = ui.eventbox(add=self._searchlabel) 144 | editlyricsevbox = ui.eventbox(add=self._editlyricslabel) 145 | self._apply_link_signals(searchevbox, 'search', _("Search Lyricwiki.org for lyrics")) 146 | self._apply_link_signals(editlyricsevbox, 'editlyrics', _("Edit lyrics at Lyricwiki.org")) 147 | lyricsbox_bottom.pack_start(searchevbox, False, False, horiz_spacing) 148 | lyricsbox_bottom.pack_start(editlyricsevbox, False, False, horiz_spacing) 149 | lyricsbox.pack_start(lyricsbox_bottom, False, False, vert_spacing) 150 | self.info_lyrics.add(lyricsbox) 151 | return self.info_lyrics 152 | 153 | def _widgets_album(self): 154 | info_album = ui.expander(markup="%s" % _("Album Info"), 155 | expand=self.config.info_album_expanded, 156 | can_focus=False) 157 | info_album.connect("activate", self._expanded, "album") 158 | self.albumText = ui.label(markup=" ", y=0, select=True, wrap=True) 159 | info_album.add(self.albumText) 160 | return info_album 161 | 162 | def get_widgets(self): 163 | return self.info_area 164 | 165 | def get_info_imagebox(self): 166 | return self._imagebox 167 | 168 | def show_lyrics_updated(self): 169 | func = "show" if self.config.show_lyrics else "hide" 170 | getattr(ui, func)(self.info_lyrics) 171 | 172 | def _apply_link_signals(self, widget, linktype, tooltip): 173 | widget.connect("enter-notify-event", self.on_link_enter) 174 | widget.connect("leave-notify-event", self.on_link_leave) 175 | widget.connect("button-press-event", self.on_link_click, linktype) 176 | widget.set_tooltip_text(tooltip) 177 | 178 | def on_link_enter(self, widget, _event): 179 | if widget.get_children()[0].get_use_markup(): 180 | ui.change_cursor(gtk.gdk.Cursor(gtk.gdk.HAND2)) 181 | 182 | def on_link_leave(self, _widget, _event): 183 | ui.change_cursor(None) 184 | 185 | def toggle_more(self): 186 | text = _("hide") if self.config.info_song_more else _("more") 187 | func = "show" if self.config.info_song_more else "hide" 188 | func = getattr(ui, func) 189 | self._morelabel.set_markup(misc.link_markup(text, True, True, 190 | self.linkcolor)) 191 | for hbox in self.info_boxes_in_more: 192 | func(hbox) 193 | 194 | def on_link_click(self, _widget, _event, linktype): 195 | if linktype == 'more': 196 | self.config.info_song_more = not self.config.info_song_more 197 | self.toggle_more() 198 | else: 199 | self.on_link_click_cb(linktype) 200 | 201 | def _expanded(self, expander, infotype): 202 | setattr(self.config, "info_%s_expanded" % infotype, 203 | not expander.get_expanded()) 204 | 205 | def clear_info(self): 206 | """Clear the info widgets of any information""" 207 | for label in self.info_labels.values(): 208 | label.set_text("") 209 | self._editlabel.set_text("") 210 | self._searchlabel.set_text("") 211 | self._editlyricslabel.set_text("") 212 | self._show_lyrics(None, None) 213 | self.albumText.set_text("") 214 | self.last_bitrate = "" 215 | 216 | def update(self, playing_or_paused, newbitrate, songinfo, update_all): 217 | # update_all = True means that every tag should update. This is 218 | # only the case on song and status changes. Otherwise we only 219 | # want to update the minimum number of widgets so the user can 220 | # do things like select label text. 221 | if not playing_or_paused: 222 | self.clear_info() 223 | return 224 | 225 | bitratelabel = self.info_labels['bitrate'] 226 | if self.last_bitrate != newbitrate: 227 | bitratelabel.set_text(newbitrate) 228 | self.last_bitrate = newbitrate 229 | 230 | if update_all: 231 | for func in ["song", "album", "lyrics"]: 232 | getattr(self, "_update_%s" % func)(songinfo) 233 | 234 | def _update_song(self, songinfo): 235 | artistlabel = self.info_labels['artist'] 236 | tracklabel = self.info_labels['track'] 237 | albumlabel = self.info_labels['album'] 238 | filelabel = self.info_labels['file'] 239 | 240 | for name in ['title', 'date', 'genre']: 241 | label = self.info_labels[name] 242 | label.set_text(mpdh.get(songinfo, name)) 243 | 244 | tracklabel.set_text(mpdh.get(songinfo, 'track', '', False)) 245 | artistlabel.set_markup(misc.link_markup(misc.escape_html( 246 | mpdh.get(songinfo, 'artist')), False, False, 247 | self.linkcolor)) 248 | albumlabel.set_markup(misc.link_markup(misc.escape_html( 249 | mpdh.get(songinfo, 'album')), False, False, 250 | self.linkcolor)) 251 | 252 | path = misc.file_from_utf8(os.path.join(self.config.musicdir[self.config.profile_num], mpdh.get(songinfo, 'file'))) 253 | if os.path.exists(path): 254 | filelabel.set_text(os.path.join(self.config.musicdir[self.config.profile_num], mpdh.get(songinfo, 'file'))) 255 | self._editlabel.set_markup(misc.link_markup(_("edit tags"), True, True, self.linkcolor)) 256 | else: 257 | filelabel.set_text(mpdh.get(songinfo, 'file')) 258 | self._editlabel.set_text("") 259 | 260 | def _update_album(self, songinfo): 261 | if 'album' not in songinfo: 262 | self.albumText.set_text(_("Album name not set.")) 263 | return 264 | 265 | artist, tracks = self.album_return_artist_and_tracks() 266 | albuminfo = _("Album info not found.") 267 | 268 | if tracks: 269 | tracks.sort(key=lambda x: mpdh.get(x, 'track', 0, True)) 270 | playtime = 0 271 | tracklist = [] 272 | for t in tracks: 273 | playtime += mpdh.get(t, 'time', 0, True) 274 | tracklist.append("%s. %s" % 275 | (mpdh.get(t, 'track', '0', 276 | False, 2), 277 | mpdh.get(t, 'title', 278 | os.path.basename( 279 | t['file'])))) 280 | 281 | album = mpdh.get(songinfo, 'album') 282 | year = mpdh.get(songinfo, 'date', None) 283 | playtime = misc.convert_time(playtime) 284 | albuminfo = "\n".join(i for i in (album, artist, year, 285 | playtime) if i) 286 | albuminfo += "\n\n" 287 | albuminfo += "\n".join(t for t in tracklist) 288 | 289 | self.albumText.set_text(albuminfo) 290 | 291 | def _update_lyrics(self, songinfo): 292 | if self.config.show_lyrics: 293 | if 'artist' in songinfo and 'title' in songinfo: 294 | self.get_lyrics_start(mpdh.get(songinfo, 'artist'), mpdh.get(songinfo, 'title'), mpdh.get(songinfo, 'artist'), mpdh.get(songinfo, 'title'), os.path.dirname(mpdh.get(songinfo, 'file'))) 295 | else: 296 | self._show_lyrics(None, None, error=_("Artist or song title not set.")) 297 | 298 | def _check_for_local_lyrics(self, artist, title, song_dir): 299 | locations = [consts.LYRICS_LOCATION_HOME, 300 | consts.LYRICS_LOCATION_PATH, 301 | consts.LYRICS_LOCATION_HOME_ALT, 302 | consts.LYRICS_LOCATION_PATH_ALT] 303 | for location in locations: 304 | filename = self.target_lyrics_filename(artist, title, 305 | song_dir, location) 306 | if os.path.exists(filename): 307 | return filename 308 | 309 | def get_lyrics_start(self, search_artist, search_title, filename_artist, filename_title, song_dir): 310 | filename_artist = misc.strip_all_slashes(filename_artist) 311 | filename_title = misc.strip_all_slashes(filename_title) 312 | filename = self._check_for_local_lyrics(filename_artist, filename_title, song_dir) 313 | lyrics = "" 314 | if filename: 315 | # If the lyrics only contain "not found", delete the file and try to 316 | # fetch new lyrics. If there is a bug in Sonata/SZI/LyricWiki that 317 | # prevents lyrics from being found, storing the "not found" will 318 | # prevent a future release from correctly fetching the lyrics. 319 | try: 320 | with open(filename, 'r') as f: 321 | lyrics = f.read() 322 | except IOError: 323 | pass 324 | if lyrics == _("Lyrics not found"): 325 | misc.remove_file(filename) 326 | filename = self._check_for_local_lyrics(filename_artist, filename_title, song_dir) 327 | if filename: 328 | # Re-use lyrics from file: 329 | try: 330 | with open(filename, 'r') as f: 331 | lyrics = f.read() 332 | except IOError: 333 | pass 334 | # Strip artist - title line from file if it exists, since we 335 | # now have that information visible elsewhere. 336 | header = "%s - %s\n\n" % (filename_artist, filename_title) 337 | if lyrics[:len(header)] == header: 338 | lyrics = lyrics[len(header):] 339 | self._show_lyrics(filename_artist, filename_title, lyrics=lyrics) 340 | else: 341 | # Fetch lyrics from lyricwiki.org etc. 342 | lyrics_fetchers = pluginsystem.get('lyrics_fetching') 343 | callback = lambda *args: self.get_lyrics_response( 344 | filename_artist, filename_title, song_dir, *args) 345 | if lyrics_fetchers: 346 | msg = _("Fetching lyrics...") 347 | for _plugin, cb in lyrics_fetchers: 348 | cb(callback, search_artist, search_title) 349 | else: 350 | msg = _("No lyrics plug-in enabled.") 351 | self._show_lyrics(filename_artist, filename_title, 352 | lyrics=msg) 353 | 354 | def get_lyrics_response(self, artist_then, title_then, song_dir, 355 | lyrics=None, error=None): 356 | if lyrics and not error: 357 | filename = self.target_lyrics_filename(artist_then, title_then, song_dir) 358 | # Save lyrics to file: 359 | misc.create_dir('~/.lyrics/') 360 | try: 361 | with open(filename, 'w') as f: 362 | lyrics = misc.unescape_html(lyrics) 363 | try: 364 | f.write(lyrics.decode(self.enc).encode('utf8')) 365 | except: 366 | f.write(lyrics) 367 | except IOError: 368 | pass 369 | 370 | self._show_lyrics(artist_then, title_then, lyrics, error) 371 | 372 | def _show_lyrics(self, artist_then, title_then, lyrics=None, error=None): 373 | # For error messages where there is no appropriate info: 374 | if not artist_then or not title_then: 375 | self._searchlabel.set_markup("") 376 | self._editlyricslabel.set_markup("") 377 | if error: 378 | self.lyricsText.set_markup(error) 379 | elif lyrics: 380 | self.lyricsText.set_markup(lyrics) 381 | else: 382 | self.lyricsText.set_markup("") 383 | return 384 | 385 | # Verify that we are displaying the correct lyrics: 386 | songinfo = self.get_playing_song() 387 | if not songinfo: 388 | return 389 | artist_now = misc.strip_all_slashes(mpdh.get(songinfo, 'artist', None)) 390 | title_now = misc.strip_all_slashes(mpdh.get(songinfo, 'title', None)) 391 | if artist_now == artist_then and title_now == title_then: 392 | self._searchlabel.set_markup(misc.link_markup( 393 | _("search"), True, True, self.linkcolor)) 394 | self._editlyricslabel.set_markup(misc.link_markup( 395 | _("edit"), True, True, self.linkcolor)) 396 | if error: 397 | self.lyricsText.set_markup(error) 398 | elif lyrics: 399 | try: 400 | self.lyricsText.set_markup(misc.escape_html(lyrics)) 401 | except: ### XXX why would this happen? 402 | self.lyricsText.set_text(lyrics) 403 | else: 404 | self.lyricsText.set_markup("") 405 | 406 | def resize_elements(self, notebook_allocation): 407 | # Resize labels in info tab to prevent horiz scrollbar: 408 | if self.config.show_covers: 409 | labelwidth = notebook_allocation.width - self.info_left_label.allocation.width - self._imagebox.allocation.width - 60 # 60 accounts for vert scrollbar, box paddings, etc.. 410 | else: 411 | labelwidth = notebook_allocation.width - self.info_left_label.allocation.width - 60 # 60 accounts for vert scrollbar, box paddings, etc.. 412 | if labelwidth > 100: 413 | for label in self.info_labels.values(): 414 | label.set_size_request(labelwidth, -1) 415 | # Resize lyrics/album gtk labels: 416 | labelwidth = notebook_allocation.width - 45 # 45 accounts for vert scrollbar, box paddings, etc.. 417 | self.lyricsText.set_size_request(labelwidth, -1) 418 | self.albumText.set_size_request(labelwidth, -1) 419 | 420 | def target_lyrics_filename(self, artist, title, song_dir, force_location=None): 421 | # FIXME Why did we have this condition here: if self.conn: 422 | lyrics_loc = force_location if force_location else self.config.lyrics_location 423 | # Note: *_ALT searching is for compatibility with other mpd clients (like ncmpcpp): 424 | file_map = { 425 | consts.LYRICS_LOCATION_HOME : ("~/.lyrics", "%s-%s.txt"), 426 | consts.LYRICS_LOCATION_PATH : (self.config.musicdir[self.config.profile_num], song_dir, "%s-%s.txt"), 427 | consts.LYRICS_LOCATION_HOME_ALT : ("~/.lyrics", "%s - %s.txt"), 428 | consts.LYRICS_LOCATION_PATH_ALT : (self.config.musicdir[self.config.profile_num], song_dir, "%s - %s.txt"), 429 | } 430 | return misc.file_from_utf8(misc.file_exists_insensitive( 431 | os.path.expanduser( 432 | os.path.join(*file_map[lyrics_loc])) 433 | % (artist, title))) 434 | --------------------------------------------------------------------------------