adds video support

This commit is contained in:
Christian Beutel
2025-03-16 18:01:12 +01:00
parent 6cfa21c025
commit 8706fe7ac9
23 changed files with 937 additions and 97 deletions

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { isVideoURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n";
interface Props {
@@ -38,6 +39,10 @@
class="group relative h-32 w-32 rounded-xl bg-cover bg-no-repeat"
style="background-image: url({src});"
>
{#if isVideoURL(src)}
<!-- svelte-ignore a11y_media_has_caption -->
<video autoplay loop class="h-32 w-32 rounded-xl object-cover" {src}></video>
{/if}
{#if isThumbnail && showThumbnailControls}
<i
class="fa fa-file-image absolute top-2 right-2 text-primary bg-white rounded-full px-[10px] py-2 shadow-lg"

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { isVideoURL } from "$lib/util/file_util";
import PhotoSwipeVideoPlugin from "$lib/vendor/photo-swipe-video-plugin";
import type { DataSource } from "photoswipe";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import { onMount } from "svelte";
@@ -17,28 +19,53 @@
let lightboxDataSource: DataSource;
onMount(() => {
lightboxDataSource = photos.map((p) => ({
src: p,
}));
lightboxDataSource = photos.map((p) => {
if (isVideoURL(p)) {
return {
type: "video",
videoSrc: p,
};
}
return {
src: p,
};
});
lightbox = new PhotoSwipeLightbox({
dataSource: lightboxDataSource,
pswpModule: async () => await import("photoswipe"),
});
const videoPlugin = new PhotoSwipeVideoPlugin(lightbox);
lightbox.init();
lightbox.on("beforeOpen", () => {
const pswp = lightbox.pswp;
const ds = pswp?.options?.dataSource;
if (Array.isArray(ds)) {
for (let idx = 0, len = ds.length; idx < len; idx++) {
const item = ds[idx];
const img = new Image();
img.onload = () => {
item.width = img.naturalWidth;
item.height = img.naturalHeight;
pswp?.refreshSlideContent(idx);
};
img.src = item.src as string;
const item = ds[idx];
if (item.type === "video") {
const v = document.createElement("video");
v.addEventListener(
"loadedmetadata",
function () {
item.width = this.videoWidth;
item.height = this.videoHeight;
pswp?.refreshSlideContent(idx);
},
false,
);
v.src = item.videoSrc as string
} else {
const img = new Image();
img.onload = () => {
item.width = img.naturalWidth;
item.height = img.naturalHeight;
pswp?.refreshSlideContent(idx);
};
img.src = item.src as string;
}
}
}
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Activity } from "$lib/models/activity";
import type { UserAnonymous } from "$lib/models/user";
import { getFileURL } from "$lib/util/file_util";
import { getFileURL, isVideoURL } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
@@ -77,15 +77,34 @@
: 'grid-cols-1'}"
>
{#each activity.photos as photo, i}
<img
class="object-cover h-full max-h-80 w-full"
class:row-span-2={i == 0 && activity.photos.length > 2}
src={getFileURL(
{ collectionId: activity.type + "s", id: activity.id },
photo,
)}
alt=""
/>
{#if isVideoURL(photo)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="object-cover h-full max-h-80 w-full"
autoplay
loop
src={getFileURL(
{
collectionId: activity.type + "s",
id: activity.id,
},
photo,
)}
></video>
{:else}
<img
class="object-cover h-full max-h-80 w-full"
class:row-span-2={i == 0 && activity.photos.length > 2}
src={getFileURL(
{
collectionId: activity.type + "s",
id: activity.id,
},
photo,
)}
alt=""
/>
{/if}
{/each}
</div>
{/if}

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import type { SummitLog } from "$lib/models/summit_log";
@@ -7,7 +6,7 @@
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import { theme } from "$lib/stores/theme_store";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import { getFileURL, isVideoURL, readAsDataURLAsync } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
@@ -55,12 +54,24 @@
<div class="p-4 my-2 border border-input-border rounded-xl">
<div class="flex items-center gap-x-4">
<div class="h-24 aspect-square shrink-0 rounded-xl overflow-hidden">
<img
id="header-img"
class="object-cover h-full"
src={thumbnail}
alt=""
/>
{#if isVideoURL(thumbnail)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
controls={false}
loop
class="object-cover h-full w-full"
onmouseenter={(e) => (e.target as any).play()}
onmouseleave={(e) => (e.target as any).pause()}
src={thumbnail}
></video>
{:else}
<img
id="header-img"
class="object-cover h-full w-full"
src={thumbnail}
alt=""
/>
{/if}
</div>
<div class="basis-full">
<div

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { SummitLog } from "$lib/models/summit_log";
import { getFileURL } from "$lib/util/file_util";
import { getFileURL, isVideoURL } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
@@ -31,7 +31,7 @@
showDescription = false,
showPhotos = false,
onopen,
ontext
ontext,
}: Props = $props();
let gallery: PhotoGallery;
@@ -81,13 +81,27 @@
onclick={() => gallery.openGallery()}
>
{#each imgSrc as img, i}
<img
class="absolute h-full rounded-xl object-cover"
style="top: {4 * i}px; right: {4 *
i}px; transform: rotate(-{i * 5}deg)"
src={img}
alt="waypoint"
/>
{#if isVideoURL(img)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
controls={false}
loop
class="absolute h-full w-full rounded-xl object-cover"
style="top: {4 * i}px; right: {4 *
i}px; transform: rotate(-{i * 5}deg)"
onmouseenter={(e) => (e.target as any).play()}
onmouseleave={(e) => (e.target as any).pause()}
src={img}
></video>
{:else}
<img
class="absolute h-full rounded-xl object-cover"
style="top: {4 * i}px; right: {4 *
i}px; transform: rotate(-{i * 5}deg)"
src={img}
alt="waypoint"
/>
{/if}
{/each}
</button>
{/if}

View File

@@ -167,12 +167,11 @@
}
});
$effect(() => {
if (waypoints) {
untrack(() => {
showWaypoints();
refreshElevationProfile();
});
}
waypoints;
untrack(() => {
showWaypoints();
refreshElevationProfile();
});
});
function getData(trails: Trail[]) {
@@ -280,7 +279,7 @@
right: 16,
bottom:
16 +
(epc?.isProfileShown
(epc?.isProfileShown && !elevationProfileContainer
? map!.getContainer().clientHeight * 0.3
: 0),
},
@@ -663,6 +662,13 @@
markers.push(marker);
}
}
markers = markers.filter((marker) => {
if (!waypoints.find((w) => w.id == marker._element.id)) {
marker.remove();
return false;
}
return true;
});
}
function hideWaypoints() {

View File

@@ -87,7 +87,10 @@
continue;
}
let photoFile = file;
if (!file.type.startsWith("image")) {
if (
!file.type.startsWith("image") &&
!["video/mp4", "video/ogg", "video/webm"].includes(file.type)
) {
continue;
} else if (file.type === "image/heic") {
const heic2any = (await import("heic2any")).default;
@@ -151,7 +154,7 @@
<input
type="file"
id="{id}-photo-input"
accept="image/*"
accept="image/*,video/mp4"
multiple={true}
style="display: none;"
onchange={() => handlePhotoSelection()}

View File

@@ -7,7 +7,7 @@
import type { Trail } from "$lib/models/trail";
import { pb } from "$lib/pocketbase";
import { theme } from "$lib/stores/theme_store";
import { getFileURL } from "$lib/util/file_util";
import { getFileURL, isVideoURL } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
@@ -55,7 +55,24 @@
<div
class="relative w-full basis-full max-h-48 overflow-hidden rounded-t-2xl"
>
<img loading="lazy" class="w-full h-full" id="header-img" src={thumbnail} alt="" />
{#if isVideoURL(thumbnail)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
id="header-img"
class="w-full h-full object-cover"
autoplay
loop
src={thumbnail}
></video>
{:else}
<img
loading="lazy"
class="w-full h-full"
id="header-img"
src={thumbnail}
alt=""
/>
{/if}
</div>
{#if (trail.public || trailIsShared) && pb.authStore.model}
<div

View File

@@ -13,7 +13,7 @@
comments_update,
} from "$lib/stores/comment_store";
import { currentUser } from "$lib/stores/user_store";
import { getFileURL } from "$lib/util/file_util";
import { getFileURL, isVideoURL } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
@@ -172,12 +172,23 @@
>
<div class="trail-info-panel-header">
<section class="relative h-80">
<img
class="w-full h-80"
class:rounded-t-3xl={mode !== "list"}
src={thumbnail}
alt=""
/>
{#if isVideoURL(thumbnail)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="w-full h-80 object-cover rounded-t-3xl"
autoplay
loop
src={thumbnail}
></video>
{:else}
<img
class="w-full h-80"
class:rounded-t-3xl={mode !== "list"}
src={thumbnail}
alt=""
/>
{/if}
<div
class="absolute bottom-0 w-full h-full bg-gradient-to-b from-transparent to-black opacity-60"
></div>
@@ -215,7 +226,10 @@
class="flex absolute justify-between items-end w-full bottom-8 left-0 px-8 gap-y-4"
>
<div class="text-white overflow-hidden">
<h4 title={trail.name} class="text-4xl font-bold line-clamp-3">
<h4
title={trail.name}
class="text-4xl font-bold line-clamp-3"
>
{trail.name}
</h4>
{#if trail.date}
@@ -414,12 +428,27 @@
{#each trail.photos ?? [] as photo, i}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
onclick={() => gallery.openGallery(i)}
src={getFileURL(trail, photo)}
alt=""
/>
{#if isVideoURL(photo)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
controls={false}
loop
class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
onclick={() => gallery.openGallery(i)}
onmouseenter={(e) =>
(e.target as any).play()}
onmouseleave={(e) =>
(e.target as any).pause()}
src={getFileURL(trail, photo)}
></video>
{:else}
<img
class="rounded-xl cursor-pointer hover:scale-105 transition-transform"
onclick={() => gallery.openGallery(i)}
src={getFileURL(trail, photo)}
alt=""
/>
{/if}
{/each}
</div>
{:else}

View File

@@ -4,7 +4,7 @@
import type { Trail } from "$lib/models/trail";
import { pb } from "$lib/pocketbase";
import { theme } from "$lib/stores/theme_store";
import { getFileURL } from "$lib/util/file_util";
import { getFileURL, isVideoURL } from "$lib/util/file_util";
import {
formatDistance,
formatElevation,
@@ -12,7 +12,6 @@
} from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import ShareInfo from "../share_info.svelte";
interface Props {
trail: Trail;
@@ -21,18 +20,34 @@
let { trail, showDescription = true }: Props = $props();
let thumbnail = $derived(trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark);
let thumbnail = $derived(
trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: $theme === "light"
? emptyStateTrailLight
: emptyStateTrailDark,
);
</script>
<li
class="flex gap-8 p-4 rounded-xl border border-input-border cursor-pointer hover:bg-secondary-hover transition-colors items-center"
>
<div class="shrink-0">
<img class="h-28 w-28 object-cover rounded-xl" src={thumbnail} alt="" />
{#if isVideoURL(thumbnail)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
class="h-28 w-28 object-cover rounded-xl"
autoplay
loop
src={thumbnail}
></video>
{:else}
<img
class="h-28 w-28 object-cover rounded-xl"
src={thumbnail}
alt=""
/>
{/if}
</div>
<div class="min-w-0 basis-full">
<div class="flex items-center gap-4">

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Trail } from "$lib/models/trail";
import { getFileURL } from "$lib/util/file_util";
import { getFileURL, isVideoURL } from "$lib/util/file_util";
import { formatDistance } from "$lib/util/format_util";
import { _ } from "svelte-i18n";
import PhotoGallery from "../photo_gallery.svelte";
@@ -52,23 +52,40 @@
: 'grid-cols-1'} cursor-pointer"
>
{#each wp.photos as photo, j}
<img
onclick={() => gallery[i].openGallery(j)}
role="presentation"
class="w-full object-cover {j == 0 &&
wp.photos.length > 2
? 'row-span-2 h-80'
: 'h-[159.5px]'}"
src={getFileURL(wp, photo)}
alt=""
/>
{#if isVideoURL(photo)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
controls={false}
loop
class="w-full object-cover {j == 0 &&
wp.photos.length > 2
? 'row-span-2 h-80'
: 'h-[159.5px]'}"
onclick={() => gallery[i].openGallery(j)}
onmouseenter={(e) => (e.target as any).play()}
onmouseleave={(e) => (e.target as any).pause()}
src={getFileURL(wp, photo)}
></video>
{:else}
<img
onclick={() => gallery[i].openGallery(j)}
role="presentation"
class="w-full object-cover {j == 0 &&
wp.photos.length > 2
? 'row-span-2 h-80'
: 'h-[159.5px]'}"
src={getFileURL(wp, photo)}
alt=""
/>
{/if}
{/each}
</div>
{/if}
<div class="p-4">
<h5 class="text-xl font-semibold">{wp.name}</h5>
<span class="text-sm text-gray-500"
><i class="fa fa-location-dot mr-1"></i> {wp.lat.toFixed(5)}, {wp.lon.toFixed(5)}</span
><i class="fa fa-location-dot mr-1"></i>
{wp.lat.toFixed(5)}, {wp.lon.toFixed(5)}</span
>
<p class="whitespace-pre-line">
{wp.description}

View File

@@ -1,6 +1,10 @@
<script lang="ts">
import type { Waypoint } from "$lib/models/waypoint";
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
import {
getFileURL,
isVideoURL,
readAsDataURLAsync,
} from "$lib/util/file_util";
import { _ } from "svelte-i18n";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import { browser } from "$app/environment";
@@ -60,13 +64,27 @@
onclick={mode == "show" ? () => gallery.openGallery() : undefined}
>
{#each imgSrc as img, i}
<img
class="absolute h-full rounded-xl object-cover aspect-square"
style="top: {6 * i}px; right: {6 *
i}px; transform: rotate(-{i * 5}deg)"
src={img}
alt="waypoint"
/>
{#if isVideoURL(img)}
<!-- svelte-ignore a11y_media_has_caption -->
<video
controls={false}
loop
class="absolute h-full rounded-xl object-cover aspect-square"
style="top: {6 * i}px; right: {6 *
i}px; transform: rotate(-{i * 5}deg)"
onmouseenter={(e) => (e.target as any).play()}
onmouseleave={(e) => (e.target as any).pause()}
src={img}
></video>
{:else}
<img
class="absolute h-full rounded-xl object-cover aspect-square"
style="top: {6 * i}px; right: {6 *
i}px; transform: rotate(-{i * 5}deg)"
src={img}
alt="waypoint"
/>
{/if}
{/each}
</button>
{/if}