diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1fdcf9eeddb5d84b260ca4eed40c86ff227913..945b6ac1ad168a76040e8701160d6d4695f78492 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 dafbdd35595c1a7c6500f02ac6109433034dbfce..71134544d94337a27c1d9cdcc602301f7740f705 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 58952894b37c24a41ec368ea4bbecef4bf4bce73..743a3ca38ea26be1ed13540a1eec82fc7d673e06 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 f59996ce17f5efb1871eeff51ff44284f645dc7e..7745a6cb78b114679521fc3ad4329206bc66fdf8 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 0000000000000000000000000000000000000000..75b31b9a4916927b2c26059222670d1cbd5c49e6 --- /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 3f8fef6632eb54191a323ba4458666df1ffce622..b21960a4900c755198865f0c3b64cf46f6619ec1 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 0000000000000000000000000000000000000000..e6303a5c41d12fae03e9d2be484225caf4c33f29 --- /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 f9dba0b15af73574c6aa489a0b410caa65047e2a..977c9020c76d5b7530c88220dfe4d69324426858 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')); +// 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.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; -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 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 { +.hidden { + width: 100%; + background: none; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + padding: 120px 0; display: flex; - justify-content: center; align-items: center; - font-size: 3.5em; - overflow: hidden; - background-position: center; - background-size: cover; + justify-content: center; + background: #000; +} + +.hiddenTextWrapper { + text-align: center; + font-size: 0.8em; + color: #fff; +} + +.videoRoot { + background: #000; + position: relative; width: 100%; height: 100%; + object-fit: contain; } -.hidden { +.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; - background: #111; + 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); + } + } } -.sensitive { - display: table-cell; - text-align: center; - font-size: 12px; +.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 0000000000000000000000000000000000000000..7b5da965ff6d4237785dcf914663af462a2de253 --- /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); + + // å°æ•°ç‚¹ä»¥ä¸‹ã®å€¤ï¼ˆï¼’ä½ã¾ã§ï¼‰ + 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 3843052a2430745d301c478208d2d8c840f6d9bf..218eb718b109393158d4d977482a6b12e3ac8815 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'