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 ( {coverUrl ? ( ) : ( Aucune pochette )} Télécharger la musique navigate(Routes.Payments)} onContinue={continueFlow} /> ); }; 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;