feat: Add rotating proxy with health check, fix CORS for localhost, update extension

This commit is contained in:
Khoa Vo 2026-04-27 17:51:09 +07:00
parent 24aa18054c
commit 7da10fb2c3
10 changed files with 290 additions and 38 deletions

View file

@ -19,7 +19,7 @@
"@svta/common-media-library": "^0.18.1", "@svta/common-media-library": "^0.18.1",
"@types/wicg-file-system-access": "^2023.10.7", "@types/wicg-file-system-access": "^2023.10.7",
"@typescript-eslint/eslint-plugin": "^8.57.2", "@typescript-eslint/eslint-plugin": "^8.57.2",
"@uimaxbai/am-lyrics": "^1.2.8", "@uimaxbai/am-lyrics": "^1.2.9",
"@vitest/web-worker": "^4.1.2", "@vitest/web-worker": "^4.1.2",
"appwrite": "^23.0.0", "appwrite": "^23.0.0",
"butterchurn": "^2.6.7", "butterchurn": "^2.6.7",
@ -678,7 +678,7 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="],
"@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.2.8", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-aR8kxqIYcVlsMCH6bbH8ANG+bN/2OAw66ZFjYD1a25hkMTyxtULWgWwAZlUfreP9V47bFvNgXIKvOqhO5JFpeg=="], "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-0IpvmCY6384cIKntIap/N9Rq/JuacSjL7Dq+QXPqRAjewZVVGcxEgka7tXtm1BnhwqIZWYWT/2Uzz6aA71RSmQ=="],
"@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="], "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="],

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
version: '3.8'
services:
monochrome:
build: .
ports:
- '8080:4173'
restart: unless-stopped
volumes:
- ./data:/data # For local storage if needed
environment:
- NODE_ENV=production

View file

@ -31,7 +31,15 @@
}, },
"content_scripts": [ "content_scripts": [
{ {
"matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"], "matches": [
"*://monochrome.samidy.com/*",
"*://monochrome.tf/*",
"*://lossless.wtf/*",
"*://localhost:5173/*",
"*://localhost:5174/*",
"*://localhost:5175/*",
"*://localhost:5176/*"
],
"js": ["content.js"], "js": ["content.js"],
"run_at": "document_start", "run_at": "document_start",
"all_frames": true "all_frames": true
@ -45,7 +53,15 @@
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["inject.js"], "resources": ["inject.js"],
"matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"] "matches": [
"*://monochrome.samidy.com/*",
"*://monochrome.tf/*",
"*://lossless.wtf/*",
"*://localhost:5173/*",
"*://localhost:5174/*",
"*://localhost:5175/*",
"*://localhost:5176/*"
]
} }
] ]
} }

View file

@ -24,7 +24,7 @@
}, },
"condition": { "condition": {
"urlFilter": "||tidal.com*", "urlFilter": "||tidal.com*",
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"], "initiatorDomains": ["monochrome.tf", "monochrome.samidy.com", "localhost"],
"resourceTypes": ["xmlhttprequest", "media"] "resourceTypes": ["xmlhttprequest", "media"]
} }
}, },
@ -48,7 +48,7 @@
}, },
"condition": { "condition": {
"urlFilter": "||tidal.com*", "urlFilter": "||tidal.com*",
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"], "initiatorDomains": ["monochrome.tf", "monochrome.samidy.com", "localhost"],
"resourceTypes": ["xmlhttprequest", "media"] "resourceTypes": ["xmlhttprequest", "media"]
} }
} }

View file

