last tickets

This commit is contained in:
Thomas Demirdjian
2025-11-21 21:57:41 +01:00
parent a58b13d09f
commit 471a3177e8
13 changed files with 677 additions and 714 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
}, },
"splash": { "splash": {
"image": "./assets/splash.png", "image": "./assets/splash.png",
"resizeMode": "cover", "resizeMode": "contain",
"backgroundColor": "#000" "backgroundColor": "#000"
}, },
"ios": { "ios": {
+11 -5
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Platform, Pressable, StyleSheet } from "react-native"; import { Pressable, StyleSheet, View } from "react-native";
import { AnimatePresence, Motion } from "@legendapp/motion"; import { AnimatePresence, Motion } from "@legendapp/motion";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
@@ -23,6 +23,13 @@ const Overlay = ({
const { isNative } = useLayoutType(); const { isNative } = useLayoutType();
const ContentContainerView = hasPortal ? Portal : React.Fragment; const ContentContainerView = hasPortal ? Portal : React.Fragment;
const resolvedBlurIntensity =
blurIntensity ?? (isNative ? defaultBlurIntensity : 15);
const shouldUseBlur = resolvedBlurIntensity > 0;
const BackgroundView = shouldUseBlur ? BlurView : View;
const backgroundProps = shouldUseBlur
? { intensity: resolvedBlurIntensity, tint: "dark" }
: {};
return ( return (
<ContentContainerView> <ContentContainerView>
@@ -45,9 +52,8 @@ const Overlay = ({
...Style.containerCenter, ...Style.containerCenter,
}} }}
> >
<BlurView <BackgroundView
intensity={blurIntensity || isNative ? defaultBlurIntensity : 15} {...backgroundProps}
tint="dark"
style={{ style={{
width: "100%", width: "100%",
height: "100%", height: "100%",
@@ -69,7 +75,7 @@ const Overlay = ({
}} }}
/> />
{children} {children}
</BlurView> </BackgroundView>
</Motion.View> </Motion.View>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
+11 -4
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, createContext, useRef } from "react"; import { useState, useEffect, createContext, useRef } from "react";
import { StyleSheet, Text, Animated } from "react-native"; import { StyleSheet, Animated } from "react-native";
import { Fonts, Palette, Style } from "../styles"; import { Palette, Style } from "../styles";
import { isWeb } from "../hooks/useLayoutType"; import { Image } from "expo-image";
export const SplashAnimationContext = createContext(); export const SplashAnimationContext = createContext();
@@ -39,7 +39,14 @@ const SplashAnimationProvider = ({ children }) => {
opacity: fadeAnim, opacity: fadeAnim,
}} }}
> >
{isWeb && <Text style={Fonts({ type: "megaTitle" })}>m</Text>} <Image
source={require("../../assets/splash.png")}
contentFit={"contain"}
style={{
width: 250,
height: 250,
}}
/>
</Animated.View> </Animated.View>
)} )}
</SplashAnimationContext.Provider> </SplashAnimationContext.Provider>
+1
View File
@@ -585,6 +585,7 @@ const Home = ({ navigation, route }) => {
{stageCardsList} {stageCardsList}
<ClubCard <ClubCard
image={icons.clubIcon}
onPress={handleClubPress} onPress={handleClubPress}
hasActiveSubscription={hasActiveSubscription} hasActiveSubscription={hasActiveSubscription}
/> />
+7 -660
View File
@@ -1,655 +1,16 @@
import { useIsFocused, useRoute } from "@react-navigation/native"; import { useIsFocused, useRoute } from "@react-navigation/native";
import { BlurView } from "expo-blur"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { VideoView, useVideoPlayer } from "expo-video"; import { View } from "react-native";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActivityIndicator,
Image,
Platform,
Pressable,
Text,
View,
} from "react-native";
import Carousel from "react-native-reanimated-carousel"; import Carousel from "react-native-reanimated-carousel";
import { responsiveHeight } from "react-native-responsive-dimensions"; import { responsiveHeight } from "react-native-responsive-dimensions";
import { icons, img } from "../../assets"; import { projectsRef } from "../../config/firebase";
import { openComments } from "../../components/bottomsheets/CommentsBottomSheet";
import KaraokeLyrics from "../../components/KaraokeLyrics";
import ProfilePicture from "../../components/ProfilePicture";
import firebase, { projectsRef, usersRef } from "../../config/firebase";
import useDataFromRef from "../../hooks/useDataFromRef"; import useDataFromRef from "../../hooks/useDataFromRef";
import { Routes } from "../../navigation"; import PlaybackItem from "./components/PlaybackItem";
import { navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { size } from "../../styles/Style";
import {
getProjectLikes,
LIKE_TARGET,
toggleProjectLike,
} from "../../utils/likes";
import { ensureAuthenticated } from "../../utils/authRedirect";
import {
createPlaybackSharePayload,
openShareSheet,
} from "../../utils/shareSheet";
import { SheetManager } from "react-native-actions-sheet";
import { Feather } from "@expo/vector-icons";
const PLAYBACK_CODEC_TAG = "h264-v1";
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const { currentUID, followUser, unfollowUser } = useUser() || {};
const baseVideoUrl = item?.playbackUrl || null;
const [overrideVideoUrl, setOverrideVideoUrl] = useState(null);
const videoUrl = overrideVideoUrl || baseVideoUrl;
const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
const [isLiked, setIsLiked] = useState(
currentUID ? initialLikedBy.includes(currentUID) : false
);
const [likesCount, setLikesCount] = useState(initialLikedBy.length);
const [repairState, setRepairState] = useState({
running: false,
error: null,
});
const repairAttemptedRef = useRef(false);
useEffect(() => {
setOverrideVideoUrl(null);
repairAttemptedRef.current = false;
setRepairState({ running: false, error: null });
}, [item?.id, baseVideoUrl]);
useEffect(() => {
logPlaybackEvent("state-change", {
baseVideoUrl: baseVideoUrl || null,
overrideVideoUrl,
repairState,
});
}, [baseVideoUrl, logPlaybackEvent, overrideVideoUrl, repairState]);
const commentsCount = useMemo(
() => Number(item?.commentsCount || 0),
[item?.commentsCount]
);
const [owner, setOwner] = useState(
item?.userId && userCache?.current?.get(item.userId)
? userCache.current.get(item.userId)
: null
);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
const uid = item?.userId;
if (!uid || !userCache) return;
const cached = userCache.current.get(uid);
if (cached) {
if (!cancelled) setOwner(cached);
return;
}
const user = await getUserByUid?.(uid);
if (!cancelled && user) {
userCache.current.set(uid, user);
setOwner(user);
}
} catch (e) {}
};
run();
return () => {
cancelled = true;
};
}, [item?.userId, userCache, getUserByUid]);
// Live sync owner from Firestore to reflect follow changes elsewhere
useEffect(() => {
const uid = item?.userId;
if (!uid) return;
const unsub = usersRef.doc(uid).onSnapshot(
(doc) => {
if (doc?.exists) {
const data = { id: doc.id, ...doc.data() };
setOwner(data);
try {
userCache?.current?.set(uid, data);
} catch (e) {}
}
},
() => {}
);
return () => unsub?.();
}, [item?.userId, userCache]);
// Follow state derived from owner.followedBy
const [isFollowing, setIsFollowing] = useState(false);
const [isFollowActionPending, setIsFollowActionPending] = useState(false);
useEffect(() => {
const list = Array.isArray(owner?.followedBy) ? owner.followedBy : [];
setIsFollowing(currentUID ? list.includes(currentUID) : false);
}, [owner?.followedBy, currentUID]);
useEffect(() => {
const lb = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
setLikesCount(lb.length);
setIsLiked(currentUID ? lb.includes(currentUID) : false);
}, [item?.likes?.playback, currentUID]);
const projectTitle = typeof item?.title === "string" ? item.title.trim() : "";
const creatorName = useMemo(() => {
if (owner?.displayName) return owner.displayName;
if (owner?.artistName) return owner.artistName;
if (owner?.userName) return owner.userName;
if (typeof item?.userName === "string") return item.userName;
return "";
}, [item?.userName, owner?.artistName, owner?.displayName, owner?.userName]);
const needsCodecRepair =
Platform.OS === "ios" &&
!!baseVideoUrl &&
item?.playbackCompatibility?.codec !== PLAYBACK_CODEC_TAG;
const logPlaybackEvent = useCallback(
(event, extra = {}) => {
if (Platform.OS !== "ios") return;
try {
console.log("[Playbacks][iOS]", event, {
projectId: item?.id,
isActive,
hasVideoUrl: !!videoUrl,
hasOverride: !!overrideVideoUrl,
needsCodecRepair,
compatibility: item?.playbackCompatibility || null,
repairRunning: repairState.running,
repairError: repairState.error?.message || null,
...extra,
});
} catch (loggingError) {
// ignore logging errors
}
},
[
item?.id,
isActive,
needsCodecRepair,
overrideVideoUrl,
repairState.error?.message,
repairState.running,
videoUrl,
]
);
const handleRepair = useCallback(
async (force = false) => {
if ((!needsCodecRepair && !force) || !item?.id) {
logPlaybackEvent("repair-skip", { force });
return;
}
if (repairAttemptedRef.current) {
logPlaybackEvent("repair-skip-already-attempted", { force });
return;
}
repairAttemptedRef.current = true;
setRepairState({ running: true, error: null });
logPlaybackEvent("repair-start", { force });
try {
const callable =
firebase.functions().httpsCallable("upload-reencodePlayback");
const { data } = await callable({ projectId: item.id });
const nextUrl = data?.url || null;
if (nextUrl) {
setOverrideVideoUrl(nextUrl);
}
setRepairState({ running: false, error: null });
logPlaybackEvent("repair-success", { nextUrlPresent: !!nextUrl });
} catch (error) {
console.log("[Playbacks] reencode failed", {
projectId: item?.id,
message: error?.message || String(error || ""),
code: error?.code,
});
setRepairState({
running: false,
error:
error instanceof Error
? error
: new Error(String(error || "Playback repair failed")),
});
logPlaybackEvent("repair-failed", {
error: error?.message || String(error || ""),
code: error?.code,
});
}
},
[item?.id, logPlaybackEvent, needsCodecRepair]
);
useEffect(() => {
if (!isActive || !needsCodecRepair) return;
handleRepair();
}, [isActive, needsCodecRepair, handleRepair]);
const videoPlayer = useVideoPlayer(videoUrl || null, (p) => {
p.loop = false;
p.muted = false;
p.timeUpdateEventInterval = 0.2;
});
useEffect(() => {
if (Platform.OS !== "ios") return;
if (!videoPlayer?.addListener) return;
const sub = videoPlayer.addListener("error", (event) => {
console.log("[Playbacks] video error", {
projectId: item?.id,
error: event?.error || event,
});
logPlaybackEvent("video-error", { error: event?.error || event });
if (!repairAttemptedRef.current) {
handleRepair(true);
}
});
return () => {
try {
sub?.remove?.();
} catch (e) {}
};
}, [videoPlayer, handleRepair, item?.id]);
const shouldAutoPlay = isActive && !repairState.running;
useEffect(() => {
const toggle = async () => {
try {
if (shouldAutoPlay) {
try {
if (videoPlayer) videoPlayer.currentTime = 0;
} catch (e) {}
try {
if (videoPlayer) videoPlayer.play();
} catch (e) {}
} else if (videoPlayer?.playing) {
videoPlayer.pause();
}
} catch (e) {}
};
toggle();
}, [shouldAutoPlay, videoPlayer]);
useEffect(() => {
return () => {
try {
if (videoPlayer?.playing) videoPlayer.pause();
} catch (e) {}
};
}, [videoPlayer]);
const handleReport = useCallback(() => {
if (!item?.id) return;
SheetManager.show("Report", {
payload: {
targetType: "playback",
projectId: item?.id || null,
playbackId: item?.id || null,
title: item?.title || "",
ownerId: item?.userId || null,
},
});
}, [item?.id, item?.title, item?.userId]);
// Build alignedWords from item musicTimestamps
const alignedWords = useMemo(() => {
const idx = Number(item?.songIndex) || 0;
const ts = item?.musicTimestamps?.[idx];
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [];
return arr.map((w) => ({
word: String(w?.word ?? ""),
startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0),
}));
}, [item?.musicTimestamps, item?.songIndex]);
// Track current time for lyrics sync
const [currentTimeS, setCurrentTimeS] = useState(0);
useEffect(() => {
if (!isActive) return;
const id = global.setInterval(() => {
try {
setCurrentTimeS(Number(videoPlayer?.currentTime || 0));
} catch (e) {}
}, 250);
return () => global.clearInterval(id);
}, [isActive, videoPlayer]);
const sharePayload = useMemo(() => {
if (!item?.id) return null;
return createPlaybackSharePayload({
projectId: item.id,
title: projectTitle || undefined,
artist: creatorName || undefined,
playbackUrl: videoUrl || undefined,
});
}, [creatorName, item?.id, projectTitle, videoUrl]);
const handleShare = useCallback(() => {
if (sharePayload) {
openShareSheet(sharePayload);
}
}, [sharePayload]);
// partage: via feuille unifiée (prend en charge QR)
return (
<View
style={{
height: responsiveHeight(100),
position: "relative",
backgroundColor: "black",
}}
>
{!!videoUrl ? (
<VideoView
player={videoPlayer}
nativeControls={false}
contentFit="cover"
style={{ width: "100%", height: "100%" }}
/>
) : (
<Image
source={img.placeholder3}
style={{ width: "100%", height: "100%" }}
/>
)}
{Platform.OS === "ios" && repairState.running && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#00000066",
}}
>
<ActivityIndicator color={Palette.white} />
<Text
style={{
marginTop: 10,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 15,
}}
>
Optimisation vidéo iOS...
</Text>
</View>
)}
{Platform.OS === "ios" && repairState.error && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 20,
backgroundColor: "#00000099",
}}
>
<Text
style={{
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
Impossible de lire cette vidéo. Veuillez réessayer plus tard.
</Text>
</View>
)}
{/* Right side actions */}
<View
style={{
position: "absolute",
width: "100%",
bottom: 130,
gap: 18,
}}
>
<View
style={{
alignSelf: "flex-end",
alignItems: "center",
gap: 20,
paddingHorizontal: 13,
}}
>
<View style={{ gap: 6, alignItems: "center" }}>
<Pressable
onPress={() => {
navigate(Routes.SingerProfile, { userId: item?.userId });
}}
>
<ProfilePicture
uri={owner?.profilePictureURL || null}
size={45}
imageProps={{ priority: "high" }}
/>
</Pressable>
{owner?.id && owner.id !== currentUID && (
<Pressable
disabled={isFollowActionPending}
onPress={async () => {
if (isFollowActionPending) return;
if (
!ensureAuthenticated(currentUID, {
onIntercept: () => {
setIsFollowActionPending(false);
},
})
) {
return;
}
try {
setIsFollowActionPending(true);
const next = !isFollowing;
console.log("follwin");
setIsFollowing(next);
// Optimistic update of local owner.followedBy
setOwner((prev) => {
const fb = Array.isArray(prev?.followedBy)
? prev.followedBy
: [];
const newFb = next
? Array.from(new Set([...fb, currentUID]))
: fb.filter((x) => x !== currentUID);
return prev ? { ...prev, followedBy: newFb } : prev;
});
if (next) await followUser?.(owner.id);
else await unfollowUser?.(owner.id);
// Timeout de 2 secondes avant de pouvoir recliquer
global.setTimeout(() => {
setIsFollowActionPending(false);
}, 1000);
} catch (e) {
// rollback on failure
setIsFollowing((v) => !v);
setIsFollowActionPending(false);
}
}}
>
<BlurView
tint="dark"
intensity={Platform.OS !== "ios" ? 10 : 20}
style={{
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: Palette.white,
overflow: "hidden",
backgroundColor: isFollowing ? "#FFFFFF1A" : undefined,
}}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// }
>
<Text
style={{
fontSize: 13,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
}}
>
{isFollowing ? "Suivi(e)" : "Suivre"}
</Text>
</BlurView>
</Pressable>
)}
</View>
<Pressable
onPress={async () => {
try {
if (!item?.id) return;
if (!ensureAuthenticated(currentUID)) {
return;
}
const nextLiked = !isLiked;
setIsLiked(nextLiked);
setLikesCount((c) => Math.max(0, c + (nextLiked ? 1 : -1)));
await toggleProjectLike({
projectId: item.id,
currentUID,
target: LIKE_TARGET.PLAYBACK,
next: nextLiked,
});
} catch (e) {
// rollback on failure
setIsLiked((v) => !v);
setLikesCount((c) => (isLiked ? c + 1 : Math.max(0, c - 1)));
}
}}
style={{ alignItems: "center" }}
>
<Image
source={isLiked ? icons.heart : icons.heartOutline}
style={size({ size: 26 })}
resizeMode="contain"
/>
{!!likesCount && (
<Text
style={{
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
{likesCount}
</Text>
)}
</Pressable>
<Pressable
onPress={() => item?.id && openComments(item.id)}
style={{ alignItems: "center" }}
>
<Image source={icons.chatBubble} style={size({ size: 26 })} />
{!!commentsCount && (
<Text
style={{
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
{commentsCount}
</Text>
)}
</Pressable>
<Pressable onPress={handleShare}>
<Image source={icons.share} style={size({ size: 26 })} />
</Pressable>
<Pressable onPress={handleReport} style={{ alignItems: "center" }}>
<Feather name="flag" size={26} color={Palette.white} />
<Text
style={{
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
>
Signaler
</Text>
</Pressable>
</View>
<View style={{ paddingHorizontal: 28 }}>
<BlurView
tint="dark"
intensity={Platform.OS !== "ios" ? 10 : 20}
style={{
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: Palette.glass,
overflow: "hidden",
}}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// }
>
{alignedWords?.length > 0 ? (
<KaraokeLyrics
alignedWords={alignedWords}
currentTimeS={currentTimeS}
/>
) : (
<Text
style={{
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{item?.title || "Description chanson"}
</Text>
)}
</BlurView>
</View>
</View>
</View>
);
};
const Playbacks = () => { const Playbacks = () => {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const carouselRef = useRef(null); const carouselRef = useRef(null);
const route = useRoute();
const focusProjectId =
route?.params?.projectId || route?.params?.focusId || null;
const userCache = useRef(new Map()); const userCache = useRef(new Map());
const { getUserByUid } = useUser() || {};
const isFocused = useIsFocused(); const isFocused = useIsFocused();
const { data: playbacks = [], loadMore } = useDataFromRef({ const { data: playbacks = [], loadMore } = useDataFromRef({
ref: projectsRef.where("playbackUrl", "!=", null), ref: projectsRef.where("playbackUrl", "!=", null),
@@ -662,33 +23,20 @@ const Playbacks = () => {
const onSnap = useCallback( const onSnap = useCallback(
(index) => { (index) => {
setActiveIndex(index); setActiveIndex(index);
// Pré-charge la suite 6 par 6
if (index >= (playbacks?.length || 0) - 6) { if (index >= (playbacks?.length || 0) - 6) {
loadMore?.(); loadMore?.();
} }
}, },
[playbacks?.length, loadMore] [playbacks?.length, loadMore],
); );
// When a specific playback id is provided via navigation params,
// try to focus it by scrolling the carousel to its index.
const triedLoadMoreRef = useRef(0); const triedLoadMoreRef = useRef(0);
useEffect(() => { useEffect(() => {
if (!focusProjectId) return; if (loadMore && triedLoadMoreRef.current < 6) {
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
if (idx >= 0) {
setActiveIndex(idx);
// give time for first render before scrolling
global.setTimeout(() => {
try {
carouselRef.current?.scrollTo?.({ index: idx, animated: false });
} catch (e) {}
}, 50);
} else if (loadMore && triedLoadMoreRef.current < 6) {
triedLoadMoreRef.current += 1; triedLoadMoreRef.current += 1;
loadMore(); loadMore();
} }
}, [focusProjectId, playbacks, loadMore]); }, [loadMore, playbacks]);
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View style={{ flex: 1, backgroundColor: "black" }}>
@@ -705,7 +53,6 @@ const Playbacks = () => {
key={item?.id || index} key={item?.id || index}
item={item} item={item}
userCache={userCache} userCache={userCache}
getUserByUid={getUserByUid}
isActive={isFocused && index === activeIndex} isActive={isFocused && index === activeIndex}
/> />
)} )}
@@ -0,0 +1,107 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Image, Platform, Pressable, StyleSheet, Text, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import ProfilePicture from "../../../components/ProfilePicture";
import { icons } from "../../../assets";
import { Palette } from "../../../styles";
import { FONT_FAMILY } from "../../../styles/Fonts";
import { size } from "../../../styles/Style";
const PlaybackActions = ({
owner,
currentUID,
isFollowing,
isFollowActionPending,
onToggleFollow,
likesCount,
isLiked,
onToggleLike,
commentsCount,
onCommentsPress,
onShare,
onReport,
onOpenProfile,
}) => {
return (
<View style={styles.actionsColumn}>
<View style={styles.profileWrapper}>
<Pressable onPress={onOpenProfile}>
<ProfilePicture
uri={owner?.profilePictureURL || null}
size={45}
imageProps={{ priority: "high" }}
/>
</Pressable>
{owner?.id && owner.id !== currentUID && (
<Pressable disabled={isFollowActionPending} onPress={onToggleFollow}>
<BlurView
tint="dark"
intensity={Platform.OS !== "ios" ? 10 : 20}
style={[
styles.followButton,
{ backgroundColor: isFollowing ? "#FFFFFF1A" : undefined },
]}
>
<Text style={styles.followText}>
{isFollowing ? "Suivi(e)" : "Suivre"}
</Text>
</BlurView>
</Pressable>
)}
</View>
<Pressable onPress={onToggleLike} style={styles.centered}>
<Image
source={isLiked ? icons.heart : icons.heartOutline}
style={size({ size: 26 })}
resizeMode="contain"
/>
{!!likesCount && <Text style={styles.countText}>{likesCount}</Text>}
</Pressable>
<Pressable onPress={onCommentsPress} style={styles.centered}>
<Image source={icons.chatBubble} style={size({ size: 26 })} />
{!!commentsCount && <Text style={styles.countText}>{commentsCount}</Text>}
</Pressable>
<Pressable onPress={onShare}>
<Image source={icons.share} style={size({ size: 26 })} />
</Pressable>
<Pressable onPress={onReport} style={styles.centered}>
<Feather name="flag" size={26} color={Palette.white} />
<Text style={styles.countText}>Signaler</Text>
</Pressable>
</View>
);
};
const styles = StyleSheet.create({
actionsColumn: {
alignSelf: "flex-end",
alignItems: "center",
gap: 20,
paddingHorizontal: 13,
},
profileWrapper: { gap: 6, alignItems: "center" },
followButton: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: Palette.white,
overflow: "hidden",
},
followText: {
fontSize: 13,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
centered: { alignItems: "center" },
countText: {
color: Palette.white,
fontSize: 11,
marginTop: 4,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
},
});
export default PlaybackActions;
@@ -0,0 +1,185 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { View, StyleSheet } from "react-native";
import { responsiveHeight } from "react-native-responsive-dimensions";
import { SheetManager } from "react-native-actions-sheet";
import { useUser } from "../../../providers/UserDataProvider";
import { Routes } from "../../../navigation";
import { navigate } from "../../../navigation/NavigationService";
import { openComments } from "../../../components/bottomsheets/CommentsBottomSheet";
import {
createPlaybackSharePayload,
openShareSheet,
} from "../../../utils/shareSheet";
import {
getProjectLikes,
LIKE_TARGET,
toggleProjectLike,
} from "../../../utils/likes";
import { ensureAuthenticated } from "../../../utils/authRedirect";
import PlaybackVideo from "./PlaybackVideo";
import PlaybackActions from "./PlaybackActions";
import PlaybackLyricsCard from "./PlaybackLyricsCard";
import usePlaybackOwner from "./usePlaybackOwner";
import usePlaybackPlayer from "./usePlaybackPlayer";
const PlaybackItem = ({ item, isActive, userCache }) => {
const { currentUID, followUser, unfollowUser, getUserByUid } = useUser() || {};
const { owner, isFollowing, isFollowActionPending, toggleFollow } =
usePlaybackOwner({
item,
userCache,
getUserByUid,
currentUID,
followUser,
unfollowUser,
});
const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
const [isLiked, setIsLiked] = useState(
currentUID ? initialLikedBy.includes(currentUID) : false
);
const [likesCount, setLikesCount] = useState(initialLikedBy.length);
useEffect(() => {
const lb = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
setLikesCount(lb.length);
setIsLiked(currentUID ? lb.includes(currentUID) : false);
}, [item?.likes?.playback, currentUID]);
const commentsCount = useMemo(
() => Number(item?.commentsCount || 0),
[item?.commentsCount]
);
const projectTitle = typeof item?.title === "string" ? item.title.trim() : "";
const creatorName = useMemo(() => {
if (owner?.displayName) return owner.displayName;
if (owner?.artistName) return owner.artistName;
if (owner?.userName) return owner.userName;
if (typeof item?.userName === "string") return item.userName;
return "";
}, [item?.userName, owner?.artistName, owner?.displayName, owner?.userName]);
const { videoUrl, videoPlayer, currentTimeS } = usePlaybackPlayer({
item,
isActive,
});
const alignedWords = useMemo(() => {
const idx = Number(item?.songIndex) || 0;
const ts = item?.musicTimestamps?.[idx];
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : [];
return arr.map((w) => ({
word: String(w?.word ?? ""),
startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0),
}));
}, [item?.musicTimestamps, item?.songIndex]);
const sharePayload = useMemo(() => {
if (!item?.id) return null;
return createPlaybackSharePayload({
projectId: item.id,
title: projectTitle || undefined,
artist: creatorName || undefined,
playbackUrl: videoUrl || undefined,
});
}, [creatorName, item?.id, projectTitle, videoUrl]);
const handleShare = useCallback(() => {
if (sharePayload) {
openShareSheet(sharePayload);
}
}, [sharePayload]);
const handleReport = useCallback(() => {
if (!item?.id) return;
SheetManager.show("Report", {
payload: {
targetType: "playback",
projectId: item?.id || null,
playbackId: item?.id || null,
title: item?.title || "",
ownerId: item?.userId || null,
},
});
}, [item?.id, item?.title, item?.userId]);
const handleLike = useCallback(async () => {
try {
if (!item?.id) return;
if (!ensureAuthenticated(currentUID)) {
return;
}
const nextLiked = !isLiked;
setIsLiked(nextLiked);
setLikesCount((c) => Math.max(0, c + (nextLiked ? 1 : -1)));
await toggleProjectLike({
projectId: item.id,
currentUID,
target: LIKE_TARGET.PLAYBACK,
next: nextLiked,
});
} catch (e) {
setIsLiked((v) => !v);
setLikesCount((c) => (isLiked ? c + 1 : Math.max(0, c - 1)));
}
}, [currentUID, isLiked, item?.id]);
const handleCommentPress = useCallback(() => {
if (item?.id) openComments(item.id);
}, [item?.id]);
const onOpenProfile = useCallback(() => {
navigate(Routes.SingerProfile, { userId: item?.userId });
}, [item?.userId]);
return (
<View style={styles.container}>
<PlaybackVideo
videoUrl={videoUrl}
videoPlayer={videoPlayer}
/>
<View style={styles.overlay}>
<PlaybackActions
owner={owner}
currentUID={currentUID}
isFollowing={isFollowing}
isFollowActionPending={isFollowActionPending}
onToggleFollow={toggleFollow}
likesCount={likesCount}
isLiked={isLiked}
onToggleLike={handleLike}
commentsCount={commentsCount}
onCommentsPress={handleCommentPress}
onShare={handleShare}
onReport={handleReport}
onOpenProfile={onOpenProfile}
/>
<PlaybackLyricsCard
alignedWords={alignedWords}
currentTimeS={currentTimeS}
fallbackTitle={item?.title}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
height: responsiveHeight(100),
position: "relative",
backgroundColor: "black",
},
overlay: {
position: "absolute",
width: "100%",
bottom: 130,
gap: 18,
},
});
export default PlaybackItem;
@@ -0,0 +1,44 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Platform, StyleSheet, Text, View } from "react-native";
import KaraokeLyrics from "../../../components/KaraokeLyrics";
import { Palette } from "../../../styles";
import { FONT_FAMILY } from "../../../styles/Fonts";
const PlaybackLyricsCard = ({ alignedWords, currentTimeS, fallbackTitle }) => {
return (
<View style={styles.container}>
<BlurView
tint="dark"
intensity={Platform.OS !== "ios" ? 10 : 20}
style={styles.blur}
>
{alignedWords?.length > 0 ? (
<KaraokeLyrics alignedWords={alignedWords} currentTimeS={currentTimeS} />
) : (
<Text style={styles.title}>{fallbackTitle || "Description chanson"}</Text>
)}
</BlurView>
</View>
);
};
const styles = StyleSheet.create({
container: {
paddingHorizontal: 28,
},
blur: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: Palette.glass,
overflow: "hidden",
},
title: {
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
});
export default PlaybackLyricsCard;
@@ -0,0 +1,34 @@
import { VideoView } from "expo-video";
import React from "react";
import { Image, StyleSheet, View } from "react-native";
import { img } from "../../../assets";
const PlaybackVideo = ({ videoUrl, videoPlayer }) => {
return (
<View style={styles.container}>
{!!videoUrl ? (
<VideoView
player={videoPlayer}
nativeControls={false}
contentFit="cover"
style={styles.fill}
/>
) : (
<Image source={img.placeholder3} style={styles.fill} />
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
},
fill: {
width: "100%",
height: "100%",
},
});
export default PlaybackVideo;
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from "react";
import { usersRef } from "../../../config/firebase";
import { ensureAuthenticated } from "../../../utils/authRedirect";
const usePlaybackOwner = ({
item,
userCache,
getUserByUid,
currentUID,
followUser,
unfollowUser,
}) => {
const [owner, setOwner] = useState(
item?.userId && userCache?.current?.get(item.userId)
? userCache.current.get(item.userId)
: null
);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
const uid = item?.userId;
if (!uid || !userCache) return;
const cached = userCache.current.get(uid);
if (cached) {
if (!cancelled) setOwner(cached);
return;
}
const user = await getUserByUid?.(uid);
if (!cancelled && user) {
userCache.current.set(uid, user);
setOwner(user);
}
} catch (e) {}
};
run();
return () => {
cancelled = true;
};
}, [item?.userId, userCache, getUserByUid]);
useEffect(() => {
const uid = item?.userId;
if (!uid) return;
const unsub = usersRef.doc(uid).onSnapshot(
(doc) => {
if (doc?.exists) {
const data = { id: doc.id, ...doc.data() };
setOwner(data);
try {
userCache?.current?.set(uid, data);
} catch (e) {}
}
},
() => {}
);
return () => unsub?.();
}, [item?.userId, userCache]);
const [isFollowing, setIsFollowing] = useState(false);
const [isFollowActionPending, setIsFollowActionPending] = useState(false);
useEffect(() => {
const list = Array.isArray(owner?.followedBy) ? owner.followedBy : [];
setIsFollowing(currentUID ? list.includes(currentUID) : false);
}, [owner?.followedBy, currentUID]);
const toggleFollow = useCallback(async () => {
if (!owner?.id || owner.id === currentUID) return;
if (isFollowActionPending) return;
if (
!ensureAuthenticated(currentUID, {
onIntercept: () => {
setIsFollowActionPending(false);
},
})
) {
return;
}
try {
setIsFollowActionPending(true);
const next = !isFollowing;
setIsFollowing(next);
setOwner((prev) => {
const fb = Array.isArray(prev?.followedBy) ? prev.followedBy : [];
const newFb = next
? Array.from(new Set([...fb, currentUID]))
: fb.filter((x) => x !== currentUID);
return prev ? { ...prev, followedBy: newFb } : prev;
});
if (next) await followUser?.(owner.id);
else await unfollowUser?.(owner.id);
global.setTimeout(() => {
setIsFollowActionPending(false);
}, 1000);
} catch (e) {
setIsFollowing((v) => !v);
setIsFollowActionPending(false);
}
}, [
currentUID,
followUser,
isFollowActionPending,
isFollowing,
owner?.id,
unfollowUser,
]);
return { owner, isFollowing, isFollowActionPending, toggleFollow };
};
export default usePlaybackOwner;
@@ -0,0 +1,61 @@
import { useEffect, useMemo, useState } from "react";
import { useVideoPlayer } from "expo-video";
const usePlaybackPlayer = ({ item, isActive }) => {
const baseVideoUrl = item?.playbackUrl || null;
const videoUrl = baseVideoUrl;
const videoPlayer = useVideoPlayer(videoUrl || null, (player) => {
player.loop = false;
player.muted = false;
player.timeUpdateEventInterval = 0.2;
});
const shouldAutoPlay = isActive;
useEffect(() => {
if (!videoPlayer) return;
if (shouldAutoPlay) {
try {
videoPlayer.currentTime = 0;
videoPlayer.play();
} catch (e) {}
} else if (videoPlayer?.playing) {
try {
videoPlayer.pause();
} catch (e) {}
}
}, [shouldAutoPlay, videoPlayer]);
useEffect(() => {
return () => {
try {
if (videoPlayer?.playing) videoPlayer.pause();
} catch (e) {}
};
}, [videoPlayer]);
const [currentTimeS, setCurrentTimeS] = useState(0);
useEffect(() => {
if (!isActive) return;
const id = global.setInterval(() => {
try {
setCurrentTimeS(Number(videoPlayer?.currentTime || 0));
} catch (e) {}
}, 250);
return () => global.clearInterval(id);
}, [isActive, videoPlayer]);
const playerState = useMemo(
() => ({
videoUrl,
videoPlayer,
currentTimeS,
}),
[videoPlayer, videoUrl, currentTimeS]
);
return playerState;
};
export default usePlaybackPlayer;
+99 -44
View File
@@ -1,5 +1,7 @@
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { import {
KeyboardAvoidingView,
Modal,
Platform, Platform,
Pressable, Pressable,
ScrollView, ScrollView,
@@ -10,7 +12,6 @@ import {
import { BaseButton } from "../../components/Button"; import { BaseButton } from "../../components/Button";
import ItemContainer from "../../components/ItemContainer/ItemContainer"; import ItemContainer from "../../components/ItemContainer/ItemContainer";
import ListSelection from "../../components/ListSelection/ListSelection"; import ListSelection from "../../components/ListSelection/ListSelection";
import Overlay from "../../components/Overlay";
import { strings } from "../../constants/strings"; import { strings } from "../../constants/strings";
import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data"; import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data";
import { Palette } from "../../styles"; import { Palette } from "../../styles";
@@ -18,6 +19,68 @@ import { FONT_FAMILY } from "../../styles/Fonts";
import CreateLyricsHeader from "./components/CreateLyricsHeader"; import CreateLyricsHeader from "./components/CreateLyricsHeader";
import CustomInput from "./components/CustomInput"; import CustomInput from "./components/CustomInput";
const OtherObjectiveModal = React.memo(
({
visible,
value,
onChange,
onCancel,
onConfirm,
placeholder,
title,
}) => {
const inputRef = useRef(null);
useEffect(() => {
if (!visible) return;
const focusTimeout = setTimeout(() => {
inputRef.current?.focus?.();
}, 60);
return () => clearTimeout(focusTimeout);
}, [visible]);
return (
<Modal
transparent
visible={visible}
animationType="fade"
onRequestClose={onCancel}
>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<Pressable style={StyleSheet.absoluteFill} onPress={onCancel} />
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>{title}</Text>
<CustomInput
placeholder={placeholder}
value={value}
setValue={(text) => onChange?.(typeof text === "string" ? text : "")}
height={Platform.OS === "web" ? 160 : undefined}
autoFocus={Platform.OS !== "web"}
inputRef={inputRef}
/>
<View style={styles.modalActions}>
<BaseButton
type="secondary"
text="Annuler"
onPress={onCancel}
containerStyle={styles.modalActionButton}
/>
<BaseButton
text="Valider"
onPress={onConfirm}
containerStyle={styles.modalActionButton}
/>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
);
const Goals = ({ const Goals = ({
selected: selectedProp, selected: selectedProp,
setSelected: setSelectedProp, setSelected: setSelectedProp,
@@ -34,6 +97,9 @@ const Goals = ({
const [previousOtherObjective, setPreviousOtherObjective] = useState( const [previousOtherObjective, setPreviousOtherObjective] = useState(
normalizedOtherObjective normalizedOtherObjective
); );
const [draftOtherObjective, setDraftOtherObjective] = useState(
normalizedOtherObjective
);
const selected = selectedProp ?? internalSelected; const selected = selectedProp ?? internalSelected;
const setSelectedBase = setSelectedProp ?? setInternalSelected; const setSelectedBase = setSelectedProp ?? setInternalSelected;
@@ -43,23 +109,18 @@ const Goals = ({
const goalsOptions = useMemo(() => GOALS ?? [], []); const goalsOptions = useMemo(() => GOALS ?? [], []);
const handleOtherObjectiveChange = (value) => { useEffect(() => {
setOtherObjective?.(value); if (!otherObjectiveModalVisible) {
const trimmed = value?.trim() ?? ""; setPreviousOtherObjective(normalizedOtherObjective);
if (trimmed.length) { setDraftOtherObjective(normalizedOtherObjective);
if (selected !== OTHER_OBJECTIVE_OPTION) {
setSelectedBase(OTHER_OBJECTIVE_OPTION);
}
} else if (selected === OTHER_OBJECTIVE_OPTION) {
setSelectedBase(null);
setPreviousSelection(null);
} }
}; }, [normalizedOtherObjective, otherObjectiveModalVisible]);
const handleGoalSelect = (value) => { const handleGoalSelect = (value) => {
if (value === OTHER_OBJECTIVE_OPTION) { if (value === OTHER_OBJECTIVE_OPTION) {
setPreviousSelection(selected ?? null); setPreviousSelection(selected ?? null);
setPreviousOtherObjective(normalizedOtherObjective); setPreviousOtherObjective(normalizedOtherObjective);
setDraftOtherObjective(normalizedOtherObjective);
setSelectedBase(value); setSelectedBase(value);
setOtherObjectiveModalVisible(true); setOtherObjectiveModalVisible(true);
return; return;
@@ -72,6 +133,7 @@ const Goals = ({
const handleOpenOtherObjectiveModal = () => { const handleOpenOtherObjectiveModal = () => {
setPreviousSelection(selected ?? null); setPreviousSelection(selected ?? null);
setPreviousOtherObjective(normalizedOtherObjective); setPreviousOtherObjective(normalizedOtherObjective);
setDraftOtherObjective(normalizedOtherObjective);
setOtherObjectiveModalVisible(true); setOtherObjectiveModalVisible(true);
}; };
@@ -85,7 +147,11 @@ const Goals = ({
}; };
const handleConfirmOtherObjective = () => { const handleConfirmOtherObjective = () => {
if (!hasOtherObjectiveValue) { const trimmedNext =
typeof draftOtherObjective === "string"
? draftOtherObjective.trim()
: "";
if (!trimmedNext) {
const fallback = const fallback =
previousSelection && previousSelection !== OTHER_OBJECTIVE_OPTION previousSelection && previousSelection !== OTHER_OBJECTIVE_OPTION
? previousSelection ? previousSelection
@@ -99,8 +165,10 @@ const Goals = ({
if (selected !== OTHER_OBJECTIVE_OPTION) { if (selected !== OTHER_OBJECTIVE_OPTION) {
setSelectedBase(OTHER_OBJECTIVE_OPTION); setSelectedBase(OTHER_OBJECTIVE_OPTION);
} }
setOtherObjective?.(draftOtherObjective);
setOtherObjectiveModalVisible(false); setOtherObjectiveModalVisible(false);
setPreviousSelection(OTHER_OBJECTIVE_OPTION); setPreviousSelection(OTHER_OBJECTIVE_OPTION);
setPreviousOtherObjective(draftOtherObjective);
}; };
return ( return (
@@ -154,35 +222,15 @@ const Goals = ({
</View> </View>
) : null} ) : null}
</ScrollView> </ScrollView>
<Overlay <OtherObjectiveModal
isVisible={otherObjectiveModalVisible} visible={otherObjectiveModalVisible}
setIsVisible={handleCancelOtherObjective} value={draftOtherObjective}
> onChange={setDraftOtherObjective}
<View style={styles.modalContent}> onCancel={handleCancelOtherObjective}
<Text style={styles.modalTitle}> onConfirm={handleConfirmOtherObjective}
{strings.writing.labels.otherObjective} placeholder={strings.writing.labels.otherObjectivePlaceholder}
</Text> title={strings.writing.labels.otherObjective}
<CustomInput />
placeholder={strings.writing.labels.otherObjectivePlaceholder}
value={otherObjective}
setValue={handleOtherObjectiveChange}
height={Platform.OS === "web" ? 160 : undefined}
/>
<View style={styles.modalActions}>
<BaseButton
type="secondary"
text="Annuler"
onPress={handleCancelOtherObjective}
containerStyle={styles.modalActionButton}
/>
<BaseButton
text="Valider"
onPress={handleConfirmOtherObjective}
containerStyle={styles.modalActionButton}
/>
</View>
</View>
</Overlay>
</> </>
); );
}; };
@@ -265,8 +313,15 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
}, },
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.6)",
alignItems: "center",
justifyContent: "center",
padding: 20,
},
modalContent: { modalContent: {
width: "90%", width: "100%",
maxWidth: 420, maxWidth: 420,
padding: 20, padding: 20,
borderRadius: 16, borderRadius: 16,
@@ -14,6 +14,8 @@ const CustomInput = ({
onFocus, onFocus,
onBlur, onBlur,
onLayout, onLayout,
autoFocus = false,
inputRef = null,
}) => { }) => {
return ( return (
<View style={{ gap: 8 }} onLayout={onLayout}> <View style={{ gap: 8 }} onLayout={onLayout}>
@@ -54,6 +56,8 @@ const CustomInput = ({
textAlignVertical={multiline ? "top" : "center"} textAlignVertical={multiline ? "top" : "center"}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
autoFocus={autoFocus}
ref={inputRef}
/> />
</View> </View>
</> </>