more fixes web + mobile

This commit is contained in:
Thomas Demirdjian
2025-11-20 11:26:24 +01:00
parent fe9b2b9e8d
commit 70c94c97b4
32 changed files with 1214 additions and 226 deletions
+242
View File
@@ -0,0 +1,242 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import React, { useCallback, useEffect, useState } from "react";
import {
Image,
Linking,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { icons } from "../assets";
import { appleAppStoreUrl, googlePlayStoreUrl } from "../data";
import useLayoutType from "../hooks/useLayoutType";
import { Palette, gutters } from "../styles";
import { FONT_FAMILY } from "../styles/Fonts";
const STORAGE_KEY = "appDownloadBanner:dismissed";
const AppDownloadBanner = () => {
const { isMobileWeb } = useLayoutType();
const [isVisible, setIsVisible] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
let mounted = true;
if (!isMobileWeb) {
setIsVisible(false);
setHasHydrated(false);
return undefined;
}
AsyncStorage.getItem(STORAGE_KEY)
.then((value) => {
if (!mounted) return;
setIsVisible(value !== "hidden");
setHasHydrated(true);
})
.catch(() => {
if (!mounted) return;
setIsVisible(true);
setHasHydrated(true);
});
return () => {
mounted = false;
};
}, [isMobileWeb]);
const handleDismiss = useCallback(() => {
setIsVisible(false);
AsyncStorage.setItem(STORAGE_KEY, "hidden").catch(() => {});
}, []);
const openLink = useCallback((url) => {
if (typeof url !== "string") return;
const target = url.trim();
if (!target) return;
if (Platform.OS === "web") {
try {
window.open(target, "_blank", "noopener,noreferrer");
return;
} catch (error) {
console.warn("AppDownloadBanner: failed to open link in new tab", error);
}
}
Linking.openURL(target).catch((error) => {
console.warn("AppDownloadBanner: failed to open store link", error);
});
}, []);
const handleOpenStore = useCallback(
(store) => {
if (store === "ios") {
openLink(appleAppStoreUrl);
return;
}
if (store === "android") {
openLink(googlePlayStoreUrl);
}
},
[openLink],
);
if (!isMobileWeb || !isVisible || !hasHydrated) {
return null;
}
return (
<View style={styles.container} pointerEvents="box-none">
<View style={styles.banner}>
<View style={styles.headerRow}>
<View style={styles.titleRow}>
<View style={styles.logoWrapper}>
<Image source={icons.musicLandLogo} style={styles.logo} />
</View>
<View style={styles.titleContent}>
<Text style={styles.title}>Télécharge l'app MusicLand</Text>
<Text style={styles.subtitle}>
Pour une expérience mobile plus fluide, utilise l'application
native et retrouve toutes les fonctionnalités.
</Text>
</View>
</View>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="Fermer l'invitation à télécharger l'application"
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
onPress={handleDismiss}
style={styles.closeBtn}
>
<Text style={styles.closeText}>×</Text>
</TouchableOpacity>
</View>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.cta, styles.appStoreCta]}
onPress={() => handleOpenStore("ios")}
>
<Text style={styles.ctaText}>App Store</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.cta, styles.playStoreCta]}
onPress={() => handleOpenStore("android")}
>
<Text style={styles.ctaText}>Google Play</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: "fixed",
bottom: gutters,
left: gutters,
right: gutters,
alignItems: "center",
zIndex: 80,
},
banner: {
width: "100%",
maxWidth: 520,
borderRadius: 18,
paddingVertical: 14,
paddingHorizontal: 16,
backgroundColor: "rgba(12, 10, 16, 0.95)",
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
shadowColor: Palette.black,
shadowOpacity: 0.3,
shadowRadius: 16,
shadowOffset: { width: 0, height: 10 },
elevation: 10,
},
headerRow: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 12,
},
titleRow: {
flex: 1,
flexDirection: "row",
alignItems: "center",
},
logoWrapper: {
width: 50,
height: 50,
borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.06)",
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
},
logo: {
width: "80%",
height: "80%",
resizeMode: "contain",
},
titleContent: {
flex: 1,
marginLeft: 12,
},
title: {
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
subtitle: {
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 18,
marginTop: 4,
},
closeBtn: {
marginLeft: 10,
},
closeText: {
color: Palette.white,
fontSize: 22,
lineHeight: 22,
fontFamily: FONT_FAMILY.InterSemiBold,
},
actions: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
cta: {
flex: 1,
height: 46,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: Palette.ultraLightWhite,
},
appStoreCta: {
backgroundColor: Palette.transparentWhite,
marginRight: 8,
},
playStoreCta: {
backgroundColor: Palette.primary,
borderColor: Palette.primary,
marginLeft: 8,
},
ctaText: {
fontSize: 15,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
});
export default AppDownloadBanner;
+16 -1
View File
@@ -9,6 +9,8 @@ import { Portal } from "@gorhom/portal";
// - url?: string | number (require), source of the video. Defaults to videos.test
// - visible?: boolean, when false returns null
// - onClose: () => void, called when user skips or when video ends
const CLOSE_THRESHOLD_SECONDS = 0.35;
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
const source = url
? typeof url === "string"
@@ -61,7 +63,19 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
return;
}
const remaining = player.duration - currentTime;
if (Number.isFinite(remaining) && remaining <= 0.1) {
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
handleClose();
}
}
);
const playingChangeSub = player.addListener?.(
"playingChange",
({ isPlaying } = {}) => {
if (isPlaying || !player?.duration || hasClosedRef.current) {
return;
}
const remaining = player.duration - (player.currentTime ?? 0);
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
handleClose();
}
}
@@ -70,6 +84,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
try {
playToEndSub?.remove?.();
timeUpdateSub?.remove?.();
playingChangeSub?.remove?.();
} catch (e) {}
};
}, [handleClose, player, visible]);
+28 -2
View File
@@ -3,6 +3,9 @@ import { Asset } from "expo-asset";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
const CLOSE_THRESHOLD_SECONDS = 0.35;
const CLOSE_POLL_INTERVAL_MS = 500;
const overlayStyle = {
position: "fixed",
top: 0,
@@ -112,8 +115,29 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
return undefined;
}
const handleEnded = handleClose;
const shouldClose = () => {
if (hasClosedRef.current) {
return false;
}
if (video.ended) {
return true;
}
const remaining = video.duration - video.currentTime;
return Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS;
};
const tryHandleClose = () => {
if (shouldClose()) {
handleClose();
}
};
const handleEnded = tryHandleClose;
const handleTimeUpdate = tryHandleClose;
const handlePause = tryHandleClose;
video.addEventListener("ended", handleEnded);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("pause", handlePause);
const pollId = setInterval(tryHandleClose, CLOSE_POLL_INTERVAL_MS);
video.currentTime = 0;
const attemptPlay = () => {
@@ -133,6 +157,9 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
return () => {
video.pause();
video.removeEventListener("ended", handleEnded);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("pause", handlePause);
clearInterval(pollId);
};
}, [handleClose, muted, uri, visible]);
@@ -154,7 +181,6 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
return null;
}
console.log("video uri", uri);
return (
<Portal>
<View pointerEvents="box-none" style={overlayStyle}>
+8 -1
View File
@@ -14,6 +14,9 @@ const MusicLandHeader = ({
style,
}) => {
const isWeb = Platform.OS === "web";
const backHitSlop = isWeb
? undefined
: { top: 16, right: 16, bottom: 16, left: 16 };
const backButtonStyle = isWeb
? {
...Style.containerRow,
@@ -37,7 +40,11 @@ const MusicLandHeader = ({
return (
<View style={{ alignItems: "center", gap: 16, ...style }}>
<View style={{ width: "100%", ...Style.containerRow, gap: 16 }}>
<Pressable style={backButtonStyle} onPress={onPressBack}>
<Pressable
style={backButtonStyle}
onPress={onPressBack}
hitSlop={backHitSlop}
>
<Image
source={backIconSource}
style={backIconStyle}
+4 -1
View File
@@ -28,7 +28,10 @@ export default ({
>
<View style={{ flex: 1 }}>
{!hideBackButton && (
<Pressable onPress={() => onBackPressed?.() || goBack()}>
<Pressable
onPress={() => onBackPressed?.() || goBack()}
hitSlop={{ top: 16, right: 16, bottom: 16, left: 16 }}
>
<Image
source={icons.chevronDown}
style={[