clear last tickets
This commit is contained in:
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 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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%",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="Qu’en 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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user