diff options
| author | HasanAbbadi <hasanabady55@gmail.com> | 2022-07-02 11:57:25 +0300 |
|---|---|---|
| committer | HasanAbbadi <hasanabady55@gmail.com> | 2022-07-02 11:57:25 +0300 |
| commit | e504a95131aac2c438d9ac3fbf504d90df57d9f8 (patch) | |
| tree | a3ab70569094dfd7477bebfe33a726ebe144ed67 | |
Init
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | jaSubs.lua | 143 | ||||
| -rw-r--r-- | jaSubs.py | 982 | ||||
| -rw-r--r-- | jaSubs_config.py | 257 |
4 files changed, 1383 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..89445e5 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# jaSubs diff --git a/jaSubs.lua b/jaSubs.lua new file mode 100644 index 0000000..360c7a7 --- /dev/null +++ b/jaSubs.lua @@ -0,0 +1,143 @@ +-- v. 2.7 +-- Interactive subtitles for `mpv` for language learners. +-- +-- default keybinding to start/stop: F5 +-- default keybinding to hide/show: F6 +-- if jaSubs start automatically - mpv won't show notification +-- +-- dirs in which jaSubs will start automatically; part of path/filename will also work; case insensitive; regexp +-- autostart_in = {'German', ' ger ', '%.ger%.', 'Deutsch', 'Hebrew'} +autostart_in = {'Japanese'} + +-- for Mac change python3 to python or pythonw +start_command = 'python3 "%s" "%s" "%s"' + +-- recomend to have these in tmpfs, or at least ssd. +sub_file = '/tmp/mpv_sub' +mpv_socket = '/tmp/mpv_socket' + +keybinding = 'F3' +keybinding_hide = 'F6' + +pyname = '~/.config/mpv/scripts/jaSubs/jaSubs.py' + +------------------------------------------------------ + +debug = false +-- debug = true + +if debug == true then + start_command = '' + start_command = 'terminator -e \'python3 "%s" "%s" "%s"; sleep 33\'' +end + +------------------------------------------------------ + +function s1() + if running == true then + s_rm() + return + end + + running = true + mp.msg.warn('Starting jaSubs...') + mp.register_event("end-file", s_rm) + rnbr = math.random(11111111, 99999999) + + if debug == true then + rnbr = '' + end + + mpv_socket_2 = mpv_socket .. '_' .. rnbr + sub_file_2 = sub_file .. '_' .. rnbr + + -- setting up socket to control mpv + mp.set_property("input-ipc-server", mpv_socket_2) + + -- without visible subs won't work + sbv = mp.get_property("sub-visibility") + mp.set_property("sub-visibility", "yes") + mp.set_property("sub-ass-override", "force") + + sub_color1 = mp.get_property("sub-color", "1/1/1/1") + sub_color2 = mp.get_property("sub-border-color", "0/0/0/1") + sub_color3 = mp.get_property("sub-shadow-color", "0/0/0/1") + mp.set_property("sub-color", "0/0/0/0") + mp.set_property("sub-border-color", "0/0/0/0") + mp.set_property("sub-shadow-color", "0/0/0/0") + + start_command_2 = start_command:format(pyname:gsub('~', os.getenv('HOME')), mpv_socket_2, sub_file_2) + os.execute(start_command_2 .. ' &') + + mp.observe_property("sub-text", "string", s2) +end + +function s2(name, value) + if type(value) == "string" then + file = io.open(sub_file_2, "w") + file:write(value) + file:close() + end +end + +function s_rm() + running = false + hidden = false + mp.msg.warn('Quitting jaSubs...') + + mp.set_property("sub-visibility", sbv) + mp.set_property("sub-color", sub_color1) + mp.set_property("sub-border-color", sub_color2) + --~ mp.set_property("sub-shadow-color", sub_color3) + + os.execute('pkill -f "' .. mpv_socket_2 .. '" &') + os.execute('(sleep 3 && rm "' .. mpv_socket_2 .. '") &') + os.execute('(sleep 3 && rm "' .. sub_file_2 .. '") &') +end + +function started() + if mp.get_property("sub") == 'no' then + return true + end + + hidden = false + + for kk, pp in pairs(autostart_in) do + if mp.get_property("path"):lower():find(pp:lower()) or mp.get_property("working-directory"):lower():find(pp:lower()) then + s1() + break + end + end +end + +function s1_1() + if running == true then + s_rm() + mp.command('show-text "Quitting jaSubs..."') + else + if mp.get_property("sub") == 'no' then + mp.command('show-text "Select subtitles before starting jaSubs."') + else + s1() + mp.command('show-text "Starting jaSubs..."') + end + end +end + +function hide_show() + if running == true then + if hidden == true then + os.execute('rm "' .. mpv_socket_2 .. '_hide" &') + mp.osd_message("Showing jaSubs.", .8) + hidden = false + else + os.execute('touch "' .. mpv_socket_2 .. '_hide" &') + mp.osd_message("Hiding jaSubs.", .8) + hidden = true + end + end +end + +mp.add_forced_key_binding(keybinding, "start-stop-jaSubs", s1_1) +mp.add_forced_key_binding(keybinding_hide, "hide-show-jaSubs", hide_show) +mp.register_event("file-loaded", started) diff --git a/jaSubs.py b/jaSubs.py new file mode 100644 index 0000000..b193dda --- /dev/null +++ b/jaSubs.py @@ -0,0 +1,982 @@ +#! /usr/bin/env python + +# v. 2.10 +# Interactive subtitles for `mpv` for language learners. + +import os, subprocess, sys +import random, re, time +import requests +import threading, queue +import calendar, math, base64 +import numpy +import ast + +from urllib.parse import quote +from json import loads +from json.decoder import JSONDecodeError + +import warnings +from six.moves import urllib + +from PyQt5.QtCore import Qt, QThread, QObject, pyqtSignal, pyqtSlot, QSize +from PyQt5.QtWidgets import QApplication, QFrame, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QWidget +from PyQt5.QtGui import QPalette, QPaintEvent, QPainter, QPainterPath, QFont, QFontMetrics, QColor, QPen, QBrush + +# Import Japanese tokinizer +from sudachipy import tokenizer +from sudachipy import dictionary +tokenizer_obj = dictionary.Dictionary().create() + +pth = os.path.expanduser('~/.config/mpv/scripts/') +os.chdir(pth) +import jaSubs_config as config + +# returns ([[word: reading, translation]..], [morphology = '', gender = '']) +# jisho.org +def jisho(word): + DOMAIN = 'jisho.org' + VERSION = '1' + + base_url = f'https://{DOMAIN}/api/v{VERSION}' + + def get(url, params=None): + if params is None: + params = {} + + response = requests.get( + url, + params=params, + ) + + json = response.json() + if response.status_code != 200: + raise APIException(response.status_code, + response.content.decode()) + + try: + word = json['data'][0]['japanese'][0]['word'] + ': ' + json['data'][0]['japanese'][0]['reading'] + reading = json['data'][0]['japanese'][0]['reading'] + translations = json['data'][0]['senses'][0]['english_definitions'] + pairs = [[word, '']] + for definition in translations: + pairs.append(['', definition]) + + return pairs, [reading, ''] + except: + return [['No translation Found', ''], ['', '']] + + def search(keyword): + url = f'{base_url}/search/words' + params = {'keyword': keyword} if keyword else {} + return get(url, params=params) + + return search(word) + + +# offline dictionary with word \t translation +def tab_divided_dict(word): + if word in offdict: + tr = re.sub('<.*?>', '', offdict[word]) if config.tab_divided_dict_remove_tags_B else offdict[word] + tr = tr.replace('\\n', '\n').replace('\\~', '~') + return [[tr, '-']], ['', ''] + else: + return [], ['', ''] + +# Google +# https://github.com/Saravananslb/py-googletranslation +class TokenAcquirer: + """Google Translate API token generator + + translate.google.com uses a token to authorize the requests. If you are + not Google, you do have this token and will have to pay for use. + This class is the result of reverse engineering on the obfuscated and + minified code used by Google to generate such token. + + The token is based on a seed which is updated once per hour and on the + text that will be translated. + Both are combined - by some strange math - in order to generate a final + token (e.g. 464393.115905) which is used by the API to validate the + request. + + This operation will cause an additional request to get an initial + token from translate.google.com. + + Example usage: + >>> from pygoogletranslation.gauthtoken import TokenAcquirer + >>> acquirer = TokenAcquirer() + >>> text = 'test' + >>> tk = acquirer.do(text) + >>> tk + 464393.115905 + """ + + def __init__(self, tkk='0', tkk_url='https://translate.google.com/translate_a/element.js', proxies=None): + + if proxies is not None: + self.proxies = proxies + else: + self.proxies = None + + r = requests.get(tkk_url, proxies=self.proxies) + + if r.status_code == 200: + re_tkk = re.search("(?<=tkk=\\')[0-9.]{0,}", str(r.content.decode("utf-8"))) + if re_tkk: + self.tkk = re_tkk.group(0) + else: + self.tkk = '0' + else: + self.tkk = '0' + + + def _xr(self, a, b): + size_b = len(b) + c = 0 + while c < size_b - 2: + d = b[c + 2] + d = ord(d[0]) - 87 if 'a' <= d else int(d) + d = self.rshift(a, d) if '+' == b[c + 1] else a << d + a = a + d & 4294967295 if '+' == b[c] else a ^ d + + c += 3 + return a + + def acquire(self, text): + a = [] + # Convert text to ints + for i in text: + val = ord(i) + if val < 0x10000: + a += [val] + else: + # Python doesn't natively use Unicode surrogates, so account for those + a += [ + math.floor((val - 0x10000) / 0x400 + 0xD800), + math.floor((val - 0x10000) % 0x400 + 0xDC00) + ] + + b = self.tkk + d = b.split('.') + b = int(d[0]) if len(d) > 1 else 0 + + # assume e means char code array + e = [] + g = 0 + size = len(a) + while g < size: + l = a[g] + # just append if l is less than 128(ascii: DEL) + if l < 128: + e.append(l) + # append calculated value if l is less than 2048 + else: + if l < 2048: + e.append(l >> 6 | 192) + else: + # append calculated value if l matches special condition + if (l & 64512) == 55296 and g + 1 < size and \ + a[g + 1] & 64512 == 56320: + g += 1 + l = 65536 + ((l & 1023) << 10) + (a[g] & 1023) # This bracket is important + e.append(l >> 18 | 240) + e.append(l >> 12 & 63 | 128) + else: + e.append(l >> 12 | 224) + e.append(l >> 6 & 63 | 128) + e.append(l & 63 | 128) + g += 1 + a = b + for i, value in enumerate(e): + a += value + a = self._xr(a, '+-a^+6') + a = self._xr(a, '+-3^+b+-f') + a ^= int(d[1]) if len(d) > 1 else 0 + if a < 0: # pragma: nocover + a = (a & 2147483647) + 2147483648 + a %= 1000000 # int(1E6) + return '{}.{}'.format(a, a ^ b) + + def do(self, text): + tk = self.acquire(text) + return tk + + + def rshift(self, val, n): + """python port for '>>>'(right shift with padding) + """ + return (val % 0x100000000) >> n + +# translate.google.com +def google(word): + word = word.replace('\n', ' ').strip() + url = 'https://translate.google.com/translate_a/single?client=t&sl={lang_from}&tl={lang_to}&hl={lang_to}&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss&dt=t&ie=UTF-8&oe=UTF-8&otf=1&pc=1&ssel=3&tsel=3&kc=2&q={word}'.format(lang_from = config.lang_from, lang_to = config.lang_to, word = quote(word)) + + pairs = [] + fname = 'urls/' + url.replace('/', "-") + try: + if ' ' in word: + raise Exception('skip saving') + + p = open(fname).read().split('=====/////-----') + try: + word_descr = p[1].strip() + except: + word_descr = '' + + for pi in p[0].strip().split('\n\n'): + pi = pi.split('\n') + pairs.append([pi[0], pi[1]]) + except: + acquirer = TokenAcquirer() + tk = acquirer.do(word) + + url = '{url}&tk={tk}'.format(url = url, tk = tk) + p = requests.get(url, headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.167 Safari/537.36'}).text + p = loads(p) + + try: + pairs.append([p[0][0][0], p[0][0][1]]) + except: + pass + + if p[1] != None: + for translations in p[1]: + for translation in translations[2]: + try: + t1 = translation[5] + ' ' + translation[0] + except: + t1 = translation[0] + + t2 = ', '.join(translation[1]) + + if not len(t1): + t1 = '-' + if not len(t2): + t2 = '-' + + pairs.append([t1, t2]) + + word_descr = '' + + return pairs, ['', ''] + +def mpv_pause(): + os.system('echo \'{ "command": ["set_property", "pause", true] }\' | socat - "' + mpv_socket + '" > /dev/null') + +def mpv_resume(): + os.system('echo \'{ "command": ["set_property", "pause", false] }\' | socat - "' + mpv_socket + '" > /dev/null') + +def mpv_pause_status(): + stdoutdata = subprocess.getoutput('echo \'{ "command": ["get_property", "pause"] }\' | socat - "' + mpv_socket + '"') + + try: + return loads(stdoutdata)['data'] + except: + return mpv_pause_status() + +def mpv_fullscreen_status(): + stdoutdata = subprocess.getoutput('echo \'{ "command": ["get_property", "fullscreen"] }\' | socat - "' + mpv_socket + '"') + + try: + return loads(stdoutdata)['data'] + except: + return mpv_fullscreen_status() + +def mpv_message(message, timeout = 3000): + os.system('echo \'{ "command": ["show-text", "' + message + '", "' + str(timeout) + '"] }\' | socat - "' + mpv_socket + '" > /dev/null') + +def stripsd2(phrase): + return ''.join(e for e in phrase.strip().lower() if e == ' ' or (e.isalnum() and not e.isdigit())).strip() + +def r2l(l): + l2 = '' + + try: + l2 = re.findall('(?!%)\W+$', l)[0][::-1] + except: + pass + + l2 += re.sub('^\W+|(?!%)\W+$', '', l) + + try: + l2 += re.findall('^\W+', l)[0][::-1] + except: + pass + + return l2 + +def split_long_lines(line, chunks = 2, max_symbols_per_line = False): + if max_symbols_per_line: + chunks = 0 + while 1: + chunks += 1 + new_lines = [] + for i in range(chunks): + new_line = ' '.join(numpy.array_split(line.split(' '), chunks)[i]) + new_lines.append(new_line) + + if len(max(new_lines, key = len)) <= max_symbols_per_line: + return '\n'.join(new_lines) + else: + new_lines = [] + for i in range(chunks): + new_line = ' '.join(numpy.array_split(line.split(' '), chunks)[i]) + new_lines.append(new_line) + + return '\n'.join(new_lines) + +def dir2(name): + print('\n'.join(dir( name ))) + exit() + +class thread_subtitles(QObject): + update_subtitles = pyqtSignal(bool, bool) + + @pyqtSlot() + def main(self): + global subs + + was_hidden = 0 + inc = 0 + auto_pause_2_ind = 0 + last_updated = time.time() + + while 1: + time.sleep(config.update_time) + + # hide subs when mpv isn't in focus or in fullscreen + if inc * config.update_time > config.focus_checking_time - 0.0001: + while 'mpv' not in subprocess.getoutput('xdotool getwindowfocus getwindowname') or (config.hide_when_not_fullscreen_B and not mpv_fullscreen_status()) or (os.path.exists(mpv_socket + '_hide')): + if not was_hidden: + self.update_subtitles.emit(True, False) + was_hidden = 1 + else: + time.sleep(config.focus_checking_time) + inc = 0 + inc += 1 + + if was_hidden: + was_hidden = 0 + self.update_subtitles.emit(False, False) + continue + + try: + tmp_file_subs = open(sub_file).read() + except: + continue + + if config.extend_subs_duration2max_B and not len(tmp_file_subs): + if not config.extend_subs_duration_limit_sec: + continue + if config.extend_subs_duration_limit_sec > time.time() - last_updated: + continue + + last_updated = time.time() + + while tmp_file_subs != subs: + if config.auto_pause == 2: + if not auto_pause_2_ind and len(re.sub(' +', ' ', stripsd2(subs.replace('\n', ' '))).split(' ')) > config.auto_pause_min_words - 1 and not mpv_pause_status(): + mpv_pause() + auto_pause_2_ind = 1 + + if auto_pause_2_ind and mpv_pause_status(): + break + + auto_pause_2_ind = 0 + + subs = tmp_file_subs + + if config.auto_pause == 1: + if len(re.sub(' +', ' ', stripsd2(subs.replace('\n', ' '))).split(' ')) > config.auto_pause_min_words - 1: + mpv_pause() + + self.update_subtitles.emit(False, False) + + break + +class thread_translations(QObject): + get_translations = pyqtSignal(str, int, bool) + + @pyqtSlot() + def main(self): + while 1: + to_new_word = False + + try: + word, globalX = config.queue_to_translate.get(False) + except: + time.sleep(config.update_time) + continue + + # changing cursor to hourglass during translation + QApplication.setOverrideCursor(Qt.WaitCursor) + + threads = [] + for translation_function_name in config.translation_function_names: + threads.append(threading.Thread(target = globals()[translation_function_name], args = (word,))) + for x in threads: + x.start() + while any(thread.is_alive() for thread in threads): + if config.queue_to_translate.qsize(): + to_new_word = True + break + time.sleep(config.update_time) + + QApplication.restoreOverrideCursor() + + if to_new_word: + continue + + if config.block_popup: + continue + + self.get_translations.emit(word, globalX, False) + +# drawing layer +# because can't calculate outline with precision +class drawing_layer(QLabel): + def __init__(self, line, subs, parent=None): + super().__init__(None) + self.line = line + self.setStyleSheet(config.style_subs) + self.psuedo_line = 0 + + def draw_text_n_outline(self, painter: QPainter, x, y, outline_width, outline_blur, text): + outline_color = QColor(config.outline_color) + + font = self.font() + text_path = QPainterPath() + text_path.addText(x, y, font, text) + + # draw blur + range_width = range(outline_width, outline_width + outline_blur) + # ~range_width = range(outline_width + outline_blur, outline_width, -1) + + for width in range_width: + if width == min(range_width): + alpha = 200 + else: + alpha = (max(range_width) - width) / max(range_width) * 200 + alpha = int(alpha) + + blur_color = QColor(outline_color.red(), outline_color.green(), outline_color.blue(), alpha) + blur_brush = QBrush(blur_color, Qt.SolidPattern) + blur_pen = QPen(blur_brush, width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + + painter.setPen(blur_pen) + painter.drawPath(text_path) + + # draw outline + outline_color = QColor(outline_color.red(), outline_color.green(), outline_color.blue(), 255) + outline_brush = QBrush(outline_color, Qt.SolidPattern) + outline_pen = QPen(outline_brush, outline_width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) + + painter.setPen(outline_pen) + painter.drawPath(text_path) + + # draw text + color = self.palette().color(QPalette.Text) + painter.setPen(color) + painter.drawText(x, y, text) + + if config.outline_B: + def paintEvent(self, evt: QPaintEvent): + if not self.psuedo_line: + self.psuedo_line = 1 + return + + x = y = 0 + y += self.fontMetrics().ascent() + painter = QPainter(self) + + self.draw_text_n_outline( + painter, + x, + y + config.outline_top_padding - config.outline_bottom_padding, + config.outline_thickness, + config.outline_blur, + text = self.line + ) + + def resizeEvent(self, *args): + self.setFixedSize( + self.fontMetrics().width(self.line), + self.fontMetrics().height() + + config.outline_bottom_padding + + config.outline_top_padding + ) + + def sizeHint(self): + return QSize( + self.fontMetrics().width(self.line), + self.fontMetrics().height() + ) + +class events_class(QLabel): + mouseHover = pyqtSignal(str, int, bool) + redraw = pyqtSignal(bool, bool) + + def __init__(self, word, subs, skip = False, parent=None): + super().__init__(word) + self.setMouseTracking(True) + self.word = word + self.subs = subs + self.skip = skip + self.highlight = False + + self.setStyleSheet('background: transparent; color: transparent;') + + def highligting(self, color, underline_width): + color = QColor(color) + color = QColor(color.red(), color.green(), color.blue(), 200) + painter = QPainter(self) + + if config.hover_underline: + font_metrics = QFontMetrics(self.font()) + text_width = font_metrics.width(self.word) + text_height = font_metrics.height() + + brush = QBrush(color) + pen = QPen(brush, underline_width, Qt.SolidLine, Qt.RoundCap) + painter.setPen(pen) + if not self.skip: + painter.drawLine(0, text_height - underline_width, text_width, text_height - underline_width) + + if config.hover_hightlight: + x = y = 0 + y += self.fontMetrics().ascent() + + painter.setPen(color) + painter.drawText(x, y + config.outline_top_padding - config.outline_bottom_padding, self.word) + + if config.outline_B: + def paintEvent(self, evt: QPaintEvent): + if self.highlight: + self.highligting(config.hover_color, config.hover_underline_thickness) + + ##################################################### + + def resizeEvent(self, event): + text_height = self.fontMetrics().height() + text_width = self.fontMetrics().width(self.word) + + self.setFixedSize(text_width, text_height + config.outline_bottom_padding + config.outline_top_padding) + + def enterEvent(self, event): + if not self.skip: + self.highlight = True + self.repaint() + config.queue_to_translate.put((self.word, event.globalX())) + + @pyqtSlot() + def leaveEvent(self, event): + if not self.skip: + self.highlight = False + self.repaint() + + config.scroll = {} + self.mouseHover.emit('', 0, False) + QApplication.restoreOverrideCursor() + + def wheel_scrolling(self, event): + if event.y() > 0: + return 'ScrollUp' + if event.y(): + return 'ScrollDown' + if event.x() > 0: + return 'ScrollLeft' + if event.x(): + return 'ScrollRight' + + def wheelEvent(self, event): + for mouse_action in config.mouse_buttons: + if self.wheel_scrolling(event.angleDelta()) == mouse_action[0]: + if event.modifiers() == eval('Qt.%s' % mouse_action[1]): + exec('self.%s(event)' % mouse_action[2]) + + def mousePressEvent(self, event): + for mouse_action in config.mouse_buttons: + if 'Scroll' not in mouse_action[0]: + if event.button() == eval('Qt.%s' % mouse_action[0]): + if event.modifiers() == eval('Qt.%s' % mouse_action[1]): + exec('self.%s(event)' % mouse_action[2]) + + ##################################################### + + def f_show_in_browser(self, event): + config.avoid_resuming = True + os.system(config.show_in_browser.replace('${word}', self.word)) + + def f_auto_pause_options(self, event): + if config.auto_pause == 2: + config.auto_pause = 0 + else: + config.auto_pause += 1 + mpv_message('auto_pause: %d' % config.auto_pause) + + @pyqtSlot() + def f_subs_screen_edge_padding_decrease(self, event): + config.subs_screen_edge_padding -= 5 + mpv_message('subs_screen_edge_padding: %d' % config.subs_screen_edge_padding) + self.redraw.emit(False, True) + + @pyqtSlot() + def f_subs_screen_edge_padding_increase(self, event): + config.subs_screen_edge_padding += 5 + mpv_message('subs_screen_edge_padding: %d' % config.subs_screen_edge_padding) + self.redraw.emit(False, True) + + @pyqtSlot() + def f_font_size_decrease(self, event): + config.style_subs = re.sub('font-size: (\d+)px;', lambda size: [ 'font-size: %dpx;' % ( int(size.group(1)) - 1 ), mpv_message('font: %s' % size.group(1)) ][0], config.style_subs, flags = re.I) + self.redraw.emit(False, True) + + @pyqtSlot() + def f_font_size_increase(self, event): + config.style_subs = re.sub('font-size: (\d+)px;', lambda size: [ 'font-size: %dpx;' % ( int(size.group(1)) + 1 ), mpv_message('font: %s' % size.group(1)) ][0], config.style_subs, flags = re.I) + self.redraw.emit(False, True) + + @pyqtSlot() + def f_translation_full_sentence(self, event): + self.mouseHover.emit(self.subs , event.globalX(), True) + + def f_auto_pause_min_words_decrease(self, event): + config.auto_pause_min_words -= 1 + mpv_message('auto_pause_min_words: %d' % config.auto_pause_min_words) + + def f_auto_pause_min_words_increase(self, event): + config.auto_pause_min_words += 1 + mpv_message('auto_pause_min_words: %d' % config.auto_pause_min_words) + +class main_class(QWidget): + def __init__(self): + super().__init__() + + self.thread_subs = QThread() + self.obj = thread_subtitles() + self.obj.update_subtitles.connect(self.render_subtitles) + self.obj.moveToThread(self.thread_subs) + self.thread_subs.started.connect(self.obj.main) + self.thread_subs.start() + + self.thread_translations = QThread() + self.obj2 = thread_translations() + self.obj2.get_translations.connect(self.render_popup) + self.obj2.moveToThread(self.thread_translations) + self.thread_translations.started.connect(self.obj2.main) + self.thread_translations.start() + + # start the forms + self.subtitles_base() + self.subtitles_base2() + self.popup_base() + + def clearLayout(self, layout): + if layout == 'subs': + layout = self.subtitles_vbox + self.subtitles.hide() + elif layout == 'subs2': + layout = self.subtitles_vbox2 + self.subtitles2.hide() + elif layout == 'popup': + layout = self.popup_vbox + self.popup.hide() + + if layout is not None: + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + + if widget is not None: + widget.deleteLater() + else: + self.clearLayout(item.layout()) + + def subtitles_base(self): + self.subtitles = QFrame() + self.subtitles.setAttribute(Qt.WA_TranslucentBackground) + self.subtitles.setWindowFlags(Qt.X11BypassWindowManagerHint) + self.subtitles.setStyleSheet(config.style_subs) + + self.subtitles_vbox = QVBoxLayout(self.subtitles) + self.subtitles_vbox.setSpacing(config.subs_padding_between_lines) + self.subtitles_vbox.setContentsMargins(0, 0, 0, 0) + + def subtitles_base2(self): + self.subtitles2 = QFrame() + self.subtitles2.setAttribute(Qt.WA_TranslucentBackground) + self.subtitles2.setWindowFlags(Qt.X11BypassWindowManagerHint) + self.subtitles2.setStyleSheet(config.style_subs) + + self.subtitles_vbox2 = QVBoxLayout(self.subtitles2) + self.subtitles_vbox2.setSpacing(config.subs_padding_between_lines) + self.subtitles_vbox2.setContentsMargins(0, 0, 0, 0) + + if config.pause_during_translation_B: + self.subtitles2.enterEvent = lambda event : [mpv_pause(), setattr(config, 'block_popup', False)][0] + self.subtitles2.leaveEvent = lambda event : [mpv_resume(), setattr(config, 'block_popup', True)][0] if not config.avoid_resuming else [setattr(config, 'avoid_resuming', False), setattr(config, 'block_popup', True)][0] + + def popup_base(self): + self.popup = QFrame() + self.popup.setAttribute(Qt.WA_TranslucentBackground) + self.popup.setWindowFlags(Qt.X11BypassWindowManagerHint) + self.popup.setStyleSheet(config.style_popup) + + self.popup_inner = QFrame() + outer_box = QVBoxLayout(self.popup) + outer_box.addWidget(self.popup_inner) + + self.popup_vbox = QVBoxLayout(self.popup_inner) + self.popup_vbox.setSpacing(0) + + def render_subtitles(self, hide = False, redraw = False): + if hide or not len(subs): + try: + self.subtitles.hide() + self.subtitles2.hide() + finally: + return + + if redraw: + self.subtitles.setStyleSheet(config.style_subs) + self.subtitles2.setStyleSheet(config.style_subs) + else: + self.clearLayout('subs') + self.clearLayout('subs2') + + if hasattr(self, 'popup'): + self.popup.hide() + + # if subtitle consists of one overly long line - split into two + if config.split_long_lines_B and len(subs.split('\n')) == 1 and len(subs.split(' ')) > config.split_long_lines_words_min - 1: + subs2 = split_long_lines(subs) + else: + subs2 = subs + + subs2 = re.sub(' +', ' ', subs2).strip() + + ############################## + + for line in subs2.split('\n'): + line2 = ' %s ' % line.strip() + ll = drawing_layer(line2, subs2) + + hbox = QHBoxLayout() + hbox.setContentsMargins(0, 0, 0, 0) + hbox.setSpacing(0) + hbox.addStretch() + hbox.addWidget(ll) + hbox.addStretch() + self.subtitles_vbox.addLayout(hbox) + + #################################### + + hbox = QHBoxLayout() + hbox.setContentsMargins(0, 0, 0, 0) + hbox.setSpacing(0) + hbox.addStretch() + + line2 += '\00' + + # Japanese Fix + mode = tokenizer.Tokenizer.SplitMode.A + line2 = [m.surface() for m in tokenizer_obj.tokenize(line2, mode)] + + for smbl in line2: + word = smbl + if smbl.isalpha(): + ll = events_class(word, subs2) + ll.mouseHover.connect(self.render_popup) + ll.redraw.connect(self.render_subtitles) + + hbox.addWidget(ll) + else: + ll = events_class(smbl, subs2, skip = True) + hbox.addWidget(ll) + + hbox.addStretch() + self.subtitles_vbox2.addLayout(hbox) + + self.subtitles.adjustSize() + self.subtitles2.adjustSize() + + w = self.subtitles.geometry().width() + h = self.subtitles.height = self.subtitles.geometry().height() + + x = (config.screen_width/2) - (w/2) + + if config.subs_top_placement_B: + y = config.subs_screen_edge_padding + else: + y = config.screen_height - config.subs_screen_edge_padding - h + + self.subtitles.setGeometry(int(x), int(y), 0, 0) + self.subtitles.show() + + self.subtitles2.setGeometry(int(x), int(y), 0, 0) + self.subtitles2.show() + + def render_popup(self, text, x_cursor_pos, is_line): + if text == '': + if hasattr(self, 'popup'): + self.popup.hide() + return + + self.clearLayout('popup') + + if is_line: + QApplication.setOverrideCursor(Qt.WaitCursor) + + line = globals()[config.translation_function_name_full_sentence](text) + if config.translation_function_name_full_sentence == 'google': + try: + line = line[0][0][0].strip() + except: + line = 'Google translation failed.' + + if config.split_long_lines_B and len(line.split('\n')) == 1 and len(line.split(' ')) > config.split_long_lines_words_min - 1: + line = split_long_lines(line) + + ll = QLabel(line) + ll.setObjectName("first_line") + self.popup_vbox.addWidget(ll) + else: + word = text + + for translation_function_name_i, translation_function_name in enumerate(config.translation_function_names): + pairs, word_descr = globals()[translation_function_name](word) + + if not len(pairs): + pairs = [['', '[Not found]']] + #return + + # ~pairs = [ [ str(i) + ' ' + pair[0], pair[1] ] for i, pair in enumerate(pairs) ] + + if word in config.scroll: + if len(pairs[config.scroll[word]:]) > config.number_of_translations: + pairs = pairs[config.scroll[word]:] + else: + pairs = pairs[-config.number_of_translations:] + if len(config.translation_function_names) == 1: + config.scroll[word] -= 1 + + for i1, pair in enumerate(pairs): + if i1 == config.number_of_translations: + break + + if config.split_long_lines_in_popup_B: + pair[0] = split_long_lines(pair[0], max_symbols_per_line = config.split_long_lines_in_popup_symbols_min) + pair[1] = split_long_lines(pair[1], max_symbols_per_line = config.split_long_lines_in_popup_symbols_min) + + if pair[0] == '-': + pair[0] = '' + if pair[1] == '-': + pair[1] = '' + + if pair[0] != '': + # to emphasize the exact form of the word + # to ignore case on input and match it on output + chnks = re.split(word, pair[0], flags = re.I) + exct_words = re.findall(word, pair[0], flags = re.I) + + hbox = QHBoxLayout() + hbox.setContentsMargins(0, 0, 0, 0) + + for i2, chnk in enumerate(chnks): + if len(chnk): + ll = QLabel(chnk) + ll.setObjectName("first_line") + hbox.addWidget(ll) + if i2 + 1 < len(chnks): + ll = QLabel(exct_words[i2]) + ll.setObjectName("first_line_emphasize_word") + hbox.addWidget(ll) + + # filling the rest of the line with empty bg + ll = QLabel() + ll.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + hbox.addWidget(ll) + + self.popup_vbox.addLayout(hbox) + + if pair[1] != '': + ll = QLabel(pair[1]) + ll.setObjectName("second_line") + self.popup_vbox.addWidget(ll) + + # padding + ll = QLabel() + ll.setStyleSheet("font-size: 6px;") + self.popup_vbox.addWidget(ll) + + if len(word_descr[0]): + ll = QLabel(word_descr[0]) + ll.setProperty("morphology", word_descr[1]) + ll.setAlignment(Qt.AlignRight) + self.popup_vbox.addWidget(ll) + + # delimiter between dictionaries + if translation_function_name_i + 1 < len(config.translation_function_names): + ll = QLabel() + ll.setObjectName("delimiter") + self.popup_vbox.addWidget(ll) + + self.popup_inner.adjustSize() + self.popup.adjustSize() + + w = self.popup.geometry().width() + h = self.popup.geometry().height() + + if w > config.screen_width: + w = config.screen_width - 20 + + if not is_line: + if w < config.screen_width / 3: + w = config.screen_width / 3 + + if x_cursor_pos == -1: + x = (config.screen_width/2) - (w/2) + else: + x = x_cursor_pos - w/5 + if x+w > config.screen_width: + x = config.screen_width - w + + if config.subs_top_placement_B: + y = self.subtitles.height + config.subs_screen_edge_padding + else: + y = config.screen_height - config.subs_screen_edge_padding - self.subtitles.height - h + + self.popup.setGeometry(int(x), int(y), int(w), 0) + self.popup.show() + + QApplication.restoreOverrideCursor() + +if __name__ == "__main__": + print('[py part] Starting jaSubs ...') + + try: + os.mkdir('urls') + except: + pass + + if 'tab_divided_dict' in config.translation_function_names: + offdict = { x.split('\t')[0].strip().lower() : x.split('\t')[1].strip() for x in open(os.path.expanduser(config.tab_divided_dict_fname)).readlines() if '\t' in x } + + mpv_socket = sys.argv[1] + sub_file = sys.argv[2] + # sub_file = '/tmp/mpv_sub_' + # mpv_socket = '/tmp/mpv_socket_' + + subs = '' + + app = QApplication(sys.argv) + + config.avoid_resuming = False + config.block_popup = False + config.scroll = {} + config.queue_to_translate = queue.Queue() + config.screen_width = app.primaryScreen().size().width() + config.screen_height = app.primaryScreen().size().height() + + form = main_class() + app.exec_() diff --git a/jaSubs_config.py b/jaSubs_config.py new file mode 100644 index 0000000..fc0ba24 --- /dev/null +++ b/jaSubs_config.py @@ -0,0 +1,257 @@ +#! /usr/bin/env python + +# v. 2.9 +# Interactive subtitles for `mpv` for language learners. + +######################################### +# all *_B variables are boolean +###### + +# make sure selected translation function supports your language and so that codes of your languages are correct +# for instance Pons doesn't support Hebrew, Google and Reverso do, but their codes are different: 'iw' and 'he' respectively +# translate from language +lang_from = 'ja' +# translate to language +lang_to = 'en' + +# dictionaries to use, one or more +# or other function's name you might write that will return ([[word, translation]..], [morphology = '', gender = '']) # available: +# jisho +# tab_divided_dict - simple offline dictionary with word \t translation per line +translation_function_names = ['jisho'] +# for automatic switch to Hebrew. Skip if it isn't your language. +translation_function_names_2 = ['google'] + +# deepl/google +translation_function_name_full_sentence = 'google' + +# number of translations in popup +number_of_translations = 4 +# number of translations to save in files for each word; 0 - to save all +number_of_translations_to_save = 50 + +# gtts|pons|forvo # gtts is google-text-to-speech +listen_via = 'gtts' + +# path to the offline dictionary +tab_divided_dict_fname = '~/d/python_shit/mpv/scripts/z.dict' +# strip <.*?> +tab_divided_dict_remove_tags_B = True + +pause_during_translation_B = True +# don't hide subtitle when its time is up and keep it on screen until the next line +extend_subs_duration2max_B = True +# limit extension duration in seconds; N == 0: do not limit +extend_subs_duration_limit_sec = 33 +# show jaSubs only in fullscreen +hide_when_not_fullscreen_B = True + +# interval between checking for the next subtitle; in seconds +update_time = .01 +# interval in seconds between checking if mpv is in focus using `xdotool` and/or in fullscreen; in seconds +focus_checking_time = .1 + +# firefox "https://en.wiktionary.org/wiki/${word}" +show_in_browser = '${BROWSER:-brave} https://translate.google.com/translate_a/single?client=t&sl=ja&tl=en&hl=en&dt=at&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss&dt=t&ie=UTF-8&oe=UTF-8&otf=1&pc=1&ssel=3&tsel=3&kc=2&q={word}' + +# for going through lines step by step +# skip pausing when subs are less then X words +auto_pause_min_words = 10 +# 0 - don't pause +# 1 - pause after subs change +# 2 - pause before subs change +# wheel click on jaSubs cycles through options +auto_pause = 0 + +######################################################### +######################################################### +### LOOKS ### +######################################################### +######################################################### + +# show subtitles at the top of the screen +subs_top_placement_B = False +# distance to the edge; in px +subs_screen_edge_padding = 25 +subs_padding_between_lines = 0 + +# when subtitle consists of only one overly long line - splitting into two +split_long_lines_B = True +# split when there are more than N words in line +split_long_lines_words_min = 8 + +# Qt's line wrapping doesn't work well +split_long_lines_in_popup_B = True +# split when there are more than N symbols in given line +split_long_lines_in_popup_symbols_min = 80 + +# ~ http://doc.qt.io/qt-5/stylesheet-reference.html +''' +/* Examples of css: */ + +background: transparent; /* fully transparent */ +background: black; /* black background*/ +background: #ffffff; /* white background*/ +background: rgba(0, 0, 0, 20%); /* semi-opaque black 20% */ +background: rgba(0, 0, 0, 50%); /* semi-opaque black 50% */ +background: rgba(0, 0, 0, 80%); /* semi-opaque black 80% */ +background: rgba(255, 255, 255, 40%); /* semi-opaque white 40% */ +background: rgba(44, 44, 44, 90%); /* semi-opaque dark-grey 90% */ + +/* Font colors: */ +color: white; +color: #BAC4D6; +color: rgb(217, 49, 49); /* red */ +color: rgba(217, 49, 49, 70%); /* semi-opaque red 70% */ + +font-family: "Trebuchet MS"; +font-weight: bold; +font-size: 33px; + +font: bold italic large "Times New Roman" 34px; + + font-family: "FiraGO"; + font-family: "Trebuchet MS"; + font-family: "American Typewriter"; +''' + +# CSS styles for subtitles +style_subs = ''' + /* looks of subtitles */ + QFrame { + background: transparent; + color: white; + color: #FFF0CD; + + font-family: "American Typewriter"; + /* font-weight: bold; */ + font-size: 52px; + } +''' + +# CSS styles for translations(popup) +style_popup = ''' + /* main */ + QFrame { + background: rgb(44, 44, 44); + + font-family: "Trebuchet MS"; + font-weight: bold; + font-size: 40px; + } + /* original language */ + QLabel#first_line { + color: #DCDCCC; + } + + /* original language - underlining exact word */ + QLabel#first_line_emphasize_word { + color: #DCDCCC; + text-decoration: underline; + } + + /* translation */ + QLabel#second_line { + color: #8B8F88; + } + + /* colorizing morphology */ + [morphology=""] { color: #CA8200; } + [morphology="m"] { color: #5EB0FF; } + [morphology="f"] { color: #E34840; } + [morphology="nt"] { color: #8BC34A; } + + /* delimiter between dictionaries */ + QFrame#delimiter { + background: #8B8F88; + font-size: 4px; /* emulating thickness */ + } +''' + +# for subtitles to be visible on background with similar color +# might look ugly with some fonts +outline_B = True +outline_color = '#000000' +# ~ N/2.5 == size in px; here it's 2px of outline + another ~ 3px of blur +outline_thickness = 5 +outline_blur = 7 +# change if outline is cropped from top/bottom of some letters depending on font +# in px; can take negative values +outline_top_padding = -2 +outline_bottom_padding = 2 + +# highlighting the word under cursor +hover_color = '#F44336' +hover_hightlight = True # may look ugly due to only int precision of QFontMetrics +hover_underline = False +hover_underline_thickness = 5 + +######################################################### +######################################################### +### reassigning mouse buttons ### +######################################################### +######################################################### + +# functions' names are self-explanatory +# ['mouse_event', 'modifier_key', 'self_explanatory_function_name'], + +# to one button multiple functions can be assigned +# by left-click this will open word in browser and save it to file. +# ['LeftButton', 'NoModifier', 'f_show_in_browser'], +# ['LeftButton', 'NoModifier', 'f_save_word_to_file'], + +# https://doc.qt.io/qt-5/qt.html#MouseButton-enum +# mouse_event: + # LeftButton + # RightButton + # MiddleButton (wheel-click) + # BackButton (Typically present on the 'thumb' side of a mouse with extra buttons. This is NOT the tilt wheel.) + # ForwardButton + + # wheel scroll up/down left/right names' are arbitrary and not from Qt + # ScrollUp + # ScrollDown + # ScrollLeft (This is the tilt wheel.) + # ScrollRight + +# Note: On macOS, the ControlModifier value corresponds to the Command keys on the keyboard. +# https://doc.qt.io/qt-5/qt.html#KeyboardModifier-enum +# modifier_key: + # NoModifier + # ControlModifier + # ShiftModifier + # AltModifier + +# self_explanatory_function_name: + # f_show_in_browser + # f_auto_pause_options + # f_listen + # f_scroll_translations_down + # f_scroll_translations_up + # f_subs_screen_edge_padding_decrease + # f_subs_screen_edge_padding_increase + # f_font_size_decrease + # f_font_size_increase + # f_auto_pause_min_words_decrease + # f_auto_pause_min_words_increase + # f_translation_full_sentence + # f_save_word_to_file + # f_deepl_translation < obsolete, changed into f_translation_full_sentence + +mouse_buttons = [ + ['LeftButton', 'NoModifier', 'f_show_in_browser'], + ['RightButton', 'NoModifier', 'f_translation_full_sentence'], + ['MiddleButton', 'NoModifier', 'f_auto_pause_options'], + + ['ScrollDown', 'ControlModifier', 'f_font_size_decrease'], + ['ScrollUp', 'ControlModifier', 'f_font_size_increase'], + + # ['ScrollLeft', 'NoModifier', 'f_auto_pause_min_words_decrease'], + # ['ScrollRight', 'NoModifier', 'f_auto_pause_min_words_increase'], + + ['ScrollDown', 'ShiftModifier', 'f_subs_screen_edge_padding_decrease'], + ['ScrollUp', 'ShiftModifier', 'f_subs_screen_edge_padding_increase'], +] + +# obsolete vars +hover_underline_width = hover_underline_thickness |
