ektoplayer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +49 -0
  3. data/bin/ektoplayer +7 -0
  4. data/lib/ektoplayer.rb +10 -0
  5. data/lib/ektoplayer/application.rb +148 -0
  6. data/lib/ektoplayer/bindings.rb +230 -0
  7. data/lib/ektoplayer/browsepage.rb +138 -0
  8. data/lib/ektoplayer/client.rb +18 -0
  9. data/lib/ektoplayer/common.rb +91 -0
  10. data/lib/ektoplayer/config.rb +247 -0
  11. data/lib/ektoplayer/controllers/browser.rb +47 -0
  12. data/lib/ektoplayer/controllers/controller.rb +9 -0
  13. data/lib/ektoplayer/controllers/help.rb +21 -0
  14. data/lib/ektoplayer/controllers/info.rb +22 -0
  15. data/lib/ektoplayer/controllers/mainwindow.rb +40 -0
  16. data/lib/ektoplayer/controllers/playlist.rb +60 -0
  17. data/lib/ektoplayer/database.rb +199 -0
  18. data/lib/ektoplayer/events.rb +56 -0
  19. data/lib/ektoplayer/models/browser.rb +127 -0
  20. data/lib/ektoplayer/models/database.rb +49 -0
  21. data/lib/ektoplayer/models/model.rb +15 -0
  22. data/lib/ektoplayer/models/player.rb +28 -0
  23. data/lib/ektoplayer/models/playlist.rb +72 -0
  24. data/lib/ektoplayer/models/search.rb +42 -0
  25. data/lib/ektoplayer/models/trackloader.rb +17 -0
  26. data/lib/ektoplayer/mp3player.rb +151 -0
  27. data/lib/ektoplayer/operations/browser.rb +19 -0
  28. data/lib/ektoplayer/operations/operations.rb +26 -0
  29. data/lib/ektoplayer/operations/player.rb +11 -0
  30. data/lib/ektoplayer/operations/playlist.rb +67 -0
  31. data/lib/ektoplayer/theme.rb +102 -0
  32. data/lib/ektoplayer/trackloader.rb +146 -0
  33. data/lib/ektoplayer/ui.rb +404 -0
  34. data/lib/ektoplayer/ui/colors.rb +105 -0
  35. data/lib/ektoplayer/ui/widgets.rb +195 -0
  36. data/lib/ektoplayer/ui/widgets/container.rb +125 -0
  37. data/lib/ektoplayer/ui/widgets/labelwidget.rb +43 -0
  38. data/lib/ektoplayer/ui/widgets/listwidget.rb +332 -0
  39. data/lib/ektoplayer/ui/widgets/tabbedcontainer.rb +110 -0
  40. data/lib/ektoplayer/updater.rb +77 -0
  41. data/lib/ektoplayer/views/browser.rb +25 -0
  42. data/lib/ektoplayer/views/help.rb +46 -0
  43. data/lib/ektoplayer/views/info.rb +208 -0
  44. data/lib/ektoplayer/views/mainwindow.rb +64 -0
  45. data/lib/ektoplayer/views/playinginfo.rb +135 -0
  46. data/lib/ektoplayer/views/playlist.rb +39 -0
  47. data/lib/ektoplayer/views/progressbar.rb +51 -0
  48. data/lib/ektoplayer/views/splash.rb +99 -0
  49. data/lib/ektoplayer/views/trackrenderer.rb +137 -0
  50. data/lib/ektoplayer/views/volumemeter.rb +74 -0
  51. metadata +164 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 6f7e3bffc50021abf9f3c8384db4c81952abb036
4
+ data.tar.gz: a7d7612d2175422b4e1f59d01e6c3992915bbb6c
5
+ SHA512:
6
+ metadata.gz: 788048c6464ae80c0d1d1ef2d0a85a307787ab8d8b7116cb4f2bf934223087238d5eb2af9ce48973899a248daee415aca40a23cf3a63c80d2554ab5a8fd7c8a7
7
+ data.tar.gz: 16bb67c65cd1b0b6c530d4f6e74f36b32d3a727e44125c8ba29c1b5e1d467362ec36d5b81ceb7a6ce5aa223d4cedd6accd8ea354b2f871d765fd581c1b82cd1a
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # Ektoplayer
2
+
3
+ Ektoplayer is a console audio player for [ektplazm.com](http://www.ektoplazm.com).
4
+
5
+ ![Screenshot 2017-03-16](http://pixelbanane.de/yafu/118231024/ekto1.png)
6
+ ![Screenshot 2017-03-16](http://pixelbanane.de/yafu/324630271/ekto2.png)
7
+
8
+ ## Requirements
9
+
10
+ * Ruby (1.9)
11
+ * Portaudio (19)
12
+ * Mpg123 (1.14)
13
+ * Sqlite3
14
+
15
+ ## Installation
16
+
17
+ Assuming you have Ruby/Rubygems installed, you need portaudio and mpg123 as
18
+ library to compile the native extensions.
19
+
20
+ ### Arch Linux
21
+
22
+ pacman -S portaudio mpg123 ncurses ruby sqlite
23
+ gem install ektoplayer
24
+
25
+ ### Debian / Ubuntu (not yet tested)
26
+
27
+ apt-get install portaudio19-dev libmpg123-dev libncurses-dev ruby1.9.1-dev sqlite-??
28
+ gem install ektoplayer
29
+
30
+ ## Features
31
+
32
+ * Listen to ektoplazm tracks
33
+ * Download whole albums
34
+ * Browse database by tags
35
+ * Vi keybindings (`hjkl`, `^d`, `^u`, `/`, `?`, `n`, `N`, `:`, ...)
36
+ * Mouse is supported
37
+ * Supports 256/16/mono colors
38
+ * Local sound file cache and download archive
39
+ * Highly configurable
40
+
41
+ ## Authors
42
+
43
+ * [Benjamin Abendroth](https://github.com/braph)
44
+
45
+ ## See also
46
+
47
+ * Ektoplayer was inspired by [Soundcloud2000](https://github.com/grobie/soundcloud2000) and [ncmpcpp](https://github.com/arybczak/ncmpcpp)
48
+ * It uses [Audite](https://github.com/georgi/audite) as playback engine and [Nokogiri](http://www.nokogiri.org/) for parsing HTML
49
+
data/bin/ektoplayer ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib = File.expand_path('../lib', File.dirname(__FILE__))
4
+ $LOAD_PATH.unshift(lib) if File.directory?(lib)
5
+
6
+ require 'ektoplayer'
7
+ Ektoplayer::Ektoplayer.start
data/lib/ektoplayer.rb ADDED
@@ -0,0 +1,10 @@
1
+ require_relative 'ektoplayer/application'
2
+
3
+ module Ektoplayer
4
+ class Ektoplayer
5
+ def Ektoplayer.start
6
+ app = Application.new
7
+ app.run
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,148 @@
1
+ require 'fileutils'
2
+ require 'date'
3
+
4
+ { '.' => %w(config theme bindings client common ui),
5
+ 'views' => %w(mainwindow),
6
+ 'models' => %w(player browser playlist database trackloader),
7
+ 'operations' => %w(operations player browser playlist),
8
+ 'controllers' => %w(mainwindow browser playlist info help)
9
+ }.each { |d,files| files.each { |f| require_relative "#{d}/#{f}" } }
10
+
11
+ module Ektoplayer
12
+ class Application
13
+ VERSION = '0.1'.freeze
14
+ GITHUB_URL = 'https://github.com/braph/ektoplayer'.freeze
15
+ EKTOPLAZM_URL = 'http://www.ektoplazm.com'.freeze
16
+
17
+ CONFIG_DIR = File.join(Dir.home, '.config', 'ektoplayer').freeze
18
+ CONFIG_FILE = File.join(CONFIG_DIR, 'ektoplayer.rc').freeze
19
+
20
+ def self.log(from, *msgs)
21
+ func = caller[0][/`.*'/][1..-2]
22
+ from = from.class unless from.is_a?String
23
+ $stderr.puts("#{DateTime.now.rfc3339} #{from}.#{func}: " + msgs.join(' '))
24
+
25
+ if e = msgs.select { |m| m.kind_of?Exception }[0]
26
+ $stderr.puts "#{e.backtrace.first}: #{e.message} (#{e.class})", e.backtrace.drop(1).map{|s| "\t#{s}"}
27
+ end
28
+ $stderr.flush
29
+ end
30
+
31
+ def self.open_log(file)
32
+ $stderr.reopen(file, ?a)
33
+ $stderr.sync=(true)
34
+ end
35
+
36
+ def run
37
+ #Thread.abort_on_exception=(true)
38
+
39
+ # make each configuration object globally accessible as a singleton
40
+ [Config, Bindings, Theme].each { |c| Common::mksingleton(c) }
41
+
42
+ if File.file? Config::CONFIG_FILE
43
+ Config.parse(Config::CONFIG_FILE, Bindings, Theme) rescue (
44
+ fail "Config: #{$!}"
45
+ )
46
+ end
47
+
48
+ FileUtils::mkdir_p Config::CONFIG_DIR rescue (
49
+ fail "Could not create config dir: #{$!}"
50
+ )
51
+
52
+ Application.open_log(Config[:log_file])
53
+
54
+ if Config[:use_cache]
55
+ unless File.directory? Config[:cache_dir]
56
+ FileUtils::mkdir Config[:cache_dir] rescue (
57
+ fail "Could not create cache dir: #{$!}"
58
+ )
59
+ end
60
+ end
61
+
62
+ [:temp_dir, :download_dir, :archive_dir].each do |key|
63
+ unless File.directory? Config[key]
64
+ FileUtils::mkdir Config[key] rescue (
65
+ fail "Could not create #{key}: #{$!}"
66
+ )
67
+ end
68
+ end
69
+
70
+ UI::Canvas.run do
71
+ if Config[:use_colors] == :auto
72
+ Theme.use_colors(ENV['TERM'] =~ /256/ ? 256 : 8)
73
+ else
74
+ Theme.use_colors(Config[:use_colors])
75
+ end
76
+
77
+ client = Client.new
78
+
79
+ # ... models ...
80
+ player = Models::Player.new(client)
81
+ browser = Models::Browser.new(client)
82
+ playlist = Models::Playlist.new
83
+ database = Models::Database.new(client)
84
+ trackloader = Models::Trackloader.new(client)
85
+
86
+ # ... operations ...
87
+ operations = Operations::Operations.new
88
+ operations.register(:quit, &method(:exit))
89
+ operations.register(:reload, &browser.method(:reload))
90
+ operations.register(:update, &database.method(:update))
91
+ operations.register(:refresh) { UI::Canvas.on_winch; UI::Canvas.update_screen(true) }
92
+ Operations::Player.new(operations, player)
93
+ Operations::Browser.new(operations, browser, playlist)
94
+ Operations::Playlist.new(operations, playlist, player, trackloader)
95
+
96
+ # ... views ...
97
+ main_w = UI::Canvas.sub(Views::MainWindow)
98
+
99
+ # next operations may take some time, espacially the ones
100
+ # using the database (browser), so we put this inside a thread
101
+ Thread.new do
102
+ # ... controllers ...
103
+ view_ops = Operations::Operations.new
104
+ Controllers::MainWindow.new(main_w, view_ops)
105
+ Controllers::Browser.new(main_w.browser, browser, view_ops, operations)
106
+ Controllers::Playlist.new(main_w.playlist, playlist, view_ops, operations)
107
+ Controllers::Help.new(main_w.help, view_ops)
108
+ Controllers::Info.new(main_w.info, playlist, trackloader, database, view_ops)
109
+ main_w.progressbar.attach(player)
110
+ main_w.volumemeter.attach(player)
111
+ main_w.playinginfo.attach(playlist, player)
112
+
113
+ # ... events ...
114
+ database.events.on(:update_finished, &browser.method(:reload ))
115
+ player.events.on(:stop) do |reason|
116
+ operations.send(:'playlist.play_next') if reason == :track_completed
117
+ end
118
+
119
+ # ... bindings ...
120
+ Bindings.bind_view(:global, main_w, view_ops, operations)
121
+ %w(splash playlist browser info help).each do |w|
122
+ Bindings.bind_view(w, main_w.send(w), view_ops, operations)
123
+ end
124
+
125
+ player.stop
126
+
127
+ # If database is empty, start an initial update
128
+ if browser.tracks(0).size < 1
129
+ operations.send(:update)
130
+ elsif (c = Config[:small_update_pages]) > 0
131
+ operations.send(:update, pages: c)
132
+ end
133
+
134
+ if (n = Config[:playlist_load_newest]) > 0
135
+ r = client.database.select(order_by: 'date', limit: n)
136
+ playlist.add(*r)
137
+ end
138
+ end
139
+
140
+ UI::Canvas.update_screen
141
+ UI::Input.start_loop
142
+ end
143
+ rescue
144
+ puts "Error: #{e}"
145
+ Application.log(self, $!)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,230 @@
1
+ require 'set'
2
+ require 'curses'
3
+
4
+ module Ektoplayer
5
+ # Keybinding storage.
6
+ #
7
+ # Provides access for changing keybinds (with collision detection):
8
+ # bind(), unbind()
9
+ #
10
+ # Provides binding these keys to widgets
11
+ # bind_section_to_widget(), bind_to_widget()
12
+ #
13
+ class Bindings
14
+ attr_reader :bindings, :commands
15
+
16
+ def register(command, description)
17
+ @commands[command.to_sym] = description.freeze
18
+ end
19
+ alias :reg :register
20
+
21
+ def initialize
22
+ @commands = {}
23
+
24
+ reg 'quit', 'Quit the program'
25
+ reg 'refresh', 'Refresh the screen'
26
+ reg 'reload', 'Apply database changes to browser'
27
+ reg 'update', 'Start a database update'
28
+
29
+ reg 'player.stop', 'Stop playing'
30
+ reg 'player.toggle', 'Toggle play/pause'
31
+ reg 'player.forward', 'Seek forward'
32
+ reg 'player.backward', 'Seek backward'
33
+
34
+ reg 'tabs.next', 'Select next tab'
35
+ reg 'tabs.prev', 'Select previous tab'
36
+
37
+ reg 'browser.enter', 'Enter selected directory'
38
+ reg 'browser.add_to_playlist', 'Add tracks under cursor to playlist'
39
+ reg 'browser.back', 'Change to parent directory'
40
+
41
+ reg 'playinginfo.toggle', 'Toggle playinginfo visibility'
42
+ reg 'progressbar.toggle', 'Toggle progressbar visibility'
43
+ reg 'volumemeter.toggle', 'Toggle volumemeter visibility'
44
+
45
+ reg 'playlist.goto_current', 'Go to current playing track'
46
+ reg 'playlist.clear', 'Delete all items in playlist'
47
+ reg 'playlist.delete', 'Delete selected track from playlist'
48
+ reg 'playlist.download_album', 'Download album archive for selected track'
49
+ reg 'playlist.play', 'Play selected track'
50
+ reg 'playlist.reload', 'Force redownload of selected track'
51
+ reg 'playlist.play_next', 'Play next track in playlist'
52
+ reg 'playlist.play_prev', 'Play previous track in playlist'
53
+
54
+ %w(info help browser playlist splash).each do |w|
55
+ reg("#{w}.show", "Show #{w}")
56
+ end
57
+
58
+ { up: 'Move cursor up', down: 'Move cursor down',
59
+ page_up: 'Scroll page up', page_down: 'Scroll page down',
60
+ top: 'Move to top', bottom: 'Move to bottom'
61
+ }.each do |cmd, desc|
62
+ %w(browser info help playlist).each { |w| reg("#{w}.#{cmd}", desc) }
63
+ end
64
+
65
+ { search_up: 'Start search upwards', search_next: 'Goto next search result',
66
+ search_down: 'Start search downwards', search_prev: 'Goto previous search result'
67
+ }.each do |cmd, desc|
68
+ %w(browser playlist).each { |w| reg("#{w}.#{cmd}", desc) }
69
+ end
70
+
71
+ @bindings = {
72
+ global: {
73
+ 'splash.show': [?`, ?^ ],
74
+ 'playlist.show': [?1 ],
75
+ 'browser.show': [?2 ],
76
+ 'info.show': [?3 ],
77
+ 'help.show': [?4 ],
78
+
79
+ 'playinginfo.toggle': [ Curses::KEY_F2 ],
80
+ 'progressbar.toggle': [ Curses::KEY_F3 ],
81
+ 'volumemeter.toggle': [ Curses::KEY_F4 ],
82
+
83
+ 'player.forward': [?f, Curses::KEY_RIGHT ],
84
+ 'player.backward': [?b, Curses::KEY_LEFT ],
85
+ 'player.stop': [?s ],
86
+ 'player.toggle': [?p ],
87
+
88
+ 'playlist.play_next': [?> ],
89
+ 'playlist.play_prev': [?< ],
90
+
91
+ 'tabs.next': [?l ],
92
+ 'tabs.prev': [?h ],
93
+
94
+ quit: [?q ],
95
+ refresh: ['^l' ],
96
+ reload: ['^r' ],
97
+ update: [?U ]},
98
+ playlist: {
99
+ # movement
100
+ 'playlist.top': [?g, Curses::KEY_HOME ],
101
+ 'playlist.bottom': [?G, Curses::KEY_END ],
102
+ 'playlist.up': [?k, Curses::KEY_UP ],
103
+ 'playlist.down': [?j, Curses::KEY_DOWN ],
104
+ 'playlist.page_down': ['^d', Curses::KEY_NPAGE ],
105
+ 'playlist.page_up': ['^u', Curses::KEY_PPAGE ],
106
+ # search
107
+ 'playlist.search_next': [?n ],
108
+ 'playlist.search_prev': [?N ],
109
+ 'playlist.search_up': [?? ],
110
+ 'playlist.search_down': [?/ ],
111
+ # playlist
112
+ 'playlist.play': [ Curses::KEY_ENTER ],
113
+ 'playlist.download_album': [?$ ],
114
+ 'playlist.reload': [?r ],
115
+ 'playlist.goto_current': [?o ],
116
+ 'playlist.clear': [?c ],
117
+ 'playlist.delete': [?d ],
118
+ # other
119
+ 'player.toggle': [' ' ]},
120
+ browser: {
121
+ # movement
122
+ 'browser.top': [?g, Curses::KEY_HOME ],
123
+ 'browser.bottom': [?G, Curses::KEY_END ],
124
+ 'browser.up': [?k, Curses::KEY_UP ],
125
+ 'browser.down': [?j, Curses::KEY_DOWN ],
126
+ 'browser.page_up': ['^u', Curses::KEY_PPAGE ],
127
+ 'browser.page_down': ['^d', Curses::KEY_NPAGE ],
128
+ # search
129
+ 'browser.search_next': [?n ],
130
+ 'browser.search_prev': [?N ],
131
+ 'browser.search_up': [?? ],
132
+ 'browser.search_down': [?/ ],
133
+ # browser
134
+ 'browser.add_to_playlist': [' ', ?a ],
135
+ 'browser.enter': [?E, Curses::KEY_ENTER ],
136
+ 'browser.back': [?b, Curses::KEY_BACKSPACE ]},
137
+ help: {
138
+ 'help.top': [?g, Curses::KEY_HOME ],
139
+ 'help.bottom': [?G, Curses::KEY_END ],
140
+ 'help.up': [?k, Curses::KEY_UP ],
141
+ 'help.down': [?j, Curses::KEY_DOWN ],
142
+ 'help.page_up': ['^u', Curses::KEY_PPAGE ],
143
+ 'help.page_down': ['^d', Curses::KEY_NPAGE ]},
144
+ info: {
145
+ 'info.top': [?g, Curses::KEY_HOME ],
146
+ 'info.bottom': [?G, Curses::KEY_END ],
147
+ 'info.up': [?k, Curses::KEY_UP ],
148
+ 'info.down': [?j, Curses::KEY_DOWN ],
149
+ 'info.page_up': ['^u', Curses::KEY_PPAGE ],
150
+ 'info.page_down': ['^d', Curses::KEY_NPAGE ]},
151
+ splash: {}
152
+ }
153
+
154
+ @bindings.default_proc = proc { |h,k| fail "Unknown widget #{k}" }
155
+ @bindings.each do |widget, hash|
156
+ hash.default_proc = proc { |h,k| h[k] = {} }
157
+ hash.values.each do |keys|
158
+ keys.map! { |key| parse_key(key) }
159
+ end
160
+ end
161
+ end
162
+
163
+ def keyname(key)
164
+ return 'SPACE' if key.to_s == ' '
165
+ return key.to_s if key.is_a? Symbol
166
+
167
+ name = Curses.keyname(key)
168
+ if name.start_with? 'KEY_'
169
+ name.sub('KEY_', '').sub(/\((\d+)\)/, '\1')
170
+ else
171
+ name
172
+ end
173
+ end
174
+
175
+ def parse_key(key)
176
+ if key.is_a? Integer
177
+ key
178
+ elsif key.size == 1
179
+ key.to_sym
180
+ elsif key.size == 2 and key.start_with?(?^)
181
+ Curses.const_get("KEY_CTRL_#{key[1].upcase}")
182
+ else
183
+ key = key.upcase.tr(?-, ?_)
184
+ key = "KEY_#{key}" unless key.start_with?('KEY_')
185
+ Curses.const_get(key)
186
+ end
187
+ rescue NameError
188
+ fail "Unknown key: #{key}"
189
+ end
190
+
191
+ def bind(widget, key, command)
192
+ widget, command = widget.to_sym, command.to_sym
193
+ fail "Unknown command #{command}" unless COMMANDS.include? command
194
+
195
+ @bindings[widget][command].delete parse_key(key)
196
+ @bindings[widget][command] << parse_key(key)
197
+ check_collisions
198
+ end
199
+
200
+ def unbind(widget, key)
201
+ @bindings[widget.to_sym].each do |command, keys|
202
+ keys.delete( (parsed_key ||= parse_key(k)) )
203
+ end
204
+ end
205
+
206
+ def bind_view(section, view, view_operations, operations)
207
+ @bindings[section.to_sym].each do |command, keys|
208
+ keys.each do |key|
209
+ meth = view_operations.method(command) rescue operations.method(command)
210
+ view.keys.on(key, &meth)
211
+ end
212
+ end
213
+ end
214
+
215
+ private def check_collisions
216
+ global_keys = @bindings[:global].values.flatten
217
+ global_keys.each do |k|
218
+ fail "Double binding in 'global': #{k}" if global_keys.count(k) > 1
219
+ end
220
+
221
+ @bindings.each_pair do |widget, commands|
222
+ widget_keys = commands.values.flatten
223
+ widget_keys.each do |k|
224
+ fail "Double binding in '#{widget}': #{k}" if widget_keys.count(k) > 1
225
+ fail "Double binding in 'global/#{widget}': #{k}" if global_keys.include? k
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end