- Dialog modals: scale+fade entrance/exit with animated backdrop - Tab panels: fade-in with subtle slide on switch - Settings sections: content slide-down on expand - Browser grid/list items: staggered cascade entrance animation - Connection banner: slide-in + attention pulse on disconnect - Accessibility: prefers-reduced-motion disables all animations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
504 lines
24 KiB
JavaScript
504 lines
24 KiB
JavaScript
// ============================================================
|
|
// Core: Shared state, constants, utilities, i18n, API commands
|
|
// ============================================================
|
|
|
|
// 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"/>';
|
|
|
|
// 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) {
|
|
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
|
|
}
|
|
|
|
// Media source registry: substring key → { name, icon }
|
|
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>'
|
|
},
|
|
'yandex music': {
|
|
name: 'Yandex Music',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
|
|
},
|
|
'яндекс музыка': {
|
|
name: 'Яндекс Музыка',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#FFCC00" d="M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 2.4a9.6 9.6 0 110 19.2 9.6 9.6 0 010-19.2z"/><path fill="#FFCC00" d="M13.2 6h-2.4v7.2L7.2 6H4.8l5.4 12h1.2l.6-1.35V6z"/></svg>'
|
|
},
|
|
'chrome': {
|
|
name: 'Google Chrome',
|
|
icon: '<svg viewBox="0 0 24 24"><circle fill="#4587F3" cx="12" cy="12" r="11"/><path fill="#DB4437" d="M12 1C7.2 1 3.1 3.8 1.3 7.9L7.7 12l1.8-3.1c.7-1.1 1.9-1.9 3.3-1.9h9.7C21 3.5 16.9 1 12 1z"/><path fill="#0F9D58" d="M7.7 12L1.3 7.9C.5 9.2 0 10.6 0 12c0 4.5 2.8 8.4 6.8 10l3.8-6.6L7.7 12z"/><path fill="#FFCD40" d="M6.8 22c2.7 1.5 6.4 1.7 9.4.2 2.8-1.4 4.9-3.9 5.8-6.8l-6.5-3.4-1.8 3.1c-.7 1.1-1.9 1.9-3.3 1.9-.9 0-1.7-.3-2.4-.7L6.8 22z"/><circle fill="#F1F1F1" cx="12" cy="12" r="4.8"/><circle fill="#4587F3" cx="12" cy="12" r="3.8"/></svg>'
|
|
},
|
|
'msedge': {
|
|
name: 'Microsoft Edge',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#0078D4" d="M21.86 17.86q.14 0 .25-.12.1-.13.1-.25 0-.06 0-.13-.12-.76-.39-1.49-.26-.72-.65-1.39-.4-.66-.92-1.25-.53-.58-1.15-1.06-.61-.48-1.3-.85-.69-.37-1.44-.6-.75-.22-1.53-.3-.8-.07-1.6 0h-.04q-.51.03-1.03.14-.5.12-1 .31-.49.2-.95.46-.46.27-.89.6-.42.32-.8.7-.37.4-.69.83-.31.44-.57.92-.25.49-.44 1 .09-.14.21-.28.12-.14.26-.27.14-.12.3-.23.16-.1.33-.18.18-.08.37-.14.18-.06.38-.08.2-.02.4-.01.21.01.41.06.28.07.53.2.25.12.47.3.21.18.39.4.18.21.32.45.14.25.23.52.1.26.14.54.04.28.02.56-.02.36-.12.72-.1.35-.27.68-.17.33-.4.62-.24.3-.52.56-.28.25-.6.46-.32.2-.67.35.44.1.9.14.44.03.89-.02.45-.05.88-.17.44-.12.85-.3.41-.2.79-.44.37-.25.71-.55.34-.3.63-.65.3-.35.54-.73.24-.39.42-.8.18-.42.3-.86.12-.43.18-.88.06-.45.06-.9 0-.48-.07-.95-.07-.47-.22-.93z"/><path fill="#50E6FF" d="M11.89.03Q10.03.17 8.3.88 6.57 1.59 5.1 2.77 3.65 3.94 2.55 5.5 1.44 7.06.79 8.88.14 10.7 0 12.65q.01.22.02.45 0 .22.03.44.04.42.12.83.08.42.2.83.12.4.28.79.16.39.36.76.2.37.43.72.24.34.51.66.27.32.57.6.3.29.63.54.33.25.68.46.35.21.72.38.38.17.77.28.39.12.79.18.41.06.82.05.41 0 .82-.07.41-.08.79-.22.39-.14.74-.34.36-.2.68-.44.33-.25.6-.54.28-.3.5-.63.23-.33.4-.7.17-.36.27-.75-1.1.9-2.44 1.36-1.33.46-2.77.46-1.26 0-2.44-.39-1.18-.39-2.17-1.08-1-1.08-1.6-2.02-.6-.94-.87-2-.27-1.07-.25-2.2.02-.55.12-1.08.1-.54.29-1.05.18-.52.44-1 .27-.49.6-.94.34-.44.74-.83.4-.38.85-.71.45-.32.94-.57.49-.25 1.02-.42.52-.16 1.07-.24.55-.07 1.1-.05.81.04 1.57.25.77.2 1.46.56.7.36 1.29.85.6.5 1.07 1.1.48.6.82 1.29.34.69.54 1.44.2.76.24 1.55.04.79-.08 1.57-.11.78-.37 1.52-.26.74-.66 1.4-.39.67-.91 1.24-.52.57-1.14 1.02-.62.44-1.32.76-.7.32-1.45.49-.75.16-1.52.18z"/></svg>'
|
|
},
|
|
'firefox': {
|
|
name: 'Firefox',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#FF7139" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm6.73 7.27c-.47-.77-1.22-1.6-1.7-1.87.54.97.86 2.07.93 3.15 0 0-.02.03-.02.05-.38-1.34-1.14-2.15-1.78-3.05-.03-.05-.06-.1-.1-.15-.03-.05-.05-.1-.06-.15 0-.02-.01-.04-.02-.05l-.01.02c-.02.03-.03.05-.04.08 0 0 0 .01-.01.02l.01-.02c-.64 1.07-1.72 2.2-2.1 3.56-.46.01-.9.09-1.32.23l-.06.03c-.03-.2-.04-.4-.04-.6 0-.67.15-1.3.4-1.87-1.08.4-1.93 1.12-2.53 1.72-.33-.36-.36-1.56-.34-1.8-.01 0-.03.02-.04.02-.27.2-.52.42-.75.66-.28.3-.53.62-.76.96-.12.2-.24.4-.34.6-.15.32-.27.66-.36 1-.02.07-.03.14-.05.21v.03c-.06.3-.1.6-.12.9v.1c0 .07 0 .14-.01.21C7.3 13.8 7.52 16.37 9 18.26l.04.05c-1.55-1-2.57-2.64-2.87-4.42-.04.2-.06.4-.07.6-.01.2-.02.4-.01.6.02.6.13 1.2.3 1.77.2.57.46 1.12.8 1.62.17.25.36.48.56.7.2.22.42.43.66.62 1.83 1.47 4.17 1.87 6.34 1.21.26-.08.5-.17.74-.28 1.1-.5 2.06-1.27 2.78-2.23.03-.03.05-.07.07-.1.08-.1.15-.2.22-.32.5-.77.84-1.62 1.02-2.5.02-.1.04-.2.05-.3.1-.57.14-1.15.12-1.73 0-.1-.01-.19-.02-.29.06-1.2-.15-2.42-.63-3.53-.1-.23-.2-.45-.32-.67z"/></svg>'
|
|
},
|
|
'opera': {
|
|
name: 'Opera',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#FF1B2D" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12c2.75 0 5.28-.93 7.3-2.49-1.24.77-2.68 1.22-4.22 1.22-2.2 0-4.17-1.1-5.55-2.83C8.1 18.1 7.2 15.22 7.2 12s.9-6.1 2.33-7.9C10.91 2.37 12.88 1.27 15.08 1.27c1.54 0 2.98.45 4.22 1.22C17.28.93 14.75 0 12 0z"/><path fill="#FF1B2D" d="M15.08 1.27c-2.2 0-4.17 1.1-5.55 2.83C8.1 5.9 7.2 8.78 7.2 12s.9 6.1 2.33 7.9c1.38 1.73 3.35 2.83 5.55 2.83 2.2 0 4.17-1.1 5.55-2.83C22.06 18.1 22.96 15.22 22.96 12s-.9-6.1-2.33-7.9c-1.38-1.73-3.35-2.83-5.55-2.83z" opacity=".75"/></svg>'
|
|
},
|
|
'brave': {
|
|
name: 'Brave',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#FB542B" d="M12 0L3.6 4.8v9.6L12 24l8.4-9.6V4.8L12 0zm5.7 14.1l-1.2 1.8c-.3.3-.6.6-.9.9l-2.1 1.5-1.5.9-1.5-.9-2.1-1.5c-.3-.3-.6-.6-.9-.9l-1.2-1.8c-.3-.6-.3-1.2 0-1.5l.6-1.5.6-1.2.6-1.2.3-.6c.15-.3.45-.3.6 0l.6.9c.15.3.45.3.6 0l.6-.9.6-.9c.15-.3.45-.3.6 0l.6.9.6.9c.15.3.45.3.6 0l.6-.9c.15-.3.45-.3.6 0l.3.6.6 1.2.6 1.2.6 1.5c.3.3.3.9 0 1.5z"/></svg>'
|
|
},
|
|
'yandex': {
|
|
name: 'Yandex Browser',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#FF0000" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M13.5 5h-2.1l-3.9 8.1V5H5.4v14h2.1l4.05-8.55V19h2.1V5z"/></svg>'
|
|
},
|
|
'vlc': {
|
|
name: 'VLC',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#FF8800" d="M12 1.5L7.5 16h9L12 1.5z"/><path fill="#FF5722" d="M6 18.5c-1.5 0-2.5.5-2.5 1.5s2.5 2.5 8.5 2.5 8.5-1.5 8.5-2.5-1-1.5-2.5-1.5H6z"/><path fill="#FF8800" d="M6 18.5h12l-1.5-2.5h-9L6 18.5z"/></svg>'
|
|
},
|
|
'aimp': {
|
|
name: 'AIMP',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#F7A600" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M12 4l-7 14h3l1.5-3h5l1.5 3h3L12 4zm0 5l1.75 3.5h-3.5L12 9z"/></svg>'
|
|
},
|
|
'foobar': {
|
|
name: 'foobar2000',
|
|
icon: '<svg viewBox="0 0 24 24"><rect fill="#1F1A17" width="24" height="24" rx="4"/><path fill="#D89B2B" d="M6 6h3v12H6V6zm4.5 0H13v12h-2.5V6zm4 0H17v12h-2.5V6z"/></svg>'
|
|
},
|
|
'music.ui': {
|
|
name: 'Groove Music',
|
|
icon: '<svg viewBox="0 0 24 24"><circle fill="#7B83EB" cx="12" cy="12" r="11"/><path fill="#FFF" d="M15 7v7a3 3 0 11-2-2.83V7h2z"/></svg>'
|
|
},
|
|
'itunes': {
|
|
name: 'iTunes',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#EA4CC0" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
|
|
},
|
|
'apple music': {
|
|
name: 'Apple Music',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#FC3C44" d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0z"/><path fill="#FFF" d="M16.5 6.5l-7 1.75v7.25a2.5 2.5 0 11-1.5-2.29V9.5l7-1.75v4.75a2.5 2.5 0 11-1.5-2.29V6.5z" opacity=".9"/></svg>'
|
|
},
|
|
'deezer': {
|
|
name: 'Deezer',
|
|
icon: '<svg viewBox="0 0 24 24"><rect fill="#000" width="24" height="24" rx="4"/><g fill="#A238FF"><rect x="2" y="16" width="3" height="2" rx=".5"/><rect x="6.5" y="14" width="3" height="4" rx=".5"/><rect x="11" y="10" width="3" height="8" rx=".5"/><rect x="15.5" y="12" width="3" height="6" rx=".5"/><rect x="19" y="8" width="3" height="10" rx=".5"/></g></svg>'
|
|
},
|
|
'tidal': {
|
|
name: 'TIDAL',
|
|
icon: '<svg viewBox="0 0 24 24"><path fill="#000" d="M12 4.8L8 8.8l4 4-4 4-4-4 4-4-4-4 4-4 4 4zm4 0l4 4-4 4-4-4 4-4z"/></svg>'
|
|
},
|
|
};
|
|
|
|
function resolveMediaSource(raw) {
|
|
if (!raw) return null;
|
|
const lower = raw.toLowerCase();
|
|
for (const [key, info] of Object.entries(MEDIA_SOURCES)) {
|
|
if (lower.includes(key)) return info;
|
|
}
|
|
return { name: raw.replace(/\.exe$/i, ''), icon: null };
|
|
}
|
|
|
|
// Cached DOM references (populated once after DOMContentLoaded)
|
|
const dom = {};
|
|
function cacheDom() {
|
|
dom.trackTitle = document.getElementById('track-title');
|
|
dom.artist = document.getElementById('artist');
|
|
dom.album = document.getElementById('album');
|
|
dom.miniTrackTitle = document.getElementById('mini-track-title');
|
|
dom.miniArtist = document.getElementById('mini-artist');
|
|
dom.albumArt = document.getElementById('album-art');
|
|
dom.albumArtGlow = document.getElementById('album-art-glow');
|
|
dom.miniAlbumArt = document.getElementById('mini-album-art');
|
|
dom.volumeSlider = document.getElementById('volume-slider');
|
|
dom.volumeDisplay = document.getElementById('volume-display');
|
|
dom.miniVolumeSlider = document.getElementById('mini-volume-slider');
|
|
dom.miniVolumeDisplay = document.getElementById('mini-volume-display');
|
|
dom.progressFill = document.getElementById('progress-fill');
|
|
dom.currentTime = document.getElementById('current-time');
|
|
dom.totalTime = document.getElementById('total-time');
|
|
dom.progressBar = document.getElementById('progress-bar');
|
|
dom.miniProgressFill = document.getElementById('mini-progress-fill');
|
|
dom.miniCurrentTime = document.getElementById('mini-current-time');
|
|
dom.miniTotalTime = document.getElementById('mini-total-time');
|
|
dom.playbackState = document.getElementById('playback-state');
|
|
dom.stateIcon = document.getElementById('state-icon');
|
|
dom.playPauseIcon = document.getElementById('play-pause-icon');
|
|
dom.miniPlayPauseIcon = document.getElementById('mini-play-pause-icon');
|
|
dom.muteIcon = document.getElementById('mute-icon');
|
|
dom.miniMuteIcon = document.getElementById('mini-mute-icon');
|
|
dom.statusDot = document.getElementById('status-dot');
|
|
dom.source = document.getElementById('source');
|
|
dom.sourceIcon = document.getElementById('sourceIcon');
|
|
dom.btnPlayPause = document.getElementById('btn-play-pause');
|
|
dom.btnNext = document.getElementById('btn-next');
|
|
dom.btnPrevious = document.getElementById('btn-previous');
|
|
dom.miniBtnPlayPause = document.getElementById('mini-btn-play-pause');
|
|
dom.miniPlayer = document.getElementById('mini-player');
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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';
|
|
|
|
// ============================================================
|
|
// Internationalization (i18n)
|
|
// ============================================================
|
|
|
|
let currentLocale = 'en';
|
|
let translations = {};
|
|
const supportedLocales = {
|
|
'en': 'English',
|
|
'ru': 'Русский'
|
|
};
|
|
|
|
// Minimal inline fallback for critical UI elements
|
|
const fallbackTranslations = {
|
|
'app.title': 'Media Server',
|
|
'auth.connect': 'Connect',
|
|
'auth.placeholder': 'Enter API Token',
|
|
'player.status.connected': 'Connected',
|
|
'player.status.disconnected': 'Disconnected'
|
|
};
|
|
|
|
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]);
|
|
});
|
|
return text;
|
|
}
|
|
|
|
async function loadTranslations(locale) {
|
|
try {
|
|
const response = await fetch(`/static/locales/${locale}.json`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load ${locale}.json`);
|
|
}
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error(`Error loading translations for ${locale}:`, error);
|
|
if (locale !== 'en') {
|
|
return await loadTranslations('en');
|
|
}
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function detectBrowserLocale() {
|
|
const browserLang = navigator.language || navigator.languages?.[0] || 'en';
|
|
const langCode = browserLang.split('-')[0];
|
|
return supportedLocales[langCode] ? langCode : 'en';
|
|
}
|
|
|
|
async function initLocale() {
|
|
const savedLocale = localStorage.getItem('locale') || detectBrowserLocale();
|
|
await setLocale(savedLocale);
|
|
}
|
|
|
|
async function setLocale(locale) {
|
|
if (!supportedLocales[locale]) {
|
|
locale = 'en';
|
|
}
|
|
translations = await loadTranslations(locale);
|
|
currentLocale = locale;
|
|
document.documentElement.setAttribute('data-locale', locale);
|
|
document.documentElement.setAttribute('lang', locale);
|
|
localStorage.setItem('locale', locale);
|
|
updateAllText();
|
|
updateLocaleSelect();
|
|
document.body.classList.remove('loading-translations');
|
|
document.body.classList.add('translations-loaded');
|
|
}
|
|
|
|
function changeLocale() {
|
|
const select = document.getElementById('locale-select');
|
|
const newLocale = select.value;
|
|
if (newLocale && newLocale !== currentLocale) {
|
|
localStorage.setItem('locale', newLocale);
|
|
setLocale(newLocale);
|
|
}
|
|
}
|
|
|
|
function updateLocaleSelect() {
|
|
const select = document.getElementById('locale-select');
|
|
if (select) {
|
|
select.value = currentLocale;
|
|
}
|
|
}
|
|
|
|
function updateAllText() {
|
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
const key = el.getAttribute('data-i18n');
|
|
el.textContent = t(key);
|
|
});
|
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
|
const key = el.getAttribute('data-i18n-placeholder');
|
|
el.placeholder = t(key);
|
|
});
|
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
|
const key = el.getAttribute('data-i18n-title');
|
|
el.title = t(key);
|
|
});
|
|
|
|
// Re-apply dynamic content with new translations
|
|
updatePlaybackState(currentState);
|
|
const connected = ws && ws.readyState === WebSocket.OPEN;
|
|
updateConnectionStatus(connected);
|
|
|
|
if (lastStatus) {
|
|
const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable');
|
|
document.getElementById('track-title').textContent = lastStatus.title || fallbackTitle;
|
|
const initSrc = resolveMediaSource(lastStatus.source);
|
|
document.getElementById('source').textContent = initSrc ? initSrc.name : t('player.unknown_source');
|
|
document.getElementById('sourceIcon').innerHTML = initSrc?.icon || '';
|
|
}
|
|
|
|
const token = localStorage.getItem('media_server_token');
|
|
if (token) {
|
|
loadScriptsTable();
|
|
loadCallbacksTable();
|
|
loadLinksTable();
|
|
displayQuickAccess();
|
|
}
|
|
renderAccentSwatches();
|
|
}
|
|
|
|
async function fetchVersion() {
|
|
try {
|
|
const response = await fetch('/api/health');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const label = document.getElementById('version-label');
|
|
if (data.version) {
|
|
label.textContent = `v${data.version}`;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching version:', error);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Shared Utilities
|
|
// ============================================================
|
|
|
|
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) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function showToast(message, type = 'success') {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
|
|
requestAnimationFrame(() => {
|
|
toast.classList.add('show');
|
|
});
|
|
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
|
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
|
|
}, TOAST_DURATION_MS);
|
|
}
|
|
|
|
function closeDialog(dialog) {
|
|
dialog.classList.add('dialog-closing');
|
|
dialog.addEventListener('animationend', () => {
|
|
dialog.classList.remove('dialog-closing');
|
|
dialog.close();
|
|
}, { once: true });
|
|
}
|
|
|
|
function showConfirm(message) {
|
|
return new Promise((resolve) => {
|
|
const dialog = document.getElementById('confirmDialog');
|
|
const msg = document.getElementById('confirmDialogMessage');
|
|
const btnCancel = document.getElementById('confirmDialogCancel');
|
|
const btnConfirm = document.getElementById('confirmDialogConfirm');
|
|
|
|
msg.textContent = message;
|
|
|
|
function cleanup() {
|
|
btnCancel.removeEventListener('click', onCancel);
|
|
btnConfirm.removeEventListener('click', onConfirm);
|
|
dialog.removeEventListener('close', onClose);
|
|
closeDialog(dialog);
|
|
}
|
|
|
|
function onCancel() { cleanup(); resolve(false); }
|
|
function onConfirm() { cleanup(); resolve(true); }
|
|
function onClose() { cleanup(); resolve(false); }
|
|
|
|
btnCancel.addEventListener('click', onCancel);
|
|
btnConfirm.addEventListener('click', onConfirm);
|
|
dialog.addEventListener('close', onClose);
|
|
|
|
dialog.showModal();
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// API Commands
|
|
// ============================================================
|
|
|
|
async function sendCommand(endpoint, body = null) {
|
|
const token = localStorage.getItem('media_server_token');
|
|
|
|
const options = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
};
|
|
|
|
if (body) {
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/media/${endpoint}`, options);
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
console.error(`Command ${endpoint} failed:`, response.status);
|
|
showToast(data.detail || `Command failed: ${endpoint}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error sending command ${endpoint}:`, error);
|
|
showToast(`Connection error: ${endpoint}`, 'error');
|
|
}
|
|
}
|
|
|
|
function togglePlayPause() {
|
|
if (currentState === 'playing') {
|
|
sendCommand('pause');
|
|
} else {
|
|
sendCommand('play');
|
|
}
|
|
}
|
|
|
|
function nextTrack() {
|
|
sendCommand('next');
|
|
}
|
|
|
|
function previousTrack() {
|
|
sendCommand('previous');
|
|
}
|
|
|
|
let lastSentVolume = -1;
|
|
function setVolume(volume) {
|
|
if (volume === lastSentVolume) return;
|
|
lastSentVolume = volume;
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'volume', volume: volume }));
|
|
} else {
|
|
sendCommand('volume', { volume: volume });
|
|
}
|
|
}
|
|
|
|
function toggleMute() {
|
|
sendCommand('mute');
|
|
}
|
|
|
|
function seek(position) {
|
|
sendCommand('seek', { position: position });
|
|
}
|
|
|
|
// ============================================================
|
|
// MDI Icon System
|
|
// ============================================================
|
|
|
|
const mdiIconCache = (() => {
|
|
try {
|
|
return JSON.parse(localStorage.getItem('mdiIconCache') || '{}');
|
|
} catch { return {}; }
|
|
})();
|
|
|
|
function _persistMdiCache() {
|
|
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
|
}
|
|
|
|
async function fetchMdiIcon(iconName) {
|
|
const name = iconName.replace(/^mdi:/, '');
|
|
if (mdiIconCache[name]) return mdiIconCache[name];
|
|
|
|
try {
|
|
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
|
|
if (response.ok) {
|
|
const svg = await response.text();
|
|
mdiIconCache[name] = svg;
|
|
_persistMdiCache();
|
|
return svg;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to fetch MDI icon:', name, e);
|
|
}
|
|
|
|
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) {
|
|
const els = container.querySelectorAll('[data-mdi-icon]');
|
|
await Promise.all(Array.from(els).map(async (el) => {
|
|
const icon = el.dataset.mdiIcon;
|
|
if (icon) {
|
|
el.innerHTML = await fetchMdiIcon(icon);
|
|
}
|
|
}));
|
|
}
|
|
|
|
function setupIconPreview(inputId, previewId) {
|
|
const input = document.getElementById(inputId);
|
|
const preview = document.getElementById(previewId);
|
|
if (!input || !preview) return;
|
|
|
|
let debounceTimer = null;
|
|
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(debounceTimer);
|
|
const value = input.value.trim();
|
|
|
|
if (!value) {
|
|
preview.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
debounceTimer = setTimeout(async () => {
|
|
const svg = await fetchMdiIcon(value);
|
|
if (input.value.trim() === value) {
|
|
preview.innerHTML = svg;
|
|
}
|
|
}, 400);
|
|
});
|
|
}
|