/* * 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;