8a55d8a468
this only works for media from the same origin due to annoying browser restrictions, but then the same applies to every other download button in misskey (e.g. the one in drive) and there's basically nothing i can to do solve it.
364 lines
8.3 KiB
Vue
364 lines
8.3 KiB
Vue
<!--
|
|
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="ph-eye-slash ph-bold ph-lg"></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="ph-music-notes ph-bold ph-lg"></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"
|
|
>
|
|
<source :src="audio.url">
|
|
</audio>
|
|
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
|
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
|
<i v-if="isPlaying" class="ph-pause ph-bold ph-lg"></i>
|
|
<i v-else class="ph-play ph-bold ph-lg"></i>
|
|
</button>
|
|
</div>
|
|
<div :class="[$style.controlsChild, $style.controlsRight]">
|
|
<a class="_button" :class="$style.controlButton" :href="audio.url" :download="audio.name" target="_blank">
|
|
<i class="ph-download ph-bold ph-lg"></i>
|
|
</a>
|
|
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
|
<i class="ph-gear ph-bold ph-lg"></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="ph-speaker-x ph-bold ph-lg"></i>
|
|
<i v-else class="ph-speaker-high ph-bold ph-lg"></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: 'ph-eye-closed ph-bold ph-lg',
|
|
action: () => {
|
|
hide.value = true;
|
|
},
|
|
},
|
|
];
|
|
|
|
if (iAmModerator) {
|
|
menu.push({
|
|
type: 'divider',
|
|
}, {
|
|
text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
|
icon: props.audio.isSensitive ? 'ph-eye ph-bold ph-lg' : 'ph-eye-slash ph-bold ph-lg',
|
|
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(.25);
|
|
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 = .25;
|
|
} 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: #000;
|
|
border: none;
|
|
outline: none;
|
|
font: inherit;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
padding: 12px 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.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>
|