diff --git a/web/src/app.html b/web/src/app.html index c0ac3cfe6c..18a873b525 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -5,7 +5,7 @@ <!-- metadata:tags --> <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 6e1751b7b3..0e9053a5ea 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -25,6 +25,7 @@ import { page } from '$app/stores'; import type { UpdatePayload } from 'vite'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import { mobileDevice } from '$lib/stores/mobile-device.svelte'; interface Props { isSelectionMode?: boolean; @@ -82,6 +83,8 @@ let bottomSectionHeight = 60; let leadout = $state(false); + const usingMobileDevice = $derived(mobileDevice.hoverNone); + const scrollTo = (top: number) => { element?.scrollTo({ top }); showSkeleton = false; @@ -714,7 +717,12 @@ <!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar --> <section id="asset-grid" - class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}" + class={[ + 'scrollbar-hidden h-full overflow-y-auto outline-none', + { 'm-0': isEmpty }, + { 'ml-4 tall:ml-0': !isEmpty }, + { 'mr-[60px]': !isEmpty && !usingMobileDevice }, + ]} tabindex="-1" bind:clientHeight={assetStore.viewportHeight} bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())} diff --git a/web/src/lib/components/shared-components/scrubber/scrubber.svelte b/web/src/lib/components/shared-components/scrubber/scrubber.svelte index d13c12cf6a..734b42205e 100644 --- a/web/src/lib/components/shared-components/scrubber/scrubber.svelte +++ b/web/src/lib/components/shared-components/scrubber/scrubber.svelte @@ -1,8 +1,12 @@ <script lang="ts"> + import Icon from '$lib/components/elements/icon.svelte'; import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte'; + import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util'; + import { mdiPlay } from '@mdi/js'; import { clamp } from 'lodash-es'; import { DateTime } from 'luxon'; + import { onMount } from 'svelte'; import { fade, fly } from 'svelte/transition'; interface Props { @@ -209,6 +213,62 @@ void onScrub?.(bucketDate, scrollPercent, bucketPercentY); }; + const getTouch = (event: TouchEvent) => { + if (event.touches.length === 1) { + return event.touches[0]; + } + return null; + }; + const onTouchStart = (event: TouchEvent) => { + const touch = getTouch(event); + if (!touch) { + isHover = false; + return; + } + const elements = document.elementsFromPoint(touch.clientX, touch.clientY); + const isHoverScrollbar = elements.some(({ id }) => { + return id === 'immich-scrubbable-scrollbar' || id === 'time-label'; + }); + + isHover = isHoverScrollbar; + + if (isHoverScrollbar) { + handleMouseEvent({ + clientY: touch.clientY, + isDragging: true, + }); + } + }; + const onTouchEnd = () => { + if (isHover) { + isHover = false; + } + handleMouseEvent({ + clientY, + isDragging: false, + }); + }; + const onTouchMove = (event: TouchEvent) => { + const touch = getTouch(event); + if (touch && isDragging) { + handleMouseEvent({ + clientY: touch.clientY, + }); + event.preventDefault(); + } else { + isHover = false; + } + }; + onMount(() => { + const opts = { + passive: false, + }; + globalThis.addEventListener('touchmove', onTouchMove, opts); + return () => { + globalThis.removeEventListener('touchmove', onTouchMove); + }; + }); + const usingMobileDevice = $derived(mobileDevice.hoverNone); </script> <svelte:window @@ -216,6 +276,9 @@ onmousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })} onmousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })} onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })} + ontouchstart={onTouchStart} + ontouchend={onTouchEnd} + ontouchcancel={onTouchEnd} /> <div @@ -237,8 +300,9 @@ onmouseenter={() => (isHover = true)} onmouseleave={() => (isHover = false)} onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)} + draggable="false" > - {#if hoverLabel && (isHover || isDragging)} + {#if !usingMobileDevice && hoverLabel && (isHover || isDragging)} <div id="time-label" class={[ @@ -251,8 +315,34 @@ {hoverLabel} </div> {/if} + {#if usingMobileDevice && ((assetStore.scrolling && scrollHoverLabel) || isHover || isDragging)} + <div + id="time-label" + class="rounded-l-full w-[32px] pl-2 text-white bg-immich-primary dark:bg-gray-600 hover:cursor-pointer select-none" + style:top="{scrollY + HOVER_DATE_HEIGHT - 25}px" + style:height="50px" + style:right="0" + style:position="absolute" + in:fade={{ duration: 200 }} + out:fade={{ duration: 200 }} + > + <Icon path={mdiPlay} size="20" class="-rotate-90 relative top-[9px] -right-[2px]" /> + <Icon path={mdiPlay} size="20" class="rotate-90 relative top-[1px] -right-[2px]" /> + {#if (assetStore.scrolling && scrollHoverLabel) || isHover || isDragging} + <p + transition:fade={{ duration: 200 }} + style:bottom={50 / 2 - 30 / 2 + 'px'} + style:right="36px" + style:width="fit-content" + class="truncate pointer-events-none absolute text-sm rounded-full w-[32px] py-2 px-4 text-white bg-immich-primary/90 dark:bg-gray-500 hover:cursor-pointer select-none font-semibold" + > + {scrollHoverLabel} + </p> + {/if} + </div> + {/if} <!-- Scroll Position Indicator Line --> - {#if !isDragging} + {#if !usingMobileDevice && !isDragging} <div class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary" style:top="{scrollY + HOVER_DATE_HEIGHT}px" @@ -280,21 +370,14 @@ data-time-segment-bucket-date={segment.date} data-label={segment.dateFormatted} style:height={segment.height + 'px'} - aria-label={segment.dateFormatted + ' ' + segment.count} > - {#if segment.hasLabel} - <div - aria-label={segment.dateFormatted + ' ' + segment.count} - class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono" - > + {#if !usingMobileDevice && segment.hasLabel} + <div class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono"> {segment.date.year} </div> {/if} - {#if segment.hasDot} - <div - aria-label={segment.dateFormatted + ' ' + segment.count} - class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300" - ></div> + {#if !usingMobileDevice && segment.hasDot} + <div class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"></div> {/if} </div> {/each} diff --git a/web/src/lib/stores/mobile-device.svelte.ts b/web/src/lib/stores/mobile-device.svelte.ts new file mode 100644 index 0000000000..f40e8aafdc --- /dev/null +++ b/web/src/lib/stores/mobile-device.svelte.ts @@ -0,0 +1,9 @@ +import { MediaQuery } from 'svelte/reactivity'; + +const hoverNone = new MediaQuery('hover: none'); + +export const mobileDevice = { + get hoverNone() { + return hoverNone.current; + }, +};