Borg/demo/index.html
snider 63b8a3ecb6 feat: adaptive bitrate streaming (ABR) for HLS-style encrypted video
Add multi-quality variant support for video content:
   - New ABR types in pkg/smsg/types.go (ABRManifest, Variant, ABRPresets)
   - New pkg/smsg/abr.go with manifest read/write and bandwidth estimation
   - New cmd/mkdemo-abr CLI tool for creating ABR variant sets via ffmpeg
   - WASM parseABRManifest and selectVariant functions
   - Demo page "Adaptive Quality" tab with ABR player
   - RFC-001 Section 3.7 documenting ABR format and algorithm
2026-01-13 15:40:15 +00:00

3596 lines
153 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dapp.fm - Zero-Trust DRM Demo</title>
<meta name="description" content="Decentralized music distribution with ChaCha20-Poly1305 encryption. No middlemen, no platforms, no 70% cuts.">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0f0f1a 0%, #1a0a2e 50%, #0f1a2e 100%);
min-height: 100vh;
color: #e0e0e0;
display: flex;
flex-direction: column;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 1rem 2rem;
flex: 1;
}
.hero {
text-align: center;
padding: 1rem 0;
}
.logo {
font-size: 4rem;
font-weight: 900;
background: linear-gradient(135deg, #ff006e 0%, #8338ec 50%, #3a86ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -3px;
margin-bottom: 0.5rem;
}
.tagline {
color: #888;
font-size: 1rem;
margin-bottom: 0.5rem;
}
.value-prop {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin: 1rem 0;
}
html, body {
overflow-x: hidden;
}
.value-item {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 0.75rem;
text-align: left;
}
.value-item .title-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.value-item .icon { font-size: 1.2rem; }
.value-item h3 { font-size: 0.9rem; color: #fff; margin: 0; }
.value-item p { font-size: 0.75rem; color: #888; line-height: 1.4; }
/* Two column layout */
.two-col {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 1.5rem;
margin: 1rem 0;
}
/* Tabs */
.tabs {
background: rgba(255,255,255,0.05);
border-radius: 20px;
border: 1px solid rgba(255,255,255,0.08);
overflow: hidden;
}
.tab-buttons {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.tab-btn {
flex: 1;
padding: 1rem;
background: transparent;
border: none;
color: #888;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.tab-btn:hover {
color: #fff;
background: rgba(255,255,255,0.03);
}
.tab-btn.active {
color: #fff;
background: rgba(131, 56, 236, 0.2);
border-bottom: 2px solid #8338ec;
}
.tab-content {
padding: 1.5rem;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.card {
background: rgba(255,255,255,0.05);
border-radius: 20px;
padding: 1.5rem;
margin: 1rem 0;
border: 1px solid rgba(255,255,255,0.08);
}
.card h2 {
font-size: 1.3rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.sidebar-card {
background: rgba(255,255,255,0.05);
border-radius: 20px;
padding: 1.5rem;
border: 1px solid rgba(255,255,255,0.08);
height: fit-content;
}
.sidebar-card h3 {
font-size: 1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 8px;
background: rgba(0,0,0,0.2);
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.status .dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status.loading .dot { background: #ffc107; animation: pulse 1s infinite; }
.status.ready .dot { background: #00ff94; }
.status.error .dot { background: #ff5252; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.demo-btn {
width: 100%;
padding: 1.25rem 2rem;
border: none;
border-radius: 12px;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
background: linear-gradient(135deg, #ff006e 0%, #8338ec 100%);
color: #fff;
box-shadow: 0 4px 20px rgba(255, 0, 110, 0.3);
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 1px;
}
.demo-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(255, 0, 110, 0.4);
}
.demo-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.demo-info {
text-align: center;
margin-top: 1rem;
font-size: 0.85rem;
color: #666;
}
.demo-info code {
background: rgba(0,0,0,0.3);
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: #00ff94;
}
/* Player */
.player { display: none; }
.player.visible { display: block; }
.track-info { text-align: center; margin-bottom: 1.5rem; }
.artwork {
width: 180px;
height: 180px;
margin: 0 auto 1rem;
border-radius: 16px;
background: linear-gradient(135deg, #1a1a2e 0%, #2d1b4e 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
overflow: hidden;
}
.artwork img, .artwork video {
width: 100%;
height: 100%;
object-fit: cover;
}
.track-title { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.25rem; }
.track-artist { color: #888; }
.artist-links {
margin-top: 0.5rem;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.artist-links a {
font-size: 0.8rem;
color: #3a86ff;
text-decoration: none;
padding: 0.25rem 0.5rem;
background: rgba(58, 134, 255, 0.1);
border-radius: 4px;
border: 1px solid rgba(58, 134, 255, 0.2);
transition: all 0.2s;
}
.artist-links a:hover {
background: rgba(58, 134, 255, 0.2);
border-color: rgba(58, 134, 255, 0.4);
}
.media-wrapper {
margin: 1.5rem 0;
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 1rem;
}
audio, video {
width: 100%;
border-radius: 8px;
outline: none;
}
video { max-height: 400px; background: #000; }
.video-wrapper {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
}
/* Track list */
.tracks { margin-top: 1.5rem; }
.tracks h3 { font-size: 0.9rem; color: #888; margin-bottom: 0.75rem; }
.track-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: rgba(0,0,0,0.2);
border-radius: 8px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.track-item:hover { background: rgba(131, 56, 236, 0.2); }
.track-item.active { background: rgba(255, 0, 110, 0.2); border: 1px solid rgba(255, 0, 110, 0.4); }
.track-num { font-weight: 700; color: #8338ec; min-width: 24px; }
.track-name { flex: 1; }
.track-time { font-family: monospace; color: #00ff94; font-size: 0.85rem; }
/* License info */
.license {
margin-top: 1.5rem;
padding: 1rem;
background: rgba(131, 56, 236, 0.1);
border: 1px solid rgba(131, 56, 236, 0.3);
border-radius: 12px;
font-size: 0.85rem;
}
.license h4 { color: #8338ec; margin-bottom: 0.5rem; }
.license p { color: #888; line-height: 1.5; }
/* Download section */
.download-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(255,255,255,0.1);
}
.download-section h3 {
text-align: center;
margin-bottom: 1rem;
color: #888;
font-size: 0.9rem;
}
.download-links {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
.download-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
color: #fff;
text-decoration: none;
font-size: 0.9rem;
transition: all 0.2s;
}
.download-link:hover {
background: rgba(255,255,255,0.15);
transform: translateY(-2px);
}
/* Footer */
footer {
text-align: center;
padding: 2rem;
color: #666;
font-size: 0.85rem;
}
footer a { color: #8338ec; text-decoration: none; }
footer a:hover { text-decoration: underline; }
.error {
background: rgba(255, 82, 82, 0.15);
border: 1px solid rgba(255, 82, 82, 0.4);
border-radius: 12px;
padding: 1rem;
margin: 1rem 0;
color: #ff6b6b;
display: none;
}
.error.visible { display: block; }
/* License Manager */
.license-manager { }
.license-intro {
color: #888;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.license-intro code {
background: rgba(0,0,0,0.3);
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: #00ff94;
}
.license-input-group {
margin-bottom: 1rem;
}
.license-input-group label {
display: block;
font-size: 0.85rem;
color: #888;
margin-bottom: 0.5rem;
}
.license-input-group input[type="file"],
.license-input-group input[type="password"] {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
}
.license-input-group input[type="file"] {
cursor: pointer;
}
.license-input-group input:focus {
outline: none;
border-color: #8338ec;
}
/* Sidebar */
.sidebar-desc {
color: #888;
font-size: 0.85rem;
margin-bottom: 1rem;
line-height: 1.4;
}
.download-links.vertical {
flex-direction: column;
gap: 0.5rem;
}
.download-links.vertical .download-link {
width: 100%;
justify-content: center;
}
.sidebar-divider {
height: 1px;
background: rgba(255,255,255,0.1);
margin: 1.25rem 0;
}
.feature-list {
list-style: none;
font-size: 0.85rem;
color: #888;
}
.feature-list li {
padding: 0.4rem 0;
padding-left: 1.2rem;
position: relative;
}
.feature-list li::before {
content: '✓';
position: absolute;
left: 0;
color: #00ff94;
}
.tech-badges {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tech-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: rgba(131, 56, 236, 0.2);
border: 1px solid rgba(131, 56, 236, 0.4);
border-radius: 4px;
color: #8338ec;
}
/* Mode Switcher */
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.mode-switcher {
display: flex;
background: rgba(255,255,255,0.05);
border-radius: 25px;
padding: 4px;
border: 1px solid rgba(255,255,255,0.1);
}
.mode-btn {
padding: 0.5rem 1.25rem;
border: none;
border-radius: 20px;
background: transparent;
color: #888;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.mode-btn:hover {
color: #fff;
}
.mode-btn.active {
background: linear-gradient(135deg, #ff006e 0%, #8338ec 100%);
color: #fff;
box-shadow: 0 2px 10px rgba(255, 0, 110, 0.3);
}
/* Mode Views */
.mode-view {
display: none;
}
.mode-view.active {
display: block;
}
/* Profile Mode Styles */
.profile-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 2rem;
min-height: 70vh;
}
.profile-sidebar {
background: rgba(255,255,255,0.03);
border-radius: 20px;
padding: 1.5rem;
border: 1px solid rgba(255,255,255,0.08);
text-align: center;
}
.profile-avatar {
width: 200px;
height: 200px;
border-radius: 50%;
margin: 0 auto 1rem;
background: linear-gradient(135deg, #ff006e 0%, #8338ec 50%, #3a86ff 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
overflow: hidden;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-name {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.profile-tagline {
color: #888;
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
.profile-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-links a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
color: #fff;
text-decoration: none;
font-size: 0.9rem;
transition: all 0.2s;
}
.profile-links a:hover {
background: rgba(131, 56, 236, 0.2);
border-color: rgba(131, 56, 236, 0.4);
}
.profile-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.profile-hero {
background: rgba(0,0,0,0.3);
border-radius: 20px;
overflow: hidden;
position: relative;
}
.profile-hero video {
width: 100%;
display: block;
}
.profile-hero-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2rem;
padding-bottom: 3.5rem; /* Space for video controls */
background: linear-gradient(transparent, rgba(0,0,0,0.9));
pointer-events: none; /* Allow clicks through to video controls */
}
.profile-hero-title {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.profile-hero-artist {
color: #888;
}
.profile-bio {
background: rgba(255,255,255,0.03);
border-radius: 20px;
padding: 1.5rem;
border: 1px solid rgba(255,255,255,0.08);
}
.profile-bio h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
}
.profile-bio p {
color: #aaa;
line-height: 1.6;
}
.profile-releases {
background: rgba(255,255,255,0.03);
border-radius: 20px;
padding: 1.5rem;
border: 1px solid rgba(255,255,255,0.08);
}
.profile-releases h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
}
.release-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.release-card {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 1rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.release-card:hover {
background: rgba(131, 56, 236, 0.2);
transform: translateY(-2px);
}
.release-card .artwork {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.release-card .title {
font-size: 0.85rem;
font-weight: 600;
}
.profile-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #888;
}
.profile-loading .spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(131, 56, 236, 0.3);
border-top-color: #8338ec;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Artist Mode Styles */
.artist-card {
background: rgba(255,255,255,0.05);
border-radius: 20px;
padding: 1.5rem;
margin-bottom: 1rem;
border: 1px solid rgba(255,255,255,0.08);
}
.artist-card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.input-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.input-group {
margin-bottom: 1rem;
}
.input-group label {
display: block;
font-size: 0.8rem;
color: #888;
margin-bottom: 0.4rem;
}
.input-group input,
.input-group select,
.input-group textarea {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
}
.input-group input:focus,
.input-group select:focus {
outline: none;
border-color: #8338ec;
}
.track-row {
display: grid;
grid-template-columns: auto 1fr 80px 80px 100px auto;
gap: 0.5rem;
align-items: center;
padding: 0.5rem;
background: rgba(131, 56, 236, 0.1);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.track-num {
color: #8338ec;
font-weight: 700;
min-width: 25px;
}
.track-row input,
.track-row select {
padding: 0.5rem;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
font-size: 0.8rem;
}
.track-row .time-input {
text-align: center;
color: #00ff94;
}
.remove-track {
padding: 0.4rem 0.6rem;
background: rgba(255,82,82,0.2);
border: 1px solid rgba(255,82,82,0.4);
color: #ff5252;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.add-track-btn {
width: 100%;
padding: 0.75rem;
background: rgba(255,255,255,0.05);
border: 1px dashed rgba(255,255,255,0.2);
border-radius: 8px;
color: #888;
cursor: pointer;
transition: all 0.2s;
}
.add-track-btn:hover {
border-color: #8338ec;
color: #8338ec;
}
.license-output {
background: rgba(0, 255, 148, 0.1);
border: 1px solid rgba(0, 255, 148, 0.3);
border-radius: 12px;
padding: 1rem;
margin-top: 1rem;
}
.license-token {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 1.1rem;
color: #00ff94;
background: rgba(0,0,0,0.3);
padding: 0.75rem 1rem;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.copy-btn {
padding: 0.4rem 0.8rem;
background: rgba(0, 255, 148, 0.2);
border: 1px solid rgba(0, 255, 148, 0.4);
color: #00ff94;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.license-actions {
display: flex;
gap: 0.75rem;
}
.license-actions button {
flex: 1;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
cursor: pointer;
}
.btn-download {
background: rgba(131, 56, 236, 0.2);
border: 1px solid rgba(131, 56, 236, 0.4);
color: #8338ec;
}
.btn-test {
background: rgba(58, 134, 255, 0.2);
border: 1px solid rgba(58, 134, 255, 0.4);
color: #3a86ff;
}
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
.stat-box {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
background: linear-gradient(135deg, #ff006e, #8338ec);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 0.75rem;
color: #888;
}
.progress {
margin-top: 1rem;
display: none;
}
.progress.visible { display: block; }
.progress-bar {
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #ff006e, #8338ec);
width: 0%;
transition: width 0.3s;
}
.progress-text {
text-align: center;
font-size: 0.8rem;
color: #888;
margin-top: 0.5rem;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
padding: 0.75rem 1rem;
}
.header-row {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.logo {
font-size: 2.5rem;
text-align: center;
}
.mode-switcher {
width: 100%;
justify-content: center;
flex-wrap: wrap;
gap: 4px;
}
.mode-btn {
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
flex: 1;
justify-content: center;
min-width: 0;
}
.value-prop {
display: none !important;
}
.tagline-row {
display: none !important;
}
.two-col {
grid-template-columns: 1fr;
gap: 1rem;
}
/* Profile layout - video first on mobile */
.profile-layout {
display: flex !important;
flex-direction: column;
gap: 1rem;
min-height: auto !important;
}
.profile-main {
order: -1;
}
.profile-sidebar {
text-align: center;
padding: 1rem;
}
.profile-avatar {
width: 120px !important;
height: 120px !important;
font-size: 2.5rem !important;
}
.profile-name {
font-size: 1.25rem !important;
}
.profile-links {
justify-content: center !important;
flex-wrap: wrap;
}
.profile-links a {
font-size: 0.75rem !important;
padding: 0.3rem 0.6rem !important;
}
/* Tabs */
.tab-buttons {
flex-wrap: wrap;
}
.tab-btn {
flex: 1;
min-width: 50%;
font-size: 0.8rem;
padding: 0.6rem;
}
/* Video */
.profile-hero {
min-height: 200px !important;
}
#profile-video {
max-height: 250px;
}
/* Forms */
.form-row {
flex-direction: column !important;
}
.form-row input,
.form-row button {
width: 100% !important;
}
/* Streaming grid */
.chunk-grid {
grid-template-columns: repeat(5, 1fr) !important;
gap: 4px !important;
}
.chunk-cell {
font-size: 0.6rem !important;
padding: 4px !important;
}
}
@media (max-width: 480px) {
.logo {
font-size: 2rem;
}
.mode-btn {
padding: 0.35rem 0.5rem;
font-size: 0.7rem;
}
.value-prop {
grid-template-columns: 1fr;
}
.tagline {
font-size: 0.85rem;
}
.chunk-grid {
grid-template-columns: repeat(4, 1fr) !important;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header-row">
<div class="hero" style="text-align: left; padding: 0;">
<div class="logo">dapp.fm</div>
</div>
<div class="mode-switcher">
<button class="mode-btn active" data-mode="profile">👤 Profile</button>
<button class="mode-btn" data-mode="fan">🎧 Fan</button>
<button class="mode-btn" data-mode="artist">🎨 Artist</button>
<button class="mode-btn" data-mode="streaming">📡 Streaming</button>
</div>
</div>
<div class="tagline-row" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; gap: 1rem;">
<p class="tagline" style="margin: 0;">Zero-Trust DRM for Independent Artists</p>
<div style="background: rgba(255, 193, 7, 0.15); border: 1px solid rgba(255, 193, 7, 0.4); border-radius: 8px; padding: 0.4rem 0.75rem; font-size: 0.75rem; color: #ffc107;">
⚠️ Demo pre-seeded with keys for protocol demonstration
</div>
</div>
<div class="value-prop">
<div class="value-item">
<div class="title-row"><span class="icon">🔐</span><h3>No Middlemen</h3></div>
<p>ChaCha20-Poly1305 encryption. The password IS the license.</p>
</div>
<div class="value-item">
<div class="title-row"><span class="icon">💰</span><h3>No Platform Fees</h3></div>
<p>Artists keep 100%. Sell direct to fans.</p>
</div>
<div class="value-item">
<div class="title-row"><span class="icon">☁️</span><h3>Host Anywhere</h3></div>
<p>CDN, IPFS, S3, your own server. Encrypted is encrypted.</p>
</div>
<div class="value-item">
<div class="title-row"><span class="icon">🌐</span><h3>Browser or Native</h3></div>
<p>WASM in browser, Wails for desktop. Same encryption.</p>
</div>
</div>
<!-- Fan Mode -->
<div id="fan-view" class="mode-view">
<div class="two-col">
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" data-tab="demo">🎵 Live Demo</button>
<button class="tab-btn" data-tab="unlock">🔓 Unlock Content</button>
</div>
<div class="tab-content">
<!-- Live Demo Tab -->
<div id="tab-demo" class="tab-panel active">
<div id="status" class="status loading">
<span class="dot"></span>
<span>Loading decryption engine...</span>
</div>
<div id="error" class="error"></div>
<button id="demo-btn" class="demo-btn" disabled>Play Demo Track</button>
<div id="progress" class="progress">
<div class="progress-bar">
<div id="progress-fill" class="progress-fill"></div>
</div>
<div id="progress-text" class="progress-text">Loading...</div>
</div>
<div class="demo-info">
Demo password: <code>PMVXogAJNVe_DDABfTmLYztaJAzsD0R7</code>
</div>
<div id="player" class="player">
<div class="track-info">
<div id="artwork" class="artwork">🎶</div>
<div id="title" class="track-title">Track Title</div>
<div id="artist" class="track-artist">Artist Name</div>
<div id="artist-links" class="artist-links" style="display: none;"></div>
</div>
<div id="media-wrapper" class="media-wrapper"></div>
<div id="track-list" class="tracks" style="display: none;">
<h3>💿 Tracks</h3>
<div id="tracks"></div>
</div>
<div class="license">
<h4>🔓 Licensed Content</h4>
<p>Decrypted entirely in your browser using WebAssembly. No servers touched your content.</p>
</div>
</div>
</div>
<!-- Unlock Content Tab -->
<div id="tab-unlock" class="tab-panel">
<div class="license-manager">
<p class="license-intro">Have a <code>.smsg</code> file? Enter your license key to unlock it.</p>
<div class="license-input-group">
<label for="smsg-file">📦 Encrypted Package</label>
<input type="file" id="smsg-file" accept=".smsg" />
</div>
<div class="license-input-group">
<label for="license-key">🔑 License Key</label>
<input type="password" id="license-key" placeholder="Enter your license key..." />
</div>
<button id="unlock-btn" class="demo-btn" disabled>Unlock Content</button>
<div id="license-error" class="error"></div>
<div id="license-progress" class="progress">
<div class="progress-bar">
<div id="license-progress-fill" class="progress-fill"></div>
</div>
<div id="license-progress-text" class="progress-text">Processing...</div>
</div>
<div id="license-player" class="player">
<div class="track-info">
<div id="license-artwork" class="artwork">🎶</div>
<div id="license-title" class="track-title">Track Title</div>
<div id="license-artist" class="track-artist">Artist Name</div>
<div id="license-artist-links" class="artist-links" style="display: none;"></div>
</div>
<div id="license-media-wrapper" class="media-wrapper"></div>
<div class="license">
<h4>🔓 Your Licensed Content</h4>
<p>Decrypted locally. Your license key never leaves your device.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar-card">
<h3>📥 Native App</h3>
<p class="sidebar-desc">For the best experience, download the desktop player.</p>
<div class="download-links vertical">
<a href="https://github.com/Snider/Borg/releases" class="download-link" target="_blank">
🐧 Linux
</a>
<a href="https://github.com/Snider/Borg/releases" class="download-link" target="_blank">
🍎 macOS
</a>
<a href="https://github.com/Snider/Borg/releases" class="download-link" target="_blank">
🪟 Windows
</a>
</div>
<div class="sidebar-divider"></div>
<h3>🎨 For Artists</h3>
<p class="sidebar-desc">Create encrypted releases with the License Manager.</p>
<a href="https://github.com/Snider/Borg/blob/main/js/borg-stmf/artist-portal.html" class="download-link" target="_blank" style="width: 100%; justify-content: center; margin-bottom: 0.5rem;">
🎫 Artist Portal
</a>
<div class="sidebar-divider"></div>
<h3>⚡ Why Native?</h3>
<ul class="feature-list">
<li>Memory-speed decryption</li>
<li>No WASM overhead</li>
<li>Offline playback</li>
<li>System integration</li>
</ul>
<div class="sidebar-divider"></div>
<h3>🛡️ Tech Stack</h3>
<div class="tech-badges">
<span class="tech-badge">ChaCha20</span>
<span class="tech-badge">Poly1305</span>
<span class="tech-badge">WASM</span>
<span class="tech-badge">Wails</span>
</div>
</div>
</div>
</div><!-- /fan-view -->
<!-- Artist Mode -->
<div id="artist-view" class="mode-view">
<div class="stats-row">
<div class="stat-box">
<div class="stat-value" id="artist-stat-licenses">0</div>
<div class="stat-label">Licenses Created</div>
</div>
<div class="stat-box">
<div class="stat-value" id="artist-stat-revenue">$0</div>
<div class="stat-label">Potential Revenue</div>
</div>
<div class="stat-box">
<div class="stat-value">100%</div>
<div class="stat-label">Your Cut</div>
</div>
</div>
<!-- Artist Tabs -->
<div class="tabs" id="artist-tabs">
<div class="tab-buttons">
<button class="tab-btn active" data-artist-tab="rekey">🔄 Re-Key</button>
<button class="tab-btn" data-artist-tab="create">🎵 Create New</button>
<button class="tab-btn" data-artist-tab="history">📋 History</button>
</div>
<div class="tab-content">
<!-- Re-Key Tab (Default) -->
<div class="tab-panel active" id="artist-panel-rekey">
<h2 style="margin-bottom: 0.5rem;">Re-Key Existing Content</h2>
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1.5rem;">
Issue new time-limited licenses for content already on your CDN. Fetch, decrypt with original key, re-encrypt with new expiry.
</p>
<div class="input-row">
<div class="input-group" style="margin-bottom: 0;">
<label>Source URL (CDN/IPFS)</label>
<input type="text" id="rekey-url" placeholder="https://cdn.example.com/album.smsg" value="demo-track.smsg" />
</div>
<div class="input-group" style="margin-bottom: 0;">
<label>Or Upload File</label>
<input type="file" id="rekey-file" accept=".smsg" />
</div>
</div>
<div class="input-row">
<div class="input-group" style="margin-bottom: 0;">
<label>Original Password (Master Key)</label>
<input type="password" id="rekey-original-password" placeholder="Enter original password..." value="PMVXogAJNVe_DDABfTmLYztaJAzsD0R7" />
</div>
<div class="input-group" style="margin-bottom: 0;">
<label>New License Type</label>
<select id="rekey-license-type">
<option value="perpetual">Perpetual (Own Forever)</option>
<option value="rental">Rental (7 days)</option>
<option value="stream">Streaming (24h)</option>
<option value="preview">Preview (30s)</option>
</select>
</div>
</div>
<div class="input-group">
<label>New License Token (leave blank to auto-generate)</label>
<input type="text" id="rekey-new-token" placeholder="Leave blank to auto-generate..." />
</div>
<button id="rekey-btn" class="demo-btn" style="background: linear-gradient(135deg, #3a86ff 0%, #8338ec 100%);">
Re-Key Content
</button>
<div id="rekey-status" style="margin-top: 1rem; display: none;"></div>
<div id="rekey-output" class="license-output" style="display: none; border-color: rgba(58, 134, 255, 0.3);">
<div class="license-token" style="background: rgba(58, 134, 255, 0.1);">
<span id="rekey-token-display" style="color: #3a86ff;"></span>
<button class="copy-btn" id="rekey-copy-token" style="background: rgba(58, 134, 255, 0.2); border-color: rgba(58, 134, 255, 0.4); color: #3a86ff;">Copy</button>
</div>
<div class="license-actions">
<button class="btn-download" id="rekey-download-btn">📥 Download Re-Keyed .smsg</button>
</div>
<p style="color: #888; font-size: 0.8rem; margin-top: 0.75rem;">
💡 Tip: Upload this to your CDN or send directly to customer with the new token.
</p>
</div>
<div style="margin-top: 1.5rem; padding: 1rem; background: rgba(58, 134, 255, 0.1); border-radius: 12px; border: 1px solid rgba(58, 134, 255, 0.2);">
<p style="color: #3a86ff; font-size: 0.85rem; margin: 0;">
<strong>Demo:</strong> Pre-filled with demo-track.smsg. Try re-keying with a 24h streaming license!
</p>
</div>
</div>
<!-- Create New Tab -->
<div class="tab-panel" id="artist-panel-create">
<div class="artist-card" style="margin-top: 0;">
<h2>🎵 Your Content</h2>
<div class="input-group">
<label>Upload Media File</label>
<input type="file" id="artist-content-file" accept="audio/*,video/*" />
</div>
<div id="artist-content-info" style="color: #888; font-size: 0.85rem;">
No file selected - using demo content for testing
</div>
</div>
<div class="artist-card">
<h2>💿 Track List</h2>
<p style="color: #666; font-size: 0.8rem; margin-bottom: 1rem;">Define chapter markers like mastering a CD</p>
<div id="artist-track-list"></div>
<button class="add-track-btn" id="artist-add-track">+ Add Track</button>
</div>
<div class="artist-card">
<h2>🎫 License Configuration</h2>
<div class="input-row">
<div class="input-group" style="margin-bottom: 0;">
<label>Release Name</label>
<input type="text" id="artist-release-name" placeholder="e.g., Summer EP 2024" />
</div>
<div class="input-group" style="margin-bottom: 0;">
<label>Master Password</label>
<input type="password" id="artist-master-password" placeholder="Your secret archival key..." />
</div>
</div>
<p style="color: #888; font-size: 0.8rem; margin: 0.5rem 0 1rem; padding: 0.5rem; background: rgba(131, 56, 236, 0.1); border-radius: 8px;">
🔐 <strong>Master Password</strong> = your archival key. Keep it secret! Use it to re-key content for time-limited licenses later.
</p>
<div class="input-row">
<div class="input-group" style="margin-bottom: 0;">
<label>License Type</label>
<select id="artist-license-type">
<option value="perpetual">Perpetual (Own Forever)</option>
<option value="rental">Rental (Time-Limited)</option>
<option value="stream">Streaming (24h)</option>
<option value="preview">Preview (30s)</option>
</select>
</div>
<div class="input-group" style="margin-bottom: 0;">
<label>Customer Token</label>
<input type="text" id="artist-custom-token" placeholder="Leave blank to auto-generate..." />
</div>
</div>
<div class="input-row">
<div class="input-group" style="margin-bottom: 0;">
<label>Customer Email (optional)</label>
<input type="email" id="artist-customer-email" placeholder="customer@example.com" />
</div>
<div class="input-group" style="margin-bottom: 0;">
<label>Price</label>
<input type="text" id="artist-price" placeholder="$9.99" />
</div>
</div>
<button id="artist-generate-btn" class="demo-btn" disabled>Generate License</button>
<div id="artist-license-output" class="license-output" style="display: none;">
<div class="license-token">
<span id="artist-token-display"></span>
<button class="copy-btn" id="artist-copy-token">Copy</button>
</div>
<div id="artist-license-guidance" style="margin: 0.75rem 0; padding: 0.75rem; border-radius: 8px; font-size: 0.85rem;"></div>
<div class="license-actions">
<button class="btn-download" id="artist-download-btn">📥 Download .smsg</button>
<button class="btn-test" id="artist-test-btn">▶️ Test in Player</button>
</div>
</div>
</div>
</div>
<!-- History Tab -->
<div class="tab-panel" id="artist-panel-history">
<h2 style="margin-bottom: 1rem;">📋 Issued Licenses</h2>
<div id="artist-license-list">
<p style="color: #666; text-align: center; padding: 2rem;">No licenses created yet. Create or re-key content to see licenses here.</p>
</div>
</div>
</div>
</div>
</div><!-- /artist-view -->
<!-- Streaming Mode -->
<div id="streaming-view" class="mode-view">
<div class="two-col">
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" data-streaming-tab="chunked">📦 Chunked Decrypt</button>
<button class="tab-btn" data-streaming-tab="seek">⏩ Seek Demo</button>
<button class="tab-btn" data-streaming-tab="abr">📶 Adaptive Quality</button>
</div>
<div class="tab-content">
<!-- Chunked Decryption Tab -->
<div id="streaming-chunked" class="tab-panel active">
<h3 style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<span>📦</span> Chunk-by-Chunk Decryption
</h3>
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1.5rem;">
V3 streaming format enables decrypt-while-downloading. Each chunk is independently decryptable
using a single Content Encryption Key (CEK). Perfect for large files and HTTP Range requests.
</p>
<div class="input-group" style="margin-bottom: 1rem;">
<label style="color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; display: block;">License Token:</label>
<input type="text" id="streaming-license" placeholder="Enter license token..."
style="width: 100%; padding: 0.75rem 1rem; border: 2px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff; font-family: monospace;">
</div>
<button id="streaming-start-btn" class="demo-btn" disabled style="margin-bottom: 1.5rem;">
Start Chunked Decryption
</button>
<!-- Progress visualization -->
<div id="streaming-progress" style="display: none;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: #888; font-size: 0.85rem;">Decryption Progress</span>
<span id="streaming-progress-text" style="color: #00ff94; font-size: 0.85rem;">0%</span>
</div>
<div style="background: rgba(0,0,0,0.3); border-radius: 8px; height: 12px; overflow: hidden; margin-bottom: 1rem;">
<div id="streaming-progress-bar" style="background: linear-gradient(90deg, #ff006e, #8338ec); height: 100%; width: 0%; transition: width 0.3s;"></div>
</div>
<!-- Chunk grid visualization -->
<div id="streaming-chunks" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(24px, 1fr)); gap: 4px; margin-bottom: 1rem;"></div>
<div id="streaming-stats" style="background: rgba(0,0,0,0.2); border-radius: 8px; padding: 1rem; font-size: 0.8rem;">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem;">
<div><span style="color: #888;">Total Chunks:</span> <span id="stat-total-chunks" style="color: #fff;">-</span></div>
<div><span style="color: #888;">Chunk Size:</span> <span id="stat-chunk-size" style="color: #fff;">-</span></div>
<div><span style="color: #888;">Decrypted:</span> <span id="stat-decrypted" style="color: #00ff94;">-</span></div>
<div><span style="color: #888;">Total Size:</span> <span id="stat-total-size" style="color: #fff;">-</span></div>
</div>
</div>
</div>
<!-- Result player -->
<div id="streaming-player" style="display: none; margin-top: 1.5rem;">
<div id="streaming-complete-banner" style="display: none; background: rgba(0,255,148,0.1); border: 1px solid rgba(0,255,148,0.3); border-radius: 12px; padding: 1rem; margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; color: #00ff94; font-weight: 600;">
<span></span> Decryption Complete - Ready to Play
</div>
</div>
<div id="streaming-media-wrapper"></div>
</div>
</div>
<!-- Seek Demo Tab -->
<div id="streaming-seek" class="tab-panel">
<h3 style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<span></span> Seekable Streaming
</h3>
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1.5rem;">
Jump to any position without decrypting previous chunks. The chunk index in the header
enables O(1) seeking to any byte offset.
</p>
<div class="input-group" style="margin-bottom: 1rem;">
<label style="color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; display: block;">Jump to Chunk:</label>
<div style="display: flex; gap: 0.5rem;">
<input type="number" id="seek-chunk-input" min="0" value="0"
style="flex: 1; padding: 0.75rem 1rem; border: 2px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff;">
<button id="seek-chunk-btn" class="demo-btn" disabled style="padding: 0.75rem 1.5rem; width: auto;">
Decrypt Chunk
</button>
</div>
</div>
<div id="seek-result" style="display: none; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 1rem; margin-top: 1rem;">
<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">Chunk <span id="seek-chunk-num">0</span> Data (first 100 bytes hex):</div>
<div id="seek-hex-output" style="font-family: monospace; font-size: 0.75rem; color: #00ff94; word-break: break-all; background: rgba(0,0,0,0.3); padding: 0.75rem; border-radius: 6px;"></div>
<div style="color: #888; font-size: 0.75rem; margin-top: 0.5rem;">
Decrypted <span id="seek-bytes-count">0</span> bytes from chunk <span id="seek-chunk-offset">0</span>
</div>
</div>
</div>
<!-- ABR (Adaptive Bitrate) Tab -->
<div id="streaming-abr" class="tab-panel">
<h3 style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<span>📶</span> Adaptive Bitrate Streaming
</h3>
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1.5rem;">
Automatic quality switching based on your network speed. Like HLS/DASH but with ChaCha20-Poly1305 encryption.
</p>
<div class="input-group" style="margin-bottom: 1rem;">
<label style="color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; display: block;">ABR Manifest URL:</label>
<input type="text" id="abr-manifest-url" placeholder="./abr/manifest.json"
value="./abr/manifest.json"
style="width: 100%; padding: 0.75rem 1rem; border: 2px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff; font-family: monospace;">
</div>
<div class="input-group" style="margin-bottom: 1rem;">
<label style="color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; display: block;">License Token:</label>
<input type="text" id="abr-license" placeholder="Enter license token..."
style="width: 100%; padding: 0.75rem 1rem; border: 2px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff; font-family: monospace;">
</div>
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem;">
<button id="abr-start-btn" class="demo-btn" style="flex: 1;">
Start ABR Stream
</button>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<label style="color: #888; font-size: 0.85rem;">Quality:</label>
<select id="abr-quality-select" style="padding: 0.5rem; border-radius: 6px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid rgba(255,255,255,0.1);">
<option value="auto">Auto</option>
</select>
</div>
</div>
<!-- ABR Status -->
<div id="abr-status" style="display: none; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<span style="color: #888; font-size: 0.85rem;">Current Quality:</span>
<span id="abr-current-quality" style="color: #00ff94; font-weight: 600;">-</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
<span style="color: #888; font-size: 0.85rem;">Estimated Bandwidth:</span>
<span id="abr-bandwidth" style="color: #8338ec; font-weight: 600;">-</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #888; font-size: 0.85rem;">Variant Switches:</span>
<span id="abr-switches" style="color: #ff006e; font-weight: 600;">0</span>
</div>
</div>
<!-- ABR Progress -->
<div id="abr-progress" style="display: none;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: #888; font-size: 0.85rem;">Download Progress</span>
<span id="abr-progress-text" style="color: #00ff94; font-size: 0.85rem;">0%</span>
</div>
<div style="background: rgba(0,0,0,0.3); border-radius: 8px; height: 12px; overflow: hidden; margin-bottom: 1rem;">
<div id="abr-progress-bar" style="background: linear-gradient(90deg, #ff006e, #8338ec); height: 100%; width: 0%; transition: width 0.3s;"></div>
</div>
</div>
<!-- ABR Player -->
<div id="abr-player" style="display: none; margin-top: 1rem;">
<div id="abr-media-wrapper"></div>
</div>
<!-- ABR Info (placeholder until manifest loads) -->
<div id="abr-info" style="background: rgba(58, 134, 255, 0.1); border: 1px solid rgba(58, 134, 255, 0.3); border-radius: 12px; padding: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; color: #3a86ff; font-weight: 600; margin-bottom: 0.5rem;">
<span></span> How ABR Works
</div>
<div style="color: #888; font-size: 0.85rem; line-height: 1.6;">
<p>1. Manifest lists multiple quality variants (1080p, 720p, 480p, 360p)</p>
<p>2. Player measures your download speed</p>
<p>3. Automatically switches to best quality for your connection</p>
<p>4. Same password decrypts all variants</p>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar-card">
<h3>📡 V3 Streaming Format</h3>
<p class="sidebar-desc">
Chunked encryption for large media files. Decrypt as you download, seek without decrypting everything.
</p>
<div class="sidebar-divider" style="margin: 1rem 0; border-top: 1px solid rgba(255,255,255,0.1);"></div>
<h4 style="font-size: 0.85rem; color: #8338ec; margin-bottom: 0.75rem;">How It Works</h4>
<div style="font-size: 0.8rem; color: #888; line-height: 1.6;">
<p style="margin-bottom: 0.5rem;"><strong style="color: #fff;">1.</strong> Content split into fixed-size chunks (default 1MB)</p>
<p style="margin-bottom: 0.5rem;"><strong style="color: #fff;">2.</strong> Single CEK encrypts all chunks</p>
<p style="margin-bottom: 0.5rem;"><strong style="color: #fff;">3.</strong> Each chunk gets unique nonce</p>
<p style="margin-bottom: 0.5rem;"><strong style="color: #fff;">4.</strong> Header contains chunk index with offsets</p>
<p><strong style="color: #fff;">5.</strong> CEK wrapped with LTHN rolling keys</p>
</div>
<div class="sidebar-divider" style="margin: 1rem 0; border-top: 1px solid rgba(255,255,255,0.1);"></div>
<h4 style="font-size: 0.85rem; color: #ff006e; margin-bottom: 0.75rem;">Use Cases</h4>
<div style="font-size: 0.8rem; color: #888;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span>🎬</span> Large video files
</div>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span>📻</span> Live streaming
</div>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span>📱</span> Mobile with limited RAM
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span>🌐</span> CDN with Range requests
</div>
</div>
<div class="sidebar-divider" style="margin: 1rem 0; border-top: 1px solid rgba(255,255,255,0.1);"></div>
<div id="streaming-file-info" style="display: none;">
<h4 style="font-size: 0.85rem; color: #00ff94; margin-bottom: 0.75rem;">Loaded File</h4>
<div style="font-size: 0.8rem; color: #888;">
<div><span style="color: #fff;">Format:</span> <span id="info-format">-</span></div>
<div><span style="color: #fff;">Cadence:</span> <span id="info-cadence">-</span></div>
<div><span style="color: #fff;">Chunked:</span> <span id="info-chunked">-</span></div>
</div>
</div>
</div>
</div>
</div><!-- /streaming-view -->
<!-- Profile Mode -->
<div id="profile-view" class="mode-view active">
<div class="profile-layout">
<!-- Sidebar -->
<div class="profile-sidebar">
<div class="profile-avatar">
<img src="profile-avatar.jpg" alt="The Conductor & The Cowboy" />
</div>
<div class="profile-name">The Conductor & The Cowboy</div>
<div class="profile-tagline">House & Techno Producers</div>
<div class="profile-links">
<a href="https://www.beatport.com/release/it-feels-so-good-the-conductor-the-cowboy-mixes/5372229" target="_blank" style="background: rgba(0, 255, 148, 0.1); border-color: rgba(0, 255, 148, 0.3);">
🛒 Buy on Beatport
</a>
<a href="https://open.spotify.com/artist/33N9EYlv06HF26gpMMFGZk" target="_blank">
🎧 Spotify
</a>
<a href="https://music.apple.com/gb/artist/the-conductor-the-cowboy/287332373" target="_blank">
🍎 Apple Music
</a>
<a href="https://soundcloud.com/conductorandcowboy" target="_blank">
☁️ SoundCloud
</a>
<a href="https://www.youtube.com/@conductorcowboy" target="_blank">
▶️ YouTube
</a>
<a href="https://www.twitch.tv/conductorandcowboy" target="_blank">
📺 Twitch
</a>
<a href="https://linktr.ee/conductorandcowboy" target="_blank">
🔗 Linktree
</a>
</div>
</div>
<!-- Main Content -->
<div class="profile-main">
<!-- Buy CTA - Top -->
<div style="background: linear-gradient(135deg, rgba(255, 0, 110, 0.2) 0%, rgba(131, 56, 236, 0.2) 100%); border-radius: 20px; padding: 1.5rem; text-align: center; border: 1px solid rgba(131, 56, 236, 0.3);">
<a href="https://www.beatport.com/release/it-feels-so-good-the-conductor-the-cowboy-mixes/5372229" target="_blank"
style="display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(135deg, #ff006e 0%, #8338ec 100%); border-radius: 30px; color: #fff; text-decoration: none; font-weight: 600; font-size: 1.1rem;">
🛒 Buy This Track on Beatport
</a>
<p style="color: #888; margin-top: 0.75rem; margin-bottom: 0; font-size: 0.85rem;">95%-100%* goes to the artist. No middlemen.</p>
<p style="color: #666; margin-top: 0.25rem; margin-bottom: 0; font-size: 0.7rem;">*only via dapp.fm</p>
</div>
<!-- Hero Video -->
<div class="profile-hero" style="position: relative; min-height: 300px;">
<div id="profile-loading" class="profile-loading" style="display: none; position: absolute; inset: 0; background: rgba(15, 15, 26, 0.95); z-index: 10; border-radius: 20px;">
<div class="spinner"></div>
<div>Loading...</div>
</div>
<div id="profile-play-btn" style="position: absolute; inset: 0; z-index: 10; cursor: pointer; background: rgba(15, 15, 26, 0.7); border-radius: 20px;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;">
<div style="font-size: 64px; margin-bottom: 10px;">▶️</div>
<div style="color: #fff; font-size: 18px;">Click to play</div>
</div>
</div>
<video id="profile-video" style="opacity: 0; transition: opacity 0.5s;" loop controls playsinline></video>
<div class="profile-hero-overlay">
<div class="profile-hero-title">It Feels So Good</div>
<div class="profile-hero-artist">The Conductor & The Cowboy's Amnesia Mix</div>
</div>
</div>
<!-- Bio -->
<div class="profile-bio">
<h2>About</h2>
<p>
The Conductor & The Cowboy are a house and techno production duo known for their infectious remixes
and original productions. With releases on major labels and support from top DJs worldwide,
they've carved out a unique sound that blends classic house grooves with modern production techniques.
</p>
<p style="margin-top: 1rem;">
Their Amnesia Mix of Sonique's "It Feels So Good" showcases their signature style -
driving beats, euphoric breakdowns, and that unmistakable dancefloor energy.
</p>
</div>
</div>
</div>
</div><!-- /profile-view -->
</div>
<footer>
<p>
<a href="https://github.com/Snider/Borg">GitHub</a> ·
<a href="https://github.com/Snider/Borg/blob/main/LICENSE">EUPL-1.2</a> ·
Built with <a href="https://github.com/Snider/Borg">Borg</a>
</p>
<p style="margin-top: 0.5rem;">Viva La OpenSource 💜</p>
</footer>
<script src="wasm_exec.js"></script>
<script>
// Demo tracks with their passwords
const DEMO_TRACKS = {
'https://demo.dapp.fm/demo-track.smsg': 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7',
'./demo-sample.smsg': '6ZhMQ034bT6maHqMaJejoxDMpfaOQvq5',
'../examples/demo-sample.smsg': '6ZhMQ034bT6maHqMaJejoxDMpfaOQvq5',
};
// Profile uses CDN track (music video)
const DEMO_URL = 'https://demo.dapp.fm/demo-track.smsg';
const DEMO_PASSWORD = DEMO_TRACKS[DEMO_URL];
// Fan/Artist tabs use local mascot video (Vi)
const VI_DEMO_URL = './demo-sample.smsg';
const VI_DEMO_PASSWORD = DEMO_TRACKS[VI_DEMO_URL];
let wasmReady = false;
let manifest = null;
// Initialize WASM
async function initWasm() {
const statusEl = document.getElementById('status');
try {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(
fetch('stmf.wasm'),
go.importObject
);
go.run(result.instance);
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 5000);
if (typeof BorgSMSG !== 'undefined' && BorgSMSG.ready) {
clearTimeout(timeout);
resolve();
return;
}
document.addEventListener('borgstmf:ready', () => {
clearTimeout(timeout);
resolve();
});
});
wasmReady = true;
statusEl.className = 'status ready';
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
const dot = document.createElement('span');
dot.className = 'dot';
const text = document.createElement('span');
text.textContent = 'Ready (v' + BorgSMSG.version + ')';
statusEl.appendChild(dot);
statusEl.appendChild(text);
document.getElementById('demo-btn').disabled = false;
// Enable Re-Key button if pre-filled values exist
if (typeof checkRekeyReady === 'function') checkRekeyReady();
// Profile play button is ready - click handler already attached
} catch (err) {
statusEl.className = 'status error';
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
const dot = document.createElement('span');
dot.className = 'dot';
const text = document.createElement('span');
text.textContent = 'Failed: ' + err.message;
statusEl.appendChild(dot);
statusEl.appendChild(text);
}
}
function showError(msg) {
const el = document.getElementById('error');
el.textContent = msg;
el.classList.add('visible');
}
function hideError() {
document.getElementById('error').classList.remove('visible');
}
function setProgress(percent, text) {
const progress = document.getElementById('progress');
const fill = document.getElementById('progress-fill');
const textEl = document.getElementById('progress-text');
progress.classList.add('visible');
fill.style.width = percent + '%';
textEl.textContent = text;
}
function hideProgress() {
document.getElementById('progress').classList.remove('visible');
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return mins + ':' + secs.toString().padStart(2, '0');
}
async function loadDemo() {
if (!wasmReady) return;
hideError();
const btn = document.getElementById('demo-btn');
btn.disabled = true;
btn.textContent = 'Loading...';
try {
// Fetch encrypted content (Vi mascot demo)
setProgress(10, 'Fetching encrypted content...');
const response = await fetch(VI_DEMO_URL);
if (!response.ok) throw new Error('Demo file not found');
const contentLength = response.headers.get('content-length');
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (contentLength) {
const pct = Math.round((received / contentLength) * 40) + 10;
setProgress(pct, 'Downloading... ' + Math.round(received / 1024 / 1024) + 'MB');
}
}
// Combine chunks and convert to base64
setProgress(55, 'Preparing for decryption...');
const allChunks = new Uint8Array(received);
let position = 0;
for (const chunk of chunks) {
allChunks.set(chunk, position);
position += chunk.length;
}
// Get manifest first (binary API)
setProgress(60, 'Reading metadata...');
try {
const info = await BorgSMSG.getInfoBinary(allChunks);
manifest = info.manifest;
} catch (e) {
console.log('No manifest:', e);
}
// Decrypt using binary API - no base64, pure zstd speed!
setProgress(70, 'Decrypting (zstd)...');
const msg = await BorgSMSG.decryptBinary(allChunks, VI_DEMO_PASSWORD);
setProgress(95, 'Preparing player...');
displayMedia(msg);
hideProgress();
btn.textContent = 'Demo Loaded!';
setTimeout(() => {
btn.textContent = 'Play Demo Track';
btn.disabled = false;
}, 2000);
} catch (err) {
hideProgress();
showError('Failed: ' + err.message);
btn.textContent = 'Play Demo Track';
btn.disabled = false;
}
}
function displayMedia(msg) {
const player = document.getElementById('player');
const mediaWrapper = document.getElementById('media-wrapper');
const artwork = document.getElementById('artwork');
// Set track info
document.getElementById('title').textContent =
(manifest && manifest.title) || msg.subject || 'Demo Track';
// Artist name - link to home if available
const artistEl = document.getElementById('artist');
const artistName = (manifest && manifest.artist) || msg.from || 'dapp.fm';
const artistHome = manifest && manifest.links && manifest.links.home;
if (artistHome) {
artistEl.innerHTML = '<a href="' + artistHome + '" target="_blank" style="color: #888; text-decoration: none; border-bottom: 1px dotted #888;">' + artistName + '</a>';
} else {
artistEl.textContent = artistName;
}
// Artist links - show all platform links
const linksEl = document.getElementById('artist-links');
linksEl.innerHTML = '';
if (manifest && manifest.links) {
const platformNames = {
home: 'Website',
beatport: 'Beatport',
soundcloud: 'SoundCloud',
bandcamp: 'Bandcamp',
spotify: 'Spotify',
apple: 'Apple Music',
youtube: 'YouTube',
instagram: 'Instagram',
twitter: 'Twitter'
};
Object.entries(manifest.links).forEach(([platform, url]) => {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.textContent = platformNames[platform] || platform;
linksEl.appendChild(a);
});
linksEl.style.display = 'flex';
} else {
linksEl.style.display = 'none';
}
// Clear previous
while (mediaWrapper.firstChild) mediaWrapper.removeChild(mediaWrapper.firstChild);
while (artwork.firstChild) artwork.removeChild(artwork.firstChild);
artwork.textContent = '🎶';
if (msg.attachments && msg.attachments.length > 0) {
msg.attachments.forEach(att => {
// att.data is already Uint8Array from decryptStream!
const blob = new Blob([att.data], { type: att.mime });
const url = URL.createObjectURL(blob);
if (att.mime.startsWith('video/')) {
const wrapper = document.createElement('div');
wrapper.className = 'video-wrapper';
const video = document.createElement('video');
video.controls = true;
video.src = url;
wrapper.appendChild(video);
mediaWrapper.appendChild(wrapper);
artwork.textContent = '🎬';
} else if (att.mime.startsWith('audio/')) {
const audio = document.createElement('audio');
audio.controls = true;
audio.src = url;
mediaWrapper.appendChild(audio);
artwork.textContent = '🎵';
} else if (att.mime.startsWith('image/')) {
const img = document.createElement('img');
img.src = url;
artwork.textContent = '';
artwork.appendChild(img);
}
});
}
// Track list
if (manifest && manifest.tracks && manifest.tracks.length > 0) {
const trackList = document.getElementById('track-list');
const tracksEl = document.getElementById('tracks');
trackList.style.display = 'block';
while (tracksEl.firstChild) tracksEl.removeChild(tracksEl.firstChild);
manifest.tracks.forEach((track, i) => {
const item = document.createElement('div');
item.className = 'track-item';
const numSpan = document.createElement('span');
numSpan.className = 'track-num';
numSpan.textContent = track.trackNum || (i + 1);
const nameSpan = document.createElement('span');
nameSpan.className = 'track-name';
nameSpan.textContent = track.title || 'Track ' + (i + 1);
const timeSpan = document.createElement('span');
timeSpan.className = 'track-time';
timeSpan.textContent = formatTime(track.start || 0);
item.appendChild(numSpan);
item.appendChild(nameSpan);
item.appendChild(timeSpan);
item.addEventListener('click', () => {
const media = document.querySelector('audio, video');
if (media) {
media.currentTime = track.start || 0;
media.play();
document.querySelectorAll('.track-item').forEach(t => t.classList.remove('active'));
item.classList.add('active');
}
});
tracksEl.appendChild(item);
});
}
player.classList.add('visible');
player.scrollIntoView({ behavior: 'smooth' });
}
// Tab switching - Fan mode tabs
document.querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
const container = btn.closest('.tabs');
// Update buttons within this tabs container
container.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update panels within this tabs container
container.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
});
});
// Tab switching - Artist mode tabs
document.querySelectorAll('[data-artist-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.artistTab;
const container = btn.closest('.tabs');
// Update buttons within this tabs container
container.querySelectorAll('[data-artist-tab]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update panels within this tabs container
container.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('artist-panel-' + tab).classList.add('active');
});
});
// License Manager
let licenseFile = null;
document.getElementById('smsg-file').addEventListener('change', (e) => {
licenseFile = e.target.files[0];
checkUnlockReady();
});
document.getElementById('license-key').addEventListener('input', checkUnlockReady);
function checkUnlockReady() {
const hasFile = licenseFile !== null;
const hasKey = document.getElementById('license-key').value.length > 0;
document.getElementById('unlock-btn').disabled = !wasmReady || !hasFile || !hasKey;
}
function showLicenseError(msg) {
const el = document.getElementById('license-error');
el.textContent = msg;
el.classList.add('visible');
}
function hideLicenseError() {
document.getElementById('license-error').classList.remove('visible');
}
function setLicenseProgress(percent, text) {
const progress = document.getElementById('license-progress');
const fill = document.getElementById('license-progress-fill');
const textEl = document.getElementById('license-progress-text');
progress.classList.add('visible');
fill.style.width = percent + '%';
textEl.textContent = text;
}
function hideLicenseProgress() {
document.getElementById('license-progress').classList.remove('visible');
}
async function unlockLicensedContent() {
if (!wasmReady || !licenseFile) return;
hideLicenseError();
const btn = document.getElementById('unlock-btn');
const password = document.getElementById('license-key').value;
btn.disabled = true;
btn.textContent = 'Unlocking...';
try {
setLicenseProgress(20, 'Reading file...');
const arrayBuffer = await licenseFile.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
setLicenseProgress(40, 'Reading metadata...');
// Get manifest (binary API)
let licenseManifest = null;
try {
const info = await BorgSMSG.getInfoBinary(bytes);
licenseManifest = info.manifest;
} catch (e) {
console.log('No manifest:', e);
}
setLicenseProgress(60, 'Decrypting (zstd)...');
// Decrypt using binary API - no base64, pure zstd speed!
const msg = await BorgSMSG.decryptBinary(bytes, password);
setLicenseProgress(90, 'Preparing player...');
displayLicensedMedia(msg, licenseManifest);
hideLicenseProgress();
btn.textContent = 'Content Unlocked!';
setTimeout(() => {
btn.textContent = 'Unlock Content';
checkUnlockReady();
}, 2000);
} catch (err) {
hideLicenseProgress();
showLicenseError('Failed: ' + err.message);
btn.textContent = 'Unlock Content';
checkUnlockReady();
}
}
function displayLicensedMedia(msg, licenseManifest) {
const player = document.getElementById('license-player');
const mediaWrapper = document.getElementById('license-media-wrapper');
const artwork = document.getElementById('license-artwork');
document.getElementById('license-title').textContent =
(licenseManifest && licenseManifest.title) || msg.subject || 'Unlocked Track';
// Artist name - link to home if available
const licenseArtistEl = document.getElementById('license-artist');
const licenseArtistName = (licenseManifest && licenseManifest.artist) || msg.from || 'Artist';
const licenseArtistHome = licenseManifest && licenseManifest.links && licenseManifest.links.home;
if (licenseArtistHome) {
licenseArtistEl.innerHTML = '<a href="' + licenseArtistHome + '" target="_blank" style="color: #888; text-decoration: none; border-bottom: 1px dotted #888;">' + licenseArtistName + '</a>';
} else {
licenseArtistEl.textContent = licenseArtistName;
}
// Artist links - show all platform links
const licenseLinksEl = document.getElementById('license-artist-links');
licenseLinksEl.innerHTML = '';
if (licenseManifest && licenseManifest.links) {
const platformNames = {
home: 'Website',
beatport: 'Beatport',
soundcloud: 'SoundCloud',
bandcamp: 'Bandcamp',
spotify: 'Spotify',
apple: 'Apple Music',
youtube: 'YouTube',
instagram: 'Instagram',
twitter: 'Twitter'
};
Object.entries(licenseManifest.links).forEach(([platform, url]) => {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.textContent = platformNames[platform] || platform;
licenseLinksEl.appendChild(a);
});
licenseLinksEl.style.display = 'flex';
} else {
licenseLinksEl.style.display = 'none';
}
while (mediaWrapper.firstChild) mediaWrapper.removeChild(mediaWrapper.firstChild);
while (artwork.firstChild) artwork.removeChild(artwork.firstChild);
artwork.textContent = '🎶';
if (msg.attachments && msg.attachments.length > 0) {
msg.attachments.forEach(att => {
const blob = new Blob([att.data], { type: att.mime });
const url = URL.createObjectURL(blob);
if (att.mime.startsWith('video/')) {
const wrapper = document.createElement('div');
wrapper.className = 'video-wrapper';
const video = document.createElement('video');
video.controls = true;
video.src = url;
wrapper.appendChild(video);
mediaWrapper.appendChild(wrapper);
artwork.textContent = '🎬';
} else if (att.mime.startsWith('audio/')) {
const audio = document.createElement('audio');
audio.controls = true;
audio.src = url;
mediaWrapper.appendChild(audio);
artwork.textContent = '🎵';
} else if (att.mime.startsWith('image/')) {
const img = document.createElement('img');
img.src = url;
artwork.textContent = '';
artwork.appendChild(img);
}
});
}
player.classList.add('visible');
player.scrollIntoView({ behavior: 'smooth' });
}
// Event listeners
document.getElementById('demo-btn').addEventListener('click', loadDemo);
document.getElementById('unlock-btn').addEventListener('click', unlockLicensedContent);
// ========== MODE SWITCHING ==========
let profileLoaded = false;
let artistDemoLoaded = false;
// Preload Vi demo for Artist mode
async function preloadArtistDemo() {
if (artistDemoLoaded || !wasmReady) return;
artistDemoLoaded = true;
const info = document.getElementById('artist-content-info');
info.textContent = 'Loading Vi demo...';
info.style.color = '#999';
try {
const response = await fetch(VI_DEMO_URL);
if (!response.ok) throw new Error('Demo file not found');
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
const msg = await BorgSMSG.decryptBinary(bytes, VI_DEMO_PASSWORD);
if (msg.attachments && msg.attachments.length > 0) {
const att = msg.attachments[0];
// Convert binary data to base64 for artistContentData
let base64 = '';
const chunkSize = 32768;
for (let i = 0; i < att.data.length; i += chunkSize) {
const chunk = att.data.subarray(i, Math.min(i + chunkSize, att.data.length));
base64 += String.fromCharCode.apply(null, chunk);
}
artistContentData = btoa(base64);
artistContentName = att.name || 'vi-demo.mp4';
artistContentMime = att.mime || 'video/mp4';
info.textContent = artistContentName + ' (' + (att.data.length / 1024 / 1024).toFixed(1) + ' MB) - Vi Demo Ready';
info.style.color = '#00ff94';
}
} catch (err) {
console.warn('Failed to preload artist demo:', err);
info.textContent = 'Demo unavailable - upload your own file';
info.style.color = '#ff9500';
artistDemoLoaded = false;
}
}
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
// Update buttons
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update views
document.querySelectorAll('.mode-view').forEach(v => v.classList.remove('active'));
document.getElementById(mode + '-view').classList.add('active');
// Profile play button is always visible - no setup needed
// Initialize streaming demo when switching to streaming mode
if (mode === 'streaming' && wasmReady && !streamingHeaderInfo) {
initStreamingDemo();
}
// Preload Vi demo if WASM already loaded (don't auto-load WASM - it's too slow on 4G)
if (mode === 'artist' && wasmReady && !artistDemoLoaded) {
preloadArtistDemo();
}
});
});
// ========== PROFILE MODE ==========
// Play button click handler - loads WASM and video only when clicked
document.getElementById('profile-play-btn').addEventListener('click', async function() {
const playBtn = this;
const video = document.getElementById('profile-video');
// Show loading state
playBtn.innerHTML = '<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;"><div class="spinner"></div><div style="color: #fff; font-size: 14px; margin-top: 10px;">Loading WASM...</div></div>';
try {
// Load WASM if not ready
if (!wasmReady) {
await initWasm();
}
playBtn.querySelector('div > div:last-child').textContent = 'Downloading...';
// Fetch encrypted content
const response = await fetch(DEMO_URL);
if (!response.ok) throw new Error('Demo file not found');
playBtn.querySelector('div > div:last-child').textContent = 'Decrypting...';
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Decrypt directly from binary
const msg = await BorgSMSG.decryptBinary(bytes, DEMO_PASSWORD);
if (msg.attachments && msg.attachments.length > 0) {
const att = msg.attachments[0];
if (att.mime.startsWith('video/')) {
const blob = new Blob([att.data], { type: att.mime });
const videoUrl = URL.createObjectURL(blob);
// Start at specific timestamp for emotional impact
const PROFILE_START_TIME = 68; // 1:08 - the drop
video.src = videoUrl;
video.addEventListener('loadedmetadata', () => {
video.currentTime = PROFILE_START_TIME;
}, { once: true });
// Hide play button, show video, and play
video.addEventListener('canplay', () => {
playBtn.style.display = 'none';
video.style.opacity = '1';
video.play();
}, { once: true });
}
}
} catch (err) {
playBtn.innerHTML = '<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: #ff5252;">Failed: ' + err.message + '</div>';
}
}, { once: true });
// ========== ARTIST MODE ==========
let artistContentData = null;
let artistContentName = 'Demo Content';
let artistContentMime = 'video/mp4';
let artistTracks = [];
let artistTrackCounter = 0;
let artistLicenses = [];
let artistLicenseCounter = 0;
// File upload
document.getElementById('artist-content-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Convert to base64 in chunks
let base64 = '';
const chunkSize = 32768;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
base64 += String.fromCharCode.apply(null, chunk);
}
artistContentData = btoa(base64);
artistContentName = file.name;
artistContentMime = file.type || 'application/octet-stream';
const info = document.getElementById('artist-content-info');
info.textContent = file.name + ' (' + (file.size / 1024 / 1024).toFixed(1) + ' MB) - Ready';
info.style.color = '#00ff94';
} catch (err) {
alert('Failed to load file: ' + err.message);
}
});
// Track management
function artistAddTrack() {
artistTrackCounter++;
artistTracks.push({
id: artistTrackCounter,
title: '',
start: '',
end: '',
type: 'full'
});
artistRenderTracks();
}
function artistRemoveTrack(id) {
artistTracks = artistTracks.filter(t => t.id !== id);
artistRenderTracks();
}
function artistRenderTracks() {
const container = document.getElementById('artist-track-list');
container.innerHTML = '';
if (artistTracks.length === 0) {
const empty = document.createElement('p');
empty.style.cssText = 'color: #666; text-align: center; padding: 1rem; font-size: 0.85rem;';
empty.textContent = 'No tracks. Click "Add Track" to define chapters.';
container.appendChild(empty);
return;
}
artistTracks.forEach((track, idx) => {
const row = document.createElement('div');
row.className = 'track-row';
row.innerHTML = '<span class="track-num">' + (idx + 1) + '.</span>' +
'<input type="text" placeholder="Track title" value="' + (track.title || '') + '" data-field="title">' +
'<input type="text" class="time-input" placeholder="0:00" value="' + (track.start || '') + '" data-field="start">' +
'<input type="text" class="time-input" placeholder="end" value="' + (track.end || '') + '" data-field="end">' +
'<select data-field="type">' +
'<option value="full"' + (track.type === 'full' ? ' selected' : '') + '>Full</option>' +
'<option value="intro"' + (track.type === 'intro' ? ' selected' : '') + '>Intro</option>' +
'<option value="verse"' + (track.type === 'verse' ? ' selected' : '') + '>Verse</option>' +
'<option value="chorus"' + (track.type === 'chorus' ? ' selected' : '') + '>Chorus</option>' +
'<option value="drop"' + (track.type === 'drop' ? ' selected' : '') + '>Drop</option>' +
'<option value="outro"' + (track.type === 'outro' ? ' selected' : '') + '>Outro</option>' +
'</select>' +
'<button class="remove-track">✕</button>';
// Event listeners
row.querySelectorAll('input, select').forEach(input => {
input.addEventListener('change', (e) => {
track[e.target.dataset.field] = e.target.value;
});
});
row.querySelector('.remove-track').addEventListener('click', () => artistRemoveTrack(track.id));
container.appendChild(row);
});
}
document.getElementById('artist-add-track').addEventListener('click', artistAddTrack);
// Generate token
function generateArtistToken() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const segments = [];
for (let s = 0; s < 4; s++) {
let segment = '';
for (let i = 0; i < 4; i++) {
segment += chars.charAt(Math.floor(Math.random() * chars.length));
}
segments.push(segment);
}
return segments.join('-');
}
// Parse time to seconds
function parseTime(str) {
if (!str) return 0;
const parts = str.split(':');
if (parts.length === 2) return parseFloat(parts[0]) * 60 + parseFloat(parts[1]);
if (parts.length === 3) return parseFloat(parts[0]) * 3600 + parseFloat(parts[1]) * 60 + parseFloat(parts[2]);
return parseFloat(str) || 0;
}
// Generate license
async function artistGenerateLicense() {
const btn = document.getElementById('artist-generate-btn');
btn.disabled = true;
// Load WASM on-demand if not ready
if (!wasmReady) {
btn.textContent = 'Loading WASM...';
try {
await initWasm();
} catch (err) {
btn.textContent = 'Generate License';
btn.disabled = false;
alert('Failed to load WASM: ' + err.message);
return;
}
}
btn.textContent = 'Generating...';
try {
const masterPassword = document.getElementById('artist-master-password').value;
const customToken = document.getElementById('artist-custom-token').value.trim();
const releaseName = document.getElementById('artist-release-name').value.trim() || artistContentName;
const licenseType = document.getElementById('artist-license-type').value;
const customerEmail = document.getElementById('artist-customer-email').value.trim();
const price = document.getElementById('artist-price').value.trim() || '$0';
// Determine encryption password and customer token
// For perpetual: master password = customer token (direct sale)
// For time-limited: encrypt with master, tell artist to use Re-Key
const isPerpetual = licenseType === 'perpetual';
const encryptionPassword = masterPassword;
const token = isPerpetual ? masterPassword : (customToken || '(use Re-Key tab)');
// Build manifest
const now = Math.floor(Date.now() / 1000);
let expiresAt = 0;
if (licenseType === 'preview') expiresAt = now + 30;
else if (licenseType === 'stream') expiresAt = now + 86400;
else if (licenseType === 'rental') expiresAt = now + 604800;
const manifest = {
title: releaseName,
artist: 'Artist',
licenseType: isPerpetual ? 'perpetual' : 'master', // Mark as master copy for time-limited
issuedAt: now,
expiresAt: isPerpetual ? 0 : expiresAt,
tracks: artistTracks.map((t, i) => ({
title: t.title || 'Track ' + (i + 1),
start: parseTime(t.start),
end: parseTime(t.end) || 0,
type: t.type
}))
};
// Use demo content if no file uploaded
const contentData = artistContentData || btoa('Demo content - upload your own file');
// Encrypt with master password
const encrypted = await BorgSMSG.encryptWithManifest({
body: 'Licensed content from dapp.fm',
subject: releaseName,
from: 'Artist',
attachments: [{
name: artistContentName,
content: contentData,
mime: artistContentMime
}]
}, encryptionPassword, manifest);
// Save license
artistLicenseCounter++;
const license = {
id: artistLicenseCounter,
token: token,
customer: customerEmail,
price: price,
timestamp: new Date(),
encrypted: encrypted,
contentName: releaseName,
licenseType: licenseType
};
artistLicenses.unshift(license);
// Show output
document.getElementById('artist-token-display').textContent = token;
document.getElementById('artist-license-output').style.display = 'block';
// Show guidance based on license type
const guidanceEl = document.getElementById('artist-license-guidance');
if (isPerpetual) {
guidanceEl.innerHTML = '✅ <strong>Direct Sale:</strong> Send customer the .smsg file + the token above. They own it forever.';
guidanceEl.style.background = 'rgba(0, 255, 148, 0.1)';
guidanceEl.style.color = '#00ff94';
} else {
guidanceEl.innerHTML = '📦 <strong>Master Copy:</strong> Upload this .smsg to your CDN. Then use the <strong>Re-Key tab</strong> to create time-limited customer tokens.';
guidanceEl.style.background = 'rgba(58, 134, 255, 0.1)';
guidanceEl.style.color = '#3a86ff';
}
// Store for download/test
document.getElementById('artist-download-btn').onclick = () => {
const blob = new Blob([encrypted], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = releaseName.replace(/[^a-z0-9]/gi, '-') + '.smsg';
a.click();
URL.revokeObjectURL(url);
};
document.getElementById('artist-test-btn').onclick = () => {
if (!isPerpetual) {
alert('For time-limited licenses, use the Re-Key tab to create a customer token first, then test that version.');
return;
}
// Switch to fan mode and load content
document.querySelector('[data-mode="fan"]').click();
document.querySelector('[data-tab="unlock"]').click();
// Pre-fill the license key (master password for perpetual)
document.getElementById('license-key').value = masterPassword;
alert('Paste the downloaded .smsg file and the token is pre-filled!');
};
// Update stats
artistUpdateStats();
artistRenderLicenses();
// Clear inputs
document.getElementById('artist-custom-token').value = '';
} catch (err) {
alert('Failed to generate: ' + err.message);
} finally {
btn.textContent = 'Generate License';
btn.disabled = false;
}
}
function artistUpdateStats() {
document.getElementById('artist-stat-licenses').textContent = artistLicenses.length;
let total = 0;
artistLicenses.forEach(l => {
const match = l.price.match(/[\d.]+/);
if (match) total += parseFloat(match[0]);
});
document.getElementById('artist-stat-revenue').textContent = '$' + total.toFixed(2);
}
function artistRenderLicenses() {
const container = document.getElementById('artist-license-list');
container.innerHTML = '';
if (artistLicenses.length === 0) {
container.innerHTML = '<p style="color: #666; text-align: center; padding: 1rem;">No licenses created yet</p>';
return;
}
artistLicenses.forEach(license => {
const item = document.createElement('div');
item.style.cssText = 'background: rgba(0,0,0,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 0.5rem;';
item.innerHTML = '<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">' +
'<span style="color: #888; font-size: 0.8rem;">#' + license.id + ' · ' + license.timestamp.toLocaleString() + '</span>' +
'<span style="color: #8338ec; font-size: 0.8rem;">' + license.licenseType.toUpperCase() + '</span>' +
'</div>' +
'<div style="font-family: monospace; color: #00ff94; background: rgba(0,0,0,0.3); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">' + license.token + '</div>' +
'<div style="display: flex; justify-content: space-between; font-size: 0.8rem; color: #888;">' +
'<span>' + license.contentName + '</span>' +
'<span>' + license.price + '</span>' +
'</div>';
container.appendChild(item);
});
}
document.getElementById('artist-generate-btn').addEventListener('click', artistGenerateLicense);
document.getElementById('artist-copy-token').addEventListener('click', async () => {
const token = document.getElementById('artist-token-display').textContent;
await navigator.clipboard.writeText(token);
const btn = document.getElementById('artist-copy-token');
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
});
// Enable generate button when master password set (WASM loads on-demand)
function checkArtistGenerateReady() {
const hasMasterPassword = document.getElementById('artist-master-password').value.length > 0;
document.getElementById('artist-generate-btn').disabled = !hasMasterPassword;
}
document.getElementById('artist-master-password').addEventListener('input', checkArtistGenerateReady);
const originalInitWasm = initWasm;
initWasm = async function() {
await originalInitWasm();
if (wasmReady) {
// Preload Vi demo if we're on Artist mode
const artistActive = document.querySelector('.mode-btn[data-mode="artist"]').classList.contains('active');
if (artistActive && !artistDemoLoaded) {
preloadArtistDemo();
}
}
};
// Init tracks
artistRenderTracks();
// ========== RE-KEY FUNCTIONALITY ==========
let rekeySourceData = null;
let rekeySourceName = 'content';
// Handle URL or file input
document.getElementById('rekey-url').addEventListener('input', checkRekeyReady);
document.getElementById('rekey-original-password').addEventListener('input', checkRekeyReady);
document.getElementById('rekey-file').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Check if it's text (base64) or binary
const isText = bytes.every(b => b < 128);
if (isText) {
rekeySourceData = new TextDecoder().decode(bytes);
} else {
let base64 = '';
const chunkSize = 32768;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
base64 += String.fromCharCode.apply(null, chunk);
}
rekeySourceData = btoa(base64);
}
rekeySourceName = file.name.replace(/\.smsg$/, '');
document.getElementById('rekey-url').value = '';
checkRekeyReady();
} catch (err) {
alert('Failed to load file: ' + err.message);
}
});
function checkRekeyReady() {
const hasSource = rekeySourceData || document.getElementById('rekey-url').value.trim();
const hasPassword = document.getElementById('rekey-original-password').value.length > 0;
document.getElementById('rekey-btn').disabled = !wasmReady || !hasSource || !hasPassword;
}
function showRekeyStatus(message, isError) {
const status = document.getElementById('rekey-status');
status.style.display = 'block';
status.style.padding = '0.75rem';
status.style.borderRadius = '8px';
status.style.fontSize = '0.85rem';
if (isError) {
status.style.background = 'rgba(255, 82, 82, 0.1)';
status.style.color = '#ff5252';
} else {
status.style.background = 'rgba(58, 134, 255, 0.1)';
status.style.color = '#3a86ff';
}
status.textContent = message;
}
async function rekeyContent() {
if (!wasmReady) return;
const btn = document.getElementById('rekey-btn');
btn.disabled = true;
btn.textContent = 'Re-Keying...';
document.getElementById('rekey-output').style.display = 'none';
try {
let sourceBase64 = rekeySourceData;
// Fetch from URL if no file uploaded
if (!sourceBase64) {
const url = document.getElementById('rekey-url').value.trim();
showRekeyStatus('Fetching from ' + url + '...');
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch: ' + response.status);
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text') || contentType.includes('json')) {
sourceBase64 = await response.text();
} else {
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
let base64 = '';
const chunkSize = 32768;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
base64 += String.fromCharCode.apply(null, chunk);
}
sourceBase64 = btoa(base64);
}
rekeySourceName = url.split('/').pop().replace(/\.smsg$/, '') || 'content';
}
const originalPassword = document.getElementById('rekey-original-password').value;
const newToken = document.getElementById('rekey-new-token').value.trim() || generateArtistToken();
const licenseType = document.getElementById('rekey-license-type').value;
// Decrypt with original password
showRekeyStatus('Decrypting with original key...');
const decrypted = await BorgSMSG.decryptStream(sourceBase64, originalPassword);
// Get original manifest if exists
let originalManifest = {};
try {
const info = await BorgSMSG.getInfo(sourceBase64);
if (info.manifest) originalManifest = info.manifest;
} catch (e) { }
// Build new manifest with updated expiry
const now = Math.floor(Date.now() / 1000);
let expiresAt = 0;
if (licenseType === 'preview') expiresAt = now + 30;
else if (licenseType === 'stream') expiresAt = now + 86400;
else if (licenseType === 'rental') expiresAt = now + 604800;
const newManifest = {
...originalManifest,
licenseType: licenseType,
issuedAt: now,
expiresAt: expiresAt,
rekeyedFrom: originalManifest.title || rekeySourceName
};
// Re-encrypt with new token
showRekeyStatus('Re-encrypting with new token...');
// Rebuild attachments from decrypted data
const attachments = [];
if (decrypted.attachments) {
for (const att of decrypted.attachments) {
// att.data is Uint8Array, convert back to base64
let base64Content = '';
const chunkSize = 32768;
for (let i = 0; i < att.data.length; i += chunkSize) {
const chunk = att.data.subarray(i, Math.min(i + chunkSize, att.data.length));
base64Content += String.fromCharCode.apply(null, chunk);
}
attachments.push({
name: att.name,
mime: att.mime,
content: btoa(base64Content)
});
}
}
const encrypted = await BorgSMSG.encryptWithManifest({
body: decrypted.body || 'Re-keyed content',
subject: decrypted.subject || newManifest.title || rekeySourceName,
from: decrypted.from || 'Artist',
attachments: attachments
}, newToken, newManifest);
// Show output
document.getElementById('rekey-status').style.display = 'none';
document.getElementById('rekey-token-display').textContent = newToken;
document.getElementById('rekey-output').style.display = 'block';
// Download handler
document.getElementById('rekey-download-btn').onclick = () => {
const blob = new Blob([encrypted], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = rekeySourceName + '-' + licenseType + '.smsg';
a.click();
URL.revokeObjectURL(url);
};
// Add to issued licenses
artistLicenseCounter++;
artistLicenses.unshift({
id: artistLicenseCounter,
token: newToken,
customer: '',
price: '(re-keyed)',
timestamp: new Date(),
encrypted: encrypted,
contentName: rekeySourceName + ' [RE-KEY]',
licenseType: licenseType
});
artistUpdateStats();
artistRenderLicenses();
} catch (err) {
showRekeyStatus('Failed: ' + err.message, true);
} finally {
btn.textContent = 'Re-Key Content';
checkRekeyReady();
}
}
document.getElementById('rekey-btn').addEventListener('click', rekeyContent);
document.getElementById('rekey-copy-token').addEventListener('click', async () => {
const token = document.getElementById('rekey-token-display').textContent;
await navigator.clipboard.writeText(token);
const btn = document.getElementById('rekey-copy-token');
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
});
// ========== STREAMING MODE ==========
// Uses v3 chunked format with chunk-by-chunk decryption
const STREAMING_V3_URL = 'demo-track-v3.smsg'; // V3 chunked file
const STREAMING_LICENSE = '6ZhMQ034bT6maHqMaJejoxDMpfaOQvq5';
let streamingHeaderInfo = null;
let streamingCEK = null;
let streamingFileBytes = null;
// Tab switching for streaming mode
document.querySelectorAll('[data-streaming-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.streamingTab;
document.querySelectorAll('[data-streaming-tab]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('#streaming-chunked, #streaming-seek, #streaming-abr').forEach(p => p.classList.remove('active'));
document.getElementById('streaming-' + tab).classList.add('active');
});
});
async function initStreamingDemo() {
if (!wasmReady) return;
document.getElementById('streaming-license').value = STREAMING_LICENSE;
// Chunk grid will be populated when we know the actual chunk count
const progressDiv = document.getElementById('streaming-progress');
progressDiv.style.display = 'block';
document.getElementById('streaming-progress-bar').style.width = '0%';
document.getElementById('streaming-progress-text').textContent = 'Ready';
document.getElementById('stat-total-chunks').textContent = '-';
document.getElementById('stat-chunk-size').textContent = '-';
document.getElementById('stat-decrypted').textContent = '0 B';
document.getElementById('stat-total-size').textContent = '-';
document.getElementById('streaming-start-btn').disabled = false;
document.getElementById('seek-chunk-btn').disabled = false;
// Update sidebar for v3
document.getElementById('streaming-file-info').style.display = 'block';
document.getElementById('info-format').textContent = 'v3 (chunked)';
document.getElementById('info-cadence').textContent = 'daily';
document.getElementById('info-chunked').textContent = 'Yes';
}
// Chunk-by-chunk streaming decryption
document.getElementById('streaming-start-btn').addEventListener('click', async () => {
const license = document.getElementById('streaming-license').value;
if (!license) return;
const playerDiv = document.getElementById('streaming-player');
const wrapper = document.getElementById('streaming-media-wrapper');
const chunksDiv = document.getElementById('streaming-chunks');
// Show player
playerDiv.style.display = 'block';
wrapper.innerHTML = `
<div style="position: relative; border-radius: 12px; overflow: hidden; background: #000;">
<video id="streaming-video" style="width: 100%; max-height: 300px; display: block; opacity: 0.3;" controls></video>
<div id="buffer-overlay" style="position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.8); padding: 0.5rem 1rem;">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<div style="color: #ff006e; font-size: 0.8rem;" id="buffer-status">Fetching header...</div>
<div style="flex: 1; background: rgba(255,255,255,0.2); height: 4px; border-radius: 2px; overflow: hidden;">
<div id="buffer-bar" style="background: linear-gradient(90deg, #ff006e, #8338ec); height: 100%; width: 0%; transition: width 0.1s;"></div>
</div>
<div id="buffer-text" style="color: #888; font-size: 0.75rem; font-family: monospace;">0%</div>
</div>
</div>
</div>
`;
const updateStatus = (status) => {
document.getElementById('buffer-status').textContent = status;
};
const updateProgress = (percent) => {
document.getElementById('streaming-progress-bar').style.width = percent + '%';
document.getElementById('streaming-progress-text').textContent = Math.round(percent) + '%';
document.getElementById('buffer-bar').style.width = percent + '%';
document.getElementById('buffer-text').textContent = Math.round(percent) + '%';
};
const markChunk = (idx, state) => {
const el = document.getElementById('chunk-' + idx);
if (!el) return;
if (state === 'done') {
el.style.background = 'linear-gradient(135deg, #ff006e, #8338ec)';
el.style.color = '#fff';
} else if (state === 'active') {
el.style.background = '#ff006e';
el.style.color = '#fff';
}
};
try {
// Stream download with MediaSource - play video as chunks arrive
updateStatus('Connecting...');
const response = await fetch(STREAMING_V3_URL);
if (!response.ok) throw new Error('V3 file not found');
const contentLength = parseInt(response.headers.get('content-length') || '0');
const reader = response.body.getReader();
// Accumulate bytes as they stream in
let receivedBytes = new Uint8Array(contentLength || 50 * 1024 * 1024);
let receivedLength = 0;
let headerParsed = false;
let numChunks = 0;
let lastDecryptedChunk = -1;
let decryptedBytes = 0;
// Collect decrypted chunks for blob URL (works with any MP4)
const decryptedChunks = [];
let videoDataOffset = 0;
let mimeType = 'video/mp4';
// Read stream
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Append to buffer
if (receivedLength + value.length > receivedBytes.length) {
const newBuffer = new Uint8Array(receivedBytes.length * 2);
newBuffer.set(receivedBytes);
receivedBytes = newBuffer;
}
receivedBytes.set(value, receivedLength);
receivedLength += value.length;
updateStatus(`Downloading... ${formatBytes(receivedLength)}`);
// Try to parse header once we have enough data
if (!headerParsed && receivedLength > 5000) {
try {
streamingFileBytes = receivedBytes.subarray(0, receivedLength);
streamingHeaderInfo = await BorgSMSG.parseV3Header(streamingFileBytes);
if (streamingHeaderInfo.chunked) {
headerParsed = true;
numChunks = streamingHeaderInfo.chunked.totalChunks;
// Update UI
document.getElementById('stat-total-chunks').textContent = numChunks;
document.getElementById('stat-chunk-size').textContent = formatBytes(streamingHeaderInfo.chunked.chunkSize);
document.getElementById('stat-total-size').textContent = formatBytes(streamingHeaderInfo.chunked.totalSize);
// Create chunk grid
chunksDiv.innerHTML = '';
for (let i = 0; i < numChunks; i++) {
const chunkEl = document.createElement('div');
chunkEl.style.cssText = 'width: 24px; height: 24px; border-radius: 4px; background: rgba(255,255,255,0.1); display: flex; align-items: center; justify-content: center; font-size: 0.6rem; color: #666; transition: all 0.15s;';
chunkEl.textContent = i + 1;
chunkEl.id = 'chunk-' + i;
chunksDiv.appendChild(chunkEl);
}
// Unwrap CEK
updateStatus('Unwrapping key...');
streamingCEK = await BorgSMSG.unwrapCEKFromHeader(
streamingHeaderInfo.wrappedKeys,
{ license: license, fingerprint: '' },
streamingHeaderInfo.cadence
);
}
} catch (e) {
// Not enough data yet, keep downloading
}
}
// Decrypt chunks as they become available
if (headerParsed && streamingCEK) {
for (let i = lastDecryptedChunk + 1; i < numChunks; i++) {
const chunkInfo = streamingHeaderInfo.chunked.index[i];
const chunkStart = streamingHeaderInfo.payloadOffset + chunkInfo.offset;
const chunkEnd = chunkStart + chunkInfo.size;
// Check if we have enough data for this chunk
if (receivedLength >= chunkEnd) {
markChunk(i, 'active');
const chunkBytes = receivedBytes.subarray(chunkStart, chunkEnd);
// Decrypt this chunk
const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, streamingCEK);
decryptedBytes += decrypted.length;
lastDecryptedChunk = i;
// First chunk: parse metadata to get MIME type
if (i === 0) {
for (let j = 0; j < Math.min(decrypted.length, 10000); j++) {
if (decrypted[j] === 0x7D) {
try {
const meta = JSON.parse(new TextDecoder().decode(decrypted.subarray(0, j + 1)));
videoDataOffset = j + 1;
if (meta.attachments && meta.attachments[0]) {
mimeType = meta.attachments[0].mime || 'video/mp4';
}
break;
} catch (e) {}
}
}
}
// Store decrypted chunk
decryptedChunks.push(decrypted);
markChunk(i, 'done');
updateProgress((i + 1) / numChunks * 100);
document.getElementById('stat-decrypted').textContent = formatBytes(decryptedBytes);
updateStatus(`Chunk ${i + 1}/${numChunks} decrypted`);
} else {
break; // Wait for more data
}
}
}
}
// Final trim
streamingFileBytes = receivedBytes.subarray(0, receivedLength);
// Decrypt any remaining chunks
if (headerParsed && streamingCEK) {
for (let i = lastDecryptedChunk + 1; i < numChunks; i++) {
markChunk(i, 'active');
const chunkInfo = streamingHeaderInfo.chunked.index[i];
const chunkStart = streamingHeaderInfo.payloadOffset + chunkInfo.offset;
const chunkEnd = chunkStart + chunkInfo.size;
const chunkBytes = streamingFileBytes.subarray(chunkStart, chunkEnd);
const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, streamingCEK);
decryptedBytes += decrypted.length;
decryptedChunks.push(decrypted);
markChunk(i, 'done');
updateProgress((i + 1) / numChunks * 100);
document.getElementById('stat-decrypted').textContent = formatBytes(decryptedBytes);
}
}
// Combine decrypted chunks and create video blob
updateStatus('Assembling video...');
const totalSize = decryptedChunks.reduce((sum, c) => sum + c.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of decryptedChunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
// Extract video data (skip JSON metadata in first chunk)
const videoData = combined.subarray(videoDataOffset);
const blob = new Blob([videoData], { type: mimeType });
const url = URL.createObjectURL(blob);
const video = document.getElementById('streaming-video');
video.src = url;
video.style.opacity = '1';
document.getElementById('buffer-overlay').style.display = 'none';
updateProgress(100);
updateStatus('Ready to play');
document.getElementById('streaming-complete-banner').style.display = 'block';
} catch (err) {
console.error('Streaming failed:', err);
document.getElementById('buffer-status').textContent = 'Error: ' + err.message;
document.getElementById('buffer-status').style.color = '#ff5252';
}
});
// Seek to specific chunk - uses v3 chunked format
document.getElementById('seek-chunk-btn').addEventListener('click', async () => {
const chunkNum = parseInt(document.getElementById('seek-chunk-input').value) || 0;
const license = document.getElementById('streaming-license').value;
if (!license) return;
try {
// Load file if not already loaded
if (!streamingFileBytes) {
const response = await fetch(STREAMING_V3_URL);
if (!response.ok) throw new Error('V3 file not found');
streamingFileBytes = new Uint8Array(await response.arrayBuffer());
}
if (!streamingHeaderInfo) {
streamingHeaderInfo = await BorgSMSG.parseV3Header(streamingFileBytes);
}
if (!streamingHeaderInfo.chunked) {
throw new Error('Not a chunked file');
}
if (chunkNum >= streamingHeaderInfo.chunked.totalChunks) {
throw new Error(`Chunk ${chunkNum} out of range (max: ${streamingHeaderInfo.chunked.totalChunks - 1})`);
}
if (!streamingCEK) {
streamingCEK = await BorgSMSG.unwrapCEKFromHeader(
streamingHeaderInfo.wrappedKeys,
{ license: license, fingerprint: '' },
streamingHeaderInfo.cadence
);
}
// Decrypt just this one chunk
const chunkInfo = streamingHeaderInfo.chunked.index[chunkNum];
const chunkStart = streamingHeaderInfo.payloadOffset + chunkInfo.offset;
const chunkEnd = chunkStart + chunkInfo.size;
const chunkBytes = streamingFileBytes.subarray(chunkStart, chunkEnd);
const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, streamingCEK);
// Show hex output
const hexOutput = Array.from(decrypted.slice(0, 100))
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
document.getElementById('seek-result').style.display = 'block';
document.getElementById('seek-chunk-num').textContent = chunkNum;
document.getElementById('seek-hex-output').textContent = hexOutput || '(empty)';
document.getElementById('seek-bytes-count').textContent = decrypted.length;
document.getElementById('seek-chunk-offset').textContent = 'offset ' + chunkInfo.offset;
} catch (err) {
console.error('Seek failed:', err);
document.getElementById('seek-result').style.display = 'block';
document.getElementById('seek-hex-output').textContent = 'Error: ' + err.message;
}
});
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
// Initialize streaming demo when switching to that mode
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.mode === 'streaming' && wasmReady && !streamingHeaderInfo) {
initStreamingDemo();
}
});
});
// WASM loads lazily on first interaction - no auto-init
// ========== ADAPTIVE BITRATE (ABR) STREAMING ==========
// ABR Controller - manages bandwidth estimation and variant selection
class ABRController {
constructor(manifest) {
this.manifest = manifest;
this.bandwidthSamples = [];
this.currentVariantIdx = manifest.defaultIdx;
this.maxSamples = 10;
this.switchCount = 0;
}
// Record a bandwidth sample from a download
recordSample(bytes, timeMs) {
if (timeMs <= 0) return;
const bps = (bytes * 8 * 1000) / timeMs;
this.bandwidthSamples.push(bps);
if (this.bandwidthSamples.length > this.maxSamples) {
this.bandwidthSamples.shift();
}
}
// Estimate current bandwidth using average of recent samples
estimateBandwidth() {
if (this.bandwidthSamples.length === 0) return 1000000; // 1 Mbps default
const count = Math.min(3, this.bandwidthSamples.length);
const recent = this.bandwidthSamples.slice(-count);
return recent.reduce((a, b) => a + b, 0) / count;
}
// Select best variant for current bandwidth
selectVariant() {
const bw = this.estimateBandwidth();
const safetyFactor = 0.8; // Use 80% of available bandwidth
let selected = 0;
for (let i = 0; i < this.manifest.variants.length; i++) {
if (this.manifest.variants[i].bandwidth <= bw * safetyFactor) {
selected = i;
}
}
return selected;
}
// Check if we should switch variants
shouldSwitch() {
const newVariant = this.selectVariant();
return newVariant !== this.currentVariantIdx;
}
// Switch to new variant
switchTo(idx) {
if (idx !== this.currentVariantIdx) {
this.currentVariantIdx = idx;
this.switchCount++;
}
}
// Format bandwidth for display
formatBandwidth(bps) {
if (bps >= 1000000) return (bps / 1000000).toFixed(1) + ' Mbps';
if (bps >= 1000) return (bps / 1000).toFixed(0) + ' Kbps';
return bps + ' bps';
}
}
// ABR state
let abrController = null;
let abrManifest = null;
let abrManualQuality = 'auto';
// Populate quality selector from manifest
function populateABRQualitySelector(manifest) {
const select = document.getElementById('abr-quality-select');
select.innerHTML = '<option value="auto">Auto</option>';
manifest.variants.forEach((v, i) => {
const bw = v.bandwidth >= 1000000
? (v.bandwidth / 1000000).toFixed(1) + ' Mbps'
: (v.bandwidth / 1000) + ' Kbps';
select.innerHTML += `<option value="${i}">${v.name} (${bw})</option>`;
});
}
// Update ABR status display
function updateABRStatus(abr, currentVariant) {
document.getElementById('abr-current-quality').textContent = currentVariant.name;
document.getElementById('abr-bandwidth').textContent = abr.formatBandwidth(abr.estimateBandwidth());
document.getElementById('abr-switches').textContent = abr.switchCount;
}
// Quality selector change
document.getElementById('abr-quality-select').addEventListener('change', (e) => {
abrManualQuality = e.target.value;
});
// Start ABR stream
document.getElementById('abr-start-btn').addEventListener('click', async () => {
const manifestUrl = document.getElementById('abr-manifest-url').value;
const license = document.getElementById('abr-license').value;
if (!manifestUrl) {
alert('Please enter a manifest URL');
return;
}
if (!license) {
alert('Please enter a license token');
return;
}
// Ensure WASM is ready
if (!wasmReady) {
document.getElementById('abr-start-btn').textContent = 'Loading WASM...';
document.getElementById('abr-start-btn').disabled = true;
await ensureWasm();
document.getElementById('abr-start-btn').textContent = 'Start ABR Stream';
document.getElementById('abr-start-btn').disabled = false;
}
const statusDiv = document.getElementById('abr-status');
const progressDiv = document.getElementById('abr-progress');
const playerDiv = document.getElementById('abr-player');
const infoDiv = document.getElementById('abr-info');
statusDiv.style.display = 'block';
progressDiv.style.display = 'block';
infoDiv.style.display = 'none';
try {
// 1. Fetch and parse ABR manifest
document.getElementById('abr-current-quality').textContent = 'Loading manifest...';
const manifestResponse = await fetch(manifestUrl);
if (!manifestResponse.ok) throw new Error('Manifest not found');
const manifestData = await manifestResponse.json();
// Parse manifest (can use WASM or direct)
abrManifest = manifestData;
if (abrManifest.version !== 'abr-v1') {
throw new Error('Invalid ABR manifest version');
}
// 2. Initialize ABR controller
abrController = new ABRController(abrManifest);
populateABRQualitySelector(abrManifest);
// 3. Select initial variant
let variantIdx = abrManualQuality === 'auto'
? abrController.selectVariant()
: parseInt(abrManualQuality);
abrController.currentVariantIdx = variantIdx;
let variant = abrManifest.variants[variantIdx];
updateABRStatus(abrController, variant);
// 4. Resolve variant URL relative to manifest
const baseUrl = manifestUrl.substring(0, manifestUrl.lastIndexOf('/') + 1);
let variantUrl = baseUrl + variant.url;
// 5. Fetch variant file
document.getElementById('abr-current-quality').textContent = variant.name + ' (downloading...)';
const startTime = performance.now();
const variantResponse = await fetch(variantUrl);
if (!variantResponse.ok) throw new Error('Variant file not found: ' + variant.url);
const variantBytes = new Uint8Array(await variantResponse.arrayBuffer());
const downloadTime = performance.now() - startTime;
// Record bandwidth sample
abrController.recordSample(variantBytes.length, downloadTime);
updateABRStatus(abrController, variant);
// 6. Parse v3 header
const header = await BorgSMSG.parseV3Header(variantBytes);
if (!header.chunked) throw new Error('Variant is not chunked');
// 7. Unwrap CEK
const cek = await BorgSMSG.unwrapCEKFromHeader(
header.wrappedKeys,
{ license: license, fingerprint: '' },
header.cadence
);
// 8. Decrypt chunks
const numChunks = header.chunked.totalChunks;
const decryptedChunks = [];
let decryptedBytes = 0;
let videoDataOffset = 0;
let mimeType = 'video/mp4';
for (let i = 0; i < numChunks; i++) {
const chunkInfo = header.chunked.index[i];
const chunkStart = header.payloadOffset + chunkInfo.offset;
const chunkEnd = chunkStart + chunkInfo.size;
const chunkBytes = variantBytes.subarray(chunkStart, chunkEnd);
const chunkStartTime = performance.now();
const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, cek);
const chunkTime = performance.now() - chunkStartTime;
// Record sample (simulate network download time for decryption measurement)
if (chunkTime > 0) {
abrController.recordSample(chunkBytes.length, chunkTime * 100); // Scale for simulation
}
decryptedChunks.push(decrypted);
decryptedBytes += decrypted.length;
// First chunk contains message metadata
if (i === 0) {
try {
const textDecoder = new TextDecoder();
let jsonEnd = 0;
for (let j = 0; j < Math.min(decrypted.length, 10000); j++) {
if (decrypted[j] === 0) { jsonEnd = j; break; }
}
if (jsonEnd > 0) {
const jsonStr = textDecoder.decode(decrypted.subarray(0, jsonEnd));
const msg = JSON.parse(jsonStr);
if (msg.attachments && msg.attachments.length > 0) {
mimeType = msg.attachments[0].mime || 'video/mp4';
}
videoDataOffset = jsonEnd + 1;
}
} catch (e) {
console.log('No embedded metadata');
}
}
// Update progress
const progress = (i + 1) / numChunks * 100;
document.getElementById('abr-progress-bar').style.width = progress + '%';
document.getElementById('abr-progress-text').textContent = Math.round(progress) + '%';
// Check for quality switch (in real ABR, would switch at this point)
if (abrManualQuality === 'auto' && abrController.shouldSwitch()) {
const newIdx = abrController.selectVariant();
abrController.switchTo(newIdx);
updateABRStatus(abrController, abrManifest.variants[newIdx]);
// Note: Real implementation would fetch new variant here
}
updateABRStatus(abrController, abrManifest.variants[abrController.currentVariantIdx]);
}
// 9. Combine and play
const totalSize = decryptedChunks.reduce((sum, c) => sum + c.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of decryptedChunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
const videoData = combined.subarray(videoDataOffset);
const blob = new Blob([videoData], { type: mimeType });
const url = URL.createObjectURL(blob);
// Show player
playerDiv.style.display = 'block';
const wrapper = document.getElementById('abr-media-wrapper');
wrapper.innerHTML = `
<div style="border-radius: 12px; overflow: hidden; background: #000;">
<video id="abr-video" style="width: 100%; max-height: 300px; display: block;" controls autoplay>
<source src="${url}" type="${mimeType}">
</video>
</div>
<div style="margin-top: 0.5rem; text-align: center; color: #888; font-size: 0.8rem;">
Playing: ${abrManifest.title} @ ${variant.name}
</div>
`;
document.getElementById('abr-current-quality').textContent = variant.name + ' (playing)';
document.getElementById('abr-progress-text').textContent = 'Complete';
} catch (err) {
console.error('ABR streaming failed:', err);
document.getElementById('abr-current-quality').textContent = 'Error: ' + err.message;
document.getElementById('abr-current-quality').style.color = '#ff5252';
}
});
</script>
</body>
</html>