#!/usr/bin/env ruby require 'optparse' require 'somadic' require 'curses' require 'progress_bar' require 'thread' require 'chronic' require 'readline' require 'yaml' SOMADIC_PATH = ENV['HOME'] + '/.somadic' module OS def OS.windows? (/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil end def OS.mac? (/darwin/ =~ RUBY_PLATFORM) != nil end def OS.unix? !OS.windows? end def OS.linux? OS.unix? and not OS.mac? end end # Monkey-patches ProgressBar so that it displays periods instead of blank # spaces. class ProgressBar def render_bar return '' if bar_width < 2 "[" + "#" * (ratio * (bar_width - 2)).ceil + "." * ((1-ratio) * (bar_width - 2)).floor + "]" end end # A curses display. class Display include Curses attr_reader :channel attr_accessor :kp_queue, :stopped, :search_phrase, :inputting def initialize curses_init @bar = ProgressBar.new(1, :bar) @kp_queue = Queue.new start_keypress_thread end # Refreshes the display. def refresh Somadic::Logger.debug('Display#refresh') Curses.clear Curses.refresh end def search(channel) #cpos Curses.lines - 1, 0 @inputting = true Curses.close_screen @search_phrase = Readline.readline('Go to channel: ', true) @search_phrase = '' unless @search_phrase[':'] cwrite Curses.lines - 1, 0, '' @inputting = false end def clear_search cwrite Curses.lines - 1, 0, '' end # Updates the display. def update(channel = nil, songs = nil) @channel = channel if channel @songs = songs if songs return if @channel.nil? || @songs.nil? cur_song = @songs.first return if cur_song.nil? # times start_time = Time.at(cur_song[:started]) rescue Time.now duration = cur_song[:duration] if @stopped end_time = nil elapsed = (Time.now - start_time).to_i remains = '][ Paused ]' elsif duration <= 0 end_time = nil elapsed = (Time.now - start_time).to_i remains = duration < 0 ? '][ Updating ]' : "][ #{format_secs(elapsed)} ]" else end_time = start_time + duration remains = "][ #{format_secs((Time.now - start_time).to_i)} " \ "/ #{format_secs(duration)} ]" end # current song track = cur_song[:track] channel_and_track = "[ #{clean_channel_name(@channel[:name])} > #{track}" up = cur_song[:votes][:up] down = cur_song[:votes][:down] votes = up + down != 0 ? "+#{up}/-#{down}" : '' space_len = Curses.cols - votes.length - channel_and_track.length - remains.length - 1 spaces = space_len > 0 ? ' ' * space_len : ' ' line = "#{channel_and_track}#{spaces}#{votes} #{remains}" over = Curses.cols - line.length if over < 0 channel_and_track = channel_and_track[0..over - 1] line = "#{channel_and_track}#{spaces}#{votes} #{remains}" end cwrite 0, 0, line, curses_reverse # current song progress unless @stopped if duration <= 0 @bar.max = @bar.count = 100 else @bar.max = duration @bar.count = (Time.now - start_time).to_i end cwrite 1, 0, @bar.to_s, curses_bold end # song history row = 2 @songs[1..6].each do |song| up = song[:votes][:up] down = song[:votes][:down] votes = up + down != 0 ? " +#{up}/-#{down} :" : '' if song[:duration] == 0 duration = Time.at(song[:started]).strftime('%H:%M:%S') else duration = format_secs(song[:duration]) end track = ": #{song[:track]}" votes_and_duration = "#{votes} #{duration} :" space_len = Curses.cols - track.length - votes_and_duration.length spaces = space_len > 0 ? ' ' * space_len : '' line = "#{track}#{spaces}#{votes_and_duration}" if space_len < 0 spaces = ' ' track = track[0..space_len - 2] line = "#{track}#{spaces}#{votes_and_duration}" end cwrite row, 0, line, curses_dim row += 1 end # TODO: this works around the dupe thing @startup, but it shouldn't be # necessary cwrite row, 0, '' cpos Curses.lines - 1, 0 end private def start_keypress_thread Thread.new do loop do unless @inputting ch = Curses.getch @kp_queue << ch if ch end sleep 0.1 end end end # Curses init def curses_init #Curses.noecho #Curses.curs_set(0) Curses.timeout = -1 Curses.init_screen Curses.start_color Curses.init_pair(COLOR_WHITE, COLOR_WHITE, COLOR_BLACK) end # Curses write def cwrite(row, col, message, color = nil) Curses.setpos(row, col) Curses.clrtoeol if color Curses.attron(color) { Curses.addstr(message) } else Curses.addstr(message) end Curses.refresh end # Cursor pos def cpos(row, col) Curses.setpos(row, col) Curses.refresh end # Colors/styles. def curses_bold curses_white|A_BOLD end def curses_reverse curses_white|A_REVERSE end def curses_dim curses_white|A_DIM end def curses_white color_pair(COLOR_WHITE) end # Formats `seconds` to hours, mins, secs. def format_secs(seconds) secs = seconds.abs hours = 0 if secs > 3600 hours = secs / 3600 secs -= 3600 * hours end mins = secs / 60 secs = secs % 60 h = hours > 0 ? "#{"%1d" % hours}:" : " " "#{h}#{"%02d" % mins}:#{"%02d" % secs}" end # Cleans up soma channel names. def clean_channel_name(name) cname = name.gsub(/130$/, '') cname.gsub!(/64$/, '') cname end end Signal.trap("INT") do |sig| @channel.stop exit end @display = Display.new @options = { cache: nil, cache_min: nil, listeners: [@display] } @optparser = OptionParser.new do |o| o.banner = 'Usage: somadic [options] site:channel [site:channel]' o.separator '' o.separator 'The `site` parameter can be di or soma. `channel` should be' o.separator 'a valid channel on that site.' o.separator '' o.separator 'DI premium channels require an environment variable: ' \ 'DI_FM_PREMIUM_ID.' o.separator '' o.on('-c CACHE_SIZE', '--cache CACHE_SIZE', 'Set the cache size (KB)') do |c| @options[:cache] = c end o.on('-m CACHE_MIN', '--cache-min CACHE_MIN', 'Set the minimum cache threshold (percent)') do |m| @options[:cache_min] = m end o.on('-h', '--help', 'Display this message') { puts o; exit } o.parse! end def usage puts @optparser puts exit end def next_channel @cur_chan ||= 0 rv = @channels[@cur_chan] @cur_chan += 1 @cur_chan = 0 if @cur_chan == @channels.count rv end def start_playing who, what = next_channel.split(':') @options[:channel] = what @options[:premium_id] = ENV['DI_FM_PREMIUM_ID'] if who == 'di' @channel = Somadic::Channel::DI.new(@options) else @channel = Somadic::Channel::Soma.new(@options) end @channel.start end def start(channels) Somadic::Logger.debug("somadic-curses, started with #{channels}") @channels = [] channels.each do |channel| if channel[':'] @channels << channel else # is there a preset file? fn = File.join(SOMADIC_PATH, 'presets', "#{channel}.yaml") if File.exist?(fn) YAML.load_file(fn).each { |c| @channels << c } else fail ArgumentError, "`#{channel}` is not a valid channel or preset." end end end start_playing # keypresses are handled thru a Queue keypresses = [] quitting = false stopped = false while !quitting begin keypresses << @display.kp_queue.pop(non_block: true) rescue ThreadError => te unless te.to_s == "queue empty" Somadic::Logger.error("kp_queue.pop error: #{te}") end end unless keypresses.empty? keypresses.each do |kp| # Somadic::Logger.debug("kp: #{kp} (a #{kp.class}) (searching=#{searching})") case kp when ' ' @channel.send(stopped ? :start : :stop) stopped = !stopped when 'c' dump_channels when 'n' goto_next_channel when 'N' goto_next_channel_random when 'q' @channel.stop quitting = true when 'r' @display.refresh when 's' search when '/' @display.search(@channel) if @display.search_phrase Somadic::Logger.debug("searching: #{@display.search_phrase}") goto_channel(@display.search_phrase) end end keypresses.delete(kp) end end @display.stopped = stopped @display.update sleep 0.1 end end def dump_channels @channel.channels.each do |c| Somadic::Logger.debug("channel: #{c}") end end def goto_channel(channel) @channel.stop who, what = channel.split(':') Somadic::Logger.debug("goto_channel: going to #{who}:#{what}") @options[:channel] = what if who == 'di' @channel = Somadic::Channel::DI.new(@options) else @channel = Somadic::Channel::Soma.new(@options) end @channel.start end def goto_next_channel goto_channel(next_channel) end def goto_next_channel_random who = @channel.is_a?(Somadic::Channel::DI) ? 'di' : 'soma' what = @channel.channels.reject { |c| c[:name] == @display.channel[:name] }.sample[:name] goto_channel("#{who}:#{what}") end def search Somadic::Logger.debug("searching for '#{@channel.song}'") if OS.mac? `open "https://www.google.com/search?safe=off&q=#{@channel.song}"` elsif OS.linux? `xdg-open "https://www.google.com/search?safe=off&q=#{@channel.song}"` end end usage if ARGV[0].nil? start(ARGV)