The four overlays that read /data/playlist.js (loading, goodbye,
music-box, music/nc) only ever consumed `tracks.length` for cosmetic
"LIBRARY :: N TRACKS" / "indexed N tracks" flavor text — all live
playback signal flows through bridges/mpd-state.py over OBS WS. The
142 KB JSON, ffprobe walk, and ETag-cached route were all producing
one integer that MPD itself already knows.
RigData::playlistCount() now shells `mpc stats` and parses "Songs: N",
returning 0 on any failure (treated by callers as empty library).
Removes RigPlaylistCommand, DataController::playlist(), the
/data/playlist.js route, the per-overlay <script src> + window.__PLAYLIST
shims, and the stale storage/data/playlist.json artifact.
AudioController still consumes config('rig.music_dirs') for the HTTP
audio stream — that's orthogonal and stays.
521 lines
19 KiB
PHP
521 lines
19 KiB
PHP
@extends('layouts.overlay')
|
|
|
|
@section('title', 'NC-MUSIC-BOX')
|
|
|
|
@section('styles')
|
|
<style>
|
|
body.overlay-body {
|
|
display: grid;
|
|
grid-template-rows: auto 1fr auto;
|
|
gap: 22px;
|
|
font-family: var(--mono);
|
|
}
|
|
.tui {
|
|
display: grid;
|
|
grid-template-columns: 1fr 36ch;
|
|
gap: 24px;
|
|
min-height: 0;
|
|
}
|
|
.pane {
|
|
position: relative;
|
|
background: var(--term-bg);
|
|
border: 2px solid var(--term-edge);
|
|
border-radius: 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
box-shadow:
|
|
0 0 60px rgba(80, 220, 100, 0.10),
|
|
inset 0 0 90px rgba(0, 30, 0, 0.65);
|
|
}
|
|
.pane::after {
|
|
content: '';
|
|
position: absolute; inset: 0;
|
|
pointer-events: none;
|
|
background: repeating-linear-gradient(
|
|
to bottom,
|
|
rgba(0,0,0,0) 0px,
|
|
rgba(0,0,0,0) 2px,
|
|
rgba(0,0,0,0.28) 3px,
|
|
rgba(0,0,0,0.28) 4px
|
|
);
|
|
opacity: 0.55;
|
|
mix-blend-mode: multiply;
|
|
}
|
|
.pane > .pane-tab {
|
|
position: absolute;
|
|
top: -14px; left: 28px;
|
|
padding: 2px 14px;
|
|
background: var(--bg);
|
|
color: var(--term-fg-bright);
|
|
font-size: 16px;
|
|
letter-spacing: 0.32em;
|
|
text-shadow: 0 0 6px var(--term-glow);
|
|
z-index: 1;
|
|
}
|
|
.pane.terminal {
|
|
/* Override hud.css `.terminal { overflow: hidden }` so the `┤ TERMINAL ├`
|
|
pane-tab can stick out 14px above the pane border. The scrolling
|
|
clip still happens inside #termOutput. */
|
|
overflow: visible;
|
|
padding: 28px 36px;
|
|
font-size: 24px;
|
|
line-height: 1.55;
|
|
color: var(--term-fg);
|
|
text-shadow: 0 0 6px var(--term-glow);
|
|
}
|
|
.pane.np-pane { overflow: visible; }
|
|
.np-pane {
|
|
padding: 24px 22px 20px;
|
|
font-size: 18px;
|
|
color: var(--term-fg);
|
|
text-shadow: 0 0 6px var(--term-glow);
|
|
display: grid;
|
|
grid-template-rows: auto auto auto auto 1fr auto auto;
|
|
gap: 18px;
|
|
}
|
|
.np-art {
|
|
position: relative;
|
|
aspect-ratio: 1 / 1;
|
|
background: rgba(0, 30, 0, 0.45);
|
|
border: 1px solid var(--term-edge-soft);
|
|
overflow: hidden;
|
|
}
|
|
.np-art img {
|
|
position: absolute; inset: 0;
|
|
width: 100%; height: 100%;
|
|
object-fit: cover;
|
|
opacity: 0;
|
|
transition: opacity 320ms ease;
|
|
}
|
|
.np-art img.on { opacity: 1; }
|
|
.np-art .placeholder {
|
|
position: absolute; inset: 0;
|
|
display: flex; flex-direction: column;
|
|
align-items: center; justify-content: center;
|
|
gap: 10px; color: var(--term-fg-dim); text-align: center;
|
|
transition: opacity 320ms ease;
|
|
}
|
|
.np-art .placeholder.off { opacity: 0; }
|
|
.np-art .placeholder svg { opacity: 0.5; }
|
|
.np-art .placeholder .ph-text {
|
|
font-size: 14px;
|
|
letter-spacing: 0.42em;
|
|
color: rgba(95, 220, 98, 0.65);
|
|
}
|
|
.np-meta {
|
|
display: grid; grid-template-columns: 6.5em 1fr;
|
|
row-gap: 6px; column-gap: 12px;
|
|
font-size: 17px; align-content: start;
|
|
}
|
|
.np-meta .key { color: var(--term-fg-dim); letter-spacing: 0.18em; }
|
|
.np-meta .val {
|
|
color: var(--term-fg-bright);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
min-width: 0;
|
|
}
|
|
.np-meta .val.empty { color: var(--term-fg-dim); }
|
|
.np-stats {
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
padding-top: 6px;
|
|
border-top: 1px dashed var(--term-edge-soft);
|
|
}
|
|
.np-stats .stats-label {
|
|
font-size: 14px; letter-spacing: 0.32em;
|
|
color: var(--term-fg-dim);
|
|
text-shadow: 0 0 4px var(--term-glow);
|
|
}
|
|
.np-stats .stats-grid {
|
|
display: grid; grid-template-columns: 6.5em 1fr;
|
|
row-gap: 4px; column-gap: 12px;
|
|
font-size: 16px;
|
|
}
|
|
.np-stats .stats-grid .key { color: var(--term-fg-dim); letter-spacing: 0.16em; }
|
|
.np-stats .stats-grid .val {
|
|
color: var(--term-fg);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.np-stats .stats-grid .val.empty { color: var(--term-fg-dim); }
|
|
.np-stats .stats-grid .val.lossless {
|
|
color: var(--term-fg-bright);
|
|
text-shadow: 0 0 8px var(--term-glow);
|
|
}
|
|
.np-spectrum { min-height: 64px; }
|
|
.np-progress { display: grid; grid-template-rows: auto auto; gap: 6px; }
|
|
.np-progress .bar {
|
|
position: relative; height: 6px;
|
|
background: rgba(95, 220, 98, 0.14);
|
|
border: 1px solid var(--term-edge-soft);
|
|
}
|
|
.np-progress .bar .fill {
|
|
position: absolute; inset: 0 auto 0 0;
|
|
width: 0%;
|
|
background: var(--term-fg);
|
|
box-shadow: 0 0 8px var(--term-glow);
|
|
transition: width 350ms linear;
|
|
}
|
|
.np-progress .time {
|
|
font-size: 15px; color: var(--term-fg-dim);
|
|
letter-spacing: 0.10em;
|
|
display: flex; justify-content: space-between;
|
|
}
|
|
.np-progress .time .now { color: var(--term-fg-bright); }
|
|
</style>
|
|
@endsection
|
|
|
|
@push('data-scripts')
|
|
<script>window.__TRACK_COUNT = @json($trackCount);</script>
|
|
@endpush
|
|
|
|
@section('content')
|
|
@include('partials.hud-strip', [
|
|
'variant' => 'onair',
|
|
'label' => 'ON AIR',
|
|
'idText' => 'OPHI-118 // MUSIC',
|
|
'signalGlyph' => '▮▮▮▮▯',
|
|
'signalProfile' => 'strong',
|
|
])
|
|
|
|
<main class="tui">
|
|
<section class="pane terminal">
|
|
<span class="pane-tab">┤ TERMINAL ├</span>
|
|
<div id="termOutput"><div id="pinnedBlock"><div class="pin-spacer"></div><div class="term-line next-line"><span class="next-arrow">↳</span><span class="next-label">next:</span><span class="next-title empty" data-slot="0">— end of queue —</span></div><div class="term-line next-line"><span class="next-arrow">↳</span><span class="next-label">then:</span><span class="next-title empty" data-slot="1">—</span></div><div class="term-line next-line"><span class="next-arrow">↳</span><span class="next-label">then:</span><span class="next-title empty" data-slot="2">—</span></div><div class="pin-spacer"></div><div class="term-line np-line"><span class="np-arrow">▶</span><span class="np-title">connecting to daemon…</span><span class="np-time">[--:-- / --:--]</span></div></div></div>
|
|
</section>
|
|
|
|
<aside class="pane np-pane">
|
|
<span class="pane-tab">┤ NOW PLAYING ├</span>
|
|
<div class="np-art">
|
|
<img id="cover" alt="">
|
|
<div class="placeholder" id="placeholder">
|
|
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect x="5" y="5" width="46" height="46" rx="2" stroke="#5fdc62" stroke-width="2"/>
|
|
<circle cx="28" cy="28" r="12" stroke="#5fdc62" stroke-width="2"/>
|
|
<circle cx="28" cy="28" r="3" fill="#5fdc62"/>
|
|
</svg>
|
|
<span class="ph-text">— NO ART —</span>
|
|
</div>
|
|
</div>
|
|
<div class="np-meta">
|
|
<span class="key">ARTIST</span><span class="val empty" id="metaArtist">—</span>
|
|
<span class="key">TITLE</span> <span class="val empty" id="metaTitle">—</span>
|
|
<span class="key">ALBUM</span> <span class="val empty" id="metaAlbum">—</span>
|
|
<span class="key">YEAR</span> <span class="val empty" id="metaYear">—</span>
|
|
<span class="key">GENRE</span> <span class="val empty" id="metaGenre">—</span>
|
|
</div>
|
|
<div class="np-stats">
|
|
<div class="stats-label">─ FILE ─</div>
|
|
<div class="stats-grid">
|
|
<span class="key">FORMAT</span> <span class="val empty" id="fileFormat">—</span>
|
|
<span class="key">QUALITY</span><span class="val empty" id="fileQuality">—</span>
|
|
<span class="key">BITRATE</span><span class="val empty" id="fileBitrate">—</span>
|
|
</div>
|
|
</div>
|
|
<div class="np-stats">
|
|
<div class="stats-label">─ LIBRARY ─</div>
|
|
<div class="stats-grid">
|
|
<span class="key">ARTISTS</span> <span class="val empty" id="statArtists">—</span>
|
|
<span class="key">ALBUMS</span> <span class="val empty" id="statAlbums">—</span>
|
|
<span class="key">SONGS</span> <span class="val empty" id="statSongs">—</span>
|
|
<span class="key">PLAYTIME</span><span class="val empty" id="statPlaytime">—</span>
|
|
<span class="key">QUEUE</span> <span class="val empty" id="statQueue">—</span>
|
|
</div>
|
|
</div>
|
|
<div aria-hidden="true"></div>
|
|
<div class="np-spectrum" id="spectrumSlot"></div>
|
|
<div class="np-progress">
|
|
<div class="bar"><div class="fill" id="progFill"></div></div>
|
|
<div class="time">
|
|
<span class="now" id="progElapsed">00:00</span>
|
|
<span id="progTotal">00:00</span>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</main>
|
|
|
|
<section class="status-strip">
|
|
<span class="label">CHAT</span>
|
|
<span class="cmd-group">
|
|
<span><span class="cmd">!skip</span><span class="desc">next track</span></span>
|
|
<span><span class="cmd">!queue</span><span class="desc">peek next 3</span></span>
|
|
<span><span class="cmd">!info</span><span class="desc">full metadata</span></span>
|
|
</span>
|
|
</section>
|
|
@endsection
|
|
|
|
@section('scripts')
|
|
<script>
|
|
'use strict';
|
|
const pad = HUD.pad;
|
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
const fmtClock = s => (!isFinite(s) || s < 0)
|
|
? '--:--' : `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
|
|
|
const trackCount = window.__TRACK_COUNT || 0;
|
|
|
|
const term = document.getElementById('termOutput');
|
|
const pinnedBlock = document.getElementById('pinnedBlock');
|
|
const npTitle = pinnedBlock.querySelector('.np-title');
|
|
const npTime = pinnedBlock.querySelector('.np-time');
|
|
const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`));
|
|
const nextRows = nextSlots.map(slot => slot.closest('.next-line'));
|
|
const PROMPT = 'OPHI-118://> ';
|
|
const MAX_TERM_LINES = 200;
|
|
const MODE_KEYS = ['repeat', 'random', 'single'];
|
|
let bootStarted = false;
|
|
let bootFinished = false;
|
|
let lastPlaybackModes = null;
|
|
let pendingState = null;
|
|
|
|
function pruneTerminal() {
|
|
while (term.children.length > MAX_TERM_LINES) {
|
|
const first = term.firstElementChild;
|
|
if (!first || first === pinnedBlock) break;
|
|
term.removeChild(first);
|
|
}
|
|
while (term.children.length > 1) {
|
|
let total = 0;
|
|
for (const c of term.children) total += c.getBoundingClientRect().height;
|
|
if (total <= term.clientHeight) break;
|
|
const first = term.firstElementChild;
|
|
if (!first || first === pinnedBlock) break;
|
|
term.removeChild(first);
|
|
}
|
|
}
|
|
function logBeforePins(text, cls = 'term-out', prompt = '> ') {
|
|
const div = document.createElement('div');
|
|
div.className = 'term-line';
|
|
if (prompt) {
|
|
const p = document.createElement('span');
|
|
p.className = 'term-prompt';
|
|
p.textContent = prompt;
|
|
div.appendChild(p);
|
|
}
|
|
const t = document.createElement('span');
|
|
if (cls) t.className = cls;
|
|
t.textContent = text;
|
|
div.appendChild(t);
|
|
term.insertBefore(div, pinnedBlock);
|
|
pruneTerminal();
|
|
}
|
|
async function typeCommand(text) {
|
|
const div = document.createElement('div');
|
|
div.className = 'term-line';
|
|
const p = document.createElement('span');
|
|
p.className = 'term-prompt';
|
|
p.textContent = PROMPT;
|
|
div.appendChild(p);
|
|
const body = document.createElement('span');
|
|
body.className = 'term-out';
|
|
div.appendChild(body);
|
|
term.insertBefore(div, pinnedBlock);
|
|
for (const c of text) {
|
|
body.textContent += c;
|
|
await sleep(38);
|
|
}
|
|
await sleep(280);
|
|
return body;
|
|
}
|
|
function normalizePlayback(playback) {
|
|
const src = playback && typeof playback === 'object' ? playback : {};
|
|
const normalized = {};
|
|
for (const key of MODE_KEYS) normalized[key] = !!src[key];
|
|
normalized.consume = !!src.consume;
|
|
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
|
|
return normalized;
|
|
}
|
|
function formatBootCommand(playback) {
|
|
const p = normalizePlayback(playback);
|
|
const parts = ['music', '--boot'];
|
|
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
|
|
MODE_KEYS.forEach(key => { if (p[key]) parts.push(`--${key}`); });
|
|
if (p.consume) parts.push('--consume');
|
|
return parts.join(' ');
|
|
}
|
|
function nextPreviewConfig(playback) {
|
|
const p = normalizePlayback(playback);
|
|
if (p.single && p.repeat) return { count: 1, empty: '— repeat single armed —' };
|
|
if (p.single) return { count: 0, empty: '— single mode —' };
|
|
if (p.random) return { count: 1, empty: '— shuffle active —' };
|
|
return { count: 3, empty: '— end of queue —' };
|
|
}
|
|
async function logModeChange(mode, enabled) {
|
|
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
|
|
logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null);
|
|
}
|
|
let modeLogChain = Promise.resolve();
|
|
function trackModeChanges(playback) {
|
|
const current = normalizePlayback(playback);
|
|
if (!lastPlaybackModes) { lastPlaybackModes = current; return; }
|
|
MODE_KEYS.forEach(key => {
|
|
if (current[key] !== lastPlaybackModes[key]) {
|
|
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
|
|
}
|
|
});
|
|
lastPlaybackModes = current;
|
|
}
|
|
|
|
const coverImg = document.getElementById('cover');
|
|
const placeholder = document.getElementById('placeholder');
|
|
const metaArtist = document.getElementById('metaArtist');
|
|
const metaTitle = document.getElementById('metaTitle');
|
|
const metaAlbum = document.getElementById('metaAlbum');
|
|
const metaYear = document.getElementById('metaYear');
|
|
const metaGenre = document.getElementById('metaGenre');
|
|
const progFill = document.getElementById('progFill');
|
|
const progElapsed = document.getElementById('progElapsed');
|
|
const progTotal = document.getElementById('progTotal');
|
|
const statArtists = document.getElementById('statArtists');
|
|
const statAlbums = document.getElementById('statAlbums');
|
|
const statSongs = document.getElementById('statSongs');
|
|
const statPlaytime = document.getElementById('statPlaytime');
|
|
const statQueue = document.getElementById('statQueue');
|
|
const fileFormat = document.getElementById('fileFormat');
|
|
const fileQuality = document.getElementById('fileQuality');
|
|
const fileBitrate = document.getElementById('fileBitrate');
|
|
|
|
const LOSSLESS_FORMATS = new Set(['FLAC', 'ALAC', 'WAV', 'WAVPACK', 'APE']);
|
|
function fmtSampleRate(hz) {
|
|
if (!hz || !isFinite(hz) || hz <= 0) return null;
|
|
const khz = hz / 1000;
|
|
const s = (khz % 1 === 0) ? khz.toFixed(0) : khz.toFixed(1);
|
|
return `${s} kHz`;
|
|
}
|
|
function fmtQuality(audio) {
|
|
const sr = fmtSampleRate(audio.samplerate);
|
|
const bits = (typeof audio.bits === 'number' && audio.bits > 0)
|
|
? `${audio.bits}-bit` : null;
|
|
if (sr && bits) return `${sr} · ${bits}`;
|
|
return sr || bits || null;
|
|
}
|
|
const fmtCount = n => (typeof n === 'number' && isFinite(n))
|
|
? n.toLocaleString('en-US') : null;
|
|
function setMeta(el, value) {
|
|
if (value && String(value).trim()) {
|
|
el.textContent = value;
|
|
el.classList.remove('empty');
|
|
} else {
|
|
el.textContent = '—';
|
|
el.classList.add('empty');
|
|
}
|
|
}
|
|
let lastCoverHash = undefined;
|
|
function showArt(_path, hash) {
|
|
if (hash === lastCoverHash) return;
|
|
lastCoverHash = hash;
|
|
if (!hash) {
|
|
coverImg.classList.remove('on');
|
|
placeholder.classList.remove('off');
|
|
coverImg.removeAttribute('src');
|
|
return;
|
|
}
|
|
coverImg.onload = () => { coverImg.classList.add('on'); placeholder.classList.add('off'); };
|
|
coverImg.onerror = () => { coverImg.classList.remove('on'); placeholder.classList.remove('off'); };
|
|
coverImg.src = `/cover.jpg?v=${hash}`;
|
|
}
|
|
|
|
let lastTrackFile = null;
|
|
function renderState(s) {
|
|
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile && s.title) {
|
|
logBeforePins(`[audio] now: ${s.title}`);
|
|
}
|
|
if (s.file) lastTrackFile = s.file;
|
|
|
|
npTitle.textContent = s.title || '—';
|
|
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
|
|
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
|
|
const preview = nextPreviewConfig(s.playback);
|
|
nextSlots.forEach((slot, i) => {
|
|
const v = i < preview.count ? titles[i] : null;
|
|
if (v) {
|
|
slot.textContent = v;
|
|
slot.classList.remove('empty');
|
|
nextRows[i].style.display = '';
|
|
} else {
|
|
if (i === 0) {
|
|
slot.textContent = preview.empty;
|
|
slot.classList.add('empty');
|
|
nextRows[i].style.display = '';
|
|
} else {
|
|
slot.textContent = '';
|
|
slot.classList.add('empty');
|
|
nextRows[i].style.display = 'none';
|
|
}
|
|
}
|
|
});
|
|
|
|
showArt(s.coverPath, s.coverHash);
|
|
setMeta(metaArtist, s.artist);
|
|
setMeta(metaTitle, s.trackTitle);
|
|
setMeta(metaAlbum, s.album);
|
|
setMeta(metaYear, s.year);
|
|
setMeta(metaGenre, s.genre);
|
|
|
|
const dur = Number(s.duration) || 0;
|
|
const cur = Number(s.currentTime) || 0;
|
|
const pct = dur > 0 ? Math.max(0, Math.min(100, (cur / dur) * 100)) : 0;
|
|
progFill.style.width = `${pct}%`;
|
|
progElapsed.textContent = fmtClock(cur);
|
|
progTotal.textContent = fmtClock(dur);
|
|
|
|
const audio = s.audio || {};
|
|
setMeta(fileFormat, audio.format);
|
|
fileFormat.classList.toggle('lossless', LOSSLESS_FORMATS.has(audio.format));
|
|
setMeta(fileQuality, fmtQuality(audio));
|
|
setMeta(fileBitrate, audio.bitrate ? `${fmtCount(audio.bitrate)} kbps` : null);
|
|
|
|
const stats = s.stats;
|
|
if (stats) {
|
|
setMeta(statArtists, fmtCount(stats.artists));
|
|
setMeta(statAlbums, fmtCount(stats.albums));
|
|
setMeta(statSongs, fmtCount(stats.songs));
|
|
setMeta(statPlaytime, stats.dbPlaytime);
|
|
}
|
|
const total = Number(s.total) || 0;
|
|
const idx = Number.isInteger(s.index) ? s.index : -1;
|
|
setMeta(statQueue, total > 0 && idx >= 0 ? `${idx + 1} / ${total}` : null);
|
|
|
|
pruneTerminal();
|
|
}
|
|
async function bootFromState(s) {
|
|
if (bootStarted) return;
|
|
bootStarted = true;
|
|
lastPlaybackModes = normalizePlayback(s.playback);
|
|
await typeCommand(formatBootCommand(s.playback));
|
|
logBeforePins('[audio] subscribing to mpd:state events', 'term-out');
|
|
await sleep(220);
|
|
if (trackCount > 0) {
|
|
logBeforePins(`[audio] library: ${trackCount} tracks`, 'term-out');
|
|
} else {
|
|
logBeforePins('[audio] library manifest empty', 'term-dim');
|
|
}
|
|
await sleep(180);
|
|
logBeforePins('[audio] standby for transmission ✓', 'term-out');
|
|
bootFinished = true;
|
|
if (pendingState) renderState(pendingState);
|
|
}
|
|
function applyState(s) {
|
|
pendingState = s;
|
|
if (!bootStarted) { bootFromState(s); return; }
|
|
if (!bootFinished) return;
|
|
renderState(s);
|
|
trackModeChanges(s.playback);
|
|
}
|
|
function setOffline(msg) {
|
|
npTitle.textContent = msg;
|
|
npTime.textContent = '[--:-- / --:--]';
|
|
}
|
|
|
|
(async () => {
|
|
await sleep(450);
|
|
logBeforePins('[audio] waiting for mpd state', 'term-dim', null);
|
|
await sleep(280);
|
|
OBSWSBootstrap.onState(applyState, {
|
|
onClose: () => {
|
|
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
|
|
setOffline('daemon disconnected');
|
|
},
|
|
});
|
|
})();
|
|
</script>
|
|
@endsection
|