2750 lines
82 KiB
HTML
2750 lines
82 KiB
HTML
<!DOCTYPE html>
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>VJ</title>
|
|
|
|
<link rel="stylesheet" href="./css/client.css" />
|
|
<link rel="stylesheet" href="./css/dot-menu.css" />
|
|
<link rel="stylesheet" href="./css/post-composer.css" />
|
|
<link rel="stylesheet" href="./css/resizable-columns.css" />
|
|
|
|
<!-- Initialize theme BEFORE any components load -->
|
|
<script>
|
|
(function () {
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme === 'dark') {
|
|
document.documentElement.classList.add('dark-mode');
|
|
if (document.body) {
|
|
document.body.classList.add('dark-mode');
|
|
}
|
|
}
|
|
})();
|
|
</script>
|
|
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
|
|
|
<!-- SVG.js library (required by HamburgerMorphing) -->
|
|
<script src="./js/vendor/svg.min.js"></script>
|
|
<script src="./js/vendor/dash.all.min.js"></script>
|
|
<script src="./js/vendor/hls.min.js"></script>
|
|
|
|
<style>
|
|
#divBody {
|
|
flex-direction: column !important;
|
|
flex-wrap: nowrap !important;
|
|
align-items: stretch !important;
|
|
justify-content: flex-start !important;
|
|
align-content: stretch !important;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#vjApp {
|
|
--vj-col-1: 280px;
|
|
--vj-col-2: 320px;
|
|
--vj-col-3: 280px;
|
|
--vj-gutter-size: 10px;
|
|
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: grid;
|
|
grid-template-columns:
|
|
var(--vj-col-1)
|
|
var(--vj-gutter-size)
|
|
var(--vj-col-2)
|
|
var(--vj-gutter-size)
|
|
var(--vj-col-3);
|
|
width: 100%;
|
|
overflow: hidden;
|
|
align-items: stretch;
|
|
}
|
|
|
|
#vjStreamCard,
|
|
#vjChatPanel,
|
|
#musicSidebar {
|
|
min-width: 0;
|
|
}
|
|
|
|
#musicSidebar {
|
|
grid-column: 1;
|
|
min-width: 220px;
|
|
}
|
|
|
|
#vjStreamCard {
|
|
grid-column: 3;
|
|
min-width: 280px;
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
padding: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
overflow: auto;
|
|
}
|
|
|
|
#vjChatPanel {
|
|
grid-column: 5;
|
|
min-width: 220px;
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
padding: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
#videoStream {
|
|
width: 100%;
|
|
aspect-ratio: 16 / 9;
|
|
background: #000;
|
|
border-radius: 8px;
|
|
display: block;
|
|
}
|
|
|
|
#divVideoControls,
|
|
#divStreamControls,
|
|
#divLiveStreamControls {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
.streamInput {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
margin-top: 2px;
|
|
margin-bottom: 2px;
|
|
padding: 6px 8px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--text-color);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.streamSection {
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
padding: 8px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.streamSectionSub {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
background: rgba(255, 255, 255, 0.015);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.streamMetaRow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.streamMetaLabel,
|
|
.streamInfoLabel {
|
|
font-size: 11px;
|
|
letter-spacing: 0.05em;
|
|
color: var(--primary-color);
|
|
text-transform: uppercase;
|
|
min-width: 92px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.streamMetaValue {
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
flex: 1;
|
|
min-width: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.streamMetaRow .streamInput {
|
|
flex: 1;
|
|
min-width: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.streamInfoBlock {
|
|
font-size: 13px;
|
|
padding: 8px;
|
|
background: rgba(255,255,255,0.02);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
color: var(--text-color);
|
|
line-height: 1.4;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.streamInfoRow {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.streamInfoLabel {
|
|
min-width: 78px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.streamInfoValue {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.streamInfoCopy {
|
|
padding: 2px 8px;
|
|
font-size: 11px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.streamFieldDisplay {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
margin-top: 2px;
|
|
margin-bottom: 2px;
|
|
padding: 6px 8px;
|
|
border: 1px solid transparent;
|
|
border-radius: 6px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
color: var(--text-color);
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
cursor: text;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
min-height: 30px;
|
|
}
|
|
|
|
.streamFieldDisplay:hover {
|
|
border-color: var(--border-color);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
}
|
|
|
|
.streamSummaryCollapsed {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
#imgStreamImagePreview {
|
|
width: 100%;
|
|
aspect-ratio: 16 / 9;
|
|
object-fit: cover;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-color);
|
|
cursor: pointer;
|
|
}
|
|
|
|
#imgStreamImagePreview.empty {
|
|
object-fit: contain;
|
|
opacity: 0.6;
|
|
padding: 10px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.streamImageEditorRow {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.streamImageEditorRow .streamInput {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
#btnStreamImageScreenshot {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
#inputStreamImage.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.btnVideoCtrl {
|
|
font-size: 20px;
|
|
line-height: 1;
|
|
padding: 6px 10px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-color);
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btnVideoCtrl.active {
|
|
background: var(--accent-color);
|
|
color: #fff;
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
#divAutoAnnounceCard {
|
|
padding: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.autoAnnounceHeader {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.autoAnnounceRow {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.autoAnnounceRow input[type="checkbox"] {
|
|
accent-color: var(--accent-color);
|
|
cursor: pointer;
|
|
margin: 0;
|
|
}
|
|
|
|
.autoAnnounceCollapseBtn {
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-color);
|
|
color: var(--text-color);
|
|
border-radius: 6px;
|
|
min-width: 28px;
|
|
height: 28px;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
line-height: 1;
|
|
font-size: 14px;
|
|
padding: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#divAutoAnnounceCard.collapsed #divAutoAnnounceDetails {
|
|
display: none;
|
|
}
|
|
|
|
#divAutoAnnounceCard.collapsed .autoAnnounceCollapseBtn {
|
|
transform: rotate(-90deg);
|
|
}
|
|
|
|
|
|
#divChatComposer {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#divChatFeed {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
overflow: auto;
|
|
min-height: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
#musicSidebar {
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
padding: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
overflow: auto;
|
|
}
|
|
|
|
|
|
.vjGutter[data-gutter-index="0"] {
|
|
grid-column: 2;
|
|
}
|
|
|
|
.vjGutter[data-gutter-index="1"] {
|
|
grid-column: 4;
|
|
}
|
|
|
|
|
|
.musicSidebarTitle {
|
|
font-size: 12px;
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.musicSidebarTitleRow {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 6px;
|
|
}
|
|
|
|
.musicSectionCollapseBtn {
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-color);
|
|
color: var(--text-color);
|
|
border-radius: 6px;
|
|
min-width: 24px;
|
|
height: 24px;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
line-height: 1;
|
|
font-size: 12px;
|
|
padding: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.musicEpisodeStatusBadge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-top: 2px;
|
|
padding: 2px 6px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 999px;
|
|
font-size: 10px;
|
|
letter-spacing: 0.03em;
|
|
text-transform: uppercase;
|
|
width: fit-content;
|
|
color: var(--muted-color);
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
|
|
.musicEpisodeStatusBadge[data-status="live"] {
|
|
color: #6ef2a3;
|
|
border-color: rgba(110, 242, 163, 0.55);
|
|
background: rgba(110, 242, 163, 0.12);
|
|
}
|
|
|
|
.musicEpisodeStatusBadge[data-status="planned"] {
|
|
color: #7ed0ff;
|
|
border-color: rgba(126, 208, 255, 0.55);
|
|
background: rgba(126, 208, 255, 0.12);
|
|
}
|
|
|
|
.musicEpisodeStatusBadge[data-status="draft"] {
|
|
color: #f6d56f;
|
|
border-color: rgba(246, 213, 111, 0.5);
|
|
background: rgba(246, 213, 111, 0.12);
|
|
}
|
|
|
|
.musicEpisodeStatusBadge[data-status="ended"] {
|
|
color: #c9c9c9;
|
|
border-color: rgba(201, 201, 201, 0.45);
|
|
background: rgba(201, 201, 201, 0.1);
|
|
}
|
|
|
|
#musicImportSection {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
|
|
#musicImportDropZone {
|
|
position: relative;
|
|
border: 1px dashed var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
transition: border-color 120ms ease, background 120ms ease;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#musicImportDropZone.dragover {
|
|
border-color: var(--accent-color);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
#musicImportDropZone.processing {
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
#musicImportDropHint {
|
|
font-size: 11px;
|
|
color: var(--muted-color);
|
|
padding: 6px 8px 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#musicImportInput {
|
|
width: 100%;
|
|
min-height: 94px;
|
|
border: none;
|
|
border-radius: 0;
|
|
padding: 8px;
|
|
font-size: 12px;
|
|
background: transparent;
|
|
color: var(--primary-color);
|
|
outline: none;
|
|
resize: vertical;
|
|
font: inherit;
|
|
}
|
|
|
|
#musicImportProcessing {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--primary-color);
|
|
background: rgba(0, 0, 0, 0.45);
|
|
backdrop-filter: blur(1px);
|
|
pointer-events: none;
|
|
}
|
|
|
|
#musicImportProcessing.visible {
|
|
display: flex;
|
|
}
|
|
|
|
.musicImportSpinner {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
|
border-top-color: var(--primary-color);
|
|
animation: musicImportSpin 700ms linear infinite;
|
|
}
|
|
|
|
@keyframes musicImportSpin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
#musicImportStatus {
|
|
font-size: 11px;
|
|
color: var(--muted-color);
|
|
min-height: 16px;
|
|
}
|
|
|
|
#musicImportResults {
|
|
font-size: 11px;
|
|
color: var(--primary-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
#musicImportResults ul {
|
|
margin: 0;
|
|
padding-left: 16px;
|
|
max-height: 110px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.musicImportActions {
|
|
display: flex;
|
|
margin: 4px 0 2px;
|
|
}
|
|
|
|
#musicPlaylistProfile {
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
background-color: var(--secondary-color);
|
|
overflow: hidden;
|
|
margin-bottom: 10px;
|
|
min-height: 120px;
|
|
position: relative;
|
|
isolation: isolate;
|
|
}
|
|
|
|
#musicPlaylistProfile::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
background-image: var(--music-playlist-banner, none);
|
|
background-size: cover;
|
|
background-position: center;
|
|
background-repeat: no-repeat;
|
|
filter: grayscale(var(--image-grayscale));
|
|
pointer-events: none;
|
|
z-index: -1;
|
|
}
|
|
|
|
#musicPlaylistProfile::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.52));
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
|
|
#musicPlaylistProfile.hidden {
|
|
display: none;
|
|
}
|
|
|
|
#musicPlaylistProfileBanner {
|
|
display: none;
|
|
}
|
|
|
|
#musicPlaylistProfileBody {
|
|
padding: 8px;
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 8px;
|
|
align-items: start;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
#musicPlaylistProfileAvatar {
|
|
width: var(--avatar-size-lg);
|
|
height: var(--avatar-size-lg);
|
|
object-fit: cover;
|
|
border-radius: var(--avatar-radius);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
#musicPlaylistProfileMeta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
|
|
#musicPlaylistProfileTitleRow {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
#musicPlaylistProfileTitle {
|
|
font-size: 14px;
|
|
color: var(--primary-color);
|
|
font-weight: 600;
|
|
min-width: 0;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
flex: 1;
|
|
}
|
|
|
|
#musicPlaylistProfileCreator {
|
|
font-size: 11px;
|
|
color: var(--muted-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
text-align: right;
|
|
}
|
|
|
|
#musicPlaylistCreatorAvatar {
|
|
width: var(--avatar-size-lg);
|
|
height: var(--avatar-size-lg);
|
|
border-radius: var(--avatar-radius);
|
|
border: 1px solid var(--border-color);
|
|
object-fit: cover;
|
|
}
|
|
|
|
#musicPlaylistCreatorText {
|
|
max-width: 132px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
#musicPlaylistDescriptionText {
|
|
font-size: 12px;
|
|
color: var(--primary-color);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
#musicPlaylistProfileEditor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding-top: 2px;
|
|
}
|
|
|
|
#musicPlaylistProfileEditor.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.musicPlaylistProfileInput {
|
|
width: 100%;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 8px;
|
|
font-size: 12px;
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
outline: none;
|
|
font: inherit;
|
|
}
|
|
|
|
#musicPlaylistDescriptionInput {
|
|
min-height: 70px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.musicPlaylistList {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.musicPlaylistItem {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 8px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
color: var(--primary-color);
|
|
transition: border-color 0.2s, color 0.2s;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.musicPlaylistItem:hover,
|
|
.musicPlaylistItem.active {
|
|
border-color: var(--accent-color);
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.musicPlaylistMeta {
|
|
color: var(--muted-color);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.musicPlaylistItem.episodeEnded {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.musicPlaylistPrimary {
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
}
|
|
|
|
.musicPlaylistCover {
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 6px;
|
|
object-fit: cover;
|
|
border: 1px solid var(--border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.musicPlaylistText {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
flex: 1;
|
|
}
|
|
|
|
.musicPlaylistTitle {
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.musicPlaylistItemActions {
|
|
display: flex;
|
|
gap: 5px;
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
}
|
|
|
|
.musicMiniBtn {
|
|
min-width: 36px;
|
|
padding: 4px 6px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.musicPlaylistItemActions .dot-menu-trigger,
|
|
.musicResultActions .dot-menu-trigger,
|
|
.musicQueueItem .dot-menu-trigger,
|
|
.musicAlbumCardActions .dot-menu-trigger {
|
|
min-width: 28px;
|
|
width: 28px;
|
|
height: 24px;
|
|
padding: 0;
|
|
font-size: 16px;
|
|
line-height: 1;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 120ms ease;
|
|
}
|
|
|
|
.musicPlaylistItem:hover .dot-menu-trigger,
|
|
.musicResultItem:hover .dot-menu-trigger,
|
|
.musicQueueItem:hover .dot-menu-trigger,
|
|
.musicAlbumCard:hover .dot-menu-trigger,
|
|
.dot-menu-wrap.is-open .dot-menu-trigger {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
#musicSearchHeader {
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
padding: 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
#musicSearchForm {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 10px;
|
|
}
|
|
|
|
#musicSearchInput {
|
|
width: 100%;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 10px;
|
|
font-size: 14px;
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
outline: none;
|
|
}
|
|
|
|
#musicSearchInput:focus {
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
#musicStatusText {
|
|
color: var(--muted-color);
|
|
font-size: 12px;
|
|
}
|
|
|
|
#musicStatusText[data-type='ok'] {
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
#musicStatusText[data-type='error'] {
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
#musicResultsSection {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
#musicResultsList {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.musicResultItem,
|
|
.musicResultEmpty {
|
|
display: grid;
|
|
grid-template-columns: 44px 1fr auto auto;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.musicResultActions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
}
|
|
|
|
.musicResultItem:last-child,
|
|
.musicResultEmpty:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.musicResultItem {
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
position: relative;
|
|
}
|
|
|
|
.musicResultItem:hover {
|
|
background-color: var(--muted-color);
|
|
}
|
|
|
|
.musicResultItem.isReorderable {
|
|
cursor: grab;
|
|
}
|
|
|
|
.musicResultItem.isReorderable:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.musicResultItem.dragging {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.musicResultItem.dropBefore::before,
|
|
.musicResultItem.dropAfter::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 8px;
|
|
right: 8px;
|
|
height: 1px;
|
|
background: var(--accent-color);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.musicResultItem.dropBefore::before {
|
|
top: 0;
|
|
}
|
|
|
|
.musicResultItem.dropAfter::after {
|
|
bottom: 0;
|
|
}
|
|
|
|
.musicResultCover {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: var(--border-radius);
|
|
object-fit: cover;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.musicResultItem.isPlaying .musicResultCover {
|
|
filter: grayscale(var(--image-grayscale-hover));
|
|
}
|
|
|
|
#musicPlayerCover.isPlaying {
|
|
filter: grayscale(var(--image-grayscale-hover));
|
|
}
|
|
|
|
.musicAlbumCardImage.isPlaying {
|
|
filter: grayscale(var(--image-grayscale-hover));
|
|
}
|
|
|
|
.musicResultMeta {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.musicResultTitle,
|
|
.musicResultSubtitle,
|
|
.musicResultDuration {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.musicResultTitle {
|
|
font-size: 14px;
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.musicResultSubtitle,
|
|
.musicResultDuration,
|
|
.musicResultEmpty {
|
|
font-size: 12px;
|
|
color: var(--muted-color);
|
|
}
|
|
|
|
.musicAddBtn {
|
|
min-width: 40px;
|
|
padding: 5px 8px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.musicSectionHeader {
|
|
padding: 10px;
|
|
font-size: 11px;
|
|
letter-spacing: 0.08em;
|
|
color: var(--muted-color);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.musicSectionBlock {
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding: 10px;
|
|
}
|
|
|
|
.musicSectionBlock:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.musicArtistGrid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
gap: 8px;
|
|
}
|
|
|
|
.musicArtistStrip,
|
|
.musicAlbumStrip {
|
|
display: flex;
|
|
gap: 8px;
|
|
overflow-x: auto;
|
|
padding-bottom: 4px;
|
|
}
|
|
|
|
.musicArtistCardWrap {
|
|
width: 140px;
|
|
min-width: 140px;
|
|
flex: 0 0 140px;
|
|
}
|
|
|
|
.musicArtistCard,
|
|
.musicAlbumCard {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
cursor: pointer;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding: 8px;
|
|
text-align: left;
|
|
width: 140px;
|
|
min-width: 140px;
|
|
max-width: 140px;
|
|
box-sizing: border-box;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.musicArtistCard {
|
|
align-items: center;
|
|
text-align: center;
|
|
min-height: 186px;
|
|
}
|
|
|
|
.musicArtistCardImage,
|
|
.musicAlbumCardImage {
|
|
width: 100%;
|
|
aspect-ratio: 1 / 1;
|
|
border-radius: var(--border-radius);
|
|
object-fit: cover;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
.musicArtistCardName,
|
|
.musicAlbumCardTitle {
|
|
font-size: 12px;
|
|
color: var(--primary-color);
|
|
width: 100%;
|
|
max-width: 100%;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.musicArtistCardName {
|
|
display: block;
|
|
min-width: 0;
|
|
}
|
|
|
|
.musicAlbumCardMeta {
|
|
font-size: 11px;
|
|
color: var(--muted-color);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.musicAlbumCard {
|
|
min-height: 210px;
|
|
}
|
|
|
|
.musicAlbumCardActions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
}
|
|
|
|
.musicSectionDivider {
|
|
height: 1px;
|
|
margin: 6px 0;
|
|
background: var(--border-color);
|
|
}
|
|
|
|
.musicQueueItem {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 8px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
position: relative;
|
|
}
|
|
|
|
.musicQueueTrack {
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
}
|
|
|
|
.musicQueueCover {
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: var(--border-radius);
|
|
object-fit: cover;
|
|
border: 1px solid var(--border-color);
|
|
flex-shrink: 0;
|
|
background: var(--secondary-color);
|
|
filter: grayscale(var(--image-grayscale));
|
|
}
|
|
|
|
.musicQueueItem.active .musicQueueCover {
|
|
filter: grayscale(var(--image-grayscale-hover));
|
|
}
|
|
|
|
.musicQueueItem.isReorderable {
|
|
cursor: grab;
|
|
}
|
|
|
|
.musicQueueItem.isReorderable:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.musicQueueItem.dragging {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.musicQueueItem.dropBefore::before,
|
|
.musicQueueItem.dropAfter::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 8px;
|
|
right: 8px;
|
|
height: 1px;
|
|
background: var(--accent-color);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.musicQueueItem.dropBefore::before {
|
|
top: 0;
|
|
}
|
|
|
|
.musicQueueItem.dropAfter::after {
|
|
bottom: 0;
|
|
}
|
|
|
|
.musicQueueItem.active {
|
|
border-color: var(--accent-color);
|
|
color: var(--accent-color);
|
|
}
|
|
|
|
.musicQueueItem.afterCurrent {
|
|
border-top: 1px solid var(--accent-color);
|
|
margin-top: 6px;
|
|
padding-top: 10px;
|
|
}
|
|
|
|
.musicQueueMeta {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.musicQueueTitle,
|
|
.musicQueueSubtitle {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.musicQueueSubtitle {
|
|
color: var(--muted-color);
|
|
font-size: 11px;
|
|
}
|
|
|
|
#musicQueueActions {
|
|
display: flex;
|
|
gap: 5px;
|
|
}
|
|
|
|
#musicQueueList {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: auto;
|
|
}
|
|
|
|
#musicPlayerBar {
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
padding: 10px;
|
|
background: var(--secondary-color);
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-shrink: 0;
|
|
margin-top: auto;
|
|
}
|
|
|
|
#musicPlayerMeta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
#musicPlayerCover {
|
|
width: 52px;
|
|
height: 52px;
|
|
border-radius: var(--border-radius);
|
|
object-fit: cover;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
#musicPlayerTitle,
|
|
#musicPlayerArtist {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
#musicPlayerTitle {
|
|
font-size: 14px;
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
#musicPlayerArtist {
|
|
font-size: 12px;
|
|
color: var(--muted-color);
|
|
}
|
|
|
|
#musicPlayerControls {
|
|
display: flex;
|
|
gap: 5px;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.musicControlBtn {
|
|
min-width: 44px;
|
|
padding: 8px 10px;
|
|
}
|
|
|
|
#musicPlaybackModeBtn {
|
|
min-width: 52px;
|
|
}
|
|
|
|
#musicProgressWrap {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr auto;
|
|
gap: 10px;
|
|
align-items: center;
|
|
min-width: 0;
|
|
}
|
|
|
|
#musicCurrentTime,
|
|
#musicDuration {
|
|
width: 40px;
|
|
text-align: center;
|
|
color: var(--muted-color);
|
|
font-size: 12px;
|
|
}
|
|
|
|
#musicProgress {
|
|
width: 100%;
|
|
accent-color: var(--primary-color);
|
|
cursor: pointer;
|
|
}
|
|
|
|
@media (max-width: 860px) {
|
|
#vjApp {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.vjGutter {
|
|
display: none;
|
|
}
|
|
|
|
#vjStreamCard,
|
|
#vjChatPanel,
|
|
#musicSidebar,
|
|
#musicQueuePanel,
|
|
#musicMain {
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
|
|
#musicSearchForm {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
#musicPlayerBar {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
#musicPlayerControls {
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
.musicModal {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 20;
|
|
padding: 12px;
|
|
}
|
|
|
|
.musicModal.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.musicModalCard {
|
|
width: min(560px, 100%);
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
padding: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
}
|
|
|
|
.musicShareSplit {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 10px;
|
|
}
|
|
|
|
@media (min-width: 900px) {
|
|
.musicShareSplit {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
|
|
.musicSharePane {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
#musicShareText {
|
|
width: 100%;
|
|
min-height: 160px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
padding: 8px;
|
|
resize: vertical;
|
|
font: inherit;
|
|
}
|
|
|
|
#musicShareRendered {
|
|
min-height: 160px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
padding: 8px;
|
|
word-break: break-word;
|
|
overflow-wrap: anywhere;
|
|
font: inherit;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.musicShareRenderedLine {
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.musicShareRenderedSpacer {
|
|
height: 1.1em;
|
|
}
|
|
|
|
.musicShareRenderedLink {
|
|
color: var(--accent-color);
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
|
|
.musicShareRenderedImage {
|
|
width: min(260px, 100%);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--border-radius);
|
|
object-fit: cover;
|
|
background: var(--secondary-color);
|
|
align-self: flex-start;
|
|
}
|
|
|
|
#musicShareActions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<!-- ================================================================
|
|
HAMBURGER BUTTON (Fixed, separate from header)
|
|
================================================================
|
|
The hamburger button is a fixed element outside the header
|
|
to ensure it stays visible above the sidenav (z-index: 10 > 3).
|
|
================================================================ -->
|
|
<div id="divSvgHam" class="divHeaderButtons">
|
|
<!-- HamburgerMorphing will be injected here -->
|
|
</div>
|
|
|
|
<!-- ================================================================
|
|
HEADER
|
|
================================================================
|
|
Standard header with title (center).
|
|
================================================================ -->
|
|
<div id="divHeader">
|
|
<div id="divHeaderFlexLeft">
|
|
<!-- Hamburger is now separate fixed element -->
|
|
</div>
|
|
|
|
<div id="divHeaderFlexCenter">
|
|
<div id="divHeaderTitleText" class="divHeaderText">STREAM</div>
|
|
</div>
|
|
|
|
<div id="divHeaderFlexRight">
|
|
<!-- No button in header right - logout is in sidenav footer -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ================================================================
|
|
BODY
|
|
================================================================
|
|
Main content area. Add your page-specific content here.
|
|
================================================================ -->
|
|
<div id="divBody">
|
|
<div id="vjApp">
|
|
<aside id="vjStreamCard">
|
|
<div class="streamSection">
|
|
<div class="musicSidebarTitle">STREAM SITE</div>
|
|
<select id="selectStreamingSite" class="streamInput" aria-label="Select streaming site"></select>
|
|
<div style="display:flex; gap:4px; flex-wrap:wrap; margin-top:4px;">
|
|
<button id="btnAddStreamingSite" class="btn musicMiniBtn" type="button">+ Add</button>
|
|
<button id="btnEditStreamingSite" class="btn musicMiniBtn" type="button">✎ Edit</button>
|
|
<button id="btnDeleteStreamingSite" class="btn musicMiniBtn" type="button">🗑 Delete</button>
|
|
</div>
|
|
<div id="streamingSiteFormPanel" class="hidden" style="margin-top:6px;">
|
|
<input id="inputSiteName" class="streamInput" placeholder="Site name" />
|
|
<input id="inputSiteStreamBaseUrl" class="streamInput" placeholder="Stream base URL (https://example.com)" />
|
|
<input id="inputSiteStreamUrlTemplate" class="streamInput" placeholder="Stream URL template ({base}/stream/{slug}/src/index.m3u8)" />
|
|
<input id="inputSiteRtmpServer" class="streamInput" placeholder="RTMP server (rtmp://example.com:1935/publish)" />
|
|
<input id="inputSiteObsKeyTemplate" class="streamInput" placeholder="OBS key template ({slug}/src/{SECRET_KEY})" />
|
|
<div style="display:flex; gap:4px; flex-wrap:wrap; margin-top:4px;">
|
|
<button id="btnSaveStreamingSite" class="btn musicMiniBtn" type="button">Save Site</button>
|
|
<button id="btnCancelStreamingSite" class="btn musicMiniBtn" type="button">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="streamSection">
|
|
<div class="musicSidebarTitle">STREAM</div>
|
|
<video id="videoStream" playsinline muted></video>
|
|
<div id="divVideoControls">
|
|
<button id="btnVideoPlay" class="btnVideoCtrl" title="Play">▶</button>
|
|
<button id="btnVideoPause" class="btnVideoCtrl" title="Pause">⏸</button>
|
|
<button id="btnVideoMute" class="btnVideoCtrl active" title="Toggle sound">🔇</button>
|
|
<button id="btnStreamRefresh" class="btnVideoCtrl" title="Refresh stream status">⟳</button>
|
|
<span id="spanStreamHealth" style="font-size:13px;color:var(--text-color)" title="Stream health">STATUS: OFFLINE</span>
|
|
<span id="divViewerCount" style="font-size:13px;color:var(--text-color)">TOTAL: —</span>
|
|
</div>
|
|
|
|
<div id="divLiveStreamControls">
|
|
<button id="btnSaveStream" class="btn" type="button">Anounce Planned.</button>
|
|
<button id="btnGoLive" class="btn" type="button">Go Live</button>
|
|
<button id="btnEndStream" class="btn" type="button">End Stream</button>
|
|
</div>
|
|
|
|
<div id="divObsConfig" class="streamInfoBlock">
|
|
<div class="streamInfoRow">
|
|
<span class="streamInfoLabel">URL</span>
|
|
<span id="spanStreamUrl" class="streamInfoValue">—</span>
|
|
<button id="btnCopyStreamUrl" class="btn streamInfoCopy" type="button">Copy</button>
|
|
</div>
|
|
<div class="streamInfoRow">
|
|
<span class="streamInfoLabel">naddr</span>
|
|
<span id="spanStreamNaddr" class="streamInfoValue">—</span>
|
|
<button id="btnCopyNaddr" class="btn streamInfoCopy" type="button">Copy</button>
|
|
</div>
|
|
<div class="streamInfoRow">
|
|
<span class="streamInfoLabel">OBS Server</span>
|
|
<span id="spanObsServer" class="streamInfoValue">—</span>
|
|
<button id="btnCopyObsServer" class="btn streamInfoCopy" type="button">Copy</button>
|
|
</div>
|
|
<div class="streamInfoRow">
|
|
<span class="streamInfoLabel">OBS Key</span>
|
|
<span id="spanObsKey" class="streamInfoValue">—</span>
|
|
<button id="btnCopyObsKey" class="btn streamInfoCopy" type="button">Copy</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
<div class="vjGutter resizableColumnsGutter" data-gutter-index="0" aria-hidden="true"></div>
|
|
|
|
<aside id="vjChatPanel">
|
|
|
|
<div class="musicSidebarTitle">CHAT</div>
|
|
<div id="divChatComposer" class="divPostItem"></div>
|
|
<div id="divChatFeed"></div>
|
|
</aside>
|
|
<div class="vjGutter resizableColumnsGutter" data-gutter-index="1" aria-hidden="true"></div>
|
|
|
|
<aside id="musicSidebar">
|
|
<div class="streamSection">
|
|
<div class="musicSidebarTitle">SHOW</div>
|
|
<select id="selectStreamShow" class="streamInput" aria-label="Select show slug">
|
|
<option value="">— Select a show —</option>
|
|
<option value="__create_new__">+ Create new show</option>
|
|
</select>
|
|
<div id="createShowPanel" class="hidden">
|
|
<input id="inputNewShowTitle" class="streamInput" placeholder="New show title (e.g. Saturday Reggae)" />
|
|
<textarea id="inputNewShowSummary" class="streamInput" rows="2" style="min-height:72px; line-height:1.4;" placeholder="New show description"></textarea>
|
|
<div id="newShowSlugPreview" class="streamFieldDisplay" style="cursor:default; font-family:monospace;">Slug: —</div>
|
|
<button id="btnCreateShow" class="btn" type="button">Create Show</button>
|
|
</div>
|
|
<img
|
|
id="imgStreamImagePreview"
|
|
class="empty"
|
|
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='360'%3E%3Crect width='100%25' height='100%25' fill='%23222'/%3E%3Ctext x='50%25' y='50%25' fill='%23bbb' font-size='24' text-anchor='middle' dominant-baseline='middle'%3EClick to set stream image%3C/text%3E%3C/svg%3E"
|
|
alt="Stream image"
|
|
title="Click to change stream image URL"
|
|
/>
|
|
<div id="streamImageEditorRow" class="streamImageEditorRow hidden">
|
|
<input id="inputStreamImage" class="streamInput" placeholder="Image URL (optional)" />
|
|
<button id="btnStreamImageScreenshot" class="btn" type="button">📸 Screenshot</button>
|
|
</div>
|
|
|
|
<div class="musicSidebarTitle">SHOW INFO</div>
|
|
<div class="streamMetaRow">
|
|
<div class="streamMetaLabel">Show Title:</div>
|
|
<div id="streamTitleDisplay" class="streamFieldDisplay streamMetaValue" title="Click to edit stream title">Stream title</div>
|
|
<input id="inputStreamTitle" class="streamInput hidden" placeholder="Stream title" />
|
|
</div>
|
|
<div class="streamMetaRow">
|
|
<div class="streamMetaLabel">Show Summary:</div>
|
|
<div id="streamSummaryDisplay" class="streamFieldDisplay streamSummaryCollapsed streamMetaValue" title="Click to edit stream summary">Stream summary</div>
|
|
<textarea id="inputStreamSummary" class="streamInput hidden" rows="2" placeholder="Stream summary (click to edit)"></textarea>
|
|
</div>
|
|
|
|
<input id="inputStreamingUrl" type="hidden" />
|
|
|
|
<div id="divStreamControls">
|
|
<button id="btnSaveShowDraft" class="btn" type="button">Save Show</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="musicSidebarTitleRow">
|
|
<div class="musicSidebarTitle">EPISODES</div>
|
|
<button id="btnCreateEpisode" class="musicSectionCollapseBtn" type="button" title="Create new episode">+</button>
|
|
</div>
|
|
<ul id="musicMyPlaylistsList" class="musicPlaylistList"></ul>
|
|
|
|
<div class="musicSidebarTitleRow">
|
|
<div class="musicSidebarTitle">USER PLAYLISTS</div>
|
|
<button id="btnToggleUserPlaylists" class="musicSectionCollapseBtn" type="button" aria-expanded="true" aria-controls="musicUserPlaylistsList" title="Collapse user playlists">▾</button>
|
|
</div>
|
|
<ul id="musicUserPlaylistsList" class="musicPlaylistList"></ul>
|
|
|
|
<div class="musicSidebarTitleRow">
|
|
<div class="musicSidebarTitle">FRIENDS</div>
|
|
<button id="btnToggleFriendPlaylists" class="musicSectionCollapseBtn" type="button" aria-expanded="true" aria-controls="musicFriendPlaylistsList" title="Collapse friends playlists">▾</button>
|
|
</div>
|
|
<ul id="musicFriendPlaylistsList" class="musicPlaylistList"></ul>
|
|
|
|
<!--
|
|
<div class="musicSidebarTitle">AI IMPORT</div>
|
|
<section id="musicImportSection">
|
|
<div id="musicImportDropZone">
|
|
<div id="musicImportDropHint"></div>
|
|
<textarea id="musicImportInput" placeholder=""></textarea>
|
|
<div id="musicImportProcessing" aria-live="polite" aria-hidden="true">
|
|
<span class="musicImportSpinner" aria-hidden="true"></span>
|
|
<span>Processing import...</span>
|
|
</div>
|
|
</div>
|
|
<div id="musicImportStatus"></div>
|
|
<div id="musicImportResults"></div>
|
|
</section>
|
|
-->
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- ================================================================
|
|
FOOTER
|
|
================================================================
|
|
Three-section footer layout:
|
|
- Left: Relay status animations (HamburgerMorphing instances)
|
|
- Center: General status information
|
|
- Right: Additional information
|
|
================================================================ -->
|
|
<div id="divFooter">
|
|
<div id="divFooterLeft" class="divFooterBox"></div>
|
|
<div id="divFooterCenter" class="divFooterBox"></div>
|
|
<div id="divFooterRight" class="divFooterBox"></div>
|
|
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
|
</div>
|
|
|
|
<!-- ================================================================
|
|
SIDENAV
|
|
================================================================
|
|
Slide-out navigation panel. Opens from left when hamburger clicked.
|
|
Uses flexbox layout to pin version bar to bottom.
|
|
Includes a version bar footer with theme toggle and logout buttons.
|
|
================================================================ -->
|
|
<div id="divSideNav">
|
|
<div id="divSideNavHeader">
|
|
<!-- No close button - use main hamburger to close -->
|
|
</div>
|
|
|
|
<div id="divSideNavBody">
|
|
<div id="divFiles"></div>
|
|
</div>
|
|
|
|
<div id="divAiSection" class="sidenavSection">
|
|
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
|
<div id="divAiList" class="sidenavSectionList">
|
|
<div id="divAiProvidersList">No saved providers yet.</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div id="divRelaySection">
|
|
<div id="divRelaySectionTitle">
|
|
リレー
|
|
</div>
|
|
<div id="divRelayList">
|
|
Loading relays...
|
|
</div>
|
|
</div>
|
|
|
|
<div id="divBlossomSection">
|
|
|
|
<div id="divBlossomSectionTitle">ブロッサム</div>
|
|
|
|
<div id="divBlossomList">Loading blossom servers...</div>
|
|
|
|
</div>
|
|
|
|
|
|
<div id="divVersionBar">
|
|
<span id="versionDisplay">v0.0.1</span>
|
|
<div id="divVersionBarButtons">
|
|
<button id="homeButton" title="Open Home (index.html) in new tab">
|
|
<div id="homeHamburgerContainer"></div>
|
|
</button>
|
|
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
|
<div id="themeToggleHamburgerContainer"></div>
|
|
</button>
|
|
<button id="logoutButton" title="Logout">
|
|
<div id="logoutHamburgerContainer"></div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ================================================================
|
|
REQUIRED SCRIPTS
|
|
================================================================
|
|
These scripts must be loaded in this order:
|
|
1. nostr.bundle.js - Nostr tools library
|
|
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
|
================================================================ -->
|
|
<script src="./nostr.bundle.js"></script>
|
|
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
|
|
|
<script type="module">
|
|
/* ================================================================
|
|
IMPORTS
|
|
================================================================
|
|
Import shared NDK functionality from init-ndk.mjs:
|
|
- initNDKPage() - Initialize authentication and worker
|
|
- getPubkey() - Get current user's pubkey
|
|
- subscribe() - Create NDK subscriptions
|
|
- publishEvent() - Publish events via NDK
|
|
- disconnect() - Disconnect from worker
|
|
- getRelayData() - Get relay connection data
|
|
- getRelayStats() - Get relay activity statistics
|
|
|
|
Import HamburgerMorphing for animated icons
|
|
================================================================ */
|
|
import {
|
|
initNDKPage,
|
|
getPubkey, injectHeaderAvatar, injectHeaderLoginButton,
|
|
subscribe,
|
|
publishEvent,
|
|
publishRawEvent,
|
|
disconnect,
|
|
getVersion,
|
|
updateVersionDisplay,
|
|
getUserSettings,
|
|
patchUserSettings,
|
|
onUserSettings,
|
|
fetchCachedProfile,
|
|
ndkFetchEvents,
|
|
queryCache,
|
|
storeProfile,
|
|
purgePlaylistCache,
|
|
} from './js/init-ndk.mjs';
|
|
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
|
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
|
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
|
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
|
import { mountComposer } from './js/post-composer.mjs';
|
|
import { initInteractions, renderPostItem } from './js/post-interactions.mjs';
|
|
import { initVjStreamPanel } from './js/vj-stream.mjs';
|
|
import { initResizableColumns } from './js/resizable-columns.mjs';
|
|
import {
|
|
decodePubkeyInput,
|
|
encodeNpub,
|
|
getTargetPubkeyFromUrl,
|
|
getExplicitAuthModeFromUrl,
|
|
getShowFromUrl,
|
|
getEpisodeFromUrl,
|
|
updateUrlSearchParams,
|
|
} from './js/url-params.mjs';
|
|
|
|
// Version will be loaded asynchronously
|
|
const versionInfo = await getVersion();
|
|
const VERSION = versionInfo.VERSION;
|
|
console.log(`[vj.html ${VERSION}] Loading...`);
|
|
|
|
/* ================================================================
|
|
GLOBAL VARIABLES
|
|
================================================================
|
|
Track state for hamburger menu, relay status, and theme.
|
|
================================================================ */
|
|
let updateIntervalId = null;
|
|
let currentPubkey = null;
|
|
let isAuthenticated = false;
|
|
let authMode = 'optional';
|
|
let targetPubkey = '';
|
|
|
|
// Hamburger menu
|
|
let hamburgerInstance = null;
|
|
let isNavOpen = false;
|
|
|
|
// Version bar buttons
|
|
let logoutHamburger = null;
|
|
let themeToggleHamburger = null;
|
|
let homeHamburger = null;
|
|
let isDarkMode = false;
|
|
|
|
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
|
|
let pageSettings = {};
|
|
let unsubscribeUserSettings = null;
|
|
let vjResizableColumns = null;
|
|
|
|
/* ================================================================
|
|
DOM VARIABLES
|
|
================================================================
|
|
Cache DOM element references for better performance.
|
|
================================================================ */
|
|
const divBody = document.getElementById("divBody");
|
|
const divSideNav = document.getElementById("divSideNav");
|
|
const divSideNavBody = document.getElementById("divSideNavBody");
|
|
const divFooterCenter = document.getElementById("divFooterCenter");
|
|
const divFooterRight = document.getElementById("divFooterRight");
|
|
|
|
let vjStreamPanel = null;
|
|
const inputStreamTitle = document.getElementById('inputStreamTitle');
|
|
const inputStreamSummary = document.getElementById('inputStreamSummary');
|
|
const inputStreamingUrl = document.getElementById('inputStreamingUrl');
|
|
const inputStreamImage = document.getElementById('inputStreamImage');
|
|
const inputStreamEpisodeDescription = document.getElementById('inputStreamEpisodeDescription');
|
|
const btnSaveShowDraft = document.getElementById('btnSaveShowDraft');
|
|
const BLANK_IMAGE_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
|
|
const VJ_COLUMN_LAYOUT_VERSION = 3;
|
|
const VJ_COLUMN_STORAGE_KEY = `vj-column-widths:v${VJ_COLUMN_LAYOUT_VERSION}`;
|
|
const VJ_COLUMN_VAR_NAMES = ['--vj-col-1', '--vj-col-2', '--vj-col-3'];
|
|
const VJ_COLUMN_MIN_WIDTHS = [220, 280, 220];
|
|
const VJ_SETTINGS_NAMESPACE = 'vjPage';
|
|
const VJ_STREAM_DRAFT_STORAGE_KEY = 'vj:stream-draft:v1';
|
|
let vjSettingsPatchTimer = null;
|
|
let pendingVjSettingsPatch = null;
|
|
let isApplyingVjPageSettings = false;
|
|
function isPlainObject(value) {
|
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
}
|
|
function deepMergeObjects(base, incoming) {
|
|
if (!isPlainObject(base)) return isPlainObject(incoming) ? { ...incoming } : {};
|
|
if (!isPlainObject(incoming)) return { ...base };
|
|
const output = { ...base };
|
|
Object.entries(incoming).forEach(([key, value]) => {
|
|
if (isPlainObject(value) && isPlainObject(output[key])) {
|
|
output[key] = deepMergeObjects(output[key], value);
|
|
} else {
|
|
output[key] = value;
|
|
}
|
|
});
|
|
return output;
|
|
}
|
|
function getVjPageSettingsNode(settings = pageSettings) {
|
|
const root = settings?.[VJ_SETTINGS_NAMESPACE];
|
|
return isPlainObject(root) ? root : {};
|
|
}
|
|
function safeTrim(value) {
|
|
return String(value || '').trim();
|
|
}
|
|
function readLegacyStreamDraft() {
|
|
try {
|
|
const raw = localStorage.getItem(VJ_STREAM_DRAFT_STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw);
|
|
if (!isPlainObject(parsed)) return {};
|
|
return {
|
|
title: safeTrim(parsed.title || ''),
|
|
summary: safeTrim(parsed.summary || ''),
|
|
streamingUrl: safeTrim(parsed.streamingUrl || ''),
|
|
image: safeTrim(parsed.image || ''),
|
|
episodeDescription: safeTrim(parsed.episodeDescription || ''),
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
function writeLegacyStreamDraft(draft) {
|
|
try {
|
|
localStorage.setItem(VJ_STREAM_DRAFT_STORAGE_KEY, JSON.stringify({
|
|
title: safeTrim(draft?.title || ''),
|
|
summary: safeTrim(draft?.summary || ''),
|
|
streamingUrl: safeTrim(draft?.streamingUrl || ''),
|
|
image: safeTrim(draft?.image || ''),
|
|
episodeDescription: safeTrim(draft?.episodeDescription || ''),
|
|
}));
|
|
} catch {
|
|
// Ignore storage write failures (private mode/quota/etc.)
|
|
}
|
|
}
|
|
function getCurrentStreamDraftFromInputs() {
|
|
return {
|
|
title: safeTrim(inputStreamTitle?.value || ''),
|
|
summary: safeTrim(inputStreamSummary?.value || ''),
|
|
streamingUrl: safeTrim(inputStreamingUrl?.value || ''),
|
|
image: safeTrim(inputStreamImage?.value || ''),
|
|
episodeDescription: safeTrim(inputStreamEpisodeDescription?.value || ''),
|
|
};
|
|
}
|
|
function queueVjSettingsPatch(partialPatch) {
|
|
if (!isPlainObject(partialPatch)) return;
|
|
|
|
pendingVjSettingsPatch = deepMergeObjects(
|
|
pendingVjSettingsPatch || {},
|
|
partialPatch,
|
|
);
|
|
|
|
const nextNode = deepMergeObjects(getVjPageSettingsNode(pageSettings), pendingVjSettingsPatch);
|
|
pageSettings = {
|
|
...(isPlainObject(pageSettings) ? pageSettings : {}),
|
|
[VJ_SETTINGS_NAMESPACE]: nextNode,
|
|
};
|
|
|
|
if (vjSettingsPatchTimer) clearTimeout(vjSettingsPatchTimer);
|
|
vjSettingsPatchTimer = setTimeout(async () => {
|
|
const patchPayload = pendingVjSettingsPatch;
|
|
pendingVjSettingsPatch = null;
|
|
vjSettingsPatchTimer = null;
|
|
|
|
if (!isPlainObject(patchPayload) || !isAuthenticated || !currentPubkey) return;
|
|
|
|
try {
|
|
await patchUserSettings({
|
|
[VJ_SETTINGS_NAMESPACE]: deepMergeObjects(
|
|
getVjPageSettingsNode(pageSettings),
|
|
patchPayload,
|
|
),
|
|
});
|
|
} catch (error) {
|
|
console.warn('[vj.html] Failed to patch VJ page settings:', error);
|
|
}
|
|
}, 300);
|
|
}
|
|
function applyVjPageSettingsFromState(settings = pageSettings, { applyColumns = false } = {}) {
|
|
const vjNode = getVjPageSettingsNode(settings);
|
|
const legacyDraft = readLegacyStreamDraft();
|
|
const streamDraft = isPlainObject(vjNode.streamDraft) ? vjNode.streamDraft : {};
|
|
const rawStreamingSites = Array.isArray(vjNode.streamingSites) ? vjNode.streamingSites : [];
|
|
const selectedStreamingSiteName = safeTrim(vjNode.selectedStreamingSiteName || vjNode.selectedSiteName || '');
|
|
|
|
isApplyingVjPageSettings = true;
|
|
try {
|
|
if (inputStreamTitle) {
|
|
inputStreamTitle.value = safeTrim(streamDraft.title || legacyDraft.title || inputStreamTitle.value || '');
|
|
}
|
|
|
|
if (inputStreamSummary) {
|
|
inputStreamSummary.value = safeTrim(streamDraft.summary || legacyDraft.summary || inputStreamSummary.value || '');
|
|
}
|
|
|
|
if (inputStreamingUrl) {
|
|
inputStreamingUrl.value = safeTrim(
|
|
streamDraft.streamingUrl
|
|
|| legacyDraft.streamingUrl
|
|
|| inputStreamingUrl.value
|
|
|| ''
|
|
);
|
|
}
|
|
|
|
if (inputStreamImage) {
|
|
inputStreamImage.value = safeTrim(streamDraft.image || legacyDraft.image || inputStreamImage.value || '');
|
|
}
|
|
|
|
if (inputStreamEpisodeDescription) {
|
|
inputStreamEpisodeDescription.value = safeTrim(
|
|
streamDraft.episodeDescription
|
|
|| legacyDraft.episodeDescription
|
|
|| inputStreamEpisodeDescription.value
|
|
|| ''
|
|
);
|
|
}
|
|
} finally {
|
|
isApplyingVjPageSettings = false;
|
|
}
|
|
|
|
if (vjStreamPanel?.setStreamingSites) {
|
|
vjStreamPanel.setStreamingSites(rawStreamingSites, selectedStreamingSiteName, { persist: false, refresh: true });
|
|
}
|
|
|
|
if (applyColumns) {
|
|
const columnsNode = isPlainObject(vjNode?.columns) ? vjNode.columns : {};
|
|
const settingsVersion = Number(columnsNode?.version || 0);
|
|
const widthsFromSettings = Array.isArray(columnsNode?.widths) ? columnsNode.widths : null;
|
|
if (settingsVersion === VJ_COLUMN_LAYOUT_VERSION
|
|
&& widthsFromSettings
|
|
&& widthsFromSettings.length === VJ_COLUMN_VAR_NAMES.length) {
|
|
vjResizableColumns?.setWidths(widthsFromSettings, { persist: false });
|
|
}
|
|
}
|
|
}
|
|
function getVjAppElement() {
|
|
return document.getElementById('vjApp');
|
|
}
|
|
function normalizeVjLayoutDomOrder() {
|
|
const appEl = getVjAppElement();
|
|
if (!appEl) return;
|
|
|
|
const orderedNodes = [
|
|
appEl.querySelector('#musicSidebar'),
|
|
appEl.querySelector('.vjGutter[data-gutter-index="0"]'),
|
|
appEl.querySelector('#vjStreamCard'),
|
|
appEl.querySelector('.vjGutter[data-gutter-index="1"]'),
|
|
appEl.querySelector('#vjChatPanel'),
|
|
].filter(Boolean);
|
|
|
|
if (!orderedNodes.length) return;
|
|
|
|
const orderedSet = new Set(orderedNodes);
|
|
const trailingNodes = Array.from(appEl.children).filter((node) => !orderedSet.has(node));
|
|
|
|
orderedNodes.forEach((node) => appEl.appendChild(node));
|
|
trailingNodes.forEach((node) => appEl.appendChild(node));
|
|
}
|
|
function readSavedVjColumnWidths() {
|
|
const settingsColumns = getVjPageSettingsNode(pageSettings)?.columns;
|
|
const settingsVersion = Number(settingsColumns?.version || 0);
|
|
const settingsWidths = settingsColumns?.widths;
|
|
if (settingsVersion === VJ_COLUMN_LAYOUT_VERSION
|
|
&& Array.isArray(settingsWidths)
|
|
&& settingsWidths.length === VJ_COLUMN_VAR_NAMES.length) {
|
|
const normalized = settingsWidths.map((value, index) => Math.max(VJ_COLUMN_MIN_WIDTHS[index], Math.round(Number(value) || 0)));
|
|
if (normalized.every((value) => Number.isFinite(value) && value > 0)) {
|
|
return normalized;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const raw = localStorage.getItem(VJ_COLUMN_STORAGE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed) || parsed.length !== VJ_COLUMN_VAR_NAMES.length) return null;
|
|
const widths = parsed.map((value, index) => Math.max(VJ_COLUMN_MIN_WIDTHS[index], Math.round(Number(value) || 0)));
|
|
return widths.every((value) => Number.isFinite(value) && value > 0) ? widths : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
function saveVjColumnWidths(widths) {
|
|
try {
|
|
localStorage.setItem(VJ_COLUMN_STORAGE_KEY, JSON.stringify(widths));
|
|
} catch {
|
|
// Ignore storage write failures (private mode/quota/etc.)
|
|
}
|
|
|
|
if (isApplyingVjPageSettings) return;
|
|
queueVjSettingsPatch({
|
|
columns: {
|
|
version: VJ_COLUMN_LAYOUT_VERSION,
|
|
widths,
|
|
},
|
|
});
|
|
}
|
|
function initVjResizableColumns() {
|
|
const appEl = getVjAppElement();
|
|
if (!appEl) return;
|
|
|
|
if (vjResizableColumns?.destroy) {
|
|
vjResizableColumns.destroy();
|
|
}
|
|
|
|
vjResizableColumns = initResizableColumns({
|
|
container: appEl,
|
|
columnVarNames: VJ_COLUMN_VAR_NAMES,
|
|
minWidths: VJ_COLUMN_MIN_WIDTHS,
|
|
gutterSelector: '.vjGutter[data-gutter-index]',
|
|
compactMediaQuery: '(max-width: 860px)',
|
|
gutterSizeVarName: '--vj-gutter-size',
|
|
defaultGutterSize: 10,
|
|
initialWidths: readSavedVjColumnWidths(),
|
|
onWidthsChange: (widths, { persist } = {}) => {
|
|
if (persist) saveVjColumnWidths(widths);
|
|
},
|
|
});
|
|
}
|
|
function resolveAuthModeFromUrl() {
|
|
// VJ page now always requires authentication.
|
|
// Ignore URL auth hints/targets for initial page access.
|
|
return 'required';
|
|
}
|
|
function isAuthRequiredError(error) {
|
|
const message = String(error?.message || error || '').toLowerCase();
|
|
return message.includes('authentication required');
|
|
}
|
|
function isReadOnlyTargetMode() {
|
|
return Boolean(targetPubkey && (!currentPubkey || targetPubkey !== currentPubkey));
|
|
}
|
|
function isExternalTargetMode() {
|
|
return Boolean(targetPubkey && (!currentPubkey || targetPubkey !== currentPubkey));
|
|
}
|
|
function syncLoggedInNpubToUrl() {
|
|
if (!isAuthenticated || !currentPubkey) return;
|
|
const npub = encodeNpub(currentPubkey);
|
|
if (!npub) return;
|
|
|
|
const url = new URL(window.location.href);
|
|
const existingTarget = decodePubkeyInput(
|
|
String(url.searchParams.get('npub') || '').trim()
|
|
|| String(url.searchParams.get('pubkey') || '').trim()
|
|
);
|
|
|
|
if (existingTarget && existingTarget !== currentPubkey) return;
|
|
|
|
const alreadyNpub = String(url.searchParams.get('npub') || '').trim();
|
|
const hasPubkeyParam = url.searchParams.has('pubkey');
|
|
if (alreadyNpub === npub && !hasPubkeyParam) return;
|
|
|
|
updateUrlSearchParams({ npub, pubkey: '' });
|
|
}
|
|
function updateShowQueryParam(showSlug) {
|
|
updateUrlSearchParams({ show: String(showSlug || '').trim().toLowerCase() });
|
|
}
|
|
function updateEpisodeQueryParam(identifier) {
|
|
updateUrlSearchParams({ episode: String(identifier || '').trim() });
|
|
}
|
|
function applyShowFromUrlToSelect() {
|
|
const desiredShowSlug = String(getShowFromUrl() || '').trim().toLowerCase();
|
|
if (!desiredShowSlug) return;
|
|
|
|
const select = document.getElementById('selectStreamShow');
|
|
if (!select) return;
|
|
|
|
let option = Array.from(select.options || []).find((entry) => String(entry.value || '').trim().toLowerCase() === desiredShowSlug);
|
|
if (!option) {
|
|
option = document.createElement('option');
|
|
option.value = desiredShowSlug;
|
|
option.textContent = desiredShowSlug;
|
|
const createOption = Array.from(select.options || []).find((entry) => String(entry.value || '').trim() === '__create_new__');
|
|
if (createOption) {
|
|
select.insertBefore(option, createOption);
|
|
} else {
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
|
|
if (String(select.value || '').trim().toLowerCase() !== desiredShowSlug) {
|
|
select.value = desiredShowSlug;
|
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
}
|
|
function applyEpisodeFromUrlToSelection() {
|
|
const desiredEpisode = String(getEpisodeFromUrl() || '').trim();
|
|
if (!desiredEpisode) return;
|
|
if (selectedPlaylistId === desiredEpisode) return;
|
|
if (myPlaylists.has(desiredEpisode)) {
|
|
openMyPlaylistByIdentifier(desiredEpisode);
|
|
}
|
|
}
|
|
function setSidebarPlaylistSectionCollapsed(section, collapsed, { persist = true } = {}) {
|
|
const normalized = section === 'friend' ? 'friend' : 'user';
|
|
const isCollapsed = Boolean(collapsed);
|
|
const storageKey = normalized === 'user'
|
|
? 'vj:user-playlists-collapsed'
|
|
: 'vj:friend-playlists-collapsed';
|
|
|
|
const listEl = document.getElementById(normalized === 'user' ? 'musicUserPlaylistsList' : 'musicFriendPlaylistsList');
|
|
const buttonEl = document.getElementById(normalized === 'user' ? 'btnToggleUserPlaylists' : 'btnToggleFriendPlaylists');
|
|
const label = normalized === 'user' ? 'user playlists' : 'friends playlists';
|
|
|
|
if (listEl) {
|
|
listEl.classList.toggle('hidden', isCollapsed);
|
|
}
|
|
|
|
if (buttonEl) {
|
|
buttonEl.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
|
buttonEl.textContent = isCollapsed ? '▸' : '▾';
|
|
buttonEl.title = isCollapsed ? `Expand ${label}` : `Collapse ${label}`;
|
|
}
|
|
|
|
if (persist) {
|
|
localStorage.setItem(storageKey, isCollapsed ? '1' : '0');
|
|
}
|
|
}
|
|
async function initializeAuthentication(mode) {
|
|
targetPubkey = getTargetPubkeyFromUrl();
|
|
if (mode === 'required') {
|
|
await initNDKPage();
|
|
currentPubkey = await getPubkey();
|
|
isAuthenticated = true;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await initNDKPage({ allowUnauthenticated: true, publicPubkey: targetPubkey || '' });
|
|
} catch (error) {
|
|
if (isAuthRequiredError(error)) {
|
|
isAuthenticated = false;
|
|
currentPubkey = null;
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
currentPubkey = await getPubkey();
|
|
isAuthenticated = Boolean(currentPubkey);
|
|
} catch {
|
|
currentPubkey = null;
|
|
isAuthenticated = false;
|
|
}
|
|
}
|
|
async function initializeAuthenticatedPageFeatures() {
|
|
if (!isAuthenticated || !currentPubkey) {
|
|
await injectHeaderLoginButton();
|
|
return;
|
|
}
|
|
await injectHeaderAvatar(currentPubkey);
|
|
console.log('[vj.html] Authenticated as:', currentPubkey);
|
|
|
|
try {
|
|
pageSettings = await getUserSettings();
|
|
} catch (error) {
|
|
console.warn('[vj.html] getUserSettings failed:', error);
|
|
pageSettings = {};
|
|
}
|
|
|
|
applyVjPageSettingsFromState(pageSettings, { applyColumns: true });
|
|
|
|
if (!unsubscribeUserSettings) {
|
|
unsubscribeUserSettings = onUserSettings((settings) => {
|
|
pageSettings = settings || {};
|
|
applyVjPageSettingsFromState(pageSettings, { applyColumns: true });
|
|
});
|
|
}
|
|
}
|
|
async function promptLoginIfNeeded() {
|
|
if (isAuthenticated && currentPubkey) return true;
|
|
await initNDKPage();
|
|
currentPubkey = await getPubkey();
|
|
isAuthenticated = true;
|
|
await initializeAuthenticatedPageFeatures();
|
|
removeNpubFromUrl();
|
|
vjStreamPanel?.onAuthChanged?.();
|
|
return true;
|
|
}
|
|
function initHamburgerMenu() {
|
|
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
hamburgerInstance.animateTo('burger');
|
|
}
|
|
|
|
/* ================================================================
|
|
SIDENAV FUNCTIONS
|
|
================================================================
|
|
Open/close sidenav with hamburger morphing animation.
|
|
================================================================ */
|
|
function openNav() {
|
|
divSideNav.style.zIndex = 3;
|
|
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
|
|
isNavOpen = true;
|
|
if (hamburgerInstance) {
|
|
hamburgerInstance.animateTo('arrow_left');
|
|
}
|
|
divSideNavBody.innerHTML = "Settings and options coming soon...";
|
|
|
|
// Initialize version bar buttons when sidenav opens (lazy load)
|
|
if (!logoutHamburger) {
|
|
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
logoutHamburger.animateTo('x');
|
|
}
|
|
|
|
if (!homeHamburger) {
|
|
homeHamburger = new HamburgerMorphing('#homeHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
homeHamburger.animateTo('triangle_up');
|
|
}
|
|
|
|
if (!themeToggleHamburger) {
|
|
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
|
size: 24,
|
|
foreground: 'var(--primary-color)',
|
|
background: 'var(--secondary-color)',
|
|
hover: 'var(--accent-color)'
|
|
});
|
|
|
|
// Determine current theme
|
|
const savedTheme = localStorage.getItem('theme');
|
|
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
|
const initialShape = isDarkMode ? 'moon' : 'circle';
|
|
themeToggleHamburger.animateTo(initialShape);
|
|
}
|
|
}
|
|
|
|
function closeNav() {
|
|
divSideNav.style.width = "0vw";
|
|
divSideNav.style.zIndex = -1;
|
|
isNavOpen = false;
|
|
if (hamburgerInstance) {
|
|
hamburgerInstance.animateTo('burger');
|
|
}
|
|
}
|
|
|
|
function toggleNav() {
|
|
if (isNavOpen) {
|
|
closeNav();
|
|
} else {
|
|
openNav();
|
|
}
|
|
}
|
|
|
|
|
|
/* ================================================================
|
|
UPDATE FOOTER
|
|
================================================================
|
|
Update footer sections with relay status, pubkey, and other info.
|
|
Called periodically by update loop.
|
|
================================================================ */
|
|
const UpdateFooter = async () => {
|
|
|
|
try {
|
|
// Update relay status visuals in footer and sidenav
|
|
await updateFooterRelayStatus();
|
|
await updateSidenavRelaySection();
|
|
|
|
await updateBlossomSection();
|
|
// Clear center and right sections
|
|
divFooterCenter.innerHTML = '';
|
|
divFooterRight.innerHTML = '';
|
|
} catch (error) {
|
|
console.error('[vj.html] Error updating footer:', error);
|
|
}
|
|
};
|
|
|
|
/* ================================================================
|
|
LOGOUT
|
|
================================================================
|
|
Complete logout process:
|
|
1. Stop update loop
|
|
2. Disconnect from NDK worker
|
|
3. Logout from nostr-login-lite
|
|
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
|
|
5. Reload page
|
|
================================================================ */
|
|
const Logout = async () => {
|
|
console.log("[vj.html] Starting logout process...");
|
|
|
|
// Stop the update loop
|
|
if (updateIntervalId) {
|
|
clearInterval(updateIntervalId);
|
|
updateIntervalId = null;
|
|
}
|
|
|
|
// Disconnect from worker
|
|
disconnect();
|
|
|
|
// Logout from nostr-login-lite
|
|
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
|
|
await window.NOSTR_LOGIN_LITE.logout();
|
|
}
|
|
|
|
// Clear all storage
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
|
|
// Clear IndexedDB
|
|
if (window.indexedDB) {
|
|
const databases = await window.indexedDB.databases();
|
|
for (const db of databases) {
|
|
if (db.name) {
|
|
window.indexedDB.deleteDatabase(db.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("[vj.html] Logged out, reloading page");
|
|
location.reload(true);
|
|
};
|
|
|
|
/* ================================================================
|
|
EVENT LISTENERS
|
|
================================================================
|
|
Wire up UI interactions.
|
|
Main hamburger button click handler is set up in main() after initialization.
|
|
================================================================ */
|
|
|
|
/* ================================================================
|
|
SUBSCRIPTION EXAMPLE
|
|
================================================================
|
|
Example of how to subscribe to Nostr events:
|
|
|
|
const sub = subscribe(
|
|
{ kinds: [1], authors: [pubkey], limit: 10 },
|
|
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
|
);
|
|
|
|
Cache usage options:
|
|
- 'CACHE_FIRST' - Check cache first, then relays
|
|
- 'ONLY_RELAY' - Only query relays
|
|
- 'ONLY_CACHE' - Only query cache
|
|
- 'PARALLEL' - Query cache and relays simultaneously
|
|
|
|
Listen for events via window events:
|
|
window.addEventListener('ndkEvent', (event) => {
|
|
const evt = event.detail;
|
|
console.log('Received event:', evt);
|
|
});
|
|
================================================================ */
|
|
|
|
/* ================================================================
|
|
PUBLISH EXAMPLE
|
|
================================================================
|
|
Example of how to publish a Nostr event:
|
|
|
|
const event = {
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: 1,
|
|
tags: [],
|
|
content: "Hello, Nostr!"
|
|
};
|
|
|
|
try {
|
|
const result = await publishEvent(event);
|
|
console.log("✅ Published to:", result.relayResults.successful);
|
|
console.log("❌ Failed:", result.relayResults.failed);
|
|
console.log("Total relays:", result.totalRelays);
|
|
} catch (error) {
|
|
console.error("Publish error:", error);
|
|
}
|
|
|
|
Note: Events are automatically signed by the NDK worker using
|
|
the message-based signer (which calls window.nostr.signEvent).
|
|
================================================================ */
|
|
|
|
/* ================================================================
|
|
USER SETTINGS EXAMPLE (NIP-78)
|
|
================================================================
|
|
Read/subscribe/write helper pattern for all pages:
|
|
|
|
// Read latest merged settings (cache + relay hydrated by worker)
|
|
const settings = await getUserSettings();
|
|
|
|
// Subscribe to cross-tab updates
|
|
const unsubscribe = onUserSettings((nextSettings) => {
|
|
// Re-render page from nextSettings
|
|
});
|
|
|
|
// Patch only your feature namespace
|
|
await patchUserSettings({
|
|
myFeature: {
|
|
someFlag: true
|
|
}
|
|
});
|
|
|
|
// On page teardown (if applicable)
|
|
// unsubscribe();
|
|
================================================================ */
|
|
|
|
/* ================================================================
|
|
INITIALIZATION
|
|
================================================================
|
|
Main initialization sequence:
|
|
1. Initialize hamburger menu
|
|
2. Set up hamburger click handler
|
|
3. Initialize NDK (handles authentication automatically)
|
|
4. Get authenticated pubkey
|
|
5. Hydrate + subscribe user settings
|
|
6. Set up relay activity listeners
|
|
7. Set up version bar button listeners
|
|
8. Start update loop
|
|
|
|
The initNDKPage() function:
|
|
- Checks if already authenticated (via nostr-login-lite)
|
|
- If not authenticated, shows login modal
|
|
- Connects to NDK SharedWorker
|
|
- Returns when authentication is complete
|
|
|
|
Authentication persists across pages via nostr-login-lite's
|
|
localStorage, so users only need to login once.
|
|
================================================================ */
|
|
(async function main() {
|
|
console.log("[vj.html] Starting initialization...");
|
|
|
|
try {
|
|
// Initialize hamburger menu first
|
|
initHamburgerMenu();
|
|
|
|
// Add click handler to hamburger
|
|
const divSvgHam = document.getElementById('divSvgHam');
|
|
if (divSvgHam) {
|
|
divSvgHam.addEventListener('click', toggleNav);
|
|
}
|
|
|
|
// Initialize version bar buttons
|
|
const homeButton = document.getElementById('homeButton');
|
|
const themeToggleButton = document.getElementById('themeToggleButton');
|
|
const logoutButton = document.getElementById('logoutButton');
|
|
|
|
if (homeButton) {
|
|
homeButton.addEventListener('click', () => {
|
|
window.open('./index.html', '_blank', 'noopener,noreferrer');
|
|
});
|
|
}
|
|
|
|
if (themeToggleButton) {
|
|
themeToggleButton.addEventListener('click', () => {
|
|
isDarkMode = !isDarkMode;
|
|
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
|
document.documentElement.classList.toggle('dark-mode', isDarkMode);
|
|
document.body.classList.toggle('dark-mode', isDarkMode);
|
|
if (themeToggleHamburger) {
|
|
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
|
}
|
|
});
|
|
}
|
|
|
|
if (logoutButton) {
|
|
logoutButton.addEventListener('click', async () => {
|
|
try {
|
|
if (!isAuthenticated) {
|
|
await promptLoginIfNeeded();
|
|
return;
|
|
}
|
|
await Logout();
|
|
} catch (error) {
|
|
console.error('Logout/login action failed:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
authMode = resolveAuthModeFromUrl();
|
|
console.log('[vj.html] Resolved auth mode:', authMode);
|
|
await initializeAuthentication(authMode);
|
|
await initializeAuthenticatedPageFeatures();
|
|
|
|
|
|
vjStreamPanel = initVjStreamPanel({
|
|
subscribe,
|
|
publishEvent,
|
|
mountComposer,
|
|
renderPostItem,
|
|
getCurrentPubkey: () => currentPubkey,
|
|
getIsAuthenticated: () => isAuthenticated,
|
|
promptLoginIfNeeded,
|
|
getStreamingSiteSettings: () => {
|
|
const node = getVjPageSettingsNode(pageSettings);
|
|
return {
|
|
sites: Array.isArray(node.streamingSites) ? node.streamingSites : [],
|
|
selectedName: safeTrim(node.selectedStreamingSiteName || node.selectedSiteName || ''),
|
|
};
|
|
},
|
|
onStreamingSiteSettingsChange: ({ sites, selectedName }) => {
|
|
queueVjSettingsPatch({
|
|
streamingSites: Array.isArray(sites) ? sites : [],
|
|
selectedStreamingSiteName: safeTrim(selectedName || ''),
|
|
});
|
|
},
|
|
});
|
|
await vjStreamPanel.initialize();
|
|
|
|
|
|
applyVjPageSettingsFromState(pageSettings, { applyColumns: false });
|
|
|
|
const persistCurrentStreamDraft = () => {
|
|
if (isApplyingVjPageSettings) return;
|
|
const draft = getCurrentStreamDraftFromInputs();
|
|
writeLegacyStreamDraft(draft);
|
|
queueVjSettingsPatch({ streamDraft: draft });
|
|
};
|
|
|
|
if (btnSaveShowDraft) {
|
|
btnSaveShowDraft.addEventListener('click', persistCurrentStreamDraft);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize relay UI components
|
|
initFooterRelayStatus();
|
|
initSidenavRelaySection();
|
|
if (isAuthenticated) {
|
|
await initBlossomSection();
|
|
initAiSectionWithLocalConfig();
|
|
}
|
|
await UpdateFooter(); // Initial update before interval starts
|
|
|
|
// Listen for relay activity broadcasts from worker
|
|
window.addEventListener('ndkRelayActivity', (event) => {
|
|
const { relayUrl, activity, stats } = event.detail;
|
|
// console.log(`[vj.html] Relay activity: ${relayUrl} - ${activity}`, stats);
|
|
setRelayActivityState(relayUrl, activity);
|
|
});
|
|
|
|
// Listen for relay activity broadcasts from worker (alternative message format)
|
|
window.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'relayActivity') {
|
|
const { relayUrl, activity } = event.data;
|
|
// console.log(`[vj.html] Relay activity: ${relayUrl} - ${activity}`);
|
|
setRelayActivityState(relayUrl, activity);
|
|
}
|
|
});
|
|
|
|
/* ============================================================
|
|
EXAMPLE: Subscribe to events
|
|
============================================================
|
|
Uncomment to subscribe to user's notes:
|
|
|
|
const notesSub = subscribe(
|
|
{ kinds: [1], authors: [currentPubkey], limit: 10 },
|
|
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
|
);
|
|
console.log("[vj.html] Subscribed to kind 1");
|
|
============================================================ */
|
|
|
|
/* ============================================================
|
|
EXAMPLE: Listen for events from worker
|
|
============================================================
|
|
Uncomment to handle incoming events:
|
|
|
|
window.addEventListener('ndkEvent', (event) => {
|
|
const evt = event.detail;
|
|
console.log("[vj.html] Received event:", evt.kind, evt.pubkey);
|
|
|
|
if (evt.pubkey === currentPubkey) {
|
|
if (evt.kind === 1) {
|
|
// Handle note event
|
|
console.log("Note:", evt.content);
|
|
}
|
|
}
|
|
});
|
|
============================================================ */
|
|
|
|
/* ============================================================
|
|
EXAMPLE: Listen for cached profile
|
|
============================================================
|
|
Uncomment to handle cached profile data:
|
|
|
|
window.addEventListener('ndkProfile', (event) => {
|
|
console.log("[vj.html] Cached profile:", event.detail);
|
|
// event.detail contains profile object (name, about, etc.)
|
|
});
|
|
============================================================ */
|
|
|
|
normalizeVjLayoutDomOrder();
|
|
initVjResizableColumns();
|
|
|
|
|
|
// Start update loop (updates footer every second)
|
|
updateIntervalId = setInterval(UpdateFooter, 1000);
|
|
|
|
// Update version display
|
|
await updateVersionDisplay();
|
|
|
|
console.log('[vj.html] Initialization complete');
|
|
} catch (error) {
|
|
console.error('[vj.html] Initialization failed:', error);
|
|
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
|
|
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
|
|
<div style="font-size: 16px; color: #666;">${error.message}</div>
|
|
<div style="margin-top: 20px;">
|
|
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
})();
|
|
|
|
/* ================================================================
|
|
WORKER MESSAGE TYPES
|
|
================================================================
|
|
The NDK worker can send these message types:
|
|
|
|
1. 'response' - Response to init/subscribe/publish requests
|
|
- data.profile - User profile (from init)
|
|
- data.relays - User relays (from init)
|
|
- data.success - Publish success status
|
|
- data.relayResults - Relay publish results
|
|
|
|
2. 'event' - Nostr event from subscription
|
|
- Dispatched as 'ndkEvent' window event
|
|
- event.detail contains the Nostr event
|
|
|
|
3. 'eose' - End of stored events for subscription
|
|
- Dispatched as 'ndkEose' window event
|
|
- event.detail.subId contains subscription ID
|
|
|
|
4. 'signRequest' - Request to sign event/encrypt/decrypt
|
|
- Handled automatically by init-ndk.mjs
|
|
- Calls window.nostr methods and sends response
|
|
|
|
5. 'error' - Error from worker
|
|
- Logged to console automatically
|
|
|
|
6. 'relayActivity' - Relay read/write activity notification
|
|
- Dispatched as 'ndkRelayActivity' window event
|
|
- Used to animate relay status icons in footer
|
|
================================================================ */
|
|
|
|
/* ================================================================
|
|
DISTRIBUTED ARCHITECTURE NOTES
|
|
================================================================
|
|
Each page is independently accessible and self-contained:
|
|
|
|
1. Authentication persists via nostr-login-lite localStorage
|
|
- Login once on any page
|
|
- All other pages automatically authenticated
|
|
|
|
2. NDK SharedWorker is shared across all tabs/pages
|
|
- Single NDK instance manages all connections
|
|
- Subscriptions from all pages handled by one worker
|
|
- Events broadcast to all connected pages
|
|
|
|
3. Dexie cache is shared across all pages
|
|
- IndexedDB persists across sessions
|
|
- Cache-first queries are fast
|
|
- Reduces relay load
|
|
|
|
4. User settings are centralized and shared
|
|
- Worker hydrates kind 30078 (`d:user-settings`) on init
|
|
- Pages read via getUserSettings()
|
|
- Pages patch via patchUserSettings({ featureNamespace: ... })
|
|
- Pages subscribe via onUserSettings() for live updates
|
|
|
|
5. Each page can be distributed independently
|
|
- Copy vj.html and customize
|
|
- No dependencies on other pages
|
|
- Works standalone or as part of suite
|
|
|
|
6. Message-based signer bridges worker and page
|
|
- Worker's NDK uses MessageBasedSigner
|
|
- Signer sends sign requests to page
|
|
- Page calls window.nostr.signEvent()
|
|
- Response sent back to worker
|
|
- NDK completes signing and publishing
|
|
|
|
7. Relay status visualization
|
|
- Footer left section shows connected relays
|
|
- Each relay has animated icon (HamburgerMorphing)
|
|
- Icons morph based on activity (read/write)
|
|
- Temporary animations show real-time activity
|
|
================================================================ */
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|