[web] PhotoSwipe Update - Before switch over (#5247)

Final set of changes, in next PR we swap
This commit is contained in:
Manav Rathi
2025-03-07 10:20:48 +05:30
committed by GitHub
5 changed files with 187 additions and 117 deletions

View File

@@ -184,9 +184,10 @@ body {
/* Transform the built in controls to better fit our requirements */
.pswp-ente .pswp__counter {
margin-top: 17.5px;
margin-top: 22.5px;
margin-inline-start: 18px;
margin-inline-end: 9px;
line-height: 20px;
}
.pswp-ente .pswp__button--zoom .pswp__icn {

View File

@@ -944,8 +944,9 @@ const Shortcuts: React.FC<ModalVisibilityProps> = ({ open, onClose }) => (
action="Toggle controls"
shortcut="H, Tap outside image"
/>
<Shortcut action="Pan" shortcut="W / A / S / D, Drag" />
<Shortcut action="Pan" shortcut="W A S D, Drag" />
<Shortcut action="Toggle live" shortcut="Space" />
<Shortcut action="Toggle audio" shortcut="M" />
<Shortcut action="Toggle favorite" shortcut="L" />
<Shortcut action="View info" shortcut="I" />
<Shortcut action="Download" shortcut="K" />

View File

@@ -260,7 +260,7 @@ export const fileViewerDidClose = () => {
* - For images and videos, this will be the single original.
*
* - For live photos, this will also be a two step process, first fetching the
* original image, then again the video component.
* video component, then fetching the image component.
*
* At this point, the data for this file will be considered final, and
* subsequent calls for the same file will return this same value unless it is
@@ -400,19 +400,32 @@ const enqueueUpdates = async (file: EnteFile) => {
await downloadManager.renderableSourceURLs(file);
const livePhotoSourceURLs =
sourceURLs.url as LivePhotoSourceURL;
// The image component of a live photo usually is an HEIC file,
// which cannot be displayed natively by browsers and needs a
// conversion, which is slow on web (faster on desktop). We
// already have both components available since they're part of
// the same zip. And in the UI, the first (default) interaction
// is to loop the live video.
//
// For these reasons, we resolve with the video first, then
// resolve with the image.
const videoURL = await livePhotoSourceURLs.video();
update({
videoURL,
isContentLoading: true,
isContentZoomable: false,
});
const imageURL = ensureString(
await livePhotoSourceURLs.image(),
);
const originalImageBlob =
livePhotoSourceURLs.originalImageBlob()!;
const imageData = {
update({
...(await withDimensions(imageURL)),
imageURL,
originalImageBlob,
};
update(imageData);
const videoURL = await livePhotoSourceURLs.video();
update({ ...imageData, videoURL });
videoURL,
});
break;
}
}

View File

@@ -245,7 +245,6 @@ export class FileViewerPhotoSwipe {
// Set the index within files that we should open to. Subsequent
// updates to the index will be tracked by PhotoSwipe internally.
index: initialIndex,
// TODO(PS): padding option? for handling custom title bar.
// TODO(PS): will we need this?
mainClass: "pswp-ente",
// TODO(PS): Translated variants
@@ -382,16 +381,26 @@ export class FileViewerPhotoSwipe {
let livePhotoPlay = true;
/**
* The live photo playback toggle button element.
* Last state of the live photo muted toggle.
*/
let livePhotoToggleButtonElement: HTMLButtonElement | undefined;
let livePhotoMute = true;
/**
* Update the state of the given `videoElement` and the
* `livePhotoToggleButtonElement` to reflect `livePhotoPlay`.
* The live photo playback toggle DOM button element.
*/
const livePhotoUpdatePlayback = (video: HTMLVideoElement) => {
const button = livePhotoToggleButtonElement;
let livePhotoPlayButtonElement: HTMLButtonElement | undefined;
/**
* The live photo muted toggle DOM button element.
*/
let livePhotoMuteButtonElement: HTMLButtonElement | undefined;
/**
* Update the state of the given {@link videoElement} and the
* {@link livePhotoPlayButtonElement} to reflect {@link livePhotoPlay}.
*/
const livePhotoUpdatePlay = (video: HTMLVideoElement) => {
const button = livePhotoPlayButtonElement;
if (button) showIf(button, true);
if (livePhotoPlay) {
@@ -405,17 +414,47 @@ export class FileViewerPhotoSwipe {
}
};
/**
* Update the state of the given {@link videoElement} and the
* {@link livePhotoMuteButtonElement} to reflect {@link livePhotoMute}.
*/
const livePhotoUpdateMute = (video: HTMLVideoElement) => {
const button = livePhotoMuteButtonElement;
if (button) showIf(button, true);
if (livePhotoMute) {
button?.classList.add("pswp-ente-off");
video.muted = true;
} else {
button?.classList.remove("pswp-ente-off");
video.muted = false;
}
};
/**
* Toggle the playback, if possible, of a live photo that's being shown
* on the current slide.
*/
const livePhotoTogglePlaybackIfPossible = () => {
const buttonElement = livePhotoToggleButtonElement;
const livePhotoTogglePlayIfPossible = () => {
const buttonElement = livePhotoPlayButtonElement;
const video = livePhotoVideoOnSlide(pswp.currSlide);
if (!buttonElement || !video) return;
livePhotoPlay = !livePhotoPlay;
livePhotoUpdatePlayback(video);
livePhotoUpdatePlay(video);
};
/**
* Toggle the muted status, if possible, of a live photo that's being shown
* on the current slide.
*/
const livePhotoToggleMuteIfPossible = () => {
const buttonElement = livePhotoMuteButtonElement;
const video = livePhotoVideoOnSlide(pswp.currSlide);
if (!buttonElement || !video) return;
livePhotoMute = !livePhotoMute;
livePhotoUpdateMute(video);
};
pswp.on("contentAppend", (e) => {
@@ -448,7 +487,8 @@ export class FileViewerPhotoSwipe {
// already been called, but now "contentAppend" is happening.
if (pswp.currSlide.data.fileID == fileID) {
livePhotoUpdatePlayback(video);
livePhotoUpdatePlay(video);
livePhotoUpdateMute(video);
}
});
@@ -505,10 +545,23 @@ export class FileViewerPhotoSwipe {
pswp.on("contentDestroy", (e) => forgetExifForItemData(e.content.data));
// State needed to hide the caption when a video is playing on a file of
// type video.
/**
* If the current slide is showing a video, then the DOM video element
* showing that video.
*/
let videoVideoEl: HTMLVideoElement | undefined;
/**
* Callback attached to video playback events when showing video files.
*
* These are needed to hide the caption when a video is playing on a
* file of type video.
*/
let onVideoPlayback: EventHandler | undefined;
/**
* The DOM element showing the caption for the current file.
*/
let captionElement: HTMLElement | undefined;
pswp.on("change", (e) => {
@@ -549,6 +602,32 @@ export class FileViewerPhotoSwipe {
}
});
/**
* Toggle the playback, if possible, of the video that's being shown on
* the current slide.
*/
const videoTogglePlayIfPossible = () => {
const video = videoVideoEl;
if (!video) return;
if (video.paused || video.ended) {
video.play();
} else {
video.pause();
}
};
/**
* Toggle the muted status, if possible, of the video that's being shown on
* the current slide.
*/
const videoToggleMuteIfPossible = () => {
const video = videoVideoEl;
if (!video) return;
video.muted = !video.muted;
};
// The PhotoSwipe dialog has being closed and the animations have
// completed.
pswp.on("destroy", () => {
@@ -564,7 +643,6 @@ export class FileViewerPhotoSwipe {
const handleViewInfo = () => onViewInfo(currentAnnotatedFile());
let favoriteButtonElement: HTMLButtonElement | undefined;
let unfavoriteButtonElement: HTMLButtonElement | undefined;
/**
* IDs of files for which a there is a favorite update in progress.
@@ -574,29 +652,32 @@ export class FileViewerPhotoSwipe {
const toggleFavorite = async () => {
const af = currentAnnotatedFile();
pendingFavoriteUpdates.add(af.file.id);
favoriteButtonElement.disabled = true;
// unfavoriteButtonElement.disabled = true;
updateFavoriteButton();
await delegate.toggleFavorite(af);
pendingFavoriteUpdates.delete(af.file.id);
favoriteButtonElement.disabled = false;
// TODO: We reload the entire slide instead of just updating
// the button state. This is because there are two buttons,
// instead of a single button toggling between two states
// e.g. like the zoom button.
//
// To fix this, a single button can be achieved by moving
// the fill of the heart as a layer.
// this.refreshCurrentSlideContent();
updateFavoriteButton();
};
const updateFavoriteButton = () => {
const button = favoriteButtonElement!;
const af = currentAnnotatedFile();
const isFavorite = delegate.isFavorite(af);
const fill = document.getElementById("pswp__icn-favorite-fill")!;
console.log(fill, isFavorite);
fill.style.display = isFavorite ? "initial" : "none";
// if (fill) showIf(fill, isFavorite);
const showFavorite = af.annotation.showFavorite;
showIf(button, showFavorite);
if (!showFavorite) {
// Nothing more to do.
return;
}
// Update the button interactivity based on pending requests.
button.disabled = pendingFavoriteUpdates.has(af.file.id);
// Update the fill visibility based on the favorite status.
showIf(
document.getElementById("pswp__icn-favorite-fill")!,
delegate.isFavorite(af),
);
};
const handleToggleFavorite = () => void toggleFavorite();
@@ -624,7 +705,7 @@ export class FileViewerPhotoSwipe {
// The "order" prop is used to position items. Some landmarks:
// - counter: 5
// - zoom: 6 (default is 10)
// - preloader: 9 (default is 7)
// - preloader: 10 (default is 7)
// - close: 20
pswp.on("uiRegister", () => {
// Move the zoom button to the left so that it is in the same place
@@ -636,57 +717,56 @@ export class FileViewerPhotoSwipe {
// order since that only allows us to edit the DOM element, not the
// underlying UI element data.
pswp.ui.uiElementsData.find((e) => e.name == "zoom").order = 6;
pswp.ui.uiElementsData.find((e) => e.name == "preloader").order = 9;
pswp.ui.uiElementsData.find((e) => e.name == "preloader").order =
10;
// Register our custom elements...
pswp.ui.registerElement({
name: "live",
// TODO(PS):
title: pt("Toggle live"),
// Safe to use the same order, since this will only be shown if
// there are no errors.
title: pt("Live"),
order: 7,
isButton: true,
html: createPSRegisterElementIconHTML("live"),
onInit: (buttonElement) => {
livePhotoToggleButtonElement = buttonElement;
livePhotoPlayButtonElement = buttonElement;
pswp.on("change", () => {
const video = livePhotoVideoOnSlide(pswp.currSlide);
if (!video) {
if (video) {
livePhotoUpdatePlay(video);
} else {
// Not a live photo, or its video hasn't loaded yet.
showIf(buttonElement, false);
return;
}
livePhotoUpdatePlayback(video);
});
},
onClick: livePhotoTogglePlaybackIfPossible,
onClick: livePhotoTogglePlayIfPossible,
});
pswp.ui.registerElement({
name: "vol",
// TODO(PS):
title: pt("Toggle audio"),
title: pt("Audio"),
order: 8,
isButton: true,
html: createPSRegisterElementIconHTML("vol"),
onInit: (buttonElement) => {
buttonElement.style.display = "none";
// buttonElement.setAttribute("id", moreButtonID);
// buttonElement.setAttribute("aria-haspopup", "true");
},
onClick: (e) => {
// const buttonElement = e.target;
// See also: `resetMoreMenuButtonOnMenuClose`.
// buttonElement.setAttribute("aria-controls", moreMenuID);
// buttonElement.setAttribute("aria-expanded", true);
// onMore(buttonElement);
livePhotoMuteButtonElement = buttonElement;
pswp.on("change", () => {
const video = livePhotoVideoOnSlide(pswp.currSlide);
if (video) {
livePhotoUpdateMute(video);
} else {
// Not a live photo, or its video hasn't loaded yet.
showIf(buttonElement, false);
}
});
},
onClick: livePhotoToggleMuteIfPossible,
});
// TODO(PS): Add force convert button for videos? Or is that covered
// by upcoming streaming changes?
pswp.ui.registerElement({
name: "error",
order: 9,
@@ -703,67 +783,31 @@ export class FileViewerPhotoSwipe {
},
});
// Only one of these two ("favorite" and "download") will end
// up being shown, so they can safely share the same order.
if (haveUser) {
const showFavoriteIf = (
buttonElement: HTMLButtonElement,
value: boolean,
) => {
updateFavoriteButton();
return;
const af = currentAnnotatedFile();
const isFavorite = delegate.isFavorite(af);
showIf(
buttonElement,
af.annotation.showFavorite && isFavorite === value,
);
buttonElement.disabled = pendingFavoriteUpdates.has(
af.file.id,
);
};
// Only one of these two ("favorite" or "unfavorite") will end
// up being shown, so they can safely share the same order.
pswp.ui.registerElement({
name: "favorite",
title: pt("Favorite"),
order: 11,
isButton: true,
html: createPSRegisterElementIconHTML("favorite"),
onClick: handleToggleFavorite,
onInit: (buttonElement) => {
favoriteButtonElement = buttonElement;
pswp.on("change", () =>
showFavoriteIf(buttonElement, false),
);
pswp.on("change", updateFavoriteButton);
},
onClick: handleToggleFavorite,
});
// pswp.ui.registerElement({
// name: "unfavorite",
// title: pt("Favorite"),
// order: 11,
// isButton: true,
// html: createPSRegisterElementIconHTML("unfavorite"),
// onClick: handleToggleFavorite,
// onInit: (buttonElement) => {
// unfavoriteButtonElement = buttonElement;
// pswp.on("change", () =>
// showFavoriteIf(buttonElement, true),
// );
// },
// });
} else {
// When we don't have a user (i.e. in the context of public
// albums), the download button is shown (if enabled for that
// album) instead of the favorite button as the first action.
//
// It can thus also use the same order as fav/unfav.
pswp.ui.registerElement({
name: "download",
title: t("download"),
order: 11,
isButton: true,
html: createPSRegisterElementIconHTML("download"),
onClick: handleDownload,
onInit: (buttonElement) =>
pswp.on("change", () =>
showIf(
@@ -771,6 +815,7 @@ export class FileViewerPhotoSwipe {
currentFileAnnotation().showDownload == "bar",
),
),
onClick: handleDownload,
});
}
@@ -785,7 +830,6 @@ export class FileViewerPhotoSwipe {
pswp.ui.registerElement({
name: "more",
// TODO(PS):
title: pt("More"),
order: 16,
isButton: true,
@@ -826,15 +870,6 @@ export class FileViewerPhotoSwipe {
});
});
// Modify the default UI elements.
pswp.addFilter("uiElement", (element, data) => {
if (element.name == "preloader") {
// TODO(PS): Left as an example. For now, this is customized in
// the CSS.
}
return element;
});
// Pan action handlers
const panner = (key: "w" | "a" | "s" | "d") => () => {
@@ -864,10 +899,24 @@ export class FileViewerPhotoSwipe {
lastActivityDate = new Date();
};
const handleTogglePlaybackIfPossible = () => {
const handleTogglePlayIfPossible = () => {
switch (currentAnnotatedFile().itemData.fileType) {
case FileType.video:
videoTogglePlayIfPossible();
return;
case FileType.livePhoto:
livePhotoTogglePlaybackIfPossible();
livePhotoTogglePlayIfPossible();
return;
}
};
const handleToggleMuteIfPossible = () => {
switch (currentAnnotatedFile().itemData.fileType) {
case FileType.video:
videoToggleMuteIfPossible();
return;
case FileType.livePhoto:
livePhotoToggleMuteIfPossible();
return;
}
};
@@ -920,7 +969,7 @@ export class FileViewerPhotoSwipe {
// Space activates controls when they're focused, so
// only act on it if no specific control is focused.
if (!isFocusedOnUIControl()) {
cb = handleTogglePlaybackIfPossible;
cb = handleTogglePlayIfPossible;
}
break;
case "Backspace":
@@ -943,6 +992,9 @@ export class FileViewerPhotoSwipe {
case "h":
cb = handleToggleUIControls;
break;
case "m":
cb = handleToggleMuteIfPossible;
break;
case "l":
cb = handleToggleFavoriteIfEnabled;
break;

View File

@@ -55,7 +55,10 @@ export async function updateMagicMetadata<T>(
}
if (typeof originalMagicMetadata?.data === "string") {
// TODO: Is this even used?
// TODO: When converting this (and other parses of magic metadata) to
// use zod, remember to use passthrough.
//
// See: [Note: Use passthrough for metadata Zod schemas]
// @ts-expect-error TODO: Need to use zod here.
originalMagicMetadata.data = await cryptoWorker.decryptMetadataJSON({
encryptedDataB64: originalMagicMetadata.data,