clear last tickets

This commit is contained in:
Thomas Demirdjian
2025-11-26 15:33:22 +01:00
parent ee9cc5dccd
commit d7b14bd873
25 changed files with 1692 additions and 1967 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 MiB

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 2.7 MiB

+1
View File
@@ -227,6 +227,7 @@ export const videos = {
Platform.OS === "web"
? require("./video/testVideoWeb.mp4")
: require("./video/testVideo.mp4"),
club: require("./video/club.mp4"),
};
export const img = {
Binary file not shown.
+19 -32
View File
@@ -29,37 +29,30 @@ export default ({
const SLIDER_WIDTH = layout?.width;
const MAX_VALUE = SLIDER_WIDTH - INITIAL_BOX_SIZE;
const handleSeekStart = () => {
if (seekEnabled && typeof onSeekStart === "function" && !seekingRef.current) {
seekingRef.current = true;
onSeekStart();
}
};
const handleSeekEnd = () => {
if (seekingRef.current && typeof onSeekEnd === "function") {
seekingRef.current = false;
onSeekEnd();
}
};
const pan = Gesture.Pan()
.enabled(seekEnabled)
.onBegin(() => {
if (
seekEnabled &&
typeof onSeekStart === "function" &&
!seekingRef.current
) {
seekingRef.current = true;
runOnJS(onSeekStart)();
}
runOnJS(handleSeekStart)();
})
.onStart(() => {
if (
seekEnabled &&
typeof onSeekStart === "function" &&
!seekingRef.current
) {
seekingRef.current = true;
runOnJS(onSeekStart)();
}
runOnJS(handleSeekStart)();
})
.onChange((event) => {
if (
seekEnabled &&
typeof onSeekStart === "function" &&
!seekingRef.current
) {
seekingRef.current = true;
runOnJS(onSeekStart)();
}
runOnJS(handleSeekStart)();
offset.value =
Math.abs(offset.value) <= MAX_VALUE
? offset.value + event.changeX <= 0
@@ -81,16 +74,10 @@ export default ({
// Reanimated -> JS thread bridge
runOnJS(onSeek)(ratio);
}
if (seekingRef.current && typeof onSeekEnd === "function") {
seekingRef.current = false;
runOnJS(onSeekEnd)();
}
runOnJS(handleSeekEnd)();
})
.onFinalize(() => {
if (seekingRef.current && typeof onSeekEnd === "function") {
seekingRef.current = false;
runOnJS(onSeekEnd)();
}
runOnJS(handleSeekEnd)();
});
// Reflect external progress into the slider UI
+4 -4
View File
@@ -28,7 +28,7 @@ const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
} catch (error) {
console.warn(
`[firebase] Unable to set functions emulator for region ${regionKey}`,
error?.message
error?.message,
);
}
};
@@ -50,9 +50,9 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === "[DEFAULT]").length) {
persistence: getReactNativePersistence(AsyncStorage),
});
// if (__DEV__) {
// firebase.functions().useEmulator("localhost", 5001);
// }
if (__DEV__) {
firebase.functions().useEmulator("localhost", 5001);
}
const defaultFunctions = firebase.functions();
functionsInstances["us-central1"] = defaultFunctions;
configureFunctionsEmulator(defaultFunctions, "us-central1");
+134 -59
View File
@@ -61,8 +61,8 @@ const HIDDEN_ROUTE_NAMES = new Set([
Routes.Register,
]);
const noopAsync = async () => {};
const noop = () => {};
const noopAsync = async () => { };
const noop = () => { };
const DEFAULT_CONTEXT = {
currentTrack: null,
@@ -272,36 +272,36 @@ const PlayerProvider = ({ children }) => {
(items = [], options = {}) => {
const normalized = Array.isArray(items)
? items
.map((item) => {
if (!item) return null;
if (typeof item === "object") {
const { metadata, context, ...rest } = item;
const safeMetadata =
metadata && typeof metadata === "object"
? { ...metadata }
: undefined;
if (safeMetadata) {
delete safeMetadata.queue;
}
const safeContext =
context && typeof context === "object"
? { ...context }
: undefined;
if (safeContext) {
delete safeContext.queue;
}
return normalizeTrack(
{
...rest,
...(safeMetadata ? { metadata: safeMetadata } : {}),
...(safeContext ? { context: safeContext } : {}),
},
{}
);
.map((item) => {
if (!item) return null;
if (typeof item === "object") {
const { metadata, context, ...rest } = item;
const safeMetadata =
metadata && typeof metadata === "object"
? { ...metadata }
: undefined;
if (safeMetadata) {
delete safeMetadata.queue;
}
return normalizeTrack(item, {});
})
.filter((track) => !!track?.source)
const safeContext =
context && typeof context === "object"
? { ...context }
: undefined;
if (safeContext) {
delete safeContext.queue;
}
return normalizeTrack(
{
...rest,
...(safeMetadata ? { metadata: safeMetadata } : {}),
...(safeContext ? { context: safeContext } : {}),
},
{}
);
}
return normalizeTrack(item, {});
})
.filter((track) => !!track?.source)
: [];
queueRef.current = normalized;
@@ -448,9 +448,105 @@ const PlayerProvider = ({ children }) => {
if (!player) return;
try {
player.loop = !!isLooping;
} catch (_err) {}
} catch (_err) { }
}, [player, isLooping]);
const seekDebounceRef = useRef(null);
const pendingSeekPosRef = useRef(null);
const isSeekingRef = useRef(false);
const waitForActiveSeek = useCallback(async () => {
if (!isSeekingRef.current) return;
// Poll every 50ms until seek is done
while (isSeekingRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}, []);
const seekTo = useCallback(
async (positionMs) => {
if (!player) return;
const bounded = Math.max(0, Number(positionMs) || 0);
const seekValue = toPlayerSeekValue(bounded);
// Cancel any pending debounce
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current);
seekDebounceRef.current = null;
}
// Store pending seek position
pendingSeekPosRef.current = seekValue;
// Update local state immediately for UI responsiveness
setPlayback((prev) => ({
...prev,
positionMs: bounded,
}));
return new Promise((resolve) => {
seekDebounceRef.current = setTimeout(async () => {
try {
isSeekingRef.current = true;
if (status?.isLoaded) {
await player.seekTo?.(seekValue);
} else {
pendingSeekValueRef.current = seekValue;
}
} catch (err) {
setError(err);
} finally {
isSeekingRef.current = false;
pendingSeekPosRef.current = null;
resolve();
}
}, 100); // 100ms debounce
});
},
[player, status?.isLoaded, toPlayerSeekValue]
);
const seekBy = useCallback(
async (deltaMs) => {
const currentPos = pendingSeekPosRef.current !== null
? (Platform.OS === "web" ? pendingSeekPosRef.current : pendingSeekPosRef.current * 1000)
: playback.positionMs;
const next = Math.max(0, currentPos + Number(deltaMs || 0));
await seekTo(next);
},
[playback.positionMs, seekTo]
);
// Helper to flush pending seek before playing
const flushPendingSeek = useCallback(async () => {
// 1. Cancel pending debounce and execute immediately
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current);
seekDebounceRef.current = null;
}
if (pendingSeekPosRef.current !== null) {
const seekValue = pendingSeekPosRef.current;
pendingSeekPosRef.current = null;
try {
isSeekingRef.current = true;
if (status?.isLoaded) {
await player.seekTo?.(seekValue);
} else {
pendingSeekValueRef.current = seekValue;
}
} catch (err) {
setError(err);
} finally {
isSeekingRef.current = false;
}
}
// 2. Wait for any active seek to complete
await waitForActiveSeek();
}, [player, status?.isLoaded, waitForActiveSeek]);
const play = useCallback(
async (trackInput, options = {}) => {
const normalized = normalizeTrack(trackInput, options);
@@ -489,6 +585,9 @@ const PlayerProvider = ({ children }) => {
if (sameTrack) {
setCurrentTrack((prev) => ({ ...prev, ...normalized }));
try {
// Flush any pending seek first
await flushPendingSeek();
if (targetPositionMs > 0) {
await player?.seekTo?.(targetSeekValue);
}
@@ -529,17 +628,18 @@ const PlayerProvider = ({ children }) => {
}));
setCurrentTrack(normalized);
},
[currentTrack?.id, player, toPlayerSeekValue, updateQueue]
[currentTrack?.id, player, toPlayerSeekValue, updateQueue, flushPendingSeek]
);
const resume = useCallback(async () => {
if (!currentTrack) return;
try {
await flushPendingSeek();
await player?.play?.();
} catch (err) {
setError(err);
}
}, [player, currentTrack]);
}, [player, currentTrack, flushPendingSeek]);
const pause = useCallback(async () => {
try {
@@ -578,32 +678,7 @@ const PlayerProvider = ({ children }) => {
[currentTrack, playback.isPlaying, pause, play, resume]
);
const seekTo = useCallback(
async (positionMs) => {
if (!player) return;
const bounded = Math.max(0, Number(positionMs) || 0);
const seekValue = toPlayerSeekValue(bounded);
try {
if (status?.isLoaded) {
await player.seekTo?.(seekValue);
} else {
pendingSeekValueRef.current = seekValue;
}
} catch (err) {
setError(err);
}
},
[player, status?.isLoaded, toPlayerSeekValue]
);
const seekBy = useCallback(
async (deltaMs) => {
const next = Math.max(0, playback.positionMs + Number(deltaMs || 0));
await seekTo(next);
},
[playback.positionMs, seekTo]
);
const stop = useCallback(async () => {
try {
+28 -33
View File
@@ -1,15 +1,23 @@
import React, { memo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Image as ExpoImage } from "expo-image";
import { FONT_FAMILY } from "../../../styles/Fonts";
import { Palette } from "../../../styles";
import { icons } from "../../../assets";
import React, { memo, useEffect } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { useVideoPlayer, VideoView } from "expo-video";
import { videos } from "../../../assets";
import { isWeb } from "../../../hooks/useLayoutType.js";
const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => {
const subtitle = hasActiveSubscription
? "Tu fais déjà partie du club !"
: "Rejoins le club !";
const ClubCard = ({ onPress }) => {
const player = useVideoPlayer(videos.club, (player) => {
player.loop = true;
player.muted = true;
player.play();
});
useEffect(() => {
if (player) {
player.muted = true;
player.loop = true;
player.play();
}
}, [player]);
return (
<Pressable
@@ -17,13 +25,12 @@ const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => {
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
>
<View style={styles.inner}>
<ExpoImage
source={icons.club}
contentFit="contain"
style={styles.clubLogo}
<VideoView
player={player}
style={styles.video}
contentFit="cover"
nativeControls={false}
/>
<ExpoImage source={image} contentFit="contain" style={styles.image} />
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
</Pressable>
);
@@ -34,32 +41,20 @@ export default memo(ClubCard);
const styles = StyleSheet.create({
card: {
marginTop: isWeb ? 16 : 28,
borderRadius: 20,
backgroundColor: "#252438",
overflow: "hidden",
width: 200,
height: 120, // Added fixed height to ensure video visibility, adjusting based on previous content size estimation
alignSelf: "center",
},
cardPressed: {
opacity: 0.85,
},
inner: {
paddingVertical: 12,
paddingHorizontal: 14,
alignItems: "center",
gap: 8,
flex: 1,
},
clubLogo: {
width: 160,
height: 36,
},
image: {
width: 52,
height: 52,
},
subtitle: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 12,
color: Palette.white,
video: {
width: "100%",
height: "100%",
},
});
+101 -35
View File
@@ -24,8 +24,14 @@ import {
import { useGlobal } from "reactn";
import { background, icons } from "../../assets";
import PressableScale from "../../components/PressableScale";
import ProgressSlider from "../../components/player/ProgressSlider";
import { increment, projectsRef, usersRef } from "../../config/firebase";
import Slider from "../../components/Slider";
import {
arrayRemove,
arrayUnion,
increment,
projectsRef,
usersRef,
} from "../../config/firebase";
import useDataFromRef from "../../hooks/useDataFromRef";
import usePlayer from "../../hooks/usePlayer";
import useTrackController from "../../hooks/useTrackController";
@@ -39,11 +45,6 @@ import {
createMusicSharePayload,
openShareSheet,
} from "../../utils/shareSheet";
import {
getProjectLikes,
LIKE_TARGET,
toggleProjectLike,
} from "../../utils/likes";
import {
formatStructureLabel,
getPromptLabelForStructure,
@@ -61,6 +62,9 @@ const MusicDetails = ({ route }) => {
const projectId = params?.projectId || null;
const [fav, setFav] = useState(false);
const [currentUID] = useGlobal("currentUID");
const wasPlayingBeforeSeek = useRef(false);
const hasCapturedSeekStateRef = useRef(false);
const lastSeekTargetMsRef = useRef(null);
const listenedMsRef = useRef(0);
const incrementDoneRef = useRef(false);
const timerRef = useRef(null);
@@ -82,10 +86,13 @@ const MusicDetails = ({ route }) => {
});
useEffect(() => {
const likes = getProjectLikes(project, LIKE_TARGET.SONG);
const liked = currentUID ? likes.includes(currentUID) : false;
setFav(liked);
}, [currentUID, project]);
if (project && currentUID) {
const liked = Array.isArray(project?.likedBy)
? project.likedBy.includes(currentUID)
: false;
setFav(liked);
}
}, [project?.likedBy, currentUID]);
const title = project?.title || "Sans titre";
const artist = useMemo(() => {
@@ -289,6 +296,15 @@ const MusicDetails = ({ route }) => {
return clearTimer;
}, [isTrackPlaying, projectId]);
const fmt = (ms) => {
const total = Math.max(0, Math.floor((ms || 0) / 1000));
const m = Math.floor(total / 60)
.toString()
.padStart(1, "0");
const s = (total % 60).toString().padStart(2, "0");
return `${m}:${s}`;
};
const togglePlay = useCallback(async () => {
if (!trackDescriptor) return;
try {
@@ -316,30 +332,41 @@ const MusicDetails = ({ route }) => {
const handleSliderSeekStart = useCallback(async () => {
if (!trackDescriptor) return;
lastSeekTargetMsRef.current = null;
if (!hasCapturedSeekStateRef.current) {
hasCapturedSeekStateRef.current = true;
wasPlayingBeforeSeek.current = isTrackPlaying;
}
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: positionMs, autoPlay: false });
}
if (isTrackPlaying) {
await pauseTrack();
}
} catch (e) {
console.log("MusicDetails seek start error", e?.message);
}
}, [
trackDescriptor,
isTrackPlaying,
isCurrentTrack,
ensureLoaded,
positionMs,
pauseTrack,
]);
const handleSliderSeek = useCallback(
async (targetMs) => {
async (ratio) => {
const dur = sliderDurationMs || 0;
if (!trackDescriptor || dur <= 0) return;
const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs)));
const targetMs = Math.max(0, Math.floor(dur * ratio));
lastSeekTargetMsRef.current = targetMs;
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: bounded, autoPlay: false });
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false });
} else {
await seekTrackTo(bounded);
await seekTrackTo(targetMs);
}
} catch (e) {
console.log("MusicDetails seek error", e?.message);
@@ -401,6 +428,37 @@ const MusicDetails = ({ route }) => {
resumeTrack,
]);
const handleSliderSeekEnd = useCallback(async () => {
const targetMs =
typeof lastSeekTargetMsRef.current === "number"
? Math.max(0, lastSeekTargetMsRef.current)
: null;
try {
if (wasPlayingBeforeSeek.current) {
if (!isCurrentTrack) {
await ensureLoaded({
startPositionMs:
targetMs !== null && Number.isFinite(targetMs)
? targetMs
: positionMs,
autoPlay: true,
});
} else {
if (targetMs !== null && Number.isFinite(targetMs)) {
await seekTrackTo(targetMs);
}
await resumeTrack();
}
}
} catch (e) {
console.log("MusicDetails seek end error", e?.message);
} finally {
wasPlayingBeforeSeek.current = false;
hasCapturedSeekStateRef.current = false;
lastSeekTargetMsRef.current = null;
}
}, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]);
const handleSeekBySeconds = useCallback(
async (deltaSeconds) => {
if (!trackDescriptor) return;
@@ -455,6 +513,10 @@ const MusicDetails = ({ route }) => {
// Build a readable text from lyrics with section labels
if (Array.isArray(project?.lyrics)) {
return project.lyrics
.filter((s) => {
const t = (s?.type || "").toLowerCase();
return ["couplet", "refrain"].includes(t);
})
.map((s) => {
const body = (s?.lyrics || "").trim();
if (!body) return null;
@@ -696,13 +758,15 @@ const MusicDetails = ({ route }) => {
pushCurrent();
let lineIdx = 0;
return grouped.map((section) => ({
...section,
lines: section.lines.map((line) => ({
...line,
globalIdx: lineIdx++,
})),
}));
return grouped
.filter((s) => ["couplet", "refrain"].includes(s.type))
.map((section) => ({
...section,
lines: section.lines.map((line) => ({
...line,
globalIdx: lineIdx++,
})),
}));
}, [alignedWords]);
const flatLines = useMemo(
@@ -729,7 +793,7 @@ const MusicDetails = ({ route }) => {
if (lyricsRef.current && typeof y === "number") {
try {
lyricsRef.current.scrollTo({ y: Math.max(0, y - 80), animated: true });
} catch (e) {}
} catch (e) { }
}
}, [currentLineIdx]);
@@ -804,11 +868,10 @@ const MusicDetails = ({ route }) => {
const next = !fav;
setFav(next);
try {
await toggleProjectLike({
projectId,
target: LIKE_TARGET.SONG,
currentUID,
next,
await projectsRef.doc(projectId).update({
likedBy: next
? arrayUnion(currentUID)
: arrayRemove(currentUID),
});
} catch (e) {
setFav(!next);
@@ -834,15 +897,18 @@ const MusicDetails = ({ route }) => {
</View>
{songUrl && (
<View style={{ paddingTop: 22 }}>
<ProgressSlider
positionMs={positionMs}
durationMs={sliderDurationMs}
isPlaying={isTrackPlaying}
<Slider
value={fmt(positionMs)}
maxValue={fmt(sliderDurationMs)}
progress={
sliderDurationMs
? Math.min(1, Math.max(0, (positionMs || 0) / sliderDurationMs))
: 0
}
seekEnabled={!!songUrl}
onSeekStart={handleSliderSeekStart}
onSeek={handleSliderSeek}
onPause={pauseTrack}
onPlay={resumeTrack}
disabled={!songUrl}
onSeekEnd={handleSliderSeekEnd}
/>
<View
style={{ ...Style.containerRow, gap: 24, alignSelf: "center" }}
+13 -30
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useState } from "react";
import { Image, StyleSheet, View } from "react-native";
import { ai, background } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
@@ -8,40 +8,23 @@ import MusicLandHeader from "../../components/MusicLandHeader";
import Page from "../../layouts/Page";
import { isWeb } from "../../hooks/useLayoutType";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { gutters } from "../../styles";
const Playback = ({ route, navigation }) => {
const { project } = route.params || {};
const { videos } = useUser();
const [showIntro, setShowIntro] = useState(false);
const benhaiUrl = isWeb ? videos?.benhaiWeb || null : videos?.benhai;
const [showIntro, setShowIntro] = useState(benhaiUrl);
const introVideoUrl = useMemo(() => {
if (!videos) {
return null;
const handleCloseIntro = () => {
if (showIntro === benhaiUrl) {
setShowIntro(videos.theo);
} else {
setShowIntro(null);
}
return isWeb ? videos?.benhaiWeb || null : videos?.benhai || null;
}, [videos]);
useEffect(() => {
if (!introVideoUrl) {
setShowIntro(false);
return;
}
setShowIntro(true);
}, [introVideoUrl]);
const handleCloseIntro = useCallback(() => {
setShowIntro(false);
}, []);
const handleShowGuide = useCallback(() => {
if (!introVideoUrl) {
return;
}
setShowIntro(true);
}, [introVideoUrl]);
};
const onPressRecord = useCallback(() => {
navigate(Routes.RecordPlayback, { project });
@@ -68,14 +51,14 @@ const Playback = ({ route, navigation }) => {
/>
<BorderGradientButton
title="Guide du Playbacker"
onPress={handleShowGuide}
onPress={() => setShowIntro(videos.theo)}
/>
{/* <BorderGradientButton title="Importer une vidéo" /> */}
</View>
</View>
<FullscreenIntroVideo
url={introVideoUrl}
visible={showIntro && !!introVideoUrl}
url={showIntro}
visible={showIntro}
onClose={handleCloseIntro}
/>
</Page>
+41 -4
View File
@@ -1,5 +1,5 @@
import { useIsFocused, useRoute } from "@react-navigation/native";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View } from "react-native";
import Carousel from "react-native-reanimated-carousel";
import { responsiveHeight } from "react-native-responsive-dimensions";
@@ -9,16 +9,30 @@ import PlaybackItem from "./components/PlaybackItem";
const Playbacks = () => {
const [activeIndex, setActiveIndex] = useState(0);
const carouselRef = useRef(null);
const userCache = useRef(new Map());
const isFocused = useIsFocused();
const { data: playbacks = [], loadMore } = useDataFromRef({
const route = useRoute();
const focusProjectId =
route?.params?.projectId || route?.params?.focusId || null;
const { data: rawPlaybacks = [], loadMore } = useDataFromRef({
ref: projectsRef.where("playbackUrl", "!=", null),
simpleRef: false,
listener: false,
usePagination: true,
batchSize: 6,
});
const focusIndex = useMemo(() => {
if (!focusProjectId) return -1;
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId);
}, [focusProjectId, rawPlaybacks]);
const playbacks = useMemo(() => {
if (focusIndex < 0) return rawPlaybacks;
const target = rawPlaybacks[focusIndex];
const before = rawPlaybacks.slice(0, focusIndex);
const after = rawPlaybacks.slice(focusIndex + 1);
return [target, ...after, ...before];
}, [focusIndex, rawPlaybacks]);
const onSnap = useCallback(
(index) => {
@@ -31,6 +45,17 @@ const Playbacks = () => {
);
const triedLoadMoreRef = useRef(0);
const focusLoadAttemptsRef = useRef(0);
const hasAppliedFocusRef = useRef(false);
useEffect(() => {
focusLoadAttemptsRef.current = 0;
hasAppliedFocusRef.current = false;
if (!focusProjectId) {
setActiveIndex(0);
}
}, [focusProjectId]);
useEffect(() => {
if (loadMore && triedLoadMoreRef.current < 6) {
triedLoadMoreRef.current += 1;
@@ -38,10 +63,22 @@ const Playbacks = () => {
}
}, [loadMore, playbacks]);
useEffect(() => {
if (!focusProjectId) return;
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
hasAppliedFocusRef.current = true;
setActiveIndex(0);
return;
}
if (loadMore && focusLoadAttemptsRef.current < 6) {
focusLoadAttemptsRef.current += 1;
loadMore();
}
}, [focusIndex, focusProjectId, loadMore, playbacks]);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<Carousel
ref={carouselRef}
data={playbacks}
vertical
height={responsiveHeight(100)}
+33 -26
View File
@@ -32,7 +32,7 @@ const Playbacks = () => {
const { getUserByUid } = useUser() || {};
const isFocused = useIsFocused();
const {
data: playbacks = [],
data: rawPlaybacks = [],
loadMore,
hasMore,
loading,
@@ -43,6 +43,18 @@ const Playbacks = () => {
usePagination: true,
batchSize: 6,
});
const focusIndex = useMemo(() => {
if (!focusProjectId) return -1;
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId);
}, [focusProjectId, rawPlaybacks]);
const playbacks = useMemo(() => {
if (focusIndex < 0) return rawPlaybacks;
const target = rawPlaybacks[focusIndex];
const before = rawPlaybacks.slice(0, focusIndex);
const after = rawPlaybacks.slice(focusIndex + 1);
return [target, ...after, ...before];
}, [focusIndex, rawPlaybacks]);
const activePlayback = playbacks?.[activeIndex] || null;
const activeVideoUrl = activePlayback?.playbackUrl || null;
@@ -167,22 +179,32 @@ const Playbacks = () => {
}, [activeVideoUrl]);
const triedLoadMoreRef = useRef(0);
const hasAppliedFocusRef = useRef(false);
const focusLoadAttemptsRef = useRef(0);
useEffect(() => {
hasAppliedFocusRef.current = false;
focusLoadAttemptsRef.current = 0;
}, [focusProjectId]);
useEffect(() => {
if (!focusProjectId) return;
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
if (idx >= 0) {
lastActiveIndexRef.current = idx;
setActiveIndex(idx);
setTimeout(() => {
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
hasAppliedFocusRef.current = true;
lastActiveIndexRef.current = 0;
setActiveIndex(0);
requestAnimationFrame(() => {
try {
listRef.current?.scrollToIndex?.({ index: idx, animated: false });
listRef.current?.scrollToOffset?.({ offset: 0, animated: false });
} catch (_e) {}
}, 50);
} else if (loadMore && triedLoadMoreRef.current < 6) {
triedLoadMoreRef.current += 1;
});
return;
}
if (loadMore && focusLoadAttemptsRef.current < 6) {
focusLoadAttemptsRef.current += 1;
loadMore();
}
}, [focusProjectId, playbacks, loadMore]);
}, [focusIndex, focusProjectId, loadMore, playbacks]);
// === FlatList (one real page per item) ===
// keep active index in sync with scroll
@@ -208,21 +230,6 @@ const Playbacks = () => {
[windowHeight, handleActiveIndexChange],
);
// programmatic jump to focus item
useEffect(() => {
if (!focusProjectId) return;
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
if (idx >= 0) {
lastActiveIndexRef.current = idx;
setActiveIndex(idx);
setTimeout(() => {
try {
listRef.current?.scrollToIndex?.({ index: idx, animated: false });
} catch (_e) {}
}, 50);
}
}, [focusProjectId, playbacks]);
const getItemLayout = useCallback(
(_data, index) => ({
length: windowHeight,
+6 -7
View File
@@ -380,12 +380,11 @@ const ManageSubscription = ({ navigation }) => {
? currentUserData.subscriptionGrantStrategy
: null;
const isUpfrontGrant = grantStrategy === "upfront";
const nextGrantTimestamp =
isUpfrontGrant
? null
: currentUserData?.subscriptionNextGrantAt ||
currentUserData?.subscriptionGrantNextAt ||
null;
const nextGrantTimestamp = isUpfrontGrant
? null
: currentUserData?.subscriptionNextGrantAt ||
currentUserData?.subscriptionGrantNextAt ||
null;
const nextGrantRawDate = toDate(nextGrantTimestamp);
const now = new Date();
@@ -727,7 +726,7 @@ const ManageSubscription = ({ navigation }) => {
/>
</View>
)}
<ClubAdvantagesCard isDev style={styles.clubCardSpacing} />
{/*<ClubAdvantagesCard isDev style={styles.clubCardSpacing} />*/}
</View>
</Page>
);
+1 -1
View File
@@ -43,7 +43,7 @@ const GeneratingSong = () => {
// project loaded from provider
// Progress based on 8 minutes cap or until status becomes GENERATED
// Progress based on an 8 minute cap or until status becomes GENERATED
useEffect(() => {
const totalMs = 8 * 60 * 1000;
const maxGeneratingProgress = 90;
+87 -67
View File
@@ -67,7 +67,7 @@ const SongReady = () => {
} catch (error) {
console.log("SongReady pause error", error?.message);
}
}
},
);
await Promise.all(tasks);
}, []);
@@ -95,15 +95,15 @@ const SongReady = () => {
console.log("SongReady play error", error?.message);
}
},
[pauseAllExcept]
[pauseAllExcept],
);
// Sync URLs from provider's selectedProject
useEffect(() => {
const urls = Array.isArray(selectedProject?.musicUrls)
? selectedProject.musicUrls
.filter((url) => typeof url === "string" && url.trim())
.map((url) => url.trim())
.filter((url) => typeof url === "string" && url.trim())
.map((url) => url.trim())
: [];
setMusicUrls(urls);
setSelectedIndex((prev) => {
@@ -150,7 +150,7 @@ const SongReady = () => {
return () => {
pauseAllPlayers();
};
}, [pauseAllPlayers])
}, [pauseAllPlayers]),
);
useEffect(() => {
@@ -177,7 +177,7 @@ const SongReady = () => {
},
},
],
{ cancelable: false }
{ cancelable: false },
);
return;
}
@@ -188,7 +188,7 @@ const SongReady = () => {
setShowRegenerateModal(false);
try {
await pauseAllPlayers();
} catch {}
} catch { }
navigate(Routes.Lyrics);
};
@@ -207,38 +207,36 @@ const SongReady = () => {
alert(
"Re-générer le morceau",
(
<View
style={{
width: "100%",
alignItems: "center",
gap: 12,
}}
>
<Text style={descriptionTextStyle}>
{`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
</Text>
<View
style={{
width: "100%",
flexDirection: "row",
alignItems: "center",
gap: 12,
justifyContent: "center",
flexWrap: "wrap",
gap: 6,
}}
>
<Text style={descriptionTextStyle}>
{`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: 6,
}}
>
<Text style={descriptionTextStyle}>Cette action coûte</Text>
<CreditAmount
value={MUSIC_GENERATION_COIN_COST}
iconSize={18}
textStyle={amountTextStyle}
/>
</View>
<Text style={descriptionTextStyle}>
Les crédits seront utilisés lors de l'étape de génération.
</Text>
<Text style={descriptionTextStyle}>Cette action coûte</Text>
<CreditAmount
value={MUSIC_GENERATION_COIN_COST}
iconSize={18}
textStyle={amountTextStyle}
/>
</View>
),
<Text style={descriptionTextStyle}>
Les crédits seront utilisés lors de l'étape de génération.
</Text>
</View>,
[
{
text: "Annuler",
@@ -250,7 +248,7 @@ const SongReady = () => {
onPress: () => handleConfirmRegenerate(),
},
],
{ cancelable: true }
{ cancelable: true },
);
return;
}
@@ -263,7 +261,7 @@ const SongReady = () => {
onPressBack={async () => {
try {
await pauseAllPlayers();
} catch {}
} catch { }
navigate(Routes.Home);
}}
progress={63}
@@ -360,6 +358,7 @@ const SongOptionCard = ({
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
const [isPlaying, setIsPlaying] = useState(false);
const shouldResumeAfterSeekRef = useRef(false);
useEffect(() => {
registerPlayer(index, player);
@@ -387,11 +386,11 @@ const SongOptionCard = ({
const id = setInterval(() => {
const durationMs = Math.max(
0,
Math.round((Number(player.duration) || 0) * 1000)
Math.round((Number(player.duration) || 0) * 1000),
);
const positionMs = Math.max(
0,
Math.round((Number(player.currentTime) || 0) * 1000)
Math.round((Number(player.currentTime) || 0) * 1000),
);
setProgressInfo((prev) => {
if (
@@ -413,35 +412,16 @@ const SongOptionCard = ({
return () => clearInterval(id);
}, [player]);
const handleSeek = async (targetMs) => {
if (!player || !progressInfo?.dur) return;
const dur = progressInfo.dur || 0;
const pos = Math.max(0, Math.min(dur, Math.floor(targetMs)));
try {
await player.seekTo?.(Math.floor(pos / 1000));
setProgressInfo((prev) => ({ ...prev, pos }));
} catch (error) {
console.log("SongReady seek error", error?.message);
}
};
const hasCapturedSeekStateRef = useRef(false);
const handleSeekStart = () => {
onSelect(index);
};
const pauseDuringSeek = async () => {
const resumePlaybackIfNeeded = useCallback(async () => {
if (!player) return;
try {
if (player.playing) {
await player.pause?.();
}
} catch (error) {
console.log("SongReady pause on seek start", error?.message);
}
};
const shouldResume = shouldResumeAfterSeekRef.current;
shouldResumeAfterSeekRef.current = false;
hasCapturedSeekStateRef.current = false; // Reset capture flag
if (!shouldResume) return;
const resumeAfterSeek = async () => {
if (!player) return;
try {
if (player.resume) {
await player.resume?.();
@@ -451,7 +431,48 @@ const SongOptionCard = ({
} catch (error) {
console.log("SongReady resume after seek", error?.message);
}
};
}, [player]);
const handleSeek = useCallback(
async (targetMs) => {
if (!player || !progressInfo?.dur) {
return;
}
const dur = progressInfo.dur || 0;
const pos = Math.max(0, Math.min(dur, Math.floor(targetMs)));
// Mise à jour visuelle immédiate
setProgressInfo((prev) => ({ ...prev, pos }));
try {
await player.seekTo?.(Math.floor(pos / 1000));
} catch (error) {
console.log("SongReady seek error", error?.message);
}
},
[player, progressInfo?.dur],
);
const handleSeekStart = useCallback(async () => {
onSelect(index);
if (!player) return;
const isCurrentlyPlaying = !!player.playing || isPlaying;
// Only capture state if we haven't already for this drag interaction
if (!hasCapturedSeekStateRef.current) {
hasCapturedSeekStateRef.current = true;
shouldResumeAfterSeekRef.current = isCurrentlyPlaying;
}
try {
if (isCurrentlyPlaying) {
await player.pause?.();
}
} catch (error) {
console.log("SongReady pause on seek start", error?.message);
}
}, [index, isPlaying, onSelect, player]);
const handleToggle = () => {
onSelect(index);
@@ -510,8 +531,7 @@ const SongOptionCard = ({
isPlaying={isPlaying}
onSeekStart={handleSeekStart}
onSeek={handleSeek}
onPause={pauseDuringSeek}
onPlay={resumeAfterSeek}
onSeekEnd={resumePlaybackIfNeeded}
disabled={!url}
/>
</View>
@@ -557,7 +577,7 @@ const RegenerateModal = ({ visible, onClose, onConfirm }) => {
const horizontalPadding = Math.max(isCompactWidth ? 16 : gutters, 12);
const verticalPadding = Math.max(
isCompactWidth ? gutters : gutters * 1.5,
12
12,
);
const availableWidth = Math.max(width - horizontalPadding * 2, 0);
const contentWidth =
@@ -33,6 +33,9 @@ const CreateLyricsWithAi = () => {
const [progress, setProgress] = useState(16);
const [parentLayout, setparentLayout] = useState(null);
const containerWidth = windowWidth;
const handleLyricsError = React.useCallback(() => {
setSelectedIndex(7);
}, [setSelectedIndex]);
// Collected state across steps
const [objective, setObjective] = useState(null); // from Goals list
@@ -496,6 +499,7 @@ const CreateLyricsWithAi = () => {
customStructure,
rhymes,
}}
onErrorRedirect={handleLyricsError}
/>
</View>
</SwiperFlatList>
@@ -26,6 +26,9 @@ const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
const CreateLyricsWithAi = () => {
const { selectedProject } = useUser();
const [selectedIndex, setSelectedIndex] = useState(0);
const handleLyricsError = React.useCallback(() => {
setSelectedIndex(7);
}, [setSelectedIndex]);
// Collected state across steps
const [objective, setObjective] = useState(null); // from Goals list
@@ -403,6 +406,7 @@ const CreateLyricsWithAi = () => {
customStructure,
rhymes,
}}
onErrorRedirect={handleLyricsError}
/>
),
},
+9 -2
View File
@@ -21,7 +21,7 @@ const FAKE_PROGRESS_MAX = 96;
const PROGRESS_INTERVAL_MS = 250;
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false };
const CreatingLyrics = ({ active, config, selections }) => {
const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
const [progress, setProgress] = useState(0);
const [called, setCalled] = useState(false);
const [result, setResult] = useState(null);
@@ -237,6 +237,13 @@ const CreatingLyrics = ({ active, config, selections }) => {
if (error && !hasShownErrorAlert.current) {
hasShownErrorAlert.current = true;
const redirect = () => {
if (typeof onErrorRedirect === "function") {
console.log(
"↩️ [CreatingLyrics] Retour à l'étape Rimes après erreur",
);
onErrorRedirect();
return;
}
console.log("↩️ [CreatingLyrics] Retour à WritingLyrics après erreur");
navigate(Routes.WritingLyrics);
};
@@ -257,7 +264,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
redirect();
}
}
}, [error]);
}, [error, onErrorRedirect]);
return (
<View
+61 -1
View File
@@ -1,5 +1,5 @@
import { Image as ExpoImage } from "expo-image";
import React from "react";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import { background } from "../../assets";
import GradientButton from "../../components/GradientButton";
@@ -11,9 +11,17 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUserData } from "../../providers/UserDataProvider";
import { gutters, Palette, Style } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import ProgressBar from "../../components/ProgressBar";
const COVER_PROGRESS_MAX = 98;
const COVER_PROGRESS_INTERVAL_MS = 500;
const COVER_FAKE_DURATION_MS = 2 * 60 * 1000;
const PhotoCover = () => {
const [coverProgress, setCoverProgress] = useState(0);
const progressIntervalRef = useRef(null);
const progressStartRef = useRef(null);
const { selectedProject } = useUserData();
const isGenerating = selectedProject?.coverStatus === "GENERATING";
const coverGenerationMessage = isWeb
@@ -29,6 +37,46 @@ const PhotoCover = () => {
coverOptions?.[0]?.finalUrl ||
coverOptions?.[0]?.generatedUrl ||
null;
const coverProgressValue = Math.max(
0,
Math.min(100, Math.round(coverProgress)),
);
useEffect(() => {
const clearProgressInterval = () => {
if (progressIntervalRef.current) {
global.clearInterval(progressIntervalRef.current);
progressIntervalRef.current = null;
}
};
if (!isGenerating || coverUrl) {
clearProgressInterval();
progressStartRef.current = null;
setCoverProgress(coverUrl ? 100 : 0);
return clearProgressInterval;
}
progressStartRef.current = new Date();
setCoverProgress(0);
clearProgressInterval();
progressIntervalRef.current = global.setInterval(() => {
const start = progressStartRef.current;
if (!start) return;
const elapsed = Date.now() - start.getTime();
const ratio = Math.max(
0,
Math.min(1, elapsed / COVER_FAKE_DURATION_MS),
);
const next = COVER_PROGRESS_MAX * ratio;
setCoverProgress((prev) => {
if (prev >= COVER_PROGRESS_MAX) return COVER_PROGRESS_MAX;
return next >= COVER_PROGRESS_MAX ? COVER_PROGRESS_MAX : next;
});
}, COVER_PROGRESS_INTERVAL_MS);
return clearProgressInterval;
}, [coverUrl, isGenerating]);
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
@@ -91,6 +139,18 @@ const PhotoCover = () => {
Génération en cours.
</Text>
)}
<View style={{ alignItems: "center", gap: 10 }}>
<ProgressBar gradient progress={coverProgressValue} />
<Text
style={{
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
}}
>
{coverProgressValue}%
</Text>
</View>
</>
) : (
<Text
+70 -26
View File
@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Image,
@@ -28,6 +28,7 @@ import { gutters, Palette, Style } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { getStageAction } from "../../utils/projectStages";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import ProgressBar from "../../components/ProgressBar";
const COVER_STYLE_PRESETS = [
"Cyberpunk",
@@ -37,6 +38,9 @@ const COVER_STYLE_PRESETS = [
"Livre de coloriage",
"Shooting",
];
const COVER_PROGRESS_MAX = 98;
const COVER_PROGRESS_INTERVAL_MS = 500;
const COVER_FAKE_DURATION_MS = 2 * 60 * 1000;
const PouchReady = () => {
const { selectedProjectId, selectedProject, updateProjectData } = useUser();
@@ -81,6 +85,9 @@ const PouchReady = () => {
const [isSelecting, setIsSelecting] = useState(false);
const [isAwaitingGenerationStart, setIsAwaitingGenerationStart] =
useState(false);
const [coverProgress, setCoverProgress] = useState(0);
const coverProgressIntervalRef = useRef(null);
const coverProgressStartRef = useRef(null);
const showGenerationLoading = useCallback(async () => {
setIsAwaitingGenerationStart(true);
@@ -184,6 +191,48 @@ const PouchReady = () => {
},
]
: [];
const isCoverLoading =
isGenerating && !hasGeneratedOptions && !coverPreviewUrl;
const coverProgressValue = Math.max(
0,
Math.min(100, Math.round(coverProgress)),
);
useEffect(() => {
const clearProgressInterval = () => {
if (coverProgressIntervalRef.current) {
global.clearInterval(coverProgressIntervalRef.current);
coverProgressIntervalRef.current = null;
}
};
if (!isCoverLoading) {
clearProgressInterval();
coverProgressStartRef.current = null;
setCoverProgress(hasGeneratedOptions || coverPreviewUrl ? 100 : 0);
return clearProgressInterval;
}
coverProgressStartRef.current = new Date();
setCoverProgress(0);
clearProgressInterval();
coverProgressIntervalRef.current = global.setInterval(() => {
const start = coverProgressStartRef.current;
if (!start) return;
const elapsed = Date.now() - start.getTime();
const ratio = Math.max(
0,
Math.min(1, elapsed / COVER_FAKE_DURATION_MS),
);
const next = COVER_PROGRESS_MAX * ratio;
setCoverProgress((prev) => {
if (prev >= COVER_PROGRESS_MAX) return COVER_PROGRESS_MAX;
return next >= COVER_PROGRESS_MAX ? COVER_PROGRESS_MAX : next;
});
}, COVER_PROGRESS_INTERVAL_MS);
return clearProgressInterval;
}, [isCoverLoading, hasGeneratedOptions, coverPreviewUrl]);
const handleSelectOption = useCallback(
async (option) => {
@@ -234,12 +283,6 @@ const PouchReady = () => {
});
}, [coverOptions, navigate, selectedOption, selectedProject]);
const primaryActionTitle = hasGeneratedOptions
? "Valider la pochette"
: isGenerating
? "Veuillez patienter..."
: "En attente de la génération";
const isPrimaryActionDisabled =
isGenerating ||
(hasGeneratedOptions ? !selectedOption || isSelecting : true);
@@ -274,10 +317,9 @@ const PouchReady = () => {
? "Ta pochette est prête!"
: "Génération de la pochette"
}
// subTitle="Quen penses-tu ?"
/>
<View style={{ flex: 1, ...Style.containerCenter }}>
<View style={{ width: "80%", gap: 24 }}>
<View style={{ width: isWeb ? "80%" : "100%", gap: 24 }}>
<View
style={{
width: "100%",
@@ -354,16 +396,6 @@ const PouchReady = () => {
);
})
) : (
// <View
// style={{
// width: isWeb ? 300 : "100%",
// height: 300,
// borderRadius: 20,
// backgroundColor: "#00000040",
// alignSelf: "center",
// ...Style.containerCenter,
// }}
// >
<>
{isGenerating && (
<View
@@ -404,6 +436,18 @@ const PouchReady = () => {
Génération en cours.
</Text>
)}
<View style={{ alignItems: "center", gap: 10 }}>
<ProgressBar gradient progress={coverProgressValue} />
<Text
style={{
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
}}
>
{coverProgressValue}%
</Text>
</View>
</View>
)}
</>
@@ -550,7 +594,7 @@ const PouchReady = () => {
<View
style={{
paddingBottom: gutters * 2,
width: "80%",
width: isWeb ? "80%" : "100%",
alignSelf: "center",
gap: 12,
}}
@@ -565,11 +609,12 @@ const PouchReady = () => {
disabled={isGenerating}
/>
)}
<GradientButton
title={primaryActionTitle}
disabled={isPrimaryActionDisabled}
onPress={onValidatePicture}
/>
{hasGeneratedOptions && (
<GradientButton
title={"Valider la pochette"}
onPress={onValidatePicture}
/>
)}
</View>
</Page>
);
@@ -760,7 +805,6 @@ const styles = StyleSheet.create({
marginLeft: 12,
},
modeColumn: {
flex: 1,
gap: 12,
minWidth: 240,
},