Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting
- Add Gitea Actions workflows: test.yml (lint + test on push/PR) and release.yml (build + NSIS installer + upload on v* tags) - Add NSIS installer with optional desktop shortcut and auto-start - Add esbuild bundler: ES module migration with IIFE bundle output - Add build-dist-windows.sh for cross-building Windows distribution - Fix all ruff lint errors (import sorting, unused imports, line length) - Remove redundant scripts (start-server.bat, stop-server.bat, start-server-background.vbs) - Update CLAUDE.md with CI/CD and release documentation
This commit is contained in:
@@ -1,5 +1,131 @@
|
||||
// ============================================================
|
||||
// Main: Initialization orchestrator (loaded last)
|
||||
// App: Entry point — imports all modules, registers window globals,
|
||||
// and orchestrates initialization (replaces main.js)
|
||||
// ============================================================
|
||||
|
||||
// Layer 0: Core state & utilities
|
||||
import {
|
||||
cacheDom, dom, registerUpdateCallbacks,
|
||||
initLocale, fetchVersion, formatTime, setupIconPreview,
|
||||
isUserAdjustingVolume, setIsUserAdjustingVolume,
|
||||
volumeUpdateTimer, setVolumeUpdateTimer,
|
||||
currentDuration, currentPosition, setVolume, seek,
|
||||
togglePlayPause, nextTrack, previousTrack, toggleMute,
|
||||
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
|
||||
changeLocale, t,
|
||||
} from './core.js';
|
||||
|
||||
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
|
||||
import {
|
||||
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
|
||||
initTheme, toggleTheme, initAccentColor, applyAccentColor,
|
||||
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
|
||||
toggleVinylMode, applyVinylMode,
|
||||
visualizerEnabled, visualizerAvailable,
|
||||
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
||||
loadAudioDevices, onAudioDeviceChanged,
|
||||
setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation,
|
||||
} from './player.js';
|
||||
|
||||
// Layer 2: WebSocket
|
||||
import {
|
||||
connectWebSocket, showAuthForm, authenticate, clearToken,
|
||||
manualReconnect, updateConnectionStatus,
|
||||
} from './websocket.js';
|
||||
|
||||
// Layer 3: Features
|
||||
import {
|
||||
loadScripts, loadScriptsTable, displayQuickAccess,
|
||||
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
|
||||
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
|
||||
closeExecutionDialog, scriptFormDirty, setScriptFormDirty,
|
||||
} from './scripts.js';
|
||||
|
||||
import {
|
||||
loadCallbacksTable,
|
||||
showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog,
|
||||
saveCallback, deleteCallbackConfirm,
|
||||
callbackFormDirty, setCallbackFormDirty,
|
||||
} from './callbacks.js';
|
||||
|
||||
import {
|
||||
loadMediaFolders, initBrowserToolbar, thumbnailCache,
|
||||
setViewMode, refreshBrowser, playAllFolder,
|
||||
previousPage, nextPage, goToPage,
|
||||
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
|
||||
downloadFile, closeFolderDialog, saveFolder,
|
||||
showManageFoldersDialog,
|
||||
} from './browser.js';
|
||||
|
||||
import {
|
||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
|
||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
|
||||
linkFormDirty, setLinkFormDirty,
|
||||
} from './links.js';
|
||||
|
||||
import {
|
||||
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
|
||||
} from './background.js';
|
||||
|
||||
// ============================================================
|
||||
// Register late-bound callbacks for core's updateAllText()
|
||||
// ============================================================
|
||||
|
||||
registerUpdateCallbacks({
|
||||
updatePlaybackState,
|
||||
updateConnectionStatus,
|
||||
loadScriptsTable,
|
||||
loadCallbacksTable,
|
||||
loadLinksTable,
|
||||
displayQuickAccess,
|
||||
renderAccentSwatches,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Register all functions on window for HTML onclick handlers
|
||||
// ============================================================
|
||||
|
||||
Object.assign(window, {
|
||||
// Player controls
|
||||
togglePlayPause, nextTrack, previousTrack, toggleMute, seek,
|
||||
// Tabs
|
||||
switchTab,
|
||||
// Theme & accent
|
||||
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
|
||||
// Vinyl & visualizer
|
||||
toggleVinylMode, toggleVisualizer,
|
||||
// Background
|
||||
toggleDynamicBackground,
|
||||
// Auth
|
||||
authenticate, clearToken, manualReconnect,
|
||||
// Locale
|
||||
changeLocale,
|
||||
// Scripts
|
||||
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
|
||||
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
|
||||
closeExecutionDialog,
|
||||
// Callbacks
|
||||
showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog,
|
||||
saveCallback, deleteCallbackConfirm,
|
||||
// Browser
|
||||
setViewMode, refreshBrowser, playAllFolder,
|
||||
previousPage, nextPage, goToPage,
|
||||
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
|
||||
downloadFile, closeFolderDialog, saveFolder,
|
||||
showManageFoldersDialog,
|
||||
// Links
|
||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
|
||||
saveLink, deleteLinkConfirm,
|
||||
// Display
|
||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||
toggleDisplayPower,
|
||||
// Audio device
|
||||
onAudioDeviceChanged,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Initialization (DOMContentLoaded)
|
||||
// ============================================================
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
@@ -50,7 +176,7 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
function setupVolumeSlider(sliderId) {
|
||||
const slider = document.getElementById(sliderId);
|
||||
slider.addEventListener('input', (e) => {
|
||||
isUserAdjustingVolume = true;
|
||||
setIsUserAdjustingVolume(true);
|
||||
const volume = parseInt(e.target.value);
|
||||
// Sync both sliders and displays
|
||||
dom.volumeDisplay.textContent = `${volume}%`;
|
||||
@@ -59,20 +185,20 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
dom.miniVolumeSlider.value = volume;
|
||||
|
||||
if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = setTimeout(() => {
|
||||
setVolumeUpdateTimer(setTimeout(() => {
|
||||
setVolume(volume);
|
||||
volumeUpdateTimer = null;
|
||||
}, VOLUME_THROTTLE_MS);
|
||||
setVolumeUpdateTimer(null);
|
||||
}, VOLUME_THROTTLE_MS));
|
||||
});
|
||||
|
||||
slider.addEventListener('change', (e) => {
|
||||
if (volumeUpdateTimer) {
|
||||
clearTimeout(volumeUpdateTimer);
|
||||
volumeUpdateTimer = null;
|
||||
setVolumeUpdateTimer(null);
|
||||
}
|
||||
const volume = parseInt(e.target.value);
|
||||
setVolume(volume);
|
||||
setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS);
|
||||
setTimeout(() => { setIsUserAdjustingVolume(false); }, VOLUME_RELEASE_DELAY_MS);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,25 +250,24 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Script form dirty state tracking
|
||||
const scriptForm = document.getElementById('scriptForm');
|
||||
scriptForm.addEventListener('input', () => {
|
||||
scriptFormDirty = true;
|
||||
setScriptFormDirty(true);
|
||||
});
|
||||
scriptForm.addEventListener('change', () => {
|
||||
scriptFormDirty = true;
|
||||
setScriptFormDirty(true);
|
||||
});
|
||||
|
||||
// Callback form dirty state tracking
|
||||
const callbackForm = document.getElementById('callbackForm');
|
||||
callbackForm.addEventListener('input', () => {
|
||||
callbackFormDirty = true;
|
||||
setCallbackFormDirty(true);
|
||||
});
|
||||
callbackForm.addEventListener('change', () => {
|
||||
callbackFormDirty = true;
|
||||
setCallbackFormDirty(true);
|
||||
});
|
||||
|
||||
// Script dialog backdrop click to close
|
||||
const scriptDialog = document.getElementById('scriptDialog');
|
||||
scriptDialog.addEventListener('click', (e) => {
|
||||
// Check if click is on the backdrop (not the dialog content)
|
||||
if (e.target === scriptDialog) {
|
||||
closeScriptDialog();
|
||||
}
|
||||
@@ -151,7 +276,6 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Callback dialog backdrop click to close
|
||||
const callbackDialog = document.getElementById('callbackDialog');
|
||||
callbackDialog.addEventListener('click', (e) => {
|
||||
// Check if click is on the backdrop (not the dialog content)
|
||||
if (e.target === callbackDialog) {
|
||||
closeCallbackDialog();
|
||||
}
|
||||
@@ -200,10 +324,10 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Track link form dirty state
|
||||
const linkForm = document.getElementById('linkForm');
|
||||
linkForm.addEventListener('input', () => {
|
||||
linkFormDirty = true;
|
||||
setLinkFormDirty(true);
|
||||
});
|
||||
linkForm.addEventListener('change', () => {
|
||||
linkFormDirty = true;
|
||||
setLinkFormDirty(true);
|
||||
});
|
||||
|
||||
// Initialize browser toolbar and load folders
|
||||
@@ -2,6 +2,8 @@
|
||||
// Background: WebGL shader-based dynamic background
|
||||
// ============================================================
|
||||
|
||||
import { frequencyData } from './player.js';
|
||||
|
||||
let bgCanvas = null;
|
||||
let bgGL = null;
|
||||
let bgProgram = null;
|
||||
@@ -216,7 +218,7 @@ function resizeBackgroundCanvas() {
|
||||
|
||||
// ---- Cached color/theme updates (called on accent or theme change, not per-frame) ----
|
||||
|
||||
function updateBackgroundColors() {
|
||||
export function updateBackgroundColors() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accentHex = style.getPropertyValue('--accent').trim();
|
||||
if (accentHex && accentHex.length >= 7) {
|
||||
@@ -245,8 +247,8 @@ function renderBackgroundFrame() {
|
||||
|
||||
const time = performance.now() / 1000 - bgStartTime;
|
||||
|
||||
// Smooth audio data from the global frequencyData (shared with visualizer)
|
||||
if (typeof frequencyData !== 'undefined' && frequencyData && frequencyData.frequencies) {
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer)
|
||||
if (frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
@@ -296,13 +298,13 @@ function stopBackground() {
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
function toggleDynamicBackground() {
|
||||
export function toggleDynamicBackground() {
|
||||
bgEnabled = !bgEnabled;
|
||||
localStorage.setItem('dynamicBackground', bgEnabled);
|
||||
applyDynamicBackground();
|
||||
}
|
||||
|
||||
function applyDynamicBackground() {
|
||||
export function applyDynamicBackground() {
|
||||
const btn = document.getElementById('bgToggle');
|
||||
if (bgEnabled) {
|
||||
startBackground();
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
// Media Browser: Navigation, rendering, search, pagination
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
t, showToast, escapeHtml, closeDialog,
|
||||
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
|
||||
} from './core.js';
|
||||
|
||||
// Browser state
|
||||
let currentFolderId = null;
|
||||
let currentPath = '';
|
||||
@@ -13,11 +18,11 @@ let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
let cachedItems = null;
|
||||
let browserSearchTerm = '';
|
||||
let browserSearchTimer = null;
|
||||
const thumbnailCache = new Map();
|
||||
export const thumbnailCache = new Map();
|
||||
const THUMBNAIL_CACHE_MAX = 200;
|
||||
|
||||
// Load media folders on page load
|
||||
async function loadMediaFolders() {
|
||||
export async function loadMediaFolders() {
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) {
|
||||
@@ -169,11 +174,11 @@ async function browsePath(folderId, path, offset = 0, nocache = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreadcrumbs(currentPath, parentPath) {
|
||||
function renderBreadcrumbs(currentPathStr, parentPath) {
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
breadcrumb.innerHTML = '';
|
||||
|
||||
const parts = (currentPath || '').split('/').filter(p => p);
|
||||
const parts = (currentPathStr || '').split('/').filter(p => p);
|
||||
let path = '/';
|
||||
|
||||
// Home link (back to folder list)
|
||||
@@ -373,10 +378,10 @@ function renderBrowserGrid(items, container) {
|
||||
// Lazy load thumbnail
|
||||
loadThumbnail(thumbnail, item.name);
|
||||
} else {
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'browser-icon';
|
||||
icon.textContent = getFileIcon(item.type);
|
||||
thumbWrapper.appendChild(icon);
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'browser-icon';
|
||||
iconEl.textContent = getFileIcon(item.type);
|
||||
thumbWrapper.appendChild(iconEl);
|
||||
}
|
||||
|
||||
// Play overlay for media files
|
||||
@@ -527,11 +532,10 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
};
|
||||
|
||||
// Revoke previous blob URL if not managed by cache
|
||||
// (Cache is keyed by path, so check values)
|
||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||
let isCached = false;
|
||||
for (const url of thumbnailCache.values()) {
|
||||
if (url === imgElement.src) { isCached = true; break; }
|
||||
for (const cachedUrl of thumbnailCache.values()) {
|
||||
if (cachedUrl === imgElement.src) { isCached = true; break; }
|
||||
}
|
||||
if (!isCached) URL.revokeObjectURL(imgElement.src);
|
||||
}
|
||||
@@ -544,10 +548,10 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
if (isList) {
|
||||
parent.textContent = '\u{1F3B5}';
|
||||
} else {
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'browser-icon';
|
||||
icon.textContent = '\u{1F3B5}';
|
||||
parent.insertBefore(icon, parent.firstChild);
|
||||
const iconEl = document.createElement('div');
|
||||
iconEl.className = 'browser-icon';
|
||||
iconEl.textContent = '\u{1F3B5}';
|
||||
parent.insertBefore(iconEl, parent.firstChild);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -600,7 +604,7 @@ async function playMediaFile(fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function playAllFolder() {
|
||||
export async function playAllFolder() {
|
||||
if (playInProgress) return;
|
||||
playInProgress = true;
|
||||
const btn = document.getElementById('playAllBtn');
|
||||
@@ -634,7 +638,7 @@ async function playAllFolder() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(fileName, event) {
|
||||
export async function downloadFile(fileName, event) {
|
||||
if (event) event.stopPropagation();
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
@@ -699,19 +703,19 @@ function renderPagination() {
|
||||
nextBtn.disabled = currentPage === totalPages;
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
export function previousPage() {
|
||||
if (currentOffset >= itemsPerPage) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
export function nextPage() {
|
||||
if (currentOffset + itemsPerPage < totalItems) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshBrowser() {
|
||||
export function refreshBrowser() {
|
||||
if (currentFolderId) {
|
||||
browsePath(currentFolderId, currentPath, currentOffset, true);
|
||||
} else {
|
||||
@@ -720,7 +724,7 @@ function refreshBrowser() {
|
||||
}
|
||||
|
||||
// Browser search
|
||||
function onBrowserSearch() {
|
||||
export function onBrowserSearch() {
|
||||
const input = document.getElementById('browserSearchInput');
|
||||
const clearBtn = document.getElementById('browserSearchClear');
|
||||
const term = input.value.trim();
|
||||
@@ -735,7 +739,7 @@ function onBrowserSearch() {
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearBrowserSearch() {
|
||||
export function clearBrowserSearch() {
|
||||
const input = document.getElementById('browserSearchInput');
|
||||
input.value = '';
|
||||
document.getElementById('browserSearchClear').style.display = 'none';
|
||||
@@ -768,7 +772,7 @@ function showBrowserSearch(visible) {
|
||||
}
|
||||
}
|
||||
|
||||
function setViewMode(mode) {
|
||||
export function setViewMode(mode) {
|
||||
if (mode === viewMode) return;
|
||||
viewMode = mode;
|
||||
localStorage.setItem('mediaBrowser.viewMode', mode);
|
||||
@@ -786,7 +790,7 @@ function setViewMode(mode) {
|
||||
}
|
||||
}
|
||||
|
||||
function onItemsPerPageChanged() {
|
||||
export function onItemsPerPageChanged() {
|
||||
const select = document.getElementById('itemsPerPageSelect');
|
||||
itemsPerPage = parseInt(select.value);
|
||||
localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage);
|
||||
@@ -798,7 +802,7 @@ function onItemsPerPageChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage() {
|
||||
export function goToPage() {
|
||||
const pageInput = document.getElementById('pageInput');
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
let page = parseInt(pageInput.value);
|
||||
@@ -813,7 +817,7 @@ function goToPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function initBrowserToolbar() {
|
||||
export function initBrowserToolbar() {
|
||||
// Restore view mode
|
||||
const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
|
||||
viewMode = savedViewMode;
|
||||
@@ -865,18 +869,16 @@ function loadLastBrowserPath() {
|
||||
}
|
||||
|
||||
// Folder Management
|
||||
function showManageFoldersDialog() {
|
||||
export function showManageFoldersDialog() {
|
||||
// TODO: Implement folder management UI
|
||||
// For now, show a simple alert
|
||||
showToast(t('browser.manage_folders_hint'), 'info');
|
||||
}
|
||||
|
||||
function closeFolderDialog() {
|
||||
export function closeFolderDialog() {
|
||||
closeDialog(document.getElementById('folderDialog'));
|
||||
}
|
||||
|
||||
async function saveFolder(event) {
|
||||
export async function saveFolder(event) {
|
||||
event.preventDefault();
|
||||
// TODO: Implement folder save functionality
|
||||
closeFolderDialog();
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
// Callbacks: CRUD management
|
||||
// ============================================================
|
||||
|
||||
let callbackFormDirty = false;
|
||||
import { t, showToast, escapeHtml, closeDialog, showConfirm } from './core.js';
|
||||
|
||||
export let callbackFormDirty = false;
|
||||
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
|
||||
|
||||
let _loadCallbacksPromise = null;
|
||||
async function loadCallbacksTable() {
|
||||
export async function loadCallbacksTable() {
|
||||
if (_loadCallbacksPromise) return _loadCallbacksPromise;
|
||||
_loadCallbacksPromise = _loadCallbacksTableImpl();
|
||||
_loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; });
|
||||
@@ -59,7 +62,7 @@ async function _loadCallbacksTableImpl() {
|
||||
}
|
||||
}
|
||||
|
||||
function showAddCallbackDialog() {
|
||||
export function showAddCallbackDialog() {
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
const form = document.getElementById('callbackForm');
|
||||
const title = document.getElementById('callbackDialogTitle');
|
||||
@@ -75,7 +78,7 @@ function showAddCallbackDialog() {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditCallbackDialog(callbackName) {
|
||||
export async function showEditCallbackDialog(callbackName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('callbackDialog');
|
||||
const title = document.getElementById('callbackDialogTitle');
|
||||
@@ -115,7 +118,7 @@ async function showEditCallbackDialog(callbackName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeCallbackDialog() {
|
||||
export async function closeCallbackDialog() {
|
||||
if (callbackFormDirty) {
|
||||
if (!await showConfirm(t('callbacks.confirm.unsaved'))) {
|
||||
return;
|
||||
@@ -128,7 +131,7 @@ async function closeCallbackDialog() {
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveCallback(event) {
|
||||
export async function saveCallback(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
@@ -179,7 +182,7 @@ async function saveCallback(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCallbackConfirm(callbackName) {
|
||||
export async function deleteCallbackConfirm(callbackName) {
|
||||
if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
// ============================================================
|
||||
|
||||
// SVG path constants (avoid rebuilding innerHTML on every state update)
|
||||
const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
|
||||
const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
|
||||
const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||||
const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||||
const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||||
export const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
|
||||
export const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
|
||||
export const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
|
||||
export const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
|
||||
export const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
|
||||
export const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
|
||||
|
||||
// Empty state illustration SVGs
|
||||
const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
|
||||
const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
|
||||
function emptyStateHtml(svgStr, text) {
|
||||
export const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
|
||||
export const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
|
||||
export function emptyStateHtml(svgStr, text) {
|
||||
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
|
||||
}
|
||||
|
||||
// Media source registry: substring key → { name, icon }
|
||||
const MEDIA_SOURCES = {
|
||||
export const MEDIA_SOURCES = {
|
||||
'spotify': {
|
||||
name: 'Spotify',
|
||||
icon: '<svg viewBox="0 0 24 24"><path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>'
|
||||
@@ -89,7 +89,7 @@ const MEDIA_SOURCES = {
|
||||
},
|
||||
};
|
||||
|
||||
function resolveMediaSource(raw) {
|
||||
export function resolveMediaSource(raw) {
|
||||
if (!raw) return null;
|
||||
const lower = raw.toLowerCase();
|
||||
for (const [key, info] of Object.entries(MEDIA_SOURCES)) {
|
||||
@@ -99,8 +99,8 @@ function resolveMediaSource(raw) {
|
||||
}
|
||||
|
||||
// Cached DOM references (populated once after DOMContentLoaded)
|
||||
const dom = {};
|
||||
function cacheDom() {
|
||||
export const dom = {};
|
||||
export function cacheDom() {
|
||||
dom.trackTitle = document.getElementById('track-title');
|
||||
dom.artist = document.getElementById('artist');
|
||||
dom.album = document.getElementById('album');
|
||||
@@ -137,26 +137,35 @@ function cacheDom() {
|
||||
}
|
||||
|
||||
// Timing constants
|
||||
const VOLUME_THROTTLE_MS = 16;
|
||||
const POSITION_INTERPOLATION_MS = 100;
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
const TOAST_DURATION_MS = 3000;
|
||||
const WS_BACKOFF_BASE_MS = 3000;
|
||||
const WS_BACKOFF_MAX_MS = 30000;
|
||||
const WS_MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const WS_PING_INTERVAL_MS = 30000;
|
||||
const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
export const VOLUME_THROTTLE_MS = 16;
|
||||
export const POSITION_INTERPOLATION_MS = 100;
|
||||
export const SEARCH_DEBOUNCE_MS = 200;
|
||||
export const TOAST_DURATION_MS = 3000;
|
||||
export const WS_BACKOFF_BASE_MS = 3000;
|
||||
export const WS_BACKOFF_MAX_MS = 30000;
|
||||
export const WS_MAX_RECONNECT_ATTEMPTS = 20;
|
||||
export const WS_PING_INTERVAL_MS = 30000;
|
||||
export const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
|
||||
// Shared state (accessed across multiple modules)
|
||||
let ws = null;
|
||||
let currentState = 'idle';
|
||||
let currentDuration = 0;
|
||||
let currentPosition = 0;
|
||||
let isUserAdjustingVolume = false;
|
||||
let volumeUpdateTimer = null;
|
||||
let scripts = [];
|
||||
let lastStatus = null;
|
||||
let currentPlayState = 'idle';
|
||||
export let ws = null;
|
||||
export function setWs(value) { ws = value; }
|
||||
export let currentState = 'idle';
|
||||
export function setCurrentState(value) { currentState = value; }
|
||||
export let currentDuration = 0;
|
||||
export function setCurrentDuration(value) { currentDuration = value; }
|
||||
export let currentPosition = 0;
|
||||
export function setCurrentPosition(value) { currentPosition = value; }
|
||||
export let isUserAdjustingVolume = false;
|
||||
export function setIsUserAdjustingVolume(value) { isUserAdjustingVolume = value; }
|
||||
export let volumeUpdateTimer = null;
|
||||
export function setVolumeUpdateTimer(value) { volumeUpdateTimer = value; }
|
||||
export let scripts = [];
|
||||
export function setScripts(value) { scripts = value; }
|
||||
export let lastStatus = null;
|
||||
export function setLastStatus(value) { lastStatus = value; }
|
||||
export let currentPlayState = 'idle';
|
||||
export function setCurrentPlayState(value) { currentPlayState = value; }
|
||||
|
||||
// ============================================================
|
||||
// Internationalization (i18n)
|
||||
@@ -178,7 +187,7 @@ const fallbackTranslations = {
|
||||
'player.status.disconnected': 'Disconnected'
|
||||
};
|
||||
|
||||
function t(key, params = {}) {
|
||||
export function t(key, params = {}) {
|
||||
let text = translations[key] || fallbackTranslations[key] || key;
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]);
|
||||
@@ -208,7 +217,7 @@ function detectBrowserLocale() {
|
||||
return supportedLocales[langCode] ? langCode : 'en';
|
||||
}
|
||||
|
||||
async function initLocale() {
|
||||
export async function initLocale() {
|
||||
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
|
||||
await setLocale(savedLocale);
|
||||
}
|
||||
@@ -228,7 +237,7 @@ async function setLocale(locale) {
|
||||
document.body.classList.add('translations-loaded');
|
||||
}
|
||||
|
||||
function changeLocale() {
|
||||
export function changeLocale() {
|
||||
const select = document.getElementById('locale-select');
|
||||
const newLocale = select.value;
|
||||
if (newLocale && newLocale !== currentLocale) {
|
||||
@@ -244,6 +253,26 @@ function updateLocaleSelect() {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: updateAllText calls functions from other modules via late-bound references.
|
||||
// These are set from app.js after all modules are loaded.
|
||||
let _updatePlaybackState = null;
|
||||
let _updateConnectionStatus = null;
|
||||
let _loadScriptsTable = null;
|
||||
let _loadCallbacksTable = null;
|
||||
let _loadLinksTable = null;
|
||||
let _displayQuickAccess = null;
|
||||
let _renderAccentSwatches = null;
|
||||
|
||||
export function registerUpdateCallbacks(callbacks) {
|
||||
_updatePlaybackState = callbacks.updatePlaybackState;
|
||||
_updateConnectionStatus = callbacks.updateConnectionStatus;
|
||||
_loadScriptsTable = callbacks.loadScriptsTable;
|
||||
_loadCallbacksTable = callbacks.loadCallbacksTable;
|
||||
_loadLinksTable = callbacks.loadLinksTable;
|
||||
_displayQuickAccess = callbacks.displayQuickAccess;
|
||||
_renderAccentSwatches = callbacks.renderAccentSwatches;
|
||||
}
|
||||
|
||||
function updateAllText() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
@@ -259,9 +288,9 @@ function updateAllText() {
|
||||
});
|
||||
|
||||
// Re-apply dynamic content with new translations
|
||||
updatePlaybackState(currentState);
|
||||
if (_updatePlaybackState) _updatePlaybackState(currentState);
|
||||
const connected = ws && ws.readyState === WebSocket.OPEN;
|
||||
updateConnectionStatus(connected);
|
||||
if (_updateConnectionStatus) _updateConnectionStatus(connected);
|
||||
|
||||
if (lastStatus) {
|
||||
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
@@ -273,15 +302,15 @@ function updateAllText() {
|
||||
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (token) {
|
||||
loadScriptsTable();
|
||||
loadCallbacksTable();
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
if (_loadScriptsTable) _loadScriptsTable();
|
||||
if (_loadCallbacksTable) _loadCallbacksTable();
|
||||
if (_loadLinksTable) _loadLinksTable();
|
||||
if (_displayQuickAccess) _displayQuickAccess();
|
||||
}
|
||||
renderAccentSwatches();
|
||||
if (_renderAccentSwatches) _renderAccentSwatches();
|
||||
}
|
||||
|
||||
async function fetchVersion() {
|
||||
export async function fetchVersion() {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
if (response.ok) {
|
||||
@@ -300,20 +329,20 @@ async function fetchVersion() {
|
||||
// Shared Utilities
|
||||
// ============================================================
|
||||
|
||||
function formatTime(seconds) {
|
||||
export function formatTime(seconds) {
|
||||
if (!seconds || seconds < 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
export function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
export function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
@@ -331,7 +360,7 @@ function showToast(message, type = 'success') {
|
||||
}, TOAST_DURATION_MS);
|
||||
}
|
||||
|
||||
function closeDialog(dialog) {
|
||||
export function closeDialog(dialog) {
|
||||
dialog.classList.add('dialog-closing');
|
||||
dialog.addEventListener('animationend', () => {
|
||||
dialog.classList.remove('dialog-closing');
|
||||
@@ -339,7 +368,7 @@ function closeDialog(dialog) {
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
function showConfirm(message) {
|
||||
export function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('confirmDialog');
|
||||
const msg = document.getElementById('confirmDialogMessage');
|
||||
@@ -371,7 +400,7 @@ function showConfirm(message) {
|
||||
// API Commands
|
||||
// ============================================================
|
||||
|
||||
async function sendCommand(endpoint, body = null) {
|
||||
export async function sendCommand(endpoint, body = null) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
const options = {
|
||||
@@ -399,7 +428,7 @@ async function sendCommand(endpoint, body = null) {
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
export function togglePlayPause() {
|
||||
if (currentState === 'playing') {
|
||||
sendCommand('pause');
|
||||
} else {
|
||||
@@ -407,16 +436,16 @@ function togglePlayPause() {
|
||||
}
|
||||
}
|
||||
|
||||
function nextTrack() {
|
||||
export function nextTrack() {
|
||||
sendCommand('next');
|
||||
}
|
||||
|
||||
function previousTrack() {
|
||||
export function previousTrack() {
|
||||
sendCommand('previous');
|
||||
}
|
||||
|
||||
let lastSentVolume = -1;
|
||||
function setVolume(volume) {
|
||||
export function setVolume(volume) {
|
||||
if (volume === lastSentVolume) return;
|
||||
lastSentVolume = volume;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
@@ -426,11 +455,11 @@ function setVolume(volume) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
export function toggleMute() {
|
||||
sendCommand('mute');
|
||||
}
|
||||
|
||||
function seek(position) {
|
||||
export function seek(position) {
|
||||
sendCommand('seek', { position: position });
|
||||
}
|
||||
|
||||
@@ -448,7 +477,7 @@ function _persistMdiCache() {
|
||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
||||
}
|
||||
|
||||
async function fetchMdiIcon(iconName) {
|
||||
export async function fetchMdiIcon(iconName) {
|
||||
const name = iconName.replace(/^mdi:/, '');
|
||||
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||
|
||||
@@ -467,7 +496,7 @@ async function fetchMdiIcon(iconName) {
|
||||
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
}
|
||||
|
||||
async function resolveMdiIcons(container) {
|
||||
export async function resolveMdiIcons(container) {
|
||||
const els = container.querySelectorAll('[data-mdi-icon]');
|
||||
await Promise.all(Array.from(els).map(async (el) => {
|
||||
const icon = el.dataset.mdiIcon;
|
||||
@@ -477,7 +506,7 @@ async function resolveMdiIcons(container) {
|
||||
}));
|
||||
}
|
||||
|
||||
function setupIconPreview(inputId, previewId) {
|
||||
export function setupIconPreview(inputId, previewId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
if (!input || !preview) return;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// ============================================================
|
||||
// Display Brightness & Power Control
|
||||
// Display Brightness & Power Control + Links Management
|
||||
// ============================================================
|
||||
|
||||
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon } from './core.js';
|
||||
|
||||
let displayBrightnessTimers = {};
|
||||
const DISPLAY_THROTTLE_MS = 50;
|
||||
|
||||
async function loadDisplayMonitors() {
|
||||
export async function loadDisplayMonitors() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
|
||||
@@ -86,7 +88,7 @@ async function loadDisplayMonitors() {
|
||||
}
|
||||
}
|
||||
|
||||
function onDisplayBrightnessInput(monitorId, value) {
|
||||
export function onDisplayBrightnessInput(monitorId, value) {
|
||||
const label = document.getElementById(`brightness-val-${monitorId}`);
|
||||
if (label) label.textContent = `${value}%`;
|
||||
|
||||
@@ -97,7 +99,7 @@ function onDisplayBrightnessInput(monitorId, value) {
|
||||
}, DISPLAY_THROTTLE_MS);
|
||||
}
|
||||
|
||||
function onDisplayBrightnessChange(monitorId, value) {
|
||||
export function onDisplayBrightnessChange(monitorId, value) {
|
||||
if (displayBrightnessTimers[monitorId]) {
|
||||
clearTimeout(displayBrightnessTimers[monitorId]);
|
||||
displayBrightnessTimers[monitorId] = null;
|
||||
@@ -121,7 +123,7 @@ async function sendDisplayBrightness(monitorId, brightness) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDisplayPower(monitorId, monitorName) {
|
||||
export async function toggleDisplayPower(monitorId, monitorName) {
|
||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||
const isOn = btn && btn.classList.contains('on');
|
||||
const newState = !isOn;
|
||||
@@ -157,7 +159,7 @@ async function toggleDisplayPower(monitorId, monitorName) {
|
||||
// Header Quick Links
|
||||
// ============================================================
|
||||
|
||||
async function loadHeaderLinks() {
|
||||
export async function loadHeaderLinks() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
if (!token) return;
|
||||
|
||||
@@ -197,9 +199,10 @@ async function loadHeaderLinks() {
|
||||
// ============================================================
|
||||
|
||||
let _loadLinksPromise = null;
|
||||
let linkFormDirty = false;
|
||||
export let linkFormDirty = false;
|
||||
export function setLinkFormDirty(value) { linkFormDirty = value; }
|
||||
|
||||
async function loadLinksTable() {
|
||||
export async function loadLinksTable() {
|
||||
if (_loadLinksPromise) return _loadLinksPromise;
|
||||
_loadLinksPromise = _loadLinksTableImpl();
|
||||
_loadLinksPromise.finally(() => { _loadLinksPromise = null; });
|
||||
@@ -251,7 +254,7 @@ async function _loadLinksTableImpl() {
|
||||
}
|
||||
}
|
||||
|
||||
function showAddLinkDialog() {
|
||||
export function showAddLinkDialog() {
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
const form = document.getElementById('linkForm');
|
||||
const title = document.getElementById('linkDialogTitle');
|
||||
@@ -269,7 +272,7 @@ function showAddLinkDialog() {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditLinkDialog(linkName) {
|
||||
export async function showEditLinkDialog(linkName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('linkDialog');
|
||||
const title = document.getElementById('linkDialogTitle');
|
||||
@@ -320,7 +323,7 @@ async function showEditLinkDialog(linkName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeLinkDialog() {
|
||||
export async function closeLinkDialog() {
|
||||
if (linkFormDirty) {
|
||||
if (!await showConfirm(t('links.confirm.unsaved'))) {
|
||||
return;
|
||||
@@ -333,7 +336,7 @@ async function closeLinkDialog() {
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveLink(event) {
|
||||
export async function saveLink(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
@@ -385,7 +388,7 @@ async function saveLink(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLinkConfirm(linkName) {
|
||||
export async function deleteLinkConfirm(linkName) {
|
||||
if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
// Player: Tabs, theme, accent, vinyl, visualizer, UI updates
|
||||
// ============================================================
|
||||
|
||||
// Tab management
|
||||
let activeTab = 'player';
|
||||
import {
|
||||
dom, t, formatTime, showToast, resolveMediaSource,
|
||||
SVG_PLAY, SVG_PAUSE, SVG_STOP, SVG_IDLE, SVG_MUTED, SVG_UNMUTED,
|
||||
ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
|
||||
currentPosition, setCurrentPosition, isUserAdjustingVolume,
|
||||
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
|
||||
POSITION_INTERPOLATION_MS, seek,
|
||||
} from './core.js';
|
||||
import { updateBackgroundColors } from './background.js';
|
||||
import { loadDisplayMonitors } from './links.js';
|
||||
|
||||
function setMiniPlayerVisible(visible) {
|
||||
// Tab management
|
||||
export let activeTab = 'player';
|
||||
|
||||
export function setMiniPlayerVisible(visible) {
|
||||
const miniPlayer = document.getElementById('mini-player');
|
||||
if (visible) {
|
||||
miniPlayer.classList.remove('hidden');
|
||||
@@ -16,7 +27,7 @@ function setMiniPlayerVisible(visible) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateTabIndicator(btn, animate = true) {
|
||||
export function updateTabIndicator(btn, animate = true) {
|
||||
const indicator = document.getElementById('tabIndicator');
|
||||
if (!indicator || !btn) return;
|
||||
const tabBar = document.getElementById('tabBar');
|
||||
@@ -32,7 +43,7 @@ function updateTabIndicator(btn, animate = true) {
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
export function switchTab(tabName) {
|
||||
activeTab = tabName;
|
||||
|
||||
document.querySelectorAll('[data-tab-content]').forEach(el => {
|
||||
@@ -75,12 +86,12 @@ function switchTab(tabName) {
|
||||
}
|
||||
|
||||
// Theme management
|
||||
function initTheme() {
|
||||
export function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
export function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
@@ -100,17 +111,17 @@ function setTheme(theme) {
|
||||
metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212');
|
||||
}
|
||||
|
||||
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
|
||||
updateBackgroundColors();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
export function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
}
|
||||
|
||||
// Accent color management
|
||||
const accentPresets = [
|
||||
export const accentPresets = [
|
||||
{ name: 'Green', color: '#1db954', hover: '#1ed760' },
|
||||
{ name: 'Blue', color: '#3b82f6', hover: '#60a5fa' },
|
||||
{ name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' },
|
||||
@@ -122,7 +133,7 @@ const accentPresets = [
|
||||
{ name: 'Yellow', color: '#eab308', hover: '#facc15' },
|
||||
];
|
||||
|
||||
function lightenColor(hex, percent) {
|
||||
export function lightenColor(hex, percent) {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
|
||||
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
|
||||
@@ -130,7 +141,7 @@ function lightenColor(hex, percent) {
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
function initAccentColor() {
|
||||
export function initAccentColor() {
|
||||
const saved = localStorage.getItem('accentColor');
|
||||
if (saved) {
|
||||
const preset = accentPresets.find(p => p.color === saved);
|
||||
@@ -143,16 +154,16 @@ function initAccentColor() {
|
||||
renderAccentSwatches();
|
||||
}
|
||||
|
||||
function applyAccentColor(color, hover) {
|
||||
export function applyAccentColor(color, hover) {
|
||||
document.documentElement.style.setProperty('--accent', color);
|
||||
document.documentElement.style.setProperty('--accent-hover', hover);
|
||||
localStorage.setItem('accentColor', color);
|
||||
const dot = document.getElementById('accentDot');
|
||||
if (dot) dot.style.background = color;
|
||||
if (typeof updateBackgroundColors === 'function') updateBackgroundColors();
|
||||
updateBackgroundColors();
|
||||
}
|
||||
|
||||
function renderAccentSwatches() {
|
||||
export function renderAccentSwatches() {
|
||||
const dropdown = document.getElementById('accentDropdown');
|
||||
if (!dropdown) return;
|
||||
const current = localStorage.getItem('accentColor') || '#1db954';
|
||||
@@ -177,13 +188,13 @@ function renderAccentSwatches() {
|
||||
dropdown.innerHTML = swatches + customRow;
|
||||
}
|
||||
|
||||
function selectAccentColor(color, hover) {
|
||||
export function selectAccentColor(color, hover) {
|
||||
applyAccentColor(color, hover);
|
||||
renderAccentSwatches();
|
||||
document.getElementById('accentDropdown').classList.remove('open');
|
||||
}
|
||||
|
||||
function toggleAccentPicker() {
|
||||
export function toggleAccentPicker() {
|
||||
document.getElementById('accentDropdown').classList.toggle('open');
|
||||
}
|
||||
|
||||
@@ -225,14 +236,14 @@ function restoreVinylAngle() {
|
||||
setInterval(saveVinylAngle, 2000);
|
||||
window.addEventListener('beforeunload', saveVinylAngle);
|
||||
|
||||
function toggleVinylMode() {
|
||||
export function toggleVinylMode() {
|
||||
if (vinylMode) saveVinylAngle();
|
||||
vinylMode = !vinylMode;
|
||||
localStorage.setItem('vinylMode', vinylMode);
|
||||
applyVinylMode();
|
||||
}
|
||||
|
||||
function applyVinylMode() {
|
||||
export function applyVinylMode() {
|
||||
const container = document.querySelector('.album-art-container');
|
||||
const btn = document.getElementById('vinylToggle');
|
||||
if (!container) return;
|
||||
@@ -260,15 +271,16 @@ function updateVinylSpin() {
|
||||
}
|
||||
|
||||
// Audio Visualizer
|
||||
let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
||||
let visualizerAvailable = false;
|
||||
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
|
||||
export let visualizerAvailable = false;
|
||||
let visualizerCtx = null;
|
||||
let visualizerAnimFrame = null;
|
||||
let frequencyData = null;
|
||||
export let frequencyData = null;
|
||||
export function setFrequencyData(value) { frequencyData = value; }
|
||||
let smoothedFrequencies = null;
|
||||
const VISUALIZER_SMOOTHING = 0.15;
|
||||
|
||||
async function checkVisualizerAvailability() {
|
||||
export async function checkVisualizerAvailability() {
|
||||
try {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const resp = await fetch('/api/media/visualizer/status', {
|
||||
@@ -285,13 +297,13 @@ async function checkVisualizerAvailability() {
|
||||
if (btn) btn.style.display = visualizerAvailable ? '' : 'none';
|
||||
}
|
||||
|
||||
function toggleVisualizer() {
|
||||
export function toggleVisualizer() {
|
||||
visualizerEnabled = !visualizerEnabled;
|
||||
localStorage.setItem('visualizerEnabled', visualizerEnabled);
|
||||
applyVisualizerMode();
|
||||
}
|
||||
|
||||
function applyVisualizerMode() {
|
||||
export function applyVisualizerMode() {
|
||||
const container = document.querySelector('.album-art-container');
|
||||
const btn = document.getElementById('visualizerToggle');
|
||||
if (!container) return;
|
||||
@@ -333,7 +345,7 @@ function startVisualizerRender() {
|
||||
renderVisualizerFrame();
|
||||
}
|
||||
|
||||
function stopVisualizerRender() {
|
||||
export function stopVisualizerRender() {
|
||||
if (visualizerAnimFrame) {
|
||||
cancelAnimationFrame(visualizerAnimFrame);
|
||||
visualizerAnimFrame = null;
|
||||
@@ -410,7 +422,7 @@ function renderVisualizerFrame() {
|
||||
}
|
||||
|
||||
// Audio device selection
|
||||
async function loadAudioDevices() {
|
||||
export async function loadAudioDevices() {
|
||||
const section = document.getElementById('audioDeviceSection');
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
if (!section || !select) return;
|
||||
@@ -478,7 +490,7 @@ function updateAudioDeviceStatus(status) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onAudioDeviceChanged() {
|
||||
export async function onAudioDeviceChanged() {
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
if (!select) return;
|
||||
|
||||
@@ -519,7 +531,7 @@ let lastPositionUpdate = 0;
|
||||
let lastPositionValue = 0;
|
||||
let interpolationInterval = null;
|
||||
|
||||
function setupProgressDrag(bar, fill) {
|
||||
export function setupProgressDrag(bar, fill) {
|
||||
let dragging = false;
|
||||
|
||||
function getPercent(clientX) {
|
||||
@@ -571,8 +583,8 @@ function setupProgressDrag(bar, fill) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(status) {
|
||||
lastStatus = status;
|
||||
export function updateUI(status) {
|
||||
setLastStatus(status);
|
||||
|
||||
const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
||||
dom.trackTitle.textContent = status.title || fallbackTitle;
|
||||
@@ -583,7 +595,7 @@ function updateUI(status) {
|
||||
dom.miniArtist.textContent = status.artist || '';
|
||||
|
||||
const previousState = currentState;
|
||||
currentState = status.state;
|
||||
setCurrentState(status.state);
|
||||
updatePlaybackState(status.state);
|
||||
|
||||
const altText = status.title && status.artist
|
||||
@@ -628,8 +640,8 @@ function updateUI(status) {
|
||||
}
|
||||
|
||||
if (status.duration && status.position !== null) {
|
||||
currentDuration = status.duration;
|
||||
currentPosition = status.position;
|
||||
setCurrentDuration(status.duration);
|
||||
setCurrentPosition(status.position);
|
||||
lastPositionUpdate = Date.now();
|
||||
lastPositionValue = status.position;
|
||||
updateProgress(status.position, status.duration);
|
||||
@@ -661,8 +673,8 @@ function updateUI(status) {
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlaybackState(state) {
|
||||
currentPlayState = state;
|
||||
export function updatePlaybackState(state) {
|
||||
setCurrentPlayState(state);
|
||||
switch(state) {
|
||||
case 'playing':
|
||||
dom.playbackState.textContent = t('state.playing');
|
||||
@@ -715,7 +727,7 @@ function updateProgress(position, duration) {
|
||||
miniBar.setAttribute('aria-valuemax', durRound);
|
||||
}
|
||||
|
||||
function startPositionInterpolation() {
|
||||
export function startPositionInterpolation() {
|
||||
if (interpolationInterval) {
|
||||
clearInterval(interpolationInterval);
|
||||
}
|
||||
@@ -728,7 +740,7 @@ function startPositionInterpolation() {
|
||||
}, POSITION_INTERPOLATION_MS);
|
||||
}
|
||||
|
||||
function stopPositionInterpolation() {
|
||||
export function stopPositionInterpolation() {
|
||||
if (interpolationInterval) {
|
||||
clearInterval(interpolationInterval);
|
||||
interpolationInterval = null;
|
||||
|
||||
@@ -2,9 +2,16 @@
|
||||
// Scripts: CRUD, quick access, execution dialog
|
||||
// ============================================================
|
||||
|
||||
let scriptFormDirty = false;
|
||||
import {
|
||||
t, showToast, escapeHtml, closeDialog, showConfirm,
|
||||
resolveMdiIcons, fetchMdiIcon,
|
||||
scripts, setScripts,
|
||||
} from './core.js';
|
||||
|
||||
async function loadScripts() {
|
||||
export let scriptFormDirty = false;
|
||||
export function setScriptFormDirty(value) { scriptFormDirty = value; }
|
||||
|
||||
export async function loadScripts() {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
|
||||
try {
|
||||
@@ -15,7 +22,7 @@ async function loadScripts() {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
scripts = await response.json();
|
||||
setScripts(await response.json());
|
||||
displayQuickAccess();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -24,7 +31,7 @@ async function loadScripts() {
|
||||
}
|
||||
|
||||
let _quickAccessGen = 0;
|
||||
async function displayQuickAccess() {
|
||||
export async function displayQuickAccess() {
|
||||
const gen = ++_quickAccessGen;
|
||||
const grid = document.getElementById('scripts-grid');
|
||||
|
||||
@@ -150,7 +157,7 @@ async function executeScript(scriptName, buttonElement) {
|
||||
// ============================================================
|
||||
|
||||
let _loadScriptsPromise = null;
|
||||
async function loadScriptsTable() {
|
||||
export async function loadScriptsTable() {
|
||||
if (_loadScriptsPromise) return _loadScriptsPromise;
|
||||
_loadScriptsPromise = _loadScriptsTableImpl();
|
||||
_loadScriptsPromise.finally(() => { _loadScriptsPromise = null; });
|
||||
@@ -206,7 +213,7 @@ async function _loadScriptsTableImpl() {
|
||||
}
|
||||
}
|
||||
|
||||
function showAddScriptDialog() {
|
||||
export function showAddScriptDialog() {
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
const form = document.getElementById('scriptForm');
|
||||
const title = document.getElementById('dialogTitle');
|
||||
@@ -224,7 +231,7 @@ function showAddScriptDialog() {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function showEditScriptDialog(scriptName) {
|
||||
export async function showEditScriptDialog(scriptName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('scriptDialog');
|
||||
const title = document.getElementById('dialogTitle');
|
||||
@@ -274,7 +281,7 @@ async function showEditScriptDialog(scriptName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeScriptDialog() {
|
||||
export async function closeScriptDialog() {
|
||||
if (scriptFormDirty) {
|
||||
if (!await showConfirm(t('scripts.confirm.unsaved'))) {
|
||||
return;
|
||||
@@ -287,7 +294,7 @@ async function closeScriptDialog() {
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
async function saveScript(event) {
|
||||
export async function saveScript(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const submitBtn = event.target.querySelector('button[type="submit"]');
|
||||
@@ -341,7 +348,7 @@ async function saveScript(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteScriptConfirm(scriptName) {
|
||||
export async function deleteScriptConfirm(scriptName) {
|
||||
if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) {
|
||||
return;
|
||||
}
|
||||
@@ -373,7 +380,7 @@ async function deleteScriptConfirm(scriptName) {
|
||||
// Execution Result Dialog (shared by scripts and callbacks)
|
||||
// ============================================================
|
||||
|
||||
function closeExecutionDialog() {
|
||||
export function closeExecutionDialog() {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
@@ -435,7 +442,7 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
async function executeScriptDebug(scriptName) {
|
||||
export async function executeScriptDebug(scriptName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
@@ -486,7 +493,7 @@ async function executeScriptDebug(scriptName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCallbackDebug(callbackName) {
|
||||
export async function executeCallbackDebug(callbackName) {
|
||||
const token = localStorage.getItem('media_server_token');
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
// WebSocket: Connection, reconnection, authentication
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
dom, t, showToast, setWs,
|
||||
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
||||
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
||||
} from './core.js';
|
||||
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||
import { loadCallbacksTable } from './callbacks.js';
|
||||
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
||||
|
||||
let reconnectTimeout = null;
|
||||
let pingInterval = null;
|
||||
let wsReconnectAttempts = 0;
|
||||
|
||||
function showAuthForm(errorMessage = '') {
|
||||
export function showAuthForm(errorMessage = '') {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
@@ -23,7 +33,7 @@ function hideAuthForm() {
|
||||
document.getElementById('auth-overlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
function authenticate() {
|
||||
export function authenticate() {
|
||||
const token = document.getElementById('token-input').value.trim();
|
||||
if (!token) {
|
||||
showAuthForm(t('auth.required'));
|
||||
@@ -34,15 +44,18 @@ function authenticate() {
|
||||
connectWebSocket(token);
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('media_server_token');
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
// Access ws via import
|
||||
import('./core.js').then(core => {
|
||||
if (core.ws) {
|
||||
core.ws.close();
|
||||
}
|
||||
});
|
||||
showAuthForm(t('auth.cleared'));
|
||||
}
|
||||
|
||||
function connectWebSocket(token) {
|
||||
export function connectWebSocket(token) {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
@@ -51,9 +64,10 @@ function connectWebSocket(token) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
const newWs = new WebSocket(wsUrl);
|
||||
setWs(newWs);
|
||||
|
||||
ws.onopen = () => {
|
||||
newWs.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
wsReconnectAttempts = 0;
|
||||
updateConnectionStatus(true);
|
||||
@@ -66,11 +80,11 @@ function connectWebSocket(token) {
|
||||
loadHeaderLinks();
|
||||
loadAudioDevices();
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
ws.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
newWs.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
newWs.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||
@@ -85,18 +99,18 @@ function connectWebSocket(token) {
|
||||
loadLinksTable();
|
||||
displayQuickAccess();
|
||||
} else if (msg.type === 'audio_data') {
|
||||
frequencyData = msg.data;
|
||||
setFrequencyData(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('WebSocket error:', msg.message);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
newWs.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateConnectionStatus(false);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
newWs.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code);
|
||||
updateConnectionStatus(false);
|
||||
stopPositionInterpolation();
|
||||
@@ -131,13 +145,13 @@ function connectWebSocket(token) {
|
||||
};
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
if (newWs && newWs.readyState === WebSocket.OPEN) {
|
||||
newWs.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function updateConnectionStatus(connected) {
|
||||
export function updateConnectionStatus(connected) {
|
||||
if (connected) {
|
||||
dom.statusDot.classList.add('connected');
|
||||
} else {
|
||||
@@ -159,7 +173,7 @@ function hideConnectionBanner() {
|
||||
banner.classList.add('hidden');
|
||||
}
|
||||
|
||||
function manualReconnect() {
|
||||
export function manualReconnect() {
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken) {
|
||||
wsReconnectAttempts = 0;
|
||||
|
||||
Reference in New Issue
Block a user