fix playback offset
This commit is contained in:
@@ -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
|
||||||
@@ -92,7 +92,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "2026.03.12"
|
versionName "2026.04.08"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"deeplinks": [
|
"deeplinks": [
|
||||||
"musicland://"
|
"musicland://"
|
||||||
],
|
],
|
||||||
"version": "2026.03.12",
|
"version": "2026.04.08",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"backgroundColor": "#0F0C14",
|
"backgroundColor": "#0F0C14",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
@@ -109,6 +109,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"./plugins/withFmtConstevalWorkaround",
|
||||||
[
|
[
|
||||||
"expo-document-picker",
|
"expo-document-picker",
|
||||||
{
|
{
|
||||||
|
|||||||
+14
@@ -52,6 +52,20 @@ target 'MusicLand' do
|
|||||||
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
|
: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<true>(str_, checker(s));'
|
||||||
|
new_line = ' detail::parse_format_string<true>(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
|
# This is necessary for Xcode 14, because it signs resource bundles by default
|
||||||
# when building for devices.
|
# when building for devices.
|
||||||
installer.target_installation_results.pod_target_installation_results
|
installer.target_installation_results.pod_target_installation_results
|
||||||
|
|||||||
@@ -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<true>(str_, checker(s));'",
|
||||||
|
" new_line = ' detail::parse_format_string<true>(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"
|
||||||
|
);
|
||||||
+61
-115
@@ -3,84 +3,57 @@ import { Text, View } from 'react-native'
|
|||||||
import { Palette } from '../styles'
|
import { Palette } from '../styles'
|
||||||
import { FONT_FAMILY } from '../styles/Fonts'
|
import { FONT_FAMILY } from '../styles/Fonts'
|
||||||
|
|
||||||
export function groupAlignedWordsToLines(alignedWords = [], { removeTags = true } = {}) {
|
const SECTION_TAG_REGEX = /^\s*\[[^\]]+\]\s*$/i
|
||||||
const out = []
|
const SECTION_TAG_LEADING_REGEX = /^\s*\[[^\]]+\]\s*/i
|
||||||
let buf = []
|
|
||||||
let start = null
|
const cleanLyricsText = (txt) =>
|
||||||
const clean = (txt) =>
|
|
||||||
String(txt || '')
|
String(txt || '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim()
|
.trim()
|
||||||
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim())
|
|
||||||
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim())
|
|
||||||
|
|
||||||
for (let i = 0; i < alignedWords.length; i++) {
|
const isSentenceEnd = (txt) => /[.!?]$/.test(String(txt || '').trim())
|
||||||
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
|
|
||||||
|
|
||||||
if (buf.length === 0) start = Number(w.startS || 0)
|
const isSectionTag = (txt) => SECTION_TAG_REGEX.test(String(txt || '').trim())
|
||||||
|
|
||||||
if (isSectionTag(textNoNewline)) {
|
const findFocusedLineIndex = (lines = [], timeS = 0) => {
|
||||||
if (!removeTags) {
|
if (!lines.length) return -1
|
||||||
const joined = clean(textNoNewline)
|
if (timeS <= (lines[0]?.startS || 0)) return 0
|
||||||
if (joined)
|
|
||||||
out.push({
|
for (let i = 0; i < lines.length; i++) {
|
||||||
text: joined,
|
const line = lines[i]
|
||||||
startS: start ?? Number(w.startS || 0),
|
if (timeS < (line.startS || 0)) return Math.max(0, i - 1)
|
||||||
endS: Number(w.endS || 0),
|
if (timeS >= (line.startS || 0) && timeS <= (line.endS || 0)) return i
|
||||||
})
|
|
||||||
}
|
|
||||||
buf = []
|
|
||||||
start = null
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clean(textNoTag)) {
|
return lines.length - 1
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupAlignedWordsToLineItems(
|
const findFirstNonPastLineIndex = (lines = [], timeS = 0) => {
|
||||||
alignedWords = [],
|
for (let i = 0; i < lines.length; i++) {
|
||||||
{ removeTags = true } = {}
|
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 = []
|
const out = []
|
||||||
let buf = []
|
let buf = []
|
||||||
let start = null
|
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 pushLine = (words, endS) => {
|
||||||
const text = clean(words.map((item) => item.text).join(' '))
|
const text = cleanLyricsText(words.map((item) => item.text).join(' '))
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|
||||||
out.push({
|
out.push({
|
||||||
text,
|
text,
|
||||||
startS: start ?? Number(words[0]?.startS || 0),
|
startS: start ?? Number(words[0]?.startS || 0),
|
||||||
@@ -93,14 +66,17 @@ export function groupAlignedWordsToLineItems(
|
|||||||
const w = alignedWords[i] || {}
|
const w = alignedWords[i] || {}
|
||||||
const original = String(w.word || '')
|
const original = String(w.word || '')
|
||||||
const textNoNewline = original.replace(/\n/g, ' ')
|
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 next = alignedWords[i + 1] || null
|
||||||
const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0
|
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 (buf.length === 0) start = Number(w.startS || 0)
|
||||||
|
|
||||||
if (isSectionTag(textNoNewline)) {
|
if (isSectionTag(textNoNewline)) {
|
||||||
if (!removeTags) {
|
if (!removeTags) {
|
||||||
const joined = clean(textNoNewline)
|
const joined = cleanLyricsText(textNoNewline)
|
||||||
if (joined) {
|
if (joined) {
|
||||||
out.push({
|
out.push({
|
||||||
text: joined,
|
text: joined,
|
||||||
@@ -121,17 +97,15 @@ export function groupAlignedWordsToLineItems(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clean(textNoTag)) {
|
if (!cleanLyricsText(textNoTag)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordItem = {
|
buf.push({
|
||||||
text: textNoTag,
|
text: textNoTag,
|
||||||
startS: Number(w.startS || 0),
|
startS: Number(w.startS || 0),
|
||||||
endS: Number(w.endS || 0),
|
endS: Number(w.endS || 0),
|
||||||
}
|
})
|
||||||
|
|
||||||
buf.push(wordItem)
|
|
||||||
|
|
||||||
const eolByNewline = /\n/.test(original)
|
const eolByNewline = /\n/.test(original)
|
||||||
const eolByPause = gapToNext >= 0.6 // threshold for a logical break
|
const eolByPause = gapToNext >= 0.6 // threshold for a logical break
|
||||||
@@ -180,47 +154,37 @@ export default function KaraokeLyrics({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const currentLineIdx = useMemo(() => {
|
const currentLineIdx = useMemo(() => {
|
||||||
if (!activeLines || activeLines.length === 0) return -1
|
return findFocusedLineIndex(activeLines, effectiveTimeS)
|
||||||
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
|
|
||||||
}, [activeLines, effectiveTimeS])
|
}, [activeLines, effectiveTimeS])
|
||||||
|
|
||||||
if (!activeLines.length) return null
|
if (!activeLines.length) return null
|
||||||
|
|
||||||
if (mode === 'teleprompter') {
|
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 inactiveAlpha = 0.35
|
||||||
const completedAlpha = 0.75
|
|
||||||
const activeAlpha = 1
|
const activeAlpha = 1
|
||||||
|
const teleprompterLineIdx = findFirstNonPastLineIndex(teleprompterData, effectiveTimeS)
|
||||||
|
const visibleLines =
|
||||||
|
teleprompterLineIdx >= 0
|
||||||
|
? teleprompterData.slice(teleprompterLineIdx, teleprompterLineIdx + 2)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!visibleLines.length) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: containerHeight,
|
gap: 6,
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
{visibleLines.map((line, idx) => {
|
||||||
style={{
|
const isTopLine = idx === 0
|
||||||
transform: [{ translateY }],
|
const isActive =
|
||||||
}}
|
isTopLine &&
|
||||||
>
|
effectiveTimeS >= Number(line.startS || 0) &&
|
||||||
{activeLines.map((line, idx) => {
|
effectiveTimeS <= Number(line.endS || 0)
|
||||||
const isUpcoming = effectiveTimeS < (line.startS || 0)
|
const lineKey = `${line.startS}-${teleprompterLineIdx + idx}`
|
||||||
const isPast = effectiveTimeS > (line.endS || 0)
|
|
||||||
const lineKey = `${line.startS}-${idx}`
|
|
||||||
|
|
||||||
if (isUpcoming) {
|
if (!isActive) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={lineKey}
|
key={lineKey}
|
||||||
@@ -237,23 +201,6 @@ export default function KaraokeLyrics({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPast) {
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
key={lineKey}
|
|
||||||
style={{
|
|
||||||
color: alphaWhite(completedAlpha),
|
|
||||||
fontSize: teleprompterFontSize,
|
|
||||||
lineHeight: teleprompterLineHeight,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{line.text}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={lineKey}
|
key={lineKey}
|
||||||
@@ -280,7 +227,6 @@ export default function KaraokeLyrics({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
|||||||
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
|
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
|
||||||
const COUNTDOWN_SECONDS = 10
|
const COUNTDOWN_SECONDS = 10
|
||||||
const CAMERA_STARTUP_DELAY_MS = 250
|
const CAMERA_STARTUP_DELAY_MS = 250
|
||||||
const KARAOKE_LEAD_S = 0.18
|
const KARAOKE_LEAD_S = 0.68
|
||||||
const LOG_PREFIX = '[RecordPlayback]'
|
const LOG_PREFIX = '[RecordPlayback]'
|
||||||
const KEEP_AWAKE_TAG = 'record-playback'
|
const KEEP_AWAKE_TAG = 'record-playback'
|
||||||
const log =
|
const log =
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const MEDIA_BOOTSTRAP_DELAY_MS = 250
|
|||||||
const MEDIA_RETRY_DELAY_MS = 700
|
const MEDIA_RETRY_DELAY_MS = 700
|
||||||
const MEDIA_MAX_RETRIES = 2
|
const MEDIA_MAX_RETRIES = 2
|
||||||
const CAMERA_STARTUP_DELAY_MS = 250
|
const CAMERA_STARTUP_DELAY_MS = 250
|
||||||
const KARAOKE_LEAD_S = 0.18
|
const KARAOKE_LEAD_S = 0.68
|
||||||
|
|
||||||
const toSeconds = (v) => {
|
const toSeconds = (v) => {
|
||||||
const n = Number(v ?? 0)
|
const n = Number(v ?? 0)
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import useSharedAudioPlayer from '../../hooks/useSharedAudioPlayer'
|
|||||||
import * as FileSystem from 'expo-file-system'
|
import * as FileSystem from 'expo-file-system'
|
||||||
import { VideoView, useVideoPlayer } from 'expo-video'
|
import { VideoView, useVideoPlayer } from 'expo-video'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 { icons } from '../../assets'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import ProgressSlider from '../../components/player/ProgressSlider'
|
import ProgressSlider from '../../components/player/ProgressSlider'
|
||||||
import SyncOffsetSlider from '../../components/player/SyncOffsetSlider'
|
|
||||||
import Page from '../../layouts/Page'
|
import Page from '../../layouts/Page'
|
||||||
import { Routes } from '../../navigation'
|
import { Routes } from '../../navigation'
|
||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
@@ -277,25 +276,62 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
return (
|
return (
|
||||||
<Page backgroundColor={Palette.grayMid} headerType="NONE">
|
<Page backgroundColor={Palette.grayMid} headerType="NONE">
|
||||||
<MusicLandHeader progress={19} onPressBack={goBack} />
|
<MusicLandHeader progress={19} onPressBack={goBack} />
|
||||||
<View style={{ flex: 1, paddingTop: 12, gap: 14, paddingBottom: gutters * 2 }}>
|
<View style={{ flex: 1, paddingBottom: gutters * 2 }}>
|
||||||
<View style={{ flex: 1, gap: 22 }}>
|
|
||||||
|
{/* 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 && (
|
{!!videoUri && (
|
||||||
<VideoView
|
<VideoView
|
||||||
player={videoPlayer}
|
player={videoPlayer}
|
||||||
nativeControls={false}
|
nativeControls={false}
|
||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
style={{
|
style={{ flex: 1, width: '100%', backgroundColor: 'transparent' }}
|
||||||
width: '80%',
|
|
||||||
flex: 1,
|
|
||||||
alignSelf: 'center',
|
|
||||||
borderRadius: 16,
|
|
||||||
backgroundColor: '#00000066',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!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>
|
||||||
|
|
||||||
<View style={{ width: '80%', alignSelf: 'center', gap: 18 }}>
|
{/* Contrôles */}
|
||||||
|
<View style={{ width: '92%', alignSelf: 'center', gap: 12, marginTop: 14 }}>
|
||||||
<ProgressSlider
|
<ProgressSlider
|
||||||
positionMs={progressInfo.pos}
|
positionMs={progressInfo.pos}
|
||||||
durationMs={progressInfo.dur}
|
durationMs={progressInfo.dur}
|
||||||
@@ -306,36 +342,116 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
onPlay={resumeAfterSeek}
|
onPlay={resumeAfterSeek}
|
||||||
disabled={!songUrl}
|
disabled={!songUrl}
|
||||||
/>
|
/>
|
||||||
<SyncOffsetSlider
|
<View
|
||||||
valueMs={currentSyncOffsetMs}
|
style={{
|
||||||
onChange={handleSyncOffsetChange}
|
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||||
disabled={!songUrl}
|
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>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleTogglePlayback}
|
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs + 100))}
|
||||||
style={{
|
disabled={!songUrl || currentSyncOffsetMs >= 2000}
|
||||||
width: 72,
|
hitSlop={10}
|
||||||
height: 72,
|
style={({ pressed }) => ({
|
||||||
borderRadius: 36,
|
width: 52,
|
||||||
alignSelf: 'center',
|
height: 52,
|
||||||
marginTop: 4,
|
borderRadius: 26,
|
||||||
|
backgroundColor: pressed ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.1)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(10, 5, 24, 0.65)',
|
opacity: !songUrl || currentSyncOffsetMs >= 2000 ? 0.3 : 1,
|
||||||
borderWidth: 1,
|
})}
|
||||||
borderColor: '#F94697',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Image
|
<Text style={{ color: Palette.white, fontSize: 26, lineHeight: 30 }}>+</Text>
|
||||||
source={progressInfo.isPlaying ? icons.pause : icons.play}
|
|
||||||
style={{ width: 26, height: 26 }}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ width: '80%', alignSelf: 'center', marginTop: 4, gap: 12 }}>
|
<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: '92%', alignSelf: 'center', marginTop: 14, gap: 12 }}>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title="Je valide"
|
title="Je valide"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import BorderGradientButton from '../../components/BorderGradientButton'
|
|||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import ProgressSlider from '../../components/player/ProgressSlider'
|
import ProgressSlider from '../../components/player/ProgressSlider'
|
||||||
import SyncOffsetSlider from '../../components/player/SyncOffsetSlider'
|
|
||||||
import Page from '../../layouts/Page'
|
import Page from '../../layouts/Page'
|
||||||
import { Routes } from '../../navigation'
|
import { Routes } from '../../navigation'
|
||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
@@ -23,7 +22,7 @@ import {
|
|||||||
snapSyncOffsetMs,
|
snapSyncOffsetMs,
|
||||||
} from '../../utils/playbackSync'
|
} from '../../utils/playbackSync'
|
||||||
|
|
||||||
const WEB_PREVIEW_WIDTH = 360
|
const WEB_PREVIEW_WIDTH = 420
|
||||||
|
|
||||||
const RecordedPlayback = ({ route }) => {
|
const RecordedPlayback = ({ route }) => {
|
||||||
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
|
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
|
||||||
@@ -306,26 +305,23 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
progress={19}
|
progress={19}
|
||||||
onPressBack={goBack}
|
onPressBack={goBack}
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 40,
|
marginBottom: 20,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View
|
<View style={{ width: '100%' }}>
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{videoUri ? (
|
{videoUri ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleTogglePlayback}
|
onPress={handleTogglePlayback}
|
||||||
style={{
|
style={{
|
||||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
width: isWeb ? WEB_PREVIEW_WIDTH : '92%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
aspectRatio: 9 / 16,
|
aspectRatio: 9 / 16,
|
||||||
borderRadius: 12,
|
borderRadius: 18,
|
||||||
backgroundColor: '#00000066',
|
backgroundColor: '#00000066',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
@@ -348,28 +344,28 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
top: '50%',
|
top: '50%',
|
||||||
transform: [{ translateX: -36 }, { translateY: -36 }],
|
transform: [{ translateX: -40 }, { translateY: -40 }],
|
||||||
width: 72,
|
width: 80,
|
||||||
height: 72,
|
height: 80,
|
||||||
borderRadius: 36,
|
borderRadius: 40,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(10, 5, 24, 0.75)',
|
backgroundColor: 'rgba(10, 5, 24, 0.72)',
|
||||||
borderWidth: 1,
|
borderWidth: 1.5,
|
||||||
borderColor: '#F94697',
|
borderColor: '#F94697',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image source={icons.play} style={{ width: 26, height: 26 }} />
|
<Image source={icons.play} style={{ width: 30, height: 30 }} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
width: isWeb ? WEB_PREVIEW_WIDTH : '92%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
aspectRatio: 9 / 16,
|
aspectRatio: 9 / 16,
|
||||||
borderRadius: 12,
|
borderRadius: 18,
|
||||||
backgroundColor: '#00000066',
|
backgroundColor: '#00000066',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -385,11 +381,11 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
width: isWeb ? WEB_PREVIEW_WIDTH : '92%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
marginTop: 12,
|
marginTop: 14,
|
||||||
gap: 18,
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProgressSlider
|
<ProgressSlider
|
||||||
@@ -402,21 +398,124 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
onPlay={resumeAfterSeek}
|
onPlay={resumeAfterSeek}
|
||||||
disabled={!songUrl}
|
disabled={!songUrl}
|
||||||
/>
|
/>
|
||||||
<SyncOffsetSlider
|
<View
|
||||||
valueMs={currentSyncOffsetMs}
|
style={{
|
||||||
onChange={handleSyncOffsetChange}
|
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||||
disabled={!songUrl}
|
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>
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
width: isWeb ? WEB_PREVIEW_WIDTH : '92%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
marginTop: 50,
|
marginTop: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
|
|||||||
+138
-41
@@ -1,5 +1,14 @@
|
|||||||
import React from 'react'
|
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 { useRoute } from '@react-navigation/native'
|
||||||
import { BlurView } from 'expo-blur'
|
import { BlurView } from 'expo-blur'
|
||||||
import { Image as ExpoImage } from 'expo-image'
|
import { Image as ExpoImage } from 'expo-image'
|
||||||
@@ -461,9 +470,9 @@ export default function Subscriptions() {
|
|||||||
|
|
||||||
const mobileListContentInset = React.useMemo(
|
const mobileListContentInset = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
paddingBottom: isCoinPackView ? gutters * 2 : mobileActionSafePadding + gutters * 3,
|
paddingBottom: mobileActionSafePadding + gutters * 3,
|
||||||
}),
|
}),
|
||||||
[isCoinPackView, mobileActionSafePadding]
|
[mobileActionSafePadding]
|
||||||
)
|
)
|
||||||
const handleBackPress = React.useCallback(() => {
|
const handleBackPress = React.useCallback(() => {
|
||||||
goBack()
|
goBack()
|
||||||
@@ -518,10 +527,6 @@ export default function Subscriptions() {
|
|||||||
}, [combinedErrorMessage, currentUserData?.coins, screenTitle])
|
}, [combinedErrorMessage, currentUserData?.coins, screenTitle])
|
||||||
|
|
||||||
const renderSegmentedControl = React.useCallback(() => {
|
const renderSegmentedControl = React.useCallback(() => {
|
||||||
if (isCoinPackView) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
{ key: 'monthly', label: 'Mensuel', plans: normalizedPlansByPeriod?.monthly },
|
{ key: 'monthly', label: 'Mensuel', plans: normalizedPlansByPeriod?.monthly },
|
||||||
{ key: 'annual', label: 'Annuel', plans: normalizedPlansByPeriod?.annual },
|
{ key: 'annual', label: 'Annuel', plans: normalizedPlansByPeriod?.annual },
|
||||||
@@ -559,7 +564,7 @@ export default function Subscriptions() {
|
|||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}, [handlePeriodChange, isCoinPackView, isMobile, normalizedPlansByPeriod, selectedPeriodKey])
|
}, [handlePeriodChange, isMobile, normalizedPlansByPeriod, selectedPeriodKey])
|
||||||
|
|
||||||
const renderMobilePlanItem = React.useCallback(
|
const renderMobilePlanItem = React.useCallback(
|
||||||
({ item }) => (
|
({ item }) => (
|
||||||
@@ -575,60 +580,137 @@ export default function Subscriptions() {
|
|||||||
[handleSelect, selectedPeriodKey, selectedPriceId]
|
[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(() => {
|
const renderMobileEmptyComponent = React.useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<View style={styles.mobileEmptyWrapper}>
|
<View style={styles.mobileEmptyWrapper}>
|
||||||
{(isCoinPackView ? isCatalogLoading && !coinPacks.length : isLoadingPlans) ? (
|
{isLoadingPlans ? (
|
||||||
<ActivityIndicator color={Palette.white} />
|
<ActivityIndicator color={Palette.white} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.emptyState}>
|
<Text style={styles.emptyState}>Aucun abonnement Stripe disponible pour le moment.</Text>
|
||||||
{isCoinPackView
|
|
||||||
? "Aucun pack de crédits n'est disponible pour le moment."
|
|
||||||
: 'Aucun abonnement Stripe disponible pour le moment.'}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}, [coinPacks.length, isCatalogLoading, isCoinPackView, isLoadingPlans])
|
}, [isLoadingPlans])
|
||||||
|
|
||||||
const renderMobileFooterComponent = React.useCallback(() => {
|
const renderMobileFooterComponent = React.useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<View style={styles.mobileFooter}>
|
<View style={styles.mobileFooter}>
|
||||||
{(isCoinPackView ? isCatalogLoading && coinPacks.length > 0 : isLoadingPlans && currentPlanCount > 0) ? (
|
{isLoadingPlans && currentPlanCount > 0 ? (
|
||||||
<View style={styles.inlineLoader}>
|
<View style={styles.inlineLoader}>
|
||||||
<ActivityIndicator color={Palette.white} size="small" />
|
<ActivityIndicator color={Palette.white} size="small" />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<Text style={styles.disclaimer}>
|
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
|
||||||
{isCoinPackView
|
|
||||||
? 'Les crédits sont ajoutés dès que le paiement Stripe est validé.'
|
|
||||||
: SUBSCRIPTION_DISCLAIMER}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}, [coinPacks.length, currentPlanCount, isCatalogLoading, isCoinPackView, isLoadingPlans])
|
}, [currentPlanCount, isLoadingPlans])
|
||||||
|
|
||||||
const renderMobileTopSticky = React.useCallback(() => {
|
const renderMobileTopSticky = React.useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<View style={styles.mobileStickyHeader}>
|
<View style={styles.mobileStickyHeader}>
|
||||||
{renderHeaderSection()}
|
{renderHeaderSection()}
|
||||||
{renderSegmentedControl()}
|
{!isCoinPackView ? renderSegmentedControl() : null}
|
||||||
</View>
|
</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(() => {
|
const renderMobileBottomActions = React.useCallback(() => {
|
||||||
return (
|
return (
|
||||||
@@ -677,16 +759,20 @@ export default function Subscriptions() {
|
|||||||
|
|
||||||
<View style={[styles.content, isMobile && styles.mobileContent]}>
|
<View style={[styles.content, isMobile && styles.mobileContent]}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
|
isCoinPackView ? (
|
||||||
|
renderMobileCoinPackView()
|
||||||
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={isCoinPackView ? coinPacks : currentPlans}
|
data={currentPlans}
|
||||||
keyExtractor={(item) => item.productId || item.priceId}
|
keyExtractor={(item) => item.priceId}
|
||||||
renderItem={isCoinPackView ? renderMobileCoinPackItem : renderMobilePlanItem}
|
renderItem={renderMobilePlanItem}
|
||||||
style={styles.mobileList}
|
style={styles.mobileList}
|
||||||
contentContainerStyle={[styles.mobileListContent, mobileListContentInset]}
|
contentContainerStyle={[styles.mobileListContent, mobileListContentInset]}
|
||||||
ListEmptyComponent={renderMobileEmptyComponent}
|
ListEmptyComponent={renderMobileEmptyComponent}
|
||||||
ListFooterComponent={renderMobileFooterComponent}
|
ListFooterComponent={renderMobileFooterComponent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{renderHeaderSection()}
|
{renderHeaderSection()}
|
||||||
@@ -793,7 +879,7 @@ export default function Subscriptions() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Page>
|
</Page>
|
||||||
{isMobile && !isCoinPackView ? renderMobileBottomActions() : null}
|
{isMobile ? renderMobileBottomActions() : null}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1326,6 +1412,17 @@ const styles = StyleSheet.create({
|
|||||||
gap: gutters,
|
gap: gutters,
|
||||||
alignItems: 'center',
|
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: {
|
mobileActions: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user