A music player that connects to your cloud/distributed storage.
at v4 450 lines 14 kB view raw
1import { Temporal } from "~/common/temporal.js"; 2 3import "@awesome.me/webawesome/dist/components/split-panel/split-panel.js"; 4import "@awesome.me/webawesome/dist/components/dialog/dialog.js"; 5import "@awesome.me/webawesome/dist/components/button/button.js"; 6import "@awesome.me/webawesome/dist/components/input/input.js"; 7import "@awesome.me/webawesome/dist/components/icon/icon.js"; 8import "@awesome.me/webawesome/dist/components/select/select.js"; 9import "@awesome.me/webawesome/dist/components/option/option.js"; 10 11import "~/common/webawesome/detect-dark.js"; 12import foundation from "~/common/foundation.js"; 13import * as Output from "~/common/output.js"; 14 15// Set doc title 16document.title = "Split View | Diffuse"; 17 18/** 19 * @import { default as WaSplitPanel } from "@awesome.me/webawesome/dist/components/split-panel/split-panel.js" 20 * @import { default as WaDialog } from "@awesome.me/webawesome/dist/components/dialog/dialog.js" 21 * @import { default as WaInput } from "@awesome.me/webawesome/dist/components/input/input.js" 22 * @import { default as WaIcon } from "@awesome.me/webawesome/dist/components/icon/icon.js" 23 * @import { default as WaSelect } from "@awesome.me/webawesome/dist/components/select/select.js" 24 */ 25 26/** 27 * @typedef {{ type: "pane", facet: string | null }} PaneNode 28 * @typedef {{ type: "split", orientation: "horizontal" | "vertical", position: number, start: Node, end: Node }} SplitNode 29 * @typedef {PaneNode | SplitNode} Node 30 */ 31 32const STORAGE_KEY = "diffuse/facets/misc/split-view/builder/layout"; 33 34// ─── State ─────────────────────────────────────────────────────────────────── 35 36/** @type {Node} */ 37let state = loadState(); 38 39let editMode = false; 40 41/** @type {string | null} */ 42let pendingPaneId = null; 43 44/** @returns {Node} */ 45function loadState() { 46 try { 47 return /** @type {Node} */ (JSON.parse( 48 localStorage.getItem(STORAGE_KEY) ?? "null", 49 )) ?? defaultState(); 50 } catch { 51 return defaultState(); 52 } 53} 54 55function saveState() { 56 localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); 57} 58 59/** @returns {PaneNode} */ 60function defaultState() { 61 return { type: "pane", facet: null }; 62} 63 64// ─── Tree helpers ───────────────────────────────────────────────────────────── 65// Nodes are identified by a dot-separated path: "root", "root.start", "root.end.start" 66 67/** 68 * @param {string} path 69 * @returns {Node} 70 */ 71function getNode(path) { 72 if (path === "root") return state; 73 const parts = path.replace(/^root\./, "").split("."); 74 /** @type {any} */ 75 let node = state; 76 for (const part of parts) node = node[part]; 77 return /** @type {Node} */ (node); 78} 79 80/** 81 * @param {string} path 82 * @param {Node} newNode 83 */ 84function replaceNode(path, newNode) { 85 if (path === "root") { 86 state = newNode; 87 return; 88 } 89 const parts = path.split("."); 90 const parentPath = parts.slice(0, -1).join("."); 91 const childKey = parts[parts.length - 1]; 92 const parent = /** @type {any} */ (getNode(parentPath)); 93 parent[childKey] = newNode; 94} 95 96// ─── Mutations ──────────────────────────────────────────────────────────────── 97 98/** 99 * @param {string} nodePath 100 * @param {"horizontal" | "vertical"} orientation 101 */ 102function splitPane(nodePath, orientation) { 103 const existing = getNode(nodePath); 104 replaceNode(nodePath, { 105 type: "split", 106 orientation, 107 position: 50, 108 start: existing, 109 end: { type: "pane", facet: null }, 110 }); 111 saveState(); 112 render(); 113} 114 115/** @param {string} nodePath */ 116function removePane(nodePath) { 117 if (nodePath === "root") return; 118 const parts = nodePath.split("."); 119 const parentPath = parts.slice(0, -1).join("."); 120 const childKey = parts[parts.length - 1]; 121 const siblingKey = childKey === "start" ? "end" : "start"; 122 const parent = /** @type {any} */ (getNode(parentPath)); 123 const sibling = /** @type {Node} */ (parent[siblingKey]); 124 replaceNode(parentPath, sibling); 125 saveState(); 126 render(); 127} 128 129/** 130 * @param {string} nodePath 131 * @param {string} facetPath 132 */ 133function setFacet(nodePath, facetPath) { 134 const pane = /** @type {PaneNode} */ (getNode(nodePath)); 135 pane.facet = facetPath; 136 saveState(); 137 render(); 138} 139 140// ─── Rendering ──────────────────────────────────────────────────────────────── 141 142const layout = /** @type {HTMLElement} */ (document.querySelector("#layout")); 143 144function render() { 145 layout.innerHTML = ""; 146 layout.appendChild(renderNode(state, "root", true)); 147 layout.classList.toggle("edit-mode", editMode); 148} 149 150/** 151 * @param {Node} node 152 * @param {string} path 153 * @param {boolean} isRoot 154 * @returns {HTMLElement} 155 */ 156function renderNode(node, path, isRoot) { 157 if (node.type === "split") { 158 const panel = 159 /** @type {WaSplitPanel} */ (document.createElement("wa-split-panel")); 160 panel.position = node.position; 161 if (node.orientation === "vertical") panel.orientation = "vertical"; 162 163 const startSlot = document.createElement("div"); 164 startSlot.slot = "start"; 165 startSlot.appendChild(renderNode(node.start, path + ".start", false)); 166 167 const endSlot = document.createElement("div"); 168 endSlot.slot = "end"; 169 endSlot.appendChild(renderNode(node.end, path + ".end", false)); 170 171 panel.appendChild(startSlot); 172 panel.appendChild(endSlot); 173 174 panel.addEventListener("wa-reposition", () => { 175 const splitNode = /** @type {SplitNode} */ (getNode(path)); 176 splitNode.position = panel.position; 177 saveState(); 178 }); 179 180 return panel; 181 } 182 183 // Pane 184 const pane = document.createElement("div"); 185 pane.className = "pane" + (node.facet ? "" : " pane--empty"); 186 187 if (node.facet) { 188 const iframe = document.createElement("iframe"); 189 const uri = node.facet.includes("://") 190 ? node.facet 191 : `diffuse://${node.facet}`; 192 iframe.src = "l/?uri=" + encodeURIComponent(uri); 193 iframe.allow = "autoplay"; 194 pane.appendChild(iframe); 195 } 196 197 const overlay = document.createElement("div"); 198 overlay.className = "pane-overlay"; 199 200 if (node.facet) { 201 const title = 202 document.querySelector(`#facet-select wa-option[value="${node.facet}"]`) 203 ?.textContent?.trim() ?? node.facet; 204 const label = document.createElement("div"); 205 label.className = "pane-name"; 206 label.style.fontWeight = "700"; 207 label.textContent = title; 208 overlay.appendChild(label); 209 } 210 211 overlay.appendChild( 212 makeWaButton( 213 node.facet ? "Change facet" : "+ Add facet", 214 "neutral", 215 "filled", 216 () => openPicker(path), 217 ), 218 ); 219 overlay.appendChild( 220 makeWaButton( 221 "Split left / right", 222 "neutral", 223 "outlined", 224 () => splitPane(path, "horizontal"), 225 ), 226 ); 227 overlay.appendChild( 228 makeWaButton( 229 "Split top / bottom", 230 "neutral", 231 "outlined", 232 () => splitPane(path, "vertical"), 233 ), 234 ); 235 236 if (!isRoot) { 237 overlay.appendChild( 238 makeWaButton("Remove", "danger", "outlined", () => removePane(path)), 239 ); 240 } 241 242 pane.appendChild(overlay); 243 return pane; 244} 245 246/** 247 * @param {string} text 248 * @param {string} variant 249 * @param {string} appearance 250 * @param {() => void} onClick 251 * @returns {HTMLElement} 252 */ 253function makeWaButton(text, variant, appearance, onClick) { 254 const btn = document.createElement("wa-button"); 255 btn.setAttribute("variant", variant); 256 btn.setAttribute("appearance", appearance); 257 btn.setAttribute("size", "small"); 258 btn.style.width = "100%"; 259 btn.textContent = text; 260 btn.addEventListener("click", (e) => { 261 e.stopPropagation(); 262 onClick(); 263 }); 264 return btn; 265} 266 267// ─── Divider drag: disable iframe pointer events while dragging ─────────────── 268 269document.addEventListener("mousedown", (e) => { 270 const isDivider = e.composedPath().some( 271 (el) => el instanceof Element && el.getAttribute("part") === "divider", 272 ); 273 if (isDivider) layout.classList.add("dragging"); 274}, { capture: true }); 275 276document.addEventListener("mouseup", () => { 277 layout.classList.remove("dragging"); 278}); 279 280// ─── Edit mode ──────────────────────────────────────────────────────────────── 281 282const editToggle = 283 /** @type {HTMLElement} */ (document.querySelector("#edit-toggle")); 284const editIcon = /** @type {WaIcon} */ (document.querySelector("#edit-icon")); 285const saveCopyBtn = 286 /** @type {HTMLElement} */ (document.querySelector("#save-copy-btn")); 287const saveCopyIcon = 288 /** @type {WaIcon} */ (document.querySelector("#save-copy-icon")); 289 290editToggle.addEventListener("click", () => { 291 editMode = !editMode; 292 editToggle.setAttribute("aria-label", editMode ? "Done" : "Edit layout"); 293 editIcon.name = editMode ? "xmark" : "border-all"; 294 layout.classList.toggle("edit-mode", editMode); 295 saveCopyBtn.style.display = editMode ? "" : "none"; 296}); 297 298saveCopyBtn.addEventListener("click", async () => { 299 await saveSimplifiedCopy(); 300 saveCopyIcon.name = "check"; 301 setTimeout(() => { 302 saveCopyIcon.name = "floppy-disk"; 303 }, 2000); 304}); 305 306// ─── Facet picker ───────────────────────────────────────────────────────────── 307 308const pickerDialog = 309 /** @type {WaDialog} */ (document.querySelector("#facet-picker")); 310const facetSelect = 311 /** @type {WaSelect} */ (document.querySelector("#facet-select")); 312const customPath = 313 /** @type {WaInput} */ (document.querySelector("#custom-path")); 314const customConfirm = 315 /** @type {HTMLElement} */ (document.querySelector("#custom-confirm")); 316 317customConfirm.addEventListener("click", () => { 318 const val = customPath.value?.trim() || /** @type {string} */ 319 (facetSelect.value) || ""; 320 if (val && pendingPaneId !== null) setFacet(pendingPaneId, val); 321 pendingPaneId = null; 322 pickerDialog.open = false; 323}); 324 325pickerDialog.addEventListener("wa-hide", (e) => { 326 if (e.target === pickerDialog) pendingPaneId = null; 327}); 328 329/** @param {string} nodePath */ 330function openPicker(nodePath) { 331 pendingPaneId = nodePath; 332 facetSelect.value = null; 333 customPath.value = ""; 334 pickerDialog.open = true; 335} 336 337// ─── Save simplified copy ───────────────────────────────────────────────────── 338 339/** 340 * @param {Node} node 341 * @param {string} indent 342 * @returns {string} 343 */ 344function generateNodeHTML(node, indent = "") { 345 const inner = indent + " "; 346 347 if (node.type === "split") { 348 const orientationAttr = node.orientation === "vertical" 349 ? ' orientation="vertical"' 350 : ""; 351 return `${indent}<wa-split-panel position="${node.position}"${orientationAttr}> 352${inner}<div slot="start"> 353${generateNodeHTML(node.start, inner + " ")} 354${inner}</div> 355${inner}<div slot="end"> 356${generateNodeHTML(node.end, inner + " ")} 357${inner}</div> 358${indent}</wa-split-panel>`; 359 } 360 361 if (node.facet) { 362 const uri = node.facet.includes("://") 363 ? node.facet 364 : `diffuse://${node.facet}`; 365 const src = "l/?uri=" + encodeURIComponent(uri); 366 return `${indent}<div class="pane"> 367${inner}<iframe src="${src}" allow="autoplay"></iframe> 368${indent}</div>`; 369 } 370 371 return `${indent}<div class="pane"></div>`; 372} 373 374/** 375 * @param {string} id 376 */ 377function generateSimplifiedHTML(id) { 378 const scriptClose = "</" + "script>"; 379 return `\ 380<link rel="stylesheet" href="vendor/@awesome.me/webawesome/styles/themes/default.css" /> 381 382<style> 383 body { margin: 0; height: 100dvh; overflow: hidden; } 384 #layout, #layout > * { height: 100%; } 385 wa-split-panel { height: 100%; } 386 [slot="start"], [slot="end"] { height: 100%; } 387 .pane { height: 100%; } 388 .pane iframe { border: none; width: 100%; height: 100%; } 389 .dragging iframe { pointer-events: none; } 390</style> 391 392<div id="layout"> 393${generateNodeHTML(state, " ")} 394</div> 395 396<script type="module"> 397 import "@awesome.me/webawesome/dist/components/split-panel/split-panel.js"; 398 import "~/common/webawesome/detect-dark.js"; 399 400 const layout = document.querySelector("#layout"); 401 402 document.addEventListener("mousedown", (e) => { 403 const isDivider = e.composedPath().some( 404 (el) => el instanceof Element && el.getAttribute("part") === "divider", 405 ); 406 if (isDivider) layout.classList.add("dragging"); 407 }, { capture: true }); 408 409 document.addEventListener("mouseup", () => { 410 layout.classList.remove("dragging"); 411 }); 412 413 const POSITIONS_KEY = "diffuse/facets/misc/split-view/${id}/positions"; 414 const savedPositions = (() => { 415 try { return JSON.parse(localStorage.getItem(POSITIONS_KEY) ?? "{}"); } 416 catch { return {}; } 417 })(); 418 419 document.querySelectorAll("wa-split-panel").forEach((panel, i) => { 420 if (savedPositions[i] !== undefined) panel.position = savedPositions[i]; 421 panel.addEventListener("wa-reposition", () => { 422 savedPositions[i] = panel.position; 423 localStorage.setItem(POSITIONS_KEY, JSON.stringify(savedPositions)); 424 }); 425 }); 426${scriptClose}`; 427} 428 429async function saveSimplifiedCopy() { 430 const output = await foundation.orchestrator.output(); 431 const id = crypto.randomUUID(); 432 const html = generateSimplifiedHTML(id); 433 const now = new Date().toISOString(); 434 435 await output.facets.save([ 436 ...(await Output.data(output.facets)), 437 { 438 $type: "sh.diffuse.output.facet", 439 id, 440 name: `Split View (${Temporal.Now.instant().toLocaleString()})`, 441 html, 442 createdAt: now, 443 updatedAt: now, 444 }, 445 ]); 446} 447 448// ─── Init ───────────────────────────────────────────────────────────────────── 449 450render();