#!/usr/bin/env ruby # Copyright (C) all contributors # License: GPL-3.0+ # frozen_string_literal: true # # Note: no idea what I'm doing, especially w.r.t. curses require 'dtas' require 'dtas/unix_client' require 'dtas/rg_state' require 'dtas/sigevent' require 'dtas/process' require 'dtas/format' include DTAS::Process begin require 'curses' rescue LoadError abort "please install the 'curses' RubyGem to use #$0" end # workaround https://bugs.debian.org/958973 $VERBOSE = nil if RUBY_VERSION.to_f < 3.0 tsec = false se = DTAS::Sigevent.new trap(:WINCH) { se.signal } w = DTAS::UNIXClient.new w.req_ok('watch') c = DTAS::UNIXClient.new cur = DTAS.yaml_load(c.req('current')) readable = [ se, w, $stdin ] set_title = (ENV['DISPLAY'] || ENV['WAYLAND_DISPLAY']) ? $stdout : nil # current rg mode rg_mode = DTAS::RGState::RG_MODE.keys.unshift("off") if (rg = cur["rg"]) && (rg = rg["mode"]) rg_mode_i = rg_mode.index(cur["rg"]["mode"]) else rg_mode_i = 0 end show_info = false def update_tfmt(prec, tsec) if tsec prec == 0 ? '%_8s' : "%_8s.%#{prec}N" else prec == 0 ? '%H:%M:%S' : "%H:%M:%S.%#{prec}N" end end trap(:INT) { exit(0) } trap(:TERM) { exit(0) } # time precision prec_nr = 1 prec_step = (0..9).to_a prec_max = prec_step.size - 1 tfmt = update_tfmt(prec_step[prec_nr], tsec) events = [] interval = 1.0 / 10 ** prec_nr def show_events(lineno, screen, events) Curses.setpos(lineno += 1, 0) Curses.clrtoeol Curses.addstr('Events:') maxy = screen.maxy - 1 maxx = screen.maxx events.reverse_each do |e| Curses.setpos(lineno += 1, 0) Curses.clrtoeol extra = e.size/maxx break if (lineno + extra) >= maxy # deal with long lines if extra rewind = lineno extra.times do Curses.setpos(lineno += 1, 0) Curses.clrtoeol end Curses.setpos(rewind, 0) Curses.addstr(e) Curses.setpos(lineno, 0) else Curses.addstr(e) end end # discard events we can't show nr_events = events.size if nr_events > maxy events = events[(nr_events - maxy)..-1] until lineno >= screen.maxy Curses.setpos(lineno += 1, 0) Curses.clrtoeol end else Curses.setpos(maxy + 1, 0) Curses.clrtoeol end end def fmt_to_s(f) r = [ f['rate'], f['channels'], f['type'], f['bits'] ] r.compact! r.join(',') end def rg_string(rg, current) rv = "rg mode=#{rg['mode']||'off'}".dup defaults = DTAS::RGState::RG_DEFAULT # don't show things that are too rare %w(preamp fallback_gain).each do |param| val = rg[param] || defaults[param] rv << " #{param}=#{val}" end env = current && current["env"] and rv << " / RGFX='#{env['RGFX']}'" rv end def may_fail(c, req, events) res = c.req(req) events << res if res != "OK" end pre_mute_vol = 1.0 enc_locale = Encoding.find("locale") $stdout.set_encoding(enc_locale) begin Curses.init_screen Curses.nonl Curses.cbreak Curses.noecho screen = Curses.stdscr screen.scrollok(true) screen.keypad(true) loop do lineno = -1 pfmt = cur['format'] elapsed = samples = 0 fmt = total = '' if current = cur['current'] infile = current['infile'] || current['command'] elapsed = DTAS.now - current['spawn_at'] if (nr = cur['current_initial']) && (current_format = current['format']) rate = current_format['rate'].to_f elapsed += nr / rate samples = current['samples'] fmt = "(#{fmt_to_s(current_format)} > #{fmt_to_s(pfmt)})" else fmt = fmt_to_s(pfmt) fmt = "(#{fmt} > #{fmt})" end elsif cur['paused'] && infile = cur['current_paused'] fmt = "[paused] (#{fmt_to_s(pfmt)})" infile = infile['command'] if Hash === infile if Array === infile infile, elapsed = infile elapsed = elapsed.to_i samples = rate = 0 if (bypass = cur['bypass']) && bypass.include?('rate') rate = pfmt['rate'].to_f else rate = qx(%W(soxi -r #{infile}), err: DTAS.null).to_i rescue 0 end elapsed /= rate.to_f if rate != 0 end end if infile # FS encoding != locale encoding, but we need to display an FS path # name to whatever locale the terminal is encoded to, so force it # and risk mojibake... infile.encode(enc_locale, undef: :replace, invalid: :replace, replace: '?') if set_title dir, base = File.split(infile) set_title.syswrite("\033]0;#{base} dtas-console\07") end Curses.setpos(lineno += 1, 0) Curses.clrtoeol Curses.addstr(infile) total = " [#{Time.at(samples / rate).utc.strftime(tfmt)}]" if samples != 0 Curses.setpos(lineno += 1, 0) Curses.clrtoeol if rate != 0 Curses.addstr("#{Time.at(elapsed).utc.strftime(tfmt)}#{total} #{fmt}") else Curses.addstr("#{elapsed} samples #{total} #{fmt}") end else Curses.setpos(lineno += 1, 0) Curses.clrtoeol Curses.addstr('idle') Curses.setpos(lineno += 1, 0) Curses.clrtoeol end rg = cur['rg'] || {} rgs = rg_string(rg, current) Curses.setpos(lineno += 1, 0) Curses.clrtoeol Curses.addstr(rgs) Curses.setpos(lineno += 1, 0) Curses.clrtoeol cur_vol = rg['volume'] || 1.0 extra = [ "volume=#{cur_vol}" ] tl = cur['tracklist'] || {} %w(repeat shuffle consume).each { |x| extra << "#{x}=#{tl[x] || 'false'}" } trim = cur['trim'] || 'off' extra << "trim=#{trim}" Curses.addstr(extra.join(' ')) pre_mute_vol = cur_vol if cur_vol != 0 if show_info && current && comments = current['comments'] Curses.setpos(lineno += 1, 0) Curses.clrtoeol Curses.addstr('comments:') comments.each do |k,v| v = v.split(/\n+/) k = k.dump if /[[:cntrl:]]/ =~ k if first = v.shift Curses.setpos(lineno += 1, 0) Curses.clrtoeol first = first.dump if /[[:cntrl:]]/ =~ first Curses.addstr(" #{k}: #{first}") v.each do |val| val = val.dump if /[[:cntrl:]]/ =~ val Curses.setpos(lineno += 1, 0) Curses.clrtoeol Curses.addstr(" #{val}") end end end end show_events(lineno, screen, events) Curses.refresh # draw and wait r = IO.select(readable, nil, nil, current ? interval : nil) or next r[0].each do |io| case io when se se.readable_iter {} # noop, just consume the event Curses.clear when w event = w.res_wait case event when "pause" if current current['infile'] || current['command'] end when %r{\Afile } end events << "#{Time.now.strftime(tfmt)} #{event}" # something happened, refresh current # we could be more intelligent here, maybe, but too much work. cur = DTAS.yaml_load(c.req('current')) when $stdin # keybindings taken from mplayer / vi case key = Curses.getch when "j" then may_fail(c, "seek -5", events) when "k" then may_fail(c, "seek +5", events) when "q" then exit(0) when Curses::KEY_DOWN then may_fail(c, "seek -60", events) when Curses::KEY_UP then may_fail(c, "seek +60", events) when Curses::KEY_LEFT then may_fail(c, "seek -10", events) when Curses::KEY_RIGHT then may_fail(c, "seek +10", events) when Curses::KEY_BACKSPACE then may_fail(c, "seek 0", events) # yes, some of us have long audio files when Curses::KEY_PPAGE then may_fail(c, "seek +600", events) when Curses::KEY_NPAGE then may_fail(c, "seek -600", events) when '9' then c.req_ok('rg volume-=0.01') when '0' then c.req_ok('rg volume+=0.01') when '=' then c.req_ok('rg volume=1') when '7' then c.req_ok('rg preamp-=1') when '8' then c.req_ok('rg preamp+=1') when 'm' then c.req_ok("rg volume=#{cur_vol == 0 ? pre_mute_vol : 0}") when "F" then c.req_ok("rg fallback_gain+=1") when "f" then c.req_ok("rg fallback_gain-=1") when ">" then c.req_ok("tl next") when "<" then c.req_ok("tl prev") when "!" then may_fail(c, "cue prev", events) when "@" then may_fail(c, "cue next", events) when "o" then tfmt = update_tfmt(prec_step[prec_nr], tsec = !tsec) when " " c.req("play_pause") when "r" # cycle through replaygain modes rg_mode_i >= 1 and c.req_ok("rg mode=#{rg_mode[rg_mode_i -= 1]}") when "R" rg_mode_i < (rg_mode.size - 1) and c.req_ok("rg mode=#{rg_mode[rg_mode_i += 1]}") when "p" # lower precision of time display if prec_nr >= 1 prec_nr -= 1 tfmt = update_tfmt(prec_step[prec_nr], tsec) interval = 1.0 / 10 ** prec_nr end when "P" # increase precision of time display if prec_nr < prec_max prec_nr += 1 tfmt = update_tfmt(prec_step[prec_nr], tsec) interval = 1.0 / 10 ** prec_nr end when 27 # TODO readline/edit mode? when 'i' show_info = !show_info Curses.clear if !show_info else Curses.setpos(screen.maxy - 1, 0) Curses.clrtoeol Curses.addstr("unknown key=#{key.inspect}") end end end end rescue EOFError Curses.close_screen abort "dtas-player exited" ensure Curses.close_screen end