/**
*
*  Base64 encode / decode
*  http://www.webtoolkit.info
*
**/
const Base64 = {
    _keyStr: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", 
	encode: function (input)
    {
        var output = "";
        var chr1, chr2, chr3, enc1, enc2, enc3, enc4;
        var i = 0;

        input = Base64._utf8_encode(input);

        while (i < input.length)
        {
            chr1 = input.charCodeAt(i++);
            chr2 = input.charCodeAt(i++);
            chr3 = input.charCodeAt(i++);

            enc1 = chr1 >> 2;
            enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
            enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
            enc4 = chr3 & 63;

            if (isNaN(chr2))
            {
                enc3 = enc4 = 64;
            }
            else if (isNaN(chr3))
            {
                enc4 = 64;
            }

            output = output +
                this._keyStr.charAt(enc1) + this._keyStr.charAt(enc2) +
                this._keyStr.charAt(enc3) + this._keyStr.charAt(enc4);
        } // Whend 

        return output;
    },
	decode: function (input)
    {
        var output = "";
        var chr1, chr2, chr3;
        var enc1, enc2, enc3, enc4;
        var i = 0;

        input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
        while (i < input.length)
        {
            enc1 = this._keyStr.indexOf(input.charAt(i++));
            enc2 = this._keyStr.indexOf(input.charAt(i++));
            enc3 = this._keyStr.indexOf(input.charAt(i++));
            enc4 = this._keyStr.indexOf(input.charAt(i++));

            chr1 = (enc1 << 2) | (enc2 >> 4);
            chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
            chr3 = ((enc3 & 3) << 6) | enc4;

            output = output + String.fromCharCode(chr1);

            if (enc3 != 64)
            {
                output = output + String.fromCharCode(chr2);
            }

            if (enc4 != 64)
            {
                output = output + String.fromCharCode(chr3);
            }

        } // Whend 

        output = Base64._utf8_decode(output);

        return output;
    },
	_utf8_encode: function (string)
    {
        var utftext = "";
        string = string.replace(/\r\n/g, "\n");

        for (var n = 0; n < string.length; n++)
        {
            var c = string.charCodeAt(n);

            if (c < 128)
            {
                utftext += String.fromCharCode(c);
            }
            else if ((c > 127) && (c < 2048))
            {
                utftext += String.fromCharCode((c >> 6) | 192);
                utftext += String.fromCharCode((c & 63) | 128);
            }
            else
            {
                utftext += String.fromCharCode((c >> 12) | 224);
                utftext += String.fromCharCode(((c >> 6) & 63) | 128);
                utftext += String.fromCharCode((c & 63) | 128);
            }

        } // Next n 

        return utftext;
    },
	_utf8_decode: function (utftext)
    {
        var string = "";
        var i = 0;
        var c, c1, c2, c3;
        c = c1 = c2 = 0;

        while (i < utftext.length)
        {
            c = utftext.charCodeAt(i);

            if (c < 128)
            {
                string += String.fromCharCode(c);
                i++;
            }
            else if ((c > 191) && (c < 224))
            {
                c2 = utftext.charCodeAt(i + 1);
                string += String.fromCharCode(((c & 31) << 6) | (c2 & 63));
                i += 2;
            }
            else
            {
                c2 = utftext.charCodeAt(i + 1);
                c3 = utftext.charCodeAt(i + 2);
                string += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63));
                i += 3;
            }

        } // Whend 

        return string;
    }
}

class ExplorerURLInputHelper {
    constructor(){
        this.events = [
            ['.modal-wrap input', 'keyup', e => {
                if(e.keyCode == 13){
                    e.preventDefault()
                    let m = document.getElementById('modal-template-option-submit')
                    if(m){
                        m.click()
                    }
                }
            }],
            ['.modal-wrap input[placeholder^="http"]', 'keyup', e => {
				let v = e.target.value
                if(v.length == 6 && v.indexOf(':') == -1){
                    e.target.value = 'http://' + v
                }
            }]
        ]
        this.active = false
	}
	getInputElement(){
		return document.querySelector('.modal-wrap input, .modal-wrap textarea')
	}
    start(){
        if(!this.active){
			this.active = true
			this.apply('on')
			let e = this.getInputElement()
			if(e){
				e.select()
			}
        }
    }
    stop(){
        this.apply('off')
        this.active = false
    }
    apply(type){
		this.events.forEach((e, i) => {
			$(this.events[i][0])[type](this.events[i][1], this.events[i][2])
		})
    }
}

class ExplorerBase extends EventEmitter {
	constructor(jQuery, container, app){
		super()
		this.debug = false
		this.app = app
		this.j = jQuery
		this.path = ''
		this.body = this.j('body')
		this.container = this.j(container)
	}
    addClass(element, className){
        if(!this.hasClass(element, className)){
            element.className = (element.className || '') + ' ' + className
        }
    }
    removeClass(element, className){
        if(this.hasClass(element, className)){
            element.className = element.className.replace(className, '')
        }
    }
    hasClass(element, className){
        return element.className && element.className.indexOf(className) != -1
    }
}

class ExplorerSelectionMemory extends ExplorerBase {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.selectionMemory = {}
		this.on('open', () => {
			this.selectionMemory[this.path] = {
				scroll: this._wrapper.scrollTop,
				index: this.selectedIndex,
				search: this.currentSearch
			}
		})
		this.on('arrow', () => {
			this.selectionMemory[this.path] = {
				scroll: this._wrapper.scrollTop,
				index: this.selectedIndex,
				search: this.currentSearch
			}
		})
		this.on('pre-render', () => {
			this.selectionMemory[this.path] = {
				scroll: this._wrapper.scrollTop,
				index: this.selectedIndex,
				search: this.currentSearch
			}
		})
		this.on('pre-modal-start', () => {
			let e = this.selected()
			if(e && e.title){
				this.selectionMemory[this.path] = {
					scroll: this._wrapper.scrollTop,
					index: this.selectedIndex,
					search: this.currentSearch
				}
			}
		})
        this.on('pos-modal-end', this.updateSelection.bind(this))
		this.app.on('explorer-reset-selection', () => {
            this.selectionMemory = {}
        })
		this.app.on('current-search', (terms, type) => {
            this.currentSearch = JSON.stringify({terms, type})
        })
	}
	updateSelection(){
		return this.updateSelectionCB(this.path, '')
	}
	updateSelectionCB(path, icon){
		if(this.isExploring()){
            this.scrollDirection = ''
            this.scrollSnapping = true
			let target = 0, index = 1
			const inSearch = path.indexOf(lang.SEARCH) != -1 || this.path.indexOf(lang.MORE_RESULTS) != -1 || this.path.indexOf(lang.SEARCH_MORE) != -1
			const inSameSearch = inSearch && this.selectionMemory[path] && this.currentSearch == this.selectionMemory[path].search		
			if(inSearch && !inSameSearch){
                this._scrollContainer.scrollTop = target
                this.focusIndex(index, false, true)
                this.scrollSnapping = false
				return true
            } else {
                this.scrolling = false
				return this.restoreSelection()
			}
		}
    }
    restoreSelection(){
		const selected = this.selected()
		if(selected && selected.id == 'explorer-search') return;
        let data = {scroll: 0, index: this.path ? 1 : 0}
		if(typeof(this.selectionMemory[this.path]) != 'undefined'){
			const inSearch = this.path.indexOf(lang.SEARCH) != -1 || this.path.indexOf(lang.MORE_RESULTS) != -1 || this.path.indexOf(lang.SEARCH_MORE) != -1
			const inSameSearch = inSearch && this.currentSearch == this.selectionMemory[this.path].search
			if(!inSearch || inSameSearch) {
				data = this.selectionMemory[this.path]
				if(data.index == 0 && this.path){
					data.index = 1
				}
			}
        }
        //console.log('selectionMemory restore', data.scroll, data.index, this.path)
		// this._scrollContainer.scrollTop = data.scroll
        this.scrollTop(data.scroll)
        if(this.activeView().level == 'default'){
			let range = this.viewportRange(data.scroll)
			if(data.index < range.start || data.index >= range.end){
				data.index = range.start
			}
			this.focus(this.currentElements[data.index], true)
            this.focusIndex(data.index, true, true) // true = force re-selecting the same entry on refresh
			return true
        }
    }
	scrollTop(y){
        if(typeof(this._scrollContainer.scrollTo) == 'function'){
			this._scrollContainer.scrollTo(0, y)
		} else {
			this._scrollContainer.scrollTop = y
		}
	}
}

