diff --git a/.config/mpv/input.conf b/.config/mpv/input.conf new file mode 100644 index 0000000..86ff82d --- /dev/null +++ b/.config/mpv/input.conf @@ -0,0 +1,3 @@ +ctrl+b script-binding toggle_mpvDLNA +; script-binding text_mpvDLNA +: script-binding command_mpvDLNA diff --git a/.config/mpv/mpv.conf b/.config/mpv/mpv.conf new file mode 100644 index 0000000..275fa93 --- /dev/null +++ b/.config/mpv/mpv.conf @@ -0,0 +1,3 @@ +profile=gpu-hq +fs + diff --git a/.config/mpv/scripts/mpvDLNA/LICENSE b/.config/mpv/scripts/mpvDLNA/LICENSE new file mode 100644 index 0000000..4503ea9 --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Matthew Woodward + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/.config/mpv/scripts/mpvDLNA/README.md b/.config/mpv/scripts/mpvDLNA/README.md new file mode 100644 index 0000000..7ed4948 --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/README.md @@ -0,0 +1,102 @@ +# mpvDLNA +A plugin to allow mpv to browse and watch content hosted on DLNA servers. Follow the Installation Instructions [here](https://github.com/chachmu/mpvDLNA#installation-instructions) + +## Usage +mpvDLNA has two main methods of interacting with DLNA servers. + +### Menu +Toggling the Menu will automatically scan for DLNA servers on the network if it does not have default servers defined in the config file. Once it has finished scanning it will display a list of servers. The arrow keys can be used to navigate the menu and selecting an entry in the list with the enter key (or right arrow key) will access it. Hitting the left arrow key will move back a folder. Attempting to access an empty folder will not enter the folder, instead it will turn the folder's name red. Accessing a media file will start playback while also adding playlist entries for the previous and next episodes so you can skip forwards and backwards without issues. + +### Text/Command +Command mode opens a psuedo command prompt that allows for a variety of commands. + +Both Command and Text mode support a fairly robust case insensitive autocompletion feature for commands (and certain types of arguments) that can also match the input to any part of the result although it sorts its recommendations by how close to the front of the string it found the input. For example, typing `re` might cause the autocompletion to first recommend "B***re***aking Bad" but tabbing through the suggestions you could find "The Wi***re***" (examples taken from the IMDb Most Popular TV Shows page). + +File Input is essentially an argument that comes at the end of the command that is required to match an entry on the DLNA server through autocompletion or the command won't execute (Since the command has to be executed on an existing entry). Normal arguments will display a hint under the command input specifying what type of argument is expected. Calling `cd` on `..` will move back a folder. + +| Command | Argument | File Input (Y/N) | Explanation | +| :-----: | :---------: | :-------------: | --------------------------------------------- | +| scan | N/A | N | Scan for DLNA servers | +| text | N/A | N | Switch to Text Mode | +| cd | N/A | Y | Access an item (or begin playback) | +| info | N/A | Y | Query the server for metadata | +| ep | Episode # | Y | Find an episode based on episode # ([see more](https://github.com/chachmu/mpvDLNA#more-on-ep)) | +| pep | Episode # | Y | Call ep and begin playback on the file | +| wake | MAC Address | N | Send a wake on lan packet | + + +Text Mode is similar to Command Mode except it is only for navigating the DLNA server. Essentially it is always running the `cd` command. This makes it ideal for quickly browsing through the DLNA server and starting playback on a specified file. + +## Installation Instructions +This script requires an installation of [mpv.io](https://mpv.io) that was built to support javascript and lua. + +1. Download the mpvDLNA folder either by cloning the repository or by downloading a zip of the [latest release](https://github.com/chachmu/mpvDLNA/releases) (make sure the folder is named `mpvDLNA`, the releases tend to add a version number to the end which can cause problems) + +2. Put the mpvDLNA folder in the `/scripts` folder for mpv (`~/.config/mpv/scripts/` for Linux or macOS or `C:/Users/Username/AppData/Roaming/mpv/scripts/` for Windows). + +3. Bind hotkeys (You can set your own but I personally like these keys) by adding these lines to `input.conf` (`~/.config/mpv/input.conf` for Linux or macOS or `C:/Users/Username/AppData/Roaming/mpv/input.conf` for Windows) + * Toggle the Menu: `ctrl+b script-binding toggle_mpvDLNA` + * Toggle Text Input: `; script-binding text_mpvDLNA` + * Toggle Command Input: `: script-binding command_mpvDLNA` + +4. Install [uPnPclient](https://github.com/flyte/upnpclient) by running `pip install upnpclient`. +5. If you intend to use the wake on lan feature you will also need to install [pywakeonlan](https://github.com/remcohaszing/pywakeonlan) by running `pip install wakeonlan` + + +## main.js +This file contains the majority of the code including the gui. It handles storing and managing all of the information but due to limitations with the version of javascript supported by MPV ([MuJS](https://mujs.com)) it passes all DLNA communication through the mpvDLNA.py file. When starting playback of a file the DLNA url of the file is added to the internal MPV playlist and an event is triggered each time a file is loaded that adds the previous and next files to the playlist so you can skip forwards and backwards without issues. + +More documentation on the specifics of how main.js works will be added at a later date. + +## mpvDLNA.py +This python script supports a few simple operations to detect and browse DLNA servers. It is basically a simple wrapper of [flyte](https://github.com/flyte)'s [uPnPclient](https://github.com/flyte/upnpclient) library. This script also supports sending wake on lan packets using the [pywakeonlan](https://github.com/remcohaszing/pywakeonlan) module. + +Supported Commands: +| Command | Explanation | +| ----------- | ----------------------------------------------------------------------------------------------- | +|-l, --list | Takes a timeout in seconds and outputs a list of DLNA Media Servers detected on the network | +|-b, --browse | Takes a DLNA server url and the id of a DLNA element and outputs that element's direct children | +|-i, --info | Takes a DLNA server url and the id of a DLNA element and outputs that element's metadata | +|-w, --wake | Takes a MAC address and attempts to send a wake on lan packet to it | + +## Config File Example +The config file must be named mpvDLNA.conf and be placed in a folder called `/script-settings` in the same directory as the `/script` folder (_NOT INSIDE_). + +``` +# List of server names to automatically add without having to run scan +server_names={Name1}+{Name2} +# List of the server addresses, must correspond with server_names +server_addrs={Address1}+{Address2} + +# List of mac addresses to autocomplete for wake on lan +mac_addresses={MAC_ID1}+{MAC_ID2} + +# List of mac addresses to send a wake on lan packet to on startup +startup_mac_addresses={MAC_ID1}+{MAC_ID2} + +# Font size of menu elements +font_size=35 + +# Font size of metadata description from the `info` command +description_font_size=10 + +# Command to use when calling python +python_version=python3 + +# Length of time to spend searching for DLNA servers (Try increasing this if you are having trouble finding your server, default is 1 second) +timeout = 20 + +# Number of nodes to fetch when making a request to the DLNA server (default is 2000) +count=5000 +``` + +## More on `ep` +Given an absolute episode number (The episodes total position in the series instead of just its place in a season) and a series `ep` will try to scan through the show's various seasons to find the episode that matches. + +This can be rather slow the first time it is called after opening MPV on longer series as mpvDLNA will have to fetch the information about every season preceding the one containing the correct episode and then every episode in that season preceding the episode itself. Successive calls for that series (up to the episode number loaded previously) should be very fast as mpvDLNA will have already stored the information it needs. + +Can potentially find the wrong episode if there are missing seasons or incomplete seasons that it can't pull the final episode number from. + + +# Troubleshooting and Feature Requests +If you have any issues with mpvDLNA or have features you would like to request feel free to [make an issue](https://github.com/chachmu/mpvDLNA/issues/new/choose) or send me an email and I will try to take a look. As a warning I may not respond immediately or be willing to implement every feature but I always welcome feedback! diff --git a/.config/mpv/scripts/mpvDLNA/main.js b/.config/mpv/scripts/mpvDLNA/main.js new file mode 100644 index 0000000..60c73d7 --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/main.js @@ -0,0 +1,1405 @@ +// mpvDLNA 3.3.1 + +"use strict"; + +mp.module_paths.push(mp.get_script_directory() + "/modules.js"); +var Options = require('Options'); +var Ass = require('AssFormat'); +var SelectionMenu = require('SelectionMenu'); +mp.module_paths.pop(); + + +var DLNA_Node = function(name, id) { + this.name = name; + this.id = id; + this.url = null; + this.children = null; + this.info = null; // format is {start: episode#, end: episode#, description: string} + + this.isPlaying = false; + this.type = "node"; +}; + + +var DLNA_Server = function(name, url) { + this.name = name; + this.id = "0" + this.url = url; + this.children = null; + + this.type = "server" +}; + +// Helper function to remove first element and trailing newlines +var removeNL = function(sp) { + for (var i = 0; i < sp.length; i++) { + var s = sp[i] + if (s[s.length - 1] == "\r") { + s = s.slice(0, -1); + } + + if (s[s.length - 1] == "\n") { + s = s.slice(0, -1); + } + + sp[i] = s; + } + + if (!sp[0] || !sp[0].length) { + sp.shift(); + } + + return sp; +}; + + +// Class to browse DLNA servers +var DLNA_Browser = function(options) { + + options = options || {}; + + // -------------------------- + + this.showHelpHint = typeof options.showHelpHint === 'boolean' ? + options.showHelpHint : true; + + this.descriptionSize = options.descriptionFontSize; + if (this.descriptionSize === null) { + this.descriptionSize = options.menuFontSize / 4.5; + } + + this.menu = new SelectionMenu({ // Throws if bindings are illegal. + maxLines: options.maxLines, + menuFontSize: options.menuFontSize, + autoCloseDelay: options.autoCloseDelay, + keyRebindings: options.keyRebindings + }); + this.menu.setMetadata({type:null}); + + this._registerCallbacks(); + + var self = this; + + + // Only use menu text colors while mpv is rendering in GUI mode (non-CLI). + this.menu.setUseTextColors(mp.get_property_bool('vo-configured')); + mp.observe_property('vo-configured', 'bool', function(name, value) { + self.menu.setUseTextColors(value); + }); + + + // Determine how to call python + this.python = null; + var versions = ["python", "python3"]; + + // If the .conf file specifies an option, test it first + if (options.python_version) { + versions.unshift(options.python_version); + } + + // Test each option + for (var i = 0; i < versions.length; i++) { + var result = mp.command_native({ + name: "subprocess", + playback_only: false, + capture_stdout: true, + capture_stderr: true, + args : [versions[i], mp.get_script_directory()+"/mpvDLNA.py", "-v"] + }); + + if (result.status != 0) { + mp.msg.debug("calling python as " + versions[i] + " errored with: " + result.stderr); + } else { + this.python = versions[i]; + break; + } + } + + // None of the options worked, throw an error + if (this.python == null) { + throw new Error("Unable to find a correctly configured python call: \n \ + in the following options: " + versions + + "\n Please add the name of your python install to the .conf file \n \ + using the format: python_version=python \n \ + or run mpv with the --msg-level=mpvDLNA=trace argument to see the errors"); + } + + // How long to spend searching for DLNA servers + this.timeout = options.timeout + + // How many nodes to fetch from the DLNA server when making a request + this.count = options.count + + // list of the parents of the current node. + // The first element represents the server we are currently browsing + // The last element represents the node we are currently on + this.parents = []; + + // List of titles to combine to get the title of the menu + this.titles = []; + + this.current_folder = []; + + // determine if we need to scan for DLNA servers next time the browser opens + this.scan = true; + this.servers = []; + + // handle servers listed in the config file + for (var i = 0; i < options.serverNames.length; i++) { + this.servers.push(new DLNA_Server(options.serverNames[i], options.serverAddrs[i])); + } + if (this.servers.length != 0) { + this.menu.title = "Servers"; + this.current_folder = this.servers; + this.menu.setOptions(this.servers, 0); + + this.scan = false; + } + + // list of wake on lan mac addresses + this.mac_addresses = options.macAddresses; + + // List of nodes added to playlist. Should mirror the MPV internal playlist. + // This is necessary because in certain edge cases if a user were to be playing + // an episode while browsing with the DLNA browser and left the folder that the + // current episodes were in then we wouldn't be able to figure out where the + // now playing indicator should be + this.playlist = []; + this.playingUrl = null; + + + // Typing functionality + this.typing_controls = { + "ESC" : function(self){ self.toggle_typing() }, + "ENTER" : function(self){ self.typing_parse() }, + "LEFT" : function(self){ self.typing_action("left") }, + "RIGHT" : function(self){ self.typing_action("right") }, + "DOWN" : function(self){ self.typing_action("down") }, + "UP" : function(self){ self.typing_action("up") }, + "BS" : function(self){ self.typing_action("backspace") }, + "CTRL+BS" : function(self){ self.typing_action("ctrl+backspace") }, + "DEL" : function(self){ self.typing_action("delete") }, + "SPACE" : function(self){ self.typing_action(" ") }, + "TAB" : function(self){ self.typing_action("tab") } + }; + + this.typing_keys = []; + for (var i = 33; i <= 126; i++) { + this.typing_keys.push(String.fromCharCode(i)); + } + + this.typing_mode = "text"; + + this.typing_active = false; + this.typing_position = 0; + this.typing_text = ""; + this.typing_output = ""; + this.autocomplete = []; + this.selected_auto = {id: 0, full: ""}; + + this.commands = { + "scan" : { func: function(self){ self.menu.showMessage("Scanning"); + self.findDLNAServers(); + self.menu.showMessage("Scan Complete"); }, + args: [], + text: false, + output: false}, + + "cd" : { func: function(self, file) { self.command_cd(file); }, + args: [], + text: true, + output: false}, + + "text" : { func: function(self, file){ self.typing_mode = "text"; + self.typing_text = ""; + self.typing_position = 0; + self.typing_active = true; + self.typing_action(""); }, + args: [], + text: false, + output: false}, + + "info" : { func: function(self, file){ var info = self.command_info(file); + if (info === null) {this.typing_output = "No Information";} + else{ + self.typing_output = "Episode Number: "+info.start; + if (info.start != info.end) {self.typing_output += "-"+info.end;} + self.typing_output += Ass.size(self.descriptionSize, true)+"\nDescription: "+info.description; + }}, + args: [], + text: true, + output: true}, + "ep" : { func: function(self, args, file){ self.command_ep(args, file); }, + args: ["Episode"], + text: true, + output: true}, + + "pep" : { func: function(self, args, file){ var result = self.command_ep(args, file); + if (result) self.select(self.menu.getSelectedItem()) }, + args: ["Episode"], + text: true, + output: true}, + + "wake" : { func: function(self, args){ self.command_wake(args); }, + args: ["Mac Address"], + auto: function(self, index){ return self.mac_addresses; }, + text: false, + output: true}, + }; + this.command_list = Object.keys(this.commands); + + // String key of the current command + this.command = null; + // List of already typed command arguments + this.arguments = []; + // List of unfinished typed command argument + this.typing_argument= ""; + this.result_displayed = true; + + // Send startup MAC Address wake on lan packets + options.startupMacAddresses.forEach(function(addr) { self.command_wake([addr]) }); +}; + + +DLNA_Browser.prototype.findDLNAServers = function() { + this.scan = false; + this.menu.title = "Scanning for DLNA Servers"; + this.menu.renderMenu("", 1); + + + mp.msg.info("scanning for dlna servers"); + this.servers = []; + + // Increase the timeout if you have trouble finding a DLNA server that you know is working + var result = mp.command_native({ + name: "subprocess", + playback_only: false, + capture_stdout: true, + capture_stderr: true, + args : [this.python, mp.get_script_directory()+"/mpvDLNA.py", "-l", this.timeout] + }); + + mp.msg.debug("mpvDLNA.py -l: " + result.stderr); + + // Get the output, delete the first element if empty, and remove trailing newlines + var sp = removeNL(result.stdout.split("\n")); + + for (var i = 0; i < sp.length; i=i+3) { + var server = new DLNA_Server(sp[i], sp[i+1]); + this.servers.push(server); + } + this.menu.title = "Servers"; + this.parents = []; + this.current_folder = this.servers; + this.menu.setOptions(this.servers, 0); + + this.menu.renderMenu("", 1); +}; + + +DLNA_Browser.prototype.toggle = function() { + + // Toggle the menu display state. + if (this.menu.menuActive) { + this.menu.hideMenu(); + } else { + this.menu.showMenu(); + // Determine if we need to scan for DLNA servers + if (this.scan) { + this.menu.title = "Scanning for DLNA Servers"; + this.findDLNAServers(); + } + } +}; + +// starts typing and sets mode to either "text" or "command" +DLNA_Browser.prototype.toggle_typing = function(mode) { + + if (!this.typing_active) { + + // if mode command is invalid just leave it on what it was before + if (mode == "text" || mode == "command") { + this.typing_mode = mode; + } + + var self = this; + Object.keys(this.typing_controls).forEach( function(key) { + mp.add_forced_key_binding(key, "typing_"+key, function(){self.typing_controls[key](self)}, {repeatable:true}) + }); + + this.typing_keys.forEach( function(key) { + mp.add_forced_key_binding(key, "typing_"+key, function(){self.typing_action(key)}, {repeatable:true}) + }); + + this.typing_text = ""; + this.typing_position = 0; + this.typing_active = true; + + this.menu.showTyping(); + this.typing_action(""); + + } else { + this.typing_active=false; + Object.keys(this.typing_controls).forEach( function(key) { + mp.remove_key_binding("typing_"+key); + }); + + this.typing_keys.forEach( function(key) { + mp.remove_key_binding("typing_"+key); + }); + + this.menu.hideTyping(); + } +}; + +DLNA_Browser.prototype.typing_action = function(key) { + var tabbing = false; + + if (key.length == 1){ + // "\" does not play nicely with the formatting characters in the osd message + if (key != "\\") { + this.typing_text = this.typing_text.slice(0, this.typing_position) + + key + this.typing_text.slice(this.typing_position); + this.typing_position+=1; + } + } else if (key == "backspace") { + // can't backspace if at the start of the line + var removed = ""; + if (this.typing_position) { + removed = this.typing_text.slice(this.typing_position-1, this.typing_position); + this.typing_text = this.typing_text.slice(0, this.typing_position-1) + + this.typing_text.slice(this.typing_position); + + this.typing_position-= 1; + } + + if (this.typing_mode == "command" && this.command != null) { + // The backspace effected the command + if (this.typing_position <= this.command.length) { + this.command = null; + this.typing_output = ""; + // The backspace effected an argument + } else if (removed == " "){ + var arg_lengths = 0; + this.arguments.forEach(function(arg){arg_lengths+=arg.length+1}); + + if (this.typing_position <= this.command.length + arg_lengths + 1) { + this.arguments.pop(); + } + } + } + } else if (key == "ctrl+backspace") { + // May change this to delete the relevant filename/argument/command later + // Right now it just clears all text + this.typing_position = 0; + this.typing_text = ""; + if (this.typing_mode == "command") { + this.command = null; + this.arguments = []; + this.typing_argument = ""; + } + } else if (key == "delete") { + this.typing_text = this.typing_text.slice(0, this.typing_position) + + this.typing_text.slice(this.typing_position+1); + + if (this.typing_mode == "command" && this.command != null) { + if (this.typing_position <= this.command.length) { + this.command = null; + + // Because we autoadd a space when completing a command and because + // using delete means the cursor is not next to it, the space becomes + // almost impossible to find. I wrote this code and still thought it + // was a bug when the invisible space character supressed the autocorrect + // Much easier for users to just not have to deal with it + if (this.typing_text[this.typing_text.length-1] == " ") { + this.typing_text = this.typing_text.slice(0, -1); + } + } + } + + } else if (key == "right") { + this.typing_position += 1; + if (this.typing_position > this.typing_text.length) { + this.typing_position = 0; + } + + } else if (key == "left") { + this.typing_position -= 1; + if (this.typing_position < 0) { + this.typing_position = this.typing_text.length; + } + + } else if (key == "tab" || key == "down") { + tabbing = true; + this.selected_auto.id++; + if (this.selected_auto.id >= this.autocomplete.length) { + this.selected_auto.id = 0; + } + + if (this.autocomplete.length) { + this.selected_auto.full = this.autocomplete[this.selected_auto.id].full; + } + } else if (key == "up") { + tabbing = true; + this.selected_auto.id--; + if (this.selected_auto.id < 0) { + this.selected_auto.id = this.autocomplete.length - 1; + } + + if (this.autocomplete.length) { + this.selected_auto.full = this.autocomplete[this.selected_auto.id].full; + } + } else if (key == "clear"){ + this.typing_text = ""; + + this.typing_position = 0; + this.autocomplete = []; + this.selected_auto = {id: 0, full: ""}; + + if (this.result_displayed) { + this.typing_output = ""; + } else { + this.result_displayed = true; + } + } + + var message = ""; + message += Ass.white(true) + this.typing_text.slice(0, this.typing_position); + message += Ass.yellow(true) + "|"; + message += Ass.white(true) + this.typing_text.slice(this.typing_position); + + // Use command mode autocorrect. + if (this.typing_mode == "command") { + + // Look for a valid command + if (this.command == null) { + this.arguments = []; + + // Check if the first piece of the input is a valid command + if (this.typing_text.split(" ").length > 1) { + var search = this.typing_text.split(" ")[0]; + + for (var i = 0; i < this.command_list.length; i++) { + if (this.command_list[i].toUpperCase() == search.toUpperCase()) { + this.command = this.command_list[i]; + break; + } + } + + // Otherwise try to autocomplete the command + } else { + message = this.autocomplete_command(this.typing_text, message, tabbing, this.command_list); + } + } + + // Have a valid command, autocomplete the arguments + if (this.command){ + // Let the user type arguments + if (this.arguments.length < this.commands[this.command].args.length) { + this.arguments = this.typing_text.split(" ").slice(1); + this.typing_argument = this.arguments.pop(); + + if (this.commands[this.command].auto != null) { + var arg_lengths = 0; + this.arguments.forEach(function(arg){arg_lengths+=arg.length+1}); + + var argument = this.typing_text.slice(this.command.length + arg_lengths + 1); + + var index = message.split(" ").slice(0,-1).join(" ").length + 1 + var msg = message.slice(index); + message = message.slice(0, index) + this.autocomplete_command(argument, msg, tabbing, this.commands[this.command].auto(this, this.arguments.length-1)); + } + } + + // Display a hint about what kind of argument to enter + if (this.arguments.length < this.commands[this.command].args.length) { + this.typing_output = "Argument: " + this.commands[this.command].args[this.arguments.length]; + } else if (this.typing_output.split(":")[0] == "Argument"){ + this.typing_output = ""; + } + + // Have all the arguments, autocomplete the file + if (this.arguments.length == this.commands[this.command].args.length && + this.commands[this.command].text){ + var arg_lengths = 0; + this.arguments.forEach(function(arg){arg_lengths+=arg.length+1}); + + var argument = this.typing_text.slice(this.command.length + arg_lengths + 1); + this.typing_argument = ""; + + var index = message.split(" ").slice(0,-1).join(" ").length + 1 + var msg = message.slice(index); + message = message.slice(0, index) + this.autocomplete_text(argument, msg, tabbing); + } + + } + + message = "$ " + message; + + // Use text mode autocorrect. + } else if (this.typing_mode == "text") { + message = this.autocomplete_text(this.typing_text, message, tabbing); + } + + message = Ass.startSeq(true) + message; + message += "\n" + Ass.alpha("00") + this.typing_output; + message += Ass.stopSeq(true); + this.menu.typingText = message; + this.menu._renderActiveText(); +}; + +// Try to find a valid command or folder +DLNA_Browser.prototype.typing_parse = function() { + var success = false; + + // Planned Commands (more to come) + // search - Either query the DLNA server if thats possible or just manually search + // Y cd - exactly the same as what text mode does now + // N play - this has been replaced with just trying to cd into the media file + // Y ep - find episode by number (maybe have option for absolute episode number instead of just its place in a season) + // pep - ep but starts playback + // Y info - query DLNA server for metadata (For some reason my DLNA server only gives metadata for episodes, not seasons\shows) + // Y text - switch to text input mode + + if (this.typing_mode == "command") { + + // This flag is used to make sure we don't accidentally autocomplete the command + // and the arguments in a single enter keystroke + var text_input = false; + + if (this.command == null) { + text_input = true; + if (this.autocomplete.length != 0) { + this.command = this.selected_auto.full + this.typing_text = this.selected_auto.full + " "; + this.typing_position = this.typing_text.length; + + // This variable is true if the command requires more information than just its name + text_input = this.commands[this.command].text || this.commands[this.command].args.length > 0; + } + } + + if (this.command != null && !text_input) { + var cmd = this.commands[this.command]; + + if (cmd.text) { + // We have all the arguments and file text needed for the command + if (cmd.args.length == this.arguments.length && this.autocomplete.length != 0) { + mp.msg.trace("Calling " + this.command + " with args: " + this.arguments); + if (this.arguments.length > 0) { + cmd.func(this, this.arguments, this.selected_auto.full); + } else { + cmd.func(this, this.selected_auto.full); + } + + this.result_displayed = !cmd.output; + success = true; + } + } else { + // Command only needs its name + if (cmd.args.length == 0) { + mp.msg.trace("Calling " + this.command + " with no args"); + cmd.func(this); + this.result_displayed = !cmd.output; + success = true; + + // We already have all but the last argument + } else if (this.arguments.length == cmd.args.length - 1){ + // Autocomplete the last argument + if (this.autocomplete.length != 0) { + this.arguments.push(this.selected_auto.full) + + // Can't autocomplete the last argument, use what the user entered + } else { + this.arguments.push(this.typing_argument) + } + + mp.msg.trace("Calling " + this.command + " with args: " + this.arguments); + cmd.func(this, this.arguments); + this.result_displayed = !cmd.output; + success = true; + + // Not enough arguments, autocomplete the one we are on + } else { + if (this.autocomplete.length != 0) { + + if (this.typing_argument.length != 0) { + this.typing_text = this.typing_text.slice(0, -this.typing_argument.length) + } + + this.typing_text += this.selected_auto.full + " "; + this.typing_position = this.typing_text.length; + } + } + } + } + + } else if (this.typing_mode == "text") { + success = this.command_cd(this.selected_auto.full); + } + + if (success) { + this.command = null; + this.typing_action("clear"); + } else { + // Rescan for autocomplete + this.typing_action(""); + } +}; + +// Works for commands and arguments +DLNA_Browser.prototype.autocomplete_command = function(text, message, tabbing, options) { + // find new autocomplete options only if we are actually typing + if (!tabbing) { + this.autocomplete = []; + + if (this.options === null || (text == "" && this.selected_auto.full=="")) { + this.selected_auto = {id: null, full: ""}; + return message; + } + + for (var i = 0; i < options.length; i++) { + var index = options[i].toUpperCase().indexOf(text.toUpperCase()); + + if (index != -1) { + this.autocomplete.push({ + pre: options[i].slice(0, index), + post: options[i].slice(index + text.length), + full: options[i], + sindex: index, + findex: i + }); + } + } + + // Prefer the search term appearing as soon in the text as possible + this.autocomplete.sort(function(a, b) { + return a.sindex == b.sindex ? a.findex - b.findex : a.sindex-b.sindex; + }); + } + + if (this.autocomplete.length > 0) { + var search = this.selected_auto.full; + // Prefer the earliest option in the list + this.selected_auto = { + pre: this.autocomplete[0].pre, + post: this.autocomplete[0].post, + full: this.autocomplete[0].full, + sindex: this.autocomplete[0].sindex, + findex: this.autocomplete[0].findex, + id: 0 + }; // have to break this out or you get crazy referencing issues + + for (var i = 0; i < this.autocomplete.length; i++) { + if (search == this.autocomplete[i].full) { + this.selected_auto = { + pre: this.autocomplete[i].pre, + post: this.autocomplete[i].post, + full: this.autocomplete[i].full, + sindex: this.autocomplete[i].sindex, + findex: this.autocomplete[i].findex, + id: i + }; // have to break this out or you get crazy referencing issues + break; + } + } + + // Move the actively selected option to the front of the list so entries are + // sorted by how close to the front of the string the search term is + if (!tabbing) { + this.autocomplete = [this.autocomplete[this.selected_auto.id]].concat( + this.autocomplete.slice(0,this.selected_auto.id), + this.autocomplete.slice(this.selected_auto.id+1)); + + this.selected_auto = { + pre: this.autocomplete[0].pre, + post: this.autocomplete[0].post, + full: this.autocomplete[0].full, + sindex: this.autocomplete[0].sindex, + findex: this.autocomplete[0].findex, + id: 0 + }; // have to break this out or you get crazy referencing issues + } + + message = Ass.alpha("DDDD6E") + this.selected_auto.pre + + Ass.alpha("00") + message + Ass.alpha("DDDD6E") + this.selected_auto.post; + } else { + this.selected_auto = {id: 0, full: ""}; + } + + return message; +} + + +DLNA_Browser.prototype.autocomplete_text = function(text, message, tabbing) { + + // find new autocomplete options only if we are actually typing + if (!tabbing) { + this.autocomplete = []; + + // add ".." to the list of autocomplete options + var options = this.current_folder.concat({name: ".."}); + + for (var i = 0; i < options.length; i++) { + var item = options[i]; + var index = item.name.toUpperCase().indexOf(text.toUpperCase()); + + if ((item.children == null || item.children.length != 0) && index != -1) { + this.autocomplete.push({ + pre: item.name.slice(0, index), + post: item.name.slice(index + text.length), + full: item.name, + sindex: index, + findex: i + }); + } + } + + // Prefer the search term appearing as soon in the text as possible + this.autocomplete.sort(function(a, b) { + return a.sindex == b.sindex ? a.findex - b.findex : a.sindex-b.sindex; + }); + } + + if (this.autocomplete.length > 0) { + var search = this.selected_auto.full; + + this.selected_auto = { + pre: this.autocomplete[0].pre, + post: this.autocomplete[0].post, + full: this.autocomplete[0].full, + sindex: this.autocomplete[0].sindex, + findex: this.autocomplete[0].findex, + id: 0 + }; // have to break this out or you get crazy referencing issues + + for (var i = 0; i < this.autocomplete.length; i++) { + if (search == this.autocomplete[i].full) { + this.selected_auto = { + pre: this.autocomplete[i].pre, + post: this.autocomplete[i].post, + full: this.autocomplete[i].full, + sindex: this.autocomplete[i].sindex, + findex: this.autocomplete[i].findex, + id: i + }; // have to break this out or you get crazy referencing issues + + break; + + + } + } + + // Move the actively selected option to the front of the list so entries are + // sorted by how close to the front of the string the search term is + if (!tabbing) { + this.autocomplete = [this.autocomplete[this.selected_auto.id]].concat( + this.autocomplete.slice(0,this.selected_auto.id), + this.autocomplete.slice(this.selected_auto.id+1)); + + this.selected_auto = { + pre: this.autocomplete[0].pre, + post: this.autocomplete[0].post, + full: this.autocomplete[0].full, + sindex: this.autocomplete[0].sindex, + findex: this.autocomplete[0].findex, + id: 0 + }; // have to break this out or you get crazy referencing issues + } + + // Update the menu selection to match + this.menu.selectionIdx = this.selected_auto.findex; + this.menu.renderMenu("", 1); + + message = Ass.alpha("DDDD6E") + this.selected_auto.pre + + Ass.alpha("00") + message + Ass.alpha("DDDD6E") + this.selected_auto.post; + } else { + this.selected_auto = {id: 0, full: ""}; + } + + return message; +} + + +DLNA_Browser.prototype.command_cd = function(text) { + var success = false; + if (text == "..") { + this.back(); + success = true; + }else { + + for (var i = 0; i < this.current_folder.length; i++) { + var item = this.current_folder[i]; + + if (text == item.name) { + success = this.select(item); + } + } + } + + return success +} + +DLNA_Browser.prototype.command_info = function(text) { + if (text == "..") { + return null; + } + + var selection = null; + for (var i = 0; i < this.current_folder.length; i++) { + var item = this.current_folder[i]; + + if (text == item.name) { + selection = item; + } + } + + return this.info(selection); +} + +DLNA_Browser.prototype.command_ep = function(args, text) { + if (text == "..") { + return false; + } + + var selection = null; + for (var i = 0; i < this.current_folder.length; i++) { + var item = this.current_folder[i]; + + if (text == item.name) { + selection = item; + } + } + + if (!selection) { + return false; + } + + var target = parseInt(args[0]); + + var episode = 0; + selection.children = this.getChildren(selection); + for (var i = 0; i < selection.children.length; i++) { + this.typing_output = "Scanning: "+ selection.children[i].name + ", reached E" + episode; + this.result_displayed = false; + this.typing_action(""); + + var info = this.info(selection.children[i]); + if (info === null) { + return false; // can't get enough information to find the episode + } else if (isNaN(info.end)) { + continue; // Maybe need to find a better check for this + } + + if (target <= episode + info.end) { + + // Season based episode# target + var s_target = target - episode; + + selection.children[i].children = this.getChildren(selection.children[i]); + for (var j = 0; j < selection.children[i].children.length; j++) { + this.typing_output = "Scanning: "+ selection.children[i].name + ", reached E" + episode; + this.result_displayed = false; + this.typing_action(""); + episode++; + + var episode_info = this.info(selection.children[i].children[j]); + + if (episode_info === null) { + continue; + } + + // episode contains target episode + if (episode_info.start <= s_target && s_target <= episode_info.end) { + this.select(selection.children[i]); + this.menu.selectionIdx = j; + this.menu.renderMenu("", 1); + + // Make the output look nice + var E = episode_info.start; + if (E < 10) { + E = "0"+E; + } + + if (episode_info.start != episode_info.end) { + if (episode_info.end < 10) { + E += "0"; + } + E +="-E"+episode_info.end; + } + + if (target < 10) { + target = "0"+target; + } + + this.typing_output = selection.name+" E"+target+" = "+selection.children[i].name+" E"+E; + return true; + } + } + + break; + } + + episode += info.end; + } + + if (target < 10) { + target = "0"+target; + } + this.typing_output = Ass.color("FF0000", true) + "Failed to find "+selection.name+" E"+target + return false; +} + +DLNA_Browser.prototype.command_wake = function(args) { + if (args[0] === null) { + this.typing_output = Ass.color("FF0000", true) + "MAC Address cannot be null"; + return; + } + + var result = mp.command_native({ + name: "subprocess", + playback_only: false, + capture_stdout: true, + capture_stderr: true, + args : [this.python, mp.get_script_directory()+"/mpvDLNA.py", "-w", args[0]] + }); + + mp.msg.debug("mpvDLNA.py -w: " + result.stderr); + + // Get the output, delete the first element if empty, and remove trailing newlines + var sp = removeNL(result.stdout.split("\n")); + + if (sp[0] == "packet sent") { + this.typing_output = "Packet Sent"; + } else if (sp[0] == "import failed"){ + this.typing_output = Ass.color("FF0000", true) + "wakeonlan python package not installed"; + } else { + this.typing_output = Ass.color("FF0000", true) + "unspecified error"; + } +} + + + +// This function adds the previous and next episodes to the playlist, +// changes the window title to the current episode title, and +// updates the now playing indicator +DLNA_Browser.prototype.on_file_load = function() { + + // DLNA isn't being used + if (this.playlist.length == 0) { + return; + } + + mp.msg.trace("on_file_load"); + + var p_index = mp.get_property_number("playlist-playing-pos", 1); + var playlist = mp.get_property_native("playlist", {}); + + var episode_number = null + for (var i = 0; i < this.playlist.length; i++) { + if (this.playlist[i].url == playlist[p_index].filename) { + episode_number = i; + break; + } + }; + + if (episode_number === null) { + mp.msg.warn("The DLNA playlist is not properly synced with the internal MPV playlist"); + return + } + + var episode = this.playlist[episode_number]; + var folder = episode.folder.children; // The code below is very confusing if you forget that folder != episode.folder + + + // Update the now playing indicator and rerender the menu if necessary + folder[episode.id].isPlaying = true; + this.menu.renderMenu("", 1); + + // Set the title to match the current episode + mp.msg.trace("setting title to: " + episode.folder.name + ": " + folder[episode.id].name); + mp.set_property("force-media-title", episode.folder.name + ": " + folder[episode.id].name); + this.playingUrl = episode.url; + + + // If there is a previous episode + if (episode.id - 1 >= 0) { + // and the playlist entry before this one either doesn't exist or isn't the previous episode + if (p_index-1 < 0 || playlist[p_index-1].filename != folder[episode.id-1].url) { + var prev = folder[episode.id-1]; + mp.commandv("loadfile", prev.url, "append"); + + // Move the last element in the list (the one we just appended) to in front of the current episode + mp.commandv("playlist-move", playlist.length, p_index); + + this.playlist.push({ folder: episode.folder, + id: episode.id-1, + url: prev.url + }); + mp.msg.trace("Added previous episode to playlist"); + } + } + + // If there is a next episode + if (episode.id + 1 < folder.length) { + // and the playlist entry after this one either doesn't exist or isn't the next episode + if (p_index+1 >= playlist.length || playlist[p_index+1].filename != folder[episode.id+1].url) { + var next = folder[episode.id+1]; + mp.commandv("loadfile", next.url, "append"); + + // Move the last element in the list (the one we just appended) to behind the current episode + mp.commandv("playlist-move", playlist.length, p_index+1); + + this.playlist.push({ folder: episode.folder, + id: episode.id+1, + url: next.url + }); + mp.msg.trace("Added next episode to playlist"); + } + } +}; + +// Removes the now playing indicator +DLNA_Browser.prototype.on_file_end = function() { + // DLNA isn't being used + if (this.playlist.length == 0) { + return; + } + + for (var i = 0; i < this.playlist.length; i++) { + if (this.playlist[i].url == this.playingUrl) { + var episode = this.playlist[i]; + episode.folder.children[episode.id].isPlaying = false; + break; + } + }; + +}; + + +DLNA_Browser.prototype.generateMenuTitle = function() { + this.menu.title=this.titles[this.titles.length-1]; + + // Already have the first element + for (var i = this.titles.length-2; i >= 0; i-- ) { + // Condense repeat menu titles by only using the more specific one + if (this.titles[i+1].indexOf(this.titles[i]) == -1) { + this.menu.title = this.titles[i] + " / " + this.menu.title; + + if (this.menu.title.length > 90) { + this.menu.title = this.menu.title.slice(this.titles[i].length + 3, this.menu.title.length); + break; + } + } + } +}; + +DLNA_Browser.prototype.getChildren = function(selection) { + + // This node has not been loaded before, fetch its children from the server + if (selection.children == null) { + var result = mp.command_native({ + name: "subprocess", + playback_only: false, + capture_stdout: true, + capture_stderr: true, + args : [this.python, mp.get_script_directory()+"/mpvDLNA.py", "-b", this.parents[0].url, selection.id, this.count] + }); + + var categories = result.stdout.split("----") + var children = []; + + // Check for items, then collections + for (var category = 0; category < 2; category++) { + // Get the output, delete the first element if empty, and remove trailing newlines + var sp = removeNL(categories[category].split("\n")); + + // Tells us if we are getting item or container type data + var is_item = sp[0] == "items:"; + var increase = (is_item ? 4 : 3); + var max_length = (is_item ? 1 : 2) + + // The first 2 elements of sp are not useful here + for (var i = 2; i+max_length < sp.length; i=i+increase) { + + var child = new DLNA_Node(sp[i], sp[i+1]); + + if (is_item) { + child.url = sp[i+2]; + } + + children.push(child); + } + } + selection.children = children; + } + + return selection.children +} + +DLNA_Browser.prototype.select = function(selection) { + mp.msg.debug("selecting"); + if (!selection) { + mp.msg.debug("selection was invalid"); + } + + if (selection.type == "server") { + mp.msg.debug("selecting server: " + selection.name); + this.parents = [selection]; + this.titles = []; + } else if (selection.type == "node") { + mp.msg.debug("selecting node: " + selection.name); + if (selection.url === null) { + this.parents.push(selection) + } else { + mp.msg.info("Loading " + selection.name + ": " + selection.url); + mp.commandv("loadfile", selection.url, "replace"); + + // Clear the DLNA playlist of playing indicators and replace it with the new playlist + this.playlist.forEach(function(episode){episode.folder.children[episode.id].isPlaying = false}); + this.playlist = [{folder: this.parents[this.parents.length-1], + id: this.parents[this.parents.length-1].children.indexOf(selection), + url: selection.url + }]; + + this.menu.hideMenu(); + if (this.typing_active) { + this.toggle_typing(); + } + + return true; + } + } else { + // This should never happen + mp.msg.debug("selection type invalid"); + return false; + } + + // This will load the children if they haven't been already + this.parents[this.parents.length-1].children = this.getChildren(selection); + + var success = false; + + // If the selection has no children then don't bother moving to it + if (this.parents[this.parents.length-1].children.length == 0) { + mp.msg.debug("selection was empty"); + this.parents.pop(); + } else { + // Update the title and menu to the new selection + this.titles.push(selection.name); + this.generateMenuTitle(); + mp.msg.trace("generated menu title"); + + this.current_folder = this.parents[this.parents.length-1].children; + mp.msg.trace("set current folder"); + this.menu.setOptions(this.parents[this.parents.length-1].children, 0); + mp.msg.trace("set options"); + success = true; + } + + this.menu.renderMenu("", 1); + return success; +} + +DLNA_Browser.prototype.info = function(selection) { + if (selection === null) { + return null; + } + + // This node has not loaded its info before, fetch its metadata + if (selection.info == null) { + var result = mp.command_native({ + name: "subprocess", + playback_only: false, + capture_stdout: true, + capture_stderr: true, + args : [this.python, mp.get_script_directory()+"/mpvDLNA.py", "-i", this.parents[0].url, selection.id, this.count] + }); + + mp.msg.debug("mpvDLNA.py -i: " + result.stderr); + + // Get the output, delete the first element if empty, and remove trailing newlines + var sp = removeNL(result.stdout.split("\n")); + + var info = {start: 1, end: 1, description: ""} + + // Tells us if we are getting item or container type data + var is_item = sp[0] == "item"; + + if (is_item) { + // The first 2 elements of sp are not useful here, get the episode number + if (sp[2] != "No Episode Number") { + info.start = parseInt(sp[2]); + info.end = info.start; + + // figure out if this is actually multiple episodes + var title_split = selection.name.split(" - "); + if (!/^\d+$/.test(title_split[0])) { + var ep_split = title_split[0].split("-"); + info.end = parseInt(ep_split[1]); + } + } + } else { + + selection.children = this.getChildren(selection); + if (selection.children.length != 0) { + var start_info = this.info(selection.children[0]); + var end_info = this.info(selection.children[selection.children.length-1]) + info.start = start_info.start; + info.end = end_info.end; + } + } + + // Everything else is the description (for some reason my DLNA server doesn't send descriptions for seasons/series, only episodes) + info.description = sp.slice(3).join("\n") + // Sometimes the description gets a little mangled but it seems like the issue is on the DLNA server side + + selection.info = info; + } + + return selection.info; +} + +DLNA_Browser.prototype.back = function() { + this.parents.pop(); + this.titles.pop(); + + if (this.parents.length == 0) { + this.current_folder = this.servers; + this.menu.setOptions(this.servers, 0); + this.menu.title = "Servers"; + } else { + this.current_folder = this.parents[this.parents.length-1].children; + this.menu.setOptions(this.parents[this.parents.length-1].children, 0); + this.generateMenuTitle(this.titles[this.titles.length-1]); + } + + this.menu.renderMenu("", 1); +} + +DLNA_Browser.prototype._registerCallbacks = function() { + + var self = this; // Inside the callbacks this will end up referring to menu instead of DLNA_Browser + + this.menu.setCallbackMenuOpen(function() { + self.select(this.getSelectedItem()); + }); + + + this.menu.setCallbackMenuLeft(function() { + self.back(); + }); + + this.menu.setCallbackMenuRight(function() { + self.select(this.getSelectedItem()); + }); +}; + + + +(function() { + // Read user configuration (uses defaults for any unconfigured options). + // * You can override these values via the configuration system, as follows: + // - Via permanent file: `/script-settings/Blackbox.conf` + // - Command override: `mpv --script-opts=mpvDLNA-server_names="{name1}+{name2}"` + // - Or by editing this file directly (not recommended, makes your updates harder). + var userConfig = new Options.advanced_options({ + // How long to keep the menu open while you are idle. + // * (float/int) Ex: `10` (ten seconds), `0` (to disable autoclose). + auto_close: 0, + // Maximum number of file selection lines to show at a time. + // * (int) Ex: `20` (twenty lines). Cannot be lower than 3. + max_lines: 10, + // What font size to use for the menu text. Large sizes look the best. + // * (int) Ex: `42` (font size fourtytwo). Cannot be lower than 1. + font_size: 40, + description_font_size: 12, + // Whether to show the "[h for help]" hint on the first launch. + // * (bool) Ex: `yes` (enable) or `no` (disable). + help_hint: true, + + server_names: '', + server_addrs: '', + mac_addresses: '', + startup_mac_addresses:'', + python_version: '', + timeout: '1', + count: '2000', + + // Keybindings. You can bind any action to multiple keys simultaneously. + // * (string) Ex: `{up}`, `{up}+{shift+w}` or `{x}+{+}` (binds to "x" and the plus key). + // - Note that all "shift variants" MUST be specified as "shift+". + 'keys_menu_up': '{up}', + 'keys_menu_down': '{down}', + 'keys_menu_up_fast': '{shift+up}', + 'keys_menu_down_fast': '{shift+down}', + 'keys_menu_left': '{left}', + 'keys_menu_right': '{right}', + 'keys_menu_open': '{enter}', + 'keys_menu_undo': '{bs}', + 'keys_menu_help': '{h}', + 'keys_menu_close': '{esc}' + }); + + // Create and initialize the media browser instance. + try { + var browser = new DLNA_Browser({ + autoCloseDelay: userConfig.getValue('auto_close'), + maxLines: userConfig.getValue('max_lines'), + menuFontSize: userConfig.getValue('font_size'), + descriptionFontSize: userConfig.getValue('description_font_size'), + showHelpHint: userConfig.getValue('help_hint'), + serverNames: userConfig.getMultiValue('server_names'), + serverAddrs: userConfig.getMultiValue('server_addrs'), + macAddresses: userConfig.getMultiValue('mac_addresses'), + startupMacAddresses: userConfig.getMultiValue('startup_mac_addresses'), + python_version: userConfig.getValue('python_version'), + timeout: userConfig.getValue('timeout'), + count: userConfig.getValue('count'), + keyRebindings: { + 'Menu-Up': userConfig.getMultiValue('keys_menu_up'), + 'Menu-Down': userConfig.getMultiValue('keys_menu_down'), + 'Menu-Up-Fast': userConfig.getMultiValue('keys_menu_up_fast'), + 'Menu-Down-Fast': userConfig.getMultiValue('keys_menu_down_fast'), + 'Menu-Left': userConfig.getMultiValue('keys_menu_left'), + 'Menu-Right': userConfig.getMultiValue('keys_menu_right'), + 'Menu-Open': userConfig.getMultiValue('keys_menu_open'), + 'Menu-Undo': userConfig.getMultiValue('keys_menu_undo'), + 'Menu-Help': userConfig.getMultiValue('keys_menu_help'), + 'Menu-Close': userConfig.getMultiValue('keys_menu_close') + } + }); + } catch (e) { + mp.msg.error('DLNA: '+e+'.'); + mp.osd_message('DLNA: '+e.stack+'.', 30); + throw e; // Critical init error. Stop script execution. + } + + // Provide the bindable mpv command which opens/cycles through the menu. + // * Bind this via input.conf: `ctrl+b script-binding Blackbox`. + // - To get to your favorites (if you've added some), press this key twice. + mp.add_key_binding(null, 'toggle_mpvDLNA', function() { + browser.toggle(); + }); + + mp.add_key_binding(null, 'text_mpvDLNA', function(){ + browser.toggle_typing("text"); + }) + + mp.add_key_binding(null, 'command_mpvDLNA', function(){ + browser.toggle_typing("command"); + }) + + // Handle necessary changes when loading the next file + // such as adding the next and previous episodes to the playlist + // and updating the window title to match the episode title + mp.register_event("file-loaded", function() { + browser.on_file_load(); + }); + + // Handle necessary changes when ending the current file + // such as marking it as no longer playing + mp.register_event("end-file", function() { + browser.on_file_end(); + }); +})(); diff --git a/.config/mpv/scripts/mpvDLNA/modules.js/AssFormat.js b/.config/mpv/scripts/mpvDLNA/modules.js/AssFormat.js new file mode 100644 index 0000000..19fdca8 --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/modules.js/AssFormat.js @@ -0,0 +1,101 @@ +/* + * ASSFORMAT.JS (MODULE) + * + * Version: 1.2.0 + * Author: VideoPlayerCode + * URL: https://github.com/VideoPlayerCode/mpv-tools + * License: Apache License, Version 2.0 + */ + +/* jshint -W097 */ +/* global mp, module, require */ + +'use strict'; + +var Utils = require('MicroUtils'); + +var Ass = {}; + +Ass._startSeq = mp.get_property_osd('osd-ass-cc/0'); + +Ass._stopSeq = mp.get_property_osd('osd-ass-cc/1'); + +Ass.startSeq = function(output) +{ + return output === false ? '' : Ass._startSeq; +}; + +Ass.stopSeq = function(output) +{ + return output === false ? '' : Ass._stopSeq; +}; + +Ass.esc = function(str, escape) +{ + if (escape === false) // Conveniently disable escaping via the same call. + return str; + // Uses the same technique as mangle_ass() in mpv's osd_libass.c: + // - Treat backslashes as literal by inserting a U+2060 WORD JOINER after + // them so libass can't interpret the next char as an escape sequence. + // - Replace `{` with `\{` to avoid opening an ASS override block. There is + // no need to escape the `}` since it's printed literally when orphaned. + // - See: https://github.com/libass/libass/issues/194#issuecomment-351902555 + return str.replace(/\\/g, '\\\u2060').replace(/\{/g, '\\{'); +}; + +Ass.size = function(fontSize, output) +{ + return output === false ? '' : '{\\fs'+fontSize+'}'; +}; + +Ass.scale = function(scalePercent, output) +{ + return output === false ? '' : '{\\fscx'+scalePercent+'\\fscy'+scalePercent+'}'; +}; + +Ass.convertPercentToHex = function(percent, invertValue) +{ + // Tip: Use with "invertValue" to convert input range 0.0 (invisible) - 1.0 + // (fully visible) to hex range '00' (fully visible) - 'FF' (invisible), for + // use with the alpha() function in a logical manner for end-users. + if (typeof percent !== 'number' || percent < 0 || percent > 1) + throw 'Invalid percentage value (must be 0.0 - 1.0)'; + return Utils.toHex( + Math.floor( // Invert range (optionally), and make into a 0-255 value. + 255 * (invertValue ? 1 - percent : percent) + ), + 2 // Fixed-size: 2 bytes (00-FF), as needed for hex in ASS subtitles. + ); +}; + +Ass.alpha = function(transparencyHex, output) +{ + return output === false ? '' : '{\\alpha&H'+transparencyHex+'&}'; // 00-FF. +}; + +Ass.color = function(rgbHex, output) +{ + return output === false ? '' : '{\\1c&H'+rgbHex.substring(4, 6)+rgbHex.substring(2, 4)+rgbHex.substring(0, 2)+'&}'; +}; + +Ass.white = function(output) +{ + return Ass.color('FFFFFF', output); +}; + +Ass.gray = function(output) +{ + return Ass.color('909090', output); +}; + +Ass.yellow = function(output) +{ + return Ass.color('FFFF90', output); +}; + +Ass.green = function(output) +{ + return Ass.color('90FF90', output); +}; + +module.exports = Ass; diff --git a/.config/mpv/scripts/mpvDLNA/modules.js/MicroUtils.js b/.config/mpv/scripts/mpvDLNA/modules.js/MicroUtils.js new file mode 100644 index 0000000..bd138f4 --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/modules.js/MicroUtils.js @@ -0,0 +1,181 @@ +/* + * MICROUTILS.US (MODULE) + * + * Version: 1.3.0 + * Author: VideoPlayerCode + * URL: https://github.com/VideoPlayerCode/mpv-tools + * License: Apache License, Version 2.0 + */ + +/* jshint -W097 */ +/* global mp, module, require */ + +'use strict'; + +var Utils = {}; + +// NOTE: This is an implementation of a non-recursive quicksort, which doesn't +// risk any stack overflows. This function is necessary because of a MuJS <= +// 1.0.1 bug which causes a stack overflow when running its built-in sort() on +// any large array. See: https://github.com/ccxvii/mujs/issues/55 +// Furthermore, this performs optimized case-insensitive sorting. +Utils.quickSort = function(arr, options) +{ + options = options || {}; + + var i, sortRef, + caseInsensitive = !!options.caseInsensitive; + + if (caseInsensitive) { + sortRef = arr.slice(0); + for (i = sortRef.length - 1; i >= 0; --i) + if (typeof sortRef[i] === 'string') + sortRef[i] = sortRef[i].toLowerCase(); + + return Utils.quickSort_Run(arr, sortRef); + } + + return Utils.quickSort_Run(arr); +}; + +Utils.quickSort_Run = function(arr, sortRef) +{ + if (arr.length <= 1) + return arr; + + var hasSortRef = !!sortRef; + if (!hasSortRef) + sortRef = arr; // Use arr instead. Makes a direct reference (no copy). + + if (arr.length !== sortRef.length) + throw 'Array and sort-reference length must be identical'; + + // Adapted from a great, public-domain C algorithm by Darel Rex Finley. + // Original implementation: http://alienryderflex.com/quicksort/ + // Ported by VideoPlayerCode and extended to sort via a 2nd reference array, + // to allow sorting the main array by _any_ criteria via the 2nd array. + var refPiv, arrPiv, beg = [], end = [], stackMax = -1, stackPtr = 0, L, R; + + beg.push(0); end.push(sortRef.length); + ++stackMax; // Tracks highest available stack index. + while (stackPtr >= 0) { + L = beg[stackPtr]; R = end[stackPtr] - 1; + if (L < R) { + if (hasSortRef) // If we have a SEPARATE sort-ref, mirror actions! + arrPiv = arr[L]; + refPiv = sortRef[L]; // Left-pivot is fastest, no MuJS math needed! + + while (L < R) { + while (sortRef[R] >= refPiv && L < R) R--; + if (L < R) { + if (hasSortRef) + arr[L] = arr[R]; + sortRef[L++] = sortRef[R]; + } + while (sortRef[L] <= refPiv && L < R) L++; + if (L < R) { + if (hasSortRef) + arr[R] = arr[L]; + sortRef[R--] = sortRef[L]; + } + } + + if (hasSortRef) + arr[L] = arrPiv; + sortRef[L] = refPiv; + + if (stackPtr === stackMax) { + beg.push(0); end.push(0); // Grow stacks to fit next elem. + ++stackMax; + } + + beg[stackPtr + 1] = L + 1; + end[stackPtr + 1] = end[stackPtr]; + end[stackPtr++] = L; + } else { + stackPtr--; + // NOTE: No need to shrink stack here. Size-reqs GROW until sorted! + // (Anyway, MuJS is slow at splice() and wastes time if we shrink.) + } + } + + return arr; +}; + +Utils.isInt = function(value) +{ + // Verify that the input is an integer (whole number). + return (typeof value !== 'number' || isNaN(value)) ? + false : + (value | 0) === value; +}; + +Utils._hexSymbols = [ + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' +]; + +Utils.toHex = function(num, outputLength) +{ + // Generates a fixed-length output, and handles negative numbers properly. + var result = ''; + while (outputLength--) { + result = Utils._hexSymbols[num & 0xF] + result; + num >>= 4; + } + return result; +}; + +Utils.shuffle = function(arr) +{ + var m = arr.length, tmp, i; + + while (m) { // While items remain to shuffle... + // Pick a remaining element... + i = Math.floor(Math.random() * m--); + + // And swap it with the current element. + tmp = arr[m]; + arr[m] = arr[i]; + arr[i] = tmp; + } + + return arr; +}; + +Utils.trim = function(str) +{ + return str.replace(/(?:^\s+|\s+$)/g, ''); // Trim left and right whitespace. +}; + +Utils.ltrim = function(str) +{ + return str.replace(/^\s+/, ''); // Trim left whitespace. +}; + +Utils.rtrim = function(str) +{ + return str.replace(/\s+$/, ''); // Trim right whitespace. +}; + +Utils.dump = function(value) +{ + mp.msg.error(JSON.stringify(value)); +}; + +Utils.benchmarkStart = function(textLabel) +{ + Utils.benchmarkTimestamp = mp.get_time(); + Utils.benchmarkTextLabel = textLabel; +}; + +Utils.benchmarkEnd = function() +{ + var now = mp.get_time(), + start = Utils.benchmarkTimestamp ? Utils.benchmarkTimestamp : now, + elapsed = now - start, + label = typeof Utils.benchmarkTextLabel === 'string' ? Utils.benchmarkTextLabel : ''; + mp.msg.info('Time Elapsed (Benchmark'+(label.length ? ': '+label : '')+'): '+elapsed+' seconds.'); +}; + +module.exports = Utils; diff --git a/.config/mpv/scripts/mpvDLNA/modules.js/Options.js b/.config/mpv/scripts/mpvDLNA/modules.js/Options.js new file mode 100644 index 0000000..d61bc4b --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/modules.js/Options.js @@ -0,0 +1,206 @@ +/* + * OPTIONS.JS (MODULE) + * + * Description: JavaScript implementation of mpv's Lua API's config file system, + * via "mp.options.read_options()". See official Lua docs for help. + * https://github.com/mpv-player/mpv/blob/master/DOCS/man/lua.rst#mpoptions-functions + * Version: 2.1.0 + * Author: VideoPlayerCode + * URL: https://github.com/VideoPlayerCode/mpv-tools + * License: Apache License, Version 2.0 + */ + +/* jshint -W097 */ +/* global mp, exports, require */ + + + + +/* This is a slightly modified version of the Options module from VideoPlayerCode + that has been changed to better work with the mpvDLNA plugin. + + Specifically it checks an additional location for the config file (/script-opts) +*/ + +'use strict'; + +var ScriptConfig = function(options, identifier) +{ + if (!options) + throw 'Options table parameter is missing.'; + this.options = options; + this.scriptName = typeof identifier === 'string' ? identifier : mp.get_script_name(); + this.configFile = null; + + // Converts string "val" to same primitive type as "destTypeVal". + var typeConv = function(destTypeVal, val) + { + switch (typeof destTypeVal) { + case 'object': + if (!Array.isArray(destTypeVal)) + val = undefined; // Unknown "object" target variable. + else if (typeof val !== 'string') + val = String(val); // Target is array, so use string values. + break; + case 'string': + if (typeof val !== 'string') + val = String(val); + break; + case 'boolean': + if (val === 'yes') + val = true; + else if (val === 'no') + val = false; + else { + mp.msg.error('Error: Can\'t convert '+JSON.stringify(val)+' to boolean!'); + val = undefined; + } + break; + case 'number': + var num = parseFloat(val); + if (!isNaN(num)) + val = num; + else { + mp.msg.error('Error: Can\'t convert '+JSON.stringify(val)+' to number!'); + val = undefined; + } + break; + default: + val = undefined; + } + return val; + }; + + // Find config file. + if (this.scriptName && this.scriptName.length) { + mp.msg.debug('Reading options for '+this.scriptName+'.'); + this.configFile = mp.find_config_file('script-settings/'+this.scriptName+'.conf'); + if (!this.configFile) // Try legacy settings location as fallback. + this.configFile = mp.find_config_file('script-opts/'+this.scriptName+'.conf'); + if (!this.configFile) // Try legacy settings location as fallback. + this.configFile = mp.find_config_file('lua-settings/'+this.scriptName+'.conf'); + } + + // Read and parse configuration if found. + var i, len, pos, key, val, isArrayVal, convVal; + if (this.configFile && this.configFile.length) { + try { + var line, configLines = mp.utils.read_file(this.configFile).split(/[\r\n]+/); + for (i = 0, len = configLines.length; i < len; ++i) { + line = configLines[i].replace(/^\s+/, ''); + if (!line.length || line.charAt(0) === '#') + continue; + pos = line.indexOf('='); + if (pos < 0) { + mp.msg.warn('"'+this.configFile+'": Ignoring malformatted config line "'+line.replace(/\s+$/, '')+'".'); + continue; + } + key = line.substring(0, pos); + val = line.substring(pos + 1); + isArrayVal = false; + if ('[]' === line.substring(pos - 2, pos)) { + key = key.substring(0, key.length - 2); + isArrayVal = true; + } + if (this.options.hasOwnProperty(key)) { + convVal = typeConv(this.options[key], val); + if (typeof convVal !== 'undefined') { + if (Array.isArray(this.options[key])) { + if (isArrayVal) + this.options[key].push(convVal); + else + mp.msg.error('"'+this.configFile+'": Ignoring non-array value for array-based option key "'+key+'".'); + } + else + this.options[key] = convVal; + } + else + mp.msg.error('"'+this.configFile+'": Unable to convert value "'+val+'" for key "'+key+'".'); + } + else + mp.msg.warn('"'+this.configFile+'": Ignoring unknown key "'+key+'".'); + } + } catch (e) { + mp.msg.error('Unable to read configuration file "'+this.configFile+'".'); + } + } + else + mp.msg.verbose('Unable to find configuration file for '+this.scriptName+'.'); + + // Parse command-line options. + if (this.scriptName && this.scriptName.length) { + var cmdOpts = mp.get_property_native('options/script-opts'), rawOpt, + prefix = this.scriptName+'-', keyLen; + len = prefix.length; + for (rawOpt in cmdOpts) { + if (!cmdOpts.hasOwnProperty(rawOpt)) + continue; + pos = rawOpt.indexOf(prefix); + if (pos !== 0) + continue; + key = rawOpt.substring(len); + keyLen = key.length; + isArrayVal = false; + if ('[]' === key.substring(keyLen - 2)) { + key = key.substring(0, keyLen - 2); + isArrayVal = true; + } + if (key.length && this.options.hasOwnProperty(key)) { + val = cmdOpts[rawOpt]; + convVal = typeConv(this.options[key], val); + if (typeof convVal !== 'undefined') { + if (Array.isArray(this.options[key])) { + if (isArrayVal) + this.options[key].push(convVal); + else + mp.msg.error('script-opts: Ignoring non-array value for array-based option key "'+key+'".'); + } + else + this.options[key] = convVal; + } + else + mp.msg.error('script-opts: Unable to convert value "'+val+'" for key "'+key+'".'); + } + else + mp.msg.warn('script-opts: Ignoring unknown key "'+key+'".'); + } + } +}; + +ScriptConfig.prototype.getValue = function(key) +{ + if (!this.options.hasOwnProperty(key)) + throw 'Invalid option "'+key+'"'; + return this.options[key]; +}; + +ScriptConfig.prototype.getMultiValue = function(key) +{ + // Multi-value format: `{one}+{two}+{three}`. + var i, len, + val = this.getValue(key), // Throws. + result = []; + if (typeof val !== 'string') + throw 'Invalid non-string value in multi-value option "'+key+'"'; + len = val.length; + if (len) { + if (val.charAt(0) !== '{' || val.charAt(len - 1) !== '}') + throw 'Missing surrounding "{}" brackets in multi-value option "'+key+'"'; + val = val.substring(1, len - 1).split('}+{'); + len = val.length; + for (i = 0; i < len; ++i) { + result.push(val[i]); + } + } + return result; +}; + +// Class `advanced_options()`: Offers extended features such as multi-values. +exports.advanced_options = ScriptConfig; + +// Function `read_options()`: Behaves like Lua API (returns plain list of opts). +exports.read_options = function(table, identifier) { + // NOTE: "table" will be modified by reference, just as the Lua version. + var config = new ScriptConfig(table, identifier); + return config.options; // This is the same object as "table". +}; diff --git a/.config/mpv/scripts/mpvDLNA/modules.js/README.md b/.config/mpv/scripts/mpvDLNA/modules.js/README.md new file mode 100644 index 0000000..ab0ed6b --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/modules.js/README.md @@ -0,0 +1,3 @@ +# This code is originally from [VideoPlayerCode](https://github.com/VideoPlayerCode)'s +# [mpv-tools](https://github.com/VideoPlayerCode/mpv-tools/) repository under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.html). +## The SelectionMenu.js and Options.js files have been modified to better work with mpvDLNA but the other files are still in their original form. diff --git a/.config/mpv/scripts/mpvDLNA/modules.js/SelectionMenu.js b/.config/mpv/scripts/mpvDLNA/modules.js/SelectionMenu.js new file mode 100644 index 0000000..260b15c --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/modules.js/SelectionMenu.js @@ -0,0 +1,649 @@ +/* + * SELECTIONMENU.JS (MODULE) + * + * Version: 1.3.1 + * Author: VideoPlayerCode + * URL: https://github.com/VideoPlayerCode/mpv-tools + * License: Apache License, Version 2.0 + */ + +/* jshint -W097 */ +/* global mp, module, require, setInterval, clearInterval, setTimeout, clearTimeout */ + + + + +/* This is a modified version of the SelectionMenu module from VideoPlayerCode + that has been changed to better work with the mpvDLNA plugin. + + Specifically it has additional support for: + Coloring empty folders red + Displaying an indicator on currently playing entries + Displaying a text input system +*/ + + +'use strict'; + +var Ass = require('AssFormat'), + Utils = require('MicroUtils'); + +var SelectionMenu = function(settings) +{ + settings = settings || {}; + + this.uniqueId = 'M'+String(mp.get_time_ms()).replace(/\./g, '').substring(3)+ + Math.floor((100+(Math.random()*899))); + this.metadata = null; + this.title = 'No title'; + this.options = []; + this.selectionIdx = 0; + this.cbMenuShow = typeof settings.cbMenuShow === 'function' ? settings.cbMenuShow : null; + this.cbMenuHide = typeof settings.cbMenuHide === 'function' ? settings.cbMenuHide : null; + this.cbMenuLeft = typeof settings.cbMenuLeft === 'function' ? settings.cbMenuLeft : null; + this.cbMenuRight = typeof settings.cbMenuRight === 'function' ? settings.cbMenuRight : null; + this.cbMenuOpen = typeof settings.cbMenuOpen === 'function' ? settings.cbMenuOpen : null; + this.cbMenuUndo = typeof settings.cbMenuUndo === 'function' ? settings.cbMenuUndo : null; + this.maxLines = typeof settings.maxLines === 'number' && + settings.maxLines >= 3 ? Math.floor(settings.maxLines) : 10; + this.menuFontAlpha = Ass.convertPercentToHex( // Throws if invalid input. + (typeof settings.menuFontAlpha === 'number' && + settings.menuFontAlpha >= 0 && settings.menuFontAlpha <= 1 ? + settings.menuFontAlpha : 1), + true // Invert input range so "1.0" is visible and "0.0" is invisible. + ); + this.menuFontSize = typeof settings.menuFontSize === 'number' && + settings.menuFontSize >= 1 ? Math.floor(settings.menuFontSize) : 40; + this.originalFontSize = null; + this.hasRegisteredKeys = false; // Also means that menu is active/open. + this.useTextColors = true; + this.currentMenuText = ''; + this.typingText = ''; + this.active = false; // Menu is visible + this.menuActive = false; // the menu portion of the UI is active + this.typingActive = false; // the typing portion of the UI is active + this.isShowingMessage = false; + this.currentMessageText = ''; + this.menuInterval = null; + this.stopMessageTimeout = null; + this.autoCloseDelay = typeof settings.autoCloseDelay === 'number' && + settings.autoCloseDelay >= 0 ? settings.autoCloseDelay : 5; // 0 = Off. + this.autoCloseActiveAt = 0; + this.keyBindings = { // Default keybindings. + 'Menu-Up':{repeatable:true, keys:['up']}, + 'Menu-Down':{repeatable:true, keys:['down']}, + 'Menu-Up-Fast':{repeatable:true, keys:['shift+up']}, + 'Menu-Down-Fast':{repeatable:true, keys:['shift+down']}, + 'Menu-Left':{repeatable:true, keys:['left']}, + 'Menu-Right':{repeatable:false, keys:['right']}, + 'Menu-Open':{repeatable:false, keys:['enter']}, + 'Menu-Undo':{repeatable:false, keys:['bs']}, + 'Menu-Help':{repeatable:false, keys:['h']}, + 'Menu-Close':{repeatable:false, keys:['esc']} + }; + + // Apply custom rebinding overrides if provided. + // Format: `{'Menu-Open':['a','shift+b']}` + // Note that all "shift variants" MUST be specified as "shift+". + var i, action, key, allKeys, erasedDefaults, + rebinds = settings.keyRebindings; + if (rebinds) { + for (action in rebinds) { + if (!rebinds.hasOwnProperty(action)) + continue; + if (!this.keyBindings.hasOwnProperty(action)) + throw 'Invalid menu action "'+action+'" in rebindings'; + erasedDefaults = false; + allKeys = rebinds[action]; + for (i = 0; i < allKeys.length; ++i) { + key = allKeys[i]; + if (typeof key !== 'string') + throw 'Invalid non-string key ('+JSON.stringify(key)+') in custom rebindings'; + key = key.toLowerCase(); // Unify case of all keys for de-dupe. + key = Utils.trim(key); // Trim whitespace. + if (!key.length) + continue; + if (!erasedDefaults) { // Erase default keys for this action. + erasedDefaults = true; + this.keyBindings[action].keys = []; + } + this.keyBindings[action].keys.push(key); + } + } + } + + // Verify that no duplicate bindings exist for the same key. + var boundKeys = {}; + for (action in this.keyBindings) { + if (!this.keyBindings.hasOwnProperty(action)) + continue; + allKeys = this.keyBindings[action].keys; + for (i = 0; i < allKeys.length; ++i) { + key = allKeys[i]; + if (boundKeys.hasOwnProperty(key)) + throw 'Invalid duplicate menu bindings for key "'+key+'" (detected in action "'+action+'")'; + boundKeys[key] = true; + } + } +}; + +SelectionMenu.prototype.setMetadata = function(metadata) +{ + this.metadata = metadata; +}; + +SelectionMenu.prototype.getMetadata = function() +{ + return this.metadata; +}; + +SelectionMenu.prototype.setTitle = function(newTitle) +{ + if (typeof newTitle !== 'string') + throw 'setTitle: No title value provided'; + this.title = newTitle; +}; + +SelectionMenu.prototype.setOptions = function(newOptions, initialSelectionIdx) +{ + if (typeof newOptions === 'undefined') + throw 'setOptions: No options value provided'; + this.options = newOptions; + this.selectionIdx = typeof initialSelectionIdx === 'number' && + initialSelectionIdx >= 0 && initialSelectionIdx < newOptions.length ? + initialSelectionIdx : 0; +}; + +SelectionMenu.prototype.setCallbackMenuShow = function(newCbMenuShow) +{ + this.cbMenuShow = typeof newCbMenuShow === 'function' ? newCbMenuShow : null; +}; + +SelectionMenu.prototype.setCallbackMenuHide = function(newCbMenuHide) +{ + this.cbMenuHide = typeof newCbMenuHide === 'function' ? newCbMenuHide : null; +}; + +SelectionMenu.prototype.setCallbackMenuLeft = function(newCbMenuLeft) +{ + this.cbMenuLeft = typeof newCbMenuLeft === 'function' ? newCbMenuLeft : null; +}; + +SelectionMenu.prototype.setCallbackMenuRight = function(newCbMenuRight) +{ + this.cbMenuRight = typeof newCbMenuRight === 'function' ? newCbMenuRight : null; +}; + +SelectionMenu.prototype.setCallbackMenuOpen = function(newCbMenuOpen) +{ + this.cbMenuOpen = typeof newCbMenuOpen === 'function' ? newCbMenuOpen : null; +}; + +SelectionMenu.prototype.setCallbackMenuUndo = function(newCbMenuUndo) +{ + this.cbMenuUndo = typeof newCbMenuUndo === 'function' ? newCbMenuUndo : null; +}; + +SelectionMenu.prototype.setUseTextColors = function(value) +{ + var hasChanged = this.useTextColors !== value; + this.useTextColors = !!value; + // Update text cache, and redraw menu if visible (otherwise don't show it). + if (hasChanged) + this.renderMenu(null, 1); // 1 = Only redraw if menu is onscreen. +}; + +SelectionMenu.prototype.isMenuActive = function() +{ + return this.active; +}; + +SelectionMenu.prototype.getSelectedItem = function() +{ + if (this.selectionIdx < 0 || this.selectionIdx >= this.options.length) + return ''; + else + return this.options[this.selectionIdx]; +}; + +SelectionMenu.prototype._processBindings = function(fnCb) +{ + if (typeof fnCb !== 'function') + throw 'Missing callback for _processBindings'; + + var i, key, allKeys, action, identifier, + bindings = this.keyBindings; + for (action in bindings) { + if (!bindings.hasOwnProperty(action)) + continue; + + allKeys = bindings[action].keys; + for (i = 0; i < allKeys.length; ++i) { + key = allKeys[i]; + identifier = this.uniqueId+'_'+action+'_'+key; + fnCb( + identifier, // Unique identifier for this binding. + action, // What action the key is assigned to trigger. + key, // What key. + bindings[action] // Details about this binding. + ); + } + } +}; + +SelectionMenu.prototype._registerMenuKeys = function() +{ + if (this.hasRegisteredKeys) + return; + + // Necessary in order to preserve "this" in the called function, since mpv's + // callbacks don't receive "this" if the object's func is keybound directly. + var createFn = function(obj, fn) { + return function() { + obj._menuAction(fn); + }; + }; + + var self = this; + this._processBindings(function(identifier, action, key, details) { + mp.add_forced_key_binding( + key, // What key. + identifier, // Unique identifier for the binding. + createFn(self, action), // Generate anonymous func to execute. + {repeatable:details.repeatable} // Extra options. + ); + }); + + this.hasRegisteredKeys = true; +}; + +SelectionMenu.prototype._unregisterMenuKeys = function() +{ + if (!this.hasRegisteredKeys) + return; + + var self = this; + this._processBindings(function(identifier, action, key, details) { + mp.remove_key_binding( + identifier // Remove binding by its unique identifier. + ); + }); + + + this.hasRegisteredKeys = false; +}; + +SelectionMenu.prototype._menuAction = function(action) +{ + if (this.isShowingMessage && action !== 'Menu-Close') + return; // Block everything except "close" while showing a message. + + switch (action) { + case 'Menu-Up': + case 'Menu-Down': + case 'Menu-Up-Fast': + case 'Menu-Down-Fast': + var maxIdx = this.options.length - 1; + + if (action === 'Menu-Up' || action === 'Menu-Up-Fast') + this.selectionIdx -= (action === 'Menu-Up-Fast' ? 10 : 1); + else + this.selectionIdx += (action === 'Menu-Down-Fast' ? 10 : 1); + + // Handle wraparound in single-move mode, or clamp in fast-move mode. + if (this.selectionIdx < 0) + this.selectionIdx = (action === 'Menu-Up-Fast' ? 0 : maxIdx); + else if (this.selectionIdx > maxIdx) + this.selectionIdx = (action === 'Menu-Down-Fast' ? maxIdx : 0); + + this.renderMenu(); + break; + case 'Menu-Left': + case 'Menu-Right': + case 'Menu-Open': + case 'Menu-Undo': + var cbName = 'cb'+action.replace(/-/g, ''); + if (typeof this[cbName] === 'function') { + // We don't know what the callback will do, and it may be slow, so + // we'll disable the menu's auto-close timeout while it runs. + this._disableAutoCloseTimeout(); // Soft-disable. + this[cbName](action); + } + break; + case 'Menu-Help': + // List all keybindings to help the user remember them. + var entry, entryTitle, allKeys, + c = this.useTextColors, + helpLines = 0, + helpString = Ass.startSeq(c)+Ass.alpha(this.menuFontAlpha, c), + bindings = this.keyBindings; + for (entry in bindings) { + if (!bindings.hasOwnProperty(entry)) + continue; + allKeys = bindings[entry].keys; + if (!entry.match(/^Menu-/) || !allKeys || !allKeys.length) + continue; + entryTitle = entry.substring(5); + if (!entryTitle.length) + continue; + Utils.quickSort(allKeys, {caseInsensitive: true}); + ++helpLines; + helpString += Ass.yellow(c)+Ass.esc(entryTitle, c)+': '+ + Ass.white(c)+Ass.esc('{'+allKeys.join('}, {')+'}', c)+'\n'; + } + helpString += Ass.stopSeq(c); + if (!helpLines) + helpString = 'No help available.'; + this.showMessage(helpString, 5000); + break; + case 'Menu-Close': + this.hideMenu(); + break; + default: + mp.msg.error('Unknown menu action "'+action+'"'); + return; + } + + this._updateAutoCloseTimeout(); // Soft-update. +}; + +SelectionMenu.prototype._disableAutoCloseTimeout = function(forceLock) +{ + this.autoCloseActiveAt = forceLock ? -2 : -1; // -2 = hard, -1 = soft. +}; + +SelectionMenu.prototype._updateAutoCloseTimeout = function(forceUnlock) +{ + if (!forceUnlock && this.autoCloseActiveAt === -2) + return; // Do nothing while autoclose is locked in "disabled" mode. + + this.autoCloseActiveAt = mp.get_time(); +}; + +SelectionMenu.prototype._handleAutoClose = function() +{ + if (this.autoCloseDelay <= 0 || this.autoCloseActiveAt <= -1) // -2 = hard, -1 = soft. + return; // Do nothing while autoclose is disabled (0) or locked (< 0). + + var now = mp.get_time(); + if (this.autoCloseActiveAt <= (now - this.autoCloseDelay)) + this.hideMenu(); +}; + +SelectionMenu.prototype._renderActiveText = function() +{ + // If nothing is supposed to be showing + if (!this.active) + return; + + // Determine which text to render (critical messages take precedence). + var msg = this.isShowingMessage ? this.currentMessageText : (this.menuActive ? this.currentMenuText : ''); + + if (typeof msg !== 'string') + msg = ''; + + // Append the typing display to the correct location in the message + if (this.typingActive) { + var newlines = (this.menuActive) ? 3 : 2; + newlines += this.maxLines - msg.split("\n").length; + + msg += Ass.startSeq(true) + Ass.alpha("FF", true); + // Make sure we get aligned properly + for (var i = 0; i < newlines; i++) { + msg += "-\n"; + } + + // This spacing is annoyingly fiddly + if (!this.menuActive) + msg+="\n"; + + msg += Ass.alpha("00", true); + msg += "--------------\n"; + + msg += Ass.stopSeq(true); + msg += this.typingText; + + } + + + // Tell mpv's OSD to show the text. It will automatically be replaced and + // refreshed every second while the menu remains open, to ensure that + // nothing else is able to overwrite our menu text. + // NOTE: The long display duration is important, because the JS engine lacks + // real threading, so any slow mpv API calls or slow JS functions will delay + // our redraw timer! Without a long display duration, the menu would vanish. + // NOTE: If a timer misses multiple intended ticks, it will only tick ONCE + // when catching up. So there can thankfully never be any large "backlog"! + mp.osd_message(msg, 1000); +}; + +SelectionMenu.prototype.renderMenu = function(selectionPrefix, renderMode) +{ + var c = this.useTextColors, + finalString; + + // Title. + finalString = Ass.startSeq(c)+Ass.alpha(this.menuFontAlpha, c)+ + Ass.gray(c)+Ass.scale(75, c)+Ass.esc(this.title, c)+':'+ + Ass.scale(100, c)+Ass.white(c)+'\n\n'; + + // Options. + if (this.options.length > 0) { + // Calculate start/end offsets around focal point. + var startIdx = this.selectionIdx - Math.floor(this.maxLines / 2); + if (startIdx < 0) + startIdx = 0; + + var endIdx = startIdx + this.maxLines - 1, + maxIdx = this.options.length - 1; + if (endIdx > maxIdx) + endIdx = maxIdx; + + // Increase number of leading lines if we've reached end of list. + var lineCount = (endIdx - startIdx) + 1, // "+1" to count start line too. + lineDiff = this.maxLines - lineCount; + startIdx -= lineDiff; + if (startIdx < 0) + startIdx = 0; + + // Format and add all output lines. + var opt; + for (var i = startIdx; i <= endIdx; ++i) { + opt = this.options[i]; + if (i === this.selectionIdx) + // NOTE: Prefix stays on screen until cursor-move or re-render.z + finalString += Ass.yellow(c)+'> '+(typeof selectionPrefix === 'string' ? + Ass.esc(selectionPrefix, c)+' ' : ''); + + // If the menu option has no children to move to + // then it should be colored red and ignored + if (opt.children != null && opt.children.length == 0) + finalString += Ass.color("FF0000", c); + + finalString += ( + i === startIdx && startIdx > 0 ? '...' : + ( + i === endIdx && endIdx < maxIdx ? '...' : Ass.esc( + typeof opt === 'object' ? opt.name : opt, + c + ) + ) + ); + + // Display the now playing indicator + if (opt.isPlaying) + finalString += " <=="; + + if (i === this.selectionIdx || (opt.children != null && opt.children.length == 0)) + finalString += Ass.white(c); + if (i !== endIdx) + finalString += '\n'; + } + } + + // End the Advanced SubStation command sequence. + finalString += Ass.stopSeq(c); + + // Update cached menu text. But only open/redraw the menu if it's already + // active OR if we're NOT being prevented from going out of "hidden" state. + this.currentMenuText = finalString; + + // Handle render mode: + // 1 = Only redraw if menu is onscreen (doesn't trigger open/redrawing if + // the menu is closed or busy showing a text message); 2 = Don't show/redraw + // at all (good for just updating the text cache silently); any other value + // (incl. undefined, aka default) = show/redraw the menu. + if ((renderMode === 1 && (!this.menuActive || this.isShowingMessage)) || renderMode === 2) + return; + this.showMenu(); +}; + + +SelectionMenu.prototype.show = function() { + var justOpened = false; + if (!this.isMenuActive()) { + justOpened = true; + this.originalFontSize = mp.get_property_number('osd-font-size'); + mp.set_property('osd-font-size', this.menuFontSize); + + + // Redraw the currently active text every second and do periodic tasks. + // NOTE: This prevents other OSD scripts from removing our menu text. + var self = this; + if (this.menuInterval !== null) + clearInterval(this.menuInterval); + this.menuInterval = setInterval(function() { + self._renderActiveText(); + self._handleAutoClose(); + }, 1000); + + // Get rid of any lingering "stop message" timeout and message. + this.stopMessage(true); + } + + // Display the currently active text instantly. + this._renderActiveText(); + + if (justOpened) { + // Run "menu show" callback if registered. + if (typeof this.cbMenuShow === 'function') { + this._disableAutoCloseTimeout(); // Soft-disable while CB runs. + this.cbMenuShow('Menu-Show'); + } + + // Force an update/unlock of the activity timeout when menu opens. + this._updateAutoCloseTimeout(true); // Hard-update. + } + + this.active = true; +} + +SelectionMenu.prototype.hide = function() { + mp.osd_message(''); + if (this.originalFontSize !== null) + mp.set_property('osd-font-size', this.originalFontSize); + if (this.menuInterval !== null) { + clearInterval(this.menuInterval); + this.menuInterval = null; + } + + // Get rid of any lingering "stop message" timeout and message. + this.stopMessage(true); + + // Run "menu hide" callback if registered. + if (typeof this.cbMenuHide === 'function') + this.cbMenuHide('Menu-Hide'); + + this.active = false; +} + +SelectionMenu.prototype.showMenu = function() +{ + if (!this.menuActive) { + this.menuActive = true; + this._registerMenuKeys(); + } + + if (!this.active) + this.show(); + + this._renderActiveText(); +}; + +SelectionMenu.prototype.hideMenu = function() +{ + if (this.menuActive) { + this.menuActive = false; + this._unregisterMenuKeys(); + + if (this.active && !this.typingActive) + this.hide(); + else + this._renderActiveText(); + } +}; + +SelectionMenu.prototype.showTyping = function() +{ + this.typingActive = true; + + if (!this.active) + this.show(); + + this._renderActiveText(); +}; + +SelectionMenu.prototype.hideTyping = function() +{ + this.typingActive = false; + + if (this.active && !this.menuActive) + this.hide(); + else + this._renderActiveText(); + + if (this.menuActive) { + this._registerMenuKeys(); + } +}; + +SelectionMenu.prototype.showMessage = function(msg, durationMs, clearSelectionPrefix) +{ + if (!this.isMenuActive()) + return; + + if (typeof msg !== 'string') + msg = 'showMessage: Invalid message value.'; + if (typeof durationMs !== 'number') + durationMs = 800; + + if (clearSelectionPrefix) + this.renderMenu(null, 2); // 2 = Only update text cache (no redraw). + + this.isShowingMessage = true; + this.currentMessageText = msg; + this._renderActiveText(); + this._disableAutoCloseTimeout(true); // Hard-disable (ignore msg idle time). + + var self = this; + if (this.stopMessageTimeout !== null) + clearTimeout(this.stopMessageTimeout); + this.stopMessageTimeout = setTimeout(function() { + self.stopMessage(); + }, durationMs); +}; + +SelectionMenu.prototype.stopMessage = function(preventRender) +{ + if (this.stopMessageTimeout !== null) { + clearTimeout(this.stopMessageTimeout); + this.stopMessageTimeout = null; + } + this.isShowingMessage = false; + this.currentMessageText = ''; + if (!preventRender) + this._renderActiveText(); + this._updateAutoCloseTimeout(true); // Hard-update (last user activity). +}; + +module.exports = SelectionMenu; diff --git a/.config/mpv/scripts/mpvDLNA/mpvDLNA.py b/.config/mpv/scripts/mpvDLNA/mpvDLNA.py new file mode 100644 index 0000000..2ddf2c8 --- /dev/null +++ b/.config/mpv/scripts/mpvDLNA/mpvDLNA.py @@ -0,0 +1,127 @@ +import sys +import upnpclient + +from lxml import etree + +# Try to import wake on lan +wol = True +try: + import wakeonlan +except ImportError as error: + wol = False + +import logging +# important information is passed through stdout so we need to supress +# the output of the upnp client module +logging.getLogger("upnpclient").setLevel(logging.CRITICAL) +logging.getLogger("ssdp").setLevel(logging.CRITICAL) + +def wake(mac): + if wol: + try: + wakeonlan.send_magic_packet(mac); + print("packet sent") + except: + print("send failed") + else: + print("import failed") + +def info(url, id, count): + device = upnpclient.Device(url) + result = device.ContentDirectory.Browse(ObjectID=id, BrowseFlag="BrowseMetadata", Filter="*", StartingIndex=0, RequestedCount=count, SortCriteria="") + root = etree.fromstring(result["Result"]) + + # Determine if we should be looking at items or containers + list = root.findall("./item", root.nsmap) + type = "item" + if len(list) == 0: + list = root.findall("./container", root.nsmap) + type = "container" + + print(type) + for t in list: + print("") + print(t.findtext("upnp:episodeNumber", "No Episode Number", root.nsmap)) + print(t.findtext("dc:description", "No Description", root.nsmap).encode().decode("ascii", errors='ignore')) + + +def browse(url, id, count): + device = upnpclient.Device(url) + result = device.ContentDirectory.Browse(ObjectID=id, BrowseFlag="BrowseDirectChildren", Filter="*", StartingIndex=0, RequestedCount=count, SortCriteria="") + root = etree.fromstring(result["Result"]) + + list = {} + + # Determine if we should be looking at items or containers + list["item"] = root.findall("./item", root.nsmap) + list["container"] = root.findall("./container", root.nsmap) + + for type in list.keys(): + print(type + "s:") + for t in list[type]: + print("") + print(t.findtext("dc:title", "untitled", root.nsmap).encode().decode("ascii", errors='ignore')) + print(t.get("id")) + + if type == "item": + print(t.findtext("res", "", root.nsmap)) + print("----") + + +def list(timeout): + devices = [] + + possibleDevices = upnpclient.discover(timeout) + for device in possibleDevices: + if "MediaServer" in device.device_type: + addToList = True + for d in devices: + if d.friendly_name == device.friendly_name: + addToList = False + break + + if addToList: + devices.append(device) + + + for device in devices: + print("") + print(device.friendly_name.encode().decode("ascii", errors='ignore')) + print(device.location) + + +def help(): + print("mpvDLNA.py supports the following commands:") + print("-h, --help Prints the help dialog") + print("-v, --version Prints version information") + print("-l, --list Takes a timeout in seconds and outputs a list of DLNA Media Servers on the network") + print("-b, --browse Takes a DLNA url and the id of a DLNA element and outputs its direct children") + print("-i, --info Takes a DLNA url and the id of a DLNA element and outputs its metadata") + print("-w, --wake Takes a MAC address and attempts to send a wake on lan packet to it") + + +if len(sys.argv) == 2: + if sys.argv[1] == "-v" or sys.argv[1] == "--version": + print("mpvDLNA.py Plugin Version 2.0.0") + else: + help() +elif len(sys.argv) == 3: + if sys.argv[1] == "-l" or sys.argv[1] == "--list": + list(int(sys.argv[2])) + elif sys.argv[1] == "-w" or sys.argv[1] == "--wake": + wake(sys.argv[2]) + else: + help() +elif len(sys.argv) >= 4: + count = 2000 + if len(sys.argv) == 5: + count = sys.argv[4] + + if sys.argv[1] == "-b" or sys.argv[1] == "--browse": + browse(sys.argv[2], sys.argv[3], count) + elif sys.argv[1] == "-i" or sys.argv[1] == "--info": + info(sys.argv[2], sys.argv[3], count) + else: + help() +else: + help() \ No newline at end of file