diff --git a/.minuitbuild-metro.log b/.minuitbuild-metro.log new file mode 100644 index 0000000..f2e4fbd --- /dev/null +++ b/.minuitbuild-metro.log @@ -0,0 +1,9 @@ +Starting project at /Users/thomasdemirdjian/Minuit/musicland +Starting Metro Bundler +warning: Bundler cache is empty, rebuilding (this may take a minute) +Waiting on http://localhost:8081 +Logs for your project will appear below. +Web Bundled 3390ms index.web.js (1956 modules) +Web Bundled 49ms index.web.js (1 module) + LOG [web] Logs will appear in the browser console + LOG [web] Logs will appear in the browser console diff --git a/android/app/build.gradle b/android/app/build.gradle index 75e6f74..8ae25a5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,7 +92,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "2026.03.12" + versionName "2026.04.08" } signingConfigs { debug { diff --git a/app.json b/app.json index 808957f..3a607a5 100644 --- a/app.json +++ b/app.json @@ -11,7 +11,7 @@ "deeplinks": [ "musicland://" ], - "version": "2026.03.12", + "version": "2026.04.08", "icon": "./assets/icon.png", "backgroundColor": "#0F0C14", "jsEngine": "hermes", @@ -109,6 +109,7 @@ } } ], + "./plugins/withFmtConstevalWorkaround", [ "expo-document-picker", { diff --git a/ios/Podfile b/ios/Podfile index 541b500..c7a4979 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -52,6 +52,20 @@ target 'MusicLand' do :ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true', ) + # Workaround for Xcode/clang consteval regression with fmt 11.0.2. + # Keep this until React Native ships a newer fmt pod. + fmt_base_header = File.join(installer.sandbox.root.to_s, 'fmt', 'include', 'fmt', 'base.h') + if File.exist?(fmt_base_header) + content = File.read(fmt_base_header) + old_line = ' detail::parse_format_string(str_, checker(s));' + new_line = ' detail::parse_format_string(str_, checker(str_));' + if content.include?(old_line) + File.chmod(0o644, fmt_base_header) unless File.writable?(fmt_base_header) + File.write(fmt_base_header, content.sub(old_line, new_line)) + Pod::UI.puts('Applied fmt consteval workaround in fmt/base.h') + end + end + # This is necessary for Xcode 14, because it signs resource bundles by default # when building for devices. installer.target_installation_results.pod_target_installation_results diff --git a/plugins/withFmtConstevalWorkaround.js b/plugins/withFmtConstevalWorkaround.js new file mode 100644 index 0000000..e1aad27 --- /dev/null +++ b/plugins/withFmtConstevalWorkaround.js @@ -0,0 +1,67 @@ +const fs = require("fs"); +const path = require("path"); +const { withDangerousMod, createRunOncePlugin } = require("@expo/config-plugins"); + +const PATCH_MARKER = "Applied fmt consteval workaround in fmt/base.h"; + +const PATCH_BLOCK = [ + " # Workaround for Xcode/clang consteval regression with fmt 11.0.2.", + " # Keep this until React Native ships a newer fmt pod.", + " fmt_base_header = File.join(installer.sandbox.root.to_s, 'fmt', 'include', 'fmt', 'base.h')", + " if File.exist?(fmt_base_header)", + " content = File.read(fmt_base_header)", + " old_line = ' detail::parse_format_string(str_, checker(s));'", + " new_line = ' detail::parse_format_string(str_, checker(str_));'", + " if content.include?(old_line)", + " File.chmod(0o644, fmt_base_header) unless File.writable?(fmt_base_header)", + " File.write(fmt_base_header, content.sub(old_line, new_line))", + ` Pod::UI.puts('${PATCH_MARKER}')`, + " end", + " end", +].join("\n"); + +function applyFmtPatchToPodfile(contents) { + if (contents.includes(PATCH_MARKER)) { + return contents; + } + + const primaryAnchor = + " # This is necessary for Xcode 14, because it signs resource bundles by default"; + if (contents.includes(primaryAnchor)) { + return contents.replace(primaryAnchor, `${PATCH_BLOCK}\n\n${primaryAnchor}`); + } + + const fallbackAnchor = " installer.target_installation_results.pod_target_installation_results"; + if (contents.includes(fallbackAnchor)) { + return contents.replace(fallbackAnchor, `${PATCH_BLOCK}\n\n${fallbackAnchor}`); + } + + throw new Error( + "withFmtConstevalWorkaround: could not find insertion anchor in ios/Podfile." + ); +} + +const withFmtConstevalWorkaround = (config) => + withDangerousMod(config, [ + "ios", + async (config) => { + const podfilePath = path.join(config.modRequest.platformProjectRoot, "Podfile"); + if (!fs.existsSync(podfilePath)) { + return config; + } + + const podfile = fs.readFileSync(podfilePath, "utf8"); + const patched = applyFmtPatchToPodfile(podfile); + if (patched !== podfile) { + fs.writeFileSync(podfilePath, patched); + } + + return config; + }, + ]); + +module.exports = createRunOncePlugin( + withFmtConstevalWorkaround, + "with-fmt-consteval-workaround", + "1.0.0" +); diff --git a/src/components/KaraokeLyrics.js b/src/components/KaraokeLyrics.js index c4ee28b..06d5ff6 100644 --- a/src/components/KaraokeLyrics.js +++ b/src/components/KaraokeLyrics.js @@ -3,84 +3,57 @@ import { Text, View } from 'react-native' import { Palette } from '../styles' import { FONT_FAMILY } from '../styles/Fonts' -export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) { - const out = [] - let buf = [] - let start = null - const clean = (txt) => - String(txt || '') - .replace(/\s+/g, ' ') - .trim() - const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim()) - const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim()) +const SECTION_TAG_REGEX = /^\s*\[[^\]]+\]\s*$/i +const SECTION_TAG_LEADING_REGEX = /^\s*\[[^\]]+\]\s*/i - for (let i = 0; i < alignedWords.length; i++) { - const w = alignedWords[i] || {} - const original = String(w.word || '') - const textNoNewline = original.replace(/\n/g, ' ') - const textNoTag = removeTags ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, '') : textNoNewline - const next = alignedWords[i + 1] || null - const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0 +const cleanLyricsText = (txt) => + String(txt || '') + .replace(/\s+/g, ' ') + .trim() - if (buf.length === 0) start = Number(w.startS || 0) +const isSentenceEnd = (txt) => /[.!?]$/.test(String(txt || '').trim()) - if (isSectionTag(textNoNewline)) { - if (!removeTags) { - const joined = clean(textNoNewline) - if (joined) - out.push({ - text: joined, - startS: start ?? Number(w.startS || 0), - endS: Number(w.endS || 0), - }) - } - buf = [] - start = null - continue - } +const isSectionTag = (txt) => SECTION_TAG_REGEX.test(String(txt || '').trim()) - if (!clean(textNoTag)) { - continue - } +const findFocusedLineIndex = (lines = [], timeS = 0) => { + if (!lines.length) return -1 + if (timeS <= (lines[0]?.startS || 0)) return 0 - buf.push(textNoTag) - - const eolByNewline = /\n/.test(original) - const eolByPause = gapToNext >= 0.6 // threshold for a logical break - const eolByPunct = isSentenceEnd(textNoTag) - const isLast = i === alignedWords.length - 1 - - if (eolByNewline || eolByPause || eolByPunct || isLast) { - const joined = clean(buf.join(' ')) - if (joined) - out.push({ - text: joined, - startS: start ?? Number(w.startS || 0), - endS: Number(w.endS || 0), - }) - buf = [] - start = null - } + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (timeS < (line.startS || 0)) return Math.max(0, i - 1) + if (timeS >= (line.startS || 0) && timeS <= (line.endS || 0)) return i } - return out + + return lines.length - 1 } -export function groupAlignedWordsToLineItems( - alignedWords = [], - { removeTags = true } = {} -) { +const findFirstNonPastLineIndex = (lines = [], timeS = 0) => { + for (let i = 0; i < lines.length; i++) { + if (timeS <= Number(lines[i]?.endS || 0)) return i + } + + return -1 +} + +export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) { + return groupAlignedWordsToLineItems(alignedWords, { removeTags }).map( + ({ text, startS, endS }) => ({ + text, + startS, + endS, + }) + ) +} + +export function groupAlignedWordsToLineItems(alignedWords = [], { removeTags = true } = {}) { const out = [] let buf = [] let start = null - const clean = (txt) => - String(txt || '') - .replace(/\s+/g, ' ') - .trim() - const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim()) - const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim()) const pushLine = (words, endS) => { - const text = clean(words.map((item) => item.text).join(' ')) + const text = cleanLyricsText(words.map((item) => item.text).join(' ')) if (!text) return + out.push({ text, startS: start ?? Number(words[0]?.startS || 0), @@ -93,14 +66,17 @@ export function groupAlignedWordsToLineItems( const w = alignedWords[i] || {} const original = String(w.word || '') const textNoNewline = original.replace(/\n/g, ' ') - const textNoTag = removeTags ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, '') : textNoNewline + const textNoTag = removeTags + ? textNoNewline.replace(SECTION_TAG_LEADING_REGEX, '') + : textNoNewline const next = alignedWords[i + 1] || null const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0 + if (buf.length === 0) start = Number(w.startS || 0) if (isSectionTag(textNoNewline)) { if (!removeTags) { - const joined = clean(textNoNewline) + const joined = cleanLyricsText(textNoNewline) if (joined) { out.push({ text: joined, @@ -121,17 +97,15 @@ export function groupAlignedWordsToLineItems( continue } - if (!clean(textNoTag)) { + if (!cleanLyricsText(textNoTag)) { continue } - const wordItem = { + buf.push({ text: textNoTag, startS: Number(w.startS || 0), endS: Number(w.endS || 0), - } - - buf.push(wordItem) + }) const eolByNewline = /\n/.test(original) const eolByPause = gapToNext >= 0.6 // threshold for a logical break @@ -180,106 +154,78 @@ export default function KaraokeLyrics({ ) const currentLineIdx = useMemo(() => { - if (!activeLines || activeLines.length === 0) return -1 - if (effectiveTimeS <= (activeLines[0]?.startS || 0)) return 0 - for (let i = 0; i < activeLines.length; i++) { - const L = activeLines[i] - if (effectiveTimeS < (L.startS || 0)) return Math.max(0, i - 1) - if (effectiveTimeS >= (L.startS || 0) && effectiveTimeS <= (L.endS || 0)) return i - } - return activeLines.length - 1 + return findFocusedLineIndex(activeLines, effectiveTimeS) }, [activeLines, effectiveTimeS]) if (!activeLines.length) return null if (mode === 'teleprompter') { - const safeLines = Math.max(1, teleprompterLines) - const containerHeight = teleprompterLineHeight * safeLines - const anchorLine = Math.max(0, Math.min(safeLines - 1, teleprompterAnchorLine)) - const anchorOffset = teleprompterLineHeight * anchorLine - const safeIdx = Math.max(0, currentLineIdx) - const translateY = anchorOffset - safeIdx * teleprompterLineHeight const inactiveAlpha = 0.35 - const completedAlpha = 0.75 const activeAlpha = 1 + const teleprompterLineIdx = findFirstNonPastLineIndex(teleprompterData, effectiveTimeS) + const visibleLines = + teleprompterLineIdx >= 0 + ? teleprompterData.slice(teleprompterLineIdx, teleprompterLineIdx + 2) + : [] + + if (!visibleLines.length) return null return ( - - {activeLines.map((line, idx) => { - const isUpcoming = effectiveTimeS < (line.startS || 0) - const isPast = effectiveTimeS > (line.endS || 0) - const lineKey = `${line.startS}-${idx}` - - if (isUpcoming) { - return ( - - {line.text} - - ) - } - - if (isPast) { - return ( - - {line.text} - - ) - } + {visibleLines.map((line, idx) => { + const isTopLine = idx === 0 + const isActive = + isTopLine && + effectiveTimeS >= Number(line.startS || 0) && + effectiveTimeS <= Number(line.endS || 0) + const lineKey = `${line.startS}-${teleprompterLineIdx + idx}` + if (!isActive) { return ( - {line.words.map((word, wordIndex) => { - const duration = Math.max(0.001, (word.endS || 0) - (word.startS || 0)) - const progress = clamp01((effectiveTimeS - (word.startS || 0)) / duration) - const alpha = inactiveAlpha + (activeAlpha - inactiveAlpha) * progress - const text = word.text - return ( - - {text} - {wordIndex < line.words.length - 1 ? ' ' : ''} - - ) - })} + {line.text} ) - })} - + } + + return ( + + {line.words.map((word, wordIndex) => { + const duration = Math.max(0.001, (word.endS || 0) - (word.startS || 0)) + const progress = clamp01((effectiveTimeS - (word.startS || 0)) / duration) + const alpha = inactiveAlpha + (activeAlpha - inactiveAlpha) * progress + const text = word.text + return ( + + {text} + {wordIndex < line.words.length - 1 ? ' ' : ''} + + ) + })} + + ) + })} ) } diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 6eb2aa4..187dba1 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -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 = diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js index fc004c4..d8d22f1 100644 --- a/src/screens/Playback/RecordPlayback.web.js +++ b/src/screens/Playback/RecordPlayback.web.js @@ -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) diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index f9f872d..c213e76 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -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 ( - - + + + {/* Vidéo avec play/pause en overlay */} + {!!videoUri && ( )} - - - - - - - - - + {!progressInfo.isPlaying && ( + + + + )} + + + + {/* Contrôles */} + + + + + Synchro audio / vidéo + + + + 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, + })} + > + + + + + + {currentSyncOffsetMs === 0 + ? '0' + : (currentSyncOffsetMs > 0 ? '+' : '') + currentSyncOffsetMs} + + + {currentSyncOffsetMs === 0 + ? 'ms · synchronisé' + : currentSyncOffsetMs > 0 + ? 'ms · son en avance' + : 'ms · son en retard'} + + + + 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, + })} + > + + + + + + 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, + })} + > + + Remettre à zéro + + + - + { diff --git a/src/screens/Playback/RecordedPlayback.web.js b/src/screens/Playback/RecordedPlayback.web.js index 7f2e3d8..7849d01 100644 --- a/src/screens/Playback/RecordedPlayback.web.js +++ b/src/screens/Playback/RecordedPlayback.web.js @@ -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, }} /> - + {videoUri ? ( )} ) : ( { { onPlay={resumeAfterSeek} disabled={!songUrl} /> - + + + Synchro audio / vidéo + + + + 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, + })} + > + + + + + + {currentSyncOffsetMs === 0 + ? '0' + : (currentSyncOffsetMs > 0 ? '+' : '') + currentSyncOffsetMs} + + + {currentSyncOffsetMs === 0 + ? 'ms · synchronisé' + : currentSyncOffsetMs > 0 + ? 'ms · son en avance' + : 'ms · son en retard'} + + + + 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, + })} + > + + + + + + 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', + })} + > + + Remettre à zéro + + + ({ - 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() { })} ) - }, [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 }) => ( - - - - ), - [handleBuyCredits, processingPriceId] - ) - const renderMobileEmptyComponent = React.useCallback(() => { return ( - {(isCoinPackView ? isCatalogLoading && !coinPacks.length : isLoadingPlans) ? ( + {isLoadingPlans ? ( ) : ( - - {isCoinPackView - ? "Aucun pack de crédits n'est disponible pour le moment." - : 'Aucun abonnement Stripe disponible pour le moment.'} - + Aucun abonnement Stripe disponible pour le moment. )} ) - }, [coinPacks.length, isCatalogLoading, isCoinPackView, isLoadingPlans]) + }, [isLoadingPlans]) const renderMobileFooterComponent = React.useCallback(() => { return ( - {(isCoinPackView ? isCatalogLoading && coinPacks.length > 0 : isLoadingPlans && currentPlanCount > 0) ? ( + {isLoadingPlans && currentPlanCount > 0 ? ( ) : null} - - {isCoinPackView - ? 'Les crédits sont ajoutés dès que le paiement Stripe est validé.' - : SUBSCRIPTION_DISCLAIMER} - + {SUBSCRIPTION_DISCLAIMER} ) - }, [coinPacks.length, currentPlanCount, isCatalogLoading, isCoinPackView, isLoadingPlans]) + }, [currentPlanCount, isLoadingPlans]) const renderMobileTopSticky = React.useCallback(() => { return ( {renderHeaderSection()} - {renderSegmentedControl()} + {!isCoinPackView ? renderSegmentedControl() : null} ) - }, [renderHeaderSection, renderSegmentedControl]) + }, [isCoinPackView, renderHeaderSection, renderSegmentedControl]) + + const renderMobileCoinPackView = React.useCallback(() => { + const hasCoinPacks = coinPacks.length > 0 + + return ( + + + Abonnements + {renderSegmentedControl()} + + {isLoadingPlans && currentPlanCount === 0 ? ( + + + + ) : null} + + {!isLoadingPlans && currentPlanCount === 0 ? ( + + Aucun abonnement Stripe disponible pour le moment. + + ) : null} + + {currentPlans.map((plan) => ( + + + + ))} + + {isLoadingPlans && currentPlanCount > 0 ? ( + + + + ) : null} + + {SUBSCRIPTION_DISCLAIMER} + + + + {COIN_PACK_SECTION_TITLE} + {isCatalogLoading && !hasCoinPacks ? ( + + + + ) : null} + + {!isCatalogLoading && !hasCoinPacks ? ( + + + Aucun pack de crédits n'est disponible pour le moment. + + + ) : null} + + {coinPacks.map((pack) => ( + + + + ))} + + {isCatalogLoading && hasCoinPacks ? ( + + + + ) : null} + + + Les crédits sont ajoutés dès que le paiement Stripe est validé. + + + + ) + }, [ + 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() { {isMobile ? ( - item.productId || item.priceId} - renderItem={isCoinPackView ? renderMobileCoinPackItem : renderMobilePlanItem} - style={styles.mobileList} - contentContainerStyle={[styles.mobileListContent, mobileListContentInset]} - ListEmptyComponent={renderMobileEmptyComponent} - ListFooterComponent={renderMobileFooterComponent} - showsVerticalScrollIndicator={false} - /> + isCoinPackView ? ( + renderMobileCoinPackView() + ) : ( + 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() { - {isMobile && !isCoinPackView ? renderMobileBottomActions() : null} + {isMobile ? renderMobileBottomActions() : null} ) } @@ -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,