function makeAlert(title, text, klass, textOnly) {
    if(!klass) {
        klass = "alert-info";
    }

    var wrap = $("<div/>").addClass("col-md-12");

    var al = $("<div/>").addClass("alert")
        .addClass(klass)
        .appendTo(wrap);
    textOnly ? al.text(text) : al.html(text) ;

    $("<br/>").prependTo(al);
    $("<strong/>").text(title).prependTo(al);
    $("<button/>").addClass("close pull-right").html("&times;")
        .on('click', function() {
            al.hide("fade", function() {
                wrap.remove();
            });
        })
        .prependTo(al);
    return wrap;
}

function formatURL(data) {
    switch(data.type) {
        case "yt":
            return "https://youtube.com/watch?v=" + data.id;
        case "vi":
            return "https://vimeo.com/" + data.id;
        case "dm":
            return "https://dailymotion.com/video/" + data.id;
        case "sc":
            return data.id;
        case "li":
            const [account,event] = data.id.split(';');
            return `https://livestream.com/accounts/${account}/events/${event}`;
        case "tw":
            return "https://twitch.tv/" + data.id;
        case "rt":
            return data.id;
        case "gd":
            return "https://docs.google.com/file/d/" + data.id;
        case "fi":
            return data.id;
        case "hl":
            return data.id;
        case "sb":
            return "https://streamable.com/" + data.id;
        case "tc":
            return "https://clips.twitch.tv/" + data.id;
        case "cm":
            return data.id;
        case "cu":
            return data.meta.embed.src;
        case "pt":
            if(data.meta.embed.onlyLong){
                return `https://${data.meta.embed.domain}/videos/watch/${data.meta.embed.uuid}`;
            } else {
                return `https://${data.meta.embed.domain}/w/${data.meta.embed.short}`;
            }
        case "bc":
            return `https://www.bitchute.com/video/${data.id}/`;
        case "bn":
            const [artist,track] = data.id.split(';');
            return `https://${artist}.bandcamp.com/track/${track}`;
        case "od":
            const [user,video] = data.id.split(';');
            return `https://odysee.com/@${user}/${video}`;
        case "nv":
            return `https://www.nicovideo.jp/watch/${data.id}`;
        default:
            return "#";
    }
}

function findUserlistItem(name) {
    var children = $("#userlist .userlist_item");
    if(children.length == 0)
        return null;
    name = name.toLowerCase();
    // WARNING: Incoming hax because of jQuery and bootstrap bullshit
    var keys = Object.keys(children);
    for(var k in keys) {
        var i = keys[k];
        if(isNaN(parseInt(i))) {
            continue;
        }
        var child = $(children[i]);
        if((child.data("name") || "").toLowerCase() == name)
            return child;
    }
    return null;
}

function formatUserlistItem(div) {
    var data = {
        name: div.data("name") || "",
        rank: div.data("rank"),
        profile: div.data("profile") || { image: "", text: ""},
        leader: div.data("leader") || false,
        icon: div.data("icon") || false,
    };
    var name = $(div.children()[1]);
    name.removeClass();
    name.css("font-style", "");
    name.addClass(getNameColor(data.rank));
    div.find(".profile-box").remove();

    var meta = div.data().meta || {}; // Not sure how this could happen.
    if (meta.afk) {
        div.addClass("userlist_afk");
    } else {
        div.removeClass("userlist_afk");
    }

    if (meta.muted) {
        div.addClass("userlist_muted");
    } else {
        div.removeClass("userlist_muted");
    }

    if (meta.smuted) {
        div.addClass("userlist_smuted");
    } else {
        div.removeClass("userlist_smuted");
    }

    name.find(".bot-tag").remove();
    if (meta.is_bot) {
        $("<span/>").addClass("bot-tag").text(" [bot]").appendTo(name);
    }

    var profile = null;
    /*
     * 2015-10-19
     * Prevent rendering unnecessary duplicates of the profile box when
     * a user's status changes.
     */
    name.unbind("mouseenter");
    name.unbind("mousemove");
    name.unbind("mouseleave");

    name.on('mouseenter', function(ev) {
        if (profile)
            profile.remove();

        var top = ev.clientY + 5;
        var horiz = ev.clientX;
        profile = $("<div/>")
            .addClass("profile-box linewrap")
            .css("top", top + "px")
            .appendTo(div);

        if(data.profile.image) {
            $("<img/>").addClass("profile-image")
                .attr("src", data.profile.image)
                .appendTo(profile);
        }
        $("<strong/>").text(data.name).appendTo(profile);

        var meta = div.data("meta") || {};
        if (meta.ip && USEROPTS.show_ip_in_tooltip) {
            $("<br/>").appendTo(profile);
            $("<em/>").text(meta.ip).appendTo(profile);
        }
        if (meta.aliases) {
            $("<br/>").appendTo(profile);
            $("<em/>").text("aliases: " + meta.aliases.join(", ")).appendTo(profile);
        }
        $("<hr/>").css("margin-top", "5px").css("margin-bottom", "5px").appendTo(profile);
        $("<p/>").text(data.profile.text).appendTo(profile);

        if ($("body").hasClass("synchtube")) horiz -= profile.outerWidth();
        profile.css("left", horiz + "px")
    });
    name.on('mousemove', function(ev) {
        var top = ev.clientY + 5;
        var horiz = ev.clientX;

        if ($("body").hasClass("synchtube")) horiz -= profile.outerWidth();
        profile.css("left", horiz + "px")
            .css("top", top + "px");
    });
    name.on('mouseleave', function() {
        profile.remove();
    });
    var icon = div.children()[0];
    icon.innerHTML = "";
    // denote current leader with a star
    if(data.leader) {
        $("<span/>").addClass("glyphicon glyphicon-star-empty").appendTo(icon);
    }
    if(div.data().meta.afk) {
        name.css("font-style", "italic");
        $("<span/>").addClass("glyphicon glyphicon-time").appendTo(icon);
    }
    if (data.icon) {
        $("<span/>").addClass("glyphicon " + data.icon).prependTo(icon);
    }
}

function getNameColor(rank) {
    if(rank >= Rank.Siteadmin)
        return "userlist_siteadmin";
    else if(rank >= Rank.Admin)
        return "userlist_owner";
    else if(rank >= Rank.Moderator)
        return "userlist_op";
    else if(rank == Rank.Guest)
        return "userlist_guest";
    else
        return "";
}

function addUserDropdown(entry) {
    var name = entry.data("name"),
        rank = entry.data("rank"),
        leader = entry.data("leader"),
        meta = entry.data("meta") || {};
    entry.find(".user-dropdown").remove();
    var menu = $("<div/>")
        .addClass("user-dropdown")
        .appendTo(entry)
        .hide();

    $("<strong/>").text(name).appendTo(menu);
    $("<br/>").appendTo(menu);

    var btngroup = $("<div/>").addClass("btn-group-vertical").appendTo(menu);

    /* ignore button */
    if (name !== CLIENT.name) {
        var ignore = $("<button/>").addClass("btn btn-xs btn-default")
            .appendTo(btngroup)
            .on('click', function () {
                if(IGNORED.indexOf(name) == -1) {
                    ignore.text("Unignore User");
                    IGNORED.push(name);
                    entry.addClass("userlist-ignored");
                } else {
                    ignore.text("Ignore User");
                    IGNORED.splice(IGNORED.indexOf(name), 1);
                    entry.removeClass("userlist-ignored");
                }
                setOpt("ignorelist", IGNORED);
            });
        if(IGNORED.indexOf(name) == -1) {
            entry.removeClass("userlist-ignored");
            ignore.text("Ignore User");
        } else {
            entry.addClass("userlist-ignored");
            ignore.text("Unignore User");
        }
    }

    /* pm button */
    if (name !== CLIENT.name) {
        var pm = $("<button/>").addClass("btn btn-xs btn-default")
            .text("Private Message")
            .appendTo(btngroup)
            .on('click', function () {
                initPm(name).find(".panel-heading").click();
                menu.hide();
            });
    }

    /* give/remove leader (moderator+ only) */
    if (hasPermission("leaderctl")) {
        var ldr = $("<button/>").addClass("btn btn-xs btn-default")
            .appendTo(btngroup);
        if(leader) {
            ldr.text("Remove Leader");
            ldr.on('click', function () {
                socket.emit("assignLeader", {
                    name: ""
                });
            });
        } else {
            ldr.text("Give Leader");
            ldr.on('click', function () {
                socket.emit("assignLeader", {
                    name: name
                });
            });
        }
    }

    /* kick button */
    if(hasPermission("kick")) {
        $("<button/>").addClass("btn btn-xs btn-default")
            .text("Kick")
            .on('click', function () {
                var reason = prompt("Enter kick reason (optional)");
                if (reason === null) {
                    return;
                }
                socket.emit("chatMsg", {
                    msg: "/kick " + name + " " + reason,
                    meta: {}
                });
            })
            .appendTo(btngroup);
    }

    /* mute buttons */
    if (hasPermission("mute")) {
        var mute = $("<button/>").addClass("btn btn-xs btn-default")
            .text("Mute")
            .on('click', function () {
                socket.emit("chatMsg", {
                    msg: "/mute " + name,
                    meta: {}
                });
            })
            .appendTo(btngroup);
        var smute = $("<button/>").addClass("btn btn-xs btn-default")
            .text("Shadow Mute")
            .on('click', function () {
                socket.emit("chatMsg", {
                    msg: "/smute " + name,
                    meta: {}
                });
            })
            .appendTo(btngroup);
        var unmute = $("<button/>").addClass("btn btn-xs btn-default")
            .text("Unmute")
            .on('click', function () {
                socket.emit("chatMsg", {
                    msg: "/unmute " + name,
                    meta: {}
                });
            })
            .appendTo(btngroup);
        if (meta.muted) {
            mute.hide();
            smute.hide();
        } else {
            unmute.hide();
        }
    }

    /* ban buttons */
    if(hasPermission("ban")) {
        $("<button/>").addClass("btn btn-xs btn-default")
            .text("Name Ban")
            .on('click', function () {
                var reason = prompt("Enter ban reason (optional)");
                if (reason === null) {
                    return;
                }
                socket.emit("chatMsg", {
                    msg: "/ban " + name + " " + reason,
                    meta: {}
                });
            })
            .appendTo(btngroup);
        $("<button/>").addClass("btn btn-xs btn-default")
            .text("IP Ban")
            .on('click', function () {
                var reason = prompt("Enter ban reason (optional)");
                if (reason === null) {
                    return;
                }
                socket.emit("chatMsg", {
                    msg: "/ipban " + name + " " + reason,
                    meta: {}
                });
            })
            .appendTo(btngroup);
    }

    var showdd = function(ev) {
        // Workaround for Chrome
        if (ev.shiftKey) return true;
        ev.preventDefault();
        if(menu.css("display") == "none") {
            $(".user-dropdown").hide();
            $(document).bind("mouseup.userlist-ddown", function (e) {
                if (menu.has(e.target).length === 0 &&
                    entry.parent().has(e.target).length === 0) {
                    menu.hide();
                    $(document).unbind("mouseup.userlist-ddown");
                }
            });
            menu.show();
            menu.css("top", entry.position().top);
        } else {
            menu.hide();
        }
        return false;
    };
    entry.contextmenu(showdd);
    entry.click(showdd);
}

function calcUserBreakdown() {
    var breakdown = {
        "Site Admins": 0,
        "Channel Admins": 0,
        "Moderators": 0,
        "Regular Users": 0,
        "Guests": 0,
        "Anonymous": 0,
        "AFK": 0
    };
    var total = 0;
    $("#userlist .userlist_item").each(function (index, item) {
        var data = {
            rank: $(item).data("rank")
        };

        if(data.rank >= 255)
            breakdown["Site Admins"]++;
        else if(data.rank >= 3)
            breakdown["Channel Admins"]++;
        else if(data.rank == 2)
            breakdown["Moderators"]++;
        else if(data.rank >= 1)
            breakdown["Regular Users"]++;
        else
            breakdown["Guests"]++;

        total++;

        if($(item).data().meta.afk)
            breakdown["AFK"]++;
    });

    breakdown["Anonymous"] = CHANNEL.usercount - total;

    return breakdown;
}

function sortUserlist() {
    var slice = Array.prototype.slice;
    var list = slice.call($("#userlist .userlist_item"));
    list.sort(function (a, b) {
        var r1 = $(a).data("rank");
        var r2 = $(b).data("rank");
        var afk1 = $(a).find(".glyphicon-time").length > 0;
        var afk2 = $(b).find(".glyphicon-time").length > 0;
        var name1 = a.children[1].innerHTML.toLowerCase();
        var name2 = b.children[1].innerHTML.toLowerCase();

        if(USEROPTS.sort_afk) {
            if(afk1 && !afk2)
                return 1;
            if(!afk1 && afk2)
                return -1;
        }

        if(USEROPTS.sort_rank) {
            if(r1 < r2)
                return 1;
            if(r1 > r2)
                return -1;
        }

        return name1 === name2 ? 0 : (name1 < name2 ? -1 : 1);
    });

    list.forEach(function (item) {
        $(item).detach();
    });
    list.forEach(function (item) {
        $(item).appendTo($("#userlist"));
    });
}

/* queue stuff */

function scrollQueue() {
    var li = playlistFind(PL_CURRENT);
    if(!li)
        return;

    li = $(li);
    $("#queue").scrollTop(0);
    var scroll = li.position().top - $("#queue").position().top;
    $("#queue").scrollTop(scroll);
}

function makeQueueEntry(item, addbtns) {
    var video = item.media;
    var li = $("<li/>");
    li.addClass("queue_entry");
    li.addClass("pluid-" + item.uid);
    li.data("uid", item.uid);
    li.data("media", video);
    li.data("temp", item.temp);
    if(video.thumb) {
        $("<img/>").attr("src", video.thumb.url)
            .css("float", "left")
            .css("clear", "both")
            .appendTo(li);
    }
    var title = $("<a/>").addClass("qe_title").appendTo(li)
        .text(video.title)
        .attr("href", formatURL(video))
        .attr("target", "_blank");
    var time = $("<span/>").addClass("qe_time").appendTo(li);
    time.text(video.duration);
    var clear = $("<div/>").addClass("qe_clear").appendTo(li);
    if(item.temp) {
        li.addClass("queue_temp");
    }

    if(addbtns)
        addQueueButtons(li);
    return li;
}

function makeSearchEntry(video) {
    var li = $("<li/>");
    li.addClass("queue_entry");
    li.data("media", video);
    if(video.thumb) {
        $("<img/>").attr("src", video.thumb.url)
            .css("float", "left")
            .css("clear", "both")
            .appendTo(li);
    }
    var title = $("<a/>").addClass("qe_title").appendTo(li)
        .text(video.title)
        .attr("href", formatURL(video))
        .attr("target", "_blank");
    var time = $("<span/>").addClass("qe_time").appendTo(li);
    time.text(video.duration);
    var clear = $("<div/>").addClass("qe_clear").appendTo(li);

    return li;
}

