Skip to content

Commit

Permalink
Added explicit supported URL schemes matching for all players to redu…
Browse files Browse the repository at this point in the history
…ce protocol abuse, added relevant section to README, added tests for new code
Baldomo committed Dec 30, 2022
1 parent e2a3cb1 commit 410a3e8
Showing 15 changed files with 486 additions and 349 deletions.
8 changes: 7 additions & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
@@ -6,4 +6,10 @@ indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
insert_final_newline = false

[{Makefile}]
indent_style = tab

[{*.html,*.js,*.json,*.css}]
indent_size = 2
11 changes: 6 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
SRC:=config.go ipc.go options.go $(wildcard cmd/open-in-mpv/*)
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

@@ -9,8 +10,8 @@ builddir:
build/linux/open-in-mpv: $(SRC) builddir
@echo -e "\n# Building for Linux"
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/open-in-mpv.desktop $(dir $@)
cp $(SCRIPTS_DIR)/install-protocol.sh $(dir $@)
cp $(SCRIPTS_DIR)/open-in-mpv.desktop $(dir $@)

build/linux.tar: build/linux/open-in-mpv
tar cf $@ -C $(dir $@)linux $(notdir $(wildcard build/linux/*))
@@ -22,15 +23,15 @@ build/mac/open-in-mpv.app: $(SRC) scripts/Info.plist builddir
@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
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
tar cf $@ -C $(dir $@)/mac open-in-mpv.app

build/windows/open-in-mpv.exe: $(SRC) builddir
@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
cp scripts/install-protocol.reg $(dir $@)
cp $(SCRIPTS_DIR)/install-protocol.reg $(dir $@)

build/windows.tar: build/windows/open-in-mpv.exe
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

install-protocol:
scripts/install-protocol.sh
$(SCRIPTS_DIR)/install-protocol.sh

uninstall:
rm /usr/bin/open-in-mpv
75 changes: 42 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).

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)
- [Configuration](#configuration)
- [Flag overrides](#flag-overrides)
- [Example](#example)
- [The `mpv://` protocol](#the-mpv-protocol)
- [Playlist and `enqueue` functionality](#playlist-and-enqueue-functionality)
- [Player support](#player-support)
- [Supported protocols](#supported-protocols)

### Installation
> 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:

```yaml
fake: [ open_in_mpv.Player ]
name: [ string ]
executable: [ string ]
fullscreen: [ string ]
pip: [ string ]
enqueue: [ string ]
new_window: [ string ]
needs_ipc: [ true | false ]
flag_overrides: [ map[string]string ]
fake: # open_in_mpv.Player
name: # string
executable: # string
fullscreen: # string
pip: # string
enqueue: # string
new_window: # string
needs_ipc: # true | false
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:

| Key | Example value | Description |
|---------------|----------------------------------------|--------------|
| `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`) |
| `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) |
| `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) |
| `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) |
| Key | Example value | Description |
| --------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `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`) |
| `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) |
| `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) |
| `needs_ipc` | `false` | Controls whether the player needs IPC communication (only generates mpv-compatible JSON commands, used for enqueing videos) |
| `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
@@ -98,29 +99,34 @@ players:
enqueue: "--enqueue"
new_window: ""
needs_ipc: true
supported_protocols:
- http
- https
- ftp
- ftps
flag_overrides:
"*": "--mpv-options=%s"
```

### 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.

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 method/path is not `/open`
- The query is empty

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 |
|---------------|----------------------------------------|-------------|
| `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 |
| `pip` | `1` | Simulates a picture-in-picture mode (only works with mpv for now) |
| `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 |
| `player` | `celluloid` | Starts any supported video player (see [Player support](#player-support)) |
| Key | Example value | Description |
| ------------- | -------------------------------------- | ------------------------------------------------------------------------------ |
| `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 |
| `pip` | `1` | Simulates a picture-in-picture mode (only works with mpv for now) |
| `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 |
| `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 |

### Playlist and `enqueue` functionality
@@ -131,4 +137,7 @@ input-ipc-server=/tmp/mpvsocket
```

### 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.
29 changes: 26 additions & 3 deletions config.go
Original file line number Diff line number Diff line change
@@ -28,6 +28,9 @@ type Player struct {
NewWindow string `yaml:"new_window"`
// Controls whether this player needs IPC command to enqueue videos
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
FlagOverrides map[string]string `yaml:"flag_overrides"`
}
@@ -37,6 +40,11 @@ type Config struct {
Players map[string]Player
}

var defaultSupportedSchemas = []string{
"http",
"https",
}

var defaultConfig = Config{
Players: map[string]Player{
"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 {
confDirs := configdir.New("", "open-in-mpv")
confDirs.LocalPath, _ = filepath.Abs(".")
@@ -68,11 +77,25 @@ func LoadConfig() error {
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
func GetPlayerInfo(name string) *Player {
func GetPlayerConfig(name string) *Player {
lowerName := strings.ToLower(name)
if p, ok := defaultConfig.Players[lowerName]; ok {
return &p
17 changes: 10 additions & 7 deletions extension/Chrome/background.html
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<script src="common.js" type="module"></script>
<script src="background.js" type="module"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<script src="common.js" type="module"></script>
<script src="background.js" type="module"></script>
</head>

<body>

</body>
</html>

</html>
28 changes: 14 additions & 14 deletions extension/Chrome/background.js
Original file line number Diff line number Diff line change
@@ -3,18 +3,18 @@ import { getOptions, openInMPV, updateBrowserAction } from "./common.js";
updateBrowserAction();

[["page", "pageUrl"], ["link", "linkUrl"], ["video", "srcUrl"], ["audio", "srcUrl"]].forEach(([item, linkType]) => {
chrome.contextMenus.create({
title: `Open this ${item} in mpv`,
id: `open${item}inmpv`,
contexts: [item],
onclick: (info, tab) => {
getOptions((options) => {
console.log("Got options: ", options);
openInMPV(tab.id, info[linkType], {
mode: options.iconActionOption,
...options,
});
});
},
});
chrome.contextMenus.create({
title: `Open this ${item} in mpv`,
id: `open${item}inmpv`,
contexts: [item],
onclick: (info, tab) => {
getOptions((options) => {
console.log("Got options: ", options);
openInMPV(tab.id, info[linkType], {
mode: options.iconActionOption,
...options,
});
});
},
});
});
215 changes: 110 additions & 105 deletions extension/Chrome/common.js
Original file line number Diff line number Diff line change
@@ -1,130 +1,135 @@
class Option {
constructor(name, type, defaultValue) {
this.name = name;
this.type = type;
this.defaultValue = defaultValue;
}
constructor(name, type, defaultValue) {
this.name = name
this.type = type
this.defaultValue = defaultValue
}

setValue(value) {
switch (this.type) {
case "radio":
Array.prototype.forEach.call(document.getElementsByName(this.name), (el) => {
el.checked = el.value === value;
});
break;
case "checkbox":
document.getElementsByName(this.name).forEach(el => el.checked = value);
break;
case "select":
document.getElementsByName(this.name).forEach(el => el.value = value);
break;
case "text":
document.getElementsByName(this.name).forEach(el => el.value = value);
break;
}
setValue(value) {
switch (this.type) {
case "radio":
Array.prototype.forEach.call(document.getElementsByName(this.name), (el) => {
el.checked = el.value === value
})
break
case "checkbox":
document.getElementsByName(this.name).forEach(el => el.checked = value)
break
case "select":
document.getElementsByName(this.name).forEach(el => el.value = value)
break
case "text":
document.getElementsByName(this.name).forEach(el => el.value = value)
break
}
}

getValue() {
switch (this.type) {
case "radio":
return document.querySelector(`input[name="${this.name}"]:checked`).value;
case "checkbox":
return document.querySelector(`input[name="${this.name}"]`).checked;
case "select":
return document.querySelector(`select[name="${this.name}"]`).value;
case "text":
return document.querySelector(`input[name="${this.name}"]`).value;
}
getValue() {
switch (this.type) {
case "radio":
return document.querySelector(`input[name="${this.name}"]:checked`).value
case "checkbox":
return document.querySelector(`input[name="${this.name}"]`).checked
case "select":
return document.querySelector(`select[name="${this.name}"]`).value
case "text":
return document.querySelector(`input[name="${this.name}"]`).value
}
}
}

const _options = [
new Option("iconAction", "radio", "clickOnly"),
new Option("iconActionOption", "radio", "direct"),
new Option("mpvPlayer", "select", "mpv"),
new Option("useCustomFlags", "checkbox", false),
new Option("customFlags", "text", "")
];
new Option("iconAction", "radio", "clickOnly"),
new Option("iconActionOption", "radio", "direct"),
new Option("mpvPlayer", "select", "mpv"),
new Option("useCustomFlags", "checkbox", false),
new Option("customFlags", "text", "")
]

export function getOptions(callback) {
const getDict = {};
_options.forEach((item) => {
getDict[item.name] = item.defaultValue;
})
chrome.storage.sync.get(getDict, callback);
const getDict = {}
_options.forEach(item => {
getDict[item.name] = item.defaultValue
})
chrome.storage.sync.get(getDict, callback)
}

export function saveOptions() {
const saveDict = {};
_options.forEach((item) => {
saveDict[item.name] = item.getValue();
})
chrome.storage.sync.set(saveDict);
const saveDict = {}
_options.forEach(item => {
saveDict[item.name] = item.getValue()
})
chrome.storage.sync.set(saveDict)
}

export function restoreOptions() {
getOptions((items) => {
_options.forEach((option) => {
option.setValue(items[option.name]);
});
});
getOptions((items) => {
_options.forEach(option => {
option.setValue(items[option.name])
})
})
}

export function openInMPV(tabId, url, options = {}) {
const baseURL = `mpv:///open?`;

// Encode video URL
const params = [`url=${encodeURIComponent(url)}`];

// Add playback options
switch (options.mode) {
case "fullScreen":
params.push("full_screen=1"); break;
case "pip":
params.push("pip=1"); break;
case "enqueue":
params.push("enqueue=1"); break;
}
const baseURL = `mpv:///open?`

// Add new window option
if (options.newWindow) {
params.push("new_window=1");
}
// Encode video URL
const params = [`url=${encodeURIComponent(url)}`]

// Add playback options
switch (options.mode) {
case "fullScreen":
params.push("full_screen=1"); break
case "pip":
params.push("pip=1"); break
case "enqueue":
params.push("enqueue=1"); break
}

// Add alternative player and user-defined custom flags
params.push(`player=${options.mpvPlayer}`);
if (options.useCustomFlags && options.customFlags !== "")
params.push(`flags=${encodeURIComponent(options.customFlags)}`);

const code = `
var link = document.createElement('a');
link.href='${baseURL}${params.join("&")}';
document.body.appendChild(link);
link.click();
`;
console.log(code);
chrome.tabs.executeScript(tabId, { code });
// Add new window option
if (options.newWindow) {
params.push("new_window=1")
}

// Add alternative player and user-defined custom flags
params.push(`player=${options.mpvPlayer}`)
if (options.useCustomFlags && options.customFlags !== "")
params.push(`flags=${encodeURIComponent(options.customFlags)}`)

const code = `
var link = document.createElement('a')
link.href='${baseURL}${params.join("&")}'
document.body.appendChild(link)
link.click()`
console.log(code)
chrome.tabs.executeScript(tabId, { code })
}

export function updateBrowserAction() {
getOptions((options) => {
if (options.iconAction === "clickOnly") {
chrome.browserAction.setPopup({ popup: "" });
chrome.browserAction.onClicked.addListener(() => {
// get active window
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => {
if (tabs.length === 0) { return; }
// TODO: filter url
const tab = tabs[0];
if (tab.id === chrome.tabs.TAB_ID_NONE) { return; }
openInMPV(tab.id, tab.url, {
mode: options.iconActionOption,
...options,
});
});
});
} else {
chrome.browserAction.setPopup({ popup: "popup.html" });
}
});
getOptions(options => {
if (options.iconAction === "clickOnly") {
chrome.browserAction.setPopup({ popup: "" })
chrome.browserAction.onClicked.addListener(() => {
// Get active tab
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => {
if (tabs.length === 0)
return

// TODO: filter url
const tab = tabs[0]
if (tab.id === chrome.tabs.TAB_ID_NONE)
return

openInMPV(tab.id, tab.url, {
mode: options.iconActionOption,
...options,
})
})
})

return
}

chrome.browserAction.setPopup({ popup: "popup.html" })
})
}
62 changes: 31 additions & 31 deletions extension/Chrome/options.css
Original file line number Diff line number Diff line change
@@ -1,68 +1,68 @@
body {
font-family: 'Inter', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
font-family: 'Inter', Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
}

.container {
width: 90%;
max-width: 880px;
margin: 1rem auto;
background: #f4f4f4;
border-radius: 6px;
box-shadow: 0 0 16px rgba(0,0,0,.2);
width: 90%;
max-width: 880px;
margin: 1rem auto;
background: #f4f4f4;
border-radius: 6px;
box-shadow: 0 0 16px rgba(0, 0, 0, .2);
}

.title {
padding: 1rem;
margin: 0;
padding: 1rem;
margin: 0;
}

.item:not(:last-child) {
margin-bottom: 0.5rem;
margin-bottom: 0.5rem;
}

.option {
padding: 1rem;
border-top: 1px solid #e4e4e4;
padding: 1rem;
border-top: 1px solid #e4e4e4;
}

.option:not(:last-child) {
border-bottom: 1px solid #e4e4e4;
border-bottom: 1px solid #e4e4e4;
}

.option .option-title {
font-weight: bold;
font-weight: bold;
}

.option .option-details {
padding-left: 0.5rem;
padding-left: 0.5rem;
}

.option .option-details:not(:first-child) {
margin-top: 0.5rem;
margin-top: 0.5rem;
}

.option .option-details:not(:last-child) {
margin-bottom: 1rem;
margin-bottom: 1rem;
}

.option-details > p {
color: rgb(70, 70, 70);
margin-block: 0;
.option-details>p {
color: rgb(70, 70, 70);
margin-block: 0;
}

input[type="text"] {
font-family: monospace;
border: 1px solid rgb(169, 169, 169);
margin: 0 0 0.5rem 0;
padding: 0.2rem;
border-radius: 6px;
font-family: monospace;
border: 1px solid rgb(169, 169, 169);
margin: 0 0 0.5rem 0;
padding: 0.2rem;
border-radius: 6px;
}

select {
margin: 0.1rem;
padding: 0.2rem;
border-radius: 6px;
background-color: #fff;
margin: 0.1rem;
padding: 0.2rem;
border-radius: 6px;
background-color: #fff;
}
109 changes: 57 additions & 52 deletions extension/Chrome/options.html
Original file line number Diff line number Diff line change
@@ -1,61 +1,66 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Options</title>
<script src="common.js" type="module"></script>
<link rel="stylesheet" href="options.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Options</title>
<script src="common.js" type="module"></script>
<link rel="stylesheet" href="options.css">
</head>

<body>
<div class="container">
<h2 class="title">Player options</h2>
<div class="option">
<label class="option-title"> Select the mpv-based player to use</label>
<br>
<select name="mpvPlayer">
<option value="mpv">mpv</option>
<option value="celluloid">Celluloid</option>
</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 class="container">
<h2 class="title">Player options</h2>
<div class="option">
<label class="option-title"> Select the mpv-based player to use</label>
<br>
<select name="mpvPlayer">
<option value="mpv">mpv</option>
<option value="celluloid">Celluloid</option>
</select>
</div>
<div class="container">
<h2 class="title">When clicking on the extension icon</h2>
<div class="option">
<div class="item">
<label><input type="radio" name="iconAction" value="clickOnly"> Open current page in mpv</label>
</div>
<div class="item">
<label><input type="radio" name="iconAction" value="menu"> Show a menu to select action</label>
</div>
</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 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>
<div class="container">
<h2 class="title">When clicking on the extension icon</h2>
<div class="option">
<div class="item">
<label><input type="radio" name="iconAction" value="clickOnly"> Open current page in mpv</label>
</div>
<div class="item">
<label><input type="radio" name="iconAction" value="menu"> Show a menu to select action</label>
</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>
</html>

</html>
18 changes: 8 additions & 10 deletions extension/Chrome/options.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { restoreOptions, saveOptions, updateBrowserAction } from "./common.js";
import { restoreOptions, saveOptions, updateBrowserAction } from "./common.js"

function listener(el) {
el.addEventListener("change", () => {
saveOptions();
updateBrowserAction();
});
}
const addListener = el => el.addEventListener("change", () => {
saveOptions()
updateBrowserAction()
})

document.addEventListener("DOMContentLoaded", restoreOptions);
document.addEventListener("DOMContentLoaded", restoreOptions)

Array.prototype.forEach.call(document.getElementsByTagName("input"), listener);
Array.prototype.forEach.call(document.getElementsByTagName("select"), listener);
Array.prototype.forEach.call(document.getElementsByTagName("input"), addListener)
Array.prototype.forEach.call(document.getElementsByTagName("select"), addListener)
61 changes: 33 additions & 28 deletions extension/Chrome/popup.html
Original file line number Diff line number Diff line change
@@ -1,34 +1,39 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
.menu {
font-family: sans-serif;
white-space: nowrap;
}
.menu .menu-item {
padding: 0.5rem;
transition: all 0.1s ease-out;
cursor: pointer;
border-radius: 6px;
}
.menu .menu-item:hover {
background: #e4e4e4;
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
.menu {
font-family: sans-serif;
white-space: nowrap;
}

.menu .menu-item {
padding: 0.5rem;
transition: all 0.1s ease-out;
cursor: pointer;
border-radius: 6px;
}

.menu .menu-item:hover {
background: #e4e4e4;
}
</style>
</head>

<body>
<div class="menu">
<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-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-enqueue">Add to queue (playlist)</div>
</div>
<script src="./popup.js" type="module"></script>
<div class="menu">
<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-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-enqueue">Add to queue (playlist)</div>
</div>
<script src="./popup.js" type="module"></script>
</body>
</html>

</html>
42 changes: 23 additions & 19 deletions extension/Chrome/popup.js
Original file line number Diff line number Diff line change
@@ -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) => {
const mode = item.id.split("-")[1];
item.addEventListener("click", () => {
getOptions((options) => {
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => {
if (tabs.length === 0) { return; }
const tab = tabs[0];
if (tab.id === chrome.tabs.TAB_ID_NONE) { return; }
console.log(mode)
openInMPV(tab.id, tab.url, {
mode,
newWindow: mode === "newWindow",
...options,
});
});
});
});
});
Array.prototype.forEach.call(document.getElementsByClassName("menu-item"), item => {
const mode = item.id.split("-")[1]
item.addEventListener("click", () => {
getOptions(options => {
chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => {
if (tabs.length === 0)
return

const tab = tabs[0]
if (tab.id === chrome.tabs.TAB_ID_NONE)
return

console.log(mode)
openInMPV(tab.id, tab.url, {
mode,
newWindow: mode === "newWindow",
...options,
})
})
})
})
})
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/Baldomo/open-in-mpv

go 1.15
go 1.18

require (
github.com/shibukawa/configdir v0.0.0-20170330084843-e180dbdc8da0
102 changes: 72 additions & 30 deletions options.go
Original file line number Diff line number Diff line change
@@ -11,14 +11,14 @@ import (
// URL and acts as a command generator (both CLI and IPC) to spawn and
// communicate with an mpv player window.
type Options struct {
Flags string
Player string
Url string
Enqueue bool
Flags string
Fullscreen bool
NeedsIpc bool
NewWindow bool
Pip bool
NeedsIpc bool
Player string
Url *url.URL
}

// 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
func NewOptions() Options {
return Options{
Flags: "",
Player: "mpv",
Url: "",
Enqueue: false,
Flags: "",
Fullscreen: false,
NeedsIpc: false,
NewWindow: 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)
}

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"))
if err != nil {
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 {
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 {
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.Fullscreen = u.Query().Get("fullscreen") == "1"
o.NewWindow = u.Query().Get("new_window") == "1"
o.Pip = u.Query().Get("pip") == "1"

o.NeedsIpc = GetPlayerInfo(o.Player).NeedsIpc
o.NeedsIpc = playerConfig.NeedsIpc

return nil
}
@@ -94,52 +111,66 @@ func (o Options) overrideFlags() string {
star bool
)

pInfo := GetPlayerInfo(o.Player)
if pInfo == nil {
playerConfig := GetPlayerConfig(o.Player)
if playerConfig == nil {
return ""
}

_, star = pInfo.FlagOverrides["*"]
// Premature look for star override in configuration
_, star = playerConfig.FlagOverrides["*"]

for _, flag := range strings.Split(o.Flags, " ") {
if star {
// Unconditionally replace all flags with the star template
stripped := strings.TrimLeft(flag, "-")
replaced := strings.ReplaceAll(pInfo.FlagOverrides["*"], `%s`, stripped)
replaced := strings.ReplaceAll(
playerConfig.FlagOverrides["*"],
`%s`,
stripped,
)
ret = append(ret, replaced)
} else {
if override, ok := pInfo.FlagOverrides[flag]; ok {
stripped := strings.TrimLeft(flag, "-")
ret = append(ret, strings.ReplaceAll(override, `%s`, stripped))
}
continue
}

// 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, " ")
}

// 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 {
var ret []string

pInfo := GetPlayerInfo(o.Player)
playerConfig := GetPlayerConfig(o.Player)

if o.Fullscreen {
ret = append(ret, pInfo.Fullscreen)
ret = append(ret, playerConfig.Fullscreen)
}

if o.Pip {
ret = append(ret, pInfo.Pip)
ret = append(ret, playerConfig.Pip)
}

if o.Flags != "" {
if len(pInfo.FlagOverrides) == 0 {
if len(playerConfig.FlagOverrides) == 0 {
ret = append(ret, o.Flags)
} else {
ret = append(ret, o.overrideFlags())
}
}

ret = append(ret, o.Url)
ret = append(ret, o.Url.String())

return ret
}
@@ -151,7 +182,7 @@ func (o Options) GenerateIPC() ([]byte, error) {
}

cmd := enqueueCmd{
[]string{"loadfile", o.Url, "append-play"},
[]string{"loadfile", o.Url.String(), "append-play"},
}

ret, err := json.Marshal(cmd)
@@ -165,3 +196,14 @@ func (o Options) GenerateIPC() ([]byte, error) {

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
}
56 changes: 46 additions & 10 deletions options_test.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
package open_in_mpv

import (
"net/url"
"strings"
"testing"
)

var fakePlayer = Player{
Name: "FakePlayer",
Executable: "fakeplayer",
Fullscreen: "",
Pip: "",
Enqueue: "",
NewWindow: "",
NeedsIpc: true,
FlagOverrides: map[string]string{},
Name: "FakePlayer",
Executable: "fakeplayer",
Fullscreen: "",
Pip: "",
Enqueue: "",
NewWindow: "",
NeedsIpc: true,
SupportedSchemes: []string{"https"},
FlagOverrides: map[string]string{},
}

func testUrl(query ...string) string {
@@ -25,7 +27,7 @@ func testUrl(query ...string) string {

func Test_GenerateCommand(t *testing.T) {
o := NewOptions()
o.Url = "example.com"
o.Url, _ = url.Parse("example.com")
o.Flags = "--vo=gpu"
o.Pip = true

@@ -79,8 +81,42 @@ func Test_overrideFlags_star(t *testing.T) {
}

func Test_Parse(t *testing.T) {
fakePlayer.FlagOverrides["*"] = "--bar=%s"
defaultConfig.Players["fakeplayer"] = fakePlayer

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()
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()
}
}

0 comments on commit 410a3e8

Please sign in to comment.