feat: add video playback UI and thumbnail display
- Add video player with controls in MediaDetailsModal - Display video metadata (duration, codecs, bitrate) in metadata panel - Show video thumbnails with play icon overlay in MediaGrid - Support video preview in upload components - Replace emoji icons with SVG play icon - Maintain natural video aspect ratio in all views - Add proper styling for video thumbnails and placeholders 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4f46b0e666
commit
aa3622d606
7 changed files with 218 additions and 21 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatFileSize, isImageFile } from '$lib/utils/mediaHelpers'
|
import { formatFileSize, isImageFile, isVideoFile } from '$lib/utils/mediaHelpers'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
interface FilePreview {
|
interface FilePreview {
|
||||||
|
|
@ -85,6 +85,8 @@
|
||||||
<div class="file-preview">
|
<div class="file-preview">
|
||||||
{#if isImageFile(preview.type)}
|
{#if isImageFile(preview.type)}
|
||||||
<img src={preview.url} alt={preview.name} />
|
<img src={preview.url} alt={preview.name} />
|
||||||
|
{:else if isVideoFile(preview.type)}
|
||||||
|
<div class="file-icon">🎬</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="file-icon">📄</div>
|
<div class="file-icon">📄</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
import MediaUsageList from './MediaUsageList.svelte'
|
import MediaUsageList from './MediaUsageList.svelte'
|
||||||
import { authenticatedFetch } from '$lib/admin-auth'
|
import { authenticatedFetch } from '$lib/admin-auth'
|
||||||
import { toast } from '$lib/stores/toast'
|
import { toast } from '$lib/stores/toast'
|
||||||
import { formatFileSize, getFileType } from '$lib/utils/mediaHelpers'
|
import { formatFileSize, getFileType, isVideoFile } from '$lib/utils/mediaHelpers'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -214,12 +214,19 @@
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
>
|
>
|
||||||
<div class="media-details-modal">
|
<div class="media-details-modal">
|
||||||
<!-- Left Pane - Image Preview -->
|
<!-- Left Pane - Media Preview -->
|
||||||
<div class="image-pane">
|
<div class="image-pane">
|
||||||
{#if media.mimeType.startsWith('image/')}
|
{#if media.mimeType.startsWith('image/')}
|
||||||
<div class="image-container">
|
<div class="image-container">
|
||||||
<SmartImage {media} alt={media.description || media.filename} class="preview-image" />
|
<SmartImage {media} alt={media.description || media.filename} class="preview-image" />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if isVideoFile(media.mimeType)}
|
||||||
|
<div class="video-container">
|
||||||
|
<video controls poster={media.thumbnailUrl || undefined} class="preview-video">
|
||||||
|
<source src={media.url} type={media.mimeType} />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="file-placeholder">
|
<div class="file-placeholder">
|
||||||
<FileIcon size={64} />
|
<FileIcon size={64} />
|
||||||
|
|
@ -386,6 +393,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
max-width: 90%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.preview-video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
border-radius: $corner-radius-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.file-placeholder {
|
.file-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SmartImage from '../SmartImage.svelte'
|
import SmartImage from '../SmartImage.svelte'
|
||||||
import FileIcon from '../icons/FileIcon.svelte'
|
import FileIcon from '../icons/FileIcon.svelte'
|
||||||
import { isImageFile } from '$lib/utils/mediaHelpers'
|
import PlayIcon from '$icons/play.svg?component'
|
||||||
|
import { isImageFile, isVideoFile } from '$lib/utils/mediaHelpers'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -86,6 +87,25 @@
|
||||||
class="media-image {item.mimeType === 'image/svg+xml' ? 'svg-image' : ''}"
|
class="media-image {item.mimeType === 'image/svg+xml' ? 'svg-image' : ''}"
|
||||||
containerWidth={150}
|
containerWidth={150}
|
||||||
/>
|
/>
|
||||||
|
{:else if isVideoFile(item.mimeType)}
|
||||||
|
{#if item.thumbnailUrl}
|
||||||
|
<div class="video-thumbnail-wrapper">
|
||||||
|
<img
|
||||||
|
src={item.thumbnailUrl}
|
||||||
|
alt={item.filename}
|
||||||
|
loading={i < 8 ? 'eager' : 'lazy'}
|
||||||
|
class="media-image video-thumbnail"
|
||||||
|
/>
|
||||||
|
<div class="video-overlay">
|
||||||
|
<PlayIcon class="play-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="media-placeholder video-placeholder">
|
||||||
|
<PlayIcon class="video-icon" />
|
||||||
|
<span class="video-label">Video</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="media-placeholder">
|
<div class="media-placeholder">
|
||||||
<FileIcon size={32} />
|
<FileIcon size={32} />
|
||||||
|
|
@ -204,6 +224,40 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-thumbnail-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.video-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
:global(.play-icon) {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: white;
|
||||||
|
margin-left: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.media-placeholder {
|
.media-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -211,6 +265,24 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: $gray-60;
|
color: $gray-60;
|
||||||
|
|
||||||
|
&.video-placeholder {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
:global(.video-icon) {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: $gray-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $gray-50;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-overlay {
|
.hover-overlay {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import { formatFileSize, getFileType } from '$lib/utils/mediaHelpers'
|
import { formatFileSize, getFileType, isVideoFile, formatDuration, formatBitrate } from '$lib/utils/mediaHelpers'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -36,7 +36,32 @@
|
||||||
<span class="value">{media.width} × {media.height}px</span>
|
<span class="value">{media.width} × {media.height}px</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if media.dominantColor}
|
{#if isVideoFile(media.mimeType)}
|
||||||
|
{#if media.duration}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">Duration</span>
|
||||||
|
<span class="value">{formatDuration(media.duration)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if media.videoCodec}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">Video Codec</span>
|
||||||
|
<span class="value">{media.videoCodec.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if media.audioCodec}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">Audio Codec</span>
|
||||||
|
<span class="value">{media.audioCodec.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if media.bitrate}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">Bitrate</span>
|
||||||
|
<span class="value">{formatBitrate(media.bitrate)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if media.dominantColor}
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">Dominant Color</span>
|
<span class="label">Dominant Color</span>
|
||||||
<span class="value color-value">
|
<span class="value color-value">
|
||||||
|
|
|
||||||
|
|
@ -37,17 +37,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFiles(newFiles: File[]) {
|
function addFiles(newFiles: File[]) {
|
||||||
// Filter for image files
|
// Filter for supported file types (images and videos)
|
||||||
const imageFiles = newFiles.filter((file) => file.type.startsWith('image/'))
|
const supportedFiles = newFiles.filter(
|
||||||
|
(file) => file.type.startsWith('image/') || file.type.startsWith('video/')
|
||||||
|
)
|
||||||
|
|
||||||
if (imageFiles.length !== newFiles.length) {
|
if (supportedFiles.length !== newFiles.length) {
|
||||||
uploadErrors = [
|
uploadErrors = [
|
||||||
...uploadErrors,
|
...uploadErrors,
|
||||||
`${newFiles.length - imageFiles.length} non-image files were skipped`
|
`${newFiles.length - supportedFiles.length} unsupported files were skipped`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
files = [...files, ...imageFiles]
|
files = [...files, ...supportedFiles]
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(id: string | number) {
|
function removeFile(id: string | number) {
|
||||||
|
|
@ -149,7 +151,7 @@
|
||||||
<!-- Drop Zone (compact when files are selected) -->
|
<!-- Drop Zone (compact when files are selected) -->
|
||||||
<FileUploadZone
|
<FileUploadZone
|
||||||
onFilesAdded={handleFilesAdded}
|
onFilesAdded={handleFilesAdded}
|
||||||
accept={['image/*']}
|
accept={['image/*', 'video/*']}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
compact={files.length > 0}
|
compact={files.length > 0}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
|
|
@ -221,6 +223,9 @@
|
||||||
|
|
||||||
.modal-inner-content {
|
.modal-inner-content {
|
||||||
padding: $unit $unit-3x $unit-3x;
|
padding: $unit $unit-3x $unit-3x;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-2x;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
|
import MediaDetailsModal from '$lib/components/admin/MediaDetailsModal.svelte'
|
||||||
import MediaUploadModal from '$lib/components/admin/MediaUploadModal.svelte'
|
import MediaUploadModal from '$lib/components/admin/MediaUploadModal.svelte'
|
||||||
import AlbumSelectorModal from '$lib/components/admin/AlbumSelectorModal.svelte'
|
import AlbumSelectorModal from '$lib/components/admin/AlbumSelectorModal.svelte'
|
||||||
import ChevronDown from '$icons/chevron-down.svg'
|
import ChevronDown from '$icons/chevron-down.svg?component'
|
||||||
|
import PlayIcon from '$icons/play.svg?component'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
let media = $state<Media[]>([])
|
let media = $state<Media[]>([])
|
||||||
|
|
@ -157,6 +158,10 @@
|
||||||
return 'File'
|
return 'File'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVideoFile(mimeType: string): boolean {
|
||||||
|
return mimeType.startsWith('video/')
|
||||||
|
}
|
||||||
|
|
||||||
function handleMediaClick(item: Media) {
|
function handleMediaClick(item: Media) {
|
||||||
selectedMedia = item
|
selectedMedia = item
|
||||||
isDetailsModalOpen = true
|
isDetailsModalOpen = true
|
||||||
|
|
@ -543,6 +548,20 @@
|
||||||
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
|
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
|
||||||
alt={item.description || item.filename}
|
alt={item.description || item.filename}
|
||||||
/>
|
/>
|
||||||
|
{:else if isVideoFile(item.mimeType)}
|
||||||
|
{#if item.thumbnailUrl}
|
||||||
|
<div class="video-thumbnail-wrapper">
|
||||||
|
<img src={item.thumbnailUrl} alt={item.description || item.filename} />
|
||||||
|
<div class="video-overlay">
|
||||||
|
<PlayIcon class="play-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="file-placeholder video-placeholder">
|
||||||
|
<PlayIcon class="video-icon" />
|
||||||
|
<span class="file-type">Video</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="file-placeholder">
|
<div class="file-placeholder">
|
||||||
<span class="file-type">{getFileType(item.mimeType)}</span>
|
<span class="file-type">{getFileType(item.mimeType)}</span>
|
||||||
|
|
@ -746,6 +765,41 @@
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-thumbnail-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
:global(.play-icon) {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: white;
|
||||||
|
margin-left: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.file-placeholder {
|
.file-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 150px;
|
height: 150px;
|
||||||
|
|
@ -754,6 +808,17 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: $gray-90;
|
background: $gray-90;
|
||||||
|
|
||||||
|
&.video-placeholder {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
:global(.video-icon) {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: $gray-60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.file-type {
|
.file-type {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $gray-40;
|
color: $gray-40;
|
||||||
|
|
|
||||||
|
|
@ -45,17 +45,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFiles(newFiles: File[]) {
|
function addFiles(newFiles: File[]) {
|
||||||
// Filter for image files
|
// Filter for supported file types (images and videos)
|
||||||
const imageFiles = newFiles.filter((file) => file.type.startsWith('image/'))
|
const supportedFiles = newFiles.filter((file) =>
|
||||||
|
file.type.startsWith('image/') || file.type.startsWith('video/')
|
||||||
|
)
|
||||||
|
|
||||||
if (imageFiles.length !== newFiles.length) {
|
if (supportedFiles.length !== newFiles.length) {
|
||||||
uploadErrors = [
|
uploadErrors = [
|
||||||
...uploadErrors,
|
...uploadErrors,
|
||||||
`${newFiles.length - imageFiles.length} non-image files were skipped`
|
`${newFiles.length - supportedFiles.length} unsupported files were skipped`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
files = [...files, ...imageFiles]
|
files = [...files, ...supportedFiles]
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(index: number) {
|
function removeFile(index: number) {
|
||||||
|
|
@ -197,6 +199,8 @@
|
||||||
<div class="file-preview">
|
<div class="file-preview">
|
||||||
{#if file.type.startsWith('image/')}
|
{#if file.type.startsWith('image/')}
|
||||||
<img src={URL.createObjectURL(file)} alt={file.name} />
|
<img src={URL.createObjectURL(file)} alt={file.name} />
|
||||||
|
{:else if file.type.startsWith('video/')}
|
||||||
|
<div class="file-icon">🎬</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="file-icon">📄</div>
|
<div class="file-icon">📄</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -317,9 +321,9 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>Drop images here</h3>
|
<h3>Drop media files here</h3>
|
||||||
<p>or click to browse and select files</p>
|
<p>or click to browse and select files</p>
|
||||||
<p class="upload-hint">Supports JPG, PNG, GIF, WebP, and SVG files</p>
|
<p class="upload-hint">Images: JPG, PNG, GIF, WebP, SVG | Videos: WebM, MP4, OGG, MOV, AVI</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="compact-content">
|
<div class="compact-content">
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -358,7 +362,7 @@
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept="image/*"
|
accept="image/*,video/*"
|
||||||
onchange={handleFileSelect}
|
onchange={handleFileSelect}
|
||||||
class="hidden-input"
|
class="hidden-input"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue