mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-25 20:14:55 +02:00
wip
This commit is contained in:
@@ -47,6 +47,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
>
|
||||
<video
|
||||
v-else-if="content.type === 'video'"
|
||||
:id="videoElId"
|
||||
ref="videoEl"
|
||||
:class="[$style.content, $style.original]"
|
||||
:src="content.url"
|
||||
draggable="false"
|
||||
@@ -63,11 +65,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkLoading/>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
|
||||
<div :class="$style.footerText">
|
||||
<div :class="[$style.header, { [$style.infoShowing]: infoShowing && !isZooming }]">
|
||||
<div :class="$style.title">
|
||||
{{ content.comment ?? content.filename }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
|
||||
<div v-if="content.type === 'video'" :class="$style.mediaControl">
|
||||
<MkVideoContol v-if="videoEl != null" :videoElId="videoElId"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -121,11 +129,13 @@ export function calculateSourceTransform({
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { markRaw, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import MkVideoContol from './MkVideoContol.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { makeDoubleTapDetector } from '@/utility/double-tap.js';
|
||||
import { deviceKind } from '@/utility/device-kind.js';
|
||||
import { isTouchUsing } from '@/utility/touch.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content: Content;
|
||||
@@ -143,6 +153,8 @@ const emit = defineEmits<{
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const mainEl = useTemplateRef('mainEl');
|
||||
const videoEl = useTemplateRef('videoEl');
|
||||
const videoElId = genId();
|
||||
|
||||
const originalContentLoaded = ref(false);
|
||||
const thumbnailContentLoaded = ref(false);
|
||||
@@ -156,15 +168,18 @@ onMounted(() => {
|
||||
infoShowing.value = true;
|
||||
});
|
||||
|
||||
const headerSize = 50;
|
||||
const footerSize = props.content.type === 'video' ? 80 : 0;
|
||||
|
||||
const padding = deviceKind === 'smartphone' ? {
|
||||
top: 0,
|
||||
top: Math.max(0, headerSize + 5),
|
||||
right: 0,
|
||||
bottom: 30,
|
||||
bottom: Math.max(0, footerSize + 5),
|
||||
left: 0,
|
||||
} : {
|
||||
top: 30,
|
||||
top: Math.max(30, headerSize + 5),
|
||||
right: 30,
|
||||
bottom: 60,
|
||||
bottom: Math.max(30, footerSize + 5),
|
||||
left: 30,
|
||||
};
|
||||
|
||||
@@ -623,28 +638,53 @@ function onCLick() {
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
bottom: v-bind("-footerSize + 'px'");
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width: max-content;
|
||||
height: 35px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background: var(--MI_THEME-panel);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 85%;
|
||||
color: var(--MI_THEME-fg);
|
||||
height: v-bind("footerSize + 'px'");
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease, bottom 200ms ease;
|
||||
}
|
||||
.footer.infoShowing {
|
||||
bottom: 10px;
|
||||
bottom: 0px;
|
||||
opacity: 1;
|
||||
}
|
||||
.footerText {
|
||||
.header {
|
||||
position: absolute;
|
||||
top: v-bind("-headerSize + 'px'");
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: v-bind("headerSize + 'px'");
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease, top 200ms ease;
|
||||
}
|
||||
.header.infoShowing {
|
||||
top: 0px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
width: max-content;
|
||||
margin: auto;
|
||||
padding: 6px 12px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background: var(--MI_THEME-panel);
|
||||
font-size: 85%;
|
||||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
.mediaControl {
|
||||
width: 100%;
|
||||
max-width: min(1000px, calc(100% - 16px));
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
margin: auto;
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
376
packages/frontend/src/components/MkVideoContol.vue
Normal file
376
packages/frontend/src/components/MkVideoContol.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
|
||||
<i v-else class="ti ti-player-play-filled"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleFullscreen">
|
||||
<i v-if="isFullscreen" class="ti ti-arrows-minimize"></i>
|
||||
<i v-else class="ti ti-arrows-maximize"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsVolume]">
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleMute">
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
<MkMediaRange
|
||||
v-model="volume"
|
||||
:sliderBgWhite="true"
|
||||
:class="$style.volumeSeekbar"
|
||||
/>
|
||||
</div>
|
||||
<MkMediaRange
|
||||
v-model="rangePercent"
|
||||
:sliderBgWhite="true"
|
||||
:class="$style.seekbarRoot"
|
||||
:buffer="bufferedDataRatio"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { exitFullscreen, requestFullscreen } from '@/utility/fullscreen.js';
|
||||
import hasAudio from '@/utility/media-has-audio.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
videoElId: string;
|
||||
}>();
|
||||
|
||||
const videoEl = window.document.getElementById(props.videoElId) as HTMLVideoElement;
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
function showMenu(ev: PointerEvent) {
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
text: i18n.ts._mediaControls.loop,
|
||||
icon: 'ti ti-repeat',
|
||||
ref: loop,
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
text: i18n.ts._mediaControls.playbackRate,
|
||||
icon: 'ti ti-clock-play',
|
||||
ref: speed,
|
||||
options: [{
|
||||
label: '0.25x',
|
||||
value: 0.25,
|
||||
}, {
|
||||
label: '0.5x',
|
||||
value: 0.5,
|
||||
}, {
|
||||
label: '0.75x',
|
||||
value: 0.75,
|
||||
}, {
|
||||
label: '1.0x',
|
||||
value: 1,
|
||||
}, {
|
||||
label: '1.25x',
|
||||
value: 1.25,
|
||||
}, {
|
||||
label: '1.5x',
|
||||
value: 1.5,
|
||||
}, {
|
||||
label: '2.0x',
|
||||
value: 2,
|
||||
}],
|
||||
},
|
||||
...(window.document.pictureInPictureEnabled ? [{
|
||||
text: i18n.ts._mediaControls.pip,
|
||||
icon: 'ti ti-picture-in-picture',
|
||||
action: togglePictureInPicture,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
menuShowing.value = true;
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
|
||||
align: 'right',
|
||||
onClosing: () => {
|
||||
menuShowing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// MediaControl: Video State
|
||||
const isHoverring = ref(false);
|
||||
const isFullscreen = ref(false);
|
||||
let controlStateTimer: number | null = null;
|
||||
|
||||
// MediaControl: Common State
|
||||
const oncePlayed = ref(false);
|
||||
const isReady = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const isActuallyPlaying = ref(false);
|
||||
const elapsedTimeMs = ref(0);
|
||||
const durationMs = ref(0);
|
||||
const rangePercent = computed({
|
||||
get: () => {
|
||||
return (elapsedTimeMs.value / durationMs.value) || 0;
|
||||
},
|
||||
set: (to) => {
|
||||
videoEl.currentTime = to * durationMs.value / 1000;
|
||||
},
|
||||
});
|
||||
const volume = ref(.25);
|
||||
const speed = ref(1);
|
||||
const loop = ref(false); // TODO: ドライブファイルのフラグに置き換える
|
||||
const bufferedEnd = ref(0);
|
||||
const bufferedDataRatio = computed(() => {
|
||||
return bufferedEnd.value / videoEl.duration;
|
||||
});
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!isReady.value) return;
|
||||
|
||||
if (isPlaying.value) {
|
||||
videoEl.pause();
|
||||
isPlaying.value = false;
|
||||
} else {
|
||||
videoEl.play();
|
||||
isPlaying.value = true;
|
||||
oncePlayed.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (playerEl.value == null) return;
|
||||
if (isFullscreen.value) {
|
||||
exitFullscreen({
|
||||
videoEl: videoEl,
|
||||
});
|
||||
isFullscreen.value = false;
|
||||
} else {
|
||||
requestFullscreen({
|
||||
videoEl: videoEl,
|
||||
playerEl: playerEl.value,
|
||||
options: {
|
||||
navigationUI: 'hide',
|
||||
},
|
||||
});
|
||||
isFullscreen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePictureInPicture() {
|
||||
if (window.document.pictureInPictureElement) {
|
||||
window.document.exitPictureInPicture();
|
||||
} else {
|
||||
videoEl.requestPictureInPicture();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (volume.value === 0) {
|
||||
volume.value = .25;
|
||||
} else {
|
||||
volume.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let onceInit = false;
|
||||
let mediaTickFrameId: number | null = null;
|
||||
let stopVideoElWatch: () => void;
|
||||
|
||||
function init() {
|
||||
if (onceInit) return;
|
||||
onceInit = true;
|
||||
|
||||
isReady.value = true;
|
||||
|
||||
function updateMediaTick() {
|
||||
try {
|
||||
bufferedEnd.value = videoEl.buffered.end(0);
|
||||
} catch (err) {
|
||||
bufferedEnd.value = 0;
|
||||
}
|
||||
|
||||
elapsedTimeMs.value = videoEl.currentTime * 1000;
|
||||
|
||||
if (videoEl.loop !== loop.value) {
|
||||
loop.value = videoEl.loop;
|
||||
}
|
||||
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
|
||||
}
|
||||
|
||||
updateMediaTick();
|
||||
|
||||
videoEl.addEventListener('play', () => {
|
||||
isActuallyPlaying.value = true;
|
||||
});
|
||||
|
||||
videoEl.addEventListener('pause', () => {
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
videoEl.addEventListener('ended', () => {
|
||||
oncePlayed.value = false;
|
||||
isActuallyPlaying.value = false;
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
durationMs.value = videoEl.duration * 1000;
|
||||
videoEl.addEventListener('durationchange', () => {
|
||||
durationMs.value = videoEl.duration * 1000;
|
||||
});
|
||||
|
||||
videoEl.volume = volume.value;
|
||||
hasAudio(videoEl).then(had => {
|
||||
if (!had) {
|
||||
videoEl.loop = videoEl.muted = true;
|
||||
videoEl.play();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(volume, (to) => {
|
||||
videoEl.volume = to;
|
||||
});
|
||||
|
||||
watch(speed, (to) => {
|
||||
videoEl.playbackRate = to;
|
||||
});
|
||||
|
||||
watch(loop, (to) => {
|
||||
videoEl.loop = to;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"left time . volume right"
|
||||
"seekbar seekbar seekbar seekbar seekbar";
|
||||
grid-template-columns: auto auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.active {
|
||||
.videoControls {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.videoOverlayPlayButton {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.controlsChild {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #fff;
|
||||
|
||||
.controlButton {
|
||||
padding: 6px;
|
||||
border-radius: calc(var(--MI-radius) / 2);
|
||||
transition: background-color .15s ease;
|
||||
font-size: 1.05rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.controlsRight {
|
||||
grid-area: right;
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
grid-area: time;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
grid-area: volume;
|
||||
|
||||
.volumeSeekbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.seekbarRoot {
|
||||
grid-area: seekbar;
|
||||
/* ▼シークバー操作をやりやすくするためにクリックイベントが伝播されないエリアを拡張する */
|
||||
margin: -10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@container (min-width: 500px) {
|
||||
.root {
|
||||
grid-template-areas: "left seekbar time volume right";
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
.volumeSeekbar {
|
||||
max-width: 90px;
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.root {
|
||||
grid-template-areas:
|
||||
"left . right"
|
||||
"seekbar seekbar seekbar";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
.controlsTime {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controlsVolume {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user