diff --git a/app/client/src/common/modalStyles.js b/app/client/src/common/modalStyles.js new file mode 100644 index 00000000..d2b3bd8c --- /dev/null +++ b/app/client/src/common/modalStyles.js @@ -0,0 +1,65 @@ +// Shared dark-theme style constants for dialogs and modals + +export const labelSx = { + fontSize: 12, + color: '#FFFFFFB3', + mb: 1, + textTransform: 'uppercase', + letterSpacing: '0.08em', +} + +export const inputSx = { + '& .MuiOutlinedInput-root': { + color: 'white', + bgcolor: '#FFFFFF0D', + borderRadius: '8px', + '& fieldset': { borderColor: '#FFFFFF26' }, + '&:hover fieldset': { borderColor: '#FFFFFF55' }, + '&.Mui-focused fieldset': { borderColor: '#3399FF' }, + }, +} + +export const rowBoxSx = { + display: 'flex', + alignItems: 'center', + gap: 1.5, + bgcolor: '#FFFFFF0D', + border: '1px solid #FFFFFF26', + borderRadius: '8px', + px: 1.5, + py: 1, +} + +export const dialogPaperSx = { + bgcolor: '#041223', + border: '1px solid #FFFFFF1A', + borderRadius: '12px', + boxShadow: '0 16px 48px #00000099', +} + +export const dialogTitleSx = { + fontWeight: 800, + color: 'white', +} + +export const checkboxSx = { + color: '#FFFFFF33', + '&.Mui-checked': { color: '#3399FF' }, + p: 0.5, +} + +export const helperTextSx = { + fontSize: 14, + color: '#FFFFFFB3', +} + +export const timeInputStyle = { + background: '#FFFFFF0D', + border: '1px solid #FFFFFF26', + borderRadius: 6, + color: 'white', + fontSize: 13, + padding: '4px 8px', + colorScheme: 'dark', + flex: 1, +} diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index ae893b76..c664490b 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -15,13 +15,15 @@ import { Chip, CircularProgress, InputAdornment, + Checkbox, + FormControlLabel, } from '@mui/material' import CloudUploadIcon from '@mui/icons-material/CloudUpload' import styled from '@emotion/styled' import { keyframes } from '@emotion/react' -import { motion } from 'framer-motion' import { VideoService, GameService, TagService } from '../../services' import { getSetting } from '../../common/utils' +import { dialogPaperSx, dialogTitleSx, inputSx, labelSx, checkboxSx, helperTextSx } from '../../common/modalStyles' import logo from '../../assets/logo.png' const Input = styled('input')({ @@ -40,6 +42,16 @@ const borderSpin = keyframes` to { transform: rotate(360deg); } ` +const shimmer = keyframes` + 0% { background-position: -400px 0; } + 100% { background-position: 400px 0; } +` + +const fadeIn = keyframes` + from { opacity: 0; } + to { opacity: 1; } +` + const maskCss = { maskImage: `url(${logo})`, maskSize: 'contain', @@ -74,6 +86,7 @@ function LogoProgress({ progress, size = 44 }) { ) } + const UploadCard = React.forwardRef(function UploadCard( { authenticated, handleAlert, mini, onUploadComplete, onProgress, dropOnly = false }, ref, @@ -97,6 +110,12 @@ const UploadCard = React.forwardRef(function UploadCard( const [gameSearchLoading, setGameSearchLoading] = React.useState(false) const [gameCreating, setGameCreating] = React.useState(false) const [gameInput, setGameInput] = React.useState('') + const [uploadToGameFolder, setUploadToGameFolder] = React.useState(false) + const [titleInput, setTitleInput] = React.useState('') + const [editingTitle, setEditingTitle] = React.useState(false) + const [titleDraft, setTitleDraft] = React.useState('') + const [thumbnail, setThumbnail] = React.useState(null) + const [thumbnailReady, setThumbnailReady] = React.useState(false) const [availableFolders, setAvailableFolders] = React.useState([]) const [selectedFolder, setSelectedFolder] = React.useState('') // Stored metadata to attach on next upload @@ -110,13 +129,48 @@ const UploadCard = React.forwardRef(function UploadCard( }, })) + const extractThumbnail = (file) => { + setThumbnail(null) + setThumbnailReady(false) + const url = URL.createObjectURL(file) + const video = document.createElement('video') + video.preload = 'auto' + video.muted = true + video.src = url + video.addEventListener('loadeddata', () => { + video.currentTime = Math.min(video.duration * 0.1, 5) + }) + video.addEventListener('seeked', () => { + requestAnimationFrame(() => { + const canvas = document.createElement('canvas') + canvas.width = video.videoWidth + canvas.height = video.videoHeight + canvas.getContext('2d').drawImage(video, 0, 0) + const dataUrl = canvas.toDataURL('image/jpeg', 0.85) + URL.revokeObjectURL(url) + setThumbnail(dataUrl) + setThumbnailReady(true) + }) + }) + video.addEventListener('error', () => { + setThumbnailReady(true) + URL.revokeObjectURL(url) + }) + video.load() + } + const openMetadataDialog = (file) => { setPendingFile(file) + extractThumbnail(file) setSelectedGame(null) setSelectedTags([]) setTagInput('') setGameInput('') setGameOptions([]) + setUploadToGameFolder(false) + setTitleInput('') + setEditingTitle(false) + setTitleDraft('') Promise.all([GameService.getGames(), TagService.getTags(), VideoService.getUploadFolders()]) .then(([gRes, tRes, fRes]) => { const games = gRes.data || [] @@ -167,12 +221,15 @@ const UploadCard = React.forwardRef(function UploadCard( pendingMetadata.current = { tag_ids: resolvedTags.length ? resolvedTags.map((t) => t.id).join(',') : null, game_id: selectedGame ? selectedGame.id : null, - folder: selectedFolder || null, + folder: (uploadToGameFolder && selectedGame ? selectedGame.name : selectedFolder) || null, + title: titleInput.trim() || null, } setDialogOpen(false) setSelectedFile(pendingFile) setIsSelected(true) setPendingFile(null) + setThumbnail(null) + setThumbnailReady(false) } const handleDialogCancel = () => { @@ -183,6 +240,11 @@ const UploadCard = React.forwardRef(function UploadCard( setTagInput('') setGameOptions([]) setGameInput('') + setUploadToGameFolder(false) + setTitleInput('') + setEditingTitle(false) + setThumbnail(null) + setThumbnailReady(false) } const handleGameInputChange = async (_, value) => { @@ -209,6 +271,7 @@ const UploadCard = React.forwardRef(function UploadCard( const handleGameChange = async (_, newValue) => { if (!newValue) { setSelectedGame(null) + setUploadToGameFolder(false) return } if (newValue._source === 'db') { @@ -289,7 +352,7 @@ const UploadCard = React.forwardRef(function UploadCard( if (!selectedFile) return const chunkSize = 90 * 1024 * 1024 // 90MB chunk size - const { tag_ids, game_id, folder } = pendingMetadata.current + const { tag_ids, game_id, folder, title } = pendingMetadata.current async function upload() { const formData = new FormData() @@ -297,6 +360,7 @@ const UploadCard = React.forwardRef(function UploadCard( if (tag_ids) formData.append('tag_ids', tag_ids) if (game_id) formData.append('game_id', game_id) if (folder) formData.append('folder', folder) + if (title) formData.append('title', title) try { if (authenticated) { await VideoService.upload(formData, uploadProgress) @@ -363,6 +427,7 @@ const UploadCard = React.forwardRef(function UploadCard( if (tag_ids) formData.append('tag_ids', tag_ids) if (game_id) formData.append('game_id', game_id) if (folder) formData.append('folder', folder) + if (title) formData.append('title', title) authenticated ? await VideoService.uploadChunked(formData, uploadProgressChunked, selectedFile.size, start) @@ -397,6 +462,76 @@ const UploadCard = React.forwardRef(function UploadCard( // eslint-disable-next-line }, [selectedFile]) + const filenameStem = pendingFile ? pendingFile.name.replace(/\.[^/.]+$/, '') : '' + const displayTitle = titleInput || filenameStem || 'Untitled' + + const inlineTitleEl = ( + + {editingTitle ? ( + setTitleDraft(e.target.value)} + onBlur={() => { + setTitleInput(titleDraft.trim()) + setEditingTitle(false) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') e.target.blur() + if (e.key === 'Escape') { + setTitleDraft(titleInput) + setEditingTitle(false) + } + }} + maxLength={200} + style={{ + width: '100%', + background: '#FFFFFF1F', + border: 'none', + borderRadius: '6px', + outline: 'none', + color: 'white', + fontWeight: 800, + fontSize: 22, + lineHeight: 1.3, + padding: '2px 6px', + boxSizing: 'border-box', + fontFamily: '"Montserrat",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', + }} + /> + ) : ( + { setTitleDraft(titleInput); setEditingTitle(true) }} + sx={{ + display: 'flex', + alignItems: 'center', + cursor: 'text', + borderRadius: '6px', + px: '6px', + mx: '-6px', + transition: 'background 0.15s', + '&:hover': { background: '#FFFFFF1F' }, + }} + > + + {displayTitle} + + + )} + + ) + const canUpload = authenticated ? !!uiConfig?.show_admin_upload : !!uiConfig?.allow_public_upload if (!canUpload) return null @@ -405,26 +540,58 @@ const UploadCard = React.forwardRef(function UploadCard( - - Upload Video + Upload Video + + + + {inlineTitleEl} + + {/* Thumbnail — left column */} + + + {thumbnailReady && thumbnail && ( + + )} + {!thumbnailReady && ( + + )} + {thumbnailReady && !thumbnail && ( + + + + )} + - - - - {/* Game selector */} - o.name || ''} - groupBy={(o) => (o._source === 'db' ? 'Already in library' : 'From SteamGridDB')} - value={selectedGame} - inputValue={gameInput} - onInputChange={handleGameInputChange} - onChange={handleGameChange} - loading={gameSearchLoading} - disabled={gameCreating} - filterOptions={(x) => x} - isOptionEqualToValue={(option, value) => - option.id === value.id || (option.steamgriddb_id && option.steamgriddb_id === value.steamgriddb_id) - } - renderInput={(params) => ( - - {(gameSearchLoading || gameCreating) && ( - - - + + {/* Form fields — right column */} + + {/* Game selector */} + + Game + o.name || ''} + groupBy={(o) => (o._source === 'db' ? 'Already in library' : 'From SteamGridDB')} + value={selectedGame} + inputValue={gameInput} + onInputChange={handleGameInputChange} + onChange={handleGameChange} + loading={gameSearchLoading} + disabled={gameCreating} + filterOptions={(x) => x} + isOptionEqualToValue={(option, value) => + option.id === value.id || (option.steamgriddb_id && option.steamgriddb_id === value.steamgriddb_id) + } + renderInput={(params) => ( + { e.currentTarget.style.display = 'none' }} style={{ width: 18, height: 18, objectFit: 'contain', borderRadius: 3 }} />{params.InputProps.startAdornment} + : params.InputProps.startAdornment, + endAdornment: <>{(gameSearchLoading || gameCreating) && }{params.InputProps.endAdornment}, + }} + /> + )} + renderOption={(props, option) => ( + + {option.icon_url && ( + { + e.currentTarget.style.display = 'none' + }} + style={{ width: 20, height: 20, objectFit: 'contain', borderRadius: 3, flexShrink: 0 }} + /> )} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - renderOption={(props, option) => ( - - {option.icon_url && ( - { - e.currentTarget.style.display = 'none' - }} - style={{ width: 20, height: 20, objectFit: 'contain', borderRadius: 3, flexShrink: 0 }} + {option.name} + {option._source === 'sgdb' && + option.release_date && + ` (${new Date(option.release_date * 1000).getFullYear()})`} + + )} + /> + {selectedGame && ( + setUploadToGameFolder(e.target.checked)} size="small" sx={checkboxSx} />} + label={Auto-sort into game folder} + sx={{ mt: 0.5, ml: 0 }} /> )} - {option.name} - {option._source === 'sgdb' && - option.release_date && - ` (${new Date(option.release_date * 1000).getFullYear()})`} - )} - /> - {availableFolders.length > 0 && ( - setSelectedFolder(value || '')} - disableClearable={!!selectedFolder} - renderInput={(params) => } - /> - )} - !selectedTags.find((s) => s.id === t.id))} - getOptionLabel={(o) => (typeof o === 'string' ? o : o.name)} - value={selectedTags} - inputValue={tagInput} - onInputChange={(_, v) => setTagInput(v)} - onChange={(_, values) => { - setSelectedTags(values.map((v) => (typeof v === 'string' ? { name: v } : v))) - setTagInput('') - }} - renderTags={(value, getTagProps) => - value.map((option, index) => ( - 0 && ( + + Upload Folder + setSelectedFolder(value || '')} + disableClearable={uploadToGameFolder ? true : !!selectedFolder} + disabled={uploadToGameFolder && !!selectedGame} + renderInput={(params) => } + /> + + )} + + Tags + !selectedTags.find((s) => s.id === t.id))} + getOptionLabel={(o) => (typeof o === 'string' ? o : o.name)} + value={selectedTags} + inputValue={tagInput} + onInputChange={(_, v) => setTagInput(v)} + onChange={(_, values) => { + setSelectedTags(values.map((v) => (typeof v === 'string' ? { name: v } : v))) + setTagInput('') }} - /> - )) - } - renderInput={(params) => ( - { - if (e.key === ',') { - e.preventDefault() - const parts = parseTagInput(tagInput) - if (parts.length > 0) { - setSelectedTags((prev) => { - const merged = [...prev] - parts.forEach((p) => { - if (!merged.find((t) => t.name.toLowerCase() === p.toLowerCase())) { - const existing = allTags.find((t) => t.name.toLowerCase() === p.toLowerCase()) - merged.push(existing || { name: p }) - } - }) - return merged - }) - setTagInput('') - } + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) } - }} - /> - )} - /> + renderInput={(params) => ( + { + if (e.key === ',') { + e.preventDefault() + const parts = parseTagInput(tagInput) + if (parts.length > 0) { + setSelectedTags((prev) => { + const merged = [...prev] + parts.forEach((p) => { + if (!merged.find((t) => t.name.toLowerCase() === p.toLowerCase())) { + const existing = allTags.find((t) => t.name.toLowerCase() === p.toLowerCase()) + merged.push(existing || { name: p }) + } + }) + return merged + }) + setTagInput('') + } + } + }} + /> + )} + /> + + + - + diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js index 50e8d232..4a00fbb4 100644 --- a/app/client/src/views/Games.js +++ b/app/client/src/views/Games.js @@ -24,6 +24,7 @@ import CheckIcon from '@mui/icons-material/Check' import ImageIcon from '@mui/icons-material/Image' import { useNavigate } from 'react-router-dom' import { GameService } from '../services' +import { dialogPaperSx, dialogTitleSx, helperTextSx, checkboxSx } from '../common/modalStyles' import { recordAssetBust, applyAssetBusts } from '../services/GameService' import LoadingSpinner from '../components/misc/LoadingSpinner' import EditGameAssetsModal from '../components/modal/EditGameAssetsModal' @@ -348,12 +349,12 @@ const Games = ({ authenticated, searchText }) => { /> {/* Delete Confirmation Dialog */} - - + + Delete {selectedGames.size} Game{selectedGames.size > 1 ? 's' : ''}? - - + + Are you sure you want to delete the selected game{selectedGames.size > 1 ? 's' : ''}? { setDeleteAssociatedVideos(e.target.checked)} - sx={{ - color: 'error.main', - '&.Mui-checked': { - color: 'error.main', - }, - }} + sx={{ ...checkboxSx, '&.Mui-checked': { color: 'error.main' } }} /> } - label="Also delete associated videos" + label={Also delete associated videos} /> - - - + diff --git a/app/client/src/views/TagVideos.js b/app/client/src/views/TagVideos.js index f57ec9b1..ff64afe2 100644 --- a/app/client/src/views/TagVideos.js +++ b/app/client/src/views/TagVideos.js @@ -1,7 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom' import { Box, Typography } from '@mui/material' -import LocalOfferIcon from '@mui/icons-material/LocalOffer' import { useParams } from 'react-router-dom' import Select from 'react-select' import { TagService } from '../services' @@ -19,6 +18,8 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { const [loading, setLoading] = React.useState(true) const [sortOrder, setSortOrder] = React.useState(SORT_OPTIONS?.[0] || { value: 'newest', label: 'Newest' }) const [toolbarTarget, setToolbarTarget] = React.useState(null) + const [currentClipIndex, setCurrentClipIndex] = React.useState(0) + const videoRefs = React.useRef([]) if (searchText !== search) { setSearch(searchText) @@ -45,6 +46,20 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { setToolbarTarget(document.getElementById('navbar-toolbar-extra')) }, []) + const clips = React.useMemo(() => videos.filter((v) => (v.info?.duration || 0) >= 5).slice(0, 6).map((v) => `/api/video/poster?id=${v.video_id}&animated=true`), [videos]) + + React.useEffect(() => { + if (clips.length <= 1) return + const timer = setInterval(() => { + setCurrentClipIndex((i) => (i + 1) % clips.length) + }, 3000) + return () => clearInterval(timer) + }, [clips.length]) + + React.useEffect(() => { + videoRefs.current.forEach((v) => { if (v) v.play().catch(() => {}) }) + }, [clips]) + const sortedVideos = React.useMemo(() => { if (!filteredVideos || !Array.isArray(filteredVideos)) return [] return [...filteredVideos].sort((a, b) => { @@ -83,24 +98,91 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { toolbarTarget, )} - {/* Tag header */} + {/* Tag hero banner */} - - - {(tag?.name || 'Tag').replace(/_/g, ' ')} - - - {sortedVideos.length} video{sortedVideos.length !== 1 ? 's' : ''} - + {/* Video clips cycling in background */} + {clips.map((src, i) => ( + { videoRefs.current[i] = el }} + src={src} + autoPlay + muted + loop + playsInline + onLoadedMetadata={(e) => { e.target.currentTime = (e.target.duration / clips.length) * i }} + sx={{ + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + opacity: i === currentClipIndex ? 0.45 : 0, + transition: 'opacity 1.5s ease', + pointerEvents: 'none', + }} + /> + ))} + + + + {/* Solid color multiply overlay */} + + + {/* Tag name - bottom left */} + + + Tag + + + {(tag?.name || 'Tag').replace(/_/g, ' ')} + + diff --git a/app/client/src/views/Tags.js b/app/client/src/views/Tags.js index d97489aa..381a97fa 100644 --- a/app/client/src/views/Tags.js +++ b/app/client/src/views/Tags.js @@ -17,7 +17,6 @@ import { FormControlLabel, Popover, TextField, - Chip, useTheme, useMediaQuery, } from '@mui/material' @@ -32,8 +31,30 @@ import { useNavigate } from 'react-router-dom' import { SketchPicker } from 'react-color' import { TagService } from '../services' import LoadingSpinner from '../components/misc/LoadingSpinner' +import { labelSx, inputSx, dialogPaperSx, dialogTitleSx } from '../common/modalStyles' -const DEFAULT_TAG_COLOR = '#2684FF' +const DEFAULT_TAG_COLOR = '#1a3a5c' + +const normalizeTagColor = (hex) => { + if (!hex || hex.length < 7) return hex + const r = parseInt(hex.slice(1,3),16)/255, g = parseInt(hex.slice(3,5),16)/255, b = parseInt(hex.slice(5,7),16)/255 + const max = Math.max(r,g,b), min = Math.min(r,g,b), d = max-min + let h = 0, s = 0, l = (max+min)/2 + if (d) { + s = l > 0.5 ? d/(2-max-min) : d/(max+min) + h = (max===r ? (g-b)/d+(g { t=(t%1+1)%1; return Math.round(255*(t<1/6?p+(q-p)*6*t:t<.5?q:t<2/3?p+(q-p)*(2/3-t)*6:p)).toString(16).padStart(2,'0') } + return `#${hf(h+1/3)}${hf(h)}${hf(h-1/3)}` +} + +const cardBorderColor = (hex) => { + const [r,g,b] = [1,3,5].map(i => parseInt(hex.slice(i,i+2),16)) + const factor = (Math.max(r,g,b)+Math.min(r,g,b))/510 < 0.25 ? 2.5 : 0.3 + return `#${[r,g,b].map(c => Math.min(255,Math.round(c*factor)).toString(16).padStart(2,'0')).join('')}` +} const Tags = ({ authenticated, searchText }) => { const [tags, setTags] = React.useState([]) @@ -44,7 +65,7 @@ const Tags = ({ authenticated, searchText }) => { const [deleteAssociatedVideos, setDeleteAssociatedVideos] = React.useState(false) const [newTagDialogOpen, setNewTagDialogOpen] = React.useState(false) const [newTagName, setNewTagName] = React.useState('') - const [newTagColor, setNewTagColor] = React.useState(DEFAULT_TAG_COLOR) + const [newTagColor, setNewTagColor] = React.useState('#1a3a5c') const [colorPickerAnchorEl, setColorPickerAnchorEl] = React.useState(null) const [editingTag, setEditingTag] = React.useState(null) const [editTagColor, setEditTagColor] = React.useState(DEFAULT_TAG_COLOR) @@ -168,52 +189,49 @@ const Tags = ({ authenticated, searchText }) => { return ( - {toolbarTarget && + {toolbarTarget && authenticated && ReactDOM.createPortal( - {authenticated && ( - - {editMode && ( - - - - - )} - {!editMode && ( - - )} - + + + )} + {!editMode && ( + + )} + + {editMode ? : } + , toolbarTarget, )} @@ -253,10 +271,10 @@ const Tags = ({ authenticated, searchText }) => { .sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })) .map((tag, index) => { const isSelected = selectedTags.has(tag.id) - const color = tag.color || DEFAULT_TAG_COLOR + const color = normalizeTagColor(tag.color || '#1a3a5c') return ( - + { onClick={() => handleTagClick(tag.id)} sx={{ position: 'relative', - height: 80, + height: 140, borderRadius: 2, overflow: 'hidden', cursor: 'pointer', + border: isSelected ? '3px solid' : '2px solid', + borderColor: cardBorderColor(color), + ...(!tag.preview_video_id && { bgcolor: `${color}44` }), display: 'flex', alignItems: 'center', - gap: 2, - pl: 2.5, - pr: 1.5, - // Left accent stripe via inset shadow - boxShadow: isSelected - ? `inset 5px 0 0 ${color}, 0 0 0 2px ${color}99` - : `inset 5px 0 0 ${color}`, - // Subtle gradient wash from tag color - background: `linear-gradient(100deg, ${color}22 0%, ${color}0D 35%, #0A1929 70%)`, - border: '1px solid', - borderColor: isSelected ? `${color}99` : 'rgba(255,255,255,0.06)', - transition: 'transform 0.18s ease, box-shadow 0.18s ease', + justifyContent: 'center', + gap: 0.25, + py: 2, + transition: 'transform 0.2s ease, box-shadow 0.2s ease', '&:hover': { - transform: 'translateY(-2px)', - boxShadow: isSelected - ? `inset 5px 0 0 ${color}, 0 0 0 2px ${color}99, 0 6px 24px ${color}30` - : `inset 5px 0 0 ${color}, 0 6px 24px ${color}28`, + transform: 'scale(1.03)', + boxShadow: `inset 0 0 0 1px ${cardBorderColor(color)}, 0 8px 32px #00000088, 0 4px 20px ${color}44`, }, + boxShadow: `inset 0 0 0 1px ${cardBorderColor(color)}, 0 4px 16px #00000066`, }} > - {/* Tag icon in accent color */} - - - {/* Name + count */} - - - {tag.name.replace(/_/g, ' ')} - - - {tag.video_count ?? 0} video{tag.video_count !== 1 ? 's' : ''} - - - - {/* Edit mode controls */} - {editMode && ( - - {/* Color edit button */} - handleOpenColorEdit(e, tag)} + {tag.preview_video_id && ( + <> + - - - {/* Checkbox */} - { - e.stopPropagation() - handleTagSelect(tag.id) + /> + + - + )} - - {/* Video count badge (non-edit mode, right side) */} - {!editMode && ( - { + e.stopPropagation() + handleTagSelect(tag.id) + }} sx={{ - height: 20, - fontSize: 11, - bgcolor: `${color}22`, - color: color, - border: `1px solid ${color}44`, - fontWeight: 700, - flexShrink: 0, - '& .MuiChip-label': { px: 1 }, + position: 'absolute', + top: 8, + left: 8, + zIndex: 2, + color: 'white', + bgcolor: '#00000080', + borderRadius: '4px', + '&.Mui-checked': { color: 'primary.main' }, }} /> )} + + + {tag.name.replace(/_/g, ' ')} + + + {tag.video_count ?? 0} video{tag.video_count !== 1 ? 's' : ''} + + @@ -371,12 +372,12 @@ const Tags = ({ authenticated, searchText }) => { )} {/* Delete Confirmation Dialog */} - setDeleteDialogOpen(false)}> - + setDeleteDialogOpen(false)} PaperProps={{ sx: dialogPaperSx }}> + Delete {selectedTags.size} Tag{selectedTags.size > 1 ? 's' : ''}? - + Are you sure you want to delete the selected tag{selectedTags.size > 1 ? 's' : ''}? { setDeleteDialogOpen(false) setDeleteAssociatedVideos(false) }} + sx={{ borderRadius: '8px', color: 'white', borderColor: 'white' }} > Cancel - @@ -492,20 +494,24 @@ const Tags = ({ authenticated, searchText }) => { setNewTagDialogOpen(false) setColorPickerAnchorEl(null) }} + PaperProps={{ sx: dialogPaperSx }} > - Create New Tag + Create New Tag - setNewTagName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()} - fullWidth - inputProps={{ maxLength: 12 }} - /> + + Tag Name + setNewTagName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()} + fullWidth + sx={inputSx} + inputProps={{ maxLength: 12 }} + /> + - Color + Color setColorPickerAnchorEl(e.currentTarget)} sx={{ @@ -546,10 +552,16 @@ const Tags = ({ authenticated, searchText }) => { setNewTagDialogOpen(false) setColorPickerAnchorEl(null) }} + sx={{ borderRadius: '8px', color: 'white', borderColor: 'white' }} > Cancel - diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index 44c18da1..0f3a385d 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -1159,7 +1159,7 @@ def get_videos(): for v in videos: vjson = v.json() vjson["view_count"] = VideoView.count(v.video_id) - vjson["tags"] = [l.tag.json() for l in VideoTagLink.query.filter_by(video_id=v.video_id).all()] + vjson["tags"] = [l.tag.json() for l in VideoTagLink.query.filter_by(video_id=v.video_id).all() if l.tag is not None] videos_json.append(vjson) if sort == "views asc": @@ -1215,7 +1215,7 @@ def get_public_videos(): if (not vjson["available"]): continue vjson["view_count"] = VideoView.count(v.video_id) - vjson["tags"] = [l.tag.json() for l in VideoTagLink.query.filter_by(video_id=v.video_id).all()] + vjson["tags"] = [l.tag.json() for l in VideoTagLink.query.filter_by(video_id=v.video_id).all() if l.tag is not None] videos_json.append(vjson) if sort == "views asc": @@ -1378,11 +1378,16 @@ def handle_video_details(id): @api.route('/api/video/poster', methods=['GET']) def get_video_poster(): video_id = request.args['id'] - webm_poster_path = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id, "boomerang-preview.webm") - jpg_poster_path = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id, "poster.jpg") + derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id) + jpg_poster_path = derived_dir / "poster.jpg" if request.args.get('animated'): - response = send_file(webm_poster_path, mimetype='video/webm') + mp4_path = derived_dir / "boomerang-preview.mp4" + webm_path = derived_dir / "boomerang-preview.webm" + if mp4_path.exists(): + response = send_file(mp4_path, mimetype='video/mp4') + else: + response = send_file(webm_path, mimetype='video/webm') else: response = send_file(jpg_poster_path, mimetype='image/jpg') @@ -2553,12 +2558,16 @@ def get_tags(): result = [] for tag in tags: t = tag.json() - t["video_count"] = ( + links = ( db.session.query(VideoTagLink) .join(Video, Video.video_id == VideoTagLink.video_id) .filter(VideoTagLink.tag_id == tag.id, Video.available.is_(True)) - .count() ) + if not current_user.is_authenticated: + links = links.join(VideoInfo, VideoInfo.video_id == VideoTagLink.video_id).filter(VideoInfo.private.is_(False)) + t["video_count"] = links.count() + random_link = links.order_by(func.random()).first() + t["preview_video_id"] = random_link.video_id if random_link else None result.append(t) return jsonify(result) @@ -2673,12 +2682,25 @@ def get_tag_videos(tag_id): continue vjson = link.video.json() vjson["view_count"] = VideoView.count(link.video_id) - vjson["tags"] = [l.tag.json() for l in VideoTagLink.query.filter_by(video_id=link.video_id).all()] + vjson["tags"] = [l.tag.json() for l in VideoTagLink.query.filter_by(video_id=link.video_id).all() if l.tag is not None] videos_json.append(vjson) return jsonify(videos_json) +def _regenerate_boomerang_bg(video_id, extension, processed_directory): + video_path = Path(processed_directory, "video_links", video_id + extension) + derived_path = Path(processed_directory, "derived", video_id) + out_path = derived_path / "boomerang-preview.mp4" + def run(): + if not video_path.exists(): + return + if not derived_path.exists(): + derived_path.mkdir(parents=True) + util.create_boomerang_preview(video_path, out_path) + threading.Thread(target=run, daemon=True).start() + + @api.route('/api/videos//tags', methods=["GET"]) def get_video_tags(video_id): links = VideoTagLink.query.filter_by(video_id=video_id).all() @@ -2711,6 +2733,7 @@ def add_tag_to_video(video_id): ) db.session.add(link) db.session.commit() + _regenerate_boomerang_bg(video.video_id, video.extension, current_app.config["PROCESSED_DIRECTORY"]) return jsonify(link.json()), 201 @@ -2737,6 +2760,7 @@ def bulk_assign_tag(): return Response(status=404, response='Tag not found.') created = 0 + new_video_ids = [] for video_id in data['video_ids']: existing = VideoTagLink.query.filter_by(video_id=video_id, tag_id=data['tag_id']).first() if not existing: @@ -2747,8 +2771,14 @@ def bulk_assign_tag(): ) db.session.add(link) created += 1 + new_video_ids.append(video_id) db.session.commit() + processed_dir = current_app.config["PROCESSED_DIRECTORY"] + for video_id in new_video_ids: + v = Video.query.filter_by(video_id=video_id).first() + if v: + _regenerate_boomerang_bg(v.video_id, v.extension, processed_dir) return jsonify({"created": created, "skipped": len(data['video_ids']) - created}), 200 diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py index db05c06a..993a66db 100755 --- a/app/server/fireshare/cli.py +++ b/app/server/fireshare/cli.py @@ -478,7 +478,12 @@ def scan_video(ctx, path, tag_ids, game_id, title): else: logger.debug(f"Skipping creation of poster for video {info.video_id} because it exists at {str(poster_path)}") db.session.commit() - + + if tag_ids: + boomerang_path = derived_path / "boomerang-preview.mp4" + if not boomerang_path.exists(): + util.create_boomerang_preview(video_path, boomerang_path) + if discord_webhook_url: logger.info(f"Posting to Discord webhook") video_url = get_public_watch_url(video_id, config, domain) @@ -635,14 +640,19 @@ def create_posters(regenerate, skip): def create_boomerang_posters(regenerate): with create_app().app_context(): processed_root = Path(current_app.config['PROCESSED_DIRECTORY']) - vinfos = VideoInfo.query.all() + vinfos = ( + VideoInfo.query + .join(VideoTagLink, VideoTagLink.video_id == VideoInfo.video_id) + .distinct() + .all() + ) for vi in vinfos: derived_path = Path(processed_root, "derived", vi.video_id) video_path = Path(processed_root, "video_links", vi.video_id + vi.video.extension) if not video_path.exists(): logger.info(f"Skipping creation of boomerang poster for video {vi.video_id} because the video at {str(video_path)} does not exist or is not accessible") continue - poster_path = Path(derived_path, "boomerang-preview.webm") + poster_path = Path(derived_path, "boomerang-preview.mp4") should_create_poster = (not poster_path.exists() or regenerate) if should_create_poster: if not derived_path.exists(): diff --git a/app/server/fireshare/util.py b/app/server/fireshare/util.py index 941eb922..61ef93af 100755 --- a/app/server/fireshare/util.py +++ b/app/server/fireshare/util.py @@ -987,17 +987,14 @@ def transcode_video_quality(video_path, out_path, height, use_gpu=False, timeout # This allows the calling code to continue processing other videos return (False, 'encoders') -def create_boomerang_preview(video_path, out_path, clip_duration=1.5): - # https://stackoverflow.com/questions/65874316/trim-a-video-and-add-the-boomerang-effect-on-it-with-ffmpeg - # https://ffmpeg.org/ffmpeg-filters.html#reverse - # https://ffmpeg.org/ffmpeg-filters.html#Examples-148 - # ffmpeg -ss 0 -t 1.5 -i in.mp4 -y -filter_complex "[0]split[a][b];[b]reverse[a_rev];[a][a_rev]concat[clip];[clip]scale=-1:720" -an out.mp4 +def create_boomerang_preview(video_path, out_path, clip_duration=5): s = time.time() - boomerang_filter_720p = '[0]split[a][b];[b]reverse[a_rev];[a][a_rev]concat[clip];[clip]scale=-1:720' - boomerang_filter_480p = '[0]split[a][b];[b]reverse[a_rev];[a][a_rev]concat[clip];[clip]scale=-1:480' - boomerang_filter = '[0]split[a][b];[b]reverse[a_rev];[a][a_rev]concat' - cmd = ['ffmpeg', '-v', 'quiet', '-ss', '0', '-t', str(clip_duration), - '-i', str(video_path), '-y', '-filter_complex', boomerang_filter_480p, '-an', str(out_path)] + duration = get_video_duration(video_path) or 0 + start = max(0, duration / 2 - clip_duration / 2) + cmd = ['ffmpeg', '-v', 'quiet', '-ss', str(start), '-t', str(clip_duration), + '-i', str(video_path), '-y', '-vf', 'scale=-2:480', + '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28', + '-an', '-movflags', '+faststart', str(out_path)] logger.info(f"Creating boomerang preview") logger.debug(f"$: {' '.join(cmd)}") sp.call(cmd) diff --git a/entrypoint.sh b/entrypoint.sh index 12fb27b3..8491d492 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -56,6 +56,15 @@ gosu appuser env PATH="$PATH" LD_LIBRARY_PATH="$LD_LIBRARY_PATH" flask db upgrad echo "Database migrations complete" +# Generate boomerang previews once on first boot +BOOMERANG_FLAG="$DATA_DIRECTORY/.boomerangs_generated" +if [ ! -f "$BOOMERANG_FLAG" ]; then + echo "First boot: generating boomerang previews..." + gosu appuser env PATH="$PATH" LD_LIBRARY_PATH="$LD_LIBRARY_PATH" python -m fireshare.cli create-boomerang-posters || true + touch "$BOOMERANG_FLAG" + echo "Boomerang generation complete" +fi + # Start gunicorn as appuser via gosu (drops from root to PUID:PGID) echo "Starting gunicorn as appuser ($PUID:$PGID)..." exec gosu appuser env PATH="$PATH" LD_LIBRARY_PATH="$LD_LIBRARY_PATH" \