diff --git a/css/looks.css b/css/looks.css index 7d720c3..e63efba 100644 --- a/css/looks.css +++ b/css/looks.css @@ -1,116 +1,105 @@ -@-moz-keyframes spin { - from { - -moz-transform: rotate(0deg); - } - - to { - -moz-transform: rotate(359deg); - } -} - -@-webkit-keyframes spin { - from { - -webkit-transform: rotate(0deg); - } - - to { - -webkit-transform: rotate(359deg); - } -} +/* ============================================ + Keyframes + ============================================ */ @keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(359deg); - } + from { transform: rotate(0deg); } + to { transform: rotate(359deg); } } @keyframes MoveUpDown { - 50% { - -webkit-transform: translateY(5px); - transform: translateY(5px); - will-change: transform; - } + 50% { + transform: translateY(5px); + will-change: transform; + } } @keyframes MoveSideSide { - 5% { - transform: rotate(0deg) translateY(0.1rem); - } - - 25% { - transform: rotate(-3deg) translateY(0.1rem); - } - - 45% { - transform: rotate(0deg) translateY(0.1rem); - } - - 55% { - transform: rotate(0deg) translateY(0.1rem); - } - - 75% { - transform: rotate(3deg) translateY(0.1rem); - } - - 95% { - transform: rotate(0deg) translateY(0.1rem); - } + 5%, 45%, 55%, 95% { transform: rotate(0deg) translateY(0.1rem); } + 25% { transform: rotate(-3deg) translateY(0.1rem); } + 75% { transform: rotate(3deg) translateY(0.1rem); } } @keyframes pulse { - from { - opacity: 1; - } - - to { - opacity: 0.5; - } + from { opacity: 1; } + to { opacity: 0.5; } } +/* ============================================ + Custom Properties + ============================================ */ + :root { - --orange: #F4810B; - --orangelite: #F6993C; - --orange66: #F4810B66; - --orange33: #F4810B33; - --pad1: 0.25rem; - --pad2: 0.5rem; - --pad3: 1rem; - --pad4: 1.5rem; - --pad5: 4rem; - --pad6: 8rem; -} + /* Colors */ + --color-bg: #181818; + --color-bg-s1: #151515; + --color-bg-s2: #111111; + --color-bg-t1: #222222; + --color-bg-t2: #282828; + --color-bg-t3: #2E2E2E; + --color-orange: #f4810b; + --color-orangelite: #f6993c; + --color-orange66: #f4810b66; + --color-orange33: #f4810b33; + --color-accent: var(--color-orange); + --color-text: #e3e3e3; + --color-text-light: #fff; + --color-text-muted: #888; + --color-text-dim: #666; + + /* Spacing */ + --pad1: 0.25rem; + --pad2: 0.5rem; + --pad3: 1rem; + --pad4: 1.5rem; + --pad5: 4rem; + --pad6: 8rem; + + /* Layout */ + --height-footer: 3rem; + + /* Radii */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-pill: 999px; + + /* Typography */ + --font-family: "Inter", "Open Sans", helvetica, arial, sans-serif; + + /* Shadows */ + --shadow-drop: 0 0.25rem 0.5rem -0.25rem black; + + /* History timeline */ + --color-border: #333; + --hist-time-w: 3.25rem; + --hist-connector-w: 1rem; + --hist-avatar-size: 1.5rem; +} + +/* ============================================ + Base / Reset + ============================================ */ *, *:before, *:after { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - -ms-box-sizing: border-box; - box-sizing: border-box; -} - -html { - font-size: 14px; + box-sizing: border-box; } html, body { - height: 100%; + height: 100%; } body { - display: flex; - flex-direction: column; - margin: 0; - font-size: 1rem; - color: #bbbbbb; - background-color: #000; - overflow: hidden; + display: flex; + flex-direction: column; + margin: 0; + font-size: 1rem; + color: var(--color-text); + background: var(--color-bg); + background: linear-gradient(var(--color-bg-t1), var(--color-bg)); + overflow: hidden; } body, @@ -118,1683 +107,2476 @@ input, select, textarea, button { - font-family: "Open Sans", helvetica, arial, sans-serif; + font-family: var(--font-family); } h2 { - font-size: 1.2rem; - font-weight: 700; - color: white; + font-size: 1.2rem; + font-weight: 700; + color: white; } a { - color: var(--orange); - text-decoration: none; + color: var(--color-orange); + text-decoration: none; } [role="button"] { - cursor: pointer; + cursor: pointer; } :focus { - outline: none; - box-shadow: 0 0 0.5rem var(--orange); + outline: none; +} + +:focus-visible { + outline: none; + box-shadow: 0 0 0.5rem var(--color-orange); } input:not([type="checkbox"]):not([type="radio"]), select, button { - height: 2rem; - padding: 0.25rem 0.5rem; - font-size: 0.9rem; - font-family: "Open Sans", helvetica, arial, sans-serif; - color: #eee; - background-color: rgba(123, 123, 123, 0.15); - border: 1px solid transparent; - border-bottom-color: rgba(0, 0, 0, 0.85); - border-top-color: rgba(255, 255, 255, 0.15); - border-radius: 0.25rem; - box-shadow: 0 0.25rem 0.5rem -0.25rem black; + height: 2rem; + padding: var(--pad1) var(--pad3); + font-family: var(--font-family); + color: var(--color-text-light); + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid transparent; + border-bottom-color: rgba(0, 0, 0, 0.85); + border-top-color: rgba(255, 255, 255, 0.15); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-drop); +} + +select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: var(--color-bg-t3); + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 0.65rem center; + background-size: 16px 16px; + padding-right: 2rem; +} + +select::-ms-expand { + display: none; +} + +/* ── Progressive enhancement: appearance: base-select (Chrome 135+) ── */ +@supports (appearance: base-select) { + select { + appearance: base-select; + /* Keep the SVG bg caret — just let base-select handle the picker */ + background-color: var(--color-bg-t3); + padding-right: var(--pad3); + } + + /* The open dropdown surface */ + select::picker(select) { + background-color: var(--color-bg-t2); + color: var(--color-text-light); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: var(--radius-md); + box-shadow: 0 0.5rem 2rem -0.25rem black; + padding: var(--pad1) 0; + } + + /* Individual options */ + select option { + padding-block: var(--pad1); + padding-inline: var(--pad2); + color: var(--color-text-light); + background-color: var(--color-bg-t2); + } + + select option:is(:hover, :focus-visible, :checked) { + background-color: var(--color-bg-t3); + } + + /* Checkmark next to the selected item */ + select::checkmark { + color: var(--color-accent); + } + + /* Hide the native picker-icon — our background-image caret replaces it */ + select::picker-icon { + display: none; + } } input:not([type="checkbox"]):not([type="radio"]) { - border-top-color: rgba(0, 0, 0, 0.85); - border-bottom-color: rgba(255, 255, 255, 0.15); - transform: translateY(-1px); - box-shadow: none; + background-color: rgba(0, 0, 0, 0.5); + border-top-color: rgba(0, 0, 0, 0.85); + border-bottom-color: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); + box-shadow: none; } input[type="checkbox"], input[type="radio"] { - margin-right: var(--pad1); + margin-right: var(--pad1); } -input[type="checkbox"]+label, -input[type="radio"]+label { - display: inline-block; - margin-right: var(--pad3); +input[type="checkbox"] + label, +input[type="radio"] + label { + display: inline-block; + margin-right: var(--pad3); } option { - background-color: #333; + background-color: #333; } -input:focus, -select:focus, -textarea:focus, -button:focus { - outline: none; +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +button:focus-visible { + outline: none; } code { - display: inline-block; - padding: 0.1em 0.2em; - font: 12px Monaco, Consolas, "Andale Mono", "DejaVu Sans Mono", monospace; - color: #bbb; - background-color: rgba(123, 123, 123, 0.5); + display: inline-block; + padding: 0.1em 0.2em; + font: 12px Monaco, Consolas, "Andale Mono", "DejaVu Sans Mono", monospace; + color: var(--color-text); + background-color: rgba(123, 123, 123, 0.5); } -.simplebar-scrollbar:before { - background: var(--orange); -} +/* ============================================ + Shared Components + ============================================ */ .previewicon { - display: flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - text-align: center; - font-size: 1rem; - color: white; - background-color: black; - border-radius: 999px; - opacity: 0.66; -} - -.track-warning { - color: var(--orange); - cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + text-align: center; + font-size: 1rem; + color: white; + background-color: black; + border: none; + border-radius: var(--radius-pill); + opacity: 0.66; + cursor: pointer; + padding: 0; } -.track-warning:empty { - display: none; +.histart .previewicon { + position: absolute; } -.utitle, .prsnJoined { - color: rgba(255, 255, 255, 0.4); - font-size: 0.66rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; +.track-warning { + color: var(--color-orange); + cursor: pointer; } -.herecon { - color: #43b581; - background-color: #282828; - height: calc(0.75rem + 4px); - width: calc(0.75rem + 4px); - padding: 2px; - font-size: 0.75em; - display: block; - position: absolute; - bottom: 0; - border-radius: 999px; - right: -0.25rem; +.track-warning:empty { + display: none; } -.herecon.idle { - color: #faa61a; +.utitle { + font-size: 0.875rem; + font-weight: 100; + color: rgba(255, 255, 255, 0.4); } .notice { - margin: 0; - background-color: #222; - overflow: hidden; - height: 100%; - padding: var(--pad4); + margin: 0; + background-color: var(--color-bg-t1); + overflow: hidden; + height: 100%; + padding: var(--pad4); } .notice#notice { - display: block; + display: block; } .notice p { - margin-bottom: 15px; - margin-top: 15px; + margin: 15px 0; } .flexpacer { - flex: 1; + flex: 1; } .tabs { - display: flex; - background-color: black; + display: flex; + align-items: center; + width: 100%; + overflow: hidden; } .tab { - flex: 1; - margin: 5px 0 0 0; - padding: 0.5em; - font-size: 0.85rem; - color: #999; - text-align: center; - background-color: #222; - border: 0; - border-top: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 0; - box-shadow: 0 -0.2rem 0.5rem -0.1rem black, inset 0 -0.2rem 0.4rem -0.2rem black; - clip-path: - polygon(0% 0%, - 0% 0%, - calc(100% - var(--pad3)) 0%, - 100% var(--pad3), - 100% 100%, - 100% 100%, - 0% 100%, - 0% 0%); + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + height: auto; + min-height: 2.5rem; + padding: 0.5em; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; + text-transform: uppercase; + letter-spacing: .05em; + color: #999; + text-align: center; + background: none; + border: none; + border-radius: 0; + box-shadow: none; } .tab.on { - color: #ffffff; - background-color: #282828; - box-shadow: 0 -0.2rem 0.5rem -0.2rem black; + position: relative; + z-index: 1; + color: #ffffff; + + &::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 3px; + background-color: var(--color-accent); + border-radius: 99px; + opacity: 0.5; + } +} + +.logoutButt { + margin-left: var(--pad2); } -.tab:not(:first-child) { - margin-left: 5px; - ; +.removemeIcon { + font-size: 1rem; + vertical-align: middle; } -span.removemeIcon.material-icons { - font-size: 1rem; - vertical-align: middle; +.djplaque .deckRemoveBtn { + grid-area: 1 / 1; + justify-self: end; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + padding: 0; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + border-radius: var(--radius-sm); + box-shadow: none; +} +.djplaque .deckRemoveBtn:hover { + color: #fff; + background: rgba(255, 255, 255, 0.12); +} +.djplaque .deckRemoveBtn i { + font-size: 0.9rem; + pointer-events: none; } .butt { - display: flex; - justify-content: center; - align-items: center; - margin: 0; - color: white; - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - text-decoration: none; - background-color: var(--orange); - border-top: 1px solid rgba(255, 255, 255, 0.3); - border-bottom: 1px solid black; - transition: all 100ms ease-in-out; - user-select: none; + display: flex; + justify-content: center; + align-items: center; + margin: 0; + min-width: 2rem; + min-height: 2rem; + color: white; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + text-decoration: none; + background-color: var(--color-orange); + border-top: 1px solid rgba(255, 255, 255, 0.3); + border-bottom: 1px solid black; + box-shadow: var(--shadow-drop); + transition: all 100ms ease-in-out; + user-select: none; + + &.small { + height: min-content; + min-height: 0; + padding: 0.5em 0.75em; + font-size: 0.65rem; + } } .butt:hover { - background-color: var(--orangelite); + background-color: var(--color-orangelite); } .graybutt { - color: #888; - background: rgba(255, 255, 255, 0.066); + color: var(--color-text-muted); + background: rgba(255, 255, 255, 0.05); } .graybutt:hover { - background: #444; + background: rgba(255, 255, 255, 0.1); } -.graybutt:focus { - background: rgba(255, 255, 255, 0.1); +.graybutt:focus-visible { + background: rgba(255, 255, 255, 0.1); } .redbutt { - background: #c43; + background: #c43; } .redbutt:hover { - background: #b54; + background: #b54; } .iconbutt { - width: 2rem; - padding: 0; - font-size: 0.5rem; + width: 2rem; + padding: 0; + font-size: 0.5rem; } .iconbutt.on { - color: var(--orange); - background: rgba(255, 255, 255, 0.033); - border-top: 1px solid black; - border-bottom: 1px solid var(--orange66); - box-shadow: inset 0 0 1rem var(--orange33); + color: var(--color-orange); + background: rgba(255, 255, 255, 0.033); + border-top: 1px solid black; + border-bottom: 1px solid var(--color-orange66); + box-shadow: inset 0 0 1rem var(--color-orange33); +} + +#fire.on { + color: white; +} + +.iconbutt .material-symbols-outlined, +.iconbutt .material-symbols-outlined { + font-size: 1.43rem; } -.iconbutt .material-icons { - font-size: 1.43rem; +.material-symbols-outlined { + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} + +.accent { + color: white; + background-color: var(--color-orange); + background-image: radial-gradient(circle at 50% 15%, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.4)); + border-right: 1px solid rgba(123, 123, 123, 0.5); + border-left: 1px solid rgba(123, 123, 123, 0.5); + border-radius: var(--radius-md); +} + +.accent:hover { + background-color: var(--color-orangelite); } .scrollWrap { - height: 100%; - overflow: auto; + height: 100%; + overflow: auto; +} +/* Native scrollbar styling - invisible by default, visible on hover */ +.scroll-view, +.scrollWrap, +.modalContent { + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 200ms ease-in-out; } -.pvbarWrap { - display: flex; +.scroll-view:hover, +.scrollWrap:hover, +.modalContent:hover { + scrollbar-color: color-mix(in oklch, var(--color-accent) 50%, transparent) transparent; +} + +/* WebKit browsers (Chrome, Safari, Edge) - default invisible scrollbars */ +.scroll-view::-webkit-scrollbar, +.scrollWrap::-webkit-scrollbar, +.modalContent::-webkit-scrollbar { + width: 8px; +} + +.scroll-view::-webkit-scrollbar-track, +.scrollWrap::-webkit-scrollbar-track, +.modalContent::-webkit-scrollbar-track { + background: transparent; } -.working .material-icons { - animation: spin 3s infinite linear; +.scroll-view::-webkit-scrollbar-thumb, +.scrollWrap::-webkit-scrollbar-thumb, +.modalContent::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 4px; + transition: background-color 200ms ease-in-out; } -.ps { - position: relative; +/* Show scrollbar thumb on container hover */ +.scroll-view:hover::-webkit-scrollbar-thumb, +.scrollWrap:hover::-webkit-scrollbar-thumb, +.modalContent:hover::-webkit-scrollbar-thumb { + background-color: var(--color-accent); } -.ps__rail-x { - display: none !important; +.pvbarWrap { + display: flex; + align-items: center; + position: relative; + + &:hover { + background-color: var(--color-bg-t1); + } } -.ps__rail-y { - z-index: 2; +.working .material-symbols-outlined { + animation: spin 3s infinite linear; } -.ps .ps__rail-y:focus, -.ps .ps__rail-y:hover, -.ps .ps__rail-y.ps--clicking { - background-color: transparent; - outline: none; + +#viewnav { + display: flex; + align-items: center; + margin-left: var(--pad3); } -.ps__thumb-y, -.ps__rail-y:hover>.ps__thumb-y, -.ps__rail-y:focus>.ps__thumb-y, -.ps__rail-y.ps--clicking .ps__thumb-y { - width: 0.5rem; - background-color: rgba(123, 123, 123, 0.5); - border-radius: 0.2rem; +#viewnav .header_icon .material-symbols-outlined, +#viewnav .header_icon .material-symbols-outlined { + font-size: 1.75rem; } -/* Top Bar / Site Header */ +/* ============================================ + Top Bar / Site Header + ============================================ */ #topbar { - display: flex; - align-items: center; - padding: var(--pad1) var(--pad3); - color: #fff; - background-color: black; + grid-area: head; + display: flex; + align-items: center; + padding: var(--pad1) var(--pad3); + color: #fff; } .ftlogo { - margin-right: 0.5rem; - color: var(--orange); - font-size: 1.25rem; - font-weight: 700; + margin-right: 0.5rem; + color: var(--color-orange); + font-size: 1.25rem; + font-weight: 700; } #idtitle { - display: none; - font-size: 1rem; - color: #fff; - font-weight: 700; - margin-right:1rem; + display: none; + margin-top: 0.5em; + font-size: 1rem; + color: #fff; + font-weight: 100; + margin-right: 1rem; } -#socialthings{ +a.sociallogo { + text-decoration: none; + display: none; } -a.sociallogo{ - text-decoration:none; - display:none; - margin: 0.2rem 0.2rem 0 0.2rem; - +a.sociallogo[href] { + display: block; } -.sociallogo svg:hover{ - fill: #fff; +.sociallogo:hover svg { + fill: #fff; } .sociallogo svg { width: 1.2rem; height: 1.2rem; - fill: #888; + fill: var(--color-text-muted); +} + +.sociallogo.facebook svg { + width: 0.95rem; + height: 0.95rem; } -div#sociallthings { - margin-top: 0.25rem; +#roomlogo { + background-image: url(../img/idlogo2.png); + background-size: contain; + width: 3rem; + height: 3rem; + margin-right: 1rem; + background-repeat: no-repeat; + background-position: center; } -div#roomlogo { - background-image: url(../img/idlogo2.png); - background-size: contain; - width: 3rem; - height: 3rem; - margin-right: 1.5rem; - background-repeat: no-repeat; - background-position: center; +#loggedInUser { + margin-left: var(--pad2); + cursor: pointer; + text-decoration: none; } -#loggedInName { - margin: 0 var(--pad2); - cursor: pointer; - text-decoration: none; +#loggedInUser .ft-avatar { + width: 2rem; + height: 2rem; } -#loggedInName:hover { - text-decoration: underline; +#loggedInUser:hover { + text-decoration: underline; } .header_icon { - margin: 0.2rem 0.2rem 0 0.2rem; - padding: 0; - font-size: 0; - line-height: 0; - background: none; - border: 0; + margin: 0.5rem 0.2rem 0 0.2rem; + padding: 0; + height: auto; + font-size: 0; + line-height: 0; + background: none; + border: 0; + box-shadow: none; } -.header_icon .material-icons { - font-size: 1.2rem; - color: #888; +.header_icon .material-symbols-outlined, +.header_icon .material-symbols-outlined { + font-size: 1.2rem; + color: var(--color-text-muted); } -/*------------------------------------ MAIN BODY THINGS */ -#mainGrid { - grid-area: main; - flex: 1; - display: grid; - grid-template-rows: auto auto auto 1fr; - grid-template-columns: 1fr; - grid-template-areas: - "stage" - "main" - "main" - "main" - ; - overflow: hidden; -} - -#mainGrid.mmusrs { - grid-template-areas: - "stage" - "theme" - "mmopts" - "users" - ; -} - -#mainGrid.mmusrs #queuebox, -#mainGrid.mmusrs #thehistoryWrap, -#mainGrid.mmusrs #actualChat, -#mainGrid.mmusrs #login, -#mainGrid.mmchat #discover, -#mainGrid.mmusrs #discover { - display: none; -} - -#mainGrid.mmchat #discover { - display: none; +.header_icon:hover .material-symbols-outlined, +.header_icon:hover .material-symbols-outlined { + color: white; } -#mainGrid.mmqueue { - grid-template-areas: - "stage" - "theme" - "mmopts" - "queues" - ; +.header_icon.on .material-symbols-outlined, +.header_icon.on .material-symbols-outlined { + color: white; } -#mainGrid.mmqueue #usersbox, -#mainGrid.mmqueue #thehistoryWrap, -#mainGrid.mmqueue #actualChat, -#mainGrid.mmqueue #discover -#mainGrid.mmqueue #login, -#mainGrid.mmusrs #discover { - display: none; +.header_icon[data-label] { + position: relative; } -#mainGrid.mmchat { - grid-template-areas: - "stage" - "theme" - "mmopts" - "chat" - ; +.header_icon[data-label]::after { + content: attr(data-label); + position: absolute; + left: calc(100% + 0.75rem); + top: 50%; + transform: translateY(-50%); + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-family: var(--font-family); + white-space: nowrap; + color: var(--color-text-light); + background: var(--color-bg-t2); + border-radius: var(--radius-sm); + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 100; } -#mainGrid.mmchat #usersbox, -#mainGrid.mmchat #queuebox, -#mainGrid.mmchat #thehistoryWrap, -#mainGrid.mmchat #login, -#mainGrid.mmusrs #discover { - display: none; +.header_icon[data-label]:hover::after { + opacity: 1; } -#mainGrid.login { - grid-template-rows: auto 1fr; - grid-template-areas: - "stage" - "login" - ; +.iconbutt[data-label] { + position: relative; } -#mainGrid.login #usersbox, -#mainGrid.login #queuebox, -#mainGrid.login #history, -#mainGrid.login #thehistoryWrap, -#mainGrid.login #themebox, -#mainGrid.login #actualChat, -#mainGrid.login #voteActions, -#mainGrid.login #minimodeoptions { - display: none; +.iconbutt[data-label]::after { + content: attr(data-label); + position: absolute; + bottom: calc(100% + 0.5rem); + left: 50%; + transform: translateX(-50%); + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-family: var(--font-family); + font-weight: 400; + text-transform: none; + letter-spacing: 0; + white-space: nowrap; + color: var(--color-text-light); + background: var(--color-bg-t2); + border-radius: var(--radius-sm); + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 100; } -/* MiniMode Nav */ -#minimodeoptions { - grid-area: mmopts; +.iconbutt[data-label]:hover::after { + opacity: 1; } -#minimodeoptions .tab { - background-color: #1b1b1b; +/* ============================================ + Main Grid Layout + ============================================ */ +#mainGrid { + grid-area: main; + flex: 1; + display: grid; + grid-template-rows: auto auto auto 1fr; + grid-template-columns: 1fr; + grid-template-areas: + "head" + "stage" + "nav" + "content"; + width: 100%; + overflow: hidden; } -#minimodeoptions .tab.on { - background-color: #222; +#appShell { + display: contents; } -/* Users Lists */ -#usersbox { - grid-area: users; - display: flex; - flex-direction: column; - overflow: hidden; +/* Suppress flash during Firebase auth check */ +#mainGrid.pre-auth > * { + visibility: hidden; } -#userslist { - flex: 1; - background-color: #282828; - overflow: hidden; +/* Mobile: show only the active content panel per tab */ + +/* View panels are hidden by default; a view-class on #mainGrid reveals the active one. + At mobile they only show when the grid is in mmqueue mode. */ +#mainGrid.mmqueue.view-playlists #queuebox { display: flex; } +#mainGrid.mmqueue.view-history #thehistoryWrap { display: flex; } +#mainGrid.mmqueue.view-cards #cardsWrap { display: block; } +#mainGrid.mmqueue.view-discover #discover { display: flex; } + +#mainGrid.mmusrs #actualChat { display: none; } +#mainGrid.mmusrs #usersbox { display: flex; } + +#mainGrid.mmqueue #usersbox, +#mainGrid.mmqueue #actualChat { display: none; } + +#mainGrid.mmchat #usersbox { display: none; } +#mainGrid.mmchat #actualChat { display: flex; } + +#mainGrid.login { + grid-template-rows: auto auto 1fr; + grid-template-areas: + "head" + "stage" + "login"; } -#usertabs { - padding-top: 0.5rem; - background-color: #222; +#mainGrid.login #stage { + max-width: 36rem; + max-height: 40vh; + width: 100%; + margin: 0 auto; } -.usersWrap { - overflow: auto; +#mainGrid.login #login { + max-width: 36rem; + margin: 0 auto; } -#allusers, -#justwaitlist { - padding-top: 1rem; +#mainGrid.login .spot.empty .djname { + color: transparent; } -#allusersWrap, -#justwaitWrap { - height: 100%; +#mainGrid.login #loggedInUser, +#mainGrid.login #viewnav { + display: none; } -.prson { - display: flex; - align-items: center; - min-width: 0; - padding: var(--pad1) var(--pad4); +/* On very short viewports (landscape mobile) hide the login form, show just the stage */ +@media only screen and (max-height: 400px) { + #mainGrid.login { + grid-template-rows: auto 1fr; + grid-template-areas: + "head" + "stage"; + } + #mainGrid.login #login { display: none; } } -#allusersWrap .prson { - cursor: pointer; - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; +/* Mobile: view selector lives in the mini-nav, not the header */ +#playlists, +#history, +#cardcase, +#discover-nav { + display: none; } -.prsnName { - flex: 1; - font-size: 0.9rem; - font-weight: 700; - line-height: 1.5; - white-space: nowrap; - overflow-x: hidden; - text-overflow: ellipsis; +/* ============================================ + MiniMode Nav + ============================================ */ +#minimodeoptions { + grid-area: nav; } -#userslist .utitle { - margin-left: auto; +/* ============================================ + Users Lists + ============================================ */ +#usersbox { + grid-area: content; + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; } -#userslist .prson .prsnJoined { - display: none; +.usersWrap { + max-width: 18rem; + overflow: auto; } -#userslist .prson:hover .prsnJoined { - display: block; +#usersStats { + display: none; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: var(--pad2) var(--pad3) 0 var(--pad3); + color: var(--color-text-muted); } -#userslist .prson:hover .utitle { - display: none; +#usersStats h2 { + padding: 0.2rem 0; + font-size: 0.75rem; + color: inherit; } -#userslist .prson.blockd .prsnName, #userslist .prson.blockd .prsnJoined, #userslist .prson.blockd .utitle { - opacity: 0.5; +#usersStats .listenerType { + display: none; + align-items: center; + font-size: 0.875rem; + font-weight: 700; } -.botson { - background-color: #000; - background-size: auto 100%; - border-radius: 999px; - background-position: center 55%; - background-repeat: no-repeat; - border-bottom: 1px solid #888; +#usersStats .material-symbols-outlined { + font-size: 1rem; + color: var(--color-text-muted); } -#userslist .botson { - margin-right: 0.75rem; - width: 2rem; - height: 2rem; - position: relative; +#allUsers { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: center; + gap: var(--pad2); + padding: var(--pad4); } -span.block { - width: 2rem; - height: 2rem; - font-size: 2rem; - background-color: rgba(0, 0, 0, 0.50); - color: var(--orange); - border-radius: 999px; +#allUsers > *, +#allUsers .prson { + display: contents; } -span.block:empty { - display: none; +#allUsers #userKEY.prson { + display: none !important; } -/* The Stage */ -#stage { - grid-area: stage; - display: flex; - flex-direction: column; - position: relative; - overflow: hidden; +#allUsersWrap { + flex: 1; + min-height: 0; + width: 100%; + overflow: auto; + display: flex; + flex-direction: column; } -#stage::before { - content: ""; - position: absolute; - z-index: 1; - top: 0; - right: 0; - bottom: 0; - left: 0; - background: linear-gradient(rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.55)); +#usersWaitlist { + display: none; + flex-direction: column; + gap: var(--pad2); + flex-shrink: 0; + padding: var(--pad2) var(--pad4); + border-bottom: 1px solid var(--color-border); + + &.has-entries { + display: flex; + + + #allUsers { + padding-top: var(--pad2); + } + } } -#stage>div:not(#screenBox) { - position: relative; - z-index: 2; +.waitlist-label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + margin-bottom: 0.1rem; + + & .material-symbols-outlined { + font-size: 1rem; + } } -#djStage { - display: flex; - flex-direction: column; - justify-content: space-between; +.waitlist-item { + display: flex; + align-items: center; + gap: var(--pad2); + font-size: 0.875rem; + + & .ft-avatar { order: 0; width: 1.5rem; height: 1.5rem; flex-shrink: 0; } + & .prsnRole { order: 1; } + & .waitlist-name { order: 2; } + & .waitlist-pos { order: 3; margin-left: auto;} } -#nowplaying { - position: relative; - display: grid; - grid-template-columns: auto 1fr auto; - grid-template-rows: auto auto 1fr; - grid-template-areas: - "art track timr" - "art artist artist" - "art plays plays" - ; - margin-bottom: auto; - padding: var(--pad4); -} - -#nowplaying::before { - content: ''; - position: absolute; - top: 3rem; - left: 3rem; - width: 15rem; - height: 5rem; - background-color: rgba(0, 0, 0, 0.25); - box-shadow: 0 0 10rem 10rem rgba(0, 0, 0, 0.25); +.waitlist-pos { + font-size: 0.75rem; + font-weight: 700; + color: var(--color-text-muted); + width: 1rem; + text-align: left; + flex-shrink: 0; } -#albumArt { - grid-area: art; - margin: var(--pad1) var(--pad3) 0 var(--pad1); - width: 4rem; - height: 4rem; - background-size: cover; - background-position: center center; +.waitlist-name { + flex: 1; + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: 500; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + & .removemeIcon { + font-size: 1rem; + color: var(--color-text-muted); + } +} +.prson.idlething .imptcon { + overflow: auto; + display: block; } -#track { - grid-area: track; - font-size: 1.25rem; - color: white; +.prsnStatus { + display: none; } -#timr { - grid-area: timr; - padding-top: 0.15em; - font-size: 0.85rem; - font-weight: 700; - letter-spacing: 0.05em; - text-align: right; +.prsnNameRole { + display: flex; + align-items: center; + margin-left: 0.25rem; + font-size: 0.875rem; + font-weight: 400; +} +.prson.blockd .prsnNameRole { + opacity: 0.5; } -#artist { - grid-area: artist; - margin-bottom: var(--pad2); - font-size: 1rem; - font-weight: 400; - overflow: hidden; - color: white; - white-space: nowrap; - text-overflow: ellipsis; +.prsnName { + font-weight: 500; + color: var(--color-text); + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; } -#source, -#plays { - font-size: 0.75rem; - font-weight: 700; - color: rgba(255, 255, 255, 0.8); +.prsnRole { + font-size: 1.2rem; + color: #43b581; + line-height: 1; +} +.prson.idle .prsnRole { + color: var(--color-orange); } -#plays { - grid-area: plays; +.imptcon { + display: none; + position: absolute; + right: -0.25rem; + height: calc(0.75rem + 4px); + width: calc(0.75rem + 4px); + padding: 2px; + font-size: 0.75em; + color: var(--color-orange); + background-color: var(--color-bg-t2); + border-radius: var(--radius-pill); } -#playCount, -#firstPlay { - margin-right: var(--pad2); +.djOrder { + display: flex; + align-items: center; + justify-content: flex-end; + font-size: 0.85rem; + font-weight: 700; + line-height: 0; + color: var(--color-text-muted); +} +.djOrder .material-symbols-outlined { + margin-right: 0.25rem; + font-size: 1.25rem; + line-height: 0; + color: rgba(123, 123, 123, 0.5); +} +.djOrder.ondeck .material-symbols-outlined, +.djOrder.waitlist .material-symbols-outlined { + color: var(--color-text-muted); +} +.djOrder span { + width: 1rem; } -#playCount:empty, -#firstPlay:empty, -#lastPlay:empty { - display: none; +.prsnJoined { + font-size: 0.75rem; + color: var(--color-text-dim); + white-space: nowrap; } -#deck { - grid-area: deck; - display: flex; - max-width: 100vw; +/* ── Reusable avatar component ── */ +.ft-avatar { + display: block; + width: 100%; + height: 100%; + border-radius: var(--radius-pill); + background-color: #000; + background-size: auto 125%; + background-position: center 66%; + background-repeat: no-repeat; + flex-shrink: 0; + overflow: hidden; } -#deck.dance .avtr:not(.animate) { - transform-origin: bottom; - animation: MoveSideSide 2s linear infinite; +#allUsers .ft-avatar { + display: flex; + align-items: center; + position: relative; + width: 1.5rem; + height: 1.5rem; + background-position: 50% 72%; } -.spot { - flex: 1; - position: relative; - display: flex; - flex-direction: column; - justify-content: flex-end; - margin-right: var(--pad2); - margin-left: var(--pad2); - min-width: 0; - height: 1.5rem; +.blockon { + display: none; + width: 2.2rem; + height: 2.2rem; + font-size: 2.2rem; + background-color: rgba(0, 0, 0, 0.5); + color: var(--color-orange); + border-radius: var(--radius-pill); +} +.prson.blockd .blockon { + display: block; +} +.blockon:empty { + display: none; } -.spot.empty .djplaque { - flex-direction: column; - color: rgba(255, 2556, 255, 0.5); +/* ============================================ + Now Playing + ============================================ */ +#nowplaying { + grid-area: visual; + align-self: start; + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto 1fr; + grid-template-areas: + "art track timrvol" + "art artist timrvol" + "art plays timrvol"; + margin-bottom: auto; + padding: var(--pad4) var(--pad4) 2.5rem var(--pad4); } -.avtr { - display: none; - position: absolute; - z-index: 0; - bottom: 1.4rem; - width: 100%; - height: 8rem; - background-size: contain; - background-repeat: no-repeat; - background-position: center bottom; +#albumArt { + grid-area: art; + margin: 0 var(--pad3) 0 var(--pad1); + width: 4rem; + height: 4rem; + background-size: cover; + background-position: center center; } -.avtr.animate { - animation: MoveUpDown 1s linear infinite; +#track { + grid-area: track; + font-size: 1.125rem; + line-height: 1.1; + color: white; } -.djplaque { - position: relative; - z-index: 1; - display: flex; - justify-content: space-between; - min-width: 0; - padding: 0 var(--pad3); - font-size: 0.75rem; - line-height: 1.5rem; - text-align: center; - background-color: #151515; - border-top-left-radius: 0.5rem; - border-top-right-radius: 0.5rem; +#timr { + grid-area: timrvol; + padding-top: 0.15em; + font-family: "Open Sans", helvetica, arial, sans-serif; + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.05em; + text-align: right; + color: rgba(255, 255, 255, 0.66); } -.djname { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - padding-right: 5px; +#artist { + grid-area: artist; + margin-bottom: var(--pad2); + font-size: 0.875rem; + font-weight: 500; + overflow: hidden; + color: white; + white-space: nowrap; + text-overflow: ellipsis; } -.playcount { - white-space: nowrap; +#source, +#plays { + font-size: 0.75rem; + font-weight: 300; + color: rgba(255, 255, 255, 0.8); } -#prgbar { - width: 100%; - height: 0.5rem; - border-bottom: 1px solid #333; - margin-bottom: 0; +#plays { + grid-area: plays; } -#volandthings, -#queueControls { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.4rem 0.5rem; - white-space: nowrap; +#playCount, +#firstPlay { + margin-right: var(--pad2); +} + +#playCount:empty, +#firstPlay:empty, +#lastPlay:empty { + display: none; } -#volandthings { - position: relative; - background-color: #151515; - border-bottom: 1px solid #333; +/* ============================================ + Player Controls + ============================================ */ +#queueControls { + display: flex; + align-items: center; + justify-content: center; + padding: 0.8rem 1rem; + white-space: nowrap; } #songthings, #voteActions, #songActions, #playerControls { - display: flex; - white-space: nowrap; + display: flex; + white-space: nowrap; } -#songthings, #playerControls { - flex: 1; + flex: 1; } #songthings .iconbutt { - margin-right: 0.25rem; + margin-right: 0.25rem; } #voteActions { - margin-right: 1rem; -} - -#playerControls { - margin-left: 1rem; + margin-right: 1rem; } #volplace { - flex: 1; - display: flex; - align-items: center; - padding-left: 0.25rem; - padding-right: 0.25rem; - white-space: nowrap; + grid-area: timrvol; + display: flex; + align-items: flex-start; + justify-content: center; + margin-top: 2rem; + max-height: 3rem; } #volstatus { - cursor: pointer; -} - -#volplace .material-icons { - font-size: 16px !important; + cursor: pointer; } #shareinfo { - text-align: right; + text-align: right; } #stealContain { - display: none; - position: fixed; - z-index: 59; - top: 50%; - left: 50%; - box-shadow: 0 0.25rem 1rem -0.25rem black; + display: none; + position: fixed; + top: 0; + left: 0; + z-index: 59; } #stealBox { - width: 16rem; - padding: var(--pad3); - background-color: #333; - border-radius: 5px; + width: 16rem; + padding: var(--pad3); + background-color: var(--color-bg-t1); + border-radius: var(--radius-md); + box-shadow: 0 0.25rem 1rem -0.25rem black; +} + +.ft-arrow { + position: absolute; + width: 8px; + height: 8px; + background: var(--color-bg-t1); + transform: rotate(45deg); +} + +#stealpicker { + width: 100%; + font-size: 1rem; + border: none; + font-family: var(--font-family); + background-color: var(--color-bg-t3); + padding-left: var(--pad3); + color: var(--color-text-light); + white-space: nowrap; +} + +/* ─── Reusable Popover Component ───────────────────────────────────────────── + Usage:
... + Position is set via JS in the toggle event handler. + ───────────────────────────────────────────────────────────────────────── */ +[popover].ft-popover { + position: fixed; + inset: auto; /* reset UA :popover-open { inset: 0 } so right/bottom don't stretch the element */ + top: 0; + left: 0; + margin: 0; + padding: 0; + border: none; + background: transparent; + overflow: visible; + color: inherit; +} + +.ft-popover-box { + background-color: var(--color-bg-t1); + border-radius: var(--radius-md); + box-shadow: 0 0.25rem 1rem -0.25rem black; + padding: var(--pad3); } -#stealArrow { - margin-top: -0.5rem; - margin-left: 1.15rem; - width: 0; - height: 0; - border-left: 1rem solid transparent; - border-right: 1rem solid transparent; - border-bottom: 1rem solid #333; +#socialPopover .ft-popover-box { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--pad3); } -select#stealpicker { - width: 100%; - font-size: 1rem; - border: none; - font-family: "Open Sans", helvetica, arial, sans-serif; - background-color: #222; - padding-left: var(--pad1); - color: #eee; - white-space: nowrap; +#socialPopover .sociallogo[href] { + display: flex; + align-items: center; + justify-content: center; + margin: 0; } #addToQueueBttn { - margin-left: 1em; - color: white; - background-color: var(--orange); - border-right: 1px solid black; - border-left: 1px solid black; - border-radius: 5px; + margin-left: 1em; } #slider { - margin-right: var(--pad2); - width: 100%; - max-width: 10rem; + height: 100%; + width: 3px; } .ui-slider { - position: relative; - text-align: left; + position: relative; + display: flex; + justify-content: center; } .ui-slider .ui-slider-handle { - position: absolute; - z-index: 2; - width: 1.25rem; - height: 1.25rem; - cursor: grab; - background-color: #bbb; - border: 0.2rem solid #666; - border-radius: 999px; - -ms-touch-action: none; - touch-action: none; + position: absolute; + z-index: 2; + width: 1.25rem; + height: 1.25rem; + cursor: grab; + background-color: var(--color-text); + border-radius: 0.4rem; + box-shadow: 0 0.5rem 0.5rem -0.25rem black; + touch-action: none; } .ui-slider .ui-slider-range { - position: absolute; - z-index: 1; - font-size: .7em; - display: block; - border: 0; - background-position: 0 0; + position: absolute; + z-index: 1; + font-size: 0.7em; + display: block; + border: 0; + background-position: 0 0; } .ui-slider-horizontal .ui-slider-range-min { - left: 0; - background-color: var(--orange); + left: 0; + background-color: var(--color-orange); } -.ui-slider-horizontal { - height: 3px; +/* Vertical volume slider */ +.ui-slider-vertical { + width: 3px; + height: 5rem; + background-color: rgba(204, 204, 204, 0.2); } -.ui-slider-horizontal .ui-slider-handle { - top: -0.6rem; - margin-left: -.6em; +.ui-slider-vertical .ui-slider-handle { + margin-bottom: -0.1875rem; + width: 2rem; + height: 0.5rem; + border-radius: 2px; + background-color: var(--color-text); + box-shadow: 0 1px 4px -1px black; } -.ui-slider-horizontal .ui-slider-range { - top: 0; - height: 100%; +.ui-slider-vertical .ui-slider-range { + left: 0; + width: 100%; + bottom: 0; } -.ui-slider-horizontal .ui-slider-range-min { - left: 0; +.ui-slider-vertical .ui-slider-range-min { + bottom: 0; + background-color: var(--color-accent); } -.ui-slider-horizontal .ui-slider-range-max { - right: 0; +/* Tick marks on either side of the track */ +#volplace { + position: relative; } -.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default, .ui-button, html .ui-button.ui-state-disabled:hover, html .ui-button.ui-state-disabled:active { - font-weight: normal; +#volplace::before, +#volplace::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + width: 10px; + background-image: repeating-linear-gradient( + to bottom, + rgba(255,255,255,0.35) 0px, + rgba(255,255,255,0.35) 1px, + transparent 1px, + transparent 5px + ); + pointer-events: none; } -.ui-widget.ui-widget-content { - border: none; - background-color: rgba(204, 204, 204, 0.43137254901960786); +#volplace::before { + left: 0; } -.ui-widget-content { - border: 1px solid #dddddd; - background: #ffffff; - color: #333333; +#volplace::after { + right: 0; } -#screenBox { - position: absolute; - top: -100%; - width: 100%; - height: 100%; - z-index: 0; - transition: top 2s ease-in-out; - pointer-events: none; +.ui-state-default, +.ui-widget-content .ui-state-default, +.ui-widget-header .ui-state-default, +.ui-button, +html .ui-button.ui-state-disabled:hover, +html .ui-button.ui-state-disabled:active { + font-weight: normal; } -#scScreen, -#playerArea { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100% +.ui-widget.ui-widget-content { + border: none; + background-color: rgba(204, 204, 204, 0.43); } -#screenover { - position: absolute; - top: 0; - width: 100%; - height: 100%; - background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.1)); +.ui-widget-content { + border: 1px solid #dddddd; + background: #ffffff; + color: #333333; } -/* Queues / Playlists */ -#queuebox { - grid-area: queues; - display: flex; - flex-direction: column; - position: relative; - overflow: hidden; +/* ============================================ + Stage Wrapper + ============================================ */ +#stage { + grid-area: stage; + display: grid; + grid-template-rows: 1fr auto; + grid-template-areas: + "visual" + "controls"; } -input#queueFilter { - width: 100%; +/* ============================================ + The Stage + ============================================ */ +#djStage > div:not(#screenBox):not(#ft-eq) { + position: relative; + z-index: 2; } -div#filterMachine { - padding: var(--pad2); +#djStage { + grid-area: visual; + display: flex; + flex-direction: column; + justify-content: flex-end; + position: relative; + overflow: hidden; + border-top-left-radius: 20px; + border-top-right-radius: 20px; + box-shadow: inset 0 -4rem 4rem -3rem rgba(0, 0, 0, 0.5), inset 0 16rem 32rem -3rem rgba(0, 0, 0, 0.75), + inset 0 4rem 2rem -4rem black; } -#mainqueue .material-icons, #thehistory .material-icons { - margin: 0 var(--pad1); - font-size: 1.2rem; +#deck { + grid-area: deck; + display: flex; + max-width: 100vw; +} + +#deck.dance .avtr:not(.animate) { + transform-origin: bottom; + animation: MoveSideSide 2s linear infinite; +} + +.spot { + flex: 1; + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-end; + margin-right: var(--pad2); + margin-left: var(--pad2); + min-width: 0; +} + +.spot.empty .djplaque { + color: rgba(255, 255, 255, 0.5); +} + +.avtr { + display: none; + position: absolute; + z-index: 0; + bottom: 1.4rem; + width: 100%; + height: 6rem; + background-size: contain; + background-repeat: no-repeat; + background-position: center bottom; +} + +.avtr.animate { + animation: MoveUpDown 1s linear infinite; +} + +.djplaque { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + align-items: center; + min-width: 0; + padding: 0 var(--pad2); + font-size: 0.75rem; + line-height: 1.5rem; + color: #fff; + background-image: linear-gradient(to bottom, var(--color-bg-t2), rgb(21,21,21)); + box-shadow: inset 0 1px rgba(255,255,255,0.2); + border-top-left-radius: var(--radius-md); + border-top-right-radius: var(--radius-md); +} +.djActive { + background-image: linear-gradient(to top, color-mix(in srgb, var(--color-bg-t2) 50%, var(--color-accent) 50%), var(--color-accent)); +} + +.djname { + grid-area: 1 / 1; + min-width: 0; + padding: 0 1.75rem; + text-align: center; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.playcount { + display: none; +} + +.addmeButt { + display: inline-block; + background-color: transparent; + border: none; +} + +#prgbar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + z-index: 1; +} +#prgbarbar { + position: absolute; + left: 0; + height: 3px; +} +#screenBox { + position: absolute; + top: -100%; + width: 100%; + height: 100%; + z-index: 0; + transition: top 2s ease-in-out; + pointer-events: none; +} + +@keyframes ft-eq-bounce { + 0%, 100% { height: 15%; } + 50% { height: 85%; } +} + +#ft-eq { + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: space-around; + gap: 5px; + padding: 5px; + box-sizing: border-box; + z-index: 0; +} + +.ft-eq-bar { + flex: 1; + min-height: 15%; + background-color: var(--color-accent); + opacity: 0.25; + animation: ft-eq-bounce 10s ease-in-out infinite; + transform: scaleX(2) scaleY(2); + filter: blur(50px); +} + +.ft-eq-bar:nth-child(1) { animation-duration: 11s; animation-delay: 0s; background-color: color-mix(in srgb, var(--color-accent) 80%, rebeccapurple 20%); } +.ft-eq-bar:nth-child(2) { animation-duration: 07s; animation-delay: -03s; background-color: color-mix(in srgb, var(--color-accent) 80%, cyan 20%); } +.ft-eq-bar:nth-child(3) { animation-duration: 13s; animation-delay: -06s; background-color: color-mix(in srgb, var(--color-accent) 80%, deepskyblue 20%); } +.ft-eq-bar:nth-child(4) { animation-duration: 08s; animation-delay: -01s; background-color: color-mix(in srgb, var(--color-accent) 80%, lime 20%); } +.ft-eq-bar:nth-child(5) { animation-duration: 10s; animation-delay: -04.5s; background-color: color-mix(in srgb, var(--color-accent) 80%, orange 20%); } +.ft-eq-bar:nth-child(6) { animation-duration: 06s; animation-delay: -02s; background-color: color-mix(in srgb, var(--color-accent) 80%, yellow 20%); } +.ft-eq-bar:nth-child(7) { animation-duration: 12s; animation-delay: -05.5s; background-color: color-mix(in srgb, var(--color-accent) 80%, magenta 20%); } +.ft-eq-bar:nth-child(8) { animation-duration: 07.5s; animation-delay: -03.5s; background-color: color-mix(in srgb, var(--color-accent) 80%, deeppink 20%); } +.ft-eq-bar:nth-child(9) { animation-duration: 10.5s; animation-delay: -01.5s; background-color: color-mix(in srgb, var(--color-accent) 80%, violet 20%); } +.ft-eq-bar:nth-child(10) { animation-duration: 08.5s; animation-delay: -05s; background-color: color-mix(in srgb, var(--color-accent) 80%, indigo 20%); } +.ft-eq-bar:nth-child(11) { animation-duration: 11.5s; animation-delay: -02.5s; background-color: color-mix(in srgb, var(--color-accent) 80%, blue 20%); } +.ft-eq-bar:nth-child(12) { animation-duration: 06.5s; animation-delay: -04s; background-color: color-mix(in srgb, var(--color-accent) 80%, navy 20%); } + +#scScreen, +#playerArea { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; +} + +#screenover { + position: absolute; + top: 0; + width: 100%; + height: 100%; + background: linear-gradient(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.15)); +} + +/* ============================================ + View Panel (Playlists / History / Cards) + ============================================ */ +#queuebox { + grid-area: content; + display: none; + flex-direction: column; + position: relative; + padding-top: var(--pad4); + overflow: hidden; +} + +.queuetop { + background-color: var(--color-bg-t1); + border-top: 1px solid rgba(255, 255, 255, 0.1); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px 20px var(--radius-md) var(--radius-md); +} + +#queueFilter { + width: 100%; + border-bottom: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +#filterMachine { + padding: 0; +} + +#mainqueue { + padding: var(--pad1) 0.75rem; +} +@media only screen and (hover: hover) { + #mainqueue { + padding-right: 0; + } +} +#mainqueue.emptyList:before, +#mainqueue.loading:before, +#mainqueue.overFiltered:before { + display: flex; + justify-content: center; + padding: var(--pad2); + font-style: italic; + color: rgba(255, 255, 255, 0.4); +} +#mainqueue.emptyList:before { + content: "this playlist is empty"; +} +#mainqueue.loading:before { + content: "loading..."; +} +#mainqueue.overFiltered:before { + content: "no tracks meet your search criteria"; +} + +#mainqueue .material-symbols-outlined, #thehistory .material-symbols-outlined { + margin: 0 var(--pad1); + font-size: 1.2rem; +} + +#thehistory button.material-symbols-outlined:not(.previewicon) { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: inherit; + box-shadow: none; +} + +#mainqueue .previewicon { + position: absolute; + z-index: 1; + left: 0.2rem; } #queuelist .pvbar { - padding: var(--pad2) var(--pad2); - background-color: #222; - border-bottom: 1px solid black; - cursor: move; - /* fallback if grab cursor is unsupported */ - cursor: grab; - cursor: -moz-grab; - cursor: -webkit-grab; + margin: var(--pad1) 0; + padding-right: var(--pad2); + background-color: var(--color-bg-t1); + border-top: 1px solid var(--color-bg-t2); + border-bottom: 1px solid var(--color-bg); + border-radius: var(--radius-md); + cursor: grab; + overflow: hidden; +} + +#queuelist .q-art { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + background-size: cover; + background-position: center; + background-color: var(--color-bg-t2); } #queuelist .pvbarWrap { - align-items: center; + align-items: center; } #queuelist .pvbar.editing .edittags { - display: none; + display: none; } #queuelist .pvbar.editing .closeeditor { - display: block; + display: block; } #queuelist .listwords { - flex: 1; - margin-left: var(--pad2); + flex: 1; + margin-left: var(--pad3); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; } #listpickerWrap { - flex: 1; -} - -select#listpicker { - width: 100%; - background-color: #333; - padding-left: var(--pad2); + flex: 1; } -#queueControls, -#filterMachine { - background-color: #222; +#listpicker { + width: 100%; } #qControlButtons { - display: flex; + display: flex; } -button#cancelqsearch { - display: none; - margin-left: var(--pad2); +#cancelqsearch { + display: none; + margin-left: var(--pad2); } #addbox, #plmanager { - display: none; - flex-direction: column; - height: 100%; - background-color: #222; - overflow: hidden; + display: none; + flex-direction: column; + height: 100%; + overflow: hidden; } #plmanager { - padding: var(--pad2); + padding: var(--pad2); } .ortxt { - padding: var(--pad1); - text-align: center; + padding: var(--pad1); + text-align: center; } -input#qsearch { - margin: var(--pad2); +#qsearch { + margin: var(--pad2); + margin-bottom: 0; } #queuelist { - flex: 1; - height: 100%; - background-color: #181818; - overflow: auto; + flex: 1; + height: 100%; + background-color: var(--color-bg-s1); + overflow: auto; } #searchResults { - flex: 1; - background-color: #282828; + flex: 1; + background-color: var(--color-bg-t2); } - #mergeContain { - display: none; - position: absolute; - z-index: 15; - width: calc(100% - 30px); - margin: 0.5rem 15px 0 15px; - box-shadow: 0 0.25rem 0.5rem -0.125rem black; + overflow: auto; + display: none; + position: absolute; + z-index: 15; + width: calc(100% - 30px); + margin: 0.5rem 15px 0 15px; + box-shadow: 0 0.25rem 0.5rem -0.125rem black; } #mergeArrow { - position: absolute; - bottom: 100%; - right: 1.85rem; - width: 0; - height: 0; - border-left: 1rem solid transparent; - border-right: 1rem solid transparent; - border-bottom: 1rem solid #333; + position: absolute; + bottom: 100%; + right: 1.85rem; + width: 0; + height: 0; + border-left: 1rem solid transparent; + border-right: 1rem solid transparent; + border-bottom: 1rem solid var(--color-bg-t2); } #mergeBox { - background-color: #333; - padding: 15px; - border-radius: 5px; + background-color: var(--color-bg-t2); + padding: 15px; + border-radius: var(--radius-md); } #mergeSetup { - display: flex; - align-items: center; - justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; } #mergeHappening { - display: none; + display: none; } -select#mergepicker, #mergepicker2 { - width: calc(50% - 50px); - font-size: 14px; - border: none; - font-family: "Open Sans", helvetica, arial, sans-serif; - background-color: #222; - padding-left: 5px; - color: #eee; - height: 24px; - white-space: nowrap; +#mergepicker, +#mergepicker2 { + width: calc(50% - 50px); + font-size: 14px; + border: none; + font-family: var(--font-family); + padding-left: 5px; + color: var(--color-text-light); + white-space: nowrap; } -#mergeBox i.material-icons { - font-size: 17px !important; - font-weight: 700; - cursor: default; - vertical-align: middle; +#mergeBox .material-symbols-outlined { + font-size: 17px !important; + font-weight: 700; + cursor: default; + vertical-align: middle; } #mergeCompleted { - display: none; + display: none; } .importResult { - display: flex; - align-items: center; - padding: var(--pad1) 0; - border-bottom: 1px solid #111; + display: flex; + align-items: center; + padding: var(--pad1) 0; + border-bottom: 1px solid var(--color-bg-s2); } -.importResult .material-icons { - margin-left: var(--pad2); - font-size: 1rem; +.importResult .material-symbols-outlined { + margin-left: var(--pad2); + font-size: 1rem; } a.importLinkCheck { - color: #eee; - line-height: 0; + color: var(--color-text-light); + line-height: 0; } .imtxt { - flex: 1; - padding-right: var(--pad2); + flex: 1; + padding-right: var(--pad2); } .tagPromptBox { - padding: var(--pad3) 0 var(--pad1) var(--pad2); - font-size: .875rem; + font-size: 0.875rem; } -.tagPromptBox .closebutt { - float: right; +.closeeditor { + display: none; } -.tagsNlink { - display: flex; +.tagPromptBox input.tagMachine { + width: 100%; + margin: 0 0 var(--pad2); } -.tagsNlink input.tagMachine { - flex: 1; - margin: 0; +/* ============================================ + Theme Bar + ============================================ */ +#themebox { + grid-area: controls; + z-index: 15; + position: relative; + display: flex; + align-items: center; + max-width: 100vw; + padding: var(--pad2) var(--pad3); + font-size: 1rem; + text-align: center; + color: #fff; + background-color: var(--color-bg-s1); + border-bottom-left-radius: 20px; + border-bottom-right-radius: 20px; +} + +.themeLeft, +.themeRight { + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--pad2); } -.tagSongLink { - padding: var(--pad1) var(--pad1) var(--pad1) var(--pad2); +.themeCenter { + flex: 1; + min-width: 0; + overflow: hidden; } -#mainqueue .material-icons.tracklink { - font-size: 1.5rem; +@keyframes theme-ticker { + from { transform: translateX(0); } + to { transform: translateX(-50%); } } -/* Theme */ -#themebox { - grid-area: theme; - z-index: 15; - padding: var(--pad2); - font-size: 1rem; - text-align: center; - white-space: nowrap; - text-overflow: ellipsis; - background-color: #333; - border-bottom: 1px solid #111; - overflow: hidden; +#currentTheme { + white-space: nowrap; + overflow: hidden; } -#currentTheme { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - width: 100%; +#currentTheme.is-ticker .ticker-run { + display: inline-block; + white-space: nowrap; + animation: theme-ticker 14s linear infinite; } -/* Chat */ +.ticker-sep { + padding: 0 0.5em; + opacity: 0.4; +} + +/* ============================================ + Chat + ============================================ */ #actualChat { - grid-area: chat; - display: flex; - flex-direction: column; - overflow: hidden; - background-color: #181818; + grid-area: content; + justify-self: end; + display: flex; + flex-direction: column; + overflow: hidden; } #chatsWrap { - flex: 1; - height: 100%; - overflow: auto; + flex: 1; + height: 100%; + padding-inline: var(--pad2); + overflow: auto; +} + +#chats { + margin: 0 auto; + max-width: 54ch; } #newchat { - width: 100%; + width: 100%; } #newchatForm { - flex: 1; - margin-right: var(--pad1); + grid-column: 2/3; + width: 100%; } .newChat { - position: relative; - display: flex; - justify-content: space-between; - margin: var(--pad3); - color: #ddd; - word-break: break-word; - background-color: #181818; + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + gap: var(--pad2); + margin: var(--pad3); + color: var(--color-text); + word-break: break-word; } -.newChat .botson { - position: relative; - z-index: 2; - margin: 0.15rem var(--pad3) 0 0; - width: 2rem; - height: 2rem; - cursor: pointer; +.newChat .ft-avatar { + position: relative; + z-index: 2; + width: 2rem; + height: 2rem; + cursor: pointer; } -#actualChat.avatarsOff .botson { - display: none; +#actualChat.avatarsOff .ft-avatar { + display: none; } .nowplayn { - position: relative; - background-color: transparent; + position: relative; + background-color: transparent; } .newChat.nowplayn, -.nowplayn+.newChat { - border: 0; +.nowplayn + .newChat { + border: 0; } -.npmsg, .lcrsp { - position: relative; - z-index: 2; - width: 100%; - padding: var(--pad1); - color: #888; - font-size: 0.75rem; - text-align: center; - background-color: #282828; - border-radius: var(--pad1); +.npmsg, +.lcrsp { + position: relative; + z-index: 2; + width: 100%; + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.npmsg { + grid-column: 1 / -1; +} + +.npmsg-fires { + position: absolute; + z-index: 2; + right: 0; + top: 0; + font-size: 10px; + line-height: 0; + pointer-events: none; } .chatContent { - flex: 1; - padding: 0 var(--pad4) 0 0; + grid-column: 2 / -1; + grid-row: 1 / -1; + display: grid; + grid-template-columns: subgrid; } .chatContent .utitle { - line-height: 1.6; - color: rgba(255, 255, 255, 0.25); + position: absolute; + z-index: 2; + left: -1.5rem; + color: var(--color-text-dim); } .chatHead { - display: flex; + position: relative; + display: flex; + align-items: center; +} + +.chatHead::after { + content: ""; + flex: 1; + height: 1px; + background: rgba(255, 255, 255, 0.1); + margin-left: var(--pad1); } .chatName { - margin-right: var(--pad2); - font-size: 0.75rem; - font-weight: 700; - color: rgba(255, 255, 255, 0.4); - cursor: pointer; + margin-right: var(--pad1); + font-size: 0.75rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; } .modDelete { - position: absolute; - right: 0; - top: 0; - font-size: 1rem; - padding-left: 0.25rem; - font-weight: 400; - padding-right: 0.25rem; - background-color: #333; - cursor: pointer; - display: none; - color: #999; + position: absolute; + right: 0; + top: 0; + font-size: 1rem; + padding: 0 0.25rem; + font-weight: 400; + background-color: #333; + cursor: pointer; + display: none; + color: #999; } .modDelete:hover { - background-color: #dd2e44; - color: #ddd; + background-color: #dd2e44; + color: #ddd; } .chatText { - position: relative; + position: relative; + font-size: 0.75rem; + grid-column: 1 / -1; } .chatText.deleteMe:hover { - background-color: #282828; + background-color: var(--color-bg-t2); } .chatText.deleteMe:hover .modDelete { - display: block; + display: block; } .chatText a { - position: relative; - display: block; + position: relative; + display: block; } .chatText .inlineImage { - display: block; - max-width: 100%; + display: block; + max-width: 100%; } .chatText .hideImage { - position: absolute; - z-index: 2; - top: 0.5rem; - right: 0.5rem; - display: flex; - justify-content: center; - align-items: center; - width: 1.5rem; - height: 1.5rem; - font-size: 1.25rem; - color: white; - background-color: rgba(255, 0, 0, 0.5); - border-radius: 999px; + position: absolute; + z-index: 2; + top: 0.5rem; + right: 0.5rem; + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; + font-size: 1.25rem; + color: white; + background-color: rgba(255, 0, 0, 0.5); + border-radius: var(--radius-pill); } .chatText.hideImg .inlineImage { - position: relative; - height: 2.5rem; - opacity: 0; - visibility: hidden; + position: relative; + height: 2.5rem; + opacity: 0; + visibility: hidden; } .chatText.hideImg a.inlineImgLink::after { - content: 'image hidden'; - position: absolute; - z-index: 1; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: flex; - justify-content: center; - align-items: center; - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.1em; - ; - color: rgba(255, 255, 255, 0.5); - background-color: black; + content: "image hidden"; + position: absolute; + z-index: 1; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + font-size: 0.66rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: rgba(255, 255, 255, 0.5); + background-color: black; } .chatText.hideImg .hideImage { - background-color: #444; - transform: rotate(45deg); + background-color: #444; + transform: rotate(45deg); } .badoop::before { - content: ""; - display: flex; - align-items: flex-start; - justify-content: center; - position: absolute; - z-index: 1; - top: calc(var(--pad3) * -1); - left: calc(var(--pad3) * -1); - bottom: calc(var(--pad3) * -1); - width: 0; - padding: 0.2rem; - font-size: 1.2rem; - font-weight: 700; - line-height: 3.2; - color: #777; - background-color: #444; - border-top-right-radius: 999px; - border-bottom-right-radius: 999px; + content: ""; + display: flex; + align-items: flex-start; + justify-content: center; + position: absolute; + z-index: 1; + top: calc(var(--pad3) * -1); + left: calc(var(--pad3) * -1); + bottom: calc(var(--pad3) * -1); + width: 0; + padding: 0.2rem; + font-size: 1.2rem; + font-weight: 700; + line-height: 3.2; + color: #777; + background-color: #444; + border-top-right-radius: var(--radius-pill); + border-bottom-right-radius: var(--radius-pill); } .chatTime { - position: relative; - z-index: 2; - font-weight: 400; - font-size: 0.66rem; - color: rgba(255, 255, 255, 0.47); - white-space: nowrap; + grid-column: 3 / -1; + grid-row: 1 / 2; + position: relative; + z-index: 2; + font-size: 0.66rem; + font-weight: 400; + line-height: 1.8; + color: rgba(255, 255, 255, 0.47); + text-align: right; + white-space: nowrap; } .chatCard { - display: block; - margin: var(--pad2) 0; + display: block; + margin: var(--pad2) 0; + max-width: 100%; } #chatbottom { - position: relative; - display: flex; - align-items: center; - padding: var(--pad1); - background-color: #282828; + position: relative; + display: grid; + grid-template-columns: auto 1fr; + justify-items: center; + gap: 0.5rem; + height: var(--height-footer); + padding-right: var(--pad3); +} + +#pickEmoji { + grid-column: 1/2; + margin-left: var(--pad3); } #pickerResults { - overflow-y: auto; - height: 33vh; - overflow-x: hidden; - text-align: center; + overflow-y: auto; + height: 33vh; + overflow-x: hidden; + text-align: center; } .pickerSecSelected { - color: #fff; - border-bottom: 2px solid #fff; + color: #fff; + border-bottom: 2px solid #fff; } #pickerResults span { - margin-right: var(--pad2); - display: inline-block; - margin-bottom: var(--pad2); + margin-right: var(--pad2); + display: inline-block; + margin-bottom: var(--pad2); } #pickerResults h3 { - margin-bottom: var(--pad3); - margin-top: var(--pad2); - text-align: center; + margin-bottom: var(--pad3); + margin-top: var(--pad2); + text-align: center; } #emojiPicker { - position: absolute; - z-index: 99; - left: 0; - bottom: 100%; - width: 100%; - padding: var(--pad3); - background-color: #2d2d2d; - box-shadow: 0 -0.25rem 0.5rem black; + position: absolute; + z-index: 99; + left: 1.5rem; + right: 1.5rem; + bottom: calc(100% + 0.5rem); + padding: var(--pad3); + background-color: var(--color-bg-t2); + box-shadow: 0 0.25rem 1rem black; + border-radius: var(--radius-md); } #pickerNav { - overflow: hidden; - white-space: nowrap; - text-align: center; + overflow: hidden; + white-space: nowrap; + text-align: center; } -input#pickerSearch { - width: 100%; - margin: var(--pad2) 0; +#pickerSearch { + width: 100%; + margin: var(--pad2) 0; } #pickerNav span { - margin-right: var(--pad2); - cursor: pointer; - filter: saturate(0); - opacity: .6; + margin-right: var(--pad2); + cursor: pointer; + filter: saturate(0); + opacity: 0.6; } #pickerNav .on { - color: var(--orange); - filter: none; - opacity: 1; + color: var(--color-orange); + filter: none; + opacity: 1; } -img.emoji { - height: 1.25em; - width: 1.25em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; +img.emoji, +.rohnmoji { + height: 1.25em; + width: 1.25em; + margin: 0 0.05em 0 0.1em; + vertical-align: -0.1em; } .rohnmoji { - height: 1.25em; - width: 1.25em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; - background-image: url(../img/rohn.png); - display: inline-block; - background-size: cover; + background-image: url(../img/rohn.png); + display: inline-block; + background-size: cover; } #morechats { - position: absolute; - bottom: 100%; - display: none; - justify-content: center; - width: 100%; - pointer-events: none; + position: absolute; + bottom: 100%; + display: none; + justify-content: center; + width: 100%; + pointer-events: none; } #morechats.show { - display: flex; + display: flex; } #morechats .butt { - height: auto; - padding: 0.2em; - font-size: 0.7rem; - background-color: #222; - border-bottom-color: #222; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - box-shadow: none; - pointer-events: auto; + height: auto; + padding: 0.2em; + font-size: 0.7rem; + background-color: var(--color-bg-t1); + border-bottom-color: var(--color-bg-t1); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + box-shadow: none; + pointer-events: auto; } #morechats .butt i { - margin: 0 0.25em; - font-size: 0.85rem; - font-weight: bold; + margin: 0 0.25em; + font-size: 0.85rem; + font-weight: bold; } #atPicker { - display: none; - position: absolute; - z-index: 2; - bottom: 100%; - left: 1.5rem; - padding: 0.25rem; - background: #222; - box-shadow: 0 -0.25rem 0.5rem black; + display: none; + position: absolute; + z-index: 2; + bottom: 100%; + left: 1.5rem; + padding: 0.25rem; + background: var(--color-bg-t1); + box-shadow: 0 -0.25rem 0.5rem black; } #atPicker.show { - display: block; + display: block; } #atPicker .butt { - margin: 0.25rem; - height: auto; - padding: 0.25em 0.5em; - text-transform: none; - letter-spacing: 0; + margin: 0.25rem; + height: auto; + padding: 0.25em 0.5em; + text-transform: none; + letter-spacing: 0; } #atPicker i { - margin: 0.25rem; - padding: 0.25em 0.5em; - font-size: 0.75rem; - color: #888; + margin: 0.25rem; + padding: 0.25em 0.5em; + font-size: 0.75rem; + color: var(--color-text-muted); } -/* History */ +/* ============================================ + History + ============================================ */ #thehistoryWrap { - position: absolute; - z-index: 15; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - background: #222; - overflow: auto; + grid-area: content; + display: none; + flex-direction: column; + overflow: hidden; + + #histFilterBar { + flex-shrink: 0; + padding: var(--pad3) var(--pad3) 0 var(--pad3); + } + + #histFilter { + width: 100%; + padding: var(--pad2) var(--pad3); + color: var(--color-text); + font-size: 0.875rem; + background: var(--color-bg-s2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + outline: none; + box-sizing: border-box; + + &:focus-visible { + border-color: var(--color-accent); + } + } +} + +#thehistory { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--pad1); + overflow-y: auto; + padding: 0 var(--pad2) 0 var(--pad3); + + .pvbar { + flex: 1; + display: flex; + align-items: center; + gap: var(--pad2); + padding: var(--pad2) var(--pad3) var(--pad2) var(--pad4); + } + + .listwords { + flex: 1; + } + + .tracklink-btn { + color: var(--color-primary); + } } -#thehistory .pvbar { - padding: var(--pad2) var(--pad2) var(--pad2) var(--pad2); - border-bottom: 1px solid black; +/* ── History Timeline ── */ + +.hist-day-group { + &.collapsed { + .hist-day-header::before { transform: rotate(-90deg); } + .hist-day-items { display: none; } + } } -.histmoreinfo { - font-size: 0.8rem; - color: #888; +.hist-day-header { + display: flex; + align-items: center; + gap: 0.35rem; + position: sticky; + top: 0; + z-index: 2; + padding: var(--pad1) var(--pad2); + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--color-text-muted); + background-color: var(--color-bg); + border: 1px solid var(--color-text-dim); + border-radius: 99px; + cursor: pointer; + user-select: none; + + &::before { + content: '▾'; + display: inline-block; + transition: transform 0.15s; + font-size: 0.75rem; + line-height: 1; + } +} + +.hist-day-items { + position: relative; + display: flex; + flex-direction: column; + padding-left: calc(var(--hist-time-w) + var(--hist-connector-w)); + + /* Vertical backbone line */ + &::before { + content: ''; + position: absolute; + left: calc(var(--hist-time-w) + var(--hist-connector-w) - 1px); + top: 0; + bottom: 0; + width: 1px; + background: var(--color-text-muted); + pointer-events: none; + } +} + +.hist-entry { + display: flex; + align-items: center; + position: relative; + + /* Timestamp */ + .hist-timestamp { + position: absolute; + left: calc(-1 * (var(--hist-time-w) + var(--hist-connector-w))); + width: var(--hist-time-w); + text-align: right; + padding-right: 0.4rem; + pointer-events: none; + font-size: 0.65rem; + color: var(--color-text-muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + + /* Horizontal connector line */ + &::before { + content: ''; + position: absolute; + left: calc(-1 * var(--hist-connector-w)); + width: var(--hist-connector-w); + height: 1px; + background: var(--color-text-dim); + } + + /* Timeline node avatar */ + & > .ft-avatar { + position: absolute; + left: calc(var(--hist-avatar-size) / -2); + width: var(--hist-avatar-size); + height: var(--hist-avatar-size); + border: 2px solid var(--color-border); + z-index: 1; + } } .histart { - display: flex; - align-items: center; - justify-content: center; - margin-right: var(--pad2); - height: 2.5rem; - width: 2.5rem; - background-size: cover; - background-position: center; + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 2.5rem; + width: 2.5rem; + background-size: cover; + background-position: center; } .histlink { - color: #bbb; + color: var(--color-text); } -.qtxt { - flex: 1; +.hist-dj-avatar[data-label] { + overflow: visible; } -/* Discover */ -#mainGrid.login #discover { - grid-area: login; - padding: var(--pad4); - background: #151515; - overflow: auto; - height: 100%; - text-align: center; +.hist-dj-avatar[data-label]::after { + content: attr(data-label); + position: absolute; + right: 120%; + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-family: var(--font-family); + white-space: nowrap; + color: var(--color-text-light); + background: var(--color-bg-t2); + border-radius: var(--radius-sm); + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; + z-index: 100; } -#mainGrid.mmqueue #discover{ - display: none !important; +.hist-dj-avatar[data-label]:hover::after { + opacity: 1; } -.miniLoginNavOnly{ - display: none; +/* ============================================ + Card Case + ============================================ */ + +#cardsWrap { + grid-area: content; + display: none; + overflow: auto; + padding: 1rem; } -#minidiscover, #minijoin{ - color: var(--orange); - cursor: pointer; +/* ============================================ + Discover + ============================================ */ + +#discover { + grid-area: content; + display: none; + flex-direction: column; + overflow: auto; + width: 100%; + height: 100%; } +#mainGrid.view-discover #discover { + display: flex; +} -#discover h2{ - font-size: 2.5rem; - font-weight: 700; - padding: var(--pad4); +#mainGrid.login.view-discover #discover { + grid-area: login; + display: flex; +} +#mainGrid.login.view-discover #login { + display: none; } -#discover p{ - padding: var(--pad2); + +.miniLoginNavOnly { + display: none; +} + +.miniLoginInvisible { + display: none !important; +} + +#minidiscover, #minijoin { + color: var(--orange); + cursor: pointer; +} + +#thediscovers { + display: flex; + flex-wrap: wrap; + gap: var(--pad2); + padding: var(--pad3); + width: 100%; + box-sizing: border-box; } #thediscovers .pvbar { - display: inline-block; - width: 300px; - height: 400px; - padding:20px; - margin-top:50px; + display: flex; + flex-direction: column; + flex: 1 1 160px; + min-width: 160px; + max-width: 240px; + padding: var(--pad2); + background-color: var(--color-bg-t1); + border-radius: var(--radius-md); + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.25); } .discart { - display: flex; - align-items: center; - justify-content: center; - margin-bottom: var(--pad2); - width: 300px; - height: 300px; - background-size: cover; - background-position: center; + width: 100%; + aspect-ratio: 1; + background-size: cover; + background-position: center; + border-radius: var(--radius-sm); + margin-bottom: var(--pad2); + display: flex; + align-items: center; + justify-content: center; +} + +#thediscovers .pvbarWrap { + display: contents; } #thediscovers .qtxt { @@ -1802,599 +2584,743 @@ img.emoji { flex: 1; } -#thediscovers .pvbarWrap { - display: flex; - flex-wrap: wrap; +#thediscovers .listwords { + font-size: 0.8rem; + font-weight: 500; + line-height: 1.3; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +#thediscovers .histdj { + font-size: 0.7rem; + color: var(--color-text-dim); + margin-top: var(--pad1); } -/* Login */ +/* ============================================ + Login + ============================================ */ #welcomeInfo { - padding-bottom: var(--pad4); + padding-bottom: var(--pad4); } #login { - grid-area: queues; - padding: var(--pad5); - background: #151515; - overflow: auto; - height: 100%; + grid-area: login; + padding: var(--pad4); + overflow: auto; + max-width: 40rem; + height: 100%; } #login h4 { - font-size: 1.25rem; - font-weight: 300; + font-size: 1.25rem; + font-weight: 300; } #login h4 a { - font-size: 1rem; + font-size: 1rem; } #login .butt { - width: auto; + width: auto; } .inputline { - display: flex; - flex-direction: column; - margin-top: 1rem; - max-width: 20rem; + display: flex; + flex-direction: column; + margin-top: 1rem; + max-width: 20rem; } -.inputline>* { - margin: var(--pad1) 0; +.inputline > * { + margin: var(--pad1) 0; } .formlinks { - margin-top: var(--pad3); + margin-top: var(--pad3); } #login .formlinks a { - margin-right: 15px; - cursor: pointer; + margin-right: 15px; + cursor: pointer; + color: var(--color-accent); +} + +#login a { + color: var(--color-accent); } #login .formlinks a:last-child { - margin-right: 0; + margin-right: 0; } #login .formlinks .selected { - display: none; + display: none; } -/* Modals */ +/* ============================================ + Modals + ============================================ */ #overlay { - position: fixed; - top: 0; - left: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.85); - overflow: hidden; + position: fixed; + top: 0; + left: 0; + z-index: 100; + display: none; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + overflow: hidden; } #overlay.show { - display: flex; + display: flex; } .modalThing { - display: none; - flex-direction: column; - max-width: 80vw; - max-height: 80vh; - background-color: #111; - overflow: hidden; + display: none; + flex-direction: column; + max-width: 100vw; + max-height: 100vh; + background-color: var(--color-bg-s2); + overflow: hidden; } .modalThing.show { - display: flex; + display: flex; } .modalHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--pad4); - color: #eee; - background-color: #222; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--pad4); + color: var(--color-text-light); + background-color: var(--color-bg-t1); +} + +.modalHeader h2 { + margin-right: auto; } .closeModal { - margin-left: var(--pad3); + margin-right: var(--pad1); } .modalContent { - flex: 1; - display: flex; - padding: var(--pad4); - overflow: auto; + flex: 1; + display: flex; + padding: var(--pad4); + overflow: auto; } -#settingsBox .modalContent, #accountSettingsBox .modalContent { - min-width: 320px; +#accountSettingsBox { + width: 420px; + max-width: 100vw; + min-height: 440px; + max-height: 100vh; } -#cardsBox .modalContent { - width: 80vw; - height: 80vh; +#accountSettingsBox .modalContent { + flex-direction: column; } -.settingline { - margin-bottom: var(--pad2); +#accountSettingsTabs { + border-radius: 0; + border-bottom: 1px solid var(--color-bg-t1); + background-color: var(--color-bg-t1); } -#cardsMain { - display: grid; - grid-gap: 10px; - grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); +#accountSettingsTabs .tab { + border-radius: 0; + flex: none; + padding: var(--pad2) var(--pad4); +} + +#accountSettingsClose { + margin-left: auto; + border-radius: 0; +} + +.tabPanel { + display: none; + flex-direction: column; + flex: 1; + width: 100%; } -.caseCard { - margin: var(--pad2); +.tabPanel.active { + display: flex; +} + +.settingline { + margin-bottom: var(--pad2); +} + +#cardsMain { + display: grid; + grid-gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } .caseCardSpot { - position: relative; + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; } .caseCardSpot canvas { - border: 1px solid #333; + display: block; + width: 100%; + height: auto; + border: 1px solid #333; +} + +span.cardShareChat, +span.cardGiftChat { + position: absolute; + font-size: 14px; + background-color: #000; + padding: 5px; + border-radius: var(--radius-md); + display: none; } span.cardShareChat { - position: absolute; - right: 85px; - bottom: 160px; - font-size: 14px; - background-color: #000; - padding: 5px; - border-radius: 5px; - display: none; + top: 33%; } span.cardGiftChat { - position: absolute; - right: 100px; - bottom: 200px; - font-size: 14px; - background-color: #000; - padding: 5px; - border-radius: 5px; - display: none; + bottom: 33%; } span.cardGiftChat:hover, span.cardShareChat:hover { - background-color: var(--orange); - color: #000; + background-color: var(--color-orange); + color: #000; } .caseCardSpot:hover .cardShareChat, .caseCardSpot:hover .cardGiftChat { - display: block; + display: block; } #importDubContent { - display: none; + display: none; } -input.tagMachine, #supercopSearch, #changeUsername { - width: 100%; - padding: 5px; - margin-bottom: 15px; +input.tagMachine, +#supercopSearch, +#changeUsername { + width: 100%; + padding: 5px; + margin-bottom: 15px; } #songlink { - border-radius: 0.25rem; + border-radius: var(--radius-sm); } #songlink svg { - width: 100%; - height: 100%; - padding: 4px; + width: 100%; + height: 100%; + padding: 4px; } #importSources { - padding: 0; - background: none; + padding: 0; + background: none; } -#importContent, #importDubContent { - padding: var(--pad2) var(--pad3); - background-color: #282828; +#importContent, +#importDubContent { + padding: var(--pad2) var(--pad3); + background-color: var(--color-bg-t2); } #dubimportButton { - margin-top: 10px; - display: none; + margin-top: 10px; + display: none; } #byId { - display: flex; - align-items: center; + display: flex; + align-items: center; } #byId .butt:disabled { - opacity: 0.4; + opacity: 0.4; } -input#plMachine, -input#plMachineById { - line-height: 30px; - width: 100%; - padding: 5px; +#plMachine, +#plMachineById { + line-height: 30px; + width: 100%; + padding: 5px; } -input#plMachineById { - padding-right: 40px; +#plMachineById { + padding-right: 40px; } .responseBox { - background-color: var(--orange); - margin: 10px; - padding: 5px; - color: #000; - border-radius: 0.25rem; + background-color: var(--color-orange); + margin: 10px; + padding: 5px; + color: #000; + border-radius: var(--radius-sm); } .responseBox:empty { - display: none; + display: none; } #mergeLists, #shuffleQueue { - margin-left: 1em; + margin-left: 1em; } -/* Unauthenticated state */ +/* ============================================ + Unauthenticated / Disconnected State + ============================================ */ #reconnecting { - display: none; - padding: var(--pad1) var(--pad2); - background-color: #bb4433; - animation: pulse 500ms infinite linear alternate; + display: none; + padding: var(--pad1) var(--pad2); + background-color: #bb4433; + animation: pulse 500ms infinite linear alternate; } body.disconnected #queuebox, body.disconnected #newchat { - opacity: 0.5; - pointer-events: none; - user-select: none; + opacity: 0.5; + pointer-events: none; + user-select: none; } body.disconnected #reconnecting { - display: block; + display: block; } body.disconnected #logOutButton { - display: none; + display: none; } -/* About page */ +/* ============================================ + About Page + ============================================ */ body.blog { - overflow: auto; -} - -div#blog { - flex: 1; - padding: var(--pad5); - background-color: #282828; + overflow: auto; } -/* Initially hidden stuff */ -#ftSuperCopButton, -#audilert, -#sc-widget, -#justwaitWrap, -.notice, -#emojiPicker, -#overlay { - display: none; +#blog { + flex: 1; + padding: var(--pad5); + background-color: var(--color-bg-t2); } -/*------------------------------------ Media Queries -*/ -@media only screen and (max-width: 799px) { - #allusersWrap .prson { - cursor: none; +/* ============================================ + Floating UI Tooltip + ============================================ */ +#ft-tooltip { + position: fixed; + top: 0; + left: 0; + z-index: 9999; + background-color: var(--color-bg-s2); + color: var(--color-text-light); + padding: 0.2rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.8rem; pointer-events: none; - } - -/* TEMPORARY TERRIBLE MOBILE DISCOVER */ - -.miniLoginInvisible{ - display: none !important; -} - -.miniLoginNavOnly{ - display: block !important; -} - -#thediscovers .pvbar { - display: inline-block; - width: 150px; - height: 300px; - padding: 10px; - margin-top: 25px; -} - -#discover h2 { - font-size: 2rem !important; -} - -#mainGrid.login #discover { - padding: var(--pad2) !important; - + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + visibility: hidden; + opacity: 0; + transition: opacity 120ms ease; } -.discart { - - width: 150px !important; - height: 150px !important; - -} - - #login { - grid-area: login !important; -} - -/* END TEMPORARY MOBILE DISCOVER */ - +#ft-tooltip.is-visible { + opacity: 1; } -@media only screen and (min-width: 800px) { - body.screen #albumArt { - display: none; - } - - body.screen #screenBox { - top: 0; - } - - #mainGrid { - gap: 0 5px; - } - - #importPromptBox .modalContent { - width: 480px; - } - - #mainGrid, - #mainGrid.mmchat { - grid-template-rows: auto auto auto 1fr; - grid-template-columns: 60vw 40vw; - grid-template-areas: - "stage theme" - "stage chat" - "mmopts chat" - "users chat" - ; - } - - #mainGrid.mmusrs { - grid-template-areas: - "stage theme" - "stage chat" - "mmopts chat" - "users chat" - ; - } - - #mainGrid.mmqueue { - grid-template-areas: - "stage theme" - "stage chat" - "mmopts chat" - "queues chat" - ; - } - - #mainGrid.mmchat #usersbox, - #mainGrid.mmqueue #actualChat, - #mainGrid.mmusrs #actualChat { - display: flex; - } - - #mainGrid.login { - grid-template-areas: - "stage queues" - "login queues" - ; - } - - #mainGrid.login #queuebox, - #mainGrid.login #thehistoryWrap -{ - display: none; - } - - #mainGrid.login .histeal, - #mmchat { +/* ============================================ + Initially Hidden Elements + ============================================ */ +#ftSuperCopButton, +#modTab, +#audilert, +#sc-widget, +.notice, +#emojiPicker { display: none; - } - - #idtitle { - display: block; - } - - #thehistoryWrap { - grid-area: queues; - display: block; - position: relative; - top: auto !important; - bottom: auto; - } - - #mainGrid.mmqueue #thehistoryWrap { - grid-area: mmopts / queues; - } } -@media only screen and (min-width: 1200px) { - - #mainGrid, - #mainGrid.mmqueue, - #mainGrid.mmchat, - #mainGrid.mmusrs { - grid-template-rows: auto auto 1fr; - grid-template-columns: minmax(16rem, 20rem) minmax(40vw, 100vw) minmax(24rem, 36rem); - grid-template-areas: - "users stage theme" - "users stage chat" - "users queues chat" - ; - } - - #mainGrid.mmusrs #queuebox, - #mainGrid.mmusrs #actualChat, - #mainGrid.mmchat #usersbox, - #mainGrid.mmchat #queuebox, - #mainGrid.mmqueue #usersbox, - #mainGrid.mmqueue #actualChat { - display: flex; - } - - #mainGrid.login { - grid-template-areas: - "stage stage queues" - "login login queues" - ; - } - - #mainGrid #minimodeoptions { - display: none; - } - - #usertabs { - padding-top: 0; - background-color: black; - } - - #djStage { - height: 30vh; - min-height: 15rem; - } - - .avtr { - display: block; - } - - #albumArt { - height: 6.5rem; - width: 6.5rem; - } - - #deck { - margin-left: 5vw !important; - margin-right: 5vw !important; - } +/* ============================================ + Media Queries + ============================================ */ + +/* ── 640px: 2-column layout, header gains view nav buttons ── */ +@media only screen and (min-width: 640px) { + /* Promote content panels to their semantic grid areas */ + #usersbox { grid-area: people; } + #actualChat { grid-area: chat; } + #queuebox, + #thehistoryWrap, + #cardsWrap, + #discover { grid-area: view; } + + /* Header view buttons are now visible */ + #playlists, + #history, + #cardcase, + #discover-nav { display: block; } + + /* Mini-nav view tabs are redundant at this size */ + #mm-playlists, + #mm-history, + #mm-cards, + #mm-discover { display: none; } + + /* Grid templates */ + #mainGrid, + #mainGrid.mmqueue, + #mainGrid.mmchat { + grid-template-rows: auto auto 1fr; + grid-template-columns: 3fr 2fr; + grid-template-areas: + "head nav" + "stage chat" + "view chat"; + } + + #mainGrid.mmusrs { + grid-template-areas: + "head nav" + "stage people" + "view people"; + } + + #mainGrid.login { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr; + grid-template-areas: + "head" + "stage" + "login"; + } + + /* At 640px+: active view panel shows via view-class regardless of layout mode */ + #mainGrid.view-playlists #queuebox { display: flex; } + #mainGrid.view-history #thehistoryWrap { display: flex; } + #mainGrid.view-cards #cardsWrap { display: block; } + #mainGrid.view-discover #discover { display: flex; } + + /* Re-show secondary panels suppressed by mobile rules */ + #mainGrid.mmqueue #actualChat, + #mainGrid.mmchat #actualChat { display: flex; } + + #mainGrid.mmusrs #usersbox { display: flex; } + + .newChat { margin-left: var(--pad2); } +} + +/* ── max 799px: touch-only users list ── */ +@media only screen and (max-width: 799px) { + #allUsersWrap .prson { + cursor: none; + pointer-events: none; + } - .djplaque { - justify-content: space-between; - } + .miniLoginInvisible { display: none !important; } + .miniLoginNavOnly { display: block !important; } - #thehistoryWrap { - grid-area: queues !important; - } + .header_icon[data-label]::after { + left: 50%; + top: calc(100% + 0.5rem); + transform: translateX(-50%); + } } -@media only screen and (min-width: 1680px) { - - #mainGrid, - #mainGrid.mmqueue, - #mainGrid.mmchat, - #mainGrid.mmusrs { - grid-template-columns: minmax(16rem, 20rem) minmax(50vw, 100vw) minmax(24rem, 36rem); - } - - #mainGrid.login { - grid-template-areas: - "stage stage queues" - "login login queues" - ; - } - - #mainGrid.login #queuebox { - display: none; - } - - #mainGrid.login #thehistoryWrap { - width: 100%; - } - - #history { - position: absolute; - left: 60%; - bottom: -1px; - width: auto; - padding: 0 var(--pad2); - border-left: var(--pad1) solid #151515; - border-bottom-color: #222; - border-radius: 0; - clip-path: - polygon(0% 0%, - 0% 0%, - calc(100% - var(--pad2)) 0%, - 100% var(--pad2), - 100% 100%, - 100% 100%, - 0% 100%, - 0% 0%); - pointer-events: none; - } - - #history::after { - content: "Recent Plays"; - margin-left: var(--pad1); - font-size: 0.85rem; - font-weight: 400; - text-transform: none; - letter-spacing: 0; - } - - #queuebox { - width: 60%; - } - - #thehistoryWrap { - display: block !important; - grid-area: queues; - justify-self: flex-end; - z-index: auto; - width: 40%; - border-left: var(--pad1) solid #151515; - } -} - -@media only screen and (min-width: 2100px) { - html { - font-size: calc(0.4em + 0.4vw); - } - - #mainGrid, - #mainGrid.mmqueue, - #mainGrid.mmchat, - #mainGrid.mmusrs { - grid-template-columns: minmax(24rem, 36rem) minmax(40vw, 100vw) minmax(24rem, 36rem); - } - - #usersbox .tab { - color: #ffffff; - background-color: #282828; - box-shadow: 0 -0.2rem 0.5rem -0.2rem black; - } - - #userslist { - display: flex; - } - - .usersWrap { - display: block !important; - flex: 1; - } - - #allusersWrap { - border-right: 5px solid black; - } +/* ── 800px: 3-column layout, header becomes vertical sidebar ── */ +@media only screen and (min-width: 800px) { + body.screen #albumArt { + display: none; + } + + body.screen #screenBox { + top: 0; + } + + #mainGrid, + #mainGrid.mmqueue, + #mainGrid.mmchat { + grid-template-rows: auto auto 1fr; + grid-template-columns: auto minmax(28rem, 1fr) minmax(18rem, 24rem); + grid-template-areas: + "head . nav" + "head stage chat" + "head view chat"; + } + + #mainGrid.mmusrs { + grid-template-areas: + "head . nav" + "head stage people" + "head view people"; + } + + #mainGrid.login { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr; + grid-template-areas: + "head" + "stage" + "login"; + } + + #mainGrid.login #topbar { + flex-direction: row; + align-items: center; + padding: var(--pad1) var(--pad3); + } + + #importPromptBox .modalContent { + width: 480px; + } + + #topbar { + flex-direction: column; + justify-content: center; + padding-top: var(--pad2); + padding-bottom: var(--pad4); + } + + #viewnav { + flex-direction: column; + margin-left: 0; + } + + .header_icon { + margin-bottom: 0.5rem; + } + + #mainGrid.login #queuebox, + #mainGrid.login #thehistoryWrap { + display: none; + } + + #loggedInUser, + #roomlogo { + margin-right: 0; + margin-left: 0; + } + + #loggedInUser { + margin-top: 0.5rem; + } + + #roomlogo { + margin-bottom: 0.5rem; + } + + /* #idtitle { + display: block; + } */ + + #actualChat { + min-width: 20rem; + max-width: 28rem; + } + + #deck { + margin-left: 2.5vw; + margin-right: 2.5vw; + } + + .chatText { + grid-column: 1 / 2; + } +} + +/* ── 1024px: 4-column layout, all panels always visible ── */ +@media only screen and (min-width: 1024px) { + #mainGrid, + #mainGrid.mmqueue, + #mainGrid.mmchat, + #mainGrid.mmusrs { + grid-template-rows: auto 1fr; + grid-template-columns: min-content minmax(min-content, auto) min-content minmax(min-content, auto); + grid-template-areas: + "head people stage chat" + "head people view chat"; + } + + /* At 1024px: chat/people panels always visible regardless of nav class. + The active view panel is still governed by view-class (inherited from 640px rules). */ + #mainGrid.mmusrs #actualChat, + #mainGrid.mmchat #usersbox, + #mainGrid.mmqueue #usersbox, + #mainGrid.mmqueue #actualChat { + display: flex; + } + + #mainGrid.login { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr; + grid-template-areas: + "head" + "stage" + "login"; + } + + #mainGrid.login #topbar { + flex-direction: row; + } + + #mainGrid.login #stage { + width: 100%; + max-width: 36rem; + justify-self: center; + } + + #mainGrid #minimodeoptions { + display: none; + } + + /* Stage wrapper + view panels: right-column sizing */ + #stage, + #queuebox, + #thehistoryWrap, + #cardsWrap { + width: 46vw; + max-width: 56rem; + justify-self: flex-end; + } + + #nowplaying, + #djStage { + margin-top: 20px; + } + + #djStage { + height: 30vh; + min-height: 15rem; + } + + .avtr { + display: block; + } + + #albumArt { + height: 6.5rem; + width: 6.5rem; + } + + .djplaque { + justify-content: space-between; + } + + #allUsersWrap { + align-self: flex-end; + transform: translateX(1vw); + } + + #usersWaitlist { + display: none; + flex-direction: column; + gap: var(--pad2) 0; + padding: var(--pad4) var(--pad4) var(--pad2); + border-bottom: 1px solid var(--color-border); + + &.has-entries { + display: flex; + } + } + + .waitlist-label { + justify-content: flex-end; + } + + .waitlist-item { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: var(--pad2); + + & > * { order: 0 !important; } + } + + .waitlist-pos { + justify-self: start; + } + + .waitlist-name { + justify-content: flex-end; + text-align: right; + } + + .waitlist-item .prsnRole { + justify-self: center; + } + + .waitlist-item .ft-avatar { + width: 1.5rem; + height: 1.5rem; + } + + #allUsers { + grid-template-columns: 1fr auto auto; + grid-auto-flow: dense; + padding-top: var(--pad5); + } + #allUsers .ft-avatar { + grid-column: -2 / -1; + } + #allUsers .prsnRole { + grid-column: -3 / -2; + } + #allUsers .djOrder { + grid-column: 1 / 2; + } + #allUsers .prsnNameRole { + grid-column: 1 / 2; + justify-content: flex-end; + } + #allUsers .prsnName { + order: 2; + } + + .newChat { + margin-left: var(--pad4); + } + + .nowplayn { + padding-left: calc(var(--pad5) - var(--pad4)); + } + + #pickEmoji { + margin-left: var(--pad4); + } + + #usersStats { + justify-content: flex-start; + padding: var(--pad3) var(--pad4); + } +} + +@media only screen and (min-width: 1370px) { + #mainGrid.login #queuebox { + display: none; + } + + #mainGrid.login #thehistoryWrap { + width: 100%; + } } diff --git a/index.html b/index.html index 395da70..6c86e2a 100644 --- a/index.html +++ b/index.html @@ -2,181 +2,184 @@ - + firetable - + - + - - - + + + + + -
- -
- -
- - - - -
-
Reconnecting...
- -
- -
- -
-
+
+
+ +
+ +
+ + +
+
+
+
Reconnecting...
+
+ +
+
+ +
+
+
+
    -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
  • -
-
-
-
-
-
-
-
-
Loading firetable...
-
-
-
-
-
-
-
-
-
+
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
    -
    -
    -
    -
    - - -
    -
    - - - link - -
    -
    - - -
    -
    -
    -
    -
    - +
    +
    + +
    +
    +
    Loading firetable...
    +
    +
    +
    +
    +
    +
    +
    +
    +
    -
    +
    +
    + + + link + + +
    +
    +
    +
    +
    + + + +
    +
    + +
    - - + + + + +
    -
    +
    -
    +
    +
    -
    @@ -185,43 +188,61 @@
    +
    - +
    -
    - -
    + +
    + arrow_downward new messages + arrow_downward +
    -
    - - -
    -
    -
    -
    -
    -
    -
    -
    -
    +
    +

    People

    +
    + room +
    +
    + radio
    -
    -
    - Loading waitlist... +
    +
    +
    +
    +
    + +
    + block + radio + lens +
    +
    + + +
    +
    +
    +
    +
    +
    +
    @@ -232,85 +253,95 @@
    - - - + + +
    - +
    arrow_right_alt + + + arrow_right_alt + +
    Merge in progress... Please stand by...
    -
    Congratulations! Merge completed.
    +
    + Congratulations! Merge completed. + +
    - +
    -
    +
    -
    - +
    - vertical_align_top - vertical_align_bottom - delete -
    -
    -
    - - - Edit the song tag + hit enter to save
    -
    - Rohn Standard Notation
    - Standard: Artist - Song Title
    - Remix: Artist - Song Title (Remixartist Remix)
    - Featuring: Artist - Song Title feat. Subartist
    - Featuring + Remix: Artist - Song Title feat. Subartist (Remixartist Remix)
    +
    - +
    or...
    - - + +
    - +
    - YoutubeSoundcloud + YoutubeSoundcloud
    -
    +
    - +
    - +
    @@ -318,103 +349,101 @@
    -
    -
    +
    +
    + +
    +
    -
    -
    - -
    -
    -
    -
    played by on at
    -
    - edit -
    +
    +
    + +
    + + open_in_new +
    -
    -

    We Are Indie Discotheque

    -

    You've found the home of Indie Discotheque, The Alternative Dance Collective.

    -

    We're spinning h0t alt dance / nu disco / synth trax 24/7. Here's some of the freshest produce we've gathered recently.

    -

    If you like what you hear, join us by creating an account or logging in!

    +
    +
    loading your cards...
    +
    + +
    -
    +
    +
    - +
    -
    played by on at
    + ·
    -
    +
    - -
    +

    Welcome To Firetable!

    -

    Want to jump on the DJ table and pick some h0t tunes to play? Just want to join the conversation?

    -


    Or learn more about Indie Discotheque.

    - +

    + Want to jump on the DJ table and pick some h0t tunes to play? Just want to join the + conversation? +

    +


    Or learn more about this station.

    -
    @@ -422,10 +451,11 @@

    Reset your password!

    Playlist Deletion Zone

    - +
    -
    - So you want to delete a playlist huh? This action is irreversible.
    If you're super sure you want to do this, pick a playlist to delete below. +
    + So you want to delete a playlist huh? This action is irreversible.
    If you're + super sure you want to do this, pick a playlist to delete below.

    @@ -434,92 +464,105 @@

    Playlist Deletion Zone

    -
    -
    -

    Setthings

    - -
    -
    - Set all of the things here on the table. -

    -
    - - -
    -
    - - -
    -
    - -
    - - -
    -
    - -
    -
    -
    - -
    - -
    - - -
    -
    - -
    -
    -
    -
    -

    Your Card Case

    - +
    -
    +
    loading your cards...
    -
    -

    Your Account

    - +
    + + + + +
    -
    -
    - Change your username to some other thing...

    - -
    -
    -
    +
    +
    +
    + Change your username to some other thing...

    + +
    +
    + Avatar:

    + +

    +
    +
    + Set all of the things here on the table. +

    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    -
    -
    -

    Supermod Control Panel

    - -
    -
    - Suspend a user's account, or manage active suspentions.

    - -
    -

    Active Suspentions

    -
    + +
    +
    +
    +
    + Suspend a user's account, or manage active suspentions.

    + +
    +

    Active Suspentions

    +
    +

    Playlist Import Machine

    - +
    -
    +
    - +
    @@ -527,7 +570,8 @@

    Playlist Import Machine

    - Select an "interactive webpage" .html file from Dubtrack. + Select an "interactive webpage" .html file from Dubtrack.
    @@ -538,20 +582,59 @@

    Playlist Import Machine

    + + +
    +
    +
    + + Edit the song tag + hit enter to save
    +
    + Rohn Standard Notation
    + Standard: Artist - Song Title
    + Remix: Artist - Song Title (Remixartist Remix)
    + Featuring: Artist - Song Title feat. Subartist
    + Featuring + Remix: Artist - Song Title feat. Subartist (Remixartist Remix) +
    +
    +
    -
    +
    + + + + @@ -561,20 +644,46 @@

    Playlist Import Machine

    - + - + + + + + + + + + + + + + + + + + +

    Your account has been suspended due to a perceived violation of our Terms of Service.

    -

    We reserve the right to modify, suspend, or terminate the Service for any reason, without notice, at any time.

    +

    We reserve the right to modify, suspend, or terminate the Service for any reason, without notice, at any time. +

    +
    + +
    +

    Your account has been suspended due to a perceived violation of our Terms of Service.

    +

    + We reserve the right to modify, suspend, or terminate the Service for any reason, without notice, at any + time. +

    - + \ No newline at end of file diff --git a/js/cards.js b/js/cards.js new file mode 100644 index 0000000..c2270c8 --- /dev/null +++ b/js/cards.js @@ -0,0 +1,259 @@ +/** + * cards.js — DJ card rendering on HTML5 Canvas. + * + * DJ cards are collectible items showing a song that was played, the DJ who + * played it, album art, and a unique card number. They can be shared in chat + * or gifted to the current DJ. + * + * Special edition cards (id8, id9) have custom artwork for anniversary events. + */ + +firetable.actions = firetable.actions || {}; + +/** + * Open the card case modal and render all cards the user owns. + */ +firetable.actions.cardCase = function () { + $("#cardsMain").html(""); + ftapi.lookup.cardCollection(function (data) { + for (var key in data) { + if (!data.hasOwnProperty(key)) continue; + var childData = data[key]; + firetable.debug && console.log('card:', childData); + $("#cardsMain").append( + '' + + '' + + 'Gift to DJ' + + 'Share In Chat' + + '' + ); + firetable.actions.displayCard(childData, key); + } + }); +}; + +/** + * Share a card in chat (sends a message with the card ID attached). + * @param {string} cardid - Card key + */ +firetable.actions.chatCard = function (cardid) { + ftapi.actions.sendChat("Check out my card...", cardid); +}; + +/** + * Gift a card to the current DJ (removes it from your collection). + * @param {string} cardid - Card key + */ +firetable.actions.giftCard = function (cardid) { + ftapi.actions.sendChat("!giftcard :gift:", cardid); + $("#caseCardSpot" + cardid).remove(); +}; + +/** + * Fetch card data from the server and render it on a canvas. + * Used when a card is shared in chat. + * @param {string} cardid - Card key + * @param {string} chatid - Chat element ID (for the canvas target) + */ +firetable.actions.showCard = function (cardid, chatid) { + ftapi.lookup.card(cardid, function (data) { + firetable.actions.displayCard(data, chatid); + }); +}; + +/** + * Render a DJ card onto a canvas element. + * + * Layout (225×300px): + * - Top bar: DJ name + coloured circle with card number + * - Centre: Avatar image overlaid on gradient + * - Accent stripe: Card metadata (number, temperature) + * - Bottom: Song title, artist, album art thumbnail, date + * + * @param {Object} data - Card data from the server + * @param {string} data.djname - DJ's display name + * @param {string} data.djid - DJ's user ID + * @param {string} data.title - Song title + * @param {string} data.artist - Artist name + * @param {string} data.image - Album art URL + * @param {Object} data.colors - {color, txt} accent colours + * @param {number} data.cardnum - Unique card serial number + * @param {string} data.num - Display number (single digit on circle badge) + * @param {number} data.temp - "Max operating temperature" gag value + * @param {number} data.date - Timestamp when the card was created + * @param {string} [data.set] - Robohash set override + * @param {string} [data.special] - Special edition identifier ("id8", "id9") + * @param {string} chatid - Suffix for the canvas element ID ("cardMaker" + chatid) + */ +firetable.actions.displayCard = function (data, chatid) { + firetable.debug && console.log("display card"); + + // ── Normalize colours ── + var defaultScheme = false; + if (data.colors) { + if (data.colors.color === "#fff" || data.colors.color === "#7f7f7f") { + data.colors.color = firetable.orange; + data.colors.txt = "#000"; + defaultScheme = true; + } + } + + // ── Default album art fallback ── + if (data.image === "img/idlogo.png" && ftconfigs.defaultAlbumArtUrl.length) { + data.image = ftconfigs.defaultAlbumArtUrl; + } + + var set = data.set || "set1"; + var canvas = document.getElementById('cardMaker' + chatid); + if (!canvas || !canvas.getContext) return; + + var ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // ── Background ── + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, 225, 300); + + // ── Centre image area with gradient overlay ── + ctx.fillStyle = defaultScheme ? "#fff" : data.colors.color; + ctx.fillRect(1, 30, 223, 175); + + var grd = ctx.createLinearGradient(0, 0, 0, 175); + grd.addColorStop(0, "rgba(0, 0, 0, 0.75)"); + grd.addColorStop(1, "rgba(0, 0, 0, 0.55)"); + ctx.fillStyle = grd; + ctx.fillRect(1, 30, 223, 175); + + // ── Accent stripe ── + ctx.fillStyle = data.colors.color; + ctx.fillRect(1, 205, 223, 10); + + // ── Bottom info area ── + ctx.fillStyle = "#151515"; + ctx.fillRect(1, 216, 223, 75); + + // ── DJ name (top left) ── + ctx.fillStyle = "#eee"; + ctx.font = "700 11px Helvetica, Arial, sans-serif"; + ctx.fillText(data.djname, 10, 20); + + // ── Print date + room name (bottom centre) ── + ctx.font = "400 8px Helvetica, Arial, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText( + "Printed " + firetable.utilities.format_date(data.date) + " | " + ftconfigs.roomNameShort, + 112.5, 299 + ); + + // ── Song title (wraps) ── + ctx.font = "700 10px Helvetica, Arial, sans-serif"; + ctx.textAlign = "left"; + var linez = firetable.utilities.wrapText(ctx, data.title, 66, 240, 160, 15); + + // ── Artist ── + ctx.font = "400 8px Helvetica, Arial, sans-serif"; + ctx.textAlign = "left"; + firetable.utilities.wrapText(ctx, data.artist, 66, 253 + (15 * linez), 160, 15); + + // ── Metadata stripe text ── + ctx.fillStyle = data.colors.txt; + ctx.font = "400 9px Helvetica, Arial, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Card No. " + data.cardnum + " | DJ Card | Max Operating Temp " + data.temp + "°", 112.5, 214); + + // ── Number badge circle (top right) ── + ctx.beginPath(); + ctx.arc(205, 15, 12, 0, 2 * Math.PI, false); + ctx.fillStyle = data.colors.color; + ctx.fill(); + + ctx.fillStyle = data.colors.txt; + ctx.font = "700 15px Helvetica, Arial, sans-serif"; + ctx.textAlign = "left"; + ctx.fillText(data.num, 200.5, 20); + + // ── Image Drawing ───────────────────────────────────────────────────── + /** + * Default image loader: draws avatar + album art thumbnail. + */ + var doImages = function () { + var avatarImg = new Image(); + avatarImg.onload = function () { + ctx.drawImage(this, 20, 30, 175, 175); + var albumImg = new Image(); + albumImg.onload = function () { + var height = data.image.match(/ytimg.com/) ? 28 : 50; + ctx.drawImage(this, 10, 230, 50, height); + ctx = null; // release context + }; + albumImg.src = data.image; + }; + avatarImg.src = firetable.utilities.avatarURL(data.djid, data.djname, "175x175"); + }; + + // ── Special Edition Cards ───────────────────────────────────────────── + if (data.special === "id8") { + // 8th anniversary card + ctx.fillStyle = data.colors.color; + ctx.fillRect(1, 30, 223, 10); + ctx.fillStyle = data.colors.txt; + ctx.font = "400 10px Helvetica, Arial, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Celebrating 8 Years of Indie Discotheque", 112.5, 38); + + var cake = new Image(); + cake.onload = function () { + ctx.drawImage(this, 10, 50, 35, 35); + var eight = new Image(); + eight.onload = function () { + ctx.drawImage(this, 180, 50, 35, 35); + doImages(); + }; + eight.src = 'img/8.png'; + }; + cake.src = 'img/cake.png'; + + } else if (data.special === "id9") { + // 9th anniversary card — rotated robot avatar + custom background + ctx.fillStyle = data.colors.color; + ctx.fillRect(1, 30, 223, 10); + ctx.fillStyle = data.colors.txt; + ctx.font = "400 10px Helvetica, Arial, sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("Celebrating 9 Years of Indie Discotheque", 112.5, 38); + + var arnold = new Image(); + arnold.onload = function () { + ctx.drawImage(this, 5, 50, 45, 45); + var avatar = new Image(); + avatar.onload = function () { + // Draw rotated avatar + ctx.save(); + ctx.translate(75 * 0.5, 75 * 0.5); + ctx.rotate(0.959931); // ~55 degrees + ctx.translate(-75 * 0.5, -75 * 0.5); + ctx.drawImage(this, 125, -81, 75, 75); + ctx.restore(); + + var bgImg = new Image(); + bgImg.onload = function () { + ctx.drawImage(this, 25, 40, 170, 170); + var albumImg = new Image(); + albumImg.onload = function () { + var height = data.image.match(/ytimg.com/) ? 28 : 50; + ctx.drawImage(this, 10, 230, 50, height); + ctx = null; + }; + albumImg.src = data.image; + }; + bgImg.src = 'img/id9.png'; + }; + avatar.src = firetable.utilities.avatarURL(data.djid, data.djname); + }; + arnold.src = 'img/arnold.png'; + + } else { + // Standard card + doImages(); + } +}; diff --git a/js/chat.js b/js/chat.js new file mode 100644 index 0000000..42118e7 --- /dev/null +++ b/js/chat.js @@ -0,0 +1,439 @@ +/** + * chat.js — Chat message rendering, slash commands, and @-mention keyboard UI. + * + * Handles: + * - Rendering incoming chat messages (newChat event) + * - Grouping consecutive messages from the same user + * - Inline image rendering (when setting is enabled) + * - Text → link conversion + * - Emoji shortname conversion + twemoji parsing + * - Mod delete button on messages + * - Slash commands (/mod, /block, /shrug, /tableflip, etc.) + * - @-mention autocomplete keyboard navigation + * - Chat removal (chatRemoved event) + */ + +firetable.actions = firetable.actions || {}; + +/** + * Display a local-only response in chat (not sent to server). + * Used for command feedback like block/unblock confirmations. + * @param {string} txt - Message text + */ +firetable.actions.localChatResponse = function (txt) { + if (txt.length) { + $("#chats").append('
    ' + txt + '
    '); + firetable.utilities.scrollToBottom(); + } +}; + +// ─── Text Processing Helpers ───────────────────────────────────────────────── + +firetable.ui = firetable.ui || {}; + +/** + * Convert URLs in text to clickable links. + * When showImages is enabled and themeBox is false, image URLs are excluded + * (they get handled separately by showImages()). + * @param {string} text - Raw text + * @param {boolean} [themeBox=false] - True when processing theme text (always linkify all) + * @returns {string} Text with URLs wrapped in anchor tags + */ +firetable.ui.textToLinks = function (text, themeBox) { + var re = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; + if (firetable.showImages && !themeBox) { + // Exclude image URLs — those are rendered inline by showImages() + re = /(https?:\/\/(?![/|.|\w|\s|-]*(?:jpe?g|png|gif))[^" ]+)/g; + } + return text.replace(re, '$1'); +}; + +/** + * Find image URLs in chat text and replace them with inline tags. + * Auto-scrolls chat if user was already at the bottom when the image loads. + * @param {string} chatTxt - Chat message text + * @returns {string} Text with image URLs replaced by inline images + */ +firetable.ui.showImages = function (chatTxt) { + if (!firetable.showImages) return chatTxt; + + var imageUrlRegex = /((http(s?):)([/|.|\w|\s|-])*\.(?:jpe?g|gif|png))/g; + if (chatTxt.search(imageUrlRegex) >= 0) { + chatTxt = chatTxt.replace(imageUrlRegex, function (imageUrl) { + // Pre-load image to auto-scroll after it renders + var chatImage = new Image(); + chatImage.onload = function () { + if (firetable.utilities.isChatPrettyMuchAtBottom()) { + firetable.utilities.scrollToBottom(); + } + }; + chatImage.src = imageUrl; + return '' + + '' + + '×'; + }); + } + return chatTxt; +}; + +/** + * Strip HTML from a string using DOMParser. + * @param {string} html - Raw HTML string + * @returns {string} Plain text content + */ +firetable.ui.strip = function (html) { + var doc = firetable.parser.parseFromString(html, 'text/html'); + return doc.body.textContent || ""; +}; + +/** + * Process raw chat text through the full formatting pipeline: + * strip HTML → inline images → linkify → emoji → backtick code + * @param {string} rawTxt - Unprocessed chat text + * @returns {string} Formatted HTML string safe for insertion + */ +firetable.ui.formatChatText = function (rawTxt) { + var txt = firetable.ui.strip(rawTxt); + txt = firetable.ui.showImages(txt); + txt = firetable.ui.textToLinks(txt); + txt = firetable.utilities.emojiShortnamestoUnicode(txt); + // Backtick → blocks + txt = txt.replace(/\`(.*?)\`/g, function (x) { + return "" + x.replace(/\`/g, "") + ""; + }); + return txt; +}; + +// ─── Chat Event Binding ────────────────────────────────────────────────────── + +/** + * Set up all chat-related ftapi event listeners and keyboard handlers. + * Called once from firetable.ui.init(). + */ +firetable.ui.setupChatEvents = function () { + var $chatTemplate = $('#chatKEY').remove(); + + // ── Incoming Chat Messages ── + ftapi.events.on("newChat", function (chatData) { + if (chatData.botCmd) return; + var namebo = chatData.id; + var utitle = ""; + var atBottom = firetable.utilities.isChatPrettyMuchAtBottom(); + + // Resolve current user's display name for @-mention detection + var you = ftapi.uid; + if (ftapi.users[ftapi.uid] && ftapi.users[ftapi.uid].username) { + you = ftapi.users[ftapi.uid].username; + } + + // Resolve sender's display name and role + if (ftapi.users[chatData.id]) { + if (ftapi.users[chatData.id].username) namebo = ftapi.users[chatData.id].username; + if (ftapi.users[chatData.id].mod) utitle = "shield"; + if (ftapi.users[chatData.id].supermod) utitle = "local_police"; + if (ftapi.users[chatData.id].hostbot) utitle = "smart_toy"; + } else if (chatData.name) { + namebo = chatData.name; + } + + // ── @-mention detection ── + var badoop = false; + if (chatData.txt.match("@" + you, 'i') || chatData.txt.match(/\@everyone/)) { + var timeSinceMessage = Date.now() - chatData.time; + if (timeSinceMessage < 10 * 1000) { + firetable.utilities.playSound("sound"); + if (firetable.desktopNotifyMentions) { + firetable.utilities.desktopNotify(chatData, namebo); + } + badoop = true; + } + } + + // ── Check if we can delete this message (mod powers) ── + var canDelete = function () { + try { + if (!ftapi.users[ftapi.uid].mod && !ftapi.users[ftapi.uid].supermod) return false; + if (ftapi.users[chatData.id]) { + if (ftapi.users[chatData.id].mod || ftapi.users[chatData.id].supermod) return false; + } + return !chatData.hidden; + } catch (e) { + return false; + } + }; + + // Format the message text + var txtOut = firetable.ui.formatChatText(chatData.txt); + if (chatData.hidden) txtOut = "[message removed]"; + + if (chatData.id === firetable.lastChatPerson && !badoop) { + // ── Group with previous message from same user ── + $("#chat" + firetable.lastChatId + " .chatContent").append( + '
    ' + ); + $("#chatTime" + firetable.lastChatId).text(firetable.utilities.format_time(chatData.time)); + $("#chattxt" + chatData.chatID).html(txtOut); + + if (canDelete()) { + $("#chattxt" + chatData.chatID).addClass("deleteMe"); + $("#chattxt" + chatData.chatID).append('
    x
    '); + $("#chattxt" + chatData.chatID).find(".modDelete").on('click', function () { + ftapi.actions.deleteChat(chatData.feedID); + }); + } + twemoji.parse(document.getElementById("chattxt" + chatData.chatID)); + + } else { + // ── New message block (different user or @-mention break) ── + var $chatthing = $chatTemplate.clone(); + $chatthing.attr('id', "chat" + chatData.chatID); + $chatthing.find('.ft-avatar').css( + 'background-image', + "url(" + firetable.utilities.avatarURL(chatData.id, namebo) + ")" + ); + $chatthing.find('.utitle').html(utitle); + $chatthing.find('.chatTime') + .attr('id', "chatTime" + chatData.chatID) + .html(firetable.utilities.format_time(chatData.time)); + if (badoop) $chatthing.addClass('badoop'); + + $chatthing.find(".chatText").html(txtOut).attr('id', "chattxt" + chatData.chatID); + $chatthing.find(".chatName").text(namebo); + + // Click-to-@ on avatar and name + firetable.utilities.chatAt($chatthing.find('.ft-avatar')); + firetable.utilities.chatAt($chatthing.find('.chatName')); + twemoji.parse($chatthing.find(".chatText")[0]); + $chatthing.appendTo("#chats"); + + if (canDelete()) { + $chatthing.find(".chatText").addClass("deleteMe"); + $chatthing.find(".chatText").append('
    x
    '); + $chatthing.find(".modDelete").on('click', function () { + ftapi.actions.deleteChat(chatData.feedID); + }); + } + + firetable.lastChatPerson = chatData.id; + firetable.lastChatId = chatData.chatID; + } + + // ── Inline card rendering ── + if (chatData.card) { + $("#chattxt" + chatData.chatID).append( + '' + ); + firetable.actions.showCard(chatData.card, chatData.chatID); + } + + // Auto-scroll if user was at bottom or is the sender + if (atBottom || ftapi.uid === chatData.id) { + firetable.utilities.scrollToBottom(); + } + }); + + // ── Chat Removal (mod delete) ── + ftapi.events.on("chatRemoved", function (data) { + $("#chattxt" + data.chatID).text("[message removed]"); + try { + if (ftapi.users[ftapi.uid].mod || ftapi.users[ftapi.uid].supermod) { + $("#chattxt" + data.chatID).removeClass("deleteMe"); + } + } catch (e) {} + }); + + // ── Hide inline image button ── + $(document).on('click', '.hideImage', function (e) { + e.stopPropagation(); + e.preventDefault(); + $(this).closest('.chatText').toggleClass('hideImg'); + }); + + // ── Chat Input: Send Message + Slash Commands ── + $("#newchat").bind("keypress", function (e) { + if (e.key === "Enter") { + var txt = $("#newchat").val(); + if (txt === "") return; + + // Hot/Rain emoji toggle for quick reactions + if (txt === ":fire:" || txt === "🔥") { + $("#cloud_with_rain").removeClass("on"); + $("#fire").addClass("on"); + } else if (txt === ":cloud_with_rain:" || txt === "🌧") { + $("#cloud_with_rain").addClass("on"); + $("#fire").removeClass("on"); + } + + // ── Slash Commands ── + var matches = txt.match(/^(?:[\/])(\w+)\s*(.*)/i); + if (matches) { + var command = matches[1].toLowerCase(); + var args = matches[2]; + + switch (command) { + case "mod": + var personToMod = firetable.actions.uidLookup(args); + if (personToMod) ftapi.actions.modUser(personToMod); + break; + case "unmod": + var personToUnmod = firetable.actions.uidLookup(args); + if (personToUnmod) ftapi.actions.unmodUser(personToUnmod); + break; + case "block": + if (args) { + ftapi.actions.blockUser(args, function (response) { + firetable.actions.localChatResponse(response); + }); + } + break; + case "unblock": + if (args) { + ftapi.actions.unblockUser(args, function (response) { + firetable.actions.localChatResponse(response); + }); + } + break; + case "hot": + ftapi.actions.sendChat(":fire:"); + $("#cloud_with_rain").removeClass("on"); + $("#fire").addClass("on"); + break; + case "storm": + ftapi.actions.sendChat(":cloud_with_rain:"); + $("#cloud_with_rain").addClass("on"); + $("#fire").removeClass("on"); + break; + case "shrug": + ftapi.actions.sendChat((args ? args + " " : "") + "¯\\_(ツ)_/¯"); + break; + case "tableflip": + ftapi.actions.sendChat((args ? args + " " : "") + "(╯°□°)╯︵ ┻━┻"); + break; + case "unflip": + ftapi.actions.sendChat((args ? args + " " : "") + "┬─┬ ノ( ゜-゜ノ)"); + break; + } + } else { + // Regular chat message + ftapi.actions.sendChat(txt); + } + + $("#newchat").val(""); + $("#emojiPicker").slideUp(); + $("#pickEmoji").removeClass("on"); + firetable.utilities.exitAtLand(); + + } else if (e.key === "@") { + // ── @-mention autocomplete trigger ── + if (firetable.atLand) { + firetable.utilities.exitAtLand(); // double @@ cancels + } else { + firetable.utilities.initAtLand(); + $('#atPicker').addClass('show'); + for (var i = 0; i < firetable.atUsersFiltered.length; i++) { + $('
    ').appendTo('#atPicker'); + } + } + + } else if (firetable.atLand) { + // ── @-mention: filter as user types ── + if (e.key === " " || e.key === "Spacebar") { + firetable.utilities.exitAtLand(); + } else if (!e.key.match(/[0-9a-zA-Z_]/)) { + firetable.atString += e.key; + $('#atPicker').html(''); + $('
    Usernames cannot contain "' + e.key + '"
    ').appendTo('#atPicker'); + } else { + firetable.atString += e.key; + firetable.utilities.updateAtLand(); + } + } + }); + + // ── @-mention: backspace/arrow navigation ── + $("#newchat").bind("keyup", function (e) { + if (!firetable.atLand) return; + if (e.key === "Backspace") { + if (!firetable.atString) { + firetable.utilities.exitAtLand(); + } else { + firetable.atString = firetable.atString.slice(0, -1); + firetable.utilities.updateAtLand(); + } + } else if (e.key === "ArrowUp") { + $('#atPicker .butt:last').focus(); + } else if (e.key === "ArrowDown") { + $('#atPicker .butt:first').focus(); + } + }); + + // ── @-mention: Tab to auto-complete ── + $("#newchat").bind("keydown", function (e) { + if (e.key === "Tab") { + if (firetable.atUsersFiltered.length === 1) { + $("#newchat").one("blur", function () { + $("#newchat").focus().val($("#newchat").val()); + }); + firetable.utilities.chooseAt(firetable.atUsersFiltered[0]); + } else { + firetable.utilities.exitAtLand(); + } + } + }); + + // ── @-mention: click on dropdown item ── + $(document).on('click', '#atPicker .butt', function (e) { + e.preventDefault(); + firetable.utilities.chooseAt($(this).text().replace("@", "")); + setTimeout(function () { + var tempText = $("#newchat").val(); + $('#newchat').focus().val(''); + $('#newchat').val(tempText); + }, 250); + }); + + // ── @-mention: arrow keys within dropdown ── + $(document).on('keyup', '#atPicker .butt:focus', function (e) { + if (e.key === "ArrowUp") { + var $prev = $('#atPicker .butt:focus').parent().prev(); + if ($prev.length) { + $prev.find('.butt').focus(); + } else { + $('#atPicker .butt:last').focus(); + } + } else if (e.key === "ArrowDown") { + var $next = $('#atPicker .butt:focus').parent().next(); + if ($next.length) { + $next.find('.butt').focus(); + } else { + $('#atPicker .butt:first').focus(); + } + } + }); + + // ── "More chats" scroll-to-bottom button ── + $("#morechats .butt").bind("click", function () { + firetable.utilities.scrollToBottom(); + }); + + // ── Fire / Rain reaction buttons ── + $("#fire").bind("click", function () { + if (firetable.song) { + var $fires = $(".npmsg" + firetable.song.cid).last().find(".npmsg-fires"); + if ($fires.text() === "") { + $fires.text("🔥").css("font-size", "10px"); + } else { + var currentSize = parseInt($fires.css("font-size")) || 10; + $fires.css("font-size", (currentSize + 3) + "px"); + } + if (firetable.utilities.isChatPrettyMuchAtBottom()) firetable.utilities.scrollToBottom(); + } + $("#fire").addClass("on"); + }); + $("#cloud_with_rain").bind("click", function () { + ftapi.actions.sendChat(":cloud_with_rain:"); + $("#cloud_with_rain").addClass("on"); + $("#fire").removeClass("on"); + }); +}; diff --git a/js/constants.js b/js/constants.js new file mode 100644 index 0000000..5f06ff2 --- /dev/null +++ b/js/constants.js @@ -0,0 +1,73 @@ +/** + * constants.js — Named constants used across the firetable application. + * + * Replaces magic numbers and hardcoded strings scattered throughout the + * codebase with a single, documented source of truth. + */ + +// ─── Media Types ───────────────────────────────────────────────────────────── +/** YouTube video */ +var MEDIA_YOUTUBE = 1; +/** SoundCloud track */ +var MEDIA_SOUNDCLOUD = 2; + +// ─── Defaults ──────────────────────────────────────────────────────────────── +/** Default player volume (0–100) */ +var DEFAULT_VOLUME = 80; +/** How long before a user is marked idle (ms) — 5 minutes */ +var IDLE_TIMEOUT = 5 * 60000; +/** How long a track preview plays before auto-stopping (ms) — 30 seconds */ +var PREVIEW_DURATION = 30 * 1000; +/** 16:9 aspect ratio used for the YouTube player sizing */ +var ASPECT_RATIO = 16 / 9; +/** Default brand colour (firetable orange) */ +var COLOR_ORANGE = "#F4810B"; + +// ─── localStorage Keys ────────────────────────────────────────────────────── +/** + * All localStorage key strings in one place. + * Usage: localStorage[STORAGE.volume] instead of localStorage["firetableVol"] + */ +var STORAGE = { + volume: "firetableVol", + mute: "firetableMute", + disableMedia: "firetableDisableMedia", + showImages: "firetableShowImages", + showAvatars: "firetableShowAvatars", + badoop: "firetableBadoop", + desktopNotify: "firetableDTNM", + screenControl: "firetableScreenControl", + avatarStyle: "firetableAvatarStyle", + lastfmSession: "ftLastfmSession" +}; + +// ─── External Service URLs ────────────────────────────────────────────────── +/** Avatar / Robohash image base URL */ +var AVATAR_BASE_URL = "https://indiediscotheque.com/robots/"; +/** SoundCloud link resolver proxy */ +var SC_RESOLVE_URL = "https://thompsn.com/resolvesc/"; +/** SoundCloud general proxy */ +var SC_PROXY_URL = "https://thompsn.com/soundcloud/"; +/** SoundCloud API track base URL */ +var SC_API_TRACK_URL = "http://api.soundcloud.com/tracks/"; +/** Last.fm API base URL */ +var LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/"; + +// ─── Emoji Data Sources ───────────────────────────────────────────────────── +var EMOJI_URLS = [ + "https://unpkg.com/unicode-emoji-json@0.3.0/data-by-group.json", + "https://unpkg.com/emojilib@2.4.0/emojis.json", + "https://unpkg.com/emojilib@3.0.4/dist/emoji-en-US.json" +]; + +// ─── Search Result Limits ─────────────────────────────────────────────────── +/** Max results returned from YouTube / SoundCloud searches */ +var SEARCH_MAX_RESULTS = 15; +/** Max results per page for YouTube playlist import pagination */ +var IMPORT_PAGE_SIZE = 50; + +// ─── Preview Bar Update Interval ──────────────────────────────────────────── +/** How often the preview progress bar updates (ms) */ +var PREVIEW_BAR_INTERVAL = 200; +/** How often the song progress bar updates (ms) */ +var PROGRESS_BAR_INTERVAL = 500; diff --git a/js/emoji.js b/js/emoji.js new file mode 100644 index 0000000..a7a3cd1 --- /dev/null +++ b/js/emoji.js @@ -0,0 +1,172 @@ +/** + * emoji.js — Emoji picker, search, and shortname-to-unicode conversion. + * + * Loads emoji data from unpkg CDN (unicode-emoji-json + emojilib), + * builds the picker DOM, and converts :shortname: strings to unicode + * characters in chat messages. + */ + +firetable.emojis = { + + /** + * Show all emoji results and section headers (resets any active search filter). + */ + h: function () { + $(".pickerResult").show(); + $("#pickerResults h3").show(); + }, + + /** + * Test whether a picker emoji element matches a search query. + * Checks both the visible text (the emoji itself) and the data-alternative-name attribute. + * @param {jQuery} $el - The emoji span element + * @param {string} query - Lowercase search term + * @returns {boolean} + */ + n: function ($el, query) { + var altName = $el.attr("data-alternative-name"); + return ($el.text().toLowerCase().indexOf(query) >= 0) || + (altName != null && altName.toLowerCase().indexOf(query) >= 0); + }, + + /** + * Toggle an emoji category section in the picker. + * Clicking the same section twice resets to showing all sections. + * @param {string} sec - The section button's element ID (e.g. "bpickersmileys_emotion") + */ + sec: function (sec) { + var selectedSec = $("#pickerNav > .on"); + var contentId = sec.substr(1); // strip the "b" prefix to get the content div ID + + if (selectedSec.length) { + if (selectedSec[0].id === sec) { + // Toggle off — back to full list + $("#" + selectedSec[0].id).removeClass("on"); + $("#pickerContents div").show(); + } else { + // Switch to new section + $("#" + selectedSec[0].id).removeClass("on"); + $("#" + selectedSec[0].id.substr(1)).hide(); + $("#" + sec).addClass("on"); + $("#" + contentId).show(); + } + } else { + // First selection + $("#" + sec).addClass("on"); + $("#pickerContents div").hide(); + $("#" + contentId).show(); + } + }, + + /** + * Filter the emoji picker results by search text. + * Hides section headers during search, shows all when cleared. + * @param {string} val - Search text + */ + niceSearch: function (val) { + if (val.length === 0) { + firetable.emojis.h(); + return; + } + // Hide headers during filtered search + if ($("#pickerResults h3").is(":visible")) { + $("#pickerResults h3").hide(); + } + val = val.toLowerCase(); + $(".pickerResult").each(function (i, el) { + if (firetable.emojis.n($(el), val)) { + $(el).show(); + } else { + $(el).hide(); + } + }); + } +}; + +// ─── Emoji Map Loading & Conversion (extends firetable.utilities) ──────────── + +/** + * Fetch emoji data from CDN and build: + * 1. firetable.emojiMap — shortname → unicode lookup + * 2. Picker DOM — category nav + emoji grid in #pickerNav / #pickerContents + */ +firetable.utilities.getEmojiMap = function () { + firetable.emojiMap = {}; + (async function () { + try { + var requests = EMOJI_URLS.map(function (url) { return fetch(url); }); + var responses = await Promise.all(requests); + var promises = responses.map(function (response) { return response.json(); }); + var data = await Promise.all(promises); + + // Build a reverse map from emojilib v2: emoji_char → old_shortname + var oldmojis = {}; + for (var oldSlug in data[1]) { + if (data[1].hasOwnProperty(oldSlug)) { + oldmojis[data[1][oldSlug].char] = oldSlug; + } + } + + // Walk the grouped emoji data and build picker DOM + emojiMap + for (var category in data[0]) { + if (!data[0].hasOwnProperty(category)) continue; + var emojisArr = data[0][category]; + var catid = category.replace(/[\s&]+/g, '_').toLowerCase(); + + // Category nav button (uses first emoji as icon) + $('#pickerNav').append( + '' + emojisArr[0].emoji + '' + ); + // Category content section + $('#pickerContents').append( + '

    ' + category + '

    ' + ); + + for (var i = 0; i < emojisArr.length; i++) { + firetable.emojiMap[emojisArr[i].slug] = emojisArr[i].emoji; + + // Gather alternative search keywords from emojilib v3 + v2 + var words = ""; + if (data[2][emojisArr[i].emoji] !== undefined) { + words += data[2][emojisArr[i].emoji].join(','); + } + if (oldmojis[emojisArr[i].emoji] !== undefined) { + words += ',' + oldmojis[emojisArr[i].emoji]; + } + $("#picker" + catid).append( + '' + emojisArr[i].emoji + '' + ); + } + + // Also add old emojilib v2 shortnames to the map + for (var emoji in oldmojis) { + if (oldmojis.hasOwnProperty(emoji)) { + firetable.emojiMap[oldmojis[emoji]] = emoji; + } + } + } + twemoji.parse(document.getElementById("pickerNav")); + } catch (err) { + console.error("Failed to load emoji data:", err); + } + })(); +}; + +/** + * Replace :shortname: emoji codes in a string with unicode characters. + * Special-cases the custom :rohn: emoji. + * @param {string} str - Text containing :shortname: codes + * @returns {string} Text with shortnames replaced by unicode or custom HTML + */ +firetable.utilities.emojiShortnamestoUnicode = function (str) { + return str.replace(/\:(.*?)\:/g, function (match) { + var shortname = match.replace(/\:/g, ""); + if (firetable.emojiMap[shortname]) { + return '' + firetable.emojiMap[shortname] + ''; + } else if (shortname === "rohn") { + return ''; + } + return match; // unknown shortname — leave as-is + }); +}; diff --git a/js/firetable.js b/js/firetable.js index fca8433..91e65ce 100644 --- a/js/firetable.js +++ b/js/firetable.js @@ -233,8 +233,11 @@ ftapi.init = function(firebaseConfig) { if (data) { returnData.user = data; if (data.username) ftapi.uname = data.username; - console.log("LOOK HERE",data) - if (data.mod || data.supermod){ + if (data.avatarStyle) { + firetable.avatarStyle = data.avatarStyle; + localStorage[STORAGE.avatarStyle] = data.avatarStyle; + } + if (data.mod || data.supermod) { ftapi.isMod = true; ftapi.events.emit("modCheck", true); } @@ -397,6 +400,23 @@ ftapi.actions = { }); }); }, + sendBotCommand: function(txt) { + var chatFeed = firebase.app("firetable").database().ref("chatFeed"); + var chatData = firebase.app("firetable").database().ref("chatData"); + var data = { + time: firebase.database.ServerValue.TIMESTAMP, + id: ftapi.uid, + txt: txt, + name: ftapi.uname, + botCmd: true + }; + var chatItem = chatData.push(data, function() { + var feedObj = { chatID: chatItem.key }; + var feedItem = chatFeed.push(feedObj, function() { + chatItem.child("feedID").set(feedItem.key); + }); + }); + }, switchList: function(listID) { var uref = firebase.app("firetable").database().ref("users/" + ftapi.uid + "/selectedList"); uref.set(listID); diff --git a/js/glitch.js b/js/glitch.js new file mode 100644 index 0000000..366ca95 --- /dev/null +++ b/js/glitch.js @@ -0,0 +1,313 @@ +/** + * glitch.js — p5.js album art glitch effect for SoundCloud tracks. + * + * Uses p5.js in global mode to create a canvas inside #scScreen. + * When a SoundCloud track loads, setup(biggerImg) is called from room.js + * to load the high-res album art and apply real-time pixel effects. + * + * Effects applied: + * - Flow Line: A horizontal bright stripe that scrolls downward + * - Shift Line: Random horizontal slices shifted left/right + * - Shift RGB: Random per-channel offset (chromatic aberration) + * - Scat Image: Random rectangular fragments repositioned + * + * Globals exposed (required by p5.js global mode): + * setup(useThis) — called automatically by p5 and from room.js newSong + * draw() — called every frame by p5 + * Glitch — class instantiated in setup() + * + * Depends on: p5.js (loaded from CDN), firetable.scImg (set by room.js) + */ + +/* jshint esversion: 6 */ + +// ─── State ─────────────────────────────────────────────────────────────────── + +/** @type {boolean} Whether the image has been loaded and Glitch is ready */ +let isLoaded = false; + +/** @type {Glitch|null} Current glitch instance */ +let glitch = null; + +/** @type {string} URL of the current SoundCloud album art */ +let imgSrc = ''; + +// ─── p5.js Entry Points ───────────────────────────────────────────────────── + +/** + * p5.js setup — creates the canvas and loads the album art. + * Also called directly from the newSong handler when a SC track starts. + * @param {string} [useThis] - Image URL to load (defaults to firetable.scImg) + */ +function setup(useThis) { + // Guard: p5.js may not have bound its globals yet (e.g. if called from + // a Firebase event before p5 auto-initializes). Bail out — p5 will call + // setup() itself once ready. + if (typeof createCanvas === 'undefined') return; + + if (!useThis) useThis = firetable.scImg; + background(0); + + var cnv = createCanvas($('#djStage').outerWidth(), $('#djStage').outerHeight()); + cnv.parent('scScreen'); + + loadImage(useThis, function (img) { + glitch = new Glitch(img); + isLoaded = true; + var $can = $('#scScreen canvas'); + var canrat = $can.width() / $can.height(); + $can.data('ratio', canrat); + }); +} + +/** + * p5.js draw loop — runs every frame. + * Clears the canvas and renders the glitch effect if the image is loaded. + */ +function draw() { + clear(); + background(0); + if (isLoaded) { + glitch.show(); + } +} + +// ─── Glitch Class ──────────────────────────────────────────────────────────── + +/** + * Pixel-level glitch effect engine. + * Operates on the raw pixel buffer of a p5.Image to create visual distortions. + */ +class Glitch { + /** + * @param {p5.Image} img - The source image to apply effects to + */ + constructor(img) { + /** Number of channels per pixel (RGBA) */ + this.channelLen = 4; + + /** The p5 image being manipulated */ + this.imgOrigin = img; + this.imgOrigin.loadPixels(); + + /** Pristine copy of the original pixel data for restoration each frame */ + this.copyData = new Uint8ClampedArray(this.imgOrigin.pixels); + + /** Flow line effect objects — each one is a scrolling bright stripe */ + this.flowLineImgs = []; + for (let i = 0; i < 1; i++) { + this.flowLineImgs.push({ + pixels: null, + t1: floor(random(0, 1000)), + speed: floor(random(4, 24)), + randX: floor(random(24, 80)) + }); + } + + /** Shift line effect buffers — horizontal slice displacement */ + this.shiftLineImgs = []; + for (let i = 0; i < 6; i++) { + this.shiftLineImgs.push(null); + } + + /** Shift RGB effect buffers — per-channel offset (chromatic aberration) */ + this.shiftRGBs = []; + for (let i = 0; i < 1; i++) { + this.shiftRGBs.push(null); + } + + /** Scattered image fragments — random rectangles repositioned */ + this.scatImgs = []; + for (let i = 0; i < 3; i++) { + this.scatImgs.push({ img: null, x: 0, y: 0 }); + } + + /** When false, effects are temporarily paused (shows clean image) */ + this.throughFlag = true; + } + + /** + * Overwrite pixel data of a destination image with source pixels. + * @param {p5.Image} destImg - Image whose pixels are replaced + * @param {Uint8ClampedArray} srcPixels - Source pixel buffer + */ + replaceData(destImg, srcPixels) { + for (let y = 0; y < destImg.height; y++) { + for (let x = 0; x < destImg.width; x++) { + let index = (y * destImg.width + x) * this.channelLen; + destImg.pixels[index] = srcPixels[index]; // R + destImg.pixels[index + 1] = srcPixels[index + 1]; // G + destImg.pixels[index + 2] = srcPixels[index + 2]; // B + destImg.pixels[index + 3] = srcPixels[index + 3]; // A + } + } + destImg.updatePixels(); + } + + /** + * Flow Line effect — adds brightness to a single horizontal scan line + * that scrolls vertically through the image. + * @param {p5.Image} srcImg - Source image + * @param {Object} obj - Effect state (t1, speed, randX) + * @returns {Uint8ClampedArray} Modified pixel buffer + */ + flowLine(srcImg, obj) { + let destPixels = new Uint8ClampedArray(srcImg.pixels); + obj.t1 %= srcImg.height; + obj.t1 += obj.speed; + let tempY = floor(obj.t1); + + for (let y = 0; y < srcImg.height; y++) { + if (tempY === y) { + for (let x = 0; x < srcImg.width; x++) { + let index = (y * srcImg.width + x) * this.channelLen; + destPixels[index] = srcImg.pixels[index] + obj.randX; // R + destPixels[index + 1] = srcImg.pixels[index + 1] + obj.randX; // G + destPixels[index + 2] = srcImg.pixels[index + 2] + obj.randX; // B + // A stays the same + } + } + } + return destPixels; + } + + /** + * Shift Line effect — displaces a random horizontal band left or right. + * @param {p5.Image} srcImg - Source image + * @returns {Uint8ClampedArray} Modified pixel buffer + */ + shiftLine(srcImg) { + let destPixels = new Uint8ClampedArray(srcImg.pixels); + let rangeH = srcImg.height; + let rangeMin = floor(random(0, rangeH)); + let rangeMax = rangeMin + floor(random(1, rangeH - rangeMin)); + let offsetX = this.channelLen * floor(random(-40, 40)); + + for (let y = 0; y < srcImg.height; y++) { + if (y > rangeMin && y < rangeMax) { + for (let x = 0; x < srcImg.width; x++) { + let index = (y * srcImg.width + x) * this.channelLen; + destPixels[index] = srcImg.pixels[index + offsetX]; // R + destPixels[index + 1] = srcImg.pixels[index + 1 + offsetX]; // G + destPixels[index + 2] = srcImg.pixels[index + 2 + offsetX]; // B + // A stays the same + } + } + } + return destPixels; + } + + /** + * Shift RGB effect — offsets each color channel independently to create + * a chromatic aberration / color-split look. + * @param {p5.Image} srcImg - Source image + * @returns {Uint8ClampedArray} Modified pixel buffer + */ + shiftRGB(srcImg) { + let range = 16; + let destPixels = new Uint8ClampedArray(srcImg.pixels); + + let randR = (floor(random(-range, range)) * srcImg.width + floor(random(-range, range))) * this.channelLen; + let randG = (floor(random(-range, range)) * srcImg.width + floor(random(-range, range))) * this.channelLen; + let randB = (floor(random(-range, range)) * srcImg.width + floor(random(-range, range))) * this.channelLen; + + for (let y = 0; y < srcImg.height; y++) { + for (let x = 0; x < srcImg.width; x++) { + let index = (y * srcImg.width + x) * this.channelLen; + destPixels[index] = srcImg.pixels[(index + randR) % srcImg.pixels.length]; // R + destPixels[index + 1] = srcImg.pixels[(index + 1 + randG) % srcImg.pixels.length]; // G + destPixels[index + 2] = srcImg.pixels[(index + 2 + randB) % srcImg.pixels.length]; // B + // A stays the same + } + } + return destPixels; + } + + /** + * Extract a random rectangular region from the source image. + * @param {p5.Image} srcImg - Source image + * @returns {p5.Image} Cropped sub-image + */ + getRandomRectImg(srcImg) { + let startX = floor(random(0, srcImg.width - 30)); + let startY = floor(random(0, srcImg.height - 50)); + let rectW = floor(random(30, srcImg.width - startX)); + let rectH = floor(random(1, 50)); + let destImg = srcImg.get(startX, startY, rectW, rectH); + destImg.loadPixels(); + return destImg; + } + + /** + * Main render — called every frame from draw(). + * Restores original pixels, randomly applies effects, then draws to canvas. + */ + show() { + // Restore pristine pixel data + this.replaceData(this.imgOrigin, this.copyData); + + // Randomly pause effects for short intervals (creates a "clean" flash) + let n = floor(random(100)); + if (n > 75 && this.throughFlag) { + this.throughFlag = false; + setTimeout(() => { + this.throughFlag = true; + }, floor(random(200, 1500))); + } + + if (!this.throughFlag) { + push(); + translate((width - this.imgOrigin.width) / 2, (height - this.imgOrigin.height) / 2); + image(this.imgOrigin, 0, 0); + pop(); + return; + } + + // Apply flow line + this.flowLineImgs.forEach((v, i, arr) => { + arr[i].pixels = this.flowLine(this.imgOrigin, v); + if (arr[i].pixels) { + this.replaceData(this.imgOrigin, arr[i].pixels); + } + }); + + // Apply shift line + this.shiftLineImgs.forEach((v, i, arr) => { + if (floor(random(100)) > 50) { + arr[i] = this.shiftLine(this.imgOrigin); + this.replaceData(this.imgOrigin, arr[i]); + } else if (arr[i]) { + this.replaceData(this.imgOrigin, arr[i]); + } + }); + + // Apply shift RGB + this.shiftRGBs.forEach((v, i, arr) => { + if (floor(random(100)) > 65) { + arr[i] = this.shiftRGB(this.imgOrigin); + this.replaceData(this.imgOrigin, arr[i]); + } + }); + + // Draw the processed image centered on the canvas + push(); + translate((width - this.imgOrigin.width) / 2, (height - this.imgOrigin.height) / 2); + image(this.imgOrigin, 0, 0); + pop(); + + // Scatter random rectangular fragments + this.scatImgs.forEach((obj) => { + push(); + translate((width - this.imgOrigin.width) / 2, (height - this.imgOrigin.height) / 2); + if (floor(random(100)) > 80) { + obj.x = floor(random(-this.imgOrigin.width * 0.3, this.imgOrigin.width * 0.7)); + obj.y = floor(random(-this.imgOrigin.height * 0.1, this.imgOrigin.height)); + obj.img = this.getRandomRectImg(this.imgOrigin); + } + if (obj.img) { + image(obj.img, obj.x, obj.y); + } + pop(); + }); + } +} diff --git a/js/helpers.js b/js/helpers.js new file mode 100644 index 0000000..160bc37 --- /dev/null +++ b/js/helpers.js @@ -0,0 +1,417 @@ +/** + * helpers.js — General-purpose utility functions for firetable. + * + * Contains formatting helpers, HTML escaping, debounce, notification sound, + * desktop notifications, chat scroll management, avatar URL builder, + * and the critical `resumeCurrentSong()` helper that replaces 8+ duplicated + * "calculate elapsed → load YT or SC" blocks from the original code. + */ + +firetable.utilities = { + + // ─── Avatar URL ────────────────────────────────────────────────────────── + + /** + * Build an avatar URL for a user. + * Supports Robohash (via proxy) and DiceBear (SVG). + * Style lookup order: explicit `style` param → ftapi.users[uid].avatarStyle + * → firetable.avatarStyle (own user cache) → "robohash:set1" fallback. + * @param {string} uid - User ID + * @param {string} username - Display name + * @param {string|null} [size="110x110"] - Dimensions (Robohash only) + * @param {string} [style] - Style key e.g. "robohash:set1" or "dicebear:pixel-art" + * @returns {string} Full avatar URL + */ + avatarURL: function (uid, username, size, style) { + size = size || "110x110"; + if (!style) { + style = (ftapi.users && ftapi.users[uid] && ftapi.users[uid].avatarStyle) + || (uid === ftapi.uid ? firetable.avatarStyle : null) + || "robohash:set1"; + } + if (style.indexOf("dicebear:") === 0) { + return "https://api.dicebear.com/9.x/" + style.slice(9) + "/svg?seed=" + encodeURIComponent(uid + username); + } + var set = style.indexOf("robohash:") === 0 ? style.slice(9) : firetable.avatarset; + return AVATAR_BASE_URL + uid + username + ".png?size=" + size + "&set=" + set; + }, + + // ─── Song Title Parsing ────────────────────────────────────────────────── + + /** + * Split a "Artist - Title" string into { artist, title }. + * Falls back: if no " - " separator, artist = fallbackArtist, title = the string. + * @param {string} raw - The raw title string (e.g. "Radiohead - Creep") + * @param {string} [fallbackArtist=""] - Used when title has no " - " separator + * @returns {{artist: string, title: string}} + */ + parseArtistTitle: function (raw, fallbackArtist) { + var parts = raw.split(" - "); + var artist = parts[0]; + var title = parts[1]; + if (!title) { + title = artist; + artist = fallbackArtist || ""; + } + return { artist: artist, title: title }; + }, + + // ─── Hex ↔ RGB ─────────────────────────────────────────────────────────── + + /** + * Convert a hex colour string to an {r, g, b} object. + * Handles both 3-char (#abc) and 6-char (#aabbcc) formats. + * @param {string} hex + * @returns {{r: number, g: number, b: number}|null} + */ + hexToRGB: function (hex) { + var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, function (m, r, g, b) { + return r + r + g + g + b + b; + }); + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; + }, + + // ─── Canvas Text Wrapping ──────────────────────────────────────────────── + + /** + * Draw word-wrapped text onto a canvas context. + * @param {CanvasRenderingContext2D} context + * @param {string} text + * @param {number} x - Starting X position + * @param {number} y - Starting Y position + * @param {number} maxWidth - Maximum line width in pixels + * @param {number} lineHeight - Vertical spacing between lines + * @returns {number} Number of extra lines drawn (0 if text fit on one line) + */ + wrapText: function (context, text, x, y, maxWidth, lineHeight) { + var words = text.split(' '); + var line = ''; + var lines = 0; + for (var n = 0; n < words.length; n++) { + var testLine = line + words[n] + ' '; + var metrics = context.measureText(testLine); + if (metrics.width > maxWidth && n > 0) { + context.fillText(line, x, y); + line = words[n] + ' '; + y += lineHeight; + lines++; + } else { + line = testLine; + } + } + context.fillText(line, x, y); + return lines; + }, + + // ─── Sound / Notifications ─────────────────────────────────────────────── + + /** + * Play an audio notification (the "badoop" sound on @-mentions). + * Respects the user's playBadoop setting. + * @param {string} filename - Audio file path without extension (adds .mp3) + */ + playSound: function (filename) { + if (firetable.playBadoop) { + document.getElementById("audilert").setAttribute('src', filename + ".mp3"); + } + }, + + /** + * Show a browser desktop notification (if permission granted). + * @param {Object} chatData - Chat message data {id, txt} + * @param {string} namebo - Display name of the sender + */ + desktopNotify: function (chatData, namebo) { + if (Notification) { + if (Notification.permission !== "granted") { + Notification.requestPermission(); + } else { + new Notification(namebo, { + icon: firetable.utilities.avatarURL(chatData.id, namebo), + body: chatData.txt + }); + } + } + }, + + // ─── Screen (Video Stage) ──────────────────────────────────────────────── + + /** Slide the video stage up (hide it) */ + screenUp: function () { + $('body').removeClass('screen'); + }, + + /** Slide the video stage down (show it) */ + screenDown: function () { + $('body').addClass('screen'); + }, + + /** Returns "count singular" or "count plural" (defaults to singular+"s") */ + pluralize: function (count, singular, plural) { + return count + " " + (count === 1 ? singular : (plural || singular + "s")); + }, + + // ─── Chat Scroll ───────────────────────────────────────────────────────── + + /** + * Check if the chat is scrolled to (or very near) the bottom. + * Used to decide whether to auto-scroll on new messages. + * @returns {boolean} + */ + isChatPrettyMuchAtBottom: function () { + if (!chatScroll || !chatScroll.contentWrapperEl) { + return true; + } + + var scrollable = chatScroll.contentEl.scrollHeight - chatScroll.el.clientHeight; + var scrolled = chatScroll.contentWrapperEl.scrollTop; + return (Math.abs(scrollable - scrolled) <= 25); + }, + + /** Scroll the chat container to the very bottom. */ + scrollToBottom: function () { + if (!chatScroll || !chatScroll.contentWrapperEl) { + return; + } + + chatScroll.contentWrapperEl.scrollTop = chatScroll.contentEl.scrollHeight; + }, + + // ─── HTML / Text Processing ────────────────────────────────────────────── + + /** + * Escape HTML special characters for safe insertion into the DOM. + * @param {string} s - Raw string + * @param {boolean} [preserveCR=false] - If true, preserves carriage returns as + * @returns {string} Escaped string + */ + htmlEscape: function (s, preserveCR) { + preserveCR = preserveCR ? ' ' : '\n'; + return ('' + s) + .replace(/&/g, '&') + .replace(/'/g, '\\'') + .replace(/"/g, '"') + .replace(//g, '>') + .replace(/\r\n/g, preserveCR) + .replace(/[\r\n]/g, preserveCR); + }, + + // ─── Date / Time Formatting ────────────────────────────────────────────── + + /** + * Format a timestamp as a short date string: M/D/YYYY + * @param {number|string|Date} d - Timestamp or Date + * @returns {string} + */ + format_date: function (d) { + var date = new Date(d); + return (date.getMonth() + 1) + "/" + date.getDate() + "/" + date.getFullYear(); + }, + + /** + * Format a timestamp as a 12-hour time string: H:MMam/pm + * @param {number|string|Date} d - Timestamp or Date + * @returns {string} + */ + format_time: function (d) { + var date = new Date(d); + var hours = date.getHours(); + var ampm = hours >= 12 ? "pm" : "am"; + if (hours > 12) hours -= 12; + if (hours === 0) hours = 12; + var minutes = date.getMinutes(); + var min = minutes > 9 ? "" + minutes : "0" + minutes; + return hours + ":" + min + ampm; + }, + + // ─── Debounce ──────────────────────────────────────────────────────────── + + /** + * Create a debounced version of a function that delays invocation until + * `wait` ms have elapsed since the last call. + * @param {Function} func + * @param {number} wait - Delay in milliseconds + * @param {boolean} [immediate=false] - Trigger on leading edge instead of trailing + * @returns {Function} + */ + debounce: function (func, wait, immediate) { + var timeout; + return function () { + var context = this; + var args = arguments; + var later = function () { + timeout = null; + if (!immediate) func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; + }, + + // ─── Chat @-Mention Click Handler ──────────────────────────────────────── + + /** + * Attach a click handler to an element that inserts "@username " into the chat input. + * Works on .prson (user list), .ft-avatar (chat avatar), and .chatName elements. + * @param {jQuery} element - jQuery-wrapped DOM element + */ + chatAt: function (element) { + element.bind("click", function () { + var nameToAt; + if (element.hasClass("prson")) { + nameToAt = $(this).find(".prsnName").text(); + } else if (element.hasClass("ft-avatar")) { + nameToAt = $(this).next(".chatContent").find(".chatName").text(); + } else if (element.hasClass("chatName")) { + nameToAt = $(this).text(); + } + $("#newchat").val(function (i, val) { + return val + "@" + nameToAt + " "; + }).focus(); + }); + }, + + // ─── @-Mention Autocomplete ────────────────────────────────────────────── + + /** + * Enter @-mention mode: populate the list of all usernames + "everyone". + */ + initAtLand: function () { + firetable.atLand = true; + firetable.atString = ""; + firetable.atUsers = ["everyone"]; + for (var user in ftapi.users) { + firetable.atUsers.push(ftapi.users[user].username); + } + firetable.atUsersFiltered = firetable.atUsers.sort(); + }, + + /** + * Filter the @-mention dropdown to match the characters typed so far. + */ + updateAtLand: function () { + firetable.atUsersFiltered = firetable.atUsers + .filter(function (user) { + return user.toLowerCase().startsWith(firetable.atString.toLowerCase()); + }) + .sort(); + $('#atPicker').html(''); + if (firetable.atUsersFiltered.length) { + for (var i = 0; i < firetable.atUsersFiltered.length; i++) { + $('
    ').appendTo('#atPicker'); + } + } else { + $('
    No users match
    ').appendTo('#atPicker'); + } + }, + + /** + * Accept an @-mention selection: insert the name, close the picker. + * @param {string} atPeep - Username to insert (without the @) + */ + chooseAt: function (atPeep) { + var $chatText = $('#newchat'); + // Remove the partial string the user typed after @ + if (firetable.atString.length > 0) { + $chatText.val($chatText.val().slice(0, firetable.atString.length * -1)); + } + $chatText.val($chatText.val() + atPeep + " "); + firetable.utilities.exitAtLand(); + }, + + /** + * Exit @-mention mode: reset state and hide the picker. + */ + exitAtLand: function () { + firetable.atLand = false; + firetable.atUsersFiltered = []; + firetable.atString = ""; + $('#atPicker').removeClass('show').html(''); + }, + + // ─── Cancel Search/Queue Preview ─────────────────────────────────────── + + /** + * If a search-result or queue preview is playing, stop it and resume + * the current room song. This pattern was duplicated 6+ times across + * search handlers, cancelqsearch, queueTrack, etc. + * + * Returns true if a preview was actually cancelled, false otherwise. + * @returns {boolean} + */ + cancelSearchPreview: function () { + if (!firetable.preview) return false; + var prefix = String(firetable.preview).slice(0, 5); + if (prefix !== "ytcid" && prefix !== "sccid") return false; + + $("#pv" + firetable.preview).html(""); + clearTimeout(firetable.ptimeout); + firetable.ptimeout = null; + $("#pvbar" + firetable.preview).css("background-image", "none"); + clearInterval(firetable.movePvBar); + firetable.movePvBar = null; + firetable.preview = false; + firetable.utilities.resumeCurrentSong(); + return true; + }, + + // ─── Resume Current Song (Deduplication Helper) ────────────────────────── + + /** + * Resume playback of the current song at the correct elapsed position. + * + * This replaces the identical block of code that was copy-pasted 8+ times + * across the original codebase (in pview, reloadtrack, queueTrack, + * cancelqsearch, search handlers, newSong, initialize, SC READY, etc.). + * + * Calculates how much time has elapsed since the song started, + * then loads either the YouTube player or SoundCloud widget at that offset. + * + * @param {Object} [opts] - Optional overrides + * @param {boolean} [opts.forceVolume=false] - Also set volume after loading + */ + resumeCurrentSong: function (opts) { + opts = opts || {}; + if (!firetable.song) return; + if (firetable.preview) return; // don't interrupt a preview + + var data = firetable.song; + var nownow = Date.now(); + var timeSince = nownow - data.started; + if (timeSince <= 0) timeSince = 0; + + var secSince = Math.floor(timeSince / 1000); + + if (data.type === MEDIA_YOUTUBE) { + if (firetable.scLoaded) firetable.scwidget.pause(); + if (!firetable.disableMediaPlayback) { + player.loadVideoById(data.cid, secSince, "large"); + } + if (opts.forceVolume) { + var vol = $("#slider").slider("value"); + player.setVolume(vol); + firetable.scwidget.setVolume(vol); + } + } else if (data.type === MEDIA_SOUNDCLOUD) { + if (firetable.ytLoaded) player.stopVideo(); + firetable.scSeek = timeSince; + if (!firetable.disableMediaPlayback) { + firetable.scwidget.load(SC_API_TRACK_URL + data.cid, { auto_play: true }); + } + if (opts.forceVolume) { + var vol = $("#slider").slider("value"); + player.setVolume(vol); + firetable.scwidget.setVolume(vol); + } + } + } +}; diff --git a/js/init.js b/js/init.js new file mode 100644 index 0000000..22aa361 --- /dev/null +++ b/js/init.js @@ -0,0 +1,182 @@ +/** + * init.js — Application bootstrap. + * + * This is the last module loaded. It defines firetable.init() which: + * 1. Marks the app as started + * 2. Applies room branding from ftconfigs (title, logo, social links) + * 3. Sets up the DOMParser for HTML stripping + * 4. Binds the window resize handler (debounced) + * 5. Initializes the SoundCloud widget and binds its READY/PLAY events + * 6. Removes playlist/tag editor DOM templates for later cloning + * 7. Boots Firebase via ftapi.init() + * 8. Initializes the SoundCloud SDK + * 9. Binds auth lifecycle events (login, logout, reconnect, ban) + * 10. Calls firetable.ui.init() to wire up the rest of the UI + * + * The final line auto-starts the app: `if (!firetable.started) firetable.init();` + * + * Depends on: All other modules must be loaded before this file. + */ + +// ─── Bootstrap ─────────────────────────────────────────────────────────────── + +firetable.init = function () { + console.log( + "\n" + + " ( ) ) (\n" + + " )\\ ) ( ( ( ( /( ) ( /( )\\ (\n" + + "(()/( )\\ )( ))\\ )\\()) ( /( )\\()) (_)) ))\\\n" + + " /(_)) ((_) (()\\ /((_) (_))/ )(_)) ((_)\\ | | /((_)\n" + + "(_) _| (_) ((_) (_)) | |_ ((_)_ | |(_) | | (_))\n" + + " | _| | | | '_| / -_) | _| / _` | | '_ \\ | | / -_)\n" + + " |_| |_| |_| \\___| \\__| \\__,_| |_.__/ |_| \\___|" + ); + + firetable.started = true; + + // ── Room Branding ── + $("#idtitle").text(ftconfigs.roomName); + $("#welcomeName").text(ftconfigs.roomName); + + if (ftconfigs.avatarset) firetable.avatarset = ftconfigs.avatarset; + + // Social links — show each icon only if the URL is configured + var socialLinks = [ + { cls: "facebook", url: ftconfigs.facebookURL }, + { cls: "reddit", url: ftconfigs.redditURL }, + { cls: "lastfm", url: ftconfigs.lastfmURL }, + { cls: "discord", url: ftconfigs.discordURL }, + { cls: "soundcloud", url: ftconfigs.soundcloudURL } + ]; + socialLinks.forEach(function (link) { + if (link.url) $(".sociallogo." + link.cls).attr("href", link.url); + }); + + // Social popover: position anchored to trigger, swap icon on toggle + var socialPopoverEl = document.getElementById('socialPopover'); + if (socialPopoverEl) { + socialPopoverEl.addEventListener('toggle', function (e) { + var btn = document.getElementById('socialTrigger'); + var icon = btn && btn.querySelector('.material-symbols-outlined'); + if (e.newState === 'open') { + if (icon) icon.textContent = 'close'; + socialPopoverEl.style.visibility = 'hidden'; + firetable.ui.positionPopover(btn, socialPopoverEl, document.getElementById('socialArrow')); + } else { + if (icon) icon.textContent = 'share'; + } + }); + } + + if (ftconfigs.logoImage) { + $("#roomlogo").css("background-image", "url(" + ftconfigs.logoImage + ")"); + } + document.title = ftconfigs.roomName + " | firetable"; + if (ftconfigs.roomInfoUrl.length) { + $("#roomInfo").attr("href", ftconfigs.roomInfoUrl); + } + $("#version").text("You're running firetable v" + firetable.version + "."); + + // ── Utilities Setup ── + firetable.utilities.getEmojiMap(); + firetable.parser = new DOMParser(); + + // ── Window Resize Handler (debounced) ── + $(window).resize(firetable.utilities.debounce(function () { + $("#thehistory").css('top', $('#stage').outerHeight() + $('#topbar').outerHeight()); + $('#playerArea, #scScreen') + .width($('#djStage').outerWidth()) + .height($('#djStage').outerHeight()); + setup(); // Re-create the p5.js canvas at the new size + }, 500)); + + firetable.utilities.scrollToBottom(); + + // ── SoundCloud Widget ── + var widgetIframe = document.getElementById('sc-widget'); + firetable.scwidget = SC.Widget(widgetIframe); + + firetable.scwidget.bind(SC.Widget.Events.READY, function () { + // When a SC track starts playing, apply volume + seek + firetable.scwidget.bind(SC.Widget.Events.PLAY, function () { + var vol = localStorage[STORAGE.volume]; + if (!vol) { + vol = DEFAULT_VOLUME; + localStorage[STORAGE.volume] = DEFAULT_VOLUME; + } + firetable.scwidget.setVolume(vol); + if (firetable.scSeek) firetable.scwidget.seekTo(firetable.scSeek); + }); + + // If a SC song was already loaded before the widget was ready, start it now + if (firetable.song && firetable.song.type == MEDIA_SOUNDCLOUD) { + var data = firetable.song; + var timeSince = Date.now() - data.started; + if (timeSince <= 0) timeSince = 0; + if (!firetable.preview) { + firetable.scSeek = timeSince; + if (!firetable.disableMediaPlayback) { + firetable.scwidget.load(SC_API_TRACK_URL + data.cid, { auto_play: true }); + } + } + } + firetable.scLoaded = true; + }); + + // ── DOM Templates ── + $playlistItemTemplate = $('#mainqueue .pvbar').remove(); + + // ── Firebase Init ── + ftapi.init(ftconfigs.firebase); + + // ── SoundCloud SDK Init ── + SC.initialize({ client_id: ftconfigs.soundcloudKey }); + + // ── Auth Lifecycle Events ── + + /** User successfully logged in */ + ftapi.events.on("loggedIn", function (data) { + firetable.actions.loggedIn(data); + }); + + /** User logged out */ + ftapi.events.on("loggedOut", firetable.actions.showLoginScreen); + + /** Firebase reconnected after a network drop */ + ftapi.events.on("authReconnected", function () { + firetable.debug && console.log('reconnected'); + $('body').removeClass('disconnected'); + $('#newchat').prop('disabled', false).focus(); + }); + + /** Firebase connection lost */ + ftapi.events.on("authDisconnected", function () { + firetable.debug && console.log('disconnected'); + $('body').addClass('disconnected'); + $('#newchat').prop('disabled', true).blur(); + }); + + /** Current user was banned */ + ftapi.events.on("userBanned", function () { + firetable.debug && console.log("ban detected."); + if (document.getElementById("notice") == null) { + var usrname2use = ftapi.uid; + if (ftapi.users[ftapi.uid] && ftapi.users[ftapi.uid].username) { + usrname2use = ftapi.users[ftapi.uid].username; + } + $('.notice').attr('id', 'notice'); + $("#troublemaker").text(usrname2use); + } + }); + + /** Current user was un-banned */ + ftapi.events.on("userUnbanned", function () { + window.location.reload(); + }); + + // ── UI Init (wires everything up) ── + firetable.ui.init(); +}; + +// ─── Auto-start ────────────────────────────────────────────────────────────── +if (!firetable.started) firetable.init(); diff --git a/js/lastfm.js b/js/lastfm.js new file mode 100644 index 0000000..456e04e --- /dev/null +++ b/js/lastfm.js @@ -0,0 +1,187 @@ +/** + * lastfm.js — Last.fm scrobbling integration. + * + * Handles: session management (connect/disconnect), nowPlaying updates, + * scrobble submissions, and track.love. Uses the MD5 implementation from + * md5.js to sign API requests. + * + * The Last.fm API key and secret are defined here. Session keys are stored + * in localStorage so scrobbling persists across page reloads. + */ + +firetable.lastfm = { + + /** @type {string|false} Last.fm session key, or false if not connected */ + sk: false, + + /** @type {string} Last.fm API key */ + key: "e86f3b80e48769c03f2b4e0609e12924", + + /** @type {string} Last.fm API shared secret (used for signing requests) */ + secret: "838d63e62b556f74176656640b75e33e", + + /** @type {number|null} Unix timestamp (seconds) when the current song started */ + songStart: null, + + /** @type {number|null} Duration of the current song in seconds */ + duration: null, + + /** @type {number|null} setTimeout ID for the scrobble timer */ + timer: null, + + // ─── Session Management ──────────────────────────────────────────────── + + /** + * Disconnect the Last.fm session and clear stored credentials. + * Re-renders the settings link to allow re-connecting. + */ + killSession: function () { + firetable.lastfm.sk = false; + localStorage[STORAGE.lastfmSession] = firetable.lastfm.sk; + $("#scrobtoggle").html( + 'Set up last.fm scrobbling' + ); + }, + + /** + * Handle a successful auth.getSession response from Last.fm. + * Parses the session key from the XHR response and stores it. + * @param {XMLHttpRequest} xhr - The completed XHR object + * @returns {Function} onload callback + */ + newSession: function (xhr) { + return function () { + var jsonResponse = JSON.parse(xhr.responseText); + firetable.lastfm.sk = jsonResponse.session.key; + localStorage[STORAGE.lastfmSession] = firetable.lastfm.sk; + $("#scrobtoggle").html( + 'Disconnect Lastfm Scrobbling' + ); + }; + }, + + // ─── API Calls ───────────────────────────────────────────────────────── + + /** + * Submit a scrobble (track.scrobble) for the current song. + * Called automatically after ~(duration - 3s) of playback. + */ + scrobble: function () { + var song = firetable.song; + var track = firetable.lastfm._cleanForScrobble(song.title); + + var params = { + artist: song.artist, + track: track, + timestamp: firetable.lastfm.songStart, + api_key: firetable.lastfm.key, + sk: firetable.lastfm.sk, + method: "track.scrobble" + }; + + params.api_sig = firetable.lastfm.getApiSignature(params); + var requestUrl = LASTFM_API_URL + '?' + serialize(params); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', requestUrl, true); + xhr.onload = function () { console.log("scrobbled"); }; + xhr.onerror = firetable.lastfm._onAjaxError; + xhr.send(); + }, + + /** + * Send a track.love for the current song. + */ + love: function () { + var song = firetable.song; + var track = firetable.lastfm._cleanForScrobble(song.title); + + var params = { + artist: song.artist, + track: track, + api_key: firetable.lastfm.key, + sk: firetable.lastfm.sk, + method: "track.love" + }; + + params.api_sig = firetable.lastfm.getApiSignature(params); + var requestUrl = LASTFM_API_URL + '?' + serialize(params); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', requestUrl, true); + xhr.onload = function () { console.log("loved"); }; + xhr.onerror = firetable.lastfm._onAjaxError; + xhr.send(); + }, + + /** + * Send a track.updateNowPlaying to Last.fm. + * Called immediately when a new song starts. + */ + nowPlaying: function () { + var song = firetable.song; + var track = firetable.lastfm._cleanForScrobble(song.title); + + var params = { + artist: song.artist, + track: track, + duration: firetable.lastfm.duration, + api_key: firetable.lastfm.key, + sk: firetable.lastfm.sk, + method: "track.updateNowPlaying" + }; + + params.api_sig = firetable.lastfm.getApiSignature(params); + var requestUrl = LASTFM_API_URL + '?' + serialize(params); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', requestUrl, true); + xhr.onload = function () { console.log("nowplaying updated"); }; + xhr.onerror = firetable.lastfm._onAjaxError; + xhr.send(); + }, + + // ─── Internal Helpers ────────────────────────────────────────────────── + + /** + * Build the api_sig parameter for a Last.fm request. + * Sorts params alphabetically, concatenates key+value pairs, + * appends the shared secret, and returns the MD5 hash. + * @param {Object} params - API parameters (excluding api_sig) + * @returns {string} 32-char hex MD5 signature + */ + getApiSignature: function (params) { + var keys = []; + for (var key in params) { + if (params.hasOwnProperty(key)) keys.push(key); + } + keys.sort(); + + var paramString = ""; + for (var i = 0; i < keys.length; i++) { + paramString += keys[i] + params[keys[i]]; + } + return calcMD5(paramString + firetable.lastfm.secret); + }, + + /** + * Strip common YouTube title suffixes that pollute scrobble data. + * e.g. " (Official Audio)", " (Official Video)" + * @param {string} title + * @returns {string} Cleaned title + */ + _cleanForScrobble: function (title) { + return title.replace(/ \([oO]fficial (?:[aA]udio|[vV]ideo)\)/, ""); + }, + + /** + * Log Last.fm API errors to the console. + * @param {XMLHttpRequest} xhr + * @param {string} status + * @param {string} error + */ + _onAjaxError: function (xhr, status, error) { + console.error("Last.fm API error:", xhr, status, error); + } +}; diff --git a/js/main.js b/js/main.original.js similarity index 98% rename from js/main.js rename to js/main.original.js index d2d342a..a8e31ba 100644 --- a/js/main.js +++ b/js/main.original.js @@ -1223,11 +1223,11 @@ firetable.actions = { name: name, cid: cid }; - $("#apv" + type + cid).find(".material-icons").text("check"); + $("#apv" + type + cid).find(".material-symbols-outlined").text("check"); $("#apv" + type + cid).css("color", firetable.orange); $("#apv" + type + cid).css("pointer-events", "none"); setTimeout(function() { - $("#apv" + type + cid).find(".material-icons").text("playlist_add"); + $("#apv" + type + cid).find(".material-symbols-outlined").text("playlist_add"); $("#apv" + type + cid).removeAttr("style"); }, 3000); @@ -2244,7 +2244,7 @@ firetable.ui = { cnt = countr; var removeMe = ""; if (data[key].removeAfter) removeMe = "departure_board" - ok1 += "
    " + countr + ". " + data[key].name + " " + removeMe + "
    "; + ok1 += "
    " + countr + ". " + data[key].name + " " + removeMe + "
    "; countr++; } } @@ -2261,7 +2261,7 @@ firetable.ui = { var removeMe = ""; if (data[key].removeAfter) removeMe = "departure_board" - ok1 += "
    " + removeMe + " " + data[key].name + "
    " + data[key].plays + "/" + firetable.playlimit + "
    "; + ok1 += "
    " + removeMe + " " + data[key].name + "
    " + data[key].plays + "/" + firetable.playlimit + "
    "; countr++; } } @@ -2315,7 +2315,7 @@ firetable.ui = { for (key in data) { if (data[key]) { ftapi.lookup.userByName(key, function(person) { - $("#activeSuspentions").append("
    " + person.username + "
    "); + $("#activeSuspentions").append("
    " + person.username + "
    "); }); } } @@ -2358,7 +2358,7 @@ firetable.ui = { var newUserToAddX = $("
    "); newUserToAddX.addClass("prson " + block); newUserToAddX.attr("id", "user" + data.userid); - newUserToAddX.html("
    " + blockcon + "" + herecon + "
    " + data.username + "" + rolename + "joined " + firetable.utilities.format_date(data.joined) + ""); + newUserToAddX.html("
    " + blockcon + "" + herecon + "
    " + data.username + "" + rolename + "joined " + firetable.utilities.format_date(data.joined) + ""); firetable.utilities.chatAt(newUserToAddX); // adds the click event to @ the user $(destination).append(newUserToAddX); }); @@ -2400,7 +2400,7 @@ firetable.ui = { destination = "#usersBot"; } - $("#user" + data.userid).html("
    " + blockcon + "" + herecon + "
    " + data.username + "" + rolename + "joined " + firetable.utilities.format_date(data.joined) + ""); + $("#user" + data.userid).html("
    " + blockcon + "" + herecon + "
    " + data.username + "" + rolename + "joined " + firetable.utilities.format_date(data.joined) + ""); }); ftapi.events.on("usersChanged", function(okdata) { if ($("#loggedInName").text() == ftapi.uid) { @@ -2605,7 +2605,7 @@ firetable.ui = { flagIcon = "flag"; } } - $newli.find('.track-warning').html(" " + flagIcon + " "); + $newli.find('.track-warning').html(" " + flagIcon + " "); $newli.find('.track-warning').prop('title', 'Flagged as ' + flagLabel + ' on ' + firetable.utilities.format_date(thisone.flagged.date) + '. Click to remove flag.'); $newli.find('.track-warning').on('click', function() { ftapi.actions.unflagTrack($(this).closest('.pvbar').attr('data-key')); @@ -3128,7 +3128,7 @@ firetable.ui = { if (response.result.items.length === 1) { var item = response.result.items[0]; vidTitle = item.snippet.title; - $("#importResults").append("
    " + item.snippet.title + " by " + item.snippet.channelTitle + "
    "); + $("#importResults").append("
    " + item.snippet.title + " by " + item.snippet.channelTitle + "
    "); } else { // no result } @@ -3158,7 +3158,7 @@ firetable.ui = { firetable.debug && console.log('import search results:', response); $.each(srchItems, function(index, item) { vidTitle = item.snippet.title; - $("#importResults").append("
    " + item.snippet.title + " by " + item.snippet.channelTitle + "
    "); + $("#importResults").append("
    " + item.snippet.title + " by " + item.snippet.channelTitle + "
    "); }) }) } @@ -3174,7 +3174,7 @@ firetable.ui = { firetable.actions.resolveSCLink(val, function(item) { if (item) { if (item.sharing == "public" && item.kind == "playlist") { - $("#importResults").append("
    " + item.title + " by " + item.user.username + " (" + item.track_count + " songs)
    "); + $("#importResults").append("
    " + item.title + " by " + item.user.username + " (" + item.track_count + " songs)
    "); } } }); @@ -3188,7 +3188,7 @@ firetable.ui = { for (var i = 0; i < lists.length; i++) { var item = lists[i]; if (item.sharing == "public") { - $("#importResults").append("
    " + item.title + " by " + item.user.username + " (" + item.track_count + " songs)
    "); + $("#importResults").append("
    " + item.title + " by " + item.user.username + " (" + item.track_count + " songs)
    "); } } }); diff --git a/js/md5.js b/js/md5.js new file mode 100644 index 0000000..09366fb --- /dev/null +++ b/js/md5.js @@ -0,0 +1,213 @@ +/** + * md5.js — MD5 message-digest algorithm. + * + * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message + * Digest Algorithm, as defined in RFC 1321. + * + * Copyright (C) Paul Johnston 1999 - 2000. + * Updated by Greg Holt 2000 - 2001. + * See http://pajhome.org.uk/site/legal.html for details. + * + * Used in this app solely for signing Last.fm API requests. + * All variables properly scoped with `var` to prevent global leaks. + */ + +/** Hex character lookup table */ +var hex_chr = "0123456789abcdef"; + +/** + * Convert a 32-bit number to a hex string with least-significant byte first. + * @param {number} num - 32-bit integer + * @returns {string} 8-character hex string + */ +function rhex(num) { + var str = ""; + for (var j = 0; j <= 3; j++) { + str += hex_chr.charAt((num >> (j * 8 + 4)) & 0x0F) + + hex_chr.charAt((num >> (j * 8)) & 0x0F); + } + return str; +} + +/** + * Convert a string to a sequence of 16-word blocks stored as an array. + * Appends padding bits and the length as described in the MD5 standard. + * @param {string} str - Input string + * @returns {number[]} Array of 32-bit integers + */ +function str2blks_MD5(str) { + var nblk = ((str.length + 8) >> 6) + 1; + var blks = new Array(nblk * 16); + var i; + for (i = 0; i < nblk * 16; i++) blks[i] = 0; + for (i = 0; i < str.length; i++) { + blks[i >> 2] |= str.charCodeAt(i) << ((i % 4) * 8); + } + blks[i >> 2] |= 0x80 << ((i % 4) * 8); + blks[nblk * 16 - 2] = str.length * 8; + return blks; +} + +/** + * Add two 32-bit integers, wrapping at 2^32. + * Uses 16-bit operations internally to work around bugs in some JS interpreters. + * @param {number} x + * @param {number} y + * @returns {number} + */ +function add(x, y) { + var lsw = (x & 0xFFFF) + (y & 0xFFFF); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xFFFF); +} + +/** + * Bitwise rotate a 32-bit number to the left. + * @param {number} num - Value to rotate + * @param {number} cnt - Number of bits to rotate + * @returns {number} + */ +function rol(num, cnt) { + return (num << cnt) | (num >>> (32 - cnt)); +} + +/* + * These functions implement the basic operation for each round of the + * MD5 algorithm (F, G, H, I respectively). + */ +function cmn(q, a, b, x, s, t) { + return add(rol(add(add(a, q), add(x, t)), s), b); +} + +function ff(a, b, c, d, x, s, t) { + return cmn((b & c) | ((~b) & d), a, b, x, s, t); +} + +function gg(a, b, c, d, x, s, t) { + return cmn((b & d) | (c & (~d)), a, b, x, s, t); +} + +function hh(a, b, c, d, x, s, t) { + return cmn(b ^ c ^ d, a, b, x, s, t); +} + +function ii(a, b, c, d, x, s, t) { + return cmn(c ^ (b | (~d)), a, b, x, s, t); +} + +/** + * Compute the MD5 hash of a string. + * @param {string} str - Input string + * @returns {string} 32-character lowercase hex digest + */ +function calcMD5(str) { + var x = str2blks_MD5(str); + var a = 1732584193; + var b = -271733879; + var c = -1732584194; + var d = 271733878; + + for (var i = 0; i < x.length; i += 16) { + var olda = a; + var oldb = b; + var oldc = c; + var oldd = d; + + a = ff(a, b, c, d, x[i + 0], 7, -680876936); + d = ff(d, a, b, c, x[i + 1], 12, -389564586); + c = ff(c, d, a, b, x[i + 2], 17, 606105819); + b = ff(b, c, d, a, x[i + 3], 22, -1044525330); + a = ff(a, b, c, d, x[i + 4], 7, -176418897); + d = ff(d, a, b, c, x[i + 5], 12, 1200080426); + c = ff(c, d, a, b, x[i + 6], 17, -1473231341); + b = ff(b, c, d, a, x[i + 7], 22, -45705983); + a = ff(a, b, c, d, x[i + 8], 7, 1770035416); + d = ff(d, a, b, c, x[i + 9], 12, -1958414417); + c = ff(c, d, a, b, x[i + 10], 17, -42063); + b = ff(b, c, d, a, x[i + 11], 22, -1990404162); + a = ff(a, b, c, d, x[i + 12], 7, 1804603682); + d = ff(d, a, b, c, x[i + 13], 12, -40341101); + c = ff(c, d, a, b, x[i + 14], 17, -1502002290); + b = ff(b, c, d, a, x[i + 15], 22, 1236535329); + + a = gg(a, b, c, d, x[i + 1], 5, -165796510); + d = gg(d, a, b, c, x[i + 6], 9, -1069501632); + c = gg(c, d, a, b, x[i + 11], 14, 643717713); + b = gg(b, c, d, a, x[i + 0], 20, -373897302); + a = gg(a, b, c, d, x[i + 5], 5, -701558691); + d = gg(d, a, b, c, x[i + 10], 9, 38016083); + c = gg(c, d, a, b, x[i + 15], 14, -660478335); + b = gg(b, c, d, a, x[i + 4], 20, -405537848); + a = gg(a, b, c, d, x[i + 9], 5, 568446438); + d = gg(d, a, b, c, x[i + 14], 9, -1019803690); + c = gg(c, d, a, b, x[i + 3], 14, -187363961); + b = gg(b, c, d, a, x[i + 8], 20, 1163531501); + a = gg(a, b, c, d, x[i + 13], 5, -1444681467); + d = gg(d, a, b, c, x[i + 2], 9, -51403784); + c = gg(c, d, a, b, x[i + 7], 14, 1735328473); + b = gg(b, c, d, a, x[i + 12], 20, -1926607734); + + a = hh(a, b, c, d, x[i + 5], 4, -378558); + d = hh(d, a, b, c, x[i + 8], 11, -2022574463); + c = hh(c, d, a, b, x[i + 11], 16, 1839030562); + b = hh(b, c, d, a, x[i + 14], 23, -35309556); + a = hh(a, b, c, d, x[i + 1], 4, -1530992060); + d = hh(d, a, b, c, x[i + 4], 11, 1272893353); + c = hh(c, d, a, b, x[i + 7], 16, -155497632); + b = hh(b, c, d, a, x[i + 10], 23, -1094730640); + a = hh(a, b, c, d, x[i + 13], 4, 681279174); + d = hh(d, a, b, c, x[i + 0], 11, -358537222); + c = hh(c, d, a, b, x[i + 3], 16, -722521979); + b = hh(b, c, d, a, x[i + 6], 23, 76029189); + a = hh(a, b, c, d, x[i + 9], 4, -640364487); + d = hh(d, a, b, c, x[i + 12], 11, -421815835); + c = hh(c, d, a, b, x[i + 15], 16, 530742520); + b = hh(b, c, d, a, x[i + 2], 23, -995338651); + + a = ii(a, b, c, d, x[i + 0], 6, -198630844); + d = ii(d, a, b, c, x[i + 7], 10, 1126891415); + c = ii(c, d, a, b, x[i + 14], 15, -1416354905); + b = ii(b, c, d, a, x[i + 5], 21, -57434055); + a = ii(a, b, c, d, x[i + 12], 6, 1700485571); + d = ii(d, a, b, c, x[i + 3], 10, -1894986606); + c = ii(c, d, a, b, x[i + 10], 15, -1051523); + b = ii(b, c, d, a, x[i + 1], 21, -2054922799); + a = ii(a, b, c, d, x[i + 8], 6, 1873313359); + d = ii(d, a, b, c, x[i + 15], 10, -30611744); + c = ii(c, d, a, b, x[i + 6], 15, -1560198380); + b = ii(b, c, d, a, x[i + 13], 21, 1309151649); + a = ii(a, b, c, d, x[i + 4], 6, -145523070); + d = ii(d, a, b, c, x[i + 11], 10, -1120210379); + c = ii(c, d, a, b, x[i + 2], 15, 718787259); + b = ii(b, c, d, a, x[i + 9], 21, -343485551); + + a = add(a, olda); + b = add(b, oldb); + c = add(c, oldc); + d = add(d, oldd); + } + return rhex(a) + rhex(b) + rhex(c) + rhex(d); +} + +/** + * Serialize an object into a URL query string. + * Handles nested objects via recursive bracket notation. + * @param {Object} obj - Key/value pairs to serialize + * @param {string} [prefix] - Key prefix for nested objects + * @returns {string} URL-encoded query string + */ +var serialize = function (obj, prefix) { + var str = []; + for (var p in obj) { + if (obj.hasOwnProperty(p)) { + var k = prefix ? prefix + "[" + p + "]" : p; + var v = obj[p]; + str.push( + typeof v === "object" + ? serialize(v, k) + : encodeURIComponent(k) + "=" + encodeURIComponent(v) + ); + } + } + return str.join("&"); +}; diff --git a/js/player.js b/js/player.js new file mode 100644 index 0000000..ef42a7a --- /dev/null +++ b/js/player.js @@ -0,0 +1,269 @@ +/** + * player.js — YouTube + SoundCloud media playback. + * + * Handles: + * - YouTube IFrame API ready callback + player initialization + * - SoundCloud Widget initialization (in init.js, widget events bound here) + * - Volume slider + mute toggle + * - Track preview (play a 30-second snippet of a song in the queue or search) + * - Reload current track at correct elapsed position + * + * Uses the `firetable.utilities.resumeCurrentSong()` helper from helpers.js + * to avoid duplicating the "calculate elapsed → load YT or SC" logic. + */ + +// ─── YouTube IFrame API Callback ───────────────────────────────────────────── +/** + * Called automatically by the YouTube IFrame API when it finishes loading. + * Creates the YT.Player instance inside #playerArea. + */ +function onYouTubeIframeAPIReady() { + player = new YT.Player('playerArea', { + width: $('#djStage').outerHeight() * ASPECT_RATIO, + height: $('#djStage').outerHeight(), + playerVars: { + autoplay: 1, + controls: 0 + }, + videoId: '5mGuCdlCcNM', // placeholder video + events: { + onReady: onPlayerReady, + onStateChange: function () { + $('#reloadtrack').removeClass('on working'); + } + } + }); +} + +/** + * Called when the YouTube player is ready. Sets up: + * - Volume from localStorage (or DEFAULT_VOLUME on first visit) + * - Mute state from localStorage + * - jQuery UI volume slider + * - Resumes the current song if one is already playing + * + * @param {Object} event - YouTube player ready event + */ +function onPlayerReady(event) { + firetable.ytLoaded = true; + + // ── Restore volume ── + var vol = localStorage[STORAGE.volume]; + if (typeof vol === "undefined") { + vol = DEFAULT_VOLUME; + localStorage[STORAGE.volume] = DEFAULT_VOLUME; + } + player.setVolume(vol); + + // ── Restore mute state ── + var muted = localStorage[STORAGE.mute]; + if (typeof muted === "undefined") { + localStorage[STORAGE.mute] = false; + muted = "false"; + $("#volstatus").removeClass('on'); + } + if (muted !== "false") { + $("#volstatus i").html(""); + $("#volstatus").addClass('on'); + } + + // ── Volume slider ── + $("#slider").slider({ + orientation: "vertical", + range: "min", + min: 0, + max: 100, + value: vol, + step: 5, + slide: function (event, ui) { + player.setVolume(ui.value); + firetable.scwidget.setVolume(ui.value); + localStorage[STORAGE.volume] = ui.value; + + var isMuted = localStorage[STORAGE.mute]; + if (isMuted !== "false") { + // Un-mute since user is dragging the slider + localStorage[STORAGE.mute] = false; + $("#volstatus i").html(""); + $("#volstatus").removeClass('on'); + } else if (ui.value === 0) { + firetable.actions.muteToggle(true); + $("#volstatus").addClass('on'); + } + } + }); + + // ── Resume song if one was already playing when YT loaded ── + if (firetable.song && firetable.song.type === MEDIA_YOUTUBE) { + firetable.utilities.resumeCurrentSong(); + } +} + +/** + * Stub — YouTube fires this when playback state changes. + * Currently unused but required by the API. + */ +function onPlayerStateChange(event) { + // no-op +} + +// ─── Mute / Volume Toggle ──────────────────────────────────────────────────── + +firetable.actions = firetable.actions || {}; + +/** + * Toggle mute on/off. Stores previous volume to restore when un-muting. + * + * @param {boolean} [zeroMute=false] - If true, force-mute at volume 0 + * (used when the slider is dragged to 0) + */ +firetable.actions.muteToggle = function (zeroMute) { + var muted = localStorage[STORAGE.mute]; + var icon = ""; // volume_up icon + + if (zeroMute) { + // Slider was dragged to 0 — mark as zero-muted + icon = ""; // volume_off icon + muted = 0; + + } else if (typeof muted !== 'undefined') { + if (muted !== "false") { + // Currently muted → un-mute, restore saved volume + if (parseInt(muted) === 0) { + // Was zero-muted — restore to default volume + $("#slider").slider("value", DEFAULT_VOLUME); + player.setVolume(DEFAULT_VOLUME); + firetable.scwidget.setVolume(DEFAULT_VOLUME); + localStorage[STORAGE.volume] = DEFAULT_VOLUME; + } else { + // Restore the volume that was saved before muting + var savedVol = parseInt(muted); + $("#slider").slider("value", savedVol); + player.setVolume(savedVol); + firetable.scwidget.setVolume(savedVol); + localStorage[STORAGE.volume] = savedVol; + } + muted = false; + } else { + // Not muted → mute: save current volume, set to 0 + icon = ""; + muted = $("#slider").slider("value"); + $("#slider").slider('value', 0); + player.setVolume(0); + firetable.scwidget.setVolume(0); + localStorage[STORAGE.volume] = 0; + } + } else { + // First time — mute + icon = ""; + muted = $("#slider").slider("value"); + $("#slider").slider('value', 0); + player.setVolume(0); + firetable.scwidget.setVolume(0); + localStorage[STORAGE.volume] = 0; + } + + if (muted) { + $("#volstatus").addClass('on'); + } else { + $("#volstatus").removeClass('on'); + } + $("#volstatus i").html(icon); + localStorage[STORAGE.mute] = muted; +}; + +// ─── Track Preview ─────────────────────────────────────────────────────────── + +/** + * Preview a song for 30 seconds. If the same song is already being previewed, + * stop the preview and resume the current room song. + * + * @param {string} id - Song key (queue key or "ytcid..."/"sccid..." for search results) + * @param {boolean} fromSearch - True if previewing from search results (id has prefix) + * @param {number} type - MEDIA_YOUTUBE or MEDIA_SOUNDCLOUD + * @param {boolean} [fromHist=false] - True if previewing from history (darker progress bar) + */ +firetable.actions.pview = function (id, fromSearch, type, fromHist) { + if (firetable.preview === id) { + // ── Already previewing this track → stop and resume room song ── + clearTimeout(firetable.ptimeout); + firetable.ptimeout = null; + $("#pv" + firetable.preview).html(""); // play_arrow + $("#pvbar" + firetable.preview).css("background-image", "none"); + clearInterval(firetable.movePvBar); + firetable.movePvBar = null; + firetable.preview = false; + + firetable.utilities.resumeCurrentSong(); + + } else { + // ── Stop any existing preview ── + if (firetable.preview) { + $("#pv" + firetable.preview).html(""); + $("#pvbar" + firetable.preview).css("background-image", "none"); + } + + firetable.preview = id; + var cid = fromSearch ? id.slice(5) : firetable.queue[id].cid; + + // Clear any existing preview timer + if (firetable.ptimeout != null) { + clearTimeout(firetable.ptimeout); + firetable.ptimeout = null; + } + if (firetable.movePvBar != null) { + clearInterval(firetable.movePvBar); + firetable.movePvBar = null; + } + + // ── Auto-stop after PREVIEW_DURATION ── + firetable.pvCount = 0; + firetable.ptimeout = setTimeout(function () { + firetable.ptimeout = null; + $("#pv" + firetable.preview).html(""); + $("#pvbar" + firetable.preview).css("background-image", "none"); + clearInterval(firetable.movePvBar); + firetable.movePvBar = null; + firetable.pvCount = 0; + firetable.preview = false; + + // Resume the room's current song + firetable.utilities.resumeCurrentSong(); + }, PREVIEW_DURATION); + + // ── Show pause icon + animate progress bar ── + $("#pv" + id).html(""); // pause icon + firetable.movePvBar = setInterval(function () { + var pcnt = (firetable.pvCount / 29) * 100; // 29 = PREVIEW_DURATION/1000 - 1 + firetable.pvCount += 0.2; + var bgColor = fromHist ? "#222" : "#222"; + $("#pvbar" + firetable.preview).css( + "background-image", + "linear-gradient(90deg, rgba(244, 129, 11, 0.267) " + pcnt + "%, " + bgColor + " " + pcnt + "%)" + ); + }, PREVIEW_BAR_INTERVAL); + + // ── Start playing the preview from the beginning ── + if (type == MEDIA_YOUTUBE) { + if (firetable.scLoaded) firetable.scwidget.pause(); + if (!firetable.disableMediaPlayback) player.loadVideoById(cid, 0, "large"); + } else if (type == MEDIA_SOUNDCLOUD) { + if (firetable.ytLoaded) player.stopVideo(); + firetable.scSeek = 0; + if (!firetable.disableMediaPlayback) { + firetable.scwidget.load(SC_API_TRACK_URL + cid, { auto_play: true }); + } + } + } +}; + +// ─── Reload Track ──────────────────────────────────────────────────────────── + +/** + * Reload the current song at the correct elapsed position. + * Shows a loading spinner on the reload button. + */ +firetable.actions.reloadtrack = function () { + $('#reloadtrack').addClass('on working'); + firetable.utilities.resumeCurrentSong(); +}; diff --git a/js/playlist.js b/js/playlist.js new file mode 100644 index 0000000..3260142 --- /dev/null +++ b/js/playlist.js @@ -0,0 +1,703 @@ +/** + * playlist.js — Queue and playlist management. + * + * Handles: + * - Adding tracks to queue (queueTrack) + * - Reordering via drag-and-drop (updateQueue, sortable) + * - Shuffle, dedup, bump-to-top, move-to-bottom, delete + * - Queue filtering (#queueFilter) + * - Merge lists between playlists + * - Queue from link (YouTube/SoundCloud URL drag-and-drop) + * - SoundCloud URL resolution (resolveSCLink, scGet) + * - Import playlist from YouTube/SoundCloud (importList) + * - Dubtrack import (dubtrackImport, dubtrackImportFileSelect) + * - List CRUD (create, delete, switch) + * - playlistChanged event handler + * - Tag editing (editTagsPrompt) + */ + +firetable.actions = firetable.actions || {}; + +// ─── Queue Track ───────────────────────────────────────────────────────────── + +/** + * Add a track to the current playlist queue. + * If a search preview is active, cancels it and resumes the room song. + * + * @param {string} cid - Content ID (YouTube video ID or SoundCloud track ID) + * @param {string} name - Track display name "Artist - Title" + * @param {number} type - MEDIA_YOUTUBE (1) or MEDIA_SOUNDCLOUD (2) + * @param {boolean} [tobottom] - If true, don't bump to top of queue + */ +firetable.actions.queueTrack = function (cid, name, type, tobottom) { + var info = { type: type, name: name, cid: cid }; + + // Visual feedback: checkmark on the queue button + $("#apv" + type + cid).find(".material-symbols-outlined").text("check"); + $("#apv" + type + cid).css("color", firetable.orange); + $("#apv" + type + cid).css("pointer-events", "none"); + setTimeout(function () { + $("#apv" + type + cid).find(".material-symbols-outlined").text("playlist_add"); + $("#apv" + type + cid).removeAttr("style"); + }, 3000); + + var cuteid = ftapi.actions.addToList(type, name, cid, false, function () { + firetable.debug && console.log('queue track id:', cuteid); + if (!tobottom) firetable.actions.bumpSongInQueue(cuteid); + }); + + // Cancel any active search preview + firetable.utilities.cancelSearchPreview(); + + // Switch view back to queue + $("#mainqueuestuff").css("display", "block"); + $("#filterMachine").css("display", "block"); + $("#addbox").css("display", "none"); + $("#cancelqsearch").hide(); + $("#qControlButtons").show(); +}; + +// ─── Queue Reorder (Sortable) ──────────────────────────────────────────────── + +/** + * Called when the user drags a song to a new position in the queue. + * Reads the new DOM order and pushes it to the server. + */ +firetable.actions.updateQueue = function () { + var arr = $('#mainqueue > div').map(function () { + return this.id.slice(5); + }).get(); + ftapi.actions.reorderList(arr, firetable.preview, function (changePV) { + if (changePV) firetable.preview = changePV; + }); +}; + +/** Shuffle the current playlist on the server. */ +firetable.actions.shuffleQueue = function () { + ftapi.actions.shuffleList(firetable.preview, function (changePV) { + if (changePV) firetable.preview = changePV; + }); +}; + +/** Remove duplicate tracks from the current playlist. */ +firetable.actions.removeDupesFromQueue = function () { + ftapi.actions.removeDuplicatesFromList(); + $("#mergeCompleted").show(); + $("#mergeHappening").hide(); +}; + +/** + * Move a track to the top of the queue. + * @param {string} songid - Track key in the playlist + */ +firetable.actions.bumpSongInQueue = function (songid) { + ftapi.actions.moveTrackToTop(songid, firetable.preview, function (changePV) { + if (changePV) firetable.preview = changePV; + }); +}; + +/** + * Delete a track from the current playlist. + * @param {string} id - Track key + */ +firetable.actions.deleteSong = function (id) { + ftapi.actions.deleteTrack(id); +}; + +/** + * Filter visible queue items by a search string. + * @param {string} val - Filter text (empty string shows all) + */ +firetable.actions.filterQueue = function (val) { + if (val.length === 0) { + $("#mainqueue .pvbar").show(); + return; + } + val = val.toLowerCase(); + $("#mainqueue .pvbar").each(function (p, q) { + var txt = $(q).find(".listwords").text(); + if (txt.match(new RegExp(val, 'ig'))) { + $(q).show(); + } else { + $(q).hide(); + } + }); +}; + +// ─── Merge / Copy Lists ────────────────────────────────────────────────────── + +/** + * Merge (copy) tracks from one playlist into another. + * If source === dest, deduplicates instead. + * If dest === -1, creates a new playlist copy. + * @param {string} source - Source list ID + * @param {string} dest - Destination list ID (or -1 for new) + * @param {string} sourceName - Display name of the source list + */ +firetable.actions.mergeLists = function (source, dest, sourceName) { + if (source === dest) { + firetable.actions.removeDupesFromQueue(); + return; + } + if (dest == -1) { + var newname = firetable.utilities.format_date(Date.now()) + " Copy of " + sourceName; + dest = ftapi.actions.createList(newname); + $("#listpicker").append(''); + } + ftapi.actions.mergeLists(source, dest, function () { + $("#mergeCompleted").show(); + $("#mergeHappening").hide(); + }); +}; + +// ─── Queue From Link (Drag & Drop URLs) ───────────────────────────────────── + +/** + * Parse a YouTube or SoundCloud URL and add the track to the queue. + * Called by the LinkGrabber when a URL is dragged onto the queue area. + * @param {string} link - Full URL + */ +firetable.actions.queueFromLink = function (link) { + if (link.match(/youtube.com\/watch/)) { + firetable.debug && console.log("yt"); + var therealid = getQueryStringValue(link, "v"); + if (therealid) { + youtubeAPIReady(function () { + gapi.client.youtube.videos.list({ + id: therealid, + part: 'snippet', + maxResults: 1 + }).execute(function (response) { + firetable.debug && console.log('queue from link:', response); + if (response.result && response.result.items && response.result.items.length) { + var item = response.result.items[0]; + var parsed = firetable.utilities.parseArtistTitle( + item.snippet.title, + item.snippet.channelTitle.replace(" - Topic", "") + ); + firetable.actions.queueTrack(item.id, parsed.artist + " - " + parsed.title, MEDIA_YOUTUBE); + } + }); + }); + } + } else if (link.match(/soundcloud.com/)) { + firetable.debug && console.log("sc"); + firetable.actions.resolveSCLink(link, function (tracks) { + if (tracks) { + var parsed = firetable.utilities.parseArtistTitle(tracks.title, tracks.user.username); + firetable.actions.queueTrack(tracks.id, parsed.artist + " - " + parsed.title, MEDIA_SOUNDCLOUD); + } + }); + } +}; + +// ─── SoundCloud Resolution ─────────────────────────────────────────────────── + +/** + * Resolve a SoundCloud URL to track/playlist metadata via proxy. + * @param {string} link - Full SoundCloud URL + * @param {Function} callback - Called with the resolved response object + */ +firetable.actions.resolveSCLink = function (link, callback) { + var importantStuff = link.replace("https://soundcloud.com/", "").replace("http://soundcloud.com/", ""); + $.ajax({ + url: SC_RESOLVE_URL + importantStuff, + type: 'GET', + dataType: 'json', + success: function (res) { + console.log(res); + callback(res.response); + } + }); +}; + +/** + * Generic SoundCloud API GET request via proxy. + * @param {string} type - Resource type (e.g. 'playlists') + * @param {string} q - Query/ID + * @param {Function} callback - Called with the response + */ +firetable.actions.scGet = function (type, q, callback) { + $.ajax({ + url: SC_PROXY_URL + "?type=" + type + "&q=" + q, + type: 'GET', + dataType: 'json', + success: function (res) { + console.log(res); + callback(res.response); + } + }); +}; + +// ─── Playlist Import ───────────────────────────────────────────────────────── + +/** + * Import a full playlist from YouTube or SoundCloud into a new local list. + * YouTube playlists are paginated (50 items per page). + * @param {string} id - Playlist/set ID + * @param {string} name - Display name for the new local list + * @param {number} type - MEDIA_YOUTUBE (1) or MEDIA_SOUNDCLOUD (2) + */ +firetable.actions.importList = function (id, name, type) { + $("#overlay").removeClass('show'); + $("#importResults").html(""); + $("#plMachine").val(""); + + if (type === MEDIA_YOUTUBE) { + var finalList = []; + + var fetchPage = function (pageToken) { + youtubeAPIReady(function () { + var params = { + playlistId: id, + maxResults: IMPORT_PAGE_SIZE, + part: "snippet" + }; + if (pageToken) params.pageToken = pageToken; + + gapi.client.youtube.playlistItems.list(params).execute(function (response) { + if (response.items && response.items.length) { + for (var idx = 0; idx < response.items.length; idx++) { + finalList.push(response.items[idx]); + } + } + if (response.nextPageToken) { + fetchPage(response.nextPageToken); + } else { + // All pages fetched — create the list + firetable.debug && console.log(finalList); + var listid = ftapi.actions.createList(name); + $("#listpicker").append(''); + for (var i = 0; i < finalList.length; i++) { + var goodTitle = finalList[i].snippet.title; + if (goodTitle !== "Private video" && goodTitle !== "Deleted video") { + ftapi.actions.addToList(MEDIA_YOUTUBE, goodTitle, finalList[i].snippet.resourceId.videoId, listid); + } + } + } + }); + }); + }; + fetchPage(); // start with first page + + } else if (type === MEDIA_SOUNDCLOUD) { + firetable.actions.scGet('playlists', id, function (listinfo) { + firetable.debug && console.log('sc tracks:', listinfo.tracks); + var listid = ftapi.actions.createList(name); + $("#listpicker").append(''); + for (var i = 0; i < listinfo.tracks.length; i++) { + var goodTitle; + if (listinfo.tracks[i].title) { + var parsed = firetable.utilities.parseArtistTitle(listinfo.tracks[i].title, listinfo.tracks[i].user.username); + goodTitle = parsed.artist + " - " + parsed.title; + } else { + goodTitle = "Unknown"; + } + ftapi.actions.addToList(MEDIA_SOUNDCLOUD, goodTitle, listinfo.tracks[i].id, listid); + } + }); + } +}; + +// ─── Dubtrack Import ───────────────────────────────────────────────────────── + +/** + * Import tracks from the parsed Dubtrack export file. + * Expects firetable.dtImportList and firetable.dtImportName to be populated + * by dubtrackImportFileSelect(). + */ +firetable.actions.dubtrackImport = function () { + $("#importDubResults").html("importing (0/" + firetable.dtImportList.length + ")..."); + $("#dubimportButton").hide(); + var listid = ftapi.actions.createList(firetable.dtImportName); + var name = firetable.dtImportName; + $("#listpicker").append(''); + + var trackarray = firetable.dtImportList; + for (var e = 0; e < trackarray.length; e++) { + var thetype = trackarray[e].type === "soundcloud" ? MEDIA_SOUNDCLOUD : MEDIA_YOUTUBE; + var numbo = e + 1; + $("#importDubResults").html("importing (" + numbo + "/" + firetable.dtImportList.length + ")..."); + if (numbo === firetable.dtImportList.length) { + $("#importDubResults").html("Import complete! You can now select another file if you'd like to do another!"); + } + ftapi.actions.addToList(thetype, trackarray[e].name, trackarray[e].cid, listid); + } +}; + +/** + * Parse a Dubtrack HTML export file and prepare it for import. + * Populates firetable.dtImportName and firetable.dtImportList. + * @param {Event} evt - File input change event + */ +firetable.ui.dubtrackImportFileSelect = function (evt) { + var file = evt.target.files[0]; + var reader = new FileReader(); + reader.readAsText(file); + reader.onload = function (event) { + try { + var allthestuff = event.currentTarget.result; + firetable.dtImportName = firetable.ui.strip(allthestuff.split('

    ')[1].split('

    ')[0]); + var hams = allthestuff.split('
  • (.*)<\/li>/gm; + var matches = thingsRegex.exec(hams[i]); + firetable.dtImportList.push({ + type: matches[2], + cid: matches[4], + name: firetable.ui.strip(matches[5]) + }); + } + if (firetable.dtImportList.length) { + $("#importDubResults").text("Ok... import " + firetable.dtImportName + " (" + firetable.utilities.pluralize(firetable.dtImportList.length, "track") + ")?"); + $("#dubimportButton").show(); + } else { + $("#importDubResults").text("ERROR... NO TRAX?"); + $("#dubimportButton").hide(); + } + } catch (e) { + console.log(e); + $("#importDubResults").text("ERROR"); + $("#dubimportButton").hide(); + } + }; +}; + +// ─── Tag Editing ───────────────────────────────────────────────────────────── + +/** + * Show the tag editor prompt on a history item. + * @param {string} songid - data-key of the history item + * @param {string} tag - Current "Artist - Title" string + */ +firetable.actions.editTagsPrompt = function (songid, tag, anchorEl) { + var popoverEl = document.getElementById('tagEditorPopover'); + if (popoverEl.matches(':popover-open')) popoverEl.hidePopover(); + $('.pvbar.editing').removeClass('editing'); + var $pvbar = $('.pvbar[data-key="' + songid + '"]').first(); + $pvbar.addClass('editing'); + firetable.editingPvbar = $pvbar; + $(popoverEl).find('.tagMachine').val(tag); + popoverEl.style.visibility = 'hidden'; + popoverEl.showPopover(); + firetable.ui.positionPopover(anchorEl || $pvbar.find('.edittags')[0], popoverEl, document.getElementById('tagEditorArrow'), 'bottom').then(function () { + $(popoverEl).find('.tagMachine')[0].focus(); + }); + firetable.debug && console.log('edit tags song id:', songid); +}; + +// ─── Playlist Event Binding ────────────────────────────────────────────────── + +/** + * Set up the playlistChanged event handler and queue-related UI bindings. + * Called once from firetable.ui.init(). + */ +firetable.ui.setupPlaylistEvents = function () { + + // ── Sortable drag-and-drop queue ── + $('#mainqueue').sortable({ + start: function (event, ui) { + ui.item.data('start_pos', ui.item.index()); + }, + update: function () { + firetable.debug && console.log("UPDATE"); + firetable.actions.updateQueue(); + } + }); + + // ── Playlist changed: re-render the queue ── + ftapi.events.on("playlistChanged", function (okdata, listID) { + firetable.queue = okdata; + $('#mainqueue').html(""); + + for (var key in okdata) { + if (!okdata.hasOwnProperty(key)) continue; + var thisone = okdata[key]; + var $newli = $playlistItemTemplate.clone(); + var psign = (key === firetable.preview) ? "" : ""; + + $newli.attr('id', "pvbar" + key) + .attr("data-key", key) + .attr("data-type", thisone.type) + .attr("data-cid", thisone.cid); + + // Album art thumbnail + var artUrl = thisone.type === MEDIA_YOUTUBE + ? 'https://i.ytimg.com/vi/' + thisone.cid + '/mqdefault.jpg' + : ''; + if (artUrl) $newli.find('.q-art').css('background-image', 'url(' + artUrl + ')'); + + // Preview button + $newli.find('.previewicon').attr('id', "pv" + key).on('click', function () { + firetable.actions.pview( + $(this).closest('.pvbar').attr('data-key'), + false, + $(this).closest('.pvbar').attr('data-type') + ); + }).html(psign); + + // Track title + $newli.find('.listwords').html(thisone.name); + + // Bump to top + $newli.find('.bumpsongs').on('click', function () { + firetable.actions.bumpSongInQueue($(this).closest('.pvbar').attr('data-key')); + }); + + // Move to bottom + $newli.find('.bottomsongs').on('click', function () { + var oldID = $(this).closest('.pvbar').attr('data-key'); + ftapi.actions.moveTrackToBottom(oldID, function (newID) { + if (firetable.preview && firetable.preview === oldID) { + firetable.preview = newID; + $("#pv" + newID).html(""); + } + }); + }); + + // Flagged track warning icon + if (thisone.flagged) { + var flagLabel = "broken"; + var flagIcon = "warning"; + if (thisone.flagged.code === 7) { + flagLabel = "age restricted"; + } else if (thisone.flagged.code === 8) { + flagLabel = "broken (manual)"; + } else if (thisone.flagged.code === 9) { + flagLabel = "low audio quality"; + flagIcon = "disc_full"; + } else if (thisone.flagged.code === 10) { + flagLabel = "offtheme"; + flagIcon = "flag"; + } + $newli.find('.track-warning') + .html(' ' + flagIcon + ' ') + .prop('title', 'Flagged as ' + flagLabel + ' on ' + firetable.utilities.format_date(thisone.flagged.date) + '. Click to remove flag.') + .on('click', function () { + ftapi.actions.unflagTrack($(this).closest('.pvbar').attr('data-key')); + $(this).html(""); + }); + } + + // Delete button + $newli.find('.deletesong').on('click', function () { + firetable.actions.deleteSong($(this).closest('.pvbar').attr('data-key')); + }); + + // Edit tags button + $newli.find('.edittags').on('click', function () { + var popoverEl = document.getElementById('tagEditorPopover'); + var $pvbar = $(this).closest('.pvbar'); + if (popoverEl.matches(':popover-open') && firetable.editingPvbar && firetable.editingPvbar.is($pvbar)) { + popoverEl.hidePopover(); + } else { + firetable.actions.editTagsPrompt( + $pvbar.attr('data-key'), + $pvbar.find('.listwords').text(), + this + ); + } + }); + + // Close editor button + $newli.find('.closeeditor').on('click', function () { + document.getElementById('tagEditorPopover').hidePopover(); + }); + + if (!ftapi.isMod) $newli.find('.edittags, .closeeditor').hide(); + + // Add-to-playlist button + $newli.find('.histeal').on('click', function () { + var $btn = $(this); + var $pvbar = $btn.closest('.pvbar'); + var btnCid = $pvbar.attr('data-cid'); + var btnType = $pvbar.attr('data-type'); + var btnTitle = firetable.utilities.htmlEscape($pvbar.find('.listwords').text()); + + if (firetable.stealSourceBtn && firetable.stealSourceBtn.is($btn) && !$("#stealContain").is(':hidden')) { + $btn.removeClass('on'); + firetable.stealSourceBtn = null; + firetable.stealTarget = null; + $("#stealContain").hide(); + return; + } + + ftapi.lookup.allLists(function (allPlaylists) { + $("#stealpicker").html( + '' + + '' + ); + for (var key in allPlaylists) { + if (allPlaylists.hasOwnProperty(key)) { + $("#stealpicker").append( + '' + ); + } + } + if (firetable.stealSourceBtn) firetable.stealSourceBtn.removeClass('on'); + $("#grab").removeClass('on'); + firetable.stealSourceBtn = $btn; + firetable.stealTarget = { cid: btnCid, type: btnType, title: btnTitle }; + $btn.addClass('on'); + var stealContainEl = document.getElementById('stealContain'); + stealContainEl.style.visibility = 'hidden'; + $("#stealContain").show(); + firetable.ui.positionPopover($btn[0], stealContainEl, document.getElementById('stealArrow'), 'left'); + }); + }); + + $('#mainqueue').append($newli); + } + }); + + // ── Queue filter input ── + $("#queueFilter").on("change paste keyup", function () { + firetable.actions.filterQueue($(this).val()); + }); + + // ── Shuffle button ── + $("#shuffleQueue").bind("click", firetable.actions.shuffleQueue); + + // ── Add-to-queue toggle ── + $("#addToQueueBttn").bind("click", function () { + $("#mainqueuestuff").css("display", "none"); + $("#filterMachine").css("display", "none"); + $("#addbox").css("display", "flex"); + $("#cancelqsearch").show(); + $("#qControlButtons").hide(); + $("#plmanager").css("display", "none"); + }); + + // ── Cancel search / back to queue ── + $("#cancelqsearch").bind("click", function () { + $("#mainqueuestuff").css("display", "block"); + $("#filterMachine").css("display", "block"); + $("#cancelqsearch").hide(); + $("#qControlButtons").show(); + $("#addbox").css("display", "none"); + firetable.utilities.cancelSearchPreview(); + }); + + // ── Create new playlist ── + $("#plmaker").bind("keyup", function (e) { + if (e.which !== 13) return; + var val = $(this).val(); + if (val) { + var listid = ftapi.actions.createList(val); + $("#listpicker").append(''); + $("#listpicker").val(listid).change(); + ftapi.actions.switchList(listid); + } + }); + + // ── Delete playlist ── + $("#pldeleteButton").bind("click", function () { + var val = $("#deletepicker").val(); + firetable.debug && console.log('playlist delete:', val); + if (ftapi.users[ftapi.uid] && ftapi.users[ftapi.uid].selectedList === val) { + $("#listpicker").val("0").change(); + } + ftapi.actions.deleteList(val); + $("#pdopt" + val).remove(); + $("#overlay").removeClass('show'); + }); + + // ── Import launcher ── + $("#plimportLauncher").bind("click", function () { + $("#overlay").addClass('show'); + $(".modalThing").removeClass('show'); + $('#importPromptBox').addClass('show'); + }); + + // ── Delete launcher ── + $("#pldeleteLauncher").bind("click", function () { + ftapi.lookup.allLists(function (allPlaylists) { + $("#deletepicker").html(""); + for (var key in allPlaylists) { + if (allPlaylists.hasOwnProperty(key)) { + $("#deletepicker").append(''); + } + } + $("#overlay").addClass('show'); + $(".modalThing").removeClass('show'); + $('#deletePromptBox').addClass('show'); + }); + }); + + // ── Dubtrack import file select ── + $('#dubtrackimportfile').bind('change', firetable.ui.dubtrackImportFileSelect); + $("#importDubGo").bind("click", firetable.actions.dubtrackImport); + + // ── Merge lists UI ── + $("#mergeLists").bind("click", function () { + var $this = $(this); + var isHidden = $("#mergeContain").is(":hidden"); + if (isHidden) { + ftapi.lookup.allLists(function (allPlaylists) { + $("#mergepicker").html(''); + $("#mergepicker2").html(''); + for (var key in allPlaylists) { + if (allPlaylists.hasOwnProperty(key)) { + $("#mergepicker").append(''); + $("#mergepicker2").append(''); + } + } + if (ftapi.users[ftapi.uid] && ftapi.users[ftapi.uid].selectedList) { + $("#mergepicker").val(ftapi.users[ftapi.uid].selectedList).change(); + $("#mergepicker2").val(-1).change(); + } + $("#mergeContain").show(); + $this.addClass('on'); + }); + } else { + $("#mergeContain").hide(); + $this.removeClass('on'); + } + }); + $("#startMerge").bind("click", function () { + var source = $("#mergepicker").val(); + var sourceName = $("#mergepicker option:selected").text(); + var dest = $("#mergepicker2").val(); + $("#mergeSetup").hide(); + $("#mergeHappening").show(); + firetable.debug && console.log(sourceName + " -> " + $("#mergepicker2 option:selected").text()); + firetable.actions.mergeLists(source, dest, sourceName); + }); + $("#mergeOK").bind("click", function () { + $("#mergeSetup").show(); + $("#mergeCompleted").hide(); + $("#mergeHappening").hide(); + $("#mergeContain").hide(); + }); + + // ── Tag editing (Enter in .tagMachine) ── + $(document).on("keyup", ".tagMachine", function (e) { + if (e.which !== 13) return; + var $pvbar = firetable.editingPvbar; + if (!$pvbar || !$pvbar.length) return; + var val = $(this).val(); + if (!val) return; + var yargo = val.split(" - "); + if (!yargo[0] || !yargo[1]) { + alert("check yr tags"); + } else { + ftapi.actions.editTag( + $pvbar.attr('data-type'), + $pvbar.attr('data-cid'), + val, + $pvbar.attr('data-histid') + ); + document.getElementById('tagEditorPopover').hidePopover(); + } + }); + + // ── Tag editor popover cleanup on auto-dismiss ── + document.getElementById('tagEditorPopover').addEventListener('toggle', function (e) { + if (e.newState === 'closed' && firetable.editingPvbar) { + firetable.editingPvbar.removeClass('editing'); + firetable.editingPvbar = null; + } + }); +}; diff --git a/js/popover.js b/js/popover.js new file mode 100644 index 0000000..b5c9204 --- /dev/null +++ b/js/popover.js @@ -0,0 +1,35 @@ +// ─── Floating UI Popover Positioning ──────────────────────────────────────── +// Shared utility: positions a floating element anchored to a reference element. +// arrowEl is optional (pass null for no arrow). + +firetable.ui.positionPopover = function (anchorEl, floatingEl, arrowEl) { + var middleware = [ + FloatingUIDOM.offset(8), + FloatingUIDOM.flip(), + FloatingUIDOM.shift({ padding: 8 }) + ]; + if (arrowEl) { + middleware.push(FloatingUIDOM.arrow({ element: arrowEl })); + } + FloatingUIDOM.computePosition(anchorEl, floatingEl, { + placement: 'bottom-start', + strategy: 'fixed', + middleware: middleware + }).then(function (pos) { + floatingEl.style.left = pos.x + 'px'; + floatingEl.style.top = pos.y + 'px'; + + if (arrowEl && pos.middlewareData.arrow) { + var ax = pos.middlewareData.arrow.x; + var ay = pos.middlewareData.arrow.y; + var staticSide = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[pos.placement.split('-')[0]]; + Object.assign(arrowEl.style, { + left: ax != null ? ax + 'px' : '', + top: ay != null ? ay + 'px' : '', + right: '', + bottom: '', + [staticSide]: '-4px' + }); + } + }); +}; diff --git a/js/room.js b/js/room.js new file mode 100644 index 0000000..af0feee --- /dev/null +++ b/js/room.js @@ -0,0 +1,702 @@ +/** + * room.js — Room state event handlers. + * + * Handles all ftapi events related to room state: + * - newSong: Load new track, start countdown/progress, Last.fm scrobble timer + * - newProduce / newHistory: Render discover/history items + * - editedHistory: Update edited tag in history list + * - modCheck: Show/hide mod-only edit buttons + * - newTheme: Display the current room theme + * - tagUpdate: Update now-playing metadata when tags are edited + * - screenStateChanged / danceStateChanged: Toggle video screen / dance mode + * - lightsChanged / colorsChanged: Festive lights CSS + accent color + * - tableChanged: Render the DJ table (up to 4 spots) + * - waitlistChanged: Render the waitlist + * - spotlightStateChanged / playLimitChanged: Highlight active DJ, update limit + * - banListChanged: Render active suspensions in mod panel + * + * BUG FIXES applied in this file: + * - `firstpart == "sc"` → `firstpart = "sc"` (was comparison, should be assignment) + * This caused SoundCloud tracks in history/discover to always get "yt" prefix. + */ + +// ─── Festive Lights CSS Generator ──────────────────────────────────────────── + +/** + * Build the "; +} + +// ─── History Item Renderer ──────────────────────────────────────────────────── + +/** + * Build and attach a history/discover item to the DOM. + * Extracted from the nearly-identical newProduce / newHistory handlers. + * + * @param {Object} data - Track data from ftapi + * @param {jQuery} $template - Cloneable template element + * @param {string} containerSel - jQuery selector for the target container + * @param {string} [artClass] - CSS class for the album art element ('discart' or 'histart') + */ +function renderHistoryItem(data, $template, containerSel, artClass) { + if (data.img === "img/idlogo.png" && ftconfigs.defaultAlbumArtUrl.length) { + data.img = ftconfigs.defaultAlbumArtUrl; + } + + // BUG FIX: was `firstpart == "sc"` (comparison), now `firstpart = "sc"` (assignment) + var firstpart = "yt"; + if (data.type == 2) firstpart = "sc"; + + var pkey = firstpart + "cid" + data.cid; + var $histItem = $template.clone(); + + $histItem.attr('id', "pvbar" + pkey) + .attr("data-key", pkey) + .attr("data-histid", data.histID) + .attr("data-cid", data.cid) + .attr("data-type", data.type); + + // Preview button + $histItem.find('.previewicon').attr('id', "pv" + pkey).on('click', function () { + firetable.actions.pview( + $(this).closest('.pvbar').attr('data-key'), + true, + $(this).closest('.pvbar').attr('data-type'), + true + ); + }); + + // Track link + $histItem.find('.histlink').attr('id', data.histID).text(data.artist + " - " + data.title); + $histItem.find('.tracklink-btn').attr('href', data.url || ''); + + // Edit tags button (mod only) + $histItem.find('.edittags').on('click', function () { + var popoverEl = document.getElementById('tagEditorPopover'); + var $pvbar = $(this).closest('.pvbar'); + if (popoverEl.matches(':popover-open') && firetable.editingPvbar && firetable.editingPvbar.is($pvbar)) { + popoverEl.hidePopover(); + } else { + firetable.actions.editTagsPrompt( + $pvbar.attr('data-key'), + data.artist + " - " + data.title, + this + ); + } + }); + try { + if (!ftapi.isMod) $histItem.find('.edittags').hide(); + } catch (e) { + console.log(e); + } + + // Metadata + $histItem.find('.histdj').text(data.dj); + $histItem.find('.hist-dj-avatar') + .css('background-image', 'url(' + firetable.utilities.avatarURL(data.djid || data.dj, data.dj, '40x40') + ')') + .attr('data-label', data.dj); + $histItem.find('.histdate').text(firetable.utilities.format_date(data.when)); + $histItem.find('.histtime').text(firetable.utilities.format_time(data.when)); + + // Add-to-playlist button (shows playlist picker dropdown) + $histItem.find('.histeal').attr('id', "apv" + data.type + data.cid).on('click', function () { + var $btn = $(this); + var btnCid = $(this).closest('.pvbar').attr('data-cid'); + var btnType = $(this).closest('.pvbar').attr('data-type'); + var btnTitle = firetable.utilities.htmlEscape($(this).closest('.pvbar').find('.histlink').text()); + + // If this button's picker is already open, close it + if (firetable.stealSourceBtn && firetable.stealSourceBtn.is($btn) && !$("#stealContain").is(':hidden')) { + $btn.removeClass('on'); + firetable.stealSourceBtn = null; + firetable.stealTarget = null; + $("#stealContain").hide(); + return; + } + + ftapi.lookup.allLists(function (allPlaylists) { + $("#stealpicker").html( + '' + + '' + ); + for (var key in allPlaylists) { + if (allPlaylists.hasOwnProperty(key)) { + $("#stealpicker").append( + '' + ); + } + } + // Mark any previously open source button as off + if (firetable.stealSourceBtn) firetable.stealSourceBtn.removeClass('on'); + $("#grab").removeClass('on'); + + firetable.stealSourceBtn = $btn; + firetable.stealTarget = { cid: btnCid, type: btnType, title: btnTitle }; + $btn.addClass('on'); + + var stealContainEl = document.getElementById('stealContain'); + stealContainEl.style.visibility = 'hidden'; + $("#stealContain").show(); + firetable.ui.positionPopover($btn[0], stealContainEl, document.getElementById('stealArrow'), 'left'); + }); + }); + + // Album art + if (artClass) { + $histItem.find('.' + artClass).css('background-image', 'url(' + data.img + ')'); + } + + if (containerSel === "#thehistory") { + var dateKey = firetable.utilities.format_date(data.when); + var when = new Date(data.when); + var dateLabel = when.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + var $dayGroup = $('#thehistory .hist-day-group[data-date="' + dateKey + '"]'); + if ($dayGroup.length === 0) { + $dayGroup = $( + '
    ' + + '
    ' + dateLabel + '
    ' + + '
    ' + + '
    ' + ); + $dayGroup.prependTo('#thehistory'); + } + var timeStr = firetable.utilities.format_time(data.when); + var $avatar = $histItem.find('.hist-dj-avatar').detach(); + var $entry = $('
    '); + $('' + timeStr + '').appendTo($entry); + $avatar.appendTo($entry); + $histItem.appendTo($entry); + $entry.prependTo($dayGroup.find('.hist-day-items')); + } else { + $histItem.prependTo(containerSel); + } +} + +// ─── Room Event Binding ────────────────────────────────────────────────────── + +/** + * Set up all room-state ftapi event handlers. + * Called once from firetable.ui.init(). + */ +firetable.ui.setupRoomEvents = function () { + + // ── Discover (Recently Played by Others) ── + var $discoverItem = $('#thediscovers .pvbar').remove(); + ftapi.events.on('newProduce', function (data) { + renderHistoryItem(data, $discoverItem, "#thediscovers", "discart"); + }); + + // ── History (Your Play History) ── + var $historyItem = $('#thehistory .pvbar').remove(); + + function applyHistoryFilter() { + var q = ($("#histFilter").val() || "").toLowerCase().trim(); + $("#thehistory .hist-entry").each(function () { + var $pvbar = $(this).find('.pvbar'); + var text = ($pvbar.find('.histlink').text() + " " + $pvbar.find('.histdj').text()).toLowerCase(); + $(this).toggle(q.length === 0 || text.indexOf(q) !== -1); + }); + $("#thehistory .hist-day-group").each(function () { + var hasVisible = $(this).find('.hist-entry:visible').length > 0; + $(this).toggle(!q.length || hasVisible); + }); + } + + $(document).on('input.histfilter', '#histFilter', applyHistoryFilter); + + $(document).on('click', '#thehistory .hist-day-header', function () { + $(this).closest('.hist-day-group').toggleClass('collapsed'); + }); + + ftapi.events.on('newHistory', function (data) { + renderHistoryItem(data, $historyItem, "#thehistory", "histart"); + applyHistoryFilter(); + }); + + // ── Edited History (tag correction) ── + ftapi.events.on('editedHistory', function (data) { + console.log("HIST EDIT", data); + $("#" + data.histID).text(data.artist + " - " + data.title); + }); + + // ── Mod Check (show edit buttons for mods) ── + ftapi.events.on('modCheck', function (data) { + if (data) $(".edittags").show(); + }); + + // ── Theme ── + function checkThemeTicker() { + var el = document.getElementById("currentTheme"); + var container = el.parentElement; + el.classList.remove('is-ticker'); + if (el.scrollWidth > container.offsetWidth) { + var originalHTML = el.innerHTML; + el.innerHTML = '' + originalHTML + ' · ' + originalHTML + ' · '; + el.classList.add('is-ticker'); + } + } + + ftapi.events.on("newTheme", function (data) { + if (!data) { + $("#currentTheme").text("!suggest a theme"); + } else { + var txtOut = firetable.ui.strip(data); + txtOut = firetable.ui.textToLinks(txtOut, true); + txtOut = firetable.utilities.emojiShortnamestoUnicode(txtOut); + txtOut = txtOut.replace(/\`(.*?)\`/g, function (x) { + return "" + x.replace(/\`/g, "") + ""; + }); + $("#currentTheme").html(txtOut); + twemoji.parse(document.getElementById("currentTheme")); + } + setTimeout(checkThemeTicker, 50); + }); + + // ── Tag Update (metadata correction while song is playing) ── + ftapi.events.on("tagUpdate", function (data) { + firetable.debug && console.log("TAG UPDATE", data); + firetable.tagUpdate = data; + if (!firetable.song) return; + if (firetable.song.cid !== data.cid || !data.adamData.track_name) return; + + $("#track").text(firetable.ui.strip(data.adamData.track_name)); + $("#artist").text(firetable.ui.strip(data.adamData.artist)); + firetable.song.title = firetable.ui.strip(data.adamData.track_name); + firetable.song.artist = firetable.ui.strip(data.adamData.artist); + + var nicename = firetable.song.djname; + var showPlaycount = data.adamData.playcount && data.adamData.playcount > 0; + + if (data.adamData.last_play) { + $("#lastPlay").text("last " + firetable.utilities.format_date(data.adamData.last_play) + " by " + data.adamData.last_play_dj); + } else { + $("#lastPlay").text(""); + } + if (data.adamData.first_play) { + $("#firstPlay").text("first " + firetable.utilities.format_date(data.adamData.first_play) + " by " + data.adamData.first_play_dj); + } else { + $("#firstPlay").text(""); + } + + var doTheScrollThing = firetable.utilities.isChatPrettyMuchAtBottom(); + if (showPlaycount) { + var count = data.adamData.playcount; + $("#playCount").text(firetable.utilities.pluralize(count, "play")); + $(".npmsg" + data.cid).last().find(".npmsg").html( + 'DJ ' + nicename + ' started playing ' + data.adamData.track_name + ' by ' + data.adamData.artist + '
    This song has been played ' + firetable.utilities.pluralize(count, "time") + '.' + ); + } else { + $("#playCount").text(""); + $(".npmsg" + data.cid).last().find(".npmsg").html( + 'DJ ' + nicename + ' started playing ' + data.adamData.track_name + ' by ' + data.adamData.artist + '' + ); + } + if (doTheScrollThing) firetable.utilities.scrollToBottom(); + }); + + // ── New Song ── + ftapi.events.on('newSong', function (data) { + $("#playCount, #lastPlay, #firstPlay").text(""); + window.dispatchEvent(new Event('resize')); + $("#cloud_with_rain, #fire").removeClass("on"); + $("#timr").countdown("destroy"); + + if (firetable.moveBar != null) { + clearInterval(firetable.moveBar); + firetable.moveBar = null; + } + + if (data.image === "img/idlogo.png" && ftconfigs.defaultAlbumArtUrl.length) { + data.image = ftconfigs.defaultAlbumArtUrl; + } + $("#prgbar").css("background", "#151515"); + + // Check if tagUpdate has pre-corrected metadata for this track + var showPlaycount = false; + if (firetable.tagUpdate && data.cid === firetable.tagUpdate.cid && firetable.tagUpdate.adamData.track_name) { + data.title = firetable.tagUpdate.adamData.track_name; + data.artist = firetable.tagUpdate.adamData.artist; + if (firetable.tagUpdate.adamData.last_play) { + $("#lastPlay").text("last " + firetable.utilities.format_date(firetable.tagUpdate.adamData.last_play) + " by " + firetable.tagUpdate.adamData.last_play_dj); + } + if (firetable.tagUpdate.adamData.first_play) { + $("#firstPlay").text("first " + firetable.utilities.format_date(firetable.tagUpdate.adamData.first_play) + " by " + firetable.tagUpdate.adamData.first_play_dj); + } + if (firetable.tagUpdate.adamData.playcount > 0) { + showPlaycount = true; + $("#playCount").text(firetable.utilities.pluralize(firetable.tagUpdate.adamData.playcount, "play")); + } + } + + // Update now-playing UI + $("#track").text(firetable.ui.strip(data.title)); + $("#artist").text(firetable.ui.strip(data.artist)); + $("#songlink").attr("href", data.url); + $("#albumArt").css("background-image", "url(" + data.image + ")"); + + // Calculate elapsed time + var nownow = Date.now(); + var timeSince = nownow - data.started; + if (timeSince <= 0) timeSince = 0; + var secSince = Math.floor(timeSince / 1000); + var timeLeft = data.duration - secSince; + firetable.song = data; + firetable.debug && console.log("NEW TRACK", data); + + // ── Last.fm scrobble timer ── + if (firetable.lastfm.timer != null) { + clearTimeout(firetable.lastfm.timer); + firetable.lastfm.timer = null; + } + if (firetable.lastfm.sk) { + firetable.lastfm.duration = Math.floor(timeLeft); + firetable.lastfm.songStart = Math.floor(Date.now() / 1000); + firetable.lastfm.timer = setTimeout(function () { + firetable.lastfm.timer = null; + firetable.lastfm.scrobble(); + }, (timeLeft * 1000) - 3000); + firetable.lastfm.nowPlaying(); + } + + // ── Platform-specific UI + playback ── + if (data.type === MEDIA_YOUTUBE) { + $("#scScreen").hide(); + $("#songlink").html(''); + + if (firetable.ytLoaded && !firetable.preview) { + if (firetable.scLoaded) firetable.scwidget.pause(); + if (!firetable.disableMediaPlayback) player.loadVideoById(data.cid, secSince, "large"); + var thevol = $("#slider").slider("value"); + player.setVolume(thevol); + firetable.scwidget.setVolume(thevol); + } + + } else if (data.type === MEDIA_SOUNDCLOUD) { + $("#scScreen").show(); + $("#songlink").html(''); + + var biggerImg = data.image.replace('-large', '-t500x500'); + firetable.scImg = biggerImg; + $("#albumArt").css("background-image", "url(" + biggerImg + ")"); + try { setup(biggerImg); } catch (e) { + firetable.debug && console.log('big image error:', e); + } + + if (firetable.scLoaded && !firetable.preview) { + if (firetable.ytLoaded) player.stopVideo(); + firetable.scSeek = timeSince; + if (!firetable.disableMediaPlayback) { + firetable.scwidget.load(SC_API_TRACK_URL + data.cid, { + auto_play: true, + single_active: false, + callback: function () { + var vol = localStorage[STORAGE.volume]; + player.setVolume(vol); + firetable.scwidget.setVolume(vol); + } + }); + } + } + } + + // ── Now-playing chat message ── + if (data.cid !== 0) { + var nicename = data.djid; + if (ftapi.users[data.djid] && ftapi.users[data.djid].username) { + nicename = ftapi.users[data.djid].username; + } + + if (firetable.nonpmsg) { + firetable.nonpmsg = false; + } else { + var doTheScrollThing = firetable.utilities.isChatPrettyMuchAtBottom(); + var npmsgHTML; + if (showPlaycount) { + npmsgHTML = '
    DJ ' + nicename + ' started playing ' + data.title + ' by ' + data.artist + '
    This song has been played ' + firetable.utilities.pluralize(firetable.tagUpdate.adamData.playcount, "time") + '.
    '; + } else { + npmsgHTML = '
    DJ ' + nicename + ' started playing ' + data.title + ' by ' + data.artist + '
    '; + } + $("#chats").append(npmsgHTML); + if (doTheScrollThing) firetable.utilities.scrollToBottom(); + firetable.lastChatPerson = false; + firetable.lastChatId = false; + } + } + + // ── Countdown timer ── + $("#timr").countdown({ + until: timeLeft, + compact: true, + description: "", + format: "MS" + }); + + // ── Progress bar ── + firetable.moveBar = setInterval(function () { + var now = Date.now(); + var sofar = now - firetable.song.started; + var pcnt = (sofar / (firetable.song.duration * 1000)) * 100; + $("#prgbar").css("background", "linear-gradient(90deg, " + firetable.color + " " + pcnt + "%, #151515 " + pcnt + "%)"); + }, PROGRESS_BAR_INTERVAL); + }); + + // ── Screen State ── + ftapi.events.on("screenStateChanged", function (data) { + firetable.debug && console.log('thescreen:', data); + firetable.screenSyncPos = data; + if (firetable.screenControl === "sync") { + if (data) firetable.utilities.screenDown(); + else firetable.utilities.screenUp(); + } + firetable.ui.updateScreenBtn(firetable.screenControl); + }); + + // ── Dance Mode ── + ftapi.events.on("danceStateChanged", function (data) { + firetable.debug && console.log('dance check:', data); + if (data) $("#deck").addClass("dance"); + else $("#deck").removeClass("dance"); + }); + + // ── Festive Lights ── + ftapi.events.on("lightsChanged", function (data) { + firetable.debug && console.log('lights check:', data); + $('.festiveLights').remove(); + if (data) { + firetable.lights = true; + var rgb = firetable.utilities.hexToRGB(firetable.color); + $("head").append(buildFestiveLightsCSS(rgb)); + } else { + firetable.lights = false; + } + }); + + // ── Waitlist ── + ftapi.events.on("waitlistChanged", function (data) { + firetable.waitlistData = data; + + // Restore any users previously hidden due to waitlist + $('#allUsers .prson[data-waitlist-hidden]').show().removeAttr('data-waitlist-hidden'); + + var html = ''; + var hasEntries = false; + if (data) { + var countr = 1; + for (var key in data) { + if (data.hasOwnProperty(key)) { + hasEntries = true; + var userId = data[key].id; + var removeMe = data[key].removeAfter + ? 'departure_board' : ''; + + // Look up role icon from live user data + var userInfo = ftapi.users && ftapi.users[userId]; + var roleicon = 'person'; + var roleiconclass = 'material-symbols-outlined'; + if (userInfo) { + if (userInfo.mod) { roleicon = 'shield'; roleiconclass = 'material-symbols-outlined-outlined'; } + if (userInfo.supermod) { roleicon = 'local_police'; roleiconclass = 'material-symbols-outlined'; } + if (userInfo.hostbot) { roleicon = 'smart_toy'; roleiconclass = 'material-symbols-outlined'; } + } + + html += '
    ' + + '' + countr + '' + + '' + + firetable.utilities.htmlEscape(data[key].name) + removeMe + + '' + + '' + roleicon + '' + + '
    ' + + '
    '; + + // Hide this user from the regular user list + $('#user' + userId).attr('data-waitlist-hidden', '1').hide(); + countr++; + } + } + } + var $wl = $('#usersWaitlist'); + if (hasEntries) { + $wl.html('
    queue_music Up next
    ' + html).addClass('has-entries'); + } else { + $wl.removeClass('has-entries').empty(); + } + }); + + // ── DJ Table ── + ftapi.events.on("tableChanged", function (data) { + firetable.tableData = data; + var html = ""; + if (data) { + var countr = 0; + for (var key in data) { + if (data.hasOwnProperty(key)) { + var isSelf = data[key].id === ftapi.uid; + var ownUser = ftapi.uid && ftapi.users && ftapi.users[ftapi.uid]; + var isMod = ownUser && (ownUser.mod || ownUser.supermod); + var showBtn = isSelf || isMod; + var btnIcon = isSelf ? 'close' : 'person_remove'; + var btnTitle = isSelf ? 'Step down' : 'Remove from deck'; + var actionBtn = showBtn + ? '' + : ''; + var departureIndicator = data[key].removeAfter + ? 'departure_board' + : ''; + html += '
    ' + + '
    ' + + '
    ' + + '
    ' + data[key].name + '
    ' + + departureIndicator + actionBtn + + '
    ' + data[key].plays + '/' + + firetable.playlimit + '
    '; + countr++; + } + } + // Fill empty spots + if (countr < 4) { + html += '
    '; + countr++; + for (var i = countr; i < 4; i++) { + html += '
     
    '; + } + } + } else { + html += '
    '; + for (var i = 0; i < 3; i++) { + html += '
     
    '; + } + } + $("#deck").html(html); + $("#deck").off('click.addme').on('click.addme', '.addmeButt', function () { + ftapi.actions.sendBotCommand("!addme"); + }); + $("#deck").off('click.remove').on('click.remove', '.deckRemoveBtn', function () { + var userId = $(this).data('userid'); + if (userId === ftapi.uid) { + ftapi.actions.sendBotCommand("!removeme"); + } else { + var tableKey = $(this).data('tablekey'); + firebase.app("firetable").database().ref("table/" + tableKey).remove(); + if (firetable.waitlistData) { + for (var wkey in firetable.waitlistData) { + if (firetable.waitlistData.hasOwnProperty(wkey) && firetable.waitlistData[wkey].id === userId) { + firebase.app("firetable").database().ref("waitlist/" + wkey).remove(); + break; + } + } + } + } + }); + + // Highlight current DJ + for (var i = 0; i < 4; i++) { + if (i === firetable.playdex) { + $("#avtr" + i).addClass("animate"); + $("#djthing" + i).addClass("djActive"); + } else { + $("#avtr" + i).removeClass("animate"); + $("#djthing" + i).removeClass("djActive"); + } + } + }); + + // Re-render deck when user data arrives (mod status affects button visibility) + ftapi.events.on("usersChanged", function () { + if (firetable.tableData !== undefined) { + ftapi.events.emit("tableChanged", firetable.tableData); + } + }); + + // ── Spotlight (Active DJ Index) ── + ftapi.events.on("spotlightStateChanged", function (data) { + firetable.playdex = data; + for (var i = 0; i < 4; i++) { + if (i === data) { + $("#avtr" + i).addClass("animate"); + $("#djthing" + i).addClass("djActive"); + } else { + $("#avtr" + i).removeClass("animate"); + $("#djthing" + i).removeClass("djActive"); + } + } + }); + + // ── Play Limit ── + ftapi.events.on("playLimitChanged", function (data) { + firetable.playlimit = data; + for (var i = 0; i < 4; i++) { + $("#plimit" + i).text(data); + } + }); + + // ── Ban List ── + ftapi.events.on("banListChanged", function (data) { + $("#activeSuspentions").html(""); + for (var key in data) { + if (data[key]) { + ftapi.lookup.userByName(key, function (person) { + $("#activeSuspentions").append( + '
    ' + person.username + '
    ' + + '
    ' + ); + }); + } + } + }); + + // ── Colors Changed (Accent Color) ── + ftapi.events.on("colorsChanged", function (data) { + firetable.debug && console.log("COLOR CHANGE!", data); + + firetable.color = data.color; + firetable.countcolor = data.txt; + if (data.color === "#fff" || data.color === "#7f7f7f") { + firetable.color = firetable.orange; + firetable.countcolor = "#fff"; + } + + // Update custom color styles + $('.customColorStyles').remove(); + $("head").append( + "" + ); + + // Rebuild festive lights with new color + $('.festiveLights').remove(); + if (firetable.lights) { + var rgb = firetable.utilities.hexToRGB(firetable.color); + $("head").append(buildFestiveLightsCSS(rgb)); + } + }); +}; diff --git a/js/search.js b/js/search.js new file mode 100644 index 0000000..fef7b02 --- /dev/null +++ b/js/search.js @@ -0,0 +1,285 @@ +/** + * search.js — YouTube and SoundCloud track & playlist search. + * + * Handles: + * - Track search via YouTube Data API v3 + SoundCloud API + * - Direct link detection (youtube.com/watch, soundcloud.com/…) + * - Playlist/import search via YouTube + SoundCloud + * - Dubtrack import file parsing + import + * - Search source toggle buttons (#ytsearchSelect, #scsearchSelect) + * - Import source toggle tabs + */ + +// ─── YouTube API Helper ────────────────────────────────────────────────────── + +/** + * Call the YouTube Data API v3 directly via AJAX. + * Avoids gapi client entirely — no discovery loading, no Promise conflicts. + * @param {string} resource - API resource path (e.g. 'search', 'videos', 'playlistItems') + * @param {Object} params - Query parameters (key is added automatically) + * @param {Function} callback - Called with the raw API response object + */ +function ytAPI(resource, params, callback) { + params.key = ftconfigs.youtubeKey; + $.ajax({ + url: 'https://www.googleapis.com/youtube/v3/' + resource, + data: params, + type: 'GET', + dataType: 'json', + success: callback, + error: function (xhr) { + var msg = (xhr.responseJSON && xhr.responseJSON.error) + ? xhr.responseJSON.error.code + ' ' + xhr.responseJSON.error.message + : xhr.status + ' ' + xhr.statusText; + console.error('YouTube API error:', msg, xhr.responseJSON || xhr.responseText); + callback({ items: [] }); + } + }); +} + +/** + * Extract a query-string parameter value from a URL string. + * @param {string} str - Full URL + * @param {string} key - Parameter name + * @returns {string} Decoded parameter value (or empty string) + */ +function getQueryStringValue(str, key) { + return unescape( + str.replace( + new RegExp("^(?:.*[&\\?]" + escape(key).replace(/[\.\+\*]/g, "\\$&") + "(?:\\=([^&]*))?)?.*$", "i"), + "$1" + ) + ); +} + +// ─── Track Search Setup ────────────────────────────────────────────────────── + +/** + * Bind the track search (#qsearch) and import search (#plMachine) keyup handlers. + * Called once from firetable.ui.init(). + */ +firetable.ui.setupSearchEvents = function () { + var $searchItemTemplate = $('#searchResults .pvbar').remove(); + + // ── Track Search (Enter in #qsearch) ── + $("#qsearch").bind("keyup", function (e) { + if (e.which !== 13) return; + var txt = $("#qsearch").val(); + if (!txt) return; + + if (firetable.searchSelectsChoice === MEDIA_YOUTUBE) { + // ── YouTube Track Search ── + var showYTResults = function (response) { + firetable.debug && console.log('queue search:', response); + $('#searchResults').html(""); + firetable.utilities.cancelSearchPreview(); + + var srchItems = response.items; + $.each(srchItems, function (index, item) { + var thecid = item.kind === "youtube#searchResult" ? item.id.videoId : item.id; + var parsed = firetable.utilities.parseArtistTitle( + item.snippet.title, + item.snippet.channelTitle.replace(" - Topic", "") + ); + var vidTitle = parsed.artist + " - " + parsed.title; + var pkey = "ytcid" + thecid; + + var $srli = $searchItemTemplate.clone(); + $srli.attr('id', "pvbar" + pkey) + .attr("data-key", pkey) + .attr("data-cid", thecid); + $srli.find('.previewicon').attr('id', "pv" + pkey).on('click', function () { + firetable.actions.pview($(this).closest('.pvbar').attr('data-key'), true, MEDIA_YOUTUBE); + }); + $srli.find('.listwords').html(vidTitle); + $srli.find('.queuetrack').on('click', function () { + firetable.actions.queueTrack( + $(this).closest('.pvbar').attr('data-cid'), + firetable.utilities.htmlEscape($(this).closest('.pvbar').find('.listwords').text()), + MEDIA_YOUTUBE + ); + }); + $("#searchResults").append($srli); + }); + }; + + // Check if it's a direct YouTube URL + var directLink = false; + var thecid = false; + if (txt.match(/youtube.com\/watch/)) { + thecid = getQueryStringValue(txt, "v"); + if (thecid) directLink = true; + } + + if (directLink) { + firetable.debug && console.log("direct yt link found"); + ytAPI('videos', { id: thecid, part: 'snippet', maxResults: 1 }, showYTResults); + } else { + $('#searchResults').html("Searching..."); + ytAPI('search', { q: txt, type: 'video', part: 'snippet', maxResults: SEARCH_MAX_RESULTS }, showYTResults); + } + + } else if (firetable.searchSelectsChoice === MEDIA_SOUNDCLOUD) { + // ── SoundCloud Track Search ── + var q = txt; + + var showSCResults = function (tracks) { + firetable.debug && console.log('sc tracks:', tracks); + $('#searchResults').html(""); + firetable.utilities.cancelSearchPreview(); + + $.each(tracks, function (index, item) { + var parsed = firetable.utilities.parseArtistTitle(item.title, item.user.username); + var vidTitle = parsed.artist + " - " + parsed.title; + var pkey = "sccid" + item.id; + + var $srli = $searchItemTemplate.clone(); + $srli.attr('id', "pvbar" + pkey) + .attr("data-key", pkey) + .attr("data-cid", item.id); + $srli.find('.previewicon').attr('id', "pv" + pkey).on('click', function () { + firetable.actions.pview($(this).closest('.pvbar').attr('data-key'), true, MEDIA_SOUNDCLOUD); + }); + $srli.find('.listwords').html(vidTitle); + $srli.find('.queuetrack').on('click', function () { + firetable.actions.queueTrack( + $(this).closest('.pvbar').attr('data-cid'), + firetable.utilities.htmlEscape($(this).closest('.pvbar').find('.listwords').text()), + MEDIA_SOUNDCLOUD + ); + }); + $("#searchResults").append($srli); + }); + }; + + var directLink = false; + if (q.match(/:\/\/soundcloud\.com\//)) directLink = true; + + $('#searchResults').html("Searching..."); + if (directLink) { + firetable.debug && console.log("sc direct link found"); + firetable.actions.resolveSCLink(q, function (item) { + var items = []; + if (item.kind === "track") items.push(item); + showSCResults(items); + }); + } else { + SC.get('/tracks', { q: q }).then(function (tracks) { + showSCResults(tracks); + }); + } + } + }); + + // ── Search Source Toggle Buttons ── + $("#ytsearchSelect").bind("click", function () { + $("#scsearchSelect").removeClass("on"); + $(this).addClass("on"); + firetable.searchSelectsChoice = MEDIA_YOUTUBE; + }); + $("#scsearchSelect").bind("click", function () { + $("#ytsearchSelect").removeClass("on"); + $(this).addClass("on"); + firetable.searchSelectsChoice = MEDIA_SOUNDCLOUD; + }); + + // ── Import Source Toggle Tabs ── + $("#ytimportchoice").bind("click", function () { + firetable.debug && console.log("yt import"); + firetable.importSelectsChoice = MEDIA_YOUTUBE; + }); + $("#scimportchoice").bind("click", function () { + firetable.debug && console.log("sc import"); + firetable.importSelectsChoice = MEDIA_SOUNDCLOUD; + }); + $("#dtimportchoice").bind("click", function () { + firetable.debug && console.log("dt import"); + firetable.importSelectsChoice = 3; // Dubtrack + }); + $("#importSources .tab").bind("click", function () { + if (firetable.importSelectsChoice === 3) { + $("#importDubContent").show(); + $("#importContent").hide(); + } else { + $("#importDubContent").hide(); + $("#importContent").show(); + } + $(this).siblings().removeClass('on'); + $(this).addClass('on'); + }); + + // ── Playlist/Import Search (#plMachine) ── + $("#plMachine").bind("keyup", function (e) { + if (e.which !== 13) return; + var val = $("#plMachine").val(); + if (!val) return; + $("#importResults").html(""); + $("#plMachine").val(""); + var searchFrom = firetable.importSelectsChoice; + + if (searchFrom === MEDIA_YOUTUBE) { + // ── YouTube Playlist Search ── + var listID; + var directLink = false; + + // Check for direct playlist URL + if (val.match(/youtube.com\/watch/) || val.match(/youtube.com\/playlist/)) { + listID = getQueryStringValue(val, "list"); + if (listID) directLink = true; + } + + if (directLink) { + ytAPI('playlists', { id: listID, part: 'snippet' }, function (response) { + if (response.items && response.items.length === 1) { + var item = response.items[0]; + $("#importResults").append( + '
    ' + item.snippet.title + ' by ' + item.snippet.channelTitle + '
    ' + + ' ' + + '
    ' + ); + } + }); + } else { + ytAPI('search', { q: val, type: 'playlist', part: 'snippet', maxResults: SEARCH_MAX_RESULTS }, function (response) { + firetable.debug && console.log('import search results:', response); + $.each(response.items || [], function (index, item) { + $("#importResults").append( + '
    ' + item.snippet.title + ' by ' + item.snippet.channelTitle + '
    ' + + ' ' + + '
    ' + ); + }); + }); + } + + } else if (searchFrom === MEDIA_SOUNDCLOUD) { + // ── SoundCloud Playlist Search ── + if (val.match(/.*\/\/soundcloud\.com\/.*\/sets\/.*/)) { + // Direct set URL + firetable.actions.resolveSCLink(val, function (item) { + if (item && item.sharing === "public" && item.kind === "playlist") { + $("#importResults").append( + '
    ' + item.title + ' by ' + item.user.username + ' (' + firetable.utilities.pluralize(item.track_count, "song") + ')
    ' + + ' ' + + '
    ' + ); + } + }); + } else { + // Keyword search for playlists + SC.get('/playlists', { q: val }).then(function (lists) { + for (var i = 0; i < lists.length; i++) { + var item = lists[i]; + if (item.sharing === "public") { + $("#importResults").append( + '
    ' + item.title + ' by ' + item.user.username + ' (' + firetable.utilities.pluralize(item.track_count, "song") + ')
    ' + + ' ' + + '
    ' + ); + } + } + }); + } + } + }); +}; diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..e60ec1b --- /dev/null +++ b/js/state.js @@ -0,0 +1,222 @@ +/** + * state.js — Central application state object for firetable. + * + * This is the single source of truth for all runtime state. Every property + * is documented with its type and purpose. Other modules read/write these + * properties directly via the global `firetable` object. + * + * Also sets up the chat scroll container, idle-detection, and throws early + * if config.js is missing. + */ + +// ─── Config Guard ──────────────────────────────────────────────────────────── +if (typeof ftconfigs === "undefined") { + throw "config.js is missing! Copy config.js.example and rename to config.js. Edit this file and add your own app's information."; +} + +// ─── Application State ────────────────────────────────────────────────────── +var firetable = { + + // ── Session ── + /** @type {boolean} Has firetable.init() been called? */ + started: false, + /** @type {boolean} Is the current user authenticated? */ + loggedIn: false, + /** @type {string|null} Current user's UID (set after login) */ + uid: null, + /** @type {string|null} Current user's display name */ + uname: null, + + // ── Avatars ── + /** @type {string} Robohash set name (e.g. "set1", "set2") */ + avatarset: "set1", + /** @type {string} Active avatar style key e.g. "robohash:set1" or "dicebear:pixel-art" */ + avatarStyle: "robohash:set1", + + // ── Preview State ── + /** @type {number} Elapsed preview bar counter (seconds, in 0.2 increments) */ + pvCount: 0, + /** @type {string|false} Song key currently being previewed, or false */ + preview: false, + /** @type {number|null} setInterval ID for the preview progress bar */ + movePvBar: null, + /** @type {number|null} setTimeout ID for auto-stopping preview after 30s */ + ptimeout: null, + + // ── Playback ── + /** @type {number} Index of the currently-active DJ seat (0-3) */ + playdex: 0, + /** @type {number|null} setInterval ID for the song progress bar */ + moveBar: null, + /** @type {Object|null} Current song data {cid, type, artist, title, started, duration, …} */ + song: null, + /** @type {boolean} Play notification sound on @-mentions? */ + playBadoop: true, + + // ── Idle ── + /** @type {boolean} Is the current user idle / tab hidden? */ + idle: false, + /** @type {number|null} Timestamp of last idle-state change */ + idleChanged: null, + + // ── Display Preferences ── + /** @type {boolean} Show inline images in chat? (typo preserved: "sbhowImages" in original) */ + showImages: false, + /** @type {string} Screen control mode: "sync" | "on" | "off" */ + screenControl: "sync", + /** @type {boolean} Are festive lights enabled? */ + lights: false, + /** @type {boolean} Server-side screen-up/down position (used when screenControl == "sync") */ + screenSyncPos: false, + /** @type {boolean} Should media playback be completely disabled? */ + disableMediaPlayback: false, + /** @type {boolean} Show avatar images in chat? */ + showAvatars: true, + + // ── SoundCloud ── + /** @type {number|false} Seek position (ms) to set when SC widget starts playing */ + scSeek: false, + /** @type {Object|null} SoundCloud Widget API instance */ + scwidget: null, + /** @type {string} URL of the current SoundCloud track's large artwork */ + scImg: "", + /** @type {boolean|null} Has the SoundCloud widget finished loading? */ + scLoaded: null, + + // ── YouTube ── + /** @type {boolean|null} Has the YouTube IFrame API finished loading? */ + ytLoaded: null, + + // ── Desktop Notifications ── + /** @type {boolean} Send desktop notifications on @-mentions? */ + desktopNotifyMentions: false, + + // ── Theme / Colours ── + /** @type {string} The default brand orange (never changes) */ + orange: COLOR_ORANGE, + /** @type {string} Current room accent colour (hex) — set by colorsChanged event */ + color: COLOR_ORANGE, + /** @type {string} Current text colour for accent backgrounds */ + countcolor: "#fff", + + // ── Playlist / Queue ── + /** @type {Object|false} Current playlist data keyed by song ID */ + queue: false, + /** @type {string|null} Which playlist panel is currently visible */ + listShowing: null, + /** @type {number} Play limit per DJ turn */ + playlimit: 2, + + // ── DOM / Parser ── + /** @type {DOMParser|null} Shared DOMParser for stripping HTML */ + parser: null, + + // ── Tag Editing ── + /** @type {string|null} Song ID currently being tag-edited */ + songToEdit: null, + /** @type {Object|null} Latest tag update data from server */ + tagUpdate: null, + + // ── Search Source Toggle ── + /** @type {number} 1 = YouTube, 2 = SoundCloud — tracks the "Add" search toggle */ + searchSelectsChoice: MEDIA_YOUTUBE, + /** @type {number} 1 = YouTube, 2 = SoundCloud, 3 = Dubtrack — tracks the "Import" toggle */ + importSelectsChoice: MEDIA_YOUTUBE, + + // ── Dubtrack Import ── + /** @type {string|null} Name parsed from Dubtrack export file */ + dtImportName: null, + /** @type {Array} Track list parsed from Dubtrack export file */ + dtImportList: [], + + // ── Chat State ── + /** @type {string|false} UID of the last person who sent a chat (for message grouping) */ + lastChatPerson: false, + /** @type {string|false} Chat ID of the last grouped message block */ + lastChatId: false, + /** @type {boolean} Suppress the first "now playing" chat message on page load */ + nonpmsg: true, + + // ── Mod Tools ── + /** @type {Object|null} Ban update subscription data */ + superCopBanUpdates: null, + + // ── Login ── + /** @type {string|null} Cached login form HTML (preserved across auth state changes) */ + loginForm: null, + + // ── Emoji ── + /** @type {Object|null} Map of shortname → unicode emoji character */ + emojiMap: null, + /** @type {boolean} Has the emoji picker been twemoji-parsed? */ + pickerInit: false, + + // ── @-Mention Autocomplete ── + /** @type {boolean} Is the user currently in @-mention mode? */ + atLand: false, + /** @type {Array} All usernames available for @-mention */ + atUsers: [], + /** @type {Array} Filtered usernames matching current @-string */ + atUsersFiltered: [], + /** @type {string} Characters typed after the @ symbol so far */ + atString: "", + + // ── Debug ── + /** @type {boolean} Enable verbose console logging */ + debug: false +}; + +// ─── Version ───────────────────────────────────────────────────────────────── +firetable.version = "01.10.8"; + +// ─── Chat Scroll Container ────────────────────────────────────────────────── +/** Native scroll container wrapper preserving the legacy chatScroll API shape. */ +var chatScrollElement = document.getElementById('chatsWrap'); +var chatScroll = chatScrollElement ? { + el: chatScrollElement, + contentEl: chatScrollElement, + contentWrapperEl: chatScrollElement, + getScrollElement: function () { + return chatScrollElement; + } +} : null; + +if (chatScroll) { + chatScroll.getScrollElement().addEventListener('scroll', function () { + if (firetable.utilities.isChatPrettyMuchAtBottom()) { + $('#morechats').removeClass('show'); + } + }, { passive: true }); +} + +// ─── Global Player Reference ──────────────────────────────────────────────── +/** @type {YT.Player} YouTube IFrame Player instance (set in onYouTubeIframeAPIReady) */ +var player; +/** @type {jQuery} Cloned template element for playlist queue items */ +var $playlistItemTemplate; + +// ─── Idle Detection ───────────────────────────────────────────────────────── +/** + * Tracks mouse/keyboard activity and fires idle/active events. + * Sends idle status to the server via ftapi so other users see you as away. + */ +var idlejs = new IdleJs({ + idle: IDLE_TIMEOUT, + events: ['mousemove', 'keydown', 'mousedown', 'touchstart'], + onIdle: function () { + ftapi.actions.changeIdleStatus(true, 1); + }, + onActive: function () { + ftapi.actions.changeIdleStatus(false, 1); + }, + onHide: function () { + ftapi.actions.changeIdleStatus(true, 1); + firetable.debug && console.log("Tab hidden — marking idle"); + }, + onShow: function () { + ftapi.actions.changeIdleStatus(false, 1); + }, + keepTracking: true, + startAtIdle: false +}); +idlejs.start(); diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..4fa8feb --- /dev/null +++ b/js/ui.js @@ -0,0 +1,835 @@ +/** + * ui.js — Miscellaneous UI bindings, settings, modals, and the LinkGrabber. + * + * This module contains: + * - firetable.ui.hidePlayerControls / showPlayerControls + * - firetable.ui.LinkGrabber (drag-and-drop link detection) + * - firetable.ui.usertab1 / usertab2 + * - firetable.ui.loginEventsInit / loginEventsDestroy / loginLinkToggle + * - firetable.ui.initSettings(): restore settings from localStorage + * - firetable.ui.setupMiscEvents(): modals, sortable, volume, grab, settings toggles, etc. + * - firetable.ui.init(): orchestrator that calls all setup*Events functions + * + * Depends on: constants.js, state.js, helpers.js, player.js + */ + +// ─── Player Controls Visibility ────────────────────────────────────────────── + +// ─── Floating UI Popover Positioning ───────────────────────────────────────── +firetable.ui.positionPopover = function (anchorEl, floatingEl, arrowEl, preferredPlacement) { + var ARROW_SIZE = 8; // px — must match .ft-arrow width/height in CSS + + // Reset to known origin before computePosition measures the element + floatingEl.style.top = '0'; + floatingEl.style.left = '0'; + + // When a preferred placement is given, use flip() to respect it but fall + // back to the opposite side if there's no space. + // Otherwise use autoPlacement() to always pick the side with most space. + var options = { + strategy: 'fixed', + middleware: [ + FloatingUIDOM.offset(ARROW_SIZE), + preferredPlacement + ? FloatingUIDOM.flip() + : FloatingUIDOM.autoPlacement({ padding: 8 }), + FloatingUIDOM.shift({ padding: 8 }), + arrowEl ? FloatingUIDOM.arrow({ element: arrowEl, padding: 8 }) : null + ] + }; + if (preferredPlacement) { + options.placement = preferredPlacement; + } + + return FloatingUIDOM.computePosition(anchorEl, floatingEl, options).then(function (pos) { + floatingEl.style.left = pos.x + 'px'; + floatingEl.style.top = pos.y + 'px'; + + if (arrowEl && pos.middlewareData.arrow) { + var ax = pos.middlewareData.arrow.x; + var ay = pos.middlewareData.arrow.y; + // staticSide is the edge the arrow pokes out of — opposite the placement side + var staticSide = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[pos.placement.split('-')[0]]; + Object.assign(arrowEl.style, { + left: ax != null ? ax + 'px' : '', + top: ay != null ? ay + 'px' : '', + right: '', + bottom: '', + [staticSide]: -(ARROW_SIZE / 2) + 'px' + }); + } + + floatingEl.style.visibility = 'visible'; + }); +}; + +/** + * Hide player/preview controls (when media playback is disabled). + */ +firetable.ui.hidePlayerControls = function () { + $("head").append( + "" + ); +}; + +/** + * Show player/preview controls (re-enables media playback UI). + */ +firetable.ui.showPlayerControls = function () { + $(".playerControlsHider").remove(); +}; + +// ─── User Tabs (All Users / Waitlist) ──────────────────────────────────────── + +/** Show the "All Users" panel, hide the "Waitlist" panel */ +firetable.ui.usertab1 = function () { + $("#allusersWrap").css("display", "block"); + $("#justwaitWrap").css("display", "none"); + $("#usertabs").find(".on").removeClass("on"); + $("#label1").addClass("on"); +}; + +/** Show the "Waitlist" panel, hide the "All Users" panel */ +firetable.ui.usertab2 = function () { + $("#usertabs").find(".on").removeClass("on"); + $("#label2").addClass("on"); + $("#allusersWrap").css("display", "none"); + $("#justwaitWrap").css("display", "block"); +}; + +// ─── LinkGrabber (Drag-and-Drop Track Detection) ───────────────────────────── + +/** + * Intercepts drag-and-drop events over the queue panel. + * Creates a transparent