fix playback offset
This commit is contained in:
@@ -25,7 +25,7 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
||||
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
|
||||
const COUNTDOWN_SECONDS = 10
|
||||
const CAMERA_STARTUP_DELAY_MS = 250
|
||||
const KARAOKE_LEAD_S = 0.18
|
||||
const KARAOKE_LEAD_S = 0.68
|
||||
const LOG_PREFIX = '[RecordPlayback]'
|
||||
const KEEP_AWAKE_TAG = 'record-playback'
|
||||
const log =
|
||||
|
||||
@@ -30,7 +30,7 @@ const MEDIA_BOOTSTRAP_DELAY_MS = 250
|
||||
const MEDIA_RETRY_DELAY_MS = 700
|
||||
const MEDIA_MAX_RETRIES = 2
|
||||
const CAMERA_STARTUP_DELAY_MS = 250
|
||||
const KARAOKE_LEAD_S = 0.18
|
||||
const KARAOKE_LEAD_S = 0.68
|
||||
|
||||
const toSeconds = (v) => {
|
||||
const n = Number(v ?? 0)
|
||||
|
||||
@@ -3,13 +3,12 @@ import useSharedAudioPlayer from '../../hooks/useSharedAudioPlayer'
|
||||
import * as FileSystem from 'expo-file-system'
|
||||
import { VideoView, useVideoPlayer } from 'expo-video'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Image, Pressable, View } from 'react-native'
|
||||
import { Image, Pressable, Text, View } from 'react-native'
|
||||
import { icons } from '../../assets'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import ProgressSlider from '../../components/player/ProgressSlider'
|
||||
import SyncOffsetSlider from '../../components/player/SyncOffsetSlider'
|
||||
import Page from '../../layouts/Page'
|
||||
import { Routes } from '../../navigation'
|
||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||
@@ -277,65 +276,182 @@ const RecordedPlayback = ({ route }) => {
|
||||
return (
|
||||
<Page backgroundColor={Palette.grayMid} headerType="NONE">
|
||||
<MusicLandHeader progress={19} onPressBack={goBack} />
|
||||
<View style={{ flex: 1, paddingTop: 12, gap: 14, paddingBottom: gutters * 2 }}>
|
||||
<View style={{ flex: 1, gap: 22 }}>
|
||||
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
|
||||
|
||||
{/* Vidéo avec play/pause en overlay */}
|
||||
<Pressable
|
||||
onPress={handleTogglePlayback}
|
||||
style={{
|
||||
flex: 1,
|
||||
width: '92%',
|
||||
alignSelf: 'center',
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#00000066',
|
||||
overflow: 'hidden',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
{!!videoUri && (
|
||||
<VideoView
|
||||
player={videoPlayer}
|
||||
nativeControls={false}
|
||||
contentFit="contain"
|
||||
style={{
|
||||
width: '80%',
|
||||
flex: 1,
|
||||
alignSelf: 'center',
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#00000066',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
style={{ flex: 1, width: '100%', backgroundColor: 'transparent' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={{ width: '80%', alignSelf: 'center', gap: 18 }}>
|
||||
<ProgressSlider
|
||||
positionMs={progressInfo.pos}
|
||||
durationMs={progressInfo.dur}
|
||||
isPlaying={progressInfo.isPlaying}
|
||||
onSeek={onSeek}
|
||||
onSeekStart={onSeekStart}
|
||||
onPause={pauseDuringSeek}
|
||||
onPlay={resumeAfterSeek}
|
||||
disabled={!songUrl}
|
||||
/>
|
||||
<SyncOffsetSlider
|
||||
valueMs={currentSyncOffsetMs}
|
||||
onChange={handleSyncOffsetChange}
|
||||
disabled={!songUrl}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleTogglePlayback}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
alignSelf: 'center',
|
||||
marginTop: 4,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(10, 5, 24, 0.65)',
|
||||
borderWidth: 1,
|
||||
borderColor: '#F94697',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={progressInfo.isPlaying ? icons.pause : icons.play}
|
||||
style={{ width: 26, height: 26 }}
|
||||
/>
|
||||
</Pressable>
|
||||
{!progressInfo.isPlaying && (
|
||||
<View
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(10, 5, 24, 0.72)',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#F94697',
|
||||
}}
|
||||
>
|
||||
<Image source={icons.play} style={{ width: 30, height: 30 }} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Contrôles */}
|
||||
<View style={{ width: '92%', alignSelf: 'center', gap: 12, marginTop: 14 }}>
|
||||
<ProgressSlider
|
||||
positionMs={progressInfo.pos}
|
||||
durationMs={progressInfo.dur}
|
||||
isPlaying={progressInfo.isPlaying}
|
||||
onSeek={onSeek}
|
||||
onSeekStart={onSeekStart}
|
||||
onPause={pauseDuringSeek}
|
||||
onPlay={resumeAfterSeek}
|
||||
disabled={!songUrl}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Synchro audio / vidéo
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<Pressable
|
||||
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))}
|
||||
disabled={!songUrl || currentSyncOffsetMs <= -2000}
|
||||
hitSlop={10}
|
||||
style={({ pressed }) => ({
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
backgroundColor: pressed ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: !songUrl || currentSyncOffsetMs <= -2000 ? 0.3 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: Palette.white, fontSize: 26, lineHeight: 30 }}>−</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ alignItems: 'center', minWidth: 90 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.white,
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
{currentSyncOffsetMs === 0
|
||||
? '0'
|
||||
: (currentSyncOffsetMs > 0 ? '+' : '') + currentSyncOffsetMs}
|
||||
</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11, marginTop: 2 }}>
|
||||
{currentSyncOffsetMs === 0
|
||||
? 'ms · synchronisé'
|
||||
: currentSyncOffsetMs > 0
|
||||
? 'ms · son en avance'
|
||||
: 'ms · son en retard'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs + 100))}
|
||||
disabled={!songUrl || currentSyncOffsetMs >= 2000}
|
||||
hitSlop={10}
|
||||
style={({ pressed }) => ({
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
backgroundColor: pressed ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: !songUrl || currentSyncOffsetMs >= 2000 ? 0.3 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: Palette.white, fontSize: 26, lineHeight: 30 }}>+</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => handleSyncOffsetChange(0)}
|
||||
disabled={!songUrl || currentSyncOffsetMs === 0}
|
||||
style={({ pressed }) => ({
|
||||
alignSelf: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
backgroundColor:
|
||||
!songUrl || currentSyncOffsetMs === 0
|
||||
? 'rgba(255,255,255,0.06)'
|
||||
: pressed
|
||||
? 'rgba(255,255,255,0.18)'
|
||||
: 'rgba(255,255,255,0.1)',
|
||||
opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1,
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.white,
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Remettre à zéro
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ width: '80%', alignSelf: 'center', marginTop: 4, gap: 12 }}>
|
||||
<View style={{ width: '92%', alignSelf: 'center', marginTop: 14, gap: 12 }}>
|
||||
<GradientButton
|
||||
title="Je valide"
|
||||
onPress={async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import ProgressSlider from '../../components/player/ProgressSlider'
|
||||
import SyncOffsetSlider from '../../components/player/SyncOffsetSlider'
|
||||
import Page from '../../layouts/Page'
|
||||
import { Routes } from '../../navigation'
|
||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
snapSyncOffsetMs,
|
||||
} from '../../utils/playbackSync'
|
||||
|
||||
const WEB_PREVIEW_WIDTH = 360
|
||||
const WEB_PREVIEW_WIDTH = 420
|
||||
|
||||
const RecordedPlayback = ({ route }) => {
|
||||
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
|
||||
@@ -306,26 +305,23 @@ const RecordedPlayback = ({ route }) => {
|
||||
progress={19}
|
||||
onPressBack={goBack}
|
||||
style={{
|
||||
marginBottom: 40,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<View style={{ width: '100%' }}>
|
||||
{videoUri ? (
|
||||
<Pressable
|
||||
onPress={handleTogglePlayback}
|
||||
style={{
|
||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
||||
width: isWeb ? WEB_PREVIEW_WIDTH : '92%',
|
||||
maxWidth: '100%',
|
||||
aspectRatio: 9 / 16,
|
||||
borderRadius: 12,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#00000066',
|
||||
overflow: 'hidden',
|
||||
alignSelf: 'center',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<video
|
||||
@@ -348,28 +344,28 @@ const RecordedPlayback = ({ route }) => {
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: [{ translateX: -36 }, { translateY: -36 }],
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
transform: [{ translateX: -40 }, { translateY: -40 }],
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(10, 5, 24, 0.75)',
|
||||
borderWidth: 1,
|
||||
backgroundColor: 'rgba(10, 5, 24, 0.72)',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#F94697',
|
||||
}}
|
||||
>
|
||||
<Image source={icons.play} style={{ width: 26, height: 26 }} />
|
||||
<Image source={icons.play} style={{ width: 30, height: 30 }} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
||||
width: isWeb ? WEB_PREVIEW_WIDTH : '92%',
|
||||
maxWidth: '100%',
|
||||
aspectRatio: 9 / 16,
|
||||
borderRadius: 12,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#00000066',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -385,11 +381,11 @@ const RecordedPlayback = ({ route }) => {
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
||||
width: isWeb ? WEB_PREVIEW_WIDTH : '92%',
|
||||
maxWidth: '100%',
|
||||
alignSelf: 'center',
|
||||
marginTop: 12,
|
||||
gap: 18,
|
||||
marginTop: 14,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<ProgressSlider
|
||||
@@ -402,21 +398,124 @@ const RecordedPlayback = ({ route }) => {
|
||||
onPlay={resumeAfterSeek}
|
||||
disabled={!songUrl}
|
||||
/>
|
||||
<SyncOffsetSlider
|
||||
valueMs={currentSyncOffsetMs}
|
||||
onChange={handleSyncOffsetChange}
|
||||
disabled={!songUrl}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.2,
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Synchro audio / vidéo
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||
<Pressable
|
||||
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))}
|
||||
disabled={!songUrl || currentSyncOffsetMs <= -2000}
|
||||
style={({ pressed }) => ({
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
backgroundColor: pressed ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: !songUrl || currentSyncOffsetMs <= -2000 ? 'not-allowed' : 'pointer',
|
||||
opacity: !songUrl || currentSyncOffsetMs <= -2000 ? 0.3 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: Palette.white, fontSize: 26, lineHeight: 30 }}>−</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ alignItems: 'center', minWidth: 90 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.white,
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
{currentSyncOffsetMs === 0
|
||||
? '0'
|
||||
: (currentSyncOffsetMs > 0 ? '+' : '') + currentSyncOffsetMs}
|
||||
</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11, marginTop: 2 }}>
|
||||
{currentSyncOffsetMs === 0
|
||||
? 'ms · synchronisé'
|
||||
: currentSyncOffsetMs > 0
|
||||
? 'ms · son en avance'
|
||||
: 'ms · son en retard'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs + 100))}
|
||||
disabled={!songUrl || currentSyncOffsetMs >= 2000}
|
||||
style={({ pressed }) => ({
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
backgroundColor: pressed ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: !songUrl || currentSyncOffsetMs >= 2000 ? 'not-allowed' : 'pointer',
|
||||
opacity: !songUrl || currentSyncOffsetMs >= 2000 ? 0.3 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: Palette.white, fontSize: 26, lineHeight: 30 }}>+</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => handleSyncOffsetChange(0)}
|
||||
disabled={!songUrl || currentSyncOffsetMs === 0}
|
||||
style={({ pressed }) => ({
|
||||
alignSelf: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
backgroundColor:
|
||||
!songUrl || currentSyncOffsetMs === 0
|
||||
? 'rgba(255,255,255,0.06)'
|
||||
: pressed
|
||||
? 'rgba(255,255,255,0.18)'
|
||||
: 'rgba(255,255,255,0.1)',
|
||||
opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1,
|
||||
cursor: !songUrl || currentSyncOffsetMs === 0 ? 'not-allowed' : 'pointer',
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.white,
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Remettre à zéro
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
||||
width: isWeb ? WEB_PREVIEW_WIDTH : '92%',
|
||||
maxWidth: '100%',
|
||||
alignSelf: 'center',
|
||||
gap: 12,
|
||||
marginTop: 50,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<GradientButton
|
||||
|
||||
+145
-48
@@ -1,5 +1,14 @@
|
||||
import React from 'react'
|
||||
import { ActivityIndicator, FlatList, Image, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { useRoute } from '@react-navigation/native'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import { Image as ExpoImage } from 'expo-image'
|
||||
@@ -461,9 +470,9 @@ export default function Subscriptions() {
|
||||
|
||||
const mobileListContentInset = React.useMemo(
|
||||
() => ({
|
||||
paddingBottom: isCoinPackView ? gutters * 2 : mobileActionSafePadding + gutters * 3,
|
||||
paddingBottom: mobileActionSafePadding + gutters * 3,
|
||||
}),
|
||||
[isCoinPackView, mobileActionSafePadding]
|
||||
[mobileActionSafePadding]
|
||||
)
|
||||
const handleBackPress = React.useCallback(() => {
|
||||
goBack()
|
||||
@@ -518,10 +527,6 @@ export default function Subscriptions() {
|
||||
}, [combinedErrorMessage, currentUserData?.coins, screenTitle])
|
||||
|
||||
const renderSegmentedControl = React.useCallback(() => {
|
||||
if (isCoinPackView) {
|
||||
return null
|
||||
}
|
||||
|
||||
const segments = [
|
||||
{ key: 'monthly', label: 'Mensuel', plans: normalizedPlansByPeriod?.monthly },
|
||||
{ key: 'annual', label: 'Annuel', plans: normalizedPlansByPeriod?.annual },
|
||||
@@ -559,7 +564,7 @@ export default function Subscriptions() {
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}, [handlePeriodChange, isCoinPackView, isMobile, normalizedPlansByPeriod, selectedPeriodKey])
|
||||
}, [handlePeriodChange, isMobile, normalizedPlansByPeriod, selectedPeriodKey])
|
||||
|
||||
const renderMobilePlanItem = React.useCallback(
|
||||
({ item }) => (
|
||||
@@ -575,60 +580,137 @@ export default function Subscriptions() {
|
||||
[handleSelect, selectedPeriodKey, selectedPriceId]
|
||||
)
|
||||
|
||||
const renderMobileCoinPackItem = React.useCallback(
|
||||
({ item }) => (
|
||||
<View style={styles.mobileCard}>
|
||||
<CoinPackCard
|
||||
pack={item}
|
||||
onBuy={handleBuyCredits}
|
||||
isProcessing={processingPriceId === item.productId}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[handleBuyCredits, processingPriceId]
|
||||
)
|
||||
|
||||
const renderMobileEmptyComponent = React.useCallback(() => {
|
||||
return (
|
||||
<View style={styles.mobileEmptyWrapper}>
|
||||
{(isCoinPackView ? isCatalogLoading && !coinPacks.length : isLoadingPlans) ? (
|
||||
{isLoadingPlans ? (
|
||||
<ActivityIndicator color={Palette.white} />
|
||||
) : (
|
||||
<Text style={styles.emptyState}>
|
||||
{isCoinPackView
|
||||
? "Aucun pack de crédits n'est disponible pour le moment."
|
||||
: 'Aucun abonnement Stripe disponible pour le moment.'}
|
||||
</Text>
|
||||
<Text style={styles.emptyState}>Aucun abonnement Stripe disponible pour le moment.</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}, [coinPacks.length, isCatalogLoading, isCoinPackView, isLoadingPlans])
|
||||
}, [isLoadingPlans])
|
||||
|
||||
const renderMobileFooterComponent = React.useCallback(() => {
|
||||
return (
|
||||
<View style={styles.mobileFooter}>
|
||||
{(isCoinPackView ? isCatalogLoading && coinPacks.length > 0 : isLoadingPlans && currentPlanCount > 0) ? (
|
||||
{isLoadingPlans && currentPlanCount > 0 ? (
|
||||
<View style={styles.inlineLoader}>
|
||||
<ActivityIndicator color={Palette.white} size="small" />
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.disclaimer}>
|
||||
{isCoinPackView
|
||||
? 'Les crédits sont ajoutés dès que le paiement Stripe est validé.'
|
||||
: SUBSCRIPTION_DISCLAIMER}
|
||||
</Text>
|
||||
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
|
||||
</View>
|
||||
)
|
||||
}, [coinPacks.length, currentPlanCount, isCatalogLoading, isCoinPackView, isLoadingPlans])
|
||||
}, [currentPlanCount, isLoadingPlans])
|
||||
|
||||
const renderMobileTopSticky = React.useCallback(() => {
|
||||
return (
|
||||
<View style={styles.mobileStickyHeader}>
|
||||
{renderHeaderSection()}
|
||||
{renderSegmentedControl()}
|
||||
{!isCoinPackView ? renderSegmentedControl() : null}
|
||||
</View>
|
||||
)
|
||||
}, [renderHeaderSection, renderSegmentedControl])
|
||||
}, [isCoinPackView, renderHeaderSection, renderSegmentedControl])
|
||||
|
||||
const renderMobileCoinPackView = React.useCallback(() => {
|
||||
const hasCoinPacks = coinPacks.length > 0
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.mobileList}
|
||||
contentContainerStyle={[styles.mobileListContent, mobileListContentInset]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.mobileSection}>
|
||||
<Text style={styles.mobileSectionTitle}>Abonnements</Text>
|
||||
{renderSegmentedControl()}
|
||||
|
||||
{isLoadingPlans && currentPlanCount === 0 ? (
|
||||
<View style={styles.mobileEmptyWrapper}>
|
||||
<ActivityIndicator color={Palette.white} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!isLoadingPlans && currentPlanCount === 0 ? (
|
||||
<View style={styles.mobileEmptyWrapper}>
|
||||
<Text style={styles.emptyState}>Aucun abonnement Stripe disponible pour le moment.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{currentPlans.map((plan) => (
|
||||
<View key={plan.priceId} style={styles.mobileCard}>
|
||||
<SubscriptionCard
|
||||
plan={plan}
|
||||
selected={selectedPriceId === plan.priceId}
|
||||
onSelect={handleSelect}
|
||||
isAnnual={selectedPeriodKey === 'annual'}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{isLoadingPlans && currentPlanCount > 0 ? (
|
||||
<View style={styles.inlineLoader}>
|
||||
<ActivityIndicator color={Palette.white} size="small" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.mobileSection}>
|
||||
<Text style={styles.mobileSectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
|
||||
{isCatalogLoading && !hasCoinPacks ? (
|
||||
<View style={styles.mobileEmptyWrapper}>
|
||||
<ActivityIndicator color={Palette.white} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!isCatalogLoading && !hasCoinPacks ? (
|
||||
<View style={styles.mobileEmptyWrapper}>
|
||||
<Text style={styles.emptyState}>
|
||||
Aucun pack de crédits n'est disponible pour le moment.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{coinPacks.map((pack) => (
|
||||
<View key={pack.productId} style={styles.mobileCard}>
|
||||
<CoinPackCard
|
||||
pack={pack}
|
||||
onBuy={handleBuyCredits}
|
||||
isProcessing={processingPriceId === pack.productId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{isCatalogLoading && hasCoinPacks ? (
|
||||
<View style={styles.inlineLoader}>
|
||||
<ActivityIndicator color={Palette.white} size="small" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={styles.disclaimer}>
|
||||
Les crédits sont ajoutés dès que le paiement Stripe est validé.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}, [
|
||||
coinPacks,
|
||||
currentPlanCount,
|
||||
currentPlans,
|
||||
handleBuyCredits,
|
||||
handleSelect,
|
||||
isCatalogLoading,
|
||||
isLoadingPlans,
|
||||
mobileListContentInset,
|
||||
processingPriceId,
|
||||
renderSegmentedControl,
|
||||
selectedPeriodKey,
|
||||
selectedPriceId,
|
||||
])
|
||||
|
||||
const renderMobileBottomActions = React.useCallback(() => {
|
||||
return (
|
||||
@@ -677,16 +759,20 @@ export default function Subscriptions() {
|
||||
|
||||
<View style={[styles.content, isMobile && styles.mobileContent]}>
|
||||
{isMobile ? (
|
||||
<FlatList
|
||||
data={isCoinPackView ? coinPacks : currentPlans}
|
||||
keyExtractor={(item) => item.productId || item.priceId}
|
||||
renderItem={isCoinPackView ? renderMobileCoinPackItem : renderMobilePlanItem}
|
||||
style={styles.mobileList}
|
||||
contentContainerStyle={[styles.mobileListContent, mobileListContentInset]}
|
||||
ListEmptyComponent={renderMobileEmptyComponent}
|
||||
ListFooterComponent={renderMobileFooterComponent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
isCoinPackView ? (
|
||||
renderMobileCoinPackView()
|
||||
) : (
|
||||
<FlatList
|
||||
data={currentPlans}
|
||||
keyExtractor={(item) => item.priceId}
|
||||
renderItem={renderMobilePlanItem}
|
||||
style={styles.mobileList}
|
||||
contentContainerStyle={[styles.mobileListContent, mobileListContentInset]}
|
||||
ListEmptyComponent={renderMobileEmptyComponent}
|
||||
ListFooterComponent={renderMobileFooterComponent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{renderHeaderSection()}
|
||||
@@ -793,7 +879,7 @@ export default function Subscriptions() {
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
{isMobile && !isCoinPackView ? renderMobileBottomActions() : null}
|
||||
{isMobile ? renderMobileBottomActions() : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1326,6 +1412,17 @@ const styles = StyleSheet.create({
|
||||
gap: gutters,
|
||||
alignItems: 'center',
|
||||
},
|
||||
mobileSection: {
|
||||
width: '100%',
|
||||
gap: gutters * 0.9,
|
||||
marginBottom: gutters * 1.4,
|
||||
},
|
||||
mobileSectionTitle: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
fontSize: 20,
|
||||
color: Palette.white,
|
||||
textAlign: 'center',
|
||||
},
|
||||
mobileActions: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
|
||||
Reference in New Issue
Block a user