e.stopPropagation()}>
-
+
{
close();
@@ -89,7 +95,7 @@ export default function GalleryPostActions({
{isNative() ? (
-
+
) : (
diff --git a/src/features/media/gallery/actions/ImageMoreActions.tsx b/src/features/media/gallery/actions/ImageMoreActions.tsx
index 683a70c362..927e751cc7 100644
--- a/src/features/media/gallery/actions/ImageMoreActions.tsx
+++ b/src/features/media/gallery/actions/ImageMoreActions.tsx
@@ -4,17 +4,20 @@ import { useAppSelector } from "#/store";
import AltText from "./AltText";
import GalleryActions from "./GalleryActions";
import { BottomContainer, BottomContainerActions } from "./shared";
+import VideoActions from "./VideoActions";
import styles from "./ImageMoreActions.module.css";
interface ImageMoreActionsProps {
imgSrc: string;
alt?: string;
+ videoRef?: React.RefObject;
}
export default function ImageMoreActions({
imgSrc,
alt,
+ videoRef,
}: ImageMoreActionsProps) {
const hideAltText = useAppSelector(
(state) => state.settings.general.media.hideAltText,
@@ -24,12 +27,13 @@ export default function ImageMoreActions({
<>
{isNative() && (
-
+
)}
{alt && !hideAltText && (
+ {videoRef && }
)}
diff --git a/src/features/media/gallery/actions/VideoActions.module.css b/src/features/media/gallery/actions/VideoActions.module.css
new file mode 100644
index 0000000000..da885a15da
--- /dev/null
+++ b/src/features/media/gallery/actions/VideoActions.module.css
@@ -0,0 +1,29 @@
+.container {
+ width: calc(100% - 48px);
+ max-width: 500px;
+ padding: 8px 16px;
+ border-radius: 16px;
+
+ backdrop-filter: blur(24px) brightness(0.7) saturate(1.2);
+}
+
+.buttons {
+ display: flex;
+ justify-content: space-between;
+
+ opacity: 0.8;
+}
+
+.range {
+ --bar-background: rgba(255, 255, 255, 0.3);
+ --knob-size: 18px;
+ --knob-background: #ccc;
+}
+
+.skipIcon {
+ width: 24px;
+}
+
+.playerButton {
+ color: white;
+}
diff --git a/src/features/media/gallery/actions/VideoActions.tsx b/src/features/media/gallery/actions/VideoActions.tsx
new file mode 100644
index 0000000000..11c208c795
--- /dev/null
+++ b/src/features/media/gallery/actions/VideoActions.tsx
@@ -0,0 +1,298 @@
+import { IonButton, IonIcon, IonRange } from "@ionic/react";
+import { play, volumeHigh, volumeOff } from "ionicons/icons";
+import { pause } from "ionicons/icons";
+import React, {
+ useContext,
+ useEffect,
+ experimental_useEffectEvent as useEffectEvent,
+ useRef,
+ useState,
+} from "react";
+
+import { back, forward, pip } from "#/features/icons";
+
+import { GalleryContext } from "../GalleryProvider";
+
+import styles from "./VideoActions.module.css";
+
+interface VideoActionsProps {
+ videoRef: React.RefObject;
+}
+
+export default function VideoActionsLoader({ videoRef }: VideoActionsProps) {
+ const [isReady, setIsReady] = useState(false);
+ const rafId = useRef(null);
+
+ useEffect(() => {
+ let done = false;
+
+ const startAnimation = () => {
+ rafId.current = requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (!done) {
+ setIsReady(true);
+ }
+ });
+ });
+ };
+
+ startAnimation();
+
+ return () => {
+ done = true;
+ if (rafId.current !== null) {
+ cancelAnimationFrame(rafId.current);
+ }
+ };
+ }, []);
+
+ return isReady ? : null;
+}
+
+function VideoActions({ videoRef }: VideoActionsProps) {
+ const { close } = useContext(GalleryContext);
+ const setupEvent = useEffectEvent(setup);
+ const teardownEvent = useEffectEvent(teardown);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [isDragging, setIsDragging] = useState(false);
+ const [isMuted, setIsMuted] = useState(false);
+ const [wasPlayingBeforeScrub, setWasPlayingBeforeScrub] = useState(false);
+
+ const [canSkip15Seconds, setCanSkip15Seconds] = useState(true);
+
+ useEffect(() => {
+ setupEvent();
+
+ return teardownEvent;
+ }, []);
+
+ function setup() {
+ if (videoRef.current) {
+ videoRef.current.addEventListener("timeupdate", handleTimeUpdate);
+ videoRef.current.addEventListener("play", handlePlay);
+ videoRef.current.addEventListener("pause", handlePause);
+ videoRef.current.addEventListener("volumechange", handleVolumeChange);
+ videoRef.current.addEventListener("durationchange", handleDurationChange);
+
+ setIsPlaying(!videoRef.current.paused);
+ setIsMuted(videoRef.current.muted);
+
+ handleDurationChange();
+ }
+ }
+
+ function teardown() {
+ if (videoRef.current) {
+ videoRef.current.removeEventListener("timeupdate", handleTimeUpdate);
+ videoRef.current.removeEventListener("play", handlePlay);
+ videoRef.current.removeEventListener("pause", handlePause);
+ videoRef.current.removeEventListener("volumechange", handleVolumeChange);
+ videoRef.current.removeEventListener(
+ "durationchange",
+ handleDurationChange,
+ );
+ }
+ }
+
+ function handleTimeUpdate() {
+ if (!videoRef.current) return;
+
+ const currentTime = videoRef.current.currentTime;
+ const duration = videoRef.current.duration;
+ setProgress((currentTime / duration) * 100);
+ }
+
+ function handleDurationChange() {
+ if (!videoRef.current) return;
+
+ setCanSkip15Seconds(videoRef.current.duration > 15);
+ }
+
+ function handlePlay() {
+ setIsPlaying(true);
+ setWasPlayingBeforeScrub(false);
+ }
+
+ function handlePause() {
+ setIsPlaying(false);
+ }
+
+ function handleVolumeChange() {
+ setIsMuted(videoRef.current?.muted ?? false);
+ }
+
+ function togglePlayPause() {
+ if (videoRef.current) {
+ if (isPlaying) {
+ videoRef.current.pause();
+ } else {
+ videoRef.current.play();
+ }
+ setIsPlaying(!isPlaying);
+ }
+ }
+
+ function toggleMute() {
+ if (videoRef.current) {
+ videoRef.current.muted = !isMuted;
+ setIsMuted(!isMuted);
+ }
+ }
+
+ function handleMouseDown() {
+ if (videoRef.current) {
+ setWasPlayingBeforeScrub(!videoRef.current.paused);
+ videoRef.current.pause();
+ }
+ setIsDragging(true);
+ }
+
+ function handleMouseUp() {
+ if (wasPlayingBeforeScrub && videoRef.current) {
+ videoRef.current.play();
+ }
+ setIsDragging(false);
+ }
+
+ function handleMouseMove(
+ event: React.MouseEvent,
+ ) {
+ if (isDragging && videoRef.current) {
+ const progressBar = event.currentTarget;
+ const rect = progressBar.getBoundingClientRect();
+ const offsetX = event.clientX - rect.left;
+ const newTime = (offsetX / rect.width) * videoRef.current.duration;
+ videoRef.current.currentTime = newTime;
+ setProgress((newTime / videoRef.current.duration) * 100);
+ }
+ }
+
+ function handleTouchStart() {
+ if (videoRef.current) {
+ setWasPlayingBeforeScrub(!videoRef.current.paused);
+ videoRef.current.pause();
+ }
+ setIsDragging(true);
+ }
+
+ function handleTouchEnd() {
+ if (wasPlayingBeforeScrub && videoRef.current) {
+ videoRef.current.play();
+ }
+ setIsDragging(false);
+ }
+
+ function handleTouchMove(event: React.TouchEvent) {
+ if (isDragging && videoRef.current) {
+ const touch = event.touches[0];
+ if (touch) {
+ const progressBar = event.currentTarget;
+ const rect = progressBar.getBoundingClientRect();
+ const offsetX = touch.clientX - rect.left;
+ const newTime = (offsetX / rect.width) * videoRef.current.duration;
+ videoRef.current.currentTime = newTime;
+ setProgress((newTime / videoRef.current.duration) * 100);
+ }
+ }
+ }
+
+ async function requestPip() {
+ if (!videoRef.current) return;
+
+ await videoRef.current.requestPictureInPicture();
+ close();
+ }
+
+ function skip(seconds: number) {
+ if (!videoRef.current) return;
+
+ videoRef.current.currentTime += seconds;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {canSkip15Seconds && (
+
skip(-15)}
+ >
+
+
+ )}
+
+
+
+
+
+ {canSkip15Seconds && (
+
skip(15)}
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/media/useMediaLoadObserver.ts b/src/features/media/useMediaLoadObserver.ts
index a0c351c566..1b39c3a20a 100644
--- a/src/features/media/useMediaLoadObserver.ts
+++ b/src/features/media/useMediaLoadObserver.ts
@@ -1,15 +1,15 @@
import { ComponentRef, useEffect, useRef } from "react";
import { imageLoaded } from "#/features/media/imageSlice";
-import type Media from "#/features/media/Media";
import { useAppDispatch } from "#/store";
+import type GalleryMedia from "./gallery/GalleryMedia";
import useAspectRatio, { isLoadedAspectRatio } from "./useAspectRatio";
export default function useMediaLoadObserver(src: string | undefined) {
const dispatch = useAppDispatch();
const aspectRatio = useAspectRatio(src);
- const mediaRef = useRef>(null);
+ const mediaRef = useRef>(null);
const resizeObserverRef = useRef();
useEffect(() => {
@@ -61,7 +61,7 @@ export default function useMediaLoadObserver(src: string | undefined) {
return [mediaRef, aspectRatio] as const;
}
-export function getTargetDimensions(target: ComponentRef) {
+export function getTargetDimensions(target: ComponentRef) {
let width, height;
switch (true) {
diff --git a/src/features/media/video/Player.module.css b/src/features/media/video/Player.module.css
index d0b4e8c594..061fb46dbf 100644
--- a/src/features/media/video/Player.module.css
+++ b/src/features/media/video/Player.module.css
@@ -3,6 +3,8 @@
overflow: hidden;
display: flex;
+
+ width: 100%; /* needed for iOS zoom in photoswipe */
}
.progress {
diff --git a/src/features/media/video/Player.tsx b/src/features/media/video/Player.tsx
index 3e2b134cbf..ccc412cbe9 100644
--- a/src/features/media/video/Player.tsx
+++ b/src/features/media/video/Player.tsx
@@ -19,10 +19,10 @@ import { getVideoSrcForUrl } from "#/helpers/url";
import styles from "./Player.module.css";
-export interface PlayerProps {
+export interface PlayerProps extends React.HTMLProps {
src: string;
- nativeControls?: boolean;
+ controls?: boolean;
progress?: boolean;
volume?: boolean;
autoPlay?: boolean;
@@ -31,23 +31,30 @@ export interface PlayerProps {
style?: CSSProperties;
alt?: string;
+ pauseWhenNotInView?: boolean;
+ allowShowPlayButton?: boolean;
+
ref?: React.RefObject;
+ videoRef?: React.RefObject;
}
export default function Player({
src: potentialSrc,
- nativeControls,
+ controls,
className,
- progress: showProgress = !nativeControls,
+ progress: showProgress = !controls,
volume = true,
autoPlay: videoAllowedToAutoplay = true,
+ pauseWhenNotInView = true,
+ allowShowPlayButton = true,
ref,
+ videoRef: _videoRef,
...rest
}: PlayerProps) {
const videoRef = useRef();
-
const [muted, setMuted] = useState(true);
const [playing, setPlaying] = useState(false);
+ const isInPipRef = useRef(false);
const shouldAppAutoplay = useShouldAutoplay();
const autoPlay = shouldAppAutoplay && videoAllowedToAutoplay;
@@ -59,12 +66,19 @@ export default function Player({
useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement, []);
+ // When portaled, need a way to access the player ref
+ useImperativeHandle(
+ _videoRef,
+ () => videoRef.current as HTMLVideoElement,
+ [],
+ );
+
const [inViewRef, inView] = useInView({
threshold: 0.5,
});
const [progress, setProgress] = useState(undefined);
- const showBigPlayButton = userPaused && !playing;
+ const showBigPlayButton = userPaused && !playing && allowShowPlayButton;
const setRefs = useCallback(
(node: HTMLVideoElement) => {
@@ -79,6 +93,7 @@ export default function Player({
const pause = useCallback(() => {
if (!videoRef.current) return;
if (userPaused) return;
+ if (isInPipRef.current) return;
wantedToPlayRef.current = false;
@@ -94,6 +109,7 @@ export default function Player({
const resume = useCallback(() => {
if (!videoRef.current) return;
if (userPaused) return;
+ if (isInPipRef.current) return;
videoRef.current.play();
wantedToPlayRef.current = true;
@@ -102,13 +118,36 @@ export default function Player({
useEffect(() => {
if (!videoRef.current) return;
+ if (!pauseWhenNotInView) {
+ if (autoPlay) resume();
+
+ return;
+ }
if (inView) {
resume();
} else {
pause();
}
- }, [inView, pause, resume]);
+ }, [inView, pause, resume, pauseWhenNotInView, autoPlay]);
+
+ useEffect(() => {
+ function enterPip() {
+ isInPipRef.current = true;
+ }
+
+ function leavePip() {
+ isInPipRef.current = false;
+ }
+
+ videoRef.current?.addEventListener("enterpictureinpicture", enterPip);
+ videoRef.current?.addEventListener("leavepictureinpicture", leavePip);
+
+ return () => {
+ videoRef.current?.removeEventListener("enterpictureinpicture", enterPip);
+ videoRef.current?.removeEventListener("leavepictureinpicture", leavePip);
+ };
+ }, []);
return (
@@ -137,7 +176,7 @@ export default function Player({
setMuted(!!videoRef.current?.muted);
}}
autoPlay={false}
- controls={nativeControls}
+ controls={controls}
onTimeUpdate={(e: ChangeEvent) => {
if (!showProgress) return;
setProgress(e.target.currentTime / e.target.duration);
@@ -147,7 +186,7 @@ export default function Player({
{showProgress && progress !== undefined && (
)}
- {!nativeControls && (
+ {!controls && (
<>
{!showBigPlayButton && volume && (