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", 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-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/__tests__/media-store-projection.test.ts b/src/modules/media/__tests__/media-store-projection.test.ts new file mode 100644 index 00000000..b7bf6fbd --- /dev/null +++ b/src/modules/media/__tests__/media-store-projection.test.ts @@ -0,0 +1,103 @@ +// @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, +})) + +const closeProjectionModule = vi.fn() + +vi.mock('@shared/composables/useProjectionWindow', () => ({ + openProjectionModule: vi.fn().mockResolvedValue(true), + isProjectionModuleOpen: vi.fn(() => true), + closeProjectionModule: (...args: unknown[]) => closeProjectionModule(...args), +})) + +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() + closeProjectionModule.mockClear() + }) + + 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') + }) + + it('close fecha a janela cabeada quando o player encerra com projeção ativa', () => { + 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', + showSlide: true, + time: '00:00', + instrumentalTime: '00:00', + imageUrl: null, + imagePosition: null, + isCover: false, + }, + ], + slideTimesSec: [0], + } + store.isProjecting = true + + store.close() + + expect(closeProjectionModule).toHaveBeenCalledTimes(1) + expect(store.isProjecting).toBe(false) + expect(store.session).toBeNull() + }) +}) 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/stores/useMediaStore.ts b/src/modules/media/stores/useMediaStore.ts index 0a376368..9c7c054a 100644 --- a/src/modules/media/stores/useMediaStore.ts +++ b/src/modules/media/stores/useMediaStore.ts @@ -1069,7 +1069,6 @@ export const useMediaStore = defineStore('media', () => { } function clearProjection(): void { - closeProjectionModule() isProjecting.value = false stopProjectionWatch() publishProjectionState() @@ -1093,12 +1092,20 @@ export const useMediaStore = defineStore('media', () => { // ignore } + const shouldCloseCableWindow = isProjectionModuleOpen('media') + if (isProjecting.value) { clearProjection() } else { clearMediaRuntime() } + // Fim da faixa / fechar player: clearProjection só limpa o runtime; + // a janela cabeada precisa fechar de verdade (paridade clock/bíblia). + if (shouldCloseCableWindow) { + closeProjectionModule() + } + session.value = null status.value = 'idle' lastErrorKey.value = null 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() {