fix tickets add reort mail and design fixes
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
import { useGlobal } from "reactn";
|
||||
import { background, icons } from "../../assets";
|
||||
import PressableScale from "../../components/PressableScale";
|
||||
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
||||
import Slider from "../../components/Slider";
|
||||
import {
|
||||
arrayRemove,
|
||||
@@ -37,6 +38,11 @@ import {
|
||||
getSegmentMeta,
|
||||
normalizeStructureType,
|
||||
} from "../../utils/songStructure";
|
||||
import {
|
||||
createMusicSharePayload,
|
||||
openShareSheet,
|
||||
} from "../../utils/shareSheet";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
|
||||
// 20 secondes
|
||||
const timeBeforeIncrement = 20000;
|
||||
@@ -44,6 +50,7 @@ const timeBeforeIncrement = 20000;
|
||||
const MusicDetails = ({ route }) => {
|
||||
const { params } = route || {};
|
||||
const action = params?.action;
|
||||
const autoPlayRequested = params?.autoPlay;
|
||||
const projectId = params?.projectId || null;
|
||||
const [fav, setFav] = useState(false);
|
||||
const [currentUID] = useGlobal("currentUID");
|
||||
@@ -51,6 +58,7 @@ const MusicDetails = ({ route }) => {
|
||||
const listenedMsRef = useRef(0);
|
||||
const incrementDoneRef = useRef(false);
|
||||
const timerRef = useRef(null);
|
||||
const hasAutoPlayedRef = useRef(false);
|
||||
|
||||
const { data: project } = useDataFromRef({
|
||||
ref: projectId ? projectsRef.doc(projectId) : null,
|
||||
@@ -105,6 +113,33 @@ const MusicDetails = ({ route }) => {
|
||||
};
|
||||
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() =>
|
||||
createMusicSharePayload({
|
||||
projectId,
|
||||
title,
|
||||
artist,
|
||||
}),
|
||||
[projectId, title, artist]
|
||||
);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
if (!sharePayload) return;
|
||||
openShareSheet(sharePayload);
|
||||
}, [sharePayload]);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
if (!projectId) return;
|
||||
SheetManager.show("Report", {
|
||||
payload: {
|
||||
targetType: "music",
|
||||
projectId,
|
||||
title,
|
||||
ownerId: project?.userId || owner?.id || null,
|
||||
},
|
||||
});
|
||||
}, [owner?.id, project?.userId, projectId, title]);
|
||||
|
||||
const {
|
||||
isCurrent: isCurrentTrack,
|
||||
isPlaying: isTrackPlaying,
|
||||
@@ -137,6 +172,7 @@ const MusicDetails = ({ route }) => {
|
||||
useEffect(() => {
|
||||
listenedMsRef.current = 0;
|
||||
incrementDoneRef.current = false;
|
||||
hasAutoPlayedRef.current = false;
|
||||
}, [trackId]);
|
||||
|
||||
// Start/stop a timer to accumulate listened milliseconds while playing
|
||||
@@ -247,6 +283,34 @@ const MusicDetails = ({ route }) => {
|
||||
[durationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoPlayRequested || hasAutoPlayedRef.current) return;
|
||||
if (!trackDescriptor || !songUrl) return;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
if (!isCurrentTrack) {
|
||||
await ensureLoaded({ startPositionMs: 0, autoPlay: true });
|
||||
} else if (!isTrackPlaying) {
|
||||
await resumeTrack();
|
||||
}
|
||||
hasAutoPlayedRef.current = true;
|
||||
} catch (e) {
|
||||
console.log("MusicDetails autoplay error", e?.message);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
}, [
|
||||
autoPlayRequested,
|
||||
trackDescriptor,
|
||||
songUrl,
|
||||
isCurrentTrack,
|
||||
ensureLoaded,
|
||||
isTrackPlaying,
|
||||
resumeTrack,
|
||||
]);
|
||||
|
||||
const handleSliderSeekEnd = useCallback(async () => {
|
||||
try {
|
||||
if (wasPlayingBeforeSeek.current) {
|
||||
@@ -627,17 +691,24 @@ const MusicDetails = ({ route }) => {
|
||||
style={styles.img}
|
||||
/>
|
||||
)}
|
||||
<View style={{ ...Style.containerSpaceBetween }}>
|
||||
<View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.name}>{artist}</Text>
|
||||
</View>
|
||||
<View style={{ ...Style.containerRow, gap: 12 }}>
|
||||
<PressableScale
|
||||
onPress={async () => {
|
||||
if (!projectId || !currentUID) return;
|
||||
const next = !fav;
|
||||
setFav(next);
|
||||
<View style={{ gap: 12 }}>
|
||||
<View
|
||||
style={{
|
||||
...Style.containerRow,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.name}>{artist}</Text>
|
||||
</View>
|
||||
<View style={{ ...Style.containerRow, gap: 16 }}>
|
||||
<PressableScale
|
||||
onPress={async () => {
|
||||
if (!projectId || !currentUID) return;
|
||||
const next = !fav;
|
||||
setFav(next);
|
||||
try {
|
||||
await projectsRef.doc(projectId).update({
|
||||
likedBy: next
|
||||
@@ -655,6 +726,12 @@ const MusicDetails = ({ route }) => {
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</PressableScale>
|
||||
<PressableScale onPress={handleReport}>
|
||||
<Feather name="flag" size={22} color={Palette.white} style={{ marginHorizontal: 2 }} />
|
||||
</PressableScale>
|
||||
{!!sharePayload && (
|
||||
<ShareBtn style={{ borderRadius: 18 }} onPress={handleShare} />
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
SheetManager.show("Playlist", { payload: { projectId } })
|
||||
@@ -666,6 +743,7 @@ const MusicDetails = ({ route }) => {
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useGlobal } from "reactn";
|
||||
import { background, icons } from "../../assets";
|
||||
import PressableScale from "../../components/PressableScale";
|
||||
import Slider from "../../components/Slider";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import {
|
||||
arrayRemove,
|
||||
arrayUnion,
|
||||
@@ -45,6 +46,11 @@ import {
|
||||
normalizeStructureType,
|
||||
segmentRequiresLyrics,
|
||||
} from "../../utils/songStructure";
|
||||
import {
|
||||
createMusicSharePayload,
|
||||
openShareSheet,
|
||||
} from "../../utils/shareSheet";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
|
||||
// 20 secondes
|
||||
const timeBeforeIncrement = 20000;
|
||||
@@ -52,14 +58,17 @@ const timeBeforeIncrement = 20000;
|
||||
const MusicDetails = ({ route }) => {
|
||||
const { params } = route || {};
|
||||
const action = params?.action;
|
||||
const autoPlayRequested = params?.autoPlay;
|
||||
const projectId = params?.projectId || null;
|
||||
const [fav, setFav] = useState(false);
|
||||
const [currentUID] = useGlobal("currentUID");
|
||||
const [, setTooltip] = useGlobal("_tooltip");
|
||||
const wasPlayingBeforeSeek = React.useRef(false);
|
||||
const lastSeekTargetMs = React.useRef(null);
|
||||
const listenedMsRef = React.useRef(0);
|
||||
const incrementDoneRef = React.useRef(false);
|
||||
const timerRef = React.useRef(null);
|
||||
const hasAutoPlayedRef = React.useRef(false);
|
||||
|
||||
const { data: project } = useDataFromRef({
|
||||
ref: projectId ? projectsRef.doc(projectId) : null,
|
||||
@@ -116,6 +125,108 @@ const MusicDetails = ({ route }) => {
|
||||
};
|
||||
}, [trackId, songUrl, title, artist, coverUrl, projectId]);
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() =>
|
||||
createMusicSharePayload({
|
||||
projectId,
|
||||
title,
|
||||
artist,
|
||||
}),
|
||||
[projectId, title, artist]
|
||||
);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
if (!sharePayload) return;
|
||||
openShareSheet(sharePayload);
|
||||
}, [sharePayload]);
|
||||
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!songUrl || isDownloading) return;
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
setDownloadProgress(0);
|
||||
|
||||
const response = await fetch(songUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download_failed_${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "audio/mpeg";
|
||||
const total = Number(response.headers.get("content-length")) || 0;
|
||||
const sanitizedTitle = String(title || "musicland-track")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
const fileName = `${sanitizedTitle || "musicland-track"}.mp3`;
|
||||
|
||||
if (response.body && typeof response.body.getReader === "function") {
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
let pseudoProgress = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
if (total > 0) {
|
||||
setDownloadProgress(Math.min(1, received / total));
|
||||
} else {
|
||||
pseudoProgress = Math.min(0.95, pseudoProgress + 0.05);
|
||||
setDownloadProgress(pseudoProgress);
|
||||
}
|
||||
}
|
||||
}
|
||||
const blob = new Blob(chunks, { type: contentType });
|
||||
setDownloadProgress(1);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const blob = await response.blob();
|
||||
setDownloadProgress(1);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("MusicDetails.download", error);
|
||||
setTooltip({
|
||||
type: "error",
|
||||
text: "Téléchargement impossible",
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
setTimeout(() => setDownloadProgress(0), 400);
|
||||
}
|
||||
}, [isDownloading, setTooltip, songUrl, title]);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
if (!projectId) return;
|
||||
SheetManager.show("Report", {
|
||||
payload: {
|
||||
targetType: "music",
|
||||
projectId,
|
||||
title,
|
||||
ownerId: project?.userId || owner?.id || null,
|
||||
},
|
||||
});
|
||||
}, [owner?.id, project?.userId, projectId, title]);
|
||||
|
||||
const {
|
||||
isCurrent: isCurrentTrack,
|
||||
isPlaying: isTrackPlaying,
|
||||
@@ -150,6 +261,7 @@ const MusicDetails = ({ route }) => {
|
||||
useEffect(() => {
|
||||
listenedMsRef.current = 0;
|
||||
incrementDoneRef.current = false;
|
||||
hasAutoPlayedRef.current = false;
|
||||
}, [trackId]);
|
||||
|
||||
// Start/stop a timer to accumulate listened milliseconds while playing
|
||||
@@ -282,6 +394,38 @@ const MusicDetails = ({ route }) => {
|
||||
[trackDescriptor, durationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoPlayRequested || hasAutoPlayedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!trackDescriptor || !songUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
if (!isCurrentTrack) {
|
||||
await ensureLoaded({ startPositionMs: 0, autoPlay: true });
|
||||
} else if (!isPlaying) {
|
||||
await resumeTrack();
|
||||
}
|
||||
hasAutoPlayedRef.current = true;
|
||||
} catch (e) {
|
||||
console.log("MusicDetails autoplay error", e?.message);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
}, [
|
||||
autoPlayRequested,
|
||||
ensureLoaded,
|
||||
isCurrentTrack,
|
||||
isPlaying,
|
||||
resumeTrack,
|
||||
songUrl,
|
||||
trackDescriptor,
|
||||
]);
|
||||
|
||||
const handleSliderSeekEnd = useCallback(async () => {
|
||||
const targetMs =
|
||||
typeof lastSeekTargetMs.current === "number"
|
||||
@@ -677,6 +821,14 @@ const MusicDetails = ({ route }) => {
|
||||
backgroundImg={
|
||||
action === "userProfile" ? background.profileBG : background.libraryBG2
|
||||
}
|
||||
shareBtn={
|
||||
sharePayload
|
||||
? {
|
||||
label: "Partager le morceau",
|
||||
onPress: handleShare,
|
||||
}
|
||||
: false
|
||||
}
|
||||
{...(action === "userProfile" && {
|
||||
containerStyle: {
|
||||
backgroundColor: "#0000004D",
|
||||
@@ -761,6 +913,45 @@ const MusicDetails = ({ route }) => {
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Pressable>
|
||||
<PressableScale onPress={handleReport}>
|
||||
<Feather
|
||||
name="flag"
|
||||
size={22}
|
||||
color={Palette.white}
|
||||
style={{ marginHorizontal: 2 }}
|
||||
/>
|
||||
</PressableScale>
|
||||
<View
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<DownloadProgressRing
|
||||
progress={downloadProgress}
|
||||
size={36}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
) : null}
|
||||
<Pressable
|
||||
onPress={handleDownload}
|
||||
disabled={isDownloading || !songUrl}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
opacity: isDownloading || !songUrl ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Feather name="download" size={18} color={Palette.white} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
SheetManager.show("Playlist", { payload: { projectId } })
|
||||
@@ -975,3 +1166,38 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 6,
|
||||
},
|
||||
});
|
||||
|
||||
const DownloadProgressRing = ({ progress = 0, size = 36, strokeWidth = 3 }) => {
|
||||
const radius = size / 2 - strokeWidth / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = Math.max(0, Math.min(1, progress || 0));
|
||||
const offset = circumference * (1 - clamped);
|
||||
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
>
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="rgba(255, 255, 255, 0.15)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
/>
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={Palette.primary}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
fill="transparent"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
LIKE_TARGET,
|
||||
toggleProjectLike,
|
||||
} from "../../utils/likes";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
|
||||
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
||||
const { currentUID, followUser, unfollowUser } = useUser() || {};
|
||||
@@ -142,6 +144,18 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
||||
} catch (e) {}
|
||||
};
|
||||
}, [videoPlayer]);
|
||||
const handleReport = useCallback(() => {
|
||||
if (!item?.id) return;
|
||||
SheetManager.show("Report", {
|
||||
payload: {
|
||||
targetType: "playback",
|
||||
projectId: item?.id || null,
|
||||
playbackId: item?.id || null,
|
||||
title: item?.title || "",
|
||||
ownerId: item?.userId || null,
|
||||
},
|
||||
});
|
||||
}, [item?.id, item?.title, item?.userId]);
|
||||
|
||||
// Build alignedWords from item musicTimestamps
|
||||
const alignedWords = useMemo(() => {
|
||||
@@ -365,6 +379,20 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
||||
>
|
||||
<Image source={icons.share} style={size({ size: 26 })} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleReport} style={{ alignItems: "center" }}>
|
||||
<Feather name="flag" size={26} color={Palette.white} />
|
||||
<Text
|
||||
style={{
|
||||
color: Palette.white,
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Signaler
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={{ paddingHorizontal: 28 }}>
|
||||
<BlurView
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
LIKE_TARGET,
|
||||
toggleProjectLike,
|
||||
} from "../../../utils/likes";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
|
||||
// Debug logging toggle for web playback
|
||||
const DEBUG_PLAYBACK_WEB = true;
|
||||
@@ -252,6 +254,19 @@ const PlaybackItem = ({
|
||||
const descriptionText =
|
||||
item?.description || item?.title || "Description chanson";
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
if (!item?.id) return;
|
||||
SheetManager.show("Report", {
|
||||
payload: {
|
||||
targetType: "playback",
|
||||
projectId: item?.id || null,
|
||||
playbackId: item?.id || null,
|
||||
title: item?.title || "",
|
||||
ownerId: item?.userId || null,
|
||||
},
|
||||
});
|
||||
}, [item?.id, item?.title, item?.userId]);
|
||||
|
||||
const handleLayout = useCallback((event) => {
|
||||
const { width = 0, height = 0 } = event?.nativeEvent?.layout || {};
|
||||
setLayoutSize((prev) => ({
|
||||
@@ -486,6 +501,13 @@ const PlaybackItem = ({
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleReport}
|
||||
style={[styles.actionButton, styles.actionSpacing]}
|
||||
>
|
||||
<Feather name="flag" size={20} color={Palette.white} />
|
||||
<Text style={styles.actionLabel}>Signaler</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.lyricsContainer}>
|
||||
|
||||
@@ -240,16 +240,7 @@ const GeneratingSong = () => {
|
||||
|
||||
if (titleOk && isIdle && !askedRef.current) {
|
||||
askedRef.current = true;
|
||||
AppAlert("Attention", "Une génération va être lancée. Continuer ?", [
|
||||
{
|
||||
text: "Non",
|
||||
style: "cancel",
|
||||
onPress: () => {
|
||||
askedRef.current = false;
|
||||
},
|
||||
},
|
||||
{ text: "Oui", onPress: () => startMusicGenerationOnce() },
|
||||
]);
|
||||
startMusicGenerationOnce();
|
||||
}
|
||||
}, [
|
||||
isFocused,
|
||||
|
||||
@@ -8,8 +8,7 @@ import MusicLandHeader from "../../components/MusicLandHeader";
|
||||
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data";
|
||||
import useLayoutType from "../../hooks/useLayoutType";
|
||||
import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||
import { goBack } from "../../navigation/NavigationService";
|
||||
import { useUser } from "../../providers/UserDataProvider";
|
||||
import { gutters } from "../../styles";
|
||||
import { sanitizeStructureList } from "../../utils/songStructure";
|
||||
@@ -23,13 +22,13 @@ import SongStyle from "./SongStyle";
|
||||
import SongTo from "./SongTo";
|
||||
import SpecificityContext from "./SpecificityContext";
|
||||
const { width: windowWidth } = Dimensions.get("window");
|
||||
const MAX_STEP_INDEX = 8;
|
||||
|
||||
const CreateLyricsWithAi = () => {
|
||||
const { isWeb } = useLayoutType();
|
||||
const { selectedProject, updateProjectData } = useUser();
|
||||
const hasLyrics = selectedProject?.hasLyrics === true;
|
||||
const { selectedProject } = useUser();
|
||||
const scrollRef = useRef(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(16);
|
||||
const [parentLayout, setparentLayout] = useState(null);
|
||||
const containerWidth = windowWidth;
|
||||
@@ -188,13 +187,6 @@ const CreateLyricsWithAi = () => {
|
||||
}
|
||||
}, [structure]);
|
||||
|
||||
// Si déjà des paroles, forcer l'accès à partir de l'étape 5 et ignorer 0-4
|
||||
React.useEffect(() => {
|
||||
if (hasLyrics && selectedIndex < 5) {
|
||||
setSelectedIndex(5);
|
||||
}
|
||||
}, [hasLyrics]);
|
||||
|
||||
const lyricsConfig = useMemo(() => {
|
||||
const resolvedObjective = shouldUseOtherObjective
|
||||
? persistedOtherObjective || undefined
|
||||
@@ -297,37 +289,7 @@ const CreateLyricsWithAi = () => {
|
||||
]);
|
||||
|
||||
const onPressNext = async () => {
|
||||
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
|
||||
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
|
||||
if (hasLyrics && selectedIndex === 6) {
|
||||
const chosenStructure = sanitizeStructureList(
|
||||
Array.isArray(customStructure) && customStructure.length > 0
|
||||
? customStructure
|
||||
: Array.isArray(parsedStructure)
|
||||
? parsedStructure
|
||||
: []
|
||||
);
|
||||
await updateProjectData({
|
||||
config: { structure: chosenStructure },
|
||||
selections: {
|
||||
objective,
|
||||
otherObjective: persistedOtherObjective,
|
||||
context,
|
||||
emotion,
|
||||
style,
|
||||
otherStyle: persistedOtherStyle,
|
||||
audience,
|
||||
structure,
|
||||
parsedStructure,
|
||||
customStructure: sanitizeStructureList(customStructure),
|
||||
rhymes,
|
||||
},
|
||||
hasLyrics: true,
|
||||
});
|
||||
navigate(Routes.Lyrics);
|
||||
return;
|
||||
}
|
||||
setSelectedIndex((idx) => idx + 1);
|
||||
setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX));
|
||||
};
|
||||
|
||||
const onPressBack = () => {
|
||||
@@ -344,14 +306,36 @@ const CreateLyricsWithAi = () => {
|
||||
// Sync scroll position and progress with selectedIndex
|
||||
React.useEffect(() => {
|
||||
if (!parentLayout?.height) return;
|
||||
try {
|
||||
const nextProgress = 16 + selectedIndex * 9;
|
||||
setProgress(nextProgress);
|
||||
scrollRef.current?.scrollToIndex?.({
|
||||
index: selectedIndex,
|
||||
animated: true,
|
||||
});
|
||||
} catch (_) {}
|
||||
const nextProgress = Math.max(0, Math.min(100, 16 + selectedIndex * 9));
|
||||
setProgress(nextProgress);
|
||||
|
||||
const ref = scrollRef.current;
|
||||
if (!ref || typeof ref.scrollToIndex !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const attemptScroll = (retries = 0) => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
ref.scrollToIndex({ index: selectedIndex, animated: true });
|
||||
} catch (error) {
|
||||
if (retries < 3) {
|
||||
setTimeout(() => attemptScroll(retries + 1), 40);
|
||||
} else {
|
||||
console.warn("[CreateLyricsWithAi] scrollToIndex failed", {
|
||||
index: selectedIndex,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
attemptScroll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedIndex, parentLayout?.height]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,8 +6,7 @@ import GradientButton from "../../components/GradientButton";
|
||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from "../../data/data";
|
||||
import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||
import { goBack } from "../../navigation/NavigationService";
|
||||
import { useUser } from "../../providers/UserDataProvider";
|
||||
import { gutters } from "../../styles";
|
||||
import { sanitizeStructureList } from "../../utils/songStructure";
|
||||
@@ -21,10 +20,11 @@ import SongStyle from "./SongStyle";
|
||||
import SongTo from "./SongTo";
|
||||
import SpecificityContext from "./SpecificityContext";
|
||||
|
||||
const MAX_STEP_INDEX = 8;
|
||||
|
||||
const CreateLyricsWithAi = () => {
|
||||
const { selectedProject, updateProjectData } = useUser();
|
||||
const hasLyrics = selectedProject?.hasLyrics === true;
|
||||
const [selectedIndex, setSelectedIndex] = useState(hasLyrics ? 5 : 0);
|
||||
const { selectedProject } = useUser();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Collected state across steps
|
||||
const [objective, setObjective] = useState(null); // from Goals list
|
||||
@@ -187,13 +187,6 @@ const CreateLyricsWithAi = () => {
|
||||
}
|
||||
}, [structure]);
|
||||
|
||||
// Si déjà des paroles, forcer l'accès à partir de l'étape 5 et ignorer 0-4
|
||||
React.useEffect(() => {
|
||||
if (hasLyrics && selectedIndex < 5) {
|
||||
setSelectedIndex(5);
|
||||
}
|
||||
}, [hasLyrics]);
|
||||
|
||||
const progress = useMemo(
|
||||
() => 16 + selectedIndex * 9,
|
||||
[selectedIndex]
|
||||
@@ -301,37 +294,7 @@ const CreateLyricsWithAi = () => {
|
||||
]);
|
||||
|
||||
const onPressNext = async () => {
|
||||
// Si l'utilisateur a déjà des paroles et vient de finir CustomizeSongStructure (index 6),
|
||||
// sauter Rhymes et CreatingLyrics et aller directement sur Lyrics
|
||||
if (hasLyrics && selectedIndex === 6) {
|
||||
const chosenStructure = sanitizeStructureList(
|
||||
Array.isArray(customStructure) && customStructure.length > 0
|
||||
? customStructure
|
||||
: Array.isArray(parsedStructure)
|
||||
? parsedStructure
|
||||
: []
|
||||
);
|
||||
await updateProjectData({
|
||||
config: { structure: chosenStructure },
|
||||
selections: {
|
||||
objective,
|
||||
otherObjective: persistedOtherObjective,
|
||||
context,
|
||||
emotion,
|
||||
style,
|
||||
otherStyle: persistedOtherStyle,
|
||||
audience,
|
||||
structure,
|
||||
parsedStructure,
|
||||
customStructure: sanitizeStructureList(customStructure),
|
||||
rhymes,
|
||||
},
|
||||
hasLyrics: true,
|
||||
});
|
||||
navigate(Routes.Lyrics);
|
||||
return;
|
||||
}
|
||||
setSelectedIndex((idx) => idx + 1);
|
||||
setSelectedIndex((idx) => Math.min(idx + 1, MAX_STEP_INDEX));
|
||||
};
|
||||
|
||||
const onPressBack = () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View } from "react-native";
|
||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||
import ListSelection from "../../components/ListSelection/ListSelection";
|
||||
import { EMOTION_CONVEY } from "../../data/data";
|
||||
import { strings } from "../../constants/strings";
|
||||
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
||||
|
||||
const EmotionConvey = ({
|
||||
@@ -19,8 +20,8 @@ const EmotionConvey = ({
|
||||
return (
|
||||
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
|
||||
<CreateLyricsHeader
|
||||
title={`Quelle émotion veux-tu\ntransmettre ?`}
|
||||
subTitle="Sélectionne une seule intention émotionnelle."
|
||||
title={strings.writing.steps.emotionTitle}
|
||||
subTitle={strings.writing.steps.emotionSubtitle}
|
||||
/>
|
||||
|
||||
<View
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Platform, ScrollView, StyleSheet, View } from "react-native";
|
||||
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||
import ListSelection from "../../components/ListSelection/ListSelection";
|
||||
import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data";
|
||||
import { strings } from "../../constants/strings";
|
||||
import { Palette } from "../../styles";
|
||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
||||
@@ -39,9 +40,14 @@ const Goals = ({
|
||||
<ScrollView contentContainerStyle={{ flex: 1, gap: 16, marginTop: 16 }}>
|
||||
<View style={{ gap: 10 }}>
|
||||
<CreateLyricsHeader
|
||||
title="Quel est le contexte ?"
|
||||
subTitle="Besoin d'idées ? Pense à un anniversaire, une équipe de sport ou ton entreprise."
|
||||
title={strings.writing.steps.contextTitle}
|
||||
subTitle={strings.writing.steps.contextSubtitle}
|
||||
/>
|
||||
<View style={styles.examplesContainer}>
|
||||
<Text style={styles.examplesText}>
|
||||
{strings.writing.steps.contextExamples}
|
||||
</Text>
|
||||
</View>
|
||||
<ItemContainer>
|
||||
<ListSelection
|
||||
options={goalsOptions}
|
||||
@@ -55,8 +61,8 @@ const Goals = ({
|
||||
</ItemContainer>
|
||||
</View>
|
||||
<CustomInput
|
||||
label="As-tu un autre objectif ?"
|
||||
placeholder="Décrire l’objectif"
|
||||
label={strings.writing.labels.otherObjective}
|
||||
placeholder={strings.writing.labels.otherObjectivePlaceholder}
|
||||
value={otherObjective}
|
||||
setValue={handleOtherObjectiveChange}
|
||||
height={Platform.OS === "web" ? 160 : undefined}
|
||||
@@ -95,4 +101,15 @@ const styles = StyleSheet.create({
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "center",
|
||||
},
|
||||
examplesContainer: {
|
||||
backgroundColor: Palette.ultraLightWhite,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
},
|
||||
examplesText: {
|
||||
fontSize: 14,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import GradientButton from "../../components/GradientButton";
|
||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||
import firebase, { projectsRef } from "../../config/firebase";
|
||||
import { strings } from "../../constants/strings";
|
||||
import { isWeb } from "../../hooks/useLayoutType";
|
||||
import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
@@ -363,24 +364,17 @@ const Lyrics = ({ navigation }) => {
|
||||
}}
|
||||
>
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.headerTitle}>Proposition de paroles</Text>
|
||||
<Text style={styles.instructions}>
|
||||
Relis attentivement la proposition générée avant de valider.
|
||||
<Text style={styles.headerTitle}>
|
||||
{strings.writing.lyrics.title}
|
||||
</Text>
|
||||
<Text style={styles.instructions}>
|
||||
<Text style={styles.instructionsHighlight}>
|
||||
Personnalisation :
|
||||
</Text>{" "}
|
||||
modifie le titre et chaque section pour que les paroles te
|
||||
ressemblent.
|
||||
</Text>
|
||||
<Text style={styles.instructions}>
|
||||
<Text style={styles.instructionsHighlight}>
|
||||
Nouvelle génération :
|
||||
</Text>{" "}
|
||||
appuie sur "Générer d'autres paroles" si tu souhaites une autre
|
||||
suggestion.
|
||||
{strings.writing.lyrics.instructions}
|
||||
</Text>
|
||||
<View style={styles.personalizationBanner}>
|
||||
<Text style={styles.personalizationText}>
|
||||
{strings.writing.lyrics.personalizationBanner}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<CustomInput
|
||||
label="Titre"
|
||||
@@ -489,8 +483,18 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
instructionsHighlight: {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
personalizationBanner: {
|
||||
backgroundColor: Palette.ultraLightWhite,
|
||||
borderRadius: 12,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
personalizationText: {
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
instrumentalBlock: {
|
||||
padding: 16,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Image, StyleSheet, View } from "react-native";
|
||||
import { Image, StyleSheet, Text, View } from "react-native";
|
||||
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
||||
import { ai, background, icons } from "../../assets";
|
||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||
@@ -10,22 +10,24 @@ import Page from "../../layouts/Page";
|
||||
import { Routes } from "../../navigation";
|
||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||
import { useUserData } from "../../providers/UserDataProvider";
|
||||
import { gutters } from "../../styles";
|
||||
import { strings } from "../../constants/strings";
|
||||
import { Palette, gutters } from "../../styles";
|
||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
|
||||
const WritingLyrics = () => {
|
||||
const { createNewProject, selectedProject, updateProjectData } =
|
||||
useUserData();
|
||||
const { setIsLoading } = useMinuit();
|
||||
|
||||
async function createAndNavigate({ hasLyrics = false }) {
|
||||
const startWriting = React.useCallback(async () => {
|
||||
try {
|
||||
await setIsLoading(true);
|
||||
if (selectedProject) {
|
||||
updateProjectData({ hasLyrics });
|
||||
updateProjectData({ hasLyrics: false });
|
||||
setTimeout(() => navigate(Routes.CreateLyricsWithAi), 500);
|
||||
return;
|
||||
}
|
||||
const newProjectId = await createNewProject({ hasLyrics });
|
||||
const newProjectId = await createNewProject({ hasLyrics: false });
|
||||
if (!newProjectId) {
|
||||
return;
|
||||
}
|
||||
@@ -35,7 +37,7 @@ const WritingLyrics = () => {
|
||||
} finally {
|
||||
await setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [createNewProject, navigate, selectedProject, setIsLoading, updateProjectData]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
@@ -48,27 +50,34 @@ const WritingLyrics = () => {
|
||||
progress={9}
|
||||
logo={icons.musicLandWriting}
|
||||
/>
|
||||
<View
|
||||
style={{ flex: 1, position: "relative", justifyContent: "flex-end" }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
paddingBottom: gutters * 2,
|
||||
paddingHorizontal: gutters,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<View style={styles.contentWrapper}>
|
||||
<View style={styles.heroContainer}>
|
||||
<Text style={styles.heroTitle}>
|
||||
{strings.writing.onboarding.heroTitle}
|
||||
</Text>
|
||||
<Text style={styles.heroSubtitle}>
|
||||
{strings.writing.onboarding.heroSubtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ctaGroup}>
|
||||
<GradientButton
|
||||
maxWidth={500}
|
||||
size="large"
|
||||
maxWidth={520}
|
||||
containerStyle={{ alignSelf: "center", width: "100%" }}
|
||||
title="Écrire des paroles avec une IA"
|
||||
onPress={() => createAndNavigate({ hasLyrics: false })}
|
||||
title={strings.writing.onboarding.primaryCta}
|
||||
onPress={startWriting}
|
||||
textStyle={{ fontFamily: FONT_FAMILY.InterSemiBold }}
|
||||
/>
|
||||
<BorderGradientButton
|
||||
maxWidth={500}
|
||||
onPress={() => createAndNavigate({ hasLyrics: true })}
|
||||
size="small"
|
||||
title={strings.writing.onboarding.secondaryCta}
|
||||
disabled
|
||||
containerStyle={{ alignSelf: "center", width: "100%" }}
|
||||
maxWidth={360}
|
||||
/>
|
||||
<Text style={styles.secondaryNote}>
|
||||
{strings.writing.onboarding.secondaryNote}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
@@ -85,4 +94,39 @@ const styles = StyleSheet.create({
|
||||
bottom: -40,
|
||||
right: -30,
|
||||
},
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
justifyContent: "space-between",
|
||||
paddingTop: gutters * 6,
|
||||
paddingBottom: gutters * 2,
|
||||
paddingHorizontal: gutters,
|
||||
gap: 32,
|
||||
},
|
||||
heroContainer: {
|
||||
gap: 12,
|
||||
maxWidth: 560,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 28,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
opacity: 0.88,
|
||||
},
|
||||
ctaGroup: {
|
||||
gap: 12,
|
||||
},
|
||||
secondaryNote: {
|
||||
textAlign: "center",
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
fontSize: 14,
|
||||
opacity: 0.85,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user