Files
musicland/src/screens/Playbacks/Playbacks.web.js
T
2025-09-30 13:32:02 +02:00

1095 lines
31 KiB
JavaScript

import { useIsFocused, useRoute } from "@react-navigation/native";
import { useAudioPlayer } from "expo-audio";
import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image";
import { VideoView, useVideoPlayer } from "expo-video";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
FlatList,
Image,
Platform,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
View,
useWindowDimensions,
} from "react-native";
// Carousel removed; using FlatList for real scrollable content
import { setGlobal } from "reactn";
import { icons, img } from "../../assets";
import KaraokeLyrics from "../../components/KaraokeLyrics";
import {
arrayRemove,
arrayUnion,
increment,
projectsRef,
serverTimestamp,
usersRef,
} 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";
// === Helpers ===
const formatRelativeTime = (date) => {
try {
const d = date instanceof Date ? date : date?.toDate?.() || null;
if (!d) return "";
const diff = Math.max(0, Date.now() - d.getTime());
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}min`;
const h = Math.floor(min / 60);
if (h < 24) return `${h}h`;
const dA = Math.floor(h / 24);
return `${dA}j`;
} catch (_e) {
return "";
}
};
// === Comments Panel ===
const CommentsPanel = ({
projectId,
description,
commentsCount,
onCommentAdded,
inputRef,
panelHeight,
}) => {
const { currentUID, currentUserData } = useUser() || {};
const [text, setText] = useState("");
const scrollRef = useRef(null);
useEffect(() => {
setText("");
try {
scrollRef.current?.scrollTo({ y: 0, animated: false });
} catch (_e) {}
}, [projectId]);
const commentsRef = useMemo(() => {
try {
return projectId
? projectsRef
.doc(projectId)
.collection("comments")
.orderBy("createdAt", "desc")
: null;
} catch (_e) {
return null;
}
}, [projectId]);
const {
data: comments = [],
setData: setComments,
loadMore,
} = useDataFromRef({
ref: commentsRef,
simpleRef: false,
listener: false,
condition: !!commentsRef,
refreshArray: [projectId],
usePagination: true,
batchSize: 20,
});
const handleScroll = useCallback(
({ nativeEvent }) => {
try {
const { layoutMeasurement, contentOffset, contentSize } =
nativeEvent || {};
if (
layoutMeasurement?.height + contentOffset?.y >=
(contentSize?.height || 0) - 120
) {
loadMore?.();
}
} catch (_e) {}
},
[loadMore]
);
const onSend = useCallback(async () => {
const value = (text || "").trim();
if (!value || !projectId || !currentUID) return;
try {
setText("");
const docRef = await projectsRef
.doc(projectId)
.collection("comments")
.add({
userId: currentUID,
userName: currentUserData?.userName || "",
profilePicture: currentUserData?.profilePictureURL || "",
text: value,
createdAt: serverTimestamp(),
});
try {
await projectsRef
.doc(projectId)
.set({ commentsCount: increment(1) }, { merge: true });
} catch (_e) {}
const optimistic = {
id: docRef?.id || Math.random().toString(36).slice(2),
userId: currentUID,
userName: currentUserData?.userName || "",
profilePicture: currentUserData?.profilePictureURL || "",
text: value,
createdAt: new Date(),
};
setComments((prev = []) => [
optimistic,
...prev.filter((x) => x?.id !== optimistic.id),
]);
onCommentAdded?.();
requestAnimationFrame(() => {
try {
scrollRef.current?.scrollTo({ y: 0, animated: true });
} catch (_e) {}
});
} catch (_e) {}
}, [
currentUID,
currentUserData,
onCommentAdded,
projectId,
setComments,
text,
]);
const canComment = !!currentUID;
const descriptionText = (description || "").trim();
return (
<BlurView
intensity={Platform.OS !== "ios" ? 10 : 20}
tint="dark"
style={[
styles.commentsWrapper,
panelHeight ? { height: panelHeight } : null,
]}
>
<View style={styles.commentsInner}>
<ScrollView
ref={scrollRef}
style={styles.commentsScroll}
contentContainerStyle={styles.commentsContent}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
onScroll={handleScroll}
scrollEventThrottle={120}
showsVerticalScrollIndicator={false}
>
<Text style={styles.sectionTitle}>Description</Text>
<View style={styles.descriptionCard}>
<Text style={styles.descriptionText}>
{descriptionText || "Description chanson..."}
</Text>
</View>
<Text style={[styles.sectionTitle, styles.sectionSpacing]}>
{`Commentaires${commentsCount ? ` (${commentsCount})` : ""}`}
</Text>
{Array.isArray(comments) && comments.length > 0 ? (
comments.map((c, index) => (
<View
key={c.id}
style={[
styles.commentRow,
index > 0 && styles.commentRowSpacing,
]}
>
{c?.profilePicture ? (
<ExpoImage
source={{ uri: c.profilePicture }}
cachePolicy="memory-disk"
priority="high"
contentFit="cover"
transition={100}
style={styles.commentAvatar}
/>
) : (
<View style={styles.commentAvatarFallback} />
)}
<View style={styles.commentBubble}>
<View style={styles.commentHeader}>
<Text style={styles.commentAuthor}>
{c?.userId === currentUID ? "vous" : c?.userName || ""}
</Text>
<Text style={styles.commentMeta}>
{formatRelativeTime(c?.createdAt)}
</Text>
</View>
<Text style={styles.commentBody}>{c?.text}</Text>
</View>
</View>
))
) : (
<Text style={styles.emptyState}>
Aucun commentaire pour le moment
</Text>
)}
</ScrollView>
<View style={styles.commentInputContainer}>
<TextInput
ref={inputRef}
value={text}
onChangeText={setText}
placeholder={
canComment
? "Commente"
: "Connecte-toi pour laisser un commentaire"
}
placeholderTextColor="#FFFFFF90"
editable={canComment}
style={[styles.commentInput, !canComment && { opacity: 0.6 }]}
/>
<Pressable
onPress={onSend}
disabled={!canComment || !text.trim()}
style={({ pressed }) => [
styles.sendButton,
(!canComment || !text.trim()) && styles.sendButtonDisabled,
pressed && canComment && styles.sendButtonPressed,
]}
>
<Image
source={icons.send}
style={styles.sendIcon}
resizeMode="contain"
/>
</Pressable>
</View>
</View>
</BlurView>
);
};
// === Playback Item (WEB-ONLY, side-by-side, sound on) ===
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
const { width: viewportWidth, height: viewportHeight } =
useWindowDimensions();
const [layoutSize, setLayoutSize] = useState({
width: viewportWidth,
height: viewportHeight,
});
const commentInputRef = useRef(null);
const { currentUID, followUser, unfollowUser } = useUser() || {};
const videoUrl = item?.playbackUrl || null;
// External audio source (if present)
const audioSource = useMemo(() => {
const fromSong = item?.songUrl ? { uri: item.songUrl } : null;
return fromSong;
}, [item]);
const hasExternalAudio = !!audioSource;
const initialLikedBy = Array.isArray(item?.likedBy) ? item.likedBy : [];
const [isLiked, setIsLiked] = useState(
currentUID ? initialLikedBy.includes(currentUID) : false
);
const [likesCount, setLikesCount] = useState(initialLikedBy.length);
const computedCommentsCount = useMemo(
() => Number(item?.commentsCount || 0),
[item?.commentsCount]
);
const [commentsCount, setCommentsCount] = useState(computedCommentsCount);
useEffect(() => {
setCommentsCount(computedCommentsCount);
}, [computedCommentsCount]);
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);
useEffect(() => {
const list = Array.isArray(owner?.followedBy) ? owner.followedBy : [];
setIsFollowing(currentUID ? list.includes(currentUID) : false);
}, [owner?.followedBy, currentUID]);
useEffect(() => {
const lb = Array.isArray(item?.likedBy) ? item.likedBy : [];
setLikesCount(lb.length);
setIsLiked(currentUID ? lb.includes(currentUID) : false);
}, [item?.likedBy, currentUID]);
// Players
const audioPlayer = useAudioPlayer(audioSource || undefined);
const videoPlayer = useVideoPlayer(videoUrl || null, (p) => {
p.loop = true;
p.muted = false; // 🔊 Sound must be on
p.timeUpdateEventInterval = 0.2;
});
// Always attempt to start both audio & video together when active
useEffect(() => {
const startBoth = async () => {
try {
if (!isActive) return;
// reset positions
try {
if (audioPlayer && hasExternalAudio) await audioPlayer.seekTo?.(0);
} catch (_e) {}
try {
if (videoPlayer) videoPlayer.currentTime = 0;
} catch (_e) {}
// play both
try {
if (videoPlayer && !videoPlayer.playing) await videoPlayer.play();
} catch (_e) {}
try {
if (hasExternalAudio && audioPlayer && !audioPlayer.playing)
await audioPlayer.play?.();
} catch (_e) {}
} catch (_e) {}
};
startBoth();
// If autoplay with sound is blocked, attach once-only global listeners
// to resume without any extra UI/button.
if (isActive) {
const resume = async () => {
try {
if (videoPlayer && !videoPlayer.playing) await videoPlayer.play();
if (hasExternalAudio && audioPlayer && !audioPlayer.playing)
await audioPlayer.play?.();
} catch (_e) {}
};
const events = [
"pointerdown",
"click",
"touchstart",
"keydown",
"wheel",
"scroll",
];
events.forEach((ev) =>
document.addEventListener(ev, resume, { once: true, passive: true })
);
return () => {
events.forEach((ev) => document.removeEventListener(ev, resume));
};
}
}, [isActive, audioPlayer, videoPlayer, hasExternalAudio]);
// Keep A/V roughly aligned
useEffect(() => {
if (!isActive || !hasExternalAudio) return;
const t = setInterval(() => {
try {
const a = Number(audioPlayer?.currentTime || 0);
const v = Number(videoPlayer?.currentTime || 0);
if (Math.abs(a - v) > 0.2 && videoPlayer) {
videoPlayer.currentTime = Math.max(0, a);
}
} catch (_e) {}
}, 300);
return () => clearInterval(t);
}, [isActive, hasExternalAudio, audioPlayer, videoPlayer]);
// Pause on unmount / when not active
useEffect(() => {
return () => {
try {
if (audioPlayer?.playing) audioPlayer.pause?.();
if (videoPlayer?.playing) videoPlayer.pause();
} catch (_e) {}
};
}, [audioPlayer, videoPlayer]);
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 [currentTimeS, setCurrentTimeS] = useState(0);
useEffect(() => {
if (!isActive) return;
const id = setInterval(() => {
try {
const useAudioTime = hasExternalAudio && !!audioPlayer?.playing;
const t = useAudioTime
? Number(audioPlayer?.currentTime || 0)
: Number(videoPlayer?.currentTime || 0);
setCurrentTimeS(t);
} catch (_e) {}
}, 250);
return () => clearInterval(id);
}, [isActive, hasExternalAudio, audioPlayer, videoPlayer]);
const descriptionText =
item?.description || item?.title || "Description chanson";
// Layout: always side-by-side on web
const handleLayout = useCallback((event) => {
const { width = 0, height = 0 } = event?.nativeEvent?.layout || {};
setLayoutSize((prev) => ({
width: width > 0 ? width : prev.width,
height: height > 0 ? height : prev.height,
}));
}, []);
const layoutWidth = layoutSize.width || viewportWidth;
const layoutHeight = layoutSize.height || viewportHeight;
// Force side-by-side
const horizontalPadding = 64;
const innerWidth = Math.max(layoutWidth - horizontalPadding * 2, 0);
const gapBetweenColumns = 40;
const minVideoWidth = 420;
const maxVideoWidth = 720;
const minCommentsWidth = 300;
const maxCommentsWidth = 420;
let desiredHeight = Math.max(420, layoutHeight * 0.8);
let videoWidth = Math.min(
maxVideoWidth,
Math.max(minVideoWidth, innerWidth * 0.58)
);
let videoHeight = videoWidth * (16 / 9);
if (videoHeight > desiredHeight) {
videoHeight = desiredHeight;
videoWidth = videoHeight * (9 / 16);
}
let commentsWidth = Math.min(
maxCommentsWidth,
Math.max(minCommentsWidth, innerWidth - videoWidth - gapBetweenColumns)
);
const panelHeight = Math.min(videoHeight, desiredHeight);
return (
<View
onLayout={handleLayout}
style={[
styles.itemContainerBase,
styles.itemContainerRow,
{
height: "90%",
paddingHorizontal: horizontalPadding,
paddingVertical: Math.max((layoutHeight - panelHeight) / 2, 24),
},
]}
>
<View
style={[
styles.videoColumn,
{ width: videoWidth, marginRight: gapBetweenColumns },
]}
>
<View style={[styles.videoSurface, { height: panelHeight }]}>
<View style={styles.videoContainer}>
{!!videoUrl ? (
<VideoView
player={videoPlayer}
nativeControls={false}
contentFit="cover"
style={StyleSheet.absoluteFillObject}
/>
) : (
<Image
source={img.placeholder3}
style={StyleSheet.absoluteFillObject}
resizeMode="cover"
/>
)}
<View style={styles.actionStack}>
<View style={styles.ownerBlock}>
<Pressable
style={styles.ownerAvatarButton}
onPress={() => {
navigate(Routes.SingerProfile, { userId: item?.userId });
}}
>
{owner?.profilePictureURL ? (
<Image
source={{ uri: owner.profilePictureURL }}
style={styles.ownerAvatar}
/>
) : (
<Image source={img.profile} style={styles.ownerAvatar} />
)}
</Pressable>
{owner?.id && currentUID && owner.id !== currentUID && (
<Pressable
style={styles.followPressable}
onPress={async () => {
try {
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);
} catch (_e) {
setIsFollowing((v) => !v);
}
}}
>
<BlurView
tint="dark"
intensity={Platform.OS !== "ios" ? 10 : 20}
style={styles.followButton}
>
<Text style={styles.followButtonText}>
{isFollowing ? "Ne plus suivre" : "Suivre"}
</Text>
</BlurView>
</Pressable>
)}
</View>
<Pressable
onPress={async () => {
try {
if (!currentUID || !item?.id) return;
const nextLiked = !isLiked;
setIsLiked(nextLiked);
setLikesCount((c) => Math.max(0, c + (nextLiked ? 1 : -1)));
const ref = projectsRef.doc(item.id);
await ref.set(
{
likedBy: nextLiked
? arrayUnion(currentUID)
: arrayRemove(currentUID),
},
{ merge: true }
);
} catch (_e) {
setIsLiked((v) => !v);
setLikesCount((c) =>
isLiked ? c + 1 : Math.max(0, c - 1)
);
}
}}
style={[styles.actionButton, styles.actionSpacing]}
>
<Image
source={isLiked ? icons.heart : icons.heartOutline}
style={styles.actionIcon}
resizeMode="contain"
/>
{!!likesCount && (
<Text style={styles.actionLabel}>{likesCount}</Text>
)}
</Pressable>
<Pressable
onPress={() => commentInputRef.current?.focus?.()}
style={[styles.actionButton, styles.actionSpacing]}
>
<Image
source={icons.chatBubble}
style={styles.actionIcon}
resizeMode="contain"
/>
{!!commentsCount && (
<Text style={styles.actionLabel}>{commentsCount}</Text>
)}
</Pressable>
<Pressable
onPress={async () => {
try {
const url = item?.playbackUrl || "";
const title = item?.title || "Partager";
const base = item?.title
? `Découvre « ${item.title} » sur MusicLand`
: `Découvre ce playback sur MusicLand`;
const message = url ? `${base}\n${url}` : base;
await Share.share(
Platform.select({
ios: url ? { url, message, title } : { message, title },
default: { message, title },
})
);
} catch (_e) {}
}}
style={[styles.actionButton, styles.actionSpacing]}
>
<Image
source={icons.share}
style={styles.actionIcon}
resizeMode="contain"
/>
</Pressable>
</View>
<View style={styles.lyricsContainer}>
<BlurView
tint="dark"
intensity={Platform.OS !== "ios" ? 10 : 20}
style={styles.lyricsCard}
>
{alignedWords?.length > 0 ? (
<KaraokeLyrics
alignedWords={alignedWords}
currentTimeS={currentTimeS}
/>
) : (
<Text style={styles.lyricsText}>{descriptionText}</Text>
)}
</BlurView>
</View>
</View>
</View>
</View>
{/* Comments always visible side-by-side */}
<View
style={[
styles.commentsColumn,
{ width: commentsWidth, height: panelHeight },
]}
>
<CommentsPanel
projectId={item?.id}
description={descriptionText}
commentsCount={commentsCount}
onCommentAdded={() =>
setCommentsCount((c) => Math.max(0, Number(c || 0) + 1))
}
inputRef={commentInputRef}
panelHeight={panelHeight}
/>
</View>
</View>
);
};
const Playbacks = () => {
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
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),
simpleRef: false,
listener: false,
usePagination: true,
batchSize: 6,
});
// Web-only layout hint
useEffect(() => {
if (Platform.OS !== "web") return undefined;
setGlobal({ webLayoutMode: isFocused ? "playbacks-wide" : "default" });
return () => setGlobal({ webLayoutMode: "default" });
}, [isFocused]);
const onSnap = useCallback(
(index) => {
setActiveIndex(index);
if (index >= (playbacks?.length || 0) - 6) {
loadMore?.();
}
},
[playbacks?.length, loadMore]
);
const triedLoadMoreRef = useRef(0);
useEffect(() => {
if (!focusProjectId) return;
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
if (idx >= 0) {
setActiveIndex(idx);
setTimeout(() => {
try {
carouselRef.current?.scrollTo?.({ index: idx, animated: false });
} catch (_e) {}
}, 50);
} else if (loadMore && triedLoadMoreRef.current < 6) {
triedLoadMoreRef.current += 1;
loadMore();
}
}, [focusProjectId, playbacks, loadMore]);
// === FlatList (one real page per item) ===
const listRef = useRef(null);
// keep active index in sync with scroll
const onMomentumScrollEnd = useCallback(
(e) => {
try {
const y = e?.nativeEvent?.contentOffset?.y || 0;
const idx = Math.round(y / windowHeight);
if (!Number.isNaN(idx))
setActiveIndex(
Math.max(0, Math.min(idx, (playbacks?.length || 1) - 1))
);
if (idx >= (playbacks?.length || 0) - 6) loadMore?.();
} catch (_e) {}
},
[windowHeight, playbacks?.length, loadMore]
);
// programmatic jump to focus item
useEffect(() => {
if (!focusProjectId) return;
const idx = playbacks.findIndex((p) => p?.id === focusProjectId);
if (idx >= 0) {
setActiveIndex(idx);
setTimeout(() => {
try {
listRef.current?.scrollToIndex?.({ index: idx, animated: false });
} catch (_e) {}
}, 50);
}
}, [focusProjectId, playbacks]);
const getItemLayout = useCallback(
(_data, index) => ({
length: windowHeight,
offset: windowHeight * index,
index,
}),
[windowHeight]
);
return (
<View style={styles.screen}>
<FlatList
ref={listRef}
data={playbacks}
keyExtractor={(item, index) => String(item?.id || index)}
renderItem={({ item, index }) => (
<View style={{ width: windowWidth, height: windowHeight }}>
<PlaybackItem
item={item}
userCache={userCache}
getUserByUid={getUserByUid}
isActive={isFocused && index === activeIndex}
/>
</View>
)}
pagingEnabled
showsVerticalScrollIndicator={false}
onMomentumScrollEnd={onMomentumScrollEnd}
getItemLayout={getItemLayout}
initialNumToRender={3}
windowSize={5}
removeClippedSubviews
scrollEventThrottle={32}
/>
</View>
);
};
export default Playbacks;
// === Styles ===
const styles = StyleSheet.create({
screen: {
flex: 1,
// prevent page scroll bounce and ensure the wheel maps to paging
// (RN Web supports these CSS props on View)
overscrollBehavior: "none",
},
itemContainerBase: {
width: "100%",
paddingVertical: 36,
alignItems: "center",
},
itemContainerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
videoColumn: {
alignItems: "center",
justifyContent: "center",
},
videoSurface: {
alignSelf: "center",
aspectRatio: 9 / 16,
borderRadius: 32,
overflow: "hidden",
backgroundColor: "#07040D",
shadowColor: "#000",
shadowOpacity: 0.45,
shadowRadius: 30,
shadowOffset: { width: 0, height: 20 },
elevation: 12,
},
videoContainer: {
flex: 1,
position: "relative",
},
actionStack: {
position: "absolute",
right: 18,
bottom: 120,
alignItems: "center",
},
ownerBlock: {
alignItems: "center",
marginBottom: 26,
},
ownerAvatarButton: {
marginBottom: 16,
},
ownerAvatar: {
...size({ size: 56 }),
borderRadius: 100,
},
followPressable: {
alignSelf: "center",
},
followButton: {
paddingVertical: 8,
paddingHorizontal: 18,
borderRadius: 14,
borderWidth: 1,
borderColor: Palette.white,
backgroundColor: "#FFFFFF20",
overflow: "hidden",
},
followButtonText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
actionButton: {
alignItems: "center",
justifyContent: "center",
},
actionSpacing: {
marginTop: 24,
},
actionIcon: {
...size({ size: 30 }),
},
actionLabel: {
marginTop: 6,
fontSize: 13,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
},
lyricsContainer: {
position: "absolute",
left: 24,
right: 24,
bottom: 32,
},
lyricsCard: {
paddingHorizontal: 18,
paddingVertical: 12,
borderRadius: 22,
backgroundColor: Palette.glass,
overflow: "hidden",
},
lyricsText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
commentsColumn: {
alignSelf: "stretch",
},
commentsWrapper: {
flex: 1,
borderRadius: 28,
overflow: "hidden",
padding: 24,
backgroundColor: Palette.glass,
minHeight: 0,
},
commentsInner: {
flex: 1,
justifyContent: "space-between",
},
commentsScroll: {
flex: 1,
},
commentsContent: {
paddingBottom: 24,
flexGrow: 1,
},
sectionTitle: {
fontSize: 18,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
marginBottom: 12,
},
sectionSpacing: {
marginTop: 24,
},
descriptionCard: {
borderRadius: 18,
backgroundColor: "#FFFFFF14",
paddingHorizontal: 18,
paddingVertical: 14,
},
descriptionText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
},
commentRow: {
flexDirection: "row",
alignItems: "flex-start",
},
commentRowSpacing: {
marginTop: 16,
},
commentAvatar: {
...size({ size: 48 }),
borderRadius: 100,
marginRight: 12,
},
commentAvatarFallback: {
...size({ size: 48 }),
borderRadius: 100,
backgroundColor: "#FFFFFF33",
marginRight: 12,
},
commentBubble: {
flex: 1,
backgroundColor: "#FFFFFF18",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 12,
},
commentHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 4,
},
commentAuthor: {
fontSize: 13,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
commentMeta: {
fontSize: 12,
color: "#FFFFFFA0",
fontFamily: FONT_FAMILY.InterRegular,
marginLeft: 8,
},
commentBody: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
},
emptyState: {
fontSize: 14,
color: "#FFFFFFBB",
fontFamily: FONT_FAMILY.InterRegular,
},
commentInputContainer: {
marginTop: 20,
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF14",
borderRadius: 18,
paddingHorizontal: 18,
paddingVertical: 10,
},
commentInput: {
flex: 1,
color: Palette.white,
fontSize: 14,
fontFamily: FONT_FAMILY.InterRegular,
minHeight: 36,
marginRight: 12,
},
sendButton: {
...size({ size: 38 }),
borderRadius: 19,
alignItems: "center",
justifyContent: "center",
backgroundColor: Palette.primary,
},
sendButtonPressed: {
transform: [{ scale: 0.95 }],
},
sendButtonDisabled: {
backgroundColor: "#FFFFFF33",
},
sendIcon: {
width: 22,
height: 22,
tintColor: Palette.white,
},
});