class ExplorerPointer extends ExplorerSelectionMemory {
    constructor(jQuery, container, app){ // window, document, jQuery, container
        super(jQuery, container, app)
        if(typeof(container) == 'string'){
            container = document.querySelector(container)
        }
		this._scrollContainer = container.querySelector('wrap')
		if(typeof(this._scrollContainer.scrollTo) != 'function'){
			this._scrollContainer.scrollTo = (x, y) => { // "scrollTo is not a function" in a tx2 tvbox device
				this._scrollContainer.scrollTop = y
				this._scrollContainer.scrollLeft = x
			}
		}
		this.scrollContainer = this.j(this._scrollContainer)
		this.views = []
		this.defaultNavGroup = ''
        this.angleWeight = 0.005
        this.className = 'selected'
        this.parentClassName = 'selected-parent'
        this.selectedIndex = 0
        this.setViewSize(2, 5, 1, 8)
        this.mouseWheelMovingTime = 0
        this.mouseWheelMovingInterval = 300
        this.scrolling = false
        this.scrollingTimer = 0
        this.scrollDirection = 'down'
		this.scrollMouseUpTimer = 0
		this.lastScrollTop = 0
		this.scrollContainer.on('mousedown', () => {
			clearTimeout(this.scrollMouseUpTimer)
			this.manuallyScrolling = true
			this.manuallyScrollingStartScrollTop = this.lastScrollTop
		})
		this.scrollContainer.on('mouseup', () => {
			if (!this.manuallyScrolling) return
			clearTimeout(this.scrollMouseUpTimer)
			this.manuallyScrolling = false
			if (this.manuallyScrollingStartScrollTop != this.lastScrollTop) {
				this.scrollMouseUpTimer = setTimeout(() => {
					this.scrollSnap(this.lastScrollTop, this.scrollDirection, () => { })
				}, 400)
			}
		});
        ['touchmove', 'scroll', 'mousewheel', 'DOMMouseScroll'].forEach(n => {
            this.scrollContainer.on(n, event => {
				clearTimeout(this.scrollMouseUpTimer)
                if(this.debug){
                    console.log('pointer.scroll', this.rendering)
                }
                if(this.rendering){
                    return
                }
                let isTrusted
                if(n == 'scroll'){
                    isTrusted = false
                } else {
                    if(typeof(event.isTrusted) != 'undefined'){
                        isTrusted = event.isTrusted
                    } else if(event.originalEvent && typeof(event.originalEvent.isTrusted) != 'undefined'){
                        isTrusted = event.originalEvent.isTrusted
                    }
                }
                if(this.debug){
                    console.log('pointer.scroll', n, this.scrollSnapping, isTrusted, event)
                }
                if(this.scrollSnapping){
                    if(isTrusted){
                        this.scrollContainer.stop(true, false)
                        this.scrollSnapping = false
                    }
                }
				clearTimeout(this.scrollingTimer)
				let st = this._scrollContainer.scrollTop
				if (!this.scrollSnapping) {
					if (st > this.lastScrollTop) {
						this.scrollDirection = 'down'
					} else if (st < this.lastScrollTop) {
						this.scrollDirection = 'up'
					}
				}
				if (this.debug) {
					console.log('pointer.scroll', n, this.lastScrollTop, st)
				} 
                if(['mousewheel', 'DOMMouseScroll'].indexOf(n) != -1){
                    this.scrolling = false
                    let now = (new Date()).getTime()
                    if(now > (this.mouseWheelMovingTime + this.mouseWheelMovingInterval)){
                        this.mouseWheelMovingTime = now
                        let delta = (event.originalEvent.wheelDelta || -event.originalEvent.detail)
                        this.arrow((delta > 0) ? 'up' : 'down', true)
                        this.emit('scroll', this.lastScrollTop, this.scrollDirection)
                    }
                    event.preventDefault()
                } else { 
                    if(this.lastScrollTop != st){
                        this.lastScrollTop = st    
                        this.scrolling = true
                        this.scrollingTimer = setTimeout(() => {
                            if(this.debug){
                                console.log('pointer.scroll', this.rendering)
                            }
                            if(this.rendering){
                                return
                            }
                            if(this.scrolling){
                                this.scrolling = false
                            }
                            const done = () => {                                
                                if(this.debug){
                                    console.log('pointer.scroll', this.rendering)
                                }
                                if(this.rendering) return
                                this.emit('scroll', this.lastScrollTop, this.scrollDirection)
                            }
                            if(this.manuallyScrolling){
                                done()
                            } else {
                                this.scrollSnap(this.lastScrollTop, this.scrollDirection, done)
                            }
                        }, 250)
                    }
                }
            })
        }) 
        window.addEventListener('resize', this.resize.bind(this))
		this.resize() // to apply initial icons size
    }
    scrollSnap(scrollTop, direction, cb){
        if(this.rendering){
			return cb()
        }
        if(scrollTop == 0 && this._scrollContainer.scrollTop == 0){ 
            return cb()
        }
		if(this._scrollContainer.scrollTop == this.lastScrollSnappingScrollTop) { // prevent looping on consecutive scroll snappings
			return cb()
		}
        let h = this.currentElements[0].offsetHeight, ih = parseInt(scrollTop / h)
        let start = ih * h, end = start + h, startDf = Math.abs(start - scrollTop), endDf = Math.abs(end - scrollTop)
        if(direction == 'down') {
            if((startDf + (h / 3)) > endDf){ // give down direction hint of 1/3
                ih--
            }
        } else { // up by default, if not defined
            if((startDf - (h / 3)) > endDf){ // give up direction hint of 1/3
                ih++
            }
        }
        this.scrollSnapping = true
		const newScrollTop = this.currentElements[ih * this.viewSizeX] ? this.currentElements[ih * this.viewSizeX].offsetTop : ih * h
        this.lastScrollSnappingScrollTop = newScrollTop
        this.scrollContainer.stop().animate({scrollTop: newScrollTop}, 150, () => {
            this.scrollSnapping = false
            this.lastScrollTop = this._scrollContainer.scrollTop
            cb()
        })
    }
    setViewSize(x, y, px, py){
        this._viewSizeX = x
        this._viewSizeY = y
        this._viewSizePortraitX = px
        this._viewSizePortraitY = py
        this.resize()
    }
    resize(){
        const portrait = (window.innerHeight > window.innerWidth)
        if (portrait) {
            this.viewSizeX = this._viewSizePortraitX
            this.viewSizeY = this._viewSizePortraitY
        } else {
            this.viewSizeX = this._viewSizeX
            this.viewSizeY = this._viewSizeY
        }
		const wide = config[portrait ? 'view-size-portrait-x' : 'view-size-x'] >= 3
        const verticalLayout = config[portrait ? 'view-size-portrait-x' : 'view-size-x'] == 1
        jQuery(document.body)[wide ? 'addClass' : 'removeClass']('explorer-wide')
		jQuery(document.body)[verticalLayout ? 'addClass' : 'removeClass']('explorer-vertical')
		let e = document.querySelector('a:not(.entry-2x) .entry-icon-image')
		if(e){
			let metrics = e.getBoundingClientRect()
			if(metrics && metrics.width){
				let min = Math.min(metrics.width, metrics.height) * 0.85
				css(`

				#explorer content a .entry-icon-image i {
    				font-size: ${min}px;
    				line-height: ${metrics.height}px;
    				min-height: ${metrics.height}px;
				}

				`, 'entry-icon-i')
			}			
		} else {
			console.log('Delaying icon size calc')
			setTimeout(() => this.resize(), 1000)
		}
    }
    isVisible(e) {
        return e.offsetParent !== null
    }
    selector(s){
        return Array.from(this.body.get(0).querySelectorAll(s)).filter(this.isVisible)
    }
    start(){
        this.body.on('focus', this.selected.bind(this))
        this.selected()
    }
    updateElement(element){ // if view has changed, find the actual corresponding element
        if(!element.parentNode){
            let prop, val
            ['title', 'data-path', 'id'].some(p => {
                if(val = e.getAttribute(p) && val.indexOf('"') == -1){
                    prop = p
                    return true
                }
            })
            let sel = e.tagName + (prop ? '[' + prop + '="' + val + '"]' : ''), n = document.querySelector(sel)
            if(n){
                element = n
            }
        }
        return element
    }
    selectables(){
        let elements = [], view = this.activeView() // is any explicitly selected??
        if(view){
            if(typeof(view.resetSelector) == 'function'){
                elements = view.resetSelector()
            } else {
                elements = this.entries()
            }
        }
        return $(elements)
    }
    findSelected(){
        let elements, element = document.activeElement
        if(element && element != document.body && (!element.className || element.className.indexOf(this.className) == -1)){ // not explicitly selected
            elements = this.selectables() // check if is any explicitly selected??
            let selected = elements.filter('.' + this.className)
            if(selected.length){
                element = selected.get(0) // yes, that's one explicitly selected
            } else {
                element = this.updateElement(element) // find this element in current view, if it's not
                if(!element.parentNode){ // not found, we'll reset so
                    element = false
                }
            }
        }
        if(!element || element == document || element == document.body){
            if(typeof(elements) == 'undefined'){
                elements = this.selectables()
            }
            let selected
			if(elements.length){
				try { // $.filter was triggering errors on some TV boxes, somehow
					selected = elements.filter('.' + this.className)
				} catch(e) {}
			}
            if(selected && selected.length){
                element = selected.get(0) // yes, that's one explicitly selected
            } else {
                element = elements.get(0)
            }
        }
		if(element && element.id == 'explorer-omni-input') {
			element = element.parentNode
		}
        if(this.debug){
            console.log('findSelected', element, elements)
        }
        return element
    } 
	selected(wrap){
        let jj, element = this.findSelected()
        if(element){
            jj = this.j(element)
            if(!jj.hasClass(this.className)){
                jj.addClass(this.className).parent().addClass(this.parentClassName)
            }
            element.focus({preventScroll: true})
        }
        return wrap ? (jj || this.j(element)) : element
    }
    focusIndex(a, force, avoidScroll){
        if(force || a != this.selectedIndex){
            a = this.scrollContainer.find('a[tabindex="'+ (a ? a : '') +'"]').get(0)
            if(this.debug){
                console.log('FOCUSINDEX', a)
            }
            if(a){
                this.focus(a, avoidScroll)
            }
        }
    }
    focus(a, avoidScroll){
        let ret = null        
        if(this.debug){
            console.log('focus', a, avoidScroll, traceback())
        }
        if(!a) {
            a = this.entries().shift()
        } else if(a instanceof this.j) {
            if(this.debug){
                console.error('focus received jQuery element', a, avoidScroll, traceback())
            }
            a = a.get(0)
        }
        if(a && a != this.selected()){
            if(!a.parentNode) return // fix
            if(this.debug){
                console.log('FOCUSENTRY', a)
            }
            document.querySelectorAll('.' + this.className).forEach(e => {
                if(e != a){
                    this.removeClass(e, this.className)
                    this.removeClass(e.parentNode, this.parentClassName)
                }
            })
            this.addClass(a, this.className)
            this.addClass(a.parentNode, this.parentClassName)
            let index = a.tabIndex
            if(typeof(index) != 'number'){
                index = -1
            }
            if(document.activeElement && document.activeElement.tagName.toLowerCase() == 'input'){
                // dirty hack to force input to lose focus
                let t = document.activeElement
                t.style.visibility = 'hidden'
                a.focus({preventScroll: true})
                t.style.visibility = 'inherit'
            } else {
                a.focus({preventScroll: true})
            }
            let n = a.querySelector('input:not([type="range"]), textarea')
            if(n) {
                n.focus({preventScroll: true})
            }
            if(index != -1) { // is main menu entry
                let lastIndex = this.selectedIndex
                this.selectedIndex = index
                if(this.debug) {
                    console.log('pointer selectedIndex =', a.tabIndex, a)
                }
				//console.log('AVOID SCROLL', avoidScroll, a.offsetTop, this._scrollContainer.scrollTop, this._scrollContainer.offsetHeight, traceback())
				if(avoidScroll && (a.offsetTop < this._scrollContainer.scrollTop || a.offsetTop >= (this._scrollContainer.scrollTop + this._scrollContainer.offsetHeight - 4))){
					avoidScroll = false
				}
                if(!avoidScroll) {
                    if(lastIndex != this.selectedIndex) {
                        let s = a.offsetTop
                        if(s < 0) {
                            s = 0
                        }
                        if(s != a.parentNode.scrollTop) {
                            a.parentNode.scrollTop = ret = s
                        }
                    }
                }
            }
            this.emit('focus', a, index)
        }
        return ret
    }  
    activeView(){     
        let ret = false 
        this.views.some(view => {
            if(!ret){
                ret = view
            }
            if(view.condition()){
                ret = view
                return true
            }
        })
        return ret
    }
    reset(){        
        if(this.debug){
            console.log('reset', traceback())
        }
        let elements, view = this.activeView()
        if(typeof(view.resetSelector) == 'function'){
            elements = view.resetSelector()
        } else {
            elements = this.entries()
        }
        if(elements.length && elements.indexOf(this.selected()) == -1){
            elements = elements.filter(e => e.getAttribute('data-type') != 'back').slice(0)
            if(elements.length){
                let _sound = sound
                this.focus(elements[0], true)
                sound = _sound
            }
        }
    }
    entries(noAsides){
		let e = [], view = this.activeView(), sel = view.selector
        if(typeof(sel)=='function'){
            sel = sel()
        }
        if(typeof(sel)=='string'){
            e.push(...this.selector(sel))
        } else {
            e.push(...sel)
        }
		e = e.filter(n => {
            return !n.className || String(n.className).indexOf('explorer-not-navigable') == -1 // Uncaught TypeError: n.className.indexOf is not a function
        })
		if(noAsides === true){
			e = e.filter(n => n.parentNode && n.parentNode == this._wrapper)
		}
		return e
    }
    addView(view){
        if(view.default === true || !this.defaultNavGroup){
            this.defaultNavGroup = view.level
        }
		jQuery(document.body).on('mousedown', view.selector, event => {
			let e = event.currentTarget
			this.focus(e, true)
		})
        return this.views.push(view) // add.selector can be a selector string, set of elements or function returning elements
    }
    distance(c, e, m){
        let r = Math.hypot(e.left - c.left, e.top - c.top);
        if(m){
            r += r * (this.angleWeight * m)
        }
        return r;
    }
    angle(c, e){
        let dy = e.top - c.top
        let dx = e.left - c.left
        let theta = Math.atan2(dy, dx) // range (-PI, PI]
        theta *= 180 / Math.PI // rads to degs, range (-180, 180]
        theta += 90
        if(theta < 0){
            theta = 360 + theta
        }
        return theta
    }
    isAngleWithinRange(angle, start, end){
        if(end > start){
            return angle >= start && angle <= end
        } else {
            return angle < end || angle > start
        }
    }
    coords(element){
        if(element && typeof(element.getBoundingClientRect) == 'function'){
            let c = element.getBoundingClientRect()
            return {
                left: parseInt(c.left + (c.width / 2)), 
                top: parseInt(c.top + (c.height / 2))
            }
        }
    }
    opposite(selected, items, direction){
        let i, n = items.indexOf(selected), x = this.viewSizeX, y = this.viewSizeY
        switch(direction){
            case 'down':
                i = n % x
                break
            case 'up':
                i = (Math.floor(items.length / x) * x) + (n % x)
                if(i >= items.length){
                    i = ((Math.floor(items.length / x) - 1) * x) + (n % x)
                }
                break
            case 'left':
                i = n
                i += (x - 1)
                if(i >= items.length){
                    i = items.length - 1
                }
                break
            case 'right':
                i = n
                i -= (x - 1)
                if(i < 0){
                    i = 0
                }
                break
        }        
        if(this.debug){
            console.log(i, items.length)
        }
        return items[i]
    }
	angleCenter(angle1, angle2) {
		const diff = (angle2 - angle1 + 360) % 360
		const center = (angle1 + diff / 2) % 360
		return center < 0 ? center + 360 : center
	}
	angleDiff(angle1, angle2Start, angle2End){
		const angle2 = this.angleCenter(angle2Start, angle2End)
		const diff = Math.abs(angle1 - angle2)
		return Math.min(diff, 360 - diff)
	}
    arrow(direction, noCycle){
        let closer, closerDist, directionAngleStart, directionAngleEnd
		let items = this.entries(), view = this.activeView(), e = this.selected()
		switch(direction){
			case 'up':
				directionAngleStart = 270
				directionAngleEnd = 90
				break
			case 'right':
				directionAngleStart = 0
				directionAngleEnd = 180
				break
			case 'down':
				directionAngleStart = 90
				directionAngleEnd = 270
				break
			case 'left':
				directionAngleStart = 180
				directionAngleEnd = 360
				break
		}
		const exy = this.coords(e)
		if(exy) {
			items.forEach(n => {
				if(n != e) {
					const nxy = this.coords(n)
					if(nxy) {
						if(['up', 'down'].includes(direction)) { // avoid bad horizontal moving
							if(nxy.top == exy.top && n.offsetHeight == e.offsetHeight){
								return
							}
						}
						if(['left', 'right'].includes(direction)) { // avoid bad vertical moving
							if(nxy.left == exy.left && n.offsetWidth == e.offsetWidth){
								return
							}
						}
						const angle = this.angle(exy, nxy)
						if(this.isAngleWithinRange(angle, directionAngleStart, directionAngleEnd)){
							const df = this.angleDiff(angle, directionAngleStart, directionAngleEnd)
							const dist = this.distance(exy, nxy, df)
							if(this.debug){
								console.warn('POINTER', dist, {df, angle, directionAngleStart, directionAngleEnd}, direction, e, n, exy, nxy, traceback())
							}
							if(!closer || dist < closerDist || 
								(n.parentNode == e.parentNode && closer.parentNode != e.parentNode)
								){
								closer = n
								closerDist = dist
							}
						}
					}
				}
			})
		} else { // if none selected, pick anyone (first in items for now)
			closer = items[0]
			closerDist = 99999
		}
        if(!closer){
            if(typeof(view.overScrollAction) != 'function' || view.overScrollAction(direction, e) !== true && noCycle !== true){
                closer = this.opposite(e, items, direction)                
                if(this.debug){
                    console.log('opposite', e, items, direction, closer)
                }
            }
        }
        if(closer){
            if(this.debug){
                console.warn('POINTER', closer, closerDist)
            }
            let pst = this._scrollContainer.scrollTop
            this.focus(closer)
            if(view.default){   
                this.lastScrollTop = this._scrollContainer.scrollTop
				if (this.lastScrollTop != pst) {
					if (!this.scrollSnapping) {
						this.scrollDirection = (this.lastScrollTop < pst) ? 'up' : 'down'
					}
					this.emit('scroll', this.lastScrollTop, this.scrollDirection)
				}
            } else {
                closer.scrollIntoViewIfNeeded({ behavior: 'smooth', block: 'nearest', inline: 'start' })
            }
        }
        this.emit('arrow', closer, direction)
    }
}

