From 8b0fdfcd69334dbf934a69cf707826b3be8cf2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 15 Jan 2024 18:17:01 +0900 Subject: [PATCH] =?UTF-8?q?enhance:=20=E5=8B=95=E7=94=BB=E3=83=BB=E9=9F=B3?= =?UTF-8?q?=E5=A3=B0=E5=91=A8=E3=82=8A=E3=81=AEUI=E3=81=A8=E5=8B=95?= =?UTF-8?q?=E4=BD=9C=E6=94=B9=E8=89=AF=20(#12925)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * (fix) `/files` をバイトレンジリクエストに対応させる * video * audio * fix * fix * spdx * fix (rangeRequest) * fix * Update CHANGELOG.md * (add) ボリュームを保存できるように * (fix) ミュート復帰時に音量が固定される * named export * tweak design * Add sensitive class for audio component * Refactor seekbar styles * Refactor hms * Revert "(add) ボリュームを保存できるように" This reverts commit 6271f9493b63f96d0dd9915207e97fe120ef9037. * Revert "(fix) ミュート復帰時に音量が固定される" This reverts commit a65002b56ecdcb10f76bcc2debbe38593a69643f. * revert revert changes --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> --- CHANGELOG.md | 2 + locales/index.d.ts | 2 + locales/ja-JP.yml | 2 + .../backend/src/server/FileServerService.ts | 111 +++- .../frontend/src/components/MkMediaAudio.vue | 363 ++++++++++++ .../frontend/src/components/MkMediaBanner.vue | 13 +- .../frontend/src/components/MkMediaRange.vue | 150 +++++ .../frontend/src/components/MkMediaVideo.vue | 540 ++++++++++++++++-- packages/frontend/src/filters/hms.ts | 65 +++ packages/frontend/src/scripts/device-kind.ts | 7 + 10 files changed, 1180 insertions(+), 75 deletions(-) create mode 100644 packages/frontend/src/components/MkMediaAudio.vue create mode 100644 packages/frontend/src/components/MkMediaRange.vue create mode 100644 packages/frontend/src/filters/hms.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1fdcf9ee..945b6ac1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Client - Feat: 新しいゲームを追加 +- Feat: 音声・映像プレイヤーを追加 - Feat: 絵文字の詳細ダイアログを追加 - Feat: 枠線をつけるMFM`$[border.width=1,style=solid,color=fff,radius=0 ...]`を追加 - Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように @@ -38,6 +39,7 @@ - Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました - Enhance: ActivityPub Deliver queueでBodyを事前処理するように (#12916) - Enhance: クリップをエクスポートできるように +- Enhance: `/files`のファイルに対してHTTP Rangeリクエストを行えるように - Enhance: `api.json`のOpenAPI Specificationを3.1.0に更新 - Fix: `drive/files/update`でファイル名のバリデーションが機能していない問題を修正 - Fix: `notes/create`で、`text`が空白文字のみで構成されているか`null`であって、かつ`text`だけであるリクエストに対するレスポンスが400になるように変更 diff --git a/locales/index.d.ts b/locales/index.d.ts index dafbdd3559..71134544d9 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1061,6 +1061,8 @@ export interface Locale { "noteIdOrUrl": string; "video": string; "videos": string; + "audio": string; + "audioFiles": string; "dataSaver": string; "accountMigration": string; "accountMoved": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 58952894b3..743a3ca38e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1058,6 +1058,8 @@ limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小し noteIdOrUrl: "ノートIDまたはURL" video: "動画" videos: "動画" +audio: "音声" +audioFiles: "音声" dataSaver: "データセーバー" accountMigration: "アカウントの移行" accountMoved: "このユーザーは新しいアカウントに移行しました:" diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index f59996ce17..7745a6cb78 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -168,11 +168,35 @@ export class FileServerService { } if (!image) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; + if (request.headers.range && file.file.size > 0) { + const range = request.headers.range as string; + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; + if (end > file.file.size) { + end = file.file.size - 1; + } + const chunksize = end - start + 1; + + image = { + data: fs.createReadStream(file.path, { + start, + end, + }), + ext: file.ext, + type: file.mime, + }; + + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + } else { + image = { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } } if ('pipe' in image.data && typeof image.data.pipe === 'function') { @@ -203,11 +227,54 @@ export class FileServerService { reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition('inline', filename)); + + if (request.headers.range && file.file.size > 0) { + const range = request.headers.range as string; + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; + if (end > file.file.size) { + end = file.file.size - 1; + } + const chunksize = end - start + 1; + const fileStream = fs.createReadStream(file.path, { + start, + end, + }); + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + return fileStream; + } + return fs.createReadStream(file.path); } else { reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition('inline', file.filename)); + + if (request.headers.range && file.file.size > 0) { + const range = request.headers.range as string; + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; + console.log(end); + if (end > file.file.size) { + end = file.file.size - 1; + } + const chunksize = end - start + 1; + const fileStream = fs.createReadStream(file.path, { + start, + end, + }); + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + return fileStream; + } + return fs.createReadStream(file.path); } } catch (e) { @@ -340,11 +407,35 @@ export class FileServerService { } if (!image) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; + if (request.headers.range && file.file && file.file.size > 0) { + const range = request.headers.range as string; + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; + if (end > file.file.size) { + end = file.file.size - 1; + } + const chunksize = end - start + 1; + + image = { + data: fs.createReadStream(file.path, { + start, + end, + }), + ext: file.ext, + type: file.mime, + }; + + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + } else { + image = { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } } if ('cleanup' in file) { diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue new file mode 100644 index 0000000000..75b31b9a49 --- /dev/null +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -0,0 +1,363 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + :class="[ + $style.audioContainer, + (audio.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + ]" + @contextmenu.stop +> + <button v-if="hide" :class="$style.hidden" @click="hide = false"> + <div :class="$style.hiddenTextWrapper"> + <b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> + </div> + </button> + <div v-else :class="$style.audioControls"> + <audio + ref="audioEl" + preload="metadata" + :class="$style.audio" + > + <source :src="audio.url"> + </audio> + <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> + </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" + :class="$style.volumeSeekbar" + /> + </div> + <MkMediaRange + v-model="rangePercent" + :class="$style.seekbarRoot" + :buffer="bufferedDataRatio" + /> + </div> +</div> +</template> + +<script lang="ts" setup> +import { shallowRef, watch, computed, ref, onDeactivated, onActivated, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu.js'; +import { defaultStore } from '@/store.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import bytes from '@/filters/bytes.js'; +import { hms } from '@/filters/hms.js'; +import MkMediaRange from '@/components/MkMediaRange.vue'; +import { iAmModerator } from '@/account.js'; + +const props = defineProps<{ + audio: Misskey.entities.DriveFile; +}>(); + +const audioEl = shallowRef<HTMLAudioElement>(); + +// eslint-disable-next-line vue/no-setup-props-destructure +const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore')); + +// Menu +const menuShowing = ref(false); + +function showMenu(ev: MouseEvent) { + let menu: MenuItem[] = []; + + menu = [ + // TODO: 再生キューに追加 + { + text: i18n.ts.hide, + icon: 'ti ti-eye-off', + action: () => { + hide.value = true; + }, + }, + ]; + + if (iAmModerator) { + menu.push({ + type: 'divider', + }, { + text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation', + danger: true, + action: () => toggleSensitive(props.audio), + }); + } + + menuShowing.value = true; + os.popupMenu(menu, ev.currentTarget ?? ev.target, { + align: 'right', + onClosing: () => { + menuShowing.value = false; + }, + }); +} + +function toggleSensitive(file: Misskey.entities.DriveFile) { + os.apiWithDialog('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }); +} + +// 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) => { + if (!audioEl.value) return; + audioEl.value.currentTime = to * durationMs.value / 1000; + }, +}); +const volume = ref(.5); +const bufferedEnd = ref(0); +const bufferedDataRatio = computed(() => { + if (!audioEl.value) return 0; + return bufferedEnd.value / audioEl.value.duration; +}); + +// MediaControl Events +function togglePlayPause() { + if (!isReady.value || !audioEl.value) return; + + if (isPlaying.value) { + audioEl.value.pause(); + isPlaying.value = false; + } else { + audioEl.value.play(); + isPlaying.value = true; + oncePlayed.value = true; + } +} + +function toggleMute() { + if (volume.value === 0) { + volume.value = .5; + } else { + volume.value = 0; + } +} + +let onceInit = false; +let stopAudioElWatch: () => void; + +function init() { + if (onceInit) return; + onceInit = true; + + stopAudioElWatch = watch(audioEl, () => { + if (audioEl.value) { + isReady.value = true; + + function updateMediaTick() { + if (audioEl.value) { + try { + bufferedEnd.value = audioEl.value.buffered.end(0); + } catch (err) { + bufferedEnd.value = 0; + } + + elapsedTimeMs.value = audioEl.value.currentTime * 1000; + } + window.requestAnimationFrame(updateMediaTick); + } + + updateMediaTick(); + + audioEl.value.addEventListener('play', () => { + isActuallyPlaying.value = true; + }); + + audioEl.value.addEventListener('pause', () => { + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + audioEl.value.addEventListener('ended', () => { + oncePlayed.value = false; + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + durationMs.value = audioEl.value.duration * 1000; + audioEl.value.addEventListener('durationchange', () => { + if (audioEl.value) { + durationMs.value = audioEl.value.duration * 1000; + } + }); + + audioEl.value.volume = volume.value; + } + }, { + immediate: true, + }); +} + +watch(volume, (to) => { + if (audioEl.value) audioEl.value.volume = to; +}); + +onMounted(() => { + init(); +}); + +onActivated(() => { + init(); +}); + +onDeactivated(() => { + isReady.value = false; + isPlaying.value = false; + isActuallyPlaying.value = false; + elapsedTimeMs.value = 0; + durationMs.value = 0; + bufferedEnd.value = 0; + hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'); + stopAudioElWatch(); + onceInit = false; +}); +</script> + +<style lang="scss" module> +.audioContainer { + container-type: inline-size; + position: relative; + border: .5px solid var(--divider); + border-radius: var(--radius); + overflow: clip; +} + +.sensitive { + position: relative; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + border-radius: inherit; + box-shadow: inset 0 0 0 4px var(--warn); + } +} + +.hidden { + width: 100%; + background: none; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + padding: 12px 0; + display: flex; + align-items: center; + justify-content: center; + background: #000; +} + +.hiddenTextWrapper { + text-align: center; + font-size: 0.8em; + color: #fff; +} + +.audioControls { + 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; + padding: 10px; +} + +.controlsChild { + display: flex; + align-items: center; + gap: 4px; + + .controlButton { + padding: 6px; + border-radius: calc(var(--radius) / 2); + font-size: 1.05rem; + + &:hover { + color: var(--accent); + background-color: var(--accentedBg); + } + } +} + +.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; +} + +@container (min-width: 500px) { + .audioControls { + 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; + } + } +} +</style> diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 3f8fef6632..b21960a490 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -5,20 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <div v-if="media.isSensitive && hide" :class="$style.sensitive" @click="hide = false"> + <MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/> + <div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="hide = false"> <span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span> <b>{{ i18n.ts.sensitive }}</b> <span>{{ i18n.ts.clickToShow }}</span> </div> - <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :class="$style.audio"> - <audio - ref="audioEl" - :src="media.url" - :title="media.name" - controls - preload="metadata" - /> - </div> <a v-else :class="$style.download" :href="media.url" @@ -35,6 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { shallowRef, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; +import MkMediaAudio from '@/components/MkMediaAudio.vue'; const props = withDefaults(defineProps<{ media: Misskey.entities.DriveFile; diff --git a/packages/frontend/src/components/MkMediaRange.vue b/packages/frontend/src/components/MkMediaRange.vue new file mode 100644 index 0000000000..e6303a5c41 --- /dev/null +++ b/packages/frontend/src/components/MkMediaRange.vue @@ -0,0 +1,150 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<!-- Media系専用のinput range --> +<template> +<div :class="$style.controlsSeekbar" :style="sliderBgWhite ? '--sliderBg: rgba(255,255,255,.25);' : '--sliderBg: var(--scrollbarHandle);'"> + <progress v-if="buffer !== undefined" :class="$style.buffer" :value="isNaN(buffer) ? 0 : buffer" min="0" max="1">{{ Math.round(buffer * 100) }}% buffered</progress> + <input v-model="model" :class="$style.seek" :style="`--value: ${modelValue * 100}%;`" type="range" min="0" max="1" step="any" @change="emit('dragEnded', modelValue)"/> +</div> +</template> + +<script setup lang="ts"> +import { computed, ModelRef } from 'vue'; + +withDefaults(defineProps<{ + buffer?: number; + sliderBgWhite?: boolean; +}>(), { + buffer: undefined, + sliderBgWhite: false, +}); + +const emit = defineEmits<{ + (ev: 'dragEnded', value: number): void; +}>(); + +// eslint-disable-next-line no-undef +const model = defineModel({ required: true }) as ModelRef<string | number>; +const modelValue = computed({ + get: () => typeof model.value === 'number' ? model.value : parseFloat(model.value), + set: v => { model.value = v; }, +}); +</script> + +<style lang="scss" module> +.controlsSeekbar { + position: relative; +} + +.seek { + position: relative; + -webkit-appearance: none; + appearance: none; + background: transparent; + border: 0; + border-radius: 26px; + color: var(--accent); + display: block; + height: 19px; + margin: 0; + min-width: 0; + padding: 0; + transition: box-shadow .3s ease; + width: 100%; + + &::-webkit-slider-runnable-track { + background-color: var(--sliderBg); + background-image: linear-gradient(to right,currentColor var(--value,0),transparent var(--value,0)); + border: 0; + border-radius: 99rem; + height: 5px; + transition: box-shadow .3s ease; + user-select: none; + } + + &::-moz-range-track { + background: transparent; + border: 0; + border-radius: 99rem; + height: 5px; + transition: box-shadow .3s ease; + user-select: none; + background-color: var(--sliderBg); + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: #fff; + border: 0; + border-radius: 100%; + box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2); + height: 13px; + margin-top: -4px; + position: relative; + transition: all .2s ease; + width: 13px; + + &:active { + box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5); + } + } + + &::-moz-range-thumb { + background: #fff; + border: 0; + border-radius: 100%; + box-shadow: 0 1px 1px rgba(35, 40, 47, .15),0 0 0 1px rgba(35, 40, 47, .2); + height: 13px; + position: relative; + transition: all .2s ease; + width: 13px; + + &:active { + box-shadow: 0 1px 1px rgba(35, 40, 47, .15), 0 0 0 1px rgba(35, 40, 47, .15), 0 0 0 3px rgba(255, 255, 255, .5); + } + } + + &::-moz-range-progress { + background: currentColor; + border-radius: 99rem; + height: 5px; + } +} + +.buffer { + appearance: none; + background: transparent; + color: var(--sliderBg); + border: 0; + border-radius: 99rem; + height: 5px; + left: 0; + margin-top: -2.5px; + padding: 0; + position: absolute; + top: 50%; + width: 100%; + + &::-webkit-progress-bar { + background: transparent; + } + + &::-webkit-progress-value { + background: currentColor; + border-radius: 100px; + min-width: 5px; + transition: width .2s ease; + } + + &::-moz-progress-bar { + background: currentColor; + border-radius: 100px; + min-width: 5px; + transition: width .2s ease; + } +} +</style> diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index f9dba0b15a..977c9020c7 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -4,68 +4,345 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div v-if="hide" :class="[$style.hidden, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]" @click="hide = false"> - <!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること --> - <div :class="$style.sensitive"> - <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> - <span>{{ i18n.ts.clickToShow }}</span> - </div> -</div> -<div v-else :class="[$style.visible, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]"> - <video - ref="videoEl" - :class="$style.video" - :poster="video.thumbnailUrl" - :title="video.comment" - :alt="video.comment" - preload="none" - controls - @contextmenu.stop - > - <source - :src="video.url" +<div + ref="playerEl" + :class="[ + $style.videoContainer, + controlsShowing && $style.active, + (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive, + ]" + @mouseover="onMouseOver" + @mouseleave="onMouseLeave" + @contextmenu.stop +> + <button v-if="hide" :class="$style.hidden" @click="hide = false"> + <div :class="$style.hiddenTextWrapper"> + <b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> + <span style="display: block;">{{ i18n.ts.clickToShow }}</span> + </div> + </button> + <div v-else :class="$style.videoRoot" @click.self="togglePlayPause"> + <video + ref="videoEl" + :class="$style.video" + :poster="video.thumbnailUrl ?? undefined" + :title="video.comment ?? undefined" + :alt="video.comment" + preload="metadata" + playsinline > - </video> - <i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i> + <source :src="video.url"> + </video> + <button v-if="isReady && !isPlaying" class="_button" :class="$style.videoOverlayPlayButton" @click="togglePlayPause"><i class="ti ti-player-play-filled"></i></button> + <div v-else-if="!isActuallyPlaying" :class="$style.videoLoading"> + <MkLoading/> + </div> + <i class="ti ti-eye-off" :class="$style.hide" @click="hide = true"></i> + <div :class="$style.indicators"> + <div v-if="video.comment" :class="$style.indicator">ALT</div> + <div v-if="video.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div> + </div> + <div :class="$style.videoControls" @click.self="togglePlayPause"> + <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> + </div> </div> </template> <script lang="ts" setup> -import { ref, shallowRef, watch } from 'vue'; +import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu.js'; import bytes from '@/filters/bytes.js'; +import { hms } from '@/filters/hms.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { isFullscreenNotSupported } from '@/scripts/device-kind.js'; import hasAudio from '@/scripts/media-has-audio.js'; +import MkMediaRange from '@/components/MkMediaRange.vue'; +import { iAmModerator } from '@/account.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; }>(); +// eslint-disable-next-line vue/no-setup-props-destructure const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); -const videoEl = shallowRef<HTMLVideoElement>(); +// Menu +const menuShowing = ref(false); -watch(videoEl, () => { - if (videoEl.value) { - videoEl.value.volume = 0.3; - hasAudio(videoEl.value).then(had => { - if (!had) { - videoEl.value.loop = videoEl.value.muted = true; - videoEl.value.play(); - } +function showMenu(ev: MouseEvent) { + let menu: MenuItem[] = []; + + menu = [ + // TODO: 再生キューに追加 + { + text: i18n.ts.hide, + icon: 'ti ti-eye-off', + action: () => { + hide.value = true; + }, + }, + ]; + + if (iAmModerator) { + menu.push({ + type: 'divider', + }, { + text: props.video.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: props.video.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation', + danger: true, + action: () => toggleSensitive(props.video), }); } + + menuShowing.value = true; + os.popupMenu(menu, ev.currentTarget ?? ev.target, { + align: 'right', + onClosing: () => { + menuShowing.value = false; + }, + }); +} + +function toggleSensitive(file: Misskey.entities.DriveFile) { + os.apiWithDialog('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }); +} + +// MediaControl: Video State +const videoEl = shallowRef<HTMLVideoElement>(); +const playerEl = shallowRef<HTMLDivElement>(); +const isHoverring = ref(false); +const controlsShowing = computed(() => { + if (!oncePlayed.value) return true; + if (isHoverring.value) return true; + if (menuShowing.value) return true; + return false; +}); +const isFullscreen = ref(false); +let controlStateTimer: string | number; + +// 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) => { + if (!videoEl.value) return; + videoEl.value.currentTime = to * durationMs.value / 1000; + }, +}); +const volume = ref(.5); +const bufferedEnd = ref(0); +const bufferedDataRatio = computed(() => { + if (!videoEl.value) return 0; + return bufferedEnd.value / videoEl.value.duration; +}); + +// MediaControl Events +function onMouseOver() { + if (controlStateTimer) { + clearTimeout(controlStateTimer); + } + isHoverring.value = true; +} + +function onMouseLeave() { + controlStateTimer = window.setTimeout(() => { + isHoverring.value = false; + }, 100); +} + +function togglePlayPause() { + if (!isReady.value || !videoEl.value) return; + + if (isPlaying.value) { + videoEl.value.pause(); + isPlaying.value = false; + } else { + videoEl.value.play(); + isPlaying.value = true; + oncePlayed.value = true; + } +} + +function toggleFullscreen() { + if (isFullscreenNotSupported && videoEl.value) { + if (isFullscreen.value) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + videoEl.value.webkitExitFullscreen(); + isFullscreen.value = false; + } else { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + videoEl.value.webkitEnterFullscreen(); + isFullscreen.value = true; + } + } else if (playerEl.value) { + if (isFullscreen.value) { + document.exitFullscreen(); + isFullscreen.value = false; + } else { + playerEl.value.requestFullscreen({ navigationUI: 'hide' }); + isFullscreen.value = true; + } + } +} + +function toggleMute() { + if (volume.value === 0) { + volume.value = .5; + } else { + volume.value = 0; + } +} + +let onceInit = false; +let stopVideoElWatch: () => void; + +function init() { + if (onceInit) return; + onceInit = true; + + stopVideoElWatch = watch(videoEl, () => { + if (videoEl.value) { + isReady.value = true; + + function updateMediaTick() { + if (videoEl.value) { + try { + bufferedEnd.value = videoEl.value.buffered.end(0); + } catch (err) { + bufferedEnd.value = 0; + } + + elapsedTimeMs.value = videoEl.value.currentTime * 1000; + } + window.requestAnimationFrame(updateMediaTick); + } + + updateMediaTick(); + + videoEl.value.addEventListener('play', () => { + isActuallyPlaying.value = true; + }); + + videoEl.value.addEventListener('pause', () => { + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + videoEl.value.addEventListener('ended', () => { + oncePlayed.value = false; + isActuallyPlaying.value = false; + isPlaying.value = false; + }); + + durationMs.value = videoEl.value.duration * 1000; + videoEl.value.addEventListener('durationchange', () => { + if (videoEl.value) { + durationMs.value = videoEl.value.duration * 1000; + } + }); + + videoEl.value.volume = volume.value; + hasAudio(videoEl.value).then(had => { + if (!had && videoEl.value) { + videoEl.value.loop = videoEl.value.muted = true; + videoEl.value.play(); + } + }); + } + }, { + immediate: true, + }); +} + +watch(volume, (to) => { + if (videoEl.value) videoEl.value.volume = to; +}); + +watch(hide, (to) => { + if (to && isFullscreen.value) { + document.exitFullscreen(); + isFullscreen.value = false; + } +}); + +onMounted(() => { + init(); +}); + +onActivated(() => { + init(); +}); + +onDeactivated(() => { + isReady.value = false; + isPlaying.value = false; + isActuallyPlaying.value = false; + elapsedTimeMs.value = 0; + durationMs.value = 0; + bufferedEnd.value = 0; + hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'); + stopVideoElWatch(); + onceInit = false; }); </script> <style lang="scss" module> -.visible { +.videoContainer { + container-type: inline-size; position: relative; + overflow: clip; } -.sensitiveContainer { +.sensitive { position: relative; &::after { @@ -81,44 +358,197 @@ watch(videoEl, () => { } } +.indicators { + display: inline-flex; + position: absolute; + top: 10px; + left: 10px; + pointer-events: none; + opacity: .5; + gap: 6px; +} + +.indicator { + /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */ + background-color: black; + border-radius: 6px; + color: var(--accentLighten); + display: inline-block; + font-weight: bold; + font-size: 0.8em; + padding: 2px 5px; +} + .hide { display: block; position: absolute; border-radius: 6px; background-color: var(--fg); color: var(--accentLighten); - font-size: 14px; + font-size: 12px; opacity: .5; - padding: 3px 6px; + padding: 5px 8px; text-align: center; cursor: pointer; top: 12px; right: 12px; } -.video { - display: flex; - justify-content: center; - align-items: center; - font-size: 3.5em; - overflow: hidden; - background-position: center; - background-size: cover; +.hidden { width: 100%; - height: 100%; + background: none; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + padding: 120px 0; + display: flex; + align-items: center; + justify-content: center; + background: #000; } -.hidden { - display: flex; - justify-content: center; - align-items: center; - background: #111; +.hiddenTextWrapper { + text-align: center; + font-size: 0.8em; color: #fff; } -.sensitive { - display: table-cell; - text-align: center; - font-size: 12px; +.videoRoot { + background: #000; + position: relative; + width: 100%; + height: 100%; + object-fit: contain; +} + +.video { + display: block; + height: 100%; + width: 100%; + pointer-events: none; +} + +.videoOverlayPlayButton { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%,-50%); + + opacity: 0; + transition: opacity .4s ease-in-out; + + background: var(--accent); + color: #fff; + padding: 1rem; + border-radius: 99rem; + + font-size: 1.1rem; +} + +.videoLoading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.videoControls { + 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; + pointer-events: none; + + padding: 35px 10px 10px 10px; + background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, .75)); + + position: absolute; + left: 0; + right: 0; + bottom: 0; + + transform: translateY(100%); + pointer-events: none; + opacity: 0; + transition: opacity .4s ease-in-out, transform .4s ease-in-out; +} + +.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(--radius) / 2); + transition: background-color .2s ease-in-out; + font-size: 1.05rem; + + &:hover { + background-color: var(--accent); + } + } +} + +.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; +} + +@container (min-width: 500px) { + .videoControls { + 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; + } + } } </style> diff --git a/packages/frontend/src/filters/hms.ts b/packages/frontend/src/filters/hms.ts new file mode 100644 index 0000000000..7b5da965ff --- /dev/null +++ b/packages/frontend/src/filters/hms.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { i18n } from '@/i18n.js'; + +export function hms(ms: number, options: { + textFormat?: 'colon' | 'locale'; + enableSeconds?: boolean; + enableMs?: boolean; +}) { + const _options = { + textFormat: 'colon', + enableSeconds: true, + enableMs: false, + ...options, + }; + + const res: { + h?: string; + m?: string; + s?: string; + ms?: string; + } = {}; + + // ミリ秒を秒に変換 + let seconds = Math.floor(ms / 1000); + + // 小数点以下の値(2位まで) + const mili = ms - seconds * 1000; + + // 時間を計算 + const hours = Math.floor(seconds / 3600); + res.h = format(hours); + seconds %= 3600; + + // 分を計算 + const minutes = Math.floor(seconds / 60); + res.m = format(minutes); + seconds %= 60; + + // 残った秒数を取得 + seconds = seconds % 60; + res.s = format(seconds); + + // ミリ秒を取得 + res.ms = format(Math.floor(mili / 10)); + + // 結果を返す + if (_options.textFormat === 'locale') { + res.h += i18n.ts._time.hour; + res.m += i18n.ts._time.minute; + res.s += i18n.ts._time.second; + } + return [ + res.h.startsWith('00') ? undefined : res.h, + res.m, + (_options.enableSeconds ? res.s : undefined), + ].filter(v => v !== undefined).join(_options.textFormat === 'colon' ? ':' : ' ') + (_options.enableMs ? _options.textFormat === 'colon' ? `.${res.ms}` : ` ${res.ms}` : ''); +} + +function format(n: number) { + return n.toString().padStart(2, '0'); +} diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts index 3843052a24..218eb718b1 100644 --- a/packages/frontend/src/scripts/device-kind.ts +++ b/packages/frontend/src/scripts/device-kind.ts @@ -11,6 +11,13 @@ const ua = navigator.userAgent.toLowerCase(); const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700); const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua); +const isIPhone = /iphone|ipod/gi.test(ua) && navigator.maxTouchPoints > 1; +// navigator.platform may be deprecated but this check is still required +const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; +const isIos = /ipad|iphone|ipod/gi.test(ua) && navigator.maxTouchPoints > 1; + +export const isFullscreenNotSupported = isIPhone || isIos; + export const deviceKind: 'smartphone' | 'tablet' | 'desktop' = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind : isSmartphone ? 'smartphone' : isTablet ? 'tablet'