more fixes web + mobile
This commit is contained in:
@@ -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;
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={[
|
||||
|
||||
Reference in New Issue
Block a user