diff --git a/backend/src/db/api/media_assets.js b/backend/src/db/api/media_assets.js index aaf9e55..eb7fc30 100644 --- a/backend/src/db/api/media_assets.js +++ b/backend/src/db/api/media_assets.js @@ -1,7 +1,6 @@ const db = require('../models'); const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -66,6 +65,11 @@ module.exports = class Media_assetsDBApi { null , + sort_order: data.sort_order + || + null + , + is_primary: data.is_primary || false @@ -164,6 +168,11 @@ module.exports = class Media_assetsDBApi { duration_seconds: item.duration_seconds || null + , + + sort_order: item.sort_order + || + null , is_primary: item.is_primary @@ -250,6 +259,9 @@ module.exports = class Media_assetsDBApi { if (data.duration_seconds !== undefined) updatePayload.duration_seconds = data.duration_seconds; + if (data.sort_order !== undefined) updatePayload.sort_order = data.sort_order; + + if (data.is_primary !== undefined) updatePayload.is_primary = data.is_primary; @@ -407,10 +419,6 @@ module.exports = class Media_assetsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - let include = [ { diff --git a/backend/src/db/migrations/20260405120000-add-sort-order-to-media-assets.js b/backend/src/db/migrations/20260405120000-add-sort-order-to-media-assets.js new file mode 100644 index 0000000..54e77f5 --- /dev/null +++ b/backend/src/db/migrations/20260405120000-add-sort-order-to-media-assets.js @@ -0,0 +1,96 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tableRows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.\"media_assets\"') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const tableName = tableRows[0].regclass_name; + + if (!tableName) { + await transaction.commit(); + return; + } + + const columnRows = await queryInterface.sequelize.query( + `SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'media_assets' + AND column_name = 'sort_order';`, + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (columnRows.length) { + await transaction.commit(); + return; + } + + await queryInterface.addColumn( + 'media_assets', + 'sort_order', + { + type: Sequelize.DataTypes.INTEGER, + allowNull: true, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tableRows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.\"media_assets\"') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const tableName = tableRows[0].regclass_name; + + if (!tableName) { + await transaction.commit(); + return; + } + + const columnRows = await queryInterface.sequelize.query( + `SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'media_assets' + AND column_name = 'sort_order';`, + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + + if (!columnRows.length) { + await transaction.commit(); + return; + } + + await queryInterface.removeColumn('media_assets', 'sort_order', { transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/media_assets.js b/backend/src/db/models/media_assets.js index 4e20015..15496da 100644 --- a/backend/src/db/models/media_assets.js +++ b/backend/src/db/models/media_assets.js @@ -1,8 +1,3 @@ -const config = require('../../config'); -const providers = config.providers; -const crypto = require('crypto'); -const bcrypt = require('bcrypt'); -const moment = require('moment'); module.exports = function(sequelize, DataTypes) { const media_assets = sequelize.define( @@ -102,6 +97,13 @@ duration_seconds: { + }, + +sort_order: { + type: DataTypes.INTEGER, + + + }, is_primary: { diff --git a/frontend/src/components/MediaCenterUploadWidget.tsx b/frontend/src/components/MediaCenterUploadWidget.tsx new file mode 100644 index 0000000..097b40a --- /dev/null +++ b/frontend/src/components/MediaCenterUploadWidget.tsx @@ -0,0 +1,1125 @@ +import React, { ChangeEvent, DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import axios from 'axios'; +import { + mdiArrowDown, + mdiArrowUp, + mdiClockOutline, + mdiClose, + mdiImageOutline, + mdiInformationOutline, + mdiOpenInNew, + mdiPlayCircleOutline, + mdiRadio, + mdiStar, + mdiStarOutline, + mdiTelevisionClassic, + mdiUpload, +} from '@mdi/js'; +import BaseButton from './BaseButton'; +import BaseIcon from './BaseIcon'; +import CardBox from './CardBox'; +import ImageField from './ImageField'; +import LoadingSpinner from './LoadingSpinner'; +import FileUploader from './Uploaders/UploadService'; +import { hasPermission } from '../helpers/userPermissions'; +import { useAppSelector } from '../stores/hooks'; + +type UploadMode = 'audio' | 'video'; +type DropzoneType = 'media' | 'preview'; + +type UploadedFile = { + id?: string; + name?: string; + publicUrl?: string; +}; + +type UploadedAsset = { + id: string; + title?: string; + asset_type?: UploadMode | 'image' | 'document'; + delivery?: string; + mime_type?: string; + source_url?: string; + file_size_bytes?: number; + bitrate_kbps?: number; + resolution?: string; + duration_seconds?: number; + is_primary?: boolean; + sort_order?: number; + createdAt?: string; + file?: UploadedFile[]; + preview_image?: UploadedFile[]; +}; + +type DetectedMediaMetadata = { + mime_type: string; + file_size_bytes: number | null; + bitrate_kbps: number | null; + resolution: string; + duration_seconds: number | null; +}; + +type FileDropzoneProps = { + accept: string; + badge?: string; + description: string; + emptyValueLabel: string; + file: File | null; + icon: string; + inputRef: React.RefObject; + isHighlighted: boolean; + label: string; + onChange: (event: ChangeEvent) => void; + onDragLeave: () => void; + onDragOver: (event: DragEvent) => void; + onDrop: (event: DragEvent) => void; +}; + +type Props = { + className?: string; +}; + +const modeOptions: Array<{ + value: UploadMode; + label: string; + helper: string; + icon: string; + accept: string; + emptyTitle: string; + emptyDescription: string; + buttonLabel: string; + schema: { formats: string[] }; +}> = [ + { + value: 'audio', + label: 'Radio', + helper: 'Upload MP3, AAC, WAV, OGG, or M4A files for your saved radio rotation.', + icon: mdiRadio, + accept: '.mp3,.aac,.wav,.ogg,.m4a', + emptyTitle: 'No radio uploads yet', + emptyDescription: 'Upload the first radio segment, show intro, or replay clip to build the saved playlist.', + buttonLabel: 'Save radio upload', + schema: { formats: ['mp3', 'aac', 'wav', 'ogg', 'm4a'] }, + }, + { + value: 'video', + label: 'Television', + helper: 'Upload MP4, WebM, MOV, or M4V files for your saved television playlist.', + icon: mdiTelevisionClassic, + accept: '.mp4,.webm,.mov,.m4v', + emptyTitle: 'No television uploads yet', + emptyDescription: 'Upload the first televised segment, promo, or replay clip to populate the playlist.', + buttonLabel: 'Save television upload', + schema: { formats: ['mp4', 'webm', 'mov', 'm4v'] }, + }, +]; + +const imageSchema = { + image: true, + formats: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'], +}; + +const emptyMetadata: DetectedMediaMetadata = { + mime_type: '', + file_size_bytes: null, + bitrate_kbps: null, + resolution: '', + duration_seconds: null, +}; + +function getAssetUrl(asset?: UploadedAsset | null) { + if (!asset) return ''; + + return asset.file?.[0]?.publicUrl || asset.source_url || ''; +} + +function getSortOrderValue(asset?: UploadedAsset | null) { + return Number.isFinite(asset?.sort_order) ? Number(asset?.sort_order) : Number.MAX_SAFE_INTEGER; +} + +function sortUploads(items: UploadedAsset[]) { + return [...items].sort((first, second) => { + if (Boolean(first.is_primary) !== Boolean(second.is_primary)) { + return first.is_primary ? -1 : 1; + } + + const orderDifference = getSortOrderValue(first) - getSortOrderValue(second); + + if (orderDifference !== 0) { + return orderDifference; + } + + return new Date(second.createdAt || 0).getTime() - new Date(first.createdAt || 0).getTime(); + }); +} + +function getNextSortOrder(items: UploadedAsset[], mode: UploadMode) { + const currentMax = items + .filter((item) => item.asset_type === mode) + .reduce((maxValue, item) => { + const numericValue = Number(item.sort_order); + return Number.isFinite(numericValue) ? Math.max(maxValue, numericValue) : maxValue; + }, 0); + + return currentMax + 1; +} + +function deriveTitleFromFile(file: File) { + return file.name.replace(/\.[^.]+$/, ''); +} + +function formatCreatedAt(value?: string) { + if (!value) return 'Saved recently'; + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) return 'Saved recently'; + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(date); +} + +function formatBytes(value?: number | null) { + if (!value) return 'Size unavailable'; + + const units = ['B', 'KB', 'MB', 'GB']; + let nextValue = value; + let index = 0; + + while (nextValue >= 1024 && index < units.length - 1) { + nextValue /= 1024; + index += 1; + } + + const precision = nextValue >= 10 || index === 0 ? 0 : 1; + + return `${nextValue.toFixed(precision)} ${units[index]}`; +} + +function formatDuration(value?: number | null) { + if (!value || value <= 0) return 'Not detected'; + + const totalSeconds = Math.round(value); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function normalizeResolutionForMode(mode: UploadMode, mediaElement: HTMLAudioElement | HTMLVideoElement) { + if (mode === 'audio') { + return 'Audio only'; + } + + const videoElement = mediaElement as HTMLVideoElement; + + if (videoElement.videoWidth && videoElement.videoHeight) { + return `${videoElement.videoWidth}×${videoElement.videoHeight}`; + } + + return ''; +} + +async function detectMediaMetadata(file: File, mode: UploadMode): Promise { + const objectUrl = URL.createObjectURL(file); + + try { + const metadata = await new Promise((resolve, reject) => { + const mediaElement = document.createElement(mode === 'audio' ? 'audio' : 'video'); + let isSettled = false; + + const finalize = (callback: () => void) => { + if (isSettled) return; + isSettled = true; + callback(); + }; + + mediaElement.preload = 'metadata'; + mediaElement.onloadedmetadata = () => { + finalize(() => { + const durationSeconds = Number.isFinite(mediaElement.duration) && mediaElement.duration > 0 + ? Math.round(mediaElement.duration) + : null; + const bitrateKbps = durationSeconds && durationSeconds > 0 + ? Math.max(1, Math.round((file.size * 8) / durationSeconds / 1000)) + : null; + + resolve({ + mime_type: file.type || '', + file_size_bytes: file.size || null, + bitrate_kbps: bitrateKbps, + resolution: normalizeResolutionForMode(mode, mediaElement), + duration_seconds: durationSeconds, + }); + }); + }; + + mediaElement.onerror = () => { + finalize(() => reject(new Error('The browser could not read media metadata from this file.'))); + }; + + mediaElement.src = objectUrl; + }); + + return metadata; + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +function FileDropzone({ + accept, + badge, + description, + emptyValueLabel, + file, + icon, + inputRef, + isHighlighted, + label, + onChange, + onDragLeave, + onDragOver, + onDrop, +}: FileDropzoneProps) { + return ( +
+ + +
+ ); +} + +export default function MediaCenterUploadWidget({ className = '' }: Props) { + const { currentUser } = useAppSelector((state) => state.auth); + const canReadMediaAssets = currentUser && hasPermission(currentUser, 'READ_MEDIA_ASSETS'); + const canCreateMediaAssets = currentUser && hasPermission(currentUser, 'CREATE_MEDIA_ASSETS'); + const canUpdateMediaAssets = currentUser && hasPermission(currentUser, 'UPDATE_MEDIA_ASSETS'); + const canDeleteMediaAssets = currentUser && hasPermission(currentUser, 'DELETE_MEDIA_ASSETS'); + + const [mode, setMode] = useState('audio'); + const [title, setTitle] = useState(''); + const [mediaFile, setMediaFile] = useState(null); + const [previewImageFile, setPreviewImageFile] = useState(null); + const [uploadedAssets, setUploadedAssets] = useState([]); + const [selectedAssetId, setSelectedAssetId] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeletingId, setIsDeletingId] = useState(''); + const [isUpdatingPlaylist, setIsUpdatingPlaylist] = useState(false); + const [metadata, setMetadata] = useState(emptyMetadata); + const [isAnalyzingMetadata, setIsAnalyzingMetadata] = useState(false); + const [metadataNotice, setMetadataNotice] = useState(''); + const [dragTarget, setDragTarget] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + const mediaInputRef = useRef(null); + const previewInputRef = useRef(null); + const metadataRequestIdRef = useRef(0); + + const currentMode = useMemo(() => { + return modeOptions.find((item) => item.value === mode) || modeOptions[0]; + }, [mode]); + + const clearMessages = useCallback(() => { + setErrorMessage(''); + setSuccessMessage(''); + }, []); + + const clearMetadata = useCallback(() => { + metadataRequestIdRef.current += 1; + setMetadata(emptyMetadata); + setMetadataNotice(''); + setIsAnalyzingMetadata(false); + }, []); + + const clearFileInputs = useCallback(() => { + if (mediaInputRef.current) { + mediaInputRef.current.value = ''; + } + + if (previewInputRef.current) { + previewInputRef.current.value = ''; + } + }, []); + + const resetUploaderForm = useCallback(() => { + setTitle(''); + setMediaFile(null); + setPreviewImageFile(null); + clearMessages(); + clearMetadata(); + clearFileInputs(); + }, [clearFileInputs, clearMessages, clearMetadata]); + + const loadUploadedAssets = useCallback(async () => { + if (!currentUser || !canReadMediaAssets) { + setUploadedAssets([]); + setIsLoading(false); + return []; + } + + setIsLoading(true); + + try { + const response = await axios.get('media_assets?page=0&limit=100&delivery=upload'); + const rows = Array.isArray(response.data?.rows) ? response.data.rows : []; + setUploadedAssets(rows); + setErrorMessage(''); + return rows; + } catch (error: any) { + console.error('Failed to load uploaded media assets:', error); + setErrorMessage('We could not load the saved upload playlist right now.'); + setUploadedAssets([]); + return []; + } finally { + setIsLoading(false); + } + }, [canReadMediaAssets, currentUser]); + + useEffect(() => { + void loadUploadedAssets(); + }, [loadUploadedAssets]); + + const savedUploads = useMemo(() => { + return sortUploads( + uploadedAssets.filter((item) => item.delivery === 'upload' && (item.asset_type === 'audio' || item.asset_type === 'video')), + ); + }, [uploadedAssets]); + + const activePlaylist = useMemo(() => { + return sortUploads(savedUploads.filter((item) => item.asset_type === mode)); + }, [mode, savedUploads]); + + useEffect(() => { + if (!activePlaylist.length) { + setSelectedAssetId(''); + return; + } + + const selectedExists = activePlaylist.some((item) => item.id === selectedAssetId); + + if (!selectedExists) { + setSelectedAssetId(activePlaylist[0].id); + } + }, [activePlaylist, selectedAssetId]); + + const selectedAsset = useMemo(() => { + return activePlaylist.find((item) => item.id === selectedAssetId) || activePlaylist[0] || null; + }, [activePlaylist, selectedAssetId]); + + const selectedAssetIndex = useMemo(() => { + if (!selectedAsset) return -1; + return activePlaylist.findIndex((item) => item.id === selectedAsset.id); + }, [activePlaylist, selectedAsset]); + + const analyzeAndStoreMediaFile = useCallback( + async (file: File) => { + clearMessages(); + setMetadataNotice(''); + + try { + FileUploader.validate(file, currentMode.schema); + } catch (error: any) { + console.error('Media file validation failed:', error); + setErrorMessage(error?.message || 'That media file is not allowed for this upload mode.'); + setMediaFile(null); + clearMetadata(); + if (mediaInputRef.current) { + mediaInputRef.current.value = ''; + } + return; + } + + setMediaFile(file); + setMetadata({ + mime_type: file.type || '', + file_size_bytes: file.size || null, + bitrate_kbps: null, + resolution: mode === 'audio' ? 'Audio only' : '', + duration_seconds: null, + }); + + if (!title.trim()) { + setTitle(deriveTitleFromFile(file)); + } + + const requestId = metadataRequestIdRef.current + 1; + metadataRequestIdRef.current = requestId; + setIsAnalyzingMetadata(true); + + try { + const detectedMetadata = await detectMediaMetadata(file, mode); + + if (metadataRequestIdRef.current !== requestId) { + return; + } + + setMetadata(detectedMetadata); + setMetadataNotice('Metadata auto-filled from the uploaded file before save.'); + } catch (error: any) { + if (metadataRequestIdRef.current !== requestId) { + return; + } + + console.error('Media metadata detection failed:', error); + setMetadata((currentValue) => ({ + ...currentValue, + mime_type: file.type || currentValue.mime_type, + file_size_bytes: file.size || currentValue.file_size_bytes, + resolution: mode === 'audio' ? 'Audio only' : currentValue.resolution, + })); + setMetadataNotice('Basic metadata was captured, but duration or resolution could not be detected in the browser.'); + } finally { + if (metadataRequestIdRef.current === requestId) { + setIsAnalyzingMetadata(false); + } + } + }, + [clearMessages, clearMetadata, currentMode.schema, mode, title], + ); + + const handlePreviewImageSelected = useCallback( + (file: File) => { + clearMessages(); + + try { + FileUploader.validate(file, imageSchema); + } catch (error: any) { + console.error('Preview image validation failed:', error); + setErrorMessage(error?.message || 'That preview image could not be used.'); + setPreviewImageFile(null); + if (previewInputRef.current) { + previewInputRef.current.value = ''; + } + return; + } + + setPreviewImageFile(file); + }, + [clearMessages], + ); + + const handleDrop = useCallback( + async (event: DragEvent, target: DropzoneType) => { + event.preventDefault(); + setDragTarget(''); + + const file = event.dataTransfer.files?.[0]; + + if (!file) { + return; + } + + if (target === 'media') { + await analyzeAndStoreMediaFile(file); + return; + } + + handlePreviewImageSelected(file); + }, + [analyzeAndStoreMediaFile, handlePreviewImageSelected], + ); + + const handleMediaInputChange = useCallback( + async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + await analyzeAndStoreMediaFile(file); + }, + [analyzeAndStoreMediaFile], + ); + + const handlePreviewInputChange = useCallback( + (event: ChangeEvent) => { + const file = event.target.files?.[0]; + + if (!file) { + return; + } + + handlePreviewImageSelected(file); + }, + [handlePreviewImageSelected], + ); + + const persistPlaylistState = useCallback( + async (items: UploadedAsset[], nextSuccessMessage: string) => { + if (!canUpdateMediaAssets) { + setErrorMessage('You do not have permission to rearrange saved uploads.'); + return; + } + + setIsUpdatingPlaylist(true); + clearMessages(); + + try { + const normalizedItems = items.map((item, index) => ({ + ...item, + sort_order: index + 1, + is_primary: Boolean(item.is_primary), + })); + + await Promise.all( + normalizedItems.map((item) => + axios.put(`media_assets/${item.id}`, { + id: item.id, + data: { + sort_order: item.sort_order, + is_primary: item.is_primary, + }, + }), + ), + ); + + await loadUploadedAssets(); + setSelectedAssetId((currentValue) => currentValue || normalizedItems[0]?.id || ''); + setSuccessMessage(nextSuccessMessage); + } catch (error: any) { + console.error('Failed to update upload playlist order:', error); + setErrorMessage(error?.response?.data?.message || error?.message || 'We could not update the saved playlist right now.'); + } finally { + setIsUpdatingPlaylist(false); + } + }, + [canUpdateMediaAssets, clearMessages, loadUploadedAssets], + ); + + const handleSubmit = async () => { + if (!canCreateMediaAssets) { + setErrorMessage('You do not have permission to save uploaded media assets.'); + return; + } + + if (!mediaFile) { + setErrorMessage(`Choose a ${mode === 'audio' ? 'radio' : 'television'} file before saving.`); + return; + } + + setIsSubmitting(true); + clearMessages(); + + try { + const uploadedMediaFile = await FileUploader.upload('media_assets/file', mediaFile, currentMode.schema); + const uploadedPreviewImage = previewImageFile + ? [await FileUploader.upload('media_assets/preview_image', previewImageFile, imageSchema)] + : []; + const hasPrimaryInMode = activePlaylist.some((item) => item.is_primary); + + await axios.post('media_assets', { + data: { + asset_type: mode, + title: title.trim() || deriveTitleFromFile(mediaFile), + delivery: 'upload', + source_url: uploadedMediaFile.publicUrl, + mime_type: metadata.mime_type || mediaFile.type || '', + file_size_bytes: metadata.file_size_bytes || mediaFile.size || null, + bitrate_kbps: metadata.bitrate_kbps || null, + resolution: metadata.resolution || (mode === 'audio' ? 'Audio only' : null), + duration_seconds: metadata.duration_seconds || null, + sort_order: getNextSortOrder(savedUploads, mode), + is_primary: !hasPrimaryInMode, + file: [{ ...uploadedMediaFile }], + preview_image: uploadedPreviewImage, + }, + }); + + resetUploaderForm(); + setSelectedAssetId(''); + await loadUploadedAssets(); + setSuccessMessage( + `${currentMode.label} upload saved to the playlist${!hasPrimaryInMode ? ' and marked as the primary playback item' : ''}.`, + ); + } catch (error: any) { + console.error('Failed to save uploaded media asset:', error); + setErrorMessage(error?.response?.data?.message || error?.message || 'We could not save this upload right now.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async (assetId: string) => { + if (!canDeleteMediaAssets) { + setErrorMessage('You do not have permission to remove uploaded media assets.'); + return; + } + + setIsDeletingId(assetId); + clearMessages(); + + try { + await axios.delete(`media_assets/${assetId}`); + await loadUploadedAssets(); + setSelectedAssetId((currentValue) => (currentValue === assetId ? '' : currentValue)); + setSuccessMessage('Upload removed from the saved playlist.'); + } catch (error: any) { + console.error('Failed to delete uploaded media asset:', error); + setErrorMessage(error?.response?.data?.message || error?.message || 'We could not remove this upload right now.'); + } finally { + setIsDeletingId(''); + } + }; + + const handleMoveAsset = async (assetId: string, direction: 'up' | 'down') => { + const currentIndex = activePlaylist.findIndex((item) => item.id === assetId); + + if (currentIndex < 0) { + return; + } + + const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + + if (nextIndex < 0 || nextIndex >= activePlaylist.length) { + return; + } + + const reorderedItems = [...activePlaylist]; + const temporaryItem = reorderedItems[currentIndex]; + reorderedItems[currentIndex] = reorderedItems[nextIndex]; + reorderedItems[nextIndex] = temporaryItem; + + await persistPlaylistState(reorderedItems, `${currentMode.label} playlist order updated.`); + setSelectedAssetId(assetId); + }; + + const handleMarkPrimary = async (assetId: string) => { + await persistPlaylistState( + activePlaylist.map((item) => ({ + ...item, + is_primary: item.id === assetId, + })), + `${currentMode.label} primary playback item updated.`, + ); + setSelectedAssetId(assetId); + }; + + const metadataCards = useMemo(() => { + return [ + { + label: 'File size', + value: formatBytes(metadata.file_size_bytes), + icon: mdiInformationOutline, + }, + { + label: 'Duration', + value: isAnalyzingMetadata ? 'Analyzing…' : formatDuration(metadata.duration_seconds), + icon: mdiClockOutline, + }, + { + label: 'Resolution', + value: isAnalyzingMetadata ? 'Analyzing…' : metadata.resolution || (mode === 'audio' ? 'Audio only' : 'Not detected'), + icon: mdiTelevisionClassic, + }, + { + label: 'Bitrate', + value: isAnalyzingMetadata ? 'Analyzing…' : metadata.bitrate_kbps ? `${metadata.bitrate_kbps} kbps` : 'Not detected', + icon: mdiRadio, + }, + ]; + }, [isAnalyzingMetadata, metadata, mode]); + + if (!currentUser || (!canReadMediaAssets && !canCreateMediaAssets)) { + return null; + } + + return ( + +
+
+
+

Saved uploads

+

Radio and television upload widget

+

+ Upload audio or video files into media_assets, auto-fill metadata before save, then reuse the saved playlist directly from the media center. +

+
+
+ {modeOptions.map((item) => ( + { + setMode(item.value); + setMediaFile(null); + clearMetadata(); + clearMessages(); + if (mediaInputRef.current) { + mediaInputRef.current.value = ''; + } + }} + /> + ))} + {canReadMediaAssets && } +
+
+ +
+
+
+
+ +
+

{currentMode.label} uploader

+

{currentMode.helper}

+
+
+ + {canCreateMediaAssets ? ( +
+
+ + setTitle(event.target.value)} + placeholder={mode === 'audio' ? 'Morning radio replay' : 'Evening television segment'} + className='w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-cyan-400 focus:ring focus:ring-cyan-100 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-cyan-500 dark:focus:ring-cyan-950' + /> +
+ + setDragTarget('')} + onDragOver={(event) => { + event.preventDefault(); + setDragTarget('media'); + }} + onDrop={(event) => void handleDrop(event, 'media')} + /> + + setDragTarget('')} + onDragOver={(event) => { + event.preventDefault(); + setDragTarget('preview'); + }} + onDrop={(event) => void handleDrop(event, 'preview')} + /> + + {(mediaFile || isAnalyzingMetadata) && ( +
+
+
+

Auto-detected media metadata

+

This preview is captured in the browser before the upload is saved.

+
+ + {isAnalyzingMetadata ? 'Analyzing…' : 'Ready to save'} + +
+ +
+ {metadataCards.map((item) => ( +
+
+ +

{item.label}

+
+

{item.value}

+
+ ))} +
+ +
+ MIME type: {metadata.mime_type || mediaFile?.type || 'Not detected'} +
+ + {metadataNotice && ( +
+ {metadataNotice} +
+ )} +
+ )} + + {(errorMessage || successMessage) && ( +
+ {errorMessage || successMessage} +
+ )} + +
+ + +
+
+ ) : ( +
+ You can view the saved playlist here, but you do not currently have permission to upload new media assets. +
+ )} +
+
+ +
+
+
+
+

{currentMode.label} playlist

+

Saved uploads stay in the playlist and can be replayed any time.

+
+ + {activePlaylist.length} item{activePlaylist.length === 1 ? '' : 's'} + +
+ + {isLoading ? ( + + ) : activePlaylist.length ? ( +
+
+ {activePlaylist.map((item, index) => { + const isSelected = selectedAsset?.id === item.id; + const fileUrl = getAssetUrl(item); + const isFirstItem = index === 0; + const isLastItem = index === activePlaylist.length - 1; + + return ( +
+ +
+ setSelectedAssetId(item.id)} /> + {fileUrl && } + {canUpdateMediaAssets && ( + <> + void handleMarkPrimary(item.id)} + disabled={isUpdatingPlaylist} + /> + void handleMoveAsset(item.id, 'up')} + disabled={isUpdatingPlaylist || isFirstItem} + /> + void handleMoveAsset(item.id, 'down')} + disabled={isUpdatingPlaylist || isLastItem} + /> + + )} + {canDeleteMediaAssets && ( + handleDelete(item.id)} + disabled={Boolean(isDeletingId) || isUpdatingPlaylist} + /> + )} +
+
+ ); + })} +
+ +
+ {selectedAsset ? ( +
+
+ +
+

Now queued

+

{selectedAsset.title || 'Untitled upload'}

+
+
+ + {selectedAsset.preview_image?.[0]?.publicUrl && selectedAsset.asset_type === 'video' && ( +
+ +
+ )} + +
+ {getAssetUrl(selectedAsset) ? ( + selectedAsset.asset_type === 'audio' ? ( +
+ +
+
+

Saved

+

{formatCreatedAt(selectedAsset.createdAt)}

+
+
+

File size

+

{formatBytes(selectedAsset.file_size_bytes)}

+
+
+

Duration

+

{formatDuration(selectedAsset.duration_seconds)}

+
+
+

Resolution

+

{selectedAsset.resolution || (selectedAsset.asset_type === 'audio' ? 'Audio only' : 'Not stored')}

+
+
+

Bitrate

+

{selectedAsset.bitrate_kbps ? `${selectedAsset.bitrate_kbps} kbps` : 'Not stored'}

+
+
+

Playlist status

+

+ {selectedAsset.is_primary ? 'Primary playback item' : 'Secondary playlist item'}{selectedAssetIndex >= 0 ? ` • Position ${selectedAssetIndex + 1}` : ''} +

+
+
+

MIME

+

{selectedAsset.mime_type || 'Not stored'}

+
+
+ +
+ {getAssetUrl(selectedAsset) && } + +
+
+ ) : null} +
+
+ ) : ( +
+ +

{currentMode.emptyTitle}

+

{currentMode.emptyDescription}

+
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/media-center.tsx b/frontend/src/pages/media-center.tsx index 8cef88b..247d9db 100644 --- a/frontend/src/pages/media-center.tsx +++ b/frontend/src/pages/media-center.tsx @@ -16,6 +16,7 @@ import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; import LayoutAuthenticated from '../layouts/Authenticated'; import LoadingSpinner from '../components/LoadingSpinner'; +import MediaCenterUploadWidget from '../components/MediaCenterUploadWidget'; import SectionMain from '../components/SectionMain'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; import { getPageTitle } from '../config'; @@ -399,6 +400,8 @@ const MediaCenterPage = () => { + + {loading && } {!loading && errorMessage && (