comp/comp

552 lines
20 KiB
Plaintext
Raw Normal View History

2017-03-20 14:49:11 +01:00
#!/usr/bin/env python3
2017-06-10 17:14:15 +02:00
# comp - Curses Omni Media Player
2017-03-26 10:26:37 +02:00
#
# comp is free software: you can redistribute it and/or modify
2017-03-26 10:26:37 +02:00
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# comp program is distributed in the hope that it will be useful,
2017-03-26 10:26:37 +02:00
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with comp. If not, see <http://www.gnu.org/licenses/>.
#
# Copyright (C) 2017 Nguyễn Gia Phong <vn.mcsinyx@gmail.com>
2017-03-26 10:26:37 +02:00
__version__ = '0.4.4'
2017-03-20 14:49:11 +01:00
import curses
import json
2017-05-07 10:59:54 +02:00
import re
2017-03-20 14:49:11 +01:00
from argparse import ArgumentParser
2017-05-07 10:59:54 +02:00
from collections import deque
from configparser import ConfigParser
from curses.ascii import ctrl, alt
2017-05-01 11:19:49 +02:00
from functools import reduce
2017-06-10 17:14:15 +02:00
from gettext import bindtextdomain, gettext as _, textdomain
2017-05-29 15:38:09 +02:00
from os import makedirs
2017-06-25 12:38:19 +02:00
from os.path import abspath, dirname, expanduser, expandvars
2017-04-03 15:52:59 +02:00
from threading import Thread
from traceback import print_exception
2017-03-20 14:49:11 +01:00
2017-05-29 15:38:09 +02:00
from mpv import MPV
2017-06-10 17:14:15 +02:00
from pkg_resources import resource_filename
from youtube_dl import YoutubeDL
2017-03-20 14:49:11 +01:00
from omp import extract_info, Omp
# Init gettext
2017-06-10 17:14:15 +02:00
bindtextdomain('omp', resource_filename('omp', 'locale'))
textdomain('omp')
# Global constants
SYSTEM_CONFIG = '/etc/comp/settings.ini'
USER_CONFIG = expanduser('~/.config/comp/settings.ini')
2017-05-07 10:59:54 +02:00
MODES = ("play-current", "play-all", "play-selected", "repeat-current",
"repeat-all", "repeat-selected", "shuffle-all", "shuffle-selected")
MODE_STR_LEN = max(len(_(mode)) for mode in MODES)
2017-05-07 10:59:54 +02:00
DURATION_COL_LEN = max(len(_("Duration")), 8)
2017-05-07 10:59:54 +02:00
def justified(s, width):
"""Return s left-justified of length width."""
return s.ljust(width)[:width]
class Comp(Omp):
"""Meta object for drawing and playing.
Attributes:
2017-05-01 11:19:49 +02:00
entries (list): list of all tracks
2017-05-07 10:59:54 +02:00
json_file (str): path to save JSON playlist
2017-05-01 11:19:49 +02:00
mode (str): the mode to pick and play tracks
mp (MPV): an mpv instance
play_backward (bool): flag show if to play the previous track
play_list (list): list of tracks according to mode
played (list): list of previously played tracks
playing (int): index of playing track in played
playlist (iterator): iterator of tracks according to mode
reading (bool): flag show if user input is being read
search_str (str): regex search string
2017-05-01 11:19:49 +02:00
scr (curses WindowObject): curses window object
2017-05-07 10:59:54 +02:00
start (int): index of the first track to be printed on screen
2017-05-01 11:19:49 +02:00
y (int): the current y-coordinate
"""
def __new__(cls, entries, json_file, mode, mpv_args, ytdlf):
self = object.__new__(cls)
2017-05-15 06:02:56 +02:00
self.play_backward, self.reading = False, False
2017-05-01 11:19:49 +02:00
self.playing, self.start, self.y = -1, 0, 1
2017-09-17 17:05:48 +02:00
self.json_file, self.mode = json_file, mode
2017-05-07 10:59:54 +02:00
self.entries, self.played = entries, []
2017-09-17 17:05:48 +02:00
self.playlist, self.search_str = iter(()), ''
self.mp = MPV(input_default_bindings=True, input_vo_keyboard=True,
ytdl=True, ytdl_format=ytdlf)
2017-05-01 11:19:49 +02:00
self.scr = curses.initscr()
return self
def adds(self, s, y, x=0, X=-1, attr=curses.A_NORMAL, lpad=1):
"""Paint the string s, added lpad spaces to the left, from
(y, x) to (y, X) with attributes attr, overwriting anything
previously on the display.
"""
if self.reading: return
curses.update_lines_cols()
y %= curses.LINES
x %= curses.COLS
length = X % curses.COLS - x + (y != curses.LINES - 1)
self.scr.addstr(y, x, (' '*lpad + s).ljust(length)[:length], attr)
def update_status(self, message='', msgattr=curses.A_NORMAL):
2017-05-01 11:19:49 +02:00
"""Update the status lines at the bottom of the screen."""
def add_status_str(s, x=0, X=-1, attr=curses.color_pair(12), lpad=1):
self.adds(s, curses.LINES - 2, x=x, X=X, attr=attr, lpad=lpad)
2017-05-01 11:19:49 +02:00
if self.mp.osd.duration is not None:
2017-06-10 17:14:15 +02:00
self.played[self.playing]['duration'] = self.mp.osd.duration
add_status_str(':', X=5, lpad=3)
2017-09-17 17:05:48 +02:00
if self.mp.video: add_status_str('V', x=1, X=2)
if self.mp.audio: add_status_str('A', X=1)
add_status_str(self.mp.osd.time_pos or '00:00:00', x=4, X=12)
add_status_str('/', x=13, X=14)
add_status_str(self.mp.osd.duration or '00:00:00', x=15, X=23)
add_status_str('|' if self.mp.pause else '>', x=24, X=25)
2017-08-07 09:22:17 +02:00
add_status_str(self.mp.media_title or '', x=26,
attr=curses.color_pair(12)|curses.A_BOLD)
add_status_str(_(self.mode), x=-2-len(_(self.mode)))
2017-06-25 12:38:19 +02:00
self.scr.refresh()
def print_msg(self, message, error=False):
"""Print the given message, in red is it's an error."""
2017-06-25 12:38:19 +02:00
attributes = curses.color_pair(1) if error else curses.A_NORMAL
self.adds(message, curses.LINES-1, attr=attributes, lpad=0)
2017-05-01 11:19:49 +02:00
self.scr.refresh()
2017-05-07 10:59:54 +02:00
def setno(self, *keys):
"""Set all keys of each entry in entries to False."""
for entry in self.entries:
for key in keys:
entry[key] = False
2017-05-01 11:19:49 +02:00
def play(self, force=False):
"""Play the next track."""
def mpv_play(entry, force):
self.setno('playing')
entry['playing'] = True
2017-05-01 11:19:49 +02:00
try:
2017-05-29 15:38:09 +02:00
self.mp.play(entry['filename'])
2017-05-01 11:19:49 +02:00
except:
entry['error'] = True
2017-05-07 10:59:54 +02:00
self.print(entry)
if force: self.mp.pause = False
2017-05-01 11:19:49 +02:00
self.mp.wait_for_playback()
2017-05-15 06:02:56 +02:00
self.play()
2017-05-01 11:19:49 +02:00
entry['playing'] = False
2017-05-07 10:59:54 +02:00
self.print(entry)
2017-05-01 11:19:49 +02:00
if self.play_backward and -self.playing < len(self.played):
self.playing -= 1
t = self.played[self.playing], force
elif self.playing < -1:
self.playing += 1
t = self.played[self.playing], force
else:
try:
self.played.append(next(self.playlist))
except StopIteration:
return
else:
t = self.played[-1], force
self.play_backward = False
play_thread = Thread(target=mpv_play, args=t, daemon=True)
play_thread.start()
2017-05-07 10:59:54 +02:00
def _writeln(self, y, title, duration, attr):
title_len = curses.COLS - DURATION_COL_LEN - 3
self.adds(title, y, attr=attr)
self.adds(duration, y, x=title_len+1, attr=attr)
2017-05-07 10:59:54 +02:00
self.scr.refresh()
2017-05-07 10:59:54 +02:00
def print(self, entry=None, y=None):
"""Print the entry in the line y."""
if entry is y is None:
entry = self.current()
y = self.idx() - self.start + 1
elif entry is None:
entry = self.entries[self.start + y - 1]
elif y is None:
y = self.idx(entry) - self.start + 1
if y < 1 or y > curses.LINES - 3: return
2017-05-01 11:19:49 +02:00
c = {'error': 1, 'playing': 3, 'selected': 5}
color = ((8 if entry is self.current() else 0)
| reduce(int.__xor__, (c.get(i, 0) for i in entry if entry[i])))
if color:
2017-05-29 15:38:09 +02:00
self._writeln(y, entry['title'], entry['duration'],
2017-05-07 10:59:54 +02:00
curses.color_pair(color) | curses.A_BOLD)
else:
2017-05-29 15:38:09 +02:00
self._writeln(y, entry['title'], entry['duration'],
2017-05-07 10:59:54 +02:00
curses.A_NORMAL)
2017-05-01 11:19:49 +02:00
2017-06-25 12:38:19 +02:00
def refresh(self):
2017-05-01 11:19:49 +02:00
"""Redraw the whole screen."""
2017-05-07 10:59:54 +02:00
self._writeln(0, _("Title"), _("Duration"),
curses.color_pair(10) | curses.A_BOLD)
for i, entry in enumerate(self.entries[self.start:][:curses.LINES-3]):
2017-05-07 10:59:54 +02:00
self.print(entry, i + 1)
self.scr.clrtobot()
self.update_status()
2017-06-25 12:38:19 +02:00
def property_handler(self, name, val): self.update_status()
def __init__(self, entries, json_file, mode, mpv_args, ytdlf):
2017-05-01 11:19:49 +02:00
curses.noecho()
curses.cbreak()
self.scr.keypad(True)
curses.curs_set(False)
curses.start_color()
curses.use_default_colors()
for i in range(1, 8): curses.init_pair(i, i, -1)
curses.init_pair(8, -1, 7)
for i in range(1, 7): curses.init_pair(i + 8, -1, i)
Omp.__init__(self, entries, json_file, mode, mpv_args, ytdlf)
2017-06-25 12:38:19 +02:00
self.refresh()
2017-05-01 11:19:49 +02:00
def __enter__(self): return self
2017-05-07 10:59:54 +02:00
def idx(self, entry=None):
2017-05-01 11:19:49 +02:00
"""Return the index of the current entry."""
2017-05-07 10:59:54 +02:00
if entry is None:
return self.start + self.y - 1
return self.entries.index(entry)
2017-05-01 11:19:49 +02:00
def current(self):
"""Return the current entry."""
try:
return self.entries[self.idx()]
except:
return {}
2017-06-25 12:38:19 +02:00
def read_input(self, prompt):
2017-05-07 10:59:54 +02:00
"""Print the prompt string at the bottom of the screen then read
from standard input.
"""
self.adds(prompt, curses.LINES - 1, lpad=0)
2017-05-07 10:59:54 +02:00
self.reading = True
curses.curs_set(True)
curses.echo()
b = self.scr.getstr(curses.LINES - 1, len(prompt))
self.reading = False
curses.curs_set(False)
curses.noecho()
return b.decode()
def move(self, delta):
2017-05-01 11:19:49 +02:00
"""Move to the relatively next delta entry."""
if not (self.entries and delta): return
2017-05-07 10:59:54 +02:00
start, prev_entry = self.start, self.current()
2017-05-01 11:19:49 +02:00
maxy = min(len(self.entries), curses.LINES - 3)
if self.idx() + delta <= 0:
self.start, self.y = 0, 1
elif self.idx() + delta >= len(self.entries):
self.start, self.y = len(self.entries) - maxy, maxy
elif self.y + delta < 1:
self.start += self.y + delta - 1
self.y = 1
elif self.y + delta > curses.LINES - 3:
self.start += self.y + delta - maxy
self.y = maxy
else:
self.y += delta
if self.start == start:
2017-05-07 10:59:54 +02:00
self.print(prev_entry)
self.print()
else:
2017-06-25 12:38:19 +02:00
self.refresh()
2017-05-07 10:59:54 +02:00
def search(self, backward=False):
"""Prompt then search for a pattern."""
s = self.read_input(_("Search {}ward [{{}}]: ".format(
'back' if backward else 'for')).format(self.search_str))
2017-09-17 17:05:48 +02:00
if s: self.search_str = s
pattern = re.compile(self.search_str, re.IGNORECASE)
entries = deque(self.entries)
if backward:
entries.rotate(-self.idx())
2017-09-17 17:05:48 +02:00
entries.reverse()
2017-05-07 10:59:54 +02:00
else:
2017-09-17 17:05:48 +02:00
entries.rotate(-self.idx() - 1)
for entry in entries:
if pattern.search(entry['title']) is not None:
self.move(self.idx(entry) - self.idx())
return
self.print_msg(_("'{}' not found").format(self.search_str), error=True)
2017-05-07 10:59:54 +02:00
def resize(self):
curses.update_lines_cols()
self.scr.clear()
2017-05-07 10:59:54 +02:00
l = curses.LINES - 3
if curses.COLS < MODE_STR_LEN + 42 or l < 1: # too small
2017-05-07 10:59:54 +02:00
sizeerr = _("Current size: {}x{}. Minimum size: {}x4.").format(
curses.COLS, curses.LINES, MODE_STR_LEN + 42)
self.scr.addstr(0, 0, sizeerr[:curses.LINES*curses.COLS-1])
self.scr.refresh()
elif self.y > l: # shorter than the current entry
2017-05-07 10:59:54 +02:00
self.start += self.y - l
self.y = l
2017-06-25 12:38:19 +02:00
self.refresh()
elif 0 < self.start > len(self.entries) - l: # longer than the list
idx, self.start = self.idx(), min(0, len(self.entries) - l)
2017-05-07 10:59:54 +02:00
self.y = idx - self.start + 1
if self.y > l:
self.start += self.y - l
self.y = l
2017-06-25 12:38:19 +02:00
self.refresh()
2017-05-07 10:59:54 +02:00
else:
2017-06-25 12:38:19 +02:00
self.refresh()
2017-03-20 14:49:11 +01:00
2017-05-01 11:19:49 +02:00
def __exit__(self, exc_type, exc_value, traceback):
curses.nocbreak()
2017-05-01 11:19:49 +02:00
self.scr.keypad(False)
curses.echo()
curses.endwin()
Omp.__exit__(self, exc_type, exc_value, traceback)
if exc_value is not None:
print_exception(exc_type, exc_value, traceback)
2017-03-20 14:49:11 +01:00
parser = ArgumentParser(description='Curses Omni Media Player')
parser.add_argument('-v', '--version', action='version',
version='%(prog)s {}'.format(__version__))
2017-05-29 15:38:09 +02:00
parser.add_argument('-e', '--extractor', default='youtube-dl',
choices=('json', 'mpv', 'youtube-dl'), required=False,
help='playlist extractor, default is youtube-dl')
parser.add_argument('playlist', help='path or URL to the playlist')
2017-06-10 17:14:15 +02:00
parser.add_argument('-c', '--config', default=USER_CONFIG, required=False,
help='path to the configuration file')
2017-05-07 10:59:54 +02:00
parser.add_argument('--vo', required=False, metavar='DRIVER',
help='specify the video output backend to be used. See\
VIDEO OUTPUT DRIVERS in mpv(1) for details and\
descriptions of available drivers')
2017-05-07 10:59:54 +02:00
parser.add_argument('-f', '--format', required=False, metavar='YTDL_FORMAT',
help='video format/quality to be passed to youtube-dl')
2017-03-20 14:49:11 +01:00
args = parser.parse_args()
2017-06-25 12:38:19 +02:00
entries = extract_info(args.playlist, args.extractor)
2017-06-14 07:21:54 +02:00
if entries is None:
print(_("'{}': Can't extract playlist").format(args.playlist))
2017-06-14 07:21:54 +02:00
exit()
2017-06-25 12:38:19 +02:00
json_file = args.playlist if args.extractor == 'json' else ''
config = ConfigParser()
2017-06-10 17:14:15 +02:00
config.read(args.config)
2017-05-07 10:59:54 +02:00
mode = config.get('comp', 'play-mode', fallback='play-current')
mpv_args = dict(config['mpv']) if 'mpv' in config else {}
if args.vo is not None: mpv_args['vo'] = args.vo
2017-06-10 17:14:15 +02:00
ytdlf = args.format or config.get('youtube-dl', 'format', fallback='best')
2017-03-24 15:57:19 +01:00
with Comp(entries, json_file, mode, mpv_args, ytdlf) as comp:
while True:
c = comp.scr.get_wch()
comp.print_msg('')
# mpv keybindings
if c == curses.KEY_LEFT:
comp.seek(-5, precision='exact')
elif c == curses.KEY_RIGHT:
comp.seek(5, precision='exact')
elif c == curses.KEY_SLEFT: # Shifted Left-arrow
comp.seek(-1, precision='exact')
elif c == curses.KEY_SRIGHT: # Shifted Right-arrow
comp.seek(1, precision='exact')
elif c == curses.KEY_UP:
comp.seek(-60, precision='exact')
elif c == curses.KEY_DOWN:
comp.seek(60, precision='exact')
elif c == curses.KEY_PPAGE:
comp.add('chapter', 1)
elif c == curses.KEY_NPAGE:
comp.add('chapter', -1)
elif c == '[':
comp.multiply('speed', 0.9091)
elif c == ']':
comp.multiply('speed', 1.1)
elif c == '{':
comp.multiply('speed', 0.5)
elif c == '}':
comp.multiply('speed', 2.0)
elif c == curses.KEY_BACKSPACE:
comp.mp.speed = 1.0
elif c == 'q':
comp.print_msg(_("Save playlist? [Y/n]"))
if comp.scr.get_wch() not in _("Nn"): comp.dump_json()
break
elif c in ('p', ' '):
comp.cycle('pause')
elif c == '.':
comp.mp.frame_step()
elif c == ',':
comp.mp.frame_back_step()
elif c == '<':
2017-05-01 11:19:49 +02:00
try:
2017-08-07 09:22:17 +02:00
if comp.mp.time_pos < 1:
2017-05-07 10:59:54 +02:00
comp.next(backward=True)
2017-05-01 11:19:49 +02:00
else:
comp.seek(0, 'absolute')
except:
pass
elif c == '>':
2017-05-07 10:59:54 +02:00
comp.next()
elif c == '\n': # curses.KEY_ENTER doesn't work
comp.update_playlist()
comp.next(force=True)
2017-09-17 17:05:48 +02:00
elif c == 'O':
comp.mp.command('cycle-values', 'osd-level', 3, 1)
elif c in ('o', 'P'):
comp.mp.show_progress()
elif c == 'z':
comp.add('sub-delay', -0.1)
elif c == 'x':
comp.add('sub-delay', 0.1)
elif c == ctrl('+'):
comp.add('audio-delay', 0.1)
elif c == ctrl('-'):
comp.add('audio-delay', -0.1)
elif c in ('/', '9'):
comp.add('volume', -2)
elif c in ('*', '0'):
comp.add('volume', 2)
elif c == 'm':
comp.cycle('mute')
elif c == '1':
comp.add('contrast', -1)
elif c == '2':
comp.add('contrast', 1)
elif c == '3':
comp.add('brightness', -1)
elif c == '4':
comp.add('brightness', 1)
elif c == '5':
comp.add('gamma', -1)
elif c == '6':
comp.add('gamma', 1)
elif c == '7':
comp.add('saturation', -1)
elif c == '8':
comp.add('saturation', 1)
elif c == alt('0'):
comp.mp.window_scale = 0.5
elif c == alt('1'):
comp.mp.window_scale = 1.0
elif c == alt('2'):
comp.mp.window_scale = 2.0
elif c == 'd':
comp.cycle('deinterlace')
elif c == 'r':
comp.add('sub-pos', -1)
elif c == 't':
comp.add('sub-pos', 1)
elif c == 'v':
comp.cycle('sub-visibility')
elif c == 'V':
comp.cycle('sub-ass-vsfilter-aspect-compat')
2017-09-17 17:05:48 +02:00
elif c == 'u':
comp.mp.command('cycle-values', 'sub-ass-override', 'force', 'no')
elif c == 'j':
comp.cycle('sub', 'up')
elif c == 'J':
comp.cycle('sub', 'down')
elif c == '#':
comp.cycle('audio')
elif c == '_':
comp.cycle('video')
elif c == 'T':
comp.cycle('ontop')
elif c == 'f':
comp.cycle('fullscreen')
elif c == 's':
comp.mp.screenshot()
elif c == 'S':
comp.mp.screenshot(includes='')
elif c == alt('s'):
comp.mp.screenshot(mode='each-frame')
elif c == 'w':
comp.add('panscan', -0.1)
elif c == 'e':
comp.add('panscan', 0.1)
2017-09-17 17:05:48 +02:00
elif c == 'A':
comp.mp.command('cycle-values', 'video-aspect',
'16:9', '4:3', '2.35:1', '-1')
elif c == 'E':
comp.cycle('edition')
elif c == 'l':
comp.mp.command('ab-loop')
2017-09-17 17:05:48 +02:00
elif c == 'L':
comp.mp.command('cycle-values', 'loop-file', 'inf', 'no')
2017-09-17 17:05:48 +02:00
# Emacs keybindings
elif c == ctrl('p'):
comp.move(-1)
elif c == ctrl('n'):
comp.move(1)
elif c == alt('v'):
comp.move(4 - curses.LINES)
elif c == ctrl('v'):
comp.move(curses.LINES - 4)
elif c in (ctrl('<'), curses.KEY_HOME):
comp.move(-len(comp.entries))
elif c in (ctrl('>'), curses.KEY_END):
comp.move(len(comp.entries))
2017-09-17 17:05:48 +02:00
elif c == ctrl(' '):
comp.current()['selected'] = not comp.current().get('selected')
comp.move(1)
2017-09-17 17:05:48 +02:00
elif c == ctrl('o'):
2017-06-25 12:38:19 +02:00
extractor = comp.read_input(_("Playlist extractor: "))
2017-09-17 17:05:48 +02:00
filename = comp.read_input(_("Open: "))
2017-06-10 17:14:15 +02:00
entries = extract_info(filename, extractor)
if entries is None:
2017-06-25 12:38:19 +02:00
comp.print_msg(
2017-06-10 17:14:15 +02:00
_("'{}': Can't extract playlist").format(filename))
else:
2017-09-17 17:05:48 +02:00
comp.entries, comp.start, comp.y = entries, 0, 1
2017-06-25 12:38:19 +02:00
comp.refresh()
2017-09-17 17:05:48 +02:00
elif c == ctrl('i'):
2017-06-25 12:38:19 +02:00
extractor = comp.read_input(_("Playlist extractor: "))
2017-09-17 17:05:48 +02:00
filename = comp.read_input(_("Insert: "))
2017-06-10 17:14:15 +02:00
entries = extract_info(filename, extractor)
if entries is None:
2017-06-25 12:38:19 +02:00
comp.print_msg(
2017-06-10 17:14:15 +02:00
_("'{}': Can't extract playlist").format(filename))
else:
2017-09-17 17:05:48 +02:00
bottom = comp.entries[comp.idx():]
comp.entries = comp.entries[:comp.idx()]
comp.entries.extend(entries)
comp.entries.extend(bottom)
2017-06-25 12:38:19 +02:00
comp.refresh()
2017-09-17 17:05:48 +02:00
elif c == ctrl('f'):
comp.search()
elif c == alt('f'):
comp.search(backward=True)
elif c == alt('m'):
2018-01-25 12:39:26 +01:00
comp.mode = MODES[(MODES.index(comp.mode) + 1) % 8]
2017-09-17 17:05:48 +02:00
comp.update_status()
elif c == curses.KEY_DC:
comp.entries.pop(comp.idx())
if 1 < len(comp.entries) - curses.LINES + 4 == comp.start:
comp.start -= 1
elif comp.idx() == len(comp.entries):
comp.y -= 1
comp.refresh()
elif c == 'W':
comp.dump_json()
2017-05-07 10:59:54 +02:00
elif c in (curses.KEY_F5, curses.KEY_RESIZE):
comp.resize()
2018-01-25 12:39:26 +01:00
elif c == ':':
try:
comp.mp.command(*comp.read_input(':').split())
except:
comp.print_msg(_("Failed to execute command"), error=True)