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 }
);