freax-media/assets/js/app.js

564 lines
17 KiB
JavaScript

// Freax Media Player - Wizard Frontend
const VIDEO_EXTENSIONS = ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'];
const LIBRARY_LABELS = { videos: 'Videos', music: 'Music' };
const state = {
step: 1,
currentType: null,
filesByType: { videos: null, music: null },
currentNode: null,
currentPathParts: [],
selectedFilePath: null
};
const mediaDebugEnabled = (() => {
const params = new URLSearchParams(window.location.search);
if (params.get('debug') === '1') return true;
try {
return window.localStorage.getItem('MEDIA_DEBUG') === '1';
} catch (_) {
return false;
}
})();
const $ = id => document.getElementById(id);
function logMediaDebug(details) {
if (!mediaDebugEnabled) return;
const {
type,
source,
url,
status,
cacheStatus,
backendMs,
frontendFetchMs,
frontendParseMs,
renderMs,
payloadBytes,
forceRefresh,
cacheGeneratedAt,
error
} = details;
console.groupCollapsed(`[media-debug] ${type}`);
if (source) console.log('source:', source);
if (url) console.log('url:', url);
if (typeof status !== 'undefined') console.log('status:', status);
if (cacheStatus) console.log('cache:', cacheStatus);
if (backendMs) console.log('backend_ms:', backendMs);
if (typeof frontendFetchMs === 'number') console.log('frontend_fetch_ms:', Math.round(frontendFetchMs));
if (typeof frontendParseMs === 'number') console.log('frontend_parse_ms:', Math.round(frontendParseMs));
if (typeof renderMs === 'number') console.log('render_ms:', Math.round(renderMs));
if (typeof payloadBytes === 'number') console.log('payload_bytes:', payloadBytes);
if (forceRefresh) console.log('force_refresh:', forceRefresh);
if (cacheGeneratedAt) console.log('cache_generated_at:', cacheGeneratedAt);
if (error) console.log('error:', error);
console.groupEnd();
}
function showLogin(message = '') {
$('loginOverlay').style.display = 'flex';
$('logoutBtn').style.display = 'none';
$('loginError').textContent = message;
$('loginError').style.display = message ? 'block' : 'none';
}
function hideLogin() {
$('loginOverlay').style.display = 'none';
$('loginError').style.display = 'none';
$('logoutBtn').style.display = 'inline-block';
}
async function authedFetch(url, options = {}) {
const resp = await fetch(url, { ...options, credentials: 'same-origin' });
if (resp.status === 401) {
showLogin('Please log in to continue.');
throw new Error('Unauthorized');
}
return resp;
}
document.addEventListener('DOMContentLoaded', () => {
setupLoginHandlers();
setupWizardListeners();
initApp();
});
function setupLoginHandlers() {
$('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = $('username').value.trim();
const password = $('password').value;
$('loginError').style.display = 'none';
$('loginSpinner').style.display = 'inline-block';
try {
const resp = await fetch('login.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.error || 'Login failed');
}
hideLogin();
initApp();
} catch (ex) {
$('loginError').textContent = ex.message || 'Login failed';
$('loginError').style.display = 'block';
} finally {
$('loginSpinner').style.display = 'none';
}
});
$('logoutBtn').addEventListener('click', async () => {
try {
await fetch('logout.php', { method: 'POST', credentials: 'same-origin' });
} catch (_) {}
state.step = 1;
state.currentType = null;
state.currentNode = null;
state.currentPathParts = [];
state.selectedFilePath = null;
state.filesByType = { videos: null, music: null };
updateWizardStep();
showLibraryPicker();
$('fileBrowser').innerHTML = '<p style="color: var(--text-secondary); padding: 1rem;">Choose a library to browse files.</p>';
showLogin('Logged out.');
$('playerContainer').innerHTML = `
<div class="player-placeholder">
<div class="player-placeholder-icon">🎵</div>
<p>Select a file to start playing</p>
</div>
`;
$('nowPlaying').style.display = 'none';
resetAudioTrackUI();
});
}
function setupWizardListeners() {
document.querySelectorAll('[data-library]').forEach(btn => {
btn.addEventListener('click', () => {
selectLibrary(btn.dataset.library).catch(() => {});
});
});
$('backBtn').addEventListener('click', () => {
if (state.currentPathParts.length === 0) {
showLibraryPicker();
return;
}
state.currentPathParts.pop();
state.currentNode = getNodeAtPath(state.currentType, state.currentPathParts);
renderCurrentDirectory();
});
$('rootBtn').addEventListener('click', () => {
if (!state.currentType) return;
goToLibraryRoot();
});
$('refreshBtn').addEventListener('click', () => {
if (!state.currentType) return;
selectLibrary(state.currentType, { forceRefresh: true }).catch(() => {});
});
}
function initApp() {
updateWizardStep();
showLibraryPicker();
const urlParams = new URLSearchParams(window.location.search);
const playPath = urlParams.get('play');
if (!playPath) {
return;
}
const ext = playPath.toLowerCase().split('.').pop();
const type = VIDEO_EXTENSIONS.includes(ext) ? 'videos' : 'music';
selectLibrary(type).then(() => {
navigateToFile(playPath);
}).catch(() => {});
}
async function selectLibrary(type, options = {}) {
const { forceRefresh = false } = options;
if (!LIBRARY_LABELS[type]) return;
setLibraryButtonActive(type);
showBrowserStep();
const loaded = await ensureLibraryData(type, forceRefresh);
if (!loaded) return;
state.currentType = type;
goToLibraryRoot();
}
function goToLibraryRoot() {
state.currentPathParts = [];
state.currentNode = getNodeAtPath(state.currentType, state.currentPathParts);
renderCurrentDirectory();
}
async function ensureLibraryData(type, forceRefresh = false) {
if (!forceRefresh && state.filesByType[type] !== null) {
logMediaDebug({ type, source: 'memory' });
hideLogin();
return true;
}
const browser = $('fileBrowser');
browser.innerHTML = '<div class="loading"></div>';
const url = `get_files.php?type=${encodeURIComponent(type)}${forceRefresh ? '&refresh=1' : ''}`;
const startedAt = performance.now();
try {
const resp = await authedFetch(url);
const fetchedAt = performance.now();
const debugHeaders = {
cacheStatus: resp.headers.get('X-Media-Cache'),
backendMs: resp.headers.get('X-Media-Time-Ms'),
forceRefresh: resp.headers.get('X-Media-Force-Refresh'),
cacheGeneratedAt: resp.headers.get('X-Media-Cache-Generated-At')
};
const data = await resp.json();
const parsedAt = performance.now();
if (data.error) {
browser.innerHTML = `<p style="color: #ff6b6b; padding: 1rem;">${data.error}</p>`;
logMediaDebug({
type,
source: 'network',
url,
status: resp.status,
cacheStatus: debugHeaders.cacheStatus,
backendMs: debugHeaders.backendMs,
frontendFetchMs: fetchedAt - startedAt,
frontendParseMs: parsedAt - fetchedAt,
forceRefresh: debugHeaders.forceRefresh,
cacheGeneratedAt: debugHeaders.cacheGeneratedAt,
error: data.error
});
return false;
}
const normalizedTree = data.message ? {} : data;
state.filesByType[type] = normalizedTree;
hideLogin();
let payloadBytes = null;
try {
payloadBytes = JSON.stringify(data).length;
} catch (_) {
payloadBytes = null;
}
logMediaDebug({
type,
source: 'network',
url,
status: resp.status,
cacheStatus: debugHeaders.cacheStatus,
backendMs: debugHeaders.backendMs,
frontendFetchMs: fetchedAt - startedAt,
frontendParseMs: parsedAt - fetchedAt,
payloadBytes,
forceRefresh: debugHeaders.forceRefresh,
cacheGeneratedAt: debugHeaders.cacheGeneratedAt
});
return true;
} catch (error) {
console.error('Error loading files:', error);
logMediaDebug({
type,
source: 'network',
url,
frontendFetchMs: performance.now() - startedAt,
error: error.message || String(error)
});
if (error.message !== 'Unauthorized') {
browser.innerHTML = '<p style="color: #ff6b6b; padding: 1rem;">Error loading files</p>';
}
return false;
}
}
function getNodeAtPath(type, pathParts) {
let node = state.filesByType[type] || {};
for (const part of pathParts) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return {};
}
const next = node[part];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
return {};
}
node = next;
}
return node;
}
function renderCurrentDirectory() {
const renderStartedAt = performance.now();
const browser = $('fileBrowser');
const node = state.currentNode;
updateWizardStep();
updateWizardPath();
browser.innerHTML = '';
if (!node || typeof node !== 'object' || Array.isArray(node)) {
browser.innerHTML = '<p style="color: var(--text-secondary); padding: 1rem;">No entries found.</p>';
return;
}
const entries = Object.entries(node);
if (entries.length === 0) {
browser.innerHTML = '<p style="color: var(--text-secondary); padding: 1rem;">No media files found.</p>';
return;
}
for (const [name, content] of entries) {
const item = document.createElement('div');
item.className = 'file-item';
if (typeof content === 'object' && !Array.isArray(content)) {
item.classList.add('folder-entry');
item.innerHTML = `<span class="file-icon">📁</span><span>${name}</span>`;
item.addEventListener('click', () => {
state.currentPathParts.push(name);
state.currentNode = content;
renderCurrentDirectory();
});
} else if (typeof content === 'string') {
item.dataset.kind = 'file';
item.dataset.path = content;
item.dataset.name = name;
item.innerHTML = `<span class="file-icon">${state.currentType === 'videos' ? '🎬' : '🎵'}</span><span>${name}</span>`;
if (content === state.selectedFilePath) {
item.classList.add('active');
}
item.addEventListener('click', () => {
navigateToPlayback(content);
});
} else {
continue;
}
browser.appendChild(item);
}
logMediaDebug({
type: state.currentType,
source: 'memory',
renderMs: performance.now() - renderStartedAt
});
}
function navigateToFile(filePath) {
if (!state.currentType) return;
const parts = filePath.split('/').filter(Boolean);
const rootName = state.currentType === 'videos' ? 'Videos' : 'Music';
if (parts[0] && parts[0].toLowerCase() === rootName.toLowerCase()) {
parts.shift();
}
const fileName = parts.pop();
if (!fileName) return;
let node = state.filesByType[state.currentType] || {};
const folderParts = [];
for (const part of parts) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
break;
}
const next = node[part];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
break;
}
folderParts.push(part);
node = next;
}
state.currentPathParts = folderParts;
state.currentNode = node;
const fileValue = node[fileName];
if (typeof fileValue === 'string') {
state.selectedFilePath = fileValue;
playFile(fileValue, fileName);
requestAnimationFrame(() => {
renderCurrentDirectory();
});
return;
}
renderCurrentDirectory();
}
function updateWizardStep() {
const stepText = {
1: 'Step 1 - Choose Library',
2: 'Step 2 - Browse',
3: 'Step 3 - Play'
}[state.step] || 'Step 1 - Choose Library';
$('wizardStep').textContent = stepText;
}
function showLibraryPicker() {
state.step = 1;
updateWizardStep();
$('libraryPicker').style.display = 'grid';
$('browserStep').style.display = 'none';
document.querySelectorAll('[data-library]').forEach(btn => btn.classList.remove('active'));
}
function showBrowserStep() {
state.step = 2;
updateWizardStep();
$('libraryPicker').style.display = 'none';
$('browserStep').style.display = 'grid';
}
function updateWizardPath() {
if (!state.currentType) return;
const label = LIBRARY_LABELS[state.currentType] || state.currentType;
const path = state.currentPathParts.length ? `/${state.currentPathParts.join('/')}` : '/';
$('currentLibraryLabel').textContent = label;
$('breadcrumb').textContent = path;
$('backBtn').disabled = state.currentPathParts.length === 0;
$('rootBtn').disabled = state.currentPathParts.length === 0;
}
function setLibraryButtonActive(type) {
document.querySelectorAll('[data-library]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.library === type);
});
}
function navigateToPlayback(path) {
const params = new URLSearchParams();
params.set('play', path);
const currentParams = new URLSearchParams(window.location.search);
if (currentParams.get('debug') === '1') {
params.set('debug', '1');
}
window.location.href = `index.php?${params.toString()}`;
}
function playFile(path, name) {
const container = $('playerContainer');
const ext = path.toLowerCase().split('.').pop();
const isVideo = VIDEO_EXTENSIONS.includes(ext);
const media = document.createElement(isVideo ? 'video' : 'audio');
media.controls = true;
media.autoplay = true;
media.src = `serve_media.php?file=${encodeURIComponent(path)}`;
media.addEventListener('error', () => {
container.innerHTML = `
<div class="player-placeholder">
<div class="player-placeholder-icon">⚠️</div>
<p style="color: #ff6b6b;">Error playing: ${name}</p>
</div>
`;
});
media.addEventListener('ended', playNextItem);
if (isVideo) {
media.addEventListener('loadedmetadata', () => setupAudioTrackSelector(media));
}
container.innerHTML = '';
container.appendChild(media);
$('nowPlayingTitle').textContent = name;
$('nowPlaying').style.display = 'block';
state.step = 3;
updateWizardStep();
resetAudioTrackUI();
}
function playNextItem() {
const files = Array.from($('fileBrowser').querySelectorAll('.file-item[data-kind="file"]'));
if (files.length === 0) return;
const currentIdx = files.findIndex(el => el.classList.contains('active'));
if (currentIdx < 0) return;
const next = files[currentIdx + 1];
if (next) {
next.click();
}
}
function resetAudioTrackUI() {
$('audioControls').style.display = 'none';
$('audioHint').style.display = 'none';
$('audioHint').textContent = '';
$('audioSelect').innerHTML = '';
}
function setupAudioTrackSelector(videoEl) {
const tracks = videoEl.audioTracks;
if (!tracks || tracks.length <= 1) {
if (!tracks) {
$('audioHint').textContent = 'Audio track switching not supported for this browser.';
$('audioHint').style.display = 'block';
}
return;
}
$('audioSelect').innerHTML = '';
for (let i = 0; i < tracks.length; i++) {
const opt = document.createElement('option');
opt.value = i;
const label = tracks[i].label?.trim() || `Track ${i + 1}`;
opt.textContent = label;
$('audioSelect').appendChild(opt);
if (tracks[i].enabled) opt.selected = true;
}
$('audioSelect').onchange = () => {
const idx = parseInt($('audioSelect').value, 10);
for (let i = 0; i < tracks.length; i++) {
tracks[i].enabled = (i === idx);
}
};
$('audioHint').style.display = 'none';
$('audioControls').style.display = 'flex';
}