Keep admitted media frames in one stable dashboard session tree
This commit is contained in:
@@ -91,23 +91,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Panel mode app session — renders alongside current page content -->
|
<!-- Stable keyed frames retain playback across late media admission. -->
|
||||||
<Transition name="panel-slide">
|
<DashboardAppSessions />
|
||||||
<div v-if="appLauncher.panelAppId && appLauncher.panelAppId !== appLauncher.mediaAppId" class="app-panel-container">
|
|
||||||
<AppSession
|
|
||||||
v-if="appLauncher.panelAppId !== appLauncher.mediaAppId"
|
|
||||||
:key="appLauncher.panelAppId"
|
|
||||||
:app-id-prop="appLauncher.panelAppId"
|
|
||||||
:path-prop="appLauncher.panelPath ?? undefined"
|
|
||||||
@close="appLauncher.closePanel()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
<Transition name="panel-slide">
|
|
||||||
<div v-if="appLauncher.mediaAppId" v-show="appLauncher.panelAppId === appLauncher.mediaAppId" class="app-panel-container">
|
|
||||||
<AppSession :key="appLauncher.mediaAppId" :app-id-prop="appLauncher.mediaAppId" :suspended="appLauncher.panelAppId !== appLauncher.mediaAppId" @close="appLauncher.closePanel()" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
|
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
|
||||||
@@ -127,7 +112,7 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||||
import { appConfirm } from '@/composables/useAppConfirm'
|
import { appConfirm } from '@/composables/useAppConfirm'
|
||||||
import AppSession from '@/views/AppSession.vue'
|
import DashboardAppSessions from '@/views/dashboard/DashboardAppSessions.vue'
|
||||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<TransitionGroup name="panel-slide">
|
||||||
|
<div v-for="id in sessionIds" :key="id" v-show="launcher.panelAppId === id" class="app-panel-container">
|
||||||
|
<AppSession :app-id-prop="id" :path-prop="paths.get(id)" :suspended="launcher.panelAppId !== id" @close="launcher.closePanel()" />
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
|
import AppSession from '@/views/AppSession.vue'
|
||||||
|
const launcher = useAppLauncherStore()
|
||||||
|
const paths = reactive(new Map<string, string | undefined>())
|
||||||
|
// A single keyed tree owns each frame throughout visible -> retained promotion.
|
||||||
|
const sessionIds = computed(() => [...new Set([launcher.mediaAppId, launcher.panelAppId].filter((id): id is string => !!id))])
|
||||||
|
watch([() => launcher.panelAppId, () => launcher.panelPath], ([id, path]) => {
|
||||||
|
if (!id) return
|
||||||
|
// Reopening playback without an explicit deep link keeps the original URL.
|
||||||
|
if (id === launcher.mediaAppId && paths.has(id) && path == null) return
|
||||||
|
paths.set(id, path ?? undefined)
|
||||||
|
}, { immediate: true })
|
||||||
|
watch(sessionIds, ids => {
|
||||||
|
for (const id of paths.keys()) if (!ids.includes(id)) paths.delete(id)
|
||||||
|
}, { flush: 'sync' })
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.panel-slide-enter-active, .panel-slide-leave-active { transition: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent, h, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
|
vi.mock('@/stores/appLauncher', async () => {
|
||||||
|
const {reactive} = await import('vue')
|
||||||
|
const state = reactive({panelAppId:null as string|null, mediaAppId:null as string|null, panelPath:null as string|null,
|
||||||
|
closePanel() {state.panelAppId=null;state.panelPath=null}})
|
||||||
|
return {useAppLauncherStore:()=>state}
|
||||||
|
})
|
||||||
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
|
import DashboardAppSessions from '../DashboardAppSessions.vue'
|
||||||
|
describe('stable dashboard app frame ownership', () => {
|
||||||
|
const mounted=vi.fn(), unmounted=vi.fn()
|
||||||
|
const Session=defineComponent({props:{appIdProp:String,pathProp:String,suspended:Boolean},setup(props) {
|
||||||
|
onMounted(()=>mounted(props.appIdProp));onUnmounted(()=>unmounted(props.appIdProp))
|
||||||
|
return ()=>h('iframe',{'data-app':props.appIdProp,src:props.pathProp ?? '/browse','data-suspended':String(props.suspended)})
|
||||||
|
}})
|
||||||
|
beforeEach(()=> {vi.clearAllMocks();const store=useAppLauncherStore();store.panelAppId=null;store.mediaAppId=null;store.panelPath=null})
|
||||||
|
it('keeps the exact playing frame and deep link through late promotion, close and reopen',async()=>{
|
||||||
|
const store=useAppLauncherStore();store.panelAppId='music';store.panelPath='/browse/album/one'
|
||||||
|
const wrapper=mount(DashboardAppSessions,{global:{stubs:{AppSession:Session,TransitionGroup:false}}})
|
||||||
|
const frame=wrapper.get('iframe').element as HTMLIFrameElement
|
||||||
|
frame.dataset.playbackPosition='17'
|
||||||
|
store.mediaAppId='music';await nextTick()
|
||||||
|
expect(wrapper.get('iframe').element).toBe(frame)
|
||||||
|
expect(mounted).toHaveBeenCalledTimes(1);expect(unmounted).not.toHaveBeenCalled()
|
||||||
|
store.closePanel();await nextTick()
|
||||||
|
expect(wrapper.get('iframe').element).toBe(frame)
|
||||||
|
expect(frame.getAttribute('src')).toBe('/browse/album/one')
|
||||||
|
expect(frame.dataset.suspended).toBe('true')
|
||||||
|
store.panelAppId='music';await nextTick()
|
||||||
|
expect(wrapper.get('iframe').element).toBe(frame)
|
||||||
|
expect(frame.dataset.playbackPosition).toBe('17')
|
||||||
|
expect(frame.getAttribute('src')).toBe('/browse/album/one')
|
||||||
|
expect(frame.dataset.suspended).toBe('false')
|
||||||
|
expect(unmounted).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount();expect(unmounted).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
it('keeps retained music while another app opens and promotes that app without recreating it',async()=>{
|
||||||
|
const store=useAppLauncherStore();store.panelAppId='music';store.panelPath='/first'
|
||||||
|
const wrapper=mount(DashboardAppSessions,{global:{stubs:{AppSession:Session,TransitionGroup:false}}})
|
||||||
|
const music=wrapper.get('iframe[data-app="music"]').element
|
||||||
|
store.mediaAppId='music';await nextTick()
|
||||||
|
store.panelPath='/station/two';store.panelAppId='radio';await nextTick()
|
||||||
|
const radio=wrapper.get('iframe[data-app="radio"]').element
|
||||||
|
expect(wrapper.get('iframe[data-app="music"]').element).toBe(music)
|
||||||
|
expect(music.getAttribute('src')).toBe('/first')
|
||||||
|
store.mediaAppId='radio';await nextTick()
|
||||||
|
expect(wrapper.get('iframe[data-app="radio"]').element).toBe(radio)
|
||||||
|
expect(mounted.mock.calls.map(call=>call[0])).toEqual(['music','radio'])
|
||||||
|
expect(unmounted).toHaveBeenCalledWith('music')
|
||||||
|
expect(unmounted).not.toHaveBeenCalledWith('radio')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user