forked from BRETT/BRETT
Media attachment cards with popover players and shared volume
- Audio/video attachments render as media cards (cover or icon, title/artist/size) instead of plain file chips; image attachments use 3:2 thumbnails matching the audio card height - Players open in a Popover (autoplay on open) with a dimming overlay; AudioPlayerDialog/VideoPlayerDialog removed - New useMediaVolume composable shares volume/mute between all audio players and Plyr via the 4brett-player storage key - Player buttons and Plyr controls follow the theme border radius - Portrait videos: aspect is applied even when metadata loaded before hydration, and the raw video is height-capped pre-JS Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,130 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useMediaControls } from '@vueuse/core'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { formatFileSize } from '~~/shared/utils/attachments'
|
||||
import type { AttachmentAudioMeta } from '~~/shared/utils/attachments'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
name: string
|
||||
size?: number | null
|
||||
cover?: string | null
|
||||
meta?: AttachmentAudioMeta | null
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const { playing, currentTime, duration, volume, muted } = useMediaControls(audioRef)
|
||||
|
||||
const metaLine = computed(() => {
|
||||
const m = props.meta
|
||||
if (!m) return null
|
||||
const parts = [m.artist, m.title].filter(Boolean)
|
||||
return parts.length ? parts.join(' – ') : (m.album || null)
|
||||
})
|
||||
|
||||
// Die Komponente wird per v-if nur gemountet, wenn der Dialog offen ist
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
playing.value = true
|
||||
})
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) playing.value = false
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function seek(event: Event) {
|
||||
currentTime.value = Number((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function formatTime(sec: number) {
|
||||
if (!Number.isFinite(sec)) return '0:00'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60).toString().padStart(2, '0')
|
||||
return `${m}:${s}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex min-w-0 items-center gap-2 pr-10">
|
||||
<Icon name="ph:file-audio-fill" class="size-4 shrink-0" />
|
||||
<span class="min-w-0 truncate">{{ name }}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="metaLine" class="truncate">
|
||||
{{ metaLine }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<img
|
||||
v-if="cover"
|
||||
:src="cover"
|
||||
:alt="name"
|
||||
class="max-h-56 w-full rounded-md border bg-muted object-contain"
|
||||
>
|
||||
|
||||
<audio ref="audioRef" :src="src" preload="metadata" class="hidden" />
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2.5 rounded-md border bg-muted/50 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||
:aria-label="playing ? $t('audio.pause') : $t('audio.play')"
|
||||
@click="toggle"
|
||||
>
|
||||
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
|
||||
<Icon name="ph:play-fill" v-else class="size-4" />
|
||||
</button>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(currentTime) }}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
:max="Number.isFinite(duration) ? duration : 0"
|
||||
step="0.1"
|
||||
:value="currentTime"
|
||||
:aria-label="$t('audio.seek')"
|
||||
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
||||
@input="seek"
|
||||
>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||
:aria-label="muted ? $t('audio.unmute') : $t('audio.mute')"
|
||||
@click="muted = !muted"
|
||||
>
|
||||
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-4" />
|
||||
<Icon name="ph:speaker-high-fill" v-else class="size-4" />
|
||||
</button>
|
||||
<input
|
||||
v-model.number="volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
:aria-label="$t('audio.volume')"
|
||||
class="w-16 min-w-0 cursor-pointer accent-primary sm:w-20"
|
||||
:class="muted && 'opacity-40'"
|
||||
>
|
||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">{{ formatFileSize(size) }}</span>
|
||||
<Button variant="outline" size="sm" class="shrink-0" as-child>
|
||||
<a :href="src" :download="name">
|
||||
<Icon name="ph:download-fill" class="size-4" />
|
||||
{{ $t('audio.download') }}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import AudioPlayerDialog from '@/components/AudioPlayerDialog.vue'
|
||||
import ImageLightbox from '@/components/ImageLightbox.vue'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
import VoiceMessagePlayer from '@/components/VoiceMessagePlayer.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { formatFileSize, isAudioAttachment, isImageAttachment, isVoiceAttachment } from '~~/shared/utils/attachments'
|
||||
import { formatFileSize, isAudioAttachment, isImageAttachment, isVideoAttachment, isVoiceAttachment } from '~~/shared/utils/attachments'
|
||||
import type { CommentAttachment } from '~~/shared/utils/attachments'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -14,12 +15,21 @@ const props = defineProps<{
|
||||
const voices = computed(() => props.attachments.filter(isVoiceAttachment))
|
||||
const images = computed(() => props.attachments.filter(a => isImageAttachment(a) && !isVoiceAttachment(a)))
|
||||
const audios = computed(() => props.attachments.filter(a => isAudioAttachment(a) && !isImageAttachment(a) && !isVoiceAttachment(a)))
|
||||
const files = computed(() => props.attachments.filter(a => !isImageAttachment(a) && !isAudioAttachment(a) && !isVoiceAttachment(a)))
|
||||
const hasFileAttachments = computed(() => images.value.length + audios.value.length + files.value.length > 0)
|
||||
const videos = computed(() => props.attachments.filter(a => isVideoAttachment(a) && !isVoiceAttachment(a)))
|
||||
const files = computed(() => props.attachments.filter(a => !isImageAttachment(a) && !isAudioAttachment(a) && !isVideoAttachment(a) && !isVoiceAttachment(a)))
|
||||
const hasFileAttachments = computed(() => images.value.length + audios.value.length + videos.value.length + files.value.length > 0)
|
||||
|
||||
const lightboxSrc = ref<string | null>(null)
|
||||
const lightboxAlt = ref('')
|
||||
const audioAtt = ref<CommentAttachment | null>(null)
|
||||
// ID des Anhangs, dessen Player-Popover offen ist (steuert auch das Dim-Overlay)
|
||||
const openAtt = ref<number | null>(null)
|
||||
|
||||
function audioMetaLine(a: CommentAttachment) {
|
||||
const m = a.meta
|
||||
if (!m) return null
|
||||
const parts = [m.artist, m.title].filter(Boolean)
|
||||
return parts.length ? parts.join(' – ') : (m.album || null)
|
||||
}
|
||||
|
||||
function fileIcon(a: CommentAttachment) {
|
||||
const mime = a.mimeType ?? ''
|
||||
@@ -42,6 +52,7 @@ function fileIcon(a: CommentAttachment) {
|
||||
</span>
|
||||
|
||||
<div v-else-if="attachments.length" class="mt-2.5 space-y-2.5">
|
||||
<div v-if="openAtt !== null" class="animate-in fade-in fixed inset-0 z-40 bg-black/50 duration-150" />
|
||||
<!-- Sprachnachrichten gehören direkt zum Kommentar, nicht in den Anhang-Bereich -->
|
||||
<div v-if="voices.length" class="flex flex-col gap-1.5">
|
||||
<VoiceMessagePlayer
|
||||
@@ -61,34 +72,80 @@ function fileIcon(a: CommentAttachment) {
|
||||
v-for="a in images"
|
||||
:key="a.id"
|
||||
type="button"
|
||||
class="group relative size-16 cursor-pointer overflow-hidden rounded-md border bg-muted transition-colors hover:border-primary"
|
||||
class="group relative aspect-[3/2] w-36 cursor-pointer overflow-hidden rounded-md border bg-muted transition-colors hover:border-primary"
|
||||
:title="a.name"
|
||||
@click.stop="lightboxSrc = a.url; lightboxAlt = a.name"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="a.url"
|
||||
:alt="a.name"
|
||||
width="64"
|
||||
height="64"
|
||||
width="288"
|
||||
height="192"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
<Popover
|
||||
v-for="a in audios"
|
||||
:key="a.id"
|
||||
type="button"
|
||||
class="flex max-w-48 cursor-pointer items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs transition-colors hover:border-primary hover:bg-accent"
|
||||
:title="$t('comments.playAttachment', { name: a.name })"
|
||||
@click.stop="audioAtt = a"
|
||||
:open="openAtt === a.id"
|
||||
@update:open="v => openAtt = v ? a.id : null"
|
||||
>
|
||||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="size-5 shrink-0 rounded-sm object-cover">
|
||||
<Icon name="ph:file-audio-fill" v-else class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ a.name }}</span>
|
||||
<span v-if="formatFileSize(a.size)" class="shrink-0 text-muted-foreground">{{ formatFileSize(a.size) }}</span>
|
||||
<Icon name="ph:play-fill" class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-24 w-52 cursor-pointer items-stretch overflow-hidden rounded-md border bg-muted/50 text-left text-xs transition-colors hover:border-primary hover:bg-accent data-[state=open]:border-primary data-[state=open]:bg-accent"
|
||||
:title="$t('comments.playAttachment', { name: a.name })"
|
||||
@click.stop
|
||||
>
|
||||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="h-full w-20 shrink-0 object-cover">
|
||||
<div v-else class="flex w-14 shrink-0 items-center justify-center bg-muted">
|
||||
<Icon name="ph:file-audio-fill" class="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
|
||||
<span class="line-clamp-2 break-words font-medium leading-snug">{{ a.meta?.title ?? a.name }}</span>
|
||||
<span v-if="a.meta?.artist" class="truncate text-muted-foreground">{{ a.meta.artist }}</span>
|
||||
<span v-if="formatFileSize(a.size)" class="text-muted-foreground">{{ formatFileSize(a.size) }}</span>
|
||||
</div>
|
||||
<Icon name="ph:play-fill" class="mx-2.5 size-4 shrink-0 self-center text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96 gap-3 p-3" @click.stop>
|
||||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="aspect-square w-full rounded-md border bg-muted object-cover">
|
||||
<div v-else class="flex aspect-square w-full items-center justify-center rounded-md border bg-muted">
|
||||
<Icon name="ph:music-note-fill" class="size-16 text-muted-foreground" />
|
||||
</div>
|
||||
<span v-if="audioMetaLine(a)" class="truncate text-sm text-muted-foreground">{{ audioMetaLine(a) }}</span>
|
||||
<VoiceMessagePlayer :src="a.url" :name="a.name" autoplay />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover
|
||||
v-for="a in videos"
|
||||
:key="a.id"
|
||||
:open="openAtt === a.id"
|
||||
@update:open="v => openAtt = v ? a.id : null"
|
||||
>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-24 w-52 cursor-pointer items-stretch overflow-hidden rounded-md border bg-muted/50 text-left text-xs transition-colors hover:border-primary hover:bg-accent data-[state=open]:border-primary data-[state=open]:bg-accent"
|
||||
:title="$t('comments.playAttachment', { name: a.name })"
|
||||
@click.stop
|
||||
>
|
||||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="h-full w-20 shrink-0 bg-muted object-cover">
|
||||
<video v-else :src="a.url" preload="metadata" muted playsinline class="h-full w-20 shrink-0 bg-muted object-cover" />
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
|
||||
<span class="line-clamp-2 break-words font-medium leading-snug">{{ a.name }}</span>
|
||||
<span v-if="formatFileSize(a.size)" class="text-muted-foreground">{{ formatFileSize(a.size) }}</span>
|
||||
</div>
|
||||
<Icon name="ph:play-fill" class="mx-2.5 size-4 shrink-0 self-center text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[24rem] p-2 sm:w-[36rem]" @click.stop>
|
||||
<VideoPlayer :src="a.url" autoplay />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<a
|
||||
v-for="a in files"
|
||||
:key="a.id"
|
||||
@@ -110,16 +167,6 @@ function fileIcon(a: CommentAttachment) {
|
||||
:alt="lightboxAlt"
|
||||
@close="lightboxSrc = null"
|
||||
/>
|
||||
<AudioPlayerDialog
|
||||
v-if="audioAtt"
|
||||
:open="true"
|
||||
:src="audioAtt.url"
|
||||
:name="audioAtt.name"
|
||||
:size="audioAtt.size"
|
||||
:cover="audioAtt.cover"
|
||||
:meta="audioAtt.meta"
|
||||
@update:open="audioAtt = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'plyr/dist/plyr.css'
|
||||
|
||||
defineProps<{
|
||||
src: string
|
||||
autoplay?: boolean
|
||||
}>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
@@ -12,6 +13,11 @@ let player: Plyr | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
if (!videoEl.value) return
|
||||
// SSR: Metadaten können vor der Hydration schon geladen sein —
|
||||
// das loadedmetadata-Event feuert dann nicht mehr
|
||||
if (videoEl.value.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
onLoadedMetadata()
|
||||
}
|
||||
const { default: PlyrClass } = await import('plyr')
|
||||
player = new PlyrClass(videoEl.value, {
|
||||
disableContextMenu: false,
|
||||
@@ -40,9 +46,10 @@ function onLoadedMetadata() {
|
||||
<video
|
||||
ref="videoEl"
|
||||
:src="src"
|
||||
:autoplay="autoplay"
|
||||
playsinline
|
||||
controls
|
||||
class="rounded-md"
|
||||
class="mx-auto max-h-[80vh] w-auto max-w-full rounded-md"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
/>
|
||||
</div>
|
||||
@@ -53,4 +60,8 @@ function onLoadedMetadata() {
|
||||
--plyr-color-main: var(--accent);
|
||||
--plyr-border-radius: var(--radius-md);
|
||||
}
|
||||
.video-player :deep(.plyr__control--overlaid),
|
||||
.video-player :deep(.plyr__control) {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useMediaControls } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
name: string
|
||||
autoplay?: boolean
|
||||
}>()
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const { playing, currentTime, duration, volume, muted } = useMediaControls(audioRef)
|
||||
const { playing, currentTime, duration, volume, muted } = useMediaVolume(audioRef)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoplay) playing.value = true
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
playing.value = !playing.value
|
||||
@@ -33,7 +36,7 @@ function formatTime(sec: number) {
|
||||
<audio ref="audioRef" :src="src" preload="metadata" class="hidden" />
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent"
|
||||
:aria-label="playing ? $t('audio.pause') : $t('audio.play')"
|
||||
@click="toggle"
|
||||
>
|
||||
@@ -54,7 +57,7 @@ function formatTime(sec: number) {
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(duration) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent"
|
||||
:aria-label="muted ? $t('audio.unmute') : $t('audio.mute')"
|
||||
@click="muted = !muted"
|
||||
>
|
||||
@@ -74,7 +77,7 @@ function formatTime(sec: number) {
|
||||
<a
|
||||
:href="src"
|
||||
:download="name"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
:aria-label="$t('audio.download')"
|
||||
:title="$t('audio.download')"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMediaControls } from '@vueuse/core'
|
||||
|
||||
// Derselbe localStorage-Key wie Plyr im VideoPlayer — so teilen sich Video- und
|
||||
// Audio-Player eine gemerkte Lautstärke (in beide Richtungen)
|
||||
const STORAGE_KEY = '4brett-player'
|
||||
|
||||
interface StoredPrefs {
|
||||
volume?: number
|
||||
muted?: boolean
|
||||
}
|
||||
|
||||
function readPrefs(): StoredPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) as StoredPrefs : {}
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function useMediaVolume(target: Parameters<typeof useMediaControls>[0]) {
|
||||
const controls = useMediaControls(target)
|
||||
|
||||
if (import.meta.client) {
|
||||
const applyStored = () => {
|
||||
const prefs = readPrefs()
|
||||
if (typeof prefs.volume === 'number' && controls.volume.value !== prefs.volume) {
|
||||
controls.volume.value = prefs.volume
|
||||
}
|
||||
if (typeof prefs.muted === 'boolean' && controls.muted.value !== prefs.muted) {
|
||||
controls.muted.value = prefs.muted
|
||||
}
|
||||
}
|
||||
|
||||
watch(toRef(target), (el) => {
|
||||
if (el) applyStored()
|
||||
}, { immediate: true })
|
||||
|
||||
watch([controls.volume, controls.muted], ([v, m]) => {
|
||||
const prefs = readPrefs()
|
||||
if (prefs.volume === v && prefs.muted === m) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...prefs, volume: v, muted: m }))
|
||||
}
|
||||
catch { /* Storage deaktiviert/voll */ }
|
||||
})
|
||||
}
|
||||
|
||||
return controls
|
||||
}
|
||||
Reference in New Issue
Block a user