class ExplorerBBCode extends ExplorerPointer {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.funnyTextColors = ['#3F0', '#39F', '#8d45ff', '#ff02c9', '#e48610']
		this.bbCodeMap = {
			'b': fragment => {
				if(fragment.substr(0, 2) == '[|'){
					return '</strong>'
				}
				return '<strong>'
			},
			'i': fragment => {
				if(fragment.substr(0, 2) == '[|'){
					return '</i>'
				}
				return '<i>'
			},
			'alpha': fragment => {
				if(fragment.substr(0, 2) == '[|'){
					return '</font>'
				}
				return '<font style="color: var(--secondary-font-color); text-shadow: none;">'
			},
			'color': (fragment, name) => {
				if(fragment.substr(0, 2) == '[|'){
					return '</font>'
				}
				let color = name.split(' ').slice(1).join(' ')
				return '<font style="color: '+ color +';">'
			}
		}

	}
	removeBBCode(text){
		return text.replace(new RegExp('\\[\\|?([^\\]]*)\\]', 'g'), '')
	}
	parseBBCode(text){
		if(typeof(this.replaceBBCodeFnPtr) == 'undefined'){
			this.replaceBBCodeFnPtr = this.replaceBBCode.bind(this)
			this.replaceBBCodeFunnyTextFnPtr = this.replaceBBCodeFunnyText.bind(this)
		}
		text = text.replace(new RegExp('\\[fun\\]([^\\[]*)\\[\\|?fun\\]', 'gi'), this.replaceBBCodeFunnyTextFnPtr)
		return text.replace(new RegExp('\\[\\|?([^\\]]*)\\]', 'g'), this.replaceBBCodeFnPtr)
	}
	replaceBBCode(fragment, name, i, text){
		const tag = name.split(' ').shift().toLowerCase()
		if(!this.bbCodeMap[tag]) {
			return text
		}
		return this.bbCodeMap[tag](fragment, name, text)
	}
	replaceBBCodeFunnyText(fragment, name, i, text){
		return this.makeFunnyText(name)
	}
	makeFunnyText(text){
		if(text.indexOf('&') != -1 && text.indexOf('&') != -1){
			text = jQuery('<span />').html(text).text()
		}
		if(!config['kids-fun-titles']){
			return text
		}
		const lettersPerColor = (text.length > 15 ? 3 : (text.length > 5 ? 2 : 1))
		const scales = [1, 0.9675, 0.925]
		const a = (this.funnyTextColors.length * lettersPerColor)
		return text.split('').map((chr, i) => {
			i--
			const scale = i < 2 ? 1 : scales[Math.floor(Math.random()*scales.length)]
			const oi = i
			const r = Math.floor(i / a)
			if(r){
				i -= r * a
			}
			const j = Math.floor(i / (lettersPerColor))
			if(chr == ' ') chr = '&nbsp;'
			return '<font class="funny-text" style="color: '+ (oi == -1 ? 'white' : this.funnyTextColors[j]) +';transform: scale('+ scale +');">'+chr+'</font>'
		}).join('')		
	}
}