@ -21,6 +21,7 @@ import { resolveDownloadTotalBytes } from './downloadProgressUtils.js';
import { readableStreamIterator } from './readableStreamIterator.js'; import { readableStreamIterator } from './readableStreamIterator.js';
import { HiFiClient, TidalResponse } from './HiFi.ts'; import { HiFiClient, TidalResponse } from './HiFi.ts';
import { isIos, isSafari, isChrome } from './platform-detection.js'; import { isIos, isSafari, isChrome } from './platform-detection.js';
import { instanceHealth } from './instance-health.js';
import { import {
TrackAlbum, TrackAlbum,
EnrichedAlbum, EnrichedAlbum,
@ -112,9 +113,9 @@ export class LosslessAPI {
}; };
const tryInstances = async (instances) => { const tryInstances = async (instances) => {
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances const maxTotalAttempts = instances.length * 2;
let lastError = null; let lastError = null;
let instanceIndex = Math.floor(Math.random() * instances.length); let instanceIndex = 0;
for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) { for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) {
const instance = instances[instanceIndex % instances.length]; const instance = instances[instanceIndex % instances.length];
@ -128,12 +129,14 @@ export class LosslessAPI {
if (response.status === 429) { if (response.status === 429) {
console.warn(`Rate limit hit on ${baseUrl}. Trying next instance...`); console.warn(`Rate limit hit on ${baseUrl}. Trying next instance...`);
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
instanceIndex++; instanceIndex++;
await delay(500); await delay(500);
continue; continue;
} }
if (response.ok) { if (response.ok) {
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
return response; return response;
} }
@ -144,6 +147,7 @@ export class LosslessAPI {
.catch(() => null); .catch(() => null);
if (errorData?.subStatus === 11002) { if (errorData?.subStatus === 11002) {
console.warn(`Auth failed on ${baseUrl}. Trying next instance...`); console.warn(`Auth failed on ${baseUrl}. Trying next instance...`);
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
instanceIndex++; instanceIndex++;
continue; continue;
} }
@ -151,6 +155,7 @@ export class LosslessAPI {
if (response.status >= 500) { if (response.status >= 500) {
console.warn(`Server error ${response.status} on ${baseUrl}. Trying next instance...`); console.warn(`Server error ${response.status} on ${baseUrl}. Trying next instance...`);
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
instanceIndex++; instanceIndex++;
continue; continue;
} }
@ -161,6 +166,7 @@ export class LosslessAPI {
if (error.name === 'AbortError') throw error; if (error.name === 'AbortError') throw error;
lastError = error; lastError = error;
console.warn(`Network error on ${baseUrl}: ${error.message}. Trying next instance...`); console.warn(`Network error on ${baseUrl}: ${error.message}. Trying next instance...`);
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
instanceIndex++; instanceIndex++;
await delay(200); await delay(200);
} }

162
js/instance-health.js Normal file
View file

@ -0,0 +1,162 @@
//instance-health.js
const HEALTH_CHECK_INTERVAL = 5 * 60 * 1000;
const HEALTH_CHECK_TIMEOUT = 8000;
const MAX_CONSECUTIVE_FAILURES = 3;
const INITIAL_DELAY = 10000;
const IS_DEV = typeof window !== 'undefined' && window.location?.hostname === 'localhost';
class InstanceHealthChecker {
constructor() {
this.healthCache = new Map();
this.checkTimers = {};
this.listeners = [];
}
onHealthUpdate(callback) {
this.listeners.push(callback);
}
notifyListeners(instances) {
this.listeners.forEach((cb) => cb(instances));
}
async checkInstance(url, type = 'api') {
const testPath = '/';
const fullUrl = url.endsWith('/') ? `${url}${testPath.substring(1)}` : `${url}${testPath}`;
const startTime = Date.now();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
try {
const response = await fetch(fullUrl, {
method: 'GET',
signal: controller.signal,
});
clearTimeout(timeoutId);
const latency = Date.now() - startTime;
const existing = this.healthCache.get(url) || { url, failures: 0 };
const status = response.ok || response.status === 401 || response.status === 404 ? 'online' : 'degraded';
this.healthCache.set(url, {
...existing,
status,
latency,
lastCheck: Date.now(),
failures: response.ok ? 0 : existing.failures,
type,
});
return { url, status, latency, online: response.ok };
} catch (error) {
clearTimeout(timeoutId);
const existing = this.healthCache.get(url) || { url, failures: 0 };
const newFailures = existing.failures + 1;
const status = newFailures >= MAX_CONSECUTIVE_FAILURES ? 'offline' : 'degraded';
this.healthCache.set(url, {
url,
status,
latency: null,
lastCheck: Date.now(),
failures: newFailures,
error: error.message,
type,
});
return { url, status, latency: null, online: false, error: error.message };
}
}
async checkAll(instances, type = 'api') {
const checks = instances.map((inst) => {
const url = typeof inst === 'string' ? inst : inst.url;
return this.checkInstance(url, type);
});
const results = await Promise.allSettled(checks);
return results.map((r) => r.value || { url: 'unknown', status: 'offline', online: false });
}
getHealthStatus(url) {
return this.healthCache.get(url) || { url, status: 'unknown', latency: null, lastCheck: null };
}
getHealthyInstances(instances, type = 'api') {
return instances.filter((inst) => {
const url = typeof inst === 'string' ? inst : inst.url;
const health = this.healthCache.get(url);
return !health || health.status === 'online' || health.status === 'degraded';
});
}
getHealthyAndSorted(instances, type = 'api') {
const withHealth = instances.map((inst) => {
const url = typeof inst === 'string' ? inst : inst.url;
const health = this.healthCache.get(url);
return {
...inst,
healthStatus: health?.status || 'unknown',
latency: health?.latency ?? null,
};
});
return withHealth.sort((a, b) => {
if (a.healthStatus === 'offline' && b.healthStatus !== 'offline') return 1;
if (b.healthStatus === 'offline' && a.healthStatus !== 'offline') return -1;
if (a.latency !== null && b.latency !== null) return a.latency - b.latency;
if (a.latency !== null) return -1;
if (b.latency !== null) return 1;
return 0;
});
}
startPeriodicCheck(instancesGetter, type = 'api') {
if (this.checkTimers[type]) {
clearInterval(this.checkTimers[type]);
}
if (IS_DEV) {
console.log(`Health check disabled in dev mode for ${type}`);
return;
}
const check = async () => {
const instances = await instancesGetter(type);
if (!instances || instances.length === 0) return;
const results = await this.checkAll(instances, type);
this.notifyListeners(results);
};
setTimeout(check, INITIAL_DELAY);
this.checkTimers[type] = setInterval(check, HEALTH_CHECK_INTERVAL);
}
stopPeriodicCheck(type = 'api') {
if (this.checkTimers[type]) {
clearInterval(this.checkTimers[type]);
delete this.checkTimers[type];
}
}
stopAllChecks() {
Object.keys(this.checkTimers).forEach((type) => this.stopPeriodicCheck(type));
}
async quickCheck(url, type = 'api') {
return this.checkInstance(url, type);
}
getCachedStatus(url) {
return this.healthCache.get(url);
}
clearCache() {
this.healthCache.clear();
}
}
export const instanceHealth = new InstanceHealthChecker();

View file

@ -2,7 +2,8 @@
import { LosslessAPI } from './api.js'; import { LosslessAPI } from './api.js';
import { PodcastsAPI } from './podcasts-api.js'; import { PodcastsAPI } from './podcasts-api.js';
import { musicProviderSettings } from './storage.js'; import { musicProviderSettings, apiSettings } from './storage.js';
import { instanceHealth } from './instance-health.js';
/** /**
* MusicAPI - Singleton class that provides a unified interface for accessing music streaming services. * MusicAPI - Singleton class that provides a unified interface for accessing music streaming services.
@ -64,7 +65,14 @@ export class MusicAPI {
} }
const api = new MusicAPI(settings); const api = new MusicAPI(settings);
return (MusicAPI.#instance = api); MusicAPI.#instance = api;
if (!window.location?.hostname?.includes('localhost')) {
instanceHealth.startPeriodicCheck((type) => apiSettings.getInstances(type), 'api');
instanceHealth.startPeriodicCheck((type) => apiSettings.getInstances(type), 'streaming');
}
return MusicAPI.#instance;
} }
getCurrentProvider() { getCurrentProvider() {

View file

@ -1,7 +1,20 @@
const PROXIES = [
{ url: 'https://audio-proxy.binimum.org/proxy-audio', param: 'url=' },
{ url: 'https://corsproxy.io/?', param: '' },
];
let proxyIndex = 0;
export const getProxyUrl = (url) => { export const getProxyUrl = (url) => {
if (!url || typeof url !== 'string') return url; if (!url || typeof url !== 'string') return url;
if (window.__tidalOriginExtension) return url; if (window.__tidalOriginExtension) return url;
if (url.startsWith('blob:')) return url; if (url.startsWith('blob:')) return url;
if (url.startsWith('https://audio-proxy.binimum.org/')) return url; if (url.startsWith('https://audio-proxy.binimum.org/')) return url;
return `https://audio-proxy.binimum.org/proxy-audio?url=${url}`;
const proxy = PROXIES[proxyIndex % PROXIES.length];
return `${proxy.url}${proxy.param}${encodeURIComponent(url)}`;
};
export const rotateProxy = () => {
proxyIndex = (proxyIndex + 1) % PROXIES.length;
}; };

View file

@ -4,9 +4,38 @@ import { SVG_RIGHT_ARROW } from './icons';
export const apiSettings = { export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances-v9', STORAGE_KEY: 'monochrome-api-instances-v9',
INSTANCES_URLS: [ INSTANCES_URLS: ['https://tidal-uptime.geeked.wtf'],
'https://tidal-uptime.geeked.wtf', FALLBACK_INSTANCES: {
], api: [
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
{ url: 'https://api.monochrome.tf', version: '2.5' },
{ url: 'https://arran.monochrome.tf', version: '2.6' },
{ url: 'https://tidal.kinoplus.online', version: '2.2' },
{ url: 'https://tidal-api.binimum.org', version: '2.6' },
{ url: 'https://wolf.qqdl.site', version: '2.2' },
{ url: 'https://maus.qqdl.site', version: '2.6' },
{ url: 'https://vogel.qqdl.site', version: '2.6' },
{ url: 'https://katze.qqdl.site', version: '2.6' },
{ url: 'https://hund.qqdl.site', version: '2.6' },
{ url: 'https://hifi-one.spotisaver.net', version: '2.5' },
{ url: 'https://hifi-two.spotisaver.net', version: '2.5' },
],
streaming: [
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
{ url: 'https://arran.monochrome.tf', version: '2.6' },
{ url: 'https://tidal.kinoplus.online', version: '2.2' },
{ url: 'https://tidal-api.binimum.org', version: '2.6' },
{ url: 'https://wolf.qqdl.site', version: '2.2' },
{ url: 'https://maus.qqdl.site', version: '2.6' },
{ url: 'https://vogel.qqdl.site', version: '2.6' },
{ url: 'https://katze.qqdl.site', version: '2.6' },
{ url: 'https://hund.qqdl.site', version: '2.6' },
{ url: 'https://hifi-one.spotisaver.net', version: '2.5' },
{ url: 'https://hifi-two.spotisaver.net', version: '2.5' },
],
},
defaultInstances: { api: [], streaming: [] }, defaultInstances: { api: [], streaming: [] },
userInstances: null, userInstances: null,
instancesLoaded: false, instancesLoaded: false,
@ -75,27 +104,8 @@ export const apiSettings = {
if (!data) { if (!data) {
console.error('Failed to load instances from all uptime APIs:', fetchError); console.error('Failed to load instances from all uptime APIs:', fetchError);
this.defaultInstances = { this.defaultInstances = {
api: [ api: [...this.FALLBACK_INSTANCES.api],
{ url: 'https://hifi.geeked.wtf', version: '2.7' }, streaming: [...this.FALLBACK_INSTANCES.streaming],
{ url: 'https://eu-central.monochrome.tf', version: '2.7' },
{ url: 'https://us-west.monochrome.tf', version: '2.7' },
{ url: 'https://api.monochrome.tf', version: '2.5' },
{ url: 'https://monochrome-api.samidy.com', version: '2.3' },
{ url: 'https://maus.qqdl.site', version: '2.6' },
{ url: 'https://vogel.qqdl.site', version: '2.6' },
{ url: 'https://katze.qqdl.site', version: '2.6' },
{ url: 'https://hund.qqdl.site', version: '2.6' },
{ url: 'https://tidal.kinoplus.online', version: '2.2' },
{ url: 'https://wolf.qqdl.site', version: '2.2' },
],
streaming: [
{ url: 'https://hifi.geeked.wtf', version: '2.7' },
{ url: 'https://maus.qqdl.site', version: '2.6' },
{ url: 'https://vogel.qqdl.site', version: '2.6' },
{ url: 'https://katze.qqdl.site', version: '2.6' },
{ url: 'https://hund.qqdl.site', version: '2.6' },
{ url: 'https://wolf.qqdl.site', version: '2.6' },
],
}; };
this.instancesLoaded = true; this.instancesLoaded = true;
this._loadPromise = null; this._loadPromise = null;
@ -113,12 +123,20 @@ export const apiSettings = {
groupedInstances.api = data.api.filter((item) => !isBlockedInstance(item)); groupedInstances.api = data.api.filter((item) => !isBlockedInstance(item));
} }
if (data.streaming && Array.isArray(data.streaming)) { if (data.streaming && Array.isArray(data.streaming) && data.streaming.length > 0) {
groupedInstances.streaming = data.streaming.filter((item) => !isBlockedInstance(item)); groupedInstances.streaming = data.streaming.filter((item) => !isBlockedInstance(item));
} else if (groupedInstances.api.length > 0) { } else if (groupedInstances.api.length > 0) {
groupedInstances.streaming = [...groupedInstances.api]; groupedInstances.streaming = [...groupedInstances.api];
} }
if (groupedInstances.streaming.length === 0) {
groupedInstances.streaming = [...this.FALLBACK_INSTANCES.streaming];
}
if (groupedInstances.api.length === 0) {
groupedInstances.api = [...this.FALLBACK_INSTANCES.api];
}
this.defaultInstances = groupedInstances; this.defaultInstances = groupedInstances;
this.instancesLoaded = true; this.instancesLoaded = true;

View file

@ -31,6 +31,7 @@ import {
fullscreenCoverNoRoundSettings, fullscreenCoverNoRoundSettings,
artistBannerSettings, artistBannerSettings,
} from './storage.js'; } from './storage.js';
import { instanceHealth } from './instance-health.js';
import { db } from './db.js'; import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js'; import { getVibrantColorFromImage } from './vibrant-color.js';
import { syncManager } from './accounts/pocketbase.js'; import { syncManager } from './accounts/pocketbase.js';
@ -6103,12 +6104,28 @@ export class UIRenderer {
const safeUrl = escapeHtml(instanceUrl || ''); const safeUrl = escapeHtml(instanceUrl || '');
const safeVersion = escapeHtml(instanceVersion); const safeVersion = escapeHtml(instanceVersion);
const health = instanceHealth.getCachedStatus(instanceUrl);
const statusColor =
health?.status === 'online'
? '#10b981'
: health?.status === 'degraded'
? '#f59e0b'
: health?.status === 'offline'
? '#ef4444'
: '#6b7280';
const latencyDisplay = health?.latency ? `${health.latency}ms` : '';
return ` return `
<li data-index="${index}" data-type="${type}" data-url="${safeUrl}"> <li data-index="${index}" data-type="${type}" data-url="${safeUrl}">
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<div class="instance-url">${safeName} ${isUser ? '<span style="font-size: 0.6rem; opacity: 0.7; background: var(--muted); padding: 1px 4px; border-radius: 3px; margin-left: 4px; vertical-align: middle;">U</span>' : ''}</div> <div class="instance-url">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${statusColor}; margin-right: 6px; vertical-align: middle;"></span>
${safeName} ${isUser ? '<span style="font-size: 0.6rem; opacity: 0.7; background: var(--muted); padding: 1px 4px; border-radius: 3px; margin-left: 4px; vertical-align: middle;">U</span>' : ''}
</div>
${safeUrl && safeUrl !== safeName ? `<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${safeUrl}</div>` : ''} ${safeUrl && safeUrl !== safeName ? `<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${safeUrl}</div>` : ''}
${safeVersion ? `<div style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.1rem;">v${safeVersion}</div>` : ''} <div style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.1rem;">
${safeVersion ? `v${safeVersion}` : ''}${safeVersion && latencyDisplay ? ' · ' : ''}${latencyDisplay}
</div>
</div> </div>
<div class="controls"> <div class="controls">
${ ${