Stefan Schuermans commited on 2013-11-22 20:44:32
              Showing 4 changed files, with 223 additions and 71 deletions.
            
| ... | ... | @@ -5,11 +5,31 @@ import re | 
| 5 | 5 | import time_fmt | 
| 6 | 6 |  | 
| 7 | 7 | class Playlist: | 
| 8 | + """ playlist object, reads a playlist form a file and handles the playlist | |
| 9 | + entries | |
| 10 | + | |
| 11 | + playlist file format: | |
| 12 | + - each line is an entry or a stop point: <line> = <entry> | <stop point> | |
| 13 | + - entries are played one after another | |
| 14 | + - playing halts at stop points | |
| 15 | + - <entry> = <name> <whitespace> <duration> | |
| 16 | + - <stop point> = "" | |
| 17 | + - <name> = [A-Za-Z0-9_]+ | |
| 18 | + - <duration> = ((<hours>:)?<minutes>:)?<seconds> | |
| 19 | + - <hours> = [0-9]+ | |
| 20 | + - <minutes> = [0-9]+ | |
| 21 | + - <seconds> = [0-9]+(.[0-9]+)""" | |
| 22 | + | |
| 8 | 23 | def __init__(self): | 
| 9 | - self.entries = [] | |
| 24 | + """create a new, empty playlist object""" | |
| 25 | + self.entries = [] # list of entries | |
| 26 | +                      # entry = dictionary { "type": "normal" or "stop" | |
| 27 | + # "name": string, name of entry | |
| 28 | + # "durtaion": float, in seconds } | |
| 10 | 29 |      self.reEntry = re.compile("^\s*([A-Za-z0-9_]+)\s+([0-9:.]+)\s*$") | 
| 11 | 30 |  | 
| 12 | 31 | def read(self, filename): | 
| 32 | + """read the playlist from filename, replacing the current playlist""" | |
| 13 | 33 | self.entries = [] | 
| 14 | 34 | f = open(filename, "r") | 
| 15 | 35 | for line in f: | 
| ... | ... | @@ -28,7 +48,10 @@ class Playlist: | 
| 28 | 48 | f.close() | 
| 29 | 49 |  | 
| 30 | 50 | def update(self, store): | 
| 51 | + """update the contents of a Gtk ListStore with the contents of this | |
| 52 | + playlist""" | |
| 31 | 53 | store.clear() | 
| 54 | + idx = 0 | |
| 32 | 55 | for entry in self.entries: | 
| 33 | 56 | if entry["type"] == "normal": | 
| 34 | 57 | name = entry["name"] | 
| ... | ... | @@ -36,5 +59,6 @@ class Playlist: | 
| 36 | 59 | else: | 
| 37 | 60 | name = "" | 
| 38 | 61 | duration = "STOP" | 
| 39 | - store.append([name, duration]) | |
| 62 | + store.append([idx, name, duration]) | |
| 63 | + idx = idx + 1 | |
| 40 | 64 |  | 
| ... | ... | @@ -1,28 +1,6 @@ | 
| 1 | 1 | <?xml version="1.0" encoding="UTF-8"?> | 
| 2 | 2 | <interface> | 
| 3 | 3 | <!-- interface-requires gtk+ 3.0 --> | 
| 4 | - <object class="GtkListStore" id="PlaylistStore"> | |
| 5 | - <columns> | |
| 6 | - <!-- column-name Name --> | |
| 7 | - <column type="gchararray"/> | |
| 8 | - <!-- column-name Dauer --> | |
| 9 | - <column type="gchararray"/> | |
| 10 | - </columns> | |
| 11 | - <data> | |
| 12 | - <row> | |
| 13 | - <col id="0" translatable="yes">Erster Akt</col> | |
| 14 | - <col id="1" translatable="yes"/> | |
| 15 | - </row> | |
| 16 | - <row> | |
| 17 | - <col id="0" translatable="yes">Zweiter Akt</col> | |
| 18 | - <col id="1" translatable="yes"/> | |
| 19 | - </row> | |
| 20 | - <row> | |
| 21 | - <col id="0" translatable="yes">Weltuntergang</col> | |
| 22 | - <col id="1" translatable="yes"/> | |
| 23 | - </row> | |
| 24 | - </data> | |
| 25 | - </object> | |
| 26 | 4 | <object class="GtkWindow" id="MainWindow"> | 
| 27 | 5 | <property name="visible">True</property> | 
| 28 | 6 | <property name="can_focus">False</property> | 
| ... | ... | @@ -46,6 +24,8 @@ | 
| 46 | 24 | <property name="visible">True</property> | 
| 47 | 25 | <property name="can_focus">True</property> | 
| 48 | 26 | <property name="model">PlaylistStore</property> | 
| 27 | + <signal name="cursor-changed" handler="onPlaylistClick" swapped="no"/> | |
| 28 | + <signal name="row-activated" handler="onPlaylistDblClick" swapped="no"/> | |
| 49 | 29 | <child internal-child="selection"> | 
| 50 | 30 | <object class="GtkTreeSelection" id="treeview-selection2"/> | 
| 51 | 31 | </child> | 
| ... | ... | @@ -301,6 +281,33 @@ | 
| 301 | 281 | </object> | 
| 302 | 282 | </child> | 
| 303 | 283 | </object> | 
| 284 | + <object class="GtkListStore" id="PlaylistStore"> | |
| 285 | + <columns> | |
| 286 | + <!-- column-name EntryIdx --> | |
| 287 | + <column type="guint"/> | |
| 288 | + <!-- column-name Name --> | |
| 289 | + <column type="gchararray"/> | |
| 290 | + <!-- column-name Dauer --> | |
| 291 | + <column type="gchararray"/> | |
| 292 | + </columns> | |
| 293 | + <data> | |
| 294 | + <row> | |
| 295 | + <col id="0">0</col> | |
| 296 | + <col id="1" translatable="yes">Erster Akt</col> | |
| 297 | + <col id="2" translatable="yes">1:00:00</col> | |
| 298 | + </row> | |
| 299 | + <row> | |
| 300 | + <col id="0">1</col> | |
| 301 | + <col id="1" translatable="yes">Zweiter Akt</col> | |
| 302 | + <col id="2" translatable="yes">23:42</col> | |
| 303 | + </row> | |
| 304 | + <row> | |
| 305 | + <col id="0">2</col> | |
| 306 | + <col id="1" translatable="yes">Weltuntergang</col> | |
| 307 | + <col id="2" translatable="yes">0.5</col> | |
| 308 | + </row> | |
| 309 | + </data> | |
| 310 | + </object> | |
| 304 | 311 | <object class="GtkAdjustment" id="Position"> | 
| 305 | 312 | <property name="upper">100</property> | 
| 306 | 313 | <property name="value">50</property> | 
| ... | ... | @@ -2,6 +2,8 @@ | 
| 2 | 2 |  | 
| 3 | 3 | import os | 
| 4 | 4 | from gi.repository import Gtk | 
| 5 | +import gobject | |
| 6 | +import time | |
| 5 | 7 |  | 
| 6 | 8 | import playlist | 
| 7 | 9 | import time_fmt | 
| ... | ... | @@ -9,21 +11,25 @@ import time_fmt | 
| 9 | 11 | scriptdir = os.path.dirname(os.path.abspath(__file__)) | 
| 10 | 12 |  | 
| 11 | 13 | class SyncGui: | 
| 14 | + | |
| 12 | 15 | def __init__(self): | 
| 16 | + """construct a SyncGui object""" | |
| 13 | 17 | self.builder = Gtk.Builder() | 
| 14 | 18 | self.builder.add_from_file(scriptdir + "/sync_gui.glade") | 
| 15 | -    self.playlistView = self.builder.get_object("PlaylistView") | |
| 16 | -    self.playlistStore = self.builder.get_object("PlaylistStore") | |
| 17 | -    self.position = self.builder.get_object("Position") | |
| 18 | -    self.positionScale = self.builder.get_object("PositionScale") | |
| 19 | -    self.positionAt = self.builder.get_object("PositionAt") | |
| 20 | -    self.positionRemaining = self.builder.get_object("PositionRemaining") | |
| 21 | -    self.btnPause = self.builder.get_object("Pause") | |
| 22 | -    self.btnPlay = self.builder.get_object("Play") | |
| 23 | -    self.status = self.builder.get_object("Status") | |
| 19 | +    self.widPlaylistView = self.builder.get_object("PlaylistView") | |
| 20 | +    self.widPlaylistStore = self.builder.get_object("PlaylistStore") | |
| 21 | +    self.widPosition = self.builder.get_object("Position") | |
| 22 | +    self.widPositionScale = self.builder.get_object("PositionScale") | |
| 23 | +    self.widPositionAt = self.builder.get_object("PositionAt") | |
| 24 | +    self.widPositionRemaining = self.builder.get_object("PositionRemaining") | |
| 25 | +    self.widBtnPause = self.builder.get_object("Pause") | |
| 26 | +    self.widBtnPlay = self.builder.get_object("Play") | |
| 27 | +    self.widStatus = self.builder.get_object("Status") | |
| 24 | 28 | self.configPlaylistColumns() | 
| 25 | 29 |      handlers = { | 
| 26 | 30 | "onDestroy": self.onDestroy, | 
| 31 | + "onPlaylistClick": self.onPlaylistClick, | |
| 32 | + "onPlaylistDblClick": self.onPlaylistDblClick, | |
| 27 | 33 | "onNewPosition": self.onNewPosition, | 
| 28 | 34 | "onPrevious": self.onPrevious, | 
| 29 | 35 | "onBackward": self.onBackward, | 
| ... | ... | @@ -36,68 +42,170 @@ class SyncGui: | 
| 36 | 42 | self.builder.connect_signals(handlers) | 
| 37 | 43 | self.playlist = playlist.Playlist() | 
| 38 | 44 |      self.playlist.read("playlist.txt") | 
| 39 | - self.playlist.update(self.playlistStore) | |
| 40 | - self.status.push(0, "TODO...") | |
| 41 | - self.showCurrentPosition() | |
| 45 | + self.playlist.update(self.widPlaylistStore) | |
| 46 | + self.widStatus.push(0, "TODO...") | |
| 47 | + self.stEntryIdx = -1 # no entry selected | |
| 48 | + self.stName = "" # no current entry name | |
| 49 | + self.stDuration = 0 # current entry has zero size | |
| 50 | + self.stPosition = 0 # at begin of current entry | |
| 51 | + self.stPlaying = False # not playing | |
| 52 | + gobject.timeout_add(10, self.onTimer10ms) | |
| 53 | + self.updateDuration() | |
| 54 | + self.updateButtonVisibility() | |
| 42 | 55 |  | 
| 43 | 56 | def configPlaylistColumns(self): | 
| 44 | - i = 0 | |
| 57 | + """configure the columns of the playlist widget at program start""" | |
| 58 | + i = 1 # first column is index (not shown) | |
| 45 | 59 | for title in ["Name", "Dauer"]: | 
| 46 | 60 | column = Gtk.TreeViewColumn(title) | 
| 47 | - self.playlistView.append_column(column) | |
| 61 | + self.widPlaylistView.append_column(column) | |
| 48 | 62 | cell = Gtk.CellRendererText() | 
| 49 | 63 | column.pack_start(cell, False) | 
| 50 | 64 | column.add_attribute(cell, "text", i) | 
| 51 | 65 | i = i + 1 | 
| 52 | 66 |  | 
| 53 | - def showPosition(self, sec): | |
| 54 | - if sec < 0: | |
| 55 | - sec = 0 | |
| 56 | - if sec > self.position.get_upper(): | |
| 57 | - sec = self.position.get_upper() | |
| 58 | - posAt = time_fmt.sec2str(sec) | |
| 59 | - posRemaining = time_fmt.sec2str(self.position.get_upper() - sec) | |
| 60 | - self.positionAt.set_text(posAt) | |
| 61 | - self.positionRemaining.set_text(posRemaining) | |
| 62 | - | |
| 63 | - def showCurrentPosition(self): | |
| 64 | - sec = self.positionScale.get_value() | |
| 65 | - self.showPosition(sec) | |
| 67 | + def showPosition(self): | |
| 68 | + """update the position texts next to the position slider""" | |
| 69 | + # format current time and remaining time | |
| 70 | + posAt = time_fmt.sec2str(self.stPosition) | |
| 71 | + posRemaining = time_fmt.sec2str(self.stDuration - self.stPosition) | |
| 72 | + self.widPositionAt.set_text(posAt) | |
| 73 | + self.widPositionRemaining.set_text(posRemaining) | |
| 74 | + | |
| 75 | + def updatePositionState(self): | |
| 76 | + """update the position in the state, but not the slider""" | |
| 77 | + # calculate (virtual) start time of playing | |
| 78 | + # i.e. the time the playing would have had started to arrive at the | |
| 79 | + # current position now if it had played continuosly | |
| 80 | + self.stPlayStart = time.time() - self.stPosition | |
| 81 | + # update position texts | |
| 82 | + self.showPosition() | |
| 83 | + | |
| 84 | + def updatePosition(self): | |
| 85 | + """update the position including the position slider""" | |
| 86 | + # update GUI slider | |
| 87 | + self.widPositionScale.set_value(self.stPosition) | |
| 88 | + # update position state | |
| 89 | + self.updatePositionState() | |
| 90 | + | |
| 91 | + def updateDuration(self): | |
| 92 | + """update the duration (i.e. range for the slider) based on the current | |
| 93 | + playlist entry""" | |
| 94 | + # get duration of new playlist entry | |
| 95 | + self.stDuration = 0 | |
| 96 | + if self.stEntryIdx >= 0: | |
| 97 | + entry = self.playlist.entries[self.stEntryIdx] | |
| 98 | + if entry["type"] == "normal": | |
| 99 | + self.stDuration = entry["duration"] | |
| 100 | + # set position to begin | |
| 101 | + self.stPosition = 0 | |
| 102 | + # update value range | |
| 103 | + self.widPosition.set_upper(self.stDuration) | |
| 104 | + # update position of slider | |
| 105 | + self.updatePosition() | |
| 106 | + | |
| 107 | + def updateButtonVisibility(self): | |
| 108 | + """update the visibility of the buttons based on if playing or not""" | |
| 109 | + self.widBtnPause.set_visible(self.stPlaying) | |
| 110 | + self.widBtnPlay.set_visible(not self.stPlaying) | |
| 66 | 111 |  | 
| 67 | 112 | def onDestroy(self, widget): | 
| 113 | + """window will be destroyed""" | |
| 68 | 114 | Gtk.main_quit() | 
| 69 | 115 |  | 
| 116 | + def onPlaylistClick(self, widget): | |
| 117 | + """playlist entry has been clicked or selected""" | |
| 118 | + # get index of selected entry | |
| 119 | + idx = -1 | |
| 120 | + sel = self.widPlaylistView.get_selection() | |
| 121 | + if sel is not None: | |
| 122 | + (model, it) = sel.get_selected() | |
| 123 | + if it is not None: | |
| 124 | + (idx, ) = model.get(it, 0) | |
| 125 | +    print("DEBUG: playlist click idx=%d" % (idx)) | |
| 126 | + # update playlist entry | |
| 127 | + self.stEntryIdx = idx | |
| 128 | + # update duration | |
| 129 | + self.updateDuration() | |
| 130 | + | |
| 131 | + def onPlaylistDblClick(self, widget, row, col): | |
| 132 | + """playlist entry has been double-clicked""" | |
| 133 | + # get index of selected entry | |
| 134 | + idx = -1 | |
| 135 | + sel = self.widPlaylistView.get_selection() | |
| 136 | + if sel is not None: | |
| 137 | + (model, it) = sel.get_selected() | |
| 138 | + if it is not None: | |
| 139 | + (idx, ) = model.get(it, 0) | |
| 140 | +    print("DEBUG: playlist double-click idx=%d" % (idx)) | |
| 141 | + # update playlist entry | |
| 142 | + self.stEntryIdx = idx | |
| 143 | + # update duration | |
| 144 | + self.updateDuration() | |
| 145 | + # start playing | |
| 146 | + # TODO | |
| 147 | + | |
| 70 | 148 | def onNewPosition(self, widget, scroll, value): | 
| 71 | -    print("new position " + str(value)); | |
| 72 | - self.showPosition(value) | |
| 149 | + """slider has been moved to a new position""" | |
| 150 | +    print("DEBUG: new position " + str(value)); | |
| 151 | + # clamp position to valid range | |
| 152 | + if value < 0: | |
| 153 | + value = 0 | |
| 154 | + if value > self.stDuration: | |
| 155 | + value = self.stDuration | |
| 156 | + # update current position - and play start time if playing | |
| 157 | + self.stPosition = value | |
| 158 | + # update position state (do not touch the slider) | |
| 159 | + self.updatePositionState() | |
| 73 | 160 |  | 
| 74 | 161 | def onPrevious(self, widget): | 
| 75 | -    print("previous") | |
| 162 | + """previous button as been pressed""" | |
| 163 | +    print("DEBUG: previous") | |
| 76 | 164 |  | 
| 77 | 165 | def onBackward(self, widget): | 
| 78 | -    print("backward") | |
| 166 | + """backward button has been pressed""" | |
| 167 | +    print("DEBUG: backward") | |
| 79 | 168 |  | 
| 80 | 169 | def onStop(self, widget): | 
| 81 | -    print("stop") | |
| 82 | - self.btnPause.set_visible(False) | |
| 83 | - self.btnPlay.set_visible(True) | |
| 170 | + """stop button has been pressed""" | |
| 171 | +    print("DEBUG: stop") | |
| 172 | + self.stPlaying = False | |
| 173 | + self.stPosition = 0 # stop goes back to begin | |
| 174 | + self.updatePosition() | |
| 175 | + self.updateButtonVisibility() | |
| 84 | 176 |  | 
| 85 | 177 | def onPause(self, widget): | 
| 86 | -    print("pause") | |
| 87 | - self.btnPause.set_visible(False) | |
| 88 | - self.btnPlay.set_visible(True) | |
| 178 | + """pause button has been pressed""" | |
| 179 | +    print("DEBUG: pause") | |
| 180 | + self.stPlaying = False | |
| 181 | + self.updateButtonVisibility() | |
| 89 | 182 |  | 
| 90 | 183 | def onPlay(self, widget): | 
| 91 | -    print("play") | |
| 92 | - self.btnPause.set_visible(True) | |
| 93 | - self.btnPlay.set_visible(False) | |
| 184 | + """play button has been pressed""" | |
| 185 | +    print("DEBUG: play") | |
| 186 | + self.stPlaying = True | |
| 187 | + self.updatePosition() | |
| 188 | + self.updateButtonVisibility() | |
| 94 | 189 |  | 
| 95 | 190 | def onForward(self, widget): | 
| 96 | -    print("forward") | |
| 191 | + """forward button has been pressed""" | |
| 192 | +    print("DEBUG: forward") | |
| 97 | 193 |  | 
| 98 | 194 | def onNext(self, widget): | 
| 99 | -    print("next") | |
| 100 | - | |
| 195 | + """next button has been pressed""" | |
| 196 | +    print("DEBUG: next") | |
| 197 | + | |
| 198 | + def onTimer10ms(self): | |
| 199 | + """timer callback, every 10ms""" | |
| 200 | + # update position if playing | |
| 201 | + if self.stPlaying: | |
| 202 | + self.stPosition = time.time() - self.stPlayStart | |
| 203 | + if self.stPosition > self.stDuration: | |
| 204 | + self.stPosition = 0 # FIXME: go to next entry | |
| 205 | + self.updatePosition() | |
| 206 | + return True | |
| 207 | + | |
| 208 | +# main application entry point | |
| 101 | 209 | if __name__ == "__main__": | 
| 102 | 210 | app = SyncGui() | 
| 103 | 211 | Gtk.main() | 
| ... | ... | @@ -1,12 +1,24 @@ | 
| 1 | 1 | #! /usr/bin/env python | 
| 2 | 2 |  | 
| 3 | +"""time format converter | |
| 4 | + | |
| 5 | +converts between time in seconds as floating point value and time as | |
| 6 | +human-readable string in hours, minutes and seconds | |
| 7 | + | |
| 8 | +<time string> = ((<hours>:)?<minutes>:)?<seconds> | |
| 9 | +<hours> = [0-9]+ | |
| 10 | +<minutes> = [0-9]+ | |
| 11 | +<seconds> = [0-9]+(.[0-9]+)""" | |
| 12 | + | |
| 3 | 13 | def sec2str(sec): | 
| 14 | + """convert time in seconds to human-readable time string""" | |
| 4 | 15 | sign = "" | 
| 5 | - if sec < 0: | |
| 16 | + sec100 = round(sec * 100) | |
| 17 | + if sec100 < 0: | |
| 6 | 18 | sign = "-"; | 
| 7 | - sec = -sec; | |
| 8 | - sec1 = int(sec) | |
| 9 | - sec100 = round((sec - sec1) * 100) | |
| 19 | + sec100 = -sec100; | |
| 20 | + sec1 = sec100 // 100 | |
| 21 | + sec100 = sec100 % 100 | |
| 10 | 22 | minu = sec1 // 60 | 
| 11 | 23 | sec1 = sec1 % 60 | 
| 12 | 24 | hour = minu // 60 | 
| ... | ... | @@ -14,6 +26,7 @@ def sec2str(sec): | 
| 14 | 26 | return "%s%u:%02u:%02u.%02u" % (sign, hour, minu, sec1, sec100) | 
| 15 | 27 |  | 
| 16 | 28 | def str2sec(str): | 
| 29 | + """convert a human readable time string into time in seconds""" | |
| 17 | 30 | total = 0 | 
| 18 | 31 | section = 0 | 
| 19 | 32 | sign = 1 | 
| 20 | 33 |