From 4a7b2899b503cb668e33db2f5858a2027ad7147b Mon Sep 17 00:00:00 2001 From: snow flurry Date: Fri, 5 Aug 2022 22:16:17 -0700 Subject: [PATCH] Initial commit --- .gitattributes | 2 + base/.config/mpv/mpv.conf | 23 + base/.config/mpv/scripts/config.lua | 119 ++ base/.config/mpv/scripts/encoder.lua | 194 ++++ base/.config/mpv/scripts/osd_styler.lua | 85 ++ base/.config/mpv/scripts/quick-scale.lua | 111 ++ base/.config/mpv/scripts/subs2srs.lua | 1356 ++++++++++++++++++++++ base/.config/picom.conf | 8 + base/.gitconfig | 5 + base/.mkshrc | 121 ++ base/.ssh/config | 36 + base/.tmux.conf | 6 + base/.tmux/source.conf | 76 ++ base/bin/clipify | 45 + base/bin/ff2mpv | 31 + base/bin/genpw | 9 + base/bin/pkg_find | 4 + base/bin/tmux-shim | 28 + base/bin/ttc2ttf | 13 + base/bin/upfile | 62 + 20 files changed, 2334 insertions(+) create mode 100644 .gitattributes create mode 100644 base/.config/mpv/mpv.conf create mode 100644 base/.config/mpv/scripts/config.lua create mode 100644 base/.config/mpv/scripts/encoder.lua create mode 100644 base/.config/mpv/scripts/osd_styler.lua create mode 100644 base/.config/mpv/scripts/quick-scale.lua create mode 100644 base/.config/mpv/scripts/subs2srs.lua create mode 100644 base/.config/picom.conf create mode 100644 base/.gitconfig create mode 100644 base/.mkshrc create mode 100644 base/.ssh/config create mode 100644 base/.tmux.conf create mode 100644 base/.tmux/source.conf create mode 100755 base/bin/clipify create mode 100755 base/bin/ff2mpv create mode 100755 base/bin/genpw create mode 100755 base/bin/pkg_find create mode 100755 base/bin/tmux-shim create mode 100755 base/bin/ttc2ttf create mode 100755 base/bin/upfile diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4e5a24a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +#pattern filter=crypt diff=crypt merge=crypt +base/.ssh/* filter=crypt diff=crypt merge=crypt diff --git a/base/.config/mpv/mpv.conf b/base/.config/mpv/mpv.conf new file mode 100644 index 0000000..07032fc --- /dev/null +++ b/base/.config/mpv/mpv.conf @@ -0,0 +1,23 @@ +[default] +# autofit=100%x100% +# autofit-larger=90%x90% + +[floaty] +# Attempt to use yt-dlp on older mpv (coughdebiancough) +config=no +focus=no +# For picom/fvwm to latch onto +x11-name="mpv-floaty" +alpha=yes +input-ipc-server="~/.tmp/floaty-mpv.sock" +ytdl-format=bestvideo[height<=?720][fps<=?30][vcodec!=?vp9]+bestaudio/best +script-opts="qs_scale=0.20" +# autofit=25%x25% +# autofit-larger=25%x25% +ontop=yes +geometry=-10-10 +autosync=30 +sub-back-color=0.0/0.0/0.0 +cache=yes +demuxer-max-bytes=204800KiB +demuxer-readahead-secs=10 diff --git a/base/.config/mpv/scripts/config.lua b/base/.config/mpv/scripts/config.lua new file mode 100644 index 0000000..fc43da3 --- /dev/null +++ b/base/.config/mpv/scripts/config.lua @@ -0,0 +1,119 @@ +local mpopt = require('mp.options') +local initial_config = {} +local default_profile_filename = 'subs2srs' +local profiles_filename = 'subs2srs_profiles' + +local config, profiles + +local function is_empty(var) + return var == nil or var == '' or (type(var) == 'table' and next(var) == nil) +end + +local function set_audio_format() + if config.audio_format == 'opus' then + config.audio_codec = 'libopus' + config.audio_extension = '.ogg' + else + config.audio_codec = 'libmp3lame' + config.audio_extension = '.mp3' + end +end + +local function set_video_format() + if config.snapshot_format == 'webp' then + config.snapshot_extension = '.webp' + config.snapshot_codec = 'libwebp' + else + config.snapshot_extension = '.jpg' + config.snapshot_codec = 'mjpeg' + end +end + +local function ensure_in_range(dimension) + config[dimension] = config[dimension] < 42 and -2 or config[dimension] + config[dimension] = config[dimension] > 640 and 640 or config[dimension] +end + +local function conditionally_set_defaults(width, height, quality) + if config[width] < 1 and config[height] < 1 then + config[width] = -2 + config[height] = 200 + end + if config[quality] < 0 or config[quality] > 100 then + config[quality] = 15 + end +end + +local function check_image_settings() + ensure_in_range('snapshot_width') + ensure_in_range('snapshot_height') + conditionally_set_defaults('snapshot_width', 'snapshot_height', 'snapshot_quality') +end + +local function validate_config() + set_audio_format() + set_video_format() + check_image_settings() +end + +local function load_profile(profile_name) + if is_empty(profile_name) then + profile_name = profiles.active + if is_empty(profile_name) then + profile_name = default_profile_filename + end + end + mpopt.read_options(config, profile_name) +end + +local function save_initial_config() + for key, value in pairs(config) do + initial_config[key] = value + end +end + +local function restore_initial_config() + for key, value in pairs(initial_config) do + config[key] = value + end +end + +local function next_profile() + local first, next, new + for profile in string.gmatch(profiles.profiles, '[^,]+') do + if not first then + first = profile + end + if profile == profiles.active then + next = true + elseif next then + next = false + new = profile + end + end + if next == true or not new then + new = first + end + profiles.active = new + restore_initial_config() + load_profile(profiles.active) + validate_config() +end + +local function init(config_table, profiles_table) + config, profiles = config_table, profiles_table + -- 'subs2srs' is the main profile, it is always loaded. + -- 'active profile' overrides it afterwards. + mpopt.read_options(profiles, profiles_filename) + load_profile(default_profile_filename) + save_initial_config(config) + if profiles.active ~= default_profile_filename then + load_profile(profiles.active) + end + validate_config() +end + +return { + init = init, + next_profile = next_profile, +} diff --git a/base/.config/mpv/scripts/encoder.lua b/base/.config/mpv/scripts/encoder.lua new file mode 100644 index 0000000..0cbb500 --- /dev/null +++ b/base/.config/mpv/scripts/encoder.lua @@ -0,0 +1,194 @@ +local mp = require('mp') +local utils = require('mp.utils') +local _config, _store_fn, _os_temp_dir, _subprocess +local encoder + +------------------------------------------------------------ +-- utility functions + +local pad_timings = function(padding, start_time, end_time) + local video_duration = mp.get_property_number('duration') + start_time = start_time - padding + end_time = end_time + padding + + if start_time < 0 then + start_time = 0 + end + + if end_time > video_duration then + end_time = video_duration + end + + return start_time, end_time +end + +local get_active_track = function(track_type) + local track_list = mp.get_property_native('track-list') + for _, track in pairs(track_list) do + if track.type == track_type and track.selected == true then + return track + end + end + return nil +end + +------------------------------------------------------------ +-- ffmpeg encoder + +local ffmpeg = {} + +ffmpeg.prefix = { "ffmpeg", "-hide_banner", "-nostdin", "-y", "-loglevel", "quiet", "-sn", } + +ffmpeg.prepend = function(args) + if next(args) ~= nil then + for i, value in ipairs(ffmpeg.prefix) do + table.insert(args, i, value) + end + end + return args +end + +ffmpeg.make_snapshot_args = function(source_path, output_path, timestamp) + return ffmpeg.prepend { + '-an', + '-ss', tostring(timestamp), + '-i', source_path, + '-map_metadata', '-1', + '-vcodec', _config.snapshot_codec, + '-lossless', '0', + '-compression_level', '6', + '-qscale:v', tostring(_config.snapshot_quality), + '-vf', string.format('scale=%d:%d', _config.snapshot_width, _config.snapshot_height), + '-vframes', '1', + output_path + } +end + +ffmpeg.make_audio_args = function(source_path, output_path, start_timestamp, end_timestamp) + local audio_track = get_active_track('audio') + local audio_track_id = audio_track['ff-index'] + + if audio_track and audio_track.external == true then + source_path = audio_track['external-filename'] + audio_track_id = 'a' + end + + return ffmpeg.prepend { + '-vn', + '-ss', tostring(start_timestamp), + '-to', tostring(end_timestamp), + '-i', source_path, + '-map_metadata', '-1', + '-map', string.format("0:%d", audio_track_id), + '-ac', '1', + '-codec:a', _config.audio_codec, + '-vbr', 'on', + '-compression_level', '10', + '-application', 'voip', + '-b:a', tostring(_config.audio_bitrate), + '-filter:a', string.format("volume=%.1f", _config.tie_volumes and mp.get_property_native('volume') / 100 or 1), + output_path + } +end + +------------------------------------------------------------ +-- mpv encoder + +local mpv = {} + +mpv.make_snapshot_args = function(source_path, output_path, timestamp) + return { + 'mpv', + source_path, + '--loop-file=no', + '--audio=no', + '--no-ocopy-metadata', + '--no-sub', + '--frames=1', + '--ovcopts-add=lossless=0', + '--ovcopts-add=compression_level=6', + table.concat { '--ovc=', _config.snapshot_codec }, + table.concat { '-start=', timestamp }, + table.concat { '--ovcopts-add=quality=', tostring(_config.snapshot_quality) }, + table.concat { '--vf-add=scale=', _config.snapshot_width, ':', _config.snapshot_height }, + table.concat { '-o=', output_path } + } +end + +mpv.make_audio_args = function(source_path, output_path, start_timestamp, end_timestamp) + local audio_track = get_active_track('audio') + local audio_track_id = mp.get_property("aid") + + if audio_track and audio_track.external == true then + source_path = audio_track['external-filename'] + audio_track_id = 'auto' + end + + return { + 'mpv', + source_path, + '--loop-file=no', + '--video=no', + '--no-ocopy-metadata', + '--no-sub', + '--audio-channels=mono', + '--oacopts-add=vbr=on', + '--oacopts-add=application=voip', + '--oacopts-add=compression_level=10', + table.concat { '--oac=', _config.audio_codec }, + table.concat { '--start=', start_timestamp }, + table.concat { '--end=', end_timestamp }, + table.concat { '--aid=', audio_track_id }, + table.concat { '--volume=', _config.tie_volumes and mp.get_property('volume') or '100' }, + table.concat { '--oacopts-add=b=', _config.audio_bitrate }, + table.concat { '-o=', output_path } + } +end + +------------------------------------------------------------ +-- main interface + +local create_snapshot = function(timestamp, filename) + local source_path = mp.get_property("path") + local output_path = utils.join_path(_os_temp_dir(), filename) + local args = encoder.make_snapshot_args(source_path, output_path, timestamp) + local on_finish = function() + _store_fn(filename, output_path) + os.remove(output_path) + end + _subprocess(args, on_finish) +end + +local create_audio = function(start_timestamp, end_timestamp, filename, padding) + local source_path = mp.get_property("path") + local output_path = utils.join_path(_os_temp_dir(), filename) + + if padding > 0 then + start_timestamp, end_timestamp = pad_timings(padding, start_timestamp, end_timestamp) + end + + local args = encoder.make_audio_args(source_path, output_path, start_timestamp, end_timestamp) + for arg in string.gmatch(_config.use_ffmpeg and _config.ffmpeg_audio_args or _config.mpv_audio_args, "%S+") do + -- Prepend before output path + table.insert(args, #args, arg) + end + local on_finish = function() + _store_fn(filename, output_path) + os.remove(output_path) + end + _subprocess(args, on_finish) +end + +local init = function(config, store_fn, os_temp_dir, subprocess) + _config = config + _store_fn = store_fn + _os_temp_dir = os_temp_dir + _subprocess = subprocess + encoder = config.use_ffmpeg and ffmpeg or mpv +end + +return { + init = init, + create_snapshot = create_snapshot, + create_audio = create_audio, +} diff --git a/base/.config/mpv/scripts/osd_styler.lua b/base/.config/mpv/scripts/osd_styler.lua new file mode 100644 index 0000000..d650d30 --- /dev/null +++ b/base/.config/mpv/scripts/osd_styler.lua @@ -0,0 +1,85 @@ +--[[ +A helper class for styling OSD messages +http://docs.aegisub.org/3.2/ASS_Tags/ + +Copyright (C) 2021 Ren Tatsumoto + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +]] + +local OSD = {} +OSD.__index = OSD + +function OSD:new() + return setmetatable({ messages = {} }, self) +end + +function OSD:append(s) + table.insert(self.messages, s) + return self +end + +function OSD:newline() + return self:append([[\N]]) +end + +function OSD:tab() + return self:append([[\h\h\h\h]]) +end + +function OSD:size(size) + return self:append('{\\fs'):append(size):append('}') +end + +function OSD:align(number) + return self:append('{\\an'):append(number):append('}') +end + +function OSD:get_text() + return table.concat(self.messages) +end + +function OSD:color(code) + return self:append('{\\1c&H') + :append(code:sub(5, 6)) + :append(code:sub(3, 4)) + :append(code:sub(1, 2)) + :append('&}') +end + +function OSD:text(text) + return self:append(text) +end + +function OSD:bold(s) + return self:append('{\\b1}'):append(s):append('{\\b0}') +end + +function OSD:italics(s) + return self:append('{\\i1}'):append(s):append('{\\i0}') +end + +function OSD:submenu(text) + return self:color('ffe1d0'):bold(text):color('ffffff') +end + +function OSD:item(text) + return self:color('fef6dd'):bold(text):color('ffffff') +end + +function OSD:red(text) + return self:color('ff0000'):bold(text):color('ffffff') +end + +return OSD diff --git a/base/.config/mpv/scripts/quick-scale.lua b/base/.config/mpv/scripts/quick-scale.lua new file mode 100644 index 0000000..396962c --- /dev/null +++ b/base/.config/mpv/scripts/quick-scale.lua @@ -0,0 +1,111 @@ +-- ----------------------------------------------------------- +-- +-- QUICK-SCALE.LUA +-- Version: 1.1 +-- Author: VideoPlayerCode +-- URL: https://github.com/VideoPlayerCode/mpv-tools +-- +-- Description: +-- +-- Quickly scale the video player to a target size, +-- with full control over target scale and max scale. +-- Helps you effortlessly resize a video to fit on your +-- desktop, or any other video dimensions you need! +-- +-- History: +-- +-- 1.0: Initial release. +-- 1.1: Do nothing if mpv is in fullscreen mode. +-- +-- ----------------------------------------------------------- +-- +-- Parameters: +-- targetwidth = How wide you want the target area to be. +-- targetheight = How tall you want the target area to be. +-- targetscale = If this is 1, we use your target width/height +-- as-is, but if it's another value then we scale your provided +-- target size by that amount. This parameter is great if you want +-- a video to be a certain percentage of your desktop resolution. +-- In that case, just set targetwidth/targetheight to your +-- desktop resolution, and set this targetscale to the percentage +-- of your desktop that you want to use for the video, such as +-- "0.25" to resize the video to 25% of your desktop resolution. +-- maxvideoscale = If this is a positive number (anything above 0), +-- then the final video scale cannot exceed this number. +-- This is useful if you for example set the target to 25% +-- of your desktop resolution. If the video is smaller than that, +-- then it would be scaled up (enlarged) to the size of the target. +-- To control that behavior, simply set this parameter. +-- Here are some examples: +-- -1, 0, or any other non-positive number: We'll enlarge +-- too-small videos and shrink too-large videos. Small videos +-- will be enlarged as much as needed to match target size. +-- 1: Video will only be allowed to enlarge to 100% of its natural size. +-- This means that small videos won't become big and blurry. +-- 1.5: Video will only be allowed to enlarge to 150% of its natural size. +local options = require 'mp.options' + +function quick_scale(targetwidth, targetheight, targetscale, maxvideoscale) + -- Don't attempt to scale the fullscreen window. + if (mp.get_property_bool("fullscreen", false)) then + return nil -- abort + end + + -- Check parameter existence. + if (targetwidth == nil or targetheight == nil + or targetscale == nil or maxvideoscale == nil) + then + mp.osd_message("Quick_Scale: Missing parameters") + return nil -- abort + end + + -- Ensure that the incoming strings are valid numbers. + targetwidth = tonumber(targetwidth) + targetheight = tonumber(targetheight) + targetscale = tonumber(targetscale) + maxvideoscale = tonumber(maxvideoscale) + if (targetwidth == nil or targetheight == nil + or targetscale == nil or maxvideoscale == nil) + then + mp.osd_message("Quick_Scale: Non-numeric parameters") + return nil -- abort + end + + -- If the target scale isn't 1 (100%), we'll re-calculate target size. + if (targetscale ~= 1) then + targetwidth = targetwidth * targetscale + targetheight = targetheight * targetscale + end + + -- Find smallest video scale that fits target size in both width and height. + -- This only looks at video and doesn't take window borders into account! + widthscale = targetwidth / mp.get_property("width") + heightscale = targetheight / mp.get_property("height") + local scale = (widthscale < heightscale and widthscale or heightscale) + + -- If we arrived at a target width/height that is larger than the video's + -- natural "100%" scale, then we may want to limit it to a maximum amount. + if (maxvideoscale > 0 and scale > maxvideoscale) then + scale = maxvideoscale + end + + -- Apply the new video scale. + mp.set_property_number("window-scale", scale) +end + +-- Bind this via input.conf. Examples: +-- To fit a video to 100% of a 1680x1050 desktop size, with unlimited video enlarging: +-- Alt+9 script-message Quick_Scale "1680" "1050" "1" "-1" +-- To fit a video to 80% of a 1680x1050 desktop size, but disallowing the +-- video from becoming larger than 150% of its natural size: +-- Alt+9 script-message Quick_Scale "1680" "1050" "0.8" "1.5" +-- To fit a video to a 200x200 box, with unlimited video enlarging: +-- Alt+9 script-message Quick_Scale "200" "200" "1" "-1" +mp.register_script_message("Quick_Scale", quick_scale) + +mp.register_event("file-loaded", function() + local scale = mp.get_opt("qs_scale") + if not (scale == nil) then + quick_scale("1920", "1080", scale, "1") + end +end) diff --git a/base/.config/mpv/scripts/subs2srs.lua b/base/.config/mpv/scripts/subs2srs.lua new file mode 100644 index 0000000..24d08ba --- /dev/null +++ b/base/.config/mpv/scripts/subs2srs.lua @@ -0,0 +1,1356 @@ +--[[ +Copyright (C) 2020 Ren Tatsumoto + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Requirements: +* mpv >= 0.32.0 +* AnkiConnect +* curl +* xclip (when running X11) +* wl-copy (when running Wayland) + +Usage: +1. Change `config` according to your needs +* Config path: ~/.config/mpv/script-opts/subs2srs.conf +* Config file isn't created automatically. + +2. Open a video + +3. Use key bindings to manipulate the script +* Open mpvacious menu - `a` +* Create a note from the current subtitle line - `Ctrl + e` + +For complete usage guide, see +]] + +local config = { + -- Common + autoclip = false, -- enable copying subs to the clipboard when mpv starts + nuke_spaces = false, -- remove all spaces from exported anki cards + clipboard_trim_enabled = true, -- remove unnecessary characters from strings before copying to the clipboard + use_ffmpeg = false, -- if set to true, use ffmpeg to create audio clips and snapshots. by default use mpv. + snapshot_format = "webp", -- webp or jpg + snapshot_quality = 15, -- from 0=lowest to 100=highest + snapshot_width = -2, -- a positive integer or -2 for auto + snapshot_height = 200, -- same + audio_format = "opus", -- opus or mp3 + audio_bitrate = "18k", -- from 16k to 32k + audio_padding = 0.12, -- Set a pad to the dialog timings. 0.5 = audio is padded by .5 seconds. 0 = disable. + tie_volumes = false, -- if set to true, the volume of the outputted audio file depends on the volume of the player at the time of export + menu_font_size = 25, + + -- Custom encoding args + ffmpeg_audio_args = '-af silenceremove=1:0:-50dB', + mpv_audio_args = '--af-append=silenceremove=1:0:-50dB', + + -- Anki + create_deck = false, -- automatically create a deck for new cards + allow_duplicates = false, -- allow making notes with the same sentence field + deck_name = "Learning", -- name of the deck for new cards + model_name = "Japanese sentences", -- Tools -> Manage note types + sentence_field = "SentKanji", + audio_field = "SentAudio", + image_field = "Image", + append_media = true, -- True to append video media after existing data, false to insert media before + + -- Note tagging + -- The tag(s) added to new notes. Spaces separate multiple tags. + -- Change to "" to disable tagging completely. + -- The following substitutions are supported: + -- %n - the name of the video + -- %t - timestamp + -- %d - episode number (if none found, returns nothing) + -- %e - SUBS2SRS_TAGS environment variable + note_tag = "subs2srs %n", + tag_nuke_brackets = true, -- delete all text inside brackets before substituting filename into tag + tag_nuke_parentheses = false, -- delete all text inside parentheses before substituting filename into tag + tag_del_episode_num = true, -- delete the episode number if found + tag_del_after_episode_num = true, -- delete everything after the found episode number (does nothing if tag_del_episode_num is disabled) + tag_filename_lowercase = false, -- convert filename to lowercase for tagging. + + -- Misc info + miscinfo_enable = true, + miscinfo_field = "Notes", -- misc notes and source information field + miscinfo_format = "%n EP%d (%t)", -- format string to use for the miscinfo_field, accepts note_tag-style format strings + + -- Forvo support + use_forvo = "yes", -- 'yes', 'no', 'always' + vocab_field = "VocabKanji", -- target word field + vocab_audio_field = "VocabAudio", -- target word audio +} + +-- Defines config profiles +-- Each name references a file in ~/.config/mpv/script-opts/*.conf +-- Profiles themselves are defined in ~/.config/mpv/script-opts/subs2srs_profiles.conf +local profiles = { + profiles = "subs2srs,subs2srs_english", + active = "subs2srs", +} + +package.path = package.path .. ";/Users/flurry/.config/mpv/scripts/?.lua" + +local mp = require('mp') +local utils = require('mp.utils') +local msg = require('mp.msg') +local config_manager = require('config') +local encoder = require('encoder') + +-- namespaces +local subs +local clip_autocopy +local menu +local platform +local append_forvo_pronunciation + +-- classes +local Subtitle + +------------------------------------------------------------ +-- utility functions + +local unpack = unpack and unpack or table.unpack + +---Returns true if table contains element. Returns false otherwise. +---@param table table +---@param element any +---@return boolean +function table.contains(table, element) + for _, value in pairs(table) do + if value == element then + return true + end + end + return false +end + +---Returns the largest numeric index. +---@param table table +---@return number +function table.max_num(table) + local max = table[1] + for _, value in ipairs(table) do + if value > max then + max = value + end + end + return max +end + +---Returns a value for the given key. If key is not available then returns default value 'nil'. +---@param table table +---@param key string +---@param default any +---@return any +function table.get(table, key, default) + if table[key] == nil then + return default or 'nil' + else + return table[key] + end +end + +local function is_empty(var) + return var == nil or var == '' or (type(var) == 'table' and next(var) == nil) +end + +local function is_running_windows() + return mp.get_property('options/vo-mmcss-profile') ~= nil +end + +local function is_running_macOS() + return mp.get_property('options/cocoa-force-dedicated-gpu') ~= nil +end + +local function is_running_wayland() + return os.getenv('WAYLAND_DISPLAY') ~= nil +end + +local function contains_non_latin_letters(str) + return str:match("[^%c%p%s%w]") +end + +local function capitalize_first_letter(string) + return string:gsub("^%l", string.upper) +end + +local function notify(message, level, duration) + level = level or 'info' + duration = duration or 1 + msg[level](message) + mp.osd_message(message, duration) +end + +local escape_special_characters +do + local entities = { + ['&'] = '&', + ['"'] = '"', + ["'"] = ''', + ['<'] = '<', + ['>'] = '>', + } + escape_special_characters = function(s) + return s:gsub('[&"\'<>]', entities) + end +end + +local function remove_extension(filename) + return filename:gsub('%.%w+$', '') +end + +local function remove_special_characters(str) + return str:gsub('[%c%p%s]', ''):gsub(' ', '') +end + +local function remove_text_in_brackets(str) + return str:gsub('%b[]', ''):gsub('【.-】', '') +end + +local function remove_filename_text_in_parentheses(str) + return str:gsub('%b()', ''):gsub('(.-)', '') +end + +local function remove_common_resolutions(str) + -- Also removes empty leftover parentheses and brackets. + return str:gsub("2160p", ""):gsub("1080p", ""):gsub("720p", ""):gsub("576p", ""):gsub("480p", ""):gsub("%(%)", ""):gsub("%[%]", "") +end + +local function remove_text_in_parentheses(str) + -- Remove text like (泣き声) or (ドアの開く音) + -- No deletion is performed if there's no text after the parentheses. + -- Note: the modifier `-´ matches zero or more occurrences. + -- However, instead of matching the longest sequence, it matches the shortest one. + return str:gsub('(%b())(.)', '%2'):gsub('((.-))(.)', '%2') +end + +local function remove_newlines(str) + return str:gsub('[\n\r]+', ' ') +end + +local function remove_leading_trailing_spaces(str) + return str:gsub('^%s*(.-)%s*$', '%1') +end + +local function remove_leading_trailing_dashes(str) + return str:gsub('^[%-_]*(.-)[%-_]*$', '%1') +end + +local function remove_all_spaces(str) + return str:gsub('%s*', '') +end + +local function remove_spaces(str) + if config.nuke_spaces == true and contains_non_latin_letters(str) then + return remove_all_spaces(str) + else + return remove_leading_trailing_spaces(str) + end +end + +local function trim(str) + str = remove_spaces(str) + str = remove_text_in_parentheses(str) + str = remove_newlines(str) + return str +end + +local function copy_to_clipboard(_, text) + if not is_empty(text) then + text = config.clipboard_trim_enabled and trim(text) or remove_newlines(text) + platform.copy_to_clipboard(text) + end +end + +local function copy_sub_to_clipboard() + copy_to_clipboard("copy-on-demand", mp.get_property("sub-text")) +end + +local function human_readable_time(seconds) + if type(seconds) ~= 'number' or seconds < 0 then + return 'empty' + end + + local parts = { + h = math.floor(seconds / 3600), + m = math.floor(seconds / 60) % 60, + s = math.floor(seconds % 60), + ms = math.floor((seconds * 1000) % 1000), + } + + local ret = string.format("%02dm%02ds%03dms", parts.m, parts.s, parts.ms) + + if parts.h > 0 then + ret = string.format('%dh%s', parts.h, ret) + end + + return ret +end + +local function subprocess(args, completion_fn) + -- if `completion_fn` is passed, the command is ran asynchronously, + -- and upon completion, `completion_fn` is called to process the results. + local command_native = type(completion_fn) == 'function' and mp.command_native_async or mp.command_native + local command_table = { + name = "subprocess", + playback_only = false, + capture_stdout = true, + args = args + } + return command_native(command_table, completion_fn) +end + +local codec_support = (function() + return { + snapshot = { + libwebp = false, + mjpeg = false, + }, + audio = { + libmp3lame = false, + libopus = false, + }, + } +end)() + +local function warn_formats(osd) + if config.use_ffmpeg then + return + end + for type, codecs in pairs(codec_support) do + for codec, supported in pairs(codecs) do + if not supported and config[type .. '_codec'] == codec then + osd:red('warning: '):newline() + osd:tab():text(string.format("your version of mpv does not support %s.", codec)):newline() + osd:tab():text(string.format("mpvacious won't be able to create %s files.", type)):newline() + end + end + end +end + +local function ensure_deck() + if config.create_deck == true then + ankiconnect.create_deck(config.deck_name) + end +end + +local function load_next_profile() + config_manager.next_profile() + ensure_deck() + notify("Loaded profile " .. profiles.active) +end + +local function get_episode_number(filename) + -- Reverses the filename to start the search from the end as the media title might contain similar numbers. + local filename_reversed = filename:reverse() + + local ep_num_patterns = { + "%s?(%d?%d?%d)[pP]?[eE]", -- Starting with E or EP (case-insensitive). "Example Series S01E01" + "%)(%d?%d?%d)%(", -- Surrounded by parentheses. "Example Series (12)" + "%](%d?%d?%d)%[", -- Surrounded by brackets. "Example Series [01]" + "%s(%d?%d?%d)%s", -- Surrounded by whitespace. "Example Series 124 [1080p 10-bit]" + "_(%d?%d?%d)_", -- Surrounded by underscores. "Example_Series_04_1080p" + "^(%d?%d?%d)[%s_]", -- Ending to the episode number. "Example Series 124" + "(%d?%d?%d)%-edosipE", -- Prepended by "Episode-". "Example Episode-165" + } + + local s, e, episode_num + for _, pattern in pairs(ep_num_patterns) do + s, e, episode_num = string.find(filename_reversed, pattern) + if not is_empty(episode_num) then + return #filename - e, #filename - s, episode_num:reverse() + end + end +end + +local function tag_format(filename) + filename = remove_extension(filename) + filename = remove_common_resolutions(filename) + + local s, e, episode_num = get_episode_number(filename) + + if config.tag_del_episode_num == true and not is_empty(s) then + if config.tag_del_after_episode_num == true then + -- Removing everything (e.g. episode name) after the episode number including itself. + filename = filename:sub(1, s) + else + -- Removing the first found instance of the episode number. + filename = filename:sub(1, s) .. filename:sub(e + 1, -1) + end + end + + if config.tag_nuke_brackets == true then + filename = remove_text_in_brackets(filename) + end + if config.tag_nuke_parentheses == true then + filename = remove_filename_text_in_parentheses(filename) + end + + if config.tag_filename_lowercase == true then + filename = filename:lower() + end + + filename = remove_leading_trailing_spaces(filename) + filename = filename:gsub(" ", "_") + filename = filename:gsub("_%-_", "_") -- Replaces garbage _-_ substrings with a underscore + filename = remove_leading_trailing_dashes(filename) + return filename, episode_num or '' +end + +local function substitute_fmt(tag) + local filename, episode = tag_format(mp.get_property("filename")) + + local function substitute_filename(_tag) + return _tag:gsub("%%n", filename) + end + + local function substitute_episode_number(_tag) + return _tag:gsub("%%d", episode) + end + + local function substitute_time_pos(_tag) + local time_pos = human_readable_time(mp.get_property_number('time-pos')) + return _tag:gsub("%%t", time_pos) + end + + local function substitute_envvar(_tag) + local env_tags = os.getenv('SUBS2SRS_TAGS') or '' + return _tag:gsub("%%e", env_tags) + end + + tag = substitute_filename(tag) + tag = substitute_episode_number(tag) + tag = substitute_time_pos(tag) + tag = substitute_envvar(tag) + tag = remove_leading_trailing_spaces(tag) + + return tag +end + +local function construct_note_fields(sub_text, snapshot_filename, audio_filename) + local ret = { + [config.sentence_field] = sub_text, + [config.image_field] = string.format('snapshot', snapshot_filename), + [config.audio_field] = string.format('[sound:%s]', audio_filename), + } + if config.miscinfo_enable == true then + ret[config.miscinfo_field] = substitute_fmt(config.miscinfo_format) + end + return ret +end + +local function minutes_ago(m) + return (os.time() - 60 * m) * 1000 +end + +local function join_media_fields(new_data, stored_data) + for _, field in pairs { config.audio_field, config.image_field, config.miscinfo_field } do + new_data[field] = table.get(stored_data, field, "") .. table.get(new_data, field, "") + end + return new_data +end + +local function update_sentence(new_data, stored_data) + -- adds support for TSCs + -- https://tatsumoto-ren.github.io/blog/discussing-various-card-templates.html#targeted-sentence-cards-or-mpvacious-cards + -- if the target word was marked by yomichan, this function makes sure that the highlighting doesn't get erased. + + if is_empty(stored_data[config.sentence_field]) then + -- sentence field is empty. can't continue. + return new_data + elseif is_empty(new_data[config.sentence_field]) then + -- *new* sentence field is empty, but old one contains data. don't delete the existing sentence. + new_data[config.sentence_field] = stored_data[config.sentence_field] + return new_data + end + + local _, opentag, target, closetag, _ = stored_data[config.sentence_field]:match('^(.-)(<[^>]+>)(.-)(]+>)(.-)$') + if target then + local prefix, _, suffix = new_data[config.sentence_field]:match(table.concat { '^(.-)(', target, ')(.-)$' }) + if prefix and suffix then + new_data[config.sentence_field] = table.concat { prefix, opentag, target, closetag, suffix } + end + end + return new_data +end + +local function audio_padding() + local video_duration = mp.get_property_number('duration') + if config.audio_padding == 0.0 or not video_duration then + return 0.0 + end + if subs.user_timings.is_set('start') or subs.user_timings.is_set('end') then + return 0.0 + end + return config.audio_padding +end + +------------------------------------------------------------ +-- utility classes + +local function new_timings() + local self = { ['start'] = -1, ['end'] = -1, } + local is_set = function(position) + return self[position] >= 0 + end + local set = function(position) + self[position] = mp.get_property_number('time-pos') + end + local get = function(position) + return self[position] + end + return { + is_set = is_set, + set = set, + get = get, + } +end + +local function new_sub_list() + local subs_list = {} + local _is_empty = function() + return next(subs_list) == nil + end + local find_i = function(sub) + for i, v in ipairs(subs_list) do + if sub < v then + return i + end + end + return #subs_list + 1 + end + local get_time = function(position) + local i = position == 'start' and 1 or #subs_list + return subs_list[i][position] + end + local get_text = function() + local speech = {} + for _, sub in ipairs(subs_list) do + table.insert(speech, sub['text']) + end + return table.concat(speech, ' ') + end + local insert = function(sub) + if sub ~= nil and not table.contains(subs_list, sub) then + table.insert(subs_list, find_i(sub), sub) + return true + end + return false + end + return { + get_time = get_time, + get_text = get_text, + is_empty = _is_empty, + insert = insert + } +end + +local function make_switch(states) + local self = { + states = states, + current_state = 1 + } + local bump = function() + self.current_state = self.current_state + 1 + if self.current_state > #self.states then + self.current_state = 1 + end + end + local get = function() + return self.states[self.current_state] + end + return { + bump = bump, + get = get + } +end + +local filename_factory = (function() + local filename + + local anki_compatible_length = (function() + -- Anki forcibly mutilates all filenames longer than 119 bytes when you run `Tools->Check Media...`. + local allowed_bytes = 119 + local timestamp_bytes = #'_99h99m99s999ms-99h99m99s999ms.webp' + + return function(str, timestamp) + -- if timestamp provided, recalculate limit_bytes + local limit_bytes = allowed_bytes - (timestamp and #timestamp or timestamp_bytes) + + if #str <= limit_bytes then + return str + end + + local bytes_per_char = contains_non_latin_letters(str) and #'車' or #'z' + local limit_chars = math.floor(limit_bytes / bytes_per_char) + + if limit_chars == limit_bytes then + return str:sub(1, limit_bytes) + end + + local ret = subprocess { + 'awk', + '-v', string.format('str=%s', str), + '-v', string.format('limit=%d', limit_chars), + 'BEGIN{print substr(str, 1, limit); exit}' + } + + if ret.status == 0 then + ret.stdout = remove_newlines(ret.stdout) + ret.stdout = remove_leading_trailing_spaces(ret.stdout) + return ret.stdout + else + return 'subs2srs_' .. os.time() + end + end + end)() + + local make_media_filename = function() + filename = mp.get_property("filename") -- filename without path + filename = remove_extension(filename) + filename = remove_text_in_brackets(filename) + filename = remove_special_characters(filename) + end + + local make_audio_filename = function(speech_start, speech_end) + local filename_timestamp = string.format( + '_%s-%s%s', + human_readable_time(speech_start), + human_readable_time(speech_end), + config.audio_extension + ) + return anki_compatible_length(filename, filename_timestamp) .. filename_timestamp + end + + local make_snapshot_filename = function(timestamp) + local filename_timestamp = string.format( + '_%s%s', + human_readable_time(timestamp), + config.snapshot_extension + ) + return anki_compatible_length(filename, filename_timestamp) .. filename_timestamp + end + + mp.register_event("file-loaded", make_media_filename) + + return { + make_audio_filename = make_audio_filename, + make_snapshot_filename = make_snapshot_filename, + } +end)() + +------------------------------------------------------------ +-- front for adding and updating notes + +local function export_to_anki(gui) + local sub = subs.get() + if sub == nil then + notify("Nothing to export.", "warn", 1) + return + end + + if not gui and is_empty(sub['text']) then + sub['text'] = string.format("mpvacious wasn't able to grab subtitles (%s)", os.time()) + end + + local snapshot_timestamp = mp.get_property_number("time-pos", 0) + local snapshot_filename = filename_factory.make_snapshot_filename(snapshot_timestamp) + local audio_filename = filename_factory.make_audio_filename(sub['start'], sub['end']) + + encoder.create_snapshot(snapshot_timestamp, snapshot_filename) + encoder.create_audio(sub['start'], sub['end'], audio_filename, audio_padding()) + + local note_fields = construct_note_fields(sub['text'], snapshot_filename, audio_filename) + subs.clear() +end + +local function update_last_note(overwrite) + return +end + +------------------------------------------------------------ +-- seeking: sub replay, sub seek, sub rewind + +local function _(params) + return function() + return pcall(unpack(params)) + end +end + +local pause_timer = (function() + local stop_time = -1 + local check_stop + local set_stop_time = function(time) + stop_time = time + mp.observe_property("time-pos", "number", check_stop) + end + local stop = function() + mp.unobserve_property(check_stop) + stop_time = -1 + end + check_stop = function(_, time) + if time > stop_time then + stop() + mp.set_property("pause", "yes") + end + end + return { + set_stop_time = set_stop_time, + check_stop = check_stop, + stop = stop, + } +end)() + +local play_control = (function() + local current_sub + + local function stop_at_the_end(sub) + pause_timer.set_stop_time(sub['end'] - 0.050) + notify("Playing till the end of the sub...", "info", 3) + end + + local function play_till_sub_end() + local sub = subs.get_current() + mp.commandv('seek', sub['start'], 'absolute') + mp.set_property("pause", "no") + stop_at_the_end(sub) + end + + local function sub_seek(direction, pause) + mp.commandv("sub_seek", direction == 'backward' and '-1' or '1') + mp.commandv("seek", "0.015", "relative+exact") + if pause then + mp.set_property("pause", "yes") + end + pause_timer.stop() + end + + local function sub_rewind() + mp.commandv('seek', subs.get_current()['start'] + 0.015, 'absolute') + pause_timer.stop() + end + + local function check_sub() + local sub = subs.get_current() + if sub and sub ~= current_sub then + mp.unobserve_property(check_sub) + stop_at_the_end(sub) + end + end + + local function play_till_next_sub_end() + current_sub = subs.get_current() + mp.observe_property("sub-text", "string", check_sub) + mp.set_property("pause", "no") + notify("Waiting till next sub...", "info", 10) + end + + return { + play_till_sub_end = play_till_sub_end, + play_till_next_sub_end = play_till_next_sub_end, + sub_seek = sub_seek, + sub_rewind = sub_rewind, + } +end)() + +------------------------------------------------------------ +-- platform specific + +local function init_platform_windows() + local self = {} + local curl_tmpfile_path = utils.join_path(os.getenv('TEMP'), 'curl_tmp.txt') + mp.register_event('shutdown', function() + os.remove(curl_tmpfile_path) + end) + + self.tmp_dir = function() + return os.getenv('TEMP') + end + + self.copy_to_clipboard = function(text) + text = text:gsub("&", "^^^&"):gsub("[<>|]", "") + mp.commandv("run", "cmd.exe", "/d", "/c", string.format("@echo off & chcp 65001 >nul & echo %s|clip", text)) + end + + self.curl_request = function(request_json, completion_fn) + end + + self.windows = true + + return self +end + +local function init_platform_nix() + local self = {} + local clip = is_running_macOS() and 'LANG=en_US.UTF-8 pbcopy' or is_running_wayland() and 'wl-copy' or 'xclip -i -selection clipboard' + + self.tmp_dir = function() + return '/tmp' + end + + self.copy_to_clipboard = function(text) + local handle = io.popen(clip, 'w') + handle:write(text) + handle:close() + end + + self.curl_request = function(request_json, completion_fn) + end + + return self +end + +platform = is_running_windows() and init_platform_windows() or init_platform_nix() + +------------------------------------------------------------ +-- utils for downloading pronunciations from Forvo + +do + local base64d -- http://lua-users.org/wiki/BaseSixtyFour + do + local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + base64d = function(data) + data = string.gsub(data, '[^'..b..'=]', '') + return (data:gsub('.', function(x) + if (x == '=') then return '' end + local r,f='',(b:find(x)-1) + for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end + return r; + end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x) + if (#x ~= 8) then return '' end + local c=0 + for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end + return string.char(c) + end)) + end + end + + local function url_encode(url) + -- https://gist.github.com/liukun/f9ce7d6d14fa45fe9b924a3eed5c3d99 + local char_to_hex = function(c) + return string.format("%%%02X", string.byte(c)) + end + if url == nil then + return + end + url = url:gsub("\n", "\r\n") + url = url:gsub("([^%w _%%%-%.~])", char_to_hex) + url = url:gsub(" ", "+") + return url + end + + local function reencode(source_path, dest_path) + end + + local function reencode_and_store(source_path, filename) + local reencoded_path = utils.join_path(platform.tmp_dir(), 'reencoded_' .. filename) + reencode(source_path, reencoded_path) + local result = ankiconnect.store_file(filename, reencoded_path) + os.remove(reencoded_path) + return result + end + + local function curl_save(source_url, save_location) + return true + end + + local function get_pronunciation_url(word) + end + + local function make_forvo_filename(word) + return string.format('forvo_%s%s', platform.windows and os.time() or word, config.audio_extension) + end + + local function get_forvo_pronunciation(word) + local audio_url = get_pronunciation_url(word) + + if is_empty(audio_url) then + msg.warn(string.format("Seems like Forvo doesn't have audio for word %s.", word)) + return + end + + local filename = make_forvo_filename(word) + local tmp_filepath = utils.join_path(platform.tmp_dir(), filename) + + local result + if curl_save(audio_url, tmp_filepath) and reencode_and_store(tmp_filepath, filename) then + result = string.format('[sound:%s]', filename) + else + msg.warn(string.format("Couldn't download audio for word %s from Forvo.", word)) + end + + os.remove(tmp_filepath) + return result + end + + append_forvo_pronunciation = function(new_data, stored_data) + if config.use_forvo == 'no' then + -- forvo functionality was disabled in the config file + return new_data + end + + if type(stored_data[config.vocab_audio_field]) ~= 'string' then + -- there is no field configured to store forvo pronunciation + return new_data + end + + if is_empty(stored_data[config.vocab_field]) then + -- target word field is empty. can't continue. + return new_data + end + + if config.use_forvo == 'always' or is_empty(stored_data[config.vocab_audio_field]) then + local forvo_pronunciation = get_forvo_pronunciation(stored_data[config.vocab_field]) + if not is_empty(forvo_pronunciation) then + if config.vocab_audio_field == config.audio_field then + -- improperly configured fields. don't lose sentence audio + new_data[config.audio_field] = forvo_pronunciation .. new_data[config.audio_field] + else + new_data[config.vocab_audio_field] = forvo_pronunciation + end + end + end + + return new_data + end +end + +------------------------------------------------------------ +-- AnkiConnect requests + +ankiconnect = {} + +ankiconnect.execute = function(request, completion_fn) + -- utils.format_json returns a string + -- On error, request_json will contain "null", not nil. + local request_json, error = utils.format_json(request) + + if error ~= nil or request_json == "null" then + return completion_fn and completion_fn() + else + return platform.curl_request(request_json, completion_fn) + end +end + +ankiconnect.parse_result = function(curl_output) + return +end + +ankiconnect.store_file = function(filename, file_path) + return true +end + +ankiconnect.create_deck = function(deck_name) + local args = { + action = "changeDeck", + version = 6, + params = { + cards = {}, + deck = deck_name + } + } + local result_notify = function(_, result, _) + return + end + ankiconnect.execute(args, result_notify) +end + +ankiconnect.add_note = function(note_fields, gui) + local action = gui and 'guiAddCards' or 'addNote' + local tags = is_empty(config.note_tag) and {} or { substitute_fmt(config.note_tag) } + local args = { + action = action, + version = 6, + params = { + note = { + deckName = config.deck_name, + modelName = config.model_name, + fields = note_fields, + options = { + allowDuplicate = config.allow_duplicates, + duplicateScope = "deck", + }, + tags = tags, + } + } + } +end + +ankiconnect.get_last_note_id = function() + local ret = ankiconnect.execute { + action = "findNotes", + version = 6, + params = { + query = "added:1" -- find all notes added today + } + } + +end + +ankiconnect.get_note_fields = function(note_id) + local ret = ankiconnect.execute { + action = "notesInfo", + version = 6, + params = { + notes = { note_id } + } + } + +end + +ankiconnect.gui_browse = function(query) + ankiconnect.execute { + action = 'guiBrowse', + version = 6, + params = { + query = query + } + } +end + +ankiconnect.add_tag = function(note_id, tag) + if not is_empty(tag) then + tag = substitute_fmt(tag) + ankiconnect.execute { + action = 'addTags', + version = 6, + params = { + notes = { note_id }, + tags = tag + } + } + end +end + +ankiconnect.append_media = function(note_id, fields, create_media_fn) + -- AnkiConnect will fail to update the note if it's selected in the Anki Browser. + -- https://github.com/FooSoft/anki-connect/issues/82 + -- Switch focus from the current note to avoid it. + ankiconnect.gui_browse("nid:1") -- impossible nid + + local args = { + action = "updateNoteFields", + version = 6, + params = { + note = { + id = note_id, + fields = fields, + } + } + } + + local on_finish = function(_, result, _) + return + end + + ankiconnect.execute(args, on_finish) +end + +------------------------------------------------------------ +-- subtitles and timings + +subs = { + dialogs = new_sub_list(), + user_timings = new_timings(), + observed = false +} + +subs.get_current = function() + return Subtitle:now() +end + +subs.get_timing = function(position) + if subs.user_timings.is_set(position) then + return subs.user_timings.get(position) + elseif not subs.dialogs.is_empty() then + return subs.dialogs.get_time(position) + end + return -1 +end + +subs.get = function() + if subs.dialogs.is_empty() then + subs.dialogs.insert(subs.get_current()) + end + local sub = Subtitle:new { + ['text'] = subs.dialogs.get_text(), + ['start'] = subs.get_timing('start'), + ['end'] = subs.get_timing('end'), + } + if sub['start'] < 0 or sub['end'] < 0 then + return nil + end + if sub['start'] == sub['end'] then + return nil + end + if sub['start'] > sub['end'] then + sub['start'], sub['end'] = sub['end'], sub['start'] + end + if not is_empty(sub['text']) then + sub['text'] = trim(sub['text']) + sub['text'] = escape_special_characters(sub['text']) + end + return sub +end + +subs.append = function() + if subs.dialogs.insert(subs.get_current()) then + menu.update() + end +end + +subs.observe = function() + mp.observe_property("sub-text", "string", subs.append) + subs.observed = true +end + +subs.unobserve = function() + mp.unobserve_property(subs.append) + subs.observed = false +end + +subs.set_timing = function(position) + subs.user_timings.set(position) + notify(capitalize_first_letter(position) .. " time has been set.") + if not subs.observed then + subs.observe() + end +end + +subs.set_starting_line = function() + subs.clear() + if subs.get_current() then + subs.observe() + notify("Timings have been set to the current sub.", "info", 2) + else + notify("There's no visible subtitle.", "info", 2) + end +end + +subs.clear = function() + subs.unobserve() + subs.dialogs = new_sub_list() + subs.user_timings = new_timings() +end + +subs.clear_and_notify = function() + subs.clear() + notify("Timings have been reset.", "info", 2) +end + +------------------------------------------------------------ +-- send subs to clipboard as they appear + +clip_autocopy = (function() + local enable = function() + mp.observe_property("sub-text", "string", copy_to_clipboard) + end + + local disable = function() + mp.unobserve_property(copy_to_clipboard) + end + + local state_notify = function() + notify(string.format("Clipboard autocopy has been %s.", config.autoclip and 'enabled' or 'disabled')) + end + + local toggle = function() + config.autoclip = not config.autoclip + if config.autoclip == true then + enable() + else + disable() + end + state_notify() + end + + local is_enabled = function() + return config.autoclip == true and 'enabled' or 'disabled' + end + + local init = function() + if config.autoclip == true then + enable() + end + end + + return { + enable = enable, + disable = disable, + init = init, + toggle = toggle, + is_enabled = is_enabled, + } +end)() + +------------------------------------------------------------ +-- Subtitle class provides methods for comparing subtitle lines + +Subtitle = { + ['text'] = '', + ['start'] = -1, + ['end'] = -1, +} + +function Subtitle:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + return o +end + +function Subtitle:now() + local delay = mp.get_property_native("sub-delay") - mp.get_property_native("audio-delay") + local text = mp.get_property("sub-text") + local this = self:new { + ['text'] = not is_empty(text) and text or "", + ['start'] = mp.get_property_number("sub-start"), + ['end'] = mp.get_property_number("sub-end"), + } + return this:valid() and this:delay(delay) or nil +end + +function Subtitle:delay(delay) + self['start'] = self['start'] + delay + self['end'] = self['end'] + delay + return self +end + +function Subtitle:valid() + return not is_empty(self['text']) and self['start'] and self['end'] and self['start'] >= 0 and self['end'] > 0 +end + +Subtitle.__eq = function(lhs, rhs) + return lhs['text'] == rhs['text'] +end + +Subtitle.__lt = function(lhs, rhs) + return lhs['start'] < rhs['start'] +end + +------------------------------------------------------------ +-- main menu + +menu = { + active = false, + hints_state = make_switch { 'hidden', 'menu', 'global', }, + overlay = nil; +} + +menu.overlay_draw = function(text) + menu.overlay.data = text + menu.overlay:update() +end + +menu.with_update = function(params) + return function() + pcall(unpack(params)) + menu.update() + end +end + +menu.keybindings = { + { key = 's', fn = menu.with_update { subs.set_timing, 'start' } }, + { key = 'e', fn = menu.with_update { subs.set_timing, 'end' } }, + { key = 'c', fn = menu.with_update { subs.set_starting_line } }, + { key = 'r', fn = menu.with_update { subs.clear_and_notify } }, + { key = 'g', fn = menu.with_update { export_to_anki, true } }, + { key = 'n', fn = menu.with_update { export_to_anki, false } }, + { key = 'm', fn = menu.with_update { update_last_note, false } }, + { key = 'M', fn = menu.with_update { update_last_note, true } }, + { key = 't', fn = menu.with_update { clip_autocopy.toggle } }, + { key = 'i', fn = menu.with_update { menu.hints_state.bump } }, + { key = 'p', fn = menu.with_update { load_next_profile } }, + { key = 'ESC', fn = function() menu.close() end }, +} + +menu.update = function() + return +end + +menu.open = function() + if menu.overlay == nil then + notify("OSD overlay is not supported in " .. mp.get_property("mpv-version"), "error", 5) + return + end + + if menu.active == true then + menu.close() + return + end + + for _, val in pairs(menu.keybindings) do + mp.add_forced_key_binding(val.key, val.key, val.fn) + end + + menu.active = true + menu.update() +end + +menu.close = function() + if menu.active == false then + return + end + + for _, val in pairs(menu.keybindings) do + mp.remove_key_binding(val.key) + end + + menu.overlay:remove() + menu.active = false +end + +------------------------------------------------------------ +-- main + +local main = (function() + local main_executed = false + return function() + if main_executed then + return + else + main_executed = true + end + + config_manager.init(config, profiles) + encoder.init(config, ankiconnect.store_file, platform.tmp_dir, subprocess) + clip_autocopy.init() + ensure_deck() + + -- Key bindings + mp.add_forced_key_binding("Ctrl+n", "mpvacious-export-note", export_to_anki) + mp.add_forced_key_binding("Ctrl+c", "mpvacious-copy-sub-to-clipboard", copy_sub_to_clipboard) + mp.add_key_binding("Ctrl+t", "mpvacious-autocopy-toggle", clip_autocopy.toggle) + + -- Open advanced menu + mp.add_key_binding("a", "mpvacious-menu-open", menu.open) + + -- Note updating + mp.add_key_binding("Ctrl+m", "mpvacious-update-last-note", _ { update_last_note, false }) + mp.add_key_binding("Ctrl+M", "mpvacious-overwrite-last-note", _ { update_last_note, true }) + + -- Vim-like seeking between subtitle lines + mp.add_key_binding("H", "mpvacious-sub-seek-back", _ { play_control.sub_seek, 'backward' }) + mp.add_key_binding("L", "mpvacious-sub-seek-forward", _ { play_control.sub_seek, 'forward' }) + + mp.add_key_binding("Alt+h", "mpvacious-sub-seek-back-pause", _ { play_control.sub_seek, 'backward', true }) + mp.add_key_binding("Alt+l", "mpvacious-sub-seek-forward-pause", _ { play_control.sub_seek, 'forward', true }) + + mp.add_key_binding("Ctrl+h", "mpvacious-sub-rewind", _ { play_control.sub_rewind }) + mp.add_key_binding("Ctrl+H", "mpvacious-sub-replay", _ { play_control.play_till_sub_end }) + mp.add_key_binding("Ctrl+L", "mpvacious-sub-play-up-to-next", _ { play_control.play_till_next_sub_end }) + end +end)() + +mp.register_event("file-loaded", main) diff --git a/base/.config/picom.conf b/base/.config/picom.conf new file mode 100644 index 0000000..1343553 --- /dev/null +++ b/base/.config/picom.conf @@ -0,0 +1,8 @@ +backend = "glx"; +dbus = false; +vsync = true; +daemon = true; + +opacity-rule = [ + "90:class_i = 'mpv-floaty' && !focused" +]; diff --git a/base/.gitconfig b/base/.gitconfig new file mode 100644 index 0000000..956bb69 --- /dev/null +++ b/base/.gitconfig @@ -0,0 +1,5 @@ +[user] + name = snow flurry + email = snow@datagirl.xyz +[init] + defaultBranch = trunk diff --git a/base/.mkshrc b/base/.mkshrc new file mode 100644 index 0000000..2d75f21 --- /dev/null +++ b/base/.mkshrc @@ -0,0 +1,121 @@ +# (mk)shrc + +if [ -f /etc/shrc ]; then + . /etc/shrc +fi + +export EDITOR=vim + +# Force 256color term for screen +if [ "$TERM" = "screen" ] ; then + TERM="screen-256color" + export TERM +fi + +export LANG= +export LC_ALL=en_US.UTF-8 + +## Ripped from a custom /etc/shrc, not very useful here +UID="$(id -u)" +if [ "$UID" -eq "0" ] ; then + PROMPT="#" + USER_COLOR='' +else + PROMPT="$" + USER_COLOR='' +fi + +get_ps1() { + rv=$? + if [ "$rv" -ne "0" -a "$rv" -ne "130" ] ; then + print -n "$rv | " + fi + if [ -n "$SSH_TTY" ] ; then + print -n "[SSH] " + fi + print -n "${USER_COLOR}${HOST%%.*}:"; + if [[ "${PWD#$HOME}" != "$PWD" ]] ; then + print -n "~${PWD#$HOME}" + else + print -n "$PWD" + fi + print -n "${USER_COLOR} ${PROMPT} " +} + +case "$-" in *i*) + # interactive mode settings go here + if /bin/test -z "${HOST}"; then + HOST="$(hostname)" + fi + PS1='$(get_ps1)' + set -o emacs + # This file is used by shells that might not support + # set -o tabcomplete, so check before trying to use it. + ( set -o tabcomplete 2>/dev/null ) && set -o tabcomplete + + alias gpg='gpg2' + alias ctop='xclip -selection clipboard -out | xclip -selection primary -in' + alias ptoc='xclip -selection primary -out | xclip -selection primary -in' + alias mutt='neomutt' + alias define='dict' + # Hack to bubble up profile, see bin/tmux-shim + alias tmux="$HOME/bin/tmux-shim" + + if [ "${KSH_VERSION:-notksh}" != "notksh" ] ; then + [[ "${KSH_VERSION}" == *"MIRBSD KSH"* ]] && bind '^L=clear-screen' + else + hn="$(hostname)" + PS1='${hn}:${PWD} \$ ' + fi + + calc() + { + echo "$@" | bc + } + + nconv() + { + if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then + echo "usage: nconv [from-base] [to-base] [num]" >&2 + return 1 + fi + + echo "obase=$2; ibase=$1; $3" | bc + } + ;; +esac + +if [ -f "$HOME/.cargo/env" ] ; then + . $HOME/.cargo/env +fi + +# Python packages go here +if [ -d "$HOME/.local/bin" ] ; then + PATH="$HOME/.local/bin:$PATH" +fi + +# Homebrew (macOS) +if [ -d "/opt/homebrew" ] ; then + PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/sbin:/usr/sbin:$PATH" + HOMEBREW_CASK_OPTS="--appdir=~/Applications" + export HOMEBREW_CASK_OPTS + # x86 brew hack + if [ -d "$HOME/.x86_64/homebrew" ] ; then + brew86() { + PATH="$HOME/.x86_64/homebrew/bin:$PATH" arch -x86_64 $HOME/.x86_64/homebrew/bin/brew "$@" + } + fi +fi + +# Flutter +if [ -d "$HOME/.opt/flutter" ] ; then + PATH="$HOME/.opt/flutter/bin:$PATH" +fi + +# Android SDK (for VMs) +if [ -d "$HOME/Library/Android/sdk" ] ; then + export ANDROID_SDK_ROOT=$HOME/Library/Android/sdk + PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/emulator:$ANDROID_SDK_ROOT/platform-tools:$PATH" +fi + +export PATH diff --git a/base/.ssh/config b/base/.ssh/config new file mode 100644 index 0000000..8d80e86 --- /dev/null +++ b/base/.ssh/config @@ -0,0 +1,36 @@ +U2FsdGVkX1862bH+oYm6IU4RPmP/hAjSpoaJvYQ95+1gDPMcdhc6+40GvFi4/OZx +2xJX0FAPblgFCwQ9LwKD3W537yTGZDetMb7PxsONKJzVwGe+glutmOW0O96UD+h/ +eS479Br40yDEiMkPGqjWT5/o5Ydjl7m7lEtSNCLlRvSNGG1lXuJZz5EIZqTU7YXe +HwyFx+JRvH75p/jjurXB8HYtBYrlmvp892fLSYnU/ywgo9HrSIA/5Z8Tf35Re+t8 +cecShhV8T+nVLCRaj3tLfAJhgCWIg/rgemSh1Xpa7U+vwd8lTmBRMbezj3RiR+yG +G+FM5vzRwXT94xFlrvrTHgpD8BsxhGHo0cs+qcrMfJny/1+vKe1jqQvqQqW5FA4x +zdiQeUh1RNZ4yiyU9d9Tj2L3i0qeqqngBJmQKk9zInz2WT956tkP3o+tFAaXuq0G +uVPFapMFq14GiVLJvwo+RHLCmkTq8p+ppsD3OrC1b2h5y5h3UoY2XdnJyMAro21l +PspgctildSORGxDd4OgrN0e38LAZM9fBEs/d9VpYrcdKB2sL6IusRqYriZ1KqURT +N41ODIjVsYLTnPBXuDm8RhVg8crwKpZZ/ZavxuRrrKgzCyzcEUH3fEDelsSxzdiw +pzJPVMfatwPCFFY8BAjOyroWosNRlnFi3CF5Rm9jkR2f73+ip8wW9nf2LlMOGIN2 +nV+egCOYKpyqkxpofbw9te/6IL7pxGakFIcF9QvTC9js1Y7wUEvL0VWggSlts0Ze +KTcqq8qd+Y2bSfNY52eNaMvfShBrdnbKBp09CkK3lsyJ61uqXspBHA2JSuxtbKil +P5zyysjg4yZbmK3XOMhYkGHwNpszxw59hb4E6BN1WyQqK7w2nTPh8s8EQRn4V9Kg +xs45pv7Un6hqdyFycSdh+t2f2ANJ1z6XuRBxJ0pwp1XE9GSo+IXNuzy7hky4nb1u +K65OC7QJip35fQYdHXDRM8xulmXs9abkUc4VH+OoyeRNnmD2IqRmIpNwOrNQZIyY +U3o6lQGQve9CE350ugb8O+b11Y/n6Phu1uUviytTM4qBNMyONGcAUVvmzHHEjgJh +40l/6IZrZ+rHByLLXq8Syb19b7XcHqTYi4A+cH9HdNK0Vzz06l+phVFuDPObk1iw +A+8H6go012uLJXSBt61qC5HtOg+Zm2Qy+cLyBAIDx/kO9IFdp/tbb8Y2FbK5FHk2 +CwwirsXSpWEHRPVfbyNKX6Sj28gEsWMS+JMVlBmz68OCbMK8BPzQ7kojoCu+KHzw +pH40CCqHPm9iGWiX0X57cgLbD4ZyK87Y2VM9tb9wxcCK5eAF4qBJZ/q4cSAMDyv4 +71c32DwfPACYlZPZl0aSlY1tHis8aZ+hrJ8SqEaF6DtRzMCeCvcSi1asNoUHJFUB +AV5s+7ehmdsF7qJTiaI0MVZ/M7G0wud8NTiQUs/UJvoZFGt/EWRQnejvTF/DpCmJ +QZ/2VAeOQ+g6pWLzj40PUojQH9nRwdOgLE5ZaZuNBtWoWJy4e6hM23AEci4MeJ09 +zjxuM7+lQdzBMiedC9jnPDUbEmQQyG7A48oPhpGIUoWpSos4JAgxcXy88bU/1emF +Tvbx8eXSgq2iEWWQug2mib7tlRHIK4G9cfKFBdI8XDqmT2mTjPjjvhBvegzrEMSW +3z0cQp0h5aP2iTGr9x9mTryRqt6mijIeVqzTtViPNj2DhfncgrqKkXP5C6Z+ozfN +69v5IpJ2pI33TFgMuPAIv6MS5mcHUN7O/XG+ZsExIahr5Vlf0z+Pk+rl0M8yx5ef +BDIZakMOqZWYbK6Owq6zKQpK1FMAbUooVYG8+HXtrW/NanlEIwXl7kSL2j0ogyoi +CqU3iNMCEhXxjQ6dQ8LvJKIQSGd10c6Xul6WVUuo8X0rFajRWUHxijqv9Z51jKUk +TwlKs6rxZwWgO4CdsTPYUd54yqidGz+GeXrAxdA3cmMoSEb0iOYEp+TOJQGlV6Gv +l/vkC+pp/9tCdDu1v/OgBujD3bNI8tSBai89lLCcy7UbDujxiSaHszEfTthMMc60 +GKBDuuthY6c+vsxN/NJCr+0n77epDlZZJbRvKQfycZSzbwhbqZJMPvPROYYXzU04 +DMiJGzMDvppp4zRmm02VJo6oksMOTLL3KNAVShOOkTRFbfDnT9oj/La70Jqy/b0M +R0+YJ6c+Q+X1broAhF3phczJDVI5OYvZzl1/v7ETeN5vgQsRTAxzd5Xo9T6I3Mnl +PQAYUQlBz14wpRwuqgA6Ng== diff --git a/base/.tmux.conf b/base/.tmux.conf new file mode 100644 index 0000000..9b05b2b --- /dev/null +++ b/base/.tmux.conf @@ -0,0 +1,6 @@ +new-session -n $HOST + +source '.tmux/source.conf' + +# Initialize TMUX plugin manager (keep this line at the very bottom of tmux.conf) +run -b '~/.tmux/plugins/tpm/tpm' diff --git a/base/.tmux/source.conf b/base/.tmux/source.conf new file mode 100644 index 0000000..6b87da2 --- /dev/null +++ b/base/.tmux/source.conf @@ -0,0 +1,76 @@ +# Used for reloading config +set -g default-terminal screen-256color + +unbind C-b +set-option -g prefix C-a + +### Keybinds +# prefix +bind-key C-a send-prefix + +# pane splitting +bind | split-window -h +bind - split-window -v +unbind '"' +unbind % + +# reload tmux.conf +bind r source-file ~/.tmux/source.conf + +bind -n M-Left select-pane -L +bind -n M-Right select-pane -R +bind -n M-Up select-pane -U +bind -n M-Down select-pane -D + +bind R move-window -r + +set -g mouse off + +set-option -g allow-rename off +set-window-option -g mode-keys vi +bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "xclip -selection clipboard -i" + +### Theming +set-option -g visual-activity off +set-option -g visual-bell both +set-option -g visual-silence off +set-window-option -g monitor-activity off +set-option -g bell-action any + +setw -g clock-mode-colour colour5 +setw -g mode-style bold,fg=colour1,bg=colour18 + +# panes +set -g pane-border-style bg=colour0,fg=colour54 +set -g pane-active-border-style bg=colour0,fg=colour54 + +# statusbar +set -g status-position top +set -g status-justify left +set -g status-style bg=colour234,fg=colour248,dim +set -g status-left '#[fg=colour5,nodim]#h#[fg=colour248,dim] :: ' +set -g status-right '#[fg=colour233,bg=colour242,bold] %d/%m #[fg=colour233,bg=colour246,bold] %H:%M:%S ' +set -g status-right-length 50 +set -g status-left-length 20 + +setw -g window-status-current-style fg=colour239,bg=colour243,bold +setw -g window-status-current-format ' #I:#[fg=colour255]#W#[fg=colour237]#F ' + +setw -g window-status-style fg=colour248,bg=colour236,none +setw -g window-status-format ' #I#[fg=colour242]:#[fg=colour250]#W#[fg=colour243]#F ' + +setw -g window-status-bell-style bold,fg=colour255,bg=colour1 + +setw -g window-style fg=colour8 +setw -g window-active-style fg=colour7 + +# messages +set -g message-style bold,fg=colour242,bg=colour16 + +# vim compat +set -sg escape-time 10 + +# plugins +set -g @plugin 'tmux-plugins/tpm' +set -g @plugin 'tmux-plugins/tmux-sensible' + diff --git a/base/bin/clipify b/base/bin/clipify new file mode 100755 index 0000000..32ea7b4 --- /dev/null +++ b/base/bin/clipify @@ -0,0 +1,45 @@ +#!/usr/bin/env mksh +# Make a video clip from a larger video + +usage() { + if [ -n "$1" ] ; then + echo "$1" >&2 + fi + echo "usage: $0 to-clip start-time end-time clipname" >&2 + echo "example:" >&2 + echo " $0 ToClip.mkv 00:15:00.0 00:15:30.0 sickflips" >&2 + exit 1 +} + +die() { + echo "err: $1" >&2 + exit 1 +} + +origfile=$1 +starttime=$2 +endtime=$3 +clipname=$4 + +if [ -z "$origfile" ] || [ -z "$starttime" ] || [ -z "$endtime" ] || [ -z "$clipname" ] +then + usage +fi + +if ! [ -f "$origfile" ] ; then + usage "$origfile doesn't exist!" +fi + +duration=$(( $(gdate -d "$endtime" "+%s") - $(gdate -d "$starttime" "+%s") )) || usage "Couldn't get clip duration" + +[ "$duration" -gt 0 ] || usage "end-time should be after start-time" + +strduration="$(gdate -ud "@${duration}" "+%H:%M:%S.%N" | cut -c-10)" + +if [ -n "$SUBSFILE" ] ; then + ffmpeg -ss "$starttime" -copyts -i "$origfile" -ss "$starttime" -t "$strduration" -c:v libx264 -c:a mp3 -ac 2 -vf subtitles="$SUBSFILE" "$clipname.mp4" +else + ffmpeg -ss "$starttime" -i "$origfile" -t "$strduration" \ + -c:v libx264 -c:a mp3 $FFMPEG_PARAM -ac 2 \ + "$clipname.mp4" +fi diff --git a/base/bin/ff2mpv b/base/bin/ff2mpv new file mode 100755 index 0000000..66d9b12 --- /dev/null +++ b/base/bin/ff2mpv @@ -0,0 +1,31 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# Modified from original ff2mpv to use floaty-mpv instead of +# standard mpv. + +require "json" + +len = $stdin.read(4).unpack1("L") +data = JSON.parse($stdin.read(len)) +url = data["url"] + +args = %w[--no-terminal] + +# HACK(ww): On macOS, graphical applications inherit their path from `launchd` +# rather than the default path list in `/etc/paths`. `launchd` doesn't include +# `/usr/local/bin` in its default list, which means that any installations +# of MPV and/or youtube-dl under that prefix aren't visible when spawning +# from, say, Firefox. The real fix is to modify `launchd.conf`, but that's +# invasive and maybe not what users want in the general case. +# Hence this nasty hack. +# ENV["PATH"] = "/usr/local/bin:#{ENV['PATH']}" if RUBY_PLATFORM =~ /darwin/ + +pid = nil + +if (/darwin/ =~ RUBY_PLATFORM) != nil then + pid = spawn "/Users/flurry/bin/iinado", url, in: :close, out: :close, err: :close +else + pid = spawn "/home/flurry/bin/floaty-mpv", url, in: :close, out: "/dev/null", err: "/home/flurry/.tmp/fmpv.log" +end + +Process.detach pid diff --git a/base/bin/genpw b/base/bin/genpw new file mode 100755 index 0000000..94e48d3 --- /dev/null +++ b/base/bin/genpw @@ -0,0 +1,9 @@ +#!/bin/sh + +if [ -z "$1" ] ; then + echo "usage: $0 [length]" >&2 + exit 1 +fi + +LC_ALL=C tr -dc '[:alnum:]' " >&2 +} + +if [ "$1" = "-x" ] ; then + with_message=1 + shift +fi + +CURL="curl" + +if [ "$1" = "-q" ] ; then + with_interop=1 + CURL="$CURL -sS" + shift +fi + +CREDFILE="$HOME/.config/upfile/credentials" +if [ ! -r "$CREDFILE" ] ; then + echo "err: can't find credentials file. must be at $CREDFILE" >&2 + exit 1 +fi + +upload_file() { + up_file="$1" + + if [ -z "$up_file" ] ; then + usage + return 2 + fi + + if [ ! -f "$up_file" ] ; then + echo "err: file $up_file doesn't exist?" >&2 + return 1 + fi + + res=$($CURL -f -X POST -F "sendfile=@\"$up_file\"" \ + -u "$(cat "$CREDFILE")" \ + https://f.2ki.xyz/cgi-bin/aperture.cgi) + + if [ $? -ne 0 ] ; then + echo "Upload failed: $res" >&2 + return 1 + fi + + CLIP_URL="$res" +} + + +upload_file "$1" || exit $? +echo -n "$CLIP_URL" | xclip -selection primary +echo -n "$CLIP_URL" | xclip -selection clipboard + +if [ -n "$with_interop" ] ; then + echo "$CLIP_URL" +elif [ -n "$with_message" ]; then + notify-send "File uploaded" "Uploaded to $CLIP_URL. URL copied to clipboard." +else + echo "Uploaded to $CLIP_URL. Copied to clipboard." +fi