Files
client/www/music-greyscale.html

5601 lines
191 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>Music</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/dot-menu.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>
<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;
}
#musicApp {
--music-col-1: 280px;
--music-col-2: 640px;
--music-col-3: 300px;
--music-gutter-size: 10px;
flex: 1;
min-height: 0;
display: grid;
grid-template-columns:
var(--music-col-1)
var(--music-gutter-size)
var(--music-col-2)
var(--music-gutter-size)
var(--music-col-3);
width: 100%;
overflow: hidden;
align-items: stretch;
}
#musicSidebar {
width: 100%;
min-width: 220px;
border: var(--border);
border-radius: var(--border-radius);
background: var(--secondary-color);
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
}
#musicMain {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
#musicQueuePanel {
width: 100%;
min-width: 240px;
border: var(--border);
border-radius: var(--border-radius);
background: var(--secondary-color);
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
min-height: 0;
}
.musicSidebarTitle {
font-size: 12px;
color: var(--muted-color);
}
.musicGutter {
width: var(--music-gutter-size);
}
#musicPlaylistNameInput {
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;
}
#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;
}
.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) {
#musicApp {
display: flex;
flex-direction: column;
}
.musicGutter {
display: none;
}
#musicSidebar,
#musicMain,
#musicQueuePanel {
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 class="divHeaderText"></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="musicApp">
<aside id="musicSidebar">
<div class="musicSidebarTitle">PLAYLISTS</div>
<div style="display:flex; gap:6px; align-items:center;">
<input id="musicViewerPubkeyInput" type="text" placeholder="npub1... or hex pubkey" style="flex:1; 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;" />
<button id="musicViewerLoadBtn" class="btn" type="button" style="min-width:56px;">Load</button>
</div>
<div id="musicViewerInfo" class="musicPlaylistMeta" style="min-height:16px;"></div>
<input id="musicPlaylistNameInput" type="text" placeholder="Type playlist name and press Enter" />
<div class="musicSidebarTitle">MY PLAYLISTS</div>
<ul id="musicMyPlaylistsList" class="musicPlaylistList"></ul>
<div class="musicSidebarTitle">FRIENDS</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 class="musicGutter resizableColumnsGutter" data-gutter-index="0" aria-hidden="true"></div>
<div id="musicMain">
<section id="musicPlaylistProfile" class="hidden">
<img id="musicPlaylistProfileBanner" alt="Playlist banner" />
<div id="musicPlaylistProfileBody">
<img id="musicPlaylistProfileAvatar" alt="Playlist avatar" />
<div id="musicPlaylistProfileMeta">
<div id="musicPlaylistProfileTitleRow">
<div id="musicPlaylistProfileTitle"></div>
<div id="musicPlaylistProfileCreator">
<img id="musicPlaylistCreatorAvatar" alt="Creator avatar" />
<span id="musicPlaylistCreatorText"></span>
<button id="musicPlaylistEditBtn" class="btn musicMiniBtn" type="button">Edit</button>
</div>
</div>
<div id="musicPlaylistDescriptionText" class="musicPlaylistMeta"></div>
<div id="musicPlaylistProfileEditor" class="hidden">
<input id="musicPlaylistTitleInput" class="musicPlaylistProfileInput" type="text" placeholder="Playlist title" />
<input id="musicPlaylistAvatarInput" class="musicPlaylistProfileInput" type="url" placeholder="Playlist avatar image URL" />
<input id="musicPlaylistBannerInput" class="musicPlaylistProfileInput" type="url" placeholder="Playlist banner image URL" />
<textarea id="musicPlaylistDescriptionInput" class="musicPlaylistProfileInput" placeholder="Playlist description"></textarea>
</div>
</div>
</div>
</section>
<header id="musicSearchHeader">
<form id="musicSearchForm" autocomplete="off">
<input id="musicSearchInput" type="text" placeholder="Search songs, albums, artists..." required />
<button id="musicSearchButton" class="btn" type="submit">Search</button>
</form>
<div id="musicHeaderNavRow" style="display:flex; gap:8px; align-items:center;">
<button id="musicBackBtn" class="btn" type="button" aria-label="Go back"></button>
<button id="musicHomeBtn" class="btn" type="button" aria-label="Go home"></button>
<button id="musicForwardBtn" class="btn" type="button" aria-label="Go forward"></button>
<p id="musicStatusText" data-type="neutral">Ready.</p>
</div>
</header>
<section id="musicResultsSection">
<ul id="musicResultsList"></ul>
</section>
</div>
<div class="musicGutter resizableColumnsGutter" data-gutter-index="1" aria-hidden="true"></div>
<aside id="musicQueuePanel">
<div class="musicSidebarTitle">QUEUE</div>
<div id="musicQueueActions">
<button id="musicQueueClearBtn" class="btn" type="button">Clear</button>
</div>
<ul id="musicQueueList"></ul>
<section id="musicPlayerBar">
<div id="musicPlayerMeta">
<img id="musicPlayerCover" alt="" />
<div>
<div id="musicPlayerTitle"></div>
<div id="musicPlayerArtist"></div>
</div>
</div>
<div id="musicPlayerControls">
<button id="musicPlaybackModeBtn" class="btn musicControlBtn" type="button" title="Playback mode"></button>
<button id="musicPrevBtn" class="btn musicControlBtn" type="button"></button>
<button id="musicPlayPauseBtn" class="btn musicControlBtn" type="button"></button>
<button id="musicNextBtn" class="btn musicControlBtn" type="button">▶▶</button>
</div>
<div id="musicProgressWrap">
<span id="musicCurrentTime">0:00</span>
<input id="musicProgress" type="range" min="0" max="100" step="0.1" value="0" />
<span id="musicDuration">0:00</span>
</div>
</section>
</aside>
<audio id="musicAudio" preload="metadata"></audio>
</div>
</div>
<div id="musicShareModal" class="musicModal hidden" aria-hidden="true">
<div class="musicModalCard" role="dialog" aria-modal="true" aria-labelledby="musicShareTitle">
<div id="musicShareTitle" class="musicSidebarTitle">SHARE TO NOSTR</div>
<div id="musicShareTarget" class="musicPlaylistMeta"></div>
<div class="musicShareSplit">
<div class="musicSharePane">
<div class="musicPlaylistMeta">Compose</div>
<textarea id="musicShareText" placeholder="Write text above the link..."></textarea>
</div>
<div class="musicSharePane">
<div class="musicPlaylistMeta">Preview</div>
<div id="musicShareRendered"></div>
</div>
</div>
<div id="musicShareActions">
<button id="musicShareCancelBtn" class="btn" type="button">Cancel</button>
<button id="musicSharePublishBtn" class="btn" type="button">Publish</button>
</div>
</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="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
} 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 { GreyscaleAPI } from './js/greyscale-api.mjs';
import { SimplePlayer } from './js/greyscale-player.mjs';
import { sendAiChatJson } from './js/ai-chat.mjs';
import {
PLAYLIST_KIND,
buildPlaylistEvent,
createPlaylistIdentifier,
parsePlaylistEvent,
isMusicPlaylistEvent,
} from './js/greyscale-playlists.mjs';
import { mountDotMenu } from './js/dot-menu.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(`[template.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 isDarkMode = false;
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
let pageSettings = {};
let unsubscribeUserSettings = null;
let musicResizableColumns = 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");
const musicEls = {
form: document.getElementById('musicSearchForm'),
input: document.getElementById('musicSearchInput'),
button: document.getElementById('musicSearchButton'),
status: document.getElementById('musicStatusText'),
results: document.getElementById('musicResultsList'),
backBtn: document.getElementById('musicBackBtn'),
homeBtn: document.getElementById('musicHomeBtn'),
forwardBtn: document.getElementById('musicForwardBtn'),
audio: document.getElementById('musicAudio'),
playPauseBtn: document.getElementById('musicPlayPauseBtn'),
prevBtn: document.getElementById('musicPrevBtn'),
nextBtn: document.getElementById('musicNextBtn'),
playerCover: document.getElementById('musicPlayerCover'),
playerTitle: document.getElementById('musicPlayerTitle'),
playerArtist: document.getElementById('musicPlayerArtist'),
currentTime: document.getElementById('musicCurrentTime'),
duration: document.getElementById('musicDuration'),
progress: document.getElementById('musicProgress'),
playlistNameInput: document.getElementById('musicPlaylistNameInput'),
playlistProfile: document.getElementById('musicPlaylistProfile'),
playlistProfileBanner: document.getElementById('musicPlaylistProfileBanner'),
playlistProfileAvatar: document.getElementById('musicPlaylistProfileAvatar'),
playlistProfileTitle: document.getElementById('musicPlaylistProfileTitle'),
playlistCreatorAvatar: document.getElementById('musicPlaylistCreatorAvatar'),
playlistCreatorText: document.getElementById('musicPlaylistCreatorText'),
playlistDescriptionText: document.getElementById('musicPlaylistDescriptionText'),
playlistEditBtn: document.getElementById('musicPlaylistEditBtn'),
playlistProfileEditor: document.getElementById('musicPlaylistProfileEditor'),
playlistTitleInput: document.getElementById('musicPlaylistTitleInput'),
playlistAvatarInput: document.getElementById('musicPlaylistAvatarInput'),
playlistBannerInput: document.getElementById('musicPlaylistBannerInput'),
playlistDescriptionInput: document.getElementById('musicPlaylistDescriptionInput'),
myPlaylistsList: document.getElementById('musicMyPlaylistsList'),
friendPlaylistsList: document.getElementById('musicFriendPlaylistsList'),
importDropZone: document.getElementById('musicImportDropZone'),
importInput: document.getElementById('musicImportInput'),
importProcessing: document.getElementById('musicImportProcessing'),
importStatus: document.getElementById('musicImportStatus'),
importResults: document.getElementById('musicImportResults'),
queueClearBtn: document.getElementById('musicQueueClearBtn'),
queueList: document.getElementById('musicQueueList'),
playbackModeBtn: document.getElementById('musicPlaybackModeBtn'),
shareModal: document.getElementById('musicShareModal'),
shareTarget: document.getElementById('musicShareTarget'),
shareRendered: document.getElementById('musicShareRendered'),
shareText: document.getElementById('musicShareText'),
shareCancelBtn: document.getElementById('musicShareCancelBtn'),
sharePublishBtn: document.getElementById('musicSharePublishBtn'),
viewerPubkeyInput: document.getElementById('musicViewerPubkeyInput'),
viewerLoadBtn: document.getElementById('musicViewerLoadBtn'),
viewerInfo: document.getElementById('musicViewerInfo'),
};
const musicApi = new GreyscaleAPI();
const musicPlayer = new SimplePlayer({
audio: musicEls.audio,
progressEl: musicEls.progress,
currentTimeEl: musicEls.currentTime,
durationEl: musicEls.duration,
});
let musicTracks = [];
let selectedPlaylistId = '';
let activePlaylistView = null;
let isSyncingPlaylistProfileInputs = false;
let isPlaylistProfileEditing = false;
const creatorProfileCache = new Map();
const myPlaylists = new Map();
const friendPlaylists = new Map();
let musicQueue = [];
let queueCurrentIndex = -1;
let musicResultsMode = 'search';
let musicResultsPlaylistId = '';
let draggedPlaylistTrackIndex = -1;
let dropPlaylistTrackIndex = -1;
let dropPlaylistTrackPosition = 'before';
let draggedQueueIndex = -1;
let dropQueueIndex = -1;
let dropQueuePosition = 'before';
let importMatchedTracks = [];
let importMissingTracks = [];
const DND_QUEUE_TRACK_INDEX_MIME = 'application/x-vj-track-index';
let draggedResultTrackIndexForQueue = -1;
let isImportRunning = false;
let importImageDataUrl = '';
let importImageName = '';
let importInputDebounceId = null;
let importDragDepth = 0;
let musicSearchState = {
query: '',
intent: 'track',
tracks: [],
albums: [],
artists: [],
};
let isHandlingRoute = false;
let playbackMode = 'normal';
let currentPlayingTrackId = '';
let pendingShareUrl = '';
let pendingShareArtUrl = '';
let pendingShareLabel = '';
const BLANK_IMAGE_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
const MUSIC_COLUMN_STORAGE_KEY = 'music-column-widths:v1';
const MUSIC_COLUMN_VAR_NAMES = ['--music-col-1', '--music-col-2', '--music-col-3'];
const MUSIC_COLUMN_MIN_WIDTHS = [220, 360, 240];
function readSavedMusicColumnWidths() {
try {
const raw = localStorage.getItem(MUSIC_COLUMN_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== MUSIC_COLUMN_VAR_NAMES.length) return null;
const widths = parsed.map((value, index) => Math.max(MUSIC_COLUMN_MIN_WIDTHS[index], Math.round(Number(value) || 0)));
return widths.every((value) => Number.isFinite(value) && value > 0) ? widths : null;
} catch {
return null;
}
}
function saveMusicColumnWidths(widths) {
try {
localStorage.setItem(MUSIC_COLUMN_STORAGE_KEY, JSON.stringify(widths));
} catch {
// Ignore storage write failures
}
}
function initMusicResizableColumns() {
const appEl = document.getElementById('musicApp');
if (!appEl) return;
if (musicResizableColumns?.destroy) {
musicResizableColumns.destroy();
}
musicResizableColumns = initResizableColumns({
container: appEl,
columnVarNames: MUSIC_COLUMN_VAR_NAMES,
minWidths: MUSIC_COLUMN_MIN_WIDTHS,
gutterSelector: '.musicGutter[data-gutter-index]',
compactMediaQuery: '(max-width: 860px)',
gutterSizeVarName: '--music-gutter-size',
defaultGutterSize: 10,
initialWidths: readSavedMusicColumnWidths(),
onWidthsChange: (widths, { persist } = {}) => {
if (persist) saveMusicColumnWidths(widths);
},
});
}
function formatMusicDuration(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
function escapeMusicHtml(value) {
return String(value)
.replace(/&/g, '\u0026amp;')
.replace(/</g, '\u0026lt;')
.replace(/>/g, '\u0026gt;')
.replace(/"/g, '\u0026quot;')
.replace(/\u0027/g, '\u0026#39;');
}
function setMusicStatus(text, type = 'neutral') {
musicEls.status.textContent = text;
musicEls.status.dataset.type = type;
}
function resolveAuthModeFromUrl() {
const explicitAuth = getExplicitAuthModeFromUrl();
if (explicitAuth) return explicitAuth;
return 'optional';
}
function isAuthRequiredError(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('authentication required');
}
async function tryGetPubkeyWithoutPrompt({ attempts = 8, delayMs = 350 } = {}) {
for (let i = 0; i < attempts; i += 1) {
try {
const pubkey = await getPubkey();
if (pubkey) return pubkey;
} catch {
// Signer can attach slightly after initial script execution.
}
if (i < attempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
return '';
}
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 getEpisodeRouteFromUrlContext() {
const episode = String(getEpisodeFromUrl() || '').trim();
if (!episode) return '';
const encodedEpisode = encodeURIComponent(episode);
if (isExternalTargetMode() && targetPubkey) {
return `/playlist/${encodeURIComponent(String(targetPubkey || ''))}/${encodedEpisode}`;
}
// show is currently contextual only; keep route schema playlist-based
void getShowFromUrl();
return `/playlist/${encodedEpisode}`;
}
function updateViewerControls() {
if (!musicEls.viewerInfo) return;
const displayTarget = targetPubkey || currentPubkey || '';
if (displayTarget) {
musicEls.viewerInfo.textContent = `Viewing playlists for ${displayTarget.slice(0, 12)}`;
} else {
musicEls.viewerInfo.textContent = 'Viewing global public playlists';
}
if (musicEls.viewerPubkeyInput && targetPubkey) {
musicEls.viewerPubkeyInput.value = targetPubkey;
}
if (musicEls.playlistNameInput) {
musicEls.playlistNameInput.disabled = isReadOnlyTargetMode();
}
}
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;
}
currentPubkey = await tryGetPubkeyWithoutPrompt();
isAuthenticated = Boolean(currentPubkey);
}
async function initializeAuthenticatedPageFeatures() {
if (!isAuthenticated || !currentPubkey) {
await injectHeaderLoginButton();
return;
}
await injectHeaderAvatar(currentPubkey);
console.log('[music.html] Authenticated as:', currentPubkey);
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[music.html] getUserSettings failed:', error);
pageSettings = {};
}
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
});
}
}
async function promptLoginIfNeeded() {
if (isAuthenticated && currentPubkey) return true;
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
await initializeAuthenticatedPageFeatures();
syncLoggedInNpubToUrl();
updateViewerControls();
return true;
}
function applyTargetPubkeyFromInput() {
const entered = String(musicEls.viewerPubkeyInput?.value || '').trim();
const decoded = decodePubkeyInput(entered);
if (entered && !decoded) {
setMusicStatus('Invalid npub/pubkey.', 'error');
return;
}
if (decoded) {
const npub = encodeNpub(decoded);
if (npub) {
updateUrlSearchParams({ npub, pubkey: '' });
} else {
updateUrlSearchParams({ npub: '', pubkey: decoded });
}
} else {
updateUrlSearchParams({ pubkey: '', npub: '' });
}
window.location.assign(window.location.toString());
}
function setMusicSearchLoading(loading) {
musicEls.button.disabled = loading;
musicEls.input.disabled = loading;
musicEls.button.textContent = loading ? 'Searching...' : 'Search';
}
function setMusicPlayButtonState() {
musicEls.playPauseBtn.textContent = musicEls.audio.paused ? '▶' : '❚❚';
}
function updatePlayingResultHighlight() {
const isPlayerActive = !!currentPlayingTrackId && !!musicEls.audio && !musicEls.audio.paused;
if (musicEls.playerCover) {
musicEls.playerCover.classList.toggle('isPlaying', isPlayerActive);
}
if (musicEls.results) {
const rows = musicEls.results.querySelectorAll('.musicResultItem[data-index]');
rows.forEach((row) => {
const index = Number(row.dataset.index);
const track = Number.isInteger(index) ? musicTracks[index] : null;
const isPlaying = isPlayerActive && String(track?.id || '') === String(currentPlayingTrackId);
row.classList.toggle('isPlaying', isPlaying);
});
const albumCover = musicEls.results.querySelector('.musicAlbumCardImage[data-album-detail-cover="true"]');
if (albumCover) {
const albumTracks = Array.isArray(musicSearchState?.detailView?.payload?.tracks)
? musicSearchState.detailView.payload.tracks
: [];
const albumHasCurrentTrack = albumTracks.some((track) => String(track?.id || '') === String(currentPlayingTrackId));
albumCover.classList.toggle('isPlaying', isPlayerActive && albumHasCurrentTrack);
}
}
}
function updateMusicPlayerMeta(track) {
if (!track) {
currentPlayingTrackId = '';
musicEls.playerTitle.textContent = '';
musicEls.playerArtist.textContent = '';
musicEls.playerCover.removeAttribute('src');
musicEls.playerCover.style.display = 'none';
updatePlayingResultHighlight();
return;
}
currentPlayingTrackId = String(track.id || '');
musicEls.playerTitle.textContent = track.title || '';
musicEls.playerArtist.textContent = track.artist || '';
const cover = musicApi.getCoverUrl(track.cover, 320);
if (cover) {
musicEls.playerCover.src = cover;
musicEls.playerCover.style.display = 'block';
} else {
musicEls.playerCover.removeAttribute('src');
musicEls.playerCover.style.display = 'none';
}
updatePlayingResultHighlight();
}
function getPlaylistStorageKey() {
return `music-playlists:${currentPubkey || 'anon'}`;
}
function getQueueStorageKey() {
return `music-queue:${currentPubkey || 'anon'}`;
}
function normalizeQueueTrack(track) {
if (!track || typeof track !== 'object') return null;
return {
id: track.id,
title: track.title || 'Unknown title',
artist: track.artist || 'Unknown artist',
duration: track.duration || 0,
cover: track.cover || '',
albumTitle: track.albumTitle || '',
albumId: track.albumId || track.album?.id || track.raw?.album?.id || '',
artistId: track.artistId || track.artist?.id || track.raw?.artist?.id || track.raw?.artists?.[0]?.id || '',
};
}
function getCurrentPlaylist() {
if (!selectedPlaylistId) return null;
return myPlaylists.get(selectedPlaylistId) || null;
}
function savePlaylistsLocal() {
try {
const payload = Array.from(myPlaylists.values()).map((playlist) => ({
identifier: playlist.identifier,
title: playlist.title,
description: playlist.description || '',
image: playlist.image || '',
banner: playlist.banner || '',
tracks: Array.isArray(playlist.tracks) ? playlist.tracks : [],
}));
localStorage.setItem(getPlaylistStorageKey(), JSON.stringify(payload));
} catch (error) {
console.warn('[music] Could not save playlists locally:', error);
}
}
function loadPlaylistsLocal() {
try {
const raw = localStorage.getItem(getPlaylistStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return;
for (const playlist of parsed) {
if (!playlist?.identifier) continue;
myPlaylists.set(playlist.identifier, {
identifier: playlist.identifier,
title: playlist.title || 'Untitled playlist',
description: playlist.description || '',
image: playlist.image || '',
banner: playlist.banner || '',
tracks: Array.isArray(playlist.tracks) ? playlist.tracks : [],
pubkey: currentPubkey || '',
eventId: playlist.eventId || '',
});
}
} catch (error) {
console.warn('[music] Could not load playlists locally:', error);
}
}
function getPlaybackModeMeta(mode) {
if (mode === 'loop-queue') return { label: '⟳🔁', title: 'Mode: Loop queue' };
if (mode === 'loop-track') return { label: '⟳🔂', title: 'Mode: Loop track' };
if (mode === 'shuffle') return { label: '⟳🔀', title: 'Mode: Shuffle' };
return { label: '⟳—', title: 'Mode: Normal' };
}
function updatePlaybackModeButton() {
if (!musicEls.playbackModeBtn) return;
const meta = getPlaybackModeMeta(playbackMode);
musicEls.playbackModeBtn.textContent = meta.label;
musicEls.playbackModeBtn.title = meta.title;
musicEls.playbackModeBtn.dataset.mode = playbackMode;
}
function setPlaybackMode(mode, { persist = true } = {}) {
const allowed = new Set(['normal', 'loop-queue', 'loop-track', 'shuffle']);
playbackMode = allowed.has(mode) ? mode : 'normal';
updatePlaybackModeButton();
if (persist) saveQueueLocal();
}
function cyclePlaybackMode() {
if (playbackMode === 'normal') {
setPlaybackMode('loop-queue');
} else if (playbackMode === 'loop-queue') {
setPlaybackMode('loop-track');
} else if (playbackMode === 'loop-track') {
setPlaybackMode('shuffle');
} else {
setPlaybackMode('normal');
}
setMusicStatus(`Playback mode: ${getPlaybackModeMeta(playbackMode).title.replace('Mode: ', '')}`, 'ok');
}
function saveQueueLocal() {
try {
const payload = {
currentIndex: queueCurrentIndex,
playbackMode,
items: musicQueue.map((track) => normalizeQueueTrack(track)).filter(Boolean),
};
localStorage.setItem(getQueueStorageKey(), JSON.stringify(payload));
} catch (error) {
console.warn('[music] Could not save queue locally:', error);
}
}
function loadQueueLocal() {
try {
const raw = localStorage.getItem(getQueueStorageKey());
if (!raw) {
setPlaybackMode('normal', { persist: false });
return;
}
const parsed = JSON.parse(raw);
const items = Array.isArray(parsed?.items) ? parsed.items : [];
musicQueue = items.map((track) => normalizeQueueTrack(track)).filter(Boolean);
queueCurrentIndex = Number.isInteger(parsed?.currentIndex) ? parsed.currentIndex : -1;
if (queueCurrentIndex >= musicQueue.length) queueCurrentIndex = musicQueue.length - 1;
setPlaybackMode(parsed?.playbackMode || 'normal', { persist: false });
} catch (error) {
console.warn('[music] Could not load queue locally:', error);
setPlaybackMode('normal', { persist: false });
}
}
const mountedMusicMenus = {
myPlaylists: [],
friendPlaylists: [],
queue: [],
tracks: [],
albums: [],
};
function destroyMountedMenuInstances(instances) {
if (!Array.isArray(instances) || !instances.length) return;
instances.forEach((instance) => {
try {
instance?.destroy?.();
} catch {
// Ignore teardown errors during remount.
}
});
instances.length = 0;
}
function getSortedPlaylistEntries() {
return Array.from(myPlaylists.values()).sort((a, b) => (a.title || '').localeCompare(b.title || ''));
}
function buildAddToPlaylistSubmenuItems(track) {
const playlists = getSortedPlaylistEntries();
const submenu = playlists.map((playlist) => ({
label: playlist.title || 'Untitled playlist',
onClick: () => {
addTrackToPlaylistById(track, playlist.identifier);
},
}));
submenu.push({
label: '+ Add new playlist',
onClick: () => {
void createPlaylistAndAddTrack(track);
},
});
return submenu;
}
function mountMyPlaylistMenus() {
destroyMountedMenuInstances(mountedMusicMenus.myPlaylists);
if (!musicEls.myPlaylistsList) return;
const hosts = musicEls.myPlaylistsList.querySelectorAll('[data-playlist-menu-host="true"]');
hosts.forEach((host) => {
const playlistId = String(host.getAttribute('data-playlist-id') || '').trim();
if (!playlistId) return;
const instance = mountDotMenu(host, {
triggerLabel: '⋯',
ariaLabel: 'Playlist actions',
position: 'right',
items: [
{
label: 'Play now',
onClick: () => {
const playlist = myPlaylists.get(playlistId);
if (playlist) void queuePlaylist(playlist, { playNow: true });
},
},
{
label: 'Add to queue',
onClick: () => {
const playlist = myPlaylists.get(playlistId);
if (playlist) void queuePlaylist(playlist, { playNow: false });
},
},
{
label: 'Rename',
onClick: () => {
void renameMyPlaylist(playlistId);
},
},
{
label: 'Download playlist',
onClick: () => {
const playlist = myPlaylists.get(playlistId);
if (playlist) void downloadPlaylistTracks(playlist);
},
},
{
label: 'Share',
onClick: () => {
const playlist = myPlaylists.get(playlistId);
if (!playlist) return;
openShareFor('playlist', {
identifier: playlist.identifier,
label: playlist.title || 'Playlist',
image: playlist.image || '',
});
},
},
{
label: 'Delete playlist',
onClick: () => {
void deleteMyPlaylist(playlistId);
},
},
],
});
mountedMusicMenus.myPlaylists.push(instance);
});
}
function mountFriendPlaylistMenus() {
destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists);
if (!musicEls.friendPlaylistsList) return;
const hosts = musicEls.friendPlaylistsList.querySelectorAll('[data-friend-playlist-menu-host="true"]');
hosts.forEach((host) => {
const key = String(host.getAttribute('data-friend-playlist-key') || '').trim();
if (!key) return;
const instance = mountDotMenu(host, {
triggerLabel: '⋯',
ariaLabel: 'Friend playlist actions',
position: 'right',
items: [
{
label: 'Play now',
onClick: () => {
const playlist = getFriendPlaylistByKey(key);
if (playlist) void queuePlaylist(playlist, { playNow: true });
},
},
{
label: 'Add to queue',
onClick: () => {
const playlist = getFriendPlaylistByKey(key);
if (playlist) void queuePlaylist(playlist, { playNow: false });
},
},
{
label: 'Download playlist',
onClick: () => {
const playlist = getFriendPlaylistByKey(key);
if (playlist) void downloadPlaylistTracks(playlist);
},
},
{
label: 'Copy to my playlists',
onClick: () => {
void (async () => {
if (isReadOnlyTargetMode()) {
setMusicStatus('Clear target user to copy playlists into your account.', 'error');
return;
}
await promptLoginIfNeeded();
const playlist = getFriendPlaylistByKey(key);
if (!playlist) {
setMusicStatus('Friend playlist not found.', 'error');
return;
}
const copyTitle = `${playlist.title || 'Untitled playlist'} (Copy)`;
const identifier = createPlaylistIdentifier(copyTitle);
const copiedTracks = (Array.isArray(playlist.tracks) ? playlist.tracks : [])
.map((track) => normalizeQueueTrack(track))
.filter(Boolean);
const copiedPlaylist = {
identifier,
title: copyTitle,
description: playlist.description || '',
image: playlist.image || '',
banner: playlist.banner || '',
tracks: copiedTracks,
pubkey: currentPubkey || '',
eventId: '',
};
myPlaylists.set(identifier, copiedPlaylist);
selectedPlaylistId = identifier;
activePlaylistView = { scope: 'my', key: identifier, playlist: copiedPlaylist };
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
await publishPlaylistRecord(copiedPlaylist);
setMusicStatus(`Copied playlist: ${copyTitle}`, 'ok');
navigate(`/playlist/${encodeURIComponent(identifier)}`);
})();
},
},
],
});
mountedMusicMenus.friendPlaylists.push(instance);
});
}
function mountQueueMenus() {
destroyMountedMenuInstances(mountedMusicMenus.queue);
if (!musicEls.queueList) return;
const hosts = musicEls.queueList.querySelectorAll('[data-queue-menu-host="true"]');
hosts.forEach((host) => {
const index = Number(host.getAttribute('data-queue-index'));
const track = musicQueue[index];
if (!track) return;
const artistId = String(host.getAttribute('data-queue-artist-id') || '').trim();
const albumId = String(host.getAttribute('data-queue-album-id') || '').trim();
const items = [
{
label: 'Add to playlist ▸',
submenu: buildAddToPlaylistSubmenuItems(track),
},
...(artistId
? [{
label: 'Go to artist',
onClick: () => {
navigate(`/artist/${encodeURIComponent(artistId)}`);
},
}]
: []),
...(albumId
? [{
label: 'Go to album',
onClick: () => {
navigate(`/album/${encodeURIComponent(albumId)}`);
},
}]
: []),
{
label: 'Download',
onClick: () => {
void downloadTrackFile(track, { suppressStatus: false });
},
},
{
label: 'Share',
onClick: () => {
if (track?.id) {
openShareFor('track', { id: track.id, label: track.title || 'Track', cover: track.cover || '' });
}
},
},
{
label: 'Remove',
onClick: () => {
removeQueueIndex(index);
},
},
];
const instance = mountDotMenu(host, {
triggerLabel: '⋯',
ariaLabel: 'Queue item actions',
position: 'right',
items,
});
mountedMusicMenus.queue.push(instance);
});
}
function mountResultsMenus() {
destroyMountedMenuInstances(mountedMusicMenus.tracks);
destroyMountedMenuInstances(mountedMusicMenus.albums);
if (!musicEls.results) return;
const trackHosts = musicEls.results.querySelectorAll('[data-track-menu-host="true"]');
trackHosts.forEach((host) => {
const index = Number(host.getAttribute('data-track-index'));
const track = musicTracks[index];
if (!track) return;
const artistId = String(host.getAttribute('data-track-artist-id') || '').trim();
const albumId = String(host.getAttribute('data-track-album-id') || '').trim();
const allowDelete = host.getAttribute('data-track-allow-delete') === 'true';
const items = [
{
label: 'Add to playlist ▸',
submenu: buildAddToPlaylistSubmenuItems(track),
},
{
label: 'Add to queue',
onClick: () => {
addTrackToQueue(track);
},
},
{
label: 'Add album to queue',
onClick: () => {
queueAlbumFromTrack(track);
},
},
...(artistId
? [{
label: 'Go to artist',
onClick: () => {
navigate(`/artist/${encodeURIComponent(artistId)}`);
},
}]
: []),
...(albumId
? [{
label: 'Go to album',
onClick: () => {
navigate(`/album/${encodeURIComponent(albumId)}`);
},
}]
: []),
...(allowDelete
? [
{
label: 'Set image',
onClick: () => {
setCurrentPlaylistMediaFromTrack(index, 'image');
},
},
{
label: 'Set banner',
onClick: () => {
setCurrentPlaylistMediaFromTrack(index, 'banner');
},
},
{
label: 'Remove from playlist',
onClick: () => {
removeTrackFromCurrentPlaylist(index);
},
},
]
: []),
{
label: 'Download',
onClick: () => {
void downloadMusicByIndex(index);
},
},
{
label: 'Share',
onClick: () => {
if (track?.id) {
openShareFor('track', { id: track.id, label: track.title || 'Track', cover: track.cover || '' });
}
},
},
];
const instance = mountDotMenu(host, {
triggerLabel: '⋯',
ariaLabel: 'Track actions',
position: 'right',
items,
});
mountedMusicMenus.tracks.push(instance);
});
const albumHosts = musicEls.results.querySelectorAll('[data-album-menu-host="true"]');
albumHosts.forEach((host) => {
const albumId = String(host.getAttribute('data-album-id') || '').trim();
if (!albumId) return;
const title = String(host.getAttribute('data-album-title') || 'Unknown album');
const cover = String(host.getAttribute('data-album-cover') || '');
const instance = mountDotMenu(host, {
triggerLabel: '⋯',
ariaLabel: 'Album actions',
position: 'right',
items: [
{
label: 'Add to queue',
onClick: () => {
void queueAlbumById(albumId);
},
},
{
label: 'Download album',
onClick: () => {
void downloadAlbumById(albumId);
},
},
{
label: 'Open album',
onClick: () => {
navigate(`/album/${encodeURIComponent(albumId)}`);
},
},
{
label: 'Share',
onClick: () => {
openShareFor('album', { id: albumId, label: title || 'Album', cover });
},
},
],
});
mountedMusicMenus.albums.push(instance);
});
}
function renderMyPlaylists() {
if (isReadOnlyTargetMode()) {
destroyMountedMenuInstances(mountedMusicMenus.myPlaylists);
musicEls.myPlaylistsList.innerHTML = '<li class="musicPlaylistMeta">Viewing target user public playlists.</li>';
return;
}
const items = Array.from(myPlaylists.values()).sort((a, b) => (a.title || '').localeCompare(b.title || ''));
if (!items.length) {
destroyMountedMenuInstances(mountedMusicMenus.myPlaylists);
musicEls.myPlaylistsList.innerHTML = '<li class="musicPlaylistMeta">No playlists yet.</li>';
return;
}
musicEls.myPlaylistsList.innerHTML = items
.map((playlist) => `
<li class="musicPlaylistItem ${playlist.identifier === selectedPlaylistId ? 'active' : ''}" data-playlist-id="${escapeMusicHtml(playlist.identifier)}">
<div class="musicPlaylistPrimary">
<img class="musicPlaylistCover" src="${escapeMusicHtml(getPlaylistAvatarUrl(playlist))}" alt="" loading="lazy" />
<div class="musicPlaylistText">
<span class="musicPlaylistTitle" data-playlist-title-id="${escapeMusicHtml(playlist.identifier)}">${escapeMusicHtml(playlist.title || 'Untitled playlist')}</span>
<span class="musicPlaylistMeta">${(playlist.tracks || []).length} tracks</span>
</div>
</div>
<div
class="musicPlaylistItemActions"
data-playlist-menu-host="true"
data-playlist-id="${escapeMusicHtml(playlist.identifier)}"
></div>
</li>
`)
.join('');
mountMyPlaylistMenus();
}
function getFriendPlaylistRouteKey(playlist) {
if (!playlist || typeof playlist !== 'object') return '';
const pubkey = String(playlist.pubkey || '').trim();
const identifier = String(playlist.identifier || '').trim();
if (pubkey && identifier) return `${pubkey}:${identifier}`;
return String(playlist.eventId || '').trim() || identifier;
}
function renderFriendPlaylists() {
const items = Array.from(friendPlaylists.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
if (!items.length) {
destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists);
musicEls.friendPlaylistsList.innerHTML = '<li class="musicPlaylistMeta">No friend playlists found.</li>';
return;
}
musicEls.friendPlaylistsList.innerHTML = items
.map((playlist) => {
const label = playlist.title || 'Untitled playlist';
const key = getFriendPlaylistRouteKey(playlist);
const creatorName = getPlaylistCreatorLabel(playlist);
return `
<li class="musicPlaylistItem" data-friend-playlist-key="${escapeMusicHtml(key)}">
<div class="musicPlaylistPrimary">
<img class="musicPlaylistCover" src="${escapeMusicHtml(getPlaylistAvatarUrl(playlist))}" alt="" loading="lazy" />
<div class="musicPlaylistText">
<span class="musicPlaylistTitle">${escapeMusicHtml(label)}</span>
<span class="musicPlaylistMeta">${escapeMusicHtml(creatorName)}</span>
</div>
</div>
<div
class="musicPlaylistItemActions"
data-friend-playlist-menu-host="true"
data-friend-playlist-key="${escapeMusicHtml(key)}"
></div>
</li>
`;
})
.join('');
mountFriendPlaylistMenus();
}
function shortPubkey(value) {
if (!value) return 'unknown';
return `${String(value).slice(0, 8)}`;
}
function getPlaylistAvatarUrl(playlist) {
if (playlist?.image) return playlist.image;
if (Array.isArray(playlist?.tracks) && playlist.tracks.length > 0) {
const first = playlist.tracks.find((track) => track?.cover);
if (first?.cover) return musicApi.getCoverUrl(first.cover, 320);
}
return BLANK_IMAGE_PIXEL;
}
function getPlaylistBannerUrl(playlist) {
if (playlist?.banner) return playlist.banner;
return getPlaylistAvatarUrl(playlist);
}
function parseKind0Profile(profileLike) {
if (!profileLike || typeof profileLike !== 'object') return null;
if (typeof profileLike.picture === 'string' || typeof profileLike.name === 'string' || typeof profileLike.display_name === 'string') {
return profileLike;
}
const content = profileLike?.content;
if (typeof content !== 'string' || !content.trim()) return null;
try {
const parsed = JSON.parse(content);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
async function ensureCreatorProfile(pubkey) {
if (!pubkey || creatorProfileCache.has(pubkey)) return;
try {
const profile = await fetchCachedProfile(pubkey);
const parsed = parseKind0Profile(profile);
if (!parsed) return;
creatorProfileCache.set(pubkey, parsed);
const activePubkey = activePlaylistView?.playlist?.pubkey || '';
if (activePubkey && activePubkey === pubkey) {
renderPlaylistProfileCard();
}
renderFriendPlaylists();
} catch (error) {
console.warn('[music] Could not load creator profile:', error);
}
}
function getPlaylistCreatorLabel(playlist) {
const pubkey = String(playlist?.pubkey || '').trim();
if (!pubkey) return 'unknown';
void ensureCreatorProfile(pubkey);
const creatorProfile = creatorProfileCache.get(pubkey) || null;
return creatorProfile?.display_name || creatorProfile?.name || shortPubkey(pubkey);
}
function getPlaylistCreatorAvatarUrl(playlist) {
const pubkey = playlist?.pubkey || '';
const creatorProfile = creatorProfileCache.get(pubkey) || null;
if (creatorProfile?.picture) return creatorProfile.picture;
if (pubkey) {
return `https://robohash.org/${encodeURIComponent(pubkey)}?set=set4&size=96x96`;
}
return getPlaylistAvatarUrl(playlist);
}
function isOwnPlaylistRecord(playlist) {
if (!playlist) return false;
if (!playlist.pubkey) return true;
return playlist.pubkey === currentPubkey;
}
async function publishPlaylistRecord(playlist) {
if (!playlist) return;
if (isReadOnlyTargetMode()) {
setMusicStatus('Viewing another user; clear target to edit your playlists.', 'error');
return;
}
if (!isAuthenticated || !currentPubkey) {
setMusicStatus('Sign in to publish playlists.', 'error');
return;
}
try {
const event = buildPlaylistEvent(playlist);
const result = await publishEvent(event);
const publishedId = result?.eventId || result?.id || '';
if (publishedId) playlist.eventId = publishedId;
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
} catch (error) {
console.error(error);
setMusicStatus(`Playlist publish failed: ${error.message || 'Unknown error'}`, 'error');
}
}
function renderPlaylistProfileCard() {
if (!musicEls.playlistProfile) return;
const playlist = activePlaylistView?.playlist || null;
if (!playlist) {
isPlaylistProfileEditing = false;
musicEls.playlistProfile.classList.add('hidden');
musicEls.playlistProfile.style.removeProperty('--music-playlist-banner');
return;
}
const own = isOwnPlaylistRecord(playlist);
if (!own) isPlaylistProfileEditing = false;
musicEls.playlistProfile.classList.remove('hidden');
const creatorPubkey = playlist.pubkey || currentPubkey || '';
const creatorLabel = getPlaylistCreatorLabel({ ...playlist, pubkey: creatorPubkey });
const bannerUrl = getPlaylistBannerUrl(playlist);
const escapedBannerUrl = String(bannerUrl || '').replace(/"/g, '\\"');
musicEls.playlistProfileBanner.src = bannerUrl;
musicEls.playlistProfile.style.setProperty(
'--music-playlist-banner',
bannerUrl ? `url("${escapedBannerUrl}")` : 'none',
);
musicEls.playlistProfileAvatar.src = getPlaylistAvatarUrl(playlist);
musicEls.playlistProfileTitle.textContent = playlist.title || 'Untitled playlist';
musicEls.playlistCreatorAvatar.src = getPlaylistCreatorAvatarUrl({ ...playlist, pubkey: creatorPubkey });
musicEls.playlistCreatorText.textContent = creatorLabel;
musicEls.playlistDescriptionText.textContent = playlist.description || '';
musicEls.playlistDescriptionText.style.display = playlist.description ? '' : 'none';
isSyncingPlaylistProfileInputs = true;
musicEls.playlistTitleInput.value = playlist.title || '';
musicEls.playlistAvatarInput.value = playlist.image || '';
musicEls.playlistBannerInput.value = playlist.banner || '';
musicEls.playlistDescriptionInput.value = playlist.description || '';
isSyncingPlaylistProfileInputs = false;
musicEls.playlistTitleInput.disabled = !own;
musicEls.playlistAvatarInput.disabled = !own;
musicEls.playlistBannerInput.disabled = !own;
musicEls.playlistDescriptionInput.disabled = !own;
musicEls.playlistEditBtn.style.display = own ? '' : 'none';
musicEls.playlistEditBtn.textContent = isPlaylistProfileEditing ? 'Done' : 'Edit';
musicEls.playlistProfileEditor.classList.toggle('hidden', !own || !isPlaylistProfileEditing);
}
function selectMyPlaylist(identifier) {
const playlist = myPlaylists.get(identifier);
if (!playlist) return;
selectedPlaylistId = identifier;
activePlaylistView = { scope: 'my', key: identifier, playlist };
renderMyPlaylists();
renderPlaylistProfileCard();
setMusicStatus(`Selected playlist: ${playlist.title || 'Untitled playlist'}`, 'ok');
}
async function createNewPlaylist() {
if (isReadOnlyTargetMode()) {
setMusicStatus('Clear target user to create your own playlists.', 'error');
return;
}
await promptLoginIfNeeded();
const proposedName = musicEls.playlistNameInput.value.trim() || `Playlist ${myPlaylists.size + 1}`;
const identifier = createPlaylistIdentifier(proposedName);
const playlist = {
identifier,
title: proposedName,
description: '',
image: '',
banner: '',
tracks: [],
pubkey: currentPubkey || '',
eventId: '',
};
myPlaylists.set(identifier, playlist);
selectedPlaylistId = identifier;
activePlaylistView = { scope: 'my', key: identifier, playlist };
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
await publishPlaylistRecord(playlist);
musicEls.playlistNameInput.value = '';
setMusicStatus(`Created playlist: ${proposedName}`, 'ok');
}
async function renameMyPlaylist(identifier) {
const id = String(identifier || '').trim();
if (!id) return;
const playlist = myPlaylists.get(id);
if (!playlist) return;
const titleEl = musicEls.myPlaylistsList.querySelector(`[data-playlist-title-id="${id}"]`);
if (!titleEl) return;
titleEl.contentEditable = 'true';
titleEl.focus();
// Select all text
const range = document.createRange();
range.selectNodeContents(titleEl);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
const handleSave = async () => {
titleEl.contentEditable = 'false';
const newName = titleEl.textContent.trim();
if (!newName || newName === playlist.title) {
titleEl.textContent = playlist.title;
return;
}
playlist.title = newName;
await publishPlaylistRecord(playlist);
renderMyPlaylists();
if (activePlaylistView?.scope === 'my' && activePlaylistView?.key === id) {
renderPlaylistProfileCard();
}
setMusicStatus(`Renamed playlist to: ${newName}`, 'ok');
};
titleEl.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
titleEl.blur();
} else if (e.key === 'Escape') {
titleEl.textContent = playlist.title;
titleEl.blur();
}
};
titleEl.onblur = handleSave;
}
async function deleteMyPlaylist(identifier) {
const id = String(identifier || '').trim();
if (!id) return;
const playlist = myPlaylists.get(id);
if (!playlist) return;
const label = playlist.title || 'Untitled playlist';
const confirmed = window.confirm(`Delete playlist "${label}"?`);
if (!confirmed) return;
myPlaylists.delete(id);
if (selectedPlaylistId === id) {
selectedPlaylistId = '';
}
if (activePlaylistView?.scope === 'my' && activePlaylistView?.key === id) {
activePlaylistView = null;
}
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
const { page, parts } = parseRoute();
if (page === 'playlist' && parts.length === 1 && parts[0] === id) {
navigate('/');
} else {
musicEls.results.innerHTML = '<li class="musicResultEmpty">Search songs, albums, artists…</li>';
setResultsContext('search');
}
setMusicStatus(`Deleted playlist: ${label}`, 'ok');
}
function addTrackToPlaylistById(track, playlistId, { silent = false } = {}) {
const id = String(playlistId || '').trim();
const playlist = myPlaylists.get(id);
if (!playlist) {
if (!silent) setMusicStatus('Playlist not found.', 'error');
return false;
}
const exists = (playlist.tracks || []).some((item) => Number(item?.id) === Number(track?.id));
if (exists) {
if (!silent) setMusicStatus('Track already in playlist.', 'neutral');
return false;
}
playlist.tracks.push({
id: track.id,
title: track.title || '',
artist: track.artist || '',
duration: track.duration || 0,
cover: track.cover || '',
albumTitle: track.albumTitle || '',
albumId: track.albumId || track.album?.id || '',
artistId: track.artistId || track.artist?.id || '',
});
if (!playlist.image && track.cover) {
playlist.image = musicApi.getCoverUrl(track.cover, 320);
}
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
void publishPlaylistRecord(playlist);
if (!silent) setMusicStatus(`Added to ${playlist.title}: ${track.title}`, 'ok');
return true;
}
function addTrackToCurrentPlaylist(track) {
const playlist = getCurrentPlaylist();
if (!playlist) {
setMusicStatus('Create or select a playlist first.', 'error');
return;
}
addTrackToPlaylistById(track, playlist.identifier);
}
function removeTrackFromCurrentPlaylist(index) {
const playlist = getCurrentPlaylist();
if (!playlist || !Array.isArray(playlist.tracks)) return;
if (!Number.isInteger(index) || index < 0 || index >= playlist.tracks.length) return;
const [removed] = playlist.tracks.splice(index, 1);
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
musicTracks = [...playlist.tracks];
setResultsContext('my-playlist', playlist.identifier);
renderMusicResults(musicTracks);
void publishPlaylistRecord(playlist);
setMusicStatus(`Removed from ${playlist.title}: ${removed?.title || 'Track'}`, 'ok');
}
function setCurrentPlaylistMediaFromTrack(index, target = 'image') {
const playlist = getCurrentPlaylist();
if (!playlist || !Array.isArray(playlist.tracks)) {
setMusicStatus('Select one of your playlists first.', 'error');
return;
}
if (!isOwnPlaylistRecord(playlist)) {
setMusicStatus('You can only edit your own playlists.', 'error');
return;
}
if (!Number.isInteger(index) || index < 0 || index >= playlist.tracks.length) return;
const track = playlist.tracks[index];
const coverUrl = musicApi.getCoverUrl(track?.cover || '', 320);
if (!coverUrl) {
setMusicStatus('Track has no image to use.', 'error');
return;
}
if (target === 'banner') {
playlist.banner = coverUrl;
} else {
playlist.image = coverUrl;
}
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
void publishPlaylistRecord(playlist);
setMusicStatus(`Set playlist ${target === 'banner' ? 'banner' : 'image'} from track: ${track?.title || 'Track'}`, 'ok');
}
async function createPlaylistAndAddTrack(track) {
if (isReadOnlyTargetMode()) {
setMusicStatus('Clear target user to create your own playlists.', 'error');
return;
}
await promptLoginIfNeeded();
const baseName = track?.title ? `${track.title} picks` : `Playlist ${myPlaylists.size + 1}`;
const identifier = createPlaylistIdentifier(baseName);
const playlist = {
identifier,
title: baseName,
description: '',
image: '',
banner: '',
tracks: [],
pubkey: currentPubkey || '',
eventId: '',
};
myPlaylists.set(identifier, playlist);
selectedPlaylistId = identifier;
activePlaylistView = { scope: 'my', key: identifier, playlist };
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
await publishPlaylistRecord(playlist);
addTrackToPlaylistById(track, identifier);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeMatchText(value) {
return String(value || '')
.toLowerCase()
.replace(/\([^)]*\)/g, ' ')
.replace(/\[[^\]]*\]/g, ' ')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function parseImportTextFallback(rawText) {
return String(rawText || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const csvParts = line.split(',').map((part) => part.trim()).filter(Boolean);
if (csvParts.length >= 2) {
return { title: csvParts[0], artist: csvParts[1] };
}
const dashParts = line.split(/\s[-–—]\s/).map((part) => part.trim()).filter(Boolean);
if (dashParts.length >= 2) {
return { artist: dashParts[0], title: dashParts[1] };
}
const byParts = line.split(/\s+by\s+/i).map((part) => part.trim()).filter(Boolean);
if (byParts.length >= 2) {
return { title: byParts[0], artist: byParts[1] };
}
return { title: line, artist: '' };
})
.filter((item) => item.title);
}
async function readImageFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read image file.'));
reader.readAsDataURL(file);
});
}
async function readTextFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read text file.'));
reader.readAsText(file);
});
}
function renderImportProcessing(active) {
musicEls.importDropZone?.classList.toggle('processing', Boolean(active));
musicEls.importProcessing?.classList.toggle('visible', Boolean(active));
if (musicEls.importProcessing) {
musicEls.importProcessing.setAttribute('aria-hidden', active ? 'false' : 'true');
}
}
function scheduleImportAutoRun(delayMs = 1500) {
if (importInputDebounceId) {
clearTimeout(importInputDebounceId);
importInputDebounceId = null;
}
importInputDebounceId = setTimeout(() => {
importInputDebounceId = null;
void runImportMatching();
}, delayMs);
}
function clearImportImageAttachment() {
importImageDataUrl = '';
importImageName = '';
console.log('[music import] image_attachment:cleared');
}
async function attachImportImageFile(file) {
importImageDataUrl = await readImageFileAsDataUrl(file);
importImageName = file.name || 'playlist-screenshot';
setImportStatus(`Image received: ${importImageName}`);
console.log('[music import] image_attachment:set', {
name: importImageName,
type: file.type,
size: file.size,
});
}
async function appendImportTextFile(file) {
const text = await readTextFile(file);
const current = String(musicEls.importInput?.value || '');
const next = current ? `${current}\n${text}` : text;
if (musicEls.importInput) {
musicEls.importInput.value = next;
}
setImportStatus(`Added text from file: ${file.name || 'text file'}`);
console.log('[music import] text_file:appended', {
name: file.name || '',
addedLength: text.length,
});
}
function setImportStatus(text) {
if (!musicEls.importStatus) return;
musicEls.importStatus.textContent = String(text || '');
}
function renderImportResults() {
if (!musicEls.importResults) return;
const matched = importMatchedTracks;
const missing = importMissingTracks;
if (!matched.length && !missing.length) {
musicEls.importResults.innerHTML = '';
return;
}
const matchedHtml = matched.length
? `<div><strong>Matched (${matched.length})</strong><ul>${matched
.map((track) => `<li>${escapeMusicHtml(track.title || 'Unknown')}${escapeMusicHtml(track.artist || 'Unknown')}</li>`)
.join('')}</ul></div>`
: '<div><strong>Matched (0)</strong></div>';
const actionsHtml = matched.length
? `<div class="musicImportActions"><button id="musicImportAddMatchesInlineBtn" class="btn" type="button" ${isImportRunning ? 'disabled' : ''}>Add Matches</button></div>`
: '';
const missingHtml = missing.length
? `<div><strong>Missing (${missing.length})</strong><ul>${missing
.map((item) => `<li>${escapeMusicHtml(item.title || 'Unknown')} ${item.artist ? `${escapeMusicHtml(item.artist)}` : ''}</li>`)
.join('')}</ul></div>`
: '<div><strong>Missing (0)</strong></div>';
musicEls.importResults.innerHTML = `${matchedHtml}${actionsHtml}${missingHtml}`;
}
async function extractTracksFromImportText(rawText, imageDataUrl = '') {
const system = 'Extract songs from user text and/or playlist screenshot image and return ONLY valid JSON with shape {"tracks":[{"title":"","artist":""}]}. Use OCR on image if present. No markdown, no prose.';
const textInput = String(rawText || '').trim();
const hasImage = Boolean(String(imageDataUrl || '').trim());
const userText = textInput
? `Extract track list from this text:\n\n${textInput}`
: 'Extract track list from the attached screenshot image only.';
console.log('[music import] extractTracksFromImportText:start', {
inputLength: textInput.length,
hasImage,
});
const userMessageContent = hasImage
? [
{ type: 'text', text: userText },
{ type: 'image_url', image_url: { url: imageDataUrl } },
]
: userText;
const aiResult = await sendAiChatJson({
messages: [
{ role: 'system', content: system },
{ role: 'user', content: userMessageContent },
],
maxTokens: 1000,
temperature: 0.1,
});
const tracks = Array.isArray(aiResult?.json?.tracks) ? aiResult.json.tracks : [];
const cleaned = tracks
.map((item) => ({
title: String(item?.title || '').trim(),
artist: String(item?.artist || '').trim(),
}))
.filter((item) => item.title);
console.log('[music import] extractTracksFromImportText:response', {
parsedTracks: tracks.length,
cleanedTracks: cleaned.length,
hasImage,
sample: cleaned.slice(0, 3),
});
if (!cleaned.length) {
throw new Error('AI returned no usable tracks.');
}
return cleaned;
}
function pickBestImportCandidate(items, wantedTitle, wantedArtist) {
if (!Array.isArray(items) || !items.length) return null;
if (!wantedArtist) return items[0];
const wantedArtistNorm = normalizeMatchText(wantedArtist);
const wantedTitleNorm = normalizeMatchText(wantedTitle);
let best = null;
let bestScore = -1;
for (const item of items) {
const artistNorm = normalizeMatchText(item?.artist || '');
const titleNorm = normalizeMatchText(item?.title || '');
let score = 0;
if (artistNorm && wantedArtistNorm && (artistNorm.includes(wantedArtistNorm) || wantedArtistNorm.includes(artistNorm))) score += 4;
if (titleNorm && wantedTitleNorm && (titleNorm.includes(wantedTitleNorm) || wantedTitleNorm.includes(titleNorm))) score += 3;
if (score > bestScore) {
best = item;
bestScore = score;
}
}
return best || items[0];
}
async function runImportMatching() {
if (isImportRunning) {
console.log('[music import] runImportMatching:blocked_already_running');
return;
}
const rawText = String(musicEls.importInput?.value || '').trim();
const hasImage = Boolean(importImageDataUrl);
console.log('[music import] runImportMatching:click', {
hasInput: Boolean(rawText),
inputLength: rawText.length,
hasImage,
});
if (!rawText && !hasImage) {
setImportStatus('Paste playlist text or upload a screenshot first.');
console.log('[music import] runImportMatching:aborted_no_text_or_image');
return;
}
isImportRunning = true;
renderImportProcessing(true);
importMatchedTracks = [];
importMissingTracks = [];
renderImportResults();
let extracted = [];
try {
setImportStatus('Extracting songs with AI...');
console.log('[music import] runImportMatching:ai_extract_start');
extracted = await extractTracksFromImportText(rawText, importImageDataUrl);
console.log('[music import] runImportMatching:ai_extract_success', {
extractedCount: extracted.length,
});
} catch (error) {
console.warn('[music import] AI extraction failed, falling back:', error);
extracted = hasImage && !rawText ? [] : parseImportTextFallback(rawText);
console.log('[music import] runImportMatching:fallback_parse', {
extractedCount: extracted.length,
hasImage,
sample: extracted.slice(0, 5),
});
if (hasImage && !rawText) {
setImportStatus('AI extraction unavailable for image-only import.');
} else {
setImportStatus(`AI extraction unavailable, using fallback parser (${extracted.length} lines).`);
}
}
if (!extracted.length) {
setImportStatus('No songs found in provided import content.');
isImportRunning = false;
renderImportProcessing(false);
renderImportResults();
console.log('[music import] runImportMatching:finished_no_extracted_tracks');
return;
}
for (let i = 0; i < extracted.length; i += 1) {
const item = extracted[i];
setImportStatus(`Matching ${i + 1}/${extracted.length}: ${item.title}`);
const query = `${item.title} ${item.artist || ''}`.trim();
let items = [];
try {
console.log('[music import] runImportMatching:search_start', {
index: i,
total: extracted.length,
query,
requested: item,
});
const res = await musicApi.searchTracks(query);
items = Array.isArray(res?.items) ? res.items : [];
console.log('[music import] runImportMatching:search_success', {
query,
resultsCount: items.length,
});
} catch (error) {
items = [];
console.warn('[music import] runImportMatching:search_error', {
query,
error: error?.message || String(error || ''),
});
}
const best = pickBestImportCandidate(items, item.title, item.artist);
if (best?.id) {
importMatchedTracks.push(best);
console.log('[music import] runImportMatching:match_found', {
requested: item,
matched: {
id: best.id,
title: best.title || '',
artist: best.artist || '',
},
});
} else {
importMissingTracks.push(item);
console.log('[music import] runImportMatching:match_missing', {
requested: item,
});
}
renderImportResults();
if (i < extracted.length - 1) {
await sleep(300);
}
}
setImportStatus(`Done: ${importMatchedTracks.length} matched, ${importMissingTracks.length} missing.`);
isImportRunning = false;
renderImportProcessing(false);
renderImportResults();
console.log('[music import] runImportMatching:done', {
matched: importMatchedTracks.length,
missing: importMissingTracks.length,
});
}
async function addImportMatchesToPlaylist() {
const playlist = getCurrentPlaylist();
console.log('[music import] addImportMatchesToPlaylist:start', {
matchedAvailable: importMatchedTracks.length,
playlistId: playlist?.identifier || null,
});
if (!playlist) {
setMusicStatus('Create or select a playlist first.', 'error');
console.log('[music import] addImportMatchesToPlaylist:aborted_no_playlist');
return;
}
const existingIds = new Set((playlist.tracks || []).map((track) => Number(track?.id)).filter((id) => Number.isFinite(id)));
let added = 0;
let skipped = 0;
for (const track of importMatchedTracks) {
const id = Number(track?.id);
if (!Number.isFinite(id) || existingIds.has(id)) {
skipped += 1;
continue;
}
existingIds.add(id);
playlist.tracks.push({
id,
title: track.title || '',
artist: track.artist || '',
duration: track.duration || 0,
cover: track.cover || '',
albumTitle: track.albumTitle || '',
});
if (!playlist.image && track.cover) {
playlist.image = musicApi.getCoverUrl(track.cover, 320);
}
added += 1;
}
console.log('[music import] addImportMatchesToPlaylist:mutated_playlist', {
added,
skipped,
newTrackCount: (playlist.tracks || []).length,
});
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
if (musicResultsMode === 'my-playlist' && musicResultsPlaylistId === playlist.identifier) {
musicTracks = [...playlist.tracks];
renderMusicResults(musicTracks);
}
try {
await publishPlaylistRecord(playlist);
console.log('[music import] addImportMatchesToPlaylist:publish_success', {
playlistId: playlist.identifier,
});
} catch (error) {
console.error('[music import] addImportMatchesToPlaylist:publish_error', error);
throw error;
}
setMusicStatus(`Imported ${added} track${added === 1 ? '' : 's'} into ${playlist.title || 'playlist'}.`, 'ok');
setImportStatus(`Added ${added} track${added === 1 ? '' : 's'} to playlist.`);
console.log('[music import] addImportMatchesToPlaylist:done', { added, skipped });
}
function setResultsContext(mode, playlistId = '') {
musicResultsMode = mode;
musicResultsPlaylistId = playlistId;
if (mode !== 'my-playlist' && mode !== 'friend-playlist') {
activePlaylistView = null;
renderPlaylistProfileCard();
}
clearPlaylistDropIndicator();
}
function hydratePlaylistFromEvent(event, isFriend = false) {
const parsed = parsePlaylistEvent(event);
if (!parsed || !isMusicPlaylistEvent(event)) return;
if (isFriend) {
const key = getFriendPlaylistRouteKey(parsed);
if (key) friendPlaylists.set(key, parsed);
renderFriendPlaylists();
} else {
const existing = myPlaylists.get(parsed.identifier);
myPlaylists.set(parsed.identifier, {
identifier: parsed.identifier,
title: parsed.title,
description: parsed.description,
image: parsed.image,
banner: parsed.banner || '',
tracks: parsed.tracks,
pubkey: parsed.pubkey,
eventId: parsed.eventId,
createdAt: parsed.createdAt,
raw: parsed.raw,
...(existing || {}),
});
savePlaylistsLocal();
renderMyPlaylists();
}
const urlEpisode = String(getEpisodeFromUrl() || '').trim();
if (urlEpisode && parsed.identifier === urlEpisode && !selectedPlaylistId) {
if (isFriend) {
const key = getFriendPlaylistRouteKey(parsed);
if (key) openFriendPlaylistByKey(key);
} else {
openMyPlaylistByIdentifier(parsed.identifier);
}
}
if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === parsed.identifier) {
activePlaylistView = { ...activePlaylistView, playlist: parsed };
renderPlaylistProfileCard();
}
}
function subscribeAllPlaylists() {
const filter = { kinds: [PLAYLIST_KIND], '#t': ['music'], limit: 600 };
if (isExternalTargetMode()) {
filter.authors = [targetPubkey];
}
subscribe(
filter,
{ closeOnEose: false, cacheUsage: 'PARALLEL' }
);
}
function handleMusicNdkEvent(event) {
const evt = event?.detail;
if (!evt || typeof evt !== 'object') return;
if (evt.kind !== PLAYLIST_KIND || !isMusicPlaylistEvent(evt)) return;
if (isExternalTargetMode() && evt.pubkey !== targetPubkey) return;
const treatAsMine = Boolean(
currentPubkey
&& evt.pubkey === currentPubkey
&& (!targetPubkey || targetPubkey === currentPubkey)
);
if (treatAsMine) {
hydratePlaylistFromEvent(evt, false);
return;
}
hydratePlaylistFromEvent(evt, true);
}
function clearQueueDropIndicator() {
if (!musicEls.queueList) return;
musicEls.queueList.querySelectorAll('.musicQueueItem.dropBefore, .musicQueueItem.dropAfter, .musicQueueItem.dragging').forEach((el) => {
el.classList.remove('dropBefore', 'dropAfter', 'dragging');
});
}
function getDropPlacementForItems(items, datasetKey, clientY) {
const ordered = (Array.isArray(items) ? items : [])
.map((el) => ({
el,
index: Number(el?.dataset?.[datasetKey]),
rect: el?.getBoundingClientRect?.(),
}))
.filter((entry) => Number.isInteger(entry.index) && entry.index >= 0 && entry.rect)
.sort((a, b) => a.rect.top - b.rect.top);
if (!ordered.length) return null;
const first = ordered[0];
const firstMid = first.rect.top + (first.rect.height / 2);
if (clientY < firstMid) {
return { index: first.index, position: 'before' };
}
for (let i = 0; i < ordered.length - 1; i += 1) {
const current = ordered[i];
const next = ordered[i + 1];
const currentMid = current.rect.top + (current.rect.height / 2);
const nextMid = next.rect.top + (next.rect.height / 2);
if (clientY >= currentMid && clientY < nextMid) {
return { index: next.index, position: 'before' };
}
}
const last = ordered[ordered.length - 1];
return { index: last.index, position: 'after' };
}
function parseDragTrackIndex(dataTransfer, mime = DND_QUEUE_TRACK_INDEX_MIME) {
const raw = String(dataTransfer?.getData?.(mime) || '').trim();
if (!raw) return -1;
const tagged = raw.match(/^vj-track-index:(\d+)$/i);
if (tagged) {
const taggedIndex = Number(tagged[1]);
return Number.isInteger(taggedIndex) && taggedIndex >= 0 ? taggedIndex : -1;
}
const idx = Number(raw);
return Number.isInteger(idx) && idx >= 0 ? idx : -1;
}
function parseTrackIndexFromDataTransfer(dataTransfer) {
const custom = parseDragTrackIndex(dataTransfer, DND_QUEUE_TRACK_INDEX_MIME);
if (custom >= 0) return custom;
return parseDragTrackIndex(dataTransfer, 'text/plain');
}
function renderQueue() {
if (!musicEls.queueList) return;
if (!musicQueue.length) {
destroyMountedMenuInstances(mountedMusicMenus.queue);
musicEls.queueList.innerHTML = '<li class="musicPlaylistMeta">Queue is empty.</li>';
return;
}
musicEls.queueList.innerHTML = musicQueue
.map((track, index) => {
const artistId = String(track?.artistId || track?.raw?.artist?.id || track?.raw?.artists?.[0]?.id || '').trim();
const albumId = String(track?.albumId || track?.raw?.album?.id || '').trim();
return `
<li class="musicQueueItem isReorderable ${index === queueCurrentIndex ? 'active' : ''} ${index === queueCurrentIndex + 1 ? 'afterCurrent' : ''}" data-queue-index="${index}" draggable="true">
<div class="musicQueueTrack">
<img class="musicQueueCover" src="${escapeMusicHtml(musicApi.getCoverUrl(track.cover, 160))}" alt="" loading="lazy" />
<div class="musicQueueMeta">
<div class="musicQueueTitle">${escapeMusicHtml(track.title || 'Unknown title')}</div>
<div class="musicQueueSubtitle">${escapeMusicHtml(track.artist || 'Unknown artist')}</div>
</div>
</div>
<div
data-queue-menu-host="true"
data-queue-index="${index}"
data-queue-artist-id="${escapeMusicHtml(artistId)}"
data-queue-album-id="${escapeMusicHtml(albumId)}"
></div>
</li>
`;
})
.join('');
mountQueueMenus();
}
async function playQueueAt(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicQueue.length) return;
queueCurrentIndex = index;
const track = musicQueue[index];
currentPlayingTrackId = String(track?.id || '');
try {
setMusicStatus(`Loading queue item: ${track.title}`, 'neutral');
await musicPlayer.playTrack(track, resolveMusicTrack);
renderQueue();
updatePlayingResultHighlight();
saveQueueLocal();
setMusicStatus(`Playing queue: ${track.title}`, 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`Queue playback failed: ${error.message || 'Unknown error'}`, 'error');
}
}
function getRandomQueueIndex(excludeIndex = -1) {
if (!musicQueue.length) return -1;
if (musicQueue.length === 1) return 0;
let next = excludeIndex;
let safety = 0;
while (next === excludeIndex && safety < 20) {
next = Math.floor(Math.random() * musicQueue.length);
safety += 1;
}
return next < 0 ? 0 : next;
}
async function playNextInQueue({ fromEnded = false } = {}) {
if (!musicQueue.length) return;
if (playbackMode === 'loop-track' && fromEnded) {
const current = queueCurrentIndex >= 0 ? queueCurrentIndex : 0;
await playQueueAt(current);
return;
}
if (playbackMode === 'shuffle') {
const next = getRandomQueueIndex(queueCurrentIndex);
if (next >= 0) await playQueueAt(next);
return;
}
if (playbackMode === 'loop-queue') {
const next = queueCurrentIndex >= 0
? (queueCurrentIndex + 1) % musicQueue.length
: 0;
await playQueueAt(next);
return;
}
const nextIndex = queueCurrentIndex >= 0 ? queueCurrentIndex + 1 : 0;
if (nextIndex >= musicQueue.length) {
if (fromEnded) {
setMusicStatus('Queue finished.', 'neutral');
}
return;
}
await playQueueAt(nextIndex);
}
async function playPrevInQueue() {
if (!musicQueue.length) return;
if (playbackMode === 'shuffle') {
const prev = getRandomQueueIndex(queueCurrentIndex);
if (prev >= 0) await playQueueAt(prev);
return;
}
if (playbackMode === 'loop-queue') {
const prevIndex = queueCurrentIndex >= 0
? (queueCurrentIndex - 1 + musicQueue.length) % musicQueue.length
: 0;
await playQueueAt(prevIndex);
return;
}
const prevIndex = queueCurrentIndex > 0 ? queueCurrentIndex - 1 : 0;
await playQueueAt(prevIndex);
}
function addTracksToQueue(tracks, { playNow = false, replace = false } = {}) {
const incoming = (Array.isArray(tracks) ? tracks : [])
.map((track) => normalizeQueueTrack(track))
.filter(Boolean);
if (!incoming.length) {
setMusicStatus('No playable tracks to queue.', 'error');
return;
}
if (replace) {
musicQueue = [];
queueCurrentIndex = -1;
}
const startIndex = musicQueue.length;
musicQueue.push(...incoming);
renderQueue();
saveQueueLocal();
if (playNow) {
void playQueueAt(startIndex);
} else {
setMusicStatus(`Queued ${incoming.length} track${incoming.length === 1 ? '' : 's'}.`, 'ok');
}
}
function addTrackToQueue(track, opts = {}) {
const item = normalizeQueueTrack(track);
if (!item) {
setMusicStatus('Invalid track for queue.', 'error');
return;
}
addTracksToQueue([item], opts);
}
async function queuePlaylist(playlist, { playNow = false } = {}) {
const tracks = Array.isArray(playlist?.tracks) ? playlist.tracks : [];
if (!tracks.length) {
setMusicStatus('Playlist has no tracks.', 'error');
return;
}
if (playNow) {
await playAlbumTracksAtTop(tracks, playlist.title || 'Playlist');
return;
}
addTracksToQueue(tracks, { playNow: false, replace: false });
setMusicStatus(`Added playlist to queue: ${playlist.title || 'Untitled playlist'}`, 'ok');
}
function queueAlbumFromTrack(track, { playNow = false } = {}) {
const albumTitle = String(track?.albumTitle || '').trim();
if (!albumTitle) {
setMusicStatus('No album information for this track.', 'error');
return;
}
const tracks = musicTracks.filter((item) => String(item?.albumTitle || '').trim() === albumTitle);
if (!tracks.length) {
setMusicStatus('No album tracks available in current results.', 'error');
return;
}
addTracksToQueue(tracks, { playNow, replace: playNow });
if (!playNow) {
setMusicStatus(`Added album to queue: ${albumTitle}`, 'ok');
}
}
function removeQueueIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicQueue.length) return;
musicQueue.splice(index, 1);
if (!musicQueue.length) {
queueCurrentIndex = -1;
} else if (queueCurrentIndex > index) {
queueCurrentIndex -= 1;
} else if (queueCurrentIndex >= musicQueue.length) {
queueCurrentIndex = musicQueue.length - 1;
}
renderQueue();
saveQueueLocal();
setMusicStatus('Removed item from queue.', 'neutral');
}
function applyQueueReorder() {
if (!Number.isInteger(draggedQueueIndex) || draggedQueueIndex < 0) return;
if (!Number.isInteger(dropQueueIndex) || dropQueueIndex < 0) return;
if (draggedQueueIndex >= musicQueue.length || dropQueueIndex >= musicQueue.length) return;
let insertIndex = dropQueuePosition === 'after' ? dropQueueIndex + 1 : dropQueueIndex;
if (insertIndex > draggedQueueIndex) insertIndex -= 1;
if (insertIndex === draggedQueueIndex) return;
const movedTrack = musicQueue[draggedQueueIndex];
musicQueue.splice(draggedQueueIndex, 1);
musicQueue.splice(insertIndex, 0, movedTrack);
if (queueCurrentIndex === draggedQueueIndex) {
queueCurrentIndex = insertIndex;
} else if (draggedQueueIndex < queueCurrentIndex && insertIndex >= queueCurrentIndex) {
queueCurrentIndex -= 1;
} else if (draggedQueueIndex > queueCurrentIndex && insertIndex <= queueCurrentIndex) {
queueCurrentIndex += 1;
}
renderQueue();
saveQueueLocal();
setMusicStatus('Queue order updated.', 'ok');
}
function clearQueue() {
musicQueue = [];
queueCurrentIndex = -1;
draggedQueueIndex = -1;
dropQueueIndex = -1;
dropQueuePosition = 'before';
renderQueue();
saveQueueLocal();
setMusicStatus('Queue cleared.', 'neutral');
}
function clearPlaylistDropIndicator() {
if (!musicEls.results) return;
musicEls.results.querySelectorAll('.musicResultItem.dropBefore, .musicResultItem.dropAfter, .musicResultItem.dragging').forEach((el) => {
el.classList.remove('dropBefore', 'dropAfter', 'dragging');
});
}
function isPlaylistReorderActive() {
return musicResultsMode === 'my-playlist' && !!musicResultsPlaylistId && musicResultsPlaylistId === selectedPlaylistId;
}
function renderTrackRows(items, { allowReorder = false, allowDelete = false } = {}) {
return items
.map(
(track, index) => {
const artistId = String(track?.artistId || track?.artist?.id || track?.raw?.artist?.id || track?.raw?.artists?.[0]?.id || '').trim();
const albumId = String(track?.albumId || track?.album?.id || track?.raw?.album?.id || '').trim();
return `
<li class="musicResultItem ${allowReorder ? 'isReorderable' : ''}" data-index="${index}" draggable="true">
<img class="musicResultCover" src="${escapeMusicHtml(musicApi.getCoverUrl(track.cover, 160))}" alt="" loading="lazy" />
<div class="musicResultMeta">
<div class="musicResultTitle">${escapeMusicHtml(track.title || 'Unknown title')}</div>
<div class="musicResultSubtitle">${escapeMusicHtml(track.artist || 'Unknown artist')}${track.albumTitle ? `${escapeMusicHtml(track.albumTitle)}` : ''}</div>
</div>
<div class="musicResultDuration">${formatMusicDuration(track.duration || 0)}</div>
<div class="musicResultActions">
<div
data-track-menu-host="true"
data-track-index="${index}"
data-track-artist-id="${escapeMusicHtml(artistId)}"
data-track-album-id="${escapeMusicHtml(albumId)}"
data-track-allow-delete="${allowDelete ? 'true' : 'false'}"
></div>
</div>
</li>
`;
}
)
.join('');
}
function updateNavigationControls() {
if (musicEls.backBtn) {
musicEls.backBtn.disabled = window.history.length <= 1;
}
if (musicEls.forwardBtn) {
musicEls.forwardBtn.disabled = false;
}
if (musicEls.homeBtn) {
const hash = window.location.hash || '';
const clean = hash.startsWith('#') ? hash.slice(1) : hash;
musicEls.homeBtn.disabled = !clean || clean === '/';
}
}
function parseRoute() {
const hash = window.location.hash || '';
const clean = hash.startsWith('#') ? hash.slice(1) : hash;
if (!clean || clean === '/') return { page: 'home', parts: [] };
const parts = clean.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
return { page: parts[0] || 'home', parts: parts.slice(1) };
}
function navigate(path) {
const finalPath = path.startsWith('/') ? path : `/${path}`;
const nextHash = `#${finalPath}`;
if (window.location.hash === nextHash) {
void handleRoute();
return;
}
window.location.hash = nextHash;
}
function renderMusicResults(items) {
if (!items.length) {
destroyMountedMenuInstances(mountedMusicMenus.tracks);
destroyMountedMenuInstances(mountedMusicMenus.albums);
musicEls.results.innerHTML = '<li class="musicResultEmpty">No tracks found.</li>';
return;
}
const allowReorder = isPlaylistReorderActive();
const allowDelete = allowReorder;
musicEls.results.innerHTML = renderTrackRows(items, { allowReorder, allowDelete });
updatePlayingResultHighlight();
mountResultsMenus();
}
function classifySearchIntent(query, results) {
const q = String(query || '').trim().toLowerCase();
const words = q.split(/\s+/).filter(Boolean);
const artists = Array.isArray(results?.artists) ? results.artists : [];
const albums = Array.isArray(results?.albums) ? results.albums : [];
const tracks = Array.isArray(results?.tracks) ? results.tracks : [];
const exactArtist = artists.some((a) => String(a?.name || '').trim().toLowerCase() === q);
if (exactArtist && words.length <= 3) return 'artist';
if (words.length === 1 && artists.length > 0) return 'artist';
const exactAlbum = albums.some((a) => String(a?.title || '').trim().toLowerCase() === q);
if (exactAlbum) return 'album';
const exactTrack = tracks.some((t) => String(t?.title || '').trim().toLowerCase() === q);
if (exactTrack && words.length >= 2) return 'track';
return words.length >= 2 ? 'track' : 'artist';
}
function getIntentSectionOrder(intent) {
if (intent === 'artist') return ['artists', 'albums', 'tracks'];
if (intent === 'album') return ['albums', 'artists', 'tracks'];
return ['tracks', 'artists', 'albums'];
}
function renderArtistSection(items, prominent = false) {
if (!items.length) return '';
const wrapperClass = 'musicArtistStrip';
return `
<li class="musicSectionHeader">ARTISTS</li>
<li class="musicSectionBlock">
<div class="${wrapperClass}">
${items
.map((artist) => {
const pic = musicApi.getArtistPictureUrl(artist.picture, 320) || musicApi.getCoverUrl(artist.picture, 320);
return `
<div class="musicArtistCardWrap">
<button class="musicArtistCard" type="button" data-open-artist-id="${escapeMusicHtml(artist.id || '')}">
<img class="musicArtistCardImage" src="${escapeMusicHtml(pic)}" alt="" loading="lazy" />
<div class="musicArtistCardName">${escapeMusicHtml(artist.name || 'Unknown artist')}</div>
</button>
<button class="btn musicMiniBtn" type="button" data-share-artist-id="${escapeMusicHtml(artist.id || '')}" data-share-artist-name="${escapeMusicHtml(artist.name || 'Unknown artist')}" data-share-artist-picture="${escapeMusicHtml(artist.picture || '')}">Share</button>
</div>
`;
})
.join('')}
</div>
</li>
`;
}
function renderAlbumSection(items, prominent = false) {
if (!items.length) return '';
const wrapperClass = prominent ? 'musicArtistGrid' : 'musicAlbumStrip';
return `
<li class="musicSectionHeader">ALBUMS</li>
<li class="musicSectionBlock">
<div class="${wrapperClass}">
${items
.map((album) => {
const cover = musicApi.getCoverUrl(album.cover, 320);
const artistName = album.artist?.name || album.artistName || 'Unknown artist';
return `
<div class="musicAlbumCard" data-open-album-id="${escapeMusicHtml(album.id || '')}">
<img class="musicAlbumCardImage" src="${escapeMusicHtml(cover)}" alt="" loading="lazy" />
<div class="musicAlbumCardTitle">${escapeMusicHtml(album.title || 'Unknown album')}</div>
<div class="musicAlbumCardMeta">${escapeMusicHtml(artistName)}</div>
<div class="musicAlbumCardActions">
<div
data-album-menu-host="true"
data-album-id="${escapeMusicHtml(album.id || '')}"
data-album-title="${escapeMusicHtml(album.title || 'Unknown album')}"
data-album-cover="${escapeMusicHtml(album.cover || '')}"
></div>
</div>
</div>
`;
})
.join('')}
</div>
</li>
`;
}
function renderTrackSection(items) {
if (!items.length) return '';
return `
<li class="musicSectionHeader">TRACKS</li>
${renderTrackRows(items, { allowReorder: false })}
`;
}
function renderUnifiedResults(state) {
const tracks = Array.isArray(state?.tracks) ? state.tracks : [];
const albums = Array.isArray(state?.albums) ? state.albums : [];
const artists = Array.isArray(state?.artists) ? state.artists : [];
const total = tracks.length + albums.length + artists.length;
if (total === 0) {
destroyMountedMenuInstances(mountedMusicMenus.tracks);
destroyMountedMenuInstances(mountedMusicMenus.albums);
musicEls.results.innerHTML = '<li class="musicResultEmpty">No results found.</li>';
return;
}
const order = getIntentSectionOrder(state?.intent || 'track');
const chunks = [];
for (const section of order) {
if (section === 'artists') {
const prominent = order[0] === 'artists';
const html = renderArtistSection(artists, prominent);
if (html) chunks.push(html);
}
if (section === 'albums') {
const prominent = order[0] === 'albums';
const html = renderAlbumSection(albums, prominent);
if (html) chunks.push(html);
}
if (section === 'tracks') {
const html = renderTrackSection(tracks);
if (html) chunks.push(html);
}
}
musicEls.results.innerHTML = chunks.join('<li class="musicSectionDivider"></li>');
updatePlayingResultHighlight();
mountResultsMenus();
}
async function queueAlbumById(albumId, { playNow = false } = {}) {
if (!albumId) return;
try {
const payload = await musicApi.getAlbum(albumId);
const tracks = Array.isArray(payload?.tracks) ? payload.tracks : [];
if (!tracks.length) {
setMusicStatus('Album has no tracks to queue.', 'error');
return;
}
if (playNow) {
await playAlbumTracksAtTop(tracks, payload?.album?.title || 'Album');
return;
}
addTracksToQueue(tracks, { playNow: false, replace: false });
} catch (error) {
setMusicStatus(`Could not queue album: ${error.message || 'Unknown error'}`, 'error');
}
}
async function playAlbumTracksAtTop(tracks, albumTitle = 'Album') {
const incoming = (Array.isArray(tracks) ? tracks : [])
.map((track) => normalizeQueueTrack(track))
.filter(Boolean);
if (!incoming.length) {
setMusicStatus('Album has no playable tracks.', 'error');
return;
}
musicQueue = [...incoming, ...musicQueue];
renderQueue();
saveQueueLocal();
try {
const startIndex = playbackMode === 'shuffle' ? getRandomQueueIndex(-1) : 0;
await playQueueAt(startIndex >= 0 ? startIndex : 0);
setMusicStatus(`Playing album from queue top: ${albumTitle}`, 'ok');
} catch (error) {
setMusicStatus(`Album playback failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function playCurrentAlbumDetailNow() {
if (musicSearchState?.detailView?.type !== 'album') return;
const detail = musicSearchState.detailView.payload || {};
const tracks = Array.isArray(detail?.tracks) ? detail.tracks : [];
const title = detail?.album?.title || 'Album';
await playAlbumTracksAtTop(tracks, title);
}
function renderArtistDetail(artist) {
const topTracks = Array.isArray(artist?.tracks) ? artist.tracks : [];
const albums = Array.isArray(artist?.albums) ? artist.albums : [];
musicTracks = topTracks;
setResultsContext('artist-detail', artist?.id || '');
musicSearchState.detailView = { type: 'artist', payload: artist };
const header = `
<li class="musicSectionHeader">ARTIST • ${escapeMusicHtml(artist?.name || 'Unknown artist')}</li>
`;
const albumsHtml = renderAlbumSection(albums, false);
const tracksHtml = topTracks.length
? `<li class="musicSectionHeader">TOP TRACKS</li>${renderTrackRows(topTracks, { allowReorder: false })}`
: '<li class="musicResultEmpty">No top tracks found.</li>';
musicEls.results.innerHTML = `${header}${albumsHtml ? '<li class="musicSectionDivider"></li>' + albumsHtml : ''}<li class="musicSectionDivider"></li>${tracksHtml}`;
updatePlayingResultHighlight();
mountResultsMenus();
}
function renderAlbumDetail(album, tracks) {
const safeTracks = Array.isArray(tracks) ? tracks : [];
musicTracks = safeTracks;
setResultsContext('album-detail', album?.id || '');
musicSearchState.detailView = { type: 'album', payload: { album, tracks: safeTracks } };
const artistName = album?.artist?.name || album?.artistName || 'Unknown artist';
const releaseYear = album?.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
const albumId = escapeMusicHtml(String(album?.id || ''));
musicEls.results.innerHTML = `
<li class="musicSectionHeader">ALBUM • ${escapeMusicHtml(album?.title || 'Unknown album')}</li>
<li class="musicSectionBlock">
<div class="musicAlbumCard" style="max-width: 360px;">
<img class="musicAlbumCardImage" src="${escapeMusicHtml(musicApi.getCoverUrl(album?.cover, 320))}" alt="" loading="lazy" data-play-album-now="${albumId}" data-album-detail-cover="true" style="cursor:pointer;" />
<div class="musicAlbumCardTitle">${escapeMusicHtml(album?.title || 'Unknown album')}</div>
<div class="musicAlbumCardMeta">${escapeMusicHtml(artistName)}${releaseYear ? `${escapeMusicHtml(String(releaseYear))}` : ''}</div>
<div class="musicAlbumCardActions">
<button class="btn musicAddBtn" type="button" data-download-album-id="${albumId}" title="Download album">Download</button>
<button class="btn musicAddBtn" type="button" data-share-album-id="${albumId}" data-share-album-title="${escapeMusicHtml(album?.title || 'Unknown album')}" data-share-album-cover="${escapeMusicHtml(album?.cover || '')}" title="Share album">Share</button>
</div>
</div>
</li>
<li class="musicSectionDivider"></li>
<li class="musicSectionHeader">TRACKS</li>
${safeTracks.length ? renderTrackRows(safeTracks, { allowReorder: false }) : '<li class="musicResultEmpty">No tracks found.</li>'}
`;
updatePlayingResultHighlight();
mountResultsMenus();
}
async function drillDownToArtist(artistId) {
if (!artistId) return;
setMusicStatus('Loading artist...', 'neutral');
try {
const artist = await musicApi.getArtist(artistId);
renderArtistDetail(artist);
setMusicStatus(`Loaded artist: ${artist?.name || 'Unknown artist'}`, 'ok');
} catch (error) {
setMusicStatus(`Artist load failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function drillDownToAlbum(albumId) {
if (!albumId) return;
setMusicStatus('Loading album...', 'neutral');
try {
const payload = await musicApi.getAlbum(albumId);
renderAlbumDetail(payload?.album || {}, payload?.tracks || []);
setMusicStatus(`Loaded album: ${payload?.album?.title || 'Unknown album'}`, 'ok');
} catch (error) {
setMusicStatus(`Album load failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function playTrackById(trackId) {
if (!trackId) return;
setMusicStatus('Loading track...', 'neutral');
try {
const res = await musicApi.searchTracks(trackId);
const items = Array.isArray(res?.items) ? res.items : [];
const exact = items.find((t) => String(t?.id || '') === String(trackId));
const track = exact || items[0] || null;
if (!track) {
setMusicStatus('Track not found.', 'error');
return;
}
const normalized = normalizeQueueTrack(track);
if (!normalized) {
setMusicStatus('Track could not be loaded.', 'error');
return;
}
musicQueue.unshift(normalized);
renderQueue();
saveQueueLocal();
await playQueueAt(0);
} catch (error) {
setMusicStatus(`Track load failed: ${error.message || 'Unknown error'}`, 'error');
}
}
function buildSharePath(kind, data = {}) {
if (kind === 'track') return `/track/${encodeURIComponent(String(data.id || ''))}`;
if (kind === 'album') return `/album/${encodeURIComponent(String(data.id || ''))}`;
if (kind === 'artist') return `/artist/${encodeURIComponent(String(data.id || ''))}`;
if (kind === 'playlist') {
if (data.pubkey) {
return `/playlist/${encodeURIComponent(String(data.pubkey || ''))}/${encodeURIComponent(String(data.identifier || ''))}`;
}
return `/playlist/${encodeURIComponent(String(data.identifier || ''))}`;
}
return '/';
}
function buildAbsoluteShareUrl(path) {
const hashPath = path.startsWith('/') ? path : `/${path}`;
return `${window.location.origin}${window.location.pathname}#${hashPath}`;
}
function isImageUrl(value = '') {
return /^https?:\/\/\S+\.(?:png|jpe?g|gif|webp|bmp|svg|avif|ico)(?:[?#]\S*)?$/i.test(value.trim());
}
function updateShareComposerPreview() {
if (!musicEls.shareRendered) return;
const raw = String(musicEls.shareText?.value || '').trim();
if (!raw) {
musicEls.shareRendered.innerHTML = '';
return;
}
const lines = raw.split(/\r?\n/);
const html = lines
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return '<div class="musicShareRenderedSpacer"></div>';
if (/^https?:\/\/\S+$/i.test(trimmed)) {
const safeUrl = escapeMusicHtml(trimmed);
if (isImageUrl(trimmed)) {
return `<a class="musicShareRenderedLink" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a><img class="musicShareRenderedImage" src="${safeUrl}" alt="Shared image" loading="lazy" />`;
}
return `<a class="musicShareRenderedLink" href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a>`;
}
return `<div class="musicShareRenderedLine">${escapeMusicHtml(line)}</div>`;
})
.join('');
musicEls.shareRendered.innerHTML = html;
}
function openShareModal(label, url, artUrl = '') {
pendingShareLabel = label || 'Item';
pendingShareUrl = url || '';
pendingShareArtUrl = artUrl || '';
if (!musicEls.shareModal || !musicEls.shareText || !musicEls.shareTarget) return;
musicEls.shareTarget.textContent = `Sharing: ${pendingShareLabel}`;
const seedLines = [pendingShareUrl, pendingShareArtUrl].filter(Boolean);
musicEls.shareText.value = `\n\n${seedLines.join('\n\n')}`;
updateShareComposerPreview();
musicEls.shareModal.classList.remove('hidden');
musicEls.shareModal.setAttribute('aria-hidden', 'false');
musicEls.shareText.focus();
musicEls.shareText.setSelectionRange(0, 0);
}
function closeShareModal() {
if (!musicEls.shareModal) return;
musicEls.shareModal.classList.add('hidden');
musicEls.shareModal.setAttribute('aria-hidden', 'true');
pendingShareLabel = '';
pendingShareUrl = '';
pendingShareArtUrl = '';
if (musicEls.shareRendered) {
musicEls.shareRendered.textContent = '';
}
}
function resolveShareArtUrl(kind, data = {}) {
if (kind === 'track') return musicApi.getCoverUrl(data.cover || '', 320);
if (kind === 'album') return musicApi.getCoverUrl(data.cover || '', 320);
if (kind === 'artist') {
return musicApi.getArtistPictureUrl(data.picture || '', 320) || musicApi.getCoverUrl(data.picture || '', 320);
}
if (kind === 'playlist') {
if (typeof data.image === 'string' && data.image.startsWith('http')) return data.image;
return musicApi.getCoverUrl(data.cover || '', 320);
}
return '';
}
function openShareFor(kind, data = {}) {
const path = buildSharePath(kind, data);
const url = buildAbsoluteShareUrl(path);
const artUrl = resolveShareArtUrl(kind, data);
const label = data.label || kind;
openShareModal(label, url, artUrl);
}
async function publishShareNote() {
if (!pendingShareUrl) {
setMusicStatus('No share URL available.', 'error');
return;
}
const content = (musicEls.shareText?.value || '').trim();
const publishBtn = musicEls.sharePublishBtn;
const cancelBtn = musicEls.shareCancelBtn;
try {
await promptLoginIfNeeded();
if (publishBtn) publishBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (!window.nostr || typeof window.nostr.signEvent !== 'function') {
throw new Error('window.nostr.signEvent is not available');
}
const unsignedEvent = {
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [
['r', pendingShareUrl],
...(pendingShareArtUrl ? [['r', pendingShareArtUrl]] : []),
['t', 'music']
],
content,
};
const signedEvent = await window.nostr.signEvent(unsignedEvent);
console.log('[music share debug] signed kind-1 event:', signedEvent);
const publishResult = await publishRawEvent(signedEvent);
console.log('[music share debug] publish result:', publishResult);
closeShareModal();
setMusicStatus('Share published; see console for relay results.', 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`Share signing failed: ${error.message || 'Unknown error'}`, 'error');
} finally {
if (publishBtn) publishBtn.disabled = false;
if (cancelBtn) cancelBtn.disabled = false;
}
}
function getPlaylistShowSlugFromRecord(playlist) {
const direct = String(playlist?.showSlug || '').trim().toLowerCase();
if (direct) return direct;
const tags = Array.isArray(playlist?.raw?.tags) ? playlist.raw.tags : [];
const showTag = tags.find((tag) => Array.isArray(tag) && tag[0] === 'show' && typeof tag[1] === 'string');
return String(showTag?.[1] || '').trim().toLowerCase();
}
function showPlaylistFromRecord(playlist, mode, key = '') {
if (!playlist) {
setMusicStatus('Playlist not found.', 'error');
return;
}
if (mode === 'my-playlist') {
selectMyPlaylist(playlist.identifier);
} else {
activePlaylistView = { scope: 'friend', key: key || playlist.identifier || '', playlist };
renderPlaylistProfileCard();
}
musicTracks = Array.isArray(playlist.tracks) ? [...playlist.tracks] : [];
setResultsContext(mode, key || playlist.identifier || '');
renderMusicResults(musicTracks);
setMusicStatus(`Loaded playlist: ${playlist.title || 'Untitled playlist'}`, 'ok');
updateUrlSearchParams({
episode: String(playlist.identifier || '').trim(),
show: getPlaylistShowSlugFromRecord(playlist),
});
const urlEpisode = String(getEpisodeFromUrl() || '').trim();
if (urlEpisode && playlist.identifier === urlEpisode) {
void queuePlaylist(playlist, { playNow: true });
}
}
function openMyPlaylistByIdentifier(identifier) {
const playlist = myPlaylists.get(identifier);
showPlaylistFromRecord(playlist, 'my-playlist', identifier);
}
function getFriendPlaylistByKey(key) {
const needle = String(key || '').trim();
if (!needle) return null;
let playlist = friendPlaylists.get(needle) || null;
if (playlist) return playlist;
playlist = Array.from(friendPlaylists.values()).find((item) => {
if (!item) return false;
if (String(item.eventId || '') === needle) return true;
return getFriendPlaylistRouteKey(item) === needle;
}) || null;
return playlist;
}
function openFriendPlaylistByKey(key) {
const playlist = getFriendPlaylistByKey(key);
showPlaylistFromRecord(playlist, 'friend-playlist', key);
}
async function handleRoute() {
if (isHandlingRoute) return;
isHandlingRoute = true;
try {
const { page, parts } = parseRoute();
if (page === 'search') {
const query = parts.join('/').trim();
if (!query) {
musicEls.results.innerHTML = '<li class="musicResultEmpty">Search songs, albums, artists…</li>';
setResultsContext('search');
setMusicStatus('Ready.', 'ok');
updateNavigationControls();
return;
}
musicEls.input.value = query;
await runMusicSearch(query, { updateRoute: false });
updateNavigationControls();
return;
}
if (page === 'album') {
const albumId = parts[0] || '';
if (albumId) {
await drillDownToAlbum(albumId);
updateNavigationControls();
return;
}
}
if (page === 'artist') {
const artistId = parts[0] || '';
if (artistId) {
await drillDownToArtist(artistId);
updateNavigationControls();
return;
}
}
if (page === 'track') {
const trackId = parts[0] || '';
if (trackId) {
await playTrackById(trackId);
updateNavigationControls();
return;
}
}
if (page === 'playlist') {
if (parts.length >= 2) {
const rawAuthor = String(parts[0] || '').trim();
const author = decodePubkeyInput(rawAuthor) || rawAuthor;
const key = `${author}:${parts.slice(1).join('/')}`;
openFriendPlaylistByKey(key);
updateNavigationControls();
return;
}
if (parts.length === 1) {
openMyPlaylistByIdentifier(parts[0]);
updateNavigationControls();
return;
}
}
setResultsContext('search');
musicTracks = [];
musicSearchState = {
query: '',
intent: 'track',
tracks: [],
albums: [],
artists: [],
detailView: null,
};
musicEls.results.innerHTML = '<li class="musicResultEmpty">Search songs, albums, artists…</li>';
setMusicStatus('Ready.', 'ok');
updateNavigationControls();
} finally {
isHandlingRoute = false;
}
}
function navigateBack() {
window.history.back();
}
function navigateForward() {
window.history.forward();
}
function navigateHome() {
navigate('/');
}
function applyPlaylistTrackReorder() {
if (!isPlaylistReorderActive()) return;
if (!Number.isInteger(draggedPlaylistTrackIndex) || draggedPlaylistTrackIndex < 0) return;
if (!Number.isInteger(dropPlaylistTrackIndex) || dropPlaylistTrackIndex < 0) return;
const playlist = getCurrentPlaylist();
if (!playlist || !Array.isArray(playlist.tracks) || !playlist.tracks.length) return;
let insertIndex = dropPlaylistTrackPosition === 'after' ? dropPlaylistTrackIndex + 1 : dropPlaylistTrackIndex;
if (insertIndex > draggedPlaylistTrackIndex) insertIndex -= 1;
if (insertIndex === draggedPlaylistTrackIndex) return;
const tracks = [...playlist.tracks];
const [moved] = tracks.splice(draggedPlaylistTrackIndex, 1);
tracks.splice(insertIndex, 0, moved);
playlist.tracks = tracks;
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
musicTracks = [...tracks];
setResultsContext('my-playlist', playlist.identifier);
renderMusicResults(musicTracks);
setMusicStatus('Playlist order updated.', 'ok');
}
async function resolveMusicTrack(track) {
return musicApi.getTrackStream(track.id, 'HI_RES_LOSSLESS');
}
function sanitizeFilenamePart(value) {
return String(value || '')
.replace(/[\\/:*?"<>|]+/g, '_')
.replace(/\s+/g, ' ')
.trim();
}
function extensionFromMime(mime = '') {
const value = String(mime || '').toLowerCase();
if (value.includes('flac')) return 'flac';
if (value.includes('mpeg') || value.includes('mp3')) return 'mp3';
if (value.includes('aac')) return 'aac';
if (value.includes('ogg')) return 'ogg';
return 'm4a';
}
function triggerBlobDownload(blob, fileName) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
async function loadClientZipModule() {
return import('./js/vendor/client-zip.esm.js');
}
async function fetchDownloadBlobForTrack(track) {
if (!track?.id) throw new Error('Track not available for download.');
console.debug('[music][download] start', {
trackId: track.id,
title: track.title || '',
});
const qualityCandidates = ['HIGH', 'MP3_320', 'LOSSLESS'];
let lastError = null;
for (const quality of qualityCandidates) {
try {
console.debug('[music][download] requesting stream', { trackId: track.id, quality });
const stream = await musicApi.getTrackStream(track.id, quality);
const streamUrl = stream?.streamUrl;
if (!streamUrl) {
lastError = new Error(`No stream URL for quality ${quality}`);
console.warn('[music][download] missing stream URL', { trackId: track.id, quality });
continue;
}
console.debug('[music][download] fetching stream URL', {
trackId: track.id,
quality,
streamUrl,
});
const res = await fetch(streamUrl);
if (!res.ok) {
lastError = new Error(`HTTP ${res.status} for quality ${quality}`);
console.warn('[music][download] fetch failed', {
trackId: track.id,
quality,
status: res.status,
statusText: res.statusText,
});
continue;
}
const blob = await res.blob();
const ext = extensionFromMime(blob.type);
console.debug('[music][download] success', {
trackId: track.id,
quality,
mimeType: blob.type,
size: blob.size,
ext,
});
return { blob, ext };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error || 'Unknown error'));
console.warn('[music][download] quality attempt threw', {
trackId: track.id,
quality,
error: lastError.message,
});
}
}
console.error('[music][download] all quality attempts failed', {
trackId: track.id,
error: lastError?.message || 'No stream URL for download',
});
throw (lastError || new Error('No stream URL for download'));
}
async function downloadTrackFile(track, { suppressStatus = false } = {}) {
if (!track?.id) {
if (!suppressStatus) setMusicStatus('Track not available for download.', 'error');
return false;
}
const title = sanitizeFilenamePart(track.title || 'Unknown title') || 'Unknown title';
const artist = sanitizeFilenamePart(track.artist || 'Unknown artist') || 'Unknown artist';
if (!suppressStatus) {
setMusicStatus(`Preparing download: ${title}`, 'neutral');
}
try {
const { blob, ext } = await fetchDownloadBlobForTrack(track);
const fileName = `${artist} - ${title}.${ext}`;
triggerBlobDownload(blob, fileName);
if (!suppressStatus) setMusicStatus(`Downloaded: ${fileName}`, 'ok');
return true;
} catch (error) {
console.error(error);
if (!suppressStatus) setMusicStatus(`Download failed: ${error.message || 'Unknown error'}`, 'error');
return false;
}
}
async function downloadMusicByIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicTracks.length) return;
const track = musicTracks[index];
await downloadTrackFile(track, { suppressStatus: false });
}
async function downloadTracksAsZip(tracks, label = 'collection') {
const list = (Array.isArray(tracks) ? tracks : []).filter((track) => track?.id);
if (!list.length) {
setMusicStatus('No downloadable tracks found.', 'error');
return;
}
const safeLabel = sanitizeFilenamePart(label || 'collection') || 'collection';
setMusicStatus(`Preparing ZIP download for ${safeLabel} (${list.length} tracks)...`, 'neutral');
try {
const files = [];
for (let i = 0; i < list.length; i += 1) {
const track = list[i];
const { blob, ext } = await fetchDownloadBlobForTrack(track);
const artist = sanitizeFilenamePart(track.artist || 'Unknown artist') || 'Unknown artist';
const title = sanitizeFilenamePart(track.title || `Track ${i + 1}`) || `Track ${i + 1}`;
const name = `${String(i + 1).padStart(2, '0')} - ${artist} - ${title}.${ext}`;
files.push({ name, input: blob, lastModified: new Date() });
}
const { downloadZip } = await loadClientZipModule();
const response = downloadZip(files);
const zipBlob = await response.blob();
triggerBlobDownload(zipBlob, `${safeLabel}.zip`);
setMusicStatus(`Downloaded ZIP: ${safeLabel}.zip`, 'ok');
} catch (error) {
console.error(error);
setMusicStatus(`ZIP download failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function downloadPlaylistTracks(playlist) {
const tracks = Array.isArray(playlist?.tracks) ? playlist.tracks : [];
const label = playlist?.title || 'playlist';
await downloadTracksAsZip(tracks, label);
}
async function downloadAlbumById(albumId) {
if (!albumId) return;
try {
const payload = await musicApi.getAlbum(albumId);
const tracks = Array.isArray(payload?.tracks) ? payload.tracks : [];
const label = payload?.album?.title || 'album';
await downloadTracksAsZip(tracks, label);
} catch (error) {
console.error(error);
setMusicStatus(`Album download failed: ${error.message || 'Unknown error'}`, 'error');
}
}
async function playMusicByIndex(index) {
if (!Number.isInteger(index) || index < 0 || index >= musicTracks.length) return;
const normalized = normalizeQueueTrack(musicTracks[index]);
if (!normalized) return;
musicQueue.unshift(normalized);
renderQueue();
saveQueueLocal();
await playQueueAt(0);
}
async function runMusicSearch(query, { updateRoute = true } = {}) {
const trimmed = query.trim();
if (!trimmed) return;
if (updateRoute) {
navigate(`/search/${encodeURIComponent(trimmed)}`);
return;
}
setMusicSearchLoading(true);
setMusicStatus(`Searching for "${trimmed}"...`, 'neutral');
try {
const [tracksRes, albumsRes, artistsRes] = await Promise.all([
musicApi.searchTracks(trimmed).catch(() => ({ items: [] })),
musicApi.searchAlbums(trimmed).catch(() => ({ items: [] })),
musicApi.searchArtists(trimmed).catch(() => ({ items: [] })),
]);
const tracks = Array.isArray(tracksRes?.items) ? tracksRes.items : [];
const albums = Array.isArray(albumsRes?.items) ? albumsRes.items : [];
const artists = Array.isArray(artistsRes?.items) ? artistsRes.items : [];
const intent = classifySearchIntent(trimmed, { tracks, albums, artists });
musicTracks = tracks;
musicSearchState = {
query: trimmed,
intent,
tracks,
albums,
artists,
detailView: null,
};
setResultsContext('search');
renderUnifiedResults(musicSearchState);
const total = tracks.length + albums.length + artists.length;
setMusicStatus(`Found ${total} result${total === 1 ? '' : 's'}.`, 'ok');
} catch (error) {
console.error(error);
musicTracks = [];
musicSearchState = {
query: trimmed,
intent: 'track',
tracks: [],
albums: [],
artists: [],
detailView: null,
};
setResultsContext('search');
renderUnifiedResults(musicSearchState);
setMusicStatus(`Search failed: ${error.message || 'Unknown error'}`, 'error');
} finally {
setMusicSearchLoading(false);
}
}
function installImageFallbacks() {
document.addEventListener('error', (event) => {
const target = event.target;
if (!(target instanceof HTMLImageElement)) return;
if (target.src === BLANK_IMAGE_PIXEL) return;
target.src = BLANK_IMAGE_PIXEL;
}, true);
}
async function initMusicFeature() {
if (!musicEls.form || !musicEls.audio) return;
setMusicStatus('Loading API instances...', 'neutral');
try {
await musicApi.initInstances();
setMusicStatus('Ready.', 'ok');
} catch (error) {
console.error(error);
setMusicStatus('Instance loading failed, using fallback list.', 'error');
}
installImageFallbacks();
initMusicResizableColumns();
musicPlayer.onTrackChanged = (track) => {
updateMusicPlayerMeta(track);
};
musicPlayer.onEnded = async () => {
await playNextInQueue({ fromEnded: true });
};
musicPlayer.setResolver(resolveMusicTrack);
musicEls.form.addEventListener('submit', async (event) => {
event.preventDefault();
await runMusicSearch(musicEls.input.value, { updateRoute: true });
});
musicEls.viewerLoadBtn?.addEventListener('click', () => {
applyTargetPubkeyFromInput();
});
musicEls.viewerPubkeyInput?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
applyTargetPubkeyFromInput();
});
musicEls.results.addEventListener('click', async (event) => {
const playAlbumNow = event.target.closest('[data-play-album-now]');
if (playAlbumNow) {
await playCurrentAlbumDetailNow();
return;
}
const queueAlbumIdBtn = event.target.closest('[data-queue-album-id]');
if (queueAlbumIdBtn) {
await queueAlbumById(queueAlbumIdBtn.dataset.queueAlbumId || '');
return;
}
const downloadAlbumIdBtn = event.target.closest('[data-download-album-id]');
if (downloadAlbumIdBtn) {
await downloadAlbumById(downloadAlbumIdBtn.dataset.downloadAlbumId || '');
return;
}
const shareArtistBtn = event.target.closest('[data-share-artist-id]');
if (shareArtistBtn) {
openShareFor('artist', {
id: shareArtistBtn.dataset.shareArtistId || '',
label: shareArtistBtn.dataset.shareArtistName || 'Artist',
picture: shareArtistBtn.dataset.shareArtistPicture || '',
});
return;
}
const shareAlbumBtn = event.target.closest('[data-share-album-id]');
if (shareAlbumBtn) {
openShareFor('album', {
id: shareAlbumBtn.dataset.shareAlbumId || '',
label: shareAlbumBtn.dataset.shareAlbumTitle || 'Album',
cover: shareAlbumBtn.dataset.shareAlbumCover || '',
});
return;
}
const artistCard = event.target.closest('[data-open-artist-id]');
if (artistCard) {
navigate(`/artist/${encodeURIComponent(artistCard.dataset.openArtistId || '')}`);
return;
}
const albumOpen = event.target.closest('[data-open-album-id]');
if (albumOpen) {
navigate(`/album/${encodeURIComponent(albumOpen.dataset.openAlbumId || '')}`);
return;
}
const addPlaylistBtn = event.target.closest('[data-add-playlist-source][data-add-playlist-index]');
if (addPlaylistBtn) {
const source = addPlaylistBtn.dataset.addPlaylistSource || '';
const idx = Number(addPlaylistBtn.dataset.addPlaylistIndex);
const track = source === 'queue' ? musicQueue[idx] : musicTracks[idx];
if (!track) return;
if (addPlaylistBtn.dataset.addPlaylistNew === 'true') {
await createPlaylistAndAddTrack(track);
} else {
addTrackToPlaylistById(track, addPlaylistBtn.dataset.addPlaylistId || '');
}
return;
}
const setPlaylistImageBtn = event.target.closest('[data-set-playlist-image-index]');
if (setPlaylistImageBtn) {
setCurrentPlaylistMediaFromTrack(Number(setPlaylistImageBtn.dataset.setPlaylistImageIndex), 'image');
return;
}
const setPlaylistBannerBtn = event.target.closest('[data-set-playlist-banner-index]');
if (setPlaylistBannerBtn) {
setCurrentPlaylistMediaFromTrack(Number(setPlaylistBannerBtn.dataset.setPlaylistBannerIndex), 'banner');
return;
}
const removePlaylistTrackBtn = event.target.closest('[data-remove-playlist-track-index]');
if (removePlaylistTrackBtn) {
removeTrackFromCurrentPlaylist(Number(removePlaylistTrackBtn.dataset.removePlaylistTrackIndex));
return;
}
const goArtistBtn = event.target.closest('[data-go-artist-id]');
if (goArtistBtn) {
const artistId = String(goArtistBtn.dataset.goArtistId || '').trim();
if (artistId) navigate(`/artist/${encodeURIComponent(artistId)}`);
return;
}
const goAlbumBtn = event.target.closest('[data-go-album-id]');
if (goAlbumBtn) {
const albumId = String(goAlbumBtn.dataset.goAlbumId || '').trim();
if (albumId) navigate(`/album/${encodeURIComponent(albumId)}`);
return;
}
const queueBtn = event.target.closest('[data-queue-index]');
if (queueBtn) {
const queueIndex = Number(queueBtn.dataset.queueIndex);
const track = musicTracks[queueIndex];
if (track) addTrackToQueue(track);
return;
}
const downloadBtn = event.target.closest('[data-download-index]');
if (downloadBtn) {
const downloadIndex = Number(downloadBtn.dataset.downloadIndex);
await downloadMusicByIndex(downloadIndex);
return;
}
const albumBtn = event.target.closest('[data-queue-album-index]');
if (albumBtn) {
const albumIndex = Number(albumBtn.dataset.queueAlbumIndex);
const track = musicTracks[albumIndex];
if (track) queueAlbumFromTrack(track);
return;
}
const shareTrackBtn = event.target.closest('[data-share-index]');
if (shareTrackBtn) {
const shareIndex = Number(shareTrackBtn.dataset.shareIndex);
const track = musicTracks[shareIndex];
if (track?.id) {
openShareFor('track', { id: track.id, label: track.title || 'Track', cover: track.cover || '' });
}
return;
}
const item = event.target.closest('.musicResultItem');
if (!item) return;
});
musicEls.results.addEventListener('dblclick', async (event) => {
const item = event.target.closest('.musicResultItem');
if (!item) return;
if (event.target.closest('button, a, input, textarea, [role="button"], .dot-menu-wrap')) {
return;
}
const index = Number(item.dataset.index);
if (!Number.isInteger(index) || index < 0) return;
await playMusicByIndex(index);
});
musicEls.results.addEventListener('dragstart', (event) => {
const item = event.target.closest('.musicResultItem[draggable="true"]');
if (!item) return;
const trackIndex = Number(item.dataset.index);
if (!Number.isInteger(trackIndex) || trackIndex < 0) return;
draggedResultTrackIndexForQueue = trackIndex;
event.dataTransfer.setData(DND_QUEUE_TRACK_INDEX_MIME, String(trackIndex));
event.dataTransfer.setData('text/plain', `vj-track-index:${trackIndex}`);
if (isPlaylistReorderActive()) {
draggedPlaylistTrackIndex = trackIndex;
event.dataTransfer.effectAllowed = 'copyMove';
} else {
event.dataTransfer.effectAllowed = 'copy';
}
item.classList.add('dragging');
});
musicEls.results.addEventListener('dragover', (event) => {
if (!isPlaylistReorderActive() || draggedPlaylistTrackIndex < 0) return;
const items = Array.from(musicEls.results.querySelectorAll('.musicResultItem[draggable="true"]'));
const placement = getDropPlacementForItems(items, 'index', event.clientY);
if (!placement) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
dropPlaylistTrackIndex = placement.index;
dropPlaylistTrackPosition = placement.position;
clearPlaylistDropIndicator();
const targetItem = items.find((el) => Number(el.dataset.index) === placement.index);
targetItem?.classList.add(placement.position === 'before' ? 'dropBefore' : 'dropAfter');
});
musicEls.results.addEventListener('drop', (event) => {
if (!isPlaylistReorderActive() || draggedPlaylistTrackIndex < 0) return;
event.preventDefault();
applyPlaylistTrackReorder();
draggedPlaylistTrackIndex = -1;
dropPlaylistTrackIndex = -1;
dropPlaylistTrackPosition = 'before';
clearPlaylistDropIndicator();
});
musicEls.results.addEventListener('dragend', () => {
draggedPlaylistTrackIndex = -1;
draggedResultTrackIndexForQueue = -1;
dropPlaylistTrackIndex = -1;
dropPlaylistTrackPosition = 'before';
clearPlaylistDropIndicator();
});
musicEls.myPlaylistsList.addEventListener('click', async (event) => {
const item = event.target.closest('[data-playlist-id]');
if (!item) return;
const playlist = myPlaylists.get(item.dataset.playlistId);
if (!playlist) return;
selectMyPlaylist(playlist.identifier);
navigate(`/playlist/${encodeURIComponent(playlist.identifier)}`);
});
musicEls.friendPlaylistsList.addEventListener('click', async (event) => {
const item = event.target.closest('[data-friend-playlist-key]');
if (!item) return;
const playlist = getFriendPlaylistByKey(item.dataset.friendPlaylistKey);
if (!playlist) return;
const pubkey = playlist.pubkey || '';
const identifier = playlist.identifier || '';
navigate(`/playlist/${encodeURIComponent(pubkey)}/${encodeURIComponent(identifier)}`);
});
musicEls.playlistNameInput?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
await createNewPlaylist();
});
musicEls.playlistEditBtn?.addEventListener('click', async () => {
const playlist = getCurrentPlaylist();
if (!playlist || !isOwnPlaylistRecord(playlist)) return;
if (isPlaylistProfileEditing) {
playlist.title = musicEls.playlistTitleInput.value.trim() || 'Untitled playlist';
playlist.image = musicEls.playlistAvatarInput.value.trim();
playlist.banner = musicEls.playlistBannerInput.value.trim();
playlist.description = musicEls.playlistDescriptionInput.value.trim();
await publishPlaylistRecord(playlist);
renderMyPlaylists();
}
isPlaylistProfileEditing = !isPlaylistProfileEditing;
renderPlaylistProfileCard();
});
const handlePlaylistProfileChange = async () => {
if (isSyncingPlaylistProfileInputs) return;
const playlist = getCurrentPlaylist();
if (!playlist || !isOwnPlaylistRecord(playlist)) return;
playlist.title = musicEls.playlistTitleInput?.value.trim() || 'Untitled playlist';
playlist.image = musicEls.playlistAvatarInput?.value.trim() || '';
playlist.banner = musicEls.playlistBannerInput?.value.trim() || '';
playlist.description = musicEls.playlistDescriptionInput?.value.trim() || '';
myPlaylists.set(playlist.identifier, playlist);
savePlaylistsLocal();
renderMyPlaylists();
renderPlaylistProfileCard();
await publishPlaylistRecord(playlist);
};
musicEls.playlistTitleInput?.addEventListener('change', () => { void handlePlaylistProfileChange(); });
musicEls.playlistAvatarInput?.addEventListener('change', () => { void handlePlaylistProfileChange(); });
musicEls.playlistBannerInput?.addEventListener('change', () => { void handlePlaylistProfileChange(); });
musicEls.playlistDescriptionInput?.addEventListener('change', () => { void handlePlaylistProfileChange(); });
musicEls.importInput?.addEventListener('input', () => {
scheduleImportAutoRun(1500);
});
musicEls.importInput?.addEventListener('paste', async (event) => {
const items = Array.from(event.clipboardData?.items || []);
const imageItem = items.find((item) => String(item.type || '').startsWith('image/'));
if (!imageItem) return;
event.preventDefault();
const file = imageItem.getAsFile();
if (!file) return;
try {
await attachImportImageFile(file);
scheduleImportAutoRun(200);
} catch (error) {
console.error('[music import] clipboard_image:read_error', error);
setImportStatus(`Clipboard image read failed: ${error?.message || 'Unknown error'}`);
}
});
musicEls.importDropZone?.addEventListener('dragenter', (event) => {
event.preventDefault();
importDragDepth += 1;
musicEls.importDropZone?.classList.add('dragover');
});
musicEls.importDropZone?.addEventListener('dragover', (event) => {
event.preventDefault();
});
musicEls.importDropZone?.addEventListener('dragleave', (event) => {
event.preventDefault();
importDragDepth = Math.max(0, importDragDepth - 1);
if (importDragDepth === 0) {
musicEls.importDropZone?.classList.remove('dragover');
}
});
musicEls.importDropZone?.addEventListener('drop', async (event) => {
event.preventDefault();
importDragDepth = 0;
musicEls.importDropZone?.classList.remove('dragover');
const files = Array.from(event.dataTransfer?.files || []);
if (!files.length) return;
for (const file of files) {
const isImage = String(file.type || '').startsWith('image/');
const isText = String(file.type || '').startsWith('text/') || /\.(txt|csv|md|json|m3u)$/i.test(file.name || '');
try {
if (isImage) {
await attachImportImageFile(file);
} else if (isText) {
await appendImportTextFile(file);
} else {
console.log('[music import] drop_file:unsupported', { name: file.name || '', type: file.type || '' });
}
} catch (error) {
console.error('[music import] drop_file:read_error', {
name: file.name || '',
error: error?.message || String(error || ''),
});
}
}
scheduleImportAutoRun(200);
});
musicEls.importResults?.addEventListener('click', async (event) => {
const addBtn = event.target.closest('#musicImportAddMatchesInlineBtn');
if (!addBtn) return;
console.log('[music import] importAddMatchesInlineBtn:clicked');
await addImportMatchesToPlaylist();
});
musicEls.shareCancelBtn?.addEventListener('click', closeShareModal);
musicEls.sharePublishBtn?.addEventListener('click', async () => {
await publishShareNote();
});
musicEls.shareText?.addEventListener('input', () => {
updateShareComposerPreview();
});
musicEls.shareText?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
if (event.shiftKey) return;
event.preventDefault();
await publishShareNote();
});
musicEls.shareModal?.addEventListener('click', (event) => {
if (event.target === musicEls.shareModal) {
closeShareModal();
}
});
musicEls.backBtn?.addEventListener('click', navigateBack);
musicEls.homeBtn?.addEventListener('click', navigateHome);
musicEls.forwardBtn?.addEventListener('click', navigateForward);
window.addEventListener('hashchange', () => {
void handleRoute();
});
musicEls.playPauseBtn.addEventListener('click', async () => {
try {
if (musicEls.audio.paused && musicQueue.length > 0) {
const hasCurrentQueueItem = Number.isInteger(queueCurrentIndex)
&& queueCurrentIndex >= 0
&& queueCurrentIndex < musicQueue.length;
const startIndex = hasCurrentQueueItem
? queueCurrentIndex
: (playbackMode === 'shuffle' ? getRandomQueueIndex(-1) : 0);
await playQueueAt(startIndex >= 0 ? startIndex : 0);
return;
}
await musicPlayer.togglePlayPause();
} catch (error) {
console.error(error);
setMusicStatus(`Playback error: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.prevBtn.addEventListener('click', async () => {
try {
await playPrevInQueue();
} catch (error) {
console.error(error);
setMusicStatus(`Previous track failed: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.nextBtn.addEventListener('click', async () => {
try {
await playNextInQueue();
} catch (error) {
console.error(error);
setMusicStatus(`Next track failed: ${error.message || 'Unknown error'}`, 'error');
}
});
musicEls.audio.addEventListener('play', () => {
setMusicPlayButtonState();
updatePlayingResultHighlight();
});
musicEls.audio.addEventListener('pause', () => {
setMusicPlayButtonState();
updatePlayingResultHighlight();
});
musicEls.playbackModeBtn?.addEventListener('click', cyclePlaybackMode);
setMusicPlayButtonState();
updateMusicPlayerMeta(null);
updatePlaybackModeButton();
musicEls.queueClearBtn?.addEventListener('click', clearQueue);
musicEls.queueList?.addEventListener('click', async (event) => {
const item = event.target.closest('[data-queue-index]');
if (!item) return;
await playQueueAt(Number(item.dataset.queueIndex));
});
musicEls.queueList?.addEventListener('dragstart', (event) => {
const item = event.target.closest('.musicQueueItem[draggable="true"]');
if (!item) return;
draggedQueueIndex = Number(item.dataset.queueIndex);
if (!Number.isInteger(draggedQueueIndex) || draggedQueueIndex < 0) {
draggedQueueIndex = -1;
return;
}
event.dataTransfer.effectAllowed = 'move';
item.classList.add('dragging');
});
musicEls.queueList?.addEventListener('dragover', (event) => {
const draggedTrackIdxFromData = parseTrackIndexFromDataTransfer(event.dataTransfer);
const isDraggingTrackToQueue = (Number.isInteger(draggedResultTrackIndexForQueue) && draggedResultTrackIndexForQueue >= 0)
|| (Number.isInteger(draggedTrackIdxFromData) && draggedTrackIdxFromData >= 0);
if (draggedQueueIndex < 0 && !isDraggingTrackToQueue) return;
event.preventDefault();
event.dataTransfer.dropEffect = draggedQueueIndex >= 0 ? 'move' : 'copy';
const items = Array.from(musicEls.queueList.querySelectorAll('.musicQueueItem[draggable="true"]'));
const placement = getDropPlacementForItems(items, 'queueIndex', event.clientY);
if (!placement) {
dropQueueIndex = -1;
dropQueuePosition = 'after';
clearQueueDropIndicator();
return;
}
dropQueueIndex = placement.index;
dropQueuePosition = placement.position;
clearQueueDropIndicator();
const targetItem = items.find((el) => Number(el.dataset.queueIndex) === placement.index);
targetItem?.classList.add(placement.position === 'before' ? 'dropBefore' : 'dropAfter');
});
musicEls.queueList?.addEventListener('drop', (event) => {
const draggedTrackIdxFromData = parseTrackIndexFromDataTransfer(event.dataTransfer);
const sourceTrackIndex = Number.isInteger(draggedResultTrackIndexForQueue) && draggedResultTrackIndexForQueue >= 0
? draggedResultTrackIndexForQueue
: (Number.isInteger(draggedTrackIdxFromData) && draggedTrackIdxFromData >= 0 ? draggedTrackIdxFromData : -1);
if (draggedQueueIndex < 0 && sourceTrackIndex < 0) return;
event.preventDefault();
if (draggedQueueIndex >= 0) {
applyQueueReorder();
} else if (sourceTrackIndex >= 0) {
const sourceTrack = normalizeQueueTrack(musicTracks[sourceTrackIndex]);
if (sourceTrack) {
let insertIndex;
if (dropQueueIndex >= 0) {
insertIndex = dropQueuePosition === 'after' ? dropQueueIndex + 1 : dropQueueIndex;
} else {
insertIndex = musicQueue.length;
}
const clampedInsertIndex = Math.max(0, Math.min(insertIndex, musicQueue.length));
musicQueue.splice(clampedInsertIndex, 0, sourceTrack);
if (queueCurrentIndex >= clampedInsertIndex) queueCurrentIndex += 1;
renderQueue();
saveQueueLocal();
setMusicStatus(`Queued: ${sourceTrack.title || 'Track'}`, 'ok');
}
}
draggedQueueIndex = -1;
draggedResultTrackIndexForQueue = -1;
dropQueueIndex = -1;
dropQueuePosition = 'before';
clearQueueDropIndicator();
});
musicEls.queueList?.addEventListener('dragend', () => {
draggedQueueIndex = -1;
draggedResultTrackIndexForQueue = -1;
dropQueueIndex = -1;
dropQueuePosition = 'before';
clearQueueDropIndicator();
});
if (!isReadOnlyTargetMode()) {
loadPlaylistsLocal();
} else {
myPlaylists.clear();
}
loadQueueLocal();
updateViewerControls();
renderMyPlaylists();
renderFriendPlaylists();
renderQueue();
subscribeAllPlaylists();
window.addEventListener('ndkEvent', handleMusicNdkEvent);
if (!window.location.hash) {
const episodeRoute = getEpisodeRouteFromUrlContext();
if (episodeRoute) {
navigate(episodeRoute);
} else {
navigate('/');
}
} else {
await handleRoute();
}
}
/* ================================================================
HAMBURGER MENU
================================================================
Initialize and control the animated hamburger menu.
================================================================ */
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 (!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('[template.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("[template.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("[template.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("[template.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 themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
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('[music.html] Resolved auth mode:', authMode);
await initializeAuthentication(authMode);
await initializeAuthenticatedPageFeatures();
syncLoggedInNpubToUrl();
updateViewerControls();
await initMusicFeature();
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
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(`[template.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(`[template.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("[template.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("[template.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("[template.html] Cached profile:", event.detail);
// event.detail contains profile object (name, about, etc.)
});
============================================================ */
// Start update loop (updates footer every second)
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
console.log('[template.html] Initialization complete');
} catch (error) {
console.error('[template.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 template.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>