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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user