Files

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>