Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
6b0487dc
Commit
6b0487dc
authored
Apr 06, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: unify live photo previews around LIVE badge playback
parent
065e8174
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
240 additions
and
30 deletions
+240
-30
AttachmentListView.tsx
...components/MemoMetadata/Attachment/AttachmentListView.tsx
+13
-9
useImagePreview.ts
web/src/components/MemoView/hooks/useImagePreview.ts
+0
-2
MotionPhotoPlayer.tsx
web/src/components/MotionPhotoPlayer.tsx
+115
-0
MotionPhotoPreview.tsx
web/src/components/MotionPhotoPreview.tsx
+75
-0
PreviewImageDialog.tsx
web/src/components/PreviewImageDialog.tsx
+13
-8
media-item.ts
web/src/utils/media-item.ts
+24
-11
No files found.
web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx
View file @
6b0487dc
import
{
DownloadIcon
,
FileIcon
,
PaperclipIcon
,
PlayIcon
}
from
"lucide-react"
;
import
{
DownloadIcon
,
FileIcon
,
PaperclipIcon
,
PlayIcon
}
from
"lucide-react"
;
import
{
useMemo
}
from
"react"
;
import
{
useMemo
}
from
"react"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection"
;
import
MetadataSection
from
"@/components/MemoMetadata/MetadataSection"
;
import
MotionPhotoPreview
from
"@/components/MotionPhotoPreview"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
cn
}
from
"@/lib/utils"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
type
{
Attachment
}
from
"@/types/proto/api/v1/attachment_service_pb"
;
import
{
getAttachmentUrl
}
from
"@/utils/attachment"
;
import
{
getAttachmentUrl
}
from
"@/utils/attachment"
;
...
@@ -49,12 +50,6 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
...
@@ -49,12 +50,6 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
);
);
};
};
const
MotionBadge
=
()
=>
(
<
span
className=
"pointer-events-none absolute left-2 top-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white backdrop-blur-sm"
>
LIVE
</
span
>
);
const
MotionItem
=
({
const
MotionItem
=
({
item
,
item
,
featured
=
false
,
featured
=
false
,
...
@@ -82,6 +77,16 @@ const MotionItem = ({
...
@@ -82,6 +77,16 @@ const MotionItem = ({
className=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
className=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
preload=
"metadata"
preload=
"metadata"
/>
/>
)
:
item
.
kind
===
"motion"
?
(
<
MotionPhotoPreview
posterUrl=
{
item
.
posterUrl
}
motionUrl=
{
item
.
previewItem
.
kind
===
"motion"
?
item
.
previewItem
.
motionUrl
:
item
.
sourceUrl
}
alt=
{
item
.
filename
}
presentationTimestampUs=
{
item
.
previewItem
.
kind
===
"motion"
?
item
.
previewItem
.
presentationTimestampUs
:
undefined
}
containerClassName=
"h-full w-full"
badgeClassName=
"left-2 top-2 px-2 py-0.5 text-[10px]"
mediaClassName=
"h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
)
:
(
)
:
(
<
img
<
img
src=
{
item
.
posterUrl
}
src=
{
item
.
posterUrl
}
...
@@ -91,9 +96,8 @@ const MotionItem = ({
...
@@ -91,9 +96,8 @@ const MotionItem = ({
decoding=
"async"
decoding=
"async"
/>
/>
)
}
)
}
<
div
className=
"pointer-events-none absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
/>
<
div
className=
"pointer-events-none absolute inset-0 bg-gradient-to-t from-foreground/15 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100"
/>
{
item
.
kind
===
"motion"
&&
<
MotionBadge
/>
}
{
item
.
kind
===
"video"
&&
(
{
item
.
previewItem
.
kind
===
"video"
&&
(
<
span
className=
"pointer-events-none absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm"
>
<
span
className=
"pointer-events-none absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/80 text-foreground/70 backdrop-blur-sm"
>
<
PlayIcon
className=
"h-3.5 w-3.5 fill-current"
/>
<
PlayIcon
className=
"h-3.5 w-3.5 fill-current"
/>
</
span
>
</
span
>
...
...
web/src/components/MemoView/hooks/useImagePreview.ts
View file @
6b0487dc
...
@@ -37,7 +37,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P
...
@@ -37,7 +37,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P
sourceUrl
:
items
,
sourceUrl
:
items
,
posterUrl
:
items
,
posterUrl
:
items
,
filename
:
"Image"
,
filename
:
"Image"
,
isMotion
:
false
,
},
},
];
];
}
}
...
@@ -49,7 +48,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P
...
@@ -49,7 +48,6 @@ function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): P
sourceUrl
:
url
,
sourceUrl
:
url
,
posterUrl
:
url
,
posterUrl
:
url
,
filename
:
"Image"
,
filename
:
"Image"
,
isMotion
:
false
,
}));
}));
}
}
...
...
web/src/components/MotionPhotoPlayer.tsx
0 → 100644
View file @
6b0487dc
import
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
cn
}
from
"@/lib/utils"
;
interface
MotionPhotoPlayerProps
{
posterUrl
:
string
;
motionUrl
:
string
;
alt
:
string
;
presentationTimestampUs
?:
bigint
;
containerClassName
?:
string
;
mediaClassName
?:
string
;
active
?:
boolean
;
loop
?:
boolean
;
}
const
MotionPhotoPlayer
=
({
posterUrl
,
motionUrl
,
alt
,
presentationTimestampUs
,
containerClassName
,
mediaClassName
,
active
,
loop
=
false
,
}:
MotionPhotoPlayerProps
)
=>
{
const
videoRef
=
useRef
<
HTMLVideoElement
>
(
null
);
const
[
isPlaying
,
setIsPlaying
]
=
useState
(
false
);
const
resetPlaybackPosition
=
useCallback
(
(
video
:
HTMLVideoElement
)
=>
{
const
startTime
=
presentationTimestampUs
&&
presentationTimestampUs
>
0
n
?
Number
(
presentationTimestampUs
)
/
1
_000_000
:
0
;
video
.
currentTime
=
startTime
;
},
[
presentationTimestampUs
],
);
const
stopPlayback
=
useCallback
(
(
resetPosition
=
true
)
=>
{
const
video
=
videoRef
.
current
;
if
(
!
video
)
{
return
;
}
video
.
pause
();
if
(
resetPosition
&&
video
.
readyState
>=
1
)
{
resetPlaybackPosition
(
video
);
}
setIsPlaying
(
false
);
},
[
resetPlaybackPosition
],
);
const
startPlayback
=
useCallback
(
async
(
loop
:
boolean
)
=>
{
const
video
=
videoRef
.
current
;
if
(
!
video
)
{
return
;
}
video
.
loop
=
loop
;
if
(
video
.
readyState
>=
1
)
{
resetPlaybackPosition
(
video
);
}
try
{
await
video
.
play
();
setIsPlaying
(
true
);
}
catch
{
setIsPlaying
(
false
);
}
},
[
resetPlaybackPosition
],
);
useEffect
(()
=>
stopPlayback
,
[
stopPlayback
]);
useEffect
(()
=>
{
if
(
!
active
)
{
stopPlayback
();
return
;
}
void
startPlayback
(
loop
);
},
[
active
,
loop
,
startPlayback
,
stopPlayback
]);
return
(
<
div
className=
{
cn
(
"relative overflow-hidden"
,
containerClassName
)
}
>
<
img
src=
{
posterUrl
}
alt=
{
alt
}
className=
{
cn
(
"block max-h-full max-w-full select-none object-cover"
,
mediaClassName
)
}
draggable=
{
false
}
loading=
"lazy"
decoding=
"async"
/>
<
video
ref=
{
videoRef
}
src=
{
motionUrl
}
poster=
{
posterUrl
}
className=
{
cn
(
"pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-200"
,
isPlaying
?
"opacity-100"
:
"opacity-0"
,
mediaClassName
,
)
}
muted
playsInline
preload=
"metadata"
disableRemotePlayback
onLoadedMetadata=
{
(
event
)
=>
resetPlaybackPosition
(
event
.
currentTarget
)
}
onEnded=
{
()
=>
stopPlayback
()
}
/>
</
div
>
);
};
export
default
MotionPhotoPlayer
;
web/src/components/MotionPhotoPreview.tsx
0 → 100644
View file @
6b0487dc
import
{
useEffect
,
useState
}
from
"react"
;
import
MotionPhotoPlayer
from
"@/components/MotionPhotoPlayer"
;
import
{
cn
}
from
"@/lib/utils"
;
interface
MotionPhotoPreviewProps
{
posterUrl
:
string
;
motionUrl
:
string
;
alt
:
string
;
presentationTimestampUs
?:
bigint
;
containerClassName
?:
string
;
mediaClassName
?:
string
;
badgeClassName
?:
string
;
loop
?:
boolean
;
}
const
MotionPhotoPreview
=
({
posterUrl
,
motionUrl
,
alt
,
presentationTimestampUs
,
containerClassName
,
mediaClassName
,
badgeClassName
,
loop
=
false
,
}:
MotionPhotoPreviewProps
)
=>
{
const
[
motionActive
,
setMotionActive
]
=
useState
(
false
);
useEffect
(()
=>
{
setMotionActive
(
false
);
},
[
motionUrl
,
posterUrl
]);
return
(
<
div
className=
{
cn
(
"relative max-w-full max-h-full"
,
containerClassName
)
}
>
<
MotionPhotoPlayer
posterUrl=
{
posterUrl
}
motionUrl=
{
motionUrl
}
alt=
{
alt
}
presentationTimestampUs=
{
presentationTimestampUs
}
active=
{
motionActive
}
loop=
{
loop
}
containerClassName=
{
cn
(
"max-w-full max-h-full"
,
containerClassName
)
}
mediaClassName=
{
mediaClassName
}
/>
<
button
type=
"button"
className=
{
cn
(
"absolute rounded-full border border-border/45 bg-background/65 px-2.5 py-1 text-xs font-semibold tracking-wide text-foreground backdrop-blur-sm transition-colors hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
,
badgeClassName
,
)
}
onMouseEnter=
{
()
=>
setMotionActive
(
true
)
}
onMouseLeave=
{
()
=>
setMotionActive
(
false
)
}
onFocus=
{
()
=>
setMotionActive
(
true
)
}
onBlur=
{
()
=>
setMotionActive
(
false
)
}
onPointerDown=
{
(
event
)
=>
{
event
.
stopPropagation
();
if
(
event
.
pointerType
!==
"mouse"
)
{
setMotionActive
(
true
);
}
}
}
onPointerUp=
{
(
event
)
=>
{
event
.
stopPropagation
();
if
(
event
.
pointerType
!==
"mouse"
)
{
setMotionActive
(
false
);
}
}
}
onPointerCancel=
{
()
=>
setMotionActive
(
false
)
}
aria
-
label=
"Hover or press to play live photo"
>
LIVE
</
button
>
</
div
>
);
};
export
default
MotionPhotoPreview
;
web/src/components/PreviewImageDialog.tsx
View file @
6b0487dc
import
{
X
}
from
"lucide-react"
;
import
{
X
}
from
"lucide-react"
;
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
MotionPhotoPreview
from
"@/components/MotionPhotoPreview"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Dialog
,
DialogContent
}
from
"@/components/ui/dialog"
;
import
{
Dialog
,
DialogContent
}
from
"@/components/ui/dialog"
;
import
type
{
PreviewMediaItem
}
from
"@/utils/media-item"
;
import
type
{
PreviewMediaItem
}
from
"@/utils/media-item"
;
...
@@ -15,8 +16,7 @@ interface Props {
...
@@ -15,8 +16,7 @@ interface Props {
function
PreviewImageDialog
({
open
,
onOpenChange
,
imgUrls
=
[],
items
,
initialIndex
=
0
}:
Props
)
{
function
PreviewImageDialog
({
open
,
onOpenChange
,
imgUrls
=
[],
items
,
initialIndex
=
0
}:
Props
)
{
const
[
currentIndex
,
setCurrentIndex
]
=
useState
(
initialIndex
);
const
[
currentIndex
,
setCurrentIndex
]
=
useState
(
initialIndex
);
const
previewItems
=
const
previewItems
=
items
??
items
??
imgUrls
.
map
((
url
)
=>
({
id
:
url
,
kind
:
"image"
as
const
,
sourceUrl
:
url
,
posterUrl
:
url
,
filename
:
"Image"
}));
imgUrls
.
map
((
url
)
=>
({
id
:
url
,
kind
:
"image"
as
const
,
sourceUrl
:
url
,
posterUrl
:
url
,
filename
:
"Image"
,
isMotion
:
false
}));
// Update current index when initialIndex prop changes
// Update current index when initialIndex prop changes
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -93,11 +93,16 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
...
@@ -93,11 +93,16 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
className=
"max-w-full max-h-full object-contain"
className=
"max-w-full max-h-full object-contain"
controls
controls
autoPlay
autoPlay
onLoadedMetadata=
{
(
event
)
=>
{
/>
if
(
currentItem
.
presentationTimestampUs
&&
currentItem
.
presentationTimestampUs
>
0
n
)
{
)
:
currentItem
.
kind
===
"motion"
?
(
event
.
currentTarget
.
currentTime
=
Number
(
currentItem
.
presentationTimestampUs
)
/
1
_000_000
;
<
MotionPhotoPreview
}
key=
{
currentItem
.
id
}
}
}
posterUrl=
{
currentItem
.
posterUrl
}
motionUrl=
{
currentItem
.
motionUrl
}
alt=
{
`Preview live photo ${safeIndex + 1} of ${previewItems.length}`
}
presentationTimestampUs=
{
currentItem
.
presentationTimestampUs
}
badgeClassName=
"left-4 top-4"
mediaClassName=
"max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] object-contain sm:max-h-[calc(100vh-4rem)] sm:max-w-[calc(100vw-4rem)]"
/>
/>
)
:
(
)
:
(
<
img
<
img
...
@@ -113,7 +118,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
...
@@ -113,7 +118,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
{
/* Screen reader description */
}
{
/* Screen reader description */
}
<
div
id=
"image-preview-description"
className=
"sr-only"
>
<
div
id=
"image-preview-description"
className=
"sr-only"
>
Image preview dialog. Press Escape to close or click outside the image
.
Attachment preview dialog. Press Escape to close or click outside the media
.
</
div
>
</
div
>
</
DialogContent
>
</
DialogContent
>
</
Dialog
>
</
Dialog
>
...
...
web/src/utils/media-item.ts
View file @
6b0487dc
...
@@ -11,16 +11,32 @@ import {
...
@@ -11,16 +11,32 @@ import {
isMotionAttachment
,
isMotionAttachment
,
}
from
"./attachment"
;
}
from
"./attachment"
;
export
interface
PreviewMediaItem
{
interface
PreviewMediaItemBase
{
id
:
string
;
id
:
string
;
kind
:
"image"
|
"video"
;
filename
:
string
;
}
export
interface
ImagePreviewMediaItem
extends
PreviewMediaItemBase
{
kind
:
"image"
;
sourceUrl
:
string
;
sourceUrl
:
string
;
posterUrl
?:
string
;
posterUrl
?:
string
;
filename
:
string
;
}
isMotion
:
boolean
;
export
interface
VideoPreviewMediaItem
extends
PreviewMediaItemBase
{
kind
:
"video"
;
sourceUrl
:
string
;
posterUrl
?:
string
;
}
export
interface
MotionPreviewMediaItem
extends
PreviewMediaItemBase
{
kind
:
"motion"
;
posterUrl
:
string
;
motionUrl
:
string
;
presentationTimestampUs
?:
bigint
;
presentationTimestampUs
?:
bigint
;
}
}
export
type
PreviewMediaItem
=
ImagePreviewMediaItem
|
VideoPreviewMediaItem
|
MotionPreviewMediaItem
;
export
interface
AttachmentVisualItem
{
export
interface
AttachmentVisualItem
{
id
:
string
;
id
:
string
;
kind
:
"image"
|
"video"
|
"motion"
;
kind
:
"image"
|
"video"
|
"motion"
;
...
@@ -115,7 +131,6 @@ function buildSingleAttachmentItem(attachment: Attachment): AttachmentVisualItem
...
@@ -115,7 +131,6 @@ function buildSingleAttachmentItem(attachment: Attachment): AttachmentVisualItem
sourceUrl
,
sourceUrl
,
posterUrl
,
posterUrl
,
filename
:
attachment
.
filename
,
filename
:
attachment
.
filename
,
isMotion
:
false
,
},
},
mimeType
:
attachment
.
type
,
mimeType
:
attachment
.
type
,
};
};
...
@@ -135,11 +150,10 @@ function buildAppleMotionItem(still: Attachment, video: Attachment): AttachmentV
...
@@ -135,11 +150,10 @@ function buildAppleMotionItem(still: Attachment, video: Attachment): AttachmentV
attachments
:
[
still
,
video
],
attachments
:
[
still
,
video
],
previewItem
:
{
previewItem
:
{
id
:
getAttachmentMotionGroupId
(
still
)
??
still
.
name
,
id
:
getAttachmentMotionGroupId
(
still
)
??
still
.
name
,
kind
:
"video"
,
kind
:
"motion"
,
sourceUrl
,
posterUrl
,
posterUrl
,
motionUrl
:
sourceUrl
,
filename
:
still
.
filename
,
filename
:
still
.
filename
,
isMotion
:
true
,
},
},
mimeType
:
still
.
type
,
mimeType
:
still
.
type
,
};
};
...
@@ -156,11 +170,10 @@ function buildAndroidMotionItem(attachment: Attachment): AttachmentVisualItem {
...
@@ -156,11 +170,10 @@ function buildAndroidMotionItem(attachment: Attachment): AttachmentVisualItem {
attachments
:
[
attachment
],
attachments
:
[
attachment
],
previewItem
:
{
previewItem
:
{
id
:
attachment
.
name
,
id
:
attachment
.
name
,
kind
:
"
video
"
,
kind
:
"
motion
"
,
source
Url
:
getAttachmentMotionClipUrl
(
attachment
),
motion
Url
:
getAttachmentMotionClipUrl
(
attachment
),
posterUrl
:
getAttachmentThumbnailUrl
(
attachment
),
posterUrl
:
getAttachmentThumbnailUrl
(
attachment
),
filename
:
attachment
.
filename
,
filename
:
attachment
.
filename
,
isMotion
:
true
,
presentationTimestampUs
:
attachment
.
motionMedia
?.
presentationTimestampUs
,
presentationTimestampUs
:
attachment
.
motionMedia
?.
presentationTimestampUs
,
},
},
mimeType
:
attachment
.
type
,
mimeType
:
attachment
.
type
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment