@linktr.ee/messaging-react 1.31.0 → 1.32.1-rc-1777007852
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Card-1CQEn-OT.js +171 -0
- package/dist/Card-1CQEn-OT.js.map +1 -0
- package/dist/Card-ClE_iExA.js +177 -0
- package/dist/Card-ClE_iExA.js.map +1 -0
- package/dist/{MediaPlayer-BCsdmsON.js → MediaPlayer-B9Ws2NeE.js} +115 -135
- package/dist/MediaPlayer-B9Ws2NeE.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +79 -63
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ChannelView.stories.tsx +38 -7
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +127 -0
- package/src/components/CustomMessageInput/index.tsx +25 -8
- package/src/components/LockedAttachment/LockedAttachment.stories.tsx +136 -93
- package/src/components/LockedAttachment/components/Creator/Card.tsx +106 -106
- package/src/components/LockedAttachment/components/Creator/CardThumbnail.tsx +114 -0
- package/src/components/LockedAttachment/components/MediaPlayer.tsx +80 -66
- package/src/components/LockedAttachment/components/Visitor/Card.tsx +53 -78
- package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +3 -3
- package/src/components/LockedAttachment/components/Visitor/CardThumbnail.tsx +81 -0
- package/src/components/LockedAttachment/types.ts +2 -0
- package/dist/Card-C5t3dZ5q.js +0 -350
- package/dist/Card-C5t3dZ5q.js.map +0 -1
- package/dist/Card-Cn2va-Qr.js +0 -205
- package/dist/Card-Cn2va-Qr.js.map +0 -1
- package/dist/MediaPlayer-BCsdmsON.js.map +0 -1
- package/src/components/LockedAttachment/components/Creator/CardAudioPreview.tsx +0 -161
- package/src/components/LockedAttachment/components/Creator/CardCollapsedThumbnail.tsx +0 -58
- package/src/components/LockedAttachment/components/Creator/CardImagePreview.tsx +0 -56
- package/src/components/LockedAttachment/components/Creator/CardVideoPreview.tsx +0 -91
- package/src/components/LockedAttachment/components/Visitor/CardImagePreview.tsx +0 -39
- package/src/components/LockedAttachment/components/Visitor/CardMediaPreview.tsx +0 -36
- package/src/components/LockedAttachment/components/Visitor/CardThumbnailPreview.tsx +0 -45
|
@@ -1,105 +1,72 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CheckCircleIcon,
|
|
3
|
+
EyeIcon,
|
|
4
|
+
EyeSlashIcon,
|
|
3
5
|
LockIcon,
|
|
4
6
|
LockOpenIcon,
|
|
5
7
|
XIcon,
|
|
6
8
|
} from '@phosphor-icons/react'
|
|
7
9
|
import classNames from 'classnames'
|
|
8
|
-
import React from 'react'
|
|
10
|
+
import React, { useCallback, useState } from 'react'
|
|
9
11
|
|
|
10
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
LockedAttachmentBaseProps,
|
|
14
|
+
LockedAttachmentSource,
|
|
15
|
+
PaymentStatus,
|
|
16
|
+
} from '../../types'
|
|
11
17
|
import { renderTypeIcon } from '../../utils/icons'
|
|
12
|
-
import { getSourceType } from '../../utils/mimeType'
|
|
13
18
|
|
|
14
|
-
import
|
|
15
|
-
import CollapsedThumbnail from './CardCollapsedThumbnail'
|
|
16
|
-
import ImagePreview from './CardImagePreview'
|
|
17
|
-
import VideoPreview from './CardVideoPreview'
|
|
19
|
+
import CardThumbnail from './CardThumbnail'
|
|
18
20
|
|
|
19
21
|
export interface CreatorCardProps extends LockedAttachmentBaseProps {
|
|
20
|
-
isPreview?: boolean
|
|
21
22
|
placeholderTitle?: string
|
|
22
23
|
placeholderAmountText?: string
|
|
23
|
-
sourceUrl?: string
|
|
24
24
|
onDismiss?: () => void
|
|
25
|
+
onPreviewClick?: () => LockedAttachmentSource
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
const CreatorCard: React.FC<CreatorCardProps> = (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
sourceUrl={sourceUrl}
|
|
60
|
-
thumbnailUrl={thumbnailUrl}
|
|
61
|
-
mimeType={mimeType}
|
|
28
|
+
const CreatorCard: React.FC<CreatorCardProps> = ({
|
|
29
|
+
title,
|
|
30
|
+
mimeType = 'application/octet-stream',
|
|
31
|
+
thumbnailUrl,
|
|
32
|
+
detail,
|
|
33
|
+
amountText,
|
|
34
|
+
placeholderTitle = 'Attachment title',
|
|
35
|
+
placeholderAmountText,
|
|
36
|
+
paymentStatus,
|
|
37
|
+
onDismiss,
|
|
38
|
+
onPreviewClick,
|
|
39
|
+
}) => {
|
|
40
|
+
const [source, setSource] = useState<LockedAttachmentSource | undefined>()
|
|
41
|
+
|
|
42
|
+
const effectiveSourceUrl = source?.sourceUrl
|
|
43
|
+
const effectiveThumbnailUrl = source?.thumbnailUrl ?? thumbnailUrl
|
|
44
|
+
|
|
45
|
+
const handleToggle = useCallback(() => {
|
|
46
|
+
if (source) {
|
|
47
|
+
setSource(undefined)
|
|
48
|
+
} else if (onPreviewClick) {
|
|
49
|
+
setSource(onPreviewClick())
|
|
50
|
+
}
|
|
51
|
+
}, [source, onPreviewClick])
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="relative w-[280px] select-none overflow-hidden rounded-[24px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]">
|
|
55
|
+
<CardHeader
|
|
56
|
+
onDismiss={onDismiss}
|
|
57
|
+
onPreviewClick={onPreviewClick}
|
|
58
|
+
sourceUrl={source?.sourceUrl}
|
|
59
|
+
paymentStatus={paymentStatus}
|
|
62
60
|
/>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
mediaPreview = (
|
|
66
|
-
<ImagePreview
|
|
67
|
-
key={sourceUrl}
|
|
68
|
-
sourceUrl={sourceUrl}
|
|
69
|
-
thumbnailUrl={thumbnailUrl}
|
|
70
|
-
mimeType={mimeType}
|
|
61
|
+
|
|
62
|
+
<CardThumbnail
|
|
71
63
|
title={title}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} else {
|
|
75
|
-
const lockedOverlayIcon = onDismiss
|
|
76
|
-
? undefined
|
|
77
|
-
: paymentStatus === 'paid'
|
|
78
|
-
? LockOpenIcon
|
|
79
|
-
: LockIcon
|
|
80
|
-
mediaPreview = (
|
|
81
|
-
<CollapsedThumbnail
|
|
82
|
-
thumbnailUrl={thumbnailUrl}
|
|
64
|
+
sourceUrl={effectiveSourceUrl}
|
|
65
|
+
thumbnailUrl={effectiveThumbnailUrl}
|
|
83
66
|
mimeType={mimeType}
|
|
84
|
-
|
|
85
|
-
darkOverlay
|
|
67
|
+
onToggle={onPreviewClick ? handleToggle : undefined}
|
|
86
68
|
/>
|
|
87
|
-
)
|
|
88
|
-
}
|
|
89
69
|
|
|
90
|
-
return (
|
|
91
|
-
<div className="relative w-[280px] select-none overflow-hidden rounded-[24px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]">
|
|
92
|
-
{onDismiss && (
|
|
93
|
-
<button
|
|
94
|
-
type="button"
|
|
95
|
-
onClick={onDismiss}
|
|
96
|
-
className="absolute right-3 top-3 z-50 flex size-8 items-center justify-center rounded-full bg-black/60 text-white"
|
|
97
|
-
aria-label="Dismiss attachment"
|
|
98
|
-
>
|
|
99
|
-
<XIcon className="size-4" weight="bold" />
|
|
100
|
-
</button>
|
|
101
|
-
)}
|
|
102
|
-
{mediaPreview}
|
|
103
70
|
<div className="px-4 pb-3 pt-3">
|
|
104
71
|
<p
|
|
105
72
|
className={classNames('mb-1.5 truncate text-base font-medium', {
|
|
@@ -109,46 +76,38 @@ const CreatorCard: React.FC<CreatorCardProps> = (props) => {
|
|
|
109
76
|
>
|
|
110
77
|
{title || placeholderTitle}
|
|
111
78
|
</p>
|
|
79
|
+
|
|
112
80
|
<div className="flex items-center gap-1">
|
|
113
81
|
{renderTypeIcon(mimeType, {
|
|
114
82
|
className: 'size-5 shrink-0 text-black/55',
|
|
115
83
|
weight: 'regular',
|
|
116
84
|
})}
|
|
85
|
+
|
|
117
86
|
{detail && (
|
|
118
87
|
<span className="text-xs font-medium text-black/55">{detail}</span>
|
|
119
88
|
)}
|
|
89
|
+
|
|
120
90
|
{paymentStatus === 'paid' ? (
|
|
121
|
-
|
|
122
|
-
<span className="text-xs font-medium text-black/55"
|
|
123
|
-
<span className="text-xs font-medium text-[#008236]">
|
|
124
|
-
Purchased
|
|
125
|
-
</span>
|
|
91
|
+
<React.Fragment>
|
|
92
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
93
|
+
<span className="text-xs font-medium text-[#008236]">Sold</span>
|
|
126
94
|
<CheckCircleIcon
|
|
127
95
|
className="size-4 text-[#008236]"
|
|
128
96
|
weight="bold"
|
|
129
97
|
/>
|
|
130
|
-
|
|
98
|
+
</React.Fragment>
|
|
131
99
|
) : (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
className={classNames('text-xs font-medium', {
|
|
144
|
-
'text-black/30': isPlaceholderAmount,
|
|
145
|
-
'text-black/55': !isPlaceholderAmount,
|
|
146
|
-
})}
|
|
147
|
-
>
|
|
148
|
-
{displayAmountText}
|
|
149
|
-
</span>
|
|
150
|
-
</>
|
|
151
|
-
)
|
|
100
|
+
<React.Fragment>
|
|
101
|
+
<span className="text-xs font-medium text-black/55">•</span>
|
|
102
|
+
<span
|
|
103
|
+
className={classNames('text-xs font-medium', {
|
|
104
|
+
'text-black/30': !amountText,
|
|
105
|
+
'text-black/55': !!amountText,
|
|
106
|
+
})}
|
|
107
|
+
>
|
|
108
|
+
{amountText || placeholderAmountText}
|
|
109
|
+
</span>
|
|
110
|
+
</React.Fragment>
|
|
152
111
|
)}
|
|
153
112
|
</div>
|
|
154
113
|
</div>
|
|
@@ -156,4 +115,45 @@ const CreatorCard: React.FC<CreatorCardProps> = (props) => {
|
|
|
156
115
|
)
|
|
157
116
|
}
|
|
158
117
|
|
|
118
|
+
interface CardHeaderProps {
|
|
119
|
+
onDismiss?: () => void
|
|
120
|
+
onPreviewClick?: () => void
|
|
121
|
+
sourceUrl?: string
|
|
122
|
+
paymentStatus?: PaymentStatus
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const CardHeader: React.FC<CardHeaderProps> = ({
|
|
126
|
+
onDismiss,
|
|
127
|
+
onPreviewClick,
|
|
128
|
+
sourceUrl,
|
|
129
|
+
paymentStatus,
|
|
130
|
+
}) => {
|
|
131
|
+
if (onDismiss) {
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={onDismiss}
|
|
136
|
+
className="absolute top-3 z-50 flex size-8 items-center justify-center rounded-full bg-black/60 text-white right-3"
|
|
137
|
+
aria-label="Dismiss attachment"
|
|
138
|
+
>
|
|
139
|
+
<XIcon className="size-4" weight="bold" />
|
|
140
|
+
</button>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const Icon = onPreviewClick
|
|
145
|
+
? sourceUrl
|
|
146
|
+
? EyeIcon
|
|
147
|
+
: EyeSlashIcon
|
|
148
|
+
: paymentStatus === 'paid'
|
|
149
|
+
? LockOpenIcon
|
|
150
|
+
: LockIcon
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="absolute top-3 z-50 flex size-8 items-center justify-center rounded-full bg-black/60 text-white left-3">
|
|
154
|
+
<Icon className="size-4" weight="fill" />
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
159
|
export default CreatorCard
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import classNames from 'classnames'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
import { renderTypeIcon } from '../../utils/icons'
|
|
5
|
+
import { getSourceType } from '../../utils/mimeType'
|
|
6
|
+
import MediaPlayer from '../MediaPlayer'
|
|
7
|
+
|
|
8
|
+
interface CardThumbnailProps {
|
|
9
|
+
title?: string
|
|
10
|
+
sourceUrl?: string
|
|
11
|
+
thumbnailUrl?: string
|
|
12
|
+
mimeType: string
|
|
13
|
+
onToggle?: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CardThumbnail: React.FC<CardThumbnailProps> = ({
|
|
17
|
+
title,
|
|
18
|
+
sourceUrl,
|
|
19
|
+
thumbnailUrl,
|
|
20
|
+
mimeType,
|
|
21
|
+
onToggle,
|
|
22
|
+
}) => {
|
|
23
|
+
const isExpanded = onToggle && sourceUrl && thumbnailUrl
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
disabled={!onToggle}
|
|
29
|
+
className={classNames(
|
|
30
|
+
'relative block w-full overflow-hidden border-0 bg-black/5 p-0 text-left appearance-none',
|
|
31
|
+
{ 'cursor-pointer': !!onToggle, 'cursor-default': !onToggle }
|
|
32
|
+
)}
|
|
33
|
+
onClick={onToggle}
|
|
34
|
+
aria-label={onToggle ? 'Toggle preview' : undefined}
|
|
35
|
+
>
|
|
36
|
+
{isExpanded ? (
|
|
37
|
+
<ThumbnailMedia
|
|
38
|
+
sourceUrl={sourceUrl}
|
|
39
|
+
thumbnailUrl={thumbnailUrl}
|
|
40
|
+
mimeType={mimeType}
|
|
41
|
+
/>
|
|
42
|
+
) : thumbnailUrl ? (
|
|
43
|
+
<div className="aspect-video overflow-hidden">
|
|
44
|
+
<img
|
|
45
|
+
src={thumbnailUrl}
|
|
46
|
+
alt={title}
|
|
47
|
+
draggable={false}
|
|
48
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
) : (
|
|
52
|
+
<div className="aspect-video flex items-center justify-center">
|
|
53
|
+
{renderTypeIcon(mimeType, {
|
|
54
|
+
className: 'size-12 text-black/20',
|
|
55
|
+
weight: 'regular',
|
|
56
|
+
})}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
{!isExpanded && (
|
|
61
|
+
<div className="pointer-events-none absolute inset-0 bg-black/30" />
|
|
62
|
+
)}
|
|
63
|
+
</button>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ThumbnailMediaProps {
|
|
68
|
+
sourceUrl: string
|
|
69
|
+
thumbnailUrl: string
|
|
70
|
+
mimeType: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ThumbnailMedia: React.FC<ThumbnailMediaProps> = ({
|
|
74
|
+
sourceUrl,
|
|
75
|
+
thumbnailUrl,
|
|
76
|
+
mimeType,
|
|
77
|
+
}) => {
|
|
78
|
+
const sourceType = getSourceType(mimeType)
|
|
79
|
+
|
|
80
|
+
if (sourceType === 'video' || sourceType === 'audio') {
|
|
81
|
+
return (
|
|
82
|
+
<MediaPlayer
|
|
83
|
+
mimeType={mimeType}
|
|
84
|
+
source={sourceUrl}
|
|
85
|
+
poster={thumbnailUrl}
|
|
86
|
+
autoPlay={true}
|
|
87
|
+
loop={true}
|
|
88
|
+
controls={true}
|
|
89
|
+
muted={false}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (sourceType === 'image') {
|
|
95
|
+
return (
|
|
96
|
+
<img src={sourceUrl} alt="" className="block w-full" draggable={false} />
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (sourceType === 'document') {
|
|
101
|
+
return (
|
|
102
|
+
<img
|
|
103
|
+
src={thumbnailUrl}
|
|
104
|
+
alt=""
|
|
105
|
+
className="block w-full"
|
|
106
|
+
draggable={false}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default CardThumbnail
|
|
@@ -3,13 +3,16 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
3
3
|
|
|
4
4
|
import { isDevBuild } from '../../../utils/isDevBuild'
|
|
5
5
|
import { renderTypeIcon } from '../utils/icons'
|
|
6
|
-
import { getSourceType
|
|
6
|
+
import { getSourceType } from '../utils/mimeType'
|
|
7
|
+
|
|
8
|
+
type TouchEventUnion =
|
|
9
|
+
| MouseEvent
|
|
10
|
+
| TouchEvent
|
|
11
|
+
| React.MouseEvent
|
|
12
|
+
| React.TouchEvent
|
|
7
13
|
|
|
8
|
-
const getPlayerBg = (sourceType: AttachmentSourceType, poster?: string) => {
|
|
9
|
-
return sourceType === 'audio' && !poster ? 'bg-black/5' : 'bg-black'
|
|
10
|
-
}
|
|
11
14
|
|
|
12
|
-
const getClientXFromEvent = (
|
|
15
|
+
const getClientXFromEvent = (e: TouchEventUnion): number => {
|
|
13
16
|
if ('touches' in e) {
|
|
14
17
|
return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0
|
|
15
18
|
}
|
|
@@ -26,7 +29,6 @@ export interface MediaPlayerProps {
|
|
|
26
29
|
loop?: boolean
|
|
27
30
|
controls?: boolean
|
|
28
31
|
showProgress?: boolean
|
|
29
|
-
onContainerClick?: () => void
|
|
30
32
|
/** When true, requests muted playback (helps autoplay policies on video). */
|
|
31
33
|
muted?: boolean
|
|
32
34
|
}
|
|
@@ -40,14 +42,67 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
|
40
42
|
loop = false,
|
|
41
43
|
controls = true,
|
|
42
44
|
showProgress = false,
|
|
43
|
-
onContainerClick,
|
|
44
45
|
muted = false,
|
|
45
46
|
}) => {
|
|
47
|
+
// --- Derived ---
|
|
46
48
|
const sourceType = getSourceType(mimeType)
|
|
49
|
+
|
|
50
|
+
// --- Refs ---
|
|
51
|
+
const playerRef = useRef<HTMLMediaElement>(null)
|
|
52
|
+
const trackRef = useRef<HTMLDivElement>(null)
|
|
53
|
+
const rafRef = useRef<number | null>(null)
|
|
54
|
+
const prevPlayingPropRef = useRef(playingProp)
|
|
55
|
+
|
|
56
|
+
// --- State: playback ---
|
|
47
57
|
const [playing, setPlaying] = useState(autoPlay)
|
|
58
|
+
const [played, setPlayed] = useState(0)
|
|
59
|
+
const [seeking, setSeeking] = useState(false)
|
|
60
|
+
|
|
61
|
+
// --- State: UI ---
|
|
62
|
+
const [scrubberHovered, setScrubberHovered] = useState(false)
|
|
63
|
+
/** Set when autoplay/play() was rejected so user can start via gesture (no controls UI). */
|
|
64
|
+
const [manualPlayRequired, setManualPlayRequired] = useState(false)
|
|
65
|
+
|
|
66
|
+
// --- State: loading ---
|
|
67
|
+
const [buffering, setBuffering] = useState(false)
|
|
68
|
+
/** True until the first canPlay fires for the current source — hides controls/spinner behind poster. */
|
|
69
|
+
const [initialLoad, setInitialLoad] = useState(true)
|
|
70
|
+
const [videoAspect, setVideoAspect] = useState<number | null>(null)
|
|
71
|
+
|
|
72
|
+
// --- Callbacks ---
|
|
73
|
+
const startPlaybackFromGesture = useCallback(() => {
|
|
74
|
+
setManualPlayRequired(false)
|
|
75
|
+
setPlaying(true)
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
const getFraction = useCallback((e: TouchEventUnion) => {
|
|
79
|
+
const track = trackRef.current
|
|
80
|
+
if (!track) return 0
|
|
81
|
+
const rect = track.getBoundingClientRect()
|
|
82
|
+
return Math.max(
|
|
83
|
+
0,
|
|
84
|
+
Math.min(1, (getClientXFromEvent(e) - rect.left) / rect.width)
|
|
85
|
+
)
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
88
|
+
const seekTo = useCallback((fraction: number) => {
|
|
89
|
+
const el = playerRef.current
|
|
90
|
+
if (el && el.duration) el.currentTime = fraction * el.duration
|
|
91
|
+
}, [])
|
|
92
|
+
|
|
93
|
+
const handleTrackPointerDown = (
|
|
94
|
+
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
|
95
|
+
) => {
|
|
96
|
+
e.stopPropagation()
|
|
97
|
+
setSeeking(true)
|
|
98
|
+
const fraction = getFraction(e)
|
|
99
|
+
setPlayed(fraction)
|
|
100
|
+
seekTo(fraction)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Effects ---
|
|
48
104
|
|
|
49
105
|
// Sync controlled playing prop to internal state
|
|
50
|
-
const prevPlayingPropRef = useRef(playingProp)
|
|
51
106
|
useEffect(() => {
|
|
52
107
|
if (
|
|
53
108
|
playingProp !== undefined &&
|
|
@@ -57,20 +112,8 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
|
57
112
|
setPlaying(playingProp)
|
|
58
113
|
}
|
|
59
114
|
}, [playingProp])
|
|
60
|
-
const [played, setPlayed] = useState(0)
|
|
61
|
-
const [seeking, setSeeking] = useState(false)
|
|
62
|
-
const [scrubberHovered, setScrubberHovered] = useState(false)
|
|
63
|
-
const [videoAspect, setVideoAspect] = useState<number | null>(null)
|
|
64
|
-
const [buffering, setBuffering] = useState(false)
|
|
65
|
-
/** True until the first canPlay fires for the current source — hides controls/spinner behind poster. */
|
|
66
|
-
const [initialLoad, setInitialLoad] = useState(true)
|
|
67
|
-
/** Set when autoplay/play() was rejected so user can start via gesture (no controls UI). */
|
|
68
|
-
const [manualPlayRequired, setManualPlayRequired] = useState(false)
|
|
69
|
-
const playerRef = useRef<HTMLMediaElement>(null)
|
|
70
|
-
const trackRef = useRef<HTMLDivElement>(null)
|
|
71
|
-
const rafRef = useRef<number | null>(null)
|
|
72
|
-
|
|
73
115
|
|
|
116
|
+
// RAF-driven progress updates
|
|
74
117
|
useEffect(() => {
|
|
75
118
|
if (!playing) {
|
|
76
119
|
if (rafRef.current !== null) {
|
|
@@ -108,39 +151,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
|
108
151
|
}
|
|
109
152
|
}, [playing])
|
|
110
153
|
|
|
111
|
-
|
|
112
|
-
setManualPlayRequired(false)
|
|
113
|
-
setPlaying(true)
|
|
114
|
-
}, [])
|
|
115
|
-
|
|
116
|
-
const getFraction = useCallback(
|
|
117
|
-
(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
|
|
118
|
-
const track = trackRef.current
|
|
119
|
-
if (!track) return 0
|
|
120
|
-
const rect = track.getBoundingClientRect()
|
|
121
|
-
return Math.max(
|
|
122
|
-
0,
|
|
123
|
-
Math.min(1, (getClientXFromEvent(e) - rect.left) / rect.width)
|
|
124
|
-
)
|
|
125
|
-
},
|
|
126
|
-
[]
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
const seekTo = useCallback((fraction: number) => {
|
|
130
|
-
const el = playerRef.current
|
|
131
|
-
if (el && el.duration) el.currentTime = fraction * el.duration
|
|
132
|
-
}, [])
|
|
133
|
-
|
|
134
|
-
const handleTrackPointerDown = (
|
|
135
|
-
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
|
136
|
-
) => {
|
|
137
|
-
e.stopPropagation()
|
|
138
|
-
setSeeking(true)
|
|
139
|
-
const fraction = getFraction(e)
|
|
140
|
-
setPlayed(fraction)
|
|
141
|
-
seekTo(fraction)
|
|
142
|
-
}
|
|
143
|
-
|
|
154
|
+
// Global seeking listeners
|
|
144
155
|
useEffect(() => {
|
|
145
156
|
if (!seeking) return
|
|
146
157
|
const onMove = (e: MouseEvent | TouchEvent) => setPlayed(getFraction(e))
|
|
@@ -160,6 +171,7 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
|
160
171
|
}
|
|
161
172
|
}, [seeking, getFraction, seekTo])
|
|
162
173
|
|
|
174
|
+
// --- Derived render values ---
|
|
163
175
|
// Use natural aspect ratio once metadata loads, fall back to 16:9 before then.
|
|
164
176
|
const aspectStyle = videoAspect
|
|
165
177
|
? { aspectRatio: String(videoAspect) }
|
|
@@ -171,24 +183,16 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
|
171
183
|
<div
|
|
172
184
|
role="button"
|
|
173
185
|
tabIndex={0}
|
|
174
|
-
className={`relative cursor-pointer overflow-hidden
|
|
186
|
+
className={`relative cursor-pointer overflow-hidden bg-black ${aspectClass}`}
|
|
175
187
|
style={aspectStyle}
|
|
176
188
|
onClick={() => {
|
|
177
189
|
if (manualPlayRequired) return
|
|
178
|
-
if (onContainerClick) {
|
|
179
|
-
onContainerClick()
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
190
|
if (controls) setPlaying((p) => !p)
|
|
183
191
|
}}
|
|
184
192
|
onKeyDown={(e) => {
|
|
185
193
|
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
186
194
|
e.preventDefault()
|
|
187
195
|
if (manualPlayRequired) return
|
|
188
|
-
if (onContainerClick) {
|
|
189
|
-
onContainerClick()
|
|
190
|
-
return
|
|
191
|
-
}
|
|
192
196
|
if (controls) setPlaying((p) => !p)
|
|
193
197
|
}}
|
|
194
198
|
>
|
|
@@ -217,7 +221,10 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
|
217
221
|
muted={muted}
|
|
218
222
|
style={{ width: '100%', height: '100%' }}
|
|
219
223
|
onLoadStart={() => setBuffering(true)}
|
|
220
|
-
onCanPlay={() => {
|
|
224
|
+
onCanPlay={() => {
|
|
225
|
+
setBuffering(false)
|
|
226
|
+
setInitialLoad(false)
|
|
227
|
+
}}
|
|
221
228
|
onWaiting={() => setBuffering(true)}
|
|
222
229
|
onPlay={() => setManualPlayRequired(false)}
|
|
223
230
|
onEnded={() => {
|
|
@@ -238,12 +245,19 @@ const MediaPlayer: React.FC<MediaPlayerProps> = ({
|
|
|
238
245
|
playsInline
|
|
239
246
|
style={{ width: '100%', height: '100%' }}
|
|
240
247
|
onLoadStart={() => setBuffering(true)}
|
|
241
|
-
onCanPlay={() => {
|
|
248
|
+
onCanPlay={() => {
|
|
249
|
+
setBuffering(false)
|
|
250
|
+
setInitialLoad(false)
|
|
251
|
+
}}
|
|
242
252
|
onWaiting={() => setBuffering(true)}
|
|
243
253
|
onPlay={() => setManualPlayRequired(false)}
|
|
244
254
|
onLoadedMetadata={() => {
|
|
245
255
|
const el = playerRef.current
|
|
246
|
-
if (
|
|
256
|
+
if (
|
|
257
|
+
el instanceof HTMLVideoElement &&
|
|
258
|
+
el.videoWidth &&
|
|
259
|
+
el.videoHeight
|
|
260
|
+
) {
|
|
247
261
|
setVideoAspect(el.videoWidth / el.videoHeight)
|
|
248
262
|
}
|
|
249
263
|
}}
|