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

Binary file not shown.

View File

@@ -0,0 +1,91 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// update
edit_photos := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "aqbpyawe",
"name": "photos",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/vnd.mozilla.apng",
"image/png",
"image/webp",
"image/svg+xml",
"image/heic",
"video/mp4"
],
"thumbs": [],
"maxSelect": 99,
"maxSize": 20971520,
"protected": false
}
}`), edit_photos); err != nil {
return err
}
collection.Schema.AddField(edit_photos)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// update
edit_photos := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "aqbpyawe",
"name": "photos",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/vnd.mozilla.apng",
"image/png",
"image/webp",
"image/svg+xml",
"image/heic"
],
"thumbs": [],
"maxSelect": 99,
"maxSize": 20971520,
"protected": false
}
}`), edit_photos); err != nil {
return err
}
collection.Schema.AddField(edit_photos)
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,94 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// update
edit_photos := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "aqbpyawe",
"name": "photos",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/vnd.mozilla.apng",
"image/png",
"image/webp",
"image/svg+xml",
"image/heic",
"video/mp4",
"video/webm",
"video/ogg"
],
"thumbs": [],
"maxSelect": 99,
"maxSize": 20971520,
"protected": false
}
}`), edit_photos); err != nil {
return err
}
collection.Schema.AddField(edit_photos)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// update
edit_photos := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "aqbpyawe",
"name": "photos",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/vnd.mozilla.apng",
"image/png",
"image/webp",
"image/svg+xml",
"image/heic",
"video/mp4"
],
"thumbs": [],
"maxSelect": 99,
"maxSize": 20971520,
"protected": false
}
}`), edit_photos); err != nil {
return err
}
collection.Schema.AddField(edit_photos)
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,93 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("dd2l9a4vxpy2ni8")
if err != nil {
return err
}
// update
edit_photos := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "ixnksbkt",
"name": "photos",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/vnd.mozilla.apng",
"image/webp",
"image/svg+xml",
"image/heic",
"video/ogg",
"video/mp4",
"video/webm"
],
"thumbs": [],
"maxSelect": 99,
"maxSize": 20971520,
"protected": false
}
}`), edit_photos); err != nil {
return err
}
collection.Schema.AddField(edit_photos)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("dd2l9a4vxpy2ni8")
if err != nil {
return err
}
// update
edit_photos := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "ixnksbkt",
"name": "photos",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/vnd.mozilla.apng",
"image/webp",
"image/svg+xml",
"image/heic"
],
"thumbs": [],
"maxSelect": 99,
"maxSize": 20971520,
"protected": false
}
}`), edit_photos); err != nil {
return err
}
collection.Schema.AddField(edit_photos)
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,91 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// update
edit_photos := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "tfhs3juh",
"name": "photos",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/vnd.mozilla.apng",
"image/webp",
"image/svg+xml",
"video/ogg",
"video/mp4",
"video/webm"
],
"thumbs": [],
"maxSelect": 99,
"maxSize": 20971520,
"protected": false
}
}`), edit_photos); err != nil {
return err
}
collection.Schema.AddField(edit_photos)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// update
edit_photos := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "tfhs3juh",
"name": "photos",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/jpeg",
"image/png",
"image/vnd.mozilla.apng",
"image/webp",
"image/svg+xml"
],
"thumbs": [],
"maxSelect": 99,
"maxSize": 20971520,
"protected": false
}
}`), edit_photos); err != nil {
return err
}
collection.Schema.AddField(edit_photos)
return dao.SaveCollection(collection)
})
}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { isVideoURL } from "$lib/util/file_util";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
interface Props { interface Props {
@@ -38,6 +39,10 @@
class="group relative h-32 w-32 rounded-xl bg-cover bg-no-repeat" class="group relative h-32 w-32 rounded-xl bg-cover bg-no-repeat"
style="background-image: url({src});" 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} {#if isThumbnail && showThumbnailControls}
<i <i
class="fa fa-file-image absolute top-2 right-2 text-primary bg-white rounded-full px-[10px] py-2 shadow-lg" 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"> <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 type { DataSource } from "photoswipe";
import PhotoSwipeLightbox from "photoswipe/lightbox"; import PhotoSwipeLightbox from "photoswipe/lightbox";
import { onMount } from "svelte"; import { onMount } from "svelte";
@@ -17,28 +19,53 @@
let lightboxDataSource: DataSource; let lightboxDataSource: DataSource;
onMount(() => { onMount(() => {
lightboxDataSource = photos.map((p) => ({ lightboxDataSource = photos.map((p) => {
src: p, if (isVideoURL(p)) {
})); return {
type: "video",
videoSrc: p,
};
}
return {
src: p,
};
});
lightbox = new PhotoSwipeLightbox({ lightbox = new PhotoSwipeLightbox({
dataSource: lightboxDataSource, dataSource: lightboxDataSource,
pswpModule: async () => await import("photoswipe"), pswpModule: async () => await import("photoswipe"),
}); });
const videoPlugin = new PhotoSwipeVideoPlugin(lightbox);
lightbox.init(); lightbox.init();
lightbox.on("beforeOpen", () => { lightbox.on("beforeOpen", () => {
const pswp = lightbox.pswp; const pswp = lightbox.pswp;
const ds = pswp?.options?.dataSource; const ds = pswp?.options?.dataSource;
if (Array.isArray(ds)) { if (Array.isArray(ds)) {
for (let idx = 0, len = ds.length; idx < len; idx++) { for (let idx = 0, len = ds.length; idx < len; idx++) {
const item = ds[idx]; const item = ds[idx];
const img = new Image(); if (item.type === "video") {
img.onload = () => { const v = document.createElement("video");
item.width = img.naturalWidth; v.addEventListener(
item.height = img.naturalHeight; "loadedmetadata",
pswp?.refreshSlideContent(idx); function () {
}; item.width = this.videoWidth;
img.src = item.src as string; 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"> <script lang="ts">
import type { Activity } from "$lib/models/activity"; import type { Activity } from "$lib/models/activity";
import type { UserAnonymous } from "$lib/models/user"; import type { UserAnonymous } from "$lib/models/user";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL, isVideoURL } from "$lib/util/file_util";
import { import {
formatDistance, formatDistance,
formatElevation, formatElevation,
@@ -77,15 +77,34 @@
: 'grid-cols-1'}" : 'grid-cols-1'}"
> >
{#each activity.photos as photo, i} {#each activity.photos as photo, i}
<img {#if isVideoURL(photo)}
class="object-cover h-full max-h-80 w-full" <!-- svelte-ignore a11y_media_has_caption -->
class:row-span-2={i == 0 && activity.photos.length > 2} <video
src={getFileURL( class="object-cover h-full max-h-80 w-full"
{ collectionId: activity.type + "s", id: activity.id }, autoplay
photo, loop
)} src={getFileURL(
alt="" {
/> 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} {/each}
</div> </div>
{/if} {/if}

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
import { pb } from "$lib/pocketbase"; import { pb } from "$lib/pocketbase";
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL, isVideoURL } from "$lib/util/file_util";
import { import {
formatDistance, formatDistance,
formatElevation, formatElevation,
@@ -55,7 +55,24 @@
<div <div
class="relative w-full basis-full max-h-48 overflow-hidden rounded-t-2xl" 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> </div>
{#if (trail.public || trailIsShared) && pb.authStore.model} {#if (trail.public || trailIsShared) && pb.authStore.model}
<div <div

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,13 @@ export function readAsDataURLAsync(file: File) {
}); });
} }
export function isVideoURL(url: string) {
if(url.startsWith("data")) {
return url.startsWith("data:video")
}
return url.includes("mp4") || url.includes("ogg") || url.includes("webm")
}
export function saveAs(data: Blob, fileName: string) { export function saveAs(data: Blob, fileName: string) {
var a = document.createElement("a") as HTMLAnchorElement; var a = document.createElement("a") as HTMLAnchorElement;
a.setAttribute("style", "display: none"); a.setAttribute("style", "display: none");

View File

@@ -72,7 +72,6 @@ export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker
marker.on("dragend", () => onDragEnd(marker, waypoint.id,)); marker.on("dragend", () => onDragEnd(marker, waypoint.id,));
} }
waypoint.marker = marker
return marker; return marker;
} }

View File

@@ -0,0 +1,14 @@
import type PhotoSwipeLightbox from 'photoswipe/lightbox';
import { defaultOptions, type PhotoSwipeVideoPluginOptions } from './options';
import VideoContentSetup from './video-content-setup';
class PhotoSwipeVideoPlugin {
constructor(lightbox: PhotoSwipeLightbox, options?: PhotoSwipeVideoPluginOptions) {
new VideoContentSetup(lightbox, {
...defaultOptions,
...options
});
}
}
export default PhotoSwipeVideoPlugin;

View File

@@ -0,0 +1,18 @@
export type PhotoSwipeVideoPluginOptions = {
videoAttributes: {
controls: string,
playsinline: string,
preload: string,
},
autoplay: boolean,
preventDragOffset: number
}
export const defaultOptions: PhotoSwipeVideoPluginOptions = {
videoAttributes: { controls: '', playsinline: '', preload: 'auto' },
autoplay: true,
// prevent drag/swipe gesture over the bottom part of video
// set to 0 to disable
preventDragOffset: 40
};

View File

@@ -0,0 +1,250 @@
import type PhotoSwipe from "photoswipe";
import type PhotoSwipeLightbox from "photoswipe/lightbox";
import type { Content } from "photoswipe/lightbox";
import type { SlideData } from "photoswipe";
import type { PhotoSwipeVideoPluginOptions } from "./options";
type VideoContentData = {
width: number,
height: number,
type: 'video',
msrc: string,
videoSrc: string,
}
type VideoContent = Content & {
element: HTMLVideoElement
data: VideoContentData
_videoPosterImg: HTMLImageElement | null
}
function isVideoContent(content?: Content | SlideData) {
if (!content) {
return false
}
return (content && content.data && content.data.type === 'video');
}
class VideoContentSetup {
options: PhotoSwipeVideoPluginOptions;
constructor(lightbox: PhotoSwipeLightbox, options: PhotoSwipeVideoPluginOptions) {
this.options = options;
this.initLightboxEvents(lightbox);
lightbox.on('init', () => {
this.initPswpEvents(lightbox.pswp!);
});
}
initLightboxEvents(lightbox: PhotoSwipeLightbox) {
lightbox.on('contentLoad', this.onContentLoad.bind(this));
lightbox.on('contentDestroy', this.onContentDestroy.bind(this));
lightbox.on('contentActivate', this.onContentActivate.bind(this));
lightbox.on('contentDeactivate', this.onContentDeactivate.bind(this));
lightbox.on('contentAppend', this.onContentAppend.bind(this));
lightbox.on('contentResize', this.onContentResize.bind(this));
lightbox.addFilter('isKeepingPlaceholder', this.isKeepingPlaceholder.bind(this));
lightbox.addFilter('isContentZoomable', this.isContentZoomable.bind(this));
lightbox.addFilter('useContentPlaceholder', this.useContentPlaceholder.bind(this));
lightbox.addFilter('domItemData', (itemData, element, linkEl) => {
if (itemData.type === 'video' && linkEl) {
if (linkEl.dataset.pswpVideoSources) {
itemData.videoSources = JSON.parse(linkEl.dataset.pswpVideoSources);
} else if (linkEl.dataset.pswpVideoSrc) {
itemData.videoSrc = linkEl.dataset.pswpVideoSrc;
} else {
itemData.videoSrc = linkEl.href;
}
}
return itemData;
});
}
initPswpEvents(pswp: PhotoSwipe) {
pswp.on('pointerDown', (e) => {
const slide = pswp.currSlide;
if (isVideoContent(slide) && this.options.preventDragOffset) {
const origEvent = e.originalEvent;
if (origEvent.type === 'pointerdown') {
const videoHeight = Math.ceil((slide?.height ?? 0) * (slide?.currZoomLevel ?? 0));
const verticalEnding = videoHeight + (slide?.bounds.center.y ?? 0);
const pointerYPos = origEvent.pageY - pswp.offset.y;
if (pointerYPos > verticalEnding - this.options.preventDragOffset
&& pointerYPos < verticalEnding) {
e.preventDefault();
}
}
}
});
// do not append video on nearby slides
pswp.on('appendHeavy', (e) => {
if (isVideoContent(e.slide) && !e.slide.isActive) {
e.preventDefault();
}
});
pswp.on('close', () => {
if (isVideoContent(pswp.currSlide?.content)) {
// Switch from zoom to fade closing transition,
// as zoom transition is choppy for videos
if (!pswp.options.showHideAnimationType
|| pswp.options.showHideAnimationType === 'zoom') {
pswp.options.showHideAnimationType = 'fade';
}
// pause video when closing
this.pauseVideo(pswp.currSlide?.content as VideoContent);
}
});
}
onContentDestroy({ content }: { content: Content }) {
if (isVideoContent(content)) {
const c = content as VideoContent
if (c._videoPosterImg) {
c._videoPosterImg.onload = c._videoPosterImg.onerror = null;
c._videoPosterImg = null;
}
}
}
onContentResize(e: any) {
if (isVideoContent(e.content)) {
e.preventDefault();
const width = e.width;
const height = e.height;
const content = e.content;
if (content.element) {
content.element.style.width = width + 'px';
content.element.style.height = height + 'px';
}
if (content.slide && content.slide.placeholder) {
// override placeholder size, so it more accurately matches the video
const placeholderElStyle = content.slide.placeholder.element.style;
placeholderElStyle.transform = 'none';
placeholderElStyle.width = width + 'px';
placeholderElStyle.height = height + 'px';
}
}
}
isKeepingPlaceholder(isZoomable: boolean, content: Content) {
if (isVideoContent(content)) {
return false;
}
return isZoomable;
}
isContentZoomable(isZoomable: boolean, content: Content) {
if (isVideoContent(content)) {
return false;
}
return isZoomable;
}
onContentActivate({ content }: { content: Content }) {
if (isVideoContent(content) && this.options.autoplay) {
this.playVideo(content as VideoContent);
}
}
onContentDeactivate({ content }: { content: Content }) {
if (isVideoContent(content)) {
this.pauseVideo(content as VideoContent);
}
}
onContentAppend(e: any) {
if (isVideoContent(e.content)) {
e.preventDefault();
e.content.isAttached = true;
e.content.appendImage();
}
}
onContentLoad(e: any) {
const content = e.content; // todo: videocontent
if (!isVideoContent(e.content)) {
return;
}
// stop default content load
e.preventDefault();
if (content.element) {
return;
}
content.state = 'loading';
content.type = 'video'; // TODO: move this to pswp core?
content.element = document.createElement('video');
if (this.options.videoAttributes) {
for (let key in this.options.videoAttributes) {
content.element.setAttribute(key, this.options.videoAttributes[key as keyof PhotoSwipeVideoPluginOptions['videoAttributes']] || '');
}
}
content.onLoaded();
// content.element.setAttribute('poster', content.data.msrc);
// this.preloadVideoPoster(content, content.data.msrc);
// content.element.style.position = 'absolute';
content.element.style.left = "0";
content.element.style.top = "0";
if (content.data.videoSrc) {
content.element.src = content.data.videoSrc;
}
}
preloadVideoPoster(content: VideoContent, src: string) {
if (!content._videoPosterImg && src) {
content._videoPosterImg = new Image();
content._videoPosterImg.src = src;
if (content._videoPosterImg.complete) {
content.onLoaded();
} else {
content._videoPosterImg.onload = content._videoPosterImg.onerror = () => {
content.onLoaded();
};
}
}
}
playVideo(content: VideoContent) {
if (content.element) {
content.element.play();
}
}
pauseVideo(content?: VideoContent) {
if (!content) {
return;
}
if (content.element) {
content.element.pause();
}
}
useContentPlaceholder(usePlaceholder: boolean, content: Content) {
if (isVideoContent(content)) {
return true;
}
return usePlaceholder;
}
}
export default VideoContentSetup;

View File

@@ -151,7 +151,7 @@
extend: validator({ extend: validator({
schema: ClientTrailCreateSchema, schema: ClientTrailCreateSchema,
}), }),
onSubmit: async (form) => { onSubmit: async (form) => {
loading = true; loading = true;
try { try {
const htmlForm = document.getElementById( const htmlForm = document.getElementById(
@@ -404,10 +404,12 @@
function deleteWaypoint(index: number) { function deleteWaypoint(index: number) {
const wp = $formData.expand!.waypoints?.splice(index, 1); const wp = $formData.expand!.waypoints?.splice(index, 1);
$formData.waypoints.splice(index, 1); $formData.waypoints.splice(index, 1);
$formData.expand!.waypoints = $formData.expand!.waypoints;
wp?.[0]?.marker?.remove(); if(!$formData.expand!.waypoints?.length) {
$formData.expand!.waypoints = []
}
$formData.expand!.waypoints = $formData.expand!.waypoints;
// updateTrailOnMap(); // updateTrailOnMap();
} }
@@ -1033,7 +1035,7 @@
</h3> </h3>
<ul> <ul>
{#each $formData.expand?.waypoints ?? [] as waypoint, i} {#each $formData.expand?.waypoints ?? [] as waypoint, i}
<li onmouseenter={() => openMarkerPopup(waypoint)}> <li onmouseenter={() => openMarkerPopup(waypoint)} onmouseleave={() => openMarkerPopup(waypoint)}>
<WaypointCard <WaypointCard
{waypoint} {waypoint}
mode="edit" mode="edit"