Index: channels/global_key.py ================================================================== --- channels/global_key.py +++ channels/global_key.py @@ -11,11 +11,11 @@ # it switches the currently playing radio station to another one in # bookmarks list. # # Valid key names are for example F9, G, R, N # - +return import keybinder from config import conf import action Index: config.py ================================================================== --- config.py +++ config.py @@ -170,12 +170,21 @@ if type(value) == dict: self[key].update(value) else: self[key] = value # descends into sub-dicts instead of wiping them with subkeys + + # check for existing filename in directory list + def find_in_dirs(self, dirs, file): + for d in dirs: + if os.path.exists(d+"/"+file): + return d+"/"+file + #-- actually fill global conf instance conf = ConfigDict() + + Index: help/streamtuner2.1 ================================================================== --- help/streamtuner2.1 +++ help/streamtuner2.1 @@ -1,9 +1,9 @@ .\" this is one of the nanoweb man pages .\" (many thanks to the manpage howto!) .\" -.TH streamtuner2 "July 2010" "BSD/Linux" "User Manuals" +.TH streamtuner2 "January 2014" "BSD/Linux" "User Manuals" .SH NAME streamtuner2 \- Browser for internet radio stations .SH SYNOPSIS .B streamtuner2 .I command @@ -20,15 +20,17 @@ It is written in Python and easy to extend. And besides the grapical interface, has a commandline interface. .SH OPTIONS -.B Display data from cache - .TP .BI help Prints out a summary of available commands. + +.PP +.B Cached data + .TP .BI stream " channel title" Searches for a station with the given title. Either looks in a single channel, or scans all plugins. .TP @@ -37,11 +39,11 @@ .TP .BI play " " [ channel ] " title" Invokes the configured audio player. .PP -.B Load data from directory service +.B Instantly retrieve data from directory service .TP .BI categories " channelname" Returns a nested JSON list of all categories/genres. .TP Index: mygtk.py ================================================================== --- mygtk.py +++ mygtk.py @@ -2,11 +2,11 @@ # encoding: UTF-8 # api: python # type: functions # title: mygtk helper functions # description: simplify usage of some gtk widgets -# version: 1.5 +# version: 1.6 # author: mario # license: public domain # # # Wrappers around gtk methods. The TreeView method .columns() allows @@ -23,20 +23,41 @@ # # # gtk modules -import pygtk -import gtk -import gtk.glade -import gobject +gtk = 0 # 0=gtk2, else gtk3 +if gtk: + from gi import pygtkcompat as pygtk + pygtk.enable() + pygtk.enable_gtk(version='3.0') + from gi.repository import Gtk as gtk + from gi.repository import GObject as gobject + from gi.repository import GdkPixbuf + ui_file = "gtk3.xml" +if not gtk: + import pygtk + import gtk + import gobject + ui_file = "ui.xml" + +# filesystem import os.path import copy +# debug def __print__(*args): print(" ".join([str(a) for a in args])) + + +try: + empty_pixbuf = gtk.gdk.pixbuf_new_from_data("\0\0\0\0",gtk.gdk.COLORSPACE_RGB,True,8,1,1,4) +except: + empty_pixbuf = GdkPixbuf.Pixbuf.new_from_data("\0\0\0\0", GdkPixbuf.Colorspace.RGB, True, 8, 1, 1, 4, None, None) + + # simplified gtk constructors --------------------------------------------- class mygtk: @@ -134,11 +155,11 @@ defaults = { str: "", unicode: u"", bool: False, int: 0, - gtk.gdk.Pixbuf: gtk.gdk.pixbuf_new_from_data("\0\0\0\0",gtk.gdk.COLORSPACE_RGB,True,8,1,1,4) + gtk.gdk.Pixbuf: empty_pixbuf } if gtk.gdk.Pixbuf in vartypes: pix_entry = vartypes.index(gtk.gdk.Pixbuf) # sort data into gtk liststore array @@ -155,11 +176,11 @@ # add ls.append(row) # had to be adapted for real TreeStore (would require additional input for grouping/level/parents) except: # brute-force typecast - ls.append( [va if ty==gtk.gdk.Pixbuf else ty(va) for va,ty in zip(row,vartypes)] ) + ls.append( [va if ty==gtk.gdk.Pixbuf else ty(va) for va,ty in zip(row,vartypes)] ) # apply array to widget widget.set_model(ls) return ls @@ -173,40 +194,48 @@ # simple two-level treeview display in one column # with entries = [main,[sub,sub], title,[...],...] # @staticmethod def tree(widget, entries, title="category", icon=gtk.STOCK_DIRECTORY): + # list types ls = gtk.TreeStore(str, str) + # add entries for entry in entries: if (type(entry) == str): main = ls.append(None, [entry, icon]) else: for sub_title in entry: ls.append(main, [sub_title, icon]) + # just one column tvcolumn = gtk.TreeViewColumn(title); widget.append_column(tvcolumn) + # inner display: icon & string pix = gtk.CellRendererPixbuf() txt = gtk.CellRendererText() + # position tvcolumn.pack_start(pix, expand=False) tvcolumn.pack_end(txt, expand=True) + # select array content source in treestore - tvcolumn.set_attributes(pix, stock_id=1) - tvcolumn.set_attributes(txt, text=0) + tvcolumn.add_attribute(pix, "stock_id", 1) + tvcolumn.add_attribute(txt, "text", 0) + # finalize widget.set_model(ls) tvcolumn.set_sort_column_id(0) + widget.set_search_column(0) #tvcolumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED) #tvcolumn.set_fixed_width(125]) - widget.set_search_column(0) #widget.expand_all() #widget.expand_row("3", False) #print(widget.row_expanded("3")) + return ls Index: st2.py ================================================================== --- st2.py +++ st2.py @@ -3,11 +3,11 @@ # api: python # type: application # title: streamtuner2 # description: directory browser for internet radio / audio streams # depends: gtk, pygtk, xml.dom.minidom, threading, lxml, pyquery, kronos -# version: 2.0.9 +# version: 2.1.0 # author: mario salzer # license: public domain # url: http://freshmeat.net/projects/streamtuner2 # config: # category: multimedia @@ -89,21 +89,17 @@ except: from threading import Thread Thread.stop = lambda self: None # gtk modules -import pygtk -import gtk -import gtk.glade -import gobject +from mygtk import pygtk, gtk, gobject, ui_file, mygtk # custom modules sys.path.insert(0, "/usr/share/streamtuner2") # pre-defined directory for modules sys.path.insert(0, ".") # pre-defined directory for modules from config import conf # initializes itself, so all conf.vars are available right away -from mygtk import mygtk # gtk treeview import http import action # needs workaround... (action.main=main) from channels import * from channels import __print__ import favicon @@ -135,12 +131,11 @@ # gtkrc stylesheet self.load_theme(), gui_startup(0.05) # instantiate gtk/glade widgets in current object gtk.Builder.__init__(self) - ui_file = [i for i in sum([[i, conf.share+"/"+i] for i in ["ui.xml", "st2.gtk"]], []) if os.path.exists(i)][0]; - gtk.Builder.add_from_file(self, ui_file), gui_startup(0.10) + gtk.Builder.add_from_file(self, conf.find_in_dirs([".", conf.share], ui_file)), gui_startup(0.10) # manual gtk operations self.extensionsCTM.set_submenu(self.extensions) # duplicates Station>Extension menu into stream context menu # initialize channels self.channels = { @@ -235,18 +230,19 @@ #self.shoutcast.gtk_list.set_search_column(4) - #-- Shortcut fo glade.get_widget() - # allows access to widgets as direct attributes instead of using .get_widget() - # also looks in self.channels[] for the named channel plugins + #-- Shortcut for glade.get_widget() + # Allows access to widgets as direct attributes instead of using .get_widget() + # Also looks in self.channels[] for the named channel plugins def __getattr__(self, name): if (self.channels.has_key(name)): return self.channels[name] # like self.shoutcast else: return self.get_object(name) # or gives an error if neither exists + # custom-named widgets are available from .widgets{} not via .get_widget() def get_widget(self, name): if self.widgets.has_key(name): return self.widgets[name] @@ -263,15 +259,17 @@ #except Exception,e: # print(e) # self.notebook_channels.set_current_page(0) # self.current_channel = "bookmarks" # return self.channels["bookmarks"] + def current_channel_gtk(self): i = self.notebook_channels.get_current_page() try: return self.channel_names[i] except: return "bookmarks" + # notebook tab clicked def channel_switch(self, notebook, page, page_num=0, *args): # can be called from channelmenu as well: @@ -289,81 +287,91 @@ print(self.channel().first_show()) except: print("channel .first_show() initialization error") - - # convert ListStore iter to row number def rowno(self): (model, iter) = self.model_iter() return model.get_path(iter)[0] + # currently selected entry in stations list, return complete data dict def row(self): return self.channel().stations() [self.rowno()] + # return ListStore object and Iterator for currently selected row in gtk.TreeView station list def model_iter(self): return self.channel().gtk_list.get_selection().get_selected() + # fetches a single varname from currently selected station entry def selected(self, name="url"): return self.row().get(name) + # play button def on_play_clicked(self, widget, event=None, *args): row = self.row() if row: self.channel().play(row) favicon.download_playing(row) + # streamripper def on_record_clicked(self, widget): row = self.row() action.record(row.get("url"), "audio/mp3", "url/direct", row=row) + # browse stream def on_homepage_stream_clicked(self, widget): url = self.selected("homepage") action.browser(url) + # browse channel def on_homepage_channel_clicked(self, widget, event=2): if event == 2 or event.type == gtk.gdk._2BUTTON_PRESS: __print__("dblclick") action.browser(self.channel().homepage) + # reload stream list in current channel-category def on_reload_clicked(self, widget=None, reload=1): __print__("reload", reload, self.current_channel, self.channels[self.current_channel], self.channel().current) category = self.channel().current self.thread( lambda: ( self.channel().load(category,reload), reload and self.bookmarks.heuristic_update(self.current_channel,category) ) ) + # thread a function, add to worker pool (for utilizing stop button) def thread(self, target, *args): thread = Thread(target=target, args=args) thread.start() self.working.append(thread) + # stop reload/update threads def on_stop_clicked(self, widget): while self.working: thread = self.working.pop() thread.stop() + # click in category list def on_category_clicked(self, widget, event, *more): category = self.channel().currentcat() __print__("on_category_clicked", category, self.current_channel) self.on_reload_clicked(None, reload=0) pass + # add current selection to bookmark store def bookmark(self, widget): self.bookmarks.add(self.row()) # code to update current list (set icon just in on-screen liststore, it would be updated with next display() anyhow - and there's no need to invalidate the ls cache, because that's referenced by model anyhow) @@ -372,39 +380,45 @@ model.set_value(iter, 0, gtk.STOCK_ABOUT) except: pass # refresh bookmarks tab self.bookmarks.load(self.bookmarks.default) + # reload category tree def update_categories(self, widget): Thread(target=self.channel().reload_categories).start() + # menu invocation: refresh favicons for all stations in current streams category def update_favicons(self, widget): entries = self.channel().stations() favicon.download_all(entries) + # save a file def save_as(self, widget): row = self.row() default_fn = row["title"] + ".m3u" fn = mygtk.save_file("Save Stream", None, default_fn, [(".m3u","*m3u"),(".pls","*pls"),(".xspf","*xspf"),(".smil","*smil"),(".asx","*asx"),("all files","*")]) if fn: action.save(row, fn) pass + # save current stream URL into clipboard def menu_copy(self, w): gtk.clipboard_get().set_text(self.selected("url")) + # remove an entry def delete_entry(self, w): n = self.rowno() del self.channel().stations()[ n ] self.channel().switch() self.channel().save() + # stream right click def station_context_menu(self, treeview, event): return station_context_menu(treeview, event) # wrapper to the static function @@ -432,10 +446,11 @@ # add text elif (type(text)==str): sbar_msg.append(1) mygtk.do(lambda:self.statusbar.push(sbar_cid, text)) pass + # load plugins from /usr/share/streamtuner2/channels/ def load_plugin_channels(self): # find plugin files @@ -478,10 +493,11 @@ traceback.print_exc() # default plugins conf.add_plugin_defaults(self.channels["bookmarks"].config, "bookmarks") #conf.add_plugin_defaults(self.channels["shoutcast"].config, "shoutcast") + # store window/widget states (sizes, selections, etc.) def app_state(self, widget): # gtk widget states widgetnames = ["win_streamtuner2", "toolbar", "notebook_channels", ] \ @@ -491,19 +507,21 @@ channelopts = {} #dict([(id, {"current":self.channels[id].current}) for id in self.channel_names]) for id in self.channels.keys(): if (self.channels[id]): channelopts[id] = {"current":self.channels[id].current} conf.save("state", channelopts, nice=1) + # apply gtkrc stylesheet def load_theme(self): if conf.get("theme"): for dir in (conf.dir, conf.share, "/usr/share"): f = dir + "/themes/" + conf.theme + "/gtk-2.0/gtkrc" if os.path.exists(f): gtk.rc_parse(f) pass + # end application and gtk+ main loop def gtk_main_quit(self, widget, *x): if conf.auto_save_appstate: self.app_state(widget) @@ -565,14 +583,16 @@ # aux win: search dialog (keeps search text in self.q) # and also: quick search textbox (uses main.q instead) class search (auxiliary_window): + # show search dialog def menu_search(self, w): self.search_dialog.show(); + # hide dialog box again def cancel(self, *args): self.search_dialog.hide() return True # stop any other gtk handlers @@ -626,10 +646,11 @@ # live search on directory server homepages def server_query(self, w): "unimplemented" + # don't search at all, open a web browser def google(self, w): self.cancel() action.browser("http://www.google.com/search?q=" + self.search_full.get_text()) @@ -684,19 +705,21 @@ # aux win: stream data editing dialog class streamedit (auxiliary_window): + # show stream data editing dialog def open(self, mw): row = main.row() for name in ("title", "playing", "genre", "homepage", "url", "favicon", "format", "extra"): w = main.get_widget("streamedit_" + name) if w: w.set_text((str(row.get(name)) if row.get(name) else "")) self.win_streamedit.show() + # copy widget contents to stream def save(self, w): row = main.row() for name in ("title", "playing", "genre", "homepage", "url", "favicon", "format", "extra"): @@ -703,18 +726,20 @@ w = main.get_widget("streamedit_" + name) if w: row[name] = w.get_text() main.channel().save() self.cancel(w) + # add a new list entry, update window def new(self, w): s = main.channel().stations() s.append({"title":"new", "url":"", "format":"audio/mp3", "genre":"", "listeners":1}); main.channel().switch() # update display main.channel().gtk_list.get_selection().select_path(str(len(s)-1)); # set cursor to last row self.open(w) + # hide window def cancel(self, *w): self.win_streamedit.hide() return True @@ -726,22 +751,25 @@ # aux win: settings UI class config_dialog (auxiliary_window): + # display win_config, pre-fill text fields from global conf. object def open(self, widget): self.add_plugins() self.apply(conf.__dict__, "config_", 0) #self.win_config.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse('#443399')) self.combobox_theme() self.win_config.show() + def hide(self, *args): self.win_config.hide() return True + # set/load values between gtk window and conf. dict def apply(self, config, prefix="config_", save=0): for key,val in config.iteritems(): # map non-alphanumeric chars from config{} to underscores in according gtk widget names @@ -759,10 +787,11 @@ elif (w and save): config[key] = w.get_active() elif (w): w.set_active(bool(val)) pass + # fill combobox def combobox_theme(self): # self.theme.combo_box_new_text() # find themes @@ -774,16 +803,18 @@ self.theme.append_text(themename) if conf.theme == themename: self.theme.set_active(num) # erase this function, so it only ever gets called once self.combobox_theme = lambda: None + # retrieve currently selected value def apply_theme(self): if self.theme.get_active() >= 0: conf.theme = self.theme.get_model()[ self.theme.get_active()][0] main.load_theme() + # add configuration setting definitions from plugins once = 0 def add_plugins(self): if self.once: @@ -813,10 +844,11 @@ self.add_( "config_"+opt["name"], gtk.Entry(), opt["description"] ) # spacer self.add_( "filler_pl_"+name, gtk.HSeparator() ) self.once = 1 + # put gtk widgets into config dialog notebook def add_(self, id, w, label=None, color=""): w.set_property("visible", True) main.widgets[id] = w @@ -890,50 +922,59 @@ # # Some feature extensions inject custom categories[] into streams{} # e.g. "search" adds its own category once activated, as does the "timer" plugin. # class bookmarks(GenericChannel): + # desc api = "streamtuner2" module = "bookmarks" title = "bookmarks" version = 0.4 base_url = "file:.config/streamtuner2/bookmarks.json" listformat = "*/*" + # i like this config = [ {"name":"like_my_bookmarks", "type":"boolean", "value":0, "description":"I like my bookmarks"}, ] + # content - categories = ["favourite", ] + categories = ["favourite", ] # timer, links, search, and links show up as needed current = "favourite" default = "favourite" streams = {"favourite":[], "search":[], "scripts":[], "timer":[], } + # cache list, to determine if a PLS url is bookmarked urls = [] # this channel does not actually retrieve/parse data from anywhere def update_categories(self): pass def update_streams(self, cat): return self.streams.get(cat, []) + # initial display def first_show(self): if not self.streams["favourite"]: self.cache() + + # all entries just come from "bookmarks.json" def cache(self): # stream list cache = conf.load(self.module) if (cache): self.streams = cache + + # save to cache file def save(self): conf.save(self.module, self.streams, nice=1) @@ -973,10 +1014,12 @@ # select a category in treeview def add_category(self, cat): if cat not in self.categories: # add category if missing self.categories.append(cat) self.display_categories() + + # change cursor def set_category(self, cat): self.add_category(cat) self.gtk_cat.get_selection().select_path(str(self.categories.index(cat))) return self.currentcat() @@ -1058,12 +1101,14 @@ #-- startup progress bar progresswin, progressbar = 0, 0 def gui_startup(p=0.0, msg="streamtuner2 is starting"): + global progresswin,progressbar if not progresswin: + # GtkWindow "progresswin" progresswin = gtk.Window() progresswin.set_property("title", "streamtuner2") progresswin.set_property("default_width", 300) progresswin.set_property("width_request", 300) @@ -1070,24 +1115,26 @@ progresswin.set_property("default_height", 30) progresswin.set_property("height_request", 30) progresswin.set_property("window_position", "center") progresswin.set_property("decorated", False) progresswin.set_property("visible", True) + # GtkProgressBar "progressbar" progressbar = gtk.ProgressBar() progressbar.set_property("visible", True) progressbar.set_property("show_text", True) progressbar.set_property("text", msg) progresswin.add(progressbar) progresswin.show_all() + try: if p<1: progressbar.set_fraction(p) progressbar.set_property("text", msg) while gtk.events_pending(): gtk.main_iteration(False) else: - progresswin.destroy() + progresswin.hide() except: return Index: ui.xml ================================================================== --- ui.xml +++ ui.xml @@ -1710,11 +1710,26 @@ True False 10 - + + True + False + 1 + 0 + 2 + You can enable <i>channels</i> and <i>plugins</i> here. Changes take effect after restarting streamtuner2. + True + right + True + + + True + True + 0 + @@ -2467,10 +2482,11 @@ True False False True True +