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() {