Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting
Lint & Test / test (push) Failing after 9s
Release / create-release (push) Successful in 1s
Release / build-windows (push) Successful in 59s

- 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:
2026-03-23 02:01:28 +03:00
parent be48318212
commit 5439af1955
41 changed files with 1702 additions and 310 deletions
+86 -57
View File
@@ -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;