2026-01-26 15:14:31 +00:00

743 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Define global variables to track music files and the current index.
let currentMusicFiles = []; // Array of objects with at least { path, index }
let currentMusicIndex = -1; // Index of the current music file
let currentTrackPath = "";
let activeTranscriptAudio = ""; // Audio file that matches the currently open transcript.
// Check thumb availability in parallel; sequentially generate missing ones to avoid concurrent creation.
function ensureFirstFivePreviews() {
const images = Array.from(document.querySelectorAll('.images-grid img')).slice(0, 5);
if (!images.length) return;
const checks = images.map(img => {
const thumbUrl = img.dataset.thumbUrl || img.src;
const fullUrl = img.dataset.fullUrl;
return fetch(thumbUrl, { method: 'GET', cache: 'no-store' })
.then(res => ({
img,
thumbUrl,
fullUrl,
hasThumb: res.ok && res.status !== 204
}))
.catch(() => ({ img, thumbUrl, fullUrl, hasThumb: false }));
});
Promise.all(checks).then(results => {
const missing = results.filter(r => !r.hasThumb && r.fullUrl);
if (!missing.length) return;
generateThumbsSequentially(missing);
});
}
async function generateThumbsSequentially(entries) {
for (const { img, thumbUrl, fullUrl } of entries) {
try {
await fetch(fullUrl, { method: 'GET', cache: 'no-store' });
const cacheBust = `_=${Date.now()}`;
const separator = thumbUrl.includes('?') ? '&' : '?';
img.src = `${thumbUrl}${separator}${cacheBust}`;
} catch (e) {
// ignore; best effort
}
}
}
function getMainContainer() {
return document.querySelector('main');
}
function getSearchContainer() {
return document.querySelector('search');
}
function viewSearch() {
// Hide the main container and show the search container
const mainContainer = getMainContainer();
const searchContainer = getSearchContainer();
if (!mainContainer || !searchContainer) return;
mainContainer.style.display = 'none';
searchContainer.style.display = 'block';
}
function viewMain() {
// Hide the search container and show the main container
const mainContainer = getMainContainer();
const searchContainer = getSearchContainer();
if (!mainContainer || !searchContainer) return;
searchContainer.style.display = 'none';
mainContainer.style.display = 'block';
}
// Helper function: decode each segment then re-encode to avoid double encoding.
function encodeSubpath(subpath) {
if (!subpath) return '';
return subpath
.split('/')
.map(segment => encodeURIComponent(decodeURIComponent(segment)))
.join('/');
}
// Convert a timecode string like "00:17" or "01:02:03" into total seconds.
function parseTimecode(text) {
const parts = text.trim().split(':').map(part => parseInt(part, 10));
if (parts.some(Number.isNaN) || parts.length < 2 || parts.length > 3) return null;
const hasHours = parts.length === 3;
const [hours, minutes, seconds] = hasHours ? parts : [0, parts[0], parts[1]];
if (seconds < 0 || seconds > 59) return null;
if (minutes < 0 || (hasHours && minutes > 59)) return null;
if (hours < 0) return null;
return hours * 3600 + minutes * 60 + seconds;
}
// Add click handlers to transcript timecodes so they seek the matching audio.
function attachTranscriptTimecodes(audioPath) {
const container = document.getElementById('transcriptContent');
const targetAudio = audioPath || activeTranscriptAudio;
if (!container || !targetAudio) return;
container.querySelectorAll('code').forEach(codeEl => {
if (codeEl.closest('pre')) return;
const label = codeEl.textContent.trim();
const seconds = parseTimecode(label);
if (seconds === null) return;
codeEl.classList.add('timecode-link');
codeEl.title = `Zu ${label} springen`;
codeEl.addEventListener('click', () => jumpToTimecode(seconds, targetAudio));
});
}
// Ensure we load the right track (if needed) and jump to the requested time.
function jumpToTimecode(seconds, relUrl) {
if (!relUrl || seconds === null) return;
const updateTrackState = () => {
currentTrackPath = relUrl;
const matchIdx = currentMusicFiles.findIndex(file => file.path === relUrl);
currentMusicIndex = matchIdx !== -1 ? matchIdx : -1;
};
const seekWhenReady = () => {
player.audio.currentTime = Math.min(
seconds,
Number.isFinite(player.audio.duration) ? player.audio.duration : seconds
);
player.audio.play();
player.updateTimeline();
};
updateTrackState();
if (player.currentRelUrl === relUrl) {
if (player.audio.readyState >= 1) {
seekWhenReady();
} else {
player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true });
}
return;
}
player.audio.addEventListener('loadedmetadata', seekWhenReady, { once: true });
const trackLink = document.querySelector(`.play-file[data-url="${relUrl}"]`);
if (trackLink) {
trackLink.click();
} else {
player.loadTrack(relUrl);
}
}
// Global variable for gallery images (updated from current folder)
let currentGalleryImages = [];
// Fallback thumbnail (camera icon) when thumbnail generation is unavailable
const thumbnailFallbackData = 'data:image/svg+xml;utf8,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200" viewBox="0 0 320 200">
<rect width="100%" height="100%" fill="#e5e5e5"/>
<g fill="#333" stroke="#333" stroke-width="4">
<rect x="100" y="60" width="120" height="80" rx="10" ry="10" fill="none"/>
<rect x="125" y="40" width="30" height="20" rx="4" ry="4" fill="#333" stroke="none"/>
<circle cx="160" cy="100" r="24" fill="none"/>
<circle cx="160" cy="100" r="10" fill="#333" stroke="none"/>
<circle cx="205" cy="75" r="5" fill="#333" stroke="none"/>
</g>
</svg>`
);
function handleThumbnailError(imgEl) {
imgEl.onerror = null; // Prevent infinite loop
imgEl.src = thumbnailFallbackData;
imgEl.classList.add('thumbnail-fallback');
const item = imgEl.closest('.image-item');
if (item) {
item.classList.add('loaded');
}
}
function handleThumbnailLoad(imgEl) {
// Some servers return 204 for missing thumbs, which triggers load with zero dimensions
if (!imgEl.naturalWidth || !imgEl.naturalHeight) {
handleThumbnailError(imgEl);
return;
}
const item = imgEl.closest('.image-item');
if (item) {
item.classList.add('loaded');
}
}
function paintFile() {
// Highlight the currently playing file
if (currentTrackPath) {
const currentMusicFile = currentMusicFiles.find(file => file.path === currentTrackPath);
if (currentMusicFile) {
const currentMusicFileElement = document.querySelector(`.play-file[data-url="${currentMusicFile.path}"]`);
if (currentMusicFileElement) {
const fileItem = currentMusicFileElement.closest('.file-item');
fileItem.classList.add('currently-playing');
}
}
}
}
function renderContent(data) {
// Render breadcrumbs
let breadcrumbHTML = '';
data.breadcrumbs.forEach((crumb, index) => {
breadcrumbHTML += `<a href="#" class="breadcrumb-link" data-path="${crumb.path}">${crumb.name}</a>`;
if (index < data.breadcrumbs.length - 1) {
breadcrumbHTML += `<span class="separator">&#8203;&gt;</span>`;
}
});
document.getElementById('breadcrumbs').innerHTML = breadcrumbHTML;
const imageFiles = data.files.filter(file => file.file_type === 'image');
const nonImageFiles = data.files.filter(file => file.file_type !== 'image');
// Check for image-only directory (no subdirectories, at least one file, all images)
const isImageOnly = data.directories.length === 0
&& imageFiles.length > 0
&& nonImageFiles.length === 0;
let contentHTML = '';
if (isImageOnly) {
// Display thumbnails grid
contentHTML += '<div class="images-grid">';
imageFiles.forEach(file => {
const thumbUrl = `${file.path}?thumbnail=true`;
contentHTML += `
<div class="image-item">
<a href="#" class="play-file image-link"
data-url="${file.path}"
data-file-type="${file.file_type}">
<img
src="/media/${thumbUrl}"
data-thumb-url="/media/${thumbUrl}"
data-full-url="/media/${file.path}"
class="thumbnail"
onload="handleThumbnailLoad(this)"
onerror="handleThumbnailError(this)"
/>
</a>
<span class="file-name">${file.name}</span>
</div>
`;
});
contentHTML += '</div>';
} else {
share_link = '';
// Render directories normally
if (data.directories.length > 0) {
const areAllShort = data.directories.every(dir => dir.name.length <= 10) && data.files.length === 0;
if (areAllShort && data.breadcrumbs.length !== 1) {
contentHTML += '<div class="directories-grid">';
data.directories.forEach(dir => {
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
share_link = `<a href="#" class="create-share" data-url="${dir.path}"><i class="bi bi-gear"></i></a>`;
}
contentHTML += `<div class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}"><i class="bi bi-folder"></i> ${dir.name}</a>
${share_link}
</div>`;
});
contentHTML += '</div>';
} else {
contentHTML += '<ul>';
if (data.breadcrumbs.length === 1 && Array.isArray(data.folder_today) && data.folder_today.length > 0) {
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="heute"><i class="bi bi-calendar2-day"></i> Heute</a></li>`;
} else if (data.breadcrumbs.length === 1 && Array.isArray(data.folder_yesterday) && data.folder_yesterday.length > 0) {
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="gestern"><i class="bi bi-calendar2-day"></i> Gestern</a></li>`;
}
if (data.breadcrumbs.length === 1 && data.toplist_enabled) {
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="toplist"><i class="bi bi-graph-up"></i> häufig angehört</a></li>`;
}
data.directories.forEach(dir => {
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
share_link = `<a href="#" class="create-share" data-url="${dir.path}"><i class="bi bi-gear"></i></a>`;
}
if (dir.path.includes('toplist')) {
link_symbol = '<i class="bi bi-star"></i>';
} else {
link_symbol = '<i class="bi bi-folder"></i>';
}
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}">${link_symbol} ${dir.name}</a>${share_link}</li>`;
});
contentHTML += '</ul>';
}
}
// Add search link at top level above directories/files
if (data.breadcrumbs.length === 1) {
contentHTML = `<ul><li class="link-item" onclick="viewSearch()"><a onclick="viewSearch()" class="link-link"><i class="bi bi-search"></i> Suche</a></li></ul>` + contentHTML;
}
// Render files (including music and non-image files)
currentMusicFiles = [];
if (nonImageFiles.length > 0) {
contentHTML += '<ul>';
nonImageFiles.forEach((file, idx) => {
let symbol = '<i class="bi bi-file-earmark"></i>';
if (file.file_type === 'music') {
symbol = '<i class="bi bi-volume-up"></i>';
currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') });
} else if (file.file_type === 'image') {
symbol = '<i class="bi bi-image"></i>';
}
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
contentHTML += `<li class="file-item">
<a href="#" class="play-file"${indexAttr} data-url="${file.path}" data-file-type="${file.file_type}">${symbol} ${file.name.replace('.mp3', '')}</a>`;
if (file.has_transcript) {
contentHTML += `<a href="#" class="show-transcript" data-url="${file.transcript_url}" data-audio-url="${file.path}" title="Show Transcript"><i class="bi bi-journal-text"></i></a>`;
}
contentHTML += `</li>`;
});
contentHTML += '</ul>';
}
// Show images in preview grid after the file list
if (imageFiles.length > 0) {
contentHTML += '<div class="images-grid">';
imageFiles.forEach(file => {
const thumbUrl = `${file.path}?thumbnail=true`;
contentHTML += `
<div class="image-item">
<a href="#" class="play-file image-link"
data-url="${file.path}"
data-file-type="${file.file_type}">
<img
src="/media/${thumbUrl}"
data-thumb-url="/media/${thumbUrl}"
data-full-url="/media/${file.path}"
class="thumbnail"
onload="handleThumbnailLoad(this)"
onerror="handleThumbnailError(this)"
/>
</a>
<span class="file-name">${file.name}</span>
</div>
`;
});
contentHTML += '</div>';
}
}
// Insert generated content
document.getElementById('content').innerHTML = contentHTML;
// Attach event listeners for directories
document.querySelectorAll('.directory-item').forEach(item => {
item.addEventListener('click', function(e) {
if (!e.target.closest('a')) {
const anchor = item.querySelector('a.directory-link');
if (anchor) anchor.click();
}
});
});
// Attach event listeners for file items (including images)
if (isImageOnly || imageFiles.length > 0) {
document.querySelectorAll('.image-item').forEach(item => {
item.addEventListener('click', function(e) {
if (!e.target.closest('a')) {
const anchor = item.querySelector('a.image-link');
if (anchor) anchor.click();
}
});
});
} else {
document.querySelectorAll('li.file-item').forEach(item => {
item.addEventListener('click', function(e) {
if (!e.target.closest('a')) {
const anchor = item.querySelector('a.play-file');
if (anchor) anchor.click();
}
});
});
}
// Update gallery images for lightbox or similar
currentGalleryImages = imageFiles.map(f => f.path);
ensureFirstFivePreviews();
attachEventListeners();
}
// Helper function to show the spinner
function showSpinner() {
const overlay = document.getElementById('fullscreen-loader');
if (overlay) overlay.style.display = 'flex';
}
function hideSpinner() {
const overlay = document.getElementById('fullscreen-loader');
if (overlay) overlay.style.display = 'none';
}
// Fetch directory data from the API.
function loadDirectory(subpath) {
const spinnerTimer = setTimeout(showSpinner, 500);
const encodedPath = encodeSubpath(subpath);
const apiUrl = '/api/path/' + encodedPath;
return fetch(apiUrl)
.then(response => response.json())
.then(data => {
clearTimeout(spinnerTimer);
hideSpinner();
if (data.breadcrumbs) {
renderContent(data);
} else if (data.error) {
document.getElementById('content').innerHTML = `<div class="alert alert-warning">${data.error}</div>`;
return;
}
if (data.playfile) {
const playFileLink = document.querySelector(`.play-file[data-url="${data.playfile}"]`);
if (playFileLink) {
playFileLink.click();
}
}
paintFile();
return data; // return data for further chaining
})
.catch(error => {
clearTimeout(spinnerTimer);
hideSpinner();
console.error('Error loading directory:', error);
document.getElementById('content').innerHTML = `<p>Error loading directory!</p><p>${error}</p>`;
throw error; // Propagate the error
});
}
// preload the next audio file
function preload_audio() {
// Prefetch the next file by triggering the backend diskcache.
if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) {
const nextFile = currentMusicFiles[currentMusicIndex + 1];
const nextMediaUrl = '/media/' + nextFile.path;
// Use a HEAD request so that the backend reads and caches the file without returning the full content.
fetch(nextMediaUrl, {
method: 'HEAD',
headers: {
'X-Cache-Request': 'true'
}
});
}
}
const TRACK_CLICK_DEBOUNCE_MS = 3000;
let lastTrackClick = { url: null, ts: 0 };
// Attach event listeners for directory, breadcrumb, file, and transcript links.
function attachEventListeners() {
// Directory link clicks.
document.querySelectorAll('.directory-link').forEach(link => {
link.addEventListener('click', function (event) {
event.preventDefault();
const newPath = this.getAttribute('data-path');
loadDirectory(newPath);
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + newPath : '/');
// Scroll to the top of the page after click:
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
// Breadcrumb link clicks.
document.querySelectorAll('.breadcrumb-link').forEach(link => {
link.addEventListener('click', function (event) {
event.preventDefault();
const newPath = this.getAttribute('data-path');
loadDirectory(newPath);
history.pushState({ subpath: newPath }, '', newPath ? '/path/' + newPath : '/');
});
});
document.querySelectorAll('.play-file').forEach(link => {
link.addEventListener('click', async function (event) {
event.preventDefault();
const { fileType, url: relUrl, index } = this.dataset;
const now = Date.now();
if (fileType === 'music') {
// If this is the same track already loaded, ignore to avoid extra GETs and unselects.
if (player.currentRelUrl === relUrl) {
return;
}
// Remove the class from all file items.
document.querySelectorAll('.file-item').forEach(item => {
item.classList.remove('currently-playing');
});
// Debounce repeated clicks on the same track to avoid extra GETs.
if (lastTrackClick.url === relUrl && now - lastTrackClick.ts < TRACK_CLICK_DEBOUNCE_MS) {
return;
}
lastTrackClick = { url: relUrl, ts: now };
// Update the current music index.
currentMusicIndex = index !== undefined ? parseInt(index) : -1;
currentTrackPath = relUrl;
// Mark the clicked item as currently playing.
this.closest('.file-item').classList.add('currently-playing');
const reqId = crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2));
player.loadTrack(relUrl, reqId);
// Delay preloading to avoid blocking playback.
setTimeout(preload_audio, 1000);
} else if (fileType === 'image') {
// Open the gallery modal for image files.
openGalleryModal(relUrl);
} else {
// serve like a download
const reqId = crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2));
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(reqId)}`;
window.location.href = urlWithReq;
}
});
});
// Transcript icon clicks.
document.querySelectorAll('.show-transcript').forEach(link => {
link.addEventListener('click', function (event) {
event.preventDefault();
const url = this.getAttribute('data-url');
const audioUrl = this.getAttribute('data-audio-url') || '';
const highlight = this.getAttribute('highlight');
activeTranscriptAudio = audioUrl;
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
// Highlight words in the transcript (substring match, case-insensitive)
if (highlight) {
// Escape special regex chars
const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Split on whitespace, escape each word, and join with |
const words = highlight
.trim()
.split(/\s+/)
.map(escapeRegExp)
.filter(Boolean);
if (words.length) {
const regex = new RegExp(`(${words.join('|')})`, 'gi');
data = data.replace(regex, '<span class="highlight">$1</span>');
}
}
document.getElementById('transcriptContent').innerHTML = marked.parse(data);
attachTranscriptTimecodes(audioUrl);
document.getElementById('transcriptModal').style.display = 'block';
})
.catch(error => {
activeTranscriptAudio = '';
document.getElementById('transcriptContent').innerHTML = '<p>Error loading transcription.</p>';
document.getElementById('transcriptModal').style.display = 'block';
});
});
});
// create token icon clicks.
document.querySelectorAll('.create-share').forEach(link => {
link.addEventListener('click', function (event) {
event.preventDefault();
const url = '/create_token/' + this.getAttribute('data-url');
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
})
.then(data => {
document.getElementById('transcriptContent').innerHTML = data;
document.getElementById('transcriptModal').style.display = 'block';
})
.catch(error => {
console.error('Error creating share:', error);
document.getElementById('transcriptContent').innerHTML = "<p>You can't share this.</p>";
document.getElementById('transcriptModal').style.display = 'block';
});
});
});
// Handle back/forward navigation (bind once).
if (!window.__appPopstateBound) {
window.addEventListener('popstate', function (event) {
const subpath = event.state ? event.state.subpath : '';
loadDirectory(subpath);
});
window.__appPopstateBound = true;
}
} // End of attachEventListeners function
function initTranscriptModal() {
const modal = document.getElementById('transcriptModal');
if (!modal || modal.dataset.bound) return;
const closeBtn = modal.querySelector('.close');
if (closeBtn) {
closeBtn.addEventListener('click', function () {
modal.style.display = 'none';
});
}
window.addEventListener('click', function (event) {
if (event.target === modal) {
modal.style.display = 'none';
}
});
modal.dataset.bound = '1';
}
function initAppPage() {
attachEventListeners();
initTranscriptModal();
if (typeof window.initSearch === 'function') window.initSearch();
if (typeof window.initMessages === 'function') window.initMessages();
if (typeof window.initGallery === 'function') window.initGallery();
if (typeof window.initCalendar === 'function') window.initCalendar();
syncThemeColor();
// Load initial directory based on URL.
let initialSubpath = '';
if (window.location.pathname.indexOf('/path/') === 0) {
initialSubpath = window.location.pathname.substring(6); // remove "/path/"
}
loadDirectory(initialSubpath);
}
let isReloadButtonVisible = true; // Boolean to track the visibility of the reload button.
function reloadDirectory() {
if (!isReloadButtonVisible) return Promise.resolve();
// Extract the current path from the URL.
let currentSubpath = '';
if (window.location.pathname.indexOf('/path/') === 0) {
currentSubpath = window.location.pathname.substring(6);
}
// Return the promise so that we can chain actions after the directory has reloaded.
return loadDirectory(currentSubpath)
.then(() => {
// Animate the reload button as before.
const reloadBtn = document.querySelector('#reload-button');
const reloadBtnSVG = document.querySelector('#reload-button svg');
void reloadBtnSVG.offsetWidth; // Force reflow to reset the animation
reloadBtnSVG.classList.add("rotate");
isReloadButtonVisible = false;
setTimeout(() => {
reloadBtnSVG.classList.remove("rotate");
reloadBtn.style.transition = 'opacity 0.5s ease';
reloadBtn.style.opacity = '1';
reloadBtn.style.pointer = 'cursor';
isReloadButtonVisible = true;
}, 10000);
});
}
if (!window.__appAudioEndedBound) {
const globalAudio = document.getElementById('globalAudio');
if (globalAudio) {
globalAudio.addEventListener('ended', () => {
reloadDirectory().then(() => {
// If we had a track playing, try to find it in the updated list.
if (currentTrackPath) {
const newIndex = currentMusicFiles.findIndex(file => file.path === currentTrackPath);
currentMusicIndex = newIndex;
}
// Now, if there's a next track, auto-play it.
if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) {
const nextFile = currentMusicFiles[currentMusicIndex + 1];
const nextLink = document.querySelector(`.play-file[data-url=\"${nextFile.path}\"]`);
if (nextLink) {
nextLink.click();
}
}
}).catch(error => {
console.error('Error during reload:', error);
});
});
window.__appAudioEndedBound = true;
}
}
// document.addEventListener("DOMContentLoaded", function() {
// // Automatically reload every 5 minutes (300,000 milliseconds)
// setInterval(reloadDirectory, 300000);
// });
function syncThemeColor() {
const cssVar = getComputedStyle(document.documentElement)
.getPropertyValue('--dark-background')
.trim();
if (!cssVar) return;
// sync the themecolor meta tag
document.querySelector('meta[name="theme-color"]')
.setAttribute('content', cssVar);
// apply fill to every <svg> inside elements with .icon-color
document
.querySelectorAll('.icon-color svg')
.forEach(svg => svg.setAttribute('fill', cssVar));
}
// syncThemeColor is called during app init.
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
window.PageRegistry.register('app', { init: initAppPage });
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAppPage);
} else {
initAppPage();
}
}