Преглед изворни кода

Feature/3266 tab remaining time (#3283)

bgrr74 пре 2 дана
родитељ
комит
517a407a34

+ 31 - 0
frontend/src/__tests__/contexts/ThemeContext.test.tsx

@@ -62,10 +62,41 @@ function wrapper({ children }: { children: ReactNode }) {
 
 describe('ThemeContext', () => {
   beforeEach(() => {
+    // The global test setup uses no-op localStorage mocks. Give this
+    // suite a real in-memory store to verify persistence across mounts.
+    const storage = new Map<string, string>();
+    vi.mocked(localStorage.getItem).mockImplementation((key) => storage.get(key) ?? null);
+    vi.mocked(localStorage.setItem).mockImplementation((key, value) => { storage.set(key, value); });
+    vi.mocked(localStorage.removeItem).mockImplementation((key) => { storage.delete(key); });
+    vi.mocked(localStorage.clear).mockImplementation(() => { storage.clear(); });
     localStorage.clear();
     document.documentElement.className = '';
   });
 
+  describe('browser tab display preference', () => {
+    it('defaults to percentage and persists the selected mode locally', () => {
+      mockMatchMedia(false);
+      const { result } = renderHook(() => useTheme(), { wrapper });
+
+      expect(result.current.tabDisplay).toBe('percentage');
+      act(() => result.current.setTabDisplay('time'));
+      expect(result.current.tabDisplay).toBe('time');
+      expect(localStorage.getItem('tab-display')).toBe('time');
+    });
+
+    it('loads a stored choice and ignores invalid stored values', () => {
+      mockMatchMedia(false);
+      localStorage.setItem('tab-display', 'time');
+      const first = renderHook(() => useTheme(), { wrapper });
+      expect(first.result.current.tabDisplay).toBe('time');
+      first.unmount();
+
+      localStorage.setItem('tab-display', 'invalid');
+      const second = renderHook(() => useTheme(), { wrapper });
+      expect(second.result.current.tabDisplay).toBe('percentage');
+    });
+  });
+
   describe('systemPreference initialization', () => {
     it('initializes systemPreference as dark when OS prefers dark', () => {
       mockMatchMedia(true);

+ 39 - 4
frontend/src/__tests__/hooks/usePrintProgressTitle.test.tsx

@@ -9,10 +9,11 @@ const h = vi.hoisted(() => ({
   theme: {
     value: {
       progressInTitle: false,
+      tabDisplay: 'percentage',
       resolvedMode: 'dark',
       darkAccent: 'green',
       lightAccent: 'green',
-    } as { progressInTitle: boolean; resolvedMode: string; darkAccent: string; lightAccent: string },
+    } as { progressInTitle: boolean; tabDisplay: 'percentage' | 'time'; resolvedMode: string; darkAccent: string; lightAccent: string },
   },
   getPrinters: vi.fn(),
   getPrinterStatus: vi.fn(),
@@ -118,7 +119,7 @@ describe('usePrintProgressTitle effect', () => {
   });
 
   it('is inert while the pref is off — never touches the tab title', async () => {
-    h.theme.value = { progressInTitle: false, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.theme.value = { progressInTitle: false, tabDisplay: 'percentage', resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
     document.title = 'Something Else';
 
     renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
@@ -130,7 +131,7 @@ describe('usePrintProgressTitle effect', () => {
   });
 
   it('shows the active print percentage in the title and swaps the favicon', async () => {
-    h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.theme.value = { progressInTitle: true, tabDisplay: 'percentage', resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
     h.getPrinters.mockResolvedValue([{ id: 1 }]);
     h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
 
@@ -140,8 +141,42 @@ describe('usePrintProgressTitle effect', () => {
     expect(faviconHref()).toBe(RING_URL);
   });
 
+  it('shows the soonest print remaining time without changing the favicon ring', async () => {
+    h.theme.value = { progressInTitle: true, tabDisplay: 'time', resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.getPrinters.mockResolvedValue([{ id: 1 }, { id: 2 }]);
+    h.getPrinterStatus.mockImplementation((id: number) => Promise.resolve(
+      id === 1 ? running(95, 600) : running(23, 86)
+    ));
+
+    renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+
+    await waitFor(() => expect(document.title).toBe('1h 26m · Bambuddy'));
+    expect(faviconHref()).toBe(RING_URL);
+  });
+
+  it('uses the same duration formatting as the Printers page', async () => {
+    h.theme.value = { progressInTitle: true, tabDisplay: 'time', resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.getPrinters.mockResolvedValue([{ id: 1 }]);
+    h.getPrinterStatus.mockResolvedValue(running(98, 5));
+
+    renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+
+    await waitFor(() => expect(document.title).toBe('5m · Bambuddy'));
+  });
+
+  it.each([null, 0, -1, Number.POSITIVE_INFINITY])('falls back to percentage for an unknown ETA (%s)', async (remaining) => {
+    h.theme.value = { progressInTitle: true, tabDisplay: 'time', resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.getPrinters.mockResolvedValue([{ id: 1 }]);
+    h.getPrinterStatus.mockResolvedValue(running(23, remaining));
+
+    renderHook(() => usePrintProgressTitle(), { wrapper: wrapper() });
+
+    await waitFor(() => expect(document.title).toBe('23% · Bambuddy'));
+    expect(faviconHref()).toBe(RING_URL);
+  });
+
   it('restores the original title and favicon when the pref is switched off', async () => {
-    h.theme.value = { progressInTitle: true, resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
+    h.theme.value = { progressInTitle: true, tabDisplay: 'percentage', resolvedMode: 'dark', darkAccent: 'green', lightAccent: 'green' };
     h.getPrinters.mockResolvedValue([{ id: 1 }]);
     h.getPrinterStatus.mockResolvedValue({ state: 'RUNNING', progress: 42, remaining_time: 600 });
 

+ 11 - 1
frontend/src/contexts/ThemeContext.tsx

@@ -22,6 +22,8 @@ interface ThemeContextType {
   // Show live print progress (% + accent-coloured ring favicon) in the browser tab
   progressInTitle: boolean;
   setProgressInTitle: (v: boolean) => void;
+  tabDisplay: 'percentage' | 'time';
+  setTabDisplay: (v: 'percentage' | 'time') => void;
   // Actions
   toggleMode: () => void;
   setMode: (mode: ThemeMode) => void;
@@ -102,6 +104,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
     localStorage.setItem('progress-in-title', String(v));
   };
 
+  const [tabDisplay, setTabDisplayState] = useState<'percentage' | 'time'>(() =>
+    localStorage.getItem('tab-display') === 'time' ? 'time' : 'percentage'
+  );
+  const setTabDisplay = (v: 'percentage' | 'time') => {
+    setTabDisplayState(v);
+    localStorage.setItem('tab-display', v);
+  };
+
   // Sync from API once auth state is known. Same gate shape as
   // useStreamTokenSync / ColorCatalogProvider: wait for AuthContext to
   // settle, then only fetch when we can actually expect a 200 (auth
@@ -218,7 +228,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
       resolvedMode,
       darkStyle, darkBackground, darkAccent,
       lightStyle, lightBackground, lightAccent,
-      progressInTitle, setProgressInTitle,
+      progressInTitle, setProgressInTitle, tabDisplay, setTabDisplay,
       toggleMode, setMode,
       setDarkStyle, setDarkBackground, setDarkAccent,
       setLightStyle, setLightBackground, setLightAccent,

+ 8 - 3
frontend/src/hooks/usePrintProgressTitle.ts

@@ -2,6 +2,7 @@ import { useQueries, useQuery } from '@tanstack/react-query';
 import { useEffect, useRef } from 'react';
 import { api } from '../api/client';
 import { useTheme } from '../contexts/ThemeContext';
+import { formatDuration } from '../utils/date';
 
 const FALLBACK_ACCENT = '#00ae42'; // Bambuddy green, if --accent can't be read (e.g. jsdom)
 
@@ -100,7 +101,7 @@ function setFavicon(dataUrl: string | null, originals: Map<HTMLLinkElement, stri
  * Mounted once, globally, inside WebSocketProvider.
  */
 export function usePrintProgressTitle() {
-  const { progressInTitle, resolvedMode, darkAccent, lightAccent } = useTheme();
+  const { progressInTitle, tabDisplay, resolvedMode, darkAccent, lightAccent } = useTheme();
   // Re-draw the ring when the active accent changes.
   const accent = resolvedMode === 'dark' ? darkAccent : lightAccent;
 
@@ -132,10 +133,14 @@ export function usePrintProgressTitle() {
 
   const active = progressInTitle ? pickActivePrint(statusQueries.map((q) => q.data)) : null;
   const pct = active && active.progress != null ? Math.round(active.progress) : null;
+  const remaining = active?.remaining_time;
+  const titleValue = pct == null ? null : tabDisplay === 'time' && remaining != null && Number.isFinite(remaining) && remaining > 0
+    ? formatDuration(remaining * 60)
+    : `${pct}%`;
 
   useEffect(() => {
     if (progressInTitle && pct != null) {
-      document.title = `${pct}% · ${defaultTitleRef.current}`;
+      document.title = `${titleValue} · ${defaultTitleRef.current}`;
       setFavicon(drawProgressFavicon(pct), originalsRef.current);
       ownsRef.current = true;
     } else if (ownsRef.current) {
@@ -145,7 +150,7 @@ export function usePrintProgressTitle() {
       ownsRef.current = false;
     }
     // else: never owned the tab → leave it entirely alone.
-  }, [progressInTitle, pct, accent]);
+  }, [progressInTitle, pct, titleValue, accent]);
 
   // Restore the tab to defaults on unmount, but only if we own it.
   useEffect(() => {

+ 4 - 1
frontend/src/i18n/locales/de.ts

@@ -2863,7 +2863,10 @@ export default {
     styleVibrant: 'Lebendig',
     themeToggleHint: 'Zwischen Dunkel-, Hell- und Systemmodus mit dem Symbol in der Seitenleiste wechseln.',
     progressInTitle: 'Druckfortschritt im Tab',
-    progressInTitleDescription: 'Zeigt den Prozentsatz des aktiven Drucks und einen Fortschrittsring im Browser-Tab an.',
+    progressInTitleDescription: 'Fortschritt des aktiven Drucks im Browser-Tab und Favicon anzeigen.',
+    tabDisplay: 'Tab-Anzeige',
+    tabDisplayPercentage: 'Prozent',
+    tabDisplayTime: 'Verbleibende Zeit',
     // Archive
     autoArchivePrints: 'Drucke automatisch archivieren',
     autoArchiveDescription: '3MF-Dateien automatisch speichern, wenn Drucke abgeschlossen sind. Aus: Kein Drucker archiviert Drucke, die außerhalb von Bambuddy gestartet wurden.',

+ 4 - 1
frontend/src/i18n/locales/en.ts

@@ -2885,7 +2885,10 @@ export default {
     styleVibrant: 'Vibrant',
     themeToggleHint: 'Toggle between dark, light, and system mode using the icon in the sidebar.',
     progressInTitle: 'Print progress in tab',
-    progressInTitleDescription: 'Show the active print\'s percentage and a progress ring in the browser tab.',
+    progressInTitleDescription: 'Show the active print\'s progress in the browser tab title and favicon.',
+    tabDisplay: 'Tab display',
+    tabDisplayPercentage: 'Percentage',
+    tabDisplayTime: 'Time remaining',
     // Archive
     autoArchivePrints: 'Auto-archive prints',
     autoArchiveDescription: 'Automatically save 3MF files when prints complete. When off, no printer archives prints started outside Bambuddy.',

+ 4 - 1
frontend/src/i18n/locales/es.ts

@@ -2865,7 +2865,10 @@ export default {
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Alterne entre modo oscuro, claro y sistema con el icono en la barra lateral.',
     progressInTitle: 'Progreso en la pestaña',
-    progressInTitleDescription: 'Muestra el porcentaje de la impresión activa y un anillo de progreso en la pestaña del navegador.',
+    progressInTitleDescription: 'Mostrar el progreso de la impresión activa en la pestaña del navegador y el favicon.',
+    tabDisplay: 'Visualización de pestaña',
+    tabDisplayPercentage: 'Porcentaje',
+    tabDisplayTime: 'Tiempo restante',
     // Archive
     autoArchivePrints: 'Archivar impresiones automáticamente',
     autoArchiveDescription: 'Guardar automáticamente los archivos 3MF cuando se completan las impresiones. Desactivado: ninguna impresora archiva las impresiones iniciadas fuera de Bambuddy.',

+ 4 - 1
frontend/src/i18n/locales/fr.ts

@@ -2817,7 +2817,10 @@ export default {
     styleVibrant: 'Vif',
     themeToggleHint: 'Basculer entre le mode sombre, clair et système avec l\'icône dans la barre latérale.',
     progressInTitle: 'Progression dans l\'onglet',
-    progressInTitleDescription: 'Affiche le pourcentage de l\'impression en cours et un anneau de progression dans l\'onglet du navigateur.',
+    progressInTitleDescription: 'Afficher la progression de l’impression en cours dans l’onglet du navigateur et le favicon.',
+    tabDisplay: 'Affichage de l’onglet',
+    tabDisplayPercentage: 'Pourcentage',
+    tabDisplayTime: 'Temps restant',
     autoArchivePrints: 'Archiver automatiquement les impressions',
     autoArchiveDescription: 'Sauvegarder automatiquement les fichiers 3MF à la fin des impressions. Désactivé : aucune imprimante n\'archive les impressions lancées en dehors de Bambuddy.',
     captureFinishPhotoDescription: 'Prendre une photo avec la caméra de l\'imprimante à la fin de l\'impression. Bambuddy enregistre un court timelapse pendant l\'impression afin que la photo puisse provenir du moment précédant l\'abaissement du plateau ; le fichier du timelapse est conservé si vous avez activé le timelapse pour cette impression, sinon il est supprimé automatiquement après la capture de la photo.',

+ 4 - 1
frontend/src/i18n/locales/it.ts

@@ -2816,7 +2816,10 @@ export default {
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Passa tra modalità scura, chiara e sistema con l\'icona nella barra laterale.',
     progressInTitle: 'Avanzamento nella scheda',
-    progressInTitleDescription: 'Mostra la percentuale della stampa attiva e un anello di avanzamento nella scheda del browser.',
+    progressInTitleDescription: 'Mostra l\'avanzamento della stampa nella scheda del browser e nella favicon.',
+    tabDisplay: 'Visualizzazione scheda',
+    tabDisplayPercentage: 'Percentuale',
+    tabDisplayTime: 'Tempo rimanente',
     autoArchivePrints: 'Archiviazione automatica stampe',
     autoArchiveDescription: 'Salva automaticamente i file 3MF al completamento delle stampe. Disattivato: nessuna stampante archivia le stampe avviate al di fuori di Bambuddy.',
     captureFinishPhotoDescription: 'Scatta una foto dalla fotocamera della stampante al completamento della stampa. Bambuddy registra un breve timelapse durante la stampa in modo che la foto possa essere ricavata dal momento precedente all\'abbassamento del piatto; il file del timelapse viene mantenuto se hai abilitato il timelapse per questa stampa, altrimenti viene eliminato automaticamente dopo l\'acquisizione della foto.',

+ 4 - 1
frontend/src/i18n/locales/ja.ts

@@ -2862,7 +2862,10 @@ export default {
     styleVibrant: 'ビビッド',
     themeToggleHint: 'サイドバーのアイコンでダーク、ライト、システムモードを切り替えます。',
     progressInTitle: 'タブに印刷の進捗を表示',
-    progressInTitleDescription: 'ブラウザのタブに進行中の印刷の進捗率と進捗リングを表示します。',
+    progressInTitleDescription: '印刷の進捗をブラウザーのタブとファビコンに表示します。',
+    tabDisplay: 'タブの表示',
+    tabDisplayPercentage: '進捗率',
+    tabDisplayTime: '残り時間',
     // Archive
     autoArchivePrints: '印刷を自動アーカイブ',
     autoArchiveDescription: '印刷完了時に3MFファイルを自動保存。オフの場合、Bambuddy 以外から開始された印刷はどのプリンターでもアーカイブされません。',

+ 4 - 1
frontend/src/i18n/locales/ko.ts

@@ -2728,7 +2728,10 @@ export default {
     styleVibrant: '비브런트',
     themeToggleHint: '사이드바의 태양/달 아이콘으로 다크 모드와 라이트 모드를 전환하세요.',
     progressInTitle: '탭에 인쇄 진행률 표시',
-    progressInTitleDescription: '브라우저 탭에 진행 중인 인쇄의 백분율과 진행 링을 표시합니다.',
+    progressInTitleDescription: '활성 인쇄의 진행 상황을 브라우저 탭 제목과 파비콘에 표시합니다.',
+    tabDisplay: '탭 표시',
+    tabDisplayPercentage: '진행률',
+    tabDisplayTime: '남은 시간',
     autoArchivePrints: '인쇄 자동 아카이브',
     autoArchiveDescription: '인쇄 완료 시 3MF 파일 자동 저장. 끄면 Bambuddy 외부에서 시작된 인쇄는 어떤 프린터에서도 보관되지 않습니다.',
     captureFinishPhotoDescription: '인쇄 완료 시 프린터 카메라로 사진 촬영. Bambuddy는 인쇄 중 짧은 타임랩스를 기록하여 베드가 내려가기 전 순간에서 사진을 가져올 수 있도록 합니다. 이 인쇄에 대해 타임랩스를 활성화한 경우 타임랩스 파일이 보관되며, 그렇지 않으면 사진 촬영 후 자동으로 삭제됩니다.',

+ 4 - 1
frontend/src/i18n/locales/nl.ts

@@ -2883,7 +2883,10 @@ export default {
     styleVibrant: 'Levendig',
     themeToggleHint: 'Schakel met het pictogram in de zijbalk tussen donkere, lichte en systeemmodus.',
     progressInTitle: 'Afdrukvoortgang in tabblad',
-    progressInTitleDescription: 'Toon het percentage van de actieve afdruk en een voortgangsring in het browsertabblad.',
+    progressInTitleDescription: 'Toon de voortgang van de actieve print in het browsertabblad en favicon.',
+    tabDisplay: 'Tabweergave',
+    tabDisplayPercentage: 'In procenten',
+    tabDisplayTime: 'Resterende tijd',
     // Archive
     autoArchivePrints: 'Afdrukken automatisch archiveren',
     autoArchiveDescription: '3MF-bestanden automatisch opslaan wanneer afdrukken zijn voltooid. Uit: geen enkele printer archiveert afdrukken die buiten Bambuddy zijn gestart.',

+ 4 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -2816,7 +2816,10 @@ export default {
     styleVibrant: 'Vibrante',
     themeToggleHint: 'Alternar entre modo escuro, claro e sistema usando o ícone na barra lateral.',
     progressInTitle: 'Progresso na aba',
-    progressInTitleDescription: 'Mostra a porcentagem da impressão ativa e um anel de progresso na aba do navegador.',
+    progressInTitleDescription: 'Mostra o progresso da impressão ativa na aba do navegador e no favicon.',
+    tabDisplay: 'Exibição da aba',
+    tabDisplayPercentage: 'Porcentagem',
+    tabDisplayTime: 'Tempo restante',
     autoArchivePrints: 'Arquivar impressões automaticamente',
     autoArchiveDescription: 'Salvar automaticamente arquivos 3MF quando impressões forem concluídas. Desativado: nenhuma impressora arquiva impressões iniciadas fora do Bambuddy.',
     captureFinishPhotoDescription: 'Tirar foto da câmera da impressora quando a impressão for concluída. Bambuddy grava um timelapse curto durante a impressão para que a foto possa ser obtida do momento antes da mesa descer; o arquivo do timelapse é mantido se você habilitou o timelapse para esta impressão, caso contrário ele é excluído automaticamente após a captura da foto.',

+ 4 - 1
frontend/src/i18n/locales/ru.ts

@@ -2732,7 +2732,10 @@ export default {
     styleVibrant: "Насыщенный",
     themeToggleHint: "Переключайте тёмную, светлую и системную тему значком в боковой панели.",
     progressInTitle: "Прогресс во вкладке",
-    progressInTitleDescription: "Показывает процент текущей печати и кольцо прогресса во вкладке браузера.",
+    progressInTitleDescription: 'Показывать ход активной печати во вкладке браузера и на значке сайта.',
+    tabDisplay: 'Отображение вкладки',
+    tabDisplayPercentage: 'Процент',
+    tabDisplayTime: 'Оставшееся время',
     autoArchivePrints: "Автоматически архивировать печать",
     autoArchiveDescription: "Автоматически сохранять 3MF после завершения печати. Если выключено, ни один принтер не архивирует печать, запущенную не из Bambuddy.",
     captureFinishPhotoDescription: "Сделать снимок камерой принтера после завершения печати. Во время печати Bambuddy записывает короткий таймлапс, чтобы получить кадр до опускания стола. Если таймлапс был включён для задания, файл сохранится; иначе после получения снимка он будет автоматически удалён.",

+ 4 - 1
frontend/src/i18n/locales/sv.ts

@@ -2882,7 +2882,10 @@ export default {
     styleVibrant: 'Livfull',
     themeToggleHint: 'Växla mellan mörkt, ljust och systemläge med ikonen i sidomenyn.',
     progressInTitle: 'Utskriftsförlopp i flik',
-    progressInTitleDescription: 'Visa den aktiva utskriftens procentförlopp och en ring i webbläsarfliken.',
+    progressInTitleDescription: 'Visa förloppet för den aktiva utskriften i webbläsarfliken och favikonen.',
+    tabDisplay: 'Flikvisning',
+    tabDisplayPercentage: 'Procent',
+    tabDisplayTime: 'Återstående tid',
     // Archive
     autoArchivePrints: 'Auto-arkivera utskrifter',
     autoArchiveDescription: 'Spara automatiskt 3MF-filer när utskrifter slutförs. Av: ingen skrivare arkiverar utskrifter som startats utanför Bambuddy.',

+ 4 - 1
frontend/src/i18n/locales/tr.ts

@@ -2866,7 +2866,10 @@ export default {
     styleVibrant: 'Canlı',
     themeToggleHint: 'Kenar çubuğundaki güneş/ay simgesini kullanarak koyu ve açık mod arasında geçiş yapın.',
     progressInTitle: 'Sekmede baskı ilerlemesi',
-    progressInTitleDescription: 'Tarayıcı sekmesinde etkin baskının yüzdesini ve bir ilerleme halkası gösterir.',
+    progressInTitleDescription: 'Etkin baskının ilerlemesini tarayıcı sekmesinde ve simgesinde göster.',
+    tabDisplay: 'Sekme görünümü',
+    tabDisplayPercentage: 'Yüzde',
+    tabDisplayTime: 'Kalan süre',
     // Arşiv
     autoArchivePrints: 'Baskıları otomatik arşivle',
     autoArchiveDescription: 'Baskılar tamamlandığında 3MF dosyalarını otomatik olarak kaydet. Kapalıyken hiçbir yazıcı Bambuddy dışında başlatılan baskıları arşivlemez.',

+ 4 - 1
frontend/src/i18n/locales/uk.ts

@@ -2880,7 +2880,10 @@ export default {
     styleVibrant: "Яскравий",
     themeToggleHint: "Перемикайтеся між темним, світлим і системним режимами за допомогою значка на бічній панелі.",
     progressInTitle: "Прогрес у вкладці",
-    progressInTitleDescription: "Показує відсоток активного друку та кільце прогресу на вкладці браузера.",
+    progressInTitleDescription: 'Показувати перебіг активного друку у вкладці браузера та на значку сайту.',
+    tabDisplay: 'Вигляд вкладки',
+    tabDisplayPercentage: 'Відсоток',
+    tabDisplayTime: 'Час до завершення',
     // Archive
     autoArchivePrints: "Автоматично архівувати друки",
     autoArchiveDescription: "Автоматично зберігати файли 3MF після завершення друку. Якщо вимкнено, жоден принтер не архівує друк, запущений не з Bambuddy.",

+ 4 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -2861,7 +2861,10 @@ export default {
     styleVibrant: '鲜艳',
     themeToggleHint: '使用侧边栏中的图标在深色、浅色和系统模式之间切换。',
     progressInTitle: '在标签页显示打印进度',
-    progressInTitleDescription: '在浏览器标签页中显示当前打印的百分比和进度环。',
+    progressInTitleDescription: '在浏览器标签页标题和网站图标中显示当前打印进度。',
+    tabDisplay: '标签页显示',
+    tabDisplayPercentage: '百分比',
+    tabDisplayTime: '剩余时间',
     autoArchivePrints: '自动归档打印',
     autoArchiveDescription: '打印完成时自动保存3MF文件。关闭时,所有打印机都不会归档在 Bambuddy 之外启动的打印。',
     captureFinishPhotoDescription: '打印完成时从打印机摄像头拍照。Bambuddy 会在打印期间录制一段短延时摄影,以便从热床下降前的瞬间获取照片;如果您为本次打印启用了延时摄影,文件将保留,否则会在拍照完成后自动删除。',

+ 4 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -2861,7 +2861,10 @@ export default {
     styleVibrant: '鮮豔',
     themeToggleHint: '使用側邊欄中的圖示在深色、淺色和系統模式之間切換。',
     progressInTitle: '在分頁顯示列印進度',
-    progressInTitleDescription: '在瀏覽器分頁中顯示目前列印的百分比和進度環。',
+    progressInTitleDescription: '在瀏覽器分頁標題和網站圖示中顯示目前列印進度。',
+    tabDisplay: '分頁顯示',
+    tabDisplayPercentage: '百分比',
+    tabDisplayTime: '剩餘時間',
     autoArchivePrints: '自動歸檔列印',
     autoArchiveDescription: '列印完成時自動儲存3MF檔案。關閉時,所有印表機都不會歸檔在 Bambuddy 之外啟動的列印。',
     captureFinishPhotoDescription: '列印完成時從印表機攝影機拍照。Bambuddy 會在列印期間錄製一段短縮時攝影,以便從熱床下降前的瞬間取得照片;如果您為本次列印啟用了縮時攝影,檔案將保留,否則會在拍照完成後自動刪除。',

+ 22 - 1
frontend/src/pages/SettingsPage.tsx

@@ -272,7 +272,7 @@ export function SettingsPage() {
     setMode,
     setDarkStyle, setDarkBackground, setDarkAccent,
     setLightStyle, setLightBackground, setLightAccent,
-    progressInTitle, setProgressInTitle,
+    progressInTitle, setProgressInTitle, tabDisplay, setTabDisplay,
   } = useTheme();
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   // Transient typed strings for the per-filament humidity threshold inputs
@@ -2103,6 +2103,27 @@ export function SettingsPage() {
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
                 </label>
               </div>
+              {progressInTitle && (
+                <div className="flex flex-wrap items-center gap-2">
+                  <span className="text-sm text-bambu-gray">{t('settings.tabDisplay')}:</span>
+                  <div className="flex gap-1">
+                    {([
+                      { id: 'percentage', label: t('settings.tabDisplayPercentage') },
+                      { id: 'time', label: t('settings.tabDisplayTime') },
+                    ] as const).map(({ id, label }) => (
+                      <button
+                        key={id}
+                        type="button"
+                        aria-pressed={tabDisplay === id}
+                        onClick={() => { setTabDisplay(id); showToast(t('settings.toast.settingsSaved'), 'success'); }}
+                        className={`px-3 py-1 text-xs rounded-lg border transition-colors ${tabDisplay === id ? 'border-bambu-green bg-bambu-green/10 text-bambu-green' : 'border-gray-300 dark:border-bambu-dark-tertiary text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white cursor-pointer'}`}
+                      >
+                        {label}
+                      </button>
+                    ))}
+                  </div>
+                </div>
+              )}
             </CardContent>
           </Card>