diff --git a/assets/icon.png b/assets/icon.png index fa8553d..a497dc1 100644 Binary files a/assets/icon.png and b/assets/icon.png differ diff --git a/functions/src/subscription/schedule.js b/functions/src/subscription/schedule.js index af001c2..2cee821 100644 --- a/functions/src/subscription/schedule.js +++ b/functions/src/subscription/schedule.js @@ -11,12 +11,22 @@ const { } = require("./shared"); const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants"); +// Toggle to stop monthly grants for annual subscriptions while keeping logic handy. +const ENABLE_ANNUAL_GRANT_SCHEDULER = false; + const processAnnualSubscriptionAllowances = onSchedule( { schedule: "30 3 * * *", timeZone: "Europe/Paris", }, async () => { + if (!ENABLE_ANNUAL_GRANT_SCHEDULER) { + console.log( + "[subscription-processAnnualSubscriptionAllowances] skipped (disabled)", + ); + return; + } + const nowTimestamp = admin.firestore.Timestamp.now(); const pageSize = 200; let lastDoc = null; diff --git a/functions/src/subscription/webhooks.js b/functions/src/subscription/webhooks.js index 770d6b3..ce7519d 100644 --- a/functions/src/subscription/webhooks.js +++ b/functions/src/subscription/webhooks.js @@ -14,15 +14,11 @@ const { computeNextGrantTimestamp, parseCoinsPerMonth, getSubscriptionMetaFromPrice, - formatSubscriptionForClient, buildSubscriptionPayload, resolveUserContext, upsertPaymentDocument, } = require("./shared"); -const { - SUBSCRIPTION_LEVEL_ALLOWANCES, - PREMIUM_SUBSCRIPTION_STATUSES, -} = require("./constants"); +const { PREMIUM_SUBSCRIPTION_STATUSES } = require("./constants"); const handleCheckoutSessionCompleted = async ( session, @@ -250,7 +246,8 @@ const handleCustomerSubscriptionEvent = async ( const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId); const metadataLevel = subscription.metadata?.subscriptionLevel || null; - const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null; + const metadataPeriod = + subscription.metadata?.subscriptionBillingPeriod || null; const resolvedLevel = metadataLevel || priceMeta?.level || null; const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null; @@ -457,6 +454,126 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { } await userRef.set(userUpdate, { merge: true }); + + const subscriptionLine = Array.isArray(invoice?.lines?.data) + ? invoice.lines.data.find( + (line) => + line && + typeof line === "object" && + (line.type === "subscription" || line.price), + ) + : null; + + const priceId = + typeof subscriptionLine?.price?.id === "string" + ? subscriptionLine.price.id + : typeof subscriptionLine?.price === "string" + ? subscriptionLine.price + : null; + + const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}; + const billingPeriod = + priceMeta.billingPeriod || + (subscriptionLine?.price?.recurring?.interval === "year" + ? "annual" + : subscriptionLine?.price?.recurring?.interval === "month" + ? "monthly" + : null); + + const coinsPerMonth = + priceMeta.coinsPerMonth ?? + parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ?? + null; + + const coinsToGrant = + billingPeriod === "annual" && typeof coinsPerMonth === "number" + ? coinsPerMonth * 12 + : null; + + const targetSubscriptionId = + resolvedSubscriptionId || + (typeof invoice.subscription === "string" ? invoice.subscription : null) || + (typeof subscriptionLine?.subscription === "string" + ? subscriptionLine.subscription + : null); + + const shouldGrantUpfront = + isInvoicePaid && + coinsToGrant && + (billingReason === "subscription_create" || + billingReason === "subscription_cycle"); + + if (shouldGrantUpfront && targetSubscriptionId) { + let grantSnapshot = null; + try { + grantSnapshot = await paymentDocRef.get(); + } catch (error) { + console.warn( + "[subscription-handleInvoiceEvent] Unable to read payment doc before grant", + invoice?.id || null, + error?.message || error, + ); + } + + const alreadyGranted = + grantSnapshot?.exists && + Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt); + + if (!alreadyGranted) { + const targetUserId = uid || firebaseUid || userRef.id; + const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}`; + + try { + const { orderId: processedOrderId } = await createOrderDocument({ + userId: targetUserId, + type: ORDER_TYPES.SUBSCRIPTION, + amount: coinsToGrant, + metadata: { + source: "STRIPE_INVOICE", + billingPeriod, + coinsPerMonth, + invoiceId: invoice.id || null, + subscriptionId: targetSubscriptionId, + grantStrategy: "upfront", + }, + orderId, + }); + + await paymentDocRef.set( + { + subscriptionCoinsGrantedAt: getServerTimestamp(), + subscriptionCoinsGrantAmount: coinsToGrant, + subscriptionCoinsGrantOrderId: processedOrderId, + subscriptionCoinsGrantSource: "invoice_upfront", + }, + { merge: true }, + ); + + await userRef.set( + { + subscriptionLastGrantAt: getServerTimestamp(), + subscriptionLastGrantAmount: coinsToGrant, + subscriptionLastGrantOrderId: processedOrderId, + subscriptionLastGrantSource: "invoice_upfront", + subscriptionNextGrantAt: null, + subscriptionGrantInterval: null, + subscriptionGrantStrategy: "upfront", + subscriptionCoinsPerMonth: coinsPerMonth, + }, + { merge: true }, + ); + } catch (error) { + console.error( + "[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins", + { + invoiceId: invoice?.id || null, + subscriptionId: targetSubscriptionId, + error: error?.message || error, + }, + ); + } + } + } }; const handleStripeWebhookEvent = async ({ event, stripe }) => { @@ -517,7 +634,10 @@ const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => { try { stripe = getStripeClient(); } catch (error) { - console.error("[subscription-handleStripeWebhook] Stripe client error", error); + console.error( + "[subscription-handleStripeWebhook] Stripe client error", + error, + ); res.status(500).send("Client Stripe indisponible"); return; } diff --git a/src/assets/icons/coin.png b/src/assets/icons/coin.png index c529766..ce33a49 100644 Binary files a/src/assets/icons/coin.png and b/src/assets/icons/coin.png differ diff --git a/src/assets/icons/coinOld.png b/src/assets/icons/coinOld.png new file mode 100644 index 0000000..c529766 Binary files /dev/null and b/src/assets/icons/coinOld.png differ diff --git a/src/components/MobileCoinBadge.js b/src/components/MobileCoinBadge.js new file mode 100644 index 0000000..cd89ca7 --- /dev/null +++ b/src/components/MobileCoinBadge.js @@ -0,0 +1,94 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { Platform, Pressable, StyleSheet } from "react-native"; +import CreditAmount from "./CreditAmount"; +import CoinPackModal from "./modal/CoinPackModal"; +import { useUser } from "../providers/UserDataProvider"; +import { Palette } from "../styles"; +import { FONT_FAMILY } from "../styles/Fonts"; +import { openCoinPackModal } from "../utils/coinPackModal"; + +const MobileCoinBadge = ({ + style, + textStyle, + iconSize = 20, + iconPosition = "left", +}) => { + const { currentUID, currentUserData } = useUser() || {}; + const [isModalVisible, setModalVisible] = useState(false); + + const coinBalance = useMemo(() => { + const value = currentUserData?.coins; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return 0; + }, [currentUserData?.coins]); + + const handlePress = useCallback(() => { + if (Platform.OS === "web") { + openCoinPackModal(); + return; + } + setModalVisible(true); + }, []); + + const handleClose = useCallback(() => { + setModalVisible(false); + }, []); + + if (!currentUID || Platform.OS === "web") { + return null; + } + + return ( + <> + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: "rgba(12, 14, 18, 0.72)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + flexShrink: 1, + }, + content: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + text: { + fontSize: 18, + color: Palette.white, + fontFamily: FONT_FAMILY.InterSemiBold, + }, +}); + +export default MobileCoinBadge; diff --git a/src/components/modal/CoinPackModal.js b/src/components/modal/CoinPackModal.js index 7dbb5b0..4476791 100644 --- a/src/components/modal/CoinPackModal.js +++ b/src/components/modal/CoinPackModal.js @@ -45,6 +45,7 @@ const formatCurrency = (amount, currency = "eur") => { const FALLBACK_BASE_PRICE_PER_COIN = 12; // ~0,12€ par jeton (valeur indicatif pour l'affichage) const FALLBACK_DISCOUNT_STEPS = [0, 12, 18, 26, 32]; +const STATIC_DISCOUNTS = [0, 30, 50]; const computeCoinPackPricing = (packs = []) => { if (!Array.isArray(packs) || !packs.length) { @@ -61,9 +62,7 @@ const computeCoinPackPricing = (packs = []) => { return { ...pack, hasValidPrice, - pricePerCoin: hasValidPrice - ? pack.unitAmount / pack.coinAmount - : null, + pricePerCoin: hasValidPrice ? pack.unitAmount / pack.coinAmount : null, }; }); @@ -146,8 +145,7 @@ const computeCoinPackPricing = (packs = []) => { discountPercent, isBasePack, isBestValue: - (bestDiscountPack && - bestDiscountPack.productId === pack.productId) || + (bestDiscountPack && bestDiscountPack.productId === pack.productId) || (!bestDiscountPack && fallbackBestId === pack.productId), }; @@ -155,7 +153,13 @@ const computeCoinPackPricing = (packs = []) => { }, {}); }; -function CoinPackCard({ pack, selected, pricingDetail, onSelect }) { +function CoinPackCard({ + pack, + selected, + pricingDetail, + onSelect, + staticDiscountPercent, +}) { const handleSelect = React.useCallback(() => { if (typeof onSelect !== "function" || !pack?.productId) { return; @@ -166,11 +170,20 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect }) { const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency); const perCoinPrice = pricingDetail?.formattedPricePerCoin; const discountPercent = pricingDetail?.discountPercent; - const discountLabel = pricingDetail?.isBasePack - ? "Pack de base (référence)" - : typeof discountPercent === "number" - ? `-${discountPercent}% vs pack de base` - : null; + const hasStaticDiscount = typeof staticDiscountPercent === "number"; + const effectiveDiscountPercent = hasStaticDiscount + ? staticDiscountPercent + : discountPercent; + const isBaseReference = !hasStaticDiscount && pricingDetail?.isBasePack; + const discountLabel = hasStaticDiscount + ? staticDiscountPercent > 0 + ? `-${staticDiscountPercent}%` + : null + : isBaseReference + ? "Pack de base (référence)" + : typeof discountPercent === "number" + ? `-${discountPercent}% vs pack de base` + : null; return ( + {/*{pricingDetail?.isBestValue ? (*/} + {/* */} + {/* Meilleure offre*/} + {/* */} + {/*) : null}*/} - {pack?.name ? {pack.name} : null} - {pricingDetail?.isBestValue ? ( - - Meilleure offre + {discountLabel ? ( + + + {discountLabel} + ) : null} + {pack?.name ? {pack.name} : null} + + + + - Diffusion sur mes plates formes de streaming + + + *Eligible au Hit parade Chanson/Video + - {pack?.description ? ( - {pack.description} - ) : null} {formattedPrice ? ( {formattedPrice} ) : null} - - {perCoinPrice - ? `≈ ${perCoinPrice} / jeton` - : "Valeurs indicatives / jeton"} - - {discountLabel ? ( - - - - {discountLabel} - - - {!pricingDetail?.isBasePack && typeof discountPercent === "number" ? ( - - Économie estimée vs pack de base - - ) : ( - - Référence prix/jeton - - )} - - ) : null} ); @@ -359,13 +361,14 @@ const CoinPackModal = ({ visible, onClose }) => { ]} showsVerticalScrollIndicator={false} > - {coinPacks.map((pack) => ( + {coinPacks.map((pack, index) => ( ))} @@ -564,6 +567,16 @@ const styles = StyleSheet.create({ color: "rgba(255, 255, 255, 0.72)", textAlign: "center", }, + perksList: { + gap: 6, + alignItems: "center", + }, + perkItem: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 13, + color: "rgba(255, 255, 255, 0.72)", + textAlign: "center", + }, priceBlock: { gap: 2, alignItems: "center", @@ -580,10 +593,6 @@ const styles = StyleSheet.create({ color: "rgba(255, 255, 255, 0.7)", textAlign: "center", }, - discountRow: { - alignItems: "center", - gap: 6, - }, discountBadge: { paddingHorizontal: 12, paddingVertical: 6, @@ -626,6 +635,9 @@ const styles = StyleSheet.create({ paddingVertical: 4, borderRadius: 10, backgroundColor: Palette.transparentGreen, + alignSelf: "center", + position: "absolute", + top: 10, }, tagBestValueText: { fontFamily: FONT_FAMILY.InterSemiBold, diff --git a/src/components/player/ProgressSlider.js b/src/components/player/ProgressSlider.js new file mode 100644 index 0000000..2f67b46 --- /dev/null +++ b/src/components/player/ProgressSlider.js @@ -0,0 +1,92 @@ +import React, { useCallback, useMemo, useRef, useState } from "react"; + +import Slider from "../Slider"; + +const clamp01 = (value) => Math.min(1, Math.max(0, value || 0)); + +const formatTime = (ms) => { + const totalSeconds = Math.max(0, Math.floor((Number(ms) || 0) / 1000)); + const minutes = Math.floor(totalSeconds / 60) + .toString() + .padStart(1, "0"); + const seconds = (totalSeconds % 60).toString().padStart(2, "0"); + return `${minutes}:${seconds}`; +}; + +const ProgressSlider = ({ + positionMs = 0, + durationMs = 0, + isPlaying = false, + onSeek, // (targetMs) => void | Promise + onPause, // () => void | Promise + onPlay, // () => void | Promise + onSeekStart, // () => void | Promise + onSeekEnd, // () => void | Promise + disabled = false, +}) => { + const wasPlayingRef = useRef(false); + const [isSeeking, setIsSeeking] = useState(false); + const [previewRatio, setPreviewRatio] = useState(null); + + const { safePosition, safeDuration, progress } = useMemo(() => { + const dur = Math.max(0, Number(durationMs) || 0); + const pos = Math.max(0, Math.min(dur, Number(positionMs) || 0)); + const ratio = dur > 0 ? clamp01(pos / dur) : 0; + return { safePosition: pos, safeDuration: dur, progress: ratio }; + }, [durationMs, positionMs]); + + const handleSeekStart = useCallback(() => { + if (disabled || !safeDuration) return; + wasPlayingRef.current = !!isPlaying; + setIsSeeking(true); + setPreviewRatio(progress); + if (typeof onSeekStart === "function") { + onSeekStart(); + } + if (isPlaying && typeof onPause === "function") { + onPause(); + } + }, [disabled, safeDuration, isPlaying, onPause, onSeekStart]); + + const handleSeek = useCallback( + (ratio) => { + if (disabled || !safeDuration || typeof onSeek !== "function") return; + const bounded = clamp01(ratio); + setPreviewRatio(bounded); + const targetMs = safeDuration * bounded; + onSeek(targetMs); + }, + [disabled, onSeek, safeDuration], + ); + + const handleSeekEnd = useCallback(() => { + if (disabled || !safeDuration) return; + setIsSeeking(false); + setPreviewRatio(null); + if (typeof onSeekEnd === "function") { + onSeekEnd(); + } + if (wasPlayingRef.current && typeof onPlay === "function") { + onPlay(); + } + wasPlayingRef.current = false; + }, [disabled, onPlay, onSeekEnd, safeDuration]); + + return ( + 0} + onSeekStart={handleSeekStart} + onSeek={handleSeek} + onSeekEnd={handleSeekEnd} + /> + ); +}; + +export default ProgressSlider; diff --git a/src/config/firebase.js b/src/config/firebase.js index eea044f..f6e808d 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -83,6 +83,7 @@ export const reportsRef = firestore.collection("reports"); export const { arrayUnion, arrayRemove, increment, serverTimestamp } = firebase.firestore.FieldValue; +export const deleteField = firebase.firestore.FieldValue.delete; export const getFunctionsClient = (region = "us-central1") => { const regionKey = region || "us-central1"; diff --git a/src/hooks/useUserLikedProjects.js b/src/hooks/useUserLikedProjects.js index 7ece43b..722b1f1 100644 --- a/src/hooks/useUserLikedProjects.js +++ b/src/hooks/useUserLikedProjects.js @@ -49,20 +49,6 @@ export default function useUserLikedProjects() { 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") { @@ -76,6 +62,30 @@ export default function useUserLikedProjects() { } return 0; }; + const pickLatest = (previous, next) => { + if (!previous) return next; + if (!next) return previous; + const prevTs = toTimestamp(previous?.updatedAt); + const nextTs = toTimestamp(next?.updatedAt); + if (nextTs > prevTs) return next; + if (nextTs < prevTs) return previous; + return next; + }; + + const pushList = (list) => { + if (!Array.isArray(list)) return; + list.forEach((project) => { + if (project?.id) { + const existing = byId.get(project.id); + byId.set(project.id, pickLatest(existing, project)); + } + }); + }; + + pushList(songLikes); + pushList(legacyLikes); + + const withUpdatedAt = Array.from(byId.values()); return withUpdatedAt.sort( (a, b) => toTimestamp(b?.updatedAt) - toTimestamp(a?.updatedAt) diff --git a/src/navigation/MainStack.js b/src/navigation/MainStack.js index c2d8937..d15183e 100644 --- a/src/navigation/MainStack.js +++ b/src/navigation/MainStack.js @@ -23,7 +23,7 @@ import RecordedPlayback from "../screens/Playback/RecordedPlayback"; import VideoFinalize from "../screens/Playback/VideoFinalize"; import PublishYoutube from "../screens/Publishing/PublishYoutube"; import PrivacyPolicy from "../screens/PrivacyPolicy"; -import Payments from "../screens/Payments"; +import Subscriptions from "../screens/Subscriptions"; import DownloadPrices from "../screens/Production/DownloadPrices"; import PlaybackDownload from "../screens/Production/PlaybackDownload"; import PlaybackExample from "../screens/Production/PlaybackExample"; @@ -121,7 +121,7 @@ const baseScreens = [ }, { name: Routes.Payments, - component: Payments, + component: Subscriptions, title: "Abonnements", }, { diff --git a/src/providers/UserDataProvider.js b/src/providers/UserDataProvider.js index d324f2a..b9dfdef 100644 --- a/src/providers/UserDataProvider.js +++ b/src/providers/UserDataProvider.js @@ -72,19 +72,46 @@ export default ({ children }) => { projects: userLikedProjects = [], loading: userLikedProjectsLoading = true, } = useUserLikedProjects(); + const toTimestamp = useCallback((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; + }, []); + const sortByPlaybackLikeDate = useCallback( + (list) => { + if (!Array.isArray(list)) return []; + const getLikeTimestamp = (project) => { + const likedAt = project?.likes?.playbackLikedAt?.[currentUID]; + const likedAtTs = likedAt ? toTimestamp(likedAt) : 0; + if (likedAtTs > 0) return likedAtTs; + return toTimestamp(project?.updatedAt); + }; + return [...list].sort( + (a, b) => getLikeTimestamp(b) - getLikeTimestamp(a), + ); + }, + [currentUID, toTimestamp], + ); const { data: userLikedPlaybacks = [], loading: userLikedPlaybacksLoading = true, } = useDataFromRef({ ref: currentUID - ? projectsRef - .where(PLAYBACK_LIKES_FIELD, "array-contains", currentUID) - .orderBy("updatedAt", "desc") + ? projectsRef.where(PLAYBACK_LIKES_FIELD, "array-contains", currentUID) : null, simpleRef: false, listener: true, condition: !!currentUID, refreshArray: [currentUID], + format: sortByPlaybackLikeDate, }); // Subscribe to user's playlists const { data: userPlaylists = [] } = useDataFromRef({ diff --git a/src/screens/HitParade/HitParade.js b/src/screens/HitParade/HitParade.js index 5064ddf..e5884e1 100644 --- a/src/screens/HitParade/HitParade.js +++ b/src/screens/HitParade/HitParade.js @@ -11,6 +11,7 @@ import { import { responsiveHeight } from "react-native-responsive-dimensions"; import { background, icons } from "../../assets"; import BorderGradientButton from "../../components/BorderGradientButton"; +import ShareBtn from "../../components/ShareBtn/ShareBtn"; import { projectsRef } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails"; @@ -20,6 +21,7 @@ import { navigate } from "../../navigation/NavigationService"; import { Palette, Style } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; +import MobileCoinBadge from "../../components/MobileCoinBadge"; import PlaybacksCard from "./components/PlaybacksCard"; import SongCard from "./components/SongCard"; const HitParade = () => { @@ -51,6 +53,18 @@ const HitParade = () => { return [arr.slice(0, 5), arr.slice(5)]; }, [topPlaybacks]); + const resolvePlaybackThumbnail = useCallback((project) => { + const candidates = [ + project?.thumbnailUrl, + project?.songThumbnailUrl, + project?.coverUrl, + ]; + const uri = candidates.find( + (value) => typeof value === "string" && value.trim().length > 0, + ); + return uri || null; + }, []); + // === RENDUS PAR PLATEFORME === const renderSongsMobile = () => ( { rank={index + 1} title={item?.title || "Sans titre"} artist={item?.userName} - coverUrl={item?.coverUrl || null} + thumbnailUrl={resolvePlaybackThumbnail(item)} onPress={() => navigate(Routes.Playbacks, { projectId: item.id })} /> )} @@ -167,7 +181,7 @@ const HitParade = () => { rank={idx + 1} title={item?.title || "Sans titre"} artist={"MusicLand"} - coverUrl={item?.coverUrl || null} + thumbnailUrl={resolvePlaybackThumbnail(item)} onPress={() => navigate(Routes.Playbacks, { projectId: item.id })} /> ))} @@ -182,7 +196,7 @@ const HitParade = () => { rank={5 + idx + 1} title={item?.title || "Sans titre"} artist={"MusicLand"} - coverUrl={item?.coverUrl || null} + thumbnailUrl={resolvePlaybackThumbnail(item)} onPress={() => navigate(Routes.Playbacks, { projectId: item.id }) } @@ -221,6 +235,21 @@ const HitParade = () => { blurIntensity={isWeb ? 0 : 0} showCoin={false} > + {!isWeb ? ( + + + + + ) : null} null, }) => { + const resolveUri = (value) => + typeof value === "string" && value.trim().length > 0 ? value : null; + const imageUri = resolveUri(thumbnailUrl) ?? resolveUri(coverUrl); + return ( diff --git a/src/screens/Library/Library.js b/src/screens/Library/Library.js index 054681c..66af849 100644 --- a/src/screens/Library/Library.js +++ b/src/screens/Library/Library.js @@ -11,6 +11,8 @@ import { import { responsiveHeight } from "react-native-responsive-dimensions"; import { background, icons } from "../../assets"; import SearchBar from "../../components/SearchBar"; +import MobileCoinBadge from "../../components/MobileCoinBadge"; +import ShareBtn from "../../components/ShareBtn/ShareBtn"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { navigate } from "../../navigation/NavigationService"; @@ -57,6 +59,21 @@ const Library = () => { return ( + {Platform.OS !== "web" ? ( + + + + + ) : null} { const projectId = params?.projectId || null; const [fav, setFav] = useState(false); const [currentUID] = useGlobal("currentUID"); - const wasPlayingBeforeSeek = useRef(false); - const hasCapturedSeekStateRef = useRef(false); - const lastSeekTargetMsRef = useRef(null); const listenedMsRef = useRef(0); const incrementDoneRef = useRef(false); const timerRef = useRef(null); @@ -292,15 +289,6 @@ const MusicDetails = ({ route }) => { return clearTimer; }, [isTrackPlaying, projectId]); - const fmt = (ms) => { - const total = Math.max(0, Math.floor((ms || 0) / 1000)); - const m = Math.floor(total / 60) - .toString() - .padStart(1, "0"); - const s = (total % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; - const togglePlay = useCallback(async () => { if (!trackDescriptor) return; try { @@ -328,41 +316,30 @@ const MusicDetails = ({ route }) => { const handleSliderSeekStart = useCallback(async () => { if (!trackDescriptor) return; - lastSeekTargetMsRef.current = null; - if (!hasCapturedSeekStateRef.current) { - hasCapturedSeekStateRef.current = true; - wasPlayingBeforeSeek.current = isTrackPlaying; - } try { if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: positionMs, autoPlay: false }); } - if (isTrackPlaying) { - await pauseTrack(); - } } catch (e) { console.log("MusicDetails seek start error", e?.message); } }, [ trackDescriptor, - isTrackPlaying, isCurrentTrack, ensureLoaded, positionMs, - pauseTrack, ]); const handleSliderSeek = useCallback( - async (ratio) => { + async (targetMs) => { const dur = sliderDurationMs || 0; if (!trackDescriptor || dur <= 0) return; - const targetMs = Math.max(0, Math.floor(dur * ratio)); - lastSeekTargetMsRef.current = targetMs; + const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs))); try { if (!isCurrentTrack) { - await ensureLoaded({ startPositionMs: targetMs, autoPlay: false }); + await ensureLoaded({ startPositionMs: bounded, autoPlay: false }); } else { - await seekTrackTo(targetMs); + await seekTrackTo(bounded); } } catch (e) { console.log("MusicDetails seek error", e?.message); @@ -424,37 +401,6 @@ const MusicDetails = ({ route }) => { resumeTrack, ]); - const handleSliderSeekEnd = useCallback(async () => { - const targetMs = - typeof lastSeekTargetMsRef.current === "number" - ? Math.max(0, lastSeekTargetMsRef.current) - : null; - try { - if (wasPlayingBeforeSeek.current) { - if (!isCurrentTrack) { - await ensureLoaded({ - startPositionMs: - targetMs !== null && Number.isFinite(targetMs) - ? targetMs - : positionMs, - autoPlay: true, - }); - } else { - if (targetMs !== null && Number.isFinite(targetMs)) { - await seekTrackTo(targetMs); - } - await resumeTrack(); - } - } - } catch (e) { - console.log("MusicDetails seek end error", e?.message); - } finally { - wasPlayingBeforeSeek.current = false; - hasCapturedSeekStateRef.current = false; - lastSeekTargetMsRef.current = null; - } - }, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]); - const handleSeekBySeconds = useCallback( async (deltaSeconds) => { if (!trackDescriptor) return; @@ -888,18 +834,15 @@ const MusicDetails = ({ route }) => { {songUrl && ( - { const [fav, setFav] = useState(false); const [currentUID] = useGlobal("currentUID"); const [, setTooltip] = useGlobal("_tooltip"); - const wasPlayingBeforeSeek = React.useRef(false); - const lastSeekTargetMs = React.useRef(null); const listenedMsRef = React.useRef(0); const incrementDoneRef = React.useRef(false); const timerRef = React.useRef(null); @@ -394,15 +392,6 @@ const MusicDetails = ({ route }) => { return clearTimer; }, [isPlaying, projectId]); - const fmt = (ms) => { - const total = Math.max(0, Math.floor((ms || 0) / 1000)); - const m = Math.floor(total / 60) - .toString() - .padStart(1, "0"); - const s = (total % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; - const togglePlay = useCallback(async () => { if (!trackDescriptor) return; try { @@ -434,54 +423,32 @@ const MusicDetails = ({ route }) => { const handleSliderSeekStart = useCallback(async () => { if (!trackDescriptor) return; try { - console.log("[MusicDetails.web] handleSliderSeekStart", { - isCurrentTrack, - isTrackPlaying, - positionMs, - durationMs, - }); - lastSeekTargetMs.current = null; - wasPlayingBeforeSeek.current = isTrackPlaying; if (!isCurrentTrack) { await ensureLoaded({ startPositionMs: positionMs, autoPlay: false, }); } - if (isTrackPlaying) { - await pauseTrack(); - } } catch (e) { console.log("MusicDetails seek start error", e?.message); } }, [ trackDescriptor, - isTrackPlaying, isCurrentTrack, ensureLoaded, positionMs, - pauseTrack, - lastSeekTargetMs, - durationMs, ]); const handleSliderSeek = useCallback( - async (ratio) => { + async (targetMs) => { const dur = sliderDurationMs || 0; if (!trackDescriptor || dur <= 0) return; - const targetMs = Math.max(0, Math.floor(dur * ratio)); - lastSeekTargetMs.current = targetMs; - console.log("[MusicDetails.web] handleSliderSeek", { - ratio, - durationMs: dur, - targetMs, - isCurrentTrack, - }); + const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs))); try { if (!isCurrentTrack) { - await ensureLoaded({ startPositionMs: targetMs, autoPlay: false }); + await ensureLoaded({ startPositionMs: bounded, autoPlay: false }); } else { - await seekTrackTo(targetMs); + await seekTrackTo(bounded); } } catch (e) { console.log("MusicDetails seek error", e?.message); @@ -553,42 +520,6 @@ const MusicDetails = ({ route }) => { trackDescriptor, ]); - const handleSliderSeekEnd = useCallback(async () => { - const targetMs = - typeof lastSeekTargetMs.current === "number" - ? Math.max(0, lastSeekTargetMs.current) - : null; - console.log("[MusicDetails.web] handleSliderSeekEnd", { - targetMs, - wasPlayingBeforeSeek: wasPlayingBeforeSeek.current, - isCurrentTrack, - positionMs, - }); - try { - if (wasPlayingBeforeSeek.current) { - if (!isCurrentTrack) { - await ensureLoaded({ - startPositionMs: - targetMs !== null && Number.isFinite(targetMs) - ? targetMs - : positionMs, - autoPlay: true, - }); - } else { - if (targetMs !== null && Number.isFinite(targetMs)) { - await seekTrackTo(targetMs); - } - await resumeTrack(); - } - } - } catch (e) { - console.log("MusicDetails seek end error", e?.message); - } finally { - wasPlayingBeforeSeek.current = false; - lastSeekTargetMs.current = null; - } - }, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]); - const handleSeekBySeconds = useCallback( async (deltaSeconds) => { if (!trackDescriptor) return; @@ -1140,16 +1071,15 @@ const MusicDetails = ({ route }) => { /> - )} diff --git a/src/screens/Library/components/LikedMusic.js b/src/screens/Library/components/LikedMusic.js index dadd0cf..8bf4538 100644 --- a/src/screens/Library/components/LikedMusic.js +++ b/src/screens/Library/components/LikedMusic.js @@ -1,5 +1,5 @@ import { Image as ExpoImage } from "expo-image"; -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { FlatList, Pressable, @@ -33,6 +33,29 @@ const LikedMusic = () => { Math.floor((containerWidth - gap * (numColumns - 1)) / numColumns) ); const navigateToMusicDetails = useNavigateToMusicDetails(); + const resolveCoverUri = useCallback((project) => { + if (!project) return null; + const isValid = (value) => + typeof value === "string" && value.trim().length > 0; + if (isValid(project?.coverUrl)) { + return project.coverUrl; + } + const cover = project?.cover || {}; + const coverCandidates = [ + cover?.result, + cover?.finalUrl, + cover?.generatedBackground, + ]; + if (Array.isArray(cover?.options)) { + coverCandidates.push( + ...cover.options.flatMap((option) => [ + option?.finalUrl, + option?.generatedUrl, + ]) + ); + } + return coverCandidates.find(isValid) || null; + }, []); return ( { liked: true, }) } - onPressPlus={() => navigate(Routes.WritingLyrics)} + onPressPlus={() => navigate(Routes.HitParade)} > {items.length > 0 ? ( { }) } > - {item?.coverUrl ? ( + {resolveCoverUri(item) ? ( { liked: true, }) } - onPressPlus={() => navigate(Routes.WritingLyrics)} + onPressPlus={() => navigate(Routes.Playbacks)} > {items.length > 0 ? ( diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 9e33442..ae99255 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -26,6 +26,7 @@ import { FONT_FAMILY } from "../../styles/Fonts"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const TIME_BEFORE_INCREMENT_MS = 20000; // 20s +const COUNTDOWN_SECONDS = 10; const LOG_PREFIX = "[RecordPlayback]"; const log = typeof __DEV__ === "undefined" || __DEV__ @@ -410,7 +411,7 @@ const RecordPlayback = ({ route }) => { } setIsPreparing(true); setShowProgress(false); - setCountdown(5); + setCountdown(COUNTDOWN_SECONDS); // On démarre l'intervalle puis on active le flag if (countdownTimerRef.current) { diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js index 42518d4..a862d38 100644 --- a/src/screens/Playback/RecordPlayback.web.js +++ b/src/screens/Playback/RecordPlayback.web.js @@ -28,6 +28,7 @@ import RestartSpinnerIcon from "../../assets/UI/RestartSpinnerIcon"; import { registerBlobUrl, releaseBlobUrl } from "../../utils/blobUrlCache"; const TIME_BEFORE_INCREMENT_MS = 20000; +const COUNTDOWN_SECONDS = __DEV__ ? 1 : 10; const WEB_PREVIEW_WIDTH = 360; const toSeconds = (v) => { @@ -318,7 +319,7 @@ const RecordPlayback = ({ route }) => { setLooping(!!originalLoopingValueRef.current.value); } }; - }, [setLooping]) + }, [setLooping]), ); useFocusEffect( @@ -327,7 +328,7 @@ const RecordPlayback = ({ route }) => { return () => { void resetSessionRef.current?.(); }; - }, []) + }, []), ); useEffect(() => { @@ -359,7 +360,7 @@ const RecordPlayback = ({ route }) => { !navigator.mediaDevices?.getUserMedia ) { setMediaError( - new Error("La capture vidéo n'est pas supportée sur ce navigateur") + new Error("La capture vidéo n'est pas supportée sur ce navigateur"), ); setMediaReady(false); return; @@ -473,7 +474,7 @@ const RecordPlayback = ({ route }) => { perfStartRef.current != null ? Math.max( 0, - (performance.now() - perfStartRef.current - pausedTotal) / 1000 + (performance.now() - perfStartRef.current - pausedTotal) / 1000, ) : 0; @@ -588,7 +589,7 @@ const RecordPlayback = ({ route }) => { }, }, ], - { cancelable: false } + { cancelable: false }, ); } } @@ -603,7 +604,7 @@ const RecordPlayback = ({ route }) => { setIsPaused(false); isPausedRef.current = false; setIsPreparing(true); - setCountdown(5); + setCountdown(COUNTDOWN_SECONDS); if (countdownTimerRef.current) clearInterval(countdownTimerRef.current); countdownTimerRef.current = setInterval(() => { setCountdown((c) => { diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index fb46b97..999be68 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -7,7 +7,7 @@ import { background, icons } from "../../assets"; import BorderGradientButton from "../../components/BorderGradientButton"; import GradientButton from "../../components/GradientButton"; import MusicLandHeader from "../../components/MusicLandHeader"; -import Slider from "../../components/Slider"; +import ProgressSlider from "../../components/player/ProgressSlider"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; import { goBack, navigate } from "../../navigation/NavigationService"; @@ -34,19 +34,8 @@ const RecordedPlayback = ({ route }) => { dur: 0, isPlaying: false, }); - const wasPlayingBeforeSeek = useRef(false); const playbackEndedRef = useRef(false); - // Format mm:ss - const fmt = (ms) => { - const total = Math.max(0, Math.floor((ms || 0) / 1000)); - const m = Math.floor(total / 60) - .toString() - .padStart(1, "0"); - const s = (total % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; - // Start both players on mount const stopPlayback = useCallback(async () => { try { @@ -94,32 +83,37 @@ const RecordedPlayback = ({ route }) => { return () => global.clearInterval(id); }, [audioPlayer, videoPlayer]); - const onSeek = async (ratio) => { + const onSeek = async (targetMs) => { try { const dur = progressInfo.dur || 0; - const pos = Math.floor(dur * ratio); + const pos = Math.max(0, Math.min(dur, Math.floor(targetMs))); if (audioPlayer && dur > 0) await audioPlayer.seekTo?.(Math.floor(pos / 1000)); if (videoPlayer) videoPlayer.currentTime = Math.max(0, pos / 1000); } catch (e) {} }; - const onSeekStart = async () => { + const onSeekStart = useCallback(() => { + playbackEndedRef.current = false; + }, []); + + const pauseDuringSeek = useCallback(async () => { try { - wasPlayingBeforeSeek.current = !!audioPlayer?.playing; - playbackEndedRef.current = false; if (audioPlayer?.playing) await audioPlayer.pause?.(); + } catch (e) {} + try { if (videoPlayer?.playing) videoPlayer.pause(); } catch (e) {} - }; - const onSeekEnd = async () => { + }, [audioPlayer, videoPlayer]); + + const resumeAfterSeek = useCallback(async () => { try { - if (wasPlayingBeforeSeek.current) { - if (audioPlayer) await audioPlayer.play?.(); - if (videoPlayer) videoPlayer.play(); - } + if (audioPlayer) await audioPlayer.play?.(); } catch (e) {} - }; + try { + if (videoPlayer) videoPlayer.play(); + } catch (e) {} + }, [audioPlayer, videoPlayer]); const sliderProgress = useMemo(() => { return progressInfo.dur @@ -190,14 +184,15 @@ const RecordedPlayback = ({ route }) => { }} /> )} - { const sliderProgress = useMemo(() => { return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0; }, [progress]); + const pendingSeekRef = useRef(null); useEffect(() => { const duration = progress?.durS || 0; @@ -154,22 +155,48 @@ const RecordedPlayback = ({ route }) => { const remaining = Math.max(0, duration - position); if (remaining <= 0.35 && !playbackEndedRef.current) { playbackEndedRef.current = true; - void stopPlayback(); + void (async () => { + await stopPlayback(); + try { + if (audioPlayer) await audioPlayer.seekTo?.(0); + } catch {} + try { + if (videoElRef.current) { + videoElRef.current.currentTime = 0; + } + } catch {} + })(); } - }, [progress, stopPlayback]); + }, [progress, stopPlayback, audioPlayer]); - const onSeek = async (ratio) => { - try { + const onSeek = useCallback( + async (ratio) => { const durS = Number(progress.durS || 0); - const target = durS * ratio; // secondes - if (audioPlayer && durS > 0) { - await audioPlayer.seekTo?.(Math.floor(target)); // seek en secondes - } - if (videoElRef.current && videoUri) { - videoElRef.current.currentTime = Math.max(0, target); - } + const target = durS > 0 ? durS * ratio : 0; // secondes + const promise = (async () => { + try { + if (audioPlayer && durS > 0) { + await audioPlayer.seekTo?.(Math.max(0, target)); + } + if (videoElRef.current && videoUri) { + videoElRef.current.currentTime = Math.max(0, target); + } + } catch {} + })(); + pendingSeekRef.current = promise; + await promise; + }, + [audioPlayer, progress.durS, videoUri], + ); + + const waitForPendingSeek = useCallback(async () => { + const promise = pendingSeekRef.current; + pendingSeekRef.current = null; + if (!promise) return; + try { + await promise; } catch {} - }; + }, []); const wasPlayingRef = useRef(false); const isSeekingRef = useRef(false); @@ -178,6 +205,7 @@ const RecordedPlayback = ({ route }) => { if (isSeekingRef.current) return; isSeekingRef.current = true; wasPlayingRef.current = !!audioPlayer?.playing; + pendingSeekRef.current = null; playbackEndedRef.current = false; if (audioPlayer?.playing) await audioPlayer.pause?.(); if (videoElRef.current && !videoElRef.current.paused) { @@ -188,14 +216,15 @@ const RecordedPlayback = ({ route }) => { const onSeekEnd = useCallback(async () => { try { if (!isSeekingRef.current) return; + await waitForPendingSeek(); isSeekingRef.current = false; if (wasPlayingRef.current) { - if (audioPlayer) await audioPlayer.play?.(); + if (audioPlayer) await audioPlayer.resume?.(); if (videoElRef.current && videoUri) videoElRef.current.play().catch(() => {}); } } catch {} - }, [audioPlayer, videoUri]); + }, [audioPlayer, videoUri, waitForPendingSeek]); const handleTogglePlayback = useCallback(async () => { try { diff --git a/src/screens/Production/PlaybackDownload.js b/src/screens/Production/PlaybackDownload.js index 97298a5..c6c49fe 100644 --- a/src/screens/Production/PlaybackDownload.js +++ b/src/screens/Production/PlaybackDownload.js @@ -35,25 +35,48 @@ import { Image as ExpoImage } from "expo-image"; import SubscriptionConfirmModal from "../../components/SubscriptionConfirmModal"; import { isWeb } from "../../hooks/useLayoutType"; -const triggerWebDownload = (url, title) => { +const triggerWebDownload = async (url, title) => { if (Platform.OS !== "web") return; if (!url) return; if (typeof document === "undefined") return; const baseName = (title || "Playback").toString().trim() || "Playback"; const sanitized = baseName.replace(/[\\/:*?"<>|]/g, "-"); - const filename = `${sanitized}.mp4`; + const extension = guessExtension(url) || "mp4"; + const filename = `${sanitized}.${extension}`; try { + const response = await fetch(url); + if (!response.ok || response.type === "opaque") { + throw new Error(`download_failed_${response.status || "opaque"}`); + } + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); - anchor.href = url; + anchor.href = blobUrl; anchor.download = filename; anchor.rel = "noopener noreferrer"; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); + URL.revokeObjectURL(blobUrl); } catch (error) { console.log("[PlaybackDownload] web download fallback", { message: error?.message, }); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.rel = "noopener noreferrer"; + anchor.target = "_blank"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + return; + } catch (fallbackError) { + console.log("[PlaybackDownload] anchor fallback failed", { + message: fallbackError?.message, + }); + } try { window.open(url, "_blank", "noopener,noreferrer"); } catch {} @@ -94,7 +117,7 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => { const { resultURI: videoUrl } = await uploadFileToFirebase({ uri, path: sourcePath, - shouldCompress: false, + shouldCompress: true, fileType: "VIDEO", blob: cachedBlob || undefined, }); @@ -116,6 +139,10 @@ const PlaybackDownload = ({ route }) => { const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false); + const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState( + project?.playbackUrl || null, + ); + const [isPublishing, setIsPublishing] = useState(false); const hasShownAfterPlaybackRef = useRef(false); const { data: video } = useDataFromRef({ @@ -136,6 +163,12 @@ const PlaybackDownload = ({ route }) => { setIsAfterPlaybackVideoVisible(true); }, [action, afterPlaybackUrl]); + useEffect(() => { + if (project?.playbackUrl) { + setPendingPlaybackUrl(project.playbackUrl); + } + }, [project?.playbackUrl]); + useEffect(() => { return () => { releaseBlobUrl(uri || null); @@ -143,7 +176,12 @@ const PlaybackDownload = ({ route }) => { }, [uri]); const handleDownloadUri = async () => { + if (isPublishing) return; if (action === "playback" && project?.id) { + if (pendingPlaybackUrl) { + await triggerWebDownload(pendingPlaybackUrl, project?.title); + return; + } // Publication du playback console.log("[PlaybackDownload] handleDownloadUri playback", { projectId: project.id, @@ -194,18 +232,12 @@ const PlaybackDownload = ({ route }) => { const resultURI = result?.url || null; if (resultURI) { - await projectsRef.doc(project.id).set( - { - playbackUrl: resultURI, - updatedAt: serverTimestamp(), - }, - { merge: true }, - ); + setPendingPlaybackUrl(resultURI); setTooltip({ type: "success", - text: "Vidéo uploadée", + text: "Playback prêt à télécharger", }); - triggerWebDownload(resultURI, project?.title); + await triggerWebDownload(resultURI, project?.title); if (tempSourcePath) { try { await firebase.storage().ref(tempSourcePath).delete(); @@ -249,6 +281,109 @@ const PlaybackDownload = ({ route }) => { // Handle song download } }; + + const handlePublish = async () => { + if (isPublishing) return; + if (!project?.id) { + if (action === "playback") { + setTooltip({ + type: "success", + text: "Playback publié !", + }); + navigate(Routes.Home); + } else { + navigate(Routes.SongRelease, { action }); + } + return; + } + let tempSourcePath = null; + let playbackUrlToSave = pendingPlaybackUrl || project?.playbackUrl || null; + const audioUrl = project?.songUrl || null; + if (!audioUrl && !playbackUrlToSave) { + setTooltip({ + type: "error", + text: "Aucune piste audio disponible pour ce projet", + }); + return; + } + + try { + setIsPublishing(true); + setIsLoading(true); + + if (!playbackUrlToSave) { + const { sourcePath, videoUrl } = await uploadSourceRecording({ + uri, + uid: currentUID, + projectId: project.id, + }); + tempSourcePath = sourcePath; + + const callable = firebase + .functions() + .httpsCallable("upload-mergeVideoAndAudio"); + + const payload = { + projectId: project?.id, + videoUrl, + audioUrl, + storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`, + }; + + console.log("[PlaybackDownload] publish: calling merge", payload); + + const { data: result } = await callable(payload); + + playbackUrlToSave = result?.url || null; + if (!playbackUrlToSave) { + throw new Error("merge_failed"); + } + setPendingPlaybackUrl(playbackUrlToSave); + } + + await projectsRef.doc(project.id).set( + { + playbackUrl: playbackUrlToSave, + updatedAt: serverTimestamp(), + }, + { merge: true }, + ); + + if (action === "playback") { + setTooltip({ + type: "success", + text: "Playback publié !", + }); + navigate(Routes.Home); + } else { + navigate(Routes.SongRelease, { action }); + } + } catch (error) { + console.log("[PlaybackDownload] publish error", { + message: error?.message, + code: error?.code, + name: error?.name, + details: error?.details, + }); + setTooltip({ + type: "error", + text: String(error?.message || "Publication impossible"), + }); + } finally { + if (tempSourcePath) { + try { + await firebase.storage().ref(tempSourcePath).delete(); + } catch (cleanupError) { + console.log("[PlaybackDownload] unable to delete temp source", { + message: cleanupError?.message, + code: cleanupError?.code, + }); + } + } + setIsPublishing(false); + setIsLoading(false); + } + }; const continueLabel = hasActiveSubscription ? "Publier" : "Publier sans générer de revenus"; @@ -298,7 +433,7 @@ const PlaybackDownload = ({ route }) => { title={continueLabel} onPress={() => { if (hasActiveSubscription) { - navigate(Routes.SongRelease, { action }); + handlePublish(); } else { setShowConfirmModal(true); } @@ -312,7 +447,7 @@ const PlaybackDownload = ({ route }) => { isVisible={showConfirmModal} setIsVisible={setShowConfirmModal} onJoinClub={() => navigate(Routes.Payments)} - onContinue={() => navigate(Routes.SongRelease, { action })} + onContinue={handlePublish} /> ); diff --git a/src/screens/Production/StreamSong.js b/src/screens/Production/StreamSong.js index 9f32b94..64a19f7 100644 --- a/src/screens/Production/StreamSong.js +++ b/src/screens/Production/StreamSong.js @@ -5,6 +5,7 @@ import MusicLandHeader from "../../components/MusicLandHeader"; import GradientButton from "../../components/GradientButton"; import { background, img } from "../../assets"; import { goBack, navigate } from "../../navigation/NavigationService"; +import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { size } from "../../styles/Style"; @@ -20,6 +21,7 @@ const StreamSong = () => { const project = params?.project || null; const { hasActiveSubscription } = useUser() || {}; const userHasActiveSubscription = hasActiveSubscription; + const { setTooltip } = useMinuit(); useEffect(() => { if (action && action !== "playback") { @@ -36,11 +38,19 @@ const StreamSong = () => { : "Rejoindre le Club MusicLand"; const handlePrimaryAction = useCallback(() => { if (userHasActiveSubscription) { + if (action === "playback") { + setTooltip({ + type: "success", + text: "Playback publié !", + }); + navigate(Routes.Home); + return; + } navigate(Routes.SongRelease, { action }); return; } navigate(Routes.Payments); - }, [action, userHasActiveSubscription]); + }, [action, setTooltip, userHasActiveSubscription]); return ( { typeof coinsPerMonth === "number" && Number.isFinite(coinsPerMonth) ? Math.round(coinsPerMonth) : null; + const grantStrategy = + typeof currentUserData?.subscriptionGrantStrategy === "string" + ? currentUserData.subscriptionGrantStrategy + : null; + const isUpfrontGrant = grantStrategy === "upfront"; const nextGrantTimestamp = - currentUserData?.subscriptionNextGrantAt || - currentUserData?.subscriptionGrantNextAt || - null; + isUpfrontGrant + ? null + : currentUserData?.subscriptionNextGrantAt || + currentUserData?.subscriptionGrantNextAt || + null; const nextGrantRawDate = toDate(nextGrantTimestamp); const now = new Date(); @@ -390,7 +397,7 @@ const ManageSubscription = ({ navigation }) => { : null; const fallbackInitialGrant = - isAnnual && createdAtDate + !isUpfrontGrant && isAnnual && createdAtDate ? computeFirstAnnualGrantFromCreation(createdAtDate) : null; const futureFallbackGrant = @@ -409,7 +416,7 @@ const ManageSubscription = ({ navigation }) => { } const nextGrantLabel = - resolvedNextGrantDate && isAnnual + resolvedNextGrantDate && isAnnual && !isUpfrontGrant ? formatDate(resolvedNextGrantDate) : null; diff --git a/src/screens/Profile/Profile.js b/src/screens/Profile/Profile.js index c92528a..b297ade 100644 --- a/src/screens/Profile/Profile.js +++ b/src/screens/Profile/Profile.js @@ -19,8 +19,10 @@ import { useGlobal } from "reactn"; import { background, icons } from "../../assets"; import BorderGradient from "../../components/BorderGradient/BorderGradient"; import MoreMenu from "../../components/MoreMenu"; +import MobileCoinBadge from "../../components/MobileCoinBadge"; import PressableScale from "../../components/PressableScale"; import ProfilePicture from "../../components/ProfilePicture"; +import ShareBtn from "../../components/ShareBtn/ShareBtn"; import firebase, { projectsRef, serverTimestamp, @@ -33,7 +35,7 @@ import useLayoutType from "../../hooks/useLayoutType"; import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; -import { navigate, push } from "../../navigation/NavigationService"; +import { navigate, push, goBack } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; @@ -69,15 +71,15 @@ const Profile = () => { const [webUploadMessage, setWebUploadMessage] = useState(""); const selfDisplayName = useMemo( () => getArtistDisplayName(currentUserData, "MusicLand"), - [currentUserData] + [currentUserData], ); const targetDisplayName = useMemo( () => getArtistDisplayName(userData, "MusicLand"), - [userData] + [userData], ); const profileTitle = useMemo( () => getArtistDisplayName(isSelf ? currentUserData : userData, "Profil"), - [currentUserData, isSelf, userData] + [currentUserData, isSelf, userData], ); const navigateToMusicDetails = useNavigateToMusicDetails(); const onPressMenu = (item) => { @@ -96,7 +98,7 @@ const Profile = () => { setFollowers( Array.isArray(currentUserData?.followedBy) ? currentUserData.followedBy.length - : 0 + : 0, ); return; } @@ -175,7 +177,7 @@ const Profile = () => { profilePictureURL: resultURI, updatedAt: serverTimestamp(), }, - { merge: true } + { merge: true }, ); const authUser = firebase.auth().currentUser; @@ -237,7 +239,8 @@ const Profile = () => { setSelectedProjectId(item.id); setMenuPosition(posTop); setShowMenu( - (prev) => !prev || posTop?.top !== (menuPosition?.top ?? null) + (prev) => + !prev || posTop?.top !== (menuPosition?.top ?? null), ); }} /> @@ -329,26 +332,43 @@ const Profile = () => { const displayedPlaybacks = Array.isArray(displayedPlaybacksSource) ? displayedPlaybacksSource.filter((project) => project?.playbackUrl) : []; + const showHeader = isWeb; + const showBackButton = !params?.noBack && !isWeb; return ( - !isWeb && isSelf ? ( - - - - ) : null - } > + {!isWeb ? ( + + + {showBackButton ? ( + + + + ) : null} + + + + + ) : null} { // Platform.OS !== "ios" ? "dimezisBlurView" : "none" // } > - {isWeb && isSelf && ( - - - - )} - + {isSelf && ( + + + + )} { style={styles.editAvatarButton} onPress={() => onChangeProfilePicture( - loaderMessages.profilePhotoUploadWeb + loaderMessages.profilePhotoUploadWeb, ) } disabled={updatingPhoto} @@ -567,6 +582,30 @@ const Profile = () => { export default Profile; const styles = StyleSheet.create({ + mobileTopRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 8, + }, + mobileTopLeft: { + flexDirection: "row", + alignItems: "center", + gap: 10, + flexShrink: 1, + }, + mobileShareButton: { + flexShrink: 0, + }, + backButton: { + paddingVertical: 6, + paddingRight: 2, + }, + backIcon: { + ...Style.iconSmall, + ...Style.mirrorHorizontal, + transform: [{ rotate: "90deg" }], + }, value: { fontSize: 16, color: Palette.white, @@ -609,6 +648,19 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", }, + settingsButton: { + position: "absolute", + top: -6, + right: -6, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: Palette.transparentBlack, + borderWidth: 1, + borderColor: Palette.white, + zIndex: 2, + ...Style.containerCenter, + }, editAvatarButton: { position: "absolute", bottom: 4, diff --git a/src/screens/Profile/components/ClubAdvantagesCard.js b/src/screens/Profile/components/ClubAdvantagesCard.js index 1bba2aa..f2d46a1 100644 --- a/src/screens/Profile/components/ClubAdvantagesCard.js +++ b/src/screens/Profile/components/ClubAdvantagesCard.js @@ -16,39 +16,32 @@ 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", + title: "Gagne des crédits avec ton abonnement", description: "Des crédits premium tous les mois pour créer plus.", iconType: "image", icon: icons.coin, accent: ICON_BASE_ACCENT, }, + { + title: "Gagne le double de cashprice grace à ton adésion au club", + description: "Cashprice doublé pour les membres du Club Musicland.", + iconType: "vector", + iconName: "chart-line", + accent: "#F8C24D", + }, ]; const ClubAdvantagesCard = ({ style, isDev = false }) => { const navigation = useNavigation(); const { hasActiveSubscription } = useUserData() || {}; - const [useWebLayout, setUseWebLayout] = React.useState(isWeb); + const [useWebLayout, setUseWebLayout] = React.useState(isDev ? isWeb : false); const advantages = CLUB_ADVANTAGES; React.useEffect(() => { if (!isDev) { - setUseWebLayout(isWeb); + setUseWebLayout(false); } - }, [isDev, isWeb]); + }, [isDev]); const title = hasActiveSubscription ? "Tu profites déjà du Club Musicland" diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index b0e5f48..e54789a 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -20,7 +20,7 @@ import CreditAmount from "../../components/CreditAmount"; import GradientButton from "../../components/GradientButton"; import ValidateModal from "../../components/modal/ValidateModal"; import MusicLandHeader from "../../components/MusicLandHeader"; -import Slider from "../../components/Slider"; +import ProgressSlider from "../../components/player/ProgressSlider"; import { isWeb } from "../../hooks/useLayoutType"; import Page from "../../layouts/Page"; import { Routes } from "../../navigation"; @@ -35,14 +35,6 @@ import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const MUSIC_GENERATION_COIN_COST = 8; const SONG_OPTIONS_PER_GENERATION = 2; const REGENERATE_MODAL_MAX_WIDTH = 540; -const formatTime = (ms) => { - const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000)); - const minutes = Math.floor(totalSeconds / 60) - .toString() - .padStart(1, "0"); - const seconds = (totalSeconds % 60).toString().padStart(2, "0"); - return `${minutes}:${seconds}`; -}; const SongReady = () => { const { @@ -368,7 +360,6 @@ const SongOptionCard = ({ const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }); const [isPlaying, setIsPlaying] = useState(false); - const wasPlayingBeforeSeek = useRef(false); useEffect(() => { registerPlayer(index, player); @@ -422,10 +413,10 @@ const SongOptionCard = ({ return () => clearInterval(id); }, [player]); - const handleSeek = async (ratio) => { + const handleSeek = async (targetMs) => { if (!player || !progressInfo?.dur) return; const dur = progressInfo.dur || 0; - const pos = Math.max(0, Math.floor(dur * ratio)); + const pos = Math.max(0, Math.min(dur, Math.floor(targetMs))); try { await player.seekTo?.(Math.floor(pos / 1000)); setProgressInfo((prev) => ({ ...prev, pos })); @@ -434,11 +425,13 @@ const SongOptionCard = ({ } }; - const handleSeekStart = async () => { + const handleSeekStart = () => { onSelect(index); + }; + + const pauseDuringSeek = async () => { if (!player) return; try { - wasPlayingBeforeSeek.current = !!player.playing; if (player.playing) { await player.pause?.(); } @@ -447,20 +440,16 @@ const SongOptionCard = ({ } }; - const handleSeekEnd = async () => { + const resumeAfterSeek = async () => { if (!player) return; try { - if (wasPlayingBeforeSeek.current) { - if (player.resume) { - await player.resume?.(); - } else { - await player.play?.(); - } + if (player.resume) { + await player.resume?.(); + } else { + await player.play?.(); } } catch (error) { console.log("SongReady resume after seek", error?.message); - } finally { - wasPlayingBeforeSeek.current = false; } }; @@ -515,18 +504,15 @@ const SongOptionCard = ({ marginBottom: responsiveHeight(1), }} >{`Morceau ${index + 1}`} - { ); }; -const PLAN_SEGMENTS = [ - { key: "monthly", label: "Mensuel" }, - { key: "annual", label: "Annuel" }, -]; - const HERO_IMAGE_WIDTH = isWeb ? 1280 : 700; const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360; const PAGE_BACKGROUND_COLOR = "#303438"; @@ -303,7 +298,7 @@ const SUBSCRIPTION_DISCLAIMER = const CARD_MIN_HEIGHT = isWeb ? 320 : 240; const CREDITS_PER_MUSIC = 8; -export default function Payments() { +export default function Subscriptions() { const route = useRoute(); const insets = useSafeAreaInsets(); const isMobile = !isWeb; @@ -315,32 +310,22 @@ export default function Payments() { const initialSubscriptionPack = initialPack && initialPack !== "packs" ? initialPack : null; - const backgroundImage = isMobile ? background.homeBG : background.bgTrans; const { subscriptions, isCatalogLoading, catalogError, createSubscriptionCheckout, } = useStripe(); - const [selectedPriceIds, setSelectedPriceIds] = React.useState({ - monthly: null, - annual: null, - }); - const [billingPeriod, setBillingPeriod] = React.useState("monthly"); + const [selectedPriceId, setSelectedPriceId] = React.useState(null); const [processingPriceId, setProcessingPriceId] = React.useState(null); const [errorMessage, setErrorMessage] = React.useState(null); const initialPackHandledRef = React.useRef(false); const normalizedPlans = React.useMemo( - () => ({ - monthly: normalizePlans(subscriptions?.monthly, "monthly"), - annual: normalizePlans(subscriptions?.annual, "annual"), - }), - [subscriptions?.monthly, subscriptions?.annual] + () => normalizePlans(subscriptions?.annual, "annual"), + [subscriptions?.annual] ); - const hasAnyPlan = - (normalizedPlans?.monthly?.length || 0) > 0 || - (normalizedPlans?.annual?.length || 0) > 0; + const hasAnyPlan = (normalizedPlans?.length || 0) > 0; const isLoadingPlans = isCatalogLoading && !hasAnyPlan; const combinedErrorMessage = errorMessage || catalogError; @@ -352,85 +337,67 @@ export default function Payments() { const shouldApplyPack = Boolean(initialSubscriptionPack) && !initialPackHandledRef.current; - let matchedPeriod = null; let matchedPriceId = null; - if (shouldApplyPack && normalizedPlans) { + if (shouldApplyPack && normalizedPlans?.length) { const desired = initialSubscriptionPack.trim().toLowerCase(); - for (const [periodKey, mapping] of Object.entries( - PACK_PRICE_ID_BY_PERIOD - )) { - const candidateId = mapping[desired]; - if (!candidateId) continue; - const exists = (normalizedPlans[periodKey] || []).some( + const candidateId = PACK_PRICE_ID_BY_PERIOD?.annual?.[desired]; + if (candidateId) { + const exists = normalizedPlans.some( (plan) => plan?.priceId === candidateId ); if (exists) { - matchedPeriod = periodKey; matchedPriceId = candidateId; - break; + } + } + + if (!matchedPriceId) { + const matched = normalizedPlans.find((plan) => { + const label = (plan?.product?.name || plan?.nickname || "") + .toString() + .toLowerCase(); + return label.includes(desired); + }); + + if (matched?.priceId) { + matchedPriceId = matched.priceId; } } } - setSelectedPriceIds((current) => { - const next = { ...current }; - - PLAN_SEGMENTS.forEach(({ key }) => { - const periodPlans = normalizedPlans[key] || []; - if (!periodPlans.length) { - next[key] = null; - return; - } - - const alreadySelected = periodPlans.some( - (plan) => plan.priceId === next[key] - ); - - if (shouldApplyPack && matchedPeriod === null) { - const matched = periodPlans.find((plan) => { - const label = (plan?.product?.name || plan?.nickname || "") - .toString() - .toLowerCase(); - return label.includes(initialSubscriptionPack); - }); - - if (matched) { - matchedPeriod = key; - matchedPriceId = matched.priceId; - } - } - - next[key] = alreadySelected ? next[key] : periodPlans[0].priceId; - }); - - if (matchedPeriod && matchedPriceId) { - next[matchedPeriod] = matchedPriceId; + setSelectedPriceId((current) => { + if (matchedPriceId) { + return matchedPriceId; } - return next; + const hasCurrent = normalizedPlans?.some( + (plan) => plan.priceId === current + ); + + if (hasCurrent) { + return current; + } + + return normalizedPlans?.[0]?.priceId || null; }); - if (matchedPeriod && matchedPriceId) { - setBillingPeriod(matchedPeriod); - initialPackHandledRef.current = true; - } else if (shouldApplyPack && !isCatalogLoading) { + if (matchedPriceId || (shouldApplyPack && !isCatalogLoading)) { initialPackHandledRef.current = true; } - }, [normalizedPlans, initialSubscriptionPack, isCatalogLoading]); + }, [ + initialSubscriptionPack, + isCatalogLoading, + normalizedPlans, + ]); - const currentPlans = normalizedPlans[billingPeriod] || []; - const selectedPriceId = selectedPriceIds[billingPeriod]; + const currentPlans = normalizedPlans || []; const handleSelect = React.useCallback( (priceId) => { - setSelectedPriceIds((current) => ({ - ...current, - [billingPeriod]: priceId, - })); + setSelectedPriceId(priceId); setProcessingPriceId(null); }, - [billingPeriod] + [] ); const handleCheckout = React.useCallback(async () => { @@ -443,7 +410,7 @@ export default function Payments() { try { await createSubscriptionCheckout(selectedPriceId); } catch (error) { - console.error("[Payments] checkout error", error); + console.error("[Subscriptions] checkout error", error); setErrorMessage( error?.message || "Une erreur est survenue lors de la création de la session Stripe." @@ -453,26 +420,6 @@ export default function Payments() { } }, [selectedPriceId, createSubscriptionCheckout]); - React.useEffect(() => { - setSelectedPriceIds((current) => { - if (current[billingPeriod]) { - return current; - } - const fallback = currentPlans[0]?.priceId || null; - if (!fallback) { - return current; - } - return { - ...current, - [billingPeriod]: fallback, - }; - }); - }, [billingPeriod, currentPlans]); - - React.useEffect(() => { - setProcessingPriceId(null); - }, [billingPeriod]); - const isProcessing = Boolean(processingPriceId); const isActionDisabled = !selectedPriceId || isProcessing || isLoadingPlans; const actionButtonTitle = isProcessing @@ -509,41 +456,7 @@ export default function Payments() { ); }, [combinedErrorMessage]); - const renderSegmentedControl = React.useCallback(() => { - return ( - - {PLAN_SEGMENTS.map(({ key, label }) => { - const isActive = billingPeriod === key; - return ( - setBillingPeriod(key)} - style={[ - styles.segmentButton, - isActive && styles.segmentButtonActive, - ]} - accessibilityRole="button" - accessibilityState={{ selected: isActive }} - > - - {label} - - - ); - })} - - ); - }, [billingPeriod, isMobile]); + const renderSegmentedControl = React.useCallback(() => null, []); const renderMobilePlanItem = React.useCallback( ({ item }) => ( diff --git a/src/utils/likes.js b/src/utils/likes.js index b0bfd47..c0f8318 100644 --- a/src/utils/likes.js +++ b/src/utils/likes.js @@ -1,4 +1,10 @@ -import { arrayRemove, arrayUnion, projectsRef } from "../config/firebase"; +import { + arrayRemove, + arrayUnion, + deleteField, + projectsRef, + serverTimestamp, +} from "../config/firebase"; import { ensureAuthenticated } from "./authRedirect"; export const LIKE_TARGET = { @@ -10,6 +16,16 @@ export const getLikeFieldPath = (target = LIKE_TARGET.SONG) => { return target === LIKE_TARGET.PLAYBACK ? "likes.playback" : "likes.song"; }; +const getLikeTimestampPath = (target = LIKE_TARGET.SONG) => { + if (target === LIKE_TARGET.PLAYBACK) { + return "likes.playbackLikedAt"; + } + if (target === LIKE_TARGET.SONG) { + return "likes.songLikedAt"; + } + return null; +}; + export const getProjectLikes = (project, target = LIKE_TARGET.SONG) => { const path = target === LIKE_TARGET.PLAYBACK ? "playback" : "song"; const likes = project?.likes; @@ -46,17 +62,44 @@ export const toggleProjectLike = async ({ }) => { if (!projectId) return; if (!ensureAuthenticated(currentUID)) return; - const fieldPath = getLikeFieldPath(target); + const likeFieldKey = + target === LIKE_TARGET.PLAYBACK ? "playback" : "song"; const likeOperation = next ? arrayUnion(currentUID) : arrayRemove(currentUID); + const likedAtKey = + target === LIKE_TARGET.PLAYBACK + ? "playbackLikedAt" + : target === LIKE_TARGET.SONG + ? "songLikedAt" + : null; + const likesPayload = { + likes: { + [likeFieldKey]: likeOperation, + ...(likedAtKey && currentUID + ? { + [likedAtKey]: { + [currentUID]: next ? serverTimestamp() : deleteField(), + }, + } + : {}), + }, + }; + const cleanupPayload = + target === LIKE_TARGET.PLAYBACK + ? { + // Remove any malformed top-level "likes.playback" key if it exists + ["likes.playback"]: deleteField(), + } + : {}; await projectsRef.doc(projectId).set( { - [fieldPath]: likeOperation, ...(target === LIKE_TARGET.SONG ? { // Keep legacy likedBy in sync for clients still reading this field likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), } : {}), + ...likesPayload, + ...cleanupPayload, }, { merge: true } );