Repository navigation
feat(calls): 画面共有のズーム・倍率スライダー・ドラッグ移動に対応 #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,8 +6,10 @@ | |
| <template> | ||
| <MkWindow :initialWidth="480" :initialHeight="320" canResize front @closed="emit('closed')"> | ||
| <template #header><i class="ti ti-app-window"></i> {{ label }}</template> | ||
| <div :class="$style.content"> | ||
| <video ref="video" autoplay muted playsinline :aria-label="label" :class="$style.video" @canplay="play"></video> | ||
| <div ref="viewport" :class="$style.content" @wheel="onWheel"> | ||
| <video ref="video" autoplay muted playsinline :aria-label="label" :class="$style.video" :style="{ transform }" @loadedmetadata="constrain" @canplay="play"></video> | ||
| <div v-if="zoom > 100" :class="[$style.pan, dragging && $style.dragging]" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="stopDragging" @pointercancel="stopDragging" @lostpointercapture="stopDragging" @click.stop></div> | ||
| <label :class="$style.zoom" @click.stop @contextmenu.stop><input type="range" min="100" max="400" step="1" :value="zoom" :aria-label="i18n.ts._calls.screenZoom" @input="setZoom(($event.target as HTMLInputElement).valueAsNumber)"><span>{{ zoom }}%</span></label> | ||
|
Check failure on line 12 in packages/frontend/src/components/MkCallsScreenWindow.vue
|
||
| <label v-if="screenVideo != null && session.screenAudioIds.value.has(screenVideo.id)" :class="$style.volume"><i class="ti ti-volume"></i><input type="range" min="0" max="100" step="1" :value="session.getScreenVolume(screenVideo.id)" :aria-label="i18n.ts._calls.screenAudioVolume" @input="session.setScreenVolume(screenVideo.id, ($event.target as HTMLInputElement).valueAsNumber)"></label> | ||
| </div> | ||
| </MkWindow> | ||
|
|
@@ -19,6 +21,7 @@ | |
| import { useCallsSession } from '@/utility/calls-session.js'; | ||
| import * as os from '@/os.js'; | ||
| import { i18n } from '@/i18n.js'; | ||
| import { useCallsScreenZoom } from '@/composables/use-calls-screen-zoom.js'; | ||
|
|
||
| const props = defineProps<{ | ||
| stream: MediaStream; | ||
|
|
@@ -29,6 +32,8 @@ | |
| const session = useCallsSession(); | ||
| const screenVideo = computed(() => session.videos.value.find(item => !item.local && item.stream === props.stream)); | ||
| const video = shallowRef<HTMLVideoElement | null>(null); | ||
| const viewport = shallowRef<HTMLElement | null>(null); | ||
| const { zoom, transform, dragging, setZoom, onWheel, onPointerDown, onPointerMove, stopDragging, reset, constrain } = useCallsScreenZoom(viewport, video, computed(() => true)); | ||
|
|
||
| function play(): void { | ||
| if (video.value?.srcObject == null) return; | ||
|
|
@@ -40,6 +45,7 @@ | |
|
|
||
| watch([video, () => props.stream], ([element, stream]) => { | ||
| if (element == null) return; | ||
| reset(); | ||
| element.srcObject = stream; | ||
| play(); | ||
| }, { flush: 'post' }); | ||
|
|
@@ -51,8 +57,13 @@ | |
| </script> | ||
|
|
||
| <style lang="scss" module> | ||
| .content { position: relative; height: 100%; } | ||
| .content { position: relative; height: 100%; overflow: hidden; } | ||
| .volume { position: absolute; top: 8px; right: 8px; display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 8px; background: var(--MI_THEME-panel); } | ||
| .volume > input { width: 100px; accent-color: var(--MI_THEME-accent); } | ||
| .video { display: block; width: 100%; height: 100%; object-fit: contain; background: var(--MI_THEME-bg); } | ||
| .pan { position: absolute; inset: 0; cursor: grab; touch-action: none; } | ||
| .pan.dragging { cursor: grabbing; } | ||
| .zoom { position: absolute; right: 8px; bottom: 8px; display: flex; align-items: center; gap: 6px; max-width: calc(100% - 32px); padding: 6px 8px; border-radius: 8px; background: var(--MI_THEME-panel); } | ||
| .zoom > input { width: 100px; min-width: 0; accent-color: var(--MI_THEME-accent); } | ||
| .zoom > span { min-width: 4ch; text-align: right; font-size: 0.8rem; font-variant-numeric: tabular-nums; } | ||
| </style> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only | |
| --> | ||
|
|
||
| <template> | ||
| <figure :class="[$style.root, speaking && $style.speaking, focused && $style.focused]"> | ||
| <video ref="video" autoplay muted playsinline :aria-label="label" :class="$style.video" @canplay="startPlayback"></video> | ||
| <figure ref="viewport" :class="[$style.root, screen && $style.screen, speaking && $style.speaking, focused && $style.focused]" @wheel="onWheel"> | ||
| <video ref="video" autoplay muted playsinline :aria-label="label" :class="$style.video" :style="screen ? { transform } : undefined" @loadedmetadata="constrain" @canplay="startPlayback"></video> | ||
| <button type="button" class="_button" :class="$style.select" :aria-label="focused ? i18n.ts._calls.showVideoGrid : i18n.ts._calls.focusVideo" :aria-pressed="focused" @click="emit('select')"></button> | ||
| <div v-if="screen && zoom > 100" :class="[$style.pan, dragging && $style.dragging]" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="stopDragging" @pointercancel="stopDragging" @lostpointercapture="stopDragging" @click.stop></div> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win ドラッグ以外のクリックでは、映像を選択できるようにしてください。 ズーム中は 🤖 Prompt for AI Agents |
||
| <label v-if="screen" :class="$style.zoom" @click.stop @contextmenu.stop><input type="range" min="100" max="400" step="1" :value="zoom" :aria-label="i18n.ts._calls.screenZoom" @input="setZoom(($event.target as HTMLInputElement).valueAsNumber)"><span>{{ zoom }}%</span></label> | ||
| <button v-if="needsPlayback" type="button" class="_button" :class="$style.play" :aria-label="i18n.ts._calls.resumeVideo" @click="startPlayback"><i class="ti ti-player-play"></i> {{ i18n.ts._calls.resumeVideo }}</button> | ||
| <button v-if="screenWindow" type="button" class="_button" :class="$style.windowButton" :aria-label="i18n.ts.openInWindow" :title="i18n.ts.openInWindow" :aria-pressed="screenWindowActive" @click="emit('screenWindow')"><i class="ti ti-app-window"></i></button> | ||
| <label v-if="audioVolume != null" :class="$style.volume" @click.stop @contextmenu.stop><i class="ti ti-volume"></i><input type="range" min="0" max="100" step="1" :value="audioVolume" :aria-label="`${i18n.ts._calls.screenAudioVolume}: ${label}`" @input="emit('volume', ($event.target as HTMLInputElement).valueAsNumber)"></label> | ||
|
|
@@ -15,20 +17,24 @@ SPDX-License-Identifier: AGPL-3.0-only | |
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import { shallowRef, watch } from 'vue'; | ||
| import { computed, shallowRef, watch } from 'vue'; | ||
| import { i18n } from '@/i18n.js'; | ||
| import { useCallsScreenZoom } from '@/composables/use-calls-screen-zoom.js'; | ||
|
|
||
| const props = defineProps<{ | ||
| stream: MediaStream; | ||
| label: string; | ||
| speaking?: boolean; | ||
| focused?: boolean; | ||
| screenWindow?: boolean; | ||
| screen?: boolean; | ||
| screenWindowActive?: boolean; | ||
| audioVolume?: number; | ||
| }>(); | ||
| const emit = defineEmits<{ (ev: 'select' | 'screenWindow'): void; (ev: 'volume', value: number): void }>(); | ||
| const video = shallowRef<HTMLVideoElement | null>(null); | ||
| const viewport = shallowRef<HTMLElement | null>(null); | ||
| const { zoom, transform, dragging, setZoom, onWheel, onPointerDown, onPointerMove, stopDragging, reset, constrain } = useCallsScreenZoom(viewport, video, computed(() => props.screen === true)); | ||
| const needsPlayback = shallowRef(false); | ||
|
|
||
| function startPlayback(): void { | ||
|
|
@@ -43,6 +49,7 @@ function startPlayback(): void { | |
|
|
||
| watch([video, () => props.stream], ([element, stream]) => { | ||
| if (element == null) return; | ||
| reset(); | ||
| element.srcObject = stream; | ||
| startPlayback(); | ||
| }, { flush: 'post' }); | ||
|
|
@@ -95,6 +102,8 @@ watch([video, () => props.stream], ([element, stream]) => { | |
| pointer-events: none; | ||
| } | ||
|
|
||
| .screen > .caption { bottom: 52px; } | ||
|
|
||
| .caption > span { | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
|
|
@@ -108,4 +117,9 @@ watch([video, () => props.stream], ([element, stream]) => { | |
| .play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; background: var(--MI_THEME-panel); } | ||
| @media (pointer: coarse) { .caption, .windowButton { opacity: 1; } } | ||
| @media (prefers-reduced-motion: reduce) { .caption, .windowButton { transition: none; } } | ||
| .pan { position: absolute; inset: 0; cursor: grab; touch-action: none; } | ||
| .pan.dragging { cursor: grabbing; } | ||
| .zoom { position: absolute; right: 56px; bottom: 8px; display: flex; align-items: center; gap: 6px; max-width: calc(100% - 80px); padding: 6px 8px; border-radius: 8px; background: var(--MI_THEME-panel); } | ||
| .zoom > input { width: 100px; min-width: 0; accent-color: var(--MI_THEME-accent); } | ||
| .zoom > span { min-width: 4ch; text-align: right; font-size: 0.8rem; font-variant-numeric: tabular-nums; } | ||
| </style> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: syuilo and misskey-project | ||
| * SPDX-License-Identifier: AGPL-3.0-only | ||
| */ | ||
|
|
||
| import { computed, onUnmounted, shallowRef, watch } from 'vue'; | ||
| import type { Ref } from 'vue'; | ||
|
|
||
| export function useCallsScreenZoom(viewport: Ref<HTMLElement | null>, video: Ref<HTMLVideoElement | null>, enabled: Ref<boolean>) { | ||
| const zoom = shallowRef(100); | ||
| const offset = shallowRef({ x: 0, y: 0 }); | ||
| const dragging = shallowRef(false); | ||
| let pointer: { id: number; x: number; y: number } | null = null; | ||
| const transform = computed(() => `translate(${offset.value.x}px, ${offset.value.y}px) scale(${zoom.value / 100})`); | ||
|
|
||
| function constrain(): void { | ||
| const element = viewport.value; | ||
| if (element == null) return; | ||
| const width = element.clientWidth; | ||
| const height = element.clientHeight; | ||
| const media = video.value; | ||
| const fit = media != null && media.videoWidth > 0 && media.videoHeight > 0 ? Math.min(width / media.videoWidth, height / media.videoHeight) : 1; | ||
| const maxX = Math.max(0, ((media?.videoWidth ? media.videoWidth * fit : width) * zoom.value / 100 - width) / 2); | ||
| const maxY = Math.max(0, ((media?.videoHeight ? media.videoHeight * fit : height) * zoom.value / 100 - height) / 2); | ||
| offset.value = { x: Math.max(-maxX, Math.min(maxX, offset.value.x)), y: Math.max(-maxY, Math.min(maxY, offset.value.y)) }; | ||
| } | ||
|
|
||
| function setZoom(value: number, x = 0, y = 0): void { | ||
| const next = Math.max(100, Math.min(400, Math.round(value))); | ||
| const ratio = next / zoom.value; | ||
| offset.value = { x: x - (x - offset.value.x) * ratio, y: y - (y - offset.value.y) * ratio }; | ||
| zoom.value = next; | ||
| constrain(); | ||
| } | ||
|
|
||
| function onWheel(event: WheelEvent): void { | ||
| if (!enabled.value || (event.target as HTMLElement).closest('input, label')) return; | ||
| event.preventDefault(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 倍率の上限・下限では、スクロールを止めないようにしてください。 100%で縮小方向、または400%で拡大方向にホイールを回しても、倍率は変わりません。それでも 🤖 Prompt for AI Agents |
||
| const rect = viewport.value?.getBoundingClientRect(); | ||
| if (rect == null) return; | ||
| const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? rect.height : 1); | ||
| setZoom(zoom.value * Math.exp(-delta * 0.002), event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2); | ||
| } | ||
|
|
||
| function onPointerDown(event: PointerEvent): void { | ||
| if (event.button !== 0 || pointer != null) return; | ||
| event.preventDefault(); | ||
| (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); | ||
| pointer = { id: event.pointerId, x: event.clientX, y: event.clientY }; | ||
| dragging.value = true; | ||
| } | ||
|
|
||
| function onPointerMove(event: PointerEvent): void { | ||
| if (pointer?.id !== event.pointerId) return; | ||
| offset.value = { x: offset.value.x + event.clientX - pointer.x, y: offset.value.y + event.clientY - pointer.y }; | ||
| pointer = { id: event.pointerId, x: event.clientX, y: event.clientY }; | ||
| constrain(); | ||
| } | ||
|
|
||
| function stopDragging(): void { | ||
| pointer = null; | ||
| dragging.value = false; | ||
| } | ||
|
|
||
| function reset(): void { | ||
| zoom.value = 100; | ||
| offset.value = { x: 0, y: 0 }; | ||
| stopDragging(); | ||
| } | ||
|
|
||
| const observer = new ResizeObserver(constrain); | ||
| watch(viewport, (element, previous) => { | ||
| if (previous != null) observer.unobserve(previous); | ||
| if (element != null) observer.observe(element); | ||
| }, { flush: 'post' }); | ||
| watch(enabled, reset); | ||
| onUnmounted(() => observer.disconnect()); | ||
|
|
||
| return { zoom, transform, dragging, setZoom, onWheel, onPointerDown, onPointerMove, stopDragging, reset, constrain }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: syuilo and misskey-project | ||
| * SPDX-License-Identifier: AGPL-3.0-only | ||
| */ | ||
|
|
||
| import { nextTick } from 'vue'; | ||
| import { afterEach, beforeEach, expect, test, vi } from 'vitest'; | ||
| import { cleanup, fireEvent, render } from '@testing-library/vue'; | ||
| import MkCallsVideo from '@/components/MkCallsVideo.vue'; | ||
|
|
||
| beforeEach(() => { | ||
| vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined); | ||
| }); | ||
| afterEach(() => { | ||
| cleanup(); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| async function setup(screen = true) { | ||
| const select = vi.fn(); | ||
| const view = render(MkCallsVideo, { props: { stream: new window.MediaStream(), label: 'Screen', screen, onSelect: select } }); | ||
| const viewport = view.container.querySelector('figure')!; | ||
| const video = view.container.querySelector('video')!; | ||
| Object.defineProperties(viewport, { clientWidth: { value: 800 }, clientHeight: { value: 600 } }); | ||
| Object.defineProperties(video, { videoWidth: { value: 1600 }, videoHeight: { value: 900 } }); | ||
| vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, width: 800, height: 600 } as DOMRect); | ||
| await nextTick(); | ||
| return { ...view, viewport, video, select }; | ||
| } | ||
|
|
||
| function wheel(element: HTMLElement, deltaY: number, clientX = 400, clientY = 300) { | ||
| // happy-dom's WheelEvent omits the MouseEvent coordinates. | ||
| const event = new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true }); | ||
| Object.assign(event, { clientX, clientY }); | ||
| return fireEvent(element, event); | ||
| } | ||
|
|
||
| test('zooms at the pointer and pans by dragging without selecting the video', async () => { | ||
| const view = await setup(); | ||
| await wheel(view.viewport, -Math.log(2) / 0.002, 600, 300); | ||
| expect(view.getByRole('slider').getAttribute('value')).toBe('200'); | ||
| expect(view.video.style.transform).toBe('translate(-200px, 0px) scale(2)'); | ||
| const pan = view.viewport.querySelector('div')!; | ||
| const capture = vi.fn(); | ||
| pan.setPointerCapture = capture; | ||
| await fireEvent.pointerDown(pan, { button: 0, pointerId: 1, clientX: 400, clientY: 300 }); | ||
| await fireEvent.pointerMove(pan, { pointerId: 1, clientX: 500, clientY: 350 }); | ||
| expect(capture).toHaveBeenCalledWith(1); | ||
| expect(view.video.style.transform).toBe('translate(-100px, 50px) scale(2)'); | ||
| await fireEvent.pointerUp(pan, { pointerId: 1 }); | ||
| await fireEvent.pointerMove(pan, { pointerId: 1, clientX: 700, clientY: 500 }); | ||
| expect(view.video.style.transform).toBe('translate(-100px, 50px) scale(2)'); | ||
| await fireEvent.click(pan); | ||
| expect(view.select).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test('slider controls the zoom, limits panning to the image and resets at 100%', async () => { | ||
| const view = await setup(); | ||
| await fireEvent.input(view.getByRole('slider'), { target: { value: '200' } }); | ||
| expect(view.getByText('200%')).toBeTruthy(); | ||
| const pan = view.viewport.querySelector('div')!; | ||
| pan.setPointerCapture = vi.fn(); | ||
| await fireEvent.pointerDown(pan, { button: 0, pointerId: 1, clientX: 400, clientY: 300 }); | ||
| await fireEvent.pointerMove(pan, { pointerId: 1, clientX: 2000, clientY: -2000 }); | ||
| expect(view.video.style.transform).toBe('translate(400px, -150px) scale(2)'); | ||
| await fireEvent.input(view.getByRole('slider'), { target: { value: '100' } }); | ||
| expect(view.video.style.transform).toBe('translate(0px, 0px) scale(1)'); | ||
| expect(view.viewport.querySelector('div')).toBeNull(); | ||
| await wheel(view.viewport, -10000); | ||
| expect(view.getByText('400%')).toBeTruthy(); | ||
| await view.rerender({ stream: new window.MediaStream() }); | ||
| expect(view.getByText('100%')).toBeTruthy(); | ||
| }); | ||
|
|
||
| test('preserves camera scrolling and video selection', async () => { | ||
| const view = await setup(false); | ||
| const event = new WheelEvent('wheel', { deltaY: -100, bubbles: true, cancelable: true }); | ||
| await fireEvent(view.viewport, event); | ||
| expect(event.defaultPrevented).toBe(false); | ||
| expect(view.queryByRole('slider')).toBeNull(); | ||
| expect(view.video.style.transform).toBe(''); | ||
| await fireEvent.click(view.getByRole('button')); | ||
| expect(view.select).toHaveBeenCalledOnce(); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
映像の固有寸法が変わった際に、両方の表示で移動位置を再制約してください。 両コンポーネントは
loadedmetadataを処理しますが、その後の固有寸法変更を処理しません。ズーム中に画面共有の縦横比が変わると、移動位置が古い制限範囲に残ります。映像要素は固有寸法の変更時にresizeを発火します。(html.spec.whatwg.org)packages/frontend/src/components/MkCallsVideo.vue#L8-L8:videoのresizeイベントでもconstrainを呼んでください。packages/frontend/src/components/MkCallsScreenWindow.vue#L10-L10: 別ウィンドウのvideoにも同じ処理を追加してください。📍 Affects 2 files
packages/frontend/src/components/MkCallsVideo.vue#L8-L8(this comment)packages/frontend/src/components/MkCallsScreenWindow.vue#L10-L10🤖 Prompt for AI Agents