class ExplorerModal extends ExplorerBBCode {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.inputHelper = new ExplorerURLInputHelper()
		this.modalTemplates = {}
		this.modalContainer = document.getElementById('modal')
		this.modalContent = this.modalContainer.querySelector('#modal-content')
		this.jmodalContent = jQuery(this.modalContent)
		this.modalContent.parentNode.addEventListener('click', e => {	
            if(e.target.id == 'modal-content'){
				if(!this.inModalMandatory()){
					this.endModal()
				}
            }
		})
	}
	plainText(html) {
		const temp = document.createElement('div');
		temp.innerHTML = html;
		return temp.textContent; // Or return temp.innerText if you need to return only visible text. It's slower.
	}
	inModal(){
		return this.body.hasClass('modal')
	}
	inModalMandatory(){
		return this.inModal() && this.body.hasClass('modal-mandatory')
	}
	startModal(content, mandatory){
		sound('warn', 16)
		this.emit('pre-modal-start')
		this.modalContent.innerHTML = content
		this.body.addClass('modal')
		mandatory && this.body.addClass('modal-mandatory')
		this.inputHelper.start()
		this.reset()
	}
	endModal(cancel){
		if(this.inModal()){
			if(this.debug){
				console.log('ENDMODAL', traceback())
			}
			this.inputHelper.stop()
			this.body.removeClass('modal modal-mandatory')
			this.emit('modal-end', cancel)
			setTimeout(() => this.emit('pos-modal-end'), 100)
		}
	}
	replaceTags(text, replaces, noSlashes) {
		if(replaces['name'] && !replaces['rawname']){
			replaces['rawname'] = replaces['name']
		}
		Object.keys(replaces).forEach(before => {
			let t = typeof(replaces[before])
			if(['string', 'number', 'boolean'].indexOf(t) != -1){
				let addSlashes = typeof(noSlashes) == 'boolean' ? !noSlashes : (t == 'string' && replaces[before].indexOf('<') == -1)
				let to = addSlashes ? this.addSlashes(replaces[before]) : String(replaces[before])
				if(to.indexOf('[') != -1){
					if(before == 'name'){
						to = this.removeBBCode(to)
					} else if(before == 'rawname') {
						to = this.parseBBCode(to)
					}
				}
				text = text.split('{' + before + '}').join(to)
				if(text.indexOf("\r\n") != -1){
					text = text.replace(new RegExp('\r\n', 'g'), '<br />')
				}
			}
		})
		text = text.replace(new RegExp('\\{[a-z\\-]+\\}', 'g'), '')
		return text
	}  
	addSlashes(text){
		return String(text || '').replace(new RegExp('"', 'g'), '&quot;')
	}
}

class ExplorerPlayer extends ExplorerModal {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
	}
	inPlayer(){
		return typeof(streamer) != 'undefined' && streamer.active
	}
	isExploring(){
		return !this.inModal() && (!this.inPlayer() || this.body.hasClass('menu-playing'))
	}
}

class ExplorerFx extends ExplorerPlayer {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.fxNavTimer = 0
		this.fxModalTimer = 0
		this.fxContainer = this.container.find('wrap')
		this.on('pre-render', (newPath, oldPath) => {
			if(typeof(oldPath) != 'string'){
				oldPath = -1
			}
			if(newPath != oldPath){
				if(oldPath == -1 || newPath.length >= oldPath.length){
					this.fxNavIn()
				} else {
					this.fxNavOut()
				}
			}
		})
		this.on('pre-modal-start', () => {
			this.fxPromptIn()
		})
		setTimeout(() => {
			this.fxContainer.parent().add(this.jmodalContent.parent()).addClass('effect-inflate-deflate-parent')
		})
	}
	fxNavIn(){
		if(!config['fx-nav-intensity']) return
		clearTimeout(this.fxNavTimer)
		if(this.fxContainer.hasClass('effect-inflate')){
			this.fxContainer.removeClass('effect-inflate')
			setTimeout(() => this.fxNavIn(), 0)
		} else {
			this.fxContainer.css('transition', 'none').removeClass('effect-deflate').addClass('effect-inflate')
			this.fxNavTimer = setTimeout(() => {
				this.fxContainer.removeClass('effect-inflate')
			}, 1500)
		}
	}
	fxNavOut(){
		if(!config['fx-nav-intensity']) return
		clearTimeout(this.fxNavTimer)
		if(this.fxContainer.hasClass('effect-deflate')){
			this.fxContainer.removeClass('effect-deflate')
			setTimeout(() => {
				this.fxNavOut()
			}, 0)
		} else {
			this.fxContainer.css('transition', 'none').removeClass('effect-inflate').addClass('effect-deflate')
			this.fxNavTimer = setTimeout(() => {
				this.fxContainer.removeClass('effect-deflate')
			}, 1500)
		}
	}
	fxPromptIn(){
		if(!config['fx-nav-intensity']) return
		clearTimeout(this.fxModalTimer)
		if(this.jmodalContent.hasClass('effect-inflate')){
			this.jmodalContent.removeClass('effect-inflate')
			setTimeout(() => this.fxPromptIn(), 0)
		} else {
			this.jmodalContent.css('transition', 'none').removeClass('effect-deflate').addClass('effect-inflate')
			this.fxModalTimer = setTimeout(() => {
				this.jmodalContent.removeClass('effect-inflate')
			}, 1500)
		}
	}
}

class ExplorerDialogQueue extends ExplorerFx {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.dialogQueue = []
		this.on('pos-modal-end', this.nextDialog.bind(this))
	}	
	queueDialog(cb){
		this.dialogQueue.push(cb)
		this.nextDialog()
	}
	nextDialog(){
		if(!this.inModal() && this.dialogQueue.length){
			let next = this.dialogQueue.shift()
			next()
		}
	}
}

