@toneflix/flipbook-native 0.0.0-stage → 0.2.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.
@@ -0,0 +1,600 @@
1
+ import {
2
+ forwardRef,
3
+ memo,
4
+ useCallback,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useReducer,
10
+ useRef,
11
+ useState,
12
+ type ReactNode,
13
+ } from 'react'
14
+ import {
15
+ AccessibilityInfo,
16
+ Animated,
17
+ PanResponder,
18
+ Pressable,
19
+ ScrollView,
20
+ StyleSheet,
21
+ Text,
22
+ View,
23
+ useWindowDimensions,
24
+ type LayoutChangeEvent,
25
+ type StyleProp,
26
+ type ViewStyle,
27
+ } from 'react-native'
28
+ import Svg, { Defs, Path, RadialGradient, Rect, Stop } from 'react-native-svg'
29
+ import {
30
+ BookLayout,
31
+ COVER_THEMES,
32
+ DEFAULT_LABELS,
33
+ FlipbookReader,
34
+ type Face,
35
+ type FlipbookBook,
36
+ type FlipbookLabels,
37
+ type GripOptions,
38
+ type TurnPhysics,
39
+ type Vec,
40
+ } from '@toneflix/flipbook-core'
41
+ import { FoldLayout, type Band, type Role } from './geometry'
42
+ import { Gradient } from './Gradient'
43
+ import { BookLookProvider, type BookFonts } from './look'
44
+ import { BackCoverView, BlankPageView, ColophonView, CoverView, PAGE_RATIO, PageView, TitlePageView } from './pages'
45
+ import { lockLandscape } from './orientation'
46
+
47
+ export interface FlipbookPlace {
48
+ /** How many leaves lie on the left: 0 is the closed front cover. */
49
+ flipped: number
50
+ label: string
51
+ faces: Face[]
52
+ }
53
+
54
+ export interface FlipbookProps {
55
+ book: FlipbookBook
56
+ /** Open at this page (1-based) instead of the cover. */
57
+ startPage?: number
58
+ /** Two pages side by side, one at a time, or whichever fits the space (default). */
59
+ layout?: 'auto' | 'spread' | 'single'
60
+ /** Show the top and bottom bars. */
61
+ controls?: boolean
62
+ /** Hide the bars after this many ms without a touch, or never with false. */
63
+ autoHide?: number | false
64
+ thumbnails?: boolean
65
+ /**
66
+ * Read sideways like a real book: locks the screen to landscape while the book is open when
67
+ * `expo-screen-orientation` is installed, and otherwise asks the reader to turn the phone.
68
+ */
69
+ landscape?: boolean
70
+ /** Covers swing on the spine like board instead of curling like paper. */
71
+ stiffCovers?: boolean
72
+ physics?: Partial<TurnPhysics>
73
+ grip?: Partial<GripOptions>
74
+ /** Strength of the light and shadow around a turning page, from 0 to 1 or more. */
75
+ shading?: number
76
+ /** Backdrop colour behind the book. Defaults to a glow tinted by the cover. */
77
+ background?: string
78
+ fonts?: BookFonts
79
+ labels?: Partial<FlipbookLabels>
80
+ /** Extra controls at the start and end of the top bar, e.g. a close button. */
81
+ topLeft?: ReactNode
82
+ topRight?: ReactNode
83
+ style?: StyleProp<ViewStyle>
84
+ onPageChange?: (place: FlipbookPlace) => void
85
+ /** A page started to turn over, e.g. to play a sound. */
86
+ onFlip?: () => void
87
+ }
88
+
89
+ export interface FlipbookHandle {
90
+ next(): void
91
+ prev(): void
92
+ go(flipped: number): void
93
+ goToPage(page: number): void
94
+ readonly reader: FlipbookReader
95
+ }
96
+
97
+ /** One face's content. Memoised so turning a page only moves its wrappers. */
98
+ const FaceContent = memo(function FaceContent({
99
+ face,
100
+ book,
101
+ width,
102
+ side,
103
+ }: {
104
+ face: Face
105
+ book: FlipbookBook
106
+ width: number
107
+ side: 'left' | 'right'
108
+ }) {
109
+ switch (face.kind) {
110
+ case 'cover':
111
+ return <CoverView book={book} width={width} />
112
+ case 'back':
113
+ return <BackCoverView book={book} width={width} />
114
+ case 'title':
115
+ return <TitlePageView book={book} width={width} side={side} />
116
+ case 'colophon':
117
+ return <ColophonView book={book} width={width} side={side} />
118
+ case 'blank':
119
+ return <BlankPageView width={width} side={side} />
120
+ case 'page':
121
+ return <PageView page={book.pages[face.index]} width={width} number={face.index + 1} side={side} />
122
+ }
123
+ })
124
+
125
+ const BAND_STOPS: Record<Band['name'], { offset: number; color: string; opacity: number }[]> = {
126
+ under: [
127
+ { offset: 0, color: '#000', opacity: 0.42 },
128
+ { offset: 0.17, color: '#000', opacity: 0.2 },
129
+ { offset: 0.53, color: '#000', opacity: 0.06 },
130
+ { offset: 1, color: '#000', opacity: 0 },
131
+ ],
132
+ bend: [
133
+ { offset: 0, color: '#000', opacity: 0.16 },
134
+ { offset: 0.5, color: '#000', opacity: 0.04 },
135
+ { offset: 1, color: '#000', opacity: 0 },
136
+ ],
137
+ curl: [
138
+ { offset: 0, color: '#000', opacity: 0.3 },
139
+ { offset: 0.035, color: '#000', opacity: 0.1 },
140
+ { offset: 0.11, color: '#fff', opacity: 0.2 },
141
+ { offset: 0.25, color: '#fff', opacity: 0.06 },
142
+ { offset: 0.6, color: '#000', opacity: 0.05 },
143
+ { offset: 1, color: '#000', opacity: 0.12 },
144
+ ],
145
+ }
146
+
147
+ /** A photo book that opens across two pages, turned by dragging, flicking, tapping or the buttons. */
148
+ export const Flipbook = forwardRef<FlipbookHandle, FlipbookProps>(function Flipbook(props, ref) {
149
+ const {
150
+ book,
151
+ startPage,
152
+ layout: layoutMode = 'auto',
153
+ controls = true,
154
+ autoHide = 3200,
155
+ thumbnails = true,
156
+ landscape = true,
157
+ stiffCovers = true,
158
+ physics,
159
+ grip,
160
+ shading = 1,
161
+ background,
162
+ fonts,
163
+ topLeft,
164
+ topRight,
165
+ style,
166
+ onPageChange,
167
+ onFlip,
168
+ } = props
169
+ const labels = { ...DEFAULT_LABELS, ...props.labels }
170
+ const screen = useWindowDimensions()
171
+ const [stage, setStage] = useState({ width: 0, height: 0 })
172
+ const [portraitChoice, setPortraitChoice] = useState<'ask' | 'single'>('ask')
173
+ const [locked, setLocked] = useState(false)
174
+ const [showPages, setShowPages] = useState(false)
175
+ const [, rerender] = useReducer((n: number) => n + 1, 0)
176
+ const reducedMotion = useRef(false)
177
+
178
+ useEffect(() => {
179
+ AccessibilityInfo.isReduceMotionEnabled?.().then((on) => (reducedMotion.current = on))
180
+ const subscription = AccessibilityInfo.addEventListener?.('reduceMotionChanged', (on) => (reducedMotion.current = on))
181
+
182
+ return () => subscription?.remove()
183
+ }, [])
184
+
185
+ useEffect(() => {
186
+ if (!landscape) return
187
+
188
+ let release: (() => void) | undefined
189
+ let cancelled = false
190
+
191
+ lockLandscape().then((unlock) => {
192
+ if (cancelled) return unlock?.()
193
+
194
+ release = unlock ?? undefined
195
+ setLocked(!!unlock)
196
+ })
197
+
198
+ return () => {
199
+ cancelled = true
200
+ release?.()
201
+ }
202
+ }, [landscape])
203
+
204
+ // Phones held upright are asked to turn sideways when the screen can't be locked; single pages are the fallback.
205
+ const portrait = screen.height > screen.width
206
+ const showRotatePrompt = landscape && !locked && portrait && portraitChoice === 'ask' && layoutMode !== 'single'
207
+ const narrow = stage.width > 0 && stage.width / stage.height < 0.95
208
+ const spread = layoutMode === 'auto' ? !(portrait && portraitChoice === 'single') && !narrow : layoutMode === 'spread'
209
+
210
+ const pageCount = book.pages.length
211
+ const layout = useMemo(() => new BookLayout(pageCount, { spread, stiffCovers }), [pageCount, spread, stiffCovers])
212
+
213
+ // Page size: as large as fits, keeping the page shape, with room for the controls.
214
+ const across = spread ? 2 : 1
215
+ const marginY = !controls || stage.height < 500 ? 16 : Math.min(150, stage.height * 0.16)
216
+ const pageW = Math.floor(Math.max(0, Math.min((stage.height - marginY) * PAGE_RATIO, (stage.width - (spread ? 48 : 16)) / across)))
217
+ const pageH = Math.floor(pageW / PAGE_RATIO)
218
+
219
+ const handlers = useRef({ onFlip })
220
+ handlers.current = { onFlip }
221
+
222
+ const [reader] = useState(
223
+ () =>
224
+ new FlipbookReader(
225
+ layout,
226
+ { frame: rerender, change: rerender, flip: () => handlers.current.onFlip?.() },
227
+ { flipped: layout.flippedForPage(startPage), physics, grip, reducedMotion: () => reducedMotion.current },
228
+ ),
229
+ )
230
+
231
+ useLayoutEffect(() => reader.configure({ layout, width: pageW, height: pageH }), [reader, layout, pageW, pageH])
232
+
233
+ useEffect(() => {
234
+ reader.engine.physics = { ...reader.engine.physics, ...physics }
235
+ reader.grip = { ...reader.grip, ...grip }
236
+ }, [reader, physics, grip])
237
+
238
+ useEffect(() => () => reader.destroy(), [reader])
239
+
240
+ const { flipped, active } = reader
241
+ const place = `${layout.spread}:${flipped}`
242
+ const onPageChangeRef = useRef(onPageChange)
243
+ onPageChangeRef.current = onPageChange
244
+
245
+ useEffect(() => {
246
+ onPageChangeRef.current?.({ flipped: reader.flipped, label: reader.label, faces: reader.layout.visible(reader.flipped) })
247
+ }, [place, reader])
248
+
249
+ useImperativeHandle(
250
+ ref,
251
+ () => ({
252
+ next: () => reader.next(),
253
+ prev: () => reader.prev(),
254
+ go: (target) => reader.go(target),
255
+ goToPage: (page) => reader.go(reader.layout.flippedForPage(page)),
256
+ reader,
257
+ }),
258
+ [reader],
259
+ )
260
+
261
+ // ------------------------------------------------- Controls that hide while reading
262
+ const chrome = useRef(new Animated.Value(1)).current
263
+ const [chromeShown, setChromeShown] = useState(true)
264
+ const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
265
+
266
+ const poke = useCallback(() => {
267
+ clearTimeout(hideTimer.current)
268
+ setChromeShown(true)
269
+ Animated.timing(chrome, { toValue: 1, duration: 200, useNativeDriver: true }).start()
270
+ if (autoHide === false) return
271
+
272
+ hideTimer.current = setTimeout(() => {
273
+ setChromeShown(false)
274
+ Animated.timing(chrome, { toValue: 0, duration: 300, useNativeDriver: true }).start()
275
+ }, autoHide)
276
+ }, [autoHide, chrome])
277
+
278
+ useEffect(() => {
279
+ poke()
280
+ return () => clearTimeout(hideTimer.current)
281
+ }, [poke])
282
+
283
+ // ------------------------------------------------- A closed book sits centred
284
+ const settled = reader.settled
285
+ const shift = layout.spread && settled ? (reader.atStart ? -pageW / 2 : reader.atEnd ? pageW / 2 : 0) : 0
286
+ const shiftValue = useRef(new Animated.Value(shift)).current
287
+
288
+ useEffect(() => {
289
+ Animated.timing(shiftValue, { toValue: shift, duration: 600, useNativeDriver: true }).start()
290
+ }, [shift, shiftValue])
291
+
292
+ // ------------------------------------------------- Touch: grab, drag, flick, tap
293
+ const geometry = useRef({ stage, pageW, pageH, across, spread: layout.spread, shift })
294
+ geometry.current = { stage, pageW, pageH, across, spread: layout.spread, shift }
295
+
296
+ const grant = useRef<Vec>({ x: 0, y: 0 })
297
+
298
+ /** A point on the stage measured from the top of the spine. */
299
+ const toBook = (p: Vec): Vec => {
300
+ const g = geometry.current
301
+ const left = (g.stage.width - g.pageW * g.across) / 2 + g.shift
302
+ const top = (g.stage.height - g.pageH) / 2
303
+
304
+ return { x: p.x - left - (g.spread ? g.pageW : 0), y: p.y - top }
305
+ }
306
+
307
+ const responder = useMemo(
308
+ () =>
309
+ PanResponder.create({
310
+ onStartShouldSetPanResponder: () => true,
311
+ onMoveShouldSetPanResponder: () => true,
312
+ onPanResponderTerminationRequest: () => false,
313
+ onPanResponderGrant: (e) => {
314
+ grant.current = { x: e.nativeEvent.locationX, y: e.nativeEvent.locationY }
315
+ poke()
316
+ reader.down(toBook(grant.current), 1)
317
+ },
318
+ onPanResponderMove: (_, g) => {
319
+ reader.move(toBook({ x: grant.current.x + g.dx, y: grant.current.y + g.dy }), 1)
320
+ },
321
+ onPanResponderRelease: (_, g) => {
322
+ const at = { x: grant.current.x + g.dx, y: grant.current.y + g.dy }
323
+
324
+ if (reader.up(toBook(at), 1) !== 'tap') return
325
+
326
+ // A tap on the outer thirds turns the page.
327
+ const width = geometry.current.stage.width
328
+
329
+ if (at.x > (width * 2) / 3) reader.next()
330
+ else if (at.x < width / 3) reader.prev()
331
+ },
332
+ onPanResponderTerminate: () => reader.cancel(),
333
+ }),
334
+ [reader, poke],
335
+ )
336
+
337
+ // ------------------------------------------------- Faces around the opening
338
+ const fold = new FoldLayout(pageW, pageH, active, active ? reader.engine.frame : null, shading)
339
+ const leaves = layout.leaves
340
+ const turnLeaf = active?.leaf ?? null
341
+ const turnDir = active?.dir ?? 1
342
+ const rightLeaf = turnLeaf !== null ? turnLeaf + 1 : flipped
343
+ const leftLeaf = turnLeaf !== null ? turnLeaf - 1 : flipped - 1
344
+ const faces: ReactNode[] = []
345
+
346
+ for (let k = Math.max(0, flipped - 2); k <= Math.min(leaves.length - 1, flipped + 1) && pageW > 0; k++) {
347
+ for (const side of ['front', 'back'] as const) {
348
+ const facesUp = (side === 'front') === (turnDir === 1)
349
+ const role: Role =
350
+ k === turnLeaf
351
+ ? facesUp
352
+ ? 'turning'
353
+ : 'flap'
354
+ : side === 'front'
355
+ ? k === rightLeaf
356
+ ? 'right'
357
+ : 'spare'
358
+ : k === leftLeaf && layout.spread
359
+ ? 'left'
360
+ : 'spare'
361
+
362
+ if (role === 'spare') continue
363
+
364
+ const onLeft = role === 'left' || (role === 'turning' && turnDir === -1 && !active?.stiff)
365
+ const placement = fold.place(role, side, onLeft)
366
+ const z = { right: 1, left: 1, turning: 3, flap: 6 }[role]
367
+
368
+ faces.push(
369
+ <View key={`${k}:${side}`} style={[placement.clip, { zIndex: z }]} pointerEvents="none">
370
+ <View style={placement.frame}>
371
+ <View style={placement.face}>
372
+ <FaceContent
373
+ face={side === 'front' ? leaves[k].front : leaves[k].back}
374
+ book={book}
375
+ width={pageW}
376
+ side={side === 'front' ? 'right' : 'left'}
377
+ />
378
+ {placement.bands.map((band) => (
379
+ <View key={band.name} style={[band.style, { opacity: band.opacity }]}>
380
+ <Gradient stops={BAND_STOPS[band.name]} />
381
+ </View>
382
+ ))}
383
+ </View>
384
+ </View>
385
+ </View>,
386
+ )
387
+ }
388
+ }
389
+
390
+ const tint = COVER_THEMES[book.theme ?? 'classic'].stage
391
+
392
+ return (
393
+ <BookLookProvider paper={book.paper} frame={book.frame} fonts={fonts}>
394
+ <View style={[styles.root, { backgroundColor: background ?? tint[2] }, style]}>
395
+ {!background && <StageGlow colors={tint} />}
396
+
397
+ <View
398
+ style={styles.stage}
399
+ onLayout={(e: LayoutChangeEvent) => setStage({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height })}
400
+ pointerEvents="box-only"
401
+ {...responder.panHandlers}
402
+ >
403
+ {pageW > 0 && (
404
+ <Animated.View style={{ width: pageW * across, height: pageH, transform: [{ translateX: shiftValue }] }} pointerEvents="none">
405
+ <View
406
+ style={[
407
+ styles.shadow,
408
+ {
409
+ left: layout.spread && reader.atStart && settled ? '52%' : '2%',
410
+ right: layout.spread && reader.atEnd && settled ? '52%' : '2%',
411
+ },
412
+ ]}
413
+ />
414
+ <View style={[styles.spine, { left: layout.spread ? pageW : 0 }]}>{faces}</View>
415
+ </Animated.View>
416
+ )}
417
+ </View>
418
+
419
+ {controls && (
420
+ <Animated.View style={[styles.bar, styles.top, { opacity: chrome }]} pointerEvents={chromeShown ? 'box-none' : 'none'}>
421
+ <Gradient
422
+ to="bottom"
423
+ stops={[
424
+ { offset: 0, color: '#000', opacity: 0.6 },
425
+ { offset: 1, color: '#000', opacity: 0 },
426
+ ]}
427
+ />
428
+ <View style={styles.group}>
429
+ {topLeft}
430
+ <View style={styles.heading}>
431
+ <Text style={styles.title} numberOfLines={1}>
432
+ {book.title}
433
+ </Text>
434
+ {book.author ? (
435
+ <Text style={styles.byline} numberOfLines={1}>
436
+ {labels.byline(book.author)}
437
+ </Text>
438
+ ) : null}
439
+ </View>
440
+ </View>
441
+ <View style={styles.group}>{topRight}</View>
442
+ </Animated.View>
443
+ )}
444
+
445
+ {showPages && thumbnails && (
446
+ <PageStrip
447
+ book={book}
448
+ layout={layout}
449
+ flipped={flipped}
450
+ onPick={(target) => {
451
+ reader.go(target)
452
+ setShowPages(false)
453
+ }}
454
+ />
455
+ )}
456
+
457
+ {controls && (
458
+ <Animated.View style={[styles.bar, styles.bottom, { opacity: chrome }]} pointerEvents={chromeShown ? 'box-none' : 'none'}>
459
+ <Gradient
460
+ to="top"
461
+ stops={[
462
+ { offset: 0, color: '#000', opacity: 0.6 },
463
+ { offset: 1, color: '#000', opacity: 0 },
464
+ ]}
465
+ />
466
+ <RoundButton label={labels.previous} disabled={reader.atStart} onPress={() => reader.prev()} icon="M15 6l-6 6 6 6" />
467
+ <Pressable
468
+ onPress={() => thumbnails && setShowPages((s) => !s)}
469
+ style={styles.pages}
470
+ role="button"
471
+ aria-expanded={thumbnails ? showPages : undefined}
472
+ >
473
+ <Text style={styles.pagesText}>{reader.label}</Text>
474
+ </Pressable>
475
+ <RoundButton label={labels.next} disabled={reader.atEnd} onPress={() => reader.next()} icon="M9 6l6 6-6 6" />
476
+ </Animated.View>
477
+ )}
478
+
479
+ {showRotatePrompt && (
480
+ <View style={styles.rotate}>
481
+ <Svg viewBox="0 0 64 64" width={80} height={80}>
482
+ <Rect x="20" y="6" width="24" height="52" rx="4" fill="none" stroke="#fff" strokeWidth={2.5} />
483
+ <Path d="M32 49a2 2 0 1 0 0.01 0" stroke="#fff" strokeWidth={4} strokeLinecap="round" />
484
+ </Svg>
485
+ <Text style={styles.rotateTitle}>{labels.rotateTitle}</Text>
486
+ <Text style={styles.rotateBody}>{labels.rotateBody}</Text>
487
+ <Pressable onPress={() => setPortraitChoice('single')} role="button">
488
+ <Text style={styles.rotateSecondary}>{labels.rotateSingle}</Text>
489
+ </Pressable>
490
+ </View>
491
+ )}
492
+ </View>
493
+ </BookLookProvider>
494
+ )
495
+ })
496
+
497
+ /** A soft glow behind the book, tinted by the cover. */
498
+ function StageGlow({ colors }: { colors: [string, string, string] }) {
499
+ return (
500
+ <Svg style={StyleSheet.absoluteFill} width="100%" height="100%" pointerEvents="none">
501
+ <Defs>
502
+ <RadialGradient id="fbStage" cx="50%" cy="40%" rx="70%" ry="70%">
503
+ <Stop offset="0" stopColor={colors[0]} />
504
+ <Stop offset="0.55" stopColor={colors[1]} />
505
+ <Stop offset="1" stopColor={colors[2]} />
506
+ </RadialGradient>
507
+ </Defs>
508
+ <Rect width="100%" height="100%" fill="url(#fbStage)" />
509
+ </Svg>
510
+ )
511
+ }
512
+
513
+ /** A strip of every spread (or page) to jump around the book. */
514
+ function PageStrip({
515
+ book,
516
+ layout,
517
+ flipped,
518
+ onPick,
519
+ }: {
520
+ book: FlipbookBook
521
+ layout: BookLayout
522
+ flipped: number
523
+ onPick: (flipped: number) => void
524
+ }) {
525
+ const width = 54
526
+
527
+ return (
528
+ <View style={styles.strip}>
529
+ <ScrollView horizontal contentContainerStyle={styles.stripList} showsHorizontalScrollIndicator={false}>
530
+ {layout.stops().map((stop) => (
531
+ <Pressable
532
+ key={stop.flipped}
533
+ onPress={() => onPick(stop.flipped)}
534
+ role="button"
535
+ aria-label={`Go to ${BookLayout.describe(stop.faces)}`}
536
+ style={[styles.stop, stop.flipped === flipped && styles.stopCurrent]}
537
+ >
538
+ {stop.faces.map((face, i) => (
539
+ <FaceContent key={i} face={face} book={book} width={width} side={i === 0 && stop.faces.length > 1 ? 'left' : 'right'} />
540
+ ))}
541
+ </Pressable>
542
+ ))}
543
+ </ScrollView>
544
+ </View>
545
+ )
546
+ }
547
+
548
+ function RoundButton({ label, icon, disabled, onPress }: { label: string; icon: string; disabled?: boolean; onPress: () => void }) {
549
+ return (
550
+ <Pressable onPress={onPress} disabled={disabled} role="button" aria-label={label} style={[styles.button, disabled && styles.disabled]}>
551
+ <Svg viewBox="0 0 24 24" width={20} height={20}>
552
+ <Path d={icon} stroke="#fff" strokeWidth={2} fill="none" strokeLinecap="round" strokeLinejoin="round" />
553
+ </Svg>
554
+ </Pressable>
555
+ )
556
+ }
557
+
558
+ const styles = StyleSheet.create({
559
+ root: { flex: 1, overflow: 'hidden', userSelect: 'none' },
560
+ stage: { flex: 1, alignItems: 'center', justifyContent: 'center' },
561
+ spine: { position: 'absolute', top: 0, width: 0, height: 0 },
562
+ shadow: { position: 'absolute', bottom: -10, height: 20, borderRadius: 999, backgroundColor: 'rgba(0,0,0,0.25)' },
563
+ bar: { position: 'absolute', left: 0, right: 0, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16 },
564
+ top: { top: 0, justifyContent: 'space-between', paddingVertical: 12 },
565
+ bottom: { bottom: 0, justifyContent: 'center', paddingTop: 24, paddingBottom: 16 },
566
+ group: { flexDirection: 'row', alignItems: 'center', gap: 8, flexShrink: 1 },
567
+ heading: { flexShrink: 1 },
568
+ title: { color: '#fff', fontSize: 18 },
569
+ byline: { color: 'rgba(255,255,255,0.6)', fontSize: 12 },
570
+ button: { padding: 10, borderRadius: 999, backgroundColor: 'rgba(255,255,255,0.1)' },
571
+ disabled: { opacity: 0.3 },
572
+ pages: {
573
+ minWidth: 160,
574
+ alignItems: 'center',
575
+ paddingVertical: 8,
576
+ paddingHorizontal: 16,
577
+ borderRadius: 999,
578
+ backgroundColor: 'rgba(255,255,255,0.1)',
579
+ },
580
+ pagesText: { color: 'rgba(255,255,255,0.9)', fontSize: 14, fontVariant: ['tabular-nums'] },
581
+ strip: { position: 'absolute', left: 16, right: 16, bottom: 80, borderRadius: 16, backgroundColor: 'rgba(0,0,0,0.6)' },
582
+ stripList: { gap: 12, padding: 12 },
583
+ stop: { flexDirection: 'row', borderWidth: 2, borderColor: 'transparent', opacity: 0.8 },
584
+ stopCurrent: { borderColor: '#fcd34d', opacity: 1 },
585
+ rotate: {
586
+ position: 'absolute',
587
+ top: 0,
588
+ right: 0,
589
+ bottom: 0,
590
+ left: 0,
591
+ alignItems: 'center',
592
+ justifyContent: 'center',
593
+ gap: 16,
594
+ paddingHorizontal: 32,
595
+ backgroundColor: 'rgba(0,0,0,0.85)',
596
+ },
597
+ rotateTitle: { color: '#fff', fontSize: 24, textAlign: 'center' },
598
+ rotateBody: { color: 'rgba(255,255,255,0.7)', fontSize: 14, textAlign: 'center' },
599
+ rotateSecondary: { color: 'rgba(255,255,255,0.8)', fontSize: 14, textDecorationLine: 'underline' },
600
+ })
@@ -0,0 +1,39 @@
1
+ import { useId } from 'react'
2
+ import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
3
+ import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
4
+
5
+ export interface GradientStop {
6
+ offset: number
7
+ color: string
8
+ opacity?: number
9
+ }
10
+
11
+ /** A linear gradient filling its box, drawn with react-native-svg so it needs no other native module. */
12
+ export function Gradient({
13
+ stops,
14
+ to = 'right',
15
+ style = StyleSheet.absoluteFill,
16
+ }: {
17
+ stops: GradientStop[]
18
+ /** Where the last stop lies. */
19
+ to?: 'right' | 'left' | 'bottom' | 'top'
20
+ style?: StyleProp<ViewStyle>
21
+ }) {
22
+ const id = `g${useId().replace(/[^a-zA-Z0-9]/g, '')}`
23
+ const [x1, y1, x2, y2] = { right: [0, 0, 1, 0], left: [1, 0, 0, 0], bottom: [0, 0, 0, 1], top: [0, 1, 0, 0] }[to]
24
+
25
+ return (
26
+ <Svg style={style} width="100%" height="100%" pointerEvents="none">
27
+ <Defs>
28
+ <LinearGradient id={id} x1={x1} y1={y1} x2={x2} y2={y2}>
29
+ {stops.map((stop, i) => (
30
+ <Stop key={i} offset={stop.offset} stopColor={stop.color} stopOpacity={stop.opacity ?? 1} />
31
+ ))}
32
+ </LinearGradient>
33
+ </Defs>
34
+ <Rect width="100%" height="100%" fill={`url(#${id})`} />
35
+ </Svg>
36
+ )
37
+ }
38
+
39
+ /** Stops for a colour given as `rgba(r,g,b,a)`, split so svg gets the alpha separately. */