From 471a3177e829715654abc0144ee93bdbcdf4ea8b Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Fri, 21 Nov 2025 21:57:41 +0100 Subject: [PATCH] last tickets --- app.json | 2 +- src/components/Overlay.js | 16 +- src/providers/SplashAnimationProvider.js | 15 +- src/screens/Home/Home.js | 1 + src/screens/Playbacks/Playbacks.js | 667 +----------------- .../Playbacks/components/PlaybackActions.js | 107 +++ .../Playbacks/components/PlaybackItem.js | 185 +++++ .../components/PlaybackLyricsCard.js | 44 ++ .../Playbacks/components/PlaybackVideo.js | 34 + .../Playbacks/components/usePlaybackOwner.js | 112 +++ .../Playbacks/components/usePlaybackPlayer.js | 61 ++ src/screens/Writing/Goals.js | 143 ++-- src/screens/Writing/components/CustomInput.js | 4 + 13 files changed, 677 insertions(+), 714 deletions(-) create mode 100644 src/screens/Playbacks/components/PlaybackActions.js create mode 100644 src/screens/Playbacks/components/PlaybackItem.js create mode 100644 src/screens/Playbacks/components/PlaybackLyricsCard.js create mode 100644 src/screens/Playbacks/components/PlaybackVideo.js create mode 100644 src/screens/Playbacks/components/usePlaybackOwner.js create mode 100644 src/screens/Playbacks/components/usePlaybackPlayer.js diff --git a/app.json b/app.json index fc69f09..5598ee5 100644 --- a/app.json +++ b/app.json @@ -17,7 +17,7 @@ }, "splash": { "image": "./assets/splash.png", - "resizeMode": "cover", + "resizeMode": "contain", "backgroundColor": "#000" }, "ios": { diff --git a/src/components/Overlay.js b/src/components/Overlay.js index edbbc95..a575065 100644 --- a/src/components/Overlay.js +++ b/src/components/Overlay.js @@ -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 ( @@ -45,9 +52,8 @@ const Overlay = ({ ...Style.containerCenter, }} > - {children} - + ) : null} diff --git a/src/providers/SplashAnimationProvider.js b/src/providers/SplashAnimationProvider.js index e4e15b6..dd04b8b 100644 --- a/src/providers/SplashAnimationProvider.js +++ b/src/providers/SplashAnimationProvider.js @@ -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 && m} + )} diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index 682d2ff..9e2de02 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -585,6 +585,7 @@ const Home = ({ navigation, route }) => { {stageCardsList} diff --git a/src/screens/Playbacks/Playbacks.js b/src/screens/Playbacks/Playbacks.js index 38c949f..b3e4f2f 100644 --- a/src/screens/Playbacks/Playbacks.js +++ b/src/screens/Playbacks/Playbacks.js @@ -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 ( - - {!!videoUrl ? ( - - ) : ( - - )} - - {Platform.OS === "ios" && repairState.running && ( - - - - Optimisation vidéo iOS... - - - )} - - {Platform.OS === "ios" && repairState.error && ( - - - Impossible de lire cette vidéo. Veuillez réessayer plus tard. - - - )} - - {/* Right side actions */} - - - - { - navigate(Routes.SingerProfile, { userId: item?.userId }); - }} - > - - - {owner?.id && owner.id !== currentUID && ( - { - 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); - } - }} - > - - - {isFollowing ? "Suivi(e)" : "Suivre"} - - - - )} - - { - 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" }} - > - - {!!likesCount && ( - - {likesCount} - - )} - - item?.id && openComments(item.id)} - style={{ alignItems: "center" }} - > - - {!!commentsCount && ( - - {commentsCount} - - )} - - - - - - - - Signaler - - - - - - {alignedWords?.length > 0 ? ( - - ) : ( - - {item?.title || "Description chanson"} - - )} - - - - - ); -}; +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 ( @@ -705,7 +53,6 @@ const Playbacks = () => { key={item?.id || index} item={item} userCache={userCache} - getUserByUid={getUserByUid} isActive={isFocused && index === activeIndex} /> )} diff --git a/src/screens/Playbacks/components/PlaybackActions.js b/src/screens/Playbacks/components/PlaybackActions.js new file mode 100644 index 0000000..94cb8fa --- /dev/null +++ b/src/screens/Playbacks/components/PlaybackActions.js @@ -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 ( + + + + + + {owner?.id && owner.id !== currentUID && ( + + + + {isFollowing ? "Suivi(e)" : "Suivre"} + + + + )} + + + + {!!likesCount && {likesCount}} + + + + {!!commentsCount && {commentsCount}} + + + + + + + Signaler + + + ); +}; + +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; diff --git a/src/screens/Playbacks/components/PlaybackItem.js b/src/screens/Playbacks/components/PlaybackItem.js new file mode 100644 index 0000000..0fc00cb --- /dev/null +++ b/src/screens/Playbacks/components/PlaybackItem.js @@ -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 ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: responsiveHeight(100), + position: "relative", + backgroundColor: "black", + }, + overlay: { + position: "absolute", + width: "100%", + bottom: 130, + gap: 18, + }, +}); + +export default PlaybackItem; diff --git a/src/screens/Playbacks/components/PlaybackLyricsCard.js b/src/screens/Playbacks/components/PlaybackLyricsCard.js new file mode 100644 index 0000000..86a6e4f --- /dev/null +++ b/src/screens/Playbacks/components/PlaybackLyricsCard.js @@ -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 ( + + + {alignedWords?.length > 0 ? ( + + ) : ( + {fallbackTitle || "Description chanson"} + )} + + + ); +}; + +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; diff --git a/src/screens/Playbacks/components/PlaybackVideo.js b/src/screens/Playbacks/components/PlaybackVideo.js new file mode 100644 index 0000000..beba69f --- /dev/null +++ b/src/screens/Playbacks/components/PlaybackVideo.js @@ -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 ( + + {!!videoUrl ? ( + + ) : ( + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + }, + fill: { + width: "100%", + height: "100%", + }, +}); + +export default PlaybackVideo; diff --git a/src/screens/Playbacks/components/usePlaybackOwner.js b/src/screens/Playbacks/components/usePlaybackOwner.js new file mode 100644 index 0000000..17645ae --- /dev/null +++ b/src/screens/Playbacks/components/usePlaybackOwner.js @@ -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; diff --git a/src/screens/Playbacks/components/usePlaybackPlayer.js b/src/screens/Playbacks/components/usePlaybackPlayer.js new file mode 100644 index 0000000..55b9d56 --- /dev/null +++ b/src/screens/Playbacks/components/usePlaybackPlayer.js @@ -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; diff --git a/src/screens/Writing/Goals.js b/src/screens/Writing/Goals.js index 4a0f235..7aa68ae 100644 --- a/src/screens/Writing/Goals.js +++ b/src/screens/Writing/Goals.js @@ -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 ( + + + + + {title} + onChange?.(typeof text === "string" ? text : "")} + height={Platform.OS === "web" ? 160 : undefined} + autoFocus={Platform.OS !== "web"} + inputRef={inputRef} + /> + + + + + + + + ); + } +); + 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 = ({ ) : null} - - - - {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, diff --git a/src/screens/Writing/components/CustomInput.js b/src/screens/Writing/components/CustomInput.js index 09198f8..6233507 100644 --- a/src/screens/Writing/components/CustomInput.js +++ b/src/screens/Writing/components/CustomInput.js @@ -14,6 +14,8 @@ const CustomInput = ({ onFocus, onBlur, onLayout, + autoFocus = false, + inputRef = null, }) => { return ( @@ -54,6 +56,8 @@ const CustomInput = ({ textAlignVertical={multiline ? "top" : "center"} onFocus={onFocus} onBlur={onBlur} + autoFocus={autoFocus} + ref={inputRef} />