function addQueueButtons(li) {
    li.find(".btn-group").remove();
    var menu = $("<div/>").addClass("btn-group").appendTo(li);
    // Play
    if(hasPermission("playlistjump")) {
        $("<button/>").addClass("btn btn-xs btn-default qbtn-play")
            .html("<span class='glyphicon glyphicon-play'></span>Play")
            .on('click', function() {
                socket.emit("jumpTo", li.data("uid"));
            })
            .appendTo(menu);
    }
    // Queue next
    if(hasPermission("playlistmove")) {
        $("<button/>").addClass("btn btn-xs btn-default qbtn-next")
            .html("<span class='glyphicon glyphicon-share-alt'></span>Queue Next")
            .on('click', function() {
                socket.emit("moveMedia", {
                    from: li.data("uid"),
                    after: PL_CURRENT
                });
            })
            .appendTo(menu);
    }
    // Temp/Untemp
    if(hasPermission("settemp")) {
        var tempstr = li.data("temp")?"Make Permanent":"Make Temporary";
        $("<button/>").addClass("btn btn-xs btn-default qbtn-tmp")
            .html("<span class='glyphicon glyphicon-flag'></span>" + tempstr)
            .on('click', function() {
                socket.emit("setTemp", {
                    uid: li.data("uid"),
                    temp: !li.data("temp")
                });
            })
            .appendTo(menu);
    }
    // Delete
    if(hasPermission("playlistdelete")) {
        $("<button/>").addClass("btn btn-xs btn-default qbtn-delete")
            .html("<span class='glyphicon glyphicon-trash'></span>Delete")
            .on('click', function() {
                socket.emit("delete", li.data("uid"));
            })
            .appendTo(menu);
    }

    if(USEROPTS.qbtn_hide && !USEROPTS.qbtn_idontlikechange
        || menu.find(".btn").length == 0)
        menu.hide();

    // I DON'T LIKE CHANGE
    if(USEROPTS.qbtn_idontlikechange) {
        menu.addClass("pull-left");
        menu.detach().prependTo(li);
        menu.find(".btn").each(function() {
            // Clear icon
            var icon = $(this).find(".glyphicon");
            $(this).html("");
            icon.appendTo(this);
        });
        menu.find(".qbtn-play").addClass("btn-success");
        menu.find(".qbtn-delete").addClass("btn-danger");
    }
    else if(menu.find(".btn").length != 0) {
        li.unbind("contextmenu");
        li.contextmenu(function(ev) {
            // Allow shift+click to open context menu
            // (Chrome workaround, works by default on Firefox)
            if (ev.shiftKey) return true;
            ev.preventDefault();
            if(menu.css("display") == "none")
                menu.show("blind");
            else
                menu.hide("blind");
            return false;
        });
    }
}

function rebuildPlaylist() {
    var qli = $("#queue li");
    if(qli.length == 0)
        return;
    REBUILDING = Math.random() + "";
    var r = REBUILDING;
    var i = 0;
    qli.each(function() {
        var li = $(this);
        (function(i, r) {
            setTimeout(function() {
                // Stop if another rebuild is running
                if(REBUILDING != r)
                    return;
                addQueueButtons(li);
                if(i == qli.length - 1) {
                    scrollQueue();
                    REBUILDING = false;
                }
            }, 10*i);
        })(i, r);
        i++;
    });
}

/* menus */

/* user settings menu */
function showUserOptions() {
    if (CLIENT.rank < 2) {
        $("a[href='#us-mod']").parent().hide();
    } else {
        $("a[href='#us-mod']").parent().show();
    }

    $("#us-theme").val(USEROPTS.theme);
    $("#us-layout").val(USEROPTS.layout);
    $("#us-no-channelcss").prop("checked", USEROPTS.ignore_channelcss);
    $("#us-no-channeljs").prop("checked", USEROPTS.ignore_channeljs);

    $("#us-synch").prop("checked", USEROPTS.synch);
    $("#us-synch-accuracy").val(USEROPTS.sync_accuracy);
    $("#us-hidevideo").prop("checked", USEROPTS.hidevid);
    $("#us-playlistbuttons").prop("checked", USEROPTS.qbtn_hide);
    $("#us-oldbtns").prop("checked", USEROPTS.qbtn_idontlikechange);
    $("#us-default-quality").val(USEROPTS.default_quality || "auto");
    $("#us-peertube").prop("checked", USEROPTS.peertube_risk);

    $("#us-chat-timestamp").prop("checked", USEROPTS.show_timestamps);
    $("#us-sort-rank").prop("checked", USEROPTS.sort_rank);
    $("#us-sort-afk").prop("checked", USEROPTS.sort_afk);
    $("#us-blink-title").val(USEROPTS.blink_title);
    $("#us-ping-sound").val(USEROPTS.boop);
    $("#us-notifications").val(USEROPTS.notifications);
    $("#us-sendbtn").prop("checked", USEROPTS.chatbtn);
    $("#us-no-emotes").prop("checked", USEROPTS.no_emotes);
    $("#us-strip-image").prop("checked", USEROPTS.strip_image);
    $("#us-chat-tab-method").val(USEROPTS.chat_tab_method);

    $("#us-modflair").prop("checked", USEROPTS.modhat);
    $("#us-shadowchat").prop("checked", USEROPTS.show_shadowchat);
    $("#us-show-ip-in-tooltip").prop("checked", USEROPTS.show_ip_in_tooltip);

    formatScriptAccessPrefs();

    $("a[href='#us-general']").trigger("click");
    $("#useroptions").modal();
}

function saveUserOptions() {
    USEROPTS.theme                = $("#us-theme").val();
    createCookie("cytube-theme", USEROPTS.theme, 1000);
    USEROPTS.layout               = $("#us-layout").val();
    USEROPTS.ignore_channelcss    = $("#us-no-channelcss").prop("checked");
    USEROPTS.ignore_channeljs     = $("#us-no-channeljs").prop("checked");
    USEROPTS.show_ip_in_tooltip   = $("#us-show-ip-in-tooltip").prop("checked");

    USEROPTS.synch                = $("#us-synch").prop("checked");
    USEROPTS.sync_accuracy        = parseFloat($("#us-synch-accuracy").val()) || 2;
    USEROPTS.hidevid              = $("#us-hidevideo").prop("checked");
    USEROPTS.qbtn_hide            = $("#us-playlistbuttons").prop("checked");
    USEROPTS.qbtn_idontlikechange = $("#us-oldbtns").prop("checked");
    USEROPTS.default_quality      = $("#us-default-quality").val();
    USEROPTS.peertube_risk        = $("#us-peertube").prop("checked");

    USEROPTS.show_timestamps      = $("#us-chat-timestamp").prop("checked");
    USEROPTS.sort_rank            = $("#us-sort-rank").prop("checked");
    USEROPTS.sort_afk             = $("#us-sort-afk").prop("checked");
    USEROPTS.blink_title          = $("#us-blink-title").val();
    USEROPTS.boop                 = $("#us-ping-sound").val();
    USEROPTS.notifications        = $("#us-notifications").val();
    USEROPTS.chatbtn              = $("#us-sendbtn").prop("checked");
    USEROPTS.no_emotes            = $("#us-no-emotes").prop("checked");
    USEROPTS.strip_image          = $("#us-strip-image").prop("checked");
    USEROPTS.chat_tab_method      = $("#us-chat-tab-method").val();

    if (CLIENT.rank >= 2) {
        USEROPTS.modhat      = $("#us-modflair").prop("checked");
        USEROPTS.show_shadowchat = $("#us-shadowchat").prop("checked");
    }

    storeOpts();
    applyOpts();
}

function storeOpts() {
    for(var key in USEROPTS) {
        setOpt(key, USEROPTS[key]);
    }
}

function applyOpts() {
    if ($("#usertheme").attr("href") !== USEROPTS.theme) {
        var old = $("#usertheme").attr("id", "usertheme_old");
        var theme = USEROPTS.theme;
        if (theme === "default") {
            theme = DEFAULT_THEME;
        }
        $("<link/>").attr("rel", "stylesheet")
            .attr("type", "text/css")
            .attr("id", "usertheme")
            .attr("href", theme)
            .attr("onload", "$('#usertheme_old').remove()")
            .appendTo($("head"));
        fixWeirdButtonAlignmentIssue();
    }

    switch (USEROPTS.layout) {
        case "synchtube-fluid":
            fluidLayout();
        case "synchtube":
            synchtubeLayout();
            break;
        case "fluid":
            fluidLayout();
            break;
        case "hd":
            hdLayout();
            break;
        case "tv":
            tvLayout();
            break;
        default:
            compactLayout();
            break;
    }

    if(USEROPTS.hidevid) {
        removeVideo();
    }

    $("#chatbtn").remove();
    if(USEROPTS.chatbtn) {
        var btn = $("<button/>").addClass("btn btn-default btn-block")
            .text("Send")
            .attr("id", "chatbtn")
            .appendTo($("#chatwrap"));
        btn.on('click', function() {
            if($("#chatline").val().trim()) {
                socket.emit("chatMsg", {
                    msg: $("#chatline").val(),
                    meta: {}
                });
                $("#chatline").val("");
            }
        });
    }

    if (USEROPTS.modhat) {
        $("#modflair").removeClass("label-default")
            .addClass("label-success");
    } else {
        $("#modflair").removeClass("label-success")
            .addClass("label-default");
    }

    if (USEROPTS.notifications !== "never") {
        if ("Notification" in window) {
            Notification.requestPermission().then(function(permission) {
                if (permission !== "granted") {
                    USEROPTS.notifications = "never";
                }
            });
        }
        else {
            USEROPTS.notifications = "never";
        }
    }
}

function parseTimeout(t) {
    var m;
    if (m = t.match(/^(\d+):(\d+):(\d+)$/)) {
        // HH:MM:SS
        return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10);
    } else if (m = t.match(/^(\d+):(\d+)$/)) {
        // MM:SS
        return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
    } else if (m = t.match(/^(\d+)$/)) {
        // Seconds
        return parseInt(m[1], 10);
    } else {
        throw new Error("Invalid timeout value '" + t + "'");
    }
}

function showPollMenu() {
    $("#pollwrap .poll-menu").remove();
    var menu = $("<div/>").addClass("well poll-menu")
        .prependTo($("#pollwrap"));

    $("<button/>").addClass("btn btn-sm btn-danger pull-right")
        .text("Cancel")
        .appendTo(menu)
        .on('click', function() {
            menu.remove();
        });

    $("<strong/>").text("Title").appendTo(menu);

    var title = $("<input/>").addClass("form-control")
        .attr("maxlength", "255")
        .attr("type", "text")
        .appendTo(menu);

    $("<strong/>").text("Timeout (optional)").appendTo(menu);
    $("<p/>").text("If you specify a timeout, the poll will automatically " +
                   "be closed after that amount of time.  You can either " +
                   "specify the number of seconds or use the format " +
                   "minutes:seconds.  Examples: 90 (90 seconds), 5:30 " +
                   "(5 minutes, 30 seconds)")
        .addClass("text-muted")
        .appendTo(menu);
    var timeout = $("<input/>").addClass("form-control")
        .attr("type", "text")
        .appendTo(menu);
    var timeoutError = null;

    var checkboxOuter = $("<div/>").addClass("checkbox").appendTo(menu);
    var lbl = $("<label/>").text("Hide poll results until it closes")
        .appendTo(checkboxOuter);
    var hidden = $("<input/>").attr("type", "checkbox")
        .prependTo(lbl);

    var retainVotesOuter = $("<div/>").addClass("checkbox").appendTo(menu);
    var retainVotesLbl = $("<label/>").text("Keep poll vote after user leaves")
        .appendTo(retainVotesOuter);
    var retainVotes = $("<input/>").attr("type", "checkbox")
        .prependTo(retainVotesLbl);

    $("<strong/>").text("Options").appendTo(menu);

    var addbtn = $("<button/>").addClass("btn btn-sm btn-default")
        .text("Add Option")
        .appendTo(menu);

    function addOption() {
        $("<input/>").addClass("form-control")
            .attr("type", "text")
            .attr("maxlength", "255")
            .addClass("poll-menu-option")
            .insertBefore(addbtn);
    }

    addbtn.click(addOption);
    addOption();
    addOption();

    $("<button/>").addClass("btn btn-default btn-block")
        .text("Open Poll")
        .appendTo(menu)
        .on('click', function() {
            var t = timeout.val().trim();
            if (t) {
                try {
                    t = parseTimeout(t);
                } catch (e) {
                    if (timeoutError) {
                        timeoutError.remove();
                    }

                    timeoutError = $("<p/>").addClass("text-danger").text(e.message);
                    timeoutError.insertAfter(timeout);
                    timeout.focus();
                    return;
                }
            } else {
                t = undefined;
            }
            var opts = [];
            menu.find(".poll-menu-option").each(function() {
                if($(this).val() != "")
                    opts.push($(this).val());
            });
            socket.emit("newPoll", {
                title: title.val(),
                opts: opts,
                obscured: hidden.prop("checked"),
                retainVotes: retainVotes.prop("checked"),
                timeout: t
            }, function ack(result) {
                if (result.error) {
                    modalAlert({
                        title: 'Error creating poll',
                        textContent: result.error.message
                    });
                } else {
                    menu.remove();
                }
            });
        });
}

function scrollChat() {
    scrollAndIgnoreEvent($("#messagebuffer").prop("scrollHeight"));
    $("#newmessages-indicator").remove();
}

function scrollAndIgnoreEvent(top) {
    IGNORE_SCROLL_EVENT = true;
    $("#messagebuffer").scrollTop(top);
}

function hasPermission(key) {
    if(key.indexOf("playlist") == 0 && CHANNEL.openqueue) {
        var key2 = "o" + key;
        var v = CHANNEL.perms[key2];
        if(typeof v == "number" && CLIENT.rank >= v) {
            return true;
        }
    }
    var v = CHANNEL.perms[key];
    if(typeof v != "number") {
        return false;
    }
    return CLIENT.rank >= v;
}

function setVisible(selector, bool) {
    // I originally added this check because of a race condition
    // Now it seems to work without but I don't trust it
    if($(selector) && $(selector).attr("id") != selector.substring(1)) {
        setTimeout(function() {
            setVisible(selector, bool);
        }, 100);
        return;
    }
    var disp = bool ? "" : "none";
    $(selector).css("display", disp);
}

function setParentVisible(selector, bool) {
    var disp = bool ? "" : "none";
    $(selector).parent().css("display", disp);
}

