From 22ffc3b51a7cca36721fb38a90bff5b66637619f Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 14 Sep 2026 15:51:11 +0200 Subject: [PATCH] gfsd --- public/s/css/f0ckm.css | 697 +++++++-- public/s/css/v0ck.css | 8 +- public/s/js/comments.js | 62 +- public/s/js/danmaku.js | 10 + public/s/js/f0ckm.js | 2405 +++++++++++++++++++++++++++++-- public/s/js/sidebar-activity.js | 25 +- public/s/js/v0ck.js | 47 +- src/inc/lib.mjs | 13 +- src/inc/routeinc/f0cklib.mjs | 12 +- src/inc/routes/apiv2/index.mjs | 125 ++ src/inc/routes/comments.mjs | 8 + views/snippets/footer.html | 5 + views/snippets/item-media.html | 29 +- 13 files changed, 3162 insertions(+), 284 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 9bcb4ea..3910eb1 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -701,39 +701,96 @@ html[theme='paper'] .metadata table th { border-color: #e2e8f0; } -/* Icons & GapRight - high contrast black colored */ +/* Icons & GapRight - aligned with .input-actions button */ html[theme='light'] .gapRight i.iconset, html[theme='light'] .gapRight svg.iconset, html[theme='light'] .gapRight > .iconset, +html[theme='light'] .gapRight button, html[theme='paper'] .gapRight i.iconset, html[theme='paper'] .gapRight svg.iconset, -html[theme='paper'] .gapRight > .iconset { - color: #000 !important; - fill: #000 !important; - background: #f1f5f9 !important; +html[theme='paper'] .gapRight > .iconset, +html[theme='paper'] .gapRight button, +[theme='light'] .gapRight i.iconset, +[theme='light'] .gapRight svg.iconset, +[theme='light'] .gapRight > .iconset, +[theme='light'] .gapRight button, +[theme='paper'] .gapRight i.iconset, +[theme='paper'] .gapRight svg.iconset, +[theme='paper'] .gapRight > .iconset, +[theme='paper'] .gapRight button { + background: #ffffff !important; border: 1px solid #cbd5e1 !important; - border-radius: 4px; + border-radius: 3px !important; + color: #334155 !important; + fill: #334155 !important; } html[theme='light'] .gapRight i.iconset:hover, html[theme='light'] .gapRight svg.iconset:hover, +html[theme='light'] .gapRight > .iconset:hover, +html[theme='light'] .gapRight button:hover, html[theme='paper'] .gapRight i.iconset:hover, -html[theme='paper'] .gapRight svg.iconset:hover { - background: #e2e8f0 !important; - color: #000 !important; - fill: #000 !important; +html[theme='paper'] .gapRight svg.iconset:hover, +html[theme='paper'] .gapRight > .iconset:hover, +html[theme='paper'] .gapRight button:hover, +[theme='light'] .gapRight i.iconset:hover, +[theme='light'] .gapRight svg.iconset:hover, +[theme='light'] .gapRight > .iconset:hover, +[theme='light'] .gapRight button:hover, +[theme='paper'] .gapRight i.iconset:hover, +[theme='paper'] .gapRight svg.iconset:hover, +[theme='paper'] .gapRight > .iconset:hover, +[theme='paper'] .gapRight button:hover, +html[theme='light'] .gapRight i.iconset.active, +html[theme='light'] .gapRight svg.iconset.active, +html[theme='light'] .gapRight > .iconset.active, +html[theme='light'] .gapRight button.active, +html[theme='paper'] .gapRight i.iconset.active, +html[theme='paper'] .gapRight svg.iconset.active, +html[theme='paper'] .gapRight > .iconset.active, +html[theme='paper'] .gapRight button.active, +[theme='light'] .gapRight i.iconset.active, +[theme='light'] .gapRight svg.iconset.active, +[theme='light'] .gapRight > .iconset.active, +[theme='light'] .gapRight button.active, +[theme='paper'] .gapRight i.iconset.active, +[theme='paper'] .gapRight svg.iconset.active, +[theme='paper'] .gapRight > .iconset.active, +[theme='paper'] .gapRight button.active, +html[theme='light'] .gapRight i.iconset.is-active, +html[theme='light'] .gapRight svg.iconset.is-active, +html[theme='light'] .gapRight > .iconset.is-active, +html[theme='light'] .gapRight button.is-active, +html[theme='paper'] .gapRight i.iconset.is-active, +html[theme='paper'] .gapRight svg.iconset.is-active, +html[theme='paper'] .gapRight > .iconset.is-active, +html[theme='paper'] .gapRight button.is-active, +[theme='light'] .gapRight i.iconset.is-active, +[theme='light'] .gapRight svg.iconset.is-active, +[theme='light'] .gapRight > .iconset.is-active, +[theme='light'] .gapRight button.is-active, +[theme='paper'] .gapRight i.iconset.is-active, +[theme='paper'] .gapRight svg.iconset.is-active, +[theme='paper'] .gapRight > .iconset.is-active, +[theme='paper'] .gapRight button.is-active { + background: #f1f5f9 !important; + border-color: var(--accent) !important; + color: var(--accent) !important; + fill: var(--accent) !important; } -html[theme='light'] .gapRight #a_favo, -html[theme='paper'] .gapRight #a_favo { +html[theme='light'] .gapRight #a_favo.fa-solid, +html[theme='paper'] .gapRight #a_favo.fa-solid, +[theme='light'] .gapRight #a_favo.fa-solid, +[theme='paper'] .gapRight #a_favo.fa-solid { color: #e11d48 !important; fill: #e11d48 !important; + border-color: rgba(225, 29, 72, 0.4) !important; } html[theme='light'] i.iconset#a_oc, html[theme='paper'] i.iconset#a_oc { - color: #000 !important; - fill: #000 !important; + color: inherit !important; } /* a_addtag and tag controls */ @@ -3155,23 +3212,41 @@ body.sidebar-right-hidden #sidebar-drag-zone { } .sidebar-media-placeholder.audio { - background: radial-gradient(circle at center, rgba(var(--accent-rgb, 153, 255, 0), 0.22) 0%, rgba(var(--accent-rgb, 153, 255, 0), 0.05) 70%), var(--nav-bg, #1e1e1e); + background: #000000; color: var(--accent); - text-shadow: 0 0 16px rgba(var(--accent-rgb, 153, 255, 0), 0.35); + text-shadow: 0 0 16px color-mix(in srgb, var(--accent, #99ff00) 35%, transparent); + perspective: 800px; + overflow: hidden; + outline: none !important; } .sidebar-media-placeholder.audio.has-cover { - background: radial-gradient(circle at center, rgba(0, 0, 0, 0.45) 0%, rgba(0, 0, 0, 0.82) 85%), var(--nav-bg, #1e1e1e); + background: #000000; +} + +.audio-reactive-bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; + opacity: 0; + transition: opacity 0.3s ease; + will-change: background, opacity; } .audio-cover-circle { - display: none; + display: flex; + align-items: center; + justify-content: center; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 155px; - height: 155px; + width: 125px; + height: 125px; max-width: calc(100% - 40px); max-height: calc(100% - 40px); aspect-ratio: 1 / 1; @@ -3179,31 +3254,63 @@ body.sidebar-right-hidden #sidebar-drag-zone { background-size: cover; background-position: center; background-repeat: no-repeat; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.88), 0 0 35px rgba(var(--accent-rgb, 153, 255, 0), 0.28), inset 0 0 20px rgba(0, 0, 0, 0.65); - border: 2px solid rgba(var(--accent-rgb, 153, 255, 0), 0.4); - overflow: hidden; + background-color: #000000; + background-image: none; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.88), 0 0 150px color-mix(in srgb, var(--accent, #99ff00) 45%, transparent); + border: 2px solid color-mix(in srgb, var(--accent, #99ff00) 40%, transparent); + outline: none !important; + overflow: visible; pointer-events: none; z-index: 1; will-change: transform, box-shadow, border-color; } -.audio-cover-circle::after { - content: ''; +.audio-eye-inner-canvas { position: absolute; - inset: 0; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); border-radius: 50%; - background: radial-gradient(circle at center, transparent 72%, rgba(0, 0, 0, 0.35) 92%, rgba(0, 0, 0, 0.75) 100%); pointer-events: none; + z-index: 2; } -.sidebar-media-placeholder.audio.has-cover .audio-cover-circle { - display: block; +.audio-cover-circle i { + font-size: 3.4rem; + color: var(--accent); + text-shadow: 0 0 16px color-mix(in srgb, var(--accent, #99ff00) 75%, transparent), 0 0 35px color-mix(in srgb, var(--accent, #99ff00) 40%, transparent); + z-index: 2; + position: relative; + pointer-events: none; + display: inline-block; + will-change: transform, filter; + transition: transform 0.04s linear; } -.sidebar-media-placeholder.audio.has-cover i { +.sidebar-media-placeholder.audio.has-cover .audio-cover-circle i { display: none !important; } +.sidebar-media-placeholder.audio:has(.audio-cover-circle) > i { + display: none !important; +} + +.sidebar-media-placeholder.audio .audio-cover-circle { + display: flex; +} + +.thumb > .sidebar-media-placeholder .audio-cover-circle, +.album-thumb-item > .sidebar-media-placeholder .audio-cover-circle, +.item-preview .sidebar-media-placeholder .audio-cover-circle { + display: none !important; +} + +.thumb > .sidebar-media-placeholder > i, +.album-thumb-item > .sidebar-media-placeholder > i, +.item-preview .sidebar-media-placeholder > i { + display: flex !important; +} + .sidebar-media-placeholder.hidden { display: none !important; } @@ -3233,6 +3340,13 @@ body.sidebar-right-hidden #sidebar-drag-zone { pointer-events: none; font-size: clamp(2rem, 5.5vw, 3.8rem); border-radius: inherit; + background: #000000 !important; +} + +.album-gallery-container.is-audio-active, +.album-audio-wrapper, +.embed-responsive:has(.sidebar-media-placeholder.audio) { + background: #000000 !important; } .v0ck:fullscreen > .sidebar-media-placeholder.audio, @@ -3252,6 +3366,13 @@ body.sidebar-right-hidden #sidebar-drag-zone { max-height: calc(100% - 100px); } +.v0ck:fullscreen .audio-cover-circle i, +.v0ck.v0ck_fullscreen .audio-cover-circle i, +#main:fullscreen .v0ck.v0ck_fullscreen .audio-cover-circle i, +#onara-modal:fullscreen .v0ck.v0ck_fullscreen .audio-cover-circle i { + font-size: clamp(5rem, 14vmin, 12rem); +} + .sidebar-media-placeholder.audio i { position: relative; z-index: 2; @@ -3311,6 +3432,50 @@ body.sidebar-right-hidden #sidebar-drag-zone { display: none !important; } +/* Embedded in Right Sidebar */ +.sidebar-tuner-container { + display: none; + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + padding: 0; + touch-action: pan-y; +} + +.sidebar-tuner-container::-webkit-scrollbar { + width: 5px; +} + +.sidebar-tuner-container::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; +} + +.sidebar-tuner-container.active { + display: block; +} + +.f0ck-audio-tuner-panel.in-sidebar { + position: static; + width: 100%; + max-height: none; + overflow-y: visible; + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; + border: none; + border-radius: 0; + box-shadow: none; + padding: 14px 14px 40px 14px; + box-sizing: border-box; +} + +.f0ck-audio-tuner-panel.in-sidebar .f0ck-tuner-close { + display: none; +} + .f0ck-tuner-header { display: flex; justify-content: space-between; @@ -3431,6 +3596,126 @@ body.sidebar-right-hidden #sidebar-drag-zone { color: #fff; } +.f0ck-tuner-presets-bar { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 8px; + padding: 10px; + margin-bottom: 14px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.f0ck-tuner-presets-select-wrap { + display: flex; + gap: 6px; + align-items: center; +} + +.f0ck-tuner-preset-select { + flex: 1; + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 6px; + color: #fff; + padding: 6px 8px; + font-size: 11px; + font-family: inherit; + cursor: pointer; + outline: none; +} + +.f0ck-tuner-preset-select:focus { + border-color: var(--accent, #99ff00); +} + +.f0ck-tuner-preset-select optgroup { + color: var(--accent, #99ff00); + background: #111; + font-weight: bold; +} + +.f0ck-tuner-preset-select option { + color: #eee; + background: #181818; +} + +.f0ck-tuner-btn-icon { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.14); + color: #bbb; + border-radius: 6px; + padding: 6px 9px; + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.f0ck-tuner-btn-icon:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.f0ck-tuner-btn-icon:not(:disabled):hover { + background: rgba(255, 50, 50, 0.25); + border-color: rgba(255, 50, 50, 0.5); + color: #ff5555; +} + +.f0ck-tuner-presets-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.f0ck-tuner-preset-input { + flex: 1; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 6px; + color: #fff; + padding: 5px 8px; + font-size: 11px; + font-family: inherit; + outline: none; +} + +.f0ck-tuner-preset-input:focus { + border-color: var(--accent, #99ff00); +} + +.f0ck-tuner-btn-save-preset, +.f0ck-tuner-btn-import-preset { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #eee; + border-radius: 6px; + padding: 5px 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.f0ck-tuner-btn-save-preset:hover { + background: var(--accent, #99ff00); + border-color: var(--accent, #99ff00); + color: #000; +} + +.f0ck-tuner-btn-import-preset:hover { + background: rgba(255, 255, 255, 0.2); + color: #fff; +} + .sidebar-video-details { display: flex; margin-top: 8px; @@ -6725,8 +7010,10 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid { .gapRight { align-items: center; - display: flex; - + display: inline-flex; + gap: 6px; + flex-wrap: wrap; + vertical-align: middle; } .admin-lock-btn { @@ -6938,7 +7225,8 @@ body:has(.index-container) > .pagewrapper > #main { overflow-y: auto !important; overflow-x: hidden !important; -webkit-overflow-scrolling: touch; - overscroll-behavior-y: contain; + overscroll-behavior-y: auto; + overscroll-behavior-x: contain; box-sizing: border-box; padding: 5px 20px 75px 20px; margin: 0; @@ -7772,7 +8060,7 @@ a.removetag i { background: transparent; border: none; padding: 0; - margin: 0; + margin: 0 0 0 5px; cursor: pointer; color: inherit; display: inline-flex; @@ -7783,44 +8071,15 @@ a.removetag i { vertical-align: middle; border-radius: 50%; outline: none; + opacity: 0.85; + width: 14px; + max-width: 14px; + overflow: visible; + pointer-events: auto; + transform: scale(1); transition: all 0.22s cubic-bezier(0.34, 1.56, 0.64, 1); } -@media (hover: hover) { - .tag-badge:not(.tag-is-excluded) .tag-exclude-btn { - opacity: 0; - width: 0; - max-width: 0; - margin-left: 0; - overflow: hidden; - transform: scale(0.5); - pointer-events: none; - } - - .tag-badge:hover .tag-exclude-btn, - .tag-badge:focus-within .tag-exclude-btn { - opacity: 0.85; - width: 14px; - max-width: 14px; - margin-left: 5px; - overflow: visible; - transform: scale(1); - pointer-events: auto; - } -} - -@media (hover: none) { - .tag-badge .tag-exclude-btn { - opacity: 0.7; - width: 14px; - max-width: 14px; - margin-left: 4px; - overflow: visible; - pointer-events: auto; - transform: scale(1); - } -} - .tag-badge .tag-exclude-btn:hover, .tag-badge .tag-exclude-btn:focus-visible { opacity: 1; @@ -7845,12 +8104,6 @@ a.removetag i { .tag-badge.tag-is-excluded .tag-exclude-btn { opacity: 0.95; - width: 14px; - max-width: 14px; - margin-left: 5px; - overflow: visible; - pointer-events: auto; - transform: scale(1); color: #ff4757; } @@ -9674,15 +9927,18 @@ table img { } } -/* gapRight icon colors: white for all, pink for favorites */ -.gapRight i.iconset { - color: #fff !important; - fill: #fff !important; +/* gapRight icon colors & active states */ +.gapRight i.iconset, +.gapRight svg.iconset, +.gapRight button { + color: var(--white, #fff); + fill: var(--white, #fff); } -.gapRight #a_favo { +.gapRight #a_favo.fa-solid { color: #ff6b9d !important; fill: #ff6b9d !important; + border-color: rgba(255, 107, 157, 0.4) !important; } /* pin icon */ @@ -9701,10 +9957,6 @@ table img { transform: rotate(-45deg); } -.iconset#subscribe-btn { - border-style: outset; -} - .imageDoor:hover:after { content: ""; background-image: var(--hover-image); @@ -14695,6 +14947,42 @@ body.layout-modern .tag-ac-input { margin: 5px; } +.item-preview .sidebar-thumb-link { + position: relative; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + margin: 5px; + overflow: hidden; + flex-shrink: 0; + text-decoration: none; +} + +.item-preview .sidebar-media-placeholder { + position: absolute; + inset: 0; + width: 32px; + height: 32px; + font-size: 14px; + border-radius: inherit; + display: flex; + align-items: center; + justify-content: center; +} + +.item-preview .sidebar-media-placeholder.audio { + background: #000000; + color: var(--accent); +} + +.item-preview .sidebar-media-placeholder.audio > i { + display: flex !important; + font-size: 14px; +} + /* ═══════════════════════════════════════════════════════════ PRIVATE MESSAGES / DM SYSTEM ═══════════════════════════════════════════════════════════ */ @@ -16347,37 +16635,109 @@ span.gchat-post-card--loading { .gapRight, body.layout-modern .gapRight, body.layout-legacy .gapRight { + display: inline-flex; + align-items: center; justify-content: center; + gap: 6px; + flex-wrap: wrap; + vertical-align: middle; } .gapRight>svg.iconset, .gapRight>i.iconset, +.gapRight>button, +.gapRight>.iconset, +.gapRight i.iconset, +.gapRight svg.iconset, +.gapRight button, body.layout-modern .gapRight>svg.iconset, body.layout-modern .gapRight>i.iconset, +body.layout-modern .gapRight>button, +body.layout-modern .gapRight>.iconset, body.layout-legacy .gapRight>svg.iconset, -body.layout-legacy .gapRight>i.iconset { +body.layout-legacy .gapRight>i.iconset, +body.layout-legacy .gapRight>button, +body.layout-legacy .gapRight>.iconset { height: 30px; + min-width: 30px; width: 30px; - margin: 5px; - padding: 5px; - right: 0px; - background: var(--badge-tag); + padding: 0; + box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; - font-size: 18px; - box-sizing: border-box; + gap: 5px; + border-radius: 3px; + border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.15)); + background: var(--navigation-links-bg, rgba(255, 255, 255, 0.08)); + color: var(--white, #fff); + fill: var(--white, #fff); + font-family: inherit; + font-size: 13px; + font-weight: bold; + line-height: 1; + cursor: pointer; + margin: 0; + position: static; + right: auto; + vertical-align: middle; + text-decoration: none; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease; +} + +.gapRight>svg.iconset:hover, +.gapRight>i.iconset:hover, +.gapRight>button:hover, +.gapRight>.iconset:hover, +.gapRight i.iconset:hover, +.gapRight svg.iconset:hover, +.gapRight button:hover, +.gapRight>svg.iconset.active, +.gapRight>i.iconset.active, +.gapRight>button.active, +.gapRight>.iconset.active, +.gapRight i.iconset.active, +.gapRight svg.iconset.active, +.gapRight button.active, +.gapRight>svg.iconset.is-active, +.gapRight>i.iconset.is-active, +.gapRight>button.is-active, +.gapRight>.iconset.is-active, +.gapRight i.iconset.is-active, +.gapRight svg.iconset.is-active, +.gapRight button.is-active, +body.layout-modern .gapRight>svg.iconset:hover, +body.layout-modern .gapRight>i.iconset:hover, +body.layout-modern .gapRight>button:hover, +body.layout-modern .gapRight>.iconset:hover, +body.layout-modern .gapRight>svg.iconset.active, +body.layout-modern .gapRight>i.iconset.active, +body.layout-modern .gapRight>button.active, +body.layout-modern .gapRight>.iconset.active, +body.layout-legacy .gapRight>svg.iconset:hover, +body.layout-legacy .gapRight>i.iconset:hover, +body.layout-legacy .gapRight>button:hover, +body.layout-legacy .gapRight>.iconset:hover, +body.layout-legacy .gapRight>svg.iconset.active, +body.layout-legacy .gapRight>i.iconset.active, +body.layout-legacy .gapRight>button.active, +body.layout-legacy .gapRight>.iconset.active { + background: var(--nav-link-hover-bg, rgba(255, 255, 255, 0.15)) !important; + border-color: var(--accent) !important; + color: var(--accent) !important; + fill: var(--accent) !important; + text-decoration: none; } .gapRight>.iconset#a_favo, body.layout-legacy .gapRight>.iconset#a_favo { - fill: #ff009b; + fill: inherit; } -/* OC Toggle Icon - now i element, color inherited from .gapRight i.iconset */ +/* OC Toggle Icon - inherits from .gapRight i.iconset */ i.iconset#a_oc { - color: #fff !important; - fill: #fff !important; + color: inherit; + fill: inherit; } /* OC Feature */ @@ -20246,7 +20606,8 @@ body.onara-modal-open .admin-bar.open { overflow-x: hidden !important; -webkit-overflow-scrolling: touch; scrollbar-width: none !important; - overscroll-behavior: contain !important; + overscroll-behavior-y: auto !important; + overscroll-behavior-x: contain !important; box-sizing: border-box !important; opacity: 0; transform: scale(0.99); @@ -21026,6 +21387,8 @@ div#flash, .album-gallery-container { position: relative; overflow: hidden; + max-width: 100%; + box-sizing: border-box; } .album-active-image { @@ -21082,7 +21445,8 @@ div#flash, top: 14px; left: 50%; transform: translateX(-50%); - pointer-events: auto; + pointer-events: none; + opacity: 0; background: rgba(16, 16, 20, 0.75); border: 1px solid rgba(255, 255, 255, 0.2); backdrop-filter: blur(12px); @@ -21098,6 +21462,24 @@ div#flash, box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); letter-spacing: 0.03em; user-select: none; + transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1), transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +@media (hover: hover) { + .album-gallery-container:hover .album-counter-pill, + .album-gallery-container:focus-within .album-counter-pill { + opacity: 1; + pointer-events: auto; + } +} + +.album-gallery-container.v0ck_hover .album-counter-pill, +.album-gallery-container.strip-peek .album-counter-pill, +.album-gallery-container:has(.v0ck.v0ck_hover) .album-counter-pill, +.album-counter-pill.is-visible, +.album-counter-pill.strip-peek { + opacity: 1; + pointer-events: auto; } .album-counter-pill i { @@ -21111,6 +21493,7 @@ div#flash, left: 50%; transform: translateX(-50%) translateY(10px); max-width: calc(100% - 32px); + box-sizing: border-box; display: flex; align-items: center; gap: 8px; @@ -21122,16 +21505,101 @@ div#flash, border-radius: 12px; z-index: 22; overflow-x: auto; + overflow-y: hidden; + overscroll-behavior: contain; + overscroll-behavior-x: contain; + overscroll-behavior-y: none; + -webkit-overflow-scrolling: touch; + touch-action: pan-x; scrollbar-width: none; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); opacity: 0; pointer-events: none; + cursor: grab; + user-select: none; + -webkit-user-select: none; + scroll-behavior: smooth; transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1), transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); } -.album-gallery-container:hover .album-thumbnails-strip, -.album-gallery-container:focus-within .album-thumbnails-strip, -.album-thumbnails-strip.is-visible { +.album-thumbnails-strip.is-dragging { + cursor: grabbing !important; + scroll-behavior: auto !important; +} + +.album-thumb-item { + cursor: pointer; +} + +.album-thumb-item img { + -webkit-user-drag: none; + user-select: none; +} + +/* Audio Track Info Badge (Artist & Song Title on top-left) */ +.audio-track-info-badge { + position: absolute; + top: 5px; + left: 10px; + text-align: left; + z-index: 25; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.audio-track-info-badge.is-visible { + opacity: 1; +} + +.audio-track-info-details { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + min-width: 0; + line-height: 1.25; +} + +.audio-track-artist { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--accent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; + align-self: flex-start; +} + +.audio-track-title { + font-size: 0.88rem; + font-weight: 700; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); + text-align: left; + align-self: flex-start; +} + +@media (hover: hover) { + .album-gallery-container:hover .album-thumbnails-strip, + .album-gallery-container:focus-within .album-thumbnails-strip { + opacity: 1; + pointer-events: auto; + transform: translateX(-50%) translateY(0); + } +} + +.album-gallery-container.v0ck_hover .album-thumbnails-strip, +.album-gallery-container.strip-peek .album-thumbnails-strip, +.album-gallery-container:has(.v0ck.v0ck_hover) .album-thumbnails-strip, +.album-thumbnails-strip.is-visible, +.album-thumbnails-strip.strip-peek { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); @@ -21141,8 +21609,7 @@ div#flash, display: none; } -.album-video-wrapper, -.album-audio-wrapper { +.album-video-wrapper { position: absolute; inset: 0; width: 100%; @@ -21150,6 +21617,14 @@ div#flash, background: #000; } +.album-audio-wrapper { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: transparent; +} + .album-gallery-container.is-video-active .album-thumbnails-strip, .album-gallery-container.is-audio-active .album-thumbnails-strip { bottom: 58px; @@ -21180,6 +21655,9 @@ div#flash, padding: 0; margin: 0; background: #000; + touch-action: pan-x; + user-select: none; + -webkit-user-select: none; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } @@ -21261,5 +21739,20 @@ div#flash, bottom: 8px; padding: 4px 8px; gap: 6px; + max-width: calc(100% - 16px); + box-sizing: border-box; + } + .audio-track-info-badge { + top: 8px; + left: 8px; + padding: 4px 8px; + gap: 6px; + max-width: calc(100% - 70px); + } + .audio-track-title { + font-size: 0.78rem; + } + .audio-track-artist { + font-size: 0.68rem; } } \ No newline at end of file diff --git a/public/s/css/v0ck.css b/public/s/css/v0ck.css index 2b09610..8153898 100644 --- a/public/s/css/v0ck.css +++ b/public/s/css/v0ck.css @@ -6,7 +6,7 @@ background-size: contain; background-repeat: no-repeat; background-position: center center; - touch-action: none; /* Prevent pull-to-refresh and scroll while interacting */ + touch-action: pan-y; z-index: 0; } @@ -556,8 +556,8 @@ transform: translate(-50%, 0); } -/* Hide mouse cursor on inactivity in fullscreen */ -.v0ck.v0ck_fullscreen:not(.v0ck_hover), -.v0ck.v0ck_fullscreen:not(.v0ck_hover) * { +/* Hide mouse cursor on inactivity when player controls auto-hide */ +.v0ck:not(.v0ck_hover), +.v0ck:not(.v0ck_hover) * { cursor: none !important; } \ No newline at end of file diff --git a/public/s/js/comments.js b/public/s/js/comments.js index b12ebc4..a9a7387 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -27,6 +27,8 @@ class CommentSystem { if (this.displayMode === 1) this.sort = 'old'; this.customEmojis = CommentSystem.emojiCache || {}; + this._selfPostedCommentIds = new Set(); + this._pendingSelfCommentTexts = new Set(); this.icons = { reply: ``, @@ -472,6 +474,34 @@ class CommentSystem { } } + _isSelfComment(data) { + if (!data) return false; + if (data.id && this._selfPostedCommentIds?.has(data.id)) return true; + if (data.body && this._pendingSelfCommentTexts?.has(data.body)) return true; + + const session = window.f0ckSession || {}; + const currentUserId = session.id || session.user_id; + if (currentUserId && data.user_id && parseInt(data.user_id, 10) === parseInt(currentUserId, 10)) { + return true; + } + + const currentUsername = session.user || this.user; + if (currentUsername && data.username && currentUsername.toLowerCase() === data.username.toLowerCase()) { + return true; + } + + if (session.is_anon && data.is_anon) { + if (data.anon_fingerprint && session.fingerprint && data.anon_fingerprint === session.fingerprint) { + return true; + } + if (data.anon_short_fingerprint && session.fingerprint && session.fingerprint.includes(data.anon_short_fingerprint)) { + return true; + } + } + + return false; + } + handleLiveComment(data) { if (!this.container || !this.itemId) return; // 1. Check if comment belongs to this item @@ -537,14 +567,20 @@ class CommentSystem { // Danmaku: fire one-shot for other users' comments. // Own comment is handled by the optimistic submit path (fire + addItem). - // The _loadDanmaku re-render will add this comment to the items rotation. - const currentUser = window.f0ckSession?.user; - if (window.danmakuInstance && data.username !== currentUser) { + const isSelf = this._isSelfComment(data); + if (window.danmakuInstance && !isSelf) { window.danmakuInstance.fire( data.body, data.display_name || data.username || '?', data.username_color || null ); + window.danmakuInstance.addItem({ + id: data.id, + content: data.body, + video_time: data.video_time ?? null, + display_name: data.display_name || data.username || '?', + username_color: data.username_color || null + }); } // Update backlinks for live comment @@ -3448,6 +3484,7 @@ class CommentSystem { } else { this.isMainSubmitting = true; } + this._pendingSelfCommentTexts.add(text); let retryCount = 0; const maxRetries = 20; // Allow several minutes of retrying during restart @@ -3534,7 +3571,7 @@ class CommentSystem { // For 4xx errors, we stop and show the error to user (likely validation or auth) const json = await res.json().catch(() => ({})); alert('Error: ' + (json.message || `Status ${res.status}`)); - this._finishSubmit(submitBtn, originalBtnHtml, parentId); + this._finishSubmit(submitBtn, originalBtnHtml, parentId, text); return; } @@ -3692,6 +3729,11 @@ class CommentSystem { poll: null }; + if (json.comment?.id) { + this._selfPostedCommentIds.add(json.comment.id); + } + this._pendingSelfCommentTexts.delete(text); + // Danmaku: fire immediately (one-shot) + add to future rotation if (window.danmakuInstance) { window.danmakuInstance.fire( @@ -3700,6 +3742,7 @@ class CommentSystem { session.username_color || null ); window.danmakuInstance.addItem({ + id: newComment.id, content: text, video_time: newComment.video_time ?? null, display_name: session.display_name || currentUsername || '?', @@ -3810,10 +3853,10 @@ class CommentSystem { } this._silentSync(); - this._finishSubmit(submitBtn, originalBtnHtml, parentId); + this._finishSubmit(submitBtn, originalBtnHtml, parentId, text); } else { alert('Error: ' + json.message); - this._finishSubmit(submitBtn, originalBtnHtml, parentId); + this._finishSubmit(submitBtn, originalBtnHtml, parentId, text); } } catch (err) { console.warn(`[CommentSystem] Submit attempt ${retryCount + 1} failed:`, err); @@ -3825,7 +3868,7 @@ class CommentSystem { setTimeout(attemptSubmit, delay); } else { alert('Failed to send comment after multiple attempts. Please check your connection.'); - this._finishSubmit(submitBtn, originalBtnHtml, parentId); + this._finishSubmit(submitBtn, originalBtnHtml, parentId, text); } } }; @@ -3857,7 +3900,10 @@ class CommentSystem { console.log('[ensureBtn] done, button in DOM:', !!contentEl.querySelector('.load-full-comment-btn')); } - _finishSubmit(btn, originalHtml, parentId) { + _finishSubmit(btn, originalHtml, parentId, submittedText = null) { + if (submittedText) { + this._pendingSelfCommentTexts?.delete(submittedText); + } if (parentId) { this.pendingSubmissions.delete(parentId); } else { diff --git a/public/s/js/danmaku.js b/public/s/js/danmaku.js index a527abf..10078d7 100644 --- a/public/s/js/danmaku.js +++ b/public/s/js/danmaku.js @@ -195,6 +195,7 @@ class Danmaku { const mapped = comments .filter(c => !c.is_deleted && c.content) .map(c => ({ + id: c.id || null, text: this._prepareText(c.content), username: c.display_name || c.username || '?', color: c.username_color || null, @@ -288,6 +289,7 @@ class Danmaku { */ addItem(comment) { if (!comment || !comment.content) return; + if (comment.id && this.items && this.items.some(i => i.id === comment.id)) return; const duration = this.media.duration; const hasDuration = isFinite(duration) && duration > 0; @@ -307,6 +309,7 @@ class Danmaku { } const item = { + id: comment.id || null, video_time: t, text: this._prepareText(comment.content), username: comment.display_name || comment.username || '?', @@ -314,6 +317,13 @@ class Danmaku { fired: true // mark as already fired — caller handles any immediate one-shot }; + if (this._synthClock && this._flashPool) { + if (!this._flashPool.some(i => (comment.id && i.id === comment.id) || (i.text === item.text && i.username === item.username))) { + this._flashPool.push(item); + } + return; + } + // Insert in sorted order const idx = this.items.findIndex(i => i.video_time > t); if (idx === -1) this.items.push(item); diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 9e8fc65..43c8da6 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -393,7 +393,14 @@ window.cancelAnimFrame = (function () { if (!coverCircle) { coverCircle = document.createElement('div'); coverCircle.className = 'audio-cover-circle'; + coverCircle.innerHTML = ''; ph.insertBefore(coverCircle, ph.firstChild); + } else { + if (!coverCircle.querySelector('i')) { + const existingI = ph.querySelector(':scope > i'); + if (existingI) coverCircle.appendChild(existingI); + else coverCircle.insertAdjacentHTML('beforeend', ''); + } } coverCircle.style.backgroundImage = `url('${coverUrl}')`; ph.classList.add('has-cover'); @@ -406,12 +413,24 @@ window.cancelAnimFrame = (function () { } else if (hasCoverart === false) { audioCover.removeAttribute('src'); if (parent) { - parent.style.background = 'none'; + parent.style.background = '#000000'; if (ph) { - if (coverCircle) { + if (!coverCircle) { + coverCircle = document.createElement('div'); + coverCircle.className = 'audio-cover-circle'; + coverCircle.innerHTML = ''; + ph.insertBefore(coverCircle, ph.firstChild); + } else { + if (!coverCircle.querySelector('i')) { + const existingI = ph.querySelector(':scope > i'); + if (existingI) coverCircle.appendChild(existingI); + else coverCircle.insertAdjacentHTML('beforeend', ''); + } coverCircle.style.backgroundImage = 'none'; + coverCircle.style.backgroundColor = '#000000'; } ph.classList.remove('has-cover'); + ph.style.backgroundColor = '#000000'; ph.style.display = ''; } } @@ -2807,6 +2826,8 @@ window.cancelAnimFrame = (function () { const idx = parseInt(thumb.getAttribute('data-index'), 10); const src = thumb.getAttribute('data-src') || thumb.querySelector('img')?.src || ''; const subSlug = thumb.getAttribute('data-subf0ck-slug') || thumb.getAttribute('data-subf0ck-id') || ''; + const mime = (thumb.getAttribute('data-mime') || '').toLowerCase(); + const isAudio = mime.startsWith('audio/') || !!thumb.querySelector('.sidebar-media-placeholder.audio'); return { order_index: isNaN(idx) ? i : idx, display_index: (isNaN(idx) ? i : idx) + 1, @@ -2814,7 +2835,10 @@ window.cancelAnimFrame = (function () { dest: src, slug: subSlug, subf0ck_id: subSlug, - id: subSlug + id: subSlug, + mime: mime || (isAudio ? 'audio/mpeg' : ''), + is_audio: isAudio, + is_video: mime.startsWith('video/') }; }); } @@ -2857,6 +2881,17 @@ window.cancelAnimFrame = (function () { const thumbItems = container.querySelectorAll('.album-thumb-item'); const stripEl = container.querySelector('.album-thumbnails-strip'); + let stripTimeout = null; + function showStripPeek(duration = 3000) { + if (stripTimeout) clearTimeout(stripTimeout); + container.classList.add('strip-peek'); + if (stripEl) stripEl.classList.add('strip-peek'); + stripTimeout = setTimeout(() => { + container.classList.remove('strip-peek'); + if (stripEl) stripEl.classList.remove('strip-peek'); + }, duration); + } + if (totalCountEl) totalCountEl.textContent = albumData.length; let albumVideoV0ck = null; @@ -2958,6 +2993,8 @@ window.cancelAnimFrame = (function () { if (mime.startsWith('video/')) { container.classList.add('is-video-active'); container.classList.remove('is-audio-active'); + const badge = container.querySelector('.audio-track-info-badge'); + if (badge) badge.classList.remove('is-visible'); if (imgEl) imgEl.style.display = 'none'; if (linkEl) linkEl.style.display = 'none'; if (audioWrapper) audioWrapper.style.display = 'none'; @@ -3010,29 +3047,39 @@ window.cancelAnimFrame = (function () { if (!ph) { ph = document.createElement('div'); ph.className = 'sidebar-media-placeholder audio'; - ph.innerHTML = ''; + ph.innerHTML = '
'; audioWrapper.prepend(ph); } let coverCircle = ph.querySelector('.audio-cover-circle'); if (!coverCircle) { coverCircle = document.createElement('div'); coverCircle.className = 'audio-cover-circle'; + coverCircle.innerHTML = ''; ph.insertBefore(coverCircle, ph.firstChild); + } else { + if (!coverCircle.querySelector('i')) { + const existingI = ph.querySelector(':scope > i'); + if (existingI) coverCircle.appendChild(existingI); + else coverCircle.insertAdjacentHTML('beforeend', ''); + } } audioWrapper.style.backgroundImage = 'none'; - audioWrapper.style.backgroundColor = 'transparent'; + audioWrapper.style.backgroundColor = '#000000'; if (coverUrl) { coverCircle.style.backgroundImage = `url('${coverUrl}')`; ph.classList.add('has-cover'); } else { coverCircle.style.backgroundImage = 'none'; + coverCircle.style.backgroundColor = '#000000'; ph.classList.remove('has-cover'); + ph.style.backgroundColor = '#000000'; } ph.style.display = ''; } if (audioEl) { audioEl.style.display = 'block'; + audioEl.crossOrigin = 'anonymous'; const hasCover = item.has_coverart && item.coverart && !item.coverart.includes('audio.webp'); const coverUrl = hasCover ? (item.coverart || item.thumb) : null; if (coverUrl) { @@ -3043,13 +3090,15 @@ window.cancelAnimFrame = (function () { if (audioEl.src !== newSrc && !audioEl.src.endsWith(newSrc)) { audioEl.src = newSrc; + audioEl.setAttribute('src', newSrc); audioEl.load(); } initAlbumAudioV0ck(); video = audioEl; - if (window.initVisualizer) window.initVisualizer(); + if (window.initVisualizer) window.initVisualizer(audioEl); + if (window.updateAudioTrackBadge) window.updateAudioTrackBadge(audioEl, item.id || item.slug, newSrc); audioEl.addEventListener('play', () => audioWrapper?.classList.add('is-playing')); audioEl.addEventListener('pause', () => audioWrapper?.classList.remove('is-playing')); @@ -3076,6 +3125,8 @@ window.cancelAnimFrame = (function () { } else { // Image container.classList.remove('is-video-active', 'is-audio-active'); + const badge = container.querySelector('.audio-track-info-badge'); + if (badge) badge.classList.remove('is-visible'); if (videoEl) { try { videoEl.pause(); } catch {} } @@ -3109,11 +3160,24 @@ window.cancelAnimFrame = (function () { thumbItems.forEach((btn, idx) => { const isActive = idx === currentIndex; btn.classList.toggle('active', isActive); - if (isActive) { - btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + if (isActive && stripEl && stripEl.clientWidth > 0) { + const stripRect = stripEl.getBoundingClientRect(); + const btnRect = btn.getBoundingClientRect(); + const offsetWithinStrip = (btnRect.left - stripRect.left) + stripEl.scrollLeft; + const targetLeft = offsetWithinStrip - (stripEl.clientWidth - btn.clientWidth) / 2; + stripEl.scrollTo({ + left: Math.max(0, targetLeft), + behavior: 'smooth' + }); } }); + if (window.scrollX > 0) { + window.scrollTo(0, window.scrollY); + } + + showStripPeek(3000); + updateInfoModal(); updateAlbumTags(); @@ -3176,7 +3240,16 @@ window.cancelAnimFrame = (function () { const fileSizeEl = modal.querySelector('#info-file-size'); if (fileSizeEl) { - fileSizeEl.textContent = sub.size || ''; + let displaySize = sub.size || ''; + if (displaySize === 'NaN B' || displaySize === 'NaN') displaySize = ''; + if (displaySize && !isNaN(displaySize)) { + const num = Number(displaySize); + if (num > 0) { + const i = Math.min(4, Math.max(0, ~~(Math.log(num) / Math.log(1024)))); + displaySize = (num / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; + } + } + fileSizeEl.textContent = displaySize; } const dimsCard = modal.querySelector('#info-card-dimensions'); @@ -3260,8 +3333,157 @@ window.cancelAnimFrame = (function () { }); } + let hasDraggedStrip = false; + if (stripEl && !stripEl._dragInitialized) { + stripEl._dragInitialized = true; + let isDown = false; + let startX = 0; + let startScrollLeft = 0; + let momentumID = null; + let velX = 0; + let lastX = 0; + let lastTime = 0; + + stripEl.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + isDown = true; + hasDraggedStrip = false; + startX = e.pageX; + startScrollLeft = stripEl.scrollLeft; + lastX = e.pageX; + lastTime = performance.now(); + velX = 0; + cancelAnimationFrame(momentumID); + }); + + window.addEventListener('mousemove', (e) => { + if (!isDown) return; + const now = performance.now(); + const walk = e.pageX - startX; + if (Math.abs(walk) > 6) { + hasDraggedStrip = true; + stripEl.classList.add('is-dragging'); + e.preventDefault(); + stripEl.scrollLeft = startScrollLeft - walk; + + const dt = Math.max(1, now - lastTime); + velX = (e.pageX - lastX) / dt; + lastX = e.pageX; + lastTime = now; + } + }); + + window.addEventListener('mouseup', () => { + if (!isDown) return; + isDown = false; + stripEl.classList.remove('is-dragging'); + showStripPeek(6000); + + if (hasDraggedStrip && Math.abs(velX) > 0.15) { + let currentVel = velX * 18; + const glide = () => { + if (Math.abs(currentVel) > 0.5) { + stripEl.scrollLeft -= currentVel; + currentVel *= 0.93; + momentumID = requestAnimationFrame(glide); + } + }; + momentumID = requestAnimationFrame(glide); + } + + setTimeout(() => { + hasDraggedStrip = false; + }, 100); + }); + + // Touch drag / swipe support for mobile + let touchStartX = 0; + let touchStartScrollLeft = 0; + let isTouchingStrip = false; + + stripEl.addEventListener('touchstart', (e) => { + if (e.touches.length === 1) { + e.stopPropagation(); + if (stripTimeout) clearTimeout(stripTimeout); + container.classList.add('strip-peek'); + stripEl.classList.add('strip-peek'); + isTouchingStrip = true; + hasDraggedStrip = false; + touchStartX = e.touches[0].clientX; + touchStartScrollLeft = stripEl.scrollLeft; + lastX = e.touches[0].clientX; + lastTime = performance.now(); + velX = 0; + cancelAnimationFrame(momentumID); + } + }, { passive: true }); + + stripEl.addEventListener('touchmove', (e) => { + if (!isTouchingStrip || e.touches.length !== 1) return; + e.stopPropagation(); + const currentX = e.touches[0].clientX; + const walk = currentX - touchStartX; + if (Math.abs(walk) > 3) { + hasDraggedStrip = true; + stripEl.classList.add('is-dragging'); + stripEl.scrollLeft = touchStartScrollLeft - walk; + const now = performance.now(); + const dt = Math.max(1, now - lastTime); + velX = (currentX - lastX) / dt; + lastX = currentX; + lastTime = now; + } + }, { passive: true }); + + stripEl.addEventListener('touchend', (e) => { + if (!isTouchingStrip) return; + e.stopPropagation(); + isTouchingStrip = false; + stripEl.classList.remove('is-dragging'); + showStripPeek(6000); + if (hasDraggedStrip) { + if (Math.abs(velX) > 0.05) { + let currentVel = velX * 24; + const glide = () => { + if (Math.abs(currentVel) > 0.5) { + stripEl.scrollLeft -= currentVel; + currentVel *= 0.93; + momentumID = requestAnimationFrame(glide); + } + }; + momentumID = requestAnimationFrame(glide); + } + setTimeout(() => { + hasDraggedStrip = false; + }, 150); + } + }, { passive: true }); + + stripEl.addEventListener('touchcancel', (e) => { + e.stopPropagation(); + isTouchingStrip = false; + stripEl.classList.remove('is-dragging'); + showStripPeek(6000); + setTimeout(() => { + hasDraggedStrip = false; + }, 100); + }, { passive: true }); + + stripEl.addEventListener('wheel', (e) => { + if (e.deltaY !== 0) { + e.preventDefault(); + stripEl.scrollLeft += e.deltaY; + } + }, { passive: false }); + } + thumbItems.forEach((btn) => { btn.addEventListener('click', (e) => { + if (hasDraggedStrip) { + e.preventDefault(); + e.stopPropagation(); + return; + } e.preventDefault(); e.stopPropagation(); const targetIdx = parseInt(btn.getAttribute('data-index'), 10); @@ -3272,29 +3494,31 @@ window.cancelAnimFrame = (function () { }); // Touch tap on container reveals thumbnail strip briefly on touch devices - let stripTimeout = null; container.addEventListener('click', (e) => { - if (stripEl && !e.target.closest('.album-thumbnails-strip, .v0ck_player_controls, .v0ck_settings_menu, .v0ck_hud, .album-btn')) { - stripEl.classList.add('strip-peek'); - if (stripTimeout) clearTimeout(stripTimeout); - stripTimeout = setTimeout(() => { - stripEl.classList.remove('strip-peek'); - }, 3000); + if (!e.target.closest('.v0ck_settings_menu, .v0ck_hud, .album-btn')) { + showStripPeek(3000); } }); // Handle touch swipe for mobile gallery navigation let touchStartX = null; let touchStartY = null; + let touchIgnored = false; container.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { + touchIgnored = !!e.target.closest('.album-thumbnails-strip, .v0ck_player_controls, .v0ck_settings_menu, .v0ck_hud, .album-btn'); touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; } }, { passive: true }); container.addEventListener('touchend', (e) => { - if (touchStartX === null || touchStartY === null) return; + if (touchStartX === null || touchStartY === null || touchIgnored) { + touchStartX = null; + touchStartY = null; + touchIgnored = false; + return; + } const touchEndX = e.changedTouches[0].clientX; const touchEndY = e.changedTouches[0].clientY; const diffX = touchEndX - touchStartX; @@ -3310,6 +3534,7 @@ window.cancelAnimFrame = (function () { } touchStartX = null; touchStartY = null; + touchIgnored = false; }, { passive: true }); // Handle hash change if user navigates back/forward to another subf0ck @@ -3672,16 +3897,70 @@ window.cancelAnimFrame = (function () { // Audio Visualizer Reactivity Tuner const DEFAULT_AUDIO_TUNING = { + useCustomColor: 0, + visualizerColor: "#241f31", + enableBeatHue: 0, + beatHueThreshold: 0.3, + beatHueStep: 45, + beatHueSmooth: 0.65, + beatHueIdleDrift: 0.2, + beatHueCooldown: 150, + coverSize: 110, + glowIntensity: 500, + coverGlowBase: 128, + glowBrightness: 2.05, + glowSensitivity: 1, + glowDynamism: 0.85, + glowSmoothness: 0.95, + solidCover: 1, + coverColor: "#000000", + coverOpacity: 0, bassGain: 0.5, - bassPower: 2.0, - scaleBounce: 0.4, - bounceBoost: 2.5, - glowIntensity: 250, - attackSpeed: 1.0, + bassPower: 0.3, + scaleBounce: 0.85, + bounceBoost: 1, + attackSpeed: 1, releaseSpeed: 0.6, - coverSize: 155, - barHeight: 0.4, - smoothing: 0.84 + enableBars: 0, + enableInnerBars: 1, + useCustomInnerColor: 0, + innerBarColor: "#ff0000", + innerBarsMode: 4, + innerRadius: 150, + innerPupilRadius: 46, + innerPupilRingOpacity: 0, + innerBarCount: 73, + innerBarWidth: 80, + innerBarHeight: 2.65, + innerBarOpacity: 0.13, + outerRingOpacity: 0, + innerHighBoost: 0.162, + innerRadialRotation: 90, + innerBarGlow: 0, + innerHideNote: 1, + barHeight: 0.9, + barWidth: 34, + barGap: 9, + barRadius: 12, + barOpacity: 0.1, + barGlow: 0, + smoothing: 0.86, + followMouse: 1, + followSpeed: 0.1, + followRadius: 15, + tiltEffect: 1, + glowAttack: 1, + glowDecay: 0.5, + enableBgGradient: 1, + bgGradientStyle: 1, + bgGradientOpacity: 0.4, + bgGradientReactivity: 1.2, + bgGradientSpread: 75, + useCustomBgColor: 0, + bgGradientColor: "#3a1c71", + enableBlink: 0, + blinkInterval: 12, + beatHueSpeed: 0.1 }; let savedTuning = null; @@ -3692,29 +3971,256 @@ window.cancelAnimFrame = (function () { window.audioVisualizerTuning = Object.assign({}, DEFAULT_AUDIO_TUNING, savedTuning || {}); + // Eye Cursor Follower & 3D Tracking + const mousePos = { x: null, y: null, active: false }; + let isEyeActive = false; + let eyeInactivityTimer = null; + + const isCursorNearV0ck = (e) => { + const margin = 10; + const players = document.querySelectorAll('.v0ck, .sidebar-media-placeholder.audio'); + for (const p of players) { + const r = p.getBoundingClientRect(); + if (r.width > 0 && r.height > 0 && + e.clientX >= r.left - margin && e.clientX <= r.right + margin && + e.clientY >= r.top - margin && e.clientY <= r.bottom + margin) { + return true; + } + } + return false; + }; + + const handlePointerMove = (e) => { + mousePos.x = e.clientX; + mousePos.y = e.clientY; + + const nearPlayer = isCursorNearV0ck(e); + if (nearPlayer) { + isEyeActive = true; + mousePos.active = true; + clearTimeout(eyeInactivityTimer); + eyeInactivityTimer = setTimeout(() => { + isEyeActive = false; + mousePos.active = false; + }, 2500); + } else { + // More than 10px outside of player: stop following and return to center + isEyeActive = false; + mousePos.active = false; + clearTimeout(eyeInactivityTimer); + } + }; + + window.addEventListener('pointermove', handlePointerMove, { passive: true }); + window.addEventListener('pointerdown', (e) => { + if (isCursorNearV0ck(e)) handlePointerMove(e); + }, { passive: true }); + + document.addEventListener('mouseleave', () => { + isEyeActive = false; + mousePos.active = false; + clearTimeout(eyeInactivityTimer); + }); + + window.addEventListener('pointerup', () => { + if (window.matchMedia && window.matchMedia('(hover: none)').matches) { + isEyeActive = false; + mousePos.active = false; + clearTimeout(eyeInactivityTimer); + } + }, { passive: true }); + + const updateEyeTracking = () => { + const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; + const isEnabled = cfg.followMouse !== undefined ? cfg.followMouse === 1 : true; + const speed = cfg.followSpeed !== undefined ? cfg.followSpeed : 0.04; + const allowTilt = cfg.tiltEffect !== undefined ? cfg.tiltEffect === 1 : true; + + const circles = document.querySelectorAll('.sidebar-media-placeholder.audio .audio-cover-circle'); + circles.forEach(circle => { + const ph = circle.closest('.sidebar-media-placeholder.audio'); + if (!ph) return; + const rect = ph.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + const v0ckPlayer = ph.closest('.v0ck') || ph; + const pRect = v0ckPlayer.getBoundingClientRect(); + const margin = 10; + + const isNearThisPlayer = ( + mousePos.x !== null && mousePos.y !== null && + mousePos.x >= pRect.left - margin && mousePos.x <= pRect.right + margin && + mousePos.y >= pRect.top - margin && mousePos.y <= pRect.bottom + margin + ); + + let isCursorHiddenOnPlayer = false; + if (v0ckPlayer && !v0ckPlayer.classList.contains('v0ck_hover')) { + if (mousePos.active && mousePos.x >= pRect.left && mousePos.x <= pRect.right && + mousePos.y >= pRect.top && mousePos.y <= pRect.bottom) { + isCursorHiddenOnPlayer = true; + } + } + + let targetX = 0; + let targetY = 0; + let tiltX = 0; + let tiltY = 0; + + if (isEnabled && isEyeActive && mousePos.active && isNearThisPlayer && !isCursorHiddenOnPlayer) { + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const dx = mousePos.x - centerX; + const dy = mousePos.y - centerY; + + const maxRadius = cfg.followRadius !== undefined ? cfg.followRadius : 20; + const dist = Math.hypot(dx, dy); + + if (dist > 0) { + const clampedDist = Math.min(dist, maxRadius); + targetX = (dx / dist) * clampedDist; + targetY = (dy / dist) * clampedDist; + } + + if (allowTilt) { + tiltX = -(targetY / (maxRadius || 1)) * 20; + tiltY = (targetX / (maxRadius || 1)) * 20; + } + } + + circle._eyeX = circle._eyeX || 0; + circle._eyeY = circle._eyeY || 0; + circle._eyeTiltX = circle._eyeTiltX || 0; + circle._eyeTiltY = circle._eyeTiltY || 0; + + circle._eyeX += (targetX - circle._eyeX) * speed; + circle._eyeY += (targetY - circle._eyeY) * speed; + circle._eyeTiltX += (tiltX - circle._eyeTiltX) * speed; + circle._eyeTiltY += (tiltY - circle._eyeTiltY) * speed; + + // If audio visualizer is NOT actively driving the transform this frame, update it here + if (!circle._visualizerDriving) { + const curX = circle._eyeX.toFixed(1); + const curY = circle._eyeY.toFixed(1); + const curTiltX = circle._eyeTiltX.toFixed(1); + const curTiltY = circle._eyeTiltY.toFixed(1); + circle.style.transform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(600px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) scale(1)`; + } + }); + + requestAnimationFrame(updateEyeTracking); + }; + + requestAnimationFrame(updateEyeTracking); + const initAudioTunerUI = () => { if (document.getElementById('f0ck-audio-tuner-panel')) return; + const sidebarContainer = document.getElementById('sidebar-tuner-container'); const panel = document.createElement('div'); panel.id = 'f0ck-audio-tuner-panel'; - panel.className = 'f0ck-audio-tuner-panel hidden'; + panel.className = sidebarContainer ? 'f0ck-audio-tuner-panel in-sidebar' : 'f0ck-audio-tuner-panel hidden'; const sliders = [ - // Disc & Glow Section + // Visualizer Color Section + { section: 'Visualizer Color & Theme', key: 'useCustomColor', label: 'Use Custom Color (1=Custom, 0=Site Accent)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Visualizer Color & Theme', key: 'visualizerColor', label: 'Custom Visualizer Color', type: 'color' }, + { section: 'Visualizer Color & Theme', key: 'enableBeatHue', label: 'Beat-Reactive Hue Color (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Visualizer Color & Theme', key: 'beatHueThreshold', label: 'Beat Trigger Threshold', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Visualizer Color & Theme', key: 'beatHueStep', label: 'Color Shift Angle per Beat', min: 1, max: 180, step: 1, unit: '°' }, + { section: 'Visualizer Color & Theme', key: 'beatHueSmooth', label: 'Color Morph Smoothness (0=Snap, 1=Glide)', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Visualizer Color & Theme', key: 'beatHueIdleDrift', label: 'Idle Ambient Hue Drift', min: 0.00, max: 2.00, step: 0.01, unit: 'x' }, + { section: 'Visualizer Color & Theme', key: 'beatHueCooldown', label: 'Min Time Between Jumps', min: 50, max: 800, step: 25, unit: 'ms' }, + + // Cover Art & Glow Section + { section: 'Cover Art & Glow Reactivity', key: 'coverSize', label: 'Cover Art Size (Diameter)', min: 40, max: 600, step: 5, unit: 'px' }, + { section: 'Cover Art & Glow Reactivity', key: 'solidCover', label: 'Solid Color Cover (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Cover Art & Glow Reactivity', key: 'coverColor', label: 'Solid Cover Color', type: 'color' }, + { section: 'Cover Art & Glow Reactivity', key: 'coverOpacity', label: 'Cover / Disc Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Cover Art & Glow Reactivity', key: 'glowIntensity', label: 'Cover Art Glow Size (Dynamic)', min: 0, max: 500, step: 5, unit: 'px' }, + { section: 'Cover Art & Glow Reactivity', key: 'coverGlowBase', label: 'Cover Art Base Glow (Ambient)', min: 0, max: 150, step: 2, unit: 'px' }, + { section: 'Cover Art & Glow Reactivity', key: 'glowBrightness', label: 'Cover Art Glow Brightness', min: 0.0, max: 3.0, step: 0.05, unit: 'x' }, + { section: 'Cover Art & Glow Reactivity', key: 'glowSensitivity', label: 'Glow Beat Reactivity / Sensitivity', min: 0.10, max: 5.00, step: 0.05, unit: 'x' }, + { section: 'Cover Art & Glow Reactivity', key: 'glowDynamism', label: 'Glow Dynamism (Dynamic Punch)', min: 0.00, max: 3.00, step: 0.05, unit: 'x' }, + { section: 'Cover Art & Glow Reactivity', key: 'glowSmoothness', label: 'Glow Smoothness (Liquid Glow)', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Cover Art & Glow Reactivity', key: 'scaleBounce', label: 'Disc Bounce (Scale)', min: 0.00, max: 5.00, step: 0.05, unit: 'x' }, - { section: 'Cover Art & Glow Reactivity', key: 'bounceBoost', label: 'Bounce Reactivity Boost', min: 1.0, max: 30.0, step: 0.5, unit: 'x' }, + { section: 'Cover Art & Glow Reactivity', key: 'bounceBoost', label: 'Bounce Reactivity Boost', min: 1.0, max: 100.0, step: 0.5, unit: 'x' }, { section: 'Cover Art & Glow Reactivity', key: 'bassGain', label: 'Bass Gain (Multiplier)', min: 0.5, max: 6.0, step: 0.1, unit: 'x' }, - { section: 'Cover Art & Glow Reactivity', key: 'bassPower', label: 'Sensitivity Curve (Power)', min: 0.30, max: 2.00, step: 0.02, unit: '' }, - { section: 'Cover Art & Glow Reactivity', key: 'glowIntensity', label: 'Glow Aura Reach', min: 10, max: 250, step: 5, unit: 'px' }, - { section: 'Cover Art & Glow Reactivity', key: 'attackSpeed', label: 'Attack Speed (Snap)', min: 0.10, max: 1.00, step: 0.02, unit: '' }, - { section: 'Cover Art & Glow Reactivity', key: 'releaseSpeed', label: 'Release Speed (Decay)', min: 0.05, max: 0.60, step: 0.01, unit: '' }, - { section: 'Cover Art & Glow Reactivity', key: 'coverSize', label: 'Cover Disc Diameter', min: 120, max: 450, step: 5, unit: 'px' }, + { section: 'Cover Art & Glow Reactivity', key: 'bassPower', label: 'Sensitivity Curve (Power)', min: 0.10, max: 2.00, step: 0.01, unit: '' }, + { section: 'Cover Art & Glow Reactivity', key: 'attackSpeed', label: 'Attack Speed (Snap)', min: 0.05, max: 1.00, step: 0.01, unit: '' }, + { section: 'Cover Art & Glow Reactivity', key: 'releaseSpeed', label: 'Release Speed (Decay)', min: 0.01, max: 1.00, step: 0.01, unit: '' }, + { section: 'Cover Art & Glow Reactivity', key: 'glowAttack', label: 'Glow Attack Speed (Swell)', min: 0.01, max: 1.00, step: 0.01, unit: '' }, + { section: 'Cover Art & Glow Reactivity', key: 'glowDecay', label: 'Glow Decay Speed (Dissipate)', min: 0.01, max: 1.00, step: 0.01, unit: '' }, + + // Reactive Background Gradient Section + { section: 'Reactive Background Gradient', key: 'enableBgGradient', label: 'Background Gradient (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Reactive Background Gradient', key: 'bgGradientStyle', label: 'Gradient Style (1=Radial Aura, 2=Dual Fog, 3=Conic Vortex, 4=Horizon Flare)', min: 1, max: 4, step: 1, unit: '' }, + { section: 'Reactive Background Gradient', key: 'bgGradientOpacity', label: 'Gradient Base Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Reactive Background Gradient', key: 'bgGradientReactivity', label: 'Beat Reactivity (Pulse Strength)', min: 0.00, max: 3.00, step: 0.05, unit: 'x' }, + { section: 'Reactive Background Gradient', key: 'bgGradientSpread', label: 'Gradient Radius / Spread', min: 10, max: 200, step: 2, unit: '%' }, + { section: 'Reactive Background Gradient', key: 'useCustomBgColor', label: 'Custom Gradient Color (1=On, 0=Accent)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Reactive Background Gradient', key: 'bgGradientColor', label: 'Custom Gradient Color', type: 'color' }, + + // Inner Eye Visualizer Section + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'enableInnerBars', label: 'Inner Visualizer (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarsMode', label: 'Inner Style (1=Center, 2=Outward Iris, 3=Arc, 4=Inverted Iris, 5=Inverted Rev, 6=Dual Stargate, 7=360° Radar)', min: 1, max: 7, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerRadius', label: 'Inner Visualizer Size (Outer Radius)', min: 30, max: 800, step: 2, unit: 'px' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerPupilRadius', label: 'Center Pupil Gap (Inner Radius)', min: 0, max: 600, step: 2, unit: 'px' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'useCustomInnerColor', label: 'Solid Custom Inner Color (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarColor', label: 'Solid Inner Bar Color', type: 'color' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarCount', label: 'Spectrum Density (Note Count)', min: 2, max: 512, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerHighBoost', label: 'Melody & Treble Sensitivity', min: 0.000, max: 2.000, step: 0.001, unit: 'x' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarHeight', label: 'Inner Bar Height / Scale', min: 0.05, max: 100.00, step: 0.05, unit: 'x' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarWidth', label: 'Inner Bar Width', min: 1, max: 250, step: 1, unit: 'px' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarOpacity', label: 'Inner Bar Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'outerRingOpacity', label: 'Outer Ring Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerPupilRingOpacity', label: 'Center Pupil Gap Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerRadialRotation', label: 'Radial Iris Rotation Angle', min: 0, max: 360, step: 5, unit: '°' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarGlow', label: 'Inner Bar Glow', min: 0, max: 30, step: 1, unit: 'px' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerHideNote', label: 'Hide Music Note Icon (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, // Visualizer Bars Section - { section: 'Bottom Visualizer Bars', key: 'barHeight', label: 'Visualizer Bar Height', min: 0.05, max: 2.00, step: 0.05, unit: 'x' }, - { section: 'Bottom Visualizer Bars', key: 'smoothing', label: 'Visualizer Bar Smoothing', min: 0.10, max: 0.95, step: 0.02, unit: '' } + { section: 'Bottom Visualizer Bars', key: 'enableBars', label: 'Show Visualizer Bars (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Bottom Visualizer Bars', key: 'barHeight', label: 'Visualizer Bar Height', min: 0.05, max: 2.50, step: 0.05, unit: 'x' }, + { section: 'Bottom Visualizer Bars', key: 'barWidth', label: 'Bar Width', min: 1, max: 35, step: 1, unit: 'px' }, + { section: 'Bottom Visualizer Bars', key: 'barGap', label: 'Bar Spacing (Gap)', min: 0, max: 15, step: 1, unit: 'px' }, + { section: 'Bottom Visualizer Bars', key: 'barRadius', label: 'Bar Top Rounding', min: 0, max: 12, step: 1, unit: 'px' }, + { section: 'Bottom Visualizer Bars', key: 'barOpacity', label: 'Bar Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Bottom Visualizer Bars', key: 'barGlow', label: 'Bar Glow Aura', min: 0, max: 30, step: 1, unit: 'px' }, + { section: 'Bottom Visualizer Bars', key: 'smoothing', label: 'Visualizer Bar Smoothing', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + + // Mouse Follow & Tilt Simulation Section + { section: 'Eye Simulation & Mouse Follow', key: 'followMouse', label: 'Follow Mouse Cursor (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Eye Simulation & Mouse Follow', key: 'followSpeed', label: 'Eye Follow Speed', min: 0.01, max: 0.40, step: 0.01, unit: '' }, + { section: 'Eye Simulation & Mouse Follow', key: 'followRadius', label: 'Eye Follow Range (Radius)', min: 5, max: 100, step: 1, unit: 'px' }, + { section: 'Eye Simulation & Mouse Follow', key: 'tiltEffect', label: '3D Eye Tilt (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' } ]; + const updateCoverArtSolidMode = () => { + const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; + const isSolid = Number(cfg.solidCover) === 1; + const color = cfg.coverColor || '#000000'; + const coverOp = cfg.coverOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.coverOpacity))) : 1.0; + const bgCol = coverOp <= 0.001 ? 'transparent' : (coverOp >= 0.999 ? color : `color-mix(in srgb, ${color} ${Math.round(coverOp * 100)}%, transparent)`); + + document.querySelectorAll('.audio-cover-circle').forEach(c => { + if (isSolid) { + if (c.style.backgroundImage && c.style.backgroundImage !== 'none' && !c._origBgImage) { + c._origBgImage = c.style.backgroundImage; + } + c.style.backgroundImage = 'none'; + c.style.backgroundColor = bgCol; + } else { + if (c._origBgImage) { + c.style.backgroundImage = c._origBgImage; + c.style.backgroundColor = bgCol; + } else { + c.style.backgroundImage = 'none'; + c.style.backgroundColor = bgCol; + } + } + }); + }; + let rowsHtml = ''; let currentSection = ''; sliders.forEach(s => { @@ -3723,15 +4229,27 @@ window.cancelAnimFrame = (function () { rowsHtml += `
${currentSection}
`; } const val = window.audioVisualizerTuning[s.key] !== undefined ? window.audioVisualizerTuning[s.key] : DEFAULT_AUDIO_TUNING[s.key]; - rowsHtml += ` -
-
- ${s.label} - ${val}${s.unit} + if (s.type === 'color') { + rowsHtml += ` +
+
+ ${s.label} + ${val} +
+
- -
- `; + `; + } else { + rowsHtml += ` +
+
+ ${s.label} + ${val}${s.unit || ''} +
+ +
+ `; + } }); panel.innerHTML = ` @@ -3740,6 +4258,17 @@ window.cancelAnimFrame = (function () {
+
+
+ + +
+
+ + + +
+
${rowsHtml}
`; - const toggleBtn = document.createElement('button'); - toggleBtn.id = 'f0ck-tuner-toggle'; - toggleBtn.type = 'button'; - toggleBtn.className = 'f0ck-tuner-toggle'; - toggleBtn.innerHTML = ' Tuner'; - toggleBtn.title = 'Open Live Audio Reactivity Tuner'; + const BUILTIN_PRESETS = { + 'Default': DEFAULT_AUDIO_TUNING, + 'Reactor': { + useCustomColor: 0, + visualizerColor: "#241f31", + enableBeatHue: 1, + beatHueThreshold: 1, + beatHueStep: 1, + beatHueSmooth: 1, + beatHueIdleDrift: 0.21, + beatHueCooldown: 800, + coverSize: 180, + glowIntensity: 500, + coverGlowBase: 150, + glowBrightness: 1.05, + glowSensitivity: 1.8, + glowDynamism: 0.85, + glowSmoothness: 0.95, + solidCover: 1, + coverColor: "#000000", + coverOpacity: 1, + bassGain: 0.5, + bassPower: 2, + scaleBounce: 0.85, + bounceBoost: 3.5, + attackSpeed: 1, + releaseSpeed: 1, + enableBars: 0, + enableInnerBars: 1, + useCustomInnerColor: 0, + innerBarColor: "#ff0000", + innerBarsMode: 4, + innerRadius: 156, + innerPupilRadius: 0, + innerPupilRingOpacity: 0, + innerBarCount: 512, + innerBarWidth: 15, + innerBarHeight: 5.4, + innerBarOpacity: 0.02, + outerRingOpacity: 0, + innerHighBoost: 0.066, + innerRadialRotation: 270, + innerBarGlow: 0, + innerHideNote: 1, + barHeight: 0.9, + barWidth: 34, + barGap: 9, + barRadius: 12, + barOpacity: 0.1, + barGlow: 0, + smoothing: 0.86, + followMouse: 1, + followSpeed: 0.1, + followRadius: 15, + tiltEffect: 1, + glowAttack: 1, + glowDecay: 0.73, + enableBlink: 0, + blinkInterval: 12, + beatHueSpeed: 0.1 + }, + 'Butterfly': { + useCustomColor: 0, + visualizerColor: "#241f31", + enableBeatHue: 1, + beatHueThreshold: 1, + beatHueStep: 1, + beatHueSmooth: 1, + beatHueIdleDrift: 0.21, + beatHueCooldown: 800, + coverSize: 205, + glowIntensity: 500, + coverGlowBase: 150, + glowBrightness: 0, + glowSensitivity: 0.1, + glowDynamism: 0, + glowSmoothness: 1, + solidCover: 1, + coverColor: "#000000", + coverOpacity: 0, + bassGain: 0.7, + bassPower: 1.51, + scaleBounce: 0.4, + bounceBoost: 3, + attackSpeed: 1, + releaseSpeed: 1, + enableBars: 0, + enableInnerBars: 1, + useCustomInnerColor: 0, + innerBarColor: "#ff0000", + innerBarsMode: 6, + innerRadius: 158, + innerPupilRadius: 0, + innerPupilRingOpacity: 0, + innerBarCount: 512, + innerBarWidth: 2, + innerBarHeight: 5.4, + innerBarOpacity: 0.94, + outerRingOpacity: 0, + innerHighBoost: 0.066, + innerRadialRotation: 270, + innerBarGlow: 0, + innerHideNote: 1, + barHeight: 0.9, + barWidth: 34, + barGap: 9, + barRadius: 12, + barOpacity: 0.1, + barGlow: 0, + smoothing: 0.86, + followMouse: 1, + followSpeed: 0.1, + followRadius: 15, + tiltEffect: 1, + glowAttack: 1, + glowDecay: 0.73, + enableBgGradient: 1, + bgGradientStyle: 1, + bgGradientOpacity: 1, + bgGradientReactivity: 0, + bgGradientSpread: 158, + useCustomBgColor: 0, + bgGradientColor: "#3a1c71", + enableBlink: 0, + blinkInterval: 12, + beatHueSpeed: 0.1 + }, + 'Hyper Laser 512': { + useCustomColor: 0, + visualizerColor: '#241f31', + enableBeatHue: 0, + coverSize: 105, + glowIntensity: 500, + coverGlowBase: 35, + glowBrightness: 2.2, + glowSensitivity: 1.2, + glowDynamism: 2.2, + glowSmoothness: 0.65, + solidCover: 1, + coverColor: '#000000', + coverOpacity: 1, + bassGain: 1.4, + bassPower: 1.1, + scaleBounce: 0, + bounceBoost: 24.5, + attackSpeed: 1, + releaseSpeed: 0.57, + enableBars: 0, + enableInnerBars: 1, + useCustomInnerColor: 0, + innerBarColor: '#000000', + innerBarsMode: 5, + innerRadius: 318, + innerPupilRadius: 292, + innerPupilRingOpacity: 0, + innerBarCount: 512, + innerBarWidth: 1, + innerBarHeight: 5, + innerBarOpacity: 0.85, + outerRingOpacity: 0, + innerHighBoost: 0.365, + innerRadialRotation: 90, + innerBarGlow: 0, + innerHideNote: 1, + smoothing: 0.34, + followMouse: 1, + followSpeed: 0.02, + followRadius: 20, + tiltEffect: 1, + glowAttack: 0.85, + glowDecay: 0.35, + enableBlink: 0, + blinkInterval: 12 + }, + 'Deep Bass Pulse': { + useCustomColor: 0, + coverSize: 110, + glowIntensity: 450, + coverGlowBase: 30, + glowBrightness: 2.5, + glowSensitivity: 1.5, + glowDynamism: 2.4, + glowSmoothness: 0.75, + solidCover: 1, + coverColor: '#000000', + coverOpacity: 1, + bassGain: 1.6, + bassPower: 1.2, + scaleBounce: 0.85, + bounceBoost: 2.0, + attackSpeed: 1, + releaseSpeed: 0.25, + enableBars: 1, + enableInnerBars: 1, + innerBarsMode: 4, + innerRadius: 180, + innerPupilRadius: 0, + innerPupilRingOpacity: 0, + innerBarCount: 180, + innerBarWidth: 2, + innerBarHeight: 2.2, + innerBarOpacity: 0.8, + outerRingOpacity: 0.3, + innerHighBoost: 0.2, + innerRadialRotation: 90, + innerBarGlow: 8, + innerHideNote: 1, + barHeight: 0.8, + barWidth: 24, + barGap: 8, + barRadius: 8, + barOpacity: 0.7, + barGlow: 15, + smoothing: 0.80 + }, + 'Stargate Strobe': { + useCustomColor: 0, + coverSize: 115, + glowIntensity: 400, + coverGlowBase: 40, + glowBrightness: 2.0, + glowSensitivity: 1.3, + glowDynamism: 2.0, + glowSmoothness: 0.50, + solidCover: 1, + coverColor: '#000000', + coverOpacity: 1, + bassGain: 1.3, + bassPower: 1.0, + scaleBounce: 0.40, + bounceBoost: 1.5, + enableBars: 0, + enableInnerBars: 1, + innerBarsMode: 6, + innerRadius: 240, + innerPupilRadius: 70, + innerPupilRingOpacity: 0.5, + innerBarCount: 256, + innerBarWidth: 2, + innerBarHeight: 2.8, + innerBarOpacity: 0.9, + outerRingOpacity: 0.5, + innerHighBoost: 0.25, + innerRadialRotation: 90, + innerBarGlow: 12, + innerHideNote: 1, + smoothing: 0.70 + }, + 'Minimal Bars': { + useCustomColor: 0, + coverSize: 100, + glowIntensity: 200, + coverGlowBase: 25, + glowBrightness: 1.2, + glowSensitivity: 1.0, + glowDynamism: 1.5, + solidCover: 0, + coverOpacity: 1, + scaleBounce: 0.50, + bounceBoost: 1.2, + enableBars: 1, + enableInnerBars: 0, + barHeight: 0.65, + barWidth: 28, + barGap: 8, + barRadius: 10, + barOpacity: 0.85, + barGlow: 10, + smoothing: 0.80 + } + }; - document.body.appendChild(panel); - document.body.appendChild(toggleBtn); + const loadUserPresets = () => { + try { + const raw = localStorage.getItem('f0ck_audio_presets'); + return raw ? JSON.parse(raw) : {}; + } catch (e) { + return {}; + } + }; - toggleBtn.addEventListener('click', () => { - panel.classList.toggle('hidden'); + const saveUserPresets = (p) => { + try { + localStorage.setItem('f0ck_audio_presets', JSON.stringify(p)); + } catch (e) {} + }; + + const presetSelect = panel.querySelector('#f0ck-tuner-preset-select'); + const deleteBtn = panel.querySelector('#f0ck-tuner-preset-delete'); + const saveBtn = panel.querySelector('#f0ck-tuner-preset-save'); + const importBtn = panel.querySelector('#f0ck-tuner-preset-import'); + const nameInput = panel.querySelector('#f0ck-tuner-preset-name'); + + const renderPresetsDropdown = (selectedVal = '') => { + const userPresets = loadUserPresets(); + let html = ``; + + html += ``; + Object.keys(BUILTIN_PRESETS).forEach(name => { + const optVal = 'builtin:' + name; + html += ``; + }); + html += ``; + + const userKeys = Object.keys(userPresets); + if (userKeys.length > 0) { + html += ``; + userKeys.forEach(name => { + const optVal = 'user:' + name; + html += ``; + }); + html += ``; + } + + presetSelect.innerHTML = html; + deleteBtn.disabled = !selectedVal || !selectedVal.startsWith('user:'); + }; + + const applyTuningConfig = (newCfg, presetName) => { + Object.assign(window.audioVisualizerTuning, newCfg); + try { + localStorage.setItem('f0ck_audio_tuning', JSON.stringify(window.audioVisualizerTuning)); + } catch (e) {} + + sliders.forEach(s => { + const input = panel.querySelector(`#input-${s.key}`); + const valEl = panel.querySelector(`#val-${s.key}`); + if (!input || !valEl) return; + const val = window.audioVisualizerTuning[s.key] !== undefined ? window.audioVisualizerTuning[s.key] : DEFAULT_AUDIO_TUNING[s.key]; + input.value = val; + if (s.type === 'color') { + valEl.textContent = val; + } else { + const num = Number(val); + const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(3)); + valEl.textContent = `${displayVal}${s.unit || ''}`; + } + }); + + const cfg = window.audioVisualizerTuning; + const isCustom = Number(cfg.useCustomColor) === 1; + const activeColor = isCustom ? (cfg.visualizerColor || '#99ff00') : ''; + document.querySelectorAll('.audio-cover-circle i').forEach(note => { + note.style.color = activeColor; + note.style.textShadow = activeColor ? `0 0 16px color-mix(in srgb, ${activeColor} 75%, transparent), 0 0 35px color-mix(in srgb, ${activeColor} 40%, transparent)` : ''; + note.style.display = Number(cfg.innerHideNote) === 1 ? 'none' : ''; + }); + document.querySelectorAll('.audio-cover-circle').forEach(c => { + if (cfg.coverSize) { + c.style.width = cfg.coverSize + 'px'; + c.style.height = cfg.coverSize + 'px'; + } + const note = c.querySelector('i'); + if (note && cfg.coverSize) note.style.fontSize = Math.round(cfg.coverSize * 0.35) + 'px'; + }); + updateCoverArtSolidMode(); + + if (presetName && typeof window.flashMessage === 'function') { + window.flashMessage(`Loaded preset: "${presetName}"`, 2200, 'info'); + } + }; + + renderPresetsDropdown(); + + presetSelect.addEventListener('change', () => { + const val = presetSelect.value; + if (!val) return; + if (val.startsWith('builtin:')) { + const name = val.replace('builtin:', ''); + if (BUILTIN_PRESETS[name]) { + applyTuningConfig(BUILTIN_PRESETS[name], name); + } + deleteBtn.disabled = true; + } else if (val.startsWith('user:')) { + const name = val.replace('user:', ''); + const userPresets = loadUserPresets(); + if (userPresets[name]) { + applyTuningConfig(userPresets[name], name); + } + deleteBtn.disabled = false; + } }); - panel.querySelector('#f0ck-tuner-close').addEventListener('click', () => { - panel.classList.add('hidden'); + const handleSavePreset = () => { + let name = (nameInput.value || '').trim(); + if (!name) { + name = prompt('Enter a name for this preset:'); + if (!name) return; + name = name.trim(); + } + if (!name) return; + + const userPresets = loadUserPresets(); + userPresets[name] = Object.assign({}, window.audioVisualizerTuning); + saveUserPresets(userPresets); + + nameInput.value = ''; + renderPresetsDropdown('user:' + name); + + if (typeof window.flashMessage === 'function') { + window.flashMessage(`Preset "${name}" saved!`, 2500, 'success'); + } + }; + + saveBtn.addEventListener('click', handleSavePreset); + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSavePreset(); + } }); + deleteBtn.addEventListener('click', () => { + const val = presetSelect.value; + if (!val || !val.startsWith('user:')) return; + const name = val.replace('user:', ''); + if (!confirm(`Delete preset "${name}"?`)) return; + + const userPresets = loadUserPresets(); + delete userPresets[name]; + saveUserPresets(userPresets); + + renderPresetsDropdown(''); + if (typeof window.flashMessage === 'function') { + window.flashMessage(`Preset "${name}" deleted`, 2200, 'info'); + } + }); + + importBtn.addEventListener('click', () => { + const raw = prompt('Paste your visualizer preset JSON:'); + if (!raw) return; + try { + const parsed = JSON.parse(raw.trim()); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Not an object'); + } + const defaultName = 'Preset ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + let name = prompt('Save preset as:', defaultName); + name = name ? name.trim() : defaultName; + + const userPresets = loadUserPresets(); + userPresets[name] = Object.assign({}, DEFAULT_AUDIO_TUNING, parsed); + saveUserPresets(userPresets); + + applyTuningConfig(userPresets[name], name); + renderPresetsDropdown('user:' + name); + + if (typeof window.flashMessage === 'function') { + window.flashMessage(`Imported and loaded "${name}"!`, 3000, 'success'); + } + } catch (err) { + alert('Invalid JSON! Please paste valid preset settings.'); + } + }); + + if (sidebarContainer) { + sidebarContainer.appendChild(panel); + } else { + const toggleBtn = document.createElement('button'); + toggleBtn.id = 'f0ck-tuner-toggle'; + toggleBtn.type = 'button'; + toggleBtn.className = 'f0ck-tuner-toggle'; + toggleBtn.innerHTML = ' Tuner'; + toggleBtn.title = 'Open Live Audio Reactivity Tuner'; + + document.body.appendChild(panel); + document.body.appendChild(toggleBtn); + + toggleBtn.addEventListener('click', () => { + panel.classList.toggle('hidden'); + }); + } + + const closeBtn = panel.querySelector('#f0ck-tuner-close'); + if (closeBtn) { + closeBtn.addEventListener('click', () => { + if (sidebarContainer && typeof window.switchSidebarTab === 'function') { + window.switchSidebarTab('comments'); + } else { + panel.classList.add('hidden'); + } + }); + } + sliders.forEach(s => { const input = panel.querySelector(`#input-${s.key}`); const valEl = panel.querySelector(`#val-${s.key}`); input.addEventListener('input', () => { - const parsed = parseFloat(input.value); - window.audioVisualizerTuning[s.key] = parsed; - valEl.textContent = `${parsed}${s.unit}`; + if (s.type === 'color') { + window.audioVisualizerTuning[s.key] = input.value; + valEl.textContent = input.value; + } else { + const parsed = parseFloat(input.value); + window.audioVisualizerTuning[s.key] = parsed; + const displayVal = Number.isInteger(parsed) ? parsed : parseFloat(parsed.toFixed(3)); + valEl.textContent = `${displayVal}${s.unit || ''}`; + } + + if (s.key === 'useCustomColor' || s.key === 'visualizerColor') { + const cfg = window.audioVisualizerTuning; + const isCustom = Number(cfg.useCustomColor) === 1; + const activeColor = isCustom ? (cfg.visualizerColor || '#99ff00') : ''; + document.querySelectorAll('.audio-cover-circle i').forEach(note => { + note.style.color = activeColor; + note.style.textShadow = activeColor ? `0 0 16px color-mix(in srgb, ${activeColor} 75%, transparent), 0 0 35px color-mix(in srgb, ${activeColor} 40%, transparent)` : ''; + }); + } else if (s.key === 'solidCover' || s.key === 'coverColor' || s.key === 'coverOpacity') { + updateCoverArtSolidMode(); + } else if (s.key === 'enableBars') { + if (Number(input.value) === 0) { + document.querySelectorAll('.v0ck-audio-visualizer-canvas, .audio-visualizer-canvas').forEach(c => { + const cCtx = c.getContext('2d'); + if (cCtx) cCtx.clearRect(0, 0, c.width, c.height); + }); + } + } else if (s.key === 'enableInnerBars') { + if (Number(input.value) === 0) { + document.querySelectorAll('.audio-eye-inner-canvas').forEach(c => { + const inCtx = c.getContext('2d'); + if (inCtx) inCtx.clearRect(0, 0, c.width, c.height); + }); + } + } else if (s.key === 'innerHideNote') { + const hide = Number(input.value) === 1; + document.querySelectorAll('.audio-cover-circle i').forEach(note => { + note.style.display = hide ? 'none' : ''; + }); + } else if (s.key === 'coverSize') { + const parsed = parseFloat(input.value); + document.querySelectorAll('.audio-cover-circle').forEach(c => { + c.style.width = parsed + 'px'; + c.style.height = parsed + 'px'; + const note = c.querySelector('i'); + if (note) note.style.fontSize = Math.round(parsed * 0.35) + 'px'; + }); + } else if (s.key === 'coverGlowBase' || s.key === 'glowBrightness') { + const cfg = window.audioVisualizerTuning; + const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; + const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; + document.querySelectorAll('.audio-cover-circle').forEach(c => { + if (!c._visualizerDriving) { + if (glowMult <= 0) { + c.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88)`; + } else { + c.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88), 0 0 ${baseGlow}px color-mix(in srgb, var(--accent, #99ff00) ${Math.round(28 * Math.min(1.5, glowMult))}%, transparent)`; + } + } + }); + } + try { localStorage.setItem('f0ck_audio_tuning', JSON.stringify(window.audioVisualizerTuning)); } catch (e) {} @@ -3794,27 +4848,293 @@ window.cancelAnimFrame = (function () { }); panel.querySelector('#f0ck-tuner-reset').addEventListener('click', () => { - Object.assign(window.audioVisualizerTuning, DEFAULT_AUDIO_TUNING); - try { - localStorage.removeItem('f0ck_audio_tuning'); - } catch (e) {} - sliders.forEach(s => { - const input = panel.querySelector(`#input-${s.key}`); - const valEl = panel.querySelector(`#val-${s.key}`); - input.value = DEFAULT_AUDIO_TUNING[s.key]; - valEl.textContent = `${DEFAULT_AUDIO_TUNING[s.key]}${s.unit}`; - }); + applyTuningConfig(DEFAULT_AUDIO_TUNING, 'Default'); + renderPresetsDropdown('builtin:Default'); if (typeof window.flashMessage === 'function') { window.flashMessage('Tuner reset to defaults', 2000, 'info'); } }); + + updateCoverArtSolidMode(); }; - window.initVisualizer = () => { - const audioElement = document.querySelector("audio#my-video, audio#f0ck-album-audio, audio"); + document.addEventListener('click', (e) => { + const tunerTab = e.target.closest('#sidebar-tab-tuner, [data-tab="tuner"]'); + if (tunerTab) { + initAudioTunerUI(); + } + }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initAudioTunerUI); + } else { + setTimeout(initAudioTunerUI, 50); + } + + const audioMetaMemoryCache = new Map(); + + // Fast client-side binary tag parser (FLAC Vorbis Comment & ID3v2) + const parseAudioTagsFromBuffer = (buffer) => { + const bytes = new Uint8Array(buffer); + const result = { artist: '', title: '', album: '' }; + + // 1. Check FLAC: "fLaC" (0x66, 0x4C, 0x61, 0x63) + if (bytes.length > 4 && bytes[0] === 0x66 && bytes[1] === 0x4C && bytes[2] === 0x61 && bytes[3] === 0x63) { + let offset = 4; + const view = new DataView(buffer); + while (offset + 4 < bytes.length) { + const header = bytes[offset]; + const isLast = (header & 0x80) !== 0; + const type = header & 0x7F; + const length = (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]; + offset += 4; + if (type === 4 && offset + length <= bytes.length) { // VORBIS_COMMENT + let p = offset; + const vendorLen = view.getUint32(p, true); p += 4 + vendorLen; + if (p + 4 <= offset + length) { + const commentCount = view.getUint32(p, true); p += 4; + const decoder = new TextDecoder('utf-8'); + for (let i = 0; i < commentCount && p + 4 <= offset + length; i++) { + const cLen = view.getUint32(p, true); p += 4; + if (p + cLen <= offset + length) { + const commentStr = decoder.decode(bytes.subarray(p, p + cLen)); + p += cLen; + const eqIdx = commentStr.indexOf('='); + if (eqIdx > 0) { + const key = commentStr.slice(0, eqIdx).toUpperCase().replace(/[-_\s]/g, ''); + const val = commentStr.slice(eqIdx + 1).trim(); + if (key === 'TITLE' && !result.title) result.title = val; + else if ((key === 'ARTIST' || key === 'ALBUMARTIST' || key === 'PERFORMER' || key === 'AUTHOR') && !result.artist) result.artist = val; + else if (key === 'ALBUM' && !result.album) result.album = val; + } + } + } + } + break; + } + offset += length; + if (isLast) break; + } + } + + // 2. Check ID3v2: "ID3" (73, 68, 51) + if (bytes.length > 10 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) { + const version = bytes[3]; + const tagSize = ((bytes[6] & 0x7F) << 21) | ((bytes[7] & 0x7F) << 14) | ((bytes[8] & 0x7F) << 7) | (bytes[9] & 0x7F); + let offset = 10; + const end = Math.min(bytes.length, 10 + tagSize); + + while (offset + 10 <= end) { + let frameId = ''; + let frameSize = 0; + + if (version >= 3) { + frameId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); + if (version === 4) { + frameSize = ((bytes[offset + 4] & 0x7F) << 21) | ((bytes[offset + 5] & 0x7F) << 14) | ((bytes[offset + 6] & 0x7F) << 7) | (bytes[offset + 7] & 0x7F); + } else { + frameSize = (bytes[offset + 4] << 24) | (bytes[offset + 5] << 16) | (bytes[offset + 6] << 8) | bytes[offset + 7]; + } + offset += 10; + } else if (version === 2) { + frameId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2]); + frameSize = (bytes[offset + 3] << 16) | (bytes[offset + 4] << 8) | bytes[offset + 5]; + offset += 6; + } + + if (!frameId || frameId.charCodeAt(0) === 0 || frameSize <= 0 || offset + frameSize > end) break; + + if (frameId === 'TIT2' || frameId === 'TT2' || frameId === 'TPE1' || frameId === 'TP1' || frameId === 'TPE2' || frameId === 'TALB') { + const encoding = bytes[offset]; + const frameData = bytes.subarray(offset + 1, offset + frameSize); + let text = ''; + try { + if (encoding === 1 || encoding === 2) { + text = new TextDecoder('utf-16').decode(frameData); + } else { + text = new TextDecoder('utf-8').decode(frameData); + } + text = text.replace(/\0+$/, '').trim(); + } catch (_) {} + + if ((frameId === 'TIT2' || frameId === 'TT2') && !result.title) result.title = text; + else if ((frameId === 'TPE1' || frameId === 'TP1') && !result.artist) result.artist = text; + else if (frameId === 'TPE2' && !result.artist) result.artist = text; + else if (frameId === 'TALB' && !result.album) result.album = text; + } + + offset += frameSize; + } + } + + return result; + }; + + let badgeUpdateSeq = 0; + window.updateAudioTrackBadge = async (audioElement, itemId, explicitSrc) => { + if (!audioElement) return; + const playerWrap = audioElement.closest('.album-gallery-container, .album-audio-wrapper, .v0ck, .embed-responsive') || audioElement.parentElement; + if (!playerWrap) return; + + let badge = playerWrap.querySelector('.album-audio-wrapper .audio-track-info-badge, .sidebar-media-placeholder.audio > .audio-track-info-badge, .audio-track-info-badge'); + if (!badge) { + const ph = playerWrap.querySelector('.album-audio-wrapper .sidebar-media-placeholder.audio, .sidebar-media-placeholder.audio') || playerWrap; + ph.insertAdjacentHTML('afterbegin', ` +
+
+ + +
+
+ `); + badge = playerWrap.querySelector('.audio-track-info-badge'); + } + if (!badge) return; + + badge.classList.remove('is-playing'); + if (audioElement._badgePlayStateListener) { + audioElement.removeEventListener('play', audioElement._badgePlayStateListener); + audioElement.removeEventListener('pause', audioElement._badgePlayStateListener); + audioElement.removeEventListener('ended', audioElement._badgePlayStateListener); + audioElement._badgePlayStateListener = null; + } + + const artistEl = badge.querySelector('.audio-track-artist'); + const titleEl = badge.querySelector('.audio-track-title'); + + const src = explicitSrc || + (audioElement.getAttribute ? audioElement.getAttribute('src') : null) || + audioElement.src || + audioElement.currentSrc || + ''; + if (!src || src === window.location.href || src.endsWith('#')) { + badge.classList.remove('is-visible'); + return; + } + + const cleanFilename = src.split('?')[0].split('/').pop(); + const cacheKey = cleanFilename || `item_${itemId || ''}`; + + const thisReqId = ++badgeUpdateSeq; + badge._lastReqId = thisReqId; + + let meta = audioMetaMemoryCache.get(cacheKey); + + const renderMeta = (m) => { + if (badge._lastReqId !== thisReqId) return; + if (!m || (!m.artist && !m.title)) { + badge.classList.remove('is-visible'); + return; + } + if (artistEl) { + artistEl.textContent = m.artist || ''; + artistEl.style.display = m.artist ? '' : 'none'; + } + if (titleEl) { + titleEl.textContent = m.title || ''; + titleEl.style.display = m.title ? '' : 'none'; + } + const fullTooltip = `${m.artist ? m.artist + ' – ' : ''}${m.title || ''}${m.album ? ' (' + m.album + ')' : ''}`; + badge.setAttribute('title', fullTooltip); + badge.classList.add('is-visible'); + + if ('mediaSession' in navigator && window.MediaMetadata) { + try { + navigator.mediaSession.metadata = new MediaMetadata({ + title: m.title || '', + artist: m.artist || '', + album: m.album || '' + }); + } catch (_) {} + } + }; + + if (meta) { + renderMeta(meta); + return; + } + + // Immediately clear previous track text while fetching new track metadata + if (artistEl) artistEl.textContent = ''; + if (titleEl) titleEl.textContent = ''; + badge.classList.remove('is-visible'); + + // 1. Fetch from server endpoint /api/v2/audio-metadata (ffprobe format tags + DB fallback) + try { + const q = new URLSearchParams(); + if (cleanFilename) q.set('src', cleanFilename); + if (itemId) q.set('id', itemId); + const apiResp = await fetch(`/api/v2/audio-metadata?${q.toString()}`); + if (badge._lastReqId !== thisReqId) return; + if (apiResp.ok) { + const data = await apiResp.json(); + if (badge._lastReqId !== thisReqId) return; + if (data.success && (data.artist || data.title)) { + meta = { artist: data.artist, title: data.title, album: data.album }; + audioMetaMemoryCache.set(cacheKey, meta); + renderMeta(meta); + return; + } + } + } catch (_) {} + + // 2. Client-side range fetch fallback (first 64KB) + try { + if (badge._lastReqId !== thisReqId) return; + const resp = await fetch(src, { + headers: { Range: 'bytes=0-65535' } + }); + if (badge._lastReqId !== thisReqId) return; + if (resp.ok || resp.status === 206) { + const buf = await resp.arrayBuffer(); + if (badge._lastReqId !== thisReqId) return; + const parsed = parseAudioTagsFromBuffer(buf); + if (parsed.artist || parsed.title) { + meta = parsed; + audioMetaMemoryCache.set(cacheKey, meta); + renderMeta(meta); + return; + } + } + } catch (_) {} + + // 3. Fallback: parse from filename if "Artist - Title" pattern + if (badge._lastReqId !== thisReqId) return; + const rawName = decodeURIComponent(cleanFilename.replace(/\.[^/.]+$/, '')); + const match = rawName.match(/^(.+?)\s*[-–—_]\s*(.+)$/); + if (match && !rawName.startsWith('subf0ck')) { + meta = { + artist: match[1].replace(/^[0-9]+[\s._-]+/, '').trim(), + title: match[2].trim(), + album: '' + }; + audioMetaMemoryCache.set(cacheKey, meta); + renderMeta(meta); + } else { + badge.classList.remove('is-visible'); + } + }; + + window.initVisualizer = (targetAudio) => { + let audioElement = targetAudio; + if (!audioElement || !audioElement.isConnected) { + const albumAudio = document.querySelector(".album-gallery-container.is-audio-active audio, #f0ck-album-audio-wrapper audio, audio#f0ck-album-audio"); + const standaloneAudio = document.querySelector("audio#my-video"); + const container = document.querySelector(".album-gallery-container"); + if (container && container.classList.contains('is-audio-active') && albumAudio) { + audioElement = albumAudio; + } else if (standaloneAudio && standaloneAudio.isConnected) { + audioElement = standaloneAudio; + } else { + audioElement = albumAudio || standaloneAudio || document.querySelector("audio"); + } + } if (audioElement) { // Ensure Tuner UI is mounted initAudioTunerUI(); + if (window.updateAudioTrackBadge) { + window.updateAudioTrackBadge(audioElement, null, audioElement.getAttribute('src') || audioElement.src); + } // Cleanup existing visualizer if (visualizerRafId) window.cancelAnimFrame(visualizerRafId); @@ -3827,7 +5147,7 @@ window.cancelAnimFrame = (function () { canvas.height = 1080; const attachCanvas = () => { - const v0ckContainer = audioElement.closest('.v0ck') || audioElement.parentElement || document.querySelector('.v0ck'); + const v0ckContainer = audioElement.closest('.v0ck, .album-audio-wrapper, .embed-responsive') || audioElement.parentElement; if (v0ckContainer && !v0ckContainer.contains(canvas)) { const controls = v0ckContainer.querySelector('.v0ck_player_controls'); if (controls) { @@ -3850,55 +5170,163 @@ window.cancelAnimFrame = (function () { let source = audioElement._mediaElementSource; let analyser = audioElement._audioAnalyser; - if (!source) { - try { - source = audioCtx.createMediaElementSource(audioElement); - audioElement._mediaElementSource = source; - } catch (e) { - console.warn("Visualizer Source creation failed:", e); + const setupAudioSource = () => { + if (!audioElement.src || audioElement.src === window.location.href || audioElement.src.endsWith('#')) { + return false; } - } + if (!source) { + try { + source = audioCtx.createMediaElementSource(audioElement); + audioElement._mediaElementSource = source; + } catch (e) { + console.warn("Visualizer Source creation failed:", e); + } + } + if (source && analyser && !source._connectedToAnalyser) { + try { + source.connect(analyser); + source.connect(audioCtx.destination); + source._connectedToAnalyser = true; + } catch (e) { + console.warn("Visualizer connect failed:", e); + } + } + return !!source; + }; const cfgInit = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; if (!analyser) { analyser = audioCtx.createAnalyser(); analyser.fftSize = 2048; - analyser.smoothingTimeConstant = cfgInit.smoothing !== undefined ? cfgInit.smoothing : 0.80; + analyser.smoothingTimeConstant = Math.min(0.99, Math.max(0, cfgInit.smoothing !== undefined ? Number(cfgInit.smoothing) : 0.80)); audioElement._audioAnalyser = analyser; - if (source) { - source.connect(analyser); - source.connect(audioCtx.destination); - } } else { analyser.fftSize = 2048; - analyser.smoothingTimeConstant = cfgInit.smoothing !== undefined ? cfgInit.smoothing : 0.80; + analyser.smoothingTimeConstant = Math.min(0.99, Math.max(0, cfgInit.smoothing !== undefined ? Number(cfgInit.smoothing) : 0.80)); } + setupAudioSource(); + let data = new Uint8Array(analyser.frequencyBinCount); let noteIcon = null; let smoothScale = 1; let smoothGlow = 0; + let currentBeatHue = 0; + let targetBeatHue = 0; + let lastBeatJumpTime = 0; const draw = (data) => { ctx.clearRect(0, 0, canvas.width, canvas.height); - const accent = getComputedStyle(document.body).getPropertyValue("--accent")?.trim() || "#99ff00"; - ctx.fillStyle = accent; const count = analyser.frequencyBinCount; - const barWidth = canvas.width / count; const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; - const barMult = cfg.barHeight !== undefined ? cfg.barHeight : 0.85; - for (let i = 0; i < count; i++) { - const value = data[i]; - if (value > 0) { - const percent = (value / 256) * barMult; - const height = (canvas.height * percent / 2) - 30; - if (height > 0) { - const offset = canvas.height - height - 1; - ctx.fillRect(i * barWidth, offset, barWidth, height); + if (!audioElement.paused && Number(cfg.enableBeatHue) === 1) { + const now = performance.now(); + const threshold = cfg.beatHueThreshold !== undefined ? Number(cfg.beatHueThreshold) : 0.30; + const stepAngle = cfg.beatHueStep !== undefined ? Math.max(1, Number(cfg.beatHueStep)) : 45; + const idleDrift = cfg.beatHueIdleDrift !== undefined ? Number(cfg.beatHueIdleDrift) : 0.20; + const cooldown = cfg.beatHueCooldown !== undefined ? Number(cfg.beatHueCooldown) : 150; + const morphSmooth = cfg.beatHueSmooth !== undefined ? Math.min(0.98, Math.max(0, Number(cfg.beatHueSmooth))) : 0.65; + + let bMax = 0, bSum = 0; + const kBins = Math.min(10, count); + for (let b = 1; b <= kBins; b++) { + const v = data[b] || 0; + if (v > bMax) bMax = v; + bSum += v; + } + const bPeak = bMax / 255; + const bAvg = bSum / (kBins * 255); + const kEnergy = Math.min(1, (bPeak * 0.75 + bAvg * 0.25) * (cfg.bassGain || 0.5)); + const pwr = Math.pow(kEnergy, (cfg.bassPower !== undefined ? cfg.bassPower : 0.3)); + + // Continuous subtle ambient drift + targetBeatHue = (targetBeatHue + idleDrift * 0.4) % 360; + + // Beat threshold & cooldown check + if (pwr >= threshold && (now - lastBeatJumpTime) >= cooldown) { + const excess = (pwr - threshold) / Math.max(0.1, 1 - threshold); + const jump = stepAngle * (1 + excess * 0.35); + targetBeatHue = (targetBeatHue + jump) % 360; + lastBeatJumpTime = now; + } + + // Interpolate currentBeatHue towards targetBeatHue + if (morphSmooth <= 0.02) { + currentBeatHue = targetBeatHue; + } else { + let diff = (targetBeatHue - currentBeatHue) % 360; + if (diff < -180) diff += 360; + if (diff > 180) diff -= 360; + currentBeatHue = (currentBeatHue + diff * (1 - morphSmooth)) % 360; + if (currentBeatHue < 0) currentBeatHue += 360; + } + } + + let accent = getComputedStyle(document.body).getPropertyValue("--accent")?.trim() || "#99ff00"; + if (Number(cfg.enableBeatHue) === 1) { + accent = `hsl(${Math.round(currentBeatHue)}, 100%, 55%)`; + } else if (Number(cfg.useCustomColor) === 1 && cfg.visualizerColor) { + accent = cfg.visualizerColor; + } + const barMult = cfg.barHeight !== undefined ? cfg.barHeight : 0.40; + const bWidth = Math.max(1, cfg.barWidth !== undefined ? cfg.barWidth : 4); + const bGap = Math.max(0, cfg.barGap !== undefined ? cfg.barGap : 2); + const bRadius = Math.max(0, cfg.barRadius !== undefined ? cfg.barRadius : 2); + const bOpacity = Math.min(1, Math.max(0, cfg.barOpacity !== undefined ? Number(cfg.barOpacity) : 0.85)); + const bGlow = Math.max(0, cfg.barGlow !== undefined ? cfg.barGlow : 8); + + const showBars = cfg.enableBars !== undefined ? Number(cfg.enableBars) === 1 : true; + if (showBars) { + const totalBarStep = bWidth + bGap; + const numBars = Math.max(8, Math.floor((canvas.width + bGap) / totalBarStep)); + const totalUsedWidth = numBars * totalBarStep - bGap; + const startX = Math.max(0, (canvas.width - totalUsedWidth) / 2); + + if (bGlow > 0) { + ctx.shadowBlur = bGlow; + ctx.shadowColor = accent; + } else { + ctx.shadowBlur = 0; + } + + ctx.fillStyle = `color-mix(in srgb, ${accent} ${Math.round(bOpacity * 100)}%, transparent)`; + + for (let i = 0; i < numBars; i++) { + const normIdx = i / numBars; + const nextNormIdx = (i + 1) / numBars; + const startBin = Math.floor(Math.pow(normIdx, 1.7) * (count * 0.78)); + const endBin = Math.min(count - 1, Math.max(startBin, Math.floor(Math.pow(nextNormIdx, 1.7) * (count * 0.78)))); + + let valMax = 0; + let valSum = 0; + let valCount = 0; + for (let b = startBin; b <= endBin; b++) { + const v = data[b] || 0; + if (v > valMax) valMax = v; + valSum += v; + valCount++; + } + const val = valCount > 0 ? (valMax * 0.7 + (valSum / valCount) * 0.3) : (data[startBin] || 0); + + if (val > 0) { + const percent = (val / 256) * barMult; + const height = (canvas.height * percent / 2) - 15; + if (height > 1) { + const x = startX + i * totalBarStep; + const y = canvas.height - height; + if (bRadius > 0 && typeof ctx.roundRect === 'function') { + ctx.beginPath(); + ctx.roundRect(x, y, bWidth, height, [bRadius, bRadius, 0, 0]); + ctx.fill(); + } else { + ctx.fillRect(x, y, bWidth, height); + } + } } } + ctx.shadowBlur = 0; } if (cfg.smoothing !== undefined && analyser.smoothingTimeConstant !== cfg.smoothing) { @@ -3906,20 +5334,66 @@ window.cancelAnimFrame = (function () { } // Animate the music note and/or cover art circle to react dynamically to the music - const v0ckContainer = audioElement.closest('.v0ck') || audioElement.parentElement || document.querySelector('.v0ck'); - const ph = v0ckContainer ? v0ckContainer.querySelector('.sidebar-media-placeholder.audio') : null; + const v0ckContainer = audioElement.closest('.v0ck, .album-audio-wrapper, .embed-responsive') || audioElement.parentElement; + const ph = v0ckContainer ? v0ckContainer.querySelector('.sidebar-media-placeholder.audio') : document.querySelector('.album-gallery-container.is-audio-active .sidebar-media-placeholder.audio, .sidebar-media-placeholder.audio'); if (!noteIcon || !noteIcon.isConnected) { noteIcon = ph ? ph.querySelector('i') : null; } const coverCircle = ph ? ph.querySelector('.audio-cover-circle') : null; + let bgEl = ph ? ph.querySelector('.audio-reactive-bg') : null; + if (ph && !bgEl) { + bgEl = document.createElement('div'); + bgEl.className = 'audio-reactive-bg'; + ph.insertBefore(bgEl, ph.firstChild); + } - if (coverCircle && cfg.coverSize) { - coverCircle.style.width = cfg.coverSize + 'px'; - coverCircle.style.height = cfg.coverSize + 'px'; + if (coverCircle) { + if (cfg.coverSize) { + coverCircle.style.width = cfg.coverSize + 'px'; + coverCircle.style.height = cfg.coverSize + 'px'; + if (noteIcon && coverCircle.contains(noteIcon)) { + noteIcon.style.fontSize = Math.round(cfg.coverSize * 0.35) + 'px'; + } + } + const coverOp = cfg.coverOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.coverOpacity))) : 1.0; + const solidCol = cfg.coverColor || '#000000'; + const bgCol = coverOp <= 0.001 ? 'transparent' : (coverOp >= 0.999 ? solidCol : `color-mix(in srgb, ${solidCol} ${Math.round(coverOp * 100)}%, transparent)`); + + if (Number(cfg.solidCover) === 1) { + if (coverCircle.style.backgroundImage && coverCircle.style.backgroundImage !== 'none' && !coverCircle._origBgImage) { + coverCircle._origBgImage = coverCircle.style.backgroundImage; + } + if (coverCircle.style.backgroundImage !== 'none') { + coverCircle.style.backgroundImage = 'none'; + } + coverCircle.style.backgroundColor = bgCol; + } else if (coverCircle._origBgImage && coverCircle.style.backgroundImage === 'none') { + coverCircle.style.backgroundImage = coverCircle._origBgImage; + coverCircle.style.backgroundColor = bgCol; + } else if (!coverCircle._origBgImage && (!coverCircle.style.backgroundImage || coverCircle.style.backgroundImage === 'none')) { + coverCircle.style.backgroundImage = 'none'; + coverCircle.style.backgroundColor = bgCol; + } + + let innerCanvas = coverCircle.querySelector('.audio-eye-inner-canvas'); + if (!innerCanvas) { + innerCanvas = document.createElement('canvas'); + innerCanvas.className = 'audio-eye-inner-canvas'; + innerCanvas.width = 300; + innerCanvas.height = 300; + coverCircle.insertBefore(innerCanvas, coverCircle.firstChild); + } + + if (noteIcon) { + noteIcon.style.display = Number(cfg.innerHideNote) === 1 ? 'none' : ''; + } } if (noteIcon || coverCircle) { - if (!audioElement.paused && audioCtx.state === 'running') { + if (!audioElement.paused) { + if (audioCtx && audioCtx.state !== 'running') { + audioCtx.resume().catch(() => {}); + } // Bass energy from kick/sub-bass bins (1 to 10): peak transient + body let bassMax = 0; let bassSum = 0; @@ -3960,58 +5434,625 @@ window.cancelAnimFrame = (function () { const bounceMultiplier = cfg.bounceBoost !== undefined ? cfg.bounceBoost : 1.5; // Target scale jumps based on slider and bounce boost const targetScale = 1 + (punch * (cfg.scaleBounce !== undefined ? cfg.scaleBounce : 0.50) * bounceMultiplier) + (midEnergy * 0.05); - const targetGlow = punch * (cfg.glowIntensity !== undefined ? cfg.glowIntensity : 20) + midEnergy * 20; + // Normalized composite glow energy: + // Combines strong kick/bass transient impact (70%) with melodic bloom (20%) and high sparkle (10%) + const dynamism = cfg.glowDynamism !== undefined ? cfg.glowDynamism : 1.50; + const glowSens = cfg.glowSensitivity !== undefined ? Number(cfg.glowSensitivity) : 1.0; + const rawGlowEnergy = Math.min(1.0, (punch * 0.70 + midEnergy * 0.20 + highEnergy * 0.10) * glowSens); + const targetGlowNorm = Math.pow(rawGlowEnergy, Math.max(0.4, dynamism)); - // Attack & release smoothing + // Scale smoothing (instant snap & decay for speaker thump) const cfgAttack = cfg.attackSpeed !== undefined ? cfg.attackSpeed : 0.85; const cfgRelease = cfg.releaseSpeed !== undefined ? cfg.releaseSpeed : 0.16; const attackSpeed = targetScale > smoothScale ? cfgAttack : cfgRelease; smoothScale += (targetScale - smoothScale) * attackSpeed; - smoothGlow += (targetGlow - smoothGlow) * attackSpeed; + + // Dedicated glow smoothing (butter-smooth swell & organic dissipation) + const smoothFactor = cfg.glowSmoothness !== undefined ? Number(cfg.glowSmoothness) : 0.70; + const speedScale = Math.max(0.08, 1.0 - smoothFactor * 0.70); + const glowAttack = (cfg.glowAttack !== undefined ? Number(cfg.glowAttack) : 0.85) * speedScale; + const glowRelease = (cfg.glowDecay !== undefined ? Number(cfg.glowDecay) : (cfg.glowRelease !== undefined ? Number(cfg.glowRelease) : 0.35)) * speedScale; + const glowSpeed = targetGlowNorm > smoothGlow ? glowAttack : glowRelease; + smoothGlow += (targetGlowNorm - smoothGlow) * Math.min(1.0, Math.max(0.01, glowSpeed)); // Rhythmic tilt based on balance const tilt = (midEnergy - kickEnergy * 0.6) * 18; if (noteIcon) { - noteIcon.style.transform = `scale(${smoothScale.toFixed(3)}) rotate(${tilt.toFixed(2)}deg)`; - noteIcon.style.filter = `drop-shadow(0 0 ${Math.round(14 + smoothGlow * 0.5)}px rgba(var(--accent-rgb, 153, 255, 0), ${(0.45 + (smoothScale - 1) * 1.5).toFixed(2)}))`; + const notePct = Math.min(100, Math.round((0.55 + (smoothScale - 1) * 1.5) * 100)); + if (Number(cfg.enableBeatHue) === 1) { + noteIcon.style.color = accent; + } + if (coverCircle && coverCircle.contains(noteIcon)) { + // Note is inside the eye circle; inherits scale from circle, add tilt & glow + noteIcon.style.transform = `rotate(${tilt.toFixed(2)}deg)`; + noteIcon.style.filter = `drop-shadow(0 0 ${Math.round(12 + smoothGlow * 20)}px color-mix(in srgb, ${accent} ${notePct}%, transparent))`; + } else { + noteIcon.style.transform = `scale(${smoothScale.toFixed(3)}) rotate(${tilt.toFixed(2)}deg)`; + noteIcon.style.filter = `drop-shadow(0 0 ${Math.round(14 + smoothGlow * 25)}px color-mix(in srgb, ${accent} ${notePct}%, transparent))`; + } } if (coverCircle) { - // Direct scale bounce: the disc visibly thumps like a speaker cone + coverCircle._visualizerDriving = true; const circleScale = smoothScale; - const glow1 = Math.round(16 + smoothGlow * 1.1); - const glow2 = Math.round(38 + smoothGlow * 2.3); - const glow3 = Math.round(75 + smoothGlow * 3.6); - const alpha1 = Math.min(0.98, (0.42 + (smoothScale - 1) * 2.4)).toFixed(2); - const alpha2 = Math.min(0.85, (0.24 + (smoothScale - 1) * 1.8)).toFixed(2); - const alpha3 = Math.min(0.60, (0.10 + (smoothScale - 1) * 1.2)).toFixed(2); - coverCircle.style.transform = `translate(-50%, -50%) scale(${circleScale.toFixed(3)})`; - coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px rgba(var(--accent-rgb, 153, 255, 0), ${alpha1}), 0 0 ${glow2}px rgba(var(--accent-rgb, 153, 255, 0), ${alpha2}), 0 0 ${glow3}px rgba(var(--accent-rgb, 153, 255, 0), ${alpha3}), inset 0 0 20px rgba(0, 0, 0, 0.65)`; - coverCircle.style.borderColor = `rgba(var(--accent-rgb, 153, 255, 0), ${(0.42 + (smoothScale - 1) * 2.0).toFixed(2)})`; + const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; + const glowReach = cfg.glowIntensity !== undefined ? cfg.glowIntensity : 250; + const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; + + // Dynamic glow spread: smoothGlow expands outward from resting base to dynamic reach + const dynSpread = smoothGlow * glowReach; + const glow1 = Math.round(baseGlow * 0.20 + dynSpread * 0.18); + const glow2 = Math.round(baseGlow * 0.50 + dynSpread * 0.50); + const glow3 = Math.round(baseGlow * 1.00 + dynSpread * 0.95); + const glow4 = Math.round(baseGlow * 1.60 + dynSpread * 1.55); + + // Continuous breathing opacities with high dynamic contrast + const bMult = Math.min(1.4, Math.max(0.2, glowMult * 0.35)); + const pct1 = Math.min(100, Math.max(0, Math.round((0.36 + smoothGlow * 0.64) * bMult * 100))); + const pct2 = Math.min(100, Math.max(0, Math.round((0.22 + smoothGlow * 0.72) * bMult * 100))); + const pct3 = Math.min(100, Math.max(0, Math.round((0.10 + smoothGlow * 0.68) * bMult * 100))); + const pct4 = Math.min(100, Math.max(0, Math.round((0.03 + smoothGlow * 0.55) * bMult * 100))); + const ringOp = cfg.outerRingOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.outerRingOpacity))) : 1.0; + const borderPct = Math.min(100, Math.max(0, Math.round((0.35 + smoothGlow * 0.65) * 100 * ringOp))); + + const curX = (coverCircle._eyeX || 0).toFixed(1); + const curY = (coverCircle._eyeY || 0).toFixed(1); + const curTiltX = (coverCircle._eyeTiltX || 0).toFixed(1); + const curTiltY = (coverCircle._eyeTiltY || 0).toFixed(1); + coverCircle.style.transform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(600px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) scale(${circleScale.toFixed(3)})`; + if (glowMult <= 0) { + coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9)`; + } else { + coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px color-mix(in srgb, ${accent} ${pct1}%, transparent), 0 0 ${glow2}px color-mix(in srgb, ${accent} ${pct2}%, transparent), 0 0 ${glow3}px color-mix(in srgb, ${accent} ${pct3}%, transparent), 0 0 ${glow4}px color-mix(in srgb, ${accent} ${pct4}%, transparent)`; + } + if (ringOp <= 0.001) { + coverCircle.style.borderColor = 'transparent'; + } else { + coverCircle.style.borderColor = `color-mix(in srgb, ${accent} ${borderPct}%, transparent)`; + } + + // Dynamic Background Gradient Layer Rendering + if (bgEl) { + const enableBg = Number(cfg.enableBgGradient !== undefined ? cfg.enableBgGradient : 1) === 1; + if (!enableBg || audioElement.paused) { + bgEl.style.opacity = '0'; + } else { + const baseOp = cfg.bgGradientOpacity !== undefined ? Number(cfg.bgGradientOpacity) : 0.40; + const reactMult = cfg.bgGradientReactivity !== undefined ? Number(cfg.bgGradientReactivity) : 1.20; + const spreadPct = cfg.bgGradientSpread !== undefined ? Number(cfg.bgGradientSpread) : 75; + const styleMode = cfg.bgGradientStyle !== undefined ? Number(cfg.bgGradientStyle) : 1; + const bgCol = (Number(cfg.useCustomBgColor) === 1 && cfg.bgGradientColor) ? cfg.bgGradientColor : accent; + + const pulse = Math.min(1.0, (smoothGlow * 0.70 + kickEnergy * 0.30) * reactMult); + const dynamicOp = Math.min(1.0, Math.max(0.0, baseOp + pulse * (1.0 - baseOp * 0.4))); + bgEl.style.opacity = dynamicOp.toFixed(3); + + const cX = coverCircle && coverCircle._eyeX ? Number(coverCircle._eyeX) : 0; + const cY = coverCircle && coverCircle._eyeY ? Number(coverCircle._eyeY) : 0; + const focalX = `calc(50% + ${(cX * 0.5).toFixed(1)}px)`; + const focalY = `calc(50% + ${(cY * 0.5).toFixed(1)}px)`; + const dynamicSpread = Math.round(spreadPct * (1.0 + pulse * 0.45)); + + if (styleMode === 1) { + // Style 1: Radial Beat Aura (breathing spherical bloom from behind disc) + const corePct = Math.min(100, Math.round(25 + pulse * 45)); + const midPct = Math.min(100, Math.round(10 + pulse * 30)); + bgEl.style.background = `radial-gradient(circle at ${focalX} ${focalY}, color-mix(in srgb, ${bgCol} ${corePct}%, transparent) 0%, color-mix(in srgb, ${bgCol} ${midPct}%, transparent) ${Math.round(dynamicSpread * 0.5)}%, transparent ${dynamicSpread}%)`; + } else if (styleMode === 2) { + // Style 2: Dual Atmospheric Fog (cosmic corners with reactive dark center) + const edgePct = Math.min(100, Math.round(15 + pulse * 50)); + bgEl.style.background = `radial-gradient(ellipse at 50% 120%, color-mix(in srgb, ${bgCol} ${edgePct}%, transparent) 0%, transparent 65%), radial-gradient(ellipse at 50% -20%, color-mix(in srgb, ${bgCol} ${Math.round(edgePct * 0.7)}%, transparent) 0%, transparent 60%)`; + } else if (styleMode === 3) { + // Style 3: Conic Sweep / Stargate Vortex + const rotAngle = Math.round(((performance.now() * 0.04) % 360)); + const corePct = Math.min(100, Math.round(18 + pulse * 42)); + bgEl.style.background = `conic-gradient(from ${rotAngle}deg at ${focalX} ${focalY}, transparent 0deg, color-mix(in srgb, ${bgCol} ${corePct}%, transparent) 90deg, transparent 180deg, color-mix(in srgb, ${bgCol} ${corePct}%, transparent) 270deg, transparent 360deg)`; + } else if (styleMode === 4) { + // Style 4: Horizontal Horizon Flare (cinematic anamorphic flare beam) + const beamHeight = Math.max(8, Math.round(18 + pulse * 35)); + const beamPct = Math.min(100, Math.round(25 + pulse * 55)); + bgEl.style.background = `radial-gradient(ellipse 90% ${beamHeight}% at ${focalX} ${focalY}, color-mix(in srgb, ${bgCol} ${beamPct}%, transparent) 0%, color-mix(in srgb, ${bgCol} ${Math.round(beamPct * 0.35)}%, transparent) 55%, transparent 85%)`; + } + } + } + + // Inner Eye Visualizer Rendering + const innerCanvas = coverCircle.querySelector('.audio-eye-inner-canvas'); + if (innerCanvas) { + const rMax = Math.max(30, Math.min(800, cfg.innerRadius !== undefined ? Number(cfg.innerRadius) : (cfg.innerSize !== undefined ? Number(cfg.innerSize) : 150))); + const rMin = Math.max(0, Math.min(rMax - 6, cfg.innerPupilRadius !== undefined ? Number(cfg.innerPupilRadius) : 0)); + + const targetDim = Math.max(300, Math.round(rMax * 2 + 20)); + if (innerCanvas.width !== targetDim || innerCanvas.height !== targetDim) { + innerCanvas.width = targetDim; + innerCanvas.height = targetDim; + } + + const currentCoverSize = Math.max(40, cfg.coverSize || 110); + const canvasDisplaySize = Math.round(currentCoverSize * (rMax / 150)); + const displayPx = canvasDisplaySize + 'px'; + if (innerCanvas.style.width !== displayPx) { + innerCanvas.style.width = displayPx; + innerCanvas.style.height = displayPx; + } + + const inCtx = innerCanvas.getContext('2d'); + inCtx.clearRect(0, 0, innerCanvas.width, innerCanvas.height); + + const showInner = cfg.enableInnerBars !== undefined ? Number(cfg.enableInnerBars) === 1 : true; + if (showInner && !audioElement.paused) { + const mode = cfg.innerBarsMode !== undefined ? Number(cfg.innerBarsMode) : 1; + const iGlow = cfg.innerBarGlow !== undefined ? cfg.innerBarGlow : 12; + const iHMult = cfg.innerBarHeight !== undefined ? cfg.innerBarHeight : 1.0; + const iBW = Math.max(1, cfg.innerBarWidth !== undefined ? cfg.innerBarWidth : 6); + const cw = innerCanvas.width; + const ch = innerCanvas.height; + const cx = cw / 2; + const cy = ch / 2; + + const innerAccent = (Number(cfg.useCustomInnerColor) === 1 && cfg.innerBarColor) ? cfg.innerBarColor : accent; + if (iGlow > 0) { + inCtx.shadowBlur = iGlow; + inCtx.shadowColor = innerAccent; + } else { + inCtx.shadowBlur = 0; + } + inCtx.fillStyle = innerAccent; + inCtx.strokeStyle = innerAccent; + const inOpacity = cfg.innerBarOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.innerBarOpacity))) : 1.0; + inCtx.globalAlpha = inOpacity; + const pupilRingOp = cfg.innerPupilRingOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.innerPupilRingOpacity))) : (cfg.outerRingOpacity !== undefined ? Number(cfg.outerRingOpacity) : 0); + + const numInnerBars = Math.max(2, Math.min(512, cfg.innerBarCount !== undefined ? Number(cfg.innerBarCount) : 32)); + const hBoost = cfg.innerHighBoost !== undefined ? Number(cfg.innerHighBoost) : 0.1; + const sRate = (audioCtx && audioCtx.sampleRate) || 44100; + const binHz = sRate / 2048; // ~21.5 Hz per FFT bin + const fMin = 28; // Deep Sub-Bass + const fMax = 15000; // Crisp Air & Sizzle + + const maxLen = (rMax - rMin) * iHMult; + const rotDeg = cfg.innerRadialRotation !== undefined ? Number(cfg.innerRadialRotation) : 90; + const rotRad = (rotDeg * Math.PI) / 180; + + if (mode === 1) { + // Mode 1: Mirrored Full-Spectrum Horizon Equalizer across eye disc + const spanW = Math.max(40, Math.round(rMax * 1.74)); + const barGap = Math.max(2, Math.floor((spanW - numInnerBars * iBW) / Math.max(1, numInnerBars - 1))); + const totalW = numInnerBars * iBW + (numInnerBars - 1) * barGap; + const startX = (cw - totalW) / 2; + + for (let i = 0; i < numInnerBars; i++) { + const normIdx = i / Math.max(1, numInnerBars - 1); + // Logarithmic musical semitone / 1/3-octave band sampling + const f1 = fMin * Math.pow(fMax / fMin, i / numInnerBars); + const f2 = fMin * Math.pow(fMax / fMin, (i + 1) / numInnerBars); + const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); + const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); + + let bMax = 0; + let bSum = 0; + let bCnt = 0; + for (let b = sBin; b <= eBin; b++) { + const val = data[b] || 0; + if (val > bMax) bMax = val; + bSum += val; + bCnt++; + } + const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); + + // Equal-energy pink noise compensation (allows melody, keys, vocals, and hi-hats to dance) + const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(normIdx, 0.72) * (hBoost * 3.5)); + const bVal = Math.min(255, rawVal * tilt); + const bPct = bVal / 255; + + const x = startX + i * (iBW + barGap); + const barCenterX = x + iBW / 2; + const distX = Math.abs(barCenterX - cx); + const maxInsideCircle = distX < (rMax - 2) ? Math.sqrt(Math.max(0, (rMax - 2) * (rMax - 2) - distX * distX)) * 2 - 12 : 24; + const maxH = Math.min(maxInsideCircle, (rMax - 5) * iHMult); + const barH = Math.max(4, bPct * maxH); + const y = cy - barH / 2; + const radius = Math.min(iBW / 2, barH / 2); + + inCtx.beginPath(); + if (typeof inCtx.roundRect === 'function') { + inCtx.roundRect(x, y, iBW, barH, [radius, radius, radius, radius]); + } else { + inCtx.rect(x, y, iBW, barH); + } + inCtx.fill(); + } + } else if (mode === 2) { + // Mode 2: 360° Full-Spectrum Outward Radial Iris + const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); + + for (let i = 0; i < numSpokes; i++) { + const angle = (i / numSpokes) * Math.PI * 2 + rotRad; + // Symmetrical musical octave distribution around iris circle + const sym = 1 - Math.abs((i / numSpokes) * 2 - 1); + const f1 = fMin * Math.pow(fMax / fMin, sym); + const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); + const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); + const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); + + let bMax = 0; + let bSum = 0; + let bCnt = 0; + for (let b = sBin; b <= eBin; b++) { + const val = data[b] || 0; + if (val > bMax) bMax = val; + bSum += val; + bCnt++; + } + const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); + const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); + const bVal = Math.min(255, rawVal * tilt); + const len = Math.max(3, (bVal / 255) * maxLen); + + const x1 = cx + Math.cos(angle) * rMin; + const y1 = cy + Math.sin(angle) * rMin; + const x2 = cx + Math.cos(angle) * (rMin + len); + const y2 = cy + Math.sin(angle) * (rMin + len); + + inCtx.beginPath(); + inCtx.lineWidth = Math.max(1, iBW); + inCtx.lineCap = 'round'; + inCtx.moveTo(x1, y1); + inCtx.lineTo(x2, y2); + inCtx.stroke(); + } + + if (pupilRingOp > 0.001 && rMin > 3) { + inCtx.save(); + inCtx.globalAlpha = inOpacity * pupilRingOp; + inCtx.beginPath(); + inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); + inCtx.lineWidth = 1.5; + inCtx.stroke(); + inCtx.restore(); + } + } else if (mode === 3) { + // Mode 3: Bottom Arc Full-Spectrum Equalizer + const spanW = Math.max(40, Math.round(rMax * 1.74)); + const barGap = Math.max(2, Math.floor((spanW - numInnerBars * iBW) / Math.max(1, numInnerBars - 1))); + const totalW = numInnerBars * iBW + (numInnerBars - 1) * barGap; + const startX = (cw - totalW) / 2; + const baseY = cy + rMax * 0.75; + + for (let i = 0; i < numInnerBars; i++) { + const normIdx = i / Math.max(1, numInnerBars - 1); + const f1 = fMin * Math.pow(fMax / fMin, i / numInnerBars); + const f2 = fMin * Math.pow(fMax / fMin, (i + 1) / numInnerBars); + const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); + const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); + + let bMax = 0; + let bSum = 0; + let bCnt = 0; + for (let b = sBin; b <= eBin; b++) { + const val = data[b] || 0; + if (val > bMax) bMax = val; + bSum += val; + bCnt++; + } + const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); + const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(normIdx, 0.72) * (hBoost * 3.5)); + const bVal = Math.min(255, rawVal * tilt); + const bPct = bVal / 255; + + const x = startX + i * (iBW + barGap); + const barCenterX = x + iBW / 2; + const distX = Math.abs(barCenterX - cx); + const maxInside = distX < (rMax - 5) ? Math.sqrt(Math.max(0, (rMax - 5) * (rMax - 5) - distX * distX)) : 20; + const maxH = Math.min(maxInside + 25, rMax * iHMult); + const barH = Math.max(4, bPct * maxH); + const y = baseY - barH; + const radius = Math.min(iBW / 2, barH / 2); + + inCtx.beginPath(); + if (typeof inCtx.roundRect === 'function') { + inCtx.roundRect(x, y, iBW, barH, [radius, radius, 0, 0]); + } else { + inCtx.rect(x, y, iBW, barH); + } + inCtx.fill(); + } + } else if (mode === 4) { + // Mode 4: 360° Inverted Inward Radial Iris (spikes fire inward from outer rim) + const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); + + for (let i = 0; i < numSpokes; i++) { + const angle = (i / numSpokes) * Math.PI * 2 + rotRad; + const sym = 1 - Math.abs((i / numSpokes) * 2 - 1); + const f1 = fMin * Math.pow(fMax / fMin, sym); + const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); + const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); + const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); + + let bMax = 0, bSum = 0, bCnt = 0; + for (let b = sBin; b <= eBin; b++) { + const val = data[b] || 0; + if (val > bMax) bMax = val; + bSum += val; + bCnt++; + } + const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); + const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); + const bVal = Math.min(255, rawVal * tilt); + const len = Math.max(3, (bVal / 255) * maxLen); + + const x1 = cx + Math.cos(angle) * rMax; + const y1 = cy + Math.sin(angle) * rMax; + const x2 = cx + Math.cos(angle) * (rMax - len); + const y2 = cy + Math.sin(angle) * (rMax - len); + + inCtx.beginPath(); + inCtx.lineWidth = Math.max(1, iBW); + inCtx.lineCap = 'round'; + inCtx.moveTo(x1, y1); + inCtx.lineTo(x2, y2); + inCtx.stroke(); + } + + // Outer glowing rim ring + if (ringOp > 0.001) { + inCtx.save(); + inCtx.globalAlpha = inOpacity * ringOp; + inCtx.beginPath(); + inCtx.arc(cx, cy, rMax, 0, Math.PI * 2); + inCtx.lineWidth = 1.5; + inCtx.stroke(); + inCtx.restore(); + } + + if (pupilRingOp > 0.001 && rMin > 3) { + inCtx.save(); + inCtx.globalAlpha = inOpacity * pupilRingOp; + inCtx.beginPath(); + inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); + inCtx.lineWidth = 1.5; + inCtx.stroke(); + inCtx.restore(); + } + } else if (mode === 5) { + // Mode 5: Reversed Inverted Radial Iris (inward spikes + reversed frequency order) + const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); + + for (let i = 0; i < numSpokes; i++) { + const angle = (i / numSpokes) * Math.PI * 2 + rotRad; + // Inverted / reversed frequency symmetry + const sym = Math.abs((i / numSpokes) * 2 - 1); + const f1 = fMin * Math.pow(fMax / fMin, sym); + const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); + const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); + const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); + + let bMax = 0, bSum = 0, bCnt = 0; + for (let b = sBin; b <= eBin; b++) { + const val = data[b] || 0; + if (val > bMax) bMax = val; + bSum += val; + bCnt++; + } + const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); + const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); + const bVal = Math.min(255, rawVal * tilt); + const len = Math.max(3, (bVal / 255) * maxLen); + + const x1 = cx + Math.cos(angle) * rMax; + const y1 = cy + Math.sin(angle) * rMax; + const x2 = cx + Math.cos(angle) * (rMax - len); + const y2 = cy + Math.sin(angle) * (rMax - len); + + inCtx.beginPath(); + inCtx.lineWidth = Math.max(1, iBW); + inCtx.lineCap = 'round'; + inCtx.moveTo(x1, y1); + inCtx.lineTo(x2, y2); + inCtx.stroke(); + } + + // Outer glowing rim ring & inner pupil ring + if (ringOp > 0.001) { + inCtx.save(); + inCtx.globalAlpha = inOpacity * ringOp; + inCtx.beginPath(); + inCtx.arc(cx, cy, rMax, 0, Math.PI * 2); + inCtx.lineWidth = 1.5; + inCtx.stroke(); + inCtx.restore(); + } + + if (pupilRingOp > 0.001 && rMin > 3) { + inCtx.save(); + inCtx.globalAlpha = inOpacity * pupilRingOp; + inCtx.beginPath(); + inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); + inCtx.lineWidth = 1.5; + inCtx.stroke(); + inCtx.restore(); + } + } else if (mode === 6) { + // Mode 6: Dual Bi-Directional Stargate Iris (spikes pulse inward from rim AND outward from pupil!) + const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); + const halfLen = maxLen * 0.52; + + for (let i = 0; i < numSpokes; i++) { + const angle = (i / numSpokes) * Math.PI * 2 + rotRad; + const sym = 1 - Math.abs((i / numSpokes) * 2 - 1); + const f1 = fMin * Math.pow(fMax / fMin, sym); + const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); + const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); + const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); + + let bMax = 0, bSum = 0, bCnt = 0; + for (let b = sBin; b <= eBin; b++) { + const val = data[b] || 0; + if (val > bMax) bMax = val; + bSum += val; + bCnt++; + } + const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); + const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); + const bVal = Math.min(255, rawVal * tilt); + const len = Math.max(3, (bVal / 255) * halfLen); + + inCtx.beginPath(); + inCtx.lineWidth = Math.max(1, iBW); + inCtx.lineCap = 'round'; + // Outward spike from pupil + inCtx.moveTo(cx + Math.cos(angle) * rMin, cy + Math.sin(angle) * rMin); + inCtx.lineTo(cx + Math.cos(angle) * (rMin + len), cy + Math.sin(angle) * (rMin + len)); + // Inward spike from outer rim + inCtx.moveTo(cx + Math.cos(angle) * rMax, cy + Math.sin(angle) * rMax); + inCtx.lineTo(cx + Math.cos(angle) * (rMax - len), cy + Math.sin(angle) * (rMax - len)); + inCtx.stroke(); + } + + if (pupilRingOp > 0.001 && rMin > 3) { + inCtx.save(); + inCtx.globalAlpha = inOpacity * pupilRingOp; + inCtx.beginPath(); + inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); + inCtx.lineWidth = 1.5; + inCtx.stroke(); + inCtx.restore(); + } + + if (ringOp > 0.001) { + inCtx.save(); + inCtx.globalAlpha = inOpacity * ringOp; + inCtx.beginPath(); + inCtx.arc(cx, cy, rMax, 0, Math.PI * 2); + inCtx.lineWidth = 1.5; + inCtx.stroke(); + inCtx.restore(); + } + } else if (mode === 7) { + // Mode 7: 360° Continuous Radar Iris (seamless full-circle chromatic spectrum) + const numSpokes = Math.max(4, Math.min(1024, numInnerBars * 2)); + + for (let i = 0; i < numSpokes; i++) { + const angle = (i / numSpokes) * Math.PI * 2 + rotRad; + const sym = i / numSpokes; + const f1 = fMin * Math.pow(fMax / fMin, sym); + const f2 = fMin * Math.pow(fMax / fMin, Math.min(1.0, sym + 1.25 / numSpokes)); + const sBin = Math.max(1, Math.min(count - 1, Math.floor(f1 / binHz))); + const eBin = Math.max(sBin, Math.min(count - 1, Math.ceil(f2 / binHz))); + + let bMax = 0, bSum = 0, bCnt = 0; + for (let b = sBin; b <= eBin; b++) { + const val = data[b] || 0; + if (val > bMax) bMax = val; + bSum += val; + bCnt++; + } + const rawVal = bCnt > 0 ? (bMax * 0.70 + (bSum / bCnt) * 0.30) : (data[sBin] || 0); + const tilt = Math.max(0.05, Math.min(1.0, hBoost) + Math.pow(sym, 0.72) * (hBoost * 3.5)); + const bVal = Math.min(255, rawVal * tilt); + const len = Math.max(3, (bVal / 255) * maxLen); + + const x1 = cx + Math.cos(angle) * rMin; + const y1 = cy + Math.sin(angle) * rMin; + const x2 = cx + Math.cos(angle) * (rMin + len); + const y2 = cy + Math.sin(angle) * (rMin + len); + + inCtx.beginPath(); + inCtx.lineWidth = Math.max(1, iBW); + inCtx.lineCap = 'round'; + inCtx.moveTo(x1, y1); + inCtx.lineTo(x2, y2); + inCtx.stroke(); + } + + if (pupilRingOp > 0.001 && rMin > 3) { + inCtx.save(); + inCtx.globalAlpha = inOpacity * pupilRingOp; + inCtx.beginPath(); + inCtx.arc(cx, cy, rMin - 2, 0, Math.PI * 2); + inCtx.lineWidth = 1.5; + inCtx.stroke(); + inCtx.restore(); + } + } + inCtx.shadowBlur = 0; + inCtx.globalAlpha = 1.0; + } + } } } else { // Decay smoothly back to neutral state when paused or idle - if (smoothScale > 1.002) { + if (bgEl) { + bgEl.style.opacity = '0'; + } + if (coverCircle) { + const inC = coverCircle.querySelector('.audio-eye-inner-canvas'); + if (inC) { + const inCtx = inC.getContext('2d'); + if (inCtx) inCtx.clearRect(0, 0, inC.width, inC.height); + } + } + if (smoothScale > 1.002 || smoothGlow > 0.005) { smoothScale += (1 - smoothScale) * 0.16; + smoothGlow += (0 - smoothGlow) * 0.09; if (noteIcon) { - noteIcon.style.transform = `scale(${smoothScale.toFixed(3)})`; - noteIcon.style.filter = ''; + if (coverCircle && coverCircle.contains(noteIcon)) { + noteIcon.style.transform = ''; + noteIcon.style.filter = ''; + } else { + noteIcon.style.transform = `scale(${smoothScale.toFixed(3)})`; + noteIcon.style.filter = ''; + } } if (coverCircle) { - coverCircle.style.transform = `translate(-50%, -50%) scale(${smoothScale.toFixed(3)})`; + const curX = (coverCircle._eyeX || 0).toFixed(1); + const curY = (coverCircle._eyeY || 0).toFixed(1); + const curTiltX = (coverCircle._eyeTiltX || 0).toFixed(1); + const curTiltY = (coverCircle._eyeTiltY || 0).toFixed(1); + coverCircle.style.transform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(600px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) scale(${smoothScale.toFixed(3)})`; + const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; + const glowReach = cfg.glowIntensity !== undefined ? cfg.glowIntensity : 250; + const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; + const dynSpread = smoothGlow * glowReach; + const glow1 = Math.round(baseGlow * 0.20 + dynSpread * 0.18); + const glow2 = Math.round(baseGlow * 0.50 + dynSpread * 0.50); + const glow3 = Math.round(baseGlow * 1.00 + dynSpread * 0.95); + const bMult = Math.min(1.4, Math.max(0.2, glowMult * 0.35)); + const pct1 = Math.min(100, Math.max(0, Math.round((0.36 + smoothGlow * 0.64) * bMult * 100))); + const pct2 = Math.min(100, Math.max(0, Math.round((0.22 + smoothGlow * 0.72) * bMult * 100))); + const pct3 = Math.min(100, Math.max(0, Math.round((0.10 + smoothGlow * 0.68) * bMult * 100))); + if (glowMult <= 0) { + coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9)`; + } else { + coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px color-mix(in srgb, ${accent} ${pct1}%, transparent), 0 0 ${glow2}px color-mix(in srgb, ${accent} ${pct2}%, transparent), 0 0 ${glow3}px color-mix(in srgb, ${accent} ${pct3}%, transparent)`; + } } - } else if (smoothScale !== 1) { + } else if (smoothScale !== 1 || smoothGlow !== 0) { smoothScale = 1; + smoothGlow = 0; if (noteIcon) { noteIcon.style.transform = ''; noteIcon.style.filter = ''; } if (coverCircle) { - coverCircle.style.transform = 'translate(-50%, -50%)'; - coverCircle.style.boxShadow = ''; - coverCircle.style.borderColor = ''; + coverCircle._visualizerDriving = false; + const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; + const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; + if (glowMult <= 0) { + coverCircle.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88)`; + } else { + coverCircle.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88), 0 0 ${baseGlow}px color-mix(in srgb, ${accent} ${Math.round(28 * Math.min(1.5, glowMult))}%, transparent)`; + } + const ringOp = cfg.outerRingOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.outerRingOpacity))) : 1.0; + if (ringOp <= 0.001) { + coverCircle.style.borderColor = 'transparent'; + } else { + coverCircle.style.borderColor = `color-mix(in srgb, ${accent} ${Math.round(40 * ringOp)}%, transparent)`; + } } + } else if (coverCircle) { + coverCircle._visualizerDriving = false; } } } @@ -4026,18 +6067,22 @@ window.cancelAnimFrame = (function () { visualizerRafId = requestAnimationFrame(loopingFunction); const resumeAudio = () => { - if (audioCtx && audioCtx.state === 'suspended') { - audioCtx.resume(); + setupAudioSource(); + if (audioCtx && audioCtx.state !== 'running') { + audioCtx.resume().catch(() => {}); } }; audioElement.addEventListener('play', resumeAudio); audioElement.addEventListener('playing', resumeAudio); - const playerWrap = audioElement.closest('.v0ck') || audioElement.parentElement; + const playerWrap = audioElement.closest('.v0ck, .album-audio-wrapper, .embed-responsive') || audioElement.parentElement; if (playerWrap) { playerWrap.addEventListener('click', resumeAudio, { passive: true }); playerWrap.addEventListener('pointerdown', resumeAudio, { passive: true }); } + ['click', 'pointerdown', 'keydown', 'touchstart'].forEach(evt => { + window.addEventListener(evt, resumeAudio, { passive: true }); + }); } }; @@ -5792,7 +7837,7 @@ window.cancelAnimFrame = (function () { const params = new URLSearchParams(); params.append('mode', window.activeMode); const targetHash = new URL(url, window.location.origin).hash.replace(/^#/, '').trim(); - if (targetHash) params.append('subf0ck', targetHash); + if (targetHash && !targetHash.startsWith('c')) params.append('subf0ck', targetHash); if (tag) params.append('tag', tag); if (hall) params.append('hall', hall); if (userHall && userHallOwner) { @@ -6592,27 +8637,69 @@ window.cancelAnimFrame = (function () { const targetUrlBase = targetUrl.split('#')[0]; const hash = anyLink.hash; - // If it's a same-page hash link, just jump and don't AJAX reload - if (targetUrlBase === currentUrl && hash) { + // Check if it's a same-item / same-page hash link + const isCurrentItem = isItemPath(window.location.href); + const isTargetItem = isItemPath(targetUrl); + const getItemKey = (u) => { + try { + const p = new URL(u, window.location.origin).pathname; + const parts = p.split('/').filter(Boolean); + const keys = parts.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s)); + return keys.length ? keys[keys.length - 1] : null; + } catch { + return null; + } + }; + const currentItemKey = isCurrentItem ? getItemKey(window.location.href) : null; + const targetItemKey = isTargetItem ? getItemKey(targetUrl) : null; + const isSameItem = isCurrentItem && isTargetItem && currentItemKey && currentItemKey === targetItemKey; + const isSamePageHash = (targetUrlBase === currentUrl) || isSameItem; + + // If it's a same-page hash link, just jump / switch subf0ck and don't AJAX reload + if (isSamePageHash && hash) { e.preventDefault(); + let handled = false; // If it's a comment link, use the robust CommentSystem logic if (hash.startsWith('#c') && window.commentSystem) { const id = hash.substring(2); window.commentSystem.scrollToComment(id); + handled = true; } else { - const targetEl = document.querySelector(hash); - if (targetEl) { - targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); - // Clear any previous highlight, then permanently mark this element - document.querySelectorAll('.anchor-target').forEach(el => el.classList.remove('anchor-target')); - targetEl.classList.add('anchor-target'); + // If it's an album / subf0ck link + const subKey = hash.replace(/^#/, '').trim(); + if (window._currentActiveAlbumGallery && typeof window._currentActiveAlbumGallery.showBySlug === 'function') { + handled = window._currentActiveAlbumGallery.showBySlug(subKey); + if (handled) { + const mediaContainer = document.querySelector('.content, .album-container, .v0ck, #my-video, .main-image'); + if (mediaContainer) { + mediaContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + } + + if (!handled) { + try { + const targetEl = document.querySelector(hash); + if (targetEl) { + targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Clear any previous highlight, then permanently mark this element + document.querySelectorAll('.anchor-target').forEach(el => el.classList.remove('anchor-target')); + targetEl.classList.add('anchor-target'); + handled = true; + } + } catch (err) { + // Ignore invalid querySelector for non-CSS selectors + } } } - // Update URL without reload - history.replaceState(null, null, targetUrl); - return; + if (handled || targetUrlBase === currentUrl) { + // Update URL without reload + history.replaceState(null, null, targetUrl); + window.dispatchEvent(new Event('hashchange')); + return; + } } // Intercept /register and /login — handle entirely client-side @@ -9174,6 +11261,19 @@ window.cancelAnimFrame = (function () { swipeRT.yDiff = 0; swipeRT.isDraggingSidebar = false; swipeRT.startedInSidebarZone = false; + let inStrip = !!(e.target && e.target.closest('.album-thumbnails-strip')); + const stripEl = document.querySelector('.album-thumbnails-strip'); + if (!inStrip && stripEl && stripEl.offsetParent !== null) { + const str = stripEl.getBoundingClientRect(); + const touch0 = e.touches[0]; + if ( + touch0.clientX >= str.left && touch0.clientX <= str.right && + touch0.clientY >= str.top && touch0.clientY <= str.bottom + ) { + inStrip = true; + } + } + swipeRT.startedInStrip = inStrip; // Coordinate-based steuerung detection — more reliable than e.target.closest() on iOS Safari // where e.target can be misreported for touch events on complex DOM hierarchies @@ -9199,15 +11299,15 @@ window.cancelAnimFrame = (function () { // Mark as candidate — direction will be confirmed on first touchmove if (isHidden) { - // Only set sidebar candidate if NOT starting inside .steuerung - if (touch.clientX > window.innerWidth - 100 && !swipeRT.startedInSteuerung) { + // Only set sidebar candidate if NOT starting inside .steuerung or .album-thumbnails-strip + if (touch.clientX > window.innerWidth - 100 && !swipeRT.startedInSteuerung && !swipeRT.startedInStrip) { swipeRT.isSidebarCandidate = true; swipeRT.startedInSidebarZone = true; } } else { // Sidebar is open: candidate if touch starts inside sidebar bounding box // Use coordinates, not e.target — iOS Safari can mis-report target in scrollable containers - if (!swipeRT.startedInSteuerung) { + if (!swipeRT.startedInSteuerung && !swipeRT.startedInStrip) { const sr = sidebar.getBoundingClientRect(); if (touch.clientX >= sr.left && touch.clientX <= sr.right && touch.clientY >= sr.top && touch.clientY <= sr.bottom) { @@ -9223,6 +11323,7 @@ window.cancelAnimFrame = (function () { document.addEventListener('touchmove', e => { if (window.f0ckSession?.disable_swiping) return; if (document.body.classList.contains('modal-open')) return; + if (swipeRT.startedInStrip) return; if (!swipeRT.xDown || !swipeRT.yDown) return; const touch = e.touches[0]; @@ -9376,8 +11477,8 @@ window.cancelAnimFrame = (function () { } if (Math.abs(swipeRT.xDiff) > Math.abs(swipeRT.yDiff)) { - // Skip navigation if this touch started in the sidebar zone - if (!swipeRT.startedInSidebarZone) { + // Skip navigation if this touch started in the sidebar zone or album thumbnail strip + if (!swipeRT.startedInSidebarZone && !swipeRT.startedInStrip) { // On item pages with .steuerung, only navigate if swipe started inside it const steuerung = document.querySelector('.steuerung'); const allowNav = !steuerung || swipeRT.startedInSteuerung; @@ -12169,11 +14270,6 @@ class NotificationSystem { } handleActivity(data) { - // 1. Dispatch to global CommentSystem if active (for Item Page) - if (window.commentSystem && typeof window.commentSystem.handleLiveComment === 'function') { - window.commentSystem.handleLiveComment(data); - } - // Respect mode filter if (typeof window.activeMode !== 'undefined' && window.activeMode !== 3) { // 3 is ALL if (window.activeMode === 0 && data.tag_id !== 1) return; // SFW mode, item is not SFW @@ -12303,6 +14399,15 @@ class NotificationSystem { `; + } else if (c.mime.startsWith('audio')) { + if (c.has_coverart) { + mediaHtml = ``; + } else { + mediaHtml = ` + `; + } } else { mediaHtml = `
diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index 66c14ed..fa16b46 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -532,14 +532,32 @@ else if (rClass === 'sfw' && blurSfw) isBlurred = true; else if (rClass === 'untagged' && blurUntagged) isBlurred = true; - let thumbUrl = `/t/${c.item_id}.webp`; + const mime = c.mime || c.item_mime || ''; + const activeDest = c.item_dest || c.dest || ''; + const isAudio = mime.startsWith('audio/') || (!mime && activeDest.match(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i)); + + let thumbUrl = c.thumb || `/t/${c.item_id}.webp`; if (isBlurred) { - thumbUrl = `/t/${c.item_id}_blur.webp`; + const baseThumb = thumbUrl.replace(/\.webp$/, ''); + thumbUrl = `${baseThumb}_blur.webp`; } if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl); - mediaHtml = ``; + if (isAudio && !c.has_coverart) { + mediaHtml = ` + + `; + } else { + mediaHtml = ` + + + `; + } itemPreview = `
@@ -1869,6 +1887,7 @@ } } }; + window.switchSidebarTab = switchSidebarTab; const initSidebarTabs = () => { const tabsContainer = document.querySelector('.sidebar-tabs'); diff --git a/public/s/js/v0ck.js b/public/s/js/v0ck.js index 63f7b23..29d199b 100644 --- a/public/s/js/v0ck.js +++ b/public/s/js/v0ck.js @@ -88,6 +88,8 @@ const updateHoverStates = () => { const isOver = mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom; p.classList.toggle("v0ck_hover", isOver); + const gal = p.closest('.album-gallery-container'); + if (gal) gal.classList.toggle("v0ck_hover", isOver); }); }; @@ -117,6 +119,8 @@ class v0ck { if (mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom) { parent.classList.add("v0ck_hover", "v0ck_no_transition"); + const gal = parent.closest('.album-gallery-container'); + if (gal) gal.classList.add("v0ck_hover"); // Remove no-transition after a frame setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50); } @@ -142,14 +146,24 @@ class v0ck { if (!ph) { ph = document.createElement('div'); ph.className = 'sidebar-media-placeholder audio'; - ph.innerHTML = '
'; + ph.innerHTML = '
'; player.prepend(ph); } let coverCircle = ph.querySelector('.audio-cover-circle'); if (!coverCircle) { coverCircle = document.createElement('div'); coverCircle.className = 'audio-cover-circle'; + coverCircle.innerHTML = ''; ph.insertBefore(coverCircle, ph.firstChild); + } else { + if (!coverCircle.querySelector('i')) { + const existingI = ph.querySelector(':scope > i'); + if (existingI) { + coverCircle.appendChild(existingI); + } else { + coverCircle.insertAdjacentHTML('beforeend', ''); + } + } } player.style.backgroundImage = 'none'; player.style.backgroundColor = 'transparent'; @@ -157,7 +171,7 @@ class v0ck { coverCircle.style.backgroundImage = `url('${poster}')`; ph.classList.add('has-cover'); } else { - coverCircle.style.backgroundImage = 'none'; + coverCircle.style.backgroundImage = ''; ph.classList.remove('has-cover'); } } @@ -203,6 +217,23 @@ class v0ck { let wasPausedWhenStarted = false; // Mobile tap-to-show-controls: true when this touch revealed the controls bar let controlsJustShown = false; + + const setHover = (active) => { + if (active) { + player.classList.add('v0ck_hover'); + const gal = player.closest('.album-gallery-container'); + if (gal) gal.classList.add('v0ck_hover'); + } else { + player.classList.remove('v0ck_hover'); + const gal = player.closest('.album-gallery-container'); + if (gal) { + gal.classList.remove('v0ck_hover'); + gal.classList.remove('strip-peek'); + const strip = gal.querySelector('.album-thumbnails-strip'); + if (strip) strip.classList.remove('strip-peek'); + } + } + }; const speedIndicator = player.querySelector('.v0ck_speed_indicator'); // (mouse position is now tracked via docMouseX/docMouseY in resetControlsTimer block) @@ -343,7 +374,7 @@ class v0ck { if (isMobile && controlsJustShown) { // First tap: controls were just revealed by this touch — don't toggle play controlsJustShown = false; - player.classList.add('v0ck_hover'); + setHover(true); return; } controlsJustShown = false; @@ -353,7 +384,7 @@ class v0ck { toggle.addEventListener('click', togglePlay); overlay.addEventListener('click', e => { e.stopPropagation(); - player.classList.add('v0ck_hover'); + setHover(true); togglePlay(); }); video.addEventListener('play', updatePlayIcon); @@ -831,7 +862,7 @@ class v0ck { } if (isMobile && !isInsidePlayer && !isFlashYankUI) { - player.classList.remove('v0ck_hover'); + setHover(false); } }); @@ -928,7 +959,7 @@ class v0ck { const isFullscreen = player.classList.contains('v0ck_fullscreen'); if (!video.paused || isFullscreen) { controlsTimer = setTimeout(() => { - player.classList.remove('v0ck_hover'); + setHover(false); if (settingsMenu && !settingsMenu.classList.contains('v0ck_hidden')) { settingsMenu.classList.add('v0ck_hidden'); document.dispatchEvent(new CustomEvent('v0ck_settings_closed')); @@ -950,7 +981,7 @@ class v0ck { docMouseX = e.clientX; docMouseY = e.clientY; } - player.classList.add('v0ck_hover'); + setHover(true); resetControlsTimer(); } @@ -998,7 +1029,7 @@ class v0ck { } docMouseX = -1; docMouseY = -1; - player.classList.remove('v0ck_hover'); + setHover(false); clearTimeout(controlsTimer); }); diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs index cf5b46a..a923a6a 100644 --- a/src/inc/lib.mjs +++ b/src/inc/lib.mjs @@ -49,7 +49,18 @@ export default new class { return slug; } - formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) { + formatSize(size) { + if (size === undefined || size === null || size === '') return '0 B'; + if (typeof size === 'string') { + if (/^\d+(\.\d+)?\s*(B|kB|KB|MB|GB|TB)$/i.test(size.trim())) { + return size.trim(); + } + const num = Number(size); + if (isNaN(num)) return '0 B'; + size = num; + } + if (isNaN(size) || size <= 0) return '0 B'; + const i = Math.min(4, Math.max(0, ~~(Math.log(size) / Math.log(1024)))); return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]; }; calcSpeed(b, s) { diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index b4601c0..81b33d3 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -1492,6 +1492,16 @@ const f0cklib = { } } + let subSizeNum = r.size; + if (!subSizeNum || isNaN(subSizeNum) || Number(subSizeNum) <= 0) { + try { + const bFile = path.join(cfg.paths.b, r.dest); + if (fs.existsSync(bFile)) { + subSizeNum = fs.statSync(bFile).size; + } + } catch (_) {} + } + let subTags = tagsBySubId.get(r.id) || []; if (subTags.length === 0 && (r.order_index === 0 || idx === 0)) { subTags = tags; @@ -1505,7 +1515,7 @@ const f0cklib = { src: `${cfg.websrv.paths.images}/${r.dest}`, filename: r.dest, mime: r.mime, - size: lib.formatSize(r.size), + size: lib.formatSize(subSizeNum), width: r.width, height: r.height, checksum: r.checksum, diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 8922f95..71d27bc 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -348,6 +348,131 @@ export default router => { } }); + const audioMetaCache = new Map(); + + group.get(/\/audio-metadata$/, async (req, res) => { + try { + const rawSrc = req.url.qs?.src || req.url.qs?.file || ''; + const itemId = req.url.qs?.id || ''; + if (!rawSrc && !itemId) { + return res.json({ success: false, msg: 'Missing src or id' }, 400); + } + + let filename = ''; + if (rawSrc) { + filename = path.basename(rawSrc.split('?')[0]); + } + + if (filename && (filename.includes('/') || filename.includes('\\') || filename.includes('..'))) { + return res.json({ success: false, msg: 'Invalid filename' }, 400); + } + + const cacheKey = filename || `item_${itemId}`; + if (audioMetaCache.has(cacheKey)) { + return res.json(audioMetaCache.get(cacheKey)); + } + + let fullPath = filename ? path.join(cfg.paths.b, filename) : null; + let originalFilename = ''; + + if (itemId) { + const isNumeric = /^\d+$/.test(itemId); + const rows = isNumeric + ? await db`SELECT dest, original_filename, title FROM items WHERE id = ${itemId}` + : await db`SELECT dest, original_filename, title FROM items WHERE slug = ${itemId}`; + if (rows && rows[0]) { + if (!fullPath) fullPath = path.join(cfg.paths.b, rows[0].dest); + originalFilename = rows[0].original_filename || ''; + } else { + const subRows = isNumeric + ? await db`SELECT dest, original_filename FROM sub_items WHERE id = ${itemId}` + : await db`SELECT dest, original_filename FROM sub_items WHERE slug = ${itemId}`; + if (subRows && subRows[0]) { + if (!fullPath) fullPath = path.join(cfg.paths.b, subRows[0].dest); + originalFilename = subRows[0].original_filename || ''; + } + } + } + + if (!fullPath) { + return res.json({ success: false, msg: 'File not found' }, 404); + } + + try { + await fs.access(fullPath); + } catch { + return res.json({ success: false, msg: 'File not found on disk' }, 404); + } + + const metadata = await queue.getVideoMetadata(fullPath); + const tags = metadata || {}; + + // Normalize all tag keys to lowercase without punctuation (e.g. 'Title' -> 'title', 'ALBUM_ARTIST' -> 'albumartist') + const lowerTags = {}; + for (const [k, v] of Object.entries(tags)) { + if (v != null && v !== '') { + const normKey = k.toLowerCase().replace(/[-_\s]/g, ''); + lowerTags[normKey] = typeof v === 'string' ? v.trim() : String(v).trim(); + } + } + + let title = lowerTags.title || + lowerTags.tracktitle || + lowerTags.song || + lowerTags.songname || + lowerTags.name || + ''; + + let artist = lowerTags.artist || + lowerTags.albumartist || + lowerTags.author || + lowerTags.performer || + lowerTags.creator || + lowerTags.composer || + lowerTags.byline || + ''; + + let album = lowerTags.album || + lowerTags.albumtitle || + ''; + + let genre = lowerTags.genre || ''; + let date = lowerTags.date || lowerTags.year || ''; + + if (!title && !artist) { + const nameToParse = originalFilename || path.parse(fullPath).name; + const match = nameToParse.match(/^(.+?)\s*[-–—_]\s*(.+)$/); + if (match) { + artist = match[1].replace(/^[0-9]+[\s._-]+/, '').trim(); + title = match[2].trim(); + } else if (nameToParse && !nameToParse.startsWith('subf0ck') && nameToParse.length < 60) { + title = nameToParse.trim(); + } + } + + const result = { + success: true, + artist: String(artist || '').trim(), + title: String(title || '').trim(), + album: String(album || '').trim(), + genre: String(genre || '').trim(), + date: String(date || '').trim(), + has_meta: !!(artist || title) + }; + + if (audioMetaCache.size > 1000) { + const firstKey = audioMetaCache.keys().next().value; + audioMetaCache.delete(firstKey); + } + audioMetaCache.set(cacheKey, result); + + return res.json(result); + } catch (err) { + console.error('[API-V2-AUDIO-METADATA] Error:', err); + return res.json({ success: false, msg: 'Failed to extract audio metadata' }, 500); + } + }); + // F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching. // Guests may read from the cache (in-memory or DB); only authenticated users trigger real outbound fetches. group.get(/\/meta\/fetch$/, async (req, res) => { diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index aa31e7e..d10974a 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -624,6 +624,9 @@ export default (router, tpl) => { const itemQuery = await db` SELECT i.slug, + i.mime, + i.dest, + i.has_coverart, i.xd_score, COALESCE(i.visibility, 0) as visibility, (SELECT ta.tag_id FROM tags_assign ta @@ -684,6 +687,10 @@ export default (router, tpl) => { db.notify('activity', JSON.stringify({ user_id: req.session.id, item_id: item_id, + item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null, + mime: itemQuery[0]?.mime || null, + dest: itemQuery[0]?.dest || null, + has_coverart: !!itemQuery[0]?.has_coverart, type: 'comment', body: notifyBody, id: commentId, @@ -1104,6 +1111,7 @@ export default (router, tpl) => { i.id as item_id, i.slug as item_slug, i.dest as item_dest, + i.has_coverart, (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id, diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 10d6272..952403a 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -131,6 +131,9 @@ +
+ @elseif(item.mime.startsWith("audio")) -
+