diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx index 604ad6eae..883bb442b 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx @@ -44,10 +44,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot try { const status = await memoriesApi.status(p.id) if (status.connected) connected.push({ id: p.id, name: p.name }) - } catch {} + } catch { } } setAvailableProviders(connected) - } catch {} + } catch { } })() }, []) @@ -158,9 +158,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot onClick={() => onPhotoClick(allPhotos, i)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPhotoClick(allPhotos, i) } }} > - {photo.media_type === 'video' && !photo.thumbnail_path ? ( - // Poster-less video (capture failed / unsupported codec): show a - // neutral tile rather than a broken 404 thumbnail (#823). + {photo.media_type === 'video' && + photo.provider === 'local' && + !photo.thumbnail_path ? ( + // Poster-less local video: show a neutral tile.
) : ( {photo.caption + > + + + {isVideo && ( +
+ + + +
+ )} +
) } @@ -60,4 +74,4 @@ export function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick ) -} +} \ No newline at end of file diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index fc92b07f7..e4ee45705 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -1,5 +1,5 @@ import { - X, Pencil, Trash2, MapPin, Clock, Camera, + X, Pencil, Trash2, MapPin, Clock, Camera, Play, Laugh, Smile, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ThumbsUp, ThumbsDown, @@ -14,30 +14,37 @@ import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' // local because the desktop variant carries raw hex values and no dark-mode // classes, so the two cannot share one table without changing how this looks. const MOOD_CONFIG: Record = { - amazing: { icon: Laugh, label: 'journey.mood.amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, - good: { icon: Smile, label: 'journey.mood.good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, - neutral: { icon: Meh, label: 'journey.mood.neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, - rough: { icon: Frown, label: 'journey.mood.rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, + amazing: { icon: Laugh, label: 'journey.mood.amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' }, + good: { icon: Smile, label: 'journey.mood.good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' }, + neutral: { icon: Meh, label: 'journey.mood.neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' }, + rough: { icon: Frown, label: 'journey.mood.rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' }, } const WEATHER_CONFIG: Record = { - sunny: { icon: Sun, label: 'journey.weather.sunny' }, - partly: { icon: CloudSun, label: 'journey.weather.partly' }, - cloudy: { icon: Cloud, label: 'journey.weather.cloudy' }, - rainy: { icon: CloudRain, label: 'journey.weather.rainy' }, + sunny: { icon: Sun, label: 'journey.weather.sunny' }, + partly: { icon: CloudSun, label: 'journey.weather.partly' }, + cloudy: { icon: Cloud, label: 'journey.weather.cloudy' }, + rainy: { icon: CloudRain, label: 'journey.weather.rainy' }, stormy: { icon: CloudLightning, label: 'journey.weather.stormy' }, - cold: { icon: Snowflake, label: 'journey.weather.cold' }, + cold: { icon: Snowflake, label: 'journey.weather.cold' }, } -function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string { - if (builder) return builder(p.photo_id) +function photoUrl( + p: JourneyPhoto, + size: 'thumbnail' | 'original' = 'original', + builder?: (id: number, size?: 'thumbnail' | 'original') => string +): string { + if (builder) return builder(p.photo_id, size) return `/api/photos/${p.photo_id}/${size}` } interface Props { entry: JourneyEntry readOnly?: boolean - publicPhotoUrl?: (photoId: number) => string + publicPhotoUrl?: ( + photoId: number, + size?: 'thumbnail' | 'original' + ) => string onClose: () => void onEdit: () => void onDelete: () => void @@ -94,21 +101,33 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo + {photos.length > 1 && (
{t('mobileJourney.photosCount', { count: photos.length })}
)} + {/* Photo strip for multiple photos */} {photos.length > 1 && (
@@ -117,21 +136,32 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo key={p.id || i} type="button" aria-label={p.caption || t('journey.photos')} - className="flex-shrink-0 bg-transparent border-0 p-0 cursor-pointer" + className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-transparent' + }`} onClick={() => onPhotoClick(photos, i)} > + + {p.media_type === 'video' && ( +
+ +
+ )} ))}
)} )} - {/* Content */}
@@ -236,6 +266,6 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo )}
- + ) } diff --git a/client/src/components/Journey/VideoPlayer.tsx b/client/src/components/Journey/VideoPlayer.tsx index e66765b99..f5dc79e06 100644 --- a/client/src/components/Journey/VideoPlayer.tsx +++ b/client/src/components/Journey/VideoPlayer.tsx @@ -25,30 +25,67 @@ export default function VideoPlayer({ useEffect(() => { const el = videoRef.current if (!el) return + const player = new Plyr(el, { - controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'fullscreen'], + controls: [ + 'play-large', + 'play', + 'progress', + 'current-time', + 'duration', + 'mute', + 'volume', + 'fullscreen', + ], autoplay: autoPlay, - // Keep playback inline so the lightbox stays in control on mobile. clickToPlay: true, + hideControls: false, }) - return () => { try { player.destroy() } catch { /* already torn down */ } } + + return () => { + try { + player.destroy() + } catch { + /* already torn down */ + } + } }, [src, autoPlay]) return (
-
) -} +} \ No newline at end of file diff --git a/client/src/index.css b/client/src/index.css index 2111be73a..a0416cc5c 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -35,6 +35,28 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow .journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; } .journey-feed-scroll::-webkit-scrollbar { display: none; } +/* Journey video: let the player fill the box the lightbox gives it. + * + * Plyr sizes its wrapper from the video's intrinsic aspect ratio via a + * padding-bottom hack, which fights a fixed-height container — a portrait clip + * then overflowed and pushed its own controls out of reach (#2041). Pinning the + * wrapper to the box and letting the video letterbox inside it keeps any aspect + * ratio whole and the control bar on screen. */ +.trek-video-player .plyr, +.trek-video-player .plyr__video-wrapper { + width: 100%; + height: 100%; + background: #000; +} +.trek-video-player .plyr__video-wrapper { + padding-bottom: 0 !important; +} +.trek-video-player video { + width: 100% !important; + height: 100% !important; + object-fit: contain !important; +} + /* Leaflet Popups — Enter-Animation vom Anchor-Tip */ .leaflet-popup { animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1); diff --git a/client/src/pages/JourneyPublicPage.tsx b/client/src/pages/JourneyPublicPage.tsx index 370e27817..0f9a4aa2a 100644 --- a/client/src/pages/JourneyPublicPage.tsx +++ b/client/src/pages/JourneyPublicPage.tsx @@ -145,19 +145,19 @@ export default function JourneyPublicPage() { perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, !desktopTwoColumn && - !isMobile && - perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, + !isMobile && + perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]; // The hook cannot build these: the thumbnail URL needs the share token, which is // the credential for the byte proxy. const photoLayer = token ? mapPhotos.map((p: { id: string; lat: number; lng: number; photoId: number }) => ({ - id: p.id, - lat: p.lat, - lng: p.lng, - thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'), - })) + id: p.id, + lat: p.lat, + lng: p.lng, + thumbUrl: photoUrl({ photo_id: p.photoId }, token, 'thumbnail'), + })) : []; // Shared timeline renderer used in both layout modes @@ -219,7 +219,7 @@ export default function JourneyPublicPage() { id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, - mediaType: (p as any).media_type, + mediaType: p.media_type, })); const isActive = activeEntryId === String(entry.id); @@ -246,7 +246,22 @@ export default function JourneyPublicPage() { className="relative block w-full cursor-pointer text-left" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })} > - +
+ + + {photos[0].media_type === 'video' && ( +
+ + + +
+ )} +
)} - {photos.length === 2 && (
{photos.slice(0, 2).map((p, i) => ( ))}
@@ -302,7 +326,8 @@ export default function JourneyPublicPage() {
@@ -314,7 +339,8 @@ export default function JourneyPublicPage() {
- )} + ) + } {/* Content */} {/* Stays a div: the story renders user markdown, links included, @@ -471,10 +499,10 @@ export default function JourneyPublicPage() { ); })} - + ); })} - + ); // Shared gallery renderer @@ -491,7 +519,7 @@ export default function JourneyPublicPage() { id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, - mediaType: (p as any).media_type, + mediaType: p.media_type, })), index: idx, }) @@ -499,11 +527,14 @@ export default function JourneyPublicPage() { > - {(photo as any).media_type === 'video' && ( + {photo.media_type === 'video' && (
@@ -523,11 +554,10 @@ export default function JourneyPublicPage() {