class ExplorerDialog extends ExplorerDialogQueue {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.lastSelectTriggerer = false
		this.modalTemplates['option'] = `
			<a href="javascript:;" class="modal-template-option" id="modal-template-option-{id}" title="{plainText}" aria-label="{plainText}">
				{tag-icon}
				<div>
					<div>
						{text}
					</div>
				</div>
			</a>
		`
		this.modalTemplates['option-detailed'] = `
			<a href="javascript:;" class="modal-template-option-detailed" id="modal-template-option-detailed-{id}" title="{plainText}" aria-label="{plainText}">
				<div>
					<div class="modal-template-option-detailed-name">
						{tag-icon} {text}
					</div>
					<div class="modal-template-option-detailed-details">{details}</div>
				</div>
			</a>
		`
		this.modalTemplates['options-group'] = `
			<div class="modal-template-options">
				{opts}
			</a>
		`
		this.modalTemplates['text'] = `
			<span class="modal-template-text" id="modal-template-option-{id}">
				<i class="fas fa-caret-right"></i>
				<input type="text" placeholder="{placeholder}" value="{text}" aria-label="{plainText}" onmousedown="explorer.inputPaste(this)" />
			</span>
		`
		this.modalTemplates['textarea'] = `
			<span class="modal-template-textarea" id="modal-template-option-{id}">
				<span>
					<span>
						<i class="fas fa-caret-right"></i>
					</span>
				</span>
				<textarea placeholder="{placeholder}" rows="3" aria-label="{plainText}">{text}</textarea>
			</span>
		`
		this.modalTemplates['message'] = `
			<span class="modal-template-message" aria-label="{plainText}">
				{text}
			</span>
		`
		this.modalTemplates['slider'] = `
			<span class="modal-template-slider" id="modal-template-option-{id}">
				<span class="modal-template-slider-left"><i class="fas fa-caret-left"></i></span>
				<input type="range" class="modal-template-slider-track" aria-label="{plainText}" />
				<span class="modal-template-slider-right"><i class="fas fa-caret-right"></i></span>
			</span>
		`
		this.modalTemplates['question'] = `
			<span class="modal-template-question" aria-label="{plainText}">
				{tag-icon}{text}
			</span>
		`
		this.modalTemplates['spacer'] = `
			<span class="modal-template-spacer">&nbsp;</span>
		`
	}
	inputPaste(input) {
		if(input.value || !top.navigator.clipboard) return
		top.navigator.clipboard.readText().then(paste => {
			if(paste) {
				paste = paste.trim()
				if(paste.startsWith('http') || paste.startsWith('//')) { // seems URL
					input.value = paste
					input.select()
				}
			}
		}).catch(console.error)
	}
	text2id(txt){
		if(txt.match(new RegExp('^[A-Za-z0-9\\-_]+$', 'g'))){
			return txt
		}
		return Base64.encode(txt).toLowerCase().replace(new RegExp('[^a-z0-9]+', 'gi'), '')
	}
	dialog(entries, cb, defaultIndex, mandatory){
		this.queueDialog(() => {
			if(this.debug){
				console.log('DIALOG', entries, cb, defaultIndex, mandatory, traceback())
			}
			let html = '', opts = '', complete, callback = (k, cancel) => {
				if(complete) return
				if(this.debug){
					console.log('DIALOG CALLBACK', k, parseInt(k), cancel, complete, traceback())
				}
				// k = parseInt(k)
				complete = true
				if(k == -1){
					k = false
				}
				if(k == 'submit'){
					let el = this.modalContainer.querySelector('input, textarea')
					if(el){
						k = el.value
					}
				}
				if(cancel === true) return
				this.endModal()
				if(typeof(cb) == 'function'){
					cb(k)
				} else if(typeof(cb) == 'string'){
					cb = [cb, k]
					if(this.debug){
						console.warn('STRCB', cb)
					}
					this.app.emit.apply(this.app, cb)
				} else if(Array.isArray(cb)){
					cb.push(k)
					if(this.debug){
						console.warn('ARRCB', cb)
					}
					this.app.emit.apply(this.app, cb)
				}
			}
			let validatedDefaultIndex, optsCount = 0, optsHasInput, allowTwoColumnsOptionsGroup = true
			entries = entries.map(e => {
				if(typeof(e.oid) == 'undefined'){
					if(typeof(e.id) != 'undefined'){
						e.oid = e.id
						e.id = this.text2id(e.id)
					}
				}
				return e
			})
			entries.forEach(e => {
				let template = e.template, isOption = ['option', 'option-detailed', 'text', 'slider'].includes(template)
				if(template == 'option' && e.details){
					template = 'option-detailed'
				}
				let tpl = this.modalTemplates[template]
				e['tag-icon'] = ''
				if(e.fa){
					if(e.fa.indexOf('//') == -1){
						e['tag-icon'] = '<i class="'+ e.fa + '"></i> '
					} else {
						e['tag-icon'] = '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=" style="background-image: url(&quot;'+ e.fa +'&quot;);"> '
					}
				}
				if(e.isPassword){
					tpl = tpl.replace('type="text"', 'type="password"')
				}
				if(!e.text){
					e.text = e.plainText || ''
				}
				if(!e.plainText){
					e.plainText = this.plainText(e.text)
				}
				e.text = String(e.text).replaceAll('"', '&quot;')
				e.plainText = String(e.plainText).replaceAll('"', '')
				tpl = this.replaceTags(tpl, e, true)
				if(this.debug){
					console.log(tpl, e)
				}
				if(isOption){
					optsCount++
					if(!validatedDefaultIndex || e.id == defaultIndex){
						validatedDefaultIndex = e.id
					}
					if(e.template == 'text'){
						optsHasInput = true
					}
					if(allowTwoColumnsOptionsGroup){
						if(e.plainText.length > 50){
							allowTwoColumnsOptionsGroup = false
						}
					}
					opts += tpl
				} else {
					html += tpl
				}
			})
			if(opts){
				html += this.replaceTags(this.modalTemplates['options-group'], {opts}, true)
			}
			console.log('MODALFOCUS', defaultIndex, validatedDefaultIndex, allowTwoColumnsOptionsGroup)
			this.startModal('<div class="modal-wrap"><div>' + html + '</div></div>', mandatory)
			let m = this.modalContent
			if(allowTwoColumnsOptionsGroup){
				let fitVal = 2, overVal	= 4
				if(optsHasInput){
					fitVal = 3, overVal	= 4
				}
				if(optsCount == fitVal || optsCount >= overVal){
					let grp = m.querySelector('.modal-template-options')
					if(grp){
						grp.className = grp.className +' two-columns'
					}
				}
			}
			entries.forEach(e => {
				if(['option', 'option-detailed'].includes(e.template)){
					let p = m.querySelector('#modal-template-option-' + e.id+', #modal-template-option-detailed-' + e.id)
					if(p){
						p.addEventListener('click', () => {
							let id = e.oid || e.id
							if(this.debug){
								console.log('OPTCLK', id, callback)
							}
							callback(id)
						})
						if(String(e.id) == String(validatedDefaultIndex)) {						
							const ensureFocus = () => this.focus(p, false);
							[150, 400, 800].forEach(ms => setTimeout(ensureFocus, ms))
						}
					}
				} else if(e.template == 'message') {						
					if(e.text.indexOf('<i ') != -1) {
						Array.from(m.querySelectorAll('.modal-template-message i')).forEach(s => {
							s.parentNode.style.display = 'block'
						})						
					}
				}
			})
			this.on('modal-end', cancel => {
				complete || callback(-1, cancel)
			})
			this.emit('dialog-start')
		})
	}
	info(title, text, cb, fa){
		this.queueDialog(() => {
			let complete, mpt = [
				{template: 'question', text: title, fa: fa || 'fas fa-info-circle'},
				{template: 'message', text},
				{template: 'option', text: 'OK', id: 'submit', fa: 'fas fa-check-circle'}
			];
			this.dialog(mpt, id => {
				if(!complete){
					if(this.debug){
						console.log('infocb', id, complete)
					}
					complete = true
					this.endModal()
					if(typeof(cb) == 'function'){
						cb()
					}
				}
				return true
			}, 'submit')
		})
	}
}

class ExplorerSelect extends ExplorerDialog {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
	}
	select(question, entries, fa, callback){
		let def, map = {}, opts = [
			{template: 'question', text: question, fa}
		]
		opts.push(...entries.map(e => {
			e.template = 'option'
			if(!e.text){
				e.text = String(e.name)
			}
			e.id = this.text2id(e.text)
			map[e.id] = e.text
			if(e.selected){
				e.fa = 'fas fa-check-circle'
				def = e.id
			}
			return e
		}))
		if(this.debug){
			console.warn('SELECT', entries)
		}
		this.dialog(opts, k => {
			if(typeof(map[k]) != 'undefined'){
				k = map[k]
			}
			callback(k)
			this.endModal()
			this.emit('select-end', k)
		}, def)
		this.emit('select-start')
	}
}

class ExplorerOpenFile extends ExplorerSelect {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.openFileDialogChooser = false
	}	
	openFile(uploadURL, cbID, accepts){
		return new Promise((resolve, reject) => {
			if(!this.openFileDialogChooser){ // JIT
				this.openFileDialogChooser = this.j('<input type="file" />')
				this.body.append(this.openFileDialogChooser)			
			}
			this.openFileDialogChooser.get(0).value = ""
			if(accepts){
				this.openFileDialogChooser.attr("accept", accepts)
			} else {
				this.openFileDialogChooser.removeAttr("accept")
			}
			this.openFileDialogChooser.off('change')
			this.openFileDialogChooser.on('change', (evt) => {
				if(this.openFileDialogChooser.get(0).files.length && this.openFileDialogChooser.get(0).files[0].name){
					this.sendFile(uploadURL, this.openFileDialogChooser.get(0).files[0], this.openFileDialogChooser.val(), cbID).then(resolve).catch(reject)
				} else {
					reject('Bad upload data')
				}
			})
			this.openFileDialogChooser.trigger('click')
		})
	}
	sendFile(uploadURL, fileData, path, cbID){
		return new Promise((resolve, reject) => {
			window.filesent = fileData
			let formData = new FormData()
			if(cbID){
				formData.append('cbid', cbID)
			}
			formData.append('file', fileData)
			this.j.ajax({
				type: "POST",
				url: uploadURL,
				data: formData,
				success: resolve,
				error: reject,
				cache: false,
				processData: false,  // tell jQuery not to process the data
				contentType: false   // tell jQuery not to set contentType
			})	
		})
	}
}

class ExplorerPrompt extends ExplorerOpenFile {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
	}
	prompt(atts){
		if(this.debug){
			console.log('PROMPT', atts)
		}
		let p, opts = [
			{template: 'question', text: atts.question, fa: atts.fa}
		]
		if(atts.message){
			opts.splice(1, 0, {template: 'message', text: atts.message})
		}
		opts.push({
			template: atts.multiline ? 'textarea' : 'text',
			text: atts.defaultValue || '',
			id: 'text',
			isPassword: atts.isPassword,
			placeholder: atts.placeholder
		})
		if(Array.isArray(atts.extraOpts) && atts.extraOpts.length){
			opts.push(...atts.extraOpts)
		} else {
			opts.push({template: 'option', text: 'OK', id: 'submit', fa: 'fas fa-check-circle'})
		}
		this.dialog(opts, id => {
			let ret = true
			if(this.debug){
				console.log('PROMPT CALLBACK', id, atts.callback, typeof(atts.callback))
			}
			if(typeof(atts.callback) == 'function'){
				ret = atts.callback(id)
			} else if(typeof(atts.callback) == 'string'){
				atts.callback = [atts.callback, id]
				if(this.debug){
					console.warn('STRCB', atts.callback)
				}
				this.app.emit.apply(this.app, atts.callback)
			} else if(Array.isArray(atts.callback)){
				atts.callback.push(id)
				if(this.debug){
					console.warn('ARRCB', atts.callback)
				}
				this.app.emit.apply(this.app, atts.callback)
			}
			if(ret !== false){
				this.endModal()
				this.emit('prompt-end', id)
			}
		}, 'submit')
		this.inputHelper.stop()

		this.emit('prompt-start')

		p = this.modalContent.querySelector('#modal-template-option-submit')
		if(p){
			p.addEventListener('keypress', (event) => {
				if(this.debug){
					console.log(event.keyCode)
				}
				if (event.keyCode != 13) {
					arrowUpPressed()
				}
			})
		}
		arrowUpPressed()
		setTimeout(() => {
			this.inputHelper.start()
		}, 200)
	}
}

class ExplorerSlider extends ExplorerPrompt {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
	}
	sliderSetValue(element, value, range, mask){
		const n = element.querySelector('.modal-template-slider-track')
		if(this.debug){
			console.warn('BROOW', n.value, value, traceback())
		}
		n.value = value
		this.sliderSync(element, range, mask)
	}
	sliderVal(element, range){
		const n = element.querySelector('.modal-template-slider-track'), value = parseInt(n.value)
		return value
	}
	sliderSync(element, range, mask){
		var l, h, n = element.querySelector('.modal-template-slider-track'), t = (range.end - range.start), step = 1, value = parseInt(n.value)
		// console.warn('SLIDERSYNC', {element, range, mask, step, value, val: this.sliderVal(element, range)})
		if(value < range.start){
			value = range.start
		} else if(value > range.end){
			value = range.end
		}
		if(this.debug){
			console.warn('SLIDER VALUE A', element, element.querySelector('.modal-template-slider-track').value, n.value)
		}
		var message
		if(mask == 'time'){
			message = clock.humanize(value, true)
		} else if(mask) {
			message = mask.replace(new RegExp('\\{0\\}'), value || '0')
		} else {
			message = value || '0'
		}
		h = element.parentNode.parentNode.querySelector('.modal-template-question')		
		h.innerHTML = h.innerHTML.split(': ')[0] + ': '+ message
		if(this.debug){
			console.warn('SLIDER VALUE B', value, '|', n, n.style.background, l, t, range, step, n.value)
		}
		if(value <= range.start){
			element.querySelector('.fa-caret-left').style.opacity = 0.1
			element.querySelector('.fa-caret-right').style.opacity = 1
		} else if(value >= range.end){
			element.querySelector('.fa-caret-left').style.opacity = 1
			element.querySelector('.fa-caret-right').style.opacity = 0.1
		} else {
			element.querySelector('.fa-caret-left').style.opacity = 1
			element.querySelector('.fa-caret-right').style.opacity = 1
		}
	}
	sliderIncreaseLeft(e, value, range, mask, step){
		if(this.debug){
			console.warn(e, value)
		}
		if(value >= range.start){
			value -= step
			if(value < range.start){
				value = range.start
			}
		}
		value = parseInt(value)
		this.sliderSetValue(e, value, range, mask)
		return value
	}
	sliderIncreaseRight(e, value, range, mask, step){
		if(value <= range.end){
			value += step
			if(value > range.end){
				value = range.end
			}
		}
		value = parseInt(value)
		this.sliderSetValue(e, value, range, mask)
		return value
	}
	slider(question, message, range, value, mask, callback, fa){
		let s, n, e, step = 1
		let opts = [
			{template: 'question', text: question, fa}
		]
		if(message && message != question) {
			opts.push({template: 'message', text: message})
		}
		opts.push({template: 'option', text: 'OK', fa: 'fas fa-check-circle', id: 'submit'})
		this.dialog(opts, ret => {
			if(ret !== false){
				ret = this.sliderVal(e, range)
				if(callback(ret) !== false){
					this.endModal()
					this.emit('slider-end', ret, e)
				}
			}
		}, '')

		s = this.j(this.modalContent.querySelector('#modal-template-option-submit'))
		s.before(this.modalTemplates['slider'])
		s.on('keydown', event => {
			switch(event.keyCode) {
				case 13:  // enter
					const g = s.get()
					g && g.submit && g.submit()
					break
				case 37: // left
					event.preventDefault()
					value = this.sliderIncreaseLeft(e, value, range, mask, step)
					break
				case 39:  // right
					event.preventDefault()
					value = this.sliderIncreaseRight(e, value, range, mask, step)
					break
			}
		})
		n = this.modalContent.querySelector('.modal-template-slider-track')
		e = n.parentNode
		if(this.debug){
			console.warn('SLIDER VAL', e, n)
		}
		this.emit('slider-start')
		n.setAttribute('min', range.start)
		n.setAttribute('max', range.end)
		n.setAttribute('step', step)
		n.addEventListener('input', () => this.sliderSync(e, range, mask))
		n.addEventListener('change', () => this.sliderSync(e, range, mask))
		n.parentNode.addEventListener('keydown', event => {
			console.log('SLIDERINPUT', event, s)
			switch(event.keyCode) {
				case 13:  // enter
					s.get(0).click()
					break
			}
		})
		// console.log('SLIDER SETUP VALUE', {e, value, range, mask})
		this.sliderSetValue(e, value, range, mask)
		this.modalContent.querySelector('.modal-template-slider-left').addEventListener('click', event => {
			value = this.sliderIncreaseLeft(e, value, range, mask, step)
		})
		this.modalContent.querySelector('.modal-template-slider-right').addEventListener('click', event => {
			value = this.sliderIncreaseRight(e, value, range, mask, step)
		})
	}
}

class ExplorerStatusFlags extends ExplorerSlider {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.statusFlags = {}
		this.on('render', this.processStatusFlags.bind(this))
		this.on('update-range', this.processStatusFlags.bind(this))
		this.app.on('stream-state-set', (url, flag) => {
			if(this.debug){
				console.warn('SETFLAGEVT', url, flag)
			}
			this.setStatusFlag(url, flag)
		})
		this.app.on('stream-state-sync', data => {
			if(this.debug){
				console.warn('SYNCSTATUSFLAGS', data)
			}
			Object.keys(data).forEach(url => {
				this.setStatusFlag(url, data[url], true)
			})
			this.processStatusFlags()
		})
	}
	setStatusFlag(url, flag, skipProcessing){	
		if(url){
			if(typeof(this.statusFlags[url]) == 'undefined' || this.statusFlags[url] != flag){
				this.statusFlags[url] = flag
				if(skipProcessing !== true){
					this.processStatusFlags()
				}
			}
		}
	}
	processStatusFlags(){
		this.currentEntries.forEach((e, i) => {
			if(!this.ranging || (i >= (this.range.start - 1) && i <= this.range.end)){ // -1 added due to entry-2x entries possibility
				if(e.url && typeof(this.statusFlags[e.url]) != 'undefined'){
					let element = this.currentElements[i], status = this.statusFlags[e.url], type = element ? element.getAttribute('data-type') : '', cls = e.class || ''
					if(status && element && cls.indexOf('skip-testing') == -1 && (cls.indexOf('allow-stream-state') != -1 || !['spacer', 'action'].includes(type))){
						let content = ''
						if(status == 'tune'){
							content = '<i class="fas fa-layer-group"></i>'
						} else if(status == 'folder') {
							content = '<i class="fas fa-box-open"></i>'
						} else if(status == 'waiting') {
							content = '<i class="fas fa-clock"></i>'
						} else {
							let watched
							if(status.endsWith(',watched')) {
								watched = true
								status = status.substr(0, status.length - 8)
							}
							let cls = '', txt = '', icon = watched ? 'fas fa-check' : ''
							if(status == 'offline') {
								if(!watched) icon = 'fas fa-times'
								cls = 'entry-status-flag-failure'
							} else {
								if(!watched) icon = 'fas fa-play'
								cls = 'entry-status-flag-success'
								if(config['status-flags-type']) txt = ' '+ status
							}
							content = '<i class="'+ icon +'" aria-hidden="true"></i>'+ txt
							if(config['status-flags-type']) content = '&nbsp;'+ content +'&nbsp;'
							content = '<span class="entry-status-flag '+ cls +'">'+ content +'</span>'
						}
						this[status == 'offline' ? 'addClass' : 'removeClass'](element, 'entry-disabled')
						if(content != element.getAttribute('data-status-flags-html')){
							element.setAttribute('data-status-flags-html', content)
							const flagsElement = element.querySelector('.entry-status-flags')
							if(flagsElement) {
								flagsElement.innerHTML = content
							}
						}
					}
				}
			}
		})
	}
}

class ExplorerLoading extends ExplorerStatusFlags {
	constructor(jQuery, container, app){
		super(jQuery, container, app)
		this.originalNames = {}
		this.app.on('set-loading', (data, active, txt) => {
			if(!data) return
			console.log('set-loading', data, active, txt)
			this.get(data).forEach(e => {
				this.setLoading(e, active, txt)
			})
		})
		this.app.on('unset-loading', () => {
			this.unsetLoading()
		})
		this.app.on('display-error', () => {
			this.unsetLoading()
		})
	}
	setLoading(element, active, txt){
		const e = this.j(element), c = e.find('.entry-name label')
		console.log('setLoading', element, c, active, txt)
		if(active){
			if(!e.hasClass('entry-loading')){
				if(!txt){
					txt = lang.OPENING
				}
				e.addClass('entry-loading')
				let t = c.html(), path = element.getAttribute('data-path')
				if(typeof(path) == 'string' && t != txt){
					this.originalNames[path] = t
					c.html(txt)
				}
			}
		} else {
			if(e.hasClass('entry-loading')){
				e.removeClass('entry-loading')
				let path = element.getAttribute('data-path')
				if(typeof(this.originalNames[path]) == 'string'){
					c.html(this.originalNames[path])
				}
			}
		}
	}
	unsetLoading(){ // remove any loading state entry
		this.container.find('a.entry-loading').each((i, e) => {
			this.setLoading(e, false)
		})
	}
}

