fix playback offset

This commit is contained in:
Thomas Demirdjian
2026-04-16 15:19:39 +02:00
parent 5c2bdc593c
commit 5a6833c1a2
11 changed files with 623 additions and 274 deletions
+9
View File
@@ -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
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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
View File
@@ -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
+67
View File
@@ -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"
);
+70 -124
View File
@@ -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'
const SECTION_TAG_REGEX = /^\s*\[[^\]]+\]\s*$/i
const SECTION_TAG_LEADING_REGEX = /^\s*\[[^\]]+\]\s*/i
const cleanLyricsText = (txt) =>
String(txt || '')
.replace(/\s+/g, ' ')
.trim()
const isSentenceEnd = (txt) => /[.!?]$/.test(String(txt || '').trim())
const isSectionTag = (txt) => SECTION_TAG_REGEX.test(String(txt || '').trim())
const findFocusedLineIndex = (lines = [], timeS = 0) => {
if (!lines.length) return -1
if (timeS <= (lines[0]?.startS || 0)) return 0
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 lines.length - 1
}
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 } = {}) { 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())
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
if (buf.length === 0) start = Number(w.startS || 0)
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
}
if (!clean(textNoTag)) {
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(
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 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>
) )
} }
+1 -1
View File
@@ -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 =
+1 -1
View File
@@ -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)
+150 -34
View File
@@ -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 () => {
+128 -29
View File
@@ -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
View File
@@ -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,