575 lines
17 KiB
JavaScript
575 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 buildMediaStreamUrl(path) {
|
|
const normalized = String(path || '').replace(/\\/g, '/');
|
|
const encodedPath = normalized
|
|
.split('/')
|
|
.filter(Boolean)
|
|
.map(part => encodeURIComponent(part))
|
|
.join('/');
|
|
|
|
return `/media/${encodedPath}`;
|
|
}
|
|
|
|
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 = buildMediaStreamUrl(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';
|
|
}
|