Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).
Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
is carried through share links and auto-selects audio + title + flag + about +
OG/meta on open.
GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
frequency bars; loop-filter rebuild makes generation ~25x faster.
Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
429 lines
19 KiB
PHP
429 lines
19 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
|
|
|
|
@once
|
|
<link rel="stylesheet" href="{{ asset('css/cropme.min.css') }}">
|
|
<script src="{{ asset('js/cropme.min.js') }}"></script>
|
|
<style>
|
|
/* ── TakeOne Cropper Modal ─────────────────────────── */
|
|
.tc-overlay {
|
|
display: none; position: fixed; inset: 0; z-index: 10100;
|
|
background: rgba(0,0,0,.82); backdrop-filter: blur(4px);
|
|
align-items: center; justify-content: center;
|
|
}
|
|
.tc-overlay.open { display: flex; animation: tcFadeIn .18s ease; }
|
|
@keyframes tcFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
.tc-modal {
|
|
background: #141414; border: 1px solid rgba(255,255,255,.12);
|
|
border-radius: 18px; width: min(540px, 95vw);
|
|
box-shadow: 0 24px 80px rgba(0,0,0,.75);
|
|
overflow: hidden; animation: tcSlideUp .2s cubic-bezier(.34,1.3,.64,1);
|
|
}
|
|
@keyframes tcSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
|
.tc-modal-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 16px 20px 14px;
|
|
border-bottom: 1px solid rgba(255,255,255,.07);
|
|
}
|
|
.tc-modal-title {
|
|
font-size: 15px; font-weight: 700; color: #fff;
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.tc-modal-title i { color: #ef4444; }
|
|
.tc-modal-close {
|
|
background: none; border: none; color: rgba(255,255,255,.45);
|
|
font-size: 20px; cursor: pointer; line-height: 1; padding: 4px 6px;
|
|
border-radius: 6px; transition: color .15s, background .15s;
|
|
}
|
|
.tc-modal-close:hover { color: #fff; background: rgba(255,255,255,.08); }
|
|
.tc-modal-body { padding: 16px 20px; }
|
|
.tc-file-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
|
.tc-file-label {
|
|
display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
|
|
height: 36px; padding: 0 14px; border-radius: 8px;
|
|
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.12);
|
|
color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
|
transition: background .15s;
|
|
}
|
|
.tc-file-label:hover { background: rgba(255,255,255,.13); }
|
|
.tc-file-name {
|
|
font-size: 12px; color: rgba(255,255,255,.38); flex: 1;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.tc-canvas {
|
|
width: 100%; height: 320px; background: #0d0d0d;
|
|
border-radius: 10px; border: 1px solid rgba(255,255,255,.07);
|
|
overflow: hidden; position: relative;
|
|
}
|
|
.tc-placeholder {
|
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
align-items: center; justify-content: center;
|
|
color: rgba(255,255,255,.2); gap: 10px; pointer-events: none;
|
|
}
|
|
.tc-placeholder i { font-size: 42px; }
|
|
.tc-placeholder span { font-size: 13px; }
|
|
.tc-controls { display: flex; gap: 14px; margin-top: 14px; }
|
|
.tc-control { flex: 1; }
|
|
.tc-control-label {
|
|
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
|
|
color: rgba(255,255,255,.35); margin-bottom: 6px; display: flex; align-items: center; gap: 5px;
|
|
}
|
|
.tc-range {
|
|
-webkit-appearance: none; appearance: none; width: 100%; height: 3px;
|
|
background: rgba(255,255,255,.12); border-radius: 3px; outline: none; cursor: pointer;
|
|
}
|
|
.tc-range::-webkit-slider-thumb {
|
|
-webkit-appearance: none; width: 15px; height: 15px;
|
|
border-radius: 50%; background: #ef4444; cursor: pointer;
|
|
box-shadow: 0 0 0 3px rgba(239,68,68,.2); transition: box-shadow .15s;
|
|
}
|
|
.tc-range::-webkit-slider-thumb:hover { box-shadow: 0 0 0 5px rgba(239,68,68,.3); }
|
|
.tc-range::-moz-range-thumb {
|
|
width: 15px; height: 15px; border: none;
|
|
border-radius: 50%; background: #ef4444; cursor: pointer;
|
|
}
|
|
.tc-modal-footer {
|
|
padding: 12px 20px 18px; display: flex; gap: 8px; justify-content: flex-end;
|
|
border-top: 1px solid rgba(255,255,255,.07); flex-wrap: wrap;
|
|
}
|
|
.tc-btn {
|
|
display: inline-flex; align-items: center; gap: 7px;
|
|
height: 38px; padding: 0 18px; border-radius: 8px;
|
|
font-size: 14px; font-weight: 600; cursor: pointer; border: none; font-family: inherit;
|
|
transition: background .15s, transform .1s, opacity .15s;
|
|
}
|
|
.tc-btn-ghost {
|
|
background: rgba(255,255,255,.07); color: rgba(255,255,255,.75);
|
|
border: 1px solid rgba(255,255,255,.12);
|
|
}
|
|
.tc-btn-ghost:hover { background: rgba(255,255,255,.13); color: #fff; }
|
|
.tc-btn-as-is {
|
|
background: rgba(255,255,255,.05); color: rgba(255,255,255,.55);
|
|
border: 1px solid rgba(255,255,255,.09); margin-right: auto;
|
|
}
|
|
.tc-btn-as-is:hover { background: rgba(255,255,255,.1); color: rgba(255,255,255,.85); }
|
|
.tc-btn-as-is:disabled { opacity: .3; cursor: not-allowed; }
|
|
.tc-btn-primary { background: #ef4444; color: #fff; }
|
|
.tc-btn-primary:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); }
|
|
.tc-btn-primary:disabled { opacity: .45; cursor: not-allowed; }
|
|
</style>
|
|
@endonce
|
|
|
|
{{-- 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>
|