@moviie/player-expo 0.4.0
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/LICENSE +21 -0
- package/README.md +230 -0
- package/app.plugin.cjs +3 -0
- package/dist/cast.cjs +100 -0
- package/dist/cast.cjs.map +1 -0
- package/dist/cast.d.cts +15 -0
- package/dist/cast.d.ts +15 -0
- package/dist/cast.mjs +92 -0
- package/dist/cast.mjs.map +1 -0
- package/dist/chunk-67DJ7NOB.mjs +294 -0
- package/dist/chunk-67DJ7NOB.mjs.map +1 -0
- package/dist/chunk-7U2LKIGU.mjs +12 -0
- package/dist/chunk-7U2LKIGU.mjs.map +1 -0
- package/dist/chunk-BJTO5JO5.mjs +10 -0
- package/dist/chunk-BJTO5JO5.mjs.map +1 -0
- package/dist/index.cjs +3934 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +450 -0
- package/dist/index.d.ts +450 -0
- package/dist/index.mjs +3571 -0
- package/dist/index.mjs.map +1 -0
- package/dist/layout.cjs +217 -0
- package/dist/layout.cjs.map +1 -0
- package/dist/layout.d.cts +20 -0
- package/dist/layout.d.ts +20 -0
- package/dist/layout.mjs +4 -0
- package/dist/layout.mjs.map +1 -0
- package/dist/moviie-cast-adapter-DmSU2u3j.d.cts +53 -0
- package/dist/moviie-cast-adapter-DmSU2u3j.d.ts +53 -0
- package/dist/plugin/with-moviie.cjs +88 -0
- package/dist/plugin/with-moviie.cjs.map +1 -0
- package/dist/plugin/with-moviie.d.cts +52 -0
- package/dist/plugin/with-moviie.d.ts +52 -0
- package/dist/plugin/with-moviie.mjs +76 -0
- package/dist/plugin/with-moviie.mjs.map +1 -0
- package/package.json +134 -0
- package/plugin/validate-options.ts +31 -0
- package/plugin/with-moviie-types.ts +21 -0
- package/plugin/with-moviie.ts +84 -0
- package/src/apply-expo-moviie-endpoints.ts +47 -0
- package/src/cast/google-cast-adapter.ts +111 -0
- package/src/cast/index.ts +12 -0
- package/src/components/controls/moviie-bottom-timeline.tsx +477 -0
- package/src/components/controls/moviie-cast-buttons.tsx +96 -0
- package/src/components/controls/moviie-chrome-edge-gradients.tsx +162 -0
- package/src/components/controls/moviie-controls.tsx +585 -0
- package/src/components/controls/moviie-skin-chrome-context.tsx +374 -0
- package/src/components/controls/moviie-skin-smart-progress.tsx +157 -0
- package/src/components/icons/embed-media-icons.tsx +282 -0
- package/src/components/icons/moviie-embed-brand-mark.tsx +58 -0
- package/src/components/moviie-error-boundary.tsx +80 -0
- package/src/components/moviie-player-error-shell.tsx +143 -0
- package/src/components/moviie-player-loading-shell.tsx +59 -0
- package/src/components/moviie-skin-custom-fullscreen-modal.tsx +232 -0
- package/src/components/moviie-video-props.ts +134 -0
- package/src/components/moviie-video.tsx +568 -0
- package/src/components/moviie-video.web.tsx +167 -0
- package/src/components/overlays/moviie-watermark.tsx +53 -0
- package/src/constants.ts +374 -0
- package/src/hooks/use-moviie-event.ts +103 -0
- package/src/hooks/use-moviie-playback-ended.ts +14 -0
- package/src/hooks/use-moviie-playback-resume-persistence.ts +76 -0
- package/src/hooks/use-moviie-playback.ts +99 -0
- package/src/hooks/use-moviie-player-types.ts +55 -0
- package/src/hooks/use-moviie-player.ts +236 -0
- package/src/hooks/use-moviie-player.web.ts +57 -0
- package/src/hooks/use-moviie-telemetry.ts +133 -0
- package/src/index.ts +90 -0
- package/src/layout.ts +4 -0
- package/src/lib/add-moviie-playback-ended-listener.ts +16 -0
- package/src/lib/build-moviie-branding-marketing-url.ts +14 -0
- package/src/lib/build-moviie-embed-brand-home-url.ts +16 -0
- package/src/lib/build-moviie-skin-layout-metrics.ts +166 -0
- package/src/lib/build-moviie-video-presentation.ts +42 -0
- package/src/lib/build-moviie-watch-embed-url.ts +30 -0
- package/src/lib/cast-adapter-registry.ts +13 -0
- package/src/lib/clamp-unit-interval.ts +3 -0
- package/src/lib/compute-custom-fullscreen-stage-dimensions.ts +39 -0
- package/src/lib/compute-moviie-playback-ended.ts +31 -0
- package/src/lib/compute-playback-buffering.ts +41 -0
- package/src/lib/compute-playback-progress-ratio.ts +33 -0
- package/src/lib/compute-timeline-scrub-commit-time.ts +40 -0
- package/src/lib/custom-fullscreen-native-orientation.ts +88 -0
- package/src/lib/format-playback-clock.ts +27 -0
- package/src/lib/jsx-native-bridge.ts +45 -0
- package/src/lib/map-smart-progress-display-ratio.ts +43 -0
- package/src/lib/moviie-cast-adapter.ts +53 -0
- package/src/lib/moviie-error-display.ts +149 -0
- package/src/lib/moviie-shell-tokens.ts +27 -0
- package/src/lib/moviie-telemetry-callbacks.ts +76 -0
- package/src/lib/optional-status-bar.ts +48 -0
- package/src/lib/partition-moviie-video-host-style.ts +54 -0
- package/src/lib/remember-playback-position-storage.ts +98 -0
- package/src/lib/resolve-moviie-skin-layout-scale.ts +17 -0
- package/src/lib/resolve-orientation-lock-for-rotate-z-deg.ts +21 -0
- package/src/lib/resolve-skin-accent-color.ts +10 -0
- package/src/lib/resolve-web-embed-iframe-src.ts +17 -0
- package/src/lib/warn-once.ts +23 -0
- package/src/platform/native-application-id.ts +10 -0
- package/src/platform/platform-client-info.ts +31 -0
- package/src/playback/fetch-playback-fresh.ts +27 -0
- package/src/playback/playback-memory-cache.ts +52 -0
- package/src/provider/moviie-provider.tsx +99 -0
- package/src/secure-store-viewer-token-store.ts +80 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "react"
|
|
2
|
+
import { StyleSheet, View, type LayoutChangeEvent } from "react-native"
|
|
3
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler"
|
|
4
|
+
import {
|
|
5
|
+
runOnJS,
|
|
6
|
+
useDerivedValue,
|
|
7
|
+
useAnimatedStyle,
|
|
8
|
+
useSharedValue,
|
|
9
|
+
} from "react-native-reanimated"
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
MOVIIE_SKIN_CHROME_FOREGROUND_HEX,
|
|
13
|
+
MOVIIE_SKIN_TIMELINE_BUFFER_LAYER_RGBA,
|
|
14
|
+
MOVIIE_SKIN_TIMELINE_RAIL_BACKGROUND_RGBA,
|
|
15
|
+
MOVIIE_SKIN_TIMELINE_SCRUB_HIT_SLOP_BOTTOM_DP,
|
|
16
|
+
MOVIIE_SKIN_TIMELINE_SCRUB_HIT_SLOP_HORIZONTAL_DP,
|
|
17
|
+
MOVIIE_SKIN_TIMELINE_SCRUB_HIT_SLOP_TOP_DP,
|
|
18
|
+
MOVIIE_SKIN_TIMELINE_THUMB_BORDER_RGBA,
|
|
19
|
+
MOVIIE_SKIN_TIMELINE_THUMB_DRAG_DIAMETER_MULTIPLIER,
|
|
20
|
+
MOVIIE_SKIN_TIMELINE_TOUCH_EXPANSION_VERTICAL_DP,
|
|
21
|
+
} from "../../constants"
|
|
22
|
+
import type { MoviieSkinLayoutMetrics } from "../../lib/build-moviie-skin-layout-metrics"
|
|
23
|
+
import { clampUnitInterval } from "../../lib/clamp-unit-interval"
|
|
24
|
+
import { computePlaybackProgressRatio } from "../../lib/compute-playback-progress-ratio"
|
|
25
|
+
import { computeTimelineScrubCommitTime } from "../../lib/compute-timeline-scrub-commit-time"
|
|
26
|
+
import { ReanimatedView } from "../../lib/jsx-native-bridge"
|
|
27
|
+
import { resolveSkinAccentColor } from "../../lib/resolve-skin-accent-color"
|
|
28
|
+
|
|
29
|
+
import { useMoviieSkinChrome } from "./moviie-skin-chrome-context"
|
|
30
|
+
|
|
31
|
+
function createMoviieSkinBottomTimelineStyles(layout: MoviieSkinLayoutMetrics) {
|
|
32
|
+
return StyleSheet.create({
|
|
33
|
+
root: {
|
|
34
|
+
bottom: 0,
|
|
35
|
+
left: 0,
|
|
36
|
+
overflow: "visible",
|
|
37
|
+
pointerEvents: "box-none",
|
|
38
|
+
position: "absolute",
|
|
39
|
+
right: 0,
|
|
40
|
+
zIndex: 24,
|
|
41
|
+
},
|
|
42
|
+
touchExpansion: {
|
|
43
|
+
overflow: "visible",
|
|
44
|
+
paddingBottom: 0,
|
|
45
|
+
paddingTop:
|
|
46
|
+
layout.timelineTouchPaddingVerticalPx +
|
|
47
|
+
MOVIIE_SKIN_TIMELINE_TOUCH_EXPANSION_VERTICAL_DP,
|
|
48
|
+
position: "relative",
|
|
49
|
+
},
|
|
50
|
+
trackStack: {
|
|
51
|
+
overflow: "visible",
|
|
52
|
+
width: "100%",
|
|
53
|
+
},
|
|
54
|
+
thumbBase: {
|
|
55
|
+
borderColor: MOVIIE_SKIN_TIMELINE_THUMB_BORDER_RGBA,
|
|
56
|
+
borderWidth: 1,
|
|
57
|
+
elevation: 8,
|
|
58
|
+
position: "absolute",
|
|
59
|
+
zIndex: 10,
|
|
60
|
+
},
|
|
61
|
+
unifiedBarSlot: {
|
|
62
|
+
overflow: "visible",
|
|
63
|
+
position: "relative",
|
|
64
|
+
width: "100%",
|
|
65
|
+
},
|
|
66
|
+
railLayer: {
|
|
67
|
+
backgroundColor: MOVIIE_SKIN_TIMELINE_RAIL_BACKGROUND_RGBA,
|
|
68
|
+
bottom: 0,
|
|
69
|
+
height: layout.timelineBarHeightPx,
|
|
70
|
+
left: 0,
|
|
71
|
+
position: "absolute",
|
|
72
|
+
right: 0,
|
|
73
|
+
zIndex: 0,
|
|
74
|
+
},
|
|
75
|
+
bufferLayer: {
|
|
76
|
+
backgroundColor: MOVIIE_SKIN_TIMELINE_BUFFER_LAYER_RGBA,
|
|
77
|
+
bottom: 0,
|
|
78
|
+
height: layout.timelineBarHeightPx,
|
|
79
|
+
left: 0,
|
|
80
|
+
position: "absolute",
|
|
81
|
+
zIndex: 1,
|
|
82
|
+
},
|
|
83
|
+
progressLayer: {
|
|
84
|
+
bottom: 0,
|
|
85
|
+
height: layout.timelineBarHeightPx,
|
|
86
|
+
left: 0,
|
|
87
|
+
position: "absolute",
|
|
88
|
+
zIndex: 2,
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function MoviieSkinBottomTimeline() {
|
|
94
|
+
const {
|
|
95
|
+
playback,
|
|
96
|
+
chromeVisible,
|
|
97
|
+
layout,
|
|
98
|
+
player,
|
|
99
|
+
setTimelineScrubClockSeconds,
|
|
100
|
+
ui,
|
|
101
|
+
} = useMoviieSkinChrome()
|
|
102
|
+
|
|
103
|
+
const styles = useMemo(
|
|
104
|
+
() => createMoviieSkinBottomTimelineStyles(layout),
|
|
105
|
+
[layout]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const { controls } = playback
|
|
109
|
+
const accent = resolveSkinAccentColor(playback.branding.primaryColor)
|
|
110
|
+
const foreground = MOVIIE_SKIN_CHROME_FOREGROUND_HEX
|
|
111
|
+
|
|
112
|
+
const durationOk =
|
|
113
|
+
controls.showProgress &&
|
|
114
|
+
Number.isFinite(ui.duration) &&
|
|
115
|
+
ui.duration > 0 &&
|
|
116
|
+
!player.isLive
|
|
117
|
+
|
|
118
|
+
const progressRatioLive = computePlaybackProgressRatio({
|
|
119
|
+
currentTime: ui.currentTime,
|
|
120
|
+
duration: ui.duration,
|
|
121
|
+
durationOk,
|
|
122
|
+
playing: ui.playing,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const bufferRatioLive =
|
|
126
|
+
durationOk &&
|
|
127
|
+
Number.isFinite(ui.bufferedPosition) &&
|
|
128
|
+
ui.bufferedPosition >= 0
|
|
129
|
+
? clampUnitInterval(ui.bufferedPosition / ui.duration)
|
|
130
|
+
: 0
|
|
131
|
+
|
|
132
|
+
// ── Shared values (UI thread) ─────────────────────────────────────────────
|
|
133
|
+
const isDraggingSV = useSharedValue(false)
|
|
134
|
+
const awaitingUnlockSV = useSharedValue(false)
|
|
135
|
+
const scrubRatioSV = useSharedValue(0)
|
|
136
|
+
const progressRatioSV = useSharedValue(progressRatioLive)
|
|
137
|
+
const bufferRatioSV = useSharedValue(bufferRatioLive)
|
|
138
|
+
const trackWidthSV = useSharedValue(0)
|
|
139
|
+
const durationSV = useSharedValue(ui.duration)
|
|
140
|
+
const chromeVisibleSV = useSharedValue(chromeVisible)
|
|
141
|
+
const timelineAccentEngagedSV = useSharedValue(false)
|
|
142
|
+
const thumbBaseDiamSV = useSharedValue(layout.timelineThumbDiameterPx)
|
|
143
|
+
const barHeightSV = useSharedValue(layout.timelineBarHeightPx)
|
|
144
|
+
|
|
145
|
+
// ── JS refs ────────────────────────────────────────────────────────────────
|
|
146
|
+
const isDraggingRef = useRef(false)
|
|
147
|
+
// shouldResumeRef permanece true até playingChange confirmar que o player retomou.
|
|
148
|
+
// Isso permite retry via statusChange caso player.play() seja chamado enquanto o seek
|
|
149
|
+
// ainda está em curso (expo-video pode ignorar play() durante loading).
|
|
150
|
+
const shouldResumeRef = useRef(false)
|
|
151
|
+
const progressRatioLiveRef = useRef(progressRatioLive)
|
|
152
|
+
progressRatioLiveRef.current = progressRatioLive
|
|
153
|
+
const chaptersRef = useRef(playback.chapters)
|
|
154
|
+
chaptersRef.current = playback.chapters
|
|
155
|
+
const chromeVisibleRef = useRef(chromeVisible)
|
|
156
|
+
chromeVisibleRef.current = chromeVisible
|
|
157
|
+
|
|
158
|
+
// ── Sync JS → shared values ────────────────────────────────────────────────
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!isDraggingRef.current) {
|
|
161
|
+
progressRatioSV.value = progressRatioLive
|
|
162
|
+
}
|
|
163
|
+
}, [progressRatioLive, progressRatioSV])
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
bufferRatioSV.value = bufferRatioLive
|
|
167
|
+
}, [bufferRatioLive, bufferRatioSV])
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
durationSV.value = ui.duration
|
|
171
|
+
}, [ui.duration, durationSV])
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
thumbBaseDiamSV.value = layout.timelineThumbDiameterPx
|
|
175
|
+
barHeightSV.value = layout.timelineBarHeightPx
|
|
176
|
+
}, [
|
|
177
|
+
layout.timelineThumbDiameterPx,
|
|
178
|
+
layout.timelineBarHeightPx,
|
|
179
|
+
thumbBaseDiamSV,
|
|
180
|
+
barHeightSV,
|
|
181
|
+
])
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
chromeVisibleSV.value = chromeVisible
|
|
185
|
+
if (chromeVisible) {
|
|
186
|
+
timelineAccentEngagedSV.value = false
|
|
187
|
+
awaitingUnlockSV.value = false
|
|
188
|
+
}
|
|
189
|
+
}, [
|
|
190
|
+
chromeVisible,
|
|
191
|
+
chromeVisibleSV,
|
|
192
|
+
timelineAccentEngagedSV,
|
|
193
|
+
awaitingUnlockSV,
|
|
194
|
+
])
|
|
195
|
+
|
|
196
|
+
// Retry em cada readyToPlay enquanto !dragging + shouldResume.
|
|
197
|
+
// Sem live seeks, este evento só pode vir do seek do commit → sem ambiguidade.
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const sub = player.addListener("statusChange", ({ status }) => {
|
|
200
|
+
if (shouldResumeRef.current && status === "readyToPlay" && !isDraggingRef.current) {
|
|
201
|
+
player.play()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
return () => sub.remove()
|
|
205
|
+
}, [player])
|
|
206
|
+
|
|
207
|
+
// playingChange com isPlaying true é a confirmação autoritativa que o play() funcionou.
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const sub = player.addListener("playingChange", ({ isPlaying }) => {
|
|
210
|
+
if (isPlaying && !isDraggingRef.current) {
|
|
211
|
+
shouldResumeRef.current = false
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
return () => sub.remove()
|
|
215
|
+
}, [player])
|
|
216
|
+
|
|
217
|
+
// ── Derived values ─────────────────────────────────────────────────────────
|
|
218
|
+
const effectiveRatioSV = useDerivedValue(() =>
|
|
219
|
+
isDraggingSV.value ? scrubRatioSV.value : progressRatioSV.value
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const showAccentSV = useDerivedValue(
|
|
223
|
+
() =>
|
|
224
|
+
chromeVisibleSV.value ||
|
|
225
|
+
isDraggingSV.value ||
|
|
226
|
+
timelineAccentEngagedSV.value
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const showThumbSV = useDerivedValue(
|
|
230
|
+
() =>
|
|
231
|
+
chromeVisibleSV.value ||
|
|
232
|
+
isDraggingSV.value ||
|
|
233
|
+
timelineAccentEngagedSV.value
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const thumbDiamSV = useDerivedValue(() =>
|
|
237
|
+
isDraggingSV.value
|
|
238
|
+
? Math.round(
|
|
239
|
+
thumbBaseDiamSV.value * MOVIIE_SKIN_TIMELINE_THUMB_DRAG_DIAMETER_MULTIPLIER
|
|
240
|
+
)
|
|
241
|
+
: thumbBaseDiamSV.value
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
// ── Animated styles ────────────────────────────────────────────────────────
|
|
245
|
+
const progressBarStyle = useAnimatedStyle(() => ({
|
|
246
|
+
backgroundColor: showAccentSV.value ? accent : foreground,
|
|
247
|
+
width: `${effectiveRatioSV.value * 100}%`,
|
|
248
|
+
}))
|
|
249
|
+
|
|
250
|
+
const bufferBarStyle = useAnimatedStyle(() => ({
|
|
251
|
+
width: `${bufferRatioSV.value * 100}%`,
|
|
252
|
+
}))
|
|
253
|
+
|
|
254
|
+
const thumbStyle = useAnimatedStyle(() => {
|
|
255
|
+
if (!showThumbSV.value) {
|
|
256
|
+
return { opacity: 0 }
|
|
257
|
+
}
|
|
258
|
+
const diameter = thumbDiamSV.value
|
|
259
|
+
const radius = diameter / 2
|
|
260
|
+
const w = trackWidthSV.value
|
|
261
|
+
const left =
|
|
262
|
+
w > 0
|
|
263
|
+
? Math.min(w - diameter, Math.max(0, effectiveRatioSV.value * w - radius))
|
|
264
|
+
: 0
|
|
265
|
+
const bh = barHeightSV.value
|
|
266
|
+
return {
|
|
267
|
+
opacity: 1,
|
|
268
|
+
backgroundColor: showAccentSV.value ? accent : foreground,
|
|
269
|
+
left,
|
|
270
|
+
width: diameter,
|
|
271
|
+
height: diameter,
|
|
272
|
+
borderRadius: radius,
|
|
273
|
+
bottom: bh / 2 - radius,
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// ── JS callbacks ───────────────────────────────────────────────────────────
|
|
278
|
+
const onScrubBegin = useCallback(() => {
|
|
279
|
+
isDraggingRef.current = true
|
|
280
|
+
// Herda shouldResume de um commit anterior se o player ainda não retomou,
|
|
281
|
+
// senão usa o estado actual do player.
|
|
282
|
+
if (!shouldResumeRef.current) {
|
|
283
|
+
shouldResumeRef.current = player.playing
|
|
284
|
+
}
|
|
285
|
+
}, [player])
|
|
286
|
+
|
|
287
|
+
const onScrubUpdate = useCallback(
|
|
288
|
+
(ratio: number) => {
|
|
289
|
+
// Sem live seek: a barra anima via Reanimated (UI thread), o frame do vídeo
|
|
290
|
+
// só é actualizado no commit. Evita seeks concorrentes que interferem com o resume.
|
|
291
|
+
setTimelineScrubClockSeconds(ratio * durationSV.value)
|
|
292
|
+
},
|
|
293
|
+
[setTimelineScrubClockSeconds, durationSV]
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const onScrubCommit = useCallback(
|
|
297
|
+
(ratio: number) => {
|
|
298
|
+
isDraggingRef.current = false
|
|
299
|
+
setTimelineScrubClockSeconds(null)
|
|
300
|
+
const dur = durationSV.value
|
|
301
|
+
if (!Number.isFinite(dur) || dur <= 0) return
|
|
302
|
+
const targetTime = computeTimelineScrubCommitTime({
|
|
303
|
+
ratio,
|
|
304
|
+
durationSeconds: dur,
|
|
305
|
+
chapters: chaptersRef.current,
|
|
306
|
+
})
|
|
307
|
+
player.currentTime = targetTime
|
|
308
|
+
// player.play() imediato pode ser ignorado se o seek ainda está em curso.
|
|
309
|
+
// O listener statusChange(readyToPlay) faz o retry enquanto shouldResumeRef=true.
|
|
310
|
+
// playingChange com isPlaying confirma e zera shouldResumeRef.
|
|
311
|
+
if (shouldResumeRef.current) {
|
|
312
|
+
player.play()
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
[setTimelineScrubClockSeconds, player, durationSV]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
const onScrubCancel = useCallback(() => {
|
|
319
|
+
isDraggingRef.current = false
|
|
320
|
+
setTimelineScrubClockSeconds(null)
|
|
321
|
+
progressRatioSV.value = progressRatioLiveRef.current
|
|
322
|
+
// Cancel não faz seek — shouldResumeRef não muda.
|
|
323
|
+
// Se shouldResume=true (estava a tocar), o playingChange listener vai limpar quando
|
|
324
|
+
// o player retomar normalmente (ou o utilizador faz outro scrub).
|
|
325
|
+
}, [setTimelineScrubClockSeconds, progressRatioSV])
|
|
326
|
+
|
|
327
|
+
const onAccentEngage = useCallback(() => {
|
|
328
|
+
timelineAccentEngagedSV.value = true
|
|
329
|
+
}, [timelineAccentEngagedSV])
|
|
330
|
+
|
|
331
|
+
const onAccentDisengageCancel = useCallback(() => {
|
|
332
|
+
timelineAccentEngagedSV.value = false
|
|
333
|
+
}, [timelineAccentEngagedSV])
|
|
334
|
+
|
|
335
|
+
// ── Gesture ────────────────────────────────────────────────────────────────
|
|
336
|
+
// activeOffsetX: ativa só após 8 dp horizontal — evita conflito com ScrollView vertical.
|
|
337
|
+
// failOffsetY: cancela se o utilizador scrollar mais de 15 dp — devolve gesto ao ScrollView.
|
|
338
|
+
const panGesture = useMemo(
|
|
339
|
+
() =>
|
|
340
|
+
Gesture.Pan()
|
|
341
|
+
.activeOffsetX([-8, 8])
|
|
342
|
+
.failOffsetY([-15, 15])
|
|
343
|
+
.enabled(durationOk)
|
|
344
|
+
.onBegin(() => {
|
|
345
|
+
"worklet"
|
|
346
|
+
if (!chromeVisibleSV.value && !timelineAccentEngagedSV.value) {
|
|
347
|
+
awaitingUnlockSV.value = true
|
|
348
|
+
runOnJS(onAccentEngage)()
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
.onStart((e) => {
|
|
352
|
+
"worklet"
|
|
353
|
+
if (awaitingUnlockSV.value) {
|
|
354
|
+
// Primeira activação após accent-engage — aguarda onUpdate
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
isDraggingSV.value = true
|
|
358
|
+
runOnJS(onScrubBegin)()
|
|
359
|
+
const w = trackWidthSV.value
|
|
360
|
+
if (w <= 0) return
|
|
361
|
+
const ratio = Math.max(0, Math.min(1, e.x / w))
|
|
362
|
+
scrubRatioSV.value = ratio
|
|
363
|
+
runOnJS(onScrubUpdate)(ratio)
|
|
364
|
+
})
|
|
365
|
+
.onUpdate((e) => {
|
|
366
|
+
"worklet"
|
|
367
|
+
const w = trackWidthSV.value
|
|
368
|
+
if (w <= 0) return
|
|
369
|
+
const ratio = Math.max(0, Math.min(1, e.x / w))
|
|
370
|
+
if (awaitingUnlockSV.value) {
|
|
371
|
+
awaitingUnlockSV.value = false
|
|
372
|
+
isDraggingSV.value = true
|
|
373
|
+
runOnJS(onScrubBegin)()
|
|
374
|
+
}
|
|
375
|
+
if (!isDraggingSV.value) return
|
|
376
|
+
scrubRatioSV.value = ratio
|
|
377
|
+
runOnJS(onScrubUpdate)(ratio)
|
|
378
|
+
})
|
|
379
|
+
.onEnd(() => {
|
|
380
|
+
"worklet"
|
|
381
|
+
if (awaitingUnlockSV.value) {
|
|
382
|
+
awaitingUnlockSV.value = false
|
|
383
|
+
runOnJS(onAccentDisengageCancel)()
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
if (!isDraggingSV.value) return
|
|
387
|
+
const committed = scrubRatioSV.value
|
|
388
|
+
// Actualiza progressRatioSV ANTES de desactivar isDragging para evitar
|
|
389
|
+
// que effectiveRatioSV salte de volta para a posição antiga (visual "pause").
|
|
390
|
+
progressRatioSV.value = committed
|
|
391
|
+
isDraggingSV.value = false
|
|
392
|
+
scrubRatioSV.value = 0
|
|
393
|
+
runOnJS(onScrubCommit)(committed)
|
|
394
|
+
})
|
|
395
|
+
.onFinalize(() => {
|
|
396
|
+
"worklet"
|
|
397
|
+
if (awaitingUnlockSV.value) {
|
|
398
|
+
awaitingUnlockSV.value = false
|
|
399
|
+
runOnJS(onAccentDisengageCancel)()
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
if (isDraggingSV.value) {
|
|
403
|
+
isDraggingSV.value = false
|
|
404
|
+
scrubRatioSV.value = 0
|
|
405
|
+
runOnJS(onScrubCancel)()
|
|
406
|
+
}
|
|
407
|
+
}),
|
|
408
|
+
[
|
|
409
|
+
durationOk,
|
|
410
|
+
isDraggingSV,
|
|
411
|
+
awaitingUnlockSV,
|
|
412
|
+
scrubRatioSV,
|
|
413
|
+
progressRatioSV,
|
|
414
|
+
trackWidthSV,
|
|
415
|
+
chromeVisibleSV,
|
|
416
|
+
timelineAccentEngagedSV,
|
|
417
|
+
onAccentEngage,
|
|
418
|
+
onAccentDisengageCancel,
|
|
419
|
+
onScrubBegin,
|
|
420
|
+
onScrubUpdate,
|
|
421
|
+
onScrubCommit,
|
|
422
|
+
onScrubCancel,
|
|
423
|
+
]
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
const onTrackLayout = useCallback(
|
|
427
|
+
(event: LayoutChangeEvent) => {
|
|
428
|
+
trackWidthSV.value = event.nativeEvent.layout.width
|
|
429
|
+
},
|
|
430
|
+
[trackWidthSV]
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
if (!durationOk) {
|
|
434
|
+
return null
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const unifiedSlotHeightPx = Math.max(
|
|
438
|
+
layout.timelineThumbDiameterPx,
|
|
439
|
+
Math.round(
|
|
440
|
+
layout.timelineThumbDiameterPx * MOVIIE_SKIN_TIMELINE_THUMB_DRAG_DIAMETER_MULTIPLIER
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
const scrubHitSlop = {
|
|
445
|
+
top: MOVIIE_SKIN_TIMELINE_SCRUB_HIT_SLOP_TOP_DP,
|
|
446
|
+
bottom: MOVIIE_SKIN_TIMELINE_SCRUB_HIT_SLOP_BOTTOM_DP,
|
|
447
|
+
left: MOVIIE_SKIN_TIMELINE_SCRUB_HIT_SLOP_HORIZONTAL_DP,
|
|
448
|
+
right: MOVIIE_SKIN_TIMELINE_SCRUB_HIT_SLOP_HORIZONTAL_DP,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<View pointerEvents="box-none" style={styles.root}>
|
|
453
|
+
<GestureDetector gesture={panGesture}>
|
|
454
|
+
<View hitSlop={scrubHitSlop} pointerEvents="auto" style={styles.touchExpansion}>
|
|
455
|
+
<View style={styles.trackStack}>
|
|
456
|
+
<View
|
|
457
|
+
accessibilityRole="progressbar"
|
|
458
|
+
accessibilityValue={{
|
|
459
|
+
max: 1,
|
|
460
|
+
min: 0,
|
|
461
|
+
now: progressRatioLive,
|
|
462
|
+
}}
|
|
463
|
+
collapsable={false}
|
|
464
|
+
style={[styles.unifiedBarSlot, { height: unifiedSlotHeightPx }]}
|
|
465
|
+
onLayout={onTrackLayout}
|
|
466
|
+
>
|
|
467
|
+
<View style={styles.railLayer} />
|
|
468
|
+
<ReanimatedView style={[styles.bufferLayer, bufferBarStyle]} />
|
|
469
|
+
<ReanimatedView style={[styles.progressLayer, progressBarStyle]} />
|
|
470
|
+
<ReanimatedView style={[styles.thumbBase, thumbStyle]} />
|
|
471
|
+
</View>
|
|
472
|
+
</View>
|
|
473
|
+
</View>
|
|
474
|
+
</GestureDetector>
|
|
475
|
+
</View>
|
|
476
|
+
)
|
|
477
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { PressableProps, ViewStyle } from 'react-native'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Pressable, View } from 'react-native'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
MOVIIE_SKIN_CHROME_CONTROL_PRESSED_OPACITY,
|
|
7
|
+
MOVIIE_SKIN_CHROME_CONTROL_REST_OPACITY,
|
|
8
|
+
MOVIIE_SKIN_CHROME_FOREGROUND_HEX,
|
|
9
|
+
MOVIIE_SKIN_FLOATING_HUD_TRAILING_HIT_SLOP_PX,
|
|
10
|
+
MOVIIE_SKIN_TOP_ICON_CAST_VISUAL_SCALE,
|
|
11
|
+
} from '../../constants'
|
|
12
|
+
import type { MoviieSkinLayoutMetrics } from '../../lib/build-moviie-skin-layout-metrics'
|
|
13
|
+
import { EmbedMediaIconCast, EmbedMediaIconCastConnected } from '../icons/embed-media-icons'
|
|
14
|
+
|
|
15
|
+
function pressOpacity(pressed: boolean): Pick<ViewStyle, 'opacity'> {
|
|
16
|
+
return {
|
|
17
|
+
opacity: pressed
|
|
18
|
+
? MOVIIE_SKIN_CHROME_CONTROL_PRESSED_OPACITY
|
|
19
|
+
: MOVIIE_SKIN_CHROME_CONTROL_REST_OPACITY,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ScrimPad(props: {
|
|
24
|
+
accessibilityLabel: string
|
|
25
|
+
hitSlop?: PressableProps['hitSlop']
|
|
26
|
+
onPress: () => void
|
|
27
|
+
chromeScrimBaseStyle: object
|
|
28
|
+
style: object
|
|
29
|
+
children: ReactNode
|
|
30
|
+
selected: boolean
|
|
31
|
+
}) {
|
|
32
|
+
return (
|
|
33
|
+
<Pressable
|
|
34
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
35
|
+
accessibilityRole="button"
|
|
36
|
+
accessibilityState={{ selected: props.selected }}
|
|
37
|
+
hitSlop={props.hitSlop}
|
|
38
|
+
onPress={props.onPress}
|
|
39
|
+
style={(state) => [props.chromeScrimBaseStyle, props.style, pressOpacity(state.pressed)]}
|
|
40
|
+
>
|
|
41
|
+
{props.children}
|
|
42
|
+
</Pressable>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function MoviieSkinCastCircleButton(props: {
|
|
47
|
+
castConnected: boolean
|
|
48
|
+
chromeScrimBaseStyle: object
|
|
49
|
+
circleScrimStyle: object
|
|
50
|
+
layout: MoviieSkinLayoutMetrics
|
|
51
|
+
onPress: () => void
|
|
52
|
+
}) {
|
|
53
|
+
const Icon = props.castConnected ? EmbedMediaIconCastConnected : EmbedMediaIconCast
|
|
54
|
+
return (
|
|
55
|
+
<ScrimPad
|
|
56
|
+
accessibilityLabel={props.castConnected ? 'Encerrar transmissão' : 'Transmitir'}
|
|
57
|
+
chromeScrimBaseStyle={props.chromeScrimBaseStyle}
|
|
58
|
+
hitSlop={MOVIIE_SKIN_FLOATING_HUD_TRAILING_HIT_SLOP_PX}
|
|
59
|
+
onPress={props.onPress}
|
|
60
|
+
selected={props.castConnected}
|
|
61
|
+
style={props.circleScrimStyle}
|
|
62
|
+
>
|
|
63
|
+
<Icon
|
|
64
|
+
color={MOVIIE_SKIN_CHROME_FOREGROUND_HEX}
|
|
65
|
+
size={props.layout.floatingHudFullscreenIconPx}
|
|
66
|
+
/>
|
|
67
|
+
</ScrimPad>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function MoviieSkinCastTopBareButton(props: {
|
|
72
|
+
castConnected: boolean
|
|
73
|
+
layout: MoviieSkinLayoutMetrics
|
|
74
|
+
onPress: () => void
|
|
75
|
+
}) {
|
|
76
|
+
const Icon = props.castConnected ? EmbedMediaIconCastConnected : EmbedMediaIconCast
|
|
77
|
+
return (
|
|
78
|
+
<Pressable
|
|
79
|
+
accessibilityLabel={props.castConnected ? 'Encerrar transmissão' : 'Transmitir'}
|
|
80
|
+
accessibilityRole="button"
|
|
81
|
+
accessibilityState={{ selected: props.castConnected }}
|
|
82
|
+
hitSlop={MOVIIE_SKIN_FLOATING_HUD_TRAILING_HIT_SLOP_PX}
|
|
83
|
+
onPress={props.onPress}
|
|
84
|
+
style={(state) => pressOpacity(state.pressed)}
|
|
85
|
+
>
|
|
86
|
+
<View>
|
|
87
|
+
<Icon
|
|
88
|
+
color={MOVIIE_SKIN_CHROME_FOREGROUND_HEX}
|
|
89
|
+
size={
|
|
90
|
+
props.layout.floatingHudFullscreenTopIconPx * MOVIIE_SKIN_TOP_ICON_CAST_VISUAL_SCALE
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
93
|
+
</View>
|
|
94
|
+
</Pressable>
|
|
95
|
+
)
|
|
96
|
+
}
|