function handleModPermissions() {
    $("#cs-chanranks-adm").attr("disabled", CLIENT.rank < 4);
    $("#cs-chanranks-owner").attr("disabled", CLIENT.rank < 4);
    /* update channel controls */
    $("#cs-pagetitle").val(CHANNEL.opts.pagetitle);
    $("#cs-pagetitle").attr("disabled", CLIENT.rank < 3);
    $("#cs-externalcss").val(CHANNEL.opts.externalcss);
    $("#cs-externalcss").attr("disabled", CLIENT.rank < 3);
    $("#cs-externaljs").val(CHANNEL.opts.externaljs);
    $("#cs-externaljs").attr("disabled", CLIENT.rank < 3);
    $("#cs-chat_antiflood").prop("checked", CHANNEL.opts.chat_antiflood);
    if ("chat_antiflood_params" in CHANNEL.opts) {
        $("#cs-chat_antiflood_burst").val(CHANNEL.opts.chat_antiflood_params.burst);
        $("#cs-chat_antiflood_sustained").val(CHANNEL.opts.chat_antiflood_params.sustained);
    }
    $("#cs-show_public").prop("checked", CHANNEL.opts.show_public);
    $("#cs-show_public").attr("disabled", CLIENT.rank < 3);
    $("#cs-password").val(CHANNEL.opts.password || "");
    $("#cs-password").attr("disabled", CLIENT.rank < 3);
    $("#cs-enable_link_regex").prop("checked", CHANNEL.opts.enable_link_regex);
    $("#cs-afk_timeout").val(CHANNEL.opts.afk_timeout);
    $("#cs-allow_voteskip").prop("checked", CHANNEL.opts.allow_voteskip);
    $("#cs-voteskip_ratio").val(CHANNEL.opts.voteskip_ratio);
    $("#cs-allow_dupes").prop("checked", CHANNEL.opts.allow_dupes);
    $("#cs-torbanned").prop("checked", CHANNEL.opts.torbanned);
    $("#cs-block_anonymous_users").prop("checked", CHANNEL.opts.block_anonymous_users);
    $("#cs-allow_ascii_control").prop("checked", CHANNEL.opts.allow_ascii_control);
    $("#cs-playlist_max_per_user").val(CHANNEL.opts.playlist_max_per_user || 0);
    $("#cs-playlist_max_duration_per_user").val(formatTime(CHANNEL.opts.playlist_max_duration_per_user));
    $("#cs-new_user_chat_delay").val(formatTime(CHANNEL.opts.new_user_chat_delay || 0));
    $("#cs-new_user_chat_link_delay").val(formatTime(CHANNEL.opts.new_user_chat_link_delay || 0));
    $("#cs-maxlength").val(formatTime(CHANNEL.opts.maxlength));
    $("#cs-csstext").val(CHANNEL.css);
    $("#cs-jstext").val(CHANNEL.js);
    $("#cs-motdtext").val(CHANNEL.motd);
    setParentVisible("a[href='#cs-motdeditor']", hasPermission("motdedit"));
    setParentVisible("a[href='#cs-permedit']", CLIENT.rank >= 3);
    setParentVisible("a[href='#cs-banlist']", hasPermission("ban"));
    setParentVisible("a[href='#cs-csseditor']", CLIENT.rank >= 3);
    setParentVisible("a[href='#cs-jseditor']", CLIENT.rank >= 3);
    setParentVisible("a[href='#cs-chatfilters']", hasPermission("filteredit"));
    setParentVisible("a[href='#cs-emotes']", hasPermission("emoteedit"));
    setParentVisible("a[href='#cs-chanranks']", CLIENT.rank >= 3);
    setParentVisible("a[href='#cs-chanlog']", CLIENT.rank >= 3);
    $("#cs-chatfilters-import").attr("disabled", !hasPermission("filterimport"));
    $("#cs-emotes-import").attr("disabled", !hasPermission("filterimport"));
}

function handlePermissionChange() {
    if(CLIENT.rank >= 2) {
        handleModPermissions();
    }

    $("#qlockbtn").attr("disabled", !hasPermission("playlistlock"));
    setVisible("#showchansettings", CLIENT.rank >= 2);
    setVisible("#playlistmanagerwrap", CLIENT.rank >= 1);
    setVisible("#modflair", CLIENT.rank >= 2);
    setVisible("#guestlogin", CLIENT.rank < 0);
    setVisible("#chatline", CLIENT.rank >= 0);
    setVisible("#queue", hasPermission("seeplaylist"));
    setVisible("#plmeta", hasPermission("seeplaylist"));
    $("#getplaylist").attr("disabled", !hasPermission("seeplaylist"));

    setVisible("#showplaylistmanager", hasPermission("seeplaylist"));
    setVisible("#showmediaurl", hasPermission("playlistadd"));
    setVisible("#showcustomembed", hasPermission("playlistaddcustom"));
    $("#queue_next").attr("disabled", !hasPermission("playlistnext"));

    if(hasPermission("playlistadd") ||
        hasPermission("playlistmove") ||
        hasPermission("playlistjump") ||
        hasPermission("playlistdelete") ||
        hasPermission("settemp")) {
        if(USEROPTS.first_visit && $("#plonotification").length == 0) {
            var al = makeAlert("Playlist Options", [
                "From the Options menu, you can choose to automatically",
                " hide the buttons on each entry (and show them when",
                " you right click).  You can also choose to use the old",
                " style of playlist buttons.",
                "<br>"].join(""))
                .attr("id", "plonotification")
                .insertAfter($("#queuefail"));

            al.find(".close").remove();

            $("<button/>").addClass("btn btn-primary")
                .text("Dismiss")
                .appendTo(al.find(".alert"))
                .on('click', function() {
                    USEROPTS.first_visit = false;
                    storeOpts();
                    al.hide("fade", function() {
                        al.remove();
                    });
                });
        }
    }

    if(hasPermission("playlistmove")) {
        $("#queue").sortable("enable");
        $("#queue").addClass("queue_sortable");
    }
    else {
        $("#queue").sortable("disable");
        $("#queue").removeClass("queue_sortable");
    }

    setVisible("#clearplaylist", hasPermission("playlistclear"));
    setVisible("#shuffleplaylist", hasPermission("playlistshuffle"));
    if (!hasPermission("addnontemp")) {
        $(".add-temp").prop("checked", true);
        $(".add-temp").attr("disabled", true);
    } else {
        $(".add-temp").attr("disabled", false);
    }

    fixWeirdButtonAlignmentIssue();

    setVisible("#newpollbtn", hasPermission("pollctl"));
    $("#voteskip").attr("disabled", !hasPermission("voteskip") ||
                                    !CHANNEL.opts.allow_voteskip);

    $("#pollwrap .active").find(".btn-danger").remove();
    if(hasPermission("pollctl")) {
        var poll = $("#pollwrap .active");
        if(poll.length > 0) {
            $("<button/>").addClass("btn btn-danger pull-right")
                .text("End Poll")
                .insertAfter(poll.find(".close"))
                .on('click', function() {
                    socket.emit("closePoll");
                });
        }
    }
    var poll = $("#pollwrap .active");
    if(poll.length > 0) {
        poll.find(".btn").attr("disabled", !hasPermission("pollvote"));
    }
    var users = $("#userlist").children();
    for(var i = 0; i < users.length; i++) {
        addUserDropdown($(users[i]));
    }

    $("#chatline").attr("disabled", !hasPermission("chat"));
    if (!hasPermission("chat")) {
        $("#chatline").attr("placeholder", "Chat permissions are restricted on this channel");
    } else {
        $("#chatline").attr("placeholder", "");
    }
    rebuildPlaylist();
}

function fixWeirdButtonAlignmentIssue() {
    // Weird things happen to the alignment in chromium when I toggle visibility
    // of the above buttons
    // This fixes it?
    var wtf = $("#videocontrols").removeClass("pull-right");
    setTimeout(function () {
        wtf.addClass("pull-right");
    }, 1);
}

/* search stuff */

function clearSearchResults() {
    $("#library").html("");
    $("#search_clear").remove();
    var p = $("#library").data("paginator");
    if(p) {
        p.paginator.html("");
    }
}

function addLibraryButtons(li, item, source) {
    var btns = $("<div/>").addClass("btn-group")
        .addClass("pull-left")
        .prependTo(li);

    var id = item.id;
    var type = item.type;

    if(hasPermission("playlistadd")) {
        if(hasPermission("playlistnext")) {
            $("<button/>").addClass("btn btn-xs btn-default")
                .text("Next")
                .on('click', function() {
                    socket.emit("queue", {
                        id: id,
                        pos: "next",
                        type: type,
                        temp: $(".add-temp").prop("checked")
                    });
                })
                .appendTo(btns);
        }
        $("<button/>").addClass("btn btn-xs btn-default")
            .text("End")
            .on('click', function() {
                socket.emit("queue", {
                    id: id,
                    pos: "end",
                    type: type,
                    temp: $(".add-temp").prop("checked")
                });
            })
            .appendTo(btns);
    }
    if(hasPermission("deletefromchannellib") && source === "library") {
        $("<button/>").addClass("btn btn-xs btn-danger")
            .html("<span class='glyphicon glyphicon-trash'></span>")
            .on('click', function() {
                socket.emit("uncache", {
                    id: id
                });
                li.hide("fade", function() {
                    li.remove();
                });
            })
            .appendTo(btns);
    }
}

/* queue stuff */

var AsyncQueue = function () {
    this._q = [];
    this._lock = false;
    this._tm = 0;
};

AsyncQueue.prototype.next = function () {
    if (this._q.length > 0) {
        if (!this.lock())
            return;
        var item = this._q.shift();
        var fn = item[0], tm = item[1];
        this._tm = Date.now() + item[1];
        fn(this);
    }
};

AsyncQueue.prototype.lock = function () {
    if (this._lock) {
        if (this._tm > 0 && Date.now() > this._tm) {
            this._tm = 0;
            return true;
        }
        return false;
    }

    this._lock = true;
    return true;
};

AsyncQueue.prototype.release = function () {
    var self = this;
    if (!self._lock)
        return false;

    self._lock = false;
    self.next();
    return true;
};

AsyncQueue.prototype.queue = function (fn) {
    var self = this;
    self._q.push([fn, 20000]);
    self.next();
};

AsyncQueue.prototype.reset = function () {
    this._q = [];
    this._lock = false;
};

var PL_ACTION_QUEUE = new AsyncQueue();

// Because jQuery UI does weird things
// 2017-03-26: Does it really though?  I have no idea if this is still needed.
function playlistFind(uid) {
    var children = document.getElementById("queue").children;
    for(var i in children) {
        if(typeof children[i].className != "string")
            continue;
        if(children[i].className.split(" ").indexOf("pluid-" + uid) > 0)
            return children[i];
    }
    return false;
}

function playlistMove(from, after, cb) {
    var lifrom = $(".pluid-" + from);
    if(lifrom.length == 0) {
        cb(false);
        return;
    }

    var q = $("#queue");

    if(after === "prepend") {
        lifrom.hide("blind", function() {
            lifrom.detach();
            lifrom.prependTo(q);
            lifrom.show("blind", cb);
        });
    }
    else if(after === "append") {
        lifrom.hide("blind", function() {
            lifrom.detach();
            lifrom.appendTo(q);
            lifrom.show("blind", cb);
        });
    }
    else {
        var liafter = $(".pluid-" + after);
        if(liafter.length == 0) {
            cb(false);
            return;
        }
        lifrom.hide("blind", function() {
            lifrom.detach();
            lifrom.insertAfter(liafter);
            lifrom.show("blind", cb);
        });
    }
}

function checkYP(id) {
    if (!/^(PL[a-zA-Z0-9_-]{32}|PL[A-F0-9]{16}|OLA[a-zA-Z0-9_-]{38})$/.test(id)) {
        throw new Error('Invalid YouTube Playlist ID.  Note that only regular user-created playlists are supported.');
    }
}

function parseMediaLink(url) {
    function parseShortCode(url){
        const type = url.slice(0,2);
        let id = url.slice(3);

        switch(type){
            // So we still trim DailyMotion URLs
            case 'dm':
                return { type, id: id.match(/([^\?&#_]+)/)[1] };
            // Raw files need to keep the query string
            case 'fi':
            case 'cm':
                return { type, id };
            case 'yp':
                checkYP(id);
                return { type, id };
            // Generic for the rest.
            default:
                return { type, id: id.match(/([^\?&#]+)/)[1] };
        }
    }
    const indeterminable = ()=>{
        return new Error(
            'Could not determine video type. ' +
            'Check https://git.io/fjtOK for a list of supported media providers.'
        );
    }
    const livestream = ()=>{
        return new Error(
            'This format of Livestream.com link is not supported. ' +
            'You must use one that has the numeric account ID.'
        );
    }

    if(typeof url != 'string') {
        return {
            id: null,
            type: null
        };
    }
    /*  Shorthand URIs  */
    if(url.match(/^[a-z]{2}:/)){
        return parseShortCode(url);
    }

    var data
    try {
        data = new URL(url);
    } catch(err){
        throw indeterminable();
    }

    if(data.protocol == 'rtmp:') {
        return { type: 'rt', id: url };
    }    
    if (data.pathname.match(/\.m3u8$/)) {
        return { type: 'hl', id: url };
    }
    if (data.pathname.match(/\.json$/)) {
        return { type: 'cm', id: url };
    }

    switch(data.hostname.replace('www.', '')){
        case 'streem.vereto.net':
            return { type: 'wp', id: url }
        case 'youtube.com':
            if(data.pathname == '/watch'){
                return { type: 'yt', id: data.searchParams.get('v') }
            }
            if(data.pathname.startsWith('/shorts/')){
                return { type: 'yt', id: data.pathname.slice(8,19) }
            }
            if(data.pathname == '/playlist'){
                checkYP(data.searchParams.get('list'));
                return { type: 'yp', id: data.searchParams.get('list') }
            }
        case 'youtu.be':
            return { type: 'yt', id: data.pathname.slice(1) }

        case 'twitch.tv':
            if(data.pathname.includes('/clip/')){
                return { type: 'tc', id: data.pathname.split('/').pop() }
            }
            if(data.pathname.startsWith('/videos/')){
                return { type: 'tv', id: `v${data.pathname.split('/').pop()}` }
            }
            return { type: 'tw', id: data.pathname.slice(1) }
        case 'clips.twitch.tv':
            return { type: 'tc', id: data.pathname.slice(1) }

        case 'livestream.com':
            if(data.pathname.startsWith('/accounts/')){
                const pattern = new RegExp('/accounts/(?<account>[0-9]+)/events/(?<event>[0-9]+)');
                const m = data.pathname.match(pattern);
                if(m.groups.account && m.groups.event){
                    return { type: 'li', id: `${m.groups.account};${m.groups.event}` };
                }
            } else {
                throw livestream();
            }

        case 'vimeo.com':
            return { type: 'vi', id: data.pathname.slice(1) }
        case 'dailymotion.com':
            return { type: 'dm', id: data.pathname.slice('7') }
        case 'soundcloud.com':
            return { type: 'sc', id: url }
        case 'streamable.com':
            return { type: 'sb', id: data.pathname.slice(1) }

        case 'docs.google.com':
        case 'drive.google.com':
            if(data.pathname.startsWith('/file/')){
                return { type: 'gd', id: data.pathname.slice('8').split('/').shift() }
            }
            if(data.pathname == '/open'){
                return { type: 'gd', id: data.searchParams.get('id') }
            }

        case 'bitchute.com':
            if(data.pathname.startsWith('/video/')){
                return { type: 'bc', id: `${data.pathname.slice(7).split('/').shift()}` }
            }

        case 'nicovideo.jp':
            if(data.pathname.startsWith('/watch/')){
                return { type: 'nv', id: `${data.pathname.slice(7).split('/').shift()}` }
            }

        case 'odysee.com':
            const format = new RegExp('/@(?<user>[^:]+)(?::\\w)?/(?<video>[^:]+)');
            if(format.test(data.pathname)){
                const {user,video} = (data.pathname.match(format)['groups']);
                return { type: 'od', id: `${user};${video}` }
            }
    }

    if(data.hostname.endsWith('.bandcamp.com') && data.pathname.startsWith('/track/')){
        const artist = data.hostname.replace('.bandcamp.com','')
        const track = data.pathname.replace('/track/','')
        return { type: 'bn', id: `${artist};${track}` }
    }

    /* PeerTubes */
    if(data.pathname.match('^/w/|^/videos/watch/')){
        const regLong = [8, 4, 4, 4, 12].map(x => `[0-9a-f]{${x}}`).join('-');
        const regShort = '[a-zA-Z0-9]{22}';
        const pattern = new RegExp(`(?:/w/|/videos/watch/)(?:(?<short>${regShort})|(?<long>${regLong}))`);
        if((m = data.pathname.match(pattern))) {
            return {
                id: `${data.hostname};${m.groups.short || m.groups.long}`,
                type: "pt"
            };
        }
    }

    /* Raw file (server will check) */
    if (data.protocol.match(/^http/)) {
        return {
            id: url,
            type: "fi"
        };
    }

    throw indeterminable();
}


function sendVideoUpdate() {
    if (!CLIENT.leader) {
        return;
    }
    PLAYER.getTime(function (seconds) {
        socket.emit("mediaUpdate", {
            id: PLAYER.mediaId,
            currentTime: seconds,
            paused: PLAYER.paused,
            type: PLAYER.mediaType
        });
    });
}

/* chat */

function stripImages(msg){
    if (!USEROPTS.strip_image) {
        return msg;
    }
    return msg.replace(IMAGE_MATCH, function(match,img){
        return CHANNEL.opts.enable_link_regex ?
            '<a target="_blank" href="'+img+'">'+img+'</a>' : img;
    });
}

function formatChatMessage(data, last) {
    // Backwards compat
    if (!data.meta || data.msgclass) {
        data.meta = {
            addClass: data.msgclass,
            // And the award for "variable name most like Java source code" goes to...
            addClassToNameAndTimestamp: data.msgclass
        };
    }
    // Phase 1: Determine whether to show the username or not
    var skip = data.username === last.name;
    if(data.meta.addClass === "server-whisper")
        skip = true;
    // Prevent impersonation by abuse of the bold filter
    if(data.msg.match(/^\s*<strong>\w+\s*:\s*<\/strong>\s*/))
        skip = false;
    if (data.meta.forceShowName)
        skip = false;

    data.msg = stripImages(data.msg);
    data.msg = execEmotes(data.msg);

    last.name = data.username;
    var div = $("<div/>");
    /* drink is a special case because the entire container gets the class, not
       just the message */
    if (data.meta.addClass === "drink") {
        div.addClass("drink");
        data.meta.addClass = "";
    }

    // Add timestamps (unless disabled)
    if (USEROPTS.show_timestamps) {
        var time = $("<span/>").addClass("timestamp").appendTo(div);
        var timestamp = new Date(data.time).toTimeString().split(" ")[0];
        time.text("["+timestamp+"] ");
        if (data.meta.addClass && data.meta.addClassToNameAndTimestamp) {
            time.addClass(data.meta.addClass);
        }
    }

    // Add username
    var name = $("<span/>");
    if (!skip) {
        name.appendTo(div);
    }
    var usernameEl = $("<strong/>").addClass("username").appendTo(name);
    usernameEl.append(document.createTextNode(data.username));
    if (data.meta && data.meta.is_bot) {
        usernameEl.append(document.createTextNode(" "));
        $("<span/>").addClass("bot-tag").text("[bot]").appendTo(usernameEl);
    }
    usernameEl.append(document.createTextNode(": "));
    if (data.meta.modflair) {
        name.addClass(getNameColor(data.meta.modflair));
    }
    if (data.meta.addClass && data.meta.addClassToNameAndTimestamp) {
        name.addClass(data.meta.addClass);
    }
    if (data.meta.superadminflair) {
        name.addClass("label")
            .addClass(data.meta.superadminflair.labelclass);
        $("<span/>").addClass(data.meta.superadminflair.icon)
            .addClass("glyphicon")
            .css("margin-right", "3px")
            .prependTo(name);
    }

    // Add the message itself
    var message = $("<span/>").appendTo(div);
    message[0].innerHTML = data.msg;

    // For /me the username is part of the message
    if (data.meta.action) {
        name.remove();
        message[0].innerHTML = data.username + " " + data.msg;
    }
    if (data.meta.addClass) {
        message.addClass(data.meta.addClass);
    }
    if (data.meta.shadow) {
        div.addClass("chat-shadow");
    }
    return div;
}

function addChatMessage(data) {
    if(IGNORED.indexOf(data.username) !== -1) {
        return;
    }
    if (data.meta.shadow && !USEROPTS.show_shadowchat) {
        return;
    }
    // This is so we discard repeated messages
    // which become annoying when the user is experiencing repeated socketio reconnects
    if (data.time < LASTCHAT.time) {
        return;
    } else {
        LASTCHAT.time = data.time;
    }

    var msgBuf = $("#messagebuffer");
    var div = formatChatMessage(data, LASTCHAT);
    if ($("body").hasClass("tv")) {
        decorateTVMessage(div[0], data.username);
    }
    // Incoming: a bunch of crap for the feature where if you hover over
    // a message, it highlights messages from that user
    var safeUsername = data.username.replace(/[^\w-]/g, '\\$');
    div.addClass("chat-msg-" + safeUsername);
    div.appendTo(msgBuf);
    div.on('mouseover', function() {
        $(".chat-msg-" + safeUsername).addClass("nick-hover");
    });
    div.on('mouseleave', function() {
        $(".nick-hover").removeClass("nick-hover");
    });
    var oldHeight = msgBuf.prop("scrollHeight");
    var numRemoved = trimChatBuffer();
    if ($("body").hasClass("tv")) {
        SCROLLCHAT = true;
        $("#newmessages-indicator").remove();
    }
    if (SCROLLCHAT) {
        scrollChat();
    } else {
        var newMessageDiv = $("#newmessages-indicator");
        if (!newMessageDiv.length) {
            newMessageDiv = $("<div/>").attr("id", "newmessages-indicator")
                    .insertBefore($("#chatline"));
            var bgHack = $("<span/>").attr("id", "newmessages-indicator-bghack")
                    .appendTo(newMessageDiv);

            $("<span/>").addClass("glyphicon glyphicon-chevron-down")
                    .appendTo(bgHack);
            $("<span/>").text("New Messages Below").appendTo(bgHack);
            $("<span/>").addClass("glyphicon glyphicon-chevron-down")
                    .appendTo(bgHack);
            newMessageDiv.on('click', function () {
                SCROLLCHAT = true;
                scrollChat();
            });
        }

        if (numRemoved > 0) {
            IGNORE_SCROLL_EVENT = true;
            var diff = oldHeight - msgBuf.prop("scrollHeight");
            scrollAndIgnoreEvent(msgBuf.scrollTop() - diff);
        }
    }

    div.find("img").load(function () {
        if (SCROLLCHAT) {
            scrollChat();
        } else if ($(this).position().top < 0) {
            scrollAndIgnoreEvent(msgBuf.scrollTop() + $(this).height());
        }
    });

    var isHighlight = false;
    if (CLIENT.name && data.username != CLIENT.name) {
        if (highlightsMe(data.msg)) {
            div.addClass("nick-highlight");
            isHighlight = true;
        }
    }

    pingMessage(isHighlight, data.username, $(div.children()[2]).text());
}

function highlightsMe(message) {
    // TODO: distinguish between text and HTML attributes as noted in #819
    return message.match(new RegExp("(^|\\b)" + CLIENT.name + "($|\\b)", "gi"));
}

function trimChatBuffer() {
    var maxSize = window.CHATMAXSIZE;
    if (!maxSize || typeof maxSize !== "number")
        maxSize = parseInt(maxSize || 100, 10) || 100;
    var buffer = document.getElementById("messagebuffer");
    var count = buffer.childNodes.length - maxSize;

    for (var i = 0; i < count; i++) {
        buffer.firstChild.remove();
    }

    return count;
}

function pingMessage(isHighlight, notificationTitle, notificationBody) {
    if (!FOCUSED) {
        if (!TITLE_BLINK && (USEROPTS.blink_title === "always" ||
            USEROPTS.blink_title === "onlyping" && isHighlight)) {
            TITLE_BLINK = setInterval(function() {
                if(document.title == "*Chat*")
                    document.title = PAGETITLE;
                else
                    document.title = "*Chat*";
            }, 1000);
        }

        if (USEROPTS.boop === "always" || (USEROPTS.boop === "onlyping" &&
            isHighlight)) {
            CHATSOUND.play();
        }

        if (USEROPTS.notifications === "always" || (USEROPTS.notifications === "onlyping" &&
            isHighlight)) {
            showDesktopNotification(notificationTitle, notificationBody);
        }
    }
}

function showDesktopNotification(notificationTitle, notificationBody)
{
    new Notification(notificationTitle, {body: notificationBody, icon: null});
}

/* layouts */

function undoHDLayout() {
    $("body").removeClass("hd");
    $("#drinkbar").detach().removeClass().addClass("col-lg-12 col-md-12")
      .appendTo("#drinkbarwrap");
    $("#chatwrap").detach().removeClass().addClass("col-lg-5 col-md-5")
      .appendTo("#main");
    $("#videowrap").detach().removeClass().addClass("col-lg-7 col-md-7")
      .appendTo("#main");

    $("#leftcontrols").detach().removeClass().addClass("col-lg-5 col-md-5")
      .prependTo("#controlsrow");

    $("#plcontrol").detach().appendTo("#rightcontrols");
    $("#videocontrols").detach().appendTo("#rightcontrols");

    $("#playlistrow").prepend('<div id="leftpane" class="col-lg-5 col-md-5" />');
    $("#leftpane").append('<div id="leftpane-inner" class="row" />');

    $("#pollwrap").detach().removeClass().addClass("col-lg-12 col-md-12")
      .appendTo("#leftpane-inner");
    $("#playlistmanagerwrap").detach().removeClass().addClass("col-lg-12 col-md-12")
      .css("margin-top", "10px")
      .appendTo("#leftpane-inner");

    $("#rightpane").detach().removeClass().addClass("col-lg-7 col-md-7")
      .appendTo("#playlistrow");

    $("nav").addClass("navbar-fixed-top");
    $("#mainpage").css("padding-top", "60px");
    $("#queue").css("max-height", "500px");
    $("#messagebuffer, #userlist").css("max-height", "");
}

function tvUserMessageColor(username) {
    var name = (username || "").toLowerCase();
    var hash = 2166136261;
    for (var i = 0; i < name.length; i++) {
        hash ^= name.charCodeAt(i);
        hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
    }
    hash >>>= 0;

    var hue = hash % 360;
    var sat = 72 + ((hash >>> 9) % 24);    // 72-95
    var light = 58 + ((hash >>> 17) % 18); // 58-75
    return "hsl(" + hue + ", " + sat + "%, " + light + "%)";
}

function clearTVMessageFadeTimer($msg) {
    var timer = $msg.data("tvFadeTimer");
    if (timer) {
        clearTimeout(timer);
        $msg.removeData("tvFadeTimer");
    }
}

function detectCurrentLayoutName() {
    var body = $("body");
    if (body.hasClass("compact")) {
        return "compact";
    }
    if (body.hasClass("hd")) {
        return "hd";
    }
    if (body.hasClass("synchtube") && body.hasClass("fluid")) {
        return "synchtube-fluid";
    }
    if (body.hasClass("synchtube")) {
        return "synchtube";
    }
    if (body.hasClass("fluid")) {
        return "fluid";
    }
    return "default";
}

function backToNormalLayout(event) {
    if (event) {
        event.preventDefault();
    }

    var nextLayout = window._layoutBeforeTV;
    if (!nextLayout || nextLayout === "tv") {
        nextLayout = USEROPTS.layout;
    }
    if (nextLayout === "default" && USEROPTS.layout === "tv") {
        nextLayout = "fluid";
    }
    if (!nextLayout || nextLayout === "tv") {
        nextLayout = "fluid";
    }

    if ($("body").hasClass("tv")) {
        undoTVLayout();
    }

    switch (nextLayout) {
        case "synchtube-fluid":
            fluidLayout();
            synchtubeLayout();
            break;
        case "synchtube":
            compactLayout();
            synchtubeLayout();
            break;
        case "fluid":
            fluidLayout();
            break;
        case "hd":
            hdLayout();
            break;
        case "compact":
            compactLayout();
            break;
        default:
            compactLayout();
            break;
    }

    window._layoutBeforeTV = null;
}

function scheduleTVMessageFade($msg) {
    clearTVMessageFadeTimer($msg);
    $msg.removeClass("tv-msg-fadeout");
    var timer = setTimeout(function() {
        if (!$("body").hasClass("tv")) {
            return;
        }
        $msg.addClass("tv-msg-fadeout");
        $msg.removeData("tvFadeTimer");
    }, 8000);

    $msg.data("tvFadeTimer", timer);
}

function decorateTVMessage(node, username) {
    if (!node || node.nodeType !== 1) {
        return;
    }

    var $msg = $(node);
    var effectiveUsername = username || $msg.data("tvUsername");
    if (!effectiveUsername) {
        var nameText = $msg.find("strong.username").first().text() || "";
        effectiveUsername = nameText.replace(/:\s*$/, "");
    }

    if (effectiveUsername) {
        $msg.data("tvUsername", effectiveUsername);
    }

    $msg.addClass("tv-msg");
    if (effectiveUsername) {
        $msg.css("color", tvUserMessageColor(effectiveUsername));
    }

    var $name = $msg.find("strong.username").first();
    if (!$name.length && effectiveUsername) {
        $name = $("<strong/>").addClass("username tv-injected-username")
            .prependTo($msg);
    }

    if ($name.length && effectiveUsername) {
        $name.text(effectiveUsername + ": ");
    }

    scheduleTVMessageFade($msg);
}

function tvLayout(event) {
    if (event) {
        event.preventDefault();
    }

    if (!$("body").hasClass("tv")) {
        window._layoutBeforeTV = detectCurrentLayoutName();
    } else {
        undoTVLayout();
    }

    if ($("body").hasClass("hd")) undoHDLayout();
    if ($("body").hasClass("synchtube")) {
        $("body").removeClass("synchtube");
        $("#chatwrap").detach().insertBefore($("#videowrap"));
        $("#leftcontrols").detach().insertBefore($("#rightcontrols"));
        $("#leftpane").detach().insertBefore($("#rightpane"));
        $("#userlist").css("float", "left");
    }
    if ($("body").hasClass("fluid")) {
        $("body").removeClass("fluid");
        $(".container-fluid").removeClass("container-fluid").addClass("container");
    }
    $("body").removeClass("compact").addClass("tv");

    $(".container").removeClass("container").addClass("container-fluid");
    $("footer .container-fluid").removeClass("container-fluid").addClass("container");

    var $bar = $("<div/>").attr("id", "tv-controls-bar").appendTo($("#main"));
    SCROLLCHAT = true;
    $("#newmessages-indicator").remove();

    $("#chatwrap form").detach().appendTo($bar);
    $("#emotelistbtn").detach().appendTo($bar);
    $("#videocontrols").detach().appendTo($bar);

    $("#messagebuffer").children().each(function() {
        decorateTVMessage(this);
    });

    var tvObserver = null;
    if (typeof MutationObserver === "function") {
        tvObserver = new MutationObserver(function(mutations) {
            mutations.forEach(function(mutation) {
                for (var i = 0; i < mutation.addedNodes.length; i++) {
                    var node = mutation.addedNodes[i];
                    if (node.nodeType === 1) {
                        decorateTVMessage(node);
                    }
                }
            });
        });
    }

    var buf = document.getElementById("messagebuffer");
    if (buf && tvObserver) {
        tvObserver.observe(buf, { childList: true });
    }

    var tvHideTimer = null;
    function tvShowControls() {
        $("body").addClass("tv-controls-visible");
        clearTimeout(tvHideTimer);
        tvHideTimer = setTimeout(function() {
            if ($("body").hasClass("tv-typing")) {
                return;
            }
            $("body").removeClass("tv-controls-visible");
            window._tvHideTimer = null;
        }, 3000);
        window._tvHideTimer = tvHideTimer;
    }

    $(document).on("mousemove.tv touchstart.tv", tvShowControls);
    $(document).on("focusin.tv", "#tv-controls-bar input, #tv-controls-bar textarea", function() {
        $("body").addClass("tv-typing tv-controls-visible");
        clearTimeout(tvHideTimer);
        window._tvHideTimer = null;
    });
    $(document).on("focusout.tv", "#tv-controls-bar input, #tv-controls-bar textarea", function() {
        if ($("#tv-controls-bar input:focus, #tv-controls-bar textarea:focus").length > 0) {
            return;
        }
        $("body").removeClass("tv-typing");
        tvShowControls();
    });
    tvShowControls();

    window._tvObserver = tvObserver;
    window._tvHideTimer = tvHideTimer;
}

function undoTVLayout() {
    $("body").removeClass("tv tv-controls-visible tv-typing");

    if (window._tvObserver) {
        window._tvObserver.disconnect();
        window._tvObserver = null;
    }

    clearTimeout(window._tvHideTimer);
    window._tvHideTimer = null;
    $(document).off("mousemove.tv touchstart.tv focusin.tv focusout.tv");

    $("#messagebuffer").children(".tv-msg").each(function() {
        var $msg = $(this);
        clearTVMessageFadeTimer($msg);
        $msg.removeClass("tv-msg tv-msg-fadeout");
        $msg.css("color", "");
    });
    $("#messagebuffer .tv-injected-username").remove();

    $("#videocontrols").detach().appendTo("#rightcontrols");
    $("#emotelistbtn").detach().insertAfter($("#newpollbtn"));
    $("#tv-controls-bar form").detach().appendTo("#chatwrap");
    $("#tv-controls-bar").remove();

    $(".container-fluid").removeClass("container-fluid").addClass("container");
    $("footer .container").removeClass("container").addClass("container-fluid");
}

function compactLayout() {
    /* Undo synchtube layout */
    if ($("body").hasClass("synchtube")) {
        $("body").removeClass("synchtube")
        $("#chatwrap").detach().insertBefore($("#videowrap"));
        $("#leftcontrols").detach().insertBefore($("#rightcontrols"));
        $("#leftpane").detach().insertBefore($("#rightpane"));
        $("#userlist").css("float", "left");
        if($("#userlisttoggle").hasClass("glyphicon-chevron-left")){
            $("#userlisttoggle").removeClass("glyphicon-chevron-left").addClass("glyphicon-chevron-right")
        }
        $("#userlisttoggle").removeClass("pull-right").addClass("pull-left")
    }

    /* Undo fluid layout */
    if ($("body").hasClass("fluid")) {
        $("body").removeClass("fluid")
        $(".container-fluid").removeClass("container-fluid").addClass("container");
    }

    /* Undo HD layout */
    if ($("body").hasClass("hd")) {
        undoHDLayout();
    }

    /* Undo TV layout */
    if ($("body").hasClass("tv")) {
        undoTVLayout();
    }

    $("body").addClass("compact");
    handleVideoResize();
}

function fluidLayout() {
    if ($("body").hasClass("tv")) {
        undoTVLayout();
    }

    if ($("body").hasClass("hd")) {
        undoHDLayout();
    }
    $(".container").removeClass("container").addClass("container-fluid");
    $("footer .container-fluid").removeClass("container-fluid").addClass("container");
    $("body").addClass("fluid");
    handleVideoResize();
}

function synchtubeLayout() {
    if ($("body").hasClass("tv")) {
        undoTVLayout();
    }

    if ($("body").hasClass("hd")) {
        undoHDLayout();
    }
    if($("#userlisttoggle").hasClass("glyphicon-chevron-right")){
        $("#userlisttoggle").removeClass("glyphicon-chevron-right").addClass("glyphicon-chevron-left")
    }
    $("#userlisttoggle").removeClass("pull-left").addClass("pull-right")
    $("#videowrap").detach().insertBefore($("#chatwrap"));
    $("#rightcontrols").detach().insertBefore($("#leftcontrols"));
    $("#rightpane").detach().insertBefore($("#leftpane"));
    $("#userlist").css("float", "right");
    $("body").addClass("synchtube");
}

/*
 * "HD" is kind of a misnomer.  Should be renamed at some point.
 */
function hdLayout() {
    if ($("body").hasClass("tv")) {
        undoTVLayout();
    }

    var videowrap = $("#videowrap"),
        chatwrap = $("#chatwrap"),
        playlist = $("#rightpane")

    videowrap.detach().insertAfter($("#drinkbar"))
        .removeClass()
        .addClass("col-md-8 col-md-offset-2");

    playlist.detach().insertBefore(chatwrap)
        .removeClass()
        .addClass("col-md-6");

    chatwrap.removeClass()
        .addClass("col-md-6");

    var ch = "320px";
    $("#messagebuffer").css("max-height", ch);
    $("#userlist").css("max-height", ch);
    $("#queue").css("max-height", "312px");

    $("#leftcontrols").detach()
        .insertAfter(chatwrap)
        .removeClass()
        .addClass("col-md-6");

    $("#playlistmanagerwrap").detach()
        .insertBefore($("#leftcontrols"))
        .css("margin-top", "0")
        .removeClass()
        .addClass("col-md-6");

    $("#showplaylistmanager").addClass("btn-sm");

    var plcontrolwrap = $("<div/>").addClass("col-md-12")
        .prependTo($("#rightpane-inner"));

    $("#plcontrol").detach().appendTo(plcontrolwrap);
    $("#videocontrols").detach()
        .appendTo(plcontrolwrap);

    $("#controlswrap").remove();

    $("#pollwrap").detach()
        .insertAfter($("#leftcontrols"))
        .removeClass()
        .addClass("col-md-6 col-md-offset-6");

    $("#leftpane").remove();
    $("nav.navbar-fixed-top").removeClass("navbar-fixed-top");
    $("#mainpage").css("padding-top", "0");

    $("body").addClass("hd");
    handleVideoResize();
}

function chatOnly() {
    var chat = $("#chatwrap").detach();
    removeVideo();
    $("#wrap").remove();
    $("footer").remove();
    chat.prependTo($("body"));
    chat.css({
        "min-height": "100%",
        "min-width": "100%",
        margin: "0",
        padding: "0"
    });
    $("<span/>").addClass("label label-default pull-right pointer")
        .text("User Options")
        .appendTo($("#chatheader"))
        .on('click', showUserOptions);
    $("<span/>").addClass("label label-default pull-right pointer")
        .attr("id", "showchansettings")
        .text("Channel Settings")
        .appendTo($("#chatheader"))
        .on('click', function () {
            $("#channeloptions").modal();
        });
    $("<span/>").addClass("label label-default pull-right pointer")
        .text("Emote List")
        .appendTo($("#chatheader"))
        .on('click', function () {
            EMOTELISTMODAL.modal();
        });
    setVisible("#showchansettings", CLIENT.rank >= 2);

    $("body").addClass("chatOnly");
    handleWindowResize();
}

function handleWindowResize() {
    if ($("body").hasClass("chatOnly")) {
        var h = $("body").outerHeight() - $("#chatline").outerHeight() -
                $("#chatheader").outerHeight();
        $("#messagebuffer").outerHeight(h);
        $("#userlist").outerHeight(h);
        return;
    } else if ($("body").hasClass("tv")) {
        return;
    } else {
        handleVideoResize();
    }
    scrollChat();
}

function handleVideoResize() {
    if ($("#ytapiplayer").length === 0) return;
    if ($("body").hasClass("tv")) return;

    var intv, ticks = 0;
    var resize = function () {
        if (++ticks > 10) clearInterval(intv);
        if ($("#ytapiplayer").parent().outerHeight() <= 0) return;
        clearInterval(intv);

        var responsiveFrame = $("#ytapiplayer").parent();
        var height = responsiveFrame.outerHeight() - $("#chatline").outerHeight() - 2;
        $("#messagebuffer").height(height);
        $("#userlist").height(height);

        $("#ytapiplayer").attr("height", VHEIGHT = responsiveFrame.outerHeight());
        $("#ytapiplayer").attr("width", VWIDTH = responsiveFrame.outerWidth());
    };

    if ($("#ytapiplayer").height() > 0) resize();
    else intv = setInterval(resize, 500);
}

$(window).on('resize', handleWindowResize);
handleWindowResize();

function removeVideo(event) {
    try {
        PLAYER.setVolume(0);
    } catch (e) {
    }

    $("#videowrap").remove();
    $("#chatwrap").removeClass("col-lg-5 col-md-5").addClass("col-md-12");
    if (event) event.preventDefault();
}

/* channel administration stuff */

function genPermissionsEditor() {
    $("#cs-permedit").html("");
    var form = $("<form/>").addClass("form-horizontal")
        .attr("action", "javascript:void(0)")
        .appendTo($("#cs-permedit"));

    function makeOption(text, key, permset, defval) {
        var group = $("<div/>").addClass("form-group")
            .appendTo(form);
        $("<label/>").addClass("control-label col-sm-4")
            .text(text)
            .appendTo(group);
        var controls = $("<div/>").addClass("col-sm-8")
            .appendTo(group);
        var select = $("<select/>").addClass("form-control")
            .appendTo(controls)
            .data("key", key);

        for (var i = 0; i < permset.length; i++) {
            $("<option/>").attr("value", permset[i][1])
                .text(permset[i][0])
                .attr("selected", defval === permset[i][1])
                .appendTo(select);
        }
    }

    function addDivider(text, nonewline) {
        $("<hr/>").appendTo(form);
        if (!nonewline) {
            $("<h3/>").text(text).appendTo(form);
        }
    }

    var standard = [
        ["Anonymous"    , "-1"],
        ["Guest"        , "0"],
        ["Registered"   , "1"],
        ["Leader"       , "1.5"],
        ["Moderator"    , "2"],
        ["Channel Admin", "3"],
        ["Nobody"       , "1000000"]
    ];

    var noanon = [
        ["Guest"        , "0"],
        ["Registered"   , "1"],
        ["Leader"       , "1.5"],
        ["Moderator"    , "2"],
        ["Channel Admin", "3"],
        ["Nobody"       , "1000000"]
    ];

    var modleader = [
        ["Leader"       , "1.5"],
        ["Moderator"    , "2"],
        ["Channel Admin", "3"],
        ["Nobody"       , "1000000"]
    ];

    var modplus = [
        ["Moderator"    , "2"],
        ["Channel Admin", "3"],
        ["Nobody"       , "1000000"]
    ];

    $("<h3/>").text("Open playlist permissions").appendTo(form);
    makeOption("Add to playlist", "oplaylistadd", standard, CHANNEL.perms.oplaylistadd+"");
    makeOption("Add/move to next", "oplaylistnext", standard, CHANNEL.perms.oplaylistnext+"");
    makeOption("Move playlist items", "oplaylistmove", standard, CHANNEL.perms.oplaylistmove+"");
    makeOption("Delete playlist items", "oplaylistdelete", standard, CHANNEL.perms.oplaylistdelete+"");
    makeOption("Jump to video", "oplaylistjump", standard, CHANNEL.perms.oplaylistjump+"");
    makeOption("Queue playlist", "oplaylistaddlist", standard, CHANNEL.perms.oplaylistaddlist+"");

    addDivider("General playlist permissions");
    makeOption("View the playlist", "seeplaylist", standard, CHANNEL.perms.seeplaylist+"");
    makeOption("Add to playlist", "playlistadd", standard, CHANNEL.perms.playlistadd+"");
    makeOption("Add/move to next", "playlistnext", standard, CHANNEL.perms.playlistnext+"");
    makeOption("Move playlist items", "playlistmove", standard, CHANNEL.perms.playlistmove+"");
    makeOption("Delete playlist items", "playlistdelete", standard, CHANNEL.perms.playlistdelete+"");
    makeOption("Jump to video", "playlistjump", standard, CHANNEL.perms.playlistjump+"");
    makeOption("Queue playlist", "playlistaddlist", standard, CHANNEL.perms.playlistaddlist+"");
    makeOption("Queue livestream", "playlistaddlive", standard, CHANNEL.perms.playlistaddlive+"");
    makeOption("Embed custom media", "playlistaddcustom", standard, CHANNEL.perms.playlistaddcustom + "");
    makeOption("Add raw video file", "playlistaddrawfile", standard, CHANNEL.perms.playlistaddrawfile + "");
    makeOption("Exceed maximum media length", "exceedmaxlength", standard, CHANNEL.perms.exceedmaxlength+"");
    makeOption("Exceed maximum total media length", "exceedmaxdurationperuser", standard, CHANNEL.perms.exceedmaxdurationperuser+"");
    makeOption("Exceed maximum number of videos per user", "exceedmaxitems", standard, CHANNEL.perms.exceedmaxitems+"");
    makeOption("Add nontemporary media", "addnontemp", standard, CHANNEL.perms.addnontemp+"");
    makeOption("Temp/untemp playlist item", "settemp", standard, CHANNEL.perms.settemp+"");
    makeOption("Lock/unlock playlist", "playlistlock", modleader, CHANNEL.perms.playlistlock+"");
    makeOption("Shuffle playlist", "playlistshuffle", standard, CHANNEL.perms.playlistshuffle+"");
    makeOption("Clear playlist", "playlistclear", standard, CHANNEL.perms.playlistclear+"");
    makeOption("Delete from channel library", "deletefromchannellib", standard, CHANNEL.perms.deletefromchannellib+"");

    addDivider("Polls");
    makeOption("Open/Close poll", "pollctl", modleader, CHANNEL.perms.pollctl+"");
    makeOption("Vote", "pollvote", standard, CHANNEL.perms.pollvote+"");
    makeOption("View hidden poll results", "viewhiddenpoll", standard, CHANNEL.perms.viewhiddenpoll+"");
    makeOption("Voteskip", "voteskip", standard, CHANNEL.perms.voteskip+"");
    makeOption("View voteskip results", "viewvoteskip", standard, CHANNEL.perms.viewvoteskip+"");

    addDivider("Moderation");
    makeOption("Assign/Remove leader", "leaderctl", modplus, CHANNEL.perms.leaderctl+"");
    makeOption("Mute users", "mute", modleader, CHANNEL.perms.mute+"");
    makeOption("Kick users", "kick", modleader, CHANNEL.perms.kick+"");
    makeOption("Ban users", "ban", modplus, CHANNEL.perms.ban+"");
    makeOption("Edit MOTD", "motdedit", modplus, CHANNEL.perms.motdedit+"");
    makeOption("Edit chat filters", "filteredit", modplus, CHANNEL.perms.filteredit+"");
    makeOption("Import chat filters", "filterimport", modplus, CHANNEL.perms.filterimport+"");
    makeOption("Edit chat emotes", "emoteedit", modplus, CHANNEL.perms.emoteedit+"");
    makeOption("Import chat emotes", "emoteimport", modplus, CHANNEL.perms.emoteimport+"");

    addDivider("Misc");
    makeOption("Drink calls", "drink", modleader, CHANNEL.perms.drink+"");
    makeOption("Chat", "chat", noanon, CHANNEL.perms.chat+"");
    makeOption("Clear Chat", "chatclear", modleader, CHANNEL.perms.chatclear+"");

    var sgroup = $("<div/>").addClass("form-group").appendTo(form);
    var sgroupinner = $("<div/>").addClass("col-sm-8 col-sm-offset-4").appendTo(sgroup);
    var submit = $("<button/>").addClass("btn btn-primary").appendTo(sgroupinner);
    submit.text("Save");
    submit.on('click', function() {
        var perms = {};
        form.find("select").each(function() {
            perms[$(this).data("key")] = parseFloat($(this).val());
        });
        socket.emit("setPermissions", perms);
    });

    var msggroup = $("<div/>").addClass("form-group").insertAfter(sgroup);
    var msginner = $("<div/>").addClass("col-sm-8 col-sm-offset-4").appendTo(msggroup);
    var text = $("<span/>").addClass("text-info").text("Permissions updated")
        .appendTo(msginner);

    setTimeout(function () {
        msggroup.hide("fade", function () {
            msggroup.remove();
        });
    }, 5000);
}

function waitUntilDefined(obj, key, fn) {
    if(typeof obj[key] === "undefined") {
        setTimeout(function () {
            waitUntilDefined(obj, key, fn);
        }, 100);
        return;
    }
    fn();
}

function chatDialog(div, zin = 'auto') {
    var parent = $("<div/>").addClass("profile-box")
        .css({
            padding: "10px",
            "z-index": zin,
            position: "absolute"
        })
        .appendTo($("#chatwrap"));

    div.appendTo(parent);
    var cw = $("#chatwrap").width();
    var ch = $("#chatwrap").height();
    var x = cw/2 - parent.width()/2;
    var y = ch/2 - parent.height()/2;
    parent.css("left", x + "px");
    parent.css("top", y + "px");
    return parent;
}

function errDialog(err) {
    var div = $("<div/>").addClass("profile-box")
        .css("padding", "10px")
        .text(err)
        .appendTo($("body"));

    $("<br/>").appendTo(div);
    $("<button/>").addClass("btn btn-xs btn-default")
        .css("width", "100%")
        .text("OK")
        .on('click', function () { div.remove(); })
        .appendTo(div);
    var cw = $("#chatwrap").width();
    var ch = $("#chatwrap").height();
    var cp = $("#chatwrap").offset();
    var x = cp.left + cw/2 - div.width()/2;
    var y = cp.top + ch/2 - div.height()/2;
    div.css("left", x + "px")
        .css("top", y + "px")
        .css("position", "absolute");
    return div;
}

/**
 * 2016-12-08
 * I *promise* that one day I will actually split this file into submodules
 * -cal
 */

/**
 * modalAlert accepts options { title, textContent, htmlContent }
 * All are optional
 */
function modalAlert(options) {
    if (typeof options !== "object" || options === null) {
        throw new Error("modalAlert() called without required parameter");
    }

    var modal = makeModal();
    modal.addClass("cytube-modal-alert");
    modal.removeClass("fade");
    modal.find(".modal-dialog").addClass("modal-dialog-nonfluid");

    if (options.title) {
        $("<h3/>").text(options.title).appendTo(modal.find(".modal-header"));
    }

    var contentDiv = $("<div/>").addClass("modal-body");
    if (options.htmlContent) {
        contentDiv.html(options.htmlContent);
    } else if (options.textContent) {
        contentDiv.text(options.textContent);
    }

    contentDiv.appendTo(modal.find(".modal-content"));

    var footer = $("<div/>").addClass("modal-footer");
    var okButton = $("<button/>").addClass("btn btn-primary")
            .attr({ "data-dismiss": "modal"})
            .text(options.dismissText || "OK")
            .appendTo(footer);
    footer.appendTo(modal.find(".modal-content"));
    modal.appendTo(document.body);
    modal.modal();
}

function queueMessage(data, type) {
    if (!data)
        data = { link: null };
    if (!data.msg || data.msg === true) {
        data.msg = "Queue failed.  Check your link to make sure it is valid.";
    }
    var ltype = "label-danger";
    var title = "Error";
    if (type === "alert-warning") {
        ltype = "label-warning";
        title = "Warning";
    }

    var alerts = $(".qfalert.qf-" + type + " .alert");
    for (var i = 0; i < alerts.length; i++) {
        var al = $(alerts[i]);
        if (al.data("reason") === data.msg) {
            var tag = al.find("." + ltype);
            if (tag.length > 0) {
                var morelinks = al.find(".qflinks");
                $("<a/>").attr("href", data.link)
                    .attr("target", "_blank")
                    .text(data.link)
                    .appendTo(morelinks);
                $("<br/>").appendTo(morelinks);
                var count = parseInt(tag.text().match(/\d+/)[0]) + 1;
                tag.text(tag.text().replace(/\d+/, ""+count));
            } else {
                var tag = $("<span/>")
                    .addClass("label pull-right pointer " + ltype)
                    .text("+ 1 more")
                    .appendTo(al);
                var morelinks = $("<div/>")
                    .addClass("qflinks")
                    .appendTo(al)
                    .hide();
                $("<a/>").attr("href", data.link)
                    .attr("target", "_blank")
                    .text(data.link)
                    .appendTo(morelinks);
                $("<br/>").appendTo(morelinks);
                tag.on('click', function () {
                    morelinks.toggle();
                });
            }
            return;
        }
    }
    var text = data.msg;
    text = text.replace(/(https?:[^ ]+)/g, "<a href='$1' target='_blank'>$1</a>");
    if (typeof data.link === "string") {
        text += "<br><a href='" + data.link + "' target='_blank'>" +
                data.link + "</a>";
    }
    var newAlert = makeAlert(title, text, type)
        .addClass("linewrap qfalert qf-" + type)
        .prependTo($("#queuefail"));
    newAlert.find(".alert").data("reason", data.msg);
}

function setupChanlogFilter(data) {
    data = data.split("\n").filter(function (ln) {
        return ln.indexOf("[") === 0 && ln.indexOf("]") > 0;
    });

    var log = $("#cs-chanlog-text");
    var select = $("#cs-chanlog-filter");
    select.html("");
    log.data("lines", data);

    var keys = {};
    data.forEach(function (ln) {
        var m = ln.match(/^\[.*?\] \[(\w+?)\].*$/);
        if (m) {
            keys[m[1]] = true;
        }
    });

    Object.keys(keys).forEach(function (key) {
        $("<option/>").attr("value", key).text(key).appendTo(select);
    });

    $("<option/>").attr("value", "chat").text("chat").prependTo(select);
}

function filterChannelLog() {
    var log = $("#cs-chanlog-text");
    var filter = $("#cs-chanlog-filter").val();
    var getKey = function (ln) {
        var left = ln.indexOf("[", 1);
        var right = ln.indexOf("]", left);
        if (left === -1) {
            return false;
        }
        return ln.substring(left+1, right);
    };

    var getTimestamp = function (ln) {
        var right = ln.indexOf("]");
        return ln.substring(1, right);
    };

    var getMessage = function (ln) {
        var right = ln.indexOf("]");
        return ln.substring(right + 2);
    };

    var show = [];
    (log.data("lines")||[]).forEach(function (ln) {
        var key = getKey(ln);
        if (!filter || !key && filter.indexOf("chat") !== -1) {
            show.push(ln);
        } else if (filter.indexOf(key) >= 0) {
            show.push(ln);
        }
    });

    log.text(show.join("\n"));
    log.scrollTop(log.prop("scrollHeight"));
}

function makeModal() {
    var wrap = $("<div/>").addClass("modal fade");
    var dialog = $("<div/>").addClass("modal-dialog").appendTo(wrap);
    var content = $("<div/>").addClass("modal-content").appendTo(dialog);

    var head = $("<div/>").addClass("modal-header").appendTo(content);
    $("<button/>").addClass("close")
        .attr("data-dismiss", "modal")
        .attr("data-hidden", "true")
        .html("&times;")
        .appendTo(head);

    wrap.on("hidden.bs.modal", function () {
        wrap.remove();
    });
    return wrap;
}

function formatCSModList() {
    var tbl = $("#cs-chanranks table");
    tbl.find("tbody").remove();
    var entries = tbl.data("entries") || [];
    entries.sort(function(a, b) {
        if (a.rank === b.rank) {
            var x = a.name.toLowerCase();
            var y = b.name.toLowerCase();
            return y == x ? 0 : (x < y ? -1 : 1);
        }

        return b.rank - a.rank;
    });

    entries.forEach(function (entry) {
        var tr = $("<tr/>").addClass("cs-chanrank-tr-" + entry.name);
        var name = $("<td/>").text(entry.name).appendTo(tr);
        name.addClass(getNameColor(entry.rank));
        var rankwrap = $("<td/>");
        var rank = $("<span/>").text(entry.rank).appendTo(rankwrap);
        var dd = $("<div/>").addClass("btn-group");
        var toggle = $("<button/>")
            .addClass("btn btn-xs btn-default dropdown-toggle")
            .attr("data-toggle", "dropdown")
            .html("Edit <span class=caret></span>")
            .appendTo(dd);
        if (CLIENT.rank <= entry.rank && !(CLIENT.rank === 4 && entry.rank === 4)) {
            toggle.addClass("disabled");
        }

        var menu = $("<ul/>").addClass("dropdown-menu")
            .attr("role", "menu")
            .appendTo(dd);

        var ranks = [
            { name: "Remove Moderator", rank: 1 },
            { name: "Moderator", rank: 2 },
            { name: "Admin", rank: 3 },
            { name: "Owner", rank: 4 },
            { name: "Founder", rank: 5 }
        ];

        ranks.forEach(function (r) {
            var li = $("<li/>").appendTo(menu);
            var a = $("<a/>")
                .addClass(getNameColor(r.rank))
                .attr("href", "javascript:void(0)")
                .text(r.name)
                .appendTo(li);
            if (r.rank !== entry.rank) {
                a.on('click', function () {
                    socket.emit("setChannelRank", {
                        name: entry.name,
                        rank: r.rank
                    });
                });
            } else {
                $("<span/>").addClass("glyphicon glyphicon-ok")
                    .appendTo(a);
                li.addClass("disabled");
            }

            if (r.rank > CLIENT.rank || (CLIENT.rank < 4 && r.rank === CLIENT.rank)) {
                li.addClass("disabled");
            }
        });

        dd.css("margin-right", "10px").prependTo(rankwrap);
        rankwrap.appendTo(tr);
        tr.appendTo(tbl);
    });
}

function formatCSBanlist() {
    var tbl = $("#cs-banlist table");
    tbl.find("tbody").remove();
    var entries = tbl.data("entries") || [];
    var sparse = {};
    for (var i = 0; i < entries.length; i++) {
        if (!(entries[i].name in sparse)) {
            sparse[entries[i].name] = [];
        }
        sparse[entries[i].name].push(entries[i]);
    }

    var flat = [];
    for (var name in sparse) {
        flat.push({
            name: name,
            bans: sparse[name]
        });
    }
    flat.sort(function (a, b) {
        var x = a.name.toLowerCase(),
            y = b.name.toLowerCase();
        return x === y ? 0 : (x > y ? 1 : -1);
    });

    var addBanRow = function (entry, after) {
        var tr = $("<tr/>");
        if (after) {
            tr.insertAfter(after);
        } else {
            tr.appendTo(tbl);
        }
        var unban = $("<button/>").addClass("btn btn-xs btn-danger")
            .appendTo($("<td/>").appendTo(tr));
        unban.on('click', function () {
            socket.emit("unban", {
                id: entry.id,
                name: entry.name
            });
        });
        $("<span/>").addClass("glyphicon glyphicon-remove-circle").appendTo(unban);
        $("<td/>").text(entry.ip).appendTo(tr);
        $("<td/>").text(entry.name).appendTo(tr);
        $("<td/>").text(entry.bannedby).appendTo(tr);
        tr.attr("title", "Ban Reason: " + entry.reason);
        return tr;
    };

    flat.forEach(function (person) {
        var bans = person.bans;
        var name = person.name;
        var first = addBanRow(bans.shift());

        if (bans.length > 0) {
            var showmore = $("<button/>").addClass("btn btn-xs btn-default pull-right");
            $("<span/>").addClass("glyphicon glyphicon-list").appendTo(showmore);
            showmore.appendTo(first.find("td")[1]);

            showmore.on('click', function () {
                if (showmore.data("elems")) {
                    showmore.data("elems").forEach(function (e) {
                        e.remove();
                    });
                    showmore.data("elems", null);
                } else {
                    var elems = [];
                    bans.forEach(function (b) {
                        elems.push(addBanRow(b, first));
                    });
                    showmore.data("elems", elems);
                }
            });
        }
    });
}

function checkEntitiesInStr(str) {
    var entities = {
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
        "\\(": "&#40;",
        "\\)": "&#41;"
    };

    var m = str.match(/([&<>"'])|(\\\()|(\\\))/);
    if (m && m[1] in entities) {
        return { src: m[1].replace(/^\\/, ""), replace: entities[m[1]] };
    } else {
        return false;
    }
}

function formatCSChatFilterList() {
    var tbl = $("#cs-chatfilters table");
    tbl.find("tbody").remove();
    tbl.find(".ui-sortable").remove();
    var entries = tbl.data("entries") || [];
    entries.forEach(function (f) {
        var tr = $("<tr/>").appendTo(tbl);
        var controlgroup = $("<div/>").addClass("btn-group")
            .appendTo($("<td/>").appendTo(tr));
        var control = $("<button/>").addClass("btn btn-xs btn-default")
            .attr("title", "Edit this filter")
            .appendTo(controlgroup);
        $("<span/>").addClass("glyphicon glyphicon-list").appendTo(control);
        var del = $("<button/>").addClass("btn btn-xs btn-danger")
            .appendTo(controlgroup);
        $("<span/>").addClass("glyphicon glyphicon-trash").appendTo(del);
        del.on('click', function () {
            socket.emit("removeFilter", f);
        });
        var name = $("<code/>").text(f.name).appendTo($("<td/>").appendTo(tr));
        var activetd = $("<td/>").appendTo(tr);
        var active = $("<input/>").attr("type", "checkbox")
            .prop("checked", f.active)
            .appendTo(activetd)
            .on('change', function () {
                f.active = $(this).prop("checked");
                socket.emit("updateFilter", f);
            });

        var reset = function () {
            control.data("editor") && control.data("editor").remove();
            control.data("editor", null);
            control.parent().find(".btn-success").remove();
            var tbody = $(tbl.children()[1]);
            if (tbody.find(".filter-edit-row").length === 0) {
                tbody.sortable("enable");
            }
        };

        control.on('click', function () {
            if (control.data("editor")) {
                return reset();
            }
            $(tbl.children()[1]).sortable("disable");
            var tr2 = $("<tr/>").insertAfter(tr).addClass("filter-edit-row");
            var wrap = $("<td/>").attr("colspan", "3").appendTo(tr2);
            var form = $("<form/>").addClass("form-inline").attr("role", "form")
                .attr("action", "javascript:void(0)")
                .appendTo(wrap);
            var addTextbox = function (placeholder) {
                var div = $("<div/>").addClass("form-group").appendTo(form)
                    .css("margin-right", "10px");
                var input = $("<input/>").addClass("form-control")
                    .attr("type", "text")
                    .attr("placeholder", placeholder)
                    .attr("title", placeholder)
                    .appendTo(div);
                return input;
            };

            var regex = addTextbox("Filter regex").val(f.source);
            var flags = addTextbox("Regex flags").val(f.flags);
            var replace = addTextbox("Replacement text").val(f.replace);

            var checkwrap = $("<div/>").addClass("checkbox").appendTo(form);
            var checklbl = $("<label/>").text("Filter Links").appendTo(checkwrap);
            var filterlinks = $("<input/>").attr("type", "checkbox")
                .prependTo(checklbl)
                .prop("checked", f.filterlinks);

            var save = $("<button/>").addClass("btn btn-xs btn-success")
                .attr("title", "Save changes")
                .insertAfter(control);
            $("<span/>").addClass("glyphicon glyphicon-floppy-save").appendTo(save);
            save.on('click', function () {
                f.source = regex.val();
                var entcheck = checkEntitiesInStr(f.source);
                if (entcheck) {
                    alert("Warning: " + entcheck.src + " will be replaced by " +
                          entcheck.replace + " in the message preprocessor.  This " +
                          "regular expression may not match what you intended it to " +
                          "match.");
                }
                f.flags = flags.val();
                f.replace = replace.val();
                f.filterlinks = filterlinks.prop("checked");

                socket.emit("updateFilter", f);
                socket.once("updateFilterSuccess", function () {
                    reset();
                });
            });

            control.data("editor", tr2);
        });
    });
    $(tbl.children()[1]).sortable({
        start: function(ev, ui) {
            FILTER_FROM = ui.item.prevAll().length;
        },
        update: function(ev, ui) {
            FILTER_TO = ui.item.prevAll().length;
            if(FILTER_TO != FILTER_FROM) {
                socket.emit("moveFilter", {
                    from: FILTER_FROM,
                    to: FILTER_TO
                });
            }
        }
    });
}

function formatTime(sec) {
    var h = Math.floor(sec / 3600) + "";
    var m = Math.floor((sec % 3600) / 60) + "";
    var s = sec % 60 + "";

    if (h.length < 2) {
        h = "0" + h;
    }

    if (m.length < 2) {
        m = "0" + m;
    }

    if (s.length < 2) {
        s = "0" + s;
    }

    if (h === "00") {
        return [m, s].join(":");
    } else {
        return [h, m, s].join(":");
    }
}

function formatUserPlaylistList() {
    var list = $("#userpl_list").data("entries") || [];
    list.sort(function (a, b) {
        var x = a.name.toLowerCase();
        var y = b.name.toLowerCase();
        return x == y ? 0 : (x < y ? -1 : 1);
    });

    $("#userpl_list").html("");
    list.forEach(function (pl) {
        var li = $("<li/>").addClass("queue_entry").appendTo($("#userpl_list"));
        var title = $("<span/>").addClass("qe_title").appendTo(li)
            .text(pl.name);
        var time = $("<span/>").addClass("pull-right").appendTo(li)
            .text(pl.count + " items, playtime " + formatTime(pl.duration));
        var clear = $("<div/>").addClass("qe_clear").appendTo(li);

        var btns = $("<div/>").addClass("btn-group pull-left").prependTo(li);
        if (hasPermission("playlistadd")) {
            $("<button/>").addClass("btn btn-xs btn-default")
                .text("End")
                .appendTo(btns)
                .on('click', function () {
                    socket.emit("queuePlaylist", {
                        name: pl.name,
                        pos: "end",
                        temp: $(".add-temp").prop("checked")
                    });
                });
        }

        if (hasPermission("playlistadd") && hasPermission("playlistnext")) {
            $("<button/>").addClass("btn btn-xs btn-default")
                .text("Next")
                .prependTo(btns)
                .on('click', function () {
                    socket.emit("queuePlaylist", {
                        name: pl.name,
                        pos: "next",
                        temp: $(".add-temp").prop("checked")
                    });
                });
        }

        $("<button/>").addClass("btn btn-xs btn-danger")
            .html("<span class='glyphicon glyphicon-trash'></span>")
            .attr("title", "Delete playlist")
            .appendTo(btns)
            .on('click', function () {
                var really = confirm("Are you sure you want to delete" +
                    " this playlist? This cannot be undone.");
                if (!really) {
                    return;
                }
                socket.emit("deletePlaylist", {
                    name: pl.name
                });
            });
    });
}

function loadEmotes(data) {
    function sanitizeText(str) {
        str = str.replace(/&/g, "&amp;")
                 .replace(/</g, "&lt;")
                 .replace(/>/g, "&gt;")
                 .replace(/"/g, "&quot;");
        return str;
    }

    CHANNEL.emotes = [];
    CHANNEL.emoteMap = {};
    CHANNEL.badEmotes = [];
    data.forEach(function (e) {
        if (e.image && e.name) {
            e.regex = new RegExp(e.source, "gi");
            CHANNEL.emotes.push(e);
            if (/\s/g.test(e.name)) {
                // Emotes with spaces can't be hashmapped
                CHANNEL.badEmotes.push(e);
            } else {
                CHANNEL.emoteMap[sanitizeText(e.name)] = e;
            }
        } else {
            console.error("Rejecting invalid emote: " + JSON.stringify(e));
        }
    });
}

function execEmotes(msg) {
    if (USEROPTS.no_emotes) {
        return msg;
    }

    if (CyTube.featureFlag && CyTube.featureFlag.efficientEmotes) {
        return execEmotesEfficient(msg);
    }

    CHANNEL.emotes.forEach(function (e) {
        msg = msg.replace(e.regex, '$1' + emoteToImg(e).outerHTML);
    });

    return msg;
}

function execEmotesEfficient(msg) {
    CHANNEL.badEmotes.forEach(function (e) {
        msg = msg.replace(e.regex, '$1' + emoteToImg(e).outerHTML);
    });
    msg = msg.replace(/[^\s]+/g, function (m) {
        if (CHANNEL.emoteMap.hasOwnProperty(m)) {
            var e = CHANNEL.emoteMap[m];
            return emoteToImg(e).outerHTML;
        } else {
            return m;
        }
    });
    return msg;
}

function emoteToImg(e) {
    var img = document.createElement('img');
    img.className = 'channel-emote';
    img.title = e.name;
    img.src = e.image;
    return img;
}

function initPm(user) {
    if ($("#pm-" + user).length > 0) {
        return $("#pm-" + user);
    }

    var pm = $("<div/>").addClass("panel panel-default pm-panel")
        .appendTo($("#pmbar"))
        .data("last", { name: "" })
        .attr("id", "pm-" + user);

    var title = $("<div/>").addClass("panel-heading").text(user).appendTo(pm);
    var close = $("<button/>").addClass("close pull-right")
        .html("&times;")
        .appendTo(title).on('click', function () {
            pm.remove();
            $("#pm-placeholder-" + user).remove();
        });

    var body = $("<div/>").addClass("panel-body").appendTo(pm).hide();
    var placeholder;
    title.on('click', function () {
        body.toggle();
        pm.removeClass("panel-primary").addClass("panel-default");
        if (!body.is(":hidden")) {
            placeholder = $("<div/>").addClass("pm-panel-placeholder")
                .attr("id", "pm-placeholder-" + user)
                .insertAfter(pm);
            var left = pm.position().left;
            pm.css("position", "absolute")
                .css("bottom", "0px")
                .css("left", left);
        } else {
            pm.css("position", "");
            $("#pm-placeholder-" + user).remove();
        }
    });
    var buffer = $("<div/>").addClass("pm-buffer linewrap").appendTo(body);
    $("<hr/>").appendTo(body);
    var input = $("<input/>").addClass("form-control pm-input").attr("type", "text")
        .attr("maxlength", 320)
        .appendTo(body);

    input.keydown(function (ev) {
        if (ev.keyCode === 13) {
            if (CHATTHROTTLE) {
                return;
            }
            var meta = {};
            var msg = input.val();
            if (msg.trim() === "") {
                return;
            }

            if (USEROPTS.modhat && CLIENT.rank >= Rank.Moderator) {
                meta.modflair = CLIENT.rank;
            }

            if (CLIENT.rank >= 2 && msg.indexOf("/m ") === 0) {
                meta.modflair = CLIENT.rank;
                msg = msg.substring(3);
            }
            socket.emit("pm", {
                to: user,
                msg: msg,
                meta: meta
            });
            input.val("");
        } else if(ev.keyCode == 9) { // Tab completion
            try {
                chatTabComplete(ev.target);
            } catch (error) {
                console.error(error);
            }
            ev.preventDefault();
            return false;
        }
    });

    return pm;
}

function checkScriptAccess(viewSource, type, cb) {
    var pref = JSPREF[CHANNEL.name.toLowerCase() + "_" + type];
    if (pref === "ALLOW") {
        return cb("ALLOW");
    } else if (pref !== "DENY") {
        var div = $("#chanjs-allow-prompt");
        if (div.length > 0) {
            setTimeout(function () {
                checkScriptAccess(viewSource, type, cb);
            }, 500);
            return;
        }

        div = $("<div/>").attr("id", "chanjs-allow-prompt");
        var close = $("<button/>").addClass("close pull-right")
            .html("&times;")
            .appendTo(div);
        var form = $("<form/>")
            .attr("action", "javascript:void(0)")
            .attr("id", "chanjs-allow-prompt")
            .attr("style", "text-align: center")
            .appendTo(div);
        if (type === "embedded") {
            form.append("<span>This channel has special features that require your permission to run.</span><br>");
        } else {
            form.append("<span>This channel has special features that require your permission to run.  This script is hosted on a third-party website and is not endorsed by the owners of the website hosting this channel.</span><br>");
        }

        $(viewSource).appendTo(form);

        form.append("<div id='chanjs-allow-prompt-buttons'>" +
                        "<button id='chanjs-allow' class='btn btn-xs btn-danger'>Allow</button>" +
                        "<button id='chanjs-deny' class='btn btn-xs btn-danger'>Deny</button>" +
                    "</div>");
        form.append("<div class='checkbox'><label><input type='checkbox' " +
                    "id='chanjs-save-pref'/>Remember my choice for this channel" +
                    "</label></div>");
        var dialog = chatDialog(div);

        close.on('click', function () {
            dialog.remove();
            /* Implicit denial of script access */
            cb("DENY");
        });

        $("#chanjs-allow").on('click', function () {
            var save = $("#chanjs-save-pref").is(":checked");
            dialog.remove();
            if (save) {
                JSPREF[CHANNEL.name.toLowerCase() + "_" + type] = "ALLOW";
                setOpt("channel_js_pref", JSPREF);
            }
            cb("ALLOW");
        });

        $("#chanjs-deny").on('click', function () {
            var save = $("#chanjs-save-pref").is(":checked");
            dialog.remove();
            if (save) {
                JSPREF[CHANNEL.name.toLowerCase() + "_" + type] = "DENY";
                setOpt("channel_js_pref", JSPREF);
            }
            cb("DENY");
        });
    }
}

function formatScriptAccessPrefs() {
    var tbl = $("#us-scriptcontrol table");
    tbl.find("tbody").remove();

    var channels = Object.keys(JSPREF).sort();
    channels.forEach(function (channel) {
        var idx = String(channel).lastIndexOf("_");
        if (idx < 0) {
            // Invalid
            console.error("Channel JS pref: invalid key '" + channel + "', deleting it");
            delete JSPREF[channel];
            setOpt("channel_js_pref", JSPREF);
            return;
        }

        var channelName = channel.substring(0, idx);
        var prefType = channel.substring(idx + 1);
        console.log(channelName, prefType);
        if (prefType !== "external" && prefType !== "embedded") {
            // Invalid
            console.error("Channel JS pref: invalid key '" + channel + "', deleting it");
            delete JSPREF[channel];
            setOpt("channel_js_pref", JSPREF);
            return;
        }

        var pref = JSPREF[channel];
        var tr = $("<tr/>").appendTo(tbl);
        $("<td/>").text(channelName).appendTo(tr);
        $("<td/>").text(prefType).appendTo(tr);

        var pref_td = $("<td/>").appendTo(tr);
        var allow_label = $("<label/>").addClass("radio-inline")
            .text("Allow").appendTo(pref_td);
        var allow = $("<input/>").attr("type", "radio")
            .prop("checked", pref === "ALLOW").
            prependTo(allow_label);
        allow.change(function () {
            if (allow.is(":checked")) {
                JSPREF[channel] = "ALLOW";
                setOpt("channel_js_pref", JSPREF);
                deny.prop("checked", false);
            }
        });

        var deny_label = $("<label/>").addClass("radio-inline")
            .text("Deny").appendTo(pref_td);
        var deny = $("<input/>").attr("type", "radio")
            .prop("checked", pref === "DENY").
            prependTo(deny_label);
        deny.change(function () {
            if (deny.is(":checked")) {
                JSPREF[channel] = "DENY";
                setOpt("channel_js_pref", JSPREF);
                allow.prop("checked", false);
            }
        });

        var clearpref = $("<button/>").addClass("btn btn-sm btn-danger")
            .text("Clear Preference")
            .appendTo($("<td/>").appendTo(tr))
            .on('click', function () {
                delete JSPREF[channel];
                setOpt("channel_js_pref", JSPREF);
                tr.remove();
            });
    });
}


class EmoteList {
    constructor(selector, emoteClickCallback){
        this._cols = 5;
        this._itemsPerPage = 25;
        this.elem = $(selector);
        this.initSearch();
        this.initSortOption();
        this.table = this.elem.find(".emotelist-table")[0];
        this.paginatorContainer = this.elem.find(".emotelist-paginator-container");
        this.emotes = [];
        this.page = 0;
        this.emoteClickCallback = emoteClickCallback || function(){};
    }
    set itemsPerPage(val) {
        this.page = 0;
        this._itemsPerPage = val;
        this.handleChange();
    }
    get itemsPerPage() {
        return this._itemsPerPage;
    }
    set cols(val) {
        this.page = 0;
        this._cols = val;
        this.handleChange();
    }
    get cols() {
        return this._cols;
    }
}

EmoteList.prototype.initSearch = function () {
    this.searchbar = this.elem.find(".emotelist-search");
    var self = this;

    this.searchbar.keyup(function () {
        var value = this.value.toLowerCase();
        if (value) {
            self.filter = function (emote) {
                return emote.name.toLowerCase().indexOf(value) >= 0;
            };
        } else {
            self.filter = null;
        }
        self.handleChange();
        self.loadPage(0);
    });
};

EmoteList.prototype.initSortOption = function () {
    this.sortOption = this.elem.find(".emotelist-alphabetical");
    this.sortAlphabetical = false;
    var self = this;

    this.sortOption.change(function () {
        self.sortAlphabetical = this.checked;
        self.handleChange();
        self.loadPage(0);
    });
};

EmoteList.prototype.handleChange = function () {
    this.emotes = CHANNEL.emotes.slice();
    if (this.sortAlphabetical) {
        this.emotes.sort(function (a, b) {
            var x = a.name.toLowerCase();
            var y = b.name.toLowerCase();

            if (x < y) {
                return -1;
            } else if (x > y) {
                return 1;
            } else {
                return 0;
            }
        });
    }

    if (this.filter) {
        this.emotes = this.emotes.filter(this.filter);
    }

    this.paginator = new NewPaginator(this.emotes.length, this.itemsPerPage,
            this.loadPage.bind(this));
    this.paginatorContainer.html("");
    this.paginatorContainer.append(this.paginator.elem);
    this.paginator.loadPage(this.page);
};

EmoteList.prototype.loadPage = function (page) {
    var tbody = this.table.children[0];
    tbody.innerHTML = "";

    var row;
    var start = page * this.itemsPerPage;
    if (start >= this.emotes.length) return;
    var end = Math.min(start + this.itemsPerPage, this.emotes.length);
    var _this = this;

    for (var i = start; i < end; i++) {
        if ((i - start) % this.cols === 0) {
            row = document.createElement("tr");
            tbody.appendChild(row);
        }

        (function (emote) {
            var td = document.createElement("td");
            td.className = "emote-preview-container";

            // Trick element to vertically align the emote within the container
            var hax = document.createElement("span");
            hax.className = "emote-preview-hax";
            td.appendChild(hax);

            var img = document.createElement("img");
            img.src = emote.image;
            img.className = "emote-preview";
            img.title = emote.name;
            img.onclick = _this.emoteClickCallback.bind(null, emote);

            td.appendChild(img);
            row.appendChild(td);
        })(this.emotes[i]);
    }

    this.page = page;
};

function onEmoteClicked(emote) {
    var val = chatline.value;
    if (!val) {
        chatline.value = emote.name;
    } else {
        if (!val.charAt(val.length - 1).match(/\s/)) {
            chatline.value += " ";
        }
        chatline.value += emote.name;
    }

    window.EMOTELISTMODAL.modal("hide");
    chatline.focus();
}

window.EMOTELIST = new EmoteList("#emotelist", onEmoteClicked);
window.EMOTELIST.sortAlphabetical = USEROPTS.emotelist_sort;

class CSEmoteList extends EmoteList {
    constructor(selector) {
        super(selector);
    }
}

CSEmoteList.prototype.loadPage = function (page) {
    var tbody = this.table.children[1];
    tbody.innerHTML = "";

    var start = page * this.itemsPerPage;
    if (start >= this.emotes.length) {
        return;
    }
    var end = Math.min(start + this.itemsPerPage, this.emotes.length);
    var self = this;
    this.page = page;

    for (var i = start; i < end; i++) {
        var row = document.createElement("tr");
        tbody.appendChild(row);

        // TODO: refactor this garbage
        (function (emote, row) {
            // Add delete button
            var tdDelete = document.createElement("td");
            var btnDelete = document.createElement("button");
            btnDelete.className = "btn btn-xs btn-danger";
            var pennJillette = document.createElement("span");
            pennJillette.className = "glyphicon glyphicon-trash";
            btnDelete.appendChild(pennJillette);
            tdDelete.appendChild(btnDelete);
            row.appendChild(tdDelete);

            btnDelete.onclick = function deleteEmote() {
                document.getElementById("cs-emotes-newname").value = emote.name;
                document.getElementById("cs-emotes-newimage").value = emote.image;
                socket.emit("removeEmote", emote);
            };

            // Add emote name
            var tdName = document.createElement("td");
            var nameDisplay = document.createElement("code");
            nameDisplay.textContent = emote.name;
            tdName.appendChild(nameDisplay);
            row.appendChild(tdName);

            var $nameDisplay = $(nameDisplay);
            $nameDisplay.on('click', function (clickEvent) {
                $nameDisplay.detach();

                var editInput = document.createElement("input");
                editInput.className = "form-control";
                editInput.type = "text";
                editInput.value = emote.name;
                tdName.appendChild(editInput);
                editInput.focus();

                function save() {
                    var val = editInput.value;
                    tdName.removeChild(editInput);
                    tdName.appendChild(nameDisplay);

                    // Nothing was changed
                    if(val === emote.name){ return }

                    // Emote name already exists
                    if( CHANNEL.emotes.filter(function(emote){ return emote.name === val }).length ){
                        /*
                         * Since we are already in a modal
                         *  and Bootstrap doesn't have supermodals
                         *   we will make a self destructing warning
                         *    as a row in the table
                         */
                        var wrow = document.createElement("tr");
                        var tdBlankDel = document.createElement("td"); wrow.appendChild(tdBlankDel);
                        var tdWarnMess = document.createElement("td"); wrow.appendChild(tdWarnMess);
                        var warnSpan = document.createElement("p"); tdWarnMess.appendChild(warnSpan);
                        warnSpan.className = "text-warning";
                        warnSpan.textContent = "An emote of that name already exists.";
                        tdWarnMess.colSpan = "2";

                        row.insertAdjacentElement("beforebegin", wrow)
                        $(wrow).delay(2500).fadeOut('slow', function(){ $(this).remove() });

                        return;
                    }
                    socket.emit("renameEmote", {
                        old: emote.name,
                        image: emote.image,
                        name: val
                    });
                }

                editInput.onblur = save;
                editInput.onkeyup = function (event) {
                    if (event.keyCode === 13) {
                        save();
                    }
                };
            });

            // Add emote image
            var tdImage = document.createElement("td");
            var urlDisplay = document.createElement("code");
            urlDisplay.textContent = emote.image;
            tdImage.appendChild(urlDisplay);
            row.appendChild(tdImage);

            // Add popover to display the image
            var $urlDisplay = $(urlDisplay);
            $urlDisplay.popover({
                html: true,
                trigger: "hover",
                content: '<img src="' + emote.image + '" class="channel-emote">'
            });

            // Change the image for an emote
            $urlDisplay.on('click', function (clickEvent) {
                $(tdImage).find(".popover").remove();
                $urlDisplay.detach();

                var inputGroup = document.createElement("div");
                inputGroup.className = "input-group";

                var editInput = document.createElement("input");
                editInput.className = "form-control";
                editInput.type = "text";
                editInput.value = emote.image;
                inputGroup.appendChild(editInput);

                var btnGroup = document.createElement("div");
                btnGroup.className = "input-group-btn";

                var saveBtn = document.createElement("button");
                saveBtn.className = "btn btn-success";
                saveBtn.textContent = "Save";
                saveBtn.type = "button";
                btnGroup.appendChild(saveBtn);

                var cancelBtn = document.createElement("button");
                cancelBtn.className = "btn btn-danger";
                cancelBtn.textContent = "Cancel";
                cancelBtn.type = "button";
                btnGroup.appendChild(cancelBtn);

                inputGroup.appendChild(btnGroup);
                tdImage.appendChild(inputGroup);

                editInput.focus();

                function save() {
                    var val = editInput.value;

                    if (val === emote.image) {
                        cleanup();
                        return;
                    }

                    socket.emit("updateEmote", {
                        name: emote.name,
                        image: val
                    });

                    cleanup();
                }

                function cleanup() {
                    tdImage.removeChild(inputGroup);
                    tdImage.appendChild(urlDisplay);
                }

                cancelBtn.onclick = cleanup;
                saveBtn.onclick = save;
                editInput.onkeyup = function (event) {
                    if (event.keyCode === 13) {
                        save();
                    }
                };
            });
        })(this.emotes[i], row);
    }
};

window.CSEMOTELIST = new CSEmoteList("#cs-emotes");
window.CSEMOTELIST.sortAlphabetical = USEROPTS.emotelist_sort;

function showChannelSettings() {
    $("#channeloptions").modal();
}

// There is a point where this file needed to stop and we have clearly passed
// it but let's keep going and see what happens

function startQueueSpinner(data) {
    if ($("#queueprogress").length > 0) {
        return;
    }

    var id = data.id;
    if (data.type === "yp") {
        id = "$any";
    }
    if (data.type === "pt") {
        id = data.id.split(';').shift()
    }

    var progress = $("<div/>").addClass("progress").attr("id", "queueprogress")
            .data("queue-id", id);
    var progressBar = $("<div/>").addClass("progress-bar progress-bar-striped active")
            .attr({
                role: "progressbar",
                "aria-valuenow": "100",
                "aria-valuemin": "0",
                "aria-valuemax": "100",
            }).css({
                width: "100%"
            }).appendTo(progress);
    progress.appendTo($("#addfromurl"));
}

function stopQueueSpinner(data) {
    const qid = $("#queueprogress").data("queue-id");
    switch (true){
        case data === null:
        case qid === "$any":
        case qid === data?.id:
            return $("#queueprogress").remove();
    }
    if (data?.type === "pt" && new RegExp(qid).test(data?.id)) {
        $("#queueprogress").remove();
    }
}

function maybePromptToUpgradeUserscript() {
    if (document.getElementById('prompt-upgrade-drive-userscript')) {
        return;
    }

    if (!window.hasDriveUserscript) {
        return;
    }

    var currentVersion = GS_VERSION.toString(); // data.js
    var userscriptVersion = window.driveUserscriptVersion;
    if (!userscriptVersion) {
        userscriptVersion = '1.0';
    }

    currentVersion = currentVersion.split('.').map(function (part) {
        return parseInt(part, 10);
    });
    userscriptVersion = userscriptVersion.split('.').map(function (part) {
        return parseInt(part, 10);
    });

    var older = false;
    for (var i = 0; i < currentVersion.length; i++) {
        if (userscriptVersion[i] < currentVersion[i]) {
            older = true;
        }
    }

    if (!older) {
        return;
    }

    var alertBox = document.createElement('div');
    alertBox.id = 'prompt-upgrade-drive-userscript';
    alertBox.className = 'alert alert-info'
    alertBox.innerHTML = 'A newer version of the Google Drive userscript is available.';
    alertBox.appendChild(document.createElement('br'));
    var infoLink = document.createElement('a');
    infoLink.className = 'btn btn-info';
    infoLink.href = '/google_drive_userscript';
    infoLink.textContent = 'Click here for installation instructions';
    infoLink.target = '_blank';
    alertBox.appendChild(infoLink);

    var closeButton = document.createElement('button');
    closeButton.className = 'close pull-right';
    closeButton.innerHTML = '&times;';
    closeButton.onclick = function () {
        alertBox.parentNode.removeChild(alertBox);
    }
    alertBox.insertBefore(closeButton, alertBox.firstChild)
    document.getElementById('videowrap').appendChild(alertBox);
}

function backoffRetry(fn, cb, options) {
    var jitter = options.jitter || 0;
    var factor = options.factor || 1;
    var isRetryable = options.isRetryable || function () { return true; };
    var tries = 0;

    function callback(error, result) {
        tries++;
        factor *= factor;
        if (error) {
            if (tries >= options.maxTries) {
                console.log('Max tries exceeded');
                cb(error, result);
            } else if (isRetryable(error)) {
                var offset = Math.random() * jitter;
                var delay = options.delay * factor + offset;
                console.log('Retrying on error: ' + error);
                console.log('Waiting ' + delay + ' ms before retrying');

                setTimeout(function () {
                    fn(callback);
                }, delay);
            }
        } else {
            cb(error, result);
        }
    }

    fn(callback);
}

CyTube.ui.changeVideoWidth = function uiChangeVideoWidth(direction) {
    var body = document.body;
    if (/hd/.test(body.className)) {
        throw new Error("ui::changeVideoWidth does not work with the 'hd' layout");
    }

    var videoWrap = document.getElementById("videowrap");
    var leftControls = document.getElementById("leftcontrols");
    var leftPane = document.getElementById("leftpane");
    var chatWrap = document.getElementById("chatwrap");
    var rightControls = document.getElementById("rightcontrols");
    var rightPane = document.getElementById("rightpane");

    var match = videoWrap.className.match(/col-md-(\d+)/);
    if (!match) {
        throw new Error("ui::changeVideoWidth: videowrap is missing bootstrap class!");
    }

    var videoWidth = parseInt(match[1], 10) + direction;
    if (videoWidth < 3 || videoWidth > 9) {
        return;
    }

    var chatWidth = 12 - videoWidth;
    videoWrap.className = "col-md-" + videoWidth + " col-lg-" + videoWidth;
    rightControls.className = "col-md-" + videoWidth + " col-lg-" + videoWidth;
    rightPane.className = "col-md-" + videoWidth + " col-lg-" + videoWidth;
    chatWrap.className = "col-md-" + chatWidth + " col-lg-" + chatWidth;
    leftControls.className = "col-md-" + chatWidth + " col-lg-" + chatWidth;
    leftPane.className = "col-md-" + chatWidth + " col-lg-" + chatWidth;

    handleVideoResize();
};

CyTube._internal_do_not_use_or_you_will_be_banned.addUserToList = function (data, removePrev) {
    if (removePrev) {
        var user = findUserlistItem(data.name);
        // Remove previous instance of user, if there was one
        if(user !== null)
            user.remove();
    }
    var div = $("<div/>")
        .addClass("userlist_item");
    var icon = $("<span/>").appendTo(div);
    var nametag = $("<span/>").text(data.name).appendTo(div);
    div.data("name", data.name);
    div.data("rank", data.rank);
    div.data("leader", Boolean(data.leader));
    div.data("profile", data.profile);
    div.data("meta", data.meta);
    if (data.meta.muted || data.meta.smuted) {
        div.data("icon", "glyphicon-volume-off");
    } else {
        div.data("icon", false);
    }
    formatUserlistItem(div);
    addUserDropdown(div, data);
    div.appendTo($("#userlist"));
};
