Added explicit supported URL schemes matching for all players to reduce protocol abuse, added relevant section to README, added tests for new code

This commit is contained in:
Baldomo
2022-12-30 01:32:08 +01:00
parent e2a3cb1833
commit 410a3e8e6d
15 changed files with 483 additions and 346 deletions

View File

@@ -6,4 +6,10 @@ indent_size = 4
end_of_line = lf end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = false trim_trailing_whitespace = false
insert_final_newline = false insert_final_newline = false
[{Makefile}]
indent_style = tab
[{*.html,*.js,*.json,*.css}]
indent_size = 2

View File

@@ -1,5 +1,6 @@
SRC:=config.go ipc.go options.go $(wildcard cmd/open-in-mpv/*) SRC:=config.go ipc.go options.go $(wildcard cmd/open-in-mpv/*)
EXT_SRC:=$(wildcard extension/Chrome/*) extension/Firefox/manifest.json EXT_SRC:=$(wildcard extension/Chrome/*) extension/Firefox/manifest.json
SCRIPTS_DIR:=scripts
all: build/linux.tar build/mac.tar build/windows.tar build/firefox.zip all: build/linux.tar build/mac.tar build/windows.tar build/firefox.zip
@@ -9,8 +10,8 @@ builddir:
build/linux/open-in-mpv: $(SRC) builddir build/linux/open-in-mpv: $(SRC) builddir
@echo -e "\n# Building for Linux" @echo -e "\n# Building for Linux"
env CGO_ENABLED=0 GOOS=linux GOARCh=amd64 go build -ldflags="-s -w" -o $@ ./cmd/open-in-mpv env CGO_ENABLED=0 GOOS=linux GOARCh=amd64 go build -ldflags="-s -w" -o $@ ./cmd/open-in-mpv
cp scripts/install-protocol.sh $(dir $@) cp $(SCRIPTS_DIR)/install-protocol.sh $(dir $@)
cp scripts/open-in-mpv.desktop $(dir $@) cp $(SCRIPTS_DIR)/open-in-mpv.desktop $(dir $@)
build/linux.tar: build/linux/open-in-mpv build/linux.tar: build/linux/open-in-mpv
tar cf $@ -C $(dir $@)linux $(notdir $(wildcard build/linux/*)) tar cf $@ -C $(dir $@)linux $(notdir $(wildcard build/linux/*))
@@ -22,7 +23,7 @@ build/mac/open-in-mpv.app: $(SRC) scripts/Info.plist builddir
@mkdir -p $@/Contents @mkdir -p $@/Contents
env CGO_ENABLED=0 GOOS=darwin GOARCh=amd64 go build -ldflags="-s -w" -o $@/Contents/MacOS/open-in-mpv ./cmd/open-in-mpv env CGO_ENABLED=0 GOOS=darwin GOARCh=amd64 go build -ldflags="-s -w" -o $@/Contents/MacOS/open-in-mpv ./cmd/open-in-mpv
cp config.yml $@/Contents/MacOS/ cp config.yml $@/Contents/MacOS/
cp scripts/Info.plist $@/Contents cp $(SCRIPTS_DIR)/Info.plist $@/Contents
build/mac.tar: build/mac/open-in-mpv.app build/mac.tar: build/mac/open-in-mpv.app
tar cf $@ -C $(dir $@)/mac open-in-mpv.app tar cf $@ -C $(dir $@)/mac open-in-mpv.app
@@ -30,7 +31,7 @@ build/mac.tar: build/mac/open-in-mpv.app
build/windows/open-in-mpv.exe: $(SRC) builddir build/windows/open-in-mpv.exe: $(SRC) builddir
@echo -e "\n# Building for Windows" @echo -e "\n# Building for Windows"
env CGO_ENABLED=0 GOOS=windows GOARCh=amd64 go build -ldflags="-s -w -H windowsgui" -o $@ ./cmd/open-in-mpv env CGO_ENABLED=0 GOOS=windows GOARCh=amd64 go build -ldflags="-s -w -H windowsgui" -o $@ ./cmd/open-in-mpv
cp scripts/install-protocol.reg $(dir $@) cp $(SCRIPTS_DIR)/install-protocol.reg $(dir $@)
build/windows.tar: build/windows/open-in-mpv.exe build/windows.tar: build/windows/open-in-mpv.exe
tar cf $@ -C $(dir $@)windows $(notdir $(wildcard build/windows/*)) tar cf $@ -C $(dir $@)windows $(notdir $(wildcard build/windows/*))
@@ -45,7 +46,7 @@ install: build/linux/open-in-mpv
cp build/linux/open-in-mpv /usr/bin cp build/linux/open-in-mpv /usr/bin
install-protocol: install-protocol:
scripts/install-protocol.sh $(SCRIPTS_DIR)/install-protocol.sh
uninstall: uninstall:
rm /usr/bin/open-in-mpv rm /usr/bin/open-in-mpv

View File

@@ -9,15 +9,14 @@
This is a simple web extension (for Chrome and Firefox) which helps open any video in the currently open tab in the [mpv player](https://mpv.io). This is a simple web extension (for Chrome and Firefox) which helps open any video in the currently open tab in the [mpv player](https://mpv.io).
The extension itself shares a lot of code with the one from the awesome [iina](https://github.com/iina/iina), while the (bare) backend is written in Go (this is a rewrite from C++). The extension itself shares a lot of code with the one from the awesome [iina](https://github.com/iina/iina), while the (bare) native binary is written in Go (this is a rewrite from C++).
- [Installation](#installation) - [Installation](#installation)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Flag overrides](#flag-overrides)
- [Example](#example)
- [The `mpv://` protocol](#the-mpv-protocol) - [The `mpv://` protocol](#the-mpv-protocol)
- [Playlist and `enqueue` functionality](#playlist-and-enqueue-functionality) - [Playlist and `enqueue` functionality](#playlist-and-enqueue-functionality)
- [Player support](#player-support) - [Player support](#player-support)
- [Supported protocols](#supported-protocols)
### Installation ### Installation
> Compiled binaries and packed extensions can be found in the [releases page](https://github.com/Baldomo/open-in-mpv/releases). > Compiled binaries and packed extensions can be found in the [releases page](https://github.com/Baldomo/open-in-mpv/releases).
@@ -38,31 +37,33 @@ The configuration file has to be named `config.yml` and can be placed in the sam
The configuration file has the following structure: The configuration file has the following structure:
```yaml ```yaml
fake: [ open_in_mpv.Player ] fake: # open_in_mpv.Player
name: [ string ] name: # string
executable: [ string ] executable: # string
fullscreen: [ string ] fullscreen: # string
pip: [ string ] pip: # string
enqueue: [ string ] enqueue: # string
new_window: [ string ] new_window: # string
needs_ipc: [ true | false ] needs_ipc: # true | false
flag_overrides: [ map[string]string ] supported_protocols: # []string
flag_overrides: # map[string]string
``` ```
> See [the default configuration](config.yml) as an example > See [the default configuration](config.yml) or the [example](#example) below
And the `open_in_mpv.Player` object is defined as follows: And the `open_in_mpv.Player` object is defined as follows:
| Key | Example value | Description | | Key | Example value | Description |
|---------------|----------------------------------------|--------------| | --------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `name` | `mpv` | Full name of the video player; not used internally | | `name` | `mpv` | Full name of the video player; not used internally |
| `executable` | `mpv` | The player's binary path/name (doesn't need full path if already in `$PATH`) | | `executable` | `mpv` | The player's binary path/name (doesn't need full path if already in `$PATH`) |
| `fullscreen` | `"--fs"` | Flag override to open the player in fullscreen (can be empty) | | `fullscreen` | `"--fs"` | Flag override to open the player in fullscreen (can be empty) |
| `pip` | `"--pip"` | Flag override to open the player in picture-in-picture mode (can be empty) | | `pip` | `"--pip"` | Flag override to open the player in picture-in-picture mode (can be empty) |
| `enqueue` | `"--enqueue"` | Flag override to add the video to the player's queue (can be empty) | | `enqueue` | `"--enqueue"` | Flag override to add the video to the player's queue (can be empty) |
| `new_window` | `"--new-window"` | Flag override to force open a new player window with the video (can be empty) | | `new_window` | `"--new-window"` | Flag override to force open a new player window with the video (can be empty) |
| `needs_ipc` | `false` | Controls whether the player needs IPC communication (only generates mpv-compatible JSON commands, used for enqueing videos) | | `needs_ipc` | `false` | Controls whether the player needs IPC communication (only generates mpv-compatible JSON commands, used for enqueing videos) |
| `flag_overrides` | `"*": "--mpv-options=%s"` | Defines arbitrary text overrides for command line flags (see below) | | `supported_protocols` | `["http", "https"]` | An arbitrary whitelist of protocols the player supports. See the [relevant section](#supported-protocols) |
| `flag_overrides` | `"*": "--mpv-%s"` | Defines arbitrary text overrides for command line flags (see below) |
#### Flag overrides #### Flag overrides
@@ -98,6 +99,11 @@ players:
enqueue: "--enqueue" enqueue: "--enqueue"
new_window: "" new_window: ""
needs_ipc: true needs_ipc: true
supported_protocols:
- http
- https
- ftp
- ftps
flag_overrides: flag_overrides:
"*": "--mpv-options=%s" "*": "--mpv-options=%s"
``` ```
@@ -105,7 +111,7 @@ players:
### The `mpv://` protocol ### The `mpv://` protocol
`open-in-mpv install-protocol` will create a custom `xdg-open` desktop file with a scheme handler for the `mpv://` protocol. This lets `xdg-open` call `open-in-mpv` with an encoded URI, so that it can be parsed and the information can be relayed to `mpv` - this logic follows how `iina` parses and opens custom URIs with the `iina://` protocol on Mac. `install-protocol.sh` has the same functionality. `open-in-mpv install-protocol` will create a custom `xdg-open` desktop file with a scheme handler for the `mpv://` protocol. This lets `xdg-open` call `open-in-mpv` with an encoded URI, so that it can be parsed and the information can be relayed to `mpv` - this logic follows how `iina` parses and opens custom URIs with the `iina://` protocol on Mac. `install-protocol.sh` has the same functionality.
Please note that this specification is enforced quite strictly, as the program will error out when: Please note that this specification is enforced quite strictly, as the program will error out when at least one of the following conditions is true:
- The protocol is not `mpv://` - The protocol is not `mpv://`
- The method/path is not `/open` - The method/path is not `/open`
@@ -113,14 +119,14 @@ Please note that this specification is enforced quite strictly, as the program w
The table below is a simple documentation of the URL query keys and values used to let the `open-in-mpv` executable what to do. The table below is a simple documentation of the URL query keys and values used to let the `open-in-mpv` executable what to do.
| Key | Example value | Description | | Key | Example value | Description |
|---------------|----------------------------------------|-------------| | ------------- | -------------------------------------- | ------------------------------------------------------------------------------ |
| `url` | `https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ` | The actual file URL to be played, URL-encoded | | `url` | `https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ` | The actual file URL to be played, URL-encoded |
| `full_screen` | `1` | Controls whether the video is played in fullscreen mode | | `full_screen` | `1` | Controls whether the video is played in fullscreen mode |
| `pip` | `1` | Simulates a picture-in-picture mode (only works with mpv for now) | | `pip` | `1` | Simulates a picture-in-picture mode (only works with mpv for now) |
| `enqueue` | `1` | Adds a video to the queue (see below) | | `enqueue` | `1` | Adds a video to the queue (see below) |
| `new_window` | `1` | Forcibly starts a video in a new window even if one is already open | | `new_window` | `1` | Forcibly starts a video in a new window even if one is already open |
| `player` | `celluloid` | Starts any supported video player (see [Player support](#player-support)) | | `player` | `celluloid` | Starts any supported video player (see [Player support](#player-support)) |
| `flags` | `--vo%3Dgpu` | Custom command options and flags to be passed to the video player, URL-encoded | | `flags` | `--vo%3Dgpu` | Custom command options and flags to be passed to the video player, URL-encoded |
### Playlist and `enqueue` functionality ### Playlist and `enqueue` functionality
@@ -131,4 +137,7 @@ input-ipc-server=/tmp/mpvsocket
``` ```
### Player support ### Player support
Supported players are defined in `config.yml`, where the struct `Player` ([see `config.go`](config.go)) defines supported functionality and command line flag overrides. To request support for a player you're welcome to open a new issue or a pull request or just add your own in your configuration file. Supported players are defined in `config.yml`, where the struct `Player` ([see `config.go`](config.go)) defines supported functionality and command line flag overrides. To request support for a player you're welcome to open a new issue or a pull request or just add your own in your configuration file.
### Supported protocols
Since opening an arbitrary URL with a shell command can cause remote code execution on the host machine (for example by loading arbitrary `.so` files on a player by using special [schemes](https://en.wikipedia.org/wiki/List_of_URI_schemes)), only protocols/[schemes](https://en.wikipedia.org/wiki/List_of_URI_schemes) explicitly specified in the configuration will be processed by the native binary without errors. Defaults to `["http", "https"]` if empty. There is also no special instructions parsing or catch-all values.

View File

@@ -28,6 +28,9 @@ type Player struct {
NewWindow string `yaml:"new_window"` NewWindow string `yaml:"new_window"`
// Controls whether this player needs IPC command to enqueue videos // Controls whether this player needs IPC command to enqueue videos
NeedsIpc bool `yaml:"needs_ipc"` NeedsIpc bool `yaml:"needs_ipc"`
// Controls which (video URL) schemes are to be opened by the current
// player. There is no match-all, each protocol has to be manuall specified
SupportedSchemes []string `yaml:"supported_protocols"`
// Overrides for any generic flag // Overrides for any generic flag
FlagOverrides map[string]string `yaml:"flag_overrides"` FlagOverrides map[string]string `yaml:"flag_overrides"`
} }
@@ -37,6 +40,11 @@ type Config struct {
Players map[string]Player Players map[string]Player
} }
var defaultSupportedSchemas = []string{
"http",
"https",
}
var defaultConfig = Config{ var defaultConfig = Config{
Players: map[string]Player{ Players: map[string]Player{
"mpv": { "mpv": {
@@ -52,7 +60,8 @@ var defaultConfig = Config{
}, },
} }
// Tries to load configuration file with fallback // Tries to load configuration file with fallback to a default configuration
// object
func LoadConfig() error { func LoadConfig() error {
confDirs := configdir.New("", "open-in-mpv") confDirs := configdir.New("", "open-in-mpv")
confDirs.LocalPath, _ = filepath.Abs(".") confDirs.LocalPath, _ = filepath.Abs(".")
@@ -68,11 +77,25 @@ func LoadConfig() error {
return err return err
} }
return yaml.Unmarshal(data, &defaultConfig) err = yaml.Unmarshal(data, &defaultConfig)
if err != nil {
return err
}
// If the player has no external configuration, use strict defaults
for name, player := range defaultConfig.Players {
if len(player.SupportedSchemes) == 0 {
log.Printf("Player '%s' has no schemas, setting to defaults", player.Name)
player.SupportedSchemes = defaultSupportedSchemas
defaultConfig.Players[name] = player
}
}
return nil
} }
// Returns player information for the given name if present, otherwise nil // Returns player information for the given name if present, otherwise nil
func GetPlayerInfo(name string) *Player { func GetPlayerConfig(name string) *Player {
lowerName := strings.ToLower(name) lowerName := strings.ToLower(name)
if p, ok := defaultConfig.Players[lowerName]; ok { if p, ok := defaultConfig.Players[lowerName]; ok {
return &p return &p

View File

@@ -1,14 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title> <title>Document</title>
<script src="common.js" type="module"></script> <script src="common.js" type="module"></script>
<script src="background.js" type="module"></script> <script src="background.js" type="module"></script>
</head> </head>
<body> <body>
</body> </body>
</html>
</html>

View File

@@ -3,18 +3,18 @@ import { getOptions, openInMPV, updateBrowserAction } from "./common.js";
updateBrowserAction(); updateBrowserAction();
[["page", "pageUrl"], ["link", "linkUrl"], ["video", "srcUrl"], ["audio", "srcUrl"]].forEach(([item, linkType]) => { [["page", "pageUrl"], ["link", "linkUrl"], ["video", "srcUrl"], ["audio", "srcUrl"]].forEach(([item, linkType]) => {
chrome.contextMenus.create({ chrome.contextMenus.create({
title: `Open this ${item} in mpv`, title: `Open this ${item} in mpv`,
id: `open${item}inmpv`, id: `open${item}inmpv`,
contexts: [item], contexts: [item],
onclick: (info, tab) => { onclick: (info, tab) => {
getOptions((options) => { getOptions((options) => {
console.log("Got options: ", options); console.log("Got options: ", options);
openInMPV(tab.id, info[linkType], { openInMPV(tab.id, info[linkType], {
mode: options.iconActionOption, mode: options.iconActionOption,
...options, ...options,
}); });
}); });
}, },
}); });
}); });

View File

@@ -1,130 +1,135 @@
class Option { class Option {
constructor(name, type, defaultValue) { constructor(name, type, defaultValue) {
this.name = name; this.name = name
this.type = type; this.type = type
this.defaultValue = defaultValue; this.defaultValue = defaultValue
} }
setValue(value) { setValue(value) {
switch (this.type) { switch (this.type) {
case "radio": case "radio":
Array.prototype.forEach.call(document.getElementsByName(this.name), (el) => { Array.prototype.forEach.call(document.getElementsByName(this.name), (el) => {
el.checked = el.value === value; el.checked = el.value === value
}); })
break; break
case "checkbox": case "checkbox":
document.getElementsByName(this.name).forEach(el => el.checked = value); document.getElementsByName(this.name).forEach(el => el.checked = value)
break; break
case "select": case "select":
document.getElementsByName(this.name).forEach(el => el.value = value); document.getElementsByName(this.name).forEach(el => el.value = value)
break; break
case "text": case "text":
document.getElementsByName(this.name).forEach(el => el.value = value); document.getElementsByName(this.name).forEach(el => el.value = value)
break; break
}
} }
}
getValue() { getValue() {
switch (this.type) { switch (this.type) {
case "radio": case "radio":
return document.querySelector(`input[name="${this.name}"]:checked`).value; return document.querySelector(`input[name="${this.name}"]:checked`).value
case "checkbox": case "checkbox":
return document.querySelector(`input[name="${this.name}"]`).checked; return document.querySelector(`input[name="${this.name}"]`).checked
case "select": case "select":
return document.querySelector(`select[name="${this.name}"]`).value; return document.querySelector(`select[name="${this.name}"]`).value
case "text": case "text":
return document.querySelector(`input[name="${this.name}"]`).value; return document.querySelector(`input[name="${this.name}"]`).value
}
} }
}
} }
const _options = [ const _options = [
new Option("iconAction", "radio", "clickOnly"), new Option("iconAction", "radio", "clickOnly"),
new Option("iconActionOption", "radio", "direct"), new Option("iconActionOption", "radio", "direct"),
new Option("mpvPlayer", "select", "mpv"), new Option("mpvPlayer", "select", "mpv"),
new Option("useCustomFlags", "checkbox", false), new Option("useCustomFlags", "checkbox", false),
new Option("customFlags", "text", "") new Option("customFlags", "text", "")
]; ]
export function getOptions(callback) { export function getOptions(callback) {
const getDict = {}; const getDict = {}
_options.forEach((item) => { _options.forEach(item => {
getDict[item.name] = item.defaultValue; getDict[item.name] = item.defaultValue
}) })
chrome.storage.sync.get(getDict, callback); chrome.storage.sync.get(getDict, callback)
} }
export function saveOptions() { export function saveOptions() {
const saveDict = {}; const saveDict = {}
_options.forEach((item) => { _options.forEach(item => {
saveDict[item.name] = item.getValue(); saveDict[item.name] = item.getValue()
}) })
chrome.storage.sync.set(saveDict); chrome.storage.sync.set(saveDict)
} }
export function restoreOptions() { export function restoreOptions() {
getOptions((items) => { getOptions((items) => {
_options.forEach((option) => { _options.forEach(option => {
option.setValue(items[option.name]); option.setValue(items[option.name])
}); })
}); })
} }
export function openInMPV(tabId, url, options = {}) { export function openInMPV(tabId, url, options = {}) {
const baseURL = `mpv:///open?`; const baseURL = `mpv:///open?`
// Encode video URL // Encode video URL
const params = [`url=${encodeURIComponent(url)}`]; const params = [`url=${encodeURIComponent(url)}`]
// Add playback options // Add playback options
switch (options.mode) { switch (options.mode) {
case "fullScreen": case "fullScreen":
params.push("full_screen=1"); break; params.push("full_screen=1"); break
case "pip": case "pip":
params.push("pip=1"); break; params.push("pip=1"); break
case "enqueue": case "enqueue":
params.push("enqueue=1"); break; params.push("enqueue=1"); break
} }
// Add new window option // Add new window option
if (options.newWindow) { if (options.newWindow) {
params.push("new_window=1"); params.push("new_window=1")
} }
// Add alternative player and user-defined custom flags // Add alternative player and user-defined custom flags
params.push(`player=${options.mpvPlayer}`); params.push(`player=${options.mpvPlayer}`)
if (options.useCustomFlags && options.customFlags !== "") if (options.useCustomFlags && options.customFlags !== "")
params.push(`flags=${encodeURIComponent(options.customFlags)}`); params.push(`flags=${encodeURIComponent(options.customFlags)}`)
const code = ` const code = `
var link = document.createElement('a'); var link = document.createElement('a')
link.href='${baseURL}${params.join("&")}'; link.href='${baseURL}${params.join("&")}'
document.body.appendChild(link); document.body.appendChild(link)
link.click(); link.click()`
`; console.log(code)
console.log(code); chrome.tabs.executeScript(tabId, { code })
chrome.tabs.executeScript(tabId, { code });
} }
export function updateBrowserAction() { export function updateBrowserAction() {
getOptions((options) => { getOptions(options => {
if (options.iconAction === "clickOnly") { if (options.iconAction === "clickOnly") {
chrome.browserAction.setPopup({ popup: "" }); chrome.browserAction.setPopup({ popup: "" })
chrome.browserAction.onClicked.addListener(() => { chrome.browserAction.onClicked.addListener(() => {
// get active window // Get active tab
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => {
if (tabs.length === 0) { return; } if (tabs.length === 0)
// TODO: filter url return
const tab = tabs[0];
if (tab.id === chrome.tabs.TAB_ID_NONE) { return; } // TODO: filter url
openInMPV(tab.id, tab.url, { const tab = tabs[0]
mode: options.iconActionOption, if (tab.id === chrome.tabs.TAB_ID_NONE)
...options, return
});
}); openInMPV(tab.id, tab.url, {
}); mode: options.iconActionOption,
} else { ...options,
chrome.browserAction.setPopup({ popup: "popup.html" }); })
} })
}); })
return
}
chrome.browserAction.setPopup({ popup: "popup.html" })
})
} }

View File

@@ -1,68 +1,68 @@
body { body {
font-family: 'Inter', Arial, sans-serif; font-family: 'Inter', Arial, sans-serif;
font-size: 14px; font-size: 14px;
line-height: 1.4; line-height: 1.4;
} }
.container { .container {
width: 90%; width: 90%;
max-width: 880px; max-width: 880px;
margin: 1rem auto; margin: 1rem auto;
background: #f4f4f4; background: #f4f4f4;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 0 16px rgba(0,0,0,.2); box-shadow: 0 0 16px rgba(0, 0, 0, .2);
} }
.title { .title {
padding: 1rem; padding: 1rem;
margin: 0; margin: 0;
} }
.item:not(:last-child) { .item:not(:last-child) {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.option { .option {
padding: 1rem; padding: 1rem;
border-top: 1px solid #e4e4e4; border-top: 1px solid #e4e4e4;
} }
.option:not(:last-child) { .option:not(:last-child) {
border-bottom: 1px solid #e4e4e4; border-bottom: 1px solid #e4e4e4;
} }
.option .option-title { .option .option-title {
font-weight: bold; font-weight: bold;
} }
.option .option-details { .option .option-details {
padding-left: 0.5rem; padding-left: 0.5rem;
} }
.option .option-details:not(:first-child) { .option .option-details:not(:first-child) {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.option .option-details:not(:last-child) { .option .option-details:not(:last-child) {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.option-details > p { .option-details>p {
color: rgb(70, 70, 70); color: rgb(70, 70, 70);
margin-block: 0; margin-block: 0;
} }
input[type="text"] { input[type="text"] {
font-family: monospace; font-family: monospace;
border: 1px solid rgb(169, 169, 169); border: 1px solid rgb(169, 169, 169);
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
padding: 0.2rem; padding: 0.2rem;
border-radius: 6px; border-radius: 6px;
} }
select { select {
margin: 0.1rem; margin: 0.1rem;
padding: 0.2rem; padding: 0.2rem;
border-radius: 6px; border-radius: 6px;
background-color: #fff; background-color: #fff;
} }

View File

@@ -1,61 +1,66 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Options</title> <title>Options</title>
<script src="common.js" type="module"></script> <script src="common.js" type="module"></script>
<link rel="stylesheet" href="options.css"> <link rel="stylesheet" href="options.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h2 class="title">Player options</h2> <h2 class="title">Player options</h2>
<div class="option"> <div class="option">
<label class="option-title"> Select the mpv-based player to use</label> <label class="option-title"> Select the mpv-based player to use</label>
<br> <br>
<select name="mpvPlayer"> <select name="mpvPlayer">
<option value="mpv">mpv</option> <option value="mpv">mpv</option>
<option value="celluloid">Celluloid</option> <option value="celluloid">Celluloid</option>
</select> </select>
</div>
<div class="option">
<label class="option-title"><input type="checkbox" name="useCustomFlags"> Use custom command line flags</label>
<div class="option-details" id="customFlagsContainer">
<input type="text" name="customFlags">
<p> Note: do include hyphens (e.g.<span style="font-family: monospace;">'--fs'</span>)</p>
</div>
</div>
</div> </div>
<div class="container"> <div class="option">
<h2 class="title">When clicking on the extension icon</h2> <label class="option-title"><input type="checkbox" name="useCustomFlags"> Use custom command line flags</label>
<div class="option"> <div class="option-details" id="customFlagsContainer">
<div class="item"> <input type="text" name="customFlags">
<label><input type="radio" name="iconAction" value="clickOnly"> Open current page in mpv</label> <p> Note: do include hyphens (e.g.<span style="font-family: monospace;">'--fs'</span>)</p>
</div> </div>
<div class="item">
<label><input type="radio" name="iconAction" value="menu"> Show a menu to select action</label>
</div>
</div>
</div> </div>
<div class="container"> </div>
<h2 class="title">Default open action</h2> <div class="container">
<div class="option"> <h2 class="title">When clicking on the extension icon</h2>
<div class="option-details"><p>Applies to "Open current page in mpv" and the right click context menu</p></div> <div class="option">
<div class="item"> <div class="item">
<label><input type="radio" name="iconActionOption" value="direct"> Open directly</label> <label><input type="radio" name="iconAction" value="clickOnly"> Open current page in mpv</label>
</div> </div>
<div class="item"> <div class="item">
<label><input type="radio" name="iconActionOption" value="fullScreen"> Enter full screen</label> <label><input type="radio" name="iconAction" value="menu"> Show a menu to select action</label>
</div> </div>
<div class="item">
<label><input type="radio" name="iconActionOption" value="pip"> Enter Picture-in-Picture</label>
</div>
<div class="item">
<label><input type="radio" name="iconActionOption" value="enqueue"> Add to queue</label>
</div>
</div>
</div> </div>
<script src="options.js" type="module"></script> </div>
<div class="container">
<h2 class="title">Default open action</h2>
<div class="option">
<div class="option-details">
<p>Applies to "Open current page in mpv" and the right click context menu</p>
</div>
<div class="item">
<label><input type="radio" name="iconActionOption" value="direct"> Open directly</label>
</div>
<div class="item">
<label><input type="radio" name="iconActionOption" value="fullScreen"> Enter full screen</label>
</div>
<div class="item">
<label><input type="radio" name="iconActionOption" value="pip"> Enter Picture-in-Picture</label>
</div>
<div class="item">
<label><input type="radio" name="iconActionOption" value="enqueue"> Add to queue</label>
</div>
</div>
</div>
<script src="options.js" type="module"></script>
</body> </body>
</html>
</html>

View File

@@ -1,13 +1,11 @@
import { restoreOptions, saveOptions, updateBrowserAction } from "./common.js"; import { restoreOptions, saveOptions, updateBrowserAction } from "./common.js"
function listener(el) { const addListener = el => el.addEventListener("change", () => {
el.addEventListener("change", () => { saveOptions()
saveOptions(); updateBrowserAction()
updateBrowserAction(); })
});
}
document.addEventListener("DOMContentLoaded", restoreOptions); document.addEventListener("DOMContentLoaded", restoreOptions)
Array.prototype.forEach.call(document.getElementsByTagName("input"), listener); Array.prototype.forEach.call(document.getElementsByTagName("input"), addListener)
Array.prototype.forEach.call(document.getElementsByTagName("select"), listener); Array.prototype.forEach.call(document.getElementsByTagName("select"), addListener)

View File

@@ -1,34 +1,39 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title> <title>Document</title>
<style> <style>
.menu { .menu {
font-family: sans-serif; font-family: sans-serif;
white-space: nowrap; white-space: nowrap;
} }
.menu .menu-item {
padding: 0.5rem; .menu .menu-item {
transition: all 0.1s ease-out; padding: 0.5rem;
cursor: pointer; transition: all 0.1s ease-out;
border-radius: 6px; cursor: pointer;
} border-radius: 6px;
.menu .menu-item:hover { }
background: #e4e4e4;
} .menu .menu-item:hover {
</style> background: #e4e4e4;
}
</style>
</head> </head>
<body> <body>
<div class="menu"> <div class="menu">
<div class="menu-item" id="open-normal">Open in mpv</div> <div class="menu-item" id="open-normal">Open in mpv</div>
<div class="menu-item" id="open-fullScreen">Open in mpv and enter full screen</div> <div class="menu-item" id="open-fullScreen">Open in mpv and enter full screen</div>
<div class="menu-item" id="open-pip">Open in mpv and enter Picture-in-Picture</div> <div class="menu-item" id="open-pip">Open in mpv and enter Picture-in-Picture</div>
<div class="menu-item" id="open-newWindow">Open in a new mpv window</div> <div class="menu-item" id="open-newWindow">Open in a new mpv window</div>
<div class="menu-item" id="open-enqueue">Add to queue (playlist)</div> <div class="menu-item" id="open-enqueue">Add to queue (playlist)</div>
</div> </div>
<script src="./popup.js" type="module"></script> <script src="./popup.js" type="module"></script>
</body> </body>
</html>
</html>

View File

@@ -1,20 +1,24 @@
import { openInMPV, getOptions } from "./common.js"; import { openInMPV, getOptions } from "./common.js"
Array.prototype.forEach.call(document.getElementsByClassName("menu-item"), (item) => { Array.prototype.forEach.call(document.getElementsByClassName("menu-item"), item => {
const mode = item.id.split("-")[1]; const mode = item.id.split("-")[1]
item.addEventListener("click", () => { item.addEventListener("click", () => {
getOptions((options) => { getOptions(options => {
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => {
if (tabs.length === 0) { return; } if (tabs.length === 0)
const tab = tabs[0]; return
if (tab.id === chrome.tabs.TAB_ID_NONE) { return; }
console.log(mode) const tab = tabs[0]
openInMPV(tab.id, tab.url, { if (tab.id === chrome.tabs.TAB_ID_NONE)
mode, return
newWindow: mode === "newWindow",
...options, console.log(mode)
}); openInMPV(tab.id, tab.url, {
}); mode,
}); newWindow: mode === "newWindow",
}); ...options,
}); })
})
})
})
})

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/Baldomo/open-in-mpv module github.com/Baldomo/open-in-mpv
go 1.15 go 1.18
require ( require (
github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0 github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0

View File

@@ -11,14 +11,14 @@ import (
// URL and acts as a command generator (both CLI and IPC) to spawn and // URL and acts as a command generator (both CLI and IPC) to spawn and
// communicate with an mpv player window. // communicate with an mpv player window.
type Options struct { type Options struct {
Flags string
Player string
Url string
Enqueue bool Enqueue bool
Flags string
Fullscreen bool Fullscreen bool
NeedsIpc bool
NewWindow bool NewWindow bool
Pip bool Pip bool
NeedsIpc bool Player string
Url *url.URL
} }
// Utility object to marshal an mpv-compatible JSON command. As defined in the // Utility object to marshal an mpv-compatible JSON command. As defined in the
@@ -30,14 +30,14 @@ type enqueueCmd struct {
// Default constructor for an Option object // Default constructor for an Option object
func NewOptions() Options { func NewOptions() Options {
return Options{ return Options{
Flags: "",
Player: "mpv",
Url: "",
Enqueue: false, Enqueue: false,
Flags: "",
Fullscreen: false, Fullscreen: false,
NeedsIpc: false,
NewWindow: false, NewWindow: false,
Pip: false, Pip: false,
NeedsIpc: false, Player: "mpv",
Url: nil,
} }
} }
@@ -60,29 +60,46 @@ func (o *Options) Parse(uri string) error {
return fmt.Errorf("Empty or malformed query: %s", u.RawQuery) return fmt.Errorf("Empty or malformed query: %s", u.RawQuery)
} }
playerConfig := GetPlayerConfig(o.Player)
if playerConfig == nil {
return fmt.Errorf("Unsupported player: %s", o.Player)
}
// Extract player command line flags
o.Flags, err = url.QueryUnescape(u.Query().Get("flags")) o.Flags, err = url.QueryUnescape(u.Query().Get("flags"))
if err != nil { if err != nil {
return err return err
} }
o.Url, err = url.QueryUnescape(u.Query().Get("url"))
// Extract video file URL
rawUrl, err := url.QueryUnescape(u.Query().Get("url"))
if err != nil { if err != nil {
return err return err
} }
// Parse the unprocessed URL
o.Url, err = url.Parse(rawUrl)
if err != nil {
return err
}
// Validate the raw URL scheme against the configured ones
if !stringSliceContains(o.Url.Scheme, playerConfig.SupportedSchemes) {
return fmt.Errorf(
"Unsupported schema for player '%s': %s. Did you forget to add it in the configuration?",
playerConfig.Name,
o.Url.Scheme,
)
}
if p, ok := u.Query()["player"]; ok { if p, ok := u.Query()["player"]; ok {
o.Player = p[0] o.Player = p[0]
} }
if GetPlayerInfo(o.Player) == nil {
return fmt.Errorf("Unsupported player: %s", o.Player)
}
o.Enqueue = u.Query().Get("enqueue") == "1" o.Enqueue = u.Query().Get("enqueue") == "1"
o.Fullscreen = u.Query().Get("fullscreen") == "1" o.Fullscreen = u.Query().Get("fullscreen") == "1"
o.NewWindow = u.Query().Get("new_window") == "1" o.NewWindow = u.Query().Get("new_window") == "1"
o.Pip = u.Query().Get("pip") == "1" o.Pip = u.Query().Get("pip") == "1"
o.NeedsIpc = GetPlayerInfo(o.Player).NeedsIpc o.NeedsIpc = playerConfig.NeedsIpc
return nil return nil
} }
@@ -94,52 +111,66 @@ func (o Options) overrideFlags() string {
star bool star bool
) )
pInfo := GetPlayerInfo(o.Player) playerConfig := GetPlayerConfig(o.Player)
if pInfo == nil { if playerConfig == nil {
return "" return ""
} }
_, star = pInfo.FlagOverrides["*"] // Premature look for star override in configuration
_, star = playerConfig.FlagOverrides["*"]
for _, flag := range strings.Split(o.Flags, " ") { for _, flag := range strings.Split(o.Flags, " ") {
if star { if star {
// Unconditionally replace all flags with the star template
stripped := strings.TrimLeft(flag, "-") stripped := strings.TrimLeft(flag, "-")
replaced := strings.ReplaceAll(pInfo.FlagOverrides["*"], `%s`, stripped) replaced := strings.ReplaceAll(
playerConfig.FlagOverrides["*"],
`%s`,
stripped,
)
ret = append(ret, replaced) ret = append(ret, replaced)
} else { continue
if override, ok := pInfo.FlagOverrides[flag]; ok { }
stripped := strings.TrimLeft(flag, "-")
ret = append(ret, strings.ReplaceAll(override, `%s`, stripped)) // Otherwise, iterate over all templates for the current flag and
} // do the necessary string replacements
if override, ok := playerConfig.FlagOverrides[flag]; ok {
stripped := strings.TrimLeft(flag, "-")
ret = append(ret, strings.ReplaceAll(
override,
`%s`,
stripped,
))
} }
} }
return strings.Join(ret, " ") return strings.Join(ret, " ")
} }
// Builds a CLI command used to invoke the player with the appropriate arguments // Builds a CLI command used to invoke the player with the appropriate
// arguments
func (o Options) GenerateCommand() []string { func (o Options) GenerateCommand() []string {
var ret []string var ret []string
pInfo := GetPlayerInfo(o.Player) playerConfig := GetPlayerConfig(o.Player)
if o.Fullscreen { if o.Fullscreen {
ret = append(ret, pInfo.Fullscreen) ret = append(ret, playerConfig.Fullscreen)
} }
if o.Pip { if o.Pip {
ret = append(ret, pInfo.Pip) ret = append(ret, playerConfig.Pip)
} }
if o.Flags != "" { if o.Flags != "" {
if len(pInfo.FlagOverrides) == 0 { if len(playerConfig.FlagOverrides) == 0 {
ret = append(ret, o.Flags) ret = append(ret, o.Flags)
} else { } else {
ret = append(ret, o.overrideFlags()) ret = append(ret, o.overrideFlags())
} }
} }
ret = append(ret, o.Url) ret = append(ret, o.Url.String())
return ret return ret
} }
@@ -151,7 +182,7 @@ func (o Options) GenerateIPC() ([]byte, error) {
} }
cmd := enqueueCmd{ cmd := enqueueCmd{
[]string{"loadfile", o.Url, "append-play"}, []string{"loadfile", o.Url.String(), "append-play"},
} }
ret, err := json.Marshal(cmd) ret, err := json.Marshal(cmd)
@@ -165,3 +196,14 @@ func (o Options) GenerateIPC() ([]byte, error) {
return ret, nil return ret, nil
} }
// Simple linear search for value in slice of strings
func stringSliceContains(value string, v []string) bool {
for _, elem := range v {
if elem == value {
return true
}
}
return false
}

View File

@@ -1,19 +1,21 @@
package open_in_mpv package open_in_mpv
import ( import (
"net/url"
"strings" "strings"
"testing" "testing"
) )
var fakePlayer = Player{ var fakePlayer = Player{
Name: "FakePlayer", Name: "FakePlayer",
Executable: "fakeplayer", Executable: "fakeplayer",
Fullscreen: "", Fullscreen: "",
Pip: "", Pip: "",
Enqueue: "", Enqueue: "",
NewWindow: "", NewWindow: "",
NeedsIpc: true, NeedsIpc: true,
FlagOverrides: map[string]string{}, SupportedSchemes: []string{"https"},
FlagOverrides: map[string]string{},
} }
func testUrl(query ...string) string { func testUrl(query ...string) string {
@@ -25,7 +27,7 @@ func testUrl(query ...string) string {
func Test_GenerateCommand(t *testing.T) { func Test_GenerateCommand(t *testing.T) {
o := NewOptions() o := NewOptions()
o.Url = "example.com" o.Url, _ = url.Parse("example.com")
o.Flags = "--vo=gpu" o.Flags = "--vo=gpu"
o.Pip = true o.Pip = true
@@ -79,8 +81,42 @@ func Test_overrideFlags_star(t *testing.T) {
} }
func Test_Parse(t *testing.T) { func Test_Parse(t *testing.T) {
fakePlayer.FlagOverrides["*"] = "--bar=%s"
defaultConfig.Players["fakeplayer"] = fakePlayer
o := NewOptions() o := NewOptions()
_ = o.Parse(testUrl("enqueue=1", "pip=1")) err := o.Parse(testUrl("player=fakeplayer", "enqueue=1", "pip=1"))
if err != nil {
t.Fatal(err)
}
fakePlayer.SupportedSchemes = []string{}
defaultConfig.Players["fakeplayer"] = fakePlayer
err = o.Parse(testUrl("player=fakeplayer", "enqueue=1", "pip=1"))
if err == nil {
t.Logf("%#v", defaultConfig.Players["fakeplayer"])
t.Fatal("Err should not be nil")
}
args := o.GenerateCommand() args := o.GenerateCommand()
t.Logf("%s %v", o.Player, args) t.Logf("%s %v", o.Player, args)
} }
func Test_sliceContains(t *testing.T) {
schemas := []string{
"http",
"https",
"ftp",
"ftps",
}
if !stringSliceContains("https", schemas) {
t.Logf("should return true if element (https) is in slice (%v)", schemas)
t.Fail()
}
if stringSliceContains("av", schemas) {
t.Logf("should return false if element (av) is not in slice (%v)", schemas)
t.Fail()
}
}