last tickets
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "cover",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#000"
|
||||
},
|
||||
"ios": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { BlurView } from "expo-blur";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
@@ -23,6 +23,13 @@ const Overlay = ({
|
||||
const { isNative } = useLayoutType();
|
||||
|
||||
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 (
|
||||
<ContentContainerView>
|
||||
@@ -45,9 +52,8 @@ const Overlay = ({
|
||||
...Style.containerCenter,
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={blurIntensity || isNative ? defaultBlurIntensity : 15}
|
||||
tint="dark"
|
||||
<BackgroundView
|
||||
{...backgroundProps}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@@ -69,7 +75,7 @@ const Overlay = ({
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</BlurView>
|
||||
</BackgroundView>
|
||||
</Motion.View>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, createContext, useRef } from "react";
|
||||
import { StyleSheet, Text, Animated } from "react-native";
|
||||
import { Fonts, Palette, Style } from "../styles";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
import { StyleSheet, Animated } from "react-native";
|
||||
import { Palette, Style } from "../styles";
|
||||
import { Image } from "expo-image";
|
||||
|
||||
export const SplashAnimationContext = createContext();
|
||||
|
||||
@@ -39,7 +39,14 @@ const SplashAnimationProvider = ({ children }) => {
|
||||
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>
|
||||
)}
|
||||
</SplashAnimationContext.Provider>
|
||||
|
||||
@@ -585,6 +585,7 @@ const Home = ({ navigation, route }) => {
|
||||
{stageCardsList}
|
||||
|
||||
<ClubCard
|
||||
image={icons.clubIcon}
|
||||
onPress={handleClubPress}
|
||||
hasActiveSubscription={hasActiveSubscription}
|
||||
/>
|
||||
|
||||
@@ -1,655 +1,16 @@
|
||||
import { useIsFocused, useRoute } from "@react-navigation/native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { VideoView, useVideoPlayer } from "expo-video";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import Carousel from "react-native-reanimated-carousel";
|
||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||
import { icons, img } from "../../assets";
|
||||
import { openComments } from "../../components/bottomsheets/CommentsBottomSheet";
|
||||
import KaraokeLyrics from "../../components/KaraokeLyrics";
|
||||
import ProfilePicture from "../../components/ProfilePicture";
|
||||
import firebase, { projectsRef, usersRef } from "../../config/firebase";
|
||||
import { projectsRef } from "../../config/firebase";
|
||||
import useDataFromRef from "../../hooks/useDataFromRef";
|
||||
import { Routes } from "../../navigation";
|
||||
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>
|
||||
);
|
||||
};
|
||||
import PlaybackItem from "./components/PlaybackItem";
|
||||
|
||||
const Playbacks = () => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const carouselRef = useRef(null);
|
||||
const route = useRoute();
|
||||
const focusProjectId =
|
||||
route?.params?.projectId || route?.params?.focusId || null;
|
||||
const userCache = useRef(new Map());
|
||||
const { getUserByUid } = useUser() || {};
|
||||
const isFocused = useIsFocused();
|
||||
const { data: playbacks = [], loadMore } = useDataFromRef({
|
||||
ref: projectsRef.where("playbackUrl", "!=", null),
|
||||
@@ -662,33 +23,20 @@ const Playbacks = () => {
|
||||
const onSnap = useCallback(
|
||||
(index) => {
|
||||
setActiveIndex(index);
|
||||
// Pré-charge la suite 6 par 6
|
||||
if (index >= (playbacks?.length || 0) - 6) {
|
||||
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);
|
||||
useEffect(() => {
|
||||
if (!focusProjectId) return;
|
||||
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) {
|
||||
if (loadMore && triedLoadMoreRef.current < 6) {
|
||||
triedLoadMoreRef.current += 1;
|
||||
loadMore();
|
||||
}
|
||||
}, [focusProjectId, playbacks, loadMore]);
|
||||
}, [loadMore, playbacks]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
@@ -705,7 +53,6 @@ const Playbacks = () => {
|
||||
key={item?.id || index}
|
||||
item={item}
|
||||
userCache={userCache}
|
||||
getUserByUid={getUserByUid}
|
||||
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;
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
@@ -10,7 +12,6 @@ import {
|
||||
import { BaseButton } from "../../components/Button";
|
||||
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
||||
import ListSelection from "../../components/ListSelection/ListSelection";
|
||||
import Overlay from "../../components/Overlay";
|
||||
import { strings } from "../../constants/strings";
|
||||
import { GOALS, OTHER_OBJECTIVE_OPTION } from "../../data/data";
|
||||
import { Palette } from "../../styles";
|
||||
@@ -18,6 +19,68 @@ import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
||||
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 = ({
|
||||
selected: selectedProp,
|
||||
setSelected: setSelectedProp,
|
||||
@@ -34,6 +97,9 @@ const Goals = ({
|
||||
const [previousOtherObjective, setPreviousOtherObjective] = useState(
|
||||
normalizedOtherObjective
|
||||
);
|
||||
const [draftOtherObjective, setDraftOtherObjective] = useState(
|
||||
normalizedOtherObjective
|
||||
);
|
||||
|
||||
const selected = selectedProp ?? internalSelected;
|
||||
const setSelectedBase = setSelectedProp ?? setInternalSelected;
|
||||
@@ -43,23 +109,18 @@ const Goals = ({
|
||||
|
||||
const goalsOptions = useMemo(() => GOALS ?? [], []);
|
||||
|
||||
const handleOtherObjectiveChange = (value) => {
|
||||
setOtherObjective?.(value);
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (trimmed.length) {
|
||||
if (selected !== OTHER_OBJECTIVE_OPTION) {
|
||||
setSelectedBase(OTHER_OBJECTIVE_OPTION);
|
||||
}
|
||||
} else if (selected === OTHER_OBJECTIVE_OPTION) {
|
||||
setSelectedBase(null);
|
||||
setPreviousSelection(null);
|
||||
useEffect(() => {
|
||||
if (!otherObjectiveModalVisible) {
|
||||
setPreviousOtherObjective(normalizedOtherObjective);
|
||||
setDraftOtherObjective(normalizedOtherObjective);
|
||||
}
|
||||
};
|
||||
}, [normalizedOtherObjective, otherObjectiveModalVisible]);
|
||||
|
||||
const handleGoalSelect = (value) => {
|
||||
if (value === OTHER_OBJECTIVE_OPTION) {
|
||||
setPreviousSelection(selected ?? null);
|
||||
setPreviousOtherObjective(normalizedOtherObjective);
|
||||
setDraftOtherObjective(normalizedOtherObjective);
|
||||
setSelectedBase(value);
|
||||
setOtherObjectiveModalVisible(true);
|
||||
return;
|
||||
@@ -72,6 +133,7 @@ const Goals = ({
|
||||
const handleOpenOtherObjectiveModal = () => {
|
||||
setPreviousSelection(selected ?? null);
|
||||
setPreviousOtherObjective(normalizedOtherObjective);
|
||||
setDraftOtherObjective(normalizedOtherObjective);
|
||||
setOtherObjectiveModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -85,7 +147,11 @@ const Goals = ({
|
||||
};
|
||||
|
||||
const handleConfirmOtherObjective = () => {
|
||||
if (!hasOtherObjectiveValue) {
|
||||
const trimmedNext =
|
||||
typeof draftOtherObjective === "string"
|
||||
? draftOtherObjective.trim()
|
||||
: "";
|
||||
if (!trimmedNext) {
|
||||
const fallback =
|
||||
previousSelection && previousSelection !== OTHER_OBJECTIVE_OPTION
|
||||
? previousSelection
|
||||
@@ -99,8 +165,10 @@ const Goals = ({
|
||||
if (selected !== OTHER_OBJECTIVE_OPTION) {
|
||||
setSelectedBase(OTHER_OBJECTIVE_OPTION);
|
||||
}
|
||||
setOtherObjective?.(draftOtherObjective);
|
||||
setOtherObjectiveModalVisible(false);
|
||||
setPreviousSelection(OTHER_OBJECTIVE_OPTION);
|
||||
setPreviousOtherObjective(draftOtherObjective);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -154,35 +222,15 @@ const Goals = ({
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
<Overlay
|
||||
isVisible={otherObjectiveModalVisible}
|
||||
setIsVisible={handleCancelOtherObjective}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{strings.writing.labels.otherObjective}
|
||||
</Text>
|
||||
<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>
|
||||
<OtherObjectiveModal
|
||||
visible={otherObjectiveModalVisible}
|
||||
value={draftOtherObjective}
|
||||
onChange={setDraftOtherObjective}
|
||||
onCancel={handleCancelOtherObjective}
|
||||
onConfirm={handleConfirmOtherObjective}
|
||||
placeholder={strings.writing.labels.otherObjectivePlaceholder}
|
||||
title={strings.writing.labels.otherObjective}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -265,8 +313,15 @@ const styles = StyleSheet.create({
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterMedium,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: "90%",
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -14,6 +14,8 @@ const CustomInput = ({
|
||||
onFocus,
|
||||
onBlur,
|
||||
onLayout,
|
||||
autoFocus = false,
|
||||
inputRef = null,
|
||||
}) => {
|
||||
return (
|
||||
<View style={{ gap: 8 }} onLayout={onLayout}>
|
||||
@@ -54,6 +56,8 @@ const CustomInput = ({
|
||||
textAlignVertical={multiline ? "top" : "center"}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
autoFocus={autoFocus}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user