├── .gitignore ├── LICENSE.md ├── README-en.md ├── README.md ├── mpvi-ps.el ├── mpvi-tests.el └── mpvi.el /.gitignore: -------------------------------------------------------------------------------- 1 | *.elc 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019 Radon Rosborough 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-en.md: -------------------------------------------------------------------------------- 1 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 2 | [![MELPA](https://melpa.org/packages/mpvi-badge.svg)](https://melpa.org/#/mpvi) 3 | 4 | # Media Tool based on EMMS and MPV 5 | 6 | What can do: 7 | - Watch local, remote and living media (danmaku support for some) 8 | - Manage playlist with EMMS, and control playing with MiniBuffer 9 | - Integrate Download/Clip/Screenshot/OCR videos and so on 10 | - Integrated with Org Mode, make taking video-notes easily 11 | - Make MPV player in EMMS support Windows 12 | 13 | I just wrote this for fun. Now release it for the ones who need it. 14 | 15 | ## Installation 16 | 17 | - It's based on [EMMS](https://www.gnu.org/software/emms/), make sure it is installed 18 | - Download and load this package `(require 'mpvi)` 19 | - Install [mpv](https://mpv.io/) and [yt-dlp](https://github.com/yt-dlp/yt-dlp), they are the main dependencies. 20 | - [Optional] Install dependencies you need: 21 | + [ffmpeg](https://ffmpeg.org/), used to clip video 22 | + [tesseract](https://github.com/tesseract-ocr/tesseract), used to OCR 23 | + [danmaku2ass](https://github.com/m13253/danmaku2ass), danmaku file converter, used when watching bilibili.com 24 | + [seam](https://github.com/Borber/seam), living video extractor, used when watching some live sites 25 | 26 | For Arch Linux User, all dependencies with one command: 27 | ```sh 28 | yay -S mpv ffmpeg yt-dlp tesseract xclip danmaku2ass-git seam-git 29 | ``` 30 | 31 | Windows User can install dependencies with `winget` or `scoop`: 32 | ```sh 33 | winget install mpv yt-dlp ffmpeg Tesseract-OCR 34 | ``` 35 | 36 | ## Usage 37 | 38 | Core commands: 39 | 1. `mpvi-open`, open video (local or remote) with MPV 40 | 2. `mpvi-seek`, control opened MPV with minibuffer 41 | 3. `mpvi-insert`, insert timestamp link of video to current org buffer 42 | 4. `mpvi-clip`, download & clip & transcode videos via ffmepg/ytdlp 43 | 5. `mpvi-emms-add`, add video link/file to EMMS playlist 44 | 45 | Command `mpvi-seek` is the most frequently used one. It integrates many functions through minibuffer: 46 | - `i` Insert timestamp link into current buffer 47 | - `Space` Toggle play and pause 48 | - `j/k/m` Change the playback speed 49 | - `n/p/N/P/M-n/M-p/C-l` Seek to any position smartly 50 | - `s/C-s/C-i` Multiple ways to take screenshots 51 | - `r/C-r` OCR recognition of the current playback screen 52 | - `t/C-t` Copy subtitle of the current playback screen 53 | - `c/C-c` Download/clip/transcode current playing video 54 | - `v/C-v` Switch playlist/category 55 | - `o/C-o` Switch to system program (for example, browser) to continue the playing 56 | - `q/C-q` quit minibuffer 57 | 58 | Timestamp link is link of format `[mpv:https://xxx.com#10-30]`. It's clickable and responses below shortcuts when cursor on it: 59 | - `, ,` Play video in current link 60 | - `, s` Enter `mpvi-seek` interface 61 | - `, a` Change the start time in current link 62 | - `, b` Change the end time in current link 63 | - `, v` Preview the screenshot of current time position in current link 64 | - `, c` Video download, transcode, clip and so on, **ALL IN ONE**. 65 | - `, h` show this help 66 | 67 | Look the keymap definitions for more: 68 | - `mpvi-open-map` 69 | - `mpvi-seek-map` 70 | - `mpvi-org-link-map` 71 | 72 | ## Miscellaneous 73 | 74 | Thanks to similar projects in the community, you teach me a lot and give me so much inspiration. 75 | 76 | Thanks to open source software like MPV/FFMPEG/EMACS, you make the world more wonderful. 77 | 78 | Finally, thanks to all the platforms and authors who contributed great videos, you guys make me happier and more powerful. :) 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 2 | [![MELPA](https://melpa.org/packages/mpvi-badge.svg)](https://melpa.org/#/mpvi) 3 | 4 | # Media Tool based on EMMS and MPV 5 | 6 | Knock knock, [English Help](README-en.md). 7 | 8 | ## 简介 9 | 10 | 将 MPV 跟 EMMS/ffmpeg/yt-dlp/Org-Mode 等有机结合,娱乐、学习两不误: 11 | - 支持本地视频、网络视频、网络直播 (也支持音频、弹幕) 12 | - 可以在 Emacs 中灵活地控制播放进度、播放速度等 13 | - 可以方便地对视频进行下载、转码、截图、OCR 等操作 14 | - 跟 Org Mode 深度集成,视频笔记、轻轻松松 (带时间戳的链接) 15 | - 可以控制 EMMS 播放的视频和音频。为 EMMS 增加了 Windows 平台的支持 16 | 17 | 结合下面项目可以更方便观看 B 站视频: 18 | - https://github.com/lorniu/bilibili.el 19 | 20 | 这是很久之前知识焦虑的时候,为了消化收藏夹中的视频而写。 21 | 22 | 最近几天将其翻新了一下。因为发现用它来拉片、看直播,简直不要太爽! 23 | 24 | ## 安装 25 | 26 | 基于 [EMMS](https://www.gnu.org/software/emms/),请确保其已安装。然后下载并加载本包 `(require 'mpvi)`。 27 | 28 | 29 | 之后安装相关依赖。除了 [mpv](https://mpv.io/) 是必须的外,其他的是可选的: 30 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp),如果要看网络视频,这个必须要安装 31 | - [ffmpeg](https://ffmpeg.org/),用来对视频进行剪辑之类的操作 32 | - [tesseract](https://github.com/tesseract-ocr/tesseract),如果需要文字识别 (OCR) 功能才安装 33 | - [danmaku2ass](https://github.com/m13253/danmaku2ass),安装这个后,看 B 站视频就有弹幕啦 34 | - [seam](https://github.com/Borber/seam),用来解析直播链接,要看直播的需要安装 35 | 36 | 对于 Arch Linux 用户,一键安装依赖: 37 | ```sh 38 | yay -S mpv yt-dlp ffmpeg tesseract danmaku2ass-git seam-git xclip 39 | ``` 40 | 41 | 对于 Windows 用户,danmaku2ass 和 seam 需要从 Github 下载,其他可以通过 winget 或 scoop 安装: 42 | ```sh 43 | winget install mpv yt-dlp ffmpeg Tesseract-OCR 44 | ``` 45 | 46 | ## 使用 47 | 48 | 核心命令: 49 | 1. `mpvi-open`,打开视频文件或网络链接 50 | 2. `mpvi-seek`,通过 minibuffer 的方式对播放的视频进行控制 51 | 3. `mpvi-insert`,在 org buffer 中插入当前播放视频的带时间戳的链接 52 | 4. `mpvi-clip`,借助 ffmepg/ytdlp 实现视频的下载、剪辑、转码 53 | 5. `mpvi-emms-add`,向 EMMS playlist 中添加视频路径或链接 54 | 55 | 其中 `mpvi-seek` 是最常用的,它通过 minibuffer 集成了很多功能。比如: 56 | - `i` 在 buffer 中插入时间戳链接 57 | - `Space` 切换暂停与播放 58 | - `j/k/m` 调整播放速度 59 | - `n/p/N/P/M-n/M-p/C-l` 等实现各种维度的播放进度调整 60 | - `s/C-s/C-i` 等实现各种方式的截图 61 | - `r/C-r` 对当前播放页面进行 OCR 识别并复制结果 62 | - `t/C-t` 复制当前页面的字幕或弹幕 63 | - `c/C-c` 下载、裁剪、转码当前播放视频 64 | - `v/C-v` 切换网络视频 playlist/category 里的视频 65 | - `o/C-o` 切换到系统默认程序 (比如浏览器) 打开当前播放视频 66 | - `q/C-q` 退出 minibuffer 67 | 68 | 时间戳是一个 `[mpv:https://xxx.com#10-30]` 格式的链接,可以直接点击。光标置于其上,有如下快捷键: 69 | - `, ,` 播放当前链接中的视频 70 | - `, s` 进入到 `mpvi-seek` 界面 71 | - `, a` 更改链接中视频的开始时间 72 | - `, b` 更改链接中视频的结束时间 73 | - `, v` 预览链接中视频时间戳位置的画面 74 | - `, c` 视频的下载、转码、截取。**All In One**, 很好用 75 | - `, h` 显示此帮助信息 76 | 77 | 其他补充: 78 | - 详细的快捷键参见 map 定义: `mpvi-open-map`, `mpvi-seek-map`, `mpvi-org-link-map`。不合意可自行扩展、重新绑定 79 | - 如果纯粹看视频/直播,并通过 minibuffer 操控 MPV,并不需要 Org Mode。但进行插入、编辑操作则必须在 Org Mode Buffer 中 80 | - 支持视频分 P 播放。比如,可以直接用 `mpvi-open` 打开 B 站播放列表 url 或专栏合集 url。也可以通过 `mpvi-emms-add` 一次将所有分 P 视频都添加到 EMMS 中,之后通过 EMMS 管理列表并控制播放 81 | - yt-dlp 对播放列表 (playlist) 信息的返回不够详细不够友好。这导致打开视频链接的时候可能会卡顿一下,并且切换列表的时候不会显示分 P 标题。这个只能通过上游解决,我懒得去提 PR,有点难受 82 | - 目前直播仅支持斗鱼和抖音。其他的可以仿照 `mpvi-ps.el` 中的代码自行扩展。我不看其他的,所以没加 83 | - 最大的遗憾是,看直播的时候没弹幕。**这种实时弹幕,不知道有没有啥实现的思路** 84 | - 本来想把 `danmaku2ass` 和解析直播链接也用 `elisp` 重新实现一下的。后面想想这简直是自寻烦恼,这种洁癖要不得,第三方的依赖不差这一两个,因此作罢 85 | - 结合视频网站的 API 可以实时抓取其收藏夹、热门视频等,结合本包食用,特别香;通过 EMMS 对视频列表进行管理,特别方便;再结合 Org Mode 对视频进行分类、整理,更香;再加上带视频时间戳链接的笔记,完美。现在基本可以做到不打开网页看 B 站视频了 (https://github.com/lorniu/bilibili.el) 86 | 87 | ## 其他 88 | 89 | 这个项目的初衷是自己爽。现在整理出来,如果有人喜欢,我也会很开心。 90 | 91 | 欢迎大家沟通交流,互相学习、共同进步。 92 | 93 | 感谢社区中的类似项目,你们给我了更多灵感。 94 | 95 | 感谢 mpv/ffmpeg/emacs 等开源软件,你们让世界更美好。 96 | 97 | 最后,感谢所有贡献了优秀视频的平台和作者,你们让我更焦虑、也更强大(~~误~~)。 98 | -------------------------------------------------------------------------------- /mpvi-ps.el: -------------------------------------------------------------------------------- 1 | ;;; mpvi-ps.el --- Media tool based on EMMS and MPV -*- lexical-binding: t -*- 2 | 3 | ;; Copyright (C) 2023 lorniu 4 | 5 | ;; Author: lorniu 6 | ;; URL: https://github.com/lorniu/mpvi 7 | ;; SPDX-License-Identifier: MIT 8 | ;; Version: 1.1 9 | 10 | ;;; Commentary: 11 | 12 | ;; Platform specialized config. 13 | ;; 14 | ;; - Use 'danmaku2ass' to convert danmaku file to ass format 15 | ;; - Use 'seam' to resolve living url: https://github.com/Borber/seam 16 | ;; 17 | ;; For Arch user: 18 | ;; 19 | ;; yay -S danmaku2ass-git seam-git 20 | ;; 21 | 22 | ;;; Code: 23 | 24 | (require 'json) 25 | (require 'cl-lib) 26 | 27 | (defvar mpvi-cache-directory) 28 | 29 | (declare-function mpvi-log "mpvi" t) 30 | (declare-function mpvi-call-process "mpvi" t) 31 | (declare-function mpvi-extract-playlist "mpvi" t) 32 | (declare-function mpvi-ytdlp-download-subtitle "mpvi" t) 33 | 34 | 35 | ;;; Utils 36 | 37 | (defvar mpvi-danmaku2ass "danmaku2ass" 38 | "Executable command or path of `danmaku2ass'. 39 | It can be executable danmaku2ass command or path of danmaku2ass.py.") 40 | 41 | (defvar mpvi-danmaku2ass-args "--protect 80 -ds 5.0 -dm 10.0 --font \"Lantinghei SC\" --fontsize 37.0 --alpha 0.8 --size 960x768") 42 | 43 | (defun mpvi-check-danmaku2ass () 44 | "Check if `danmaku2ass' available." 45 | (or (executable-find mpvi-danmaku2ass) 46 | (and (string-suffix-p ".py" mpvi-danmaku2ass) (file-exists-p mpvi-danmaku2ass)) 47 | (user-error "Please config `mpvi-danmaku2ass' for danmaku2ass command or path first"))) 48 | 49 | (defun mpvi-convert-danmaku2ass (danmaku-file &optional confirm) 50 | "Convert DANMAKU-FILE to ASS format. 51 | If CONFIRM not nil then prompt user the options." 52 | (interactive (list (and (mpvi-check-danmaku2ass) 53 | (read-file-name "Danmaku file: " mpvi-cache-directory nil t) t))) 54 | (mpvi-check-danmaku2ass) 55 | (unless (file-regular-p danmaku-file) 56 | (user-error "Danmaku file '%s' not valid" danmaku-file)) 57 | (let* ((dest (concat (file-name-sans-extension danmaku-file) ".ass")) 58 | (file (file-truename danmaku-file)) 59 | (options (concat "-o \"" dest "\" " mpvi-danmaku2ass-args))) 60 | (when confirm 61 | (setq options (read-string "Confirm options for danmaku2ass: " options))) 62 | (setq options (split-string-and-unquote options)) 63 | (with-temp-buffer 64 | (mpvi-log "Convert danmaku to ass format for %s" danmaku-file) 65 | (if (executable-find mpvi-danmaku2ass) 66 | (apply #'mpvi-call-process mpvi-danmaku2ass file options) 67 | (apply #'mpvi-call-process 68 | "python3" mpvi-danmaku2ass file options)) 69 | (if (file-exists-p dest) 70 | (prog1 dest 71 | (when (called-interactively-p 'any) 72 | (kill-new dest) 73 | (message "Convert done: %s" dest))) 74 | (user-error "Convert danmaku file to ass failed: %S" (string-trim (buffer-string))))))) 75 | 76 | (defun mpvi-extract-url-by-seam (platform rid) 77 | "Get real video url for UP with roomid RID by `seam'. 78 | PLATFORM can be bili, douyu and so on, see `https://github.com/Borber/seam' for detail." 79 | (unless (executable-find "seam") 80 | (user-error "You should have `seam' in path to extract url (https://github.com/Borber/seam)")) 81 | (with-temp-buffer 82 | (mpvi-log "Get living url with seam for %s: %s" platform rid) 83 | (mpvi-call-process "seam" (format "%s" platform) rid) 84 | (goto-char (point-max)) 85 | (skip-chars-backward " \t\r\n") 86 | (backward-sexp) 87 | (let* ((json (ignore-errors (json-read))) 88 | (title (alist-get 'title json)) 89 | (nodes (mapcar (lambda (n) (alist-get 'url n)) (alist-get 'nodes json)))) 90 | (if (> (length nodes) 0) 91 | (let ((path (if (= (length nodes) 1) (car nodes) 92 | (completing-read "Choose living source: " nodes nil t)))) 93 | (prog1 (list path title) ; return path and title 94 | (mpvi-log "Live url: %s" path))) 95 | (user-error "Error when get url for %s/%s: %s" platform rid (string-trim (buffer-string))))))) 96 | 97 | 98 | ;;; Bilibili 99 | 100 | (defvar mpvi-bilibili-enable-danmaku t) 101 | 102 | (defvar mpvi-bilibili-extra-opts `((lavfi . "\"fps=60\"") 103 | (sub-ass-force-margins . "yes"))) 104 | 105 | (defun mpvi-bilibili-add-begin-time-to-url (url timestart) 106 | "Add param TIMESTART to URL. 107 | Then the opened URL in browser will begin from TIMESTART instead." 108 | (format "%s%st=%s" url (if (string-match-p "\\?" url) "&" "?") timestart)) 109 | 110 | (cl-defmethod mpvi-extract-url ((_ (eql :www.bilibili.com)) url &key urlonly) 111 | "Return mpv options with danmaku file as sub-file for bilibili URL. 112 | If URLONLY is not nil, don't resolve danmaku file." 113 | (let (ret) 114 | (when (and (not urlonly) mpvi-bilibili-enable-danmaku (mpvi-check-danmaku2ass)) 115 | (condition-case err 116 | ;; danmaku.xml -> danmaku.ass 117 | (let ((sub (mpvi-convert-danmaku2ass (mpvi-ytdlp-download-subtitle url) current-prefix-arg))) 118 | (setq ret (list :subfile sub :opts mpvi-bilibili-extra-opts))) 119 | (error (message "Bilibili load danmaku failed: %S" err)))) 120 | ;; default 121 | (unless ret 122 | (setq ret (list :opts mpvi-bilibili-extra-opts))) 123 | ;; if this is a link with query string of p=NUM 124 | (when (string-match "^\\(.*\\)\\?p=\\([0-9]+\\)" url) 125 | (nconc ret `(:playlist-url ,(match-string 1 url) :playlist-index ,(string-to-number (match-string 2 url))))) 126 | ;; begin time 127 | (append ret `(:out-url-decorator ,#'mpvi-bilibili-add-begin-time-to-url)))) 128 | 129 | (cl-defmethod mpvi-extract-playlist ((_ (eql :www.bilibili.com)) url &rest args) 130 | "Extract playlist for bilibili URL. ARGS are extra arguments. 131 | For bilibili, url with `?p=NUM' suffix is not a playlist link." 132 | (unless (string-match "^\\(.*\\)\\?p=\\([0-9]+\\)" url) 133 | (apply #'mpvi-extract-playlist nil url args))) 134 | 135 | 136 | ;;; Douyu Living 137 | 138 | (cl-defmethod mpvi-extract-url ((_ (eql :www.douyu.com)) url &rest _) 139 | "Return the real video URL for douyu." 140 | (when (or (string-match "^https://www.douyu.com/\\([0-9]+\\)" url) 141 | (string-match "^https://www.douyu.com/topic/[[:alnum:]]+\\?rid=\\([0-9]+\\)" url)) 142 | (let ((ret (mpvi-extract-url-by-seam 'douyu (match-string 1 url)))) 143 | (list :url (car ret) :title (cadr ret) :logo "DouYu")))) 144 | 145 | (cl-defmethod mpvi-extract-playlist ((_ (eql :www.douyu.com)) &rest _) 146 | "No need to check playlist for douyu link." nil) 147 | 148 | 149 | ;;; Douyin Living 150 | 151 | (cl-defmethod mpvi-extract-url ((_ (eql :live.douyin.com)) url &rest _) 152 | "Return the real video URL for douyin living." 153 | (when (string-match "^https://live.douyin.com/\\([0-9]+\\)" url) 154 | (let ((ret (mpvi-extract-url-by-seam 'douyin (match-string 1 url)))) 155 | (list :url (car ret) :title (cadr ret) :logo "DouYin")))) 156 | 157 | (cl-defmethod mpvi-extract-playlist ((_ (eql :live.douyin.com)) &rest _) 158 | "No need to check playlist for douyin living link." nil) 159 | 160 | (provide 'mpvi-ps) 161 | 162 | ;;; mpvi-ps.el ends here 163 | -------------------------------------------------------------------------------- /mpvi-tests.el: -------------------------------------------------------------------------------- 1 | ;;; mpvi-tests.el --- Tests for mpvi.el -*- lexical-binding: t -*- 2 | 3 | ;; Copyright (C) 2023 lorniu 4 | 5 | ;; Author: lorniu 6 | ;; SPDX-License-Identifier: MIT 7 | 8 | ;;; Commentary: 9 | 10 | ;; Unit Tests 11 | 12 | ;;; Code: 13 | 14 | (require 'ert) 15 | (require 'mpvi) 16 | 17 | (ert-deftest test-mpvi-time-to-secs () 18 | (should (eq (mpvi-time-to-secs nil ) nil)) 19 | (should (= (mpvi-time-to-secs 23 ) 23)) 20 | (should (= (mpvi-time-to-secs 23.1 ) 23.1)) 21 | (should (= (mpvi-time-to-secs "23" ) 23)) 22 | (should (= (mpvi-time-to-secs "23.1" ) 23.1)) 23 | (should (= (mpvi-time-to-secs "0:23.1" ) 23.1)) 24 | (should (= (mpvi-time-to-secs "10:23" ) 623)) 25 | (should (= (mpvi-time-to-secs "-10:23" ) -623)) 26 | (should (= (mpvi-time-to-secs "1:00:23") 3623)) 27 | (should-error (mpvi-time-to-secs t)) 28 | (should-error (mpvi-time-to-secs "10/3")) 29 | (should-error (mpvi-time-to-secs '(1 2 3)))) 30 | 31 | (ert-deftest test-mpvi-secs-to-hms () 32 | (should (equal (mpvi-secs-to-hms 3) "00:03")) 33 | (should (equal (mpvi-secs-to-hms 3.23) "00:03.23")) 34 | (should (equal (mpvi-secs-to-hms 3.23 t) "0:00:03.23")) 35 | (should (equal (mpvi-secs-to-hms 11111.11) "3:05:11.11")) 36 | (should (equal (mpvi-secs-to-hms 11111.11 t) "3:05:11.11"))) 37 | 38 | (ert-deftest test-mpvi-parse-link () 39 | (should (equal (mpvi-parse-link "~/xxx/aaa.flv") 40 | (list "~/xxx/aaa.flv" nil nil))) 41 | (should (equal (mpvi-parse-link "~/xxx/aaa.flv#3") 42 | (list "~/xxx/aaa.flv" 3 nil))) 43 | (should (equal (mpvi-parse-link "~/xxx/aaa.flv#3-5") 44 | (list "~/xxx/aaa.flv" 3 5))) 45 | (should (equal (mpvi-parse-link "~/xxx/aaa.flv#1:3-1:5") 46 | (list "~/xxx/aaa.flv" 63 65))) 47 | (should (equal (mpvi-parse-link "~/xxx/aaa.flv#1:3") 48 | (list "~/xxx/aaa.flv" 63 nil))) 49 | (should (equal (mpvi-parse-link "~/xxx/aaa.flv#-1:3") 50 | (list "~/xxx/aaa.flv" nil 63))) 51 | (should-error (mpvi-parse-link "~/xxx/aaa.flv#fff:3"))) 52 | 53 | (ert-deftest test-mpvi-extract-url () 54 | ;;(mpvi-extract-url nil "https://www.bilibili.com/video/BV17x411973o") 55 | ;;(mpvi-extract-url nil "https://www.bilibili.com/video/BV1Lb411Q7yq") 56 | ;;(mpvi-extract-url nil "https://www.bilibili.com/video/BV1Lb411Q7yq?p=3") 57 | ;;(mpvi-extract-url nil "https://www.douyu.com/topic/crpd?rid=9999") 58 | ) 59 | 60 | (provide 'mpvi-tests) 61 | 62 | ;;; mpvi-tests.el ends here 63 | -------------------------------------------------------------------------------- /mpvi.el: -------------------------------------------------------------------------------- 1 | ;;; mpvi.el --- Media tool based on EMMS and MPV -*- lexical-binding: t -*- 2 | 3 | ;; Copyright (C) 2023 lorniu 4 | 5 | ;; Author: lorniu 6 | ;; URL: https://github.com/lorniu/mpvi 7 | ;; Package-Requires: ((emacs "28.1") (emms "11")) 8 | ;; Keywords: convenience, docs, multimedia, application 9 | ;; SPDX-License-Identifier: MIT 10 | ;; Version: 1.1 11 | 12 | ;;; Commentary: 13 | ;; 14 | ;; Integrate MPV, EMMS, Org and others with Emacs, make watching/download/convert 15 | ;; video or audio conveniently and taking notes easily. Make EMMS support Windows. 16 | ;; 17 | ;; Installation: 18 | ;; - Install `emms' from elpa 19 | ;; - Install `mpvi' from melpa, then load it 20 | ;; - Install the dependencies: mpv (required), yt-dlp, ffmpeg, seam, danmaku2ass and tesseract 21 | ;; 22 | ;; Use `mpvi-open' to open a video/audio, then control the MPV with `mpvi-seek'. 23 | ;; 24 | ;; You can alse control MPV that is opened from `emms'. 25 | 26 | ;;; Code: 27 | 28 | (require 'ffap) 29 | (require 'emms) 30 | (require 'emms-player-mpv) 31 | 32 | (defgroup mpvi nil 33 | "Integrate MPV with Emacs." 34 | :group 'external 35 | :prefix 'mpvi-) 36 | 37 | (defcustom mpvi-cache-directory 38 | (let ((dir (expand-file-name "mpvi/" (temporary-file-directory)))) 39 | (unless (file-exists-p dir) (make-directory dir)) 40 | dir) 41 | "Used to save temporary files." 42 | :type 'directory) 43 | 44 | (defvar mpvi-last-save-directory nil) 45 | 46 | (defvar mpvi-play-history nil) 47 | 48 | (defvar mpvi-annotation-face '(:inherit completions-annotations)) 49 | 50 | (defvar mpvi-build-link-function #'mpvi-build-mpv-link) 51 | 52 | (defvar mpvi-screenshot-function #'mpvi-screenshot) 53 | 54 | (defvar mpvi-ocr-function #'mpvi-ocr-by-tesseract) 55 | 56 | (defvar mpvi-local-video-handler #'mpvi-convert-by-ffmpeg) 57 | 58 | (defvar mpvi-remote-video-handler #'mpvi-ytdlp-download) 59 | 60 | ;; Silence compiler 61 | 62 | (defvar org-attach-method) 63 | (defvar org-mouse-map) 64 | (declare-function org-link-set-parameters "org.el" t t) 65 | (declare-function org-open-at-point "org.el" t t) 66 | (declare-function org-insert-item "org.el" t t) 67 | (declare-function org-at-item-p "org.el" t t) 68 | (declare-function org-display-inline-images "org.el" t t) 69 | (declare-function org-attach-attach "org.el" t t) 70 | (declare-function org-timer-secs-to-hms "org.el" t t) 71 | (declare-function org-timer-fix-incomplete "org.el" t t) 72 | (declare-function org-timer-hms-to-secs "org.el" t t) 73 | (declare-function org-element-context "org.el" t t) 74 | 75 | ;; Helpers 76 | 77 | (defun mpvi-log (fmt &rest args) 78 | "Output log when `emms-player-mpv-debug' not nil. 79 | FMT and ARGS are like arguments in `message'." 80 | (when emms-player-mpv-debug 81 | (apply #'message (concat "[mpvi] " fmt) args))) 82 | 83 | (defun mpvi-call-process (program &rest args) 84 | "Helper for `call-process', PROGRAM and ARGS are the same." 85 | (mpvi-log ">>> %s %s" program 86 | (mapconcat (lambda (a) (shell-quote-argument a)) args " ")) 87 | (apply #'call-process program nil t nil args)) 88 | 89 | (defun mpvi-url-p (url) 90 | "Return if URL is an URL." 91 | (member (url-type (url-generic-parse-url url)) '("http" "https"))) 92 | 93 | (defun mpvi-ffap-guesser () 94 | "Return proper url or file at current point." 95 | (let* ((mark-active nil) 96 | (guess (or (when (derived-mode-p 'org-mode) 97 | (let ((elem (org-element-context))) 98 | (when (equal 'link (car elem)) 99 | (setq elem (cadr elem)) 100 | (pcase (plist-get elem :type) 101 | ("mpv" (car (mpvi-parse-link (plist-get elem :path)))) 102 | ((or "http" "https") (plist-get elem :raw-link)))))) 103 | (ffap-url-at-point) 104 | (ffap-file-at-point)))) 105 | (when (and guess (not (mpvi-url-p guess))) 106 | (if (file-exists-p guess) 107 | (when (file-directory-p guess) 108 | (setq guess (file-name-as-directory guess))) 109 | (setq guess nil))) 110 | guess)) 111 | 112 | (defun mpvi-read-file-name (prompt default-name) 113 | "Read file name using a PROMPT minibuffer. 114 | DEFAULT-NAME is used when only get a directory name." 115 | (let* ((default-directory (or mpvi-last-save-directory default-directory)) 116 | (target (read-file-name prompt))) 117 | (if (directory-name-p target) 118 | (expand-file-name (file-name-nondirectory default-name) target) 119 | (expand-file-name target)))) 120 | 121 | (defun mpvi-time-to-secs (time &optional total) 122 | "Convert TIME to seconds format. 123 | When there is \\='%' in time, return percent seconds from TOTAL." 124 | (require 'org-timer) 125 | (cond ((or (null time) (numberp time)) time) 126 | ((or (not (stringp time)) (not (string-match-p "^-?[0-9:.%]+$" time))) 127 | (user-error "This is not a valid time: %s" time)) 128 | ((cl-find ?: time) 129 | (+ (org-timer-hms-to-secs (org-timer-fix-incomplete time)) 130 | (if-let* ((p (cl-search "." time))) (string-to-number (cl-subseq time p)) 0))) 131 | ((cl-find ?% time) 132 | (if (null total) 133 | (user-error "Percent time need TOTAL non nil") 134 | (/ (* total (string-to-number (substring time 0 (- (length time) 1)))) 100.0))) 135 | (t (string-to-number time)))) 136 | 137 | (defun mpvi-secs-to-hms (secs &optional full truncate) 138 | "Convert SECS to h:mm:ss.xx format. 139 | If FULL is nil, remove '0:' prefix. If TRUNCATE is non-nil, remove frac suffix." 140 | (require 'org-timer) 141 | (let* ((frac (cadr (split-string (number-to-string secs) "\\."))) 142 | (ts (concat (org-timer-secs-to-hms (truncate secs)) (if frac ".") frac))) 143 | (when (and (not full) (string-prefix-p "0:" ts)) 144 | (setq ts (cl-subseq ts 2))) 145 | (if truncate (car (split-string ts "\\.")) ts))) 146 | 147 | (defun mpvi-secs-to-string (secs &optional groupp) 148 | "Truncate SECS and format to string, keep at most 2 float digits. 149 | When GROUPP not nil then try to insert commas to string for better reading." 150 | (let ((ret (number-to-string 151 | (if (integerp secs) secs 152 | (/ (truncate (* 100 secs)) (float 100)))))) 153 | (when groupp 154 | (while (string-match "\\(.*[0-9]\\)\\([0-9][0-9][0-9].*\\)" ret) 155 | (setq ret (concat (match-string 1 ret) "," (match-string 2 ret))))) 156 | ret)) 157 | 158 | (defun mpvi-mpv-version () 159 | "Return the current mpv version as a cons cell." 160 | (with-temp-buffer 161 | (process-file "mpv" nil t "--version") 162 | (goto-char (point-min)) 163 | (if (re-search-forward "v\\([0-9]+\\)\\.\\([0-9]+\\)" (line-end-position) t) 164 | (cons (string-to-number (match-string 1)) 165 | (string-to-number (match-string 2))) 166 | (user-error "No mpv version found")))) 167 | 168 | (defun mpvi-compare-mpv-version (comparefn version) 169 | "Compare current mpv verion with the special VERSION through COMPAREFN. 170 | VERSION should be a cons cell, like \\='(0 38) representing version 0.38." 171 | (let ((current (mpvi-mpv-version))) 172 | (funcall comparefn 173 | (+ (* (car current) 1000) (cdr current)) 174 | (+ (* (car version) 1000) (cdr version))))) 175 | 176 | ;; MPV 177 | 178 | (defvar mpvi-current-url-metadata nil) 179 | 180 | (cl-defgeneric mpvi-extract-url (type url &rest _) 181 | "Extract URL for different platforms. 182 | 183 | Return a plist: 184 | - :url/title/subfile for the real url, display media title and sub-file 185 | - :opts/cmds for extra options for `loadfile', and commands executed after load 186 | - :started for function executed after loaded 187 | - :out-url-decorator for function to decorate url when open in external program 188 | - others maybe used in anywhere else 189 | 190 | TYPE should be keyword as :host format, for example :www.youtube.com, 191 | if it's nil then this method will be a dispatcher." 192 | (:method (type url &rest args) 193 | (unless type ; the first call 194 | (let* ((typefn (lambda (url) (intern (concat ":" (url-host (url-generic-parse-url url)))))) 195 | (playlist (mpvi-extract-playlist (funcall typefn url) url))) 196 | (if (and playlist (null (car playlist))) ; when no selected-index, return all items in playlist 197 | (list :playlist-url url :playlist-items (cdr playlist)) 198 | (let ((purl (if playlist (nth (car playlist) (cdr playlist)))) ret) 199 | (if-let* ((dest (apply #'mpvi-extract-url ; dispatch to method 200 | (funcall typefn (or purl url)) 201 | (or purl url) args))) 202 | (progn (setq ret dest) 203 | (unless (plist-get ret :url) 204 | (plist-put ret :url (or purl url)))) 205 | (setq ret (list :url (or purl url)))) 206 | (when playlist 207 | (plist-put ret :playlist-url url) 208 | (plist-put ret :playlist-index (car playlist)) 209 | (plist-put ret :playlist-items (cdr playlist))) 210 | (unless (equal (plist-get ret :url) url) 211 | (plist-put ret :origin-url url)) 212 | ret)))))) 213 | 214 | (cl-defgeneric mpvi-extract-playlist (type url &optional no-choose) 215 | "Check if URL is a playlist link. If it is, return the selected playlist-item. 216 | TYPE is platform as the same as in `mpvi-extract-url'. 217 | Don't prompt user to choose When NO-CHOOSE is not nil. 218 | Return list of (index-or-title playlist-items)." 219 | (:method (_type url &optional no-choose) 220 | (let ((meta (mpvi-ytdlp-url-metadata url))) 221 | (when (assoc 'is_playlist meta) 222 | (let ((urls (cl-loop for item across (alist-get 'entries meta) 223 | collect (alist-get 'url item)))) 224 | (if no-choose 225 | (cons (alist-get 'title meta) urls) 226 | (let* ((items (cl-loop 227 | for url in urls for i from 1 228 | for item = (if (member url mpvi-play-history) (propertize url 'face mpvi-annotation-face) url) 229 | collect (propertize item 'line-prefix (propertize (format "%2d. " i) 'face mpvi-annotation-face)))) 230 | (item (completing-read 231 | (concat "Playlist" (if-let* ((title (alist-get 'title meta))) (format " (%s)" title)) ": ") 232 | (lambda (input pred action) 233 | (if (eq action 'metadata) 234 | `(metadata (display-sort-function . ,#'identity)) 235 | (complete-with-action action items input pred))) 236 | nil t nil nil (car items)))) 237 | (cons (cl-position item urls :test #'string=) urls)))))))) 238 | 239 | (defun mpvi-check-live () 240 | "Check if MPV is runing." 241 | (unless (emms-player-mpv-proc-playing-p) 242 | (user-error "No living MPV found")) 243 | (with-temp-buffer 244 | (unless (and (zerop (call-process emms-player-mpv-command-name nil '(t t) nil "--version")) 245 | (progn (goto-char (point-min)) (re-search-forward "^mpv\\s-+v?\\(\\([0-9]+\\.?\\)+\\)" nil t 1)) 246 | (string> (mapconcat (lambda (n) (format "%03d" n)) 247 | (seq-map 'string-to-number (split-string (match-string-no-properties 1) "\\." t)) 248 | ".") 249 | "000.016.999")) 250 | (user-error "You should update MPV to support ipc connect")))) 251 | 252 | (defun mpvi-origin-path (&optional path) 253 | "Reverse of `mpvi-extract-url', return the origin url for PATH. 254 | When PATH is nil then return the path of current playing video." 255 | (unless path 256 | (mpvi-check-live) 257 | (setq path (mpvi-cmd `(get_property path)))) 258 | (or (plist-get mpvi-current-url-metadata :origin-url) path)) 259 | 260 | (defun mpvi-cmd (cmd) 261 | "Request MPV for CMD. This is sync version of `emms-player-mpv-cmd'." 262 | (when (emms-player-mpv-proc-playing-p) 263 | (catch 'mpvi-ret 264 | (emms-player-mpv-cmd cmd (lambda (data _err) 265 | (ignore-errors 266 | (throw 'mpvi-ret data)))) 267 | (while (emms-player-mpv-proc-playing-p) (sleep-for 0.05)) 268 | (throw 'mpvi-ret nil)))) 269 | 270 | (defalias 'mpvi-async-cmd #'emms-player-mpv-cmd) 271 | 272 | (cl-defun mpvi-prop (sym &optional (val nil supplied)) 273 | "Run command set_property SYM VAL in MPV. 274 | Run get_property instead if VAL is absent." 275 | (if supplied 276 | (mpvi-async-cmd `(set_property ,sym ,val)) 277 | (mpvi-cmd `(get_property ,sym)))) 278 | 279 | (defun mpvi-pause (&optional how) 280 | "Set or toggle pause state of MPV. 281 | HOW is :json-false or t that returned by get-property. 282 | Toggle pause if HOW is nil." 283 | (interactive) 284 | (mpvi-async-cmd 285 | (if how 286 | `(set pause ,(if (eq how :json-false) 'no 'yes)) 287 | `(cycle pause)))) 288 | 289 | (defun mpvi-seekable (&optional arg) 290 | "Whether current video is seekable. 291 | Alert user when not seekable when ARG not nil." 292 | (let ((seekable (eq (mpvi-prop 'seekable) t))) 293 | (if (and arg (not seekable)) 294 | (user-error "Current video is not seekable, do nothing") 295 | seekable))) 296 | 297 | (defun mpvi-speed (&optional n) 298 | "Tune the speed base on N." 299 | (mpvi-seekable 'assert) 300 | (pcase n 301 | ('nil (mpvi-prop 'speed 1)) ; reset 302 | ((pred numberp) 303 | (let ((factor (* 1.1 n))) 304 | (mpvi-async-cmd `(multiply speed ,(if (>= n 0) factor (/ -1 factor)))))) 305 | (_ (mpvi-prop 'speed (read-from-minibuffer "Speed to: " n nil t))))) 306 | 307 | (defcustom mpvi-post-play-cmds nil 308 | "Command list let MPV process run after loading a file. 309 | See `emms-player-mpv-cmd' for syntax." 310 | :type 'list) 311 | 312 | (cl-defun mpvi-play (path &optional (beg 0) end emms noseek) 313 | "Play PATH from BEG to END. 314 | EMMS is a flag that this is invoked from EMMS. 315 | When NOSEEK is not nil then dont try to seek but open directly." 316 | (if (mpvi-url-p path) 317 | (unless (executable-find "yt-dlp") 318 | (user-error "You should have 'yt-dlp' installed to play remote url")) 319 | (setq path (expand-file-name path))) 320 | (if (and (not noseek) (emms-player-mpv-proc-playing-p) (equal path (mpvi-origin-path))) 321 | ;; when path is current playing, just seek to position 322 | (when (mpvi-seekable) 323 | (mpvi-prop 'ab-loop-a (if end beg "no")) 324 | (mpvi-prop 'ab-loop-b (or end "no")) 325 | (mpvi-prop 'playback-time beg) 326 | (mpvi-prop 'pause 'no)) 327 | ;; start and loadfile 328 | (message "Waiting %s..." path) 329 | (if (emms-player-mpv-proc-playing-p) (ignore-errors (mpvi-pause t))) 330 | ;; If path is not the current playing, load it 331 | (let (logo title subfile opts cmds started) 332 | (when (mpvi-url-p path) 333 | ;; preprocessing url and extra mpv commands 334 | (when-let* ((ret (mpvi-extract-url nil path))) 335 | (unless (plist-get ret :url) (user-error "Unknown url")) 336 | (setq mpvi-current-url-metadata ret) 337 | (setq path (or (plist-get ret :url) path)) 338 | (setq logo (plist-get ret :logo)) 339 | (setq title (plist-get ret :title)) 340 | (setq subfile (plist-get ret :subfile)) 341 | (setq opts (plist-get ret :opts)) 342 | (setq cmds (plist-get ret :cmds)) 343 | (setq started (plist-get ret :started)))) 344 | (setq opts 345 | `((start . ,beg) 346 | ,@(when end 347 | `((ab-loop-a . ,beg) 348 | (ab-loop-b . ,end))) 349 | ,(when title 350 | `(force-media-title . ,(format "\"%s\"" title))) 351 | ,(when subfile 352 | `(sub-file . ,(format "\"%s\"" subfile))) 353 | ,@opts)) 354 | (mpvi-log "load opts: %S" opts) 355 | (let* ((optstr (mapconcat (lambda (x) 356 | (format "%s=%s" (car x) (cdr x))) 357 | (delq nil opts) ",")) 358 | (loadopts (if (ignore-errors (mpvi-compare-mpv-version #'< (cons 0 38))) 359 | (list optstr) 360 | (list -1 optstr))) 361 | (loadhandler (lambda (_ err) 362 | (if err 363 | (message "Load video failed (%S)" err) 364 | (if started 365 | (funcall started) 366 | (message "%s" 367 | (if title 368 | (concat (if logo (concat "/" logo)) ": " 369 | (propertize title 'face 'font-lock-keyword-face)) 370 | ""))) 371 | (push path mpvi-play-history)))) 372 | (lst `(((set_property speed 1)) 373 | ((set_property keep-open ,(if emms 'no 'yes))) 374 | ;; Since mpv 0.38.0, an insertion index argument is added as the third argument 375 | ;; https://mpv.io/manual/master/#command-interface, loadfile 376 | ((loadfile ,path replace ,@loadopts) . ,loadhandler) 377 | ((set_property pause no)) 378 | ,@(cl-loop for c in `(,@cmds ,@mpvi-post-play-cmds) 379 | if (car-safe (car c)) collect c 380 | else collect (list c)))) 381 | (cmd (cons 'batch (delq nil lst)))) 382 | (mpvi-log "load-commands: %S" cmd) 383 | (if (and emms (emms-player-mpv-proc-playing-p)) (emms-player-mpv-stop)) 384 | (mpvi-async-cmd cmd))))) 385 | 386 | ;; Timestamp Link 387 | 388 | (defun mpvi-parse-link (link) 389 | "Extract path, beg, end from LINK." 390 | (if (string-match "^\\([^#]+\\)\\(?:#\\([0-9:.]+\\)?-?\\([0-9:.]+\\)?\\)?$" link) 391 | (let ((path (match-string 1 link)) 392 | (beg (match-string 2 link)) 393 | (end (match-string 3 link))) 394 | (list path (mpvi-time-to-secs beg) (mpvi-time-to-secs end))) 395 | (user-error "Link is not valid"))) 396 | 397 | (defun mpvi-parse-link-at-point () 398 | "Return the mpv link object at point." 399 | (unless (derived-mode-p 'org-mode) 400 | (user-error "You must parse MPV link in org mode")) 401 | (let* ((link (org-element-context)) 402 | (node (cadr link))) 403 | (when (equal "mpv" (plist-get node :type)) 404 | (let ((meta (mpvi-parse-link (plist-get node :path))) 405 | (begin (org-element-property :begin link)) 406 | (end (save-excursion (goto-char (org-element-property :end link)) (skip-chars-backward " \t") (point)))) 407 | `(:path ,(car meta) :vbeg ,(cadr meta) :vend ,(caddr meta) :begin ,begin :end ,end ,@node))))) 408 | 409 | (defun mpvi-build-mpv-link (path &optional beg end desc) 410 | "Build mpv link with timestamp that used in org buffer. 411 | PATH is local video file or remote url. BEG and END is the position number. 412 | DESC is optional, used to describe the current timestamp link." 413 | (concat "[[mpv:" path (if (or beg end) "#") 414 | (if beg (number-to-string beg)) 415 | (if end "-") 416 | (if end (number-to-string end)) 417 | "][▶ " 418 | (if beg (mpvi-secs-to-hms beg nil t)) 419 | (if end " → ") 420 | (if end (mpvi-secs-to-hms end nil t)) 421 | "]]" 422 | (if desc (concat " " desc)))) 423 | 424 | (defcustom mpvi-attach-link-attrs "#+attr_html: :width 666" 425 | "Attrs insert above a inserted attach image. 426 | The :width can make image cannot display too large in org mode." 427 | :type 'string) 428 | 429 | (defun mpvi-insert-attach-link (file) 430 | "Save image FILE to org file using `org-attach'." 431 | (require 'org-attach) 432 | ;; attach it 433 | (let ((org-attach-method 'mv)) (org-attach-attach file)) 434 | ;; insert the attrs 435 | (when mpvi-attach-link-attrs 436 | (insert (string-trim mpvi-attach-link-attrs) "\n")) 437 | ;; insert the link 438 | (insert "[[attachment:" (file-name-base file) "." (file-name-extension file) "]]") 439 | ;; show it 440 | (org-display-inline-images)) 441 | 442 | (cl-defmacro mpvi-with-current-mpv-link ((var &optional path errmsg) &rest form) 443 | "Run FORM when there is a mpv PATH at point that is playing. 444 | Bind the link object to VAR for convenience. Alert user with ERRMSG when 445 | there is a different path at point." 446 | (declare (indent 1)) 447 | `(progn 448 | (mpvi-check-live) 449 | (let ((,var (mpvi-parse-link-at-point))) 450 | (when (and ,var (not (equal (plist-get ,var :path) 451 | ,(or path `(mpvi-origin-path))))) 452 | (user-error ,(or errmsg "Current link is not the actived one, do nothing"))) 453 | ,@form))) 454 | 455 | ;; screenshot 456 | 457 | (defvar mpvi-clipboard-command 458 | (cond ((executable-find "xclip") 459 | ;; A hangs issue: 460 | ;; https://www.reddit.com/r/emacs/comments/da9h10/why_does_shellcommand_hang_using_xclip_filter_to/ 461 | "xclip -selection clipboard -t image/png -filter < \"%s\" &>/dev/null") 462 | ((and (executable-find "powershell") (memq system-type '(cygwin windows-nt))) 463 | "powershell -Command \"Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::SetImage($([System.Drawing.Image]::Fromfile(\\\"%s\\\")))\""))) 464 | 465 | (defun mpvi-image-to-clipboard (image-file) 466 | "Save IMAGE-FILE data to system clipboard. 467 | I don't know whether better solutions exist." 468 | (if (and mpvi-clipboard-command (file-exists-p image-file)) 469 | (let ((command (format mpvi-clipboard-command (shell-quote-argument image-file)))) 470 | (mpvi-log "Copy image to clipboard: %s" command) 471 | (shell-command command)) 472 | (user-error "Nothing to do with copy image file"))) 473 | 474 | (defun mpvi-screenshot (path pos &optional target) 475 | "Capture the screenshot of PATH at POS and save to TARGET." 476 | (unless (mpvi-url-p path) 477 | (setq path (expand-file-name path))) 478 | (setq target 479 | (if target (expand-file-name target) 480 | (expand-file-name (format-time-string "IMG-%s.png") mpvi-cache-directory))) 481 | (with-temp-buffer 482 | (if (zerop (call-process "mpv" nil nil nil path 483 | "--no-terminal" "--no-audio" "--vo=image" "--frames=1" 484 | (format "--start=%s" (or pos 0)) 485 | "-o" target)) 486 | target 487 | (user-error "Capture failed: %s" (string-trim (buffer-string)))))) 488 | 489 | (defun mpvi-screenshot-current-playing (&optional target flag) 490 | "Capture screenshot from current playing mpv and save to TARGET. 491 | If TARGET is nil save to temporary directory, if it is t save to clipboard. 492 | If FLAG is string, pass directly to mpv as of screenshot-to-file, if 493 | it is nil pass \"video\" as default, else prompt user to choose one." 494 | (mpvi-check-live) 495 | (let ((file (if (stringp target) 496 | (expand-file-name target) 497 | (expand-file-name (format-time-string "IMG-%s.png") mpvi-cache-directory))) 498 | (flags (list "video" "subtitles" "window"))) 499 | (unless (or (null flag) (stringp flag)) 500 | (setq flag (completing-read "Flag of screenshot: " flags nil t))) 501 | (unless (member flag flags) (setq flag "video")) 502 | (mpvi-cmd `(screenshot-to-file ,file ,flag)) 503 | (if (eq target t) ; if filename is t save data to clipboard 504 | (mpvi-image-to-clipboard file) 505 | (prog1 file (kill-new file))))) 506 | 507 | ;; tesseract 508 | 509 | (defcustom mpvi-tesseract-args "-l chi_sim" 510 | "Extra options pass to `tesseract'." 511 | :type 'string) 512 | 513 | (defun mpvi-ocr-by-tesseract (file) 514 | "Run tesseract OCR on the screenshot FILE." 515 | (unless (executable-find "tesseract") 516 | (user-error "Program `tesseract' not found")) 517 | (with-temp-buffer 518 | (if (zerop (apply #'mpvi-call-process "tesseract" file "stdout" 519 | (if mpvi-tesseract-args (split-string-and-unquote mpvi-tesseract-args)))) 520 | (buffer-string) 521 | (user-error "OCR tesseract failed: %s" (string-trim (buffer-string)))))) 522 | 523 | ;; ffmpeg 524 | 525 | (defcustom mpvi-ffmpeg-extra-args nil 526 | "Extra options pass to `ffmpeg'." 527 | :type 'string) 528 | 529 | (defcustom mpvi-ffmpeg-gif-filter "fps=10,crop=iw:ih:0:0,scale=320:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" 530 | "Filter used when use `ffmpeg' to convert to gif file." 531 | :type 'string) 532 | 533 | (defun mpvi-convert-by-ffmpeg (file &optional target beg end opts) 534 | "Convert local video FILE from BEG to END using ffmpeg, output to TARGET. 535 | This can be used to cut/resize/reformat and so on. 536 | OPTS is a string, pass to `ffmpeg' when it is not nil." 537 | (cl-assert (file-regular-p file)) 538 | (unless (executable-find "ffmpeg") 539 | (user-error "Program `ffmpeg' not found")) 540 | (let* ((beg (if (numberp beg) (format " -ss %s" beg) "")) 541 | (end (if (numberp end) (format " -to %s" end) "")) 542 | (target (expand-file-name 543 | (or target (format-time-string "mpv-video-%s.mp4")) 544 | mpvi-last-save-directory)) 545 | (extra (concat (if (member (file-name-extension target) '("gif" "webp")) 546 | (format " -vf \"%s\" -loop 0" mpvi-ffmpeg-gif-filter) 547 | " -c copy") 548 | (if (or opts mpvi-ffmpeg-extra-args) 549 | (concat " " (string-trim (or opts mpvi-ffmpeg-extra-args)))))) 550 | (command (string-trim 551 | (minibuffer-with-setup-hook 552 | (lambda () 553 | (use-local-map (make-composed-keymap nil (current-local-map))) 554 | (local-set-key (kbd "C-x C-q") 555 | (lambda () 556 | (interactive) 557 | (let ((inhibit-read-only t)) 558 | (set-text-properties (minibuffer-prompt-end) (point-max) nil)))) 559 | (local-set-key (kbd "") 560 | (lambda () 561 | (interactive) 562 | (let ((cmd (minibuffer-contents))) 563 | (with-temp-buffer 564 | (insert (string-trim cmd)) 565 | (let ((quote (if (member (char-before) '(?' ?\")) (char-before)))) 566 | (when (re-search-backward (if quote (format " +%c" quote) " +") nil t) 567 | (setq target (buffer-substring (match-end 0) (if quote (- (point-max) 1) (point-max))))))) 568 | (if (file-exists-p target) 569 | (message 570 | (propertize 571 | (format "Output file %s is already exist!" target) 572 | 'face 'font-lock-warning-face)) 573 | (exit-minibuffer)))))) 574 | (read-string "Confirm: " 575 | (concat (propertize 576 | (concat "ffmpeg" 577 | (propertize " -loglevel error" 'invisible t) 578 | (format " -i %s" (expand-file-name file))) 579 | 'face 'font-lock-constant-face 'read-only t) 580 | beg end extra (format " \"%s\"" target))))))) 581 | (make-directory (file-name-directory target) t) ; ensure directory 582 | (setq mpvi-last-save-directory (file-name-directory target)) ; record the dir 583 | (with-temp-buffer 584 | (mpvi-log "Convert file %s" file) 585 | (apply #'mpvi-call-process (split-string-and-unquote command)) 586 | (if (file-exists-p target) 587 | (prog1 target 588 | (kill-new target) 589 | (message "Save to %s done." (propertize target 'face 'font-lock-keyword-face))) 590 | (user-error "Convert with ffmpeg failed: %s" (string-trim (buffer-string))))))) 591 | 592 | ;; yt-dlp 593 | 594 | (defcustom mpvi-ytdlp-extra-args nil 595 | "The default extra options pass to `yt-dlp'." 596 | :type 'string) 597 | 598 | (defvar mpvi-ytdlp-metadata-cache nil) 599 | 600 | (defun mpvi-ytdlp-url-metadata (url &optional opts) 601 | "Return metadata for URL, pass extra OPTS to `yt-dlp' for querying. 602 | I just want to judge if current URL is a playlist link, but I can't find 603 | better/faster solution. Maybe cache the results is one choice, but I don't think 604 | it's good enough. Then I can not find good way to get all descriptions of 605 | playlist item with light request. This should be improved someday." 606 | (unless (executable-find "yt-dlp") 607 | (user-error "Program `yt-dlp' should be installed")) 608 | (or (cdr (assoc url mpvi-ytdlp-metadata-cache)) 609 | (with-temp-buffer 610 | (condition-case nil 611 | (progn 612 | (mpvi-log "Request matadata for %s" url) 613 | (apply #'mpvi-call-process 614 | "yt-dlp" url "-J" "--flat-playlist" "--no-warnings" 615 | (split-string-and-unquote (or opts mpvi-ytdlp-extra-args ""))) 616 | (goto-char (point-min)) 617 | (let* ((json (json-read)) 618 | (playlistp (equal "playlist" (alist-get '_type json)))) 619 | (if playlistp (nconc json (list '(is_playlist . t)))) 620 | (push (cons url json) mpvi-ytdlp-metadata-cache) 621 | json)) 622 | (error (user-error "Error when get metadata for %s: %s" url (string-trim (buffer-string)))))))) 623 | 624 | (defun mpvi-ytdlp-pick-format (url) 625 | "Completing read the formats for video with URL. 626 | Return (suggestion-save-name . video-format)." 627 | (unless (executable-find "yt-dlp") 628 | (user-error "Program 'yt-dlp' should be installed")) 629 | (with-temp-buffer 630 | (mpvi-call-process "yt-dlp" "-F" url) 631 | (goto-char (point-min)) 632 | (unless (re-search-forward "Available formats for \\(.+\\):" nil t) 633 | (user-error "Nothing found: %s" (string-trim (buffer-string)))) 634 | (let* ((name (if (equal (mpvi-prop 'path) url) 635 | (mpvi-prop 'media-title) 636 | (match-string 1))) 637 | (fmts (cl-loop with text = (string-trim (buffer-substring 638 | (progn (search-forward "-\n" nil t) (point)) 639 | (point-max))) 640 | for item in (split-string text "\n") 641 | collect (cons (concat (propertize "> " 'face 'font-lock-keyword-face) item) 642 | (split-string item " +")))) 643 | (format (string-trim 644 | (completing-read 645 | "Format (choose directly for one, input like '1,4' for multiple. Default: 'bv,ba'): " 646 | (lambda (input _pred action) 647 | (pcase action 648 | ('metadata 649 | `(metadata (display-sort-function . ,#'identity))) 650 | (`(boundaries . _) 651 | `(boundaries . ,(cons (length input) 0))) 652 | (_ (complete-with-action action fmts "" nil)))) 653 | nil nil nil nil "bv,ba"))) 654 | (format (if (string-prefix-p ">" format) 655 | (cadr (assoc format fmts)) 656 | (string-trim (cl-subseq format 0 (cl-position ?\> format))))) 657 | (ext (if-let* ((fmt (cl-find-if (lambda (c) (equal (cadr c) format)) fmts))) 658 | (caddr fmt) "mp4"))) 659 | (setq format (string-replace " " "" (string-replace "," "+" format))) 660 | (cons (concat name "_" format "." ext) format)))) 661 | 662 | (defun mpvi-ytdlp-output-field (url field &optional opts) 663 | "Get FIELD information for video URL. 664 | FIELD can be id/title/urls/description/format/thumbnail/formats_table and so on. 665 | Pass extra OPTS to mpv if it is not nil." 666 | (unless (executable-find "yt-dlp") 667 | (user-error "Program 'yt-dlp' should be installed")) 668 | (with-temp-buffer 669 | (mpvi-log "yt-dlp output template for %s of %s" field url) 670 | (apply #'mpvi-call-process 671 | "yt-dlp" url "--print" field 672 | (split-string-and-unquote (or opts mpvi-ytdlp-extra-args ""))) 673 | (goto-char (point-min)) 674 | (if (re-search-forward "^yt-dlp: error:.*$" nil t) 675 | (user-error "Error to get `yt-dlp' template/%s: %s" field (match-string 0)) 676 | (string-trim (buffer-string))))) 677 | 678 | (defun mpvi-ytdlp-download (url &optional target beg end opts) 679 | "Download and clip video for URL to TARGET. Use BEG and END for range (trim). 680 | OPTS is a string, pass to `yt-dlp' when it is not nil." 681 | (cl-assert (mpvi-url-p url)) 682 | (unless (and (executable-find "yt-dlp") (executable-find "ffmpeg")) 683 | (user-error "Programs `yt-dlp' and `ffmpeg' should be installed")) 684 | (let* ((fmt (mpvi-ytdlp-pick-format url)) 685 | (beg (if (numberp beg) (format " -ss %s" beg))) 686 | (end (if (numberp end) (format " -to %s" end))) 687 | (extra (if (or opts mpvi-ytdlp-extra-args) 688 | (concat " " (string-trim (or opts mpvi-ytdlp-extra-args))) 689 | "")) 690 | (target (expand-file-name (or target (car fmt)) mpvi-last-save-directory)) 691 | (command (string-trim 692 | (minibuffer-with-setup-hook 693 | (lambda () 694 | (backward-char) 695 | (use-local-map (make-composed-keymap nil (current-local-map))) 696 | (local-set-key (kbd "") 697 | (lambda () 698 | (interactive) 699 | (let ((cmd (minibuffer-contents))) 700 | (with-temp-buffer 701 | (insert cmd) 702 | (goto-char (point-min)) 703 | (when (re-search-forward " -o +['\"]?\\([^'\"]+\\)" nil t) 704 | (setq target (match-string 1))) 705 | (if (file-exists-p target) 706 | (message 707 | (propertize 708 | (format "Output file %s is already exist!" target) 709 | 'face 'font-lock-warning-face)) 710 | (exit-minibuffer))))))) 711 | (read-string 712 | "Confirm: " 713 | (concat (propertize (concat "yt-dlp " url) 'face 'font-lock-constant-face 'read-only t) 714 | " -f \"" (cdr fmt) "\"" 715 | (if (or beg end) " --downloader ffmpeg --downloader-args \"ffmpeg_i:") 716 | beg end (if (or beg end) "\"") extra 717 | " -o \"" target "\"")))))) 718 | (make-directory (file-name-directory target) t) ; ensure directory 719 | (setq mpvi-last-save-directory (file-name-directory target)) ; record the dir 720 | (with-temp-buffer 721 | (mpvi-log "Download/Clip url %s" url) 722 | (apply #'mpvi-call-process (split-string-and-unquote command)) 723 | (if (file-exists-p target) 724 | (prog1 target 725 | (kill-new target) 726 | (message "Save to %s done." (propertize target 'face 'font-lock-keyword-face))) 727 | (user-error "Download and clip with yt-dlp/ffmpeg failed: %s" (string-trim (buffer-string))))))) 728 | 729 | (defun mpvi-ytdlp-download-subtitle (url &optional prefix opts) 730 | "Download subtitle for URL and save as file named begin with PREFIX. 731 | Pass OPTS to `yt-dlp' when it is not nil." 732 | (unless (executable-find "yt-dlp") 733 | (user-error "Program `yt-dlp' should be installed")) 734 | (with-temp-buffer 735 | (mpvi-log "Downloading subtitle for %s" url) 736 | (apply #'mpvi-call-process 737 | "yt-dlp" url "--write-subs" "--skip-download" 738 | "-o" (or prefix (expand-file-name "SUB-%(fulltitle)s" mpvi-cache-directory)) 739 | (split-string-and-unquote (or opts mpvi-ytdlp-extra-args ""))) 740 | (goto-char (point-min)) 741 | (if (re-search-forward "Destination:\\(.*\\)$" nil t) 742 | (string-trim (match-string 1)) 743 | (user-error "Error when download subtitle: %s" (string-trim (buffer-string)))))) 744 | 745 | 746 | ;;; Patch `emms-player-mpv.el' for better integrated 747 | ;; 748 | ;; 1) Emacs don't have builtin way of connecting to Windows named pipe server 749 | ;; 750 | ;; - Should improve `make-network-process' to support this. Here solved by PowerShell 751 | ;; 752 | ;; 2) Some MPV events like 'end-file/playback-restart' not triggered as expected on Windows (BUG?), 753 | ;; So some logics in `emms-player-mpv-event-handler' are not working. 754 | ;; 755 | ;; - Maybe should improve MPV for Windows. Here workaround by adding some ugly patches in EMMS 756 | ;; 757 | ;; 3) The APIs in `emms-player-mpv.el' are too tightly tied to EMMS playlist 758 | ;; 759 | ;; - Maybe should refactor the APIs to make them can be used standalone, that is, can connect 760 | ;; to MPV and play videos without having to update EMMS playlist and so on 761 | ;; 762 | 763 | ;; Windows support, implement by PowerShell 764 | 765 | (defun mpvi-emms-player-mpv-ipc-init (func) 766 | "Advice for FUNC `emms-player-mpv-ipc-init', add Windows support." 767 | (if (eq system-type 'windows-nt) 768 | (mpvi-connect-to-win-named-pipe emms-player-mpv-ipc-socket) 769 | (funcall func))) 770 | 771 | (defun mpvi-emms-player-mpv-ipc-recv (json-string) 772 | "Advice for `emms-player-mpv-ipc-recv', patch for output of PowerShell. 773 | JSON-STRING is json format string return by ipc process." 774 | (emms-player-mpv-debug-msg "json << %s" json-string) 775 | (let (json) 776 | (condition-case err 777 | (setq json (json-read-from-string json-string)) 778 | ;; PowerShell will output error message when something goes wrong to standard output, 779 | ;; It's not json format, so catch it here 780 | (error (erase-buffer) (user-error "ERR in IPC-RECV: %s\n------\n%s" err json-string))) 781 | (let ((rid (alist-get 'request_id json))) 782 | (when (and rid (not (alist-get 'command json))) ; skip the echoed 'command' for Windows 783 | (emms-player-mpv-ipc-req-resolve 784 | rid (alist-get 'data json) (alist-get 'error json))) 785 | (when (alist-get 'event json) 786 | (emms-player-mpv-event-handler json) 787 | ;; Only call the hook when video is played from EMMS 788 | (when (emms-playlist-current-selected-track) 789 | (run-hook-with-args 'emms-player-mpv-event-functions json)))))) 790 | 791 | (defun mpvi-emms-player-mpv-event-handler (func json-data) 792 | "Advice for FUNC `mpvi-emms-player-mpv-event-handler', workaround for Windows. 793 | JSON-DATA is argument." 794 | (when (eq system-type 'windows-nt) 795 | (pcase (alist-get 'event json-data) 796 | ("start-file" ; playback-restart event not working in Windows 797 | (unless (emms-player-mpv-proc-playing-p) 798 | (emms-player-mpv-proc-playing t) 799 | (emms-player-started emms-player-mpv)) 800 | (emms-player-mpv-event-playing-time-sync)))) 801 | (funcall func json-data)) 802 | 803 | (defun mpvi-emms-player-mpv-force-stop (&rest _) 804 | "Advice for `emms-player-mpv-proc-sentinel' and `emms-player-mpv-stop'." 805 | ;; Event 'end-file' is not working correctly on Windows! So have a try like this.. 806 | (when (eq system-type 'windows-nt) 807 | (emms-player-mpv-proc-stop) 808 | (emms-player-mpv-ipc-stop) 809 | (emms-player-mpv-proc-playing nil))) 810 | 811 | (defun mpvi-connect-to-win-named-pipe (pipename) 812 | "Connect to MPV by PIPENAME via `PowerShell'." 813 | (emms-player-mpv-ipc-stop) 814 | (emms-player-mpv-debug-msg "ipc: init for windows") 815 | (with-current-buffer (get-buffer-create emms-player-mpv-ipc-buffer) 816 | (erase-buffer)) 817 | (setq emms-player-mpv-ipc-id 1 818 | emms-player-mpv-ipc-req-table nil) 819 | (setq pipename (string-replace "/" "\\" pipename)) ; path seperator on Windows is different 820 | (with-timeout (5 (emms-player-mpv-ipc-stop) 821 | (user-error "No MPV process found")) 822 | (while (not (mpvi-win-named-pipe-exists-p pipename)) 823 | (sleep-for 0.05))) 824 | (let* ((ps1 " $conn = [System.IO.Pipes.NamedPipeClientStream]::new('.', '%s'); 825 | try { 826 | $reader = [System.IO.StreamReader]::new($conn); 827 | $writer = [System.IO.StreamWriter]::new($conn); 828 | $conn.Connect(5000); 829 | while (1) { 830 | $msg = Read-Host; 831 | $writer.WriteLine($msg); 832 | $writer.Flush(); 833 | $conn.WaitForPipeDrain(); 834 | do { 835 | $ret = $reader.ReadLine(); 836 | Write-Host $ret; 837 | } while ($ret -match '\"event\":'); 838 | } 839 | } 840 | catch [System.TimeoutException], [System.InvalidOperationException] { Write-Host 'Connect to MPV failed'; } 841 | catch { Write-Host $_; } 842 | finally { $conn.Dispose(); } ") 843 | (cmd (format "& {%s}" (replace-regexp-in-string 844 | "[ \n\r\t]+" " " (format ps1 pipename)))) 845 | (proc (make-process :name "emms-player-mpv-ipc" 846 | :connection-type 'pipe 847 | :buffer (get-buffer-create emms-player-mpv-ipc-buffer) 848 | :noquery t 849 | :filter #'emms-player-mpv-ipc-filter 850 | :sentinel #'emms-player-mpv-ipc-sentinel 851 | :command (list "powershell" "-NoProfile" "-Command" cmd)))) 852 | (with-timeout (5 (setq emms-player-mpv-ipc-proc nil) 853 | (user-error "Connect to MPV failed")) 854 | (while (not (eq (process-status emms-player-mpv-proc) 'run)) 855 | (sleep-for 0.05))) 856 | (setq emms-player-mpv-ipc-proc proc))) 857 | 858 | (defun mpvi-win-named-pipe-exists-p (pipename) 859 | "Check if named pipe with name of PIPENAME exists on Windows." 860 | (unless (executable-find "powershell") 861 | (user-error "Cannot find PowerShell")) 862 | (with-temp-buffer 863 | (call-process "powershell" nil t nil 864 | "-Command" 865 | (format "& {Get-ChildItem \\\\.\\pipe\\ | Where-Object {$_.Name -eq '%s'}}" 866 | pipename)) 867 | (> (length (buffer-string)) 0))) 868 | 869 | ;; Only update track when videos are played from EMMS buffer 870 | 871 | (defun mpvi-emms-player-started (player) 872 | "Advice for `emms-player-started', PLAYER is the current player." 873 | (setq emms-player-playing-p player 874 | emms-player-paused-p nil) 875 | (when (emms-playlist-current-selected-track) ; add this 876 | (run-hooks 'emms-player-started-hook))) 877 | 878 | (defun mpvi-emms-player-stopped () 879 | "Advice for `emms-player-stopped'." 880 | (setq emms-player-playing-p nil) 881 | (when (emms-playlist-current-selected-track) ; add this 882 | (if emms-player-stopped-p 883 | (run-hooks 'emms-player-stopped-hook) 884 | (sleep-for emms-player-delay) 885 | (run-hooks 'emms-player-finished-hook) 886 | (funcall emms-player-next-function)))) 887 | 888 | ;; Integrate `emms-player-start' with `mpvi-play' 889 | 890 | (defun mpvi-emms-player-mpv-start (track) 891 | "Play TRACK in EMMS. Integrate with `mpvi-play'." 892 | (setq emms-player-mpv-stopped nil) 893 | (emms-player-mpv-proc-playing nil) 894 | (let* ((track-name (emms-track-get track 'name)) 895 | (start-func (lambda () (mpvi-play track-name nil nil t)))) ; <- change this 896 | (if (and (not (eq system-type 'windows-nt)) ; pity, auto switch next not working on Windows 897 | emms-player-mpv-ipc-stop-command) 898 | (setq emms-player-mpv-ipc-stop-command start-func) 899 | (funcall start-func)))) 900 | 901 | ;; Minor mode 902 | 903 | ;;;###autoload 904 | (define-minor-mode mpvi-emms-integrated-mode 905 | "Global minor mode to toggle EMMS integration." 906 | :global t 907 | (if mpvi-emms-integrated-mode 908 | (progn 909 | (advice-add #'emms-player-mpv-ipc-init :around #'mpvi-emms-player-mpv-ipc-init) 910 | (advice-add #'emms-player-mpv-ipc-recv :override #'mpvi-emms-player-mpv-ipc-recv) 911 | (advice-add #'emms-player-mpv-event-handler :around #'mpvi-emms-player-mpv-event-handler) 912 | (advice-add #'emms-player-mpv-proc-sentinel :after #'mpvi-emms-player-mpv-force-stop) 913 | ;; 914 | (advice-add #'emms-player-started :override #'mpvi-emms-player-started) 915 | (advice-add #'emms-player-stopped :override #'mpvi-emms-player-stopped) 916 | ;; 917 | (advice-add #'emms-player-mpv-start :override #'mpvi-emms-player-mpv-start) 918 | (advice-add #'emms-player-mpv-stop :after #'mpvi-emms-player-mpv-force-stop)) 919 | (advice-remove #'emms-player-mpv-ipc-init #'mpvi-emms-player-mpv-ipc-init) 920 | (advice-remove #'emms-player-mpv-ipc-recv #'mpvi-emms-player-mpv-ipc-recv) 921 | (advice-remove #'emms-player-mpv-event-handler #'mpvi-emms-player-mpv-event-handler) 922 | (advice-remove #'emms-player-mpv-proc-sentinel #'mpvi-emms-player-mpv-force-stop) 923 | (advice-remove #'emms-player-started #'mpvi-emms-player-started) 924 | (advice-remove #'emms-player-stopped #'mpvi-emms-player-stopped) 925 | (advice-remove #'emms-player-mpv-start #'mpvi-emms-player-mpv-start) 926 | (advice-remove #'emms-player-mpv-stop #'mpvi-emms-player-mpv-force-stop))) 927 | 928 | (mpvi-emms-integrated-mode 1) 929 | 930 | 931 | ;;; Interactive Commands 932 | 933 | ;; [open] 934 | 935 | (defcustom mpvi-favor-paths nil 936 | "Your favor video path list. 937 | Item should be a path string or a cons. 938 | 939 | For example: 940 | 941 | \\='(\"~/video/aaa.mp4\" 942 | \"https://www.youtube.com/watch?v=NQXA\" 943 | (\"https://www.douyu.com/110\" . \"some description\")) 944 | 945 | This can be used by `mpvi-open-from-favors' to quick open video." 946 | :type 'list) 947 | 948 | (defvar mpvi-open-map 949 | (let ((map (make-sparse-keymap))) 950 | (set-keymap-parent map minibuffer-local-map) 951 | (define-key map (kbd "C-x b") #'mpvi-open-from-favors) 952 | (define-key map (kbd "C-x ") (lambda () (interactive) (throw 'mpvi-open (list (minibuffer-contents) 'add)))) 953 | (define-key map (kbd "C-x C-w") (lambda () (interactive) (throw 'mpvi-open (list (minibuffer-contents) 'dup)))) 954 | map)) 955 | 956 | ;;;###autoload 957 | (defun mpvi-open (path &optional act) 958 | "Deal with PATH, which is a local video or remote url. 959 | Play the video if ACT is nil or play, add to EMMS if ACT is add, 960 | clip the video if ACT is dup. 961 | Keybind `C-x RET' to add to playlist. 962 | Keybind `C-x b' to choose video path from `mpvi-favor-paths'." 963 | (interactive (catch 'mpvi-open 964 | (minibuffer-with-setup-hook 965 | (lambda () 966 | (use-local-map (make-composed-keymap (list (current-local-map) mpvi-open-map)))) 967 | (list (unwind-protect 968 | (catch 'ffap-prompter 969 | (ffap-read-file-or-url 970 | "Playing video (file or url): " 971 | (prog1 (mpvi-ffap-guesser) (ffap-highlight)))) 972 | (ffap-highlight t)))))) 973 | (unless (and (> (length path) 0) (or (mpvi-url-p path) (file-exists-p path))) 974 | (user-error "Not correct file or url")) 975 | (prog1 (setq path (if (mpvi-url-p path) path (expand-file-name path))) 976 | (cond 977 | ((or (null act) (equal act 'play)) 978 | (setq mpvi-current-url-metadata nil) 979 | (with-current-emms-playlist (setq emms-playlist-selected-marker nil)) 980 | (mpvi-play path)) 981 | ((equal act 'add) 982 | (mpvi-emms-add path)) 983 | ((equal act 'dup) 984 | (if (mpvi-url-p path) 985 | (mpvi-ytdlp-download path) 986 | (mpvi-convert-by-ffmpeg path)))))) 987 | 988 | ;;;###autoload 989 | (defun mpvi-open-from-favors () 990 | "Choose video from `mpvi-favor-paths' and play it." 991 | (interactive) 992 | (unless (consp mpvi-favor-paths) 993 | (user-error "You should add your favor paths into `mpvi-favor-paths' first")) 994 | (let* ((annfn (lambda (it) 995 | (when-let* ((s (alist-get it mpvi-favor-paths))) 996 | (format " (%s)" s)))) 997 | (path (completing-read "Choose video to play: " 998 | (lambda (input pred action) 999 | (if (eq action 'metadata) 1000 | `(metadata (display-sort-function . ,#'identity) 1001 | (annotation-function . ,annfn)) 1002 | (complete-with-action action mpvi-favor-paths input pred))) 1003 | nil t))) 1004 | ;; called directly vs called from minibuffer 1005 | (if (= (recursion-depth) 0) 1006 | (mpvi-open path) 1007 | (throw 'mpvi-open (list path 'play))))) 1008 | 1009 | ;; [seek] 1010 | 1011 | (defvar mpvi-seek-paused nil) 1012 | 1013 | (defvar mpvi-seek-overlay nil) 1014 | 1015 | (defvar mpvi-seek-refresh-timer nil) 1016 | 1017 | (defvar mpvi-seek-map 1018 | (let ((map (make-sparse-keymap))) 1019 | (set-keymap-parent map minibuffer-local-map) 1020 | (define-key map (kbd "i") #'mpvi-seeking-insert) 1021 | (define-key map (kbd "l") #'mpvi-seeking-revert) 1022 | (define-key map (kbd "n") (lambda () (interactive) (mpvi-seeking-walk 1))) 1023 | (define-key map (kbd "p") (lambda () (interactive) (mpvi-seeking-walk -1))) 1024 | (define-key map (kbd "N") (lambda () (interactive) (mpvi-seeking-walk "1%"))) 1025 | (define-key map (kbd "P") (lambda () (interactive) (mpvi-seeking-walk "-1%"))) 1026 | (define-key map (kbd "M-n") (lambda () (interactive) (mpvi-seeking-walk :ff))) 1027 | (define-key map (kbd "M-p") (lambda () (interactive) (mpvi-seeking-walk :fb))) 1028 | (define-key map (kbd "C-l") (lambda () (interactive) (mpvi-seeking-walk 0))) 1029 | (define-key map (kbd "C-n") (lambda () (interactive) (mpvi-seeking-walk 1))) 1030 | (define-key map (kbd "C-p") (lambda () (interactive) (mpvi-seeking-walk -1))) 1031 | (define-key map (kbd "M-<") (lambda () (interactive) (mpvi-seeking-revert 0))) 1032 | (define-key map (kbd "j") (lambda () (interactive) (mpvi-speed -1))) 1033 | (define-key map (kbd "k") (lambda () (interactive) (mpvi-speed 1))) 1034 | (define-key map (kbd "m") (lambda () (interactive) (mpvi-speed nil))) 1035 | (define-key map (kbd "v") #'mpvi-current-playing-switch-playlist) 1036 | (define-key map (kbd "C-v") #'mpvi-current-playing-switch-playlist) 1037 | (define-key map (kbd "c") #'mpvi-seeking-clip) 1038 | (define-key map (kbd "C-c") #'mpvi-seeking-clip) 1039 | (define-key map (kbd "s") #'mpvi-seeking-capture-save-as) 1040 | (define-key map (kbd "C-s") #'mpvi-seeking-capture-to-clipboard) 1041 | (define-key map (kbd "C-i") #'mpvi-seeking-capture-as-attach) 1042 | (define-key map (kbd "r") #'mpvi-seeking-ocr-to-kill-ring) 1043 | (define-key map (kbd "C-r") #'mpvi-seeking-ocr-to-kill-ring) 1044 | (define-key map (kbd "t") #'mpvi-seeking-copy-sub-text) 1045 | (define-key map (kbd "C-t") #'mpvi-seeking-copy-sub-text) 1046 | (define-key map (kbd "T") #'mpvi-current-playing-load-subtitle) 1047 | (define-key map (kbd "SPC") #'mpvi-seeking-pause) 1048 | (define-key map (kbd "o") #'mpvi-current-playing-open-externally) 1049 | (define-key map (kbd "C-o") #'mpvi-current-playing-open-externally) 1050 | (define-key map (kbd "q") #'abort-minibuffers) 1051 | (define-key map (kbd "C-q") #'abort-minibuffers) 1052 | (define-key map (kbd "h") #'mpvi-seeking-short-help) 1053 | map)) 1054 | 1055 | (defun mpvi-seek-refresh-annotation () 1056 | "Show information of the current playing in minibuffer." 1057 | (ignore-errors (cancel-timer mpvi-seek-refresh-timer)) 1058 | (if mpvi-seek-overlay (delete-overlay mpvi-seek-overlay)) 1059 | (let ((vf (lambda (s) (if s (propertize (format "%s" s) 'face mpvi-annotation-face)))) 1060 | (sf (lambda (s) (propertize " " 'display `(space :align-to (- right-fringe ,(1+ (length s))))))) ; space 1061 | (ov (make-overlay (point-max) (point-max) nil t t))) 1062 | (overlay-put ov 'intangible t) 1063 | (setq mpvi-seek-overlay ov) 1064 | (if (mpvi-seekable) 1065 | (condition-case nil 1066 | (let* ((loop (if (eq (mpvi-prop 'loop) t) (funcall vf "[Looping] "))) 1067 | (paused (if (eq (mpvi-prop 'pause) t) (funcall vf "[Paused] "))) 1068 | (time (funcall vf (mpvi-secs-to-hms (mpvi-prop 'time-pos) nil t))) 1069 | (total (funcall vf (mpvi-secs-to-hms (mpvi-prop 'duration) nil t))) 1070 | (percent (funcall vf (format "%.1f%%" (mpvi-prop 'percent-pos)))) 1071 | (speed (funcall vf (format "%.2f" (mpvi-prop 'speed)))) 1072 | (concated (concat loop (if loop " ") paused (if paused " ") 1073 | time "/" total " " percent " Speed: " speed)) 1074 | (space (funcall sf concated))) 1075 | (overlay-put ov 'before-string (propertize (concat space concated) 'cursor t)) 1076 | (setq mpvi-seek-refresh-timer (run-with-timer 1 nil #'mpvi-seek-refresh-annotation))) 1077 | (error nil)) 1078 | (let* ((title (funcall vf (concat " >> " (string-trim (or (mpvi-prop 'media-title) ""))))) 1079 | (state (funcall vf (if (eq (mpvi-prop 'pause) t) "Paused"))) 1080 | (space (funcall sf state))) 1081 | (delete-minibuffer-contents) 1082 | (insert "0") 1083 | (overlay-put ov 'before-string (propertize (concat title space state) 'cursor t)))))) 1084 | 1085 | ;;;###autoload 1086 | (defun mpvi-seek (&optional pos prompt) 1087 | "Interactively seek POS for current playing video. 1088 | PROMPT is used if non-nil for `minibuffer-prompt'." 1089 | (interactive) 1090 | (mpvi-check-live) 1091 | (let ((paused (mpvi-prop 'pause)) 1092 | (keep-open (mpvi-prop 'keep-open))) 1093 | (mpvi-pause t) 1094 | (mpvi-prop 'keep-open 'yes) ; dont close on end 1095 | (unwind-protect 1096 | (when-let* 1097 | ((ret 1098 | (catch 'mpvi-seek 1099 | (minibuffer-with-setup-hook 1100 | (lambda () 1101 | (add-hook 'after-change-functions 1102 | (lambda (start end _) 1103 | (let ((text (minibuffer-contents))) 1104 | (unless (or (string-match-p "^[0-9]+\\.?[0-9]*$" text) ; 23.3 1105 | (string-match-p "^[0-9]\\{1,2\\}\\(\\.[0-9]*\\)?%$" text) ; 23% 1106 | (string-match-p ; 1:23:32 1107 | "^\\([0-9]+:\\)?\\([0-9]\\{1,2\\}\\):\\([0-9]\\{1,2\\}\\)?$" text)) 1108 | (delete-region start end)))) 1109 | nil t) 1110 | (add-hook 'minibuffer-exit-hook (lambda () 1111 | (ignore-errors (cancel-timer mpvi-seek-refresh-timer)) 1112 | (setq mpvi-seek-refresh-timer nil))) 1113 | (add-hook 'post-command-hook #'mpvi-seek-refresh-annotation nil t) 1114 | (when mpvi-seek-refresh-timer (cancel-timer mpvi-seek-refresh-timer)) 1115 | (setq mpvi-seek-refresh-timer (run-with-timer 1 nil #'mpvi-seek-refresh-annotation))) 1116 | (ignore-errors 1117 | (read-from-minibuffer 1118 | (or prompt (if (mpvi-seekable) 1119 | (format "Seek (0-%d, mm:ss or n%%): " (mpvi-prop 'duration)) 1120 | "MPV Controller: ")) 1121 | (format "%.1f" (or pos (mpvi-prop 'playback-time))) 1122 | mpvi-seek-map nil 'mpvi-seek-hist)))))) 1123 | (unless (eq (mpvi-prop 'pause) :json-false) 1124 | (when (mpvi-seekable) 1125 | (when-let ((pos (mpvi-time-to-secs ret (mpvi-prop 'duration)))) 1126 | (mpvi-prop 'playback-time pos)))) 1127 | (cons (ignore-errors (mpvi-prop 'playback-time)) paused)) 1128 | (mpvi-pause (or mpvi-seek-paused paused)) 1129 | (mpvi-prop 'keep-open (if (eq keep-open :json-false) 'no 'yes))))) 1130 | 1131 | (defun mpvi-seeking-walk (offset) 1132 | "Seek forward or backward with factor of OFFSET. 1133 | If OFFSET is number then step by seconds. 1134 | If OFFSET is xx% format then step by percent. 1135 | If OFFSET is :ff or :fb then step forward/backward one frame." 1136 | (pcase offset 1137 | (:ff (mpvi-cmd `(frame_step))) 1138 | (:fb (mpvi-cmd `(frame_back_step))) 1139 | (_ 1140 | (when (and (stringp offset) (string-match-p "^-?[0-9]\\{0,2\\}\\.?[0-9]*%$" offset)) ; percent 1141 | (setq offset (* (/ (string-to-number (cl-subseq offset 0 -1)) 100.0) (mpvi-prop 'duration)))) 1142 | (unless (numberp offset) (setq offset 1)) 1143 | (let* ((total (mpvi-prop 'duration)) 1144 | (old (if (or (zerop offset) (eq (mpvi-prop 'pause) t)) 1145 | (let ((str (string-trim (minibuffer-contents)))) 1146 | (or (mpvi-time-to-secs str total) 1147 | (user-error "Not valid time input"))) 1148 | (mpvi-prop 'playback-time))) 1149 | (new (+ old offset))) 1150 | (if (< new 0) (setq new 0)) 1151 | (if (> new total) (setq new total)) 1152 | (mpvi-prop 'playback-time new)))) 1153 | (unless (zerop offset) (mpvi-seeking-revert))) 1154 | 1155 | (defun mpvi-seeking-revert (&optional num) 1156 | "Insert current playback-time to minibuffer. 1157 | If NUM is not nil, go back that position first." 1158 | (interactive) 1159 | (when (and num (mpvi-seekable)) 1160 | (mpvi-prop 'playback-time num)) 1161 | (delete-minibuffer-contents) 1162 | (insert (mpvi-secs-to-string (mpvi-prop 'playback-time)))) 1163 | 1164 | (defun mpvi-seeking-pause () 1165 | "Revert and pause." 1166 | (interactive) 1167 | (mpvi-async-cmd `(cycle pause)) 1168 | (setq mpvi-seek-paused (eq (mpvi-prop 'pause) t)) 1169 | (when mpvi-seek-paused (mpvi-seeking-revert))) 1170 | 1171 | (defun mpvi-seeking-insert () 1172 | "Insert new link in minibuffer seek." 1173 | (interactive) 1174 | (mpvi-seekable 'assert) 1175 | (with-current-buffer (window-buffer (minibuffer-selected-window)) 1176 | (let ((paused (mpvi-prop 'pause))) 1177 | (mpvi-pause t) 1178 | (unwind-protect 1179 | (if (derived-mode-p 'org-mode) 1180 | (let* ((desc (string-trim (read-string "Notes: "))) 1181 | (link (funcall mpvi-build-link-function 1182 | (mpvi-origin-path) 1183 | (mpvi-prop 'playback-time) 1184 | nil desc))) 1185 | (cond ((org-at-item-p) (end-of-line) (org-insert-item)) 1186 | (t (end-of-line) (insert "\n"))) 1187 | (set-window-point (get-buffer-window) (point)) 1188 | (save-excursion (insert link))) 1189 | (user-error "This is not org-mode, should not insert timestamp link")) 1190 | (mpvi-pause paused)))) 1191 | (mpvi-seeking-revert)) 1192 | 1193 | (defun mpvi-seeking-clip () 1194 | "Download/Clip current playing video." 1195 | (interactive) 1196 | (let ((path (mpvi-prop 'path))) 1197 | (funcall (if (mpvi-url-p path) mpvi-remote-video-handler mpvi-local-video-handler) path)) 1198 | (throw 'mpvi-seek nil)) 1199 | 1200 | (defun mpvi-seeking-copy-sub-text () 1201 | "Copy current sub text to kill ring." 1202 | (interactive) 1203 | (when-let* ((sub (ignore-errors (mpvi-prop 'sub-text)))) 1204 | (kill-new sub) 1205 | (throw 'mpvi-seek "Copied to kill ring, yank to the place you want."))) 1206 | 1207 | (defun mpvi-seeking-capture-save-as () 1208 | "Capture current screenshot and prompt to save." 1209 | (interactive) 1210 | (let ((target (mpvi-read-file-name "Screenshot save to: " (format-time-string "mpv-%F-%X.png")))) 1211 | (make-directory (file-name-directory target) t) 1212 | (mpvi-screenshot-current-playing target current-prefix-arg) 1213 | (throw 'mpvi-seek (format "Captured to %s" target)))) 1214 | 1215 | (defun mpvi-seeking-capture-to-clipboard () 1216 | "Capture current screenshot and save to clipboard." 1217 | (interactive) 1218 | (mpvi-screenshot-current-playing t current-prefix-arg) 1219 | (throw 'mpvi-seek "Screenshot is in clipboard, paste to use")) 1220 | 1221 | (defun mpvi-seeking-capture-as-attach () 1222 | "Capture current screenshot and insert as attach link." 1223 | (interactive nil org-mode) 1224 | (with-current-buffer (window-buffer (minibuffer-selected-window)) 1225 | (unless (derived-mode-p 'org-mode) 1226 | (user-error "This is not org-mode, should not insert org link"))) 1227 | (with-current-buffer (window-buffer (minibuffer-selected-window)) 1228 | (when (mpvi-parse-link-at-point) 1229 | (end-of-line) (insert "\n")) 1230 | (mpvi-insert-attach-link (mpvi-screenshot-current-playing nil current-prefix-arg))) 1231 | (throw 'mpvi-seek "Capture and insert done.")) 1232 | 1233 | (defun mpvi-seeking-ocr-to-kill-ring () 1234 | "OCR current screenshot and save the result into kill ring." 1235 | (interactive) 1236 | (with-current-buffer (window-buffer (minibuffer-selected-window)) 1237 | (let ((ret (funcall mpvi-ocr-function (mpvi-screenshot-current-playing)))) 1238 | (kill-new ret))) 1239 | (throw 'mpvi-seek "OCR done into kill ring, please yank it.")) 1240 | 1241 | (defun mpvi-current-playing-switch-playlist () 1242 | "Extract playlist from current video url. 1243 | If any, prompt user to choose one video in playlist to play." 1244 | (interactive) 1245 | (mpvi-check-live) 1246 | (if-let* ((playlist (plist-get mpvi-current-url-metadata :playlist-url)) 1247 | (playlist-index (plist-get mpvi-current-url-metadata :playlist-index)) 1248 | (msg "Switch done.")) 1249 | (condition-case nil 1250 | (throw 'mpvi-seek (prog1 msg (mpvi-play playlist nil nil nil t))) 1251 | (error (message msg))) 1252 | (user-error "No playlist found for current playing url"))) 1253 | 1254 | (defun mpvi-current-playing-load-subtitle (subfile) 1255 | "Load or reload the SUBFILE for current playing video." 1256 | (interactive (list (read-file-name "Danmaku file: " mpvi-cache-directory nil t))) 1257 | (mpvi-check-live) 1258 | (cl-assert (file-regular-p subfile)) 1259 | (when (string-suffix-p ".danmaku.xml" subfile) ; bilibili 1260 | (require 'mpvi-ps) 1261 | (setq subfile (mpvi-convert-danmaku2ass subfile 'confirm))) 1262 | (ignore-errors (mpvi-async-cmd `(sub-remove))) 1263 | (mpvi-async-cmd `(sub-add ,subfile)) 1264 | (message "Sub file loaded!")) 1265 | 1266 | (defun mpvi-current-playing-open-externally () 1267 | "Open current playing video PATH with system program." 1268 | (interactive) 1269 | (mpvi-check-live) 1270 | (if-let* ((path (mpvi-origin-path))) 1271 | (let ((called-from-seek (> (recursion-depth) 0))) 1272 | (if (or (not called-from-seek) 1273 | (y-or-n-p (format "Open '%s' externally?" path))) 1274 | (let ((msg "Open in system program done.")) 1275 | ;; add begin time for url if necessary 1276 | (when-let* ((f (plist-get mpvi-current-url-metadata :out-url-decorator))) 1277 | (setq path (funcall f path (mpvi-prop 'playback-time)))) 1278 | (browse-url path) 1279 | (if called-from-seek 1280 | (progn (setq mpvi-seek-paused t) 1281 | (throw 'mpvi-seek msg)) 1282 | (mpvi-pause t) 1283 | (message msg))) 1284 | (message ""))) 1285 | (user-error "No playing path found"))) 1286 | 1287 | (defun mpvi-seeking-short-help () 1288 | "Command tips for current seek." 1289 | (interactive) 1290 | (let ((tips '(("c" . "Clip") 1291 | ("i" . "Insert") 1292 | ("n/p" . "Position") 1293 | ("j/k/m" . "Speed") 1294 | ("o" . "Open") 1295 | ("r" . "OCR") 1296 | ("t/T" . "Subtitle") 1297 | ("v/C-v" . "Playlist") 1298 | ("s/C-s/C-i" . "Capture") 1299 | ("SPC" . "TogglePause")))) 1300 | (tooltip-show 1301 | (mapconcat (lambda (tip) (concat (car tip) ": " (cdr tip))) 1302 | tips "\n")))) 1303 | 1304 | ;; [others] 1305 | 1306 | ;;;###autoload 1307 | (defun mpvi-insert (&optional prompt) 1308 | "Insert a mpv link or update a mpv link at point. 1309 | PROMPT is used in minibuffer when invoke `mpvi-seek'." 1310 | (interactive "P" org-mode) 1311 | (if (derived-mode-p 'org-mode) 1312 | (let ((path (mpvi-origin-path)) description) 1313 | (unless (mpvi-seekable) 1314 | (user-error "Current video is not seekable, it makes no sense to insert timestamp link")) 1315 | (mpvi-with-current-mpv-link (node path) 1316 | (when-let* ((ret (mpvi-seek (if node (plist-get node :vbeg)) prompt))) 1317 | (mpvi-pause t) 1318 | ;; if on a mpv link, update it 1319 | (if node (delete-region (plist-get node :begin) (plist-get node :end)) 1320 | ;; if new insert, prompt for description 1321 | (unwind-protect 1322 | (setq description (string-trim (read-string "Description: "))) 1323 | (mpvi-pause (cdr ret)))) 1324 | ;; insert the new link 1325 | (let ((link (funcall mpvi-build-link-function path (car ret) 1326 | (if node (plist-get node :vend)) 1327 | (if (> (length description) 0) description)))) 1328 | (save-excursion (insert link)))))) 1329 | (user-error "This is not org-mode, should not insert org link"))) 1330 | 1331 | ;;;###autoload 1332 | (defun mpvi-clip (path &optional target beg end) 1333 | "Cut or convert video for PATH from BEG to END, save to TARGET. 1334 | Default handle current video at point." 1335 | (interactive 1336 | (if-let* ((node (ignore-errors (mpvi-parse-link-at-point)))) 1337 | (let ((path (plist-get node :path))) 1338 | (if (or (mpvi-url-p path) (file-exists-p path)) 1339 | (list path 1340 | (unless (mpvi-url-p path) (mpvi-read-file-name "Save to: " path)) 1341 | (plist-get node :vbeg) (plist-get node :vend)) 1342 | (user-error "File not found: %s" path))) 1343 | (let* ((path (unwind-protect 1344 | (ffap-read-file-or-url 1345 | "Clip video (file or url): " 1346 | (prog1 (mpvi-ffap-guesser) (ffap-highlight))) 1347 | (ffap-highlight t))) 1348 | (target (unless (mpvi-url-p path) (mpvi-read-file-name "Save to: " path)))) 1349 | (list path target)))) 1350 | (funcall (if (mpvi-url-p path) mpvi-remote-video-handler mpvi-local-video-handler) 1351 | path target beg end)) 1352 | 1353 | ;;;###autoload 1354 | (defun mpvi-emms-add (path &optional label) 1355 | "Add PATH to EMMS playlist. LABEL is extra info to show in EMMS buffer." 1356 | (interactive (list (ffap-read-file-or-url 1357 | "Add to EMMS (file or url): " 1358 | (prog1 (mpvi-ffap-guesser) (ffap-highlight))))) 1359 | (unless (and (> (length path) 0) (or (mpvi-url-p path) (file-exists-p path))) 1360 | (user-error "Not correct file or url")) 1361 | (if (mpvi-url-p path) 1362 | (let ((playlist (mpvi-extract-playlist 1363 | (intern (concat ":" (url-host (url-generic-parse-url path)))) path t)) 1364 | choosen) 1365 | (when playlist 1366 | (setq choosen 1367 | (completing-read "Choose from playlist: " 1368 | (lambda (input pred action) 1369 | (if (eq action 'metadata) 1370 | `(metadata (display-sort-function . ,#'identity)) 1371 | (complete-with-action action (cons "ALL" (cdr playlist)) input pred))) 1372 | nil t))) 1373 | (if (equal choosen "ALL") (setq choosen (cdr playlist))) 1374 | (setq choosen (or choosen path)) 1375 | (unless (consp choosen) (setq choosen (list choosen))) 1376 | (cl-loop with desc = (or label (read-string "Description: " (car playlist))) 1377 | for url in choosen 1378 | for disp = (if (> (length desc) 0) (format "%s - %s" desc url) url) 1379 | do (emms-add-url (propertize url 'display disp)))) 1380 | (setq path (expand-file-name path)) 1381 | (cond ((file-directory-p path) 1382 | (emms-add-directory path)) 1383 | ((file-regular-p path) 1384 | (emms-add-file path)) 1385 | (t (user-error "Unkown source: %s" path))))) 1386 | 1387 | 1388 | ;;; Integrate with Org Link 1389 | 1390 | (defvar mpvi-org-link-map 1391 | (let ((map (make-sparse-keymap))) 1392 | (define-key map (kbd ", s") #'mpvi-current-link-seek) 1393 | (define-key map (kbd ", a") #'mpvi-insert) 1394 | (define-key map (kbd ", b") #'mpvi-current-link-update-end-pos) 1395 | (define-key map (kbd ", v") #'mpvi-current-link-show-preview) 1396 | (define-key map (kbd ", c") #'mpvi-clip) 1397 | (define-key map (kbd ", ,") #'org-open-at-point) 1398 | (define-key map (kbd ", SPC") #'mpvi-pause) 1399 | (define-key map (kbd ", h") #'mpvi-current-link-short-help) 1400 | map)) 1401 | 1402 | (defvar mpvi-org-link-face '(:inherit org-link :underline nil :box (:style flat-button))) 1403 | 1404 | (defun mpvi-org-link-push (link) 1405 | "Play the mpv LINK." 1406 | (pcase-let ((`(,path ,beg ,end) (mpvi-parse-link link))) 1407 | (mpvi-play path beg end))) 1408 | 1409 | (defcustom mpvi-org-https-link-rules nil 1410 | "Rules to check if current https link should be opened with MPV. 1411 | One rule is a regexp string to check against link url." 1412 | :type '(repeat string)) 1413 | 1414 | (defun mpvi-org-https-link-push (url arg) 1415 | "Play the normal https URL with MPV if it matches any of the rules. 1416 | ARG is the argument." 1417 | (if (cl-find-if (lambda (r) (string-match-p r url)) mpvi-org-https-link-rules) 1418 | (mpvi-open (concat "https:" url)) 1419 | (browse-url (concat "https:" url) arg))) 1420 | 1421 | (defun mpvi-current-link-seek () 1422 | "Seek position for this link." 1423 | (interactive nil org-mode) 1424 | (mpvi-with-current-mpv-link (node) 1425 | (when node (mpvi-seek)))) 1426 | 1427 | (defun mpvi-current-link-update-end-pos () 1428 | "Update the end position on this link." 1429 | (interactive nil org-mode) 1430 | (mpvi-with-current-mpv-link (node) 1431 | (when node 1432 | (let ((ret (mpvi-seek (or (plist-get node :vend) 1433 | (max (plist-get node :vbeg) (mpvi-prop 'playback-time))) 1434 | (format "Set end position (%d-%d): " (plist-get node :vbeg) (mpvi-prop 'duration))))) 1435 | (delete-region (plist-get node :begin) (plist-get node :end)) 1436 | (let ((link (funcall mpvi-build-link-function (plist-get node :path) 1437 | (plist-get node :vbeg) (car ret)))) 1438 | (save-excursion (insert link))))))) 1439 | 1440 | (defun mpvi-current-link-show-preview () 1441 | "Show the preview tooltip for this link." 1442 | (interactive nil org-mode) 1443 | (when-let* ((node (mpvi-parse-link-at-point))) 1444 | (let* ((scr (funcall mpvi-screenshot-function (plist-get node :path) (plist-get node :vbeg))) 1445 | (img (create-image scr nil nil :width 400)) 1446 | (help (propertize " " 'display img)) 1447 | (x-gtk-use-system-tooltips nil)) 1448 | (tooltip-show help)))) 1449 | 1450 | (defun mpvi-current-link-short-help () 1451 | "Command tips for current link." 1452 | (interactive nil org-mode) 1453 | (let ((tips '((",s" . "Seek") 1454 | (",a" . "StampStart") 1455 | (",b" . "StampEnd") 1456 | (",c" . "Clip") 1457 | (",v" . "Preview") 1458 | (",," . "Play") 1459 | (",SPC" . "Pause")))) 1460 | (message (mapconcat (lambda (tip) 1461 | (concat (propertize (car tip) 'face 'font-lock-keyword-face) 1462 | "/" (cdr tip))) 1463 | tips " ")))) 1464 | 1465 | ;;;###autoload 1466 | (defun mpvi-org-link-init () 1467 | "Setup org link with `mpv' prefix." 1468 | (require 'org) 1469 | (set-keymap-parent mpvi-org-link-map org-mouse-map) 1470 | (org-link-set-parameters "mpv" 1471 | :face mpvi-org-link-face 1472 | :keymap mpvi-org-link-map 1473 | :follow #'mpvi-org-link-push) 1474 | (org-link-set-parameters "https" 1475 | :follow #'mpvi-org-https-link-push)) 1476 | 1477 | ;;;###autoload 1478 | (with-eval-after-load 'org (mpvi-org-link-init)) 1479 | 1480 | 1481 | ;;; Miscellaneous 1482 | 1483 | (require 'mpvi-ps) ; optional platform specialized config 1484 | 1485 | (provide 'mpvi) 1486 | 1487 | ;;; mpvi.el ends here 1488 | --------------------------------------------------------------------------------