diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 92d3a68..79d46ea 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -113,53 +113,126 @@ let versionCheckIntervalId = null;
let versionCheckInFlight = false;
let reconnectIntervalId = null;
let sessionRuntimeStarted = false;
-let connectionStatusEl = null;
let connectionState = '';
+let connectionStatusText = '';
+let connectionRetryBannerEl = null;
+let connectionStatusCountdownId = null;
+let connectionNextRetryAtMs = 0;
+let connectionCheckInFlight = false;
setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling();
initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
-function ensureConnectionStatusEl() {
- if (connectionStatusEl) return connectionStatusEl;
+function ensureConnectionIndicatorEl() {
+ return document.getElementById('toolbar-connection-indicator');
+}
+
+function ensureConnectionRetryBannerEl() {
+ if (connectionRetryBannerEl) return connectionRetryBannerEl;
if (!appShellEl) return null;
const el = document.createElement('div');
- el.id = 'connection-status-slot';
- el.className = 'connection-status-slot is-connecting';
- el.textContent = 'Подключение к серверу...';
+ el.id = 'connection-retry-banner';
+ el.className = 'connection-retry-banner is-connecting';
+ el.textContent = 'Соединяюсь…';
+ el.addEventListener('click', () => {
+ void triggerImmediateConnectionRetry();
+ });
appShellEl.append(el);
- connectionStatusEl = el;
+ connectionRetryBannerEl = el;
return el;
}
+function stopConnectionCountdown() {
+ if (connectionStatusCountdownId) {
+ window.clearInterval(connectionStatusCountdownId);
+ connectionStatusCountdownId = null;
+ }
+}
+
+function getConnectionRetrySecondsLeft() {
+ const leftMs = Math.max(0, Number(connectionNextRetryAtMs || 0) - Date.now());
+ return Math.ceil(leftMs / 1000);
+}
+
+function refreshConnectionUi() {
+ const state = String(connectionState || 'connecting').trim();
+
+ const indicatorEl = ensureConnectionIndicatorEl();
+ if (indicatorEl) {
+ indicatorEl.classList.remove('is-connected', 'is-disconnected', 'is-connecting', 'is-updating', 'is-unknown');
+ indicatorEl.classList.add(`is-${state || 'unknown'}`);
+ }
+
+ const bannerEl = ensureConnectionRetryBannerEl();
+ if (!bannerEl) return;
+
+ if (state === 'connected' || state === 'updating') {
+ bannerEl.hidden = true;
+ stopConnectionCountdown();
+ return;
+ }
+
+ bannerEl.hidden = false;
+ bannerEl.classList.remove('is-connected', 'is-disconnected', 'is-connecting', 'is-updating');
+ bannerEl.classList.add(`is-${state}`);
+
+ if (connectionStatusText) {
+ bannerEl.textContent = connectionStatusText;
+ return;
+ }
+
+ if (state === 'connecting') {
+ bannerEl.textContent = 'Соединяюсь… Нажмите, чтобы попробовать сразу';
+ return;
+ }
+
+ if (state === 'disconnected') {
+ const secs = getConnectionRetrySecondsLeft();
+ bannerEl.textContent = `Нет соединения. Повтор через ${secs}с. Нажмите для попытки сейчас`;
+ return;
+ }
+
+ bannerEl.textContent = 'Проблема с соединением. Нажмите для повтора';
+}
+
+function startConnectionCountdown() {
+ if (connectionStatusCountdownId) return;
+ connectionStatusCountdownId = window.setInterval(() => {
+ if (connectionState !== 'disconnected') return;
+ const secs = getConnectionRetrySecondsLeft();
+ if (secs <= 0 && !connectionCheckInFlight) {
+ connectionStatusText = '';
+ setConnectionStatus('connecting', 'Соединяюсь…');
+ void checkConnectionHealth();
+ return;
+ }
+ refreshConnectionUi();
+ }, 1000);
+}
+
function setConnectionStatus(nextState, text = '') {
- const el = ensureConnectionStatusEl();
- if (!el) return;
const state = String(nextState || '').trim();
if (!state) return;
- if (state === connectionState && !text) return;
connectionState = state;
- el.classList.remove('is-connected', 'is-connecting', 'is-disconnected', 'is-updating');
- el.classList.add(`is-${state}`);
-
- if (text) {
- el.textContent = text;
- return;
- }
- if (state === 'connected') {
- el.textContent = 'Подключено к серверу';
- return;
- }
+ connectionStatusText = String(text || '').trim();
if (state === 'disconnected') {
- el.textContent = 'Нет соединения с сервером';
- return;
+ if (!connectionNextRetryAtMs || connectionNextRetryAtMs <= Date.now()) {
+ connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
+ }
+ startConnectionCountdown();
+ } else {
+ stopConnectionCountdown();
}
- if (state === 'updating') {
- el.textContent = 'Найдена новая версия, обновляю приложение...';
- return;
- }
- el.textContent = 'Подключение к серверу...';
+ refreshConnectionUi();
+}
+
+async function triggerImmediateConnectionRetry() {
+ if (connectionCheckInFlight) return;
+ connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
+ setConnectionStatus('connecting', 'Соединяюсь…');
+ await checkConnectionHealth();
}
function parseBuildHashFromHtml(html) {
@@ -207,14 +280,22 @@ function startVersionMonitor() {
}
async function checkConnectionHealth() {
+ if (connectionCheckInFlight) return;
+ connectionCheckInFlight = true;
if (connectionState !== 'connected') {
setConnectionStatus('connecting');
}
try {
+ if (!wsIsOpen()) {
+ await authService.ws.open();
+ }
await authService.ws.request('Ping', { ts: Date.now() }, 7000);
setConnectionStatus('connected');
} catch {
+ connectionStatusText = '';
setConnectionStatus('disconnected');
+ } finally {
+ connectionCheckInFlight = false;
}
}
@@ -223,8 +304,15 @@ function startConnectionMonitor() {
window.clearInterval(pingIntervalId);
pingIntervalId = null;
}
+
+ connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
void checkConnectionHealth();
+
pingIntervalId = window.setInterval(() => {
+ connectionNextRetryAtMs = Date.now() + CONNECTION_CHECK_INTERVAL_MS;
+ if (connectionState === 'disconnected') {
+ refreshConnectionUi();
+ }
void checkConnectionHealth();
}, CONNECTION_CHECK_INTERVAL_MS);
}
@@ -355,6 +443,7 @@ function renderPageFailureFallback(pageId, error) {
screenEl.classList.toggle('no-app-chrome', false);
toolbarEl.innerHTML = '';
+ refreshConnectionUi();
}
function renderApp() {
@@ -395,6 +484,7 @@ function renderApp() {
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
+ refreshConnectionUi();
} catch (error) {
console.error('[renderApp] controlled fallback', error);
renderPageFailureFallback(pageId, error);
diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js
index 5a1920e..ee71320 100644
--- a/shine-UI/js/components/toolbar.js
+++ b/shine-UI/js/components/toolbar.js
@@ -15,8 +15,22 @@ export function renderToolbar(currentPageId, navigate) {
ITEMS.forEach((item) => {
const btn = document.createElement('button');
- btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}`;
- btn.innerHTML = `${item.icon}${item.label}`;
+ const isProfile = item.pageId === 'profile-view';
+ btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}`;
+ if (isProfile) {
+ btn.innerHTML = `
+ ${item.icon}
+
+ ${item.label}
+
+ connected
+
+
+
+ `;
+ } else {
+ btn.innerHTML = `${item.icon}${item.label}`;
+ }
btn.addEventListener('click', () => navigate(item.pageId));
root.append(btn);
});
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 0060830..2e48629 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -611,6 +611,62 @@
font-size: 11px;
}
+.toolbar-label-wrap {
+ display: grid;
+ justify-items: center;
+ line-height: 1.05;
+}
+
+.toolbar-btn-profile {
+ position: relative;
+}
+
+.toolbar-connection-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-top: 1px;
+ min-height: 9px;
+}
+
+.toolbar-connection-text {
+ font-size: 8px;
+ letter-spacing: 0.02em;
+ text-transform: lowercase;
+ color: rgba(191, 213, 255, 0.8);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.toolbar-connection-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 999px;
+ background: rgba(148, 162, 195, 0.65);
+ box-shadow: 0 0 0 2px rgba(97, 116, 156, 0.25);
+ transition: 0.2s ease;
+}
+
+.toolbar-connection-indicator.is-connected .toolbar-connection-text {
+ opacity: 1;
+ color: rgba(145, 255, 192, 0.9);
+}
+
+.toolbar-connection-indicator.is-connected .toolbar-connection-dot {
+ background: #71e9a5;
+ box-shadow: 0 0 0 2px rgba(72, 201, 134, 0.28);
+}
+
+.toolbar-connection-indicator.is-connecting .toolbar-connection-dot {
+ background: #f0c56b;
+ box-shadow: 0 0 0 2px rgba(240, 197, 107, 0.22);
+}
+
+.toolbar-connection-indicator.is-disconnected .toolbar-connection-dot {
+ background: #e48792;
+ box-shadow: 0 0 0 2px rgba(228, 135, 146, 0.24);
+}
+
.toolbar-btn.active {
background: rgba(83, 216, 251, 0.14);
color: var(--text);
diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css
index c7efd84..9b2c4c9 100644
--- a/shine-UI/styles/layout.css
+++ b/shine-UI/styles/layout.css
@@ -41,11 +41,11 @@ body {
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
}
-.connection-status-slot {
+.connection-retry-banner {
position: absolute;
left: 12px;
right: 12px;
- bottom: calc(62px + env(safe-area-inset-bottom));
+ bottom: calc(74px + env(safe-area-inset-bottom));
z-index: 5;
border-radius: 11px;
border: 1px solid rgba(133, 156, 201, 0.3);
@@ -55,26 +55,27 @@ body {
line-height: 1.2;
text-align: center;
padding: 7px 10px;
- pointer-events: none;
+ pointer-events: auto;
+ cursor: pointer;
backdrop-filter: blur(10px);
}
-.connection-status-slot.is-connected {
+.connection-retry-banner.is-connected {
border-color: rgba(124, 235, 171, 0.4);
color: #d8ffe9;
}
-.connection-status-slot.is-connecting {
+.connection-retry-banner.is-connecting {
border-color: rgba(238, 196, 107, 0.42);
color: #ffe8bb;
}
-.connection-status-slot.is-disconnected {
+.connection-retry-banner.is-disconnected {
border-color: rgba(228, 127, 145, 0.44);
color: #ffdce3;
}
-.connection-status-slot.is-updating {
+.connection-retry-banner.is-updating {
border-color: rgba(144, 201, 255, 0.44);
color: #d9eeff;
}