From 4ffbeaff035ed25ef8a6c815e496bcb4aaba1f9e Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sun, 22 Mar 2026 01:06:16 -0700 Subject: [PATCH 01/12] Add animated banner in tags, new boomerang logic generated on tag --- app/client/src/views/TagVideos.js | 110 ++++++++++++++++++++++++++---- app/server/fireshare/api.py | 38 +++++++++-- app/server/fireshare/cli.py | 2 +- app/server/fireshare/util.py | 13 ++-- 4 files changed, 132 insertions(+), 31 deletions(-) diff --git a/app/client/src/views/TagVideos.js b/app/client/src/views/TagVideos.js index f57ec9b1..8badec63 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.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,89 @@ 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.1 : 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/server/fireshare/api.py b/app/server/fireshare/api.py index f5b96ba9..02360704 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -1151,7 +1151,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": @@ -1207,7 +1207,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": @@ -1339,11 +1339,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') @@ -2461,12 +2466,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() @@ -2499,6 +2517,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 @@ -2525,6 +2544,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: @@ -2535,8 +2555,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 f9bbf8e1..5360e769 100755 --- a/app/server/fireshare/cli.py +++ b/app/server/fireshare/cli.py @@ -641,7 +641,7 @@ def create_boomerang_posters(regenerate): 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 6cac6fde..6cb58ec1 100644 --- a/app/server/fireshare/util.py +++ b/app/server/fireshare/util.py @@ -882,17 +882,12 @@ 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)] + '-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) From 23024585c983ce693f228d5b3212b3e64591a3bc Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:25:41 -0700 Subject: [PATCH 02/12] Add text styling to tags --- app/client/public/index.html | 3 + app/client/src/views/Tags.js | 184 ++++++++++++++++++++++++++++------- app/server/fireshare/api.py | 6 +- 3 files changed, 154 insertions(+), 39 deletions(-) diff --git a/app/client/public/index.html b/app/client/public/index.html index c353a492..ba9c0d00 100644 --- a/app/client/public/index.html +++ b/app/client/public/index.html @@ -10,6 +10,9 @@ + + + diff --git a/app/client/src/views/Tags.js b/app/client/src/views/Tags.js index 4668225c..c1c848f0 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' @@ -31,6 +30,74 @@ import { SketchPicker } from 'react-color' import { TagService } from '../services' import LoadingSpinner from '../components/misc/LoadingSpinner' +const labelSx = { fontSize: 12, color: '#FFFFFFB3', mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' } + +const hexToHsl = (hex) => { + const r = parseInt(hex.slice(1, 3), 16) / 255 + const g = parseInt(hex.slice(3, 5), 16) / 255 + const b = parseInt(hex.slice(5, 7), 16) / 255 + const max = Math.max(r, g, b), min = Math.min(r, g, b) + let h = 0, s = 0, l = (max + min) / 2 + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break + case g: h = ((b - r) / d + 2) / 6; break + case b: h = ((r - g) / d + 4) / 6; break + default: break + } + } + return [h * 360, s * 100, l * 100] +} + +const hslToHex = (h, s, l) => { + h /= 360; s /= 100; l /= 100 + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1 + if (t > 1) t -= 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + let r, g, b + if (s === 0) { + r = g = b = l + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + r = hue2rgb(p, q, h + 1 / 3) + g = hue2rgb(p, q, h) + b = hue2rgb(p, q, h - 1 / 3) + } + return `#${Math.round(r * 255).toString(16).padStart(2, '0')}${Math.round(g * 255).toString(16).padStart(2, '0')}${Math.round(b * 255).toString(16).padStart(2, '0')}` +} + +const normalizeTagColor = (hex) => { + if (!hex || hex.length < 7) return hex + const [h, s, l] = hexToHsl(hex) + return hslToHex(h, Math.min(s, 65), Math.max(30, Math.min(55, l))) +} + +const darkenColor = (hex, factor = 0.3) => { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `#${Math.round(r * factor).toString(16).padStart(2, '0')}${Math.round(g * factor).toString(16).padStart(2, '0')}${Math.round(b * factor).toString(16).padStart(2, '0')}` +} + +const inputSx = { + '& .MuiOutlinedInput-root': { + color: 'white', + bgcolor: '#FFFFFF0D', + borderRadius: '8px', + '& fieldset': { borderColor: '#FFFFFF26' }, + '&:hover fieldset': { borderColor: '#FFFFFF55' }, + '&.Mui-focused fieldset': { borderColor: '#3399FF' }, + }, +} + const Tags = ({ authenticated, searchText }) => { const [tags, setTags] = React.useState([]) const [loading, setLoading] = React.useState(true) @@ -149,8 +216,8 @@ const Tags = ({ authenticated, searchText }) => { {authenticated && ( {editMode && ( - - @@ -168,7 +236,7 @@ const Tags = ({ authenticated, searchText }) => { variant="contained" startIcon={} onClick={() => setNewTagDialogOpen(true)} - sx={{ height: 38 }} + sx={{ height: 38, borderRadius: '8px', bgcolor: '#3399FF', '&:hover': { bgcolor: '#1976D2' } }} > New Tag @@ -206,10 +274,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 || '#2684FF' + const color = normalizeTagColor(tag.color || '#2684FF') return ( - + { onClick={() => handleTagClick(tag.id)} sx={{ position: 'relative', - height: 100, + height: 140, borderRadius: 2, overflow: 'hidden', cursor: 'pointer', - border: isSelected ? '3px solid' : '2px solid transparent', - borderColor: isSelected ? 'primary.secondary' : color, - bgcolor: `${color}90`, + border: isSelected ? '3px solid' : '2px solid', + borderColor: darkenColor(color), + ...(!tag.preview_video_id && { bgcolor: `${color}44` }), display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - gap: 1, + gap: 0.25, py: 2, transition: 'transform 0.2s ease, box-shadow 0.2s ease', '&:hover': { transform: 'scale(1.03)', - boxShadow: `0 4px 20px ${color}44`, + boxShadow: `inset 0 0 0 1px ${darkenColor(color)}, 0 8px 32px #00000088, 0 4px 20px ${color}44`, }, - boxShadow: `inset 0 0 0 1px ${color}55`, + boxShadow: `inset 0 0 0 1px ${darkenColor(color)}, 0 4px 16px #00000066`, }} > + {tag.preview_video_id && ( + <> + + + + + )} {editMode && ( { left: 8, zIndex: 2, color: 'white', - bgcolor: 'rgba(0, 0, 0, 0.5)', + bgcolor: '#00000080', borderRadius: '4px', '&.Mui-checked': { color: 'primary.main' }, }} /> )} - - {tag.name.replace(/_/g, ' ')} - - + + + {tag.name.replace(/_/g, ' ')} + + + {tag.video_count ?? 0} video{tag.video_count !== 1 ? 's' : ''} + + @@ -277,11 +377,11 @@ const Tags = ({ authenticated, searchText }) => { {/* Delete Confirmation Dialog */} setDeleteDialogOpen(false)}> - + 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 - @@ -318,19 +419,22 @@ const Tags = ({ authenticated, searchText }) => { setColorPickerAnchorEl(null) }} > - 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={{ @@ -371,10 +475,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 02360704..ea4bdd25 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -2346,12 +2346,14 @@ 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() ) + 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) From c29be6fd4a509b7402e18b0e286005df1b053077 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:06:42 -0700 Subject: [PATCH 03/12] Adjusted Font --- app/client/src/views/TagVideos.js | 1 + app/client/src/views/Tags.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/client/src/views/TagVideos.js b/app/client/src/views/TagVideos.js index 8badec63..b190f85c 100644 --- a/app/client/src/views/TagVideos.js +++ b/app/client/src/views/TagVideos.js @@ -176,6 +176,7 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { color: 'white', letterSpacing: '-0.5px', lineHeight: 1, + fontFamily: '"Montserrat",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', }} > {(tag?.name || 'Tag').replace(/_/g, ' ')} diff --git a/app/client/src/views/Tags.js b/app/client/src/views/Tags.js index c1c848f0..1527cfd6 100644 --- a/app/client/src/views/Tags.js +++ b/app/client/src/views/Tags.js @@ -360,7 +360,7 @@ const Tags = ({ authenticated, searchText }) => { /> )} - + {tag.name.replace(/_/g, ' ')} From 631331c125fecc03f3d3633dc71607d382958d36 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:46:23 -0700 Subject: [PATCH 04/12] Adjusted boomerang times to middle of the clip --- app/client/src/views/Tags.js | 19 ++++++++++++------- app/server/fireshare/util.py | 4 +++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/client/src/views/Tags.js b/app/client/src/views/Tags.js index 1527cfd6..25ece75e 100644 --- a/app/client/src/views/Tags.js +++ b/app/client/src/views/Tags.js @@ -77,7 +77,7 @@ const hslToHex = (h, s, l) => { const normalizeTagColor = (hex) => { if (!hex || hex.length < 7) return hex const [h, s, l] = hexToHsl(hex) - return hslToHex(h, Math.min(s, 65), Math.max(30, Math.min(55, l))) + return hslToHex(h, Math.min(s, 65), Math.max(15, Math.min(55, l))) } const darkenColor = (hex, factor = 0.3) => { @@ -87,6 +87,11 @@ const darkenColor = (hex, factor = 0.3) => { return `#${Math.round(r * factor).toString(16).padStart(2, '0')}${Math.round(g * factor).toString(16).padStart(2, '0')}${Math.round(b * factor).toString(16).padStart(2, '0')}` } +const cardBorderColor = (hex) => { + const [h, s, l] = hexToHsl(hex) + return l < 25 ? hslToHex(h, s, l + 15) : darkenColor(hex) +} + const inputSx = { '& .MuiOutlinedInput-root': { color: 'white', @@ -107,7 +112,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('#2684FF') + const [newTagColor, setNewTagColor] = React.useState('#051529') const [colorPickerAnchorEl, setColorPickerAnchorEl] = React.useState(null) const [toolbarTarget, setToolbarTarget] = React.useState(null) const navigate = useNavigate() @@ -274,7 +279,7 @@ 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 = normalizeTagColor(tag.color || '#2684FF') + const color = normalizeTagColor(tag.color || '#051529') return ( @@ -292,7 +297,7 @@ const Tags = ({ authenticated, searchText }) => { overflow: 'hidden', cursor: 'pointer', border: isSelected ? '3px solid' : '2px solid', - borderColor: darkenColor(color), + borderColor: cardBorderColor(color), ...(!tag.preview_video_id && { bgcolor: `${color}44` }), display: 'flex', flexDirection: 'column', @@ -303,9 +308,9 @@ const Tags = ({ authenticated, searchText }) => { transition: 'transform 0.2s ease, box-shadow 0.2s ease', '&:hover': { transform: 'scale(1.03)', - boxShadow: `inset 0 0 0 1px ${darkenColor(color)}, 0 8px 32px #00000088, 0 4px 20px ${color}44`, + boxShadow: `inset 0 0 0 1px ${cardBorderColor(color)}, 0 8px 32px #00000088, 0 4px 20px ${color}44`, }, - boxShadow: `inset 0 0 0 1px ${darkenColor(color)}, 0 4px 16px #00000066`, + boxShadow: `inset 0 0 0 1px ${cardBorderColor(color)}, 0 4px 16px #00000066`, }} > {tag.preview_video_id && ( @@ -360,7 +365,7 @@ const Tags = ({ authenticated, searchText }) => { /> )} - + {tag.name.replace(/_/g, ' ')} diff --git a/app/server/fireshare/util.py b/app/server/fireshare/util.py index 6cb58ec1..6e34622f 100644 --- a/app/server/fireshare/util.py +++ b/app/server/fireshare/util.py @@ -884,7 +884,9 @@ def transcode_video_quality(video_path, out_path, height, use_gpu=False, timeout def create_boomerang_preview(video_path, out_path, clip_duration=5): s = time.time() - cmd = ['ffmpeg', '-v', 'quiet', '-ss', '0', '-t', str(clip_duration), + 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)] From d7d7994d96cc238503cb97ce7121bf13696e1e41 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:55:12 -0700 Subject: [PATCH 05/12] Created Modal Style sheet to maintain cohesiveness across UI --- app/client/src/common/modalStyles.js | 54 ++++++++++++ .../components/modal/UpdateDetailsModal.js | 43 +-------- app/client/src/views/Dashboard.js | 11 +-- app/client/src/views/Tags.js | 88 ++++--------------- 4 files changed, 81 insertions(+), 115 deletions(-) create mode 100644 app/client/src/common/modalStyles.js diff --git a/app/client/src/common/modalStyles.js b/app/client/src/common/modalStyles.js new file mode 100644 index 00000000..06134657 --- /dev/null +++ b/app/client/src/common/modalStyles.js @@ -0,0 +1,54 @@ +// 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 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/modal/UpdateDetailsModal.js b/app/client/src/components/modal/UpdateDetailsModal.js index 09f8ee71..5beb1633 100644 --- a/app/client/src/components/modal/UpdateDetailsModal.js +++ b/app/client/src/components/modal/UpdateDetailsModal.js @@ -19,32 +19,7 @@ import { DayPicker } from 'react-day-picker' import { VideoService, GameService, TagService } from '../../services' import GameSearch from '../game/GameSearch' import './datepicker-dark.css' - -// ─── Shared style constants ─────────────────────────────────────────────────── - -const labelSx = { fontSize: 12, color: '#FFFFFFB3', mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' } - -const inputSx = { - '& .MuiOutlinedInput-root': { - color: 'white', - bgcolor: '#FFFFFF0D', - borderRadius: '8px', - '& fieldset': { borderColor: '#FFFFFF26' }, - '&:hover fieldset': { borderColor: '#FFFFFF55' }, - '&.Mui-focused fieldset': { borderColor: '#3399FF' }, - }, -} - -const rowBoxSx = { - display: 'flex', - alignItems: 'center', - gap: 1.5, - bgcolor: '#FFFFFF0D', - border: '1px solid #FFFFFF26', - borderRadius: '8px', - px: 1.5, - py: 1, -} +import { labelSx, inputSx, rowBoxSx, dialogPaperSx, timeInputStyle } from '../../common/modalStyles' const modalSx = { position: 'absolute', @@ -52,22 +27,8 @@ const modalSx = { left: '50%', transform: 'translate(-50%, -50%)', width: 500, - bgcolor: '#041223', - border: '1px solid #FFFFFF1A', - borderRadius: '12px', - boxShadow: '0 16px 48px #00000099', p: 4, -} - -const timeInputStyle = { - background: '#FFFFFF0D', - border: '1px solid #FFFFFF26', - borderRadius: 6, - color: 'white', - fontSize: 13, - padding: '4px 8px', - colorScheme: 'dark', - flex: 1, + ...dialogPaperSx, } // ─── Sub-components ─────────────────────────────────────────────────────────── diff --git a/app/client/src/views/Dashboard.js b/app/client/src/views/Dashboard.js index 12b065e4..f5654ffe 100644 --- a/app/client/src/views/Dashboard.js +++ b/app/client/src/views/Dashboard.js @@ -31,6 +31,7 @@ import TagChip from '../components/misc/TagChip' import selectSortTheme from '../common/reactSelectSortTheme' import { SORT_OPTIONS } from '../common/constants' +import { inputSx, dialogPaperSx, dialogTitleSx } from '../common/modalStyles' const Dashboard = ({ authenticated, @@ -632,8 +633,8 @@ const Dashboard = ({ {/* Tag Selected Dialog */} - - + + Tag {selectedVideos.size} Clip{selectedVideos.size !== 1 ? 's' : ''} @@ -641,7 +642,7 @@ const Dashboard = ({ multiple freeSolo componentsProps={{ root: { sx: { '& .MuiAutocomplete-tag': { my: 0.25 } } } }} - sx={{ '& .MuiOutlinedInput-root': { gap: 0.5 } }} + sx={{ ...inputSx, '& .MuiOutlinedInput-root': { ...inputSx['& .MuiOutlinedInput-root'], gap: 0.5 } }} options={allTags.filter((t) => !selectedTagsForBulk.find((s) => s.id === t.id))} getOptionLabel={(option) => (typeof option === 'string' ? option : option.name)} value={selectedTagsForBulk} @@ -695,8 +696,8 @@ const Dashboard = ({ /> - - + diff --git a/app/client/src/views/Tags.js b/app/client/src/views/Tags.js index 25ece75e..21b0cc9a 100644 --- a/app/client/src/views/Tags.js +++ b/app/client/src/views/Tags.js @@ -29,78 +29,27 @@ import { useNavigate } from 'react-router-dom' import { SketchPicker } from 'react-color' import { TagService } from '../services' import LoadingSpinner from '../components/misc/LoadingSpinner' - -const labelSx = { fontSize: 12, color: '#FFFFFFB3', mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' } - -const hexToHsl = (hex) => { - const r = parseInt(hex.slice(1, 3), 16) / 255 - const g = parseInt(hex.slice(3, 5), 16) / 255 - const b = parseInt(hex.slice(5, 7), 16) / 255 - const max = Math.max(r, g, b), min = Math.min(r, g, b) - let h = 0, s = 0, l = (max + min) / 2 - if (max !== min) { - const d = max - min - s = l > 0.5 ? d / (2 - max - min) : d / (max + min) - switch (max) { - case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break - case g: h = ((b - r) / d + 2) / 6; break - case b: h = ((r - g) / d + 4) / 6; break - default: break - } - } - return [h * 360, s * 100, l * 100] -} - -const hslToHex = (h, s, l) => { - h /= 360; s /= 100; l /= 100 - const hue2rgb = (p, q, t) => { - if (t < 0) t += 1 - if (t > 1) t -= 1 - if (t < 1 / 6) return p + (q - p) * 6 * t - if (t < 1 / 2) return q - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 - return p - } - let r, g, b - if (s === 0) { - r = g = b = l - } else { - const q = l < 0.5 ? l * (1 + s) : l + s - l * s - const p = 2 * l - q - r = hue2rgb(p, q, h + 1 / 3) - g = hue2rgb(p, q, h) - b = hue2rgb(p, q, h - 1 / 3) - } - return `#${Math.round(r * 255).toString(16).padStart(2, '0')}${Math.round(g * 255).toString(16).padStart(2, '0')}${Math.round(b * 255).toString(16).padStart(2, '0')}` -} +import { labelSx, inputSx, dialogPaperSx, dialogTitleSx } from '../common/modalStyles' const normalizeTagColor = (hex) => { if (!hex || hex.length < 7) return hex - const [h, s, l] = hexToHsl(hex) - return hslToHex(h, Math.min(s, 65), Math.max(15, Math.min(55, l))) -} - -const darkenColor = (hex, factor = 0.3) => { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return `#${Math.round(r * factor).toString(16).padStart(2, '0')}${Math.round(g * factor).toString(16).padStart(2, '0')}${Math.round(b * factor).toString(16).padStart(2, '0')}` + 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 [h, s, l] = hexToHsl(hex) - return l < 25 ? hslToHex(h, s, l + 15) : darkenColor(hex) -} - -const inputSx = { - '& .MuiOutlinedInput-root': { - color: 'white', - bgcolor: '#FFFFFF0D', - borderRadius: '8px', - '& fieldset': { borderColor: '#FFFFFF26' }, - '&:hover fieldset': { borderColor: '#FFFFFF55' }, - '&.Mui-focused fieldset': { borderColor: '#3399FF' }, - }, + 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 }) => { @@ -381,8 +330,8 @@ const Tags = ({ authenticated, searchText }) => { )} {/* Delete Confirmation Dialog */} - setDeleteDialogOpen(false)}> - + setDeleteDialogOpen(false)} PaperProps={{ sx: dialogPaperSx }}> + Delete {selectedTags.size} Tag{selectedTags.size > 1 ? 's' : ''}? @@ -423,8 +372,9 @@ const Tags = ({ authenticated, searchText }) => { setNewTagDialogOpen(false) setColorPickerAnchorEl(null) }} + PaperProps={{ sx: dialogPaperSx }} > - Create New Tag + Create New Tag Tag Name From aaf36c8ee30337375fbde3d6dc0b86e45505abac Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:17:12 -0700 Subject: [PATCH 06/12] define DEFAULT_TAG_COLOR --- app/client/src/views/Tags.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/client/src/views/Tags.js b/app/client/src/views/Tags.js index f0b0d6a2..f55c84d7 100644 --- a/app/client/src/views/Tags.js +++ b/app/client/src/views/Tags.js @@ -33,6 +33,8 @@ import { TagService } from '../services' import LoadingSpinner from '../components/misc/LoadingSpinner' import { labelSx, inputSx, dialogPaperSx, dialogTitleSx } from '../common/modalStyles' +const DEFAULT_TAG_COLOR = '#051529' + 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 From 915f784f59f035c00eb4df58f1f7e21152e8ab55 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:44:14 -0700 Subject: [PATCH 07/12] Added Checkmark to Upload Card to autosort uploads --- app/client/src/common/modalStyles.js | 11 +++ app/client/src/components/cards/UploadCard.js | 85 ++++++++++++------- app/client/src/views/Games.js | 26 +++--- 3 files changed, 79 insertions(+), 43 deletions(-) diff --git a/app/client/src/common/modalStyles.js b/app/client/src/common/modalStyles.js index 06134657..d2b3bd8c 100644 --- a/app/client/src/common/modalStyles.js +++ b/app/client/src/common/modalStyles.js @@ -42,6 +42,17 @@ export const dialogTitleSx = { 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', diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index ae893b76..4f32d29c 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -15,6 +15,8 @@ import { Chip, CircularProgress, InputAdornment, + Checkbox, + FormControlLabel, } from '@mui/material' import CloudUploadIcon from '@mui/icons-material/CloudUpload' import styled from '@emotion/styled' @@ -22,6 +24,7 @@ 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')({ @@ -97,6 +100,7 @@ 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 [availableFolders, setAvailableFolders] = React.useState([]) const [selectedFolder, setSelectedFolder] = React.useState('') // Stored metadata to attach on next upload @@ -117,6 +121,7 @@ const UploadCard = React.forwardRef(function UploadCard( setTagInput('') setGameInput('') setGameOptions([]) + setUploadToGameFolder(false) Promise.all([GameService.getGames(), TagService.getTags(), VideoService.getUploadFolders()]) .then(([gRes, tRes, fRes]) => { const games = gRes.data || [] @@ -167,7 +172,7 @@ 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, } setDialogOpen(false) setSelectedFile(pendingFile) @@ -183,6 +188,7 @@ const UploadCard = React.forwardRef(function UploadCard( setTagInput('') setGameOptions([]) setGameInput('') + setUploadToGameFolder(false) } const handleGameInputChange = async (_, value) => { @@ -209,6 +215,7 @@ const UploadCard = React.forwardRef(function UploadCard( const handleGameChange = async (_, newValue) => { if (!newValue) { setSelectedGame(null) + setUploadToGameFolder(false) return } if (newValue._source === 'db') { @@ -407,20 +414,13 @@ const UploadCard = React.forwardRef(function UploadCard( onClose={handleDialogCancel} maxWidth="sm" fullWidth - PaperProps={{ - sx: { - bgcolor: '#0b132b', - border: '1px solid #FFFFFF14', - borderRadius: '16px', - backgroundImage: 'none', - }, - }} + PaperProps={{ sx: dialogPaperSx }} > - Upload Video + Upload Video {/* Game selector */} + + Game o.name || ''} @@ -454,9 +456,9 @@ const UploadCard = React.forwardRef(function UploadCard( renderInput={(params) => ( )} /> + {selectedGame && ( + setUploadToGameFolder(e.target.checked)} size="small" sx={checkboxSx} />} + label={Auto-sort into game folder} + sx={{ mt: 0.5, ml: 0 }} + /> + )} + {availableFolders.length > 0 && ( + + Upload Folder setSelectedFolder(value || '')} - disableClearable={!!selectedFolder} - renderInput={(params) => } + disableClearable={uploadToGameFolder ? true : !!selectedFolder} + disabled={uploadToGameFolder && !!selectedGame} + renderInput={(params) => } /> + )} + + Tags ( { if (e.key === ',') { @@ -561,6 +577,7 @@ const UploadCard = React.forwardRef(function UploadCard( /> )} /> + - + From 32673cf62eef74a3746992888eb944b0bfed9f8c Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:04:43 -0700 Subject: [PATCH 08/12] Added Thumbnail view to upload card --- app/client/src/components/cards/UploadCard.js | 697 ++++++++++-------- 1 file changed, 406 insertions(+), 291 deletions(-) diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index 4f32d29c..d0b8e9c8 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -43,6 +43,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', @@ -101,6 +111,8 @@ const UploadCard = React.forwardRef(function UploadCard( const [gameCreating, setGameCreating] = React.useState(false) const [gameInput, setGameInput] = React.useState('') const [uploadToGameFolder, setUploadToGameFolder] = React.useState(false) + 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 @@ -114,8 +126,39 @@ 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('') @@ -178,6 +221,8 @@ const UploadCard = React.forwardRef(function UploadCard( setSelectedFile(pendingFile) setIsSelected(true) setPendingFile(null) + setThumbnail(null) + setThumbnailReady(false) } const handleDialogCancel = () => { @@ -189,6 +234,8 @@ const UploadCard = React.forwardRef(function UploadCard( setGameOptions([]) setGameInput('') setUploadToGameFolder(false) + setThumbnail(null) + setThumbnailReady(false) } const handleGameInputChange = async (_, value) => { @@ -412,19 +459,57 @@ const UploadCard = React.forwardRef(function UploadCard( - - Upload Video + Upload Video + + + + + {/* Thumbnail — left column */} + + + {thumbnailReady && thumbnail && ( + + )} + {!thumbnailReady && ( + + )} + {thumbnailReady && !thumbnail && ( + + + + )} + - - - - {/* 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) => ( - - {(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()})`} - )} - /> - {selectedGame && ( - setUploadToGameFolder(e.target.checked)} size="small" sx={checkboxSx} />} - label={Auto-sort into game folder} - sx={{ mt: 0.5, ml: 0 }} - /> - )} - - {availableFolders.length > 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('') - }} - 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('') + } + } + }} + /> + )} + /> + + @@ -709,19 +790,57 @@ const UploadCard = React.forwardRef(function UploadCard( - - - Upload Video + + Upload Video + + + + + {/* Thumbnail — left column */} + + + {thumbnailReady && thumbnail && ( + + )} + {!thumbnailReady && ( + + )} + {thumbnailReady && !thumbnail && ( + + + + )} + - - - - {/* 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) => ( - - {(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()})`} - )} - /> - {selectedGame && ( - setUploadToGameFolder(e.target.checked)} size="small" sx={checkboxSx} />} - label={Auto-sort into game folder} - sx={{ mt: 0.5, ml: 0 }} - /> - )} - - {/* Folder selector */} - {availableFolders.length > 0 && ( - - Upload Folder - setSelectedFolder(value || '')} - disableClearable={uploadToGameFolder ? true : !!selectedFolder} - disabled={uploadToGameFolder && !!selectedGame} - renderInput={(params) => } - /> - - )} - - {/* Tag selector */} - - 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) => { - const resolved = values.map((v) => (typeof v === 'string' ? { name: v } : v)) - setSelectedTags(resolved) - setTagInput('') - }} - renderTags={(value, getTagProps) => - value.map((option, index) => ( - 0 && ( + + Upload Folder + setSelectedFolder(value || '')} + disableClearable={uploadToGameFolder ? true : !!selectedFolder} + disabled={uploadToGameFolder && !!selectedGame} + renderInput={(params) => } + /> + + )} + + {/* Tag selector */} + + 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) => { + const resolved = values.map((v) => (typeof v === 'string' ? { name: v } : v)) + setSelectedTags(resolved) + 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('') + } + } + }} + /> + )} + /> + + From fbce1cd557ec8c26d686e0c022eddb871dcd35b7 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:33:41 -0700 Subject: [PATCH 09/12] Added Title Edit to Upload Card --- app/client/src/components/cards/UploadCard.js | 91 ++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index d0b8e9c8..c664490b 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -21,7 +21,6 @@ import { 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' @@ -87,6 +86,7 @@ function LogoProgress({ progress, size = 44 }) { ) } + const UploadCard = React.forwardRef(function UploadCard( { authenticated, handleAlert, mini, onUploadComplete, onProgress, dropOnly = false }, ref, @@ -111,6 +111,9 @@ const UploadCard = React.forwardRef(function UploadCard( 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([]) @@ -165,6 +168,9 @@ const UploadCard = React.forwardRef(function UploadCard( setGameInput('') setGameOptions([]) setUploadToGameFolder(false) + setTitleInput('') + setEditingTitle(false) + setTitleDraft('') Promise.all([GameService.getGames(), TagService.getTags(), VideoService.getUploadFolders()]) .then(([gRes, tRes, fRes]) => { const games = gRes.data || [] @@ -216,6 +222,7 @@ const UploadCard = React.forwardRef(function UploadCard( tag_ids: resolvedTags.length ? resolvedTags.map((t) => t.id).join(',') : null, game_id: selectedGame ? selectedGame.id : null, folder: (uploadToGameFolder && selectedGame ? selectedGame.name : selectedFolder) || null, + title: titleInput.trim() || null, } setDialogOpen(false) setSelectedFile(pendingFile) @@ -234,6 +241,8 @@ const UploadCard = React.forwardRef(function UploadCard( setGameOptions([]) setGameInput('') setUploadToGameFolder(false) + setTitleInput('') + setEditingTitle(false) setThumbnail(null) setThumbnailReady(false) } @@ -343,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() @@ -351,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) @@ -417,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) @@ -451,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 @@ -469,7 +550,8 @@ const UploadCard = React.forwardRef(function UploadCard( Upload Video - + + {inlineTitleEl} {/* Thumbnail — left column */} @@ -800,7 +882,8 @@ const UploadCard = React.forwardRef(function UploadCard( Upload Video - + + {inlineTitleEl} {/* Thumbnail — left column */} From 94f1b652e1edfcd2be74716736feff4178486378 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:26:49 -0700 Subject: [PATCH 10/12] added preview generation flag on first boot, adjust video count in tags based on auth --- app/server/fireshare/api.py | 3 +++ app/server/fireshare/cli.py | 7 ++++++- entrypoint.sh | 9 +++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index 50747e38..a85d5e52 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -2561,8 +2561,11 @@ def get_tags(): links = ( db.session.query(VideoTagLink) .join(Video, Video.video_id == VideoTagLink.video_id) + .join(VideoInfo, VideoInfo.video_id == VideoTagLink.video_id) .filter(VideoTagLink.tag_id == tag.id, Video.available.is_(True)) ) + if not current_user.is_authenticated: + links = links.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 diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py index f71e9e82..9b867314 100755 --- a/app/server/fireshare/cli.py +++ b/app/server/fireshare/cli.py @@ -635,7 +635,12 @@ 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) 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" \ From 9eaf8db094ffeb7fcf8dbc759af0c8fbf3b11f52 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:37:13 -0700 Subject: [PATCH 11/12] TagVideos.js now filter vides shorter than 5 sec, adjusted banner opacity --- app/client/src/views/TagVideos.js | 5 +- app/client/src/views/Tags.js | 88 +++++++++++++++---------------- app/server/fireshare/api.py | 3 +- app/server/fireshare/cli.py | 7 ++- 4 files changed, 52 insertions(+), 51 deletions(-) diff --git a/app/client/src/views/TagVideos.js b/app/client/src/views/TagVideos.js index b190f85c..0fafd8fb 100644 --- a/app/client/src/views/TagVideos.js +++ b/app/client/src/views/TagVideos.js @@ -46,7 +46,7 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { setToolbarTarget(document.getElementById('navbar-toolbar-extra')) }, []) - const clips = React.useMemo(() => videos.slice(0, 6).map((v) => `/api/video/poster?id=${v.video_id}&animated=true`), [videos]) + 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 @@ -138,7 +138,7 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { sx={{ position: 'absolute', inset: 0, - background: 'linear-gradient(to top, #00000088 0%, transparent 50%)', + background: 'linear-gradient(to top, #00000066 0%, transparent 60%)', pointerEvents: 'none', }} /> @@ -150,6 +150,7 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { inset: 0, bgcolor: color, mixBlendMode: 'multiply', + opacity: 0.8, pointerEvents: 'none', }} /> diff --git a/app/client/src/views/Tags.js b/app/client/src/views/Tags.js index f55c84d7..381a97fa 100644 --- a/app/client/src/views/Tags.js +++ b/app/client/src/views/Tags.js @@ -33,7 +33,7 @@ import { TagService } from '../services' import LoadingSpinner from '../components/misc/LoadingSpinner' import { labelSx, inputSx, dialogPaperSx, dialogTitleSx } from '../common/modalStyles' -const DEFAULT_TAG_COLOR = '#051529' +const DEFAULT_TAG_COLOR = '#1a3a5c' const normalizeTagColor = (hex) => { if (!hex || hex.length < 7) return hex @@ -65,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('#051529') + 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) @@ -189,53 +189,49 @@ const Tags = ({ authenticated, searchText }) => { return ( - {toolbarTarget && + {toolbarTarget && authenticated && ReactDOM.createPortal( - {authenticated && ( - - {editMode && ( - - - - - )} - {!editMode && ( - - )} - + + + )} + {!editMode && ( + + )} + + {editMode ? : } + , toolbarTarget, )} @@ -275,7 +271,7 @@ 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 = normalizeTagColor(tag.color || '#051529') + const color = normalizeTagColor(tag.color || '#1a3a5c') return ( diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index a85d5e52..0f3a385d 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -2561,11 +2561,10 @@ def get_tags(): links = ( db.session.query(VideoTagLink) .join(Video, Video.video_id == VideoTagLink.video_id) - .join(VideoInfo, VideoInfo.video_id == VideoTagLink.video_id) .filter(VideoTagLink.tag_id == tag.id, Video.available.is_(True)) ) if not current_user.is_authenticated: - links = links.filter(VideoInfo.private.is_(False)) + 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 diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py index 9b867314..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) From 7d8c100b72361455de44b66545c7261dd763362c Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:43:56 -0700 Subject: [PATCH 12/12] final adjustments to color in banner --- app/client/src/views/TagVideos.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/client/src/views/TagVideos.js b/app/client/src/views/TagVideos.js index 0fafd8fb..ff64afe2 100644 --- a/app/client/src/views/TagVideos.js +++ b/app/client/src/views/TagVideos.js @@ -127,7 +127,7 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { width: '100%', height: '100%', objectFit: 'cover', - opacity: i === currentClipIndex ? 0.1 : 0, + opacity: i === currentClipIndex ? 0.45 : 0, transition: 'opacity 1.5s ease', pointerEvents: 'none', }} @@ -150,7 +150,7 @@ const TagVideos = ({ cardSize, authenticated, searchText }) => { inset: 0, bgcolor: color, mixBlendMode: 'multiply', - opacity: 0.8, + opacity: 1, pointerEvents: 'none', }} />