ghassan f98e5415a3 Add lyrics pipeline, playlist views, admin toggles, and player polish
Lyrics pipeline (Whisper + Demucs + description alignment):
- New GenerateLyricsJob runs WhisperX with VAD filtering and forced word
  alignment, writes per-track JSON to NAS.
- New DecorateLyricsJob calls the active LLM provider to bake one to
  several emojis into each line (heavy decoration prompt).
- LyricsDescriptionParser strips heading content, section markers, and
  emoji-decoration from a song's description while preserving every
  language verbatim.
- correct_whisper_with_description aligner: strong-match anchors only,
  vocal-region-aware gap-fill so missing verses land on actual singing.
- Owner UI for generate/regenerate/edit/delete in the player gear.

Admin pages:
- /admin/lyrics    toggles for VAD, vocal gap-fill, Demucs, master
- /admin/gpu       extracted GPU section, encoder picker, FFmpeg path
- /admin/backup    extracted users-and-settings export/import
- /admin/settings  now AI/LLM only with provider list and Test button
- /admin/nas-storage hosts NAS settings, repair, disable flow, browser
- Shared partials/settings-styles for a uniform look across pages.

Playlist view tracking:
- Migration adds playlists.view_count and playlist_views dedup table.
- Playlist::bumpViewIfNew increments per device with a one-hour window.
- Tracked from /playlists/{id}, /playlists/share/{token}, /ps/{token},
  and /videos/{id}?playlist={token}.  Dispatched after-response so it
  never blocks the page render.
- Loading a playlist on the video page now runs one query instead of
  the four the old getNextVideo/getPreviousVideo path triggered.
- View counts shown on every playlist card and the playlist hero.

Player polish:
- Floating mini-player is draggable, persists its position in
  localStorage, clamps to viewport on resize.
- Mini disabled entirely on mobile (less than 768px).
- New gear-menu Mini Player toggle (persists in localStorage) lets the
  user disable both scroll-activation and SPA-nav-activation.
- Close button keeps media playing when used on the player's own page.
- SPA navigator now swaps a #page-scripts container so per-page JS
  (channel tabs, etc.) gets re-executed after content swaps.

Storage layout:
- Runtime data moved from /storage/* to /data/* and gitignored.
- /ml/venv, /ml/cache, /ml/__pycache__ excluded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 22:01:47 +03:00

324 lines
14 KiB
PHP

@php
$id = $attributes->get('id', 'cropper_' . Str::random(6));
$width = (int) $attributes->get('width', 300);
$height = (int) $attributes->get('height', 300);
$shape = $attributes->get('shape', 'circle'); // 'circle' or 'square'
$folder = $attributes->get('folder', 'uploads');
$filename = $attributes->get('filename', 'image_' . time());
$callback = $attributes->get('callback', ''); // JS function called with (url) after server upload
$updateUrl = $attributes->get('update-url', ''); // POST {path} here after server upload
$title = $attributes->get('title', $shape === 'circle' ? 'Change Photo' : 'Crop Image');
$outputWidth = (int) $attributes->get('output-width', 0); // final output px width (0 = viewport size)
$targetInput = $attributes->get('target-input', ''); // form mode: ID of file input to set result on
$previewImg = $attributes->get('preview-img', ''); // ID of img to update with preview
$resultCallback = $attributes->get('result-callback', ''); // callback mode: JS fn called with the cropped File
@endphp
{{-- Cropme assets + .tc-* styles now live in layouts/app.blade.php <head>
so they survive SPA-nav innerHTML swaps on #main. --}}
{{-- Modal --}}
<div class="tc-overlay" id="tcOverlay_{{ $id }}" role="dialog" aria-modal="true">
<div class="tc-modal">
<div class="tc-modal-header">
<span class="tc-modal-title">
<i class="bi bi-crop"></i>
{{ $title }}
</span>
<button class="tc-modal-close" onclick="closeCropperModal('{{ $id }}')" aria-label="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="tc-modal-body">
<div class="tc-file-row">
<label class="tc-file-label" for="tcInput_{{ $id }}">
<i class="bi bi-upload"></i>
Choose image
</label>
<span class="tc-file-name" id="tcFileName_{{ $id }}">No file chosen</span>
<input type="file" id="tcInput_{{ $id }}" accept="image/*" style="display:none" aria-label="Select image file">
</div>
<div class="tc-canvas" id="tcCanvas_{{ $id }}">
<div class="tc-placeholder" id="tcPlaceholder_{{ $id }}">
<i class="bi bi-image"></i>
<span>Upload an image to start cropping</span>
</div>
</div>
<div class="tc-controls">
<div class="tc-control">
<label class="tc-control-label" for="tcZoom_{{ $id }}">
<i class="bi bi-zoom-in"></i> Zoom
</label>
<input type="range" class="tc-range" id="tcZoom_{{ $id }}" min="0" max="100" step="1" value="0">
</div>
<div class="tc-control">
<label class="tc-control-label" for="tcRot_{{ $id }}">
<i class="bi bi-arrow-clockwise"></i> Rotate
</label>
<input type="range" class="tc-range" id="tcRot_{{ $id }}" min="-180" max="180" step="1" value="0">
</div>
</div>
</div>
<div class="tc-modal-footer">
<button class="tc-btn tc-btn-as-is" id="tcAsIsBtn_{{ $id }}" disabled
onclick="tcUploadAsIs_{{ $id }}()">
<i class="bi bi-image"></i>
Upload as-is
</button>
<button class="tc-btn tc-btn-ghost" onclick="closeCropperModal('{{ $id }}')">
Cancel
</button>
<button class="tc-btn tc-btn-primary" id="tcSaveBtn_{{ $id }}" disabled
onclick="tcSave_{{ $id }}()">
<i class="bi bi-check-lg"></i>
<span id="tcSaveBtnText_{{ $id }}">Crop & Save</span>
</button>
</div>
</div>
</div>
<script>
(function () {
var cropperInst = null;
var originalFile = null;
var _intercept = false; // flag: we are programmatically setting the file, skip interceptor
var zoomMin = 0.01, zoomMax = 3;
var id = '{{ $id }}';
var vw = {{ $width }};
var vh = {{ $height }};
var shape = '{{ $shape }}';
var folder = '{{ $folder }}';
var filename = '{{ $filename }}';
var uploadUrl = '{{ route('image.upload') }}';
var updateUrl = '{{ $updateUrl }}';
var callbackFn = '{{ $callback }}';
var outputWidth = {{ $outputWidth > 0 ? $outputWidth : 0 }};
var targetInputId = '{{ $targetInput }}'; // form mode: ID of the file input to intercept
var previewImgId = '{{ $previewImg }}';
var resultCbName = '{{ $resultCallback }}'; // callback mode: name of a global fn given the cropped File
var isFormMode = targetInputId !== '';
var isCallbackMode = resultCbName !== '';
// In callback mode the host function decides when to close / advance, so we
// never auto-close here — we just hand the File back and reset the button.
function deliverResult(file) {
if (typeof window[resultCbName] === 'function') window[resultCbName](file);
var sb = document.getElementById('tcSaveBtn_' + id);
var st = document.getElementById('tcSaveBtnText_' + id);
var ab = document.getElementById('tcAsIsBtn_' + id);
if (sb) sb.disabled = false;
if (ab) ab.disabled = false;
if (st) st.textContent = 'Crop & Save';
}
function getCsrf() {
var m = document.querySelector('meta[name="csrf-token"]');
return m ? m.content : '';
}
/* ── Open / close ── */
function openModal() {
document.getElementById('tcOverlay_' + id).classList.add('open');
document.body.style.overflow = 'hidden';
}
window['openCropperModal_' + id] = openModal;
window.closeCropperModal = window.closeCropperModal || function (i) {
var el = document.getElementById('tcOverlay_' + i);
if (el) { el.classList.remove('open'); document.body.style.overflow = ''; }
};
document.getElementById('tcOverlay_' + id).addEventListener('click', function (e) {
if (e.target === this) window.closeCropperModal(id);
});
/* ── Preload a File into the cropper ── */
function preloadFile(file) {
if (!file) return;
originalFile = file;
document.getElementById('tcFileName_' + id).textContent = file.name;
var reader = new FileReader();
reader.onload = function (e) { initCropper(e.target.result); };
reader.readAsDataURL(file);
}
window['tcPreload_' + id] = preloadFile;
function initCropper(dataUrl) {
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
document.getElementById('tcSaveBtn_' + id).disabled = false;
document.getElementById('tcAsIsBtn_' + id).disabled = false;
var canvas = document.getElementById('tcCanvas_' + id);
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
cropperInst = new Cropme(canvas, {
container: { width: '100%', height: 320 },
viewport: {
width: vw, height: vh,
type: shape,
border: { enable: true, width: 2, color: '#ef4444' }
},
transformOrigin: 'viewport',
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
rotation: { enable: true, slider: false }
});
cropperInst.bind({ url: dataUrl }).then(function () {
document.getElementById('tcZoom_' + id).value = 0;
document.getElementById('tcRot_' + id).value = 0;
});
}
/* ── Internal file input (the "Choose image" button inside the modal) ── */
document.getElementById('tcInput_' + id).addEventListener('change', function () {
if (this.files && this.files[0]) preloadFile(this.files[0]);
});
/* ── Form-mode: expose open helper for external callers ── */
// Callers should invoke window['openCropperModal_' + id]() directly from their
// dropzone click/drop handlers rather than relying on event interception.
/* ── Zoom / rotate sliders ── */
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
if (!cropperInst || !cropperInst.properties.image) return;
var p = parseFloat(this.value) / 100;
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
var s = cropperInst.properties;
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
});
document.getElementById('tcRot_' + id).addEventListener('input', function () {
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
});
/* ── Helpers ── */
function base64ToFile(base64, name) {
var arr = base64.split(',');
var mime = (arr[0].match(/:(.*?);/) || [])[1] || 'image/png';
var bin = atob(arr[1]);
var u8 = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return new File([u8], name || 'cropped.png', { type: mime });
}
function setOnTargetInput(file) {
var el = document.getElementById(targetInputId);
if (!el) return;
try {
var dt = new DataTransfer();
dt.items.add(file);
_intercept = true;
el.files = dt.files;
el.dispatchEvent(new Event('change', { bubbles: true }));
} finally {
_intercept = false;
}
if (previewImgId) {
var prev = document.getElementById(previewImgId);
if (prev) prev.src = URL.createObjectURL(file);
}
}
function uploadToServer(base64, onDone, onFail) {
fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ image: base64, folder: folder, filename: filename })
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.message || 'Upload failed');
if (updateUrl) {
return fetch(updateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': getCsrf() },
body: JSON.stringify({ path: res.path })
}).then(function () { return res; });
}
return res;
})
.then(onDone)
.catch(onFail);
}
/* ── Crop & Save ── */
window['tcSave_' + id] = function () {
if (!cropperInst) return;
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
btn.disabled = true;
txt.textContent = 'Saving…';
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
cropperInst.crop(cropOpts).then(function (base64) {
if (isCallbackMode) {
var cbName = originalFile ? originalFile.name : 'cropped.png';
deliverResult(base64ToFile(base64, cbName));
return;
}
if (isFormMode) {
var fname = originalFile ? originalFile.name : 'cropped.png';
setOnTargetInput(base64ToFile(base64, fname));
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
btn.disabled = false;
txt.textContent = 'Crop & Save';
} else {
uploadToServer(base64, function (res) {
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
txt.textContent = 'Crop & Save';
});
}
});
};
/* ── Upload as-is ── */
window['tcUploadAsIs_' + id] = function () {
if (!originalFile) { window.closeCropperModal(id); return; }
if (isCallbackMode) {
deliverResult(originalFile);
} else if (isFormMode) {
// Put the original (un-cropped) file on the target input so the form sees it.
setOnTargetInput(originalFile);
window.closeCropperModal(id);
} else {
// Server mode: read original file as base64 and upload unchanged
var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id);
var aisBtn = document.getElementById('tcAsIsBtn_' + id);
btn.disabled = true;
aisBtn.disabled = true;
txt.textContent = 'Uploading…';
var reader = new FileReader();
reader.onload = function (e) {
uploadToServer(e.target.result, function (res) {
window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
}, function (err) {
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
btn.disabled = false;
aisBtn.disabled = false;
txt.textContent = 'Crop & Save';
});
};
reader.readAsDataURL(originalFile);
}
};
})();
</script>