clear last tickets

This commit is contained in:
Thomas Demirdjian
2025-11-25 14:58:06 +01:00
parent 72852ad92b
commit ee9cc5dccd
31 changed files with 1009 additions and 531 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 574 KiB

+10
View File
@@ -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;
+127 -7
View File
@@ -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

+94
View File
@@ -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;
+65 -53
View File
@@ -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,
+92
View File
@@ -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;
+1
View File
@@ -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";
+24 -14
View File
@@ -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)
+2 -2
View File
@@ -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",
}, },
{ {
+30 -3
View File
@@ -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({
+32 -3
View File
@@ -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>
+17
View File
@@ -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}
+12 -69
View File
@@ -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" }}
+12 -82
View File
@@ -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>
)} )}
+27 -4
View File
@@ -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 ? (
+2 -1
View File
@@ -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) {
+7 -6
View File
@@ -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) => {
+24 -29
View File
@@ -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}
+39 -10
View File
@@ -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 {
+150 -15
View File
@@ -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}
/> />
</> </>
); );
+11 -1
View File
@@ -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
+10 -3
View File
@@ -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;
+78 -26
View File
@@ -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"
+15 -29
View File
@@ -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
View File
@@ -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 }
); );