clear last tickets
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 574 KiB |
@@ -11,12 +11,22 @@ const {
|
|||||||
} = require("./shared");
|
} = require("./shared");
|
||||||
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
|
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(
|
const processAnnualSubscriptionAllowances = onSchedule(
|
||||||
{
|
{
|
||||||
schedule: "30 3 * * *",
|
schedule: "30 3 * * *",
|
||||||
timeZone: "Europe/Paris",
|
timeZone: "Europe/Paris",
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
|
if (!ENABLE_ANNUAL_GRANT_SCHEDULER) {
|
||||||
|
console.log(
|
||||||
|
"[subscription-processAnnualSubscriptionAllowances] skipped (disabled)",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nowTimestamp = admin.firestore.Timestamp.now();
|
const nowTimestamp = admin.firestore.Timestamp.now();
|
||||||
const pageSize = 200;
|
const pageSize = 200;
|
||||||
let lastDoc = null;
|
let lastDoc = null;
|
||||||
|
|||||||
@@ -14,15 +14,11 @@ const {
|
|||||||
computeNextGrantTimestamp,
|
computeNextGrantTimestamp,
|
||||||
parseCoinsPerMonth,
|
parseCoinsPerMonth,
|
||||||
getSubscriptionMetaFromPrice,
|
getSubscriptionMetaFromPrice,
|
||||||
formatSubscriptionForClient,
|
|
||||||
buildSubscriptionPayload,
|
buildSubscriptionPayload,
|
||||||
resolveUserContext,
|
resolveUserContext,
|
||||||
upsertPaymentDocument,
|
upsertPaymentDocument,
|
||||||
} = require("./shared");
|
} = require("./shared");
|
||||||
const {
|
const { PREMIUM_SUBSCRIPTION_STATUSES } = require("./constants");
|
||||||
SUBSCRIPTION_LEVEL_ALLOWANCES,
|
|
||||||
PREMIUM_SUBSCRIPTION_STATUSES,
|
|
||||||
} = require("./constants");
|
|
||||||
|
|
||||||
const handleCheckoutSessionCompleted = async (
|
const handleCheckoutSessionCompleted = async (
|
||||||
session,
|
session,
|
||||||
@@ -250,7 +246,8 @@ const handleCustomerSubscriptionEvent = async (
|
|||||||
|
|
||||||
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
|
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
|
||||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
|
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 resolvedLevel = metadataLevel || priceMeta?.level || null;
|
||||||
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
|
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
|
||||||
|
|
||||||
@@ -457,6 +454,126 @@ const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await userRef.set(userUpdate, { merge: true });
|
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 }) => {
|
const handleStripeWebhookEvent = async ({ event, stripe }) => {
|
||||||
@@ -517,7 +634,10 @@ const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
stripe = getStripeClient();
|
stripe = getStripeClient();
|
||||||
} catch (error) {
|
} 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");
|
res.status(500).send("Client Stripe indisponible");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={[styles.container, style]}
|
||||||
|
>
|
||||||
|
<CreditAmount
|
||||||
|
value={coinBalance}
|
||||||
|
style={styles.content}
|
||||||
|
textStyle={[styles.text, textStyle]}
|
||||||
|
iconSize={iconSize}
|
||||||
|
iconPosition={iconPosition}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
<CoinPackModal visible={isModalVisible} onClose={handleClose} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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_BASE_PRICE_PER_COIN = 12; // ~0,12€ par jeton (valeur indicatif pour l'affichage)
|
||||||
const FALLBACK_DISCOUNT_STEPS = [0, 12, 18, 26, 32];
|
const FALLBACK_DISCOUNT_STEPS = [0, 12, 18, 26, 32];
|
||||||
|
const STATIC_DISCOUNTS = [0, 30, 50];
|
||||||
|
|
||||||
const computeCoinPackPricing = (packs = []) => {
|
const computeCoinPackPricing = (packs = []) => {
|
||||||
if (!Array.isArray(packs) || !packs.length) {
|
if (!Array.isArray(packs) || !packs.length) {
|
||||||
@@ -61,9 +62,7 @@ const computeCoinPackPricing = (packs = []) => {
|
|||||||
return {
|
return {
|
||||||
...pack,
|
...pack,
|
||||||
hasValidPrice,
|
hasValidPrice,
|
||||||
pricePerCoin: hasValidPrice
|
pricePerCoin: hasValidPrice ? pack.unitAmount / pack.coinAmount : null,
|
||||||
? pack.unitAmount / pack.coinAmount
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,8 +145,7 @@ const computeCoinPackPricing = (packs = []) => {
|
|||||||
discountPercent,
|
discountPercent,
|
||||||
isBasePack,
|
isBasePack,
|
||||||
isBestValue:
|
isBestValue:
|
||||||
(bestDiscountPack &&
|
(bestDiscountPack && bestDiscountPack.productId === pack.productId) ||
|
||||||
bestDiscountPack.productId === pack.productId) ||
|
|
||||||
(!bestDiscountPack && fallbackBestId === 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(() => {
|
const handleSelect = React.useCallback(() => {
|
||||||
if (typeof onSelect !== "function" || !pack?.productId) {
|
if (typeof onSelect !== "function" || !pack?.productId) {
|
||||||
return;
|
return;
|
||||||
@@ -166,7 +170,16 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect }) {
|
|||||||
const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency);
|
const formattedPrice = formatCurrency(pack?.unitAmount, pack?.currency);
|
||||||
const perCoinPrice = pricingDetail?.formattedPricePerCoin;
|
const perCoinPrice = pricingDetail?.formattedPricePerCoin;
|
||||||
const discountPercent = pricingDetail?.discountPercent;
|
const discountPercent = pricingDetail?.discountPercent;
|
||||||
const discountLabel = pricingDetail?.isBasePack
|
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)"
|
? "Pack de base (référence)"
|
||||||
: typeof discountPercent === "number"
|
: typeof discountPercent === "number"
|
||||||
? `-${discountPercent}% vs pack de base`
|
? `-${discountPercent}% vs pack de base`
|
||||||
@@ -184,6 +197,11 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect }) {
|
|||||||
accessibilityState={{ selected }}
|
accessibilityState={{ selected }}
|
||||||
>
|
>
|
||||||
<BlurView intensity={20} tint="dark" style={styles.cardBlur}>
|
<BlurView intensity={20} tint="dark" style={styles.cardBlur}>
|
||||||
|
{/*{pricingDetail?.isBestValue ? (*/}
|
||||||
|
{/* <View style={styles.tagBestValue}>*/}
|
||||||
|
{/* <Text style={styles.tagBestValueText}>Meilleure offre</Text>*/}
|
||||||
|
{/* </View>*/}
|
||||||
|
{/*) : null}*/}
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
<CreditAmount
|
<CreditAmount
|
||||||
value={pack?.coinAmount}
|
value={pack?.coinAmount}
|
||||||
@@ -191,55 +209,39 @@ function CoinPackCard({ pack, selected, pricingDetail, onSelect }) {
|
|||||||
textStyle={styles.coinAmount}
|
textStyle={styles.coinAmount}
|
||||||
iconSize={26}
|
iconSize={26}
|
||||||
/>
|
/>
|
||||||
|
{discountLabel ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: -5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.discountBadge,
|
||||||
|
isBaseReference && styles.discountBadgeBase,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.discountText]}>{discountLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
|
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
|
||||||
{pricingDetail?.isBestValue ? (
|
|
||||||
<View style={styles.tagBestValue}>
|
|
||||||
<Text style={styles.tagBestValueText}>Meilleure offre</Text>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
<View style={styles.perksList}>
|
||||||
|
<Text style={styles.perkItem}>
|
||||||
|
- Diffusion sur mes plates formes de streaming
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.perkItem}>
|
||||||
|
*Eligible au Hit parade Chanson/Video
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{pack?.description ? (
|
|
||||||
<Text style={styles.packDescription}>{pack.description}</Text>
|
|
||||||
) : null}
|
|
||||||
<View style={styles.priceBlock}>
|
<View style={styles.priceBlock}>
|
||||||
{formattedPrice ? (
|
{formattedPrice ? (
|
||||||
<Text style={styles.packPrice}>{formattedPrice}</Text>
|
<Text style={styles.packPrice}>{formattedPrice}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Text style={styles.pricePerCoin} numberOfLines={1}>
|
|
||||||
{perCoinPrice
|
|
||||||
? `≈ ${perCoinPrice} / jeton`
|
|
||||||
: "Valeurs indicatives / jeton"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
{discountLabel ? (
|
|
||||||
<View style={styles.discountRow}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.discountBadge,
|
|
||||||
pricingDetail?.isBestValue && styles.discountBadgeBest,
|
|
||||||
pricingDetail?.isBasePack && styles.discountBadgeBase,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.discountText,
|
|
||||||
pricingDetail?.isBestValue && styles.discountTextBest,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{discountLabel}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{!pricingDetail?.isBasePack && typeof discountPercent === "number" ? (
|
|
||||||
<Text style={styles.discountHelper}>
|
|
||||||
Économie estimée vs pack de base
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.discountHelperMuted}>
|
|
||||||
Référence prix/jeton
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</BlurView>
|
</BlurView>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -359,13 +361,14 @@ const CoinPackModal = ({ visible, onClose }) => {
|
|||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{coinPacks.map((pack) => (
|
{coinPacks.map((pack, index) => (
|
||||||
<CoinPackCard
|
<CoinPackCard
|
||||||
key={pack.productId}
|
key={pack.productId}
|
||||||
pack={pack}
|
pack={pack}
|
||||||
selected={selectedPackId === pack.productId}
|
selected={selectedPackId === pack.productId}
|
||||||
pricingDetail={packPricingById[pack.productId]}
|
pricingDetail={packPricingById[pack.productId]}
|
||||||
onSelect={setSelectedPackId}
|
onSelect={setSelectedPackId}
|
||||||
|
staticDiscountPercent={STATIC_DISCOUNTS[index]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -564,6 +567,16 @@ const styles = StyleSheet.create({
|
|||||||
color: "rgba(255, 255, 255, 0.72)",
|
color: "rgba(255, 255, 255, 0.72)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
perksList: {
|
||||||
|
gap: 6,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
perkItem: {
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "rgba(255, 255, 255, 0.72)",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
priceBlock: {
|
priceBlock: {
|
||||||
gap: 2,
|
gap: 2,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -580,10 +593,6 @@ const styles = StyleSheet.create({
|
|||||||
color: "rgba(255, 255, 255, 0.7)",
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
discountRow: {
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
discountBadge: {
|
discountBadge: {
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
@@ -626,6 +635,9 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
backgroundColor: Palette.transparentGreen,
|
backgroundColor: Palette.transparentGreen,
|
||||||
|
alignSelf: "center",
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
},
|
},
|
||||||
tagBestValueText: {
|
tagBestValueText: {
|
||||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
|||||||
@@ -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<void>
|
||||||
|
onPause, // () => void | Promise<void>
|
||||||
|
onPlay, // () => void | Promise<void>
|
||||||
|
onSeekStart, // () => void | Promise<void>
|
||||||
|
onSeekEnd, // () => void | Promise<void>
|
||||||
|
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 (
|
||||||
|
<Slider
|
||||||
|
value={formatTime(
|
||||||
|
isSeeking && previewRatio != null
|
||||||
|
? safeDuration * previewRatio
|
||||||
|
: safePosition,
|
||||||
|
)}
|
||||||
|
maxValue={formatTime(safeDuration)}
|
||||||
|
progress={progress}
|
||||||
|
seekEnabled={!disabled && safeDuration > 0}
|
||||||
|
onSeekStart={handleSeekStart}
|
||||||
|
onSeek={handleSeek}
|
||||||
|
onSeekEnd={handleSeekEnd}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProgressSlider;
|
||||||
@@ -83,6 +83,7 @@ export const reportsRef = firestore.collection("reports");
|
|||||||
|
|
||||||
export const { arrayUnion, arrayRemove, increment, serverTimestamp } =
|
export const { arrayUnion, arrayRemove, increment, serverTimestamp } =
|
||||||
firebase.firestore.FieldValue;
|
firebase.firestore.FieldValue;
|
||||||
|
export const deleteField = firebase.firestore.FieldValue.delete;
|
||||||
|
|
||||||
export const getFunctionsClient = (region = "us-central1") => {
|
export const getFunctionsClient = (region = "us-central1") => {
|
||||||
const regionKey = region || "us-central1";
|
const regionKey = region || "us-central1";
|
||||||
|
|||||||
@@ -49,20 +49,6 @@ export default function useUserLikedProjects() {
|
|||||||
|
|
||||||
const projects = useMemo(() => {
|
const projects = useMemo(() => {
|
||||||
const byId = new Map();
|
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) => {
|
const toTimestamp = (value) => {
|
||||||
if (!value) return 0;
|
if (!value) return 0;
|
||||||
if (typeof value.toDate === "function") {
|
if (typeof value.toDate === "function") {
|
||||||
@@ -76,6 +62,30 @@ export default function useUserLikedProjects() {
|
|||||||
}
|
}
|
||||||
return 0;
|
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(
|
return withUpdatedAt.sort(
|
||||||
(a, b) => toTimestamp(b?.updatedAt) - toTimestamp(a?.updatedAt)
|
(a, b) => toTimestamp(b?.updatedAt) - toTimestamp(a?.updatedAt)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import RecordedPlayback from "../screens/Playback/RecordedPlayback";
|
|||||||
import VideoFinalize from "../screens/Playback/VideoFinalize";
|
import VideoFinalize from "../screens/Playback/VideoFinalize";
|
||||||
import PublishYoutube from "../screens/Publishing/PublishYoutube";
|
import PublishYoutube from "../screens/Publishing/PublishYoutube";
|
||||||
import PrivacyPolicy from "../screens/PrivacyPolicy";
|
import PrivacyPolicy from "../screens/PrivacyPolicy";
|
||||||
import Payments from "../screens/Payments";
|
import Subscriptions from "../screens/Subscriptions";
|
||||||
import DownloadPrices from "../screens/Production/DownloadPrices";
|
import DownloadPrices from "../screens/Production/DownloadPrices";
|
||||||
import PlaybackDownload from "../screens/Production/PlaybackDownload";
|
import PlaybackDownload from "../screens/Production/PlaybackDownload";
|
||||||
import PlaybackExample from "../screens/Production/PlaybackExample";
|
import PlaybackExample from "../screens/Production/PlaybackExample";
|
||||||
@@ -121,7 +121,7 @@ const baseScreens = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: Routes.Payments,
|
name: Routes.Payments,
|
||||||
component: Payments,
|
component: Subscriptions,
|
||||||
title: "Abonnements",
|
title: "Abonnements",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,19 +72,46 @@ export default ({ children }) => {
|
|||||||
projects: userLikedProjects = [],
|
projects: userLikedProjects = [],
|
||||||
loading: userLikedProjectsLoading = true,
|
loading: userLikedProjectsLoading = true,
|
||||||
} = useUserLikedProjects();
|
} = 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 {
|
const {
|
||||||
data: userLikedPlaybacks = [],
|
data: userLikedPlaybacks = [],
|
||||||
loading: userLikedPlaybacksLoading = true,
|
loading: userLikedPlaybacksLoading = true,
|
||||||
} = useDataFromRef({
|
} = useDataFromRef({
|
||||||
ref: currentUID
|
ref: currentUID
|
||||||
? projectsRef
|
? projectsRef.where(PLAYBACK_LIKES_FIELD, "array-contains", currentUID)
|
||||||
.where(PLAYBACK_LIKES_FIELD, "array-contains", currentUID)
|
|
||||||
.orderBy("updatedAt", "desc")
|
|
||||||
: null,
|
: null,
|
||||||
simpleRef: false,
|
simpleRef: false,
|
||||||
listener: true,
|
listener: true,
|
||||||
condition: !!currentUID,
|
condition: !!currentUID,
|
||||||
refreshArray: [currentUID],
|
refreshArray: [currentUID],
|
||||||
|
format: sortByPlaybackLikeDate,
|
||||||
});
|
});
|
||||||
// Subscribe to user's playlists
|
// Subscribe to user's playlists
|
||||||
const { data: userPlaylists = [] } = useDataFromRef({
|
const { data: userPlaylists = [] } = useDataFromRef({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
|
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
||||||
import { projectsRef } from "../../config/firebase";
|
import { projectsRef } from "../../config/firebase";
|
||||||
import useDataFromRef from "../../hooks/useDataFromRef";
|
import useDataFromRef from "../../hooks/useDataFromRef";
|
||||||
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
|
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
|
||||||
@@ -20,6 +21,7 @@ import { navigate } from "../../navigation/NavigationService";
|
|||||||
import { Palette, Style } from "../../styles";
|
import { Palette, Style } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
import MobileCoinBadge from "../../components/MobileCoinBadge";
|
||||||
import PlaybacksCard from "./components/PlaybacksCard";
|
import PlaybacksCard from "./components/PlaybacksCard";
|
||||||
import SongCard from "./components/SongCard";
|
import SongCard from "./components/SongCard";
|
||||||
const HitParade = () => {
|
const HitParade = () => {
|
||||||
@@ -51,6 +53,18 @@ const HitParade = () => {
|
|||||||
return [arr.slice(0, 5), arr.slice(5)];
|
return [arr.slice(0, 5), arr.slice(5)];
|
||||||
}, [topPlaybacks]);
|
}, [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 ===
|
// === RENDUS PAR PLATEFORME ===
|
||||||
const renderSongsMobile = () => (
|
const renderSongsMobile = () => (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -91,7 +105,7 @@ const HitParade = () => {
|
|||||||
rank={index + 1}
|
rank={index + 1}
|
||||||
title={item?.title || "Sans titre"}
|
title={item?.title || "Sans titre"}
|
||||||
artist={item?.userName}
|
artist={item?.userName}
|
||||||
coverUrl={item?.coverUrl || null}
|
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
||||||
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -167,7 +181,7 @@ const HitParade = () => {
|
|||||||
rank={idx + 1}
|
rank={idx + 1}
|
||||||
title={item?.title || "Sans titre"}
|
title={item?.title || "Sans titre"}
|
||||||
artist={"MusicLand"}
|
artist={"MusicLand"}
|
||||||
coverUrl={item?.coverUrl || null}
|
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
||||||
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
onPress={() => navigate(Routes.Playbacks, { projectId: item.id })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -182,7 +196,7 @@ const HitParade = () => {
|
|||||||
rank={5 + idx + 1}
|
rank={5 + idx + 1}
|
||||||
title={item?.title || "Sans titre"}
|
title={item?.title || "Sans titre"}
|
||||||
artist={"MusicLand"}
|
artist={"MusicLand"}
|
||||||
coverUrl={item?.coverUrl || null}
|
thumbnailUrl={resolvePlaybackThumbnail(item)}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
navigate(Routes.Playbacks, { projectId: item.id })
|
navigate(Routes.Playbacks, { projectId: item.id })
|
||||||
}
|
}
|
||||||
@@ -221,6 +235,21 @@ const HitParade = () => {
|
|||||||
blurIntensity={isWeb ? 0 : 0}
|
blurIntensity={isWeb ? 0 : 0}
|
||||||
showCoin={false}
|
showCoin={false}
|
||||||
>
|
>
|
||||||
|
{!isWeb ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MobileCoinBadge style={{ flexShrink: 1 }} />
|
||||||
|
<ShareBtn label="Partager" style={{ flexShrink: 0 }} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View style={{ gap: 12, flex: 1 }}>
|
<View style={{ gap: 12, flex: 1 }}>
|
||||||
<Image
|
<Image
|
||||||
source={icons.musicLandLogo}
|
source={icons.musicLandLogo}
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ const PlaybacksCard = ({
|
|||||||
rank = 1,
|
rank = 1,
|
||||||
title = "Sans titre",
|
title = "Sans titre",
|
||||||
artist = "MusicLand",
|
artist = "MusicLand",
|
||||||
|
thumbnailUrl = null,
|
||||||
coverUrl = null,
|
coverUrl = null,
|
||||||
onPress = () => null,
|
onPress = () => null,
|
||||||
}) => {
|
}) => {
|
||||||
|
const resolveUri = (value) =>
|
||||||
|
typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||||
|
const imageUri = resolveUri(thumbnailUrl) ?? resolveUri(coverUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={onPress}>
|
<Pressable onPress={onPress}>
|
||||||
<BlurView
|
<BlurView
|
||||||
@@ -45,7 +50,7 @@ const PlaybacksCard = ({
|
|||||||
{rank}
|
{rank}
|
||||||
</Text>
|
</Text>
|
||||||
<Image
|
<Image
|
||||||
source={coverUrl ? { uri: coverUrl } : img.placeholder3}
|
source={imageUri ? { uri: imageUri } : img.placeholder3}
|
||||||
style={{ width: 67, height: 108, borderRadius: 16 }}
|
style={{ width: 67, height: 108, borderRadius: 16 }}
|
||||||
/>
|
/>
|
||||||
<View>
|
<View>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import SearchBar from "../../components/SearchBar";
|
import SearchBar from "../../components/SearchBar";
|
||||||
|
import MobileCoinBadge from "../../components/MobileCoinBadge";
|
||||||
|
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { navigate } from "../../navigation/NavigationService";
|
import { navigate } from "../../navigation/NavigationService";
|
||||||
@@ -57,6 +59,21 @@ const Library = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page backgroundImg={background.libraryBG} headerType="NONE">
|
<Page backgroundImg={background.libraryBG} headerType="NONE">
|
||||||
|
{Platform.OS !== "web" ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MobileCoinBadge style={{ flexShrink: 1 }} />
|
||||||
|
<ShareBtn label="Partager" style={{ flexShrink: 0 }} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View style={{ gap: 17, paddingBottom: 20 }}>
|
<View style={{ gap: 17, paddingBottom: 20 }}>
|
||||||
<Image
|
<Image
|
||||||
source={icons.musicLandLogo}
|
source={icons.musicLandLogo}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { useGlobal } from "reactn";
|
import { useGlobal } from "reactn";
|
||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import PressableScale from "../../components/PressableScale";
|
import PressableScale from "../../components/PressableScale";
|
||||||
import Slider from "../../components/Slider";
|
import ProgressSlider from "../../components/player/ProgressSlider";
|
||||||
import { increment, projectsRef, usersRef } from "../../config/firebase";
|
import { increment, projectsRef, usersRef } from "../../config/firebase";
|
||||||
import useDataFromRef from "../../hooks/useDataFromRef";
|
import useDataFromRef from "../../hooks/useDataFromRef";
|
||||||
import usePlayer from "../../hooks/usePlayer";
|
import usePlayer from "../../hooks/usePlayer";
|
||||||
@@ -61,9 +61,6 @@ const MusicDetails = ({ route }) => {
|
|||||||
const projectId = params?.projectId || null;
|
const projectId = params?.projectId || null;
|
||||||
const [fav, setFav] = useState(false);
|
const [fav, setFav] = useState(false);
|
||||||
const [currentUID] = useGlobal("currentUID");
|
const [currentUID] = useGlobal("currentUID");
|
||||||
const wasPlayingBeforeSeek = useRef(false);
|
|
||||||
const hasCapturedSeekStateRef = useRef(false);
|
|
||||||
const lastSeekTargetMsRef = useRef(null);
|
|
||||||
const listenedMsRef = useRef(0);
|
const listenedMsRef = useRef(0);
|
||||||
const incrementDoneRef = useRef(false);
|
const incrementDoneRef = useRef(false);
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
@@ -292,15 +289,6 @@ const MusicDetails = ({ route }) => {
|
|||||||
return clearTimer;
|
return clearTimer;
|
||||||
}, [isTrackPlaying, projectId]);
|
}, [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 () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
try {
|
try {
|
||||||
@@ -328,41 +316,30 @@ const MusicDetails = ({ route }) => {
|
|||||||
|
|
||||||
const handleSliderSeekStart = useCallback(async () => {
|
const handleSliderSeekStart = useCallback(async () => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
lastSeekTargetMsRef.current = null;
|
|
||||||
if (!hasCapturedSeekStateRef.current) {
|
|
||||||
hasCapturedSeekStateRef.current = true;
|
|
||||||
wasPlayingBeforeSeek.current = isTrackPlaying;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({ startPositionMs: positionMs, autoPlay: false });
|
await ensureLoaded({ startPositionMs: positionMs, autoPlay: false });
|
||||||
}
|
}
|
||||||
if (isTrackPlaying) {
|
|
||||||
await pauseTrack();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails seek start error", e?.message);
|
console.log("MusicDetails seek start error", e?.message);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
trackDescriptor,
|
trackDescriptor,
|
||||||
isTrackPlaying,
|
|
||||||
isCurrentTrack,
|
isCurrentTrack,
|
||||||
ensureLoaded,
|
ensureLoaded,
|
||||||
positionMs,
|
positionMs,
|
||||||
pauseTrack,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSliderSeek = useCallback(
|
const handleSliderSeek = useCallback(
|
||||||
async (ratio) => {
|
async (targetMs) => {
|
||||||
const dur = sliderDurationMs || 0;
|
const dur = sliderDurationMs || 0;
|
||||||
if (!trackDescriptor || dur <= 0) return;
|
if (!trackDescriptor || dur <= 0) return;
|
||||||
const targetMs = Math.max(0, Math.floor(dur * ratio));
|
const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs)));
|
||||||
lastSeekTargetMsRef.current = targetMs;
|
|
||||||
try {
|
try {
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false });
|
await ensureLoaded({ startPositionMs: bounded, autoPlay: false });
|
||||||
} else {
|
} else {
|
||||||
await seekTrackTo(targetMs);
|
await seekTrackTo(bounded);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails seek error", e?.message);
|
console.log("MusicDetails seek error", e?.message);
|
||||||
@@ -424,37 +401,6 @@ const MusicDetails = ({ route }) => {
|
|||||||
resumeTrack,
|
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(
|
const handleSeekBySeconds = useCallback(
|
||||||
async (deltaSeconds) => {
|
async (deltaSeconds) => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
@@ -888,18 +834,15 @@ const MusicDetails = ({ route }) => {
|
|||||||
</View>
|
</View>
|
||||||
{songUrl && (
|
{songUrl && (
|
||||||
<View style={{ paddingTop: 22 }}>
|
<View style={{ paddingTop: 22 }}>
|
||||||
<Slider
|
<ProgressSlider
|
||||||
value={fmt(positionMs)}
|
positionMs={positionMs}
|
||||||
maxValue={fmt(sliderDurationMs)}
|
durationMs={sliderDurationMs}
|
||||||
progress={
|
isPlaying={isTrackPlaying}
|
||||||
sliderDurationMs
|
|
||||||
? Math.min(1, Math.max(0, (positionMs || 0) / sliderDurationMs))
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
seekEnabled={!!songUrl}
|
|
||||||
onSeekStart={handleSliderSeekStart}
|
onSeekStart={handleSliderSeekStart}
|
||||||
onSeek={handleSliderSeek}
|
onSeek={handleSliderSeek}
|
||||||
onSeekEnd={handleSliderSeekEnd}
|
onPause={pauseTrack}
|
||||||
|
onPlay={resumeTrack}
|
||||||
|
disabled={!songUrl}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{ ...Style.containerRow, gap: 24, alignSelf: "center" }}
|
style={{ ...Style.containerRow, gap: 24, alignSelf: "center" }}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
import { useGlobal } from "reactn";
|
import { useGlobal } from "reactn";
|
||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import PressableScale from "../../components/PressableScale";
|
import PressableScale from "../../components/PressableScale";
|
||||||
import Slider from "../../components/Slider";
|
import ProgressSlider from "../../components/player/ProgressSlider";
|
||||||
import { increment, projectsRef, usersRef } from "../../config/firebase";
|
import { increment, projectsRef, usersRef } from "../../config/firebase";
|
||||||
import { ensureAuthenticated } from "../../utils/authRedirect";
|
import { ensureAuthenticated } from "../../utils/authRedirect";
|
||||||
import useDataFromRef from "../../hooks/useDataFromRef";
|
import useDataFromRef from "../../hooks/useDataFromRef";
|
||||||
@@ -66,8 +66,6 @@ const MusicDetails = ({ route }) => {
|
|||||||
const [fav, setFav] = useState(false);
|
const [fav, setFav] = useState(false);
|
||||||
const [currentUID] = useGlobal("currentUID");
|
const [currentUID] = useGlobal("currentUID");
|
||||||
const [, setTooltip] = useGlobal("_tooltip");
|
const [, setTooltip] = useGlobal("_tooltip");
|
||||||
const wasPlayingBeforeSeek = React.useRef(false);
|
|
||||||
const lastSeekTargetMs = React.useRef(null);
|
|
||||||
const listenedMsRef = React.useRef(0);
|
const listenedMsRef = React.useRef(0);
|
||||||
const incrementDoneRef = React.useRef(false);
|
const incrementDoneRef = React.useRef(false);
|
||||||
const timerRef = React.useRef(null);
|
const timerRef = React.useRef(null);
|
||||||
@@ -394,15 +392,6 @@ const MusicDetails = ({ route }) => {
|
|||||||
return clearTimer;
|
return clearTimer;
|
||||||
}, [isPlaying, projectId]);
|
}, [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 () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
try {
|
try {
|
||||||
@@ -434,54 +423,32 @@ const MusicDetails = ({ route }) => {
|
|||||||
const handleSliderSeekStart = useCallback(async () => {
|
const handleSliderSeekStart = useCallback(async () => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
try {
|
try {
|
||||||
console.log("[MusicDetails.web] handleSliderSeekStart", {
|
|
||||||
isCurrentTrack,
|
|
||||||
isTrackPlaying,
|
|
||||||
positionMs,
|
|
||||||
durationMs,
|
|
||||||
});
|
|
||||||
lastSeekTargetMs.current = null;
|
|
||||||
wasPlayingBeforeSeek.current = isTrackPlaying;
|
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({
|
await ensureLoaded({
|
||||||
startPositionMs: positionMs,
|
startPositionMs: positionMs,
|
||||||
autoPlay: false,
|
autoPlay: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isTrackPlaying) {
|
|
||||||
await pauseTrack();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails seek start error", e?.message);
|
console.log("MusicDetails seek start error", e?.message);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
trackDescriptor,
|
trackDescriptor,
|
||||||
isTrackPlaying,
|
|
||||||
isCurrentTrack,
|
isCurrentTrack,
|
||||||
ensureLoaded,
|
ensureLoaded,
|
||||||
positionMs,
|
positionMs,
|
||||||
pauseTrack,
|
|
||||||
lastSeekTargetMs,
|
|
||||||
durationMs,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSliderSeek = useCallback(
|
const handleSliderSeek = useCallback(
|
||||||
async (ratio) => {
|
async (targetMs) => {
|
||||||
const dur = sliderDurationMs || 0;
|
const dur = sliderDurationMs || 0;
|
||||||
if (!trackDescriptor || dur <= 0) return;
|
if (!trackDescriptor || dur <= 0) return;
|
||||||
const targetMs = Math.max(0, Math.floor(dur * ratio));
|
const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs)));
|
||||||
lastSeekTargetMs.current = targetMs;
|
|
||||||
console.log("[MusicDetails.web] handleSliderSeek", {
|
|
||||||
ratio,
|
|
||||||
durationMs: dur,
|
|
||||||
targetMs,
|
|
||||||
isCurrentTrack,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false });
|
await ensureLoaded({ startPositionMs: bounded, autoPlay: false });
|
||||||
} else {
|
} else {
|
||||||
await seekTrackTo(targetMs);
|
await seekTrackTo(bounded);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails seek error", e?.message);
|
console.log("MusicDetails seek error", e?.message);
|
||||||
@@ -553,42 +520,6 @@ const MusicDetails = ({ route }) => {
|
|||||||
trackDescriptor,
|
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(
|
const handleSeekBySeconds = useCallback(
|
||||||
async (deltaSeconds) => {
|
async (deltaSeconds) => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
@@ -1140,16 +1071,15 @@ const MusicDetails = ({ route }) => {
|
|||||||
/>
|
/>
|
||||||
</PressableScale>
|
</PressableScale>
|
||||||
</View>
|
</View>
|
||||||
<Slider
|
<ProgressSlider
|
||||||
value={fmt(positionMs)}
|
positionMs={positionMs}
|
||||||
maxValue={fmt(sliderDurationMs)}
|
durationMs={sliderDurationMs}
|
||||||
progress={
|
isPlaying={isPlaying}
|
||||||
sliderDurationMs ? (positionMs || 0) / sliderDurationMs : 0
|
|
||||||
}
|
|
||||||
seekEnabled={!!songUrl}
|
|
||||||
onSeekStart={handleSliderSeekStart}
|
onSeekStart={handleSliderSeekStart}
|
||||||
onSeek={handleSliderSeek}
|
onSeek={handleSliderSeek}
|
||||||
onSeekEnd={handleSliderSeekEnd}
|
onPause={pauseTrack}
|
||||||
|
onPlay={resumeTrack}
|
||||||
|
disabled={!songUrl}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Image as ExpoImage } from "expo-image";
|
import { Image as ExpoImage } from "expo-image";
|
||||||
import React, { useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -33,6 +33,29 @@ const LikedMusic = () => {
|
|||||||
Math.floor((containerWidth - gap * (numColumns - 1)) / numColumns)
|
Math.floor((containerWidth - gap * (numColumns - 1)) / numColumns)
|
||||||
);
|
);
|
||||||
const navigateToMusicDetails = useNavigateToMusicDetails();
|
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 (
|
return (
|
||||||
<CardContainer
|
<CardContainer
|
||||||
label="Musiques likées"
|
label="Musiques likées"
|
||||||
@@ -43,7 +66,7 @@ const LikedMusic = () => {
|
|||||||
liked: true,
|
liked: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onPressPlus={() => navigate(Routes.WritingLyrics)}
|
onPressPlus={() => navigate(Routes.HitParade)}
|
||||||
>
|
>
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -66,9 +89,9 @@ const LikedMusic = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item?.coverUrl ? (
|
{resolveCoverUri(item) ? (
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
source={{ uri: item.coverUrl }}
|
source={{ uri: resolveCoverUri(item) }}
|
||||||
cachePolicy="memory-disk"
|
cachePolicy="memory-disk"
|
||||||
priority="high"
|
priority="high"
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const LikedPlayback = () => {
|
|||||||
liked: true,
|
liked: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onPressPlus={() => navigate(Routes.WritingLyrics)}
|
onPressPlus={() => navigate(Routes.Playbacks)}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { FONT_FAMILY } from "../../styles/Fonts";
|
|||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
|
||||||
const TIME_BEFORE_INCREMENT_MS = 20000; // 20s
|
const TIME_BEFORE_INCREMENT_MS = 20000; // 20s
|
||||||
|
const COUNTDOWN_SECONDS = 10;
|
||||||
const LOG_PREFIX = "[RecordPlayback]";
|
const LOG_PREFIX = "[RecordPlayback]";
|
||||||
const log =
|
const log =
|
||||||
typeof __DEV__ === "undefined" || __DEV__
|
typeof __DEV__ === "undefined" || __DEV__
|
||||||
@@ -410,7 +411,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
}
|
}
|
||||||
setIsPreparing(true);
|
setIsPreparing(true);
|
||||||
setShowProgress(false);
|
setShowProgress(false);
|
||||||
setCountdown(5);
|
setCountdown(COUNTDOWN_SECONDS);
|
||||||
|
|
||||||
// On démarre l'intervalle puis on active le flag
|
// On démarre l'intervalle puis on active le flag
|
||||||
if (countdownTimerRef.current) {
|
if (countdownTimerRef.current) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import RestartSpinnerIcon from "../../assets/UI/RestartSpinnerIcon";
|
|||||||
import { registerBlobUrl, releaseBlobUrl } from "../../utils/blobUrlCache";
|
import { registerBlobUrl, releaseBlobUrl } from "../../utils/blobUrlCache";
|
||||||
|
|
||||||
const TIME_BEFORE_INCREMENT_MS = 20000;
|
const TIME_BEFORE_INCREMENT_MS = 20000;
|
||||||
|
const COUNTDOWN_SECONDS = __DEV__ ? 1 : 10;
|
||||||
const WEB_PREVIEW_WIDTH = 360;
|
const WEB_PREVIEW_WIDTH = 360;
|
||||||
|
|
||||||
const toSeconds = (v) => {
|
const toSeconds = (v) => {
|
||||||
@@ -318,7 +319,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
setLooping(!!originalLoopingValueRef.current.value);
|
setLooping(!!originalLoopingValueRef.current.value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [setLooping])
|
}, [setLooping]),
|
||||||
);
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -327,7 +328,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
void resetSessionRef.current?.();
|
void resetSessionRef.current?.();
|
||||||
};
|
};
|
||||||
}, [])
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -359,7 +360,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
!navigator.mediaDevices?.getUserMedia
|
!navigator.mediaDevices?.getUserMedia
|
||||||
) {
|
) {
|
||||||
setMediaError(
|
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);
|
setMediaReady(false);
|
||||||
return;
|
return;
|
||||||
@@ -473,7 +474,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
perfStartRef.current != null
|
perfStartRef.current != null
|
||||||
? Math.max(
|
? Math.max(
|
||||||
0,
|
0,
|
||||||
(performance.now() - perfStartRef.current - pausedTotal) / 1000
|
(performance.now() - perfStartRef.current - pausedTotal) / 1000,
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
@@ -588,7 +589,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{ cancelable: false }
|
{ cancelable: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,7 +604,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
isPausedRef.current = false;
|
isPausedRef.current = false;
|
||||||
setIsPreparing(true);
|
setIsPreparing(true);
|
||||||
setCountdown(5);
|
setCountdown(COUNTDOWN_SECONDS);
|
||||||
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
|
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
|
||||||
countdownTimerRef.current = setInterval(() => {
|
countdownTimerRef.current = setInterval(() => {
|
||||||
setCountdown((c) => {
|
setCountdown((c) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { background, icons } from "../../assets";
|
|||||||
import BorderGradientButton from "../../components/BorderGradientButton";
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
import Slider from "../../components/Slider";
|
import ProgressSlider from "../../components/player/ProgressSlider";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||||
@@ -34,19 +34,8 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
dur: 0,
|
dur: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
});
|
});
|
||||||
const wasPlayingBeforeSeek = useRef(false);
|
|
||||||
const playbackEndedRef = 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
|
// Start both players on mount
|
||||||
const stopPlayback = useCallback(async () => {
|
const stopPlayback = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -94,32 +83,37 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
return () => global.clearInterval(id);
|
return () => global.clearInterval(id);
|
||||||
}, [audioPlayer, videoPlayer]);
|
}, [audioPlayer, videoPlayer]);
|
||||||
|
|
||||||
const onSeek = async (ratio) => {
|
const onSeek = async (targetMs) => {
|
||||||
try {
|
try {
|
||||||
const dur = progressInfo.dur || 0;
|
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)
|
if (audioPlayer && dur > 0)
|
||||||
await audioPlayer.seekTo?.(Math.floor(pos / 1000));
|
await audioPlayer.seekTo?.(Math.floor(pos / 1000));
|
||||||
if (videoPlayer) videoPlayer.currentTime = Math.max(0, pos / 1000);
|
if (videoPlayer) videoPlayer.currentTime = Math.max(0, pos / 1000);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSeekStart = async () => {
|
const onSeekStart = useCallback(() => {
|
||||||
try {
|
|
||||||
wasPlayingBeforeSeek.current = !!audioPlayer?.playing;
|
|
||||||
playbackEndedRef.current = false;
|
playbackEndedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pauseDuringSeek = useCallback(async () => {
|
||||||
|
try {
|
||||||
if (audioPlayer?.playing) await audioPlayer.pause?.();
|
if (audioPlayer?.playing) await audioPlayer.pause?.();
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
if (videoPlayer?.playing) videoPlayer.pause();
|
if (videoPlayer?.playing) videoPlayer.pause();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
}, [audioPlayer, videoPlayer]);
|
||||||
const onSeekEnd = async () => {
|
|
||||||
|
const resumeAfterSeek = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (wasPlayingBeforeSeek.current) {
|
|
||||||
if (audioPlayer) await audioPlayer.play?.();
|
if (audioPlayer) await audioPlayer.play?.();
|
||||||
if (videoPlayer) videoPlayer.play();
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
try {
|
||||||
|
if (videoPlayer) videoPlayer.play();
|
||||||
|
} catch (e) {}
|
||||||
|
}, [audioPlayer, videoPlayer]);
|
||||||
|
|
||||||
const sliderProgress = useMemo(() => {
|
const sliderProgress = useMemo(() => {
|
||||||
return progressInfo.dur
|
return progressInfo.dur
|
||||||
@@ -190,14 +184,15 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Slider
|
<ProgressSlider
|
||||||
value={fmt(progressInfo.pos)}
|
positionMs={progressInfo.pos}
|
||||||
maxValue={fmt(progressInfo.dur)}
|
durationMs={progressInfo.dur}
|
||||||
progress={sliderProgress}
|
isPlaying={progressInfo.isPlaying}
|
||||||
seekEnabled={!!songUrl}
|
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
onSeekStart={onSeekStart}
|
onSeekStart={onSeekStart}
|
||||||
onSeekEnd={onSeekEnd}
|
onPause={pauseDuringSeek}
|
||||||
|
onPlay={resumeAfterSeek}
|
||||||
|
disabled={!songUrl}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleTogglePlayback}
|
onPress={handleTogglePlayback}
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
const sliderProgress = useMemo(() => {
|
const sliderProgress = useMemo(() => {
|
||||||
return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0;
|
return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0;
|
||||||
}, [progress]);
|
}, [progress]);
|
||||||
|
const pendingSeekRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const duration = progress?.durS || 0;
|
const duration = progress?.durS || 0;
|
||||||
@@ -154,22 +155,48 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
const remaining = Math.max(0, duration - position);
|
const remaining = Math.max(0, duration - position);
|
||||||
if (remaining <= 0.35 && !playbackEndedRef.current) {
|
if (remaining <= 0.35 && !playbackEndedRef.current) {
|
||||||
playbackEndedRef.current = true;
|
playbackEndedRef.current = true;
|
||||||
void stopPlayback();
|
void (async () => {
|
||||||
}
|
await stopPlayback();
|
||||||
}, [progress, stopPlayback]);
|
|
||||||
|
|
||||||
const onSeek = async (ratio) => {
|
|
||||||
try {
|
try {
|
||||||
|
if (audioPlayer) await audioPlayer.seekTo?.(0);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
if (videoElRef.current) {
|
||||||
|
videoElRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}, [progress, stopPlayback, audioPlayer]);
|
||||||
|
|
||||||
|
const onSeek = useCallback(
|
||||||
|
async (ratio) => {
|
||||||
const durS = Number(progress.durS || 0);
|
const durS = Number(progress.durS || 0);
|
||||||
const target = durS * ratio; // secondes
|
const target = durS > 0 ? durS * ratio : 0; // secondes
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
if (audioPlayer && durS > 0) {
|
if (audioPlayer && durS > 0) {
|
||||||
await audioPlayer.seekTo?.(Math.floor(target)); // seek en secondes
|
await audioPlayer.seekTo?.(Math.max(0, target));
|
||||||
}
|
}
|
||||||
if (videoElRef.current && videoUri) {
|
if (videoElRef.current && videoUri) {
|
||||||
videoElRef.current.currentTime = Math.max(0, target);
|
videoElRef.current.currentTime = Math.max(0, target);
|
||||||
}
|
}
|
||||||
} catch {}
|
} 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 wasPlayingRef = useRef(false);
|
||||||
const isSeekingRef = useRef(false);
|
const isSeekingRef = useRef(false);
|
||||||
@@ -178,6 +205,7 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
if (isSeekingRef.current) return;
|
if (isSeekingRef.current) return;
|
||||||
isSeekingRef.current = true;
|
isSeekingRef.current = true;
|
||||||
wasPlayingRef.current = !!audioPlayer?.playing;
|
wasPlayingRef.current = !!audioPlayer?.playing;
|
||||||
|
pendingSeekRef.current = null;
|
||||||
playbackEndedRef.current = false;
|
playbackEndedRef.current = false;
|
||||||
if (audioPlayer?.playing) await audioPlayer.pause?.();
|
if (audioPlayer?.playing) await audioPlayer.pause?.();
|
||||||
if (videoElRef.current && !videoElRef.current.paused) {
|
if (videoElRef.current && !videoElRef.current.paused) {
|
||||||
@@ -188,14 +216,15 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
const onSeekEnd = useCallback(async () => {
|
const onSeekEnd = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!isSeekingRef.current) return;
|
if (!isSeekingRef.current) return;
|
||||||
|
await waitForPendingSeek();
|
||||||
isSeekingRef.current = false;
|
isSeekingRef.current = false;
|
||||||
if (wasPlayingRef.current) {
|
if (wasPlayingRef.current) {
|
||||||
if (audioPlayer) await audioPlayer.play?.();
|
if (audioPlayer) await audioPlayer.resume?.();
|
||||||
if (videoElRef.current && videoUri)
|
if (videoElRef.current && videoUri)
|
||||||
videoElRef.current.play().catch(() => {});
|
videoElRef.current.play().catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [audioPlayer, videoUri]);
|
}, [audioPlayer, videoUri, waitForPendingSeek]);
|
||||||
|
|
||||||
const handleTogglePlayback = useCallback(async () => {
|
const handleTogglePlayback = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,25 +35,48 @@ import { Image as ExpoImage } from "expo-image";
|
|||||||
import SubscriptionConfirmModal from "../../components/SubscriptionConfirmModal";
|
import SubscriptionConfirmModal from "../../components/SubscriptionConfirmModal";
|
||||||
import { isWeb } from "../../hooks/useLayoutType";
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
|
|
||||||
const triggerWebDownload = (url, title) => {
|
const triggerWebDownload = async (url, title) => {
|
||||||
if (Platform.OS !== "web") return;
|
if (Platform.OS !== "web") return;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
const baseName = (title || "Playback").toString().trim() || "Playback";
|
const baseName = (title || "Playback").toString().trim() || "Playback";
|
||||||
const sanitized = baseName.replace(/[\\/:*?"<>|]/g, "-");
|
const sanitized = baseName.replace(/[\\/:*?"<>|]/g, "-");
|
||||||
const filename = `${sanitized}.mp4`;
|
const extension = guessExtension(url) || "mp4";
|
||||||
|
const filename = `${sanitized}.${extension}`;
|
||||||
try {
|
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");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = url;
|
anchor.href = blobUrl;
|
||||||
anchor.download = filename;
|
anchor.download = filename;
|
||||||
anchor.rel = "noopener noreferrer";
|
anchor.rel = "noopener noreferrer";
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
document.body.removeChild(anchor);
|
document.body.removeChild(anchor);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("[PlaybackDownload] web download fallback", {
|
console.log("[PlaybackDownload] web download fallback", {
|
||||||
message: error?.message,
|
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 {
|
try {
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -94,7 +117,7 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => {
|
|||||||
const { resultURI: videoUrl } = await uploadFileToFirebase({
|
const { resultURI: videoUrl } = await uploadFileToFirebase({
|
||||||
uri,
|
uri,
|
||||||
path: sourcePath,
|
path: sourcePath,
|
||||||
shouldCompress: false,
|
shouldCompress: true,
|
||||||
fileType: "VIDEO",
|
fileType: "VIDEO",
|
||||||
blob: cachedBlob || undefined,
|
blob: cachedBlob || undefined,
|
||||||
});
|
});
|
||||||
@@ -116,6 +139,10 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] =
|
const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(
|
||||||
|
project?.playbackUrl || null,
|
||||||
|
);
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
const hasShownAfterPlaybackRef = useRef(false);
|
const hasShownAfterPlaybackRef = useRef(false);
|
||||||
|
|
||||||
const { data: video } = useDataFromRef({
|
const { data: video } = useDataFromRef({
|
||||||
@@ -136,6 +163,12 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
setIsAfterPlaybackVideoVisible(true);
|
setIsAfterPlaybackVideoVisible(true);
|
||||||
}, [action, afterPlaybackUrl]);
|
}, [action, afterPlaybackUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (project?.playbackUrl) {
|
||||||
|
setPendingPlaybackUrl(project.playbackUrl);
|
||||||
|
}
|
||||||
|
}, [project?.playbackUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
releaseBlobUrl(uri || null);
|
releaseBlobUrl(uri || null);
|
||||||
@@ -143,7 +176,12 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
}, [uri]);
|
}, [uri]);
|
||||||
|
|
||||||
const handleDownloadUri = async () => {
|
const handleDownloadUri = async () => {
|
||||||
|
if (isPublishing) return;
|
||||||
if (action === "playback" && project?.id) {
|
if (action === "playback" && project?.id) {
|
||||||
|
if (pendingPlaybackUrl) {
|
||||||
|
await triggerWebDownload(pendingPlaybackUrl, project?.title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Publication du playback
|
// Publication du playback
|
||||||
console.log("[PlaybackDownload] handleDownloadUri playback", {
|
console.log("[PlaybackDownload] handleDownloadUri playback", {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
@@ -194,18 +232,12 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
const resultURI = result?.url || null;
|
const resultURI = result?.url || null;
|
||||||
|
|
||||||
if (resultURI) {
|
if (resultURI) {
|
||||||
await projectsRef.doc(project.id).set(
|
setPendingPlaybackUrl(resultURI);
|
||||||
{
|
|
||||||
playbackUrl: resultURI,
|
|
||||||
updatedAt: serverTimestamp(),
|
|
||||||
},
|
|
||||||
{ merge: true },
|
|
||||||
);
|
|
||||||
setTooltip({
|
setTooltip({
|
||||||
type: "success",
|
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) {
|
if (tempSourcePath) {
|
||||||
try {
|
try {
|
||||||
await firebase.storage().ref(tempSourcePath).delete();
|
await firebase.storage().ref(tempSourcePath).delete();
|
||||||
@@ -249,6 +281,109 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
// Handle song download
|
// 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
|
const continueLabel = hasActiveSubscription
|
||||||
? "Publier"
|
? "Publier"
|
||||||
: "Publier sans générer de revenus";
|
: "Publier sans générer de revenus";
|
||||||
@@ -298,7 +433,7 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
title={continueLabel}
|
title={continueLabel}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (hasActiveSubscription) {
|
if (hasActiveSubscription) {
|
||||||
navigate(Routes.SongRelease, { action });
|
handlePublish();
|
||||||
} else {
|
} else {
|
||||||
setShowConfirmModal(true);
|
setShowConfirmModal(true);
|
||||||
}
|
}
|
||||||
@@ -312,7 +447,7 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
isVisible={showConfirmModal}
|
isVisible={showConfirmModal}
|
||||||
setIsVisible={setShowConfirmModal}
|
setIsVisible={setShowConfirmModal}
|
||||||
onJoinClub={() => navigate(Routes.Payments)}
|
onJoinClub={() => navigate(Routes.Payments)}
|
||||||
onContinue={() => navigate(Routes.SongRelease, { action })}
|
onContinue={handlePublish}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import MusicLandHeader from "../../components/MusicLandHeader";
|
|||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import { background, img } from "../../assets";
|
import { background, img } from "../../assets";
|
||||||
import { goBack, navigate } from "../../navigation/NavigationService";
|
import { goBack, navigate } from "../../navigation/NavigationService";
|
||||||
|
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
import { size } from "../../styles/Style";
|
import { size } from "../../styles/Style";
|
||||||
@@ -20,6 +21,7 @@ const StreamSong = () => {
|
|||||||
const project = params?.project || null;
|
const project = params?.project || null;
|
||||||
const { hasActiveSubscription } = useUser() || {};
|
const { hasActiveSubscription } = useUser() || {};
|
||||||
const userHasActiveSubscription = hasActiveSubscription;
|
const userHasActiveSubscription = hasActiveSubscription;
|
||||||
|
const { setTooltip } = useMinuit();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (action && action !== "playback") {
|
if (action && action !== "playback") {
|
||||||
@@ -36,11 +38,19 @@ const StreamSong = () => {
|
|||||||
: "Rejoindre le Club MusicLand";
|
: "Rejoindre le Club MusicLand";
|
||||||
const handlePrimaryAction = useCallback(() => {
|
const handlePrimaryAction = useCallback(() => {
|
||||||
if (userHasActiveSubscription) {
|
if (userHasActiveSubscription) {
|
||||||
|
if (action === "playback") {
|
||||||
|
setTooltip({
|
||||||
|
type: "success",
|
||||||
|
text: "Playback publié !",
|
||||||
|
});
|
||||||
|
navigate(Routes.Home);
|
||||||
|
return;
|
||||||
|
}
|
||||||
navigate(Routes.SongRelease, { action });
|
navigate(Routes.SongRelease, { action });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate(Routes.Payments);
|
navigate(Routes.Payments);
|
||||||
}, [action, userHasActiveSubscription]);
|
}, [action, setTooltip, userHasActiveSubscription]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
|
|||||||
@@ -375,8 +375,15 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
typeof coinsPerMonth === "number" && Number.isFinite(coinsPerMonth)
|
typeof coinsPerMonth === "number" && Number.isFinite(coinsPerMonth)
|
||||||
? Math.round(coinsPerMonth)
|
? Math.round(coinsPerMonth)
|
||||||
: null;
|
: null;
|
||||||
|
const grantStrategy =
|
||||||
|
typeof currentUserData?.subscriptionGrantStrategy === "string"
|
||||||
|
? currentUserData.subscriptionGrantStrategy
|
||||||
|
: null;
|
||||||
|
const isUpfrontGrant = grantStrategy === "upfront";
|
||||||
const nextGrantTimestamp =
|
const nextGrantTimestamp =
|
||||||
currentUserData?.subscriptionNextGrantAt ||
|
isUpfrontGrant
|
||||||
|
? null
|
||||||
|
: currentUserData?.subscriptionNextGrantAt ||
|
||||||
currentUserData?.subscriptionGrantNextAt ||
|
currentUserData?.subscriptionGrantNextAt ||
|
||||||
null;
|
null;
|
||||||
const nextGrantRawDate = toDate(nextGrantTimestamp);
|
const nextGrantRawDate = toDate(nextGrantTimestamp);
|
||||||
@@ -390,7 +397,7 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const fallbackInitialGrant =
|
const fallbackInitialGrant =
|
||||||
isAnnual && createdAtDate
|
!isUpfrontGrant && isAnnual && createdAtDate
|
||||||
? computeFirstAnnualGrantFromCreation(createdAtDate)
|
? computeFirstAnnualGrantFromCreation(createdAtDate)
|
||||||
: null;
|
: null;
|
||||||
const futureFallbackGrant =
|
const futureFallbackGrant =
|
||||||
@@ -409,7 +416,7 @@ const ManageSubscription = ({ navigation }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextGrantLabel =
|
const nextGrantLabel =
|
||||||
resolvedNextGrantDate && isAnnual
|
resolvedNextGrantDate && isAnnual && !isUpfrontGrant
|
||||||
? formatDate(resolvedNextGrantDate)
|
? formatDate(resolvedNextGrantDate)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import { useGlobal } from "reactn";
|
|||||||
import { background, icons } from "../../assets";
|
import { background, icons } from "../../assets";
|
||||||
import BorderGradient from "../../components/BorderGradient/BorderGradient";
|
import BorderGradient from "../../components/BorderGradient/BorderGradient";
|
||||||
import MoreMenu from "../../components/MoreMenu";
|
import MoreMenu from "../../components/MoreMenu";
|
||||||
|
import MobileCoinBadge from "../../components/MobileCoinBadge";
|
||||||
import PressableScale from "../../components/PressableScale";
|
import PressableScale from "../../components/PressableScale";
|
||||||
import ProfilePicture from "../../components/ProfilePicture";
|
import ProfilePicture from "../../components/ProfilePicture";
|
||||||
|
import ShareBtn from "../../components/ShareBtn/ShareBtn";
|
||||||
import firebase, {
|
import firebase, {
|
||||||
projectsRef,
|
projectsRef,
|
||||||
serverTimestamp,
|
serverTimestamp,
|
||||||
@@ -33,7 +35,7 @@ import useLayoutType from "../../hooks/useLayoutType";
|
|||||||
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
|
import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { navigate, push } from "../../navigation/NavigationService";
|
import { navigate, push, goBack } from "../../navigation/NavigationService";
|
||||||
import { useUser } from "../../providers/UserDataProvider";
|
import { useUser } from "../../providers/UserDataProvider";
|
||||||
import { Palette } from "../../styles";
|
import { Palette } from "../../styles";
|
||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
@@ -69,15 +71,15 @@ const Profile = () => {
|
|||||||
const [webUploadMessage, setWebUploadMessage] = useState("");
|
const [webUploadMessage, setWebUploadMessage] = useState("");
|
||||||
const selfDisplayName = useMemo(
|
const selfDisplayName = useMemo(
|
||||||
() => getArtistDisplayName(currentUserData, "MusicLand"),
|
() => getArtistDisplayName(currentUserData, "MusicLand"),
|
||||||
[currentUserData]
|
[currentUserData],
|
||||||
);
|
);
|
||||||
const targetDisplayName = useMemo(
|
const targetDisplayName = useMemo(
|
||||||
() => getArtistDisplayName(userData, "MusicLand"),
|
() => getArtistDisplayName(userData, "MusicLand"),
|
||||||
[userData]
|
[userData],
|
||||||
);
|
);
|
||||||
const profileTitle = useMemo(
|
const profileTitle = useMemo(
|
||||||
() => getArtistDisplayName(isSelf ? currentUserData : userData, "Profil"),
|
() => getArtistDisplayName(isSelf ? currentUserData : userData, "Profil"),
|
||||||
[currentUserData, isSelf, userData]
|
[currentUserData, isSelf, userData],
|
||||||
);
|
);
|
||||||
const navigateToMusicDetails = useNavigateToMusicDetails();
|
const navigateToMusicDetails = useNavigateToMusicDetails();
|
||||||
const onPressMenu = (item) => {
|
const onPressMenu = (item) => {
|
||||||
@@ -96,7 +98,7 @@ const Profile = () => {
|
|||||||
setFollowers(
|
setFollowers(
|
||||||
Array.isArray(currentUserData?.followedBy)
|
Array.isArray(currentUserData?.followedBy)
|
||||||
? currentUserData.followedBy.length
|
? currentUserData.followedBy.length
|
||||||
: 0
|
: 0,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,7 +177,7 @@ const Profile = () => {
|
|||||||
profilePictureURL: resultURI,
|
profilePictureURL: resultURI,
|
||||||
updatedAt: serverTimestamp(),
|
updatedAt: serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const authUser = firebase.auth().currentUser;
|
const authUser = firebase.auth().currentUser;
|
||||||
@@ -237,7 +239,8 @@ const Profile = () => {
|
|||||||
setSelectedProjectId(item.id);
|
setSelectedProjectId(item.id);
|
||||||
setMenuPosition(posTop);
|
setMenuPosition(posTop);
|
||||||
setShowMenu(
|
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)
|
const displayedPlaybacks = Array.isArray(displayedPlaybacksSource)
|
||||||
? displayedPlaybacksSource.filter((project) => project?.playbackUrl)
|
? displayedPlaybacksSource.filter((project) => project?.playbackUrl)
|
||||||
: [];
|
: [];
|
||||||
|
const showHeader = isWeb;
|
||||||
|
const showBackButton = !params?.noBack && !isWeb;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
backgroundImg={isWeb ? background.profileWebBG : background.profileWebBG}
|
backgroundImg={isWeb ? background.profileWebBG : background.profileWebBG}
|
||||||
headerType="NAVIGATE"
|
headerType={showHeader ? "NAVIGATE" : "NONE"}
|
||||||
hideBackButton={params?.noBack}
|
hideBackButton={params?.noBack}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: gutters * 2,
|
paddingBottom: gutters * 2,
|
||||||
}}
|
}}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
backgroundColor: isWeb ? "transparent" : "#0000004D",
|
backgroundColor: isWeb ? "transparent" : "#0000004D",
|
||||||
|
paddingTop: showHeader ? undefined : 0,
|
||||||
}}
|
}}
|
||||||
rightComponent={() =>
|
|
||||||
!isWeb && isSelf ? (
|
|
||||||
<PressableScale onPress={handleOpenSettings}>
|
|
||||||
<Feather name="settings" size={24} color={Palette.white} />
|
|
||||||
</PressableScale>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
{!isWeb ? (
|
||||||
|
<View style={styles.mobileTopRow}>
|
||||||
|
<View style={styles.mobileTopLeft}>
|
||||||
|
{showBackButton ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={goBack}
|
||||||
|
hitSlop={16}
|
||||||
|
style={styles.backButton}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={icons.chevronDown}
|
||||||
|
style={styles.backIcon}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
<MobileCoinBadge />
|
||||||
|
</View>
|
||||||
|
<ShareBtn label="Partager" style={styles.mobileShareButton} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View style={{ flex: 1, marginTop: responsiveHeight(2), gap: 20 }}>
|
<View style={{ flex: 1, marginTop: responsiveHeight(2), gap: 20 }}>
|
||||||
<View style={{ borderRadius: 20, overflow: "hidden" }}>
|
<View style={{ borderRadius: 20, overflow: "hidden" }}>
|
||||||
<BlurView
|
<BlurView
|
||||||
@@ -362,21 +382,16 @@ const Profile = () => {
|
|||||||
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
|
||||||
// }
|
// }
|
||||||
>
|
>
|
||||||
{isWeb && isSelf && (
|
<View style={{ alignItems: "center" }}>
|
||||||
|
{isSelf && (
|
||||||
<PressableScale
|
<PressableScale
|
||||||
style={{
|
|
||||||
zIndex: 2,
|
|
||||||
position: "absolute",
|
|
||||||
right: 10,
|
|
||||||
top: 10,
|
|
||||||
}}
|
|
||||||
onPress={handleOpenSettings}
|
onPress={handleOpenSettings}
|
||||||
|
hitSlop={10}
|
||||||
|
style={styles.settingsButton}
|
||||||
>
|
>
|
||||||
<Feather name="settings" size={24} color={Palette.white} />
|
<Feather name="settings" size={20} color={Palette.white} />
|
||||||
</PressableScale>
|
</PressableScale>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={{ alignItems: "center" }}>
|
|
||||||
<View style={styles.avatarWrapper}>
|
<View style={styles.avatarWrapper}>
|
||||||
<ProfilePicture
|
<ProfilePicture
|
||||||
uri={
|
uri={
|
||||||
@@ -392,7 +407,7 @@ const Profile = () => {
|
|||||||
style={styles.editAvatarButton}
|
style={styles.editAvatarButton}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
onChangeProfilePicture(
|
onChangeProfilePicture(
|
||||||
loaderMessages.profilePhotoUploadWeb
|
loaderMessages.profilePhotoUploadWeb,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={updatingPhoto}
|
disabled={updatingPhoto}
|
||||||
@@ -567,6 +582,30 @@ const Profile = () => {
|
|||||||
export default Profile;
|
export default Profile;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
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: {
|
value: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
@@ -609,6 +648,19 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "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: {
|
editAvatarButton: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 4,
|
bottom: 4,
|
||||||
|
|||||||
@@ -16,39 +16,32 @@ const ICON_BASE_ACCENT = "#A96BFF";
|
|||||||
|
|
||||||
const CLUB_ADVANTAGES = [
|
const CLUB_ADVANTAGES = [
|
||||||
{
|
{
|
||||||
title: "Revenus partagés",
|
title: "Gagne des crédits avec ton abonnement",
|
||||||
description: "Gagne sur tes écoutes et ta popularité.",
|
|
||||||
iconType: "vector",
|
|
||||||
iconName: "currency-usd",
|
|
||||||
accent: "#F8C24D",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Concours hit du mois",
|
|
||||||
description: "Vise le podium du mois et empoche la récompense.",
|
|
||||||
iconType: "vector",
|
|
||||||
iconName: "chart-line",
|
|
||||||
accent: "#7AE0FF",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Crédits mensuels",
|
|
||||||
description: "Des crédits premium tous les mois pour créer plus.",
|
description: "Des crédits premium tous les mois pour créer plus.",
|
||||||
iconType: "image",
|
iconType: "image",
|
||||||
icon: icons.coin,
|
icon: icons.coin,
|
||||||
accent: ICON_BASE_ACCENT,
|
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 ClubAdvantagesCard = ({ style, isDev = false }) => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { hasActiveSubscription } = useUserData() || {};
|
const { hasActiveSubscription } = useUserData() || {};
|
||||||
const [useWebLayout, setUseWebLayout] = React.useState(isWeb);
|
const [useWebLayout, setUseWebLayout] = React.useState(isDev ? isWeb : false);
|
||||||
const advantages = CLUB_ADVANTAGES;
|
const advantages = CLUB_ADVANTAGES;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isDev) {
|
if (!isDev) {
|
||||||
setUseWebLayout(isWeb);
|
setUseWebLayout(false);
|
||||||
}
|
}
|
||||||
}, [isDev, isWeb]);
|
}, [isDev]);
|
||||||
|
|
||||||
const title = hasActiveSubscription
|
const title = hasActiveSubscription
|
||||||
? "Tu profites déjà du Club Musicland"
|
? "Tu profites déjà du Club Musicland"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import CreditAmount from "../../components/CreditAmount";
|
|||||||
import GradientButton from "../../components/GradientButton";
|
import GradientButton from "../../components/GradientButton";
|
||||||
import ValidateModal from "../../components/modal/ValidateModal";
|
import ValidateModal from "../../components/modal/ValidateModal";
|
||||||
import MusicLandHeader from "../../components/MusicLandHeader";
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
||||||
import Slider from "../../components/Slider";
|
import ProgressSlider from "../../components/player/ProgressSlider";
|
||||||
import { isWeb } from "../../hooks/useLayoutType";
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
import Page from "../../layouts/Page";
|
import Page from "../../layouts/Page";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
@@ -35,14 +35,6 @@ import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
|||||||
const MUSIC_GENERATION_COIN_COST = 8;
|
const MUSIC_GENERATION_COIN_COST = 8;
|
||||||
const SONG_OPTIONS_PER_GENERATION = 2;
|
const SONG_OPTIONS_PER_GENERATION = 2;
|
||||||
const REGENERATE_MODAL_MAX_WIDTH = 540;
|
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 SongReady = () => {
|
||||||
const {
|
const {
|
||||||
@@ -368,7 +360,6 @@ const SongOptionCard = ({
|
|||||||
|
|
||||||
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
|
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const wasPlayingBeforeSeek = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerPlayer(index, player);
|
registerPlayer(index, player);
|
||||||
@@ -422,10 +413,10 @@ const SongOptionCard = ({
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [player]);
|
}, [player]);
|
||||||
|
|
||||||
const handleSeek = async (ratio) => {
|
const handleSeek = async (targetMs) => {
|
||||||
if (!player || !progressInfo?.dur) return;
|
if (!player || !progressInfo?.dur) return;
|
||||||
const dur = progressInfo.dur || 0;
|
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 {
|
try {
|
||||||
await player.seekTo?.(Math.floor(pos / 1000));
|
await player.seekTo?.(Math.floor(pos / 1000));
|
||||||
setProgressInfo((prev) => ({ ...prev, pos }));
|
setProgressInfo((prev) => ({ ...prev, pos }));
|
||||||
@@ -434,11 +425,13 @@ const SongOptionCard = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSeekStart = async () => {
|
const handleSeekStart = () => {
|
||||||
onSelect(index);
|
onSelect(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pauseDuringSeek = async () => {
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
try {
|
try {
|
||||||
wasPlayingBeforeSeek.current = !!player.playing;
|
|
||||||
if (player.playing) {
|
if (player.playing) {
|
||||||
await player.pause?.();
|
await player.pause?.();
|
||||||
}
|
}
|
||||||
@@ -447,20 +440,16 @@ const SongOptionCard = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSeekEnd = async () => {
|
const resumeAfterSeek = async () => {
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
try {
|
try {
|
||||||
if (wasPlayingBeforeSeek.current) {
|
|
||||||
if (player.resume) {
|
if (player.resume) {
|
||||||
await player.resume?.();
|
await player.resume?.();
|
||||||
} else {
|
} else {
|
||||||
await player.play?.();
|
await player.play?.();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("SongReady resume after seek", error?.message);
|
console.log("SongReady resume after seek", error?.message);
|
||||||
} finally {
|
|
||||||
wasPlayingBeforeSeek.current = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -515,18 +504,15 @@ const SongOptionCard = ({
|
|||||||
marginBottom: responsiveHeight(1),
|
marginBottom: responsiveHeight(1),
|
||||||
}}
|
}}
|
||||||
>{`Morceau ${index + 1}`}</Text>
|
>{`Morceau ${index + 1}`}</Text>
|
||||||
<Slider
|
<ProgressSlider
|
||||||
value={formatTime(progressInfo?.pos)}
|
positionMs={progressInfo?.pos}
|
||||||
maxValue={formatTime(progressInfo?.dur)}
|
durationMs={progressInfo?.dur}
|
||||||
progress={
|
isPlaying={isPlaying}
|
||||||
progressInfo?.dur
|
|
||||||
? (progressInfo.pos || 0) / progressInfo.dur
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
seekEnabled={!!url}
|
|
||||||
onSeekStart={handleSeekStart}
|
onSeekStart={handleSeekStart}
|
||||||
onSeek={handleSeek}
|
onSeek={handleSeek}
|
||||||
onSeekEnd={handleSeekEnd}
|
onPause={pauseDuringSeek}
|
||||||
|
onPlay={resumeAfterSeek}
|
||||||
|
disabled={!url}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -290,11 +290,6 @@ const normalizePlans = (plans = [], periodKey) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLAN_SEGMENTS = [
|
|
||||||
{ key: "monthly", label: "Mensuel" },
|
|
||||||
{ key: "annual", label: "Annuel" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 700;
|
const HERO_IMAGE_WIDTH = isWeb ? 1280 : 700;
|
||||||
const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
|
const HERO_IMAGE_HEIGHT = isWeb ? 760 : 360;
|
||||||
const PAGE_BACKGROUND_COLOR = "#303438";
|
const PAGE_BACKGROUND_COLOR = "#303438";
|
||||||
@@ -303,7 +298,7 @@ const SUBSCRIPTION_DISCLAIMER =
|
|||||||
const CARD_MIN_HEIGHT = isWeb ? 320 : 240;
|
const CARD_MIN_HEIGHT = isWeb ? 320 : 240;
|
||||||
const CREDITS_PER_MUSIC = 8;
|
const CREDITS_PER_MUSIC = 8;
|
||||||
|
|
||||||
export default function Payments() {
|
export default function Subscriptions() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = !isWeb;
|
const isMobile = !isWeb;
|
||||||
@@ -315,32 +310,22 @@ export default function Payments() {
|
|||||||
const initialSubscriptionPack =
|
const initialSubscriptionPack =
|
||||||
initialPack && initialPack !== "packs" ? initialPack : null;
|
initialPack && initialPack !== "packs" ? initialPack : null;
|
||||||
|
|
||||||
const backgroundImage = isMobile ? background.homeBG : background.bgTrans;
|
|
||||||
const {
|
const {
|
||||||
subscriptions,
|
subscriptions,
|
||||||
isCatalogLoading,
|
isCatalogLoading,
|
||||||
catalogError,
|
catalogError,
|
||||||
createSubscriptionCheckout,
|
createSubscriptionCheckout,
|
||||||
} = useStripe();
|
} = useStripe();
|
||||||
const [selectedPriceIds, setSelectedPriceIds] = React.useState({
|
const [selectedPriceId, setSelectedPriceId] = React.useState(null);
|
||||||
monthly: null,
|
|
||||||
annual: null,
|
|
||||||
});
|
|
||||||
const [billingPeriod, setBillingPeriod] = React.useState("monthly");
|
|
||||||
const [processingPriceId, setProcessingPriceId] = React.useState(null);
|
const [processingPriceId, setProcessingPriceId] = React.useState(null);
|
||||||
const [errorMessage, setErrorMessage] = React.useState(null);
|
const [errorMessage, setErrorMessage] = React.useState(null);
|
||||||
const initialPackHandledRef = React.useRef(false);
|
const initialPackHandledRef = React.useRef(false);
|
||||||
|
|
||||||
const normalizedPlans = React.useMemo(
|
const normalizedPlans = React.useMemo(
|
||||||
() => ({
|
() => normalizePlans(subscriptions?.annual, "annual"),
|
||||||
monthly: normalizePlans(subscriptions?.monthly, "monthly"),
|
[subscriptions?.annual]
|
||||||
annual: normalizePlans(subscriptions?.annual, "annual"),
|
|
||||||
}),
|
|
||||||
[subscriptions?.monthly, subscriptions?.annual]
|
|
||||||
);
|
);
|
||||||
const hasAnyPlan =
|
const hasAnyPlan = (normalizedPlans?.length || 0) > 0;
|
||||||
(normalizedPlans?.monthly?.length || 0) > 0 ||
|
|
||||||
(normalizedPlans?.annual?.length || 0) > 0;
|
|
||||||
const isLoadingPlans = isCatalogLoading && !hasAnyPlan;
|
const isLoadingPlans = isCatalogLoading && !hasAnyPlan;
|
||||||
const combinedErrorMessage = errorMessage || catalogError;
|
const combinedErrorMessage = errorMessage || catalogError;
|
||||||
|
|
||||||
@@ -352,85 +337,67 @@ export default function Payments() {
|
|||||||
const shouldApplyPack =
|
const shouldApplyPack =
|
||||||
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
Boolean(initialSubscriptionPack) && !initialPackHandledRef.current;
|
||||||
|
|
||||||
let matchedPeriod = null;
|
|
||||||
let matchedPriceId = null;
|
let matchedPriceId = null;
|
||||||
|
|
||||||
if (shouldApplyPack && normalizedPlans) {
|
if (shouldApplyPack && normalizedPlans?.length) {
|
||||||
const desired = initialSubscriptionPack.trim().toLowerCase();
|
const desired = initialSubscriptionPack.trim().toLowerCase();
|
||||||
for (const [periodKey, mapping] of Object.entries(
|
const candidateId = PACK_PRICE_ID_BY_PERIOD?.annual?.[desired];
|
||||||
PACK_PRICE_ID_BY_PERIOD
|
if (candidateId) {
|
||||||
)) {
|
const exists = normalizedPlans.some(
|
||||||
const candidateId = mapping[desired];
|
|
||||||
if (!candidateId) continue;
|
|
||||||
const exists = (normalizedPlans[periodKey] || []).some(
|
|
||||||
(plan) => plan?.priceId === candidateId
|
(plan) => plan?.priceId === candidateId
|
||||||
);
|
);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
matchedPeriod = periodKey;
|
|
||||||
matchedPriceId = candidateId;
|
matchedPriceId = candidateId;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedPriceIds((current) => {
|
if (!matchedPriceId) {
|
||||||
const next = { ...current };
|
const matched = normalizedPlans.find((plan) => {
|
||||||
|
|
||||||
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 || "")
|
const label = (plan?.product?.name || plan?.nickname || "")
|
||||||
.toString()
|
.toString()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return label.includes(initialSubscriptionPack);
|
return label.includes(desired);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matched) {
|
if (matched?.priceId) {
|
||||||
matchedPeriod = key;
|
|
||||||
matchedPriceId = matched.priceId;
|
matchedPriceId = matched.priceId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next[key] = alreadySelected ? next[key] : periodPlans[0].priceId;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchedPeriod && matchedPriceId) {
|
|
||||||
next[matchedPeriod] = matchedPriceId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
setSelectedPriceId((current) => {
|
||||||
|
if (matchedPriceId) {
|
||||||
|
return matchedPriceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCurrent = normalizedPlans?.some(
|
||||||
|
(plan) => plan.priceId === current
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasCurrent) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedPlans?.[0]?.priceId || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matchedPeriod && matchedPriceId) {
|
if (matchedPriceId || (shouldApplyPack && !isCatalogLoading)) {
|
||||||
setBillingPeriod(matchedPeriod);
|
|
||||||
initialPackHandledRef.current = true;
|
|
||||||
} else if (shouldApplyPack && !isCatalogLoading) {
|
|
||||||
initialPackHandledRef.current = true;
|
initialPackHandledRef.current = true;
|
||||||
}
|
}
|
||||||
}, [normalizedPlans, initialSubscriptionPack, isCatalogLoading]);
|
}, [
|
||||||
|
initialSubscriptionPack,
|
||||||
|
isCatalogLoading,
|
||||||
|
normalizedPlans,
|
||||||
|
]);
|
||||||
|
|
||||||
const currentPlans = normalizedPlans[billingPeriod] || [];
|
const currentPlans = normalizedPlans || [];
|
||||||
const selectedPriceId = selectedPriceIds[billingPeriod];
|
|
||||||
|
|
||||||
const handleSelect = React.useCallback(
|
const handleSelect = React.useCallback(
|
||||||
(priceId) => {
|
(priceId) => {
|
||||||
setSelectedPriceIds((current) => ({
|
setSelectedPriceId(priceId);
|
||||||
...current,
|
|
||||||
[billingPeriod]: priceId,
|
|
||||||
}));
|
|
||||||
setProcessingPriceId(null);
|
setProcessingPriceId(null);
|
||||||
},
|
},
|
||||||
[billingPeriod]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCheckout = React.useCallback(async () => {
|
const handleCheckout = React.useCallback(async () => {
|
||||||
@@ -443,7 +410,7 @@ export default function Payments() {
|
|||||||
try {
|
try {
|
||||||
await createSubscriptionCheckout(selectedPriceId);
|
await createSubscriptionCheckout(selectedPriceId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Payments] checkout error", error);
|
console.error("[Subscriptions] checkout error", error);
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
error?.message ||
|
error?.message ||
|
||||||
"Une erreur est survenue lors de la création de la session Stripe."
|
"Une erreur est survenue lors de la création de la session Stripe."
|
||||||
@@ -453,26 +420,6 @@ export default function Payments() {
|
|||||||
}
|
}
|
||||||
}, [selectedPriceId, createSubscriptionCheckout]);
|
}, [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 isProcessing = Boolean(processingPriceId);
|
||||||
const isActionDisabled = !selectedPriceId || isProcessing || isLoadingPlans;
|
const isActionDisabled = !selectedPriceId || isProcessing || isLoadingPlans;
|
||||||
const actionButtonTitle = isProcessing
|
const actionButtonTitle = isProcessing
|
||||||
@@ -509,41 +456,7 @@ export default function Payments() {
|
|||||||
);
|
);
|
||||||
}, [combinedErrorMessage]);
|
}, [combinedErrorMessage]);
|
||||||
|
|
||||||
const renderSegmentedControl = React.useCallback(() => {
|
const renderSegmentedControl = React.useCallback(() => null, []);
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.segmentedControl,
|
|
||||||
isMobile && styles.segmentedControlMobile,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{PLAN_SEGMENTS.map(({ key, label }) => {
|
|
||||||
const isActive = billingPeriod === key;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={key}
|
|
||||||
onPress={() => setBillingPeriod(key)}
|
|
||||||
style={[
|
|
||||||
styles.segmentButton,
|
|
||||||
isActive && styles.segmentButtonActive,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityState={{ selected: isActive }}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.segmentLabel,
|
|
||||||
isActive && styles.segmentLabelActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [billingPeriod, isMobile]);
|
|
||||||
|
|
||||||
const renderMobilePlanItem = React.useCallback(
|
const renderMobilePlanItem = React.useCallback(
|
||||||
({ item }) => (
|
({ item }) => (
|
||||||
+46
-3
@@ -1,4 +1,10 @@
|
|||||||
import { arrayRemove, arrayUnion, projectsRef } from "../config/firebase";
|
import {
|
||||||
|
arrayRemove,
|
||||||
|
arrayUnion,
|
||||||
|
deleteField,
|
||||||
|
projectsRef,
|
||||||
|
serverTimestamp,
|
||||||
|
} from "../config/firebase";
|
||||||
import { ensureAuthenticated } from "./authRedirect";
|
import { ensureAuthenticated } from "./authRedirect";
|
||||||
|
|
||||||
export const LIKE_TARGET = {
|
export const LIKE_TARGET = {
|
||||||
@@ -10,6 +16,16 @@ export const getLikeFieldPath = (target = LIKE_TARGET.SONG) => {
|
|||||||
return target === LIKE_TARGET.PLAYBACK ? "likes.playback" : "likes.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) => {
|
export const getProjectLikes = (project, target = LIKE_TARGET.SONG) => {
|
||||||
const path = target === LIKE_TARGET.PLAYBACK ? "playback" : "song";
|
const path = target === LIKE_TARGET.PLAYBACK ? "playback" : "song";
|
||||||
const likes = project?.likes;
|
const likes = project?.likes;
|
||||||
@@ -46,17 +62,44 @@ export const toggleProjectLike = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
if (!ensureAuthenticated(currentUID)) 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 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(
|
await projectsRef.doc(projectId).set(
|
||||||
{
|
{
|
||||||
[fieldPath]: likeOperation,
|
|
||||||
...(target === LIKE_TARGET.SONG
|
...(target === LIKE_TARGET.SONG
|
||||||
? {
|
? {
|
||||||
// Keep legacy likedBy in sync for clients still reading this field
|
// Keep legacy likedBy in sync for clients still reading this field
|
||||||
likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID),
|
likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...likesPayload,
|
||||||
|
...cleanupPayload,
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user