diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index caeda6a..8ee479c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ MusicLand - cover + contain false \ No newline at end of file diff --git a/bun.lock b/bun.lock index b335c0c..3a2fef2 100644 --- a/bun.lock +++ b/bun.lock @@ -47,6 +47,7 @@ "expo-linking": "~7.0.5", "expo-location": "~18.0.10", "expo-notifications": "~0.29.14", + "expo-sharing": "~13.0.1", "expo-speech": "~13.0.1", "expo-splash-screen": "~0.29.24", "expo-status-bar": "~2.0.1", @@ -1388,6 +1389,8 @@ "expo-notifications": ["expo-notifications@0.29.14", "", { "dependencies": { "@expo/image-utils": "^0.6.5", "@ide/backoff": "^1.0.0", "abort-controller": "^3.0.0", "assert": "^2.0.0", "badgin": "^1.1.5", "expo-application": "~6.0.2", "expo-constants": "~17.0.8" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-AVduNx9mKOgcAqBfrXS1OHC9VAQZrDQLbVbcorMjPDGXW7m0Q5Q+BG6FYM/saVviF2eO8fhQRsTT40yYv5/bhQ=="], + "expo-sharing": ["expo-sharing@13.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw=="], + "expo-speech": ["expo-speech@13.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-J7tvFzORsFpIKihMnayeY5lCPc15giDrlN+ws2uUNo0MvLv1HCYEu/5p3+aMmZXXsY5I1QlconD4CwRWw3JFig=="], "expo-splash-screen": ["expo-splash-screen@0.29.24", "", { "dependencies": { "@expo/prebuild-config": "~8.2.0" }, "peerDependencies": { "expo": "*" } }, "sha512-k2rdjbb3Qeg4g104Sdz6+qXXYba8QgiuZRSxHX8IpsSYiiTU48BmCCGy12sN+O1B+sD1/+WPL4duCa1Fy6+Y4g=="], diff --git a/functions/src/upload.js b/functions/src/upload.js index 2bb322a..0b54d99 100644 --- a/functions/src/upload.js +++ b/functions/src/upload.js @@ -125,9 +125,11 @@ async function markPlaybackCompatibility({ projectId, initiatorUid = null }) { const docRef = db.collection("projects").doc(projectId); await docRef.set( { - "playbackCompatibility.codec": PLAYBACK_CODEC_TAG, - "playbackCompatibility.migratedAt": FieldValue.serverTimestamp(), - "playbackCompatibility.migratedBy": initiatorUid, + playbackCompatibility: { + codec: PLAYBACK_CODEC_TAG, + migratedAt: FieldValue.serverTimestamp(), + migratedBy: initiatorUid, + }, }, { merge: true } ); diff --git a/package.json b/package.json index 3fd65a7..7c3b081 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "expo-linking": "~7.0.5", "expo-location": "~18.0.10", "expo-notifications": "~0.29.14", + "expo-sharing": "~13.0.1", "expo-speech": "~13.0.1", "expo-splash-screen": "~0.29.24", "expo-status-bar": "~2.0.1", diff --git a/src/components/AppActionSheet.js b/src/components/AppActionSheet.js index 9ca1e6c..52b00d0 100644 --- a/src/components/AppActionSheet.js +++ b/src/components/AppActionSheet.js @@ -56,6 +56,8 @@ const AppActionSheet = ({ width: 50, zIndex: 2, }} + closeOnTouchBackdrop + closeOnPressBack onClose={onClose} {...sheetProps} > diff --git a/src/components/SubscriptionConfirmModal.js b/src/components/SubscriptionConfirmModal.js new file mode 100644 index 0000000..cc271e8 --- /dev/null +++ b/src/components/SubscriptionConfirmModal.js @@ -0,0 +1,99 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { gutters, Palette } from "../styles"; +import { FONT_FAMILY } from "../styles/Fonts"; +import BorderGradientButton from "./BorderGradientButton"; +import GradientButton from "./GradientButton"; +import Overlay from "./Overlay"; + +const SubscriptionConfirmModal = ({ + isVisible, + setIsVisible, + onJoinClub, + onContinue, + continueLabel = "Continuer vers la création de playback", +}) => { + const handleJoinClub = () => { + if (typeof setIsVisible === "function") { + setIsVisible(false); + } + if (typeof onJoinClub === "function") { + onJoinClub(); + } + }; + + const handleContinue = () => { + if (typeof setIsVisible === "function") { + setIsVisible(false); + } + if (typeof onContinue === "function") { + onContinue(); + } + }; + + return ( + + + + Continuer sans générer de revenus ? + + + Rejoins le Club Musicland pour monétiser tes écoutes et accéder aux + concours. + + + + + + + + ); +}; + +export default SubscriptionConfirmModal; + +const styles = StyleSheet.create({ + modalCard: { + width: "90%", + maxWidth: 420, + alignSelf: "center", + padding: gutters * 1.4, + borderRadius: 20, + backgroundColor: "rgba(37, 36, 56, 0.96)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.12)", + gap: gutters * 0.8, + }, + modalTitle: { + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 18, + color: Palette.white, + textAlign: "center", + }, + modalDescription: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 14, + lineHeight: 20, + color: Palette.grayMid, + textAlign: "center", + }, + modalActions: { + gap: gutters * 0.6, + marginTop: gutters * 0.6, + }, + modalAction: { + width: "100%", + }, +}); diff --git a/src/components/modal/PlaybackPickerModal.js b/src/components/modal/PlaybackPickerModal.js index 8c45bd1..d782db2 100644 --- a/src/components/modal/PlaybackPickerModal.js +++ b/src/components/modal/PlaybackPickerModal.js @@ -1,4 +1,10 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Image, Pressable, @@ -6,6 +12,7 @@ import { StyleSheet, Text, View, + findNodeHandle, } from "react-native"; import { SheetManager } from "react-native-actions-sheet"; import AppActionSheet from "../AppActionSheet"; @@ -25,6 +32,7 @@ const PlaybackPickerModal = () => { const { userProjects = [], createNewProject } = useUserData(); const [isCreating, setIsCreating] = useState(false); const [webVisible, setWebVisible] = useState(true); + const modalRef = useRef(null); const sanitizedProjects = useMemo(() => { if (!Array.isArray(userProjects)) { @@ -101,13 +109,45 @@ const PlaybackPickerModal = () => { const hasProjects = sanitizedProjects.length > 0; + useEffect(() => { + if (!isWeb || !webVisible || typeof document === "undefined") { + return; + } + + const handlePointerDown = (event) => { + const node = modalRef.current; + if (!node) { + return; + } + + const domNode = findNodeHandle(node); + if (domNode && typeof domNode.contains === "function") { + if (domNode.contains(event.target)) { + return; + } + } + + if (domNode === event.target) { + return; + } + + hideSheet(); + }; + + document.addEventListener("pointerdown", handlePointerDown); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + }; + }, [hideSheet, webVisible]); + if (isWeb && !webVisible) { return null; } return ( - + Ajouter un playback Choisis une musique déjà créée pour y ajouter un playback ou lance un diff --git a/src/components/modal/ValidateModal.js b/src/components/modal/ValidateModal.js index 4b4cac6..ee3139e 100644 --- a/src/components/modal/ValidateModal.js +++ b/src/components/modal/ValidateModal.js @@ -6,7 +6,16 @@ import { FONT_FAMILY } from "../../styles/Fonts"; import BorderGradientButton from "../BorderGradientButton"; import GradientButton from "../GradientButton"; -const ValidateModal = ({ visible, onClose, onPressValidate }) => { +const ValidateModal = ({ + visible, + onClose, + onPressValidate, + onPressSecondary, + title = "Attention !", + description = "Lorsque tu clique sur valider, tu ne pourra plus changer ni le texte ni la mélodie.", + primaryLabel = "Valider", + secondaryLabel = "Retour", +}) => { return ( { textAlign: "center", }} > - Attention ! + {title} { textAlign: "center", }} > - Lorsque tu clique sur valider, tu ne pourra plus changer ni le - texte ni la mélodie. + {description} - - { - onClose(); + onClose?.(); + onPressSecondary?.(); + }} + /> + { + onClose?.(); onPressValidate?.(); }} /> diff --git a/src/components/player/GlobalAudioPlayer.js b/src/components/player/GlobalAudioPlayer.js index c62d4bf..c09b7db 100644 --- a/src/components/player/GlobalAudioPlayer.js +++ b/src/components/player/GlobalAudioPlayer.js @@ -4,7 +4,7 @@ import { Image, Pressable, StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { icons, img } from "../../assets"; -import { arrayRemove, arrayUnion, projectsRef } from "../../config/firebase"; +import { projectsRef } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; import { isWeb } from "../../hooks/useLayoutType"; import usePlayer from "../../hooks/usePlayer"; @@ -13,6 +13,11 @@ import { useUser } from "../../providers/UserDataProvider"; import { gutters, Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { ensureAuthenticated } from "../../utils/authRedirect"; +import { + getProjectLikes, + LIKE_TARGET, + toggleProjectLike, +} from "../../utils/likes"; import { responsiveHeight } from "react-native-responsive-dimensions"; const formatDuration = (ms) => { @@ -60,9 +65,9 @@ const GlobalAudioPlayer = () => { refreshArray: [projectId], }); - const likedByList = Array.isArray(projectData?.likedBy) - ? projectData.likedBy - : []; + const likedByList = useMemo(() => { + return getProjectLikes(projectData, LIKE_TARGET.SONG); + }, [projectData]); const likedFromDoc = useMemo(() => { if (!projectId || !currentUID) return false; @@ -100,12 +105,12 @@ const GlobalAudioPlayer = () => { const next = !effectiveIsLiked; setPendingLike(next); try { - await projectsRef.doc(projectId).set( - { - likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), - }, - { merge: true }, - ); + await toggleProjectLike({ + projectId, + target: LIKE_TARGET.SONG, + currentUID, + next, + }); } catch (_error) { setPendingLike(null); } diff --git a/src/hooks/useUserLikedProjects.js b/src/hooks/useUserLikedProjects.js index 7fe8c5c..7ece43b 100644 --- a/src/hooks/useUserLikedProjects.js +++ b/src/hooks/useUserLikedProjects.js @@ -1,4 +1,5 @@ import { useGlobal } from "reactn"; +import { useMemo } from "react"; import firebase from "../config/firebase"; import { getLikeFieldPath, LIKE_TARGET } from "../utils/likes"; import useDataFromRef from "./useDataFromRef"; @@ -6,17 +7,20 @@ import useDataFromRef from "./useDataFromRef"; export default function useUserLikedProjects() { const [currentUID] = useGlobal("currentUID"); - const { data, loading } = useDataFromRef({ + const likeField = getLikeFieldPath(LIKE_TARGET.SONG); + + const { data: songLikes = [], loading: songLikesLoading } = useDataFromRef({ ref: currentUID ? firebase .firestore() .collection("projects") .where( - getLikeFieldPath(LIKE_TARGET.SONG), + likeField, "array-contains", currentUID ) + .orderBy("updatedAt", "desc") : null, simpleRef: false, listener: true, @@ -24,5 +28,62 @@ export default function useUserLikedProjects() { refreshArray: [currentUID], }); - return { projects: Array.isArray(data) ? data : [], loading }; + const { data: legacyLikes = [], loading: legacyLikesLoading } = + useDataFromRef({ + ref: + currentUID + ? firebase + .firestore() + .collection("projects") + .where( + "likedBy", + "array-contains", + currentUID + ) + : null, + simpleRef: false, + listener: true, + condition: !!currentUID, + refreshArray: [currentUID], + }); + + const projects = useMemo(() => { + const byId = new Map(); + + const pushList = (list) => { + if (!Array.isArray(list)) return; + list.forEach((project) => { + if (project?.id && !byId.has(project.id)) { + byId.set(project.id, project); + } + }); + }; + + pushList(songLikes); + pushList(legacyLikes); + + const withUpdatedAt = Array.from(byId.values()); + const toTimestamp = (value) => { + if (!value) return 0; + if (typeof value.toDate === "function") { + return value.toDate().getTime(); + } + if (typeof value.seconds === "number") { + return value.seconds * 1000; + } + if (typeof value === "number") { + return value; + } + return 0; + }; + + return withUpdatedAt.sort( + (a, b) => toTimestamp(b?.updatedAt) - toTimestamp(a?.updatedAt) + ); + }, [legacyLikes, songLikes]); + + return { + projects, + loading: !!(songLikesLoading || legacyLikesLoading), + }; } diff --git a/src/navigation/MainStack.js b/src/navigation/MainStack.js index cd0f444..c2d8937 100644 --- a/src/navigation/MainStack.js +++ b/src/navigation/MainStack.js @@ -25,7 +25,7 @@ import PublishYoutube from "../screens/Publishing/PublishYoutube"; import PrivacyPolicy from "../screens/PrivacyPolicy"; import Payments from "../screens/Payments"; import DownloadPrices from "../screens/Production/DownloadPrices"; -import DownloadSongs from "../screens/Production/DownloadSongs"; +import PlaybackDownload from "../screens/Production/PlaybackDownload"; import PlaybackExample from "../screens/Production/PlaybackExample"; import Production from "../screens/Production/Production"; import ProductionOnboarding from "../screens/Production/ProductionOnboarding"; @@ -62,6 +62,7 @@ import { ChooseCoverType } from "../screens/cover/ChooseCoverType"; import PhotoCover from "../screens/cover/PhotoCover"; import PouchReady from "../screens/cover/PouchReady"; import ValidateCover from "../screens/cover/ValidateCover"; +import SongDownload from "../screens/cover/SongDownload"; import { Palette } from "../styles"; import { BottomTabScreen } from "./BottomTab"; import { Routes } from "./Routes"; @@ -179,6 +180,10 @@ const baseScreens = [ name: Routes.ValidateCover, component: ValidateCover, }, + { + name: Routes.SongDownload, + component: SongDownload, + }, { name: Routes.ProductionOnboarding, component: ProductionOnboarding, @@ -188,8 +193,8 @@ const baseScreens = [ component: Production, }, { - name: Routes.DownloadSongs, - component: DownloadSongs, + name: Routes.PlaybackDownload, + component: PlaybackDownload, }, { name: Routes.DownloadPrices, diff --git a/src/navigation/Routes.js b/src/navigation/Routes.js index 4d059ec..d0ba0f1 100644 --- a/src/navigation/Routes.js +++ b/src/navigation/Routes.js @@ -41,11 +41,12 @@ export const Routes = { PouchReady: "PouchReady", PhotoCover: "PhotoCover", ValidateCover: "ValidateCover", + SongDownload: "SongDownload", FinishCompose: "FinishCompose", ProductionOnboarding: "ProductionOnboarding", Production: "Production", - DownloadSongs: "DownloadSongs", + PlaybackDownload: "PlaybackDownload", DownloadPrices: "DownloadPrices", SongDownloaded: "SongDownloaded", StreamSong: "StreamSong", diff --git a/src/providers/PlayerProvider.js b/src/providers/PlayerProvider.js index 14f69ba..6d8d703 100644 --- a/src/providers/PlayerProvider.js +++ b/src/providers/PlayerProvider.js @@ -28,11 +28,12 @@ const HIDDEN_ROUTE_NAMES = new Set([ Routes.PouchReady, Routes.PhotoCover, Routes.ValidateCover, + Routes.SongDownload, Routes.FinishCompose, Routes.GeneratingSong, Routes.ProductionOnboarding, Routes.Production, - Routes.DownloadSongs, + Routes.PlaybackDownload, Routes.DownloadPrices, Routes.SongDownloaded, Routes.StreamSong, diff --git a/src/providers/UserDataProvider.js b/src/providers/UserDataProvider.js index 767455c..d324f2a 100644 --- a/src/providers/UserDataProvider.js +++ b/src/providers/UserDataProvider.js @@ -3,7 +3,7 @@ import { useDataFromRef } from "react-native-minuit/src/hooks"; import useMinuit from "react-native-minuit/src/hooks/useMinuit"; import { createContext, useContext, useGlobal } from "reactn"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { checkIfEmailIsValid } from "../actions/signupActions"; import { showPremiumRequiredAlert } from "../components/Alert"; import firebase, { @@ -18,9 +18,15 @@ import { getUserPreferredArtistName } from "../utils/artistName"; import { getLikeFieldPath, LIKE_TARGET } from "../utils/likes"; import { ensureAuthenticated } from "../utils/authRedirect"; import { isWeb } from "../hooks/useLayoutType"; +import useUserLikedProjects from "../hooks/useUserLikedProjects"; -const SONG_LIKES_FIELD = getLikeFieldPath(LIKE_TARGET.SONG); const PLAYBACK_LIKES_FIELD = getLikeFieldPath(LIKE_TARGET.PLAYBACK); +const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ + "trialing", + "active", + "past_due", + "unpaid", +]); export const UserDataContext = createContext(); @@ -61,21 +67,11 @@ export default ({ children }) => { const userPlaybacks = Array.isArray(userProjects) ? userProjects.filter((project) => project.playbackUrl) : []; - // Subscribe to user's liked projects + // Subscribe to user's liked projects (both new and legacy storage) const { - data: userLikedProjects = [], + projects: userLikedProjects = [], loading: userLikedProjectsLoading = true, - } = useDataFromRef({ - ref: currentUID - ? projectsRef - .where(SONG_LIKES_FIELD, "array-contains", currentUID) - .orderBy("updatedAt", "desc") - : null, - simpleRef: false, - listener: true, - condition: !!currentUID, - refreshArray: [currentUID], - }); + } = useUserLikedProjects(); const { data: userLikedPlaybacks = [], loading: userLikedPlaybacksLoading = true, @@ -421,6 +417,49 @@ export default ({ children }) => { simpleRef: true, }); + const hasActiveSubscription = useMemo(() => { + const pickStatus = (value) => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : null; + }; + + const statusCandidates = [ + pickStatus(currentUserDoc?.stripeSubscriptionStatus), + pickStatus(currentUserDoc?.stripeSubscription?.status), + pickStatus(currentUserDoc?.stripeSubscription?.stripeSubscriptionStatus), + pickStatus(currentUserDoc?.stripeSubscription?.metadata?.status), + ].filter(Boolean); + + if ( + statusCandidates.some((status) => + ACTIVE_SUBSCRIPTION_STATUSES.has(status), + ) + ) { + return true; + } + + const premiumUntil = currentUserDoc?.premiumUntil; + if (premiumUntil) { + const asDate = + premiumUntil?.toDate?.() || + (premiumUntil?.seconds ? new Date(premiumUntil.seconds * 1000) : null); + if (asDate && asDate > new Date()) { + return true; + } + } + + if (currentUserDoc?.premium?.active) { + return true; + } + + const hasPremiumLevel = + typeof currentUserDoc?.premiumLevel === "string" && + currentUserDoc.premiumLevel.trim().length > 0; + + return hasPremiumLevel; + }, [currentUserDoc]); + return ( { createNewProject, videos, + hasActiveSubscription, }} > {children} diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index 9e2de02..ec040ce 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -111,45 +111,11 @@ const Home = ({ navigation, route }) => { currentUID, createNewProject, videos, + hasActiveSubscription, } = useUser(); const { setTooltip } = useMinuit(); const [videoUrl, setVideoUrl] = useState(null); - const hasActiveSubscription = useMemo(() => { - if (!currentUserData) { - return false; - } - - const pickStatus = (value) => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed.toLowerCase() : null; - }; - - const statusCandidates = [ - pickStatus(currentUserData?.stripeSubscriptionStatus), - pickStatus(currentUserData?.stripeSubscription?.status), - pickStatus(currentUserData?.stripeSubscription?.stripeSubscriptionStatus), - pickStatus(currentUserData?.stripeSubscription?.metadata?.status), - ].filter(Boolean); - - if ( - statusCandidates.some((status) => - ACTIVE_SUBSCRIPTION_STATUSES.has(status), - ) - ) { - return true; - } - - const hasPremiumLevel = - typeof currentUserData?.premiumLevel === "string" && - currentUserData.premiumLevel.trim().length > 0; - - return hasPremiumLevel; - }, [currentUserData]); - if (!currentUID) { return ; } diff --git a/src/screens/Library/AllMyList.js b/src/screens/Library/AllMyList.js index 172d41d..42e05bc 100644 --- a/src/screens/Library/AllMyList.js +++ b/src/screens/Library/AllMyList.js @@ -11,6 +11,7 @@ import { navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { Palette, Style, gutters } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; +import { getProjectLikes, LIKE_TARGET } from "../../utils/likes"; import MusicCard from "./components/MusicCard"; export default function AllMyList() { @@ -28,6 +29,7 @@ export default function AllMyList() { userLikedProjectsLoading, userPlaybacks, userLikedPlaybacks, + userLikedPlaybacksLoading, } = useUser(); const navigateToMusicDetails = useNavigateToMusicDetails(); @@ -55,9 +57,18 @@ export default function AllMyList() { ]); const sanitizedItems = Array.isArray(items) ? items : []; - const itemsLoading = liked - ? userLikedProjectsLoading - : userProjectsLoading; + const itemsLoading = useMemo(() => { + if (scope === "playback") { + return liked ? userLikedPlaybacksLoading : userProjectsLoading; + } + return liked ? userLikedProjectsLoading : userProjectsLoading; + }, [ + liked, + scope, + userLikedPlaybacksLoading, + userLikedProjectsLoading, + userProjectsLoading, + ]); const emptyStateContent = useMemo(() => { const variant = liked ? "liked" : "default"; @@ -172,25 +183,30 @@ export default function AllMyList() { {loadingMessage} ) : sanitizedItems.length > 0 ? ( - sanitizedItems.map((item) => ( - onPressItem(item)} - onPressMore={(posTop) => { - setSelectedProjectId(item.id); - setMenuPosition(posTop); - setShowMenu( - (prev) => - !prev || posTop?.top !== (menuPosition?.top ?? null) - ); - }} - /> - )) + sanitizedItems.map((item) => { + const likeTarget = + scope === "playback" ? LIKE_TARGET.PLAYBACK : LIKE_TARGET.SONG; + return ( + onPressItem(item)} + onPressMore={(posTop) => { + setSelectedProjectId(item.id); + setMenuPosition(posTop); + setShowMenu( + (prev) => + !prev || posTop?.top !== (menuPosition?.top ?? null) + ); + }} + /> + ); + }) ) : ( diff --git a/src/screens/Library/AllMyPlaylist.web.js b/src/screens/Library/AllMyPlaylist.web.js index 6a9ee2f..3ab4d36 100644 --- a/src/screens/Library/AllMyPlaylist.web.js +++ b/src/screens/Library/AllMyPlaylist.web.js @@ -15,6 +15,7 @@ import { useUser } from "../../providers/UserDataProvider"; import { gutters, Palette, Style } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { size } from "../../styles/Style"; +import { getProjectLikes, LIKE_TARGET } from "../../utils/likes"; import MusicCard from "./components/MusicCard"; const AllMyPlaylist = ({ route }) => { @@ -277,7 +278,8 @@ const AllMyPlaylist = ({ route }) => { subtitle={p?.userName} imageUri={p?.coverUrl || null} projectId={p?.id} - likedBy={p?.likedBy || []} + likedBy={getProjectLikes(p, LIKE_TARGET.SONG)} + likeTarget={LIKE_TARGET.SONG} onPress={() => handlePlayFromPlaylist(p)} onPressMore={(posTop) => { setSelectedProjectId(p.id); diff --git a/src/screens/Library/Library.js b/src/screens/Library/Library.js index 10ed58f..054681c 100644 --- a/src/screens/Library/Library.js +++ b/src/screens/Library/Library.js @@ -86,7 +86,7 @@ const Library = () => { experimentalBlurMethod="dimezisBlurView" style={{ borderRadius: 12, padding: 12, overflow: "hidden" }} > - {hasMyMusic && } + {hasBackTracks && } {hasLikedMusic && } diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index d650055..fba1f9a 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -25,13 +25,7 @@ import { useGlobal } from "reactn"; import { background, icons } from "../../assets"; import PressableScale from "../../components/PressableScale"; import Slider from "../../components/Slider"; -import { - arrayRemove, - arrayUnion, - increment, - projectsRef, - usersRef, -} from "../../config/firebase"; +import { increment, projectsRef, usersRef } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; import usePlayer from "../../hooks/usePlayer"; import useTrackController from "../../hooks/useTrackController"; @@ -45,6 +39,11 @@ import { createMusicSharePayload, openShareSheet, } from "../../utils/shareSheet"; +import { + getProjectLikes, + LIKE_TARGET, + toggleProjectLike, +} from "../../utils/likes"; import { formatStructureLabel, getPromptLabelForStructure, @@ -86,13 +85,10 @@ const MusicDetails = ({ route }) => { }); useEffect(() => { - if (project && currentUID) { - const liked = Array.isArray(project?.likedBy) - ? project.likedBy.includes(currentUID) - : false; - setFav(liked); - } - }, [project?.likedBy, currentUID]); + const likes = getProjectLikes(project, LIKE_TARGET.SONG); + const liked = currentUID ? likes.includes(currentUID) : false; + setFav(liked); + }, [currentUID, project]); const title = project?.title || "Sans titre"; const artist = useMemo(() => { @@ -862,10 +858,11 @@ const MusicDetails = ({ route }) => { const next = !fav; setFav(next); try { - await projectsRef.doc(projectId).update({ - likedBy: next - ? arrayUnion(currentUID) - : arrayRemove(currentUID), + await toggleProjectLike({ + projectId, + target: LIKE_TARGET.SONG, + currentUID, + next, }); } catch (e) { setFav(!next); diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index 5410008..29a2791 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -27,13 +27,7 @@ import { useGlobal } from "reactn"; import { background, icons } from "../../assets"; import PressableScale from "../../components/PressableScale"; import Slider from "../../components/Slider"; -import { - arrayRemove, - arrayUnion, - increment, - projectsRef, - usersRef, -} from "../../config/firebase"; +import { increment, projectsRef, usersRef } from "../../config/firebase"; import { ensureAuthenticated } from "../../utils/authRedirect"; import useDataFromRef from "../../hooks/useDataFromRef"; import useTrackController from "../../hooks/useTrackController"; @@ -50,6 +44,11 @@ import { normalizeStructureType, segmentRequiresLyrics, } from "../../utils/songStructure"; +import { + getProjectLikes, + LIKE_TARGET, + toggleProjectLike, +} from "../../utils/likes"; import { createMusicSharePayload, openShareSheet, @@ -117,13 +116,10 @@ const MusicDetails = ({ route }) => { }); useEffect(() => { - if (project && currentUID) { - const liked = Array.isArray(project?.likedBy) - ? project.likedBy.includes(currentUID) - : false; - setFav(liked); - } - }, [project?.likedBy, currentUID]); + const likes = getProjectLikes(project, LIKE_TARGET.SONG); + const liked = currentUID ? likes.includes(currentUID) : false; + setFav(liked); + }, [currentUID, project]); const title = project?.title || "Sans titre"; const artist = useMemo(() => { @@ -1069,21 +1065,22 @@ const MusicDetails = ({ route }) => { { - if (!projectId) return; - if (!ensureAuthenticated(currentUID)) return; - const next = !fav; - setFav(next); - try { - await projectsRef.doc(projectId).update({ - likedBy: next - ? arrayUnion(currentUID) - : arrayRemove(currentUID), - }); - } catch (e) { - setFav(!next); - } - }} - > + if (!projectId) return; + if (!ensureAuthenticated(currentUID)) return; + const next = !fav; + setFav(next); + try { + await toggleProjectLike({ + projectId, + target: LIKE_TARGET.SONG, + currentUID, + next, + }); + } catch (e) { + setFav(!next); + } + }} + > { @@ -156,7 +157,8 @@ const PlaylistDetails = () => { subtitle={p?.userName} imageUri={p?.coverUrl || null} projectId={p?.id} - likedBy={p?.likedBy || []} + likedBy={getProjectLikes(p, LIKE_TARGET.SONG)} + likeTarget={LIKE_TARGET.SONG} onPress={() => handlePlayFromPlaylist(p)} onPressMore={(posTop) => { setSelectedProjectId(p.id); diff --git a/src/screens/Library/components/MyMusic.js b/src/screens/Library/components/MyMusic.js index b9fda17..a9b4250 100644 --- a/src/screens/Library/components/MyMusic.js +++ b/src/screens/Library/components/MyMusic.js @@ -1,13 +1,17 @@ import React, { useState } from "react"; -import { View } from "react-native"; +import { Text, View } from "react-native"; import { SheetManager } from "react-native-actions-sheet"; import { useGlobal } from "reactn"; +import GradientButton from "../../../components/GradientButton"; import MoreMenu from "../../../components/MoreMenu"; import { projectsRef } from "../../../config/firebase"; import useNavigateToMusicDetails from "../../../hooks/useNavigateToMusicDetails"; import { Routes } from "../../../navigation"; import { navigate } from "../../../navigation/NavigationService"; import { useUser } from "../../../providers/UserDataProvider"; +import { Palette } from "../../../styles"; +import { FONT_FAMILY } from "../../../styles/Fonts"; +import { getProjectLikes, LIKE_TARGET } from "../../../utils/likes"; import CardContainer from "./CardContainer"; import MusicCard from "./MusicCard"; const MyMusic = () => { @@ -16,6 +20,7 @@ const MyMusic = () => { const [showMenu, setShowMenu] = useState(false); const [selectedProjectId, setSelectedProjectId] = useState(null); const projects = Array.isArray(userProjects) ? userProjects.slice(0, 3) : []; + const hasProjects = projects.length > 0; const [, setTooltip] = useGlobal("_tooltip"); const navigateToMusicDetails = useNavigateToMusicDetails(); @@ -32,69 +37,91 @@ const MyMusic = () => { } onPressPlus={() => navigate(Routes.WritingLyrics)} > - - {Array.isArray(projects) && - projects.map((p) => ( - - navigateToMusicDetails({ - projectId: p.id, - songUrl: p?.songUrl, - project: p, - }) - } - onPressMore={(posTop) => { - setSelectedProjectId(p.id); - setMenuPosition(posTop); - setShowMenu( - (prev) => !prev || posTop?.top !== (menuPosition?.top ?? null) - ); - }} - /> - ))} - setShowMenu(false)} - inPlaylist={false} - projectId={selectedProjectId} - extraItems={[ - { - label: "Supprimer", - onPress: () => - SheetManager.show("Delete", { - payload: { - title: "Supprimer le projet", - message: - "Cette action supprimera définitivement ce projet.", - onConfirm: async () => { - try { - if (!selectedProjectId) return; - await projectsRef.doc(selectedProjectId).delete(); - setTooltip({ - type: "success", - text: "Projet supprimé", - }); - } catch (e) { - setTooltip({ - type: "error", - text: e?.message || "Suppression impossible", - }); - } + {hasProjects ? ( + + {Array.isArray(projects) && + projects.map((p) => ( + + navigateToMusicDetails({ + projectId: p.id, + songUrl: p?.songUrl, + project: p, + }) + } + onPressMore={(posTop) => { + setSelectedProjectId(p.id); + setMenuPosition(posTop); + setShowMenu( + (prev) => + !prev || posTop?.top !== (menuPosition?.top ?? null) + ); + }} + /> + ))} + setShowMenu(false)} + inPlaylist={false} + projectId={selectedProjectId} + extraItems={[ + { + label: "Supprimer", + onPress: () => + SheetManager.show("Delete", { + payload: { + title: "Supprimer le projet", + message: + "Cette action supprimera définitivement ce projet.", + onConfirm: async () => { + try { + if (!selectedProjectId) return; + await projectsRef.doc(selectedProjectId).delete(); + setTooltip({ + type: "success", + text: "Projet supprimé", + }); + } catch (e) { + setTooltip({ + type: "error", + text: e?.message || "Suppression impossible", + }); + } + }, }, - }, - }), - }, - ]} - /> - + }), + }, + ]} + /> + + ) : ( + + + Aucune musique pour l'instant + + navigate(Routes.WritingLyrics)} + title=" Créer une musique" + /> + + )} ); }; diff --git a/src/screens/Library/components/MyPlaylist.js b/src/screens/Library/components/MyPlaylist.js index 45cddf6..cca5d40 100644 --- a/src/screens/Library/components/MyPlaylist.js +++ b/src/screens/Library/components/MyPlaylist.js @@ -3,6 +3,7 @@ import React from "react"; import { Image, Platform, Pressable, Text, View } from "react-native"; import { SheetManager } from "react-native-actions-sheet"; import { icons } from "../../../assets"; +import GradientButton from "../../../components/GradientButton"; import useLayoutType from "../../../hooks/useLayoutType"; import { Routes } from "../../../navigation"; import { navigate } from "../../../navigation/NavigationService"; @@ -17,6 +18,7 @@ const MyPlaylist = () => { ? userPlaylists.slice(0, 2) : []; const { isWeb } = useLayoutType(); + const hasPlaylists = playlists.length > 0; return ( { } > - {playlists.map((item) => ( - { - console.log("isWeb", isWeb); - if (isWeb) { - navigate(Routes.AllMyPlaylist, { playListId: item.id }); - } else { - navigate(Routes.PlaylistDetails, { playlistId: item.id }); - } - }} - > - ( + { + console.log("isWeb", isWeb); + if (isWeb) { + navigate(Routes.AllMyPlaylist, { playListId: item.id }); + } else { + navigate(Routes.PlaylistDetails, { playlistId: item.id }); + } }} - // experimentalBlurMethod={ - // Platform.OS !== "ios" ? "dimezisBlurView" : "none" - // } > - - {item?.name || "Sans nom"} - - - - - ))} + + {item?.name || "Sans nom"} + + + + + )) + ) : ( + + + Aucune playlist pour l'instant + + + SheetManager.show("Playlist", { + payload: { startAtCreate: true }, + }) + } + title=" Créer une playlist" + /> + + )} ); diff --git a/src/screens/Library/components/SearchResultsList.js b/src/screens/Library/components/SearchResultsList.js index 8d3a14a..383a459 100644 --- a/src/screens/Library/components/SearchResultsList.js +++ b/src/screens/Library/components/SearchResultsList.js @@ -8,6 +8,7 @@ import { navigate } from "../../../navigation/NavigationService"; import { Palette, Style } from "../../../styles"; import { FONT_FAMILY } from "../../../styles/Fonts"; import { getArtistDisplayName } from "../../../utils/artistName"; +import { getProjectLikes, LIKE_TARGET } from "../../../utils/likes"; import CreateLyricsHeader from "../../Writing/components/CreateLyricsHeader"; import MusicCard from "./MusicCard"; import { defaultAvatar } from "../../../data/data"; @@ -157,7 +158,8 @@ const SearchResultsList = ({ subtitle={project?.userName} imageUri={resolveCoverUri(project)} projectId={project?.id} - likedBy={project?.likedBy || []} + likedBy={getProjectLikes(project, LIKE_TARGET.SONG)} + likeTarget={LIKE_TARGET.SONG} onPress={() => handleMusicPress(project)} onPressMore={(positionTop) => { setSelectedProjectId(project.id); @@ -203,7 +205,8 @@ const SearchResultsList = ({ preferThumbnail: true, })} projectId={project?.id} - likedBy={project?.likedBy || []} + likedBy={getProjectLikes(project, LIKE_TARGET.PLAYBACK)} + likeTarget={LIKE_TARGET.PLAYBACK} onPress={() => handlePlaybackPress(project)} onPressMore={(positionTop) => { setSelectedProjectId(project.id); diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index 89840e0..fb46b97 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -226,7 +226,7 @@ const RecordedPlayback = ({ route }) => { { - navigate(Routes.DownloadSongs, { + navigate(Routes.PlaybackDownload, { action: "playback", uri: videoUri, project, diff --git a/src/screens/Playback/RecordedPlayback.web.js b/src/screens/Playback/RecordedPlayback.web.js index 7b67bc9..17cfd96 100644 --- a/src/screens/Playback/RecordedPlayback.web.js +++ b/src/screens/Playback/RecordedPlayback.web.js @@ -355,7 +355,7 @@ const RecordedPlayback = ({ route }) => { title="Je valide" onPress={() => { shouldPreserveBlobRef.current = true; - navigate(Routes.DownloadSongs, { + navigate(Routes.PlaybackDownload, { action: "playback", uri: videoUri || null, // peut être null sur web project, diff --git a/src/screens/Playback/VideoFinalize.js b/src/screens/Playback/VideoFinalize.js index 44c99de..994d4f9 100644 --- a/src/screens/Playback/VideoFinalize.js +++ b/src/screens/Playback/VideoFinalize.js @@ -40,7 +40,7 @@ const VideoFinalize = () => { - navigate(Routes.DownloadSongs, { + navigate(Routes.PlaybackDownload, { action: "playback", }) } diff --git a/src/screens/Production/DownloadSongs.js b/src/screens/Production/PlaybackDownload.js similarity index 58% rename from src/screens/Production/DownloadSongs.js rename to src/screens/Production/PlaybackDownload.js index 697e057..97298a5 100644 --- a/src/screens/Production/DownloadSongs.js +++ b/src/screens/Production/PlaybackDownload.js @@ -1,17 +1,18 @@ -import { BlurView } from "expo-blur"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { Image, Platform, Pressable, + ScrollView, StyleSheet, Text, View, } from "react-native"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; -import { responsiveHeight } from "react-native-responsive-dimensions"; import { background } from "../../assets"; import FullscreenIntroVideo from "../../components/FullscreenIntroVideo"; +import BorderGradientButton from "../../components/BorderGradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; import firebase, { projectsRef, @@ -23,12 +24,16 @@ import useDataFromRef from "../../hooks/useDataFromRef"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; -import { useUserData } from "../../providers/UserDataProvider"; +import { useUserData, useUser } from "../../providers/UserDataProvider"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import { size } from "../../styles/Style"; -import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import { getBlobForUrl, releaseBlobUrl } from "../../utils/blobUrlCache"; +import ClubAdvantagesCard from "../Profile/components/ClubAdvantagesCard"; +import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; +import { Image as ExpoImage } from "expo-image"; +import SubscriptionConfirmModal from "../../components/SubscriptionConfirmModal"; +import { isWeb } from "../../hooks/useLayoutType"; const triggerWebDownload = (url, title) => { if (Platform.OS !== "web") return; @@ -46,7 +51,7 @@ const triggerWebDownload = (url, title) => { anchor.click(); document.body.removeChild(anchor); } catch (error) { - console.log("[DownloadSongs] web download fallback", { + console.log("[PlaybackDownload] web download fallback", { message: error?.message, }); try { @@ -64,7 +69,7 @@ const guessExtension = (inputUri = "") => { }; const uploadSourceRecording = async ({ uri, uid, projectId }) => { - console.log("[DownloadSongs] uploadSourceRecording params", { + console.log("[PlaybackDownload] uploadSourceRecording params", { hasUri: Boolean(uri), uid, projectId, @@ -76,13 +81,13 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => { const extension = guessExtension(uri); const sourcePath = `users/${uid}/projects/${projectId}/recordings/source-${Date.now()}.${extension}`; console.log("source path : ", sourcePath); - console.log("[DownloadSongs] uploadSourceRecording source path", { + console.log("[PlaybackDownload] uploadSourceRecording source path", { extension, sourcePath, }); const cachedBlob = getBlobForUrl(uri); - console.log("[DownloadSongs] uploadSourceRecording blob cache", { + console.log("[PlaybackDownload] uploadSourceRecording blob cache", { hasBlob: Boolean(cachedBlob), }); @@ -97,10 +102,11 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => { return { sourcePath, videoUrl }; }; -const DownloadSongs = ({ route }) => { +const PlaybackDownload = ({ route }) => { const { currentUID } = useUserData(); + const { hasActiveSubscription } = useUser() || {}; const { action, uri, project } = route.params || {}; - console.log("[DownloadSongs] route params", { + console.log("[PlaybackDownload] route params", { action, projectId: project?.id, hasUri: Boolean(uri), @@ -109,6 +115,7 @@ const DownloadSongs = ({ route }) => { const { setIsLoading, setTooltip } = useMinuit(); const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false); + const [showConfirmModal, setShowConfirmModal] = useState(false); const hasShownAfterPlaybackRef = useRef(false); const { data: video } = useDataFromRef({ @@ -138,7 +145,7 @@ const DownloadSongs = ({ route }) => { const handleDownloadUri = async () => { if (action === "playback" && project?.id) { // Publication du playback - console.log("[DownloadSongs] handleDownloadUri playback", { + console.log("[PlaybackDownload] handleDownloadUri playback", { projectId: project.id, uri, currentUID, @@ -173,13 +180,16 @@ const DownloadSongs = ({ route }) => { }; console.log( - "[DownloadSongs] calling upload-mergeVideoAndAudio", - payload + "[PlaybackDownload] calling upload-mergeVideoAndAudio", + payload, ); const { data: result } = await callable(payload); - console.log("[DownloadSongs] upload-mergeVideoAndAudio result", result); + console.log( + "[PlaybackDownload] upload-mergeVideoAndAudio result", + result, + ); const resultURI = result?.url || null; @@ -189,7 +199,7 @@ const DownloadSongs = ({ route }) => { playbackUrl: resultURI, updatedAt: serverTimestamp(), }, - { merge: true } + { merge: true }, ); setTooltip({ type: "success", @@ -200,7 +210,7 @@ const DownloadSongs = ({ route }) => { try { await firebase.storage().ref(tempSourcePath).delete(); } catch (cleanupError) { - console.log("[DownloadSongs] unable to delete temp source", { + console.log("[PlaybackDownload] unable to delete temp source", { message: cleanupError?.message, code: cleanupError?.code, }); @@ -213,7 +223,7 @@ const DownloadSongs = ({ route }) => { }); } } catch (error) { - console.log("[DownloadSongs] error upload playback", { + console.log("[PlaybackDownload] error upload playback", { message: error?.message, code: error?.code, name: error?.name, @@ -227,7 +237,7 @@ const DownloadSongs = ({ route }) => { setTooltip({ type: "error", text: String( - error?.message || "Erreur lors de la publication du playback" + error?.message || "Erreur lors de la publication du playback", ), }); } finally { @@ -239,6 +249,9 @@ const DownloadSongs = ({ route }) => { // Handle song download } }; + const continueLabel = hasActiveSubscription + ? "Publier" + : "Publier sans générer de revenus"; return ( <> { headerType="NONE" > - - - - - - Prêt à télécharger - {action === "playback" ? " ton Playback" : " ta chanson"} ? - - - - - - Télécharger{" "} - {action === "playback" ? "mon Playback" : "ma chanson"} - - Pour moi uniquement - - - - navigate(Routes.StreamSong, { - action, - }) - } - > - - - Diffuser{" "} - {action === "playback" ? "mon Playback" : "ma chanson"}{" "} - sur la plateforme de MusicLand (+ réseaux sociaux) et - participer au concours - - - Top 3: 1er 15% du CA ML - 2ème 10% du CA ML - 3ème 5% du - CA ML (catégorie{" "} - {action === "playback" ? "Playback" : "chanson"}) - - - + + + + + {project?.coverUrl ? ( + + ) : ( + + Aucune pochette - - - + )} + + + + Télécharger le playback + + + + + + { + if (hasActiveSubscription) { + navigate(Routes.SongRelease, { action }); + } else { + setShowConfirmModal(true); + } + }} + containerStyle={styles.continueButton} + /> + - {action === "playback" && ( - setIsAfterPlaybackVideoVisible(false)} - /> - )} + navigate(Routes.Payments)} + onContinue={() => navigate(Routes.SongRelease, { action })} + /> ); }; -export default DownloadSongs; +export default PlaybackDownload; const styles = StyleSheet.create({ - title: { - fontSize: 22, - color: Palette.white, + container: { + flexGrow: 1, + paddingHorizontal: 16, + paddingVertical: 12, + gap: 14, + }, + coverRow: { + flexDirection: isWeb ? "row" : "column", + alignItems: "center", + justifyContent: isWeb ? "center" : "center", + gap: 12, + }, + coverImage: { + ...size({ size: 140 }), + borderRadius: 14, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.18)", + }, + downloadTile: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 14, + backgroundColor: "#8C4BFF", + }, + downloadText: { fontFamily: FONT_FAMILY.InterSemiBold, - }, - label: { - fontSize: 16, + fontSize: 15, color: Palette.white, - fontFamily: FONT_FAMILY.HelveticaNeueBold, }, - description: { - fontSize: 12, - color: Palette.white, - fontFamily: FONT_FAMILY.InterRegular, + clubCardSpacing: { + marginTop: 10, }, - blurView: { - paddingHorizontal: 10, - paddingBottom: 8, - paddingTop: 10, - }, - itemContainer: { - borderRadius: 12, - overflow: "hidden", - backgroundColor: "#FFFFFF03", - shadowColor: "#0000001A", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - - elevation: 5, + continueButton: { + marginTop: 10, }, }); diff --git a/src/screens/Production/Production.js b/src/screens/Production/Production.js index 879708f..20b75b1 100644 --- a/src/screens/Production/Production.js +++ b/src/screens/Production/Production.js @@ -23,7 +23,7 @@ const Production = () => { > navigate(Routes.DownloadSongs)} + onPress={() => navigate(Routes.PlaybackDownload)} /> diff --git a/src/screens/Production/StreamSong.js b/src/screens/Production/StreamSong.js index 1851e74..9f32b94 100644 --- a/src/screens/Production/StreamSong.js +++ b/src/screens/Production/StreamSong.js @@ -1,5 +1,5 @@ import { View, Text, Image } from "react-native"; -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useEffect } from "react"; import Page from "../../layouts/Page"; import MusicLandHeader from "../../components/MusicLandHeader"; import GradientButton from "../../components/GradientButton"; @@ -14,80 +14,25 @@ import { Routes } from "../../navigation"; import { useRoute } from "@react-navigation/native"; import { useUser } from "../../providers/UserDataProvider"; -const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ - "trialing", - "active", - "past_due", - "unpaid", -]); - -const pickStatus = (value) => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed.toLowerCase() : null; -}; - -const hasActiveSubscription = (userData) => { - if (!userData) { - return false; - } - - const statusCandidates = [ - pickStatus(userData?.stripeSubscriptionStatus), - pickStatus(userData?.stripeSubscription?.status), - pickStatus(userData?.stripeSubscription?.stripeSubscriptionStatus), - pickStatus(userData?.stripeSubscription?.metadata?.status), - ].filter(Boolean); - - if ( - statusCandidates.some((status) => - ACTIVE_SUBSCRIPTION_STATUSES.has(status), - ) - ) { - return true; - } - - const premiumUntil = userData?.premiumUntil; - if (premiumUntil) { - const asDate = - premiumUntil?.toDate?.() || - (premiumUntil?.seconds ? new Date(premiumUntil.seconds * 1000) : null); - - if (asDate && asDate > new Date()) { - return true; - } - } - - if (userData?.premium?.active) { - return true; - } - - return false; -}; - const StreamSong = () => { const params = useRoute().params; const action = params?.action; - const { currentUserData = null } = useUser() || {}; - const isPlaybackFlow = action === "playback"; - const userHasActiveSubscription = useMemo( - () => hasActiveSubscription(currentUserData), - [currentUserData], - ); - const headingText = isPlaybackFlow - ? "Publier ton playback" - : "Publier ta chanson"; + const project = params?.project || null; + const { hasActiveSubscription } = useUser() || {}; + const userHasActiveSubscription = hasActiveSubscription; + + useEffect(() => { + if (action && action !== "playback") { + navigate(Routes.SongDownload, { project }); + } + }, [action, project]); + + const headingText = "Publier ton playback"; const descriptionText = userHasActiveSubscription - ? `Ton abonnement est actif, tu peux publier ${ - isPlaybackFlow ? "ce playback" : "cette chanson" - } sur MusicLand et participer au concours.` - : `Pour publier ${isPlaybackFlow ? "ton playback" : "ta chanson"} sur la plateforme, tu dois rejoindre le Club MusicLand.`; + ? "Ton abonnement est actif, publie ton playback et participe au concours." + : "Pour publier ton playback sur la plateforme, rejoins le Club MusicLand."; const ctaLabel = userHasActiveSubscription - ? isPlaybackFlow - ? "Publier mon playback" - : "Publier ma chanson" + ? "Publier mon playback" : "Rejoindre le Club MusicLand"; const handlePrimaryAction = useCallback(() => { if (userHasActiveSubscription) { diff --git a/src/screens/Profile/ManageSubscription.js b/src/screens/Profile/ManageSubscription.js index b566454..8ae603f 100644 --- a/src/screens/Profile/ManageSubscription.js +++ b/src/screens/Profile/ManageSubscription.js @@ -1,7 +1,6 @@ import { useFocusEffect } from "@react-navigation/native"; import React, { useCallback, useMemo, useState } from "react"; import { Alert, Platform, StyleSheet, Text, View } from "react-native"; - import BorderGradientButton from "../../components/BorderGradientButton"; import CreditAmount from "../../components/CreditAmount"; import { background } from "../../assets"; @@ -14,6 +13,7 @@ import { FONT_FAMILY } from "../../styles/Fonts"; import { formatDate, toDate } from "../../utils/dateFormatting"; import { Image as ExpoImage } from "expo-image"; import { isWeb } from "../../hooks/useLayoutType"; +import ClubAdvantagesCard from "./components/ClubAdvantagesCard"; const FUNCTIONS_REGION = "europe-west1"; @@ -46,7 +46,7 @@ const PERIOD_LABELS = { annual: "Annuel", }; -const PAGE_BACKGROUND_COLOR = "#303438"; +const PAGE_BACKGROUND_COLOR = "#252438"; const getStatusColors = (status) => { switch (status) { @@ -263,7 +263,9 @@ const ManageSubscription = ({ navigation }) => { const statusSource = pickString(currentUserData?.stripeSubscriptionStatus) || pickString(pickFromSources((source) => source?.status)) || - pickString(pickFromSources((source) => source?.stripeSubscriptionStatus)) || + pickString( + pickFromSources((source) => source?.stripeSubscriptionStatus), + ) || pickString(pickFromSources((source) => source?.metadata?.status)) || null; @@ -291,7 +293,9 @@ const ManageSubscription = ({ navigation }) => { const candidates = [ pickString(pickFromSources((source) => source?.billingPeriod)), pickString(currentUserData?.premiumBillingPeriod), - pickString(pickFromSources((source) => source?.metadata?.billingPeriod)), + pickString( + pickFromSources((source) => source?.metadata?.billingPeriod), + ), pickString( pickFromSources( (source) => source?.metadata?.subscriptionBillingPeriod, @@ -716,6 +720,7 @@ const ManageSubscription = ({ navigation }) => { /> )} + ); @@ -808,6 +813,9 @@ const styles = StyleSheet.create({ emptyButton: { marginTop: gutters * 1.5, }, + clubCardSpacing: { + marginTop: gutters, + }, }); export default ManageSubscription; diff --git a/src/screens/Profile/components/ClubAdvantagesCard.js b/src/screens/Profile/components/ClubAdvantagesCard.js new file mode 100644 index 0000000..1bba2aa --- /dev/null +++ b/src/screens/Profile/components/ClubAdvantagesCard.js @@ -0,0 +1,344 @@ +import React from "react"; +import { StyleSheet, Switch, Text, View } from "react-native"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { Image as ExpoImage } from "expo-image"; +import { useNavigation } from "@react-navigation/native"; + +import ClubCard from "../../Home/components/ClubCard"; +import { gutters, Palette } from "../../../styles"; +import { FONT_FAMILY } from "../../../styles/Fonts"; +import { Routes } from "../../../navigation/Routes"; +import { useUserData } from "../../../providers/UserDataProvider"; +import { icons } from "../../../assets"; +import { isWeb } from "../../../hooks/useLayoutType"; + +const ICON_BASE_ACCENT = "#A96BFF"; + +const CLUB_ADVANTAGES = [ + { + title: "Revenus partagés", + description: "Gagne sur tes écoutes et ta popularité.", + iconType: "vector", + iconName: "currency-usd", + accent: "#F8C24D", + }, + { + title: "Concours hit du mois", + description: "Vise le podium du mois et empoche la récompense.", + iconType: "vector", + iconName: "chart-line", + accent: "#7AE0FF", + }, + { + title: "Crédits mensuels", + description: "Des crédits premium tous les mois pour créer plus.", + iconType: "image", + icon: icons.coin, + accent: ICON_BASE_ACCENT, + }, +]; + +const ClubAdvantagesCard = ({ style, isDev = false }) => { + const navigation = useNavigation(); + const { hasActiveSubscription } = useUserData() || {}; + const [useWebLayout, setUseWebLayout] = React.useState(isWeb); + const advantages = CLUB_ADVANTAGES; + + React.useEffect(() => { + if (!isDev) { + setUseWebLayout(isWeb); + } + }, [isDev, isWeb]); + + const title = hasActiveSubscription + ? "Tu profites déjà du Club Musicland" + : "Rejoins le Club Musicland"; + const subtitle = hasActiveSubscription + ? "Grâce à ton abonnement, tu bénéficies de tous ces avantages." + : "Découvre ce que tu gagnes en passant au club premium."; + + const handleOpenPlans = () => { + navigation.navigate(Routes.Payments); + }; + + return ( + + {isDev ? ( + + + Test layout web + + + + ) : null} + + + + {title} + {subtitle} + + + + + {advantages.map((advantage) => { + const accent = advantage.accent || Palette.white; + const iconType = advantage.iconType || "image"; + + return ( + + {hasActiveSubscription ? ( + + ) : null} + + {iconType === "vector" ? ( + + ) : ( + + )} + + + + {advantage.title} + + + {advantage.description} + + + + ); + })} + + + + + ); +}; + +const styles = StyleSheet.create({ + card: { + paddingHorizontal: gutters * 1.25, + paddingVertical: gutters, + borderRadius: 24, + backgroundColor: "#252438", + borderWidth: 1, + gap: gutters, + borderColor: "white", + }, + cardInactive: { + borderColor: "rgba(255, 255, 255, 0.08)", + }, + cardActive: { + borderColor: Palette.green, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: gutters, + }, + testSwitches: { + gap: gutters * 0.75, + backgroundColor: "rgba(255, 255, 255, 0.08)", + padding: gutters * 0.9, + borderRadius: 14, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.12)", + }, + switchRow: { + flexDirection: "row", + alignItems: "center", + gap: gutters * 0.5, + justifyContent: "space-between", + }, + switchLabel: { + fontFamily: FONT_FAMILY.InterMedium, + fontSize: 11, + color: Palette.grayMid, + textTransform: "uppercase", + letterSpacing: 0.6, + }, + titleBlock: { + flex: 1, + gap: gutters * 0.3, + alignItems: "center", + }, + title: { + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 18, + color: Palette.white, + textAlign: "center", + }, + subtitle: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 13, + color: "rgba(255, 255, 255, 0.82)", + lineHeight: 18, + textAlign: "center", + }, + advantagesList: { + gap: gutters * 0.75, + }, + advantagesListWeb: { + flexDirection: "row", + flexWrap: "wrap", + justifyContent: "space-between", + }, + advantagesListMobile: { + flexDirection: "column", + }, + advantageCard: { + flexDirection: "row", + alignItems: "flex-start", + gap: gutters * 0.65, + backgroundColor: "rgba(255, 255, 255, 0.08)", + borderRadius: 16, + borderWidth: 0, + padding: gutters, + shadowColor: "#000", + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.2, + shadowRadius: 10, + elevation: 2, + }, + advantageCardWeb: { + flexBasis: "31%", + maxWidth: "32%", + minWidth: 200, + flexGrow: 1, + flexShrink: 1, + flexDirection: "column", + alignItems: "center", + justifyContent: "flex-start", + gap: gutters * 0.5, + position: "relative", + }, + advantageCardMobile: { + width: "100%", + position: "relative", + }, + advantageIcon: { + width: 42, + height: 42, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + }, + advantageIconWeb: { + alignSelf: "center", + marginBottom: gutters * 0.15, + }, + advantageIconImage: { + width: 24, + height: 24, + }, + advantageContent: { + flex: 1, + gap: gutters * 0.2, + }, + advantageContentWeb: { + alignItems: "center", + gap: gutters * 0.4, + }, + advantageTitle: { + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 15, + color: Palette.white, + }, + advantageText: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 13, + lineHeight: 18, + color: "rgba(255, 255, 255, 0.85)", + }, + advantageTextCenter: { + textAlign: "center", + }, + advantageCheck: { + position: "absolute", + }, + advantageCheckWeb: { + top: 10, + right: 10, + }, + advantageCheckMobile: { + top: "50%", + right: 12, + transform: [{ translateY: -10 }], + }, +}); + +export default ClubAdvantagesCard; diff --git a/src/screens/Register.js b/src/screens/Register.js index b802ae1..cb45b06 100644 --- a/src/screens/Register.js +++ b/src/screens/Register.js @@ -126,6 +126,15 @@ const Register = () => { d’utilisation et notre politique de confidentialité. + + Tu as déjà un compte ?{" "} + navigate(Routes.Login)} + > + Se connecter + + ); diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js index db218c6..5f4b677 100644 --- a/src/screens/Studio/ComposeSong.web.js +++ b/src/screens/Studio/ComposeSong.web.js @@ -6,7 +6,13 @@ import React, { useState, } from "react"; import { useRoute } from "@react-navigation/native"; -import { Dimensions, FlatList, Modal, Text, View } from "react-native"; +import { + FlatList, + Modal, + Text, + View, + useWindowDimensions, +} from "react-native"; import { background } from "../../assets"; import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; @@ -28,20 +34,35 @@ import ChooseRhythm from "./ChooseRhythm"; import CustomizeVoice from "./CustomizeVoice"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; -const { width: windowWidth } = Dimensions.get("window"); const MUSIC_GENERATION_COIN_COST = 8; const CONFIRM_MODAL_MAX_WIDTH = 540; const FUNCTIONS_REGION = "europe-west1"; const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"]; const FIRST_VOICE_STEP_INDEX = 1; const OPTIONAL_VOICE_CATEGORIES = new Set(["SENSIBILITÉ", "TECHNIQUE"]); +const PAGE_WIDTH_RATIO_WEB = 0.6; // keep in sync with Page default width on web +const PAGE_MAX_WIDTH = 1200; +const PAGE_HORIZONTAL_PADDING = gutters * 2; const ComposeSong = () => { const scrollRef = useRef(null); + const { width: windowWidth } = useWindowDimensions(); const [selectedIndex, setSelectedIndex] = useState(0); const [progress, setProgress] = useState(18); const [parentLayout, setParentLayout] = useState(null); - const containerWidth = parentLayout?.width || windowWidth || 1; + const estimatedContainerWidth = useMemo(() => { + const safeWindowWidth = + typeof windowWidth === "number" && Number.isFinite(windowWidth) + ? windowWidth + : 0; + const estimatedPageWidth = Math.min( + safeWindowWidth * PAGE_WIDTH_RATIO_WEB, + PAGE_MAX_WIDTH + ); + const paddedWidth = estimatedPageWidth - PAGE_HORIZONTAL_PADDING; + return Math.max(1, paddedWidth); + }, [windowWidth]); + const containerWidth = parentLayout?.width || estimatedContainerWidth; const route = useRoute(); const { selectedProjectId, diff --git a/src/screens/Writing/CustomizeSongStructure.web.js b/src/screens/Writing/CustomizeSongStructure.web.js index ea40e39..91654ca 100644 --- a/src/screens/Writing/CustomizeSongStructure.web.js +++ b/src/screens/Writing/CustomizeSongStructure.web.js @@ -919,10 +919,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { title="Personnalise la structure de ta chanson" subTitle="Réorganise par glisser-déposer" /> - + @@ -931,22 +928,28 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { - - {labeledItems.map((item, index) => ( - - {insertPreviewIndex === index && ( + + + {labeledItems.map((item, index) => ( + + {insertPreviewIndex === index && ( + + )} + + + ))} + {insertPreviewIndex != null && + insertPreviewIndex >= labeledItems.length && ( )} - - - ))} - {insertPreviewIndex != null && - insertPreviewIndex >= labeledItems.length && ( - - )} - + + @@ -956,85 +959,94 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => { - - {paletteSegments.map((segment) => { - const count = segmentCounts[segment.type] || 0; - const isActive = activeItemId === `palette-${segment.type}`; - const blurStyle = StyleSheet.flatten([ - styles.itemBlur, - styles.paletteItemBlur, - isActive ? styles.activeBlur : null, - ]); - const badgeStyle = StyleSheet.flatten([ - styles.countBadge, - count > 0 ? styles.countBadgeActive : null, - ]); + + + {paletteSegments.map((segment) => { + const count = segmentCounts[segment.type] || 0; + const isActive = activeItemId === `palette-${segment.type}`; + const blurStyle = StyleSheet.flatten([ + styles.itemBlur, + styles.paletteItemBlur, + isActive ? styles.activeBlur : null, + ]); + const badgeStyle = StyleSheet.flatten([ + styles.countBadge, + count > 0 ? styles.countBadgeActive : null, + ]); - return ( - - startPaletteDrag(segment.type, event) - } - > - + startPaletteDrag(segment.type, event) + } > - - - {segment.label} - {segment.description ? ( - - {segment.description} - - ) : null} - - - ev?.stopPropagation?.()} - onPressIn={(ev) => ev?.stopPropagation?.()} - onPress={(ev) => { - ev?.stopPropagation?.(); - const result = addOptionalSegment(segment.type); - if (result?.success) { - setInsertPreviewIndex(null); - setActiveItemId(null); + + + + {segment.label} + {segment.description ? ( + + {segment.description} + + ) : null} + + + ev?.stopPropagation?.()} + onPressIn={(ev) => ev?.stopPropagation?.()} + onPress={(ev) => { + ev?.stopPropagation?.(); + const result = + addOptionalSegment(segment.type); + if (result?.success) { + setInsertPreviewIndex(null); + setActiveItemId(null); + } + }} + hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }} + style={({ pressed }) => + StyleSheet.flatten([ + styles.addButton, + pressed ? styles.addButtonPressed : null, + ]) } - }} - hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }} - style={({ pressed }) => - StyleSheet.flatten([ - styles.addButton, - pressed ? styles.addButtonPressed : null, - ]) - } - > - - - - {count} + > + + + + + {count} + + - - - - ); - })} - + + + ); + })} + + - + {dragOverlay ? ( - - + + - {/* - - {strings.writing.steps.contextExamples} - - */} - - - + setListContainerLayout(event.nativeEvent.layout)} + > + + + + {selected === OTHER_OBJECTIVE_OPTION ? ( @@ -221,7 +221,7 @@ const Goals = ({ ) : null} - + { ], ); - const onValidatePicture = useCallback(async () => { + const onValidatePicture = useCallback(() => { if (!selectedOption) { return; } - try { - await setLoading(true, { - message: "Validation de la pochette en cours...", - }); - const existingCover = selectedProject?.cover || {}; - const finalUrl = - selectedOption.finalUrl || selectedOption.generatedUrl || null; - const nextCoverData = { - ...existingCover, - options: coverOptions, - selectedOptionId: selectedOption.id, - result: finalUrl, - generatedBackground: - selectedOption.generatedUrl || selectedOption.finalUrl || null, - }; - await updateProjectData({ - cover: nextCoverData, - coverUrl: finalUrl, - }); - const projectForStage = { - ...selectedProject, - cover: nextCoverData, - coverUrl: finalUrl, - }; - const playbackStage = getStageAction("director", projectForStage); - await setLoading(false); - const targetRoute = playbackStage?.route || Routes.Playback; - const params = playbackStage?.params || { - project: projectForStage, - }; - navigate(targetRoute, params); - } catch (e) { - console.log("PouchReady: unable to validate cover", e?.message); - } finally { - await setLoading(false); - } - }, [ - coverOptions, - navigate, - selectedOption, - selectedProject, - setLoading, - updateProjectData, - ]); + navigate(Routes.SongDownload, { + project: selectedProject, + selectedOption, + coverOptions, + }); + }, [coverOptions, navigate, selectedOption, selectedProject]); const primaryActionTitle = hasGeneratedOptions ? "Valider la pochette" diff --git a/src/screens/cover/SongDownload.js b/src/screens/cover/SongDownload.js new file mode 100644 index 0000000..8fbd274 --- /dev/null +++ b/src/screens/cover/SongDownload.js @@ -0,0 +1,485 @@ +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; diff --git a/src/screens/cover/ValidateCover.js b/src/screens/cover/ValidateCover.js index a99b5de..8327bda 100644 --- a/src/screens/cover/ValidateCover.js +++ b/src/screens/cover/ValidateCover.js @@ -11,7 +11,6 @@ import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; import { useUserData } from "../../providers/UserDataProvider"; import { gutters, Palette, Style } from "../../styles"; -import { getStageAction } from "../../utils/projectStages"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const ValidateCover = () => { @@ -91,39 +90,11 @@ const ValidateCover = () => { if (!selectedOption) { return; } - try { - await setIsLoading(true); - const existingCover = selectedProject?.cover || {}; - const finalUrl = - selectedOption.finalUrl || selectedOption.generatedUrl || null; - const nextCoverData = { - ...existingCover, - options: coverOptions, - selectedOptionId: selectedOption.id, - result: finalUrl, - generatedBackground: - selectedOption.generatedUrl || selectedOption.finalUrl || null, - }; - await updateProjectData({ - cover: nextCoverData, - coverUrl: finalUrl, - }); - const projectForStage = { - ...selectedProject, - cover: nextCoverData, - coverUrl: finalUrl, - }; - const playbackStage = getStageAction("director", projectForStage); - await setIsLoading(false); - const targetRoute = playbackStage?.route || Routes.Playback; - const params = playbackStage?.params || { project: projectForStage }; - navigate(targetRoute, params); - return; - } catch (e) { - console.log(e); - } finally { - await setIsLoading(false); - } + navigate(Routes.SongDownload, { + project: selectedProject, + selectedOption, + coverOptions, + }); } return ( diff --git a/src/utils/likes.js b/src/utils/likes.js index e2f66c8..b0bfd47 100644 --- a/src/utils/likes.js +++ b/src/utils/likes.js @@ -14,7 +14,19 @@ export const getProjectLikes = (project, target = LIKE_TARGET.SONG) => { const path = target === LIKE_TARGET.PLAYBACK ? "playback" : "song"; const likes = project?.likes; const list = likes ? likes[path] : null; - return Array.isArray(list) ? list : []; + const normalized = Array.isArray(list) ? list : []; + const legacy = Array.isArray(project?.likedBy) ? project.likedBy : []; + + if (target !== LIKE_TARGET.SONG || legacy.length === 0) { + return normalized; + } + + const merged = new Set(normalized); + legacy.forEach((uid) => { + if (uid) merged.add(uid); + }); + + return Array.from(merged); }; export const isProjectLikedByUser = ( @@ -35,11 +47,16 @@ export const toggleProjectLike = async ({ if (!projectId) return; if (!ensureAuthenticated(currentUID)) return; const fieldPath = getLikeFieldPath(target); + const likeOperation = next ? arrayUnion(currentUID) : arrayRemove(currentUID); await projectsRef.doc(projectId).set( { - [fieldPath]: next - ? arrayUnion(currentUID) - : arrayRemove(currentUID), + [fieldPath]: likeOperation, + ...(target === LIKE_TARGET.SONG + ? { + // Keep legacy likedBy in sync for clients still reading this field + likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), + } + : {}), }, { merge: true } );