class Explorer extends ExplorerLoading {
	constructor(jQuery, container, app){
		super(jQuery, container, app)	
		this.app.on('menu-playing', () => {			
			if(!this.body.hasClass('menu-playing')){
				this.body.addClass('menu-playing')
				this.app.emit('explorer-menu-playing', true)
				setTimeout(() => this.reset(), 100)
			}
		})	
		this.app.on('render', (entries, path, icon) => {
			this.render(entries, path, icon)
		})
		this.app.on('explorer-select', (entries, path, icon) => {
			this.setupSelect(entries, path, icon)
		})
		this.app.on('info', (a, b, c) => {
			this.info(a, b, null, c)
		})
		this.app.on('dialog', (a, b, c, d) => {
			this.dialog(a, b, c, d)
		})
		this.app.on('dialog-close', cancel => {
			this.endModal(cancel)
		})
		this.app.on('prompt', atts => this.prompt(atts))
		this.initialized = false
		this.currentEntries = []
		this.currentElements = []
		this.range = {start: 0, end: 99}
		this.lastOpenedElement = null // prevent multi-click
		this.lastOpenedElementTime = null; // prevent multi-click
		['header', 'footer'].forEach(n => {
			this[n] = this.container.find(n)
		})
		this.icons = {
			'back': 'fas fa-chevron-left',
			'action': 'fas fa-cog',
			'slider': 'fas fa-cog',
			'input': 'fas fa-keyboard',
			'stream': 'fas fa-play-circle',
			'check': 'fas fa-toggle-off',
			'group': 'fas fa-box-open'
		}
		this.ranging = false
		this.content = this.container.find('content')
		this.wrapper = this.content.find('wrap')
		this._wrapper = this.wrapper.get(0)
		this.templates = {
			default: `
<a tabindex="{tabindex}" href="{url}" title="{name}" aria-label="{name}" data-original-icon="{fa}" data-path="{path}" data-type="{type}" onclick="explorer.action(event, this)" class="{class}">
	<span class="entry-wrapper">
		<span class="entry-data-in">
			<span class="entry-name" aria-hidden="true">
				<span class="entry-status-flags"></span>
				<label>{prepend}{rawname}</label>
			</span>
			<span class="entry-details">{details}</span>
		</span>
		<span class="entry-icon-image">
			<i class="{fa}" aria-hidden="true"></i>
		</span>
	</span>
</a>`,
			spacer: `
<a tabindex="{tabindex}" href="{url}" title="{name}" aria-label="{name}" data-path="{path}" data-type="{type}" onclick="explorer.action(event, this)" class="{class}">
	<span class="entry-wrapper">
		<span class="entry-data-in">
			<span class="entry-name" aria-hidden="true">
				<label>{rawname}</label>
			</span>
		</span>
		<span class="entry-icon-image">
			<i class="{fa}" aria-hidden="true"></i>
		</span>
	</span>
</a>`,
			input: `
<a tabindex="{tabindex}" href="{url}" title="{name}" aria-label="{name}" data-default-value="{value}" data-original-icon="{fa}" data-question="{question}" data-path="{path}" data-type="{type}" data-multiline="{multiline}" data-placeholder="{placeholder}" onclick="explorer.action(event, this)" class="{class}">
	<span class="entry-wrapper">
		<span class="entry-data-in">
			<span class="entry-name" aria-hidden="true">
				<span class="entry-status-flags"></span>
				<label>{prepend}{rawname}</label>
			</span>
			<span class="entry-details">{details}</span>
		</span>
		<span class="entry-icon-image">
			<i class="{fa}" aria-hidden="true"></i>
		</span>
	</span>
</a>`,
			select: `
<a tabindex="{tabindex}" href="{url}" title="{name}" aria-label="{name}" data-original-icon="{fa}" data-question="{question}" data-path="{path}" data-type="{type}" onclick="explorer.action(event, this)" class="{class}">
	<span class="entry-wrapper">
		<span class="entry-data-in">			
			<span class="entry-name" aria-hidden="true">
				<span class="entry-status-flags"></span>
				<label>{prepend}{rawname}</label>
			</span>
			<span class="entry-details">{details} {value}</span>
		</span>
		<span class="entry-icon-image">
			<i class="{fa}" aria-hidden="true"></i>
		</span>
	</span>
</a>`,
			slider: `
<a tabindex="{tabindex}" href="{url}" title="{name}" aria-label="{name}" data-default-value="{value}" data-range-start="{range.start}" data-range-end="{range.end}" data-mask="{mask}" data-original-icon="{fa}" data-question="{question}" data-details="{details}" data-path="{path}" data-type="{type}" onclick="explorer.action(event, this)" class="{class}">
	<span class="entry-wrapper">
		<span class="entry-data-in">		
			<span class="entry-name" aria-hidden="true">
				<span class="entry-status-flags"></span>
				<label>{prepend}{rawname}</label>
			</span>
			<span class="entry-details">{value}</span>
		</span>
		<span class="entry-icon-image">
			<i class="{fa}" aria-hidden="true"></i>
		</span>
	</span>
</a>`,
			check: `
<a tabindex="{tabindex}" href="{url}" title="{name}" aria-label="{name}" data-icon="" data-path="{path}" data-type="{type}" onclick="explorer.action(event, this)" class="{class}">
	<span class="entry-wrapper">
		<span class="entry-data-in">		
			<span class="entry-name" aria-hidden="true">
				<span class="entry-status-flags"></span>
				<label>{prepend}{rawname}</label>
			</span>
			<span class="entry-details">{details}</span>
		</span>
		<span class="entry-icon-image">
			<i class="fas fa-toggle-{checked} entry-logo-fa" aria-hidden="true"></i>
		</span>
	</span>
</a>`
		}                      
		this.app.on('trigger', data => {
			if(this.debug){
				console.warn('TRIGGER', data)
			}
			this.get(data).forEach(e => {
				e.click()
			})
		})                   
	}
	get(data){
		let ss = []
		if(data){
			let ks = Object.keys(data)
			if(ks.length){
				this.currentEntries.forEach((e, i) => {
					if(ks.every(k => data[k] == e[k])){
						ss.push(this.currentElements[i])
					}
				})
			}
		}
		return ss
	}
	diffEntries(a, b){
		let diff;
		['name', 'details', 'fa', 'type', 'prepend', 'class'].some(p => { // url comparing give false positives like undefined != javascript:;
			if(a[p] != b[p]){
				diff = p
				return true
			}
		})
		return diff
	}
	insertElementAt(e, i){
		if(i == 0){
			this.wrapper.prepend(e)
		} else {
			let dest = this.wrapper.find('a:nth-child('+ i +')')
			if(!dest.length) dest = this.wrapper.find('a').last()
			dest.after(e)
		}
	}
	adaptiveRender(entries, path, prevEntries){
		let move
		entries.forEach((e, j) => {
			let ne, flag = -1
			if(!['check', 'slider', 'action'].includes(e.type)){
				prevEntries.some((n, i) => {
					let diff = this.diffEntries(e, n)
					if(!diff){
						ne = this.currentElements[i]
						if(!ne) return
						if(i != j) {
							move = true
							flag = 2 // DOM element was moved
						} else {
							flag = 1 // DOM element was kept
						}
						ne.setAttribute('tabindex', j)
						return true
					}
				})
			}
			if(!ne) {
				let tpl = this.templates['default']
				if(typeof(this.templates[e.type]) != 'undefined') {
					tpl = this.templates[e.type]
				}
				ne = jQuery(this.renderEntry(e, tpl, path)).get(0)
				flag = 3 // DOM element was generated
				move = true
			}
			if(!move) return
			this.insertElementAt(ne, j)
		})
		this.wrapper.find('a:gt('+ (entries.length - 1) +')').remove()
	}
	render(entries, path, icon){
		this.rendering = true
		clearTimeout(this.scrollingTimer)
		let html = '', targetScrollTop = 0
		if(typeof(this.selectionMemory[path]) != 'undefined') {
			targetScrollTop = this.selectionMemory[path].scroll
		}
		let prevEntries, useAdaptiveRender = path == this.path
		if(useAdaptiveRender) {
			prevEntries = this.currentEntries
		}
		this.currentEntries = entries
		entries = this.getRange(targetScrollTop)
		this.emit('pre-render', path, this.path)
		this.path = path
		this.lastOpenedElement = null
		if(useAdaptiveRender) {
			this.adaptiveRender(entries, path, prevEntries)
			prevEntries = null
		} else {
			this._scrollContainer.style.minHeight = (targetScrollTop + this._scrollContainer.offsetHeight) + 'px' // avoid scrolling
			entries.forEach(e => {
				let tpl = this.templates['default']
				if(typeof(this.templates[e.type]) != 'undefined'){
					tpl = this.templates[e.type]
				}
				html += this.renderEntry(e, tpl, path)
			})
			css('span.entry-wrapper {visibility: hidden; }', 'explorer-render-hack')
			this.scrollTop(targetScrollTop)
			this._wrapper.innerHTML = html
			this.scrollTop(targetScrollTop)
		}
		this.currentElements = Array.from(this._wrapper.getElementsByTagName('a'))
		setTimeout(() => {
			this.restoreSelection() // keep it in this timer, or the hell gates will open up!
			this.rendering = false
			this.emit('render', this.path, icon)
        	if(!useAdaptiveRender) {
				this._scrollContainer.style.minHeight = 0
				css('span.entry-wrapper {visibility: inherit;}', 'explorer-render-hack')
			}
			if(!this.initialized){
				this.initialized = true
				this.emit('init')
			}
			this.restoreSelection() // redundant, but needed
		}, 0)
	}
	viewportRange(scrollTop, entriesCount){
		let limit = (this.viewSizeX * this.viewSizeY)
		if(this.currentElements.length){ // without elements (not initialized), we can't calc the element height
			if(typeof(scrollTop) != 'number'){
				scrollTop = wrap.scrollTop
			}
			if(typeof(entriesCount) != 'number'){
				entriesCount = this.currentElements.length
			}
			let entryHeight = this.currentElements[0].offsetHeight
			let i = Math.round(scrollTop / entryHeight) * this.viewSizeX
			return {start: i, end: Math.min(i + limit, entriesCount - 1)}
		} else {
			return {start: 0, end: limit}
		}
	}
	viewportEntries(onlyWithIcons){
		let ret = [], as = this.currentElements
		if(as.length){
			let range = this.viewportRange()
			ret = as.slice(range.start, range.end)
			if(onlyWithIcons){
				ret = ret.filter(a => {
					return a.getAttribute('data-icon')
				})
			}
		}
		return ret
	}
    getRange(targetScrollTop){
		if(typeof(targetScrollTop) != 'number'){
			targetScrollTop = this._wrapper.scrollTop
		}
		this.ranging = false
		let entries = [], tolerance = this.viewSizeX, vs = Math.ceil(this.viewSizeX * this.viewSizeY), minLengthForRanging = vs + (tolerance * 2), shouldRange = config['show-logos'] && this.currentEntries.length >= minLengthForRanging
		if(targetScrollTop == 0){
			this.range = {start: 0, end: vs}
		} else {
			this.range = this.viewportRange(targetScrollTop, this.currentEntries.length)
			this.range.end = this.range.start + (vs -1)
		}
		if(shouldRange){
			let trange = Object.assign({}, this.range)
			trange.end += tolerance
			if(trange.start >= tolerance){
				trange.start -= tolerance
			}
			this.currentEntries.forEach((e, i) => {
				let lazy = i < trange.start || i > trange.end
				entries[i] = Object.assign({lazy}, e)
				if(lazy && !this.ranging) {
					this.ranging = true
				}
			})
		} else {
			entries = this.currentEntries.slice(0)
		}
        return entries
    }
	updateRange(y){
		if(this.ranging){
			const changed = [], shouldUpdateRange = config['show-logos'] && this.currentEntries.length > (this.viewSizeX * this.viewSizeY)
			if(shouldUpdateRange){
				let rgx = new RegExp('<img', 'i'), elements = this.currentElements, entries = this.getRange(y || this._wrapper.scrollTop)
				//console.log('selectionMemory upadeteRange', entries)
				if(this.debug){
					console.warn("UPDATING RANGE", entries, traceback())
				}
				entries.forEach(e => {
					if(!elements[e.tabindex]) return
					let type = elements[e.tabindex].getAttribute('data-type')
					let update = e.lazy != (type == 'spacer')
					if(update && e.lazy){
						if(!elements[e.tabindex].innerHTML.match(rgx)){
							e.lazy = false
							update = false
						}
					}
					if(this.debug){
						console.warn(e.type, type, elements[e.tabindex], e.tabindex, this.selectedIndex)
					}
					if(update){
						if(e.lazy){
							e.type = 'spacer'
						}
						let tpl = this.templates['default']
						if(typeof(this.templates[e.type]) != 'undefined'){
							tpl = this.templates[e.type]
						}
						let n = this.j(this.renderEntry(e, tpl, this.path)).get(0)
						elements[e.tabindex].parentNode.replaceChild(n, elements[e.tabindex])
						changed.push(n)
					}
				})
				this.app.emit('explorer-update-range', this.range, this.path)
				if(changed.length){
					if(this.debug){
						console.warn('UPDATING', changed, this.selectedIndex, this.range)
						//console.log('selectionMemory updateRange', changed, this.selectedIndex, this.range, this._wrapper.scrollTop)
					}
					this.currentElements = Array.from(this._wrapper.getElementsByTagName('a'))
					this.emit('update-range', changed)
					if(this.selectedIndex < this.range.start || this.selectedIndex >= this.range.end){
						this.focus(this.currentElements[this.range.start], true)
					} else {
						this.focus(this.currentElements[this.selectedIndex], true)
					}
				}
			}
		}
	}
	delayedFocus(element, avoidScroll){
		setTimeout(() => {
			if(this.debug){
				console.warn('DELAYED FOCUS', element)
			}
			this.focus(element, avoidScroll)
		}, 50)
	}
	check(element){
		sound('switch', 12)
		const path = element.getAttribute('data-path'), value = !element.querySelector('.fa-toggle-on')
		element.querySelector('.entry-icon-image').innerHTML = '<i class="fas fa-toggle-'+ (value?'on':'off') +' entry-logo-fa" aria-hidden="true"></i>'
		if(this.debug){
			console.warn('NAVCHK', path, value)
		}
		this.app.emit('explorer-check', path, value)
	}
	setupSelect(entries, path, fa){
		const element = this.container.find('[data-path="'+ path.replaceAll('"', '&quot;') +'"]').eq(0)
		if(element && element.length){
			let icon = element.find('img')
			if(icon && icon.length){
				icon = icon.css('background-image')
				if(icon){
					icon = icon.match(new RegExp('url\\([\'"]*([^\\)\'"]*)', 'i'))
					if(icon){
						fa = icon[1]
					}
				}
			}
		}
		if(!Array.isArray(entries)){
			return
		}
		this.select(path.split('/').pop(), entries, fa, retPath => {
			console.warn('NAVSELECT', path, entries, retPath)
			if(retPath){
				if(this.debug){
					console.warn('NAVSELECT', path, entries, retPath)
				}
				let actionPath = path +'/'+ retPath
				entries.some(e => {
					if(e.name == retPath){						
						if(e.path){
							actionPath = e.path
						}
						return true
					}
				})
				this.app.emit('explorer-open', actionPath)
				if(element){
					element.attr('data-default-value', retPath)
				}
			}
			this.delayedFocus(this.lastSelectTriggerer, true)
		})
	}
	setupSlider(element){
		const path = element.getAttribute('data-path')
		const start = parseInt(element.getAttribute('data-range-start') || 0)
		const end = parseInt(element.getAttribute('data-range-end') || 100)
		const mask = element.getAttribute('data-mask')
		const def = this.currentEntries[element.tabIndex].value || ''
		const fa = element.getAttribute('data-original-icon') || ''
		const question = element.getAttribute('data-question') || element.getAttribute('title')
		const message = element.getAttribute('data-details')
		this.slider(question, message, {start, end}, parseInt(def || 0), mask, value => {
			if(value !== false){
				if(this.debug){
					console.warn('NAVINPUT', path, value)
				}
				this.app.emit('explorer-input', path, value)
				if(this.currentEntries[element.tabIndex]) {
					this.currentEntries[element.tabIndex].value = value
				}
				element.setAttribute('data-default-value', value)
				this.emit('input-save', element, value)
			}
			this.delayedFocus(element, true)
		}, fa)
	}
	open(element){
		this.focus(element, true)
		let timeToLock = 3, path = element.getAttribute('data-path'), type = element.getAttribute('data-type'), tabindex = element.tabIndex || 0
		if(type == 'spacer'){
			type = this.currentEntries[tabindex].type
		}
		if(this.lastOpenedElement == element && ['back', 'stream', 'group'].includes(type) && ((this.lastOpenedElementTime + timeToLock) > time())){
			if(this.debug){
				console.log('multi-click prevented')
			}
			return
		}
		this.lastOpenedElement = element
		this.lastOpenedElementTime = time()
		this.j(element).one('blur', () => {
			this.lastOpenedElement = null
		})
		switch(type){
			case 'back':
				sound('click-out', 3)
				break
			case 'group':
				sound('click-in', 4)
				break
			case 'stream':
				sound('warn', 16)
				break
		}
		if(tabindex == '{tabindex}'){
			tabindex = false
		}
		if(type == 'back'){
			path = path.split('/')
			path.pop()
			path.pop()
			path = path.join('/')
			this.setLoading(element, true, lang.RETURNING)
		} else if(type == 'group'){
			this.setLoading(element, true)
		}
		if(this.debug){
			console.warn('explorer-open', path, type, tabindex || false)
		}
		this.emit('open', type, path, element, tabindex || false)
		if(type == 'action'){
			this.app.emit('explorer-action', path, tabindex || false)
		} else if(type == 'back'){
			this.app.emit('explorer-back')
		} else if(type == 'select'){
			this.lastSelectTriggerer = element
			this.app.emit('explorer-select', path, tabindex || false)
		} else {
			this.app.emit('explorer-open', path, tabindex || false)
		}
	}
	action(event, element){
		let type = element.getAttribute('data-type')
		console.log('action', type, event, element)
		switch(type){
			case 'slider':
				this.setupSlider(element)
				break
			case 'check':
				this.check(element)
				break
			default:
				if(type == 'select'){
					this.lastSelectTriggerer = this
				}
				this.open(element)
		}
		event.preventDefault()
		event.stopPropagation()
		return false
	}
	triggerAction(path, name){
		return new Promise((resolve, reject) => {
			let failed, timer = 0
			this.once('render', p => {
				if(failed){
					return
				}
				clearTimeout(timer)
				if(p == path){
					let n = -1
					this.currentEntries.some((e, i) => {
						if(e.name == name){
							n = i 
							return true
						}
					})
					if(n != -1){
						this.currentElements[n].click()
						resolve(true)
					} else {
						reject('option not found')
					}
				} else {
					reject('navigation failed')
				}
			})
			this.app.emit('explorer-open', path)
			timer = setTimeout(() => {
				failed = true
				reject('timeout')
			}, 5000)
		})
	}
	prepareEntry(e, path){
		if(!e.url){
			e.url = 'javascript:;'
		}
		if(!e.type){
			e.type = typeof(e.url) ? 'stream' : 'action'
		}
		if(!e.fa){
			if(!e.icon || e.icon.indexOf('fa-') == -1){
				if(this.icons[e.type]){
					e.fa = 'fas ' + this.icons[e.type]
				}
			} else {
				e.fa = e.icon
			}
		}
		if(!e.path){
			e.path = path
			if(e.path){
				e.path +=  '/'
			}
			e.path += e.name
		}
		return e
	}
	renderEntry(e, tpl, path){
		e = this.prepareEntry(e, path)
		const reps = {}
		Object.keys(e).forEach(k => {
			if(k == 'range') {
				reps['range.start'] = e[k].start || 0
				reps['range.end'] = e[k].end || 100
			} else if (k == 'value' && typeof(e['mask']) == 'string') {
				if(e.mask == 'time'){
					reps[k] = clock.humanize(e[k], true)
				} else {
					reps[k] = e.mask.replace('{0}', e[k])
				}
			} else {
				reps[k] = e[k] || ''
			}
		})
		if(e.type && e.type == 'check'){
			reps['checked'] = e['value'] ? 'on' : 'off'
		}
		tpl = this.replaceTags(tpl, reps)
		if(e.color){
			tpl = tpl.replace(new RegExp('<i ', 'i'), '<i style="color: ' + e.color + ';" ')
		}
		return tpl
	}
}