From f43715beba20c1911b33200861d0082f7d713b25 Mon Sep 17 00:00:00 2001 From: Ezequias Fonseca Date: Fri, 28 Aug 2026 16:05:44 -0300 Subject: [PATCH 1/5] feat(projection): keep window open with background when clearing projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oculta o conteúdo (letra, texto, versículos) ao encerrar projeção via toggle mantendo a janela secundária aberta com o background/palco. O fechamento total das janelas segue disponível pelo botão fechar todas as telas. --- src/modules/bible/stores/useBibleStore.ts | 1 - .../countdown/stores/useCountdownStore.ts | 3 +- .../views/CountdownProjectionView.vue | 1 + .../__tests__/media-store-projection.test.ts | 65 +++++++++++++++++++ src/modules/media/stores/useMediaStore.ts | 2 - src/modules/random/stores/useRandomStore.ts | 2 - .../random/views/RandomProjectionView.vue | 1 + src/modules/timer/stores/useTimerStore.ts | 3 +- .../timer/views/TimerProjectionView.vue | 1 + 9 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 src/modules/media/__tests__/media-store-projection.test.ts diff --git a/src/modules/bible/stores/useBibleStore.ts b/src/modules/bible/stores/useBibleStore.ts index fb7294e6..dcd7d4a7 100644 --- a/src/modules/bible/stores/useBibleStore.ts +++ b/src/modules/bible/stores/useBibleStore.ts @@ -223,7 +223,6 @@ export const useBibleStore = defineStore('bible', () => { const projectingTvsOnly = ref(false) function clearProjectionWindow() { - closeProjectionModule() isProjecting.value = false projectingTvsOnly.value = false stopProjectionWatch() diff --git a/src/modules/countdown/stores/useCountdownStore.ts b/src/modules/countdown/stores/useCountdownStore.ts index 2f3684ac..9d33845d 100644 --- a/src/modules/countdown/stores/useCountdownStore.ts +++ b/src/modules/countdown/stores/useCountdownStore.ts @@ -2,7 +2,6 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { - closeProjectionModule, isProjectionModuleOpen, openProjectionModule, } from '@shared/composables/useProjectionWindow' @@ -284,9 +283,9 @@ export const useCountdownStore = defineStore('countdown', () => { } function clearProjection() { - closeProjectionModule() isProjecting.value = false projectingTvsOnly.value = false + syncRuntime() stopProjectionWatch() } diff --git a/src/modules/countdown/views/CountdownProjectionView.vue b/src/modules/countdown/views/CountdownProjectionView.vue index 35d8490a..9d1d9b78 100644 --- a/src/modules/countdown/views/CountdownProjectionView.vue +++ b/src/modules/countdown/views/CountdownProjectionView.vue @@ -146,6 +146,7 @@ const effectiveConfig = computed(() => { :style="stageStyle" >
diff --git a/src/modules/media/__tests__/media-store-projection.test.ts b/src/modules/media/__tests__/media-store-projection.test.ts new file mode 100644 index 00000000..5ab1c48f --- /dev/null +++ b/src/modules/media/__tests__/media-store-projection.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useMediaStore } from '../stores/useMediaStore' +import { readMediaRuntimeFromStorage } from '../services/media-runtime' + +vi.mock('@shared/services/desktop-bridge', () => ({ + getDesktopBridge: () => null, + isDesktopApp: () => false, +})) + +vi.mock('@shared/composables/useProjectionWindow', () => ({ + openProjectionModule: vi.fn().mockResolvedValue(true), + isProjectionModuleOpen: vi.fn(() => true), +})) + +vi.mock('@modules/settings/services/palco-session', () => ({ + palcoSession: { + slots: vi.fn().mockResolvedValue([]), + }, +})) + +describe('useMediaStore — controle de projeção e ocultação de conteúdo', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + }) + + it('clearProjection define isProjecting como falso e publica runtime inativo sem fechar a janela', () => { + const store = useMediaStore() + store.session = { + musicId: 10, + albumId: 1, + mode: 'audio', + title: 'Hino Teste', + subtitle: 'Álbum Teste', + coverUrl: null, + audioUrl: 'https://example.com/audio.mp3', + hasInstrumental: false, + slides: [ + { + order: 0, + lyric: 'Linha 1\nLinha 2', + showSlide: true, + time: '00:00', + instrumentalTime: '00:00', + imageUrl: 'https://example.com/slide1.jpg', + imagePosition: null, + isCover: false, + }, + ], + slideTimesSec: [0], + } + store.isProjecting = true + + store.clearProjection() + + expect(store.isProjecting).toBe(false) + const runtime = readMediaRuntimeFromStorage() + expect(runtime.active).toBe(false) + expect(runtime.imageUrl).toBe('https://example.com/slide1.jpg') + expect(runtime.title).toBe('Hino Teste') + }) +}) diff --git a/src/modules/media/stores/useMediaStore.ts b/src/modules/media/stores/useMediaStore.ts index c7b60733..6ab4520d 100644 --- a/src/modules/media/stores/useMediaStore.ts +++ b/src/modules/media/stores/useMediaStore.ts @@ -11,7 +11,6 @@ import { getPalcoRoute, isPalcoTvOnlyRoute } from '@modules/settings/services/pa import { palcoSession } from '@modules/settings/services/palco-session' import { useLocalLibraryStore } from '@modules/sync/stores/useLocalLibraryStore' import { - closeProjectionModule, isProjectionModuleOpen, openProjectionModule, } from '@shared/composables/useProjectionWindow' @@ -1051,7 +1050,6 @@ export const useMediaStore = defineStore('media', () => { } function clearProjection(): void { - closeProjectionModule() isProjecting.value = false stopProjectionWatch() publishProjectionState() diff --git a/src/modules/random/stores/useRandomStore.ts b/src/modules/random/stores/useRandomStore.ts index 7ba55094..41085715 100644 --- a/src/modules/random/stores/useRandomStore.ts +++ b/src/modules/random/stores/useRandomStore.ts @@ -2,7 +2,6 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { - closeProjectionModule, isProjectionModuleOpen, openProjectionModule, } from '@shared/composables/useProjectionWindow' @@ -394,7 +393,6 @@ export const useRandomStore = defineStore('random', () => { } function clearProjection() { - closeProjectionModule() isProjecting.value = false projectingTvsOnly.value = false runtime.value = { ...runtime.value, projecting: false } diff --git a/src/modules/random/views/RandomProjectionView.vue b/src/modules/random/views/RandomProjectionView.vue index a0b80c05..173a2d94 100644 --- a/src/modules/random/views/RandomProjectionView.vue +++ b/src/modules/random/views/RandomProjectionView.vue @@ -134,6 +134,7 @@ const effectiveConfig = computed(() => { :style="stageStyle" >
diff --git a/src/modules/timer/stores/useTimerStore.ts b/src/modules/timer/stores/useTimerStore.ts index 680c09ed..01aee2f9 100644 --- a/src/modules/timer/stores/useTimerStore.ts +++ b/src/modules/timer/stores/useTimerStore.ts @@ -2,7 +2,6 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { - closeProjectionModule, isProjectionModuleOpen, openProjectionModule, } from '@shared/composables/useProjectionWindow' @@ -196,9 +195,9 @@ export const useTimerStore = defineStore('timer', () => { } function clearProjection() { - closeProjectionModule() isProjecting.value = false projectingTvsOnly.value = false + syncRuntime() stopProjectionWatch() } diff --git a/src/modules/timer/views/TimerProjectionView.vue b/src/modules/timer/views/TimerProjectionView.vue index 063729e4..57a341e7 100644 --- a/src/modules/timer/views/TimerProjectionView.vue +++ b/src/modules/timer/views/TimerProjectionView.vue @@ -141,6 +141,7 @@ const effectiveConfig = computed(() => { :style="stageStyle" >
From 776f16b6c691f48f4a18c11a455f2912f1b9623f Mon Sep 17 00:00:00 2001 From: Ezequias Fonseca Date: Fri, 28 Aug 2026 16:57:46 -0300 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20vers=C3=A3o=201.22.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 779577e8..8c49d6ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "louvorja-piano", - "version": "1.22.0", + "version": "1.22.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "louvorja-piano", - "version": "1.22.0", + "version": "1.22.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b430e536..3b0927a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "louvorja-piano", - "version": "1.22.0", + "version": "1.22.1", "private": true, "license": "MIT", "type": "module", From 721b623b5ec1ca4092932006c2d770bbade1df19 Mon Sep 17 00:00:00 2001 From: Rafael Dias Zendron Date: Sun, 30 Aug 2026 00:51:45 -0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(projection):=20Linux=20=E2=80=94=20proj?= =?UTF-8?q?e=C3=A7=C3=A3o=20abre=20no=20segundo=20monitor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No Linux, setFullScreen(true) é EWMH: o window manager decide o monitor e vários WMs (Cinnamon/GNOME/XFCE) jogam o fullscreen no primário, ignorando o monitor alvo passado via window.open (monitor=). Usuário relatou que a projeção nunca abria no monitor 2 em nenhuma versão Linux (1.8.9, 1.22.1, 1.28) — só arrastando a janela. - Linux agora usa a estratégia do win32: borderless + setBounds(bounds do monitor alvo) + alwaysOnTop (fullscreen fake, imune ao WM) - Modo janela com monitor explícito passa a posicionar 800x600 centrado no workArea do monitor alvo (antes ignorava o monitor completamente) - Lógica de display extraída p/ electron/projection-display.mjs (pura, testável): resolveProjectionDisplay + buildProjectionWindowBounds - 8 testes novos (43/43 verdes); npm run build ok --- .../__tests__/projection-display.test.mjs | 80 +++++++++++++++++++ electron/main.mjs | 42 +++++----- electron/projection-display.mjs | 57 +++++++++++++ 3 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 electron/__tests__/projection-display.test.mjs create mode 100644 electron/projection-display.mjs diff --git a/electron/__tests__/projection-display.test.mjs b/electron/__tests__/projection-display.test.mjs new file mode 100644 index 00000000..85cd59d0 --- /dev/null +++ b/electron/__tests__/projection-display.test.mjs @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + resolveProjectionDisplay, + buildProjectionWindowBounds, +} from '../projection-display.mjs' + +const PRIMARY = { + id: 1, + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + workArea: { x: 0, y: 0, width: 1920, height: 1044 }, +} +const SECONDARY = { + id: 2, + bounds: { x: 1920, y: 0, width: 1360, height: 768 }, + workArea: { x: 1920, y: 0, width: 1360, height: 728 }, +} +const DISPLAYS = [PRIMARY, SECONDARY] + +describe('resolveProjectionDisplay', () => { + it('retorna o monitor pedido por id', () => { + expect(resolveProjectionDisplay({ monitorId: 2, displays: DISPLAYS, primary: PRIMARY })).toBe(SECONDARY) + }) + + it('cai para o primário quando monitorId é null', () => { + expect(resolveProjectionDisplay({ monitorId: null, displays: DISPLAYS, primary: PRIMARY })).toBe(PRIMARY) + }) + + it('cai para o primário quando monitorId não existe (monitor desconectado)', () => { + expect(resolveProjectionDisplay({ monitorId: 99, displays: DISPLAYS, primary: PRIMARY })).toBe(PRIMARY) + }) + + it('ignora monitorId não finito', () => { + expect(resolveProjectionDisplay({ monitorId: Number.NaN, displays: DISPLAYS, primary: PRIMARY })).toBe(PRIMARY) + }) +}) + +describe('buildProjectionWindowBounds (fullscreen)', () => { + it('fullscreen usa bounds totais do monitor alvo', () => { + const b = buildProjectionWindowBounds({ + fullscreen: true, + monitorId: 2, + displays: DISPLAYS, + primary: PRIMARY, + }) + expect(b).toEqual({ x: 1920, y: 0, width: 1360, height: 768 }) + }) + + it('fullscreen sem monitor usa bounds do primário', () => { + const b = buildProjectionWindowBounds({ + fullscreen: true, + monitorId: null, + displays: DISPLAYS, + primary: PRIMARY, + }) + expect(b).toEqual({ x: 0, y: 0, width: 1920, height: 1080 }) + }) +}) + +describe('buildProjectionWindowBounds (janela)', () => { + it('modo janela posiciona centrado no workArea do monitor alvo', () => { + const b = buildProjectionWindowBounds({ + fullscreen: false, + monitorId: 2, + displays: DISPLAYS, + primary: PRIMARY, + }) + // 800x600 centrado em workArea 1920..3280 x 0..728 + expect(b).toEqual({ x: 2200, y: 64, width: 800, height: 600 }) + }) + + it('modo janela sem monitor explícito não força posição (WM decide)', () => { + const b = buildProjectionWindowBounds({ + fullscreen: false, + monitorId: null, + displays: DISPLAYS, + primary: PRIMARY, + }) + expect(b).toBeNull() + }) +}) diff --git a/electron/main.mjs b/electron/main.mjs index 082d9541..fd029066 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -17,6 +17,7 @@ import { attachRemoteServer } from "./remote-server.mjs"; import { attachPalcoServer } from "./palco-server.mjs"; import { ensureWorkspaceDirectories } from "./paths.mjs"; import { registerLocalFileProtocol, registerLocalScheme } from "./protocol.mjs"; +import { buildProjectionWindowBounds } from "./projection-display.mjs"; import { initUpdater } from "./updater.mjs"; import { loadWindowState, trackWindowState } from "./window-state.mjs"; import { registerYoutubeEmbedHeaders } from "./youtube-embed.mjs"; @@ -240,26 +241,24 @@ function buildPopupWindowOptions(url, features = '') { }, } - if (!fullscreen) { - windowConfig.skipTaskbar = true - return windowConfig - } - const displays = screen.getAllDisplays() - let targetDisplay = null - if (monitorId != null && Number.isFinite(monitorId)) { - targetDisplay = displays.find((display) => display.id === monitorId) ?? null - } + const bounds = buildProjectionWindowBounds({ + fullscreen, + monitorId, + displays, + primary: screen.getPrimaryDisplay(), + }) - // Sem monitor explícito: primário (openFullscreenOnPrimary). Não assumir 1º estendido. - if (!targetDisplay) { - targetDisplay = screen.getPrimaryDisplay() + // Modo janela sem monitor explícito: posição livre (o WM decide). + if (!bounds) { + windowConfig.skipTaskbar = true + return windowConfig } - windowConfig.x = targetDisplay.bounds.x - windowConfig.y = targetDisplay.bounds.y - windowConfig.width = targetDisplay.bounds.width - windowConfig.height = targetDisplay.bounds.height + windowConfig.x = bounds.x + windowConfig.y = bounds.y + windowConfig.width = bounds.width + windowConfig.height = bounds.height windowConfig.resizable = false windowConfig.skipTaskbar = true @@ -268,13 +267,18 @@ function buildPopupWindowOptions(url, features = '') { /** * Aplica fullscreen / bounds sem chrome após criar a janela (legado win32/linux/mac). + * + * Linux: NÃO usa setFullScreen — o fullscreen EWMH é decidido pelo window + * manager, que em vários WMs ignora o monitor da janela e joga no primário + * (bug "não projeta no segundo monitor"). Usa a mesma estratégia do win32: + * borderless + setBounds(display.bounds) + alwaysOnTop (fullscreen fake). * @param {import('electron').BrowserWindow} childWindow * @param {boolean} fullscreen */ function applyProjectionDisplayMode(childWindow, fullscreen) { - if (!fullscreen || childWindow.isDestroyed()) return + if (childWindow.isDestroyed()) return - if (process.platform === 'win32') { + if (fullscreen && (process.platform === 'win32' || process.platform === 'linux')) { const bounds = childWindow.getBounds() const display = screen.getDisplayMatching(bounds) childWindow.setFullScreen(false) @@ -283,6 +287,8 @@ function applyProjectionDisplayMode(childWindow, fullscreen) { return } + if (!fullscreen) return + childWindow.setFullScreen(true) if (process.platform === 'darwin') { childWindow.setSimpleFullScreen(true) diff --git a/electron/projection-display.mjs b/electron/projection-display.mjs new file mode 100644 index 00000000..35b07eac --- /dev/null +++ b/electron/projection-display.mjs @@ -0,0 +1,57 @@ +/** + * Seleção de monitor e bounds das janelas de projeção. + * + * No Linux, `setFullScreen(true)` é EWMH (_NET_WM_STATE_FULLSCREEN): o window + * manager decide o monitor e vários WMs (Cinnamon, GNOME, XFCE) ignoram o + * posicionamento pré-show, jogando o fullscreen no monitor primário. Por isso + * a projeção no Linux usa a mesma estratégia do Windows: janela borderless + * com bounds do monitor alvo + alwaysOnTop (fullscreen "fake"), imune ao + * comportamento do WM. Bug: "não projeta no segundo monitor" — v1.8.9+. + */ + +const WINDOWED_WIDTH = 800 +const WINDOWED_HEIGHT = 600 + +/** + * Resolve o display alvo da projeção. + * @param {{ monitorId?: number | null, displays: Array, primary: import('electron').Display }} args + * @returns {import('electron').Display} + */ +export function resolveProjectionDisplay({ monitorId, displays, primary }) { + if (monitorId != null && Number.isFinite(monitorId)) { + const found = displays.find((display) => display.id === monitorId) + if (found) return found + } + return primary +} + +/** + * Bounds da janela de projeção. + * - fullscreen: bounds totais do monitor alvo (cobre barra de tarefas). + * - janela com monitor explícito: 800x600 centrado no workArea do monitor. + * - janela sem monitor explícito: null (posição livre, o WM decide). + * @param {{ fullscreen: boolean, monitorId?: number | null, displays: Array, primary: import('electron').Display }} args + * @returns {{ x: number, y: number, width: number, height: number } | null} + */ +export function buildProjectionWindowBounds({ fullscreen, monitorId, displays, primary }) { + const target = resolveProjectionDisplay({ monitorId, displays, primary }) + + if (fullscreen) { + return { + x: target.bounds.x, + y: target.bounds.y, + width: target.bounds.width, + height: target.bounds.height, + } + } + + if (monitorId == null || !Number.isFinite(monitorId)) return null + + const work = target.workArea + return { + x: work.x + Math.round((work.width - WINDOWED_WIDTH) / 2), + y: work.y + Math.round((work.height - WINDOWED_HEIGHT) / 2), + width: WINDOWED_WIDTH, + height: WINDOWED_HEIGHT, + } +} From e987931f2ef34b3a8d9d9d14872c93c27bd5515f Mon Sep 17 00:00:00 2001 From: Rafael Dias Zendron Date: Mon, 31 Aug 2026 18:42:08 -0300 Subject: [PATCH 4/5] fix(media): confina auto-follow da letra ao aside --- .../__tests__/media-aside-scroll.test.ts | 29 ++++++++++++++++ .../media/services/media-aside-scroll.ts | 34 +++++++++++++++++++ src/modules/media/views/MediaView.vue | 16 ++++----- 3 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 src/modules/media/__tests__/media-aside-scroll.test.ts create mode 100644 src/modules/media/services/media-aside-scroll.ts diff --git a/src/modules/media/__tests__/media-aside-scroll.test.ts b/src/modules/media/__tests__/media-aside-scroll.test.ts new file mode 100644 index 00000000..8dce2a7c --- /dev/null +++ b/src/modules/media/__tests__/media-aside-scroll.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { revealItemScrollTop } from '../services/media-aside-scroll' + +describe('media aside auto-follow', () => { + it('centraliza o item no scroll do aside, sem depender do documento', () => { + expect( + revealItemScrollTop({ + scrollTop: 200, + containerTop: 100, + containerHeight: 400, + itemTop: 350, + itemHeight: 40, + }), + ).toBe(270) + }) + + it('não gera scroll negativo para o primeiro item', () => { + expect( + revealItemScrollTop({ + scrollTop: 0, + containerTop: 100, + containerHeight: 400, + itemTop: 110, + itemHeight: 40, + }), + ).toBe(0) + }) +}) diff --git a/src/modules/media/services/media-aside-scroll.ts b/src/modules/media/services/media-aside-scroll.ts new file mode 100644 index 00000000..1948c696 --- /dev/null +++ b/src/modules/media/services/media-aside-scroll.ts @@ -0,0 +1,34 @@ +export function revealItemScrollTop({ + scrollTop, + containerTop, + containerHeight, + itemTop, + itemHeight, +}: { + scrollTop: number + containerTop: number + containerHeight: number + itemTop: number + itemHeight: number +}): number { + return Math.max( + 0, + scrollTop + itemTop - containerTop - (containerHeight - itemHeight) / 2, + ) +} + +export function revealItemInAside(aside: HTMLElement, item: HTMLElement): void { + const asideBox = aside.getBoundingClientRect() + const itemBox = item.getBoundingClientRect() + + aside.scrollTo({ + top: revealItemScrollTop({ + scrollTop: aside.scrollTop, + containerTop: asideBox.top, + containerHeight: aside.clientHeight, + itemTop: itemBox.top, + itemHeight: itemBox.height, + }), + behavior: 'auto', + }) +} diff --git a/src/modules/media/views/MediaView.vue b/src/modules/media/views/MediaView.vue index 61589ce6..15a7658a 100644 --- a/src/modules/media/views/MediaView.vue +++ b/src/modules/media/views/MediaView.vue @@ -8,6 +8,7 @@ import StagePaletteButton from '../../settings/components/StagePaletteButton.vue import MediaPlayerPill from '../components/MediaPlayerPill.vue' import MediaSlideStage from '../components/MediaSlideStage.vue' import { useMediaPlayer } from '../composables/useMediaPlayer' +import { revealItemInAside } from '../services/media-aside-scroll' import { stripHtmlBreaks } from '../services/media-slides' import type { MediaPlaybackMode } from '../types/media' @@ -63,7 +64,7 @@ const { syncProjectionFlag, } = useMediaPlayer() -const playlistListEl = ref(null) +const playlistAsideEl = ref(null) /** Slide/faixa em reprodução visível no painel lateral (auto-scroll). */ const activeListIndex = computed(() => @@ -73,11 +74,9 @@ const activeListIndex = computed(() => watch(activeListIndex, async (index) => { if (index < 0) return await nextTick() - const list = playlistListEl.value - const active = list?.querySelector('.media-window__playlist-item--active') - // Letra em execução sempre no topo do painel (center vertical do item - // no primeiro terço) — evidência contínua enquanto toca. - active?.scrollIntoView({ block: 'center', behavior: 'smooth' }) + const aside = playlistAsideEl.value + const active = aside?.querySelector('.media-window__playlist-item--active') + if (aside && active) revealItemInAside(aside, active) }) const stageLyric = computed(() => currentSlide.value?.lyric ?? '') @@ -242,11 +241,12 @@ async function onToggleFullscreen() {