Files
musicland/src/screens/cover/SongDownload.js
T
Thomas Demirdjian 72852ad92b last tickets
2025-11-24 15:35:14 +01:00

486 lines
16 KiB
JavaScript

import React, { useCallback, useMemo, useState } from "react";
import { Image as ExpoImage } from "expo-image";
import {
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import * as FileSystem from "expo-file-system";
import * as Sharing from "expo-sharing";
import BorderGradientButton from "../../components/BorderGradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation/Routes";
import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { gutters, Palette, Style } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { background } from "../../assets";
import { getStageAction } from "../../utils/projectStages";
import ClubAdvantagesCard from "../Profile/components/ClubAdvantagesCard";
import useGlobalLoading from "../../hooks/useGlobalLoading";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { isWeb } from "../../hooks/useLayoutType";
import { getArtistDisplayName } from "../../utils/artistName";
import { toDate } from "../../utils/dateFormatting";
import SubscriptionConfirmModal from "../../components/SubscriptionConfirmModal";
const SongDownload = ({ route }) => {
const {
project: routeProject,
selectedOption: routeSelectedOption,
coverOptions: routeCoverOptions,
} = route?.params || {};
const { selectedProject, updateProjectData, hasActiveSubscription } =
useUser();
const { setLoading } = useGlobalLoading();
const [isDownloading, setIsDownloading] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const projectForStage = useMemo(
() => routeProject || selectedProject || null,
[routeProject, selectedProject],
);
const coverOptions = useMemo(() => {
if (Array.isArray(routeCoverOptions) && routeCoverOptions.length) {
return routeCoverOptions.filter(Boolean);
}
if (Array.isArray(projectForStage?.cover?.options)) {
return projectForStage.cover.options.filter(Boolean);
}
return [];
}, [projectForStage?.cover?.options, routeCoverOptions]);
const selectedOption = useMemo(() => {
if (routeSelectedOption) {
return routeSelectedOption;
}
const selectedId =
projectForStage?.cover?.selectedOptionId ||
projectForStage?.cover?.selectedOption?.id ||
null;
if (selectedId && coverOptions.length) {
const match = coverOptions.find((option) => option?.id === selectedId);
if (match) return match;
}
return coverOptions[0] || null;
}, [coverOptions, projectForStage?.cover, routeSelectedOption]);
const coverUrl =
selectedOption?.finalUrl ||
selectedOption?.generatedUrl ||
projectForStage?.coverUrl ||
projectForStage?.cover?.result ||
projectForStage?.cover?.generatedBackground ||
null;
const trackTitle =
typeof projectForStage?.title === "string" && projectForStage.title.trim()
? projectForStage.title.trim()
: "Musicland Track";
const continueFlow = useCallback(async () => {
if (!selectedOption) {
return;
}
try {
await setLoading(true, { message: "Sauvegarde de ta pochette..." });
const existingCover = projectForStage?.cover || {};
const finalUrl =
selectedOption.finalUrl || selectedOption.generatedUrl || coverUrl;
const nextCoverData = {
...existingCover,
options: coverOptions,
selectedOptionId: selectedOption.id,
result: finalUrl,
generatedBackground:
selectedOption.generatedUrl || selectedOption.finalUrl || null,
};
await updateProjectData({
cover: nextCoverData,
coverUrl: finalUrl,
});
const nextProject = {
...projectForStage,
cover: nextCoverData,
coverUrl: finalUrl,
};
const nextPlaybackStage = getStageAction("director", nextProject);
const targetRoute = nextPlaybackStage?.route || Routes.Playback;
const params = nextPlaybackStage?.params || { project: nextProject };
navigate(targetRoute, params);
} catch (error) {
console.log("[SongDownload] continue error", error?.message);
} finally {
await setLoading(false);
}
}, [
coverOptions,
coverUrl,
navigate,
projectForStage,
selectedOption,
setLoading,
updateProjectData,
]);
const handleContinue = useCallback(() => {
if (hasActiveSubscription) {
continueFlow();
return;
}
setShowConfirmModal(true);
}, [continueFlow, hasActiveSubscription]);
const handleDownload = useCallback(async () => {
const downloadUrl =
projectForStage?.songUrl ||
projectForStage?.playbackUrl ||
selectedOption?.finalUrl ||
selectedOption?.generatedUrl ||
null;
if (!downloadUrl || isDownloading) {
return;
}
await setLoading(true, { message: "Préparation du téléchargement..." });
const artist = getArtistDisplayName(projectForStage, "MusicLand");
const createdDate = toDate(projectForStage?.createdAt) || new Date();
const createdLabel = createdDate
? createdDate.toISOString().split("T")[0]
: "";
const triggerWebDownload = async (url, title) => {
setIsDownloading(true);
await setLoading(true, { message: "Préparation du téléchargement..." });
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`download_failed_${response.status}`);
}
const contentType =
response.headers.get("content-type") || "audio/mpeg";
const baseName =
String(trackTitle || "musicland-track")
.replace(/[\\/:*?"<>|]+/g, "-")
.trim() || "musicland-track";
const filename = `${baseName}.mp3`;
const buffer = await response.arrayBuffer();
if (contentType.includes("audio")) {
const toSynchSafe = (size) => {
const out = new Uint8Array(4);
out[0] = (size >> 21) & 0x7f;
out[1] = (size >> 14) & 0x7f;
out[2] = (size >> 7) & 0x7f;
out[3] = size & 0x7f;
return out;
};
const concatBytes = (...arrays) => {
const totalLength = arrays.reduce(
(sum, arr) => sum + arr.length,
0,
);
const result = new Uint8Array(totalLength);
let offset = 0;
arrays.forEach((arr) => {
result.set(arr, offset);
offset += arr.length;
});
return result;
};
const buildTextFrame = (id, value) => {
const encoder = new TextEncoder();
const textBytes = encoder.encode(value || "");
const data = concatBytes(new Uint8Array([0x03]), textBytes);
const header = concatBytes(
new TextEncoder().encode(id),
toSynchSafe(data.length),
new Uint8Array([0x00, 0x00]),
);
return concatBytes(header, data);
};
const buildApicFrame = (imageBytes, mime) => {
if (!imageBytes) return null;
const encoder = new TextEncoder();
const mimeBytes = encoder.encode(mime || "image/jpeg");
const data = concatBytes(
new Uint8Array([0x03]),
mimeBytes,
new Uint8Array([0x00]), // mime terminator
new Uint8Array([0x03]), // front cover
new Uint8Array([0x00]), // empty description
new Uint8Array(imageBytes),
);
const header = concatBytes(
new TextEncoder().encode("APIC"),
toSynchSafe(data.length),
new Uint8Array([0x00, 0x00]),
);
return concatBytes(header, data);
};
const buildId3Tag = (audioBytes, coverBytes, coverMime) => {
const frames = [];
frames.push(buildTextFrame("TIT2", trackTitle));
frames.push(buildTextFrame("TPE1", artist));
frames.push(buildTextFrame("TDRC", createdLabel));
const apic = buildApicFrame(coverBytes, coverMime);
if (apic) frames.push(apic);
const framesData = concatBytes(...frames);
const header = concatBytes(
new TextEncoder().encode("ID3"),
new Uint8Array([0x04, 0x00]), // version 2.4.0
new Uint8Array([0x00]), // flags
toSynchSafe(framesData.length),
);
return concatBytes(header, framesData, audioBytes);
};
const fetchCoverBytes = async () => {
if (!coverUrl) return { bytes: null, mime: null };
try {
const res = await fetch(coverUrl);
const mime = res.headers?.get("content-type") || "image/jpeg";
const bufferImage = await res.arrayBuffer();
return { bytes: new Uint8Array(bufferImage), mime };
} catch {
return { bytes: null, mime: null };
}
};
const { bytes: coverBytes, mime: coverMime } =
await fetchCoverBytes();
const merged = buildId3Tag(
new Uint8Array(buffer),
coverBytes,
coverMime,
);
const blob = new Blob([merged], { type: contentType });
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = blobUrl;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(blobUrl);
} else {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = blobUrl;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(blobUrl);
}
} finally {
setIsDownloading(false);
}
};
if (isWeb) {
await triggerWebDownload(downloadUrl, projectForStage?.title);
await setLoading(false);
return;
}
setIsDownloading(true);
try {
const baseName =
String(trackTitle || "musicland-track")
.replace(/[\\/:*?"<>|]+/g, "-")
.trim() || "musicland-track";
const fileName = `${baseName}.mp3`;
const targetUri = `${FileSystem.cacheDirectory || ""}${fileName}`;
const downloadResult = await FileSystem.downloadAsync(
downloadUrl,
targetUri,
);
if (!downloadResult?.uri) {
return;
}
if (Platform.OS === "android") {
const permissions =
await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (!permissions.granted || !permissions.directoryUri) {
return;
}
const base64 = await FileSystem.readAsStringAsync(downloadResult.uri, {
encoding: FileSystem.EncodingType.Base64,
});
try {
const destUri =
await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
fileName,
"audio/mpeg",
);
await FileSystem.writeAsStringAsync(destUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
} catch (error) {
console.log("[SongDownload] SAF write error", error?.message);
}
} else {
if (await Sharing.isAvailableAsync()) {
try {
await Sharing.shareAsync(downloadResult.uri, {
mimeType: "audio/mpeg",
dialogTitle: "Enregistrer la musique",
});
} catch (error) {
console.log("[SongDownload] share error", error?.message);
}
} else {
try {
await Linking.openURL(downloadResult.uri);
} catch (error) {
console.log(
"[SongDownload] open local file error",
error?.message,
);
}
}
}
} catch (error) {
console.log("[SongDownload] native download open error", error?.message);
} finally {
setIsDownloading(false);
await setLoading(false);
}
}, [coverUrl, isDownloading, projectForStage, selectedOption, trackTitle]);
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={84} />
<ScrollView
contentContainerStyle={[
styles.container,
!isWeb && styles.containerMobile,
styles.scrollContent,
]}
showsVerticalScrollIndicator={false}
>
<CreateLyricsHeader title="Ta pochette est validée" />
<View style={styles.coverRow}>
{coverUrl ? (
<ExpoImage
source={{ uri: coverUrl }}
style={styles.coverImage}
contentFit="cover"
/>
) : (
<View style={[styles.coverImage, styles.coverPlaceholder]}>
<Text style={styles.placeholderText}>Aucune pochette</Text>
</View>
)}
<Pressable style={styles.downloadTile} onPress={handleDownload}>
<MaterialCommunityIcons
name="download"
size={22}
color={Palette.white}
/>
<Text style={styles.downloadText}>Télécharger la musique</Text>
</Pressable>
</View>
<ClubAdvantagesCard style={styles.clubCardSpacing} />
<BorderGradientButton
title={
hasActiveSubscription
? "Continuer"
: "Continuer sans générer de revenus"
}
onPress={handleContinue}
containerStyle={styles.continueButton}
/>
</ScrollView>
<SubscriptionConfirmModal
isVisible={showConfirmModal}
setIsVisible={setShowConfirmModal}
onJoinClub={() => navigate(Routes.Payments)}
onContinue={continueFlow}
/>
</Page>
);
};
const styles = StyleSheet.create({
container: {
flexGrow: 1,
width: "100%",
paddingHorizontal: gutters * 1.2,
paddingBottom: gutters * 1.8,
paddingTop: gutters,
gap: gutters * 1.2,
},
containerMobile: {
paddingHorizontal: 0,
paddingBottom: gutters * 1.2,
paddingTop: gutters * 0.8,
},
scrollContent: {
paddingBottom: gutters * 2.6,
},
coverRow: {
flexDirection: isWeb ? "row" : "column",
alignItems: "center",
justifyContent: "center",
gap: gutters * 0.8,
},
coverImage: {
width: 150,
height: 150,
borderRadius: 16,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.18)",
},
coverPlaceholder: {
...Style.centered,
backgroundColor: "rgba(255, 255, 255, 0.06)",
},
placeholderText: {
fontFamily: FONT_FAMILY.InterMedium,
color: Palette.grayMid,
},
downloadTile: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingVertical: gutters * 0.9,
paddingHorizontal: gutters * 1.2,
borderRadius: 14,
backgroundColor: "#8C4BFF",
borderWidth: 0,
},
downloadText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15,
color: Palette.white,
},
clubCardSpacing: {
marginTop: gutters * 0.5,
},
continueButton: {
marginTop: gutters * 0.5,
},
});
export default SongDownload;