From 2215d502acffb1948aa9b0f2cd052823a13a9b21 Mon Sep 17 00:00:00 2001 From: riteshshukla04 Date: Mon, 27 Apr 2026 03:07:35 +0530 Subject: [PATCH] chore:auto --- src/components/OtaUpdates/index.tsx | 7 +- .../Settings/components/preferences-tab.tsx | 16 + src/screens/Onboarding/components.tsx | 580 ++++++++++++++++ src/screens/Onboarding/index.tsx | 218 ++++++ src/screens/Onboarding/steps.tsx | 649 ++++++++++++++++++ src/screens/index.tsx | 16 +- src/screens/types.d.ts | 1 + src/stores/settings/app.ts | 21 + 8 files changed, 1505 insertions(+), 3 deletions(-) create mode 100644 src/screens/Onboarding/components.tsx create mode 100644 src/screens/Onboarding/index.tsx create mode 100644 src/screens/Onboarding/steps.tsx diff --git a/src/components/OtaUpdates/index.tsx b/src/components/OtaUpdates/index.tsx index b9941e3b0..ab8312110 100644 --- a/src/components/OtaUpdates/index.tsx +++ b/src/components/OtaUpdates/index.tsx @@ -13,6 +13,7 @@ import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-na import DeviceInfo from 'react-native-device-info' import { OTA_UPDATE_ENABLED } from '../../configs/config' import { githubOTA, OTAUpdateManager, reloadApp, getStoredOtaVersion } from 'react-native-nitro-ota' +import { useAppSettingsStore } from '../../stores/settings/app' const version = DeviceInfo.getVersion() @@ -77,12 +78,14 @@ const GitUpdateModal = () => { }) } + const userOtaEnabled = useAppSettingsStore((s) => s.enableOtaUpdates) + useEffect(() => { - if (__DEV__ || !OTA_UPDATE_ENABLED || isPRUpdate) { + if (__DEV__ || !OTA_UPDATE_ENABLED || isPRUpdate || !userOtaEnabled) { return } onCheckGitVersion() - }, []) + }, [userOtaEnabled]) return null return ( diff --git a/src/components/Settings/components/preferences-tab.tsx b/src/components/Settings/components/preferences-tab.tsx index 8e0cfc5de..68acabbef 100644 --- a/src/components/Settings/components/preferences-tab.tsx +++ b/src/components/Settings/components/preferences-tab.tsx @@ -5,6 +5,7 @@ import { ColorPreset, ThemeSetting, useColorPresetSetting, + useEnableOtaUpdatesSetting, useHideRunTimesSetting, useReducedHapticsSetting, useSendMetricsSetting, @@ -220,6 +221,7 @@ export default function PreferencesTab(): React.JSX.Element { const [themeSetting, setThemeSetting] = useThemeSetting() const [colorPreset, setColorPreset] = useColorPresetSetting() const [hideRunTimes, setHideRunTimes] = useHideRunTimesSetting() + const [enableOtaUpdates, setEnableOtaUpdates] = useEnableOtaUpdatesSetting() const left = useSwipeSettingsStore((s) => s.left) const right = useSwipeSettingsStore((s) => s.right) @@ -369,6 +371,20 @@ export default function PreferencesTab(): React.JSX.Element { /> ), }, + { + title: 'OTA Updates', + iconName: enableOtaUpdates ? 'cloud-download' : 'cloud-off-outline', + iconColor: enableOtaUpdates ? '$success' : '$borderColor', + subTitle: 'Pull the latest JS bundle on launch', + children: ( + + ), + }, { title: 'Send Analytics', iconName: sendMetrics ? 'bug-check' : 'bug', diff --git a/src/screens/Onboarding/components.tsx b/src/screens/Onboarding/components.tsx new file mode 100644 index 000000000..8d9e0f0dc --- /dev/null +++ b/src/screens/Onboarding/components.tsx @@ -0,0 +1,580 @@ +import React, { useEffect } from 'react' +import { Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native' +import Svg, { Defs, LinearGradient as SvgLinearGradient, Path, Stop } from 'react-native-svg' +import LinearGradient from 'react-native-linear-gradient' +import { Text, XStack, YStack, useTheme } from 'tamagui' +import Animated, { + Easing, + FadeInDown, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated' + +const SPRING = { damping: 14, stiffness: 180, mass: 0.7 } +const EASE = Easing.bezier(0.2, 0.85, 0.25, 1) + +export const JELLY_GRADIENT = ['#00DBB9', '#7317FF'] as const + +const JELLYFISH_PATH = + 'M 132.42608,20.981312 C 118.51246,20.911283 83.233511,23.746074 56.360365,51.654305 45.796,62.625558 29.55089,83.905814 26.58811,113.29033 c -7.40294,73.42144 52.764147,103.89842 72.788054,79.38036 -36.463868,13.92446 -66.671402,-28.89748 -59.6722,-76.99187 3.473754,-23.869592 17.814027,-42.416674 28.164335,-53.036571 6.067777,-6.225821 25.589544,-24.085966 53.223751,-28.03376 40.2294,-5.747136 90.97538,13.064823 77.70773,59.122749 23.93052,-19.1854 -0.16561,-72.416675 -66.3737,-72.749926 z m 33.61844,27.660523 c -0.15637,0.873109 17.57063,4.677552 20.26801,22.43059 2.06149,13.422789 -2.18647,27.390333 -4.63596,33.964525 -4.8986,13.14736 -13.61719,27.52365 -27.22475,41.0988 -13.60757,13.57515 -27.791,22.7016 -42.0741,27.88703 -6.56165,2.38218 -20.400716,6.449 -33.757671,4.28356 -17.395471,-2.78877 -21.23873,-19.91698 -22.558854,-19.68101 -1.335432,0.23968 1.561315,20.72272 21.171639,25.99425 14.891942,4.02256 30.768266,0.18731 38.009456,-2.07186 3.62148,-1.12988 7.23511,-2.50103 10.82155,-4.11196 3.28495,3.59755 13.04938,10.79723 28.88307,3.60756 0.58109,2.54687 1.67998,5.8919 2.57742,9.75615 1.93279,11.93762 2.38159,21.49289 9.97511,31.68005 5.5257,6.03215 9.50278,13.04102 12.97768,20.37001 3.44066,7.25698 4.53197,18.68518 0.0145,28.1854 -2.77917,5.84432 5.98727,10.01289 8.76645,4.16857 5.97041,-12.55594 5.17179,-26.78287 -0.5015,-37.49929 -3.14998,-8.32268 -8.2272,-12.94231 -13.28804,-22.87489 -6.63164,-8.89346 -4.70435,-15.72057 -5.5061,-25.77123 -0.44898,-6.67531 -2.19296,-12.13481 -4.06054,-14.75266 0.19433,-0.14943 0.38902,-0.30124 0.58471,-0.45471 1.94031,2.828 5.48286,6.55225 8.99522,10.35414 7.5628,8.40093 14.55322,17.47623 18.46481,28.18628 1.518,4.63827 2.34922,9.61912 3.86468,14.68334 1.10607,3.69475 3.66449,10.51609 8.48217,15.14497 7.13321,6.86804 17.79854,8.73118 26.96605,4.73364 5.52038,-2.45974 1.90681,-10.74731 -3.65205,-8.37588 -5.2181,2.27539 -12.0543,1.54567 -16.93836,-3.04193 -3.28244,-3.09429 -5.44418,-8.22123 -6.4883,-11.17515 -1.64816,-4.66496 -2.82379,-9.69781 -4.76079,-14.66484 -3.7344,-10.0023 -9.91217,-18.14002 -19.62958,-30.68165 -3.13054,-4.28151 -6.1771,-8.57628 -8.6116,-10.99431 1.35116,-1.29249 2.77537,-2.72541 4.10849,-4.14373 1.91368,1.00791 4.53363,2.08915 7.10652,4.00276 4.82027,3.82167 8.01938,9.20222 11.40914,14.23845 10.08919,15.44711 18.1641,20.56112 23.16435,23.07769 6.45911,3.25828 13.22974,4.50847 18.15975,8.01303 3.4288,2.68495 6.28676,6.8256 7.36535,11.09888 1.39967,6.19967 10.69763,4.11044 9.31069,-2.09209 -1.94969,-6.42602 -5.77756,-12.73634 -11.22309,-16.73845 -6.30294,-4.42247 -13.52343,-5.69332 -19.43373,-8.62604 -10.31999,-5.13635 -16.42496,-15.39067 -18.9484,-19.85606 -3.34284,-6.50737 -6.64541,-13.11375 -12.00827,-18.21925 -1.78269,-1.66064 -3.39523,-2.77163 -4.84457,-3.47873 3.88041,0.45662 9.55226,0.99509 15.85042,2.96162 7.55765,3.29282 10.5626,3.71024 27.95346,22.32139 2.96137,3.83614 8.24352,8.31628 12.57159,12.51787 6.5578,6.54831 14.58539,11.59384 23.63927,13.70922 7.01883,1.65857 9.51992,-8.86135 2.50578,-10.5396 -3.75877,-0.89381 -13.19573,-3.56655 -20.73312,-9.29971 -4.14644,-3.27043 -7.80617,-7.35297 -11.01163,-11.12083 -3.20553,-3.76786 -6.69155,-8.66951 -11.25314,-13.31172 -6.05619,-5.99911 -13.20575,-10.43792 -20.5985,-13.19385 -7.71091,-2.87457 -13.18338,-3.36271 -16.65987,-2.88594 2.80508,-6.13646 6.82252,-19.22387 -3.87276,-28.95935 1.60309,-3.63734 2.92801,-7.23174 3.99583,-10.75048 2.20765,-7.27471 5.84933,-23.021753 1.71828,-37.800883 -5.09913,-18.326582 -23.61986,-22.082312 -25.4062,-21.301692 z m -42.4311,5.940061 C 110.65172,59.753012 97.989474,68.444707 86.893063,79.556601 75.796648,90.668498 66.949595,103.19833 62.007446,116.18441 53.923889,137.42491 54.944325,192.4134 113.91851,166.16575 75.517716,170.15678 68.460798,144.04326 77.97804,117.18106 81.454571,107.36863 88.179713,97.792328 96.699944,89.272088 105.2202,80.751833 114.89444,74.29118 124.60891,70.549601 159.9886,57.537934 176.68266,66.263104 173.59418,106.49009 202.03524,38.745967 138.47627,48.6523 123.61342,54.581896 Z m 17.95926,44.352112 c -8.23301,-0.24878 -18.1321,6.306662 -25.70189,14.852052 -10.09307,11.39384 -16.044796,26.32531 -8.38453,33.38155 7.66026,7.05625 22.51622,-0.78205 33.23133,-12.15734 10.71512,-11.37531 17.28889,-26.28767 8.38453,-33.38098 -2.22609,-1.773332 -4.7851,-2.612352 -7.52944,-2.695282 z' + +/** Animated jellyfish logo with gentle floating motion. */ +export function JellyfishMark({ + size = 92, + gradient = JELLY_GRADIENT, + floating = true, +}: { + size?: number + gradient?: readonly [string, string] + floating?: boolean +}) { + const float = useSharedValue(0) + useEffect(() => { + if (!floating) return + float.value = withRepeat( + withSequence( + withTiming(1, { duration: 2000, easing: EASE }), + withTiming(0, { duration: 2000, easing: EASE }), + ), + -1, + false, + ) + return () => cancelAnimation(float) + }, [floating]) + + const style = useAnimatedStyle(() => ({ + transform: [{ translateY: -8 * float.value }, { rotate: `${-1 + 2.5 * float.value}deg` }], + })) + + return ( + + + + + + + + + + + + ) +} + +/** Looping pulse rings emanating from a center. */ +export function PulseRings({ color, size = 120 }: { color: string; size?: number }) { + return ( + <> + {[0, 1, 2].map((i) => ( + + ))} + + ) +} + +function PulseRing({ color, size, delay }: { color: string; size: number; delay: number }) { + const t = useSharedValue(0) + useEffect(() => { + t.value = withRepeat( + withSequence( + withTiming(0, { duration: delay }), + withTiming(1, { duration: 2800, easing: EASE }), + ), + -1, + false, + ) + return () => cancelAnimation(t) + }, [delay]) + + const style = useAnimatedStyle(() => ({ + opacity: 0.7 * (1 - t.value), + transform: [{ scale: 0.6 + t.value * 1.0 }], + })) + + return ( + + ) +} + +/** Slow drifting gradient blobs in the background, themed by primary/secondary. */ +export function AmbientBlobs() { + const theme = useTheme() + const a = useSharedValue(0) + const b = useSharedValue(0) + useEffect(() => { + a.value = withRepeat( + withSequence( + withTiming(1, { duration: 7000, easing: EASE }), + withTiming(0, { duration: 7000, easing: EASE }), + ), + -1, + false, + ) + b.value = withRepeat( + withSequence( + withTiming(1, { duration: 9000, easing: EASE }), + withTiming(0, { duration: 9000, easing: EASE }), + ), + -1, + false, + ) + return () => { + cancelAnimation(a) + cancelAnimation(b) + } + }, []) + + const styleA = useAnimatedStyle(() => ({ + transform: [ + { translateX: -30 * a.value }, + { translateY: 20 * a.value }, + { scale: 1 + 0.12 * a.value }, + ], + })) + const styleB = useAnimatedStyle(() => ({ + transform: [ + { translateX: 40 * b.value }, + { translateY: -30 * b.value }, + { scale: 1 + 0.08 * b.value }, + ], + })) + + const primary = (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + const secondary = (theme.secondary as unknown as { val: string })?.val ?? '#4FC3F7' + + return ( + + + + + ) +} + +/** Spring-pressable button — scales down on press. */ +export function PressableScale({ + onPress, + children, + style, + hitSlop, + disabled, +}: { + onPress?: () => void + children: React.ReactNode + style?: StyleProp + hitSlop?: number + disabled?: boolean +}) { + const scale = useSharedValue(1) + const animStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }] })) + return ( + (scale.value = withSpring(0.96, SPRING))} + onPressOut={() => (scale.value = withSpring(1, SPRING))} + onPress={onPress} + hitSlop={hitSlop} + > + {children} + + ) +} + +/** Selectable card row — used for theme/preset/quality lists. */ +export function OptionCard({ + icon, + title, + subtitle, + right, + selected, + onPress, + accent, + index = 0, +}: { + icon?: React.ReactNode + title: string + subtitle?: string + right?: React.ReactNode + selected: boolean + onPress: () => void + accent?: string + index?: number +}) { + const theme = useTheme() + const primary = accent ?? (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + const surface = (theme.background75 as unknown as { val: string })?.val ?? '#26242C' + const border = (theme.borderColor as unknown as { val: string })?.val ?? '#3A3744' + const text = (theme.color as unknown as { val: string })?.val ?? '#fff' + const sub = (theme.neutral as unknown as { val: string })?.val ?? '#A6A2BD' + + return ( + + + + {icon !== undefined && ( + + {typeof icon === 'string' ? ( + + {icon} + + ) : ( + icon + )} + + )} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {right} + + {selected && ( + + + + )} + + + + + ) +} + +/** Section eyebrow + heading + sub. */ +export function StepHeader({ + eyebrow, + title, + sub, +}: { + eyebrow?: string + title: string + sub?: string +}) { + return ( + + {eyebrow ? ( + + {eyebrow.toUpperCase()} + + ) : null} + + {title} + + {sub ? ( + + {sub} + + ) : null} + + ) +} + +/** Header progress bar — segmented bars fill as user advances. */ +export function ProgressBar({ idx, total }: { idx: number; total: number }) { + const theme = useTheme() + const primary = (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + const border = (theme.borderColor as unknown as { val: string })?.val ?? '#3A3744' + return ( + + {Array.from({ length: total }).map((_, i) => { + const filled = i < idx + return + })} + + ) +} + +function Segment({ + filled, + primary, + border, +}: { + filled: boolean + primary: string + border: string +}) { + const w = useSharedValue(filled ? 1 : 0) + useEffect(() => { + w.value = withTiming(filled ? 1 : 0, { duration: 520, easing: EASE }) + }, [filled]) + const style = useAnimatedStyle(() => ({ transform: [{ scaleX: w.value }] })) + return ( + + + + ) +} + +/** Animated Switch styled like the Jellify pill. */ +export function AnimatedSwitch({ + value, + onChange, +}: { + value: boolean + onChange: (v: boolean) => void +}) { + const theme = useTheme() + const primary = (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + const border = (theme.borderColor as unknown as { val: string })?.val ?? '#3A3744' + const x = useSharedValue(value ? 22 : 0) + useEffect(() => { + x.value = withSpring(value ? 22 : 0, SPRING) + }, [value]) + const knob = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] })) + return ( + onChange(!value)}> + + + + + ) +} + +/** Gradient CTA — animated background shift; used for the final "Drop the beat" button. */ +export function GradientCTA({ + label, + onPress, + colors, +}: { + label: string + onPress: () => void + colors: string[] +}) { + return ( + + + + {label} + + + + ) +} + +/** Twinkling sparkle dot — used on the Ready screen. */ +export function Sparkle({ + dx, + dy, + size, + delay, + color, +}: { + dx: number + dy: number + size: number + delay: number + color: string +}) { + const t = useSharedValue(0) + useEffect(() => { + t.value = withRepeat( + withSequence( + withTiming(0, { duration: delay }), + withTiming(1, { duration: 1200, easing: EASE }), + withTiming(0, { duration: 1200, easing: EASE }), + ), + -1, + false, + ) + return () => cancelAnimation(t) + }, [delay]) + const style = useAnimatedStyle(() => ({ + opacity: t.value, + transform: [{ scale: 0.5 + t.value * 0.5 }], + })) + return ( + + ) +} + +/** Breathing wrapper — gentle scale loop. */ +export function Breathing({ children }: { children: React.ReactNode }) { + const t = useSharedValue(0) + useEffect(() => { + t.value = withRepeat( + withSequence( + withTiming(1, { duration: 1700, easing: EASE }), + withTiming(0, { duration: 1700, easing: EASE }), + ), + -1, + false, + ) + return () => cancelAnimation(t) + }, []) + const style = useAnimatedStyle(() => ({ transform: [{ scale: 1 + 0.06 * t.value }] })) + return {children} +} + +const styles = StyleSheet.create({ + ring: { + position: 'absolute', + borderWidth: 1.5, + }, +}) diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx new file mode 100644 index 000000000..b28c316e0 --- /dev/null +++ b/src/screens/Onboarding/index.tsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react' +import { Pressable } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated' +import { Text, XStack, YStack, useTheme } from 'tamagui' +import { useNavigation } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' +import Icon from '../../components/Global/components/icon' +import { useApi, useJellifyLibrary } from '../../stores' +import type { RootStackParamList } from '../types' +import { + useColorPresetSetting, + useEnableOtaUpdatesSetting, + useOnboardingCompleted, + useThemeSetting, +} from '../../stores/settings/app' +import { useStreamingQuality } from '../../stores/settings/player' +import { useDownloadQuality } from '../../stores/settings/usage' +import { AmbientBlobs, PressableScale, ProgressBar } from './components' +import { StepDownload, StepOTA, StepReady, StepStreaming, StepTheme, StepWelcome } from './steps' + +const QUESTION_STEPS = 4 // theme, streaming, ota, download + +/** + * Jellify Onboarding — 6 screens (welcome, theme, streaming, OTA, download, ready) + * with Reanimated page transitions and ambient gradient backdrop. + * + * Persists selections directly to existing settings stores. Marks + * `onboardingCompleted` true when finished, so the gate in `screens/index.tsx` + * stops mounting it on subsequent launches. + */ +export default function Onboarding(): React.JSX.Element { + const [step, setStep] = useState(0) + const [direction, setDirection] = useState<'forward' | 'back'>('forward') + + const [theme, setTheme] = useThemeSetting() + const [preset, setPreset] = useColorPresetSetting() + const [streaming, setStreaming] = useStreamingQuality() + const [download, setDownload] = useDownloadQuality() + const [ota, setOta] = useEnableOtaUpdatesSetting() + const [, setOnboardingCompleted] = useOnboardingCompleted() + const api = useApi() + const [library] = useJellifyLibrary() + const navigation = useNavigation>() + + const tamagui = useTheme() + const primary = (tamagui.primary as unknown as { val: string })?.val ?? '#887BFF' + + const next = () => { + setDirection('forward') + setStep((s) => Math.min(s + 1, 5)) + } + const back = () => { + setDirection('back') + setStep((s) => Math.max(s - 1, 0)) + } + const skipToReady = () => { + setDirection('forward') + setStep(5) + } + const finish = () => { + setOnboardingCompleted(true) + navigation.reset({ + index: 0, + routes: [{ name: api && library ? 'Tabs' : 'Login' }], + }) + } + + const showHeader = step > 0 + const showFooter = step > 0 && step < 5 + + const renderContent = () => { + switch (step) { + case 0: + return + case 1: + return ( + + ) + case 2: + return + case 3: + return + case 4: + return ( + + ) + case 5: + return ( + + ) + default: + return null + } + } + + return ( + + + + + {showHeader && ( + + + + + + + {step < 5 ? ( + + ) : ( + + Review settings + + )} + {step < 5 && ( + + + Skip + + + )} + + )} + + + + {renderContent()} + + + + {showFooter && ( + + + + + {step === 4 ? 'Almost done →' : 'Continue →'} + + + + + )} + + + ) +} diff --git a/src/screens/Onboarding/steps.tsx b/src/screens/Onboarding/steps.tsx new file mode 100644 index 000000000..05f41f729 --- /dev/null +++ b/src/screens/Onboarding/steps.tsx @@ -0,0 +1,649 @@ +import React from 'react' +import { ScrollView } from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated' +import { Text, XStack, YStack, useTheme } from 'tamagui' +import StreamingQuality from '../../enums/audio-quality' +import { + AnimatedSwitch, + Breathing, + GradientCTA, + JELLY_GRADIENT, + JellyfishMark, + OptionCard, + PressableScale, + PulseRings, + Sparkle, + StepHeader, +} from './components' +import type { ColorPreset, ThemeSetting } from '../../stores/settings/app' + +const themeIcons: Record = { + system: '◐', + light: '☀', + dark: '☾', + oled: '●', +} + +const THEME_META: { id: ThemeSetting; name: string; desc: string }[] = [ + { id: 'system', name: 'Match Device', desc: 'Follows your system' }, + { id: 'light', name: 'Light', desc: 'Clean & bright' }, + { id: 'dark', name: 'Dark', desc: 'Easy on the eyes' }, + { id: 'oled', name: 'OLED Black', desc: 'Deepest black, longest battery' }, +] + +export const PRESET_META: { + id: ColorPreset + name: string + vibe: string + swatch: [string, string] +}[] = [ + { id: 'purple', name: 'Purple', vibe: 'You crazy diamond', swatch: ['#887BFF', '#4b0fd6'] }, + { id: 'ocean', name: 'Ocean', vibe: 'Deep & breezy', swatch: ['#4FC3F7', '#0288D1'] }, + { id: 'forest', name: 'Forest', vibe: 'Acoustic & earthy', swatch: ['#9CCC65', '#0E8F15'] }, + { id: 'sunset', name: 'Sunset', vibe: 'Warm vinyl evenings', swatch: ['#FFAB91', '#FF5722'] }, + { id: 'peanut', name: 'Peanut', vibe: 'Roasted, mellow tones', swatch: ['#A1887F', '#aa5125'] }, +] + +const QUALITY_META: { + id: StreamingQuality + label: string + kbps: string + desc: string + icon: string +}[] = [ + { + id: StreamingQuality.Low, + label: 'Low', + kbps: '128 kbps', + desc: 'Save data — perfect for cellular', + icon: '📶', + }, + { + id: StreamingQuality.Medium, + label: 'Medium', + kbps: '256 kbps', + desc: 'Balanced quality and bandwidth', + icon: '📡', + }, + { + id: StreamingQuality.High, + label: 'High', + kbps: '320 kbps', + desc: 'Crisp playback on any speakers', + icon: '🎧', + }, + { + id: StreamingQuality.Original, + label: 'Original', + kbps: 'Lossless', + desc: 'Untouched — bit-for-bit from source', + icon: '💎', + }, +] + +export { THEME_META, QUALITY_META } + +// ─── Step 0: Welcome ──────────────────────────────────── +export function StepWelcome({ onNext }: { onNext: () => void }) { + const theme = useTheme() + const primary = (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + return ( + + + + + + + + Welcome to{' '} + + Jellify + + + + + + The Jellyfin music client that actually feels like a music app. Let's get + you tuned up. + + + + + + + Let's jellify ✨ + + + + + + ) +} + +// ─── Step 1: Theme & preset ───────────────────────────── +export function StepTheme({ + mode, + preset, + onChangeMode, + onChangePreset, +}: { + mode: ThemeSetting + preset: ColorPreset + onChangeMode: (m: ThemeSetting) => void + onChangePreset: (p: ColorPreset) => void +}) { + return ( + + + + THEME + + + {THEME_META.map((t, i) => ( + onChangeMode(t.id)} + index={i} + /> + ))} + + + + COLOR PRESET + + + {PRESET_META.map((p, i) => ( + + } + title={p.name} + subtitle={p.vibe} + selected={preset === p.id} + onPress={() => onChangePreset(p.id)} + accent={p.swatch[1]} + index={i + 4} + /> + ))} + + + ) +} + +// ─── Step 2: Streaming Quality ────────────────────────── +export function StepStreaming({ + value, + onChange, +}: { + value: StreamingQuality + onChange: (q: StreamingQuality) => void +}) { + const theme = useTheme() + const primary = (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + const border = (theme.borderColor as unknown as { val: string })?.val ?? '#3A3744' + const idx = QUALITY_META.findIndex((q) => q.id === value) + const current = QUALITY_META[idx] + + return ( + + + + + + {QUALITY_META.map((q, i) => { + const active = i === idx + const isUnder = i <= idx + return ( + + ) + })} + + + Currently:{' '} + + {current?.label} · {current?.kbps} + + + + + + {QUALITY_META.map((q, i) => ( + onChange(q.id)} + index={i} + /> + ))} + + + ) +} + +// ─── Step 3: OTA Updates ──────────────────────────────── +export function StepOTA({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) { + const theme = useTheme() + const primary = (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + const success = (theme.success as unknown as { val: string })?.val ?? '#57E9C9' + return ( + + + + onChange(!value)} style={{ width: '100%' }}> + + + + ☁︎ + + + + OTA updates + + + {value + ? 'Enabled — checking on launch' + : 'Off — only manual updates'} + + + + + + {value ? ( + + + + Pulls signed JS bundles from Jellify's repo + + + Restart prompt — never auto-applies mid-session + + + Native code still updates via the store + + + + ) : null} + + + + + + + Tip:{' '} + + You can opt into PR previews later from Settings → About to test unreleased + features. + + + + ) +} + +function Bullet({ children, color }: { children: React.ReactNode; color: string }) { + return ( + + + + ✓ + + + + {children} + + + ) +} + +// ─── Step 4: Download Quality ─────────────────────────── +const STORAGE_GB: Record = { + [StreamingQuality.Low]: '1.0 GB', + [StreamingQuality.Medium]: '1.8 GB', + [StreamingQuality.High]: '2.4 GB', + [StreamingQuality.Original]: '8.0 GB', +} + +const STORAGE_PCT: Record = { + [StreamingQuality.Low]: 12, + [StreamingQuality.Medium]: 22, + [StreamingQuality.High]: 30, + [StreamingQuality.Original]: 100, +} + +export function StepDownload({ + value, + streamingValue, + onChange, +}: { + value: StreamingQuality + streamingValue: StreamingQuality + onChange: (q: StreamingQuality) => void +}) { + const theme = useTheme() + const primary = (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + const secondary = (theme.secondary as unknown as { val: string })?.val ?? '#4FC3F7' + return ( + + + + + + + ~1000 tracks ≈ + + + {STORAGE_GB[value]} + + + + + + + + + + + {QUALITY_META.map((q, i) => ( + onChange(q.id)} + index={i} + right={ + q.id === streamingValue ? ( + + + SAME AS STREAM + + + ) : undefined + } + /> + ))} + + + ) +} + +// ─── Step 5: Ready ────────────────────────────────────── +export function StepReady({ + settings, + onFinish, +}: { + settings: { + theme: ThemeSetting + preset: ColorPreset + streaming: StreamingQuality + download: StreamingQuality + ota: boolean + } + onFinish: () => void +}) { + const theme = useTheme() + const primary = (theme.primary as unknown as { val: string })?.val ?? '#887BFF' + const presetMeta = PRESET_META.find((p) => p.id === settings.preset) + const tmode = THEME_META.find((t) => t.id === settings.theme) + const sq = QUALITY_META.find((q) => q.id === settings.streaming) + const dq = QUALITY_META.find((q) => q.id === settings.download) + + return ( + + + + + + + + + + + + + + + Ready to{' '} + + jellify + + . + + + + + Your sound, dialed in. + + + + + + + + + + + + + + + + + + ) +} + +function Chip({ label, dotColor }: { label: string; dotColor?: string }) { + return ( + + {dotColor ? ( + + ) : null} + + {label} + + + ) +} diff --git a/src/screens/index.tsx b/src/screens/index.tsx index 360876fb6..81d4efc74 100644 --- a/src/screens/index.tsx +++ b/src/screens/index.tsx @@ -20,6 +20,8 @@ import GenreSelectionScreen from './GenreSelection' import YearSelectionScreen from './YearSelection' import MigrateDownloadsScreen from './MigrateDownloads' import { bottomSheetPresentation, playerSheetPresentation } from '../utils/navigating/form-sheet' +import Onboarding from './Onboarding' +import { useOnboardingCompleted } from '../stores/settings/app' const RootStack = createNativeStackNavigator() @@ -28,9 +30,21 @@ export default function Root(): React.JSX.Element { const api = useApi() const [library] = useJellifyLibrary() + const [onboardingCompleted] = useOnboardingCompleted() + + const initialRouteName: keyof RootStackParamList = !onboardingCompleted + ? 'Onboarding' + : api && library + ? 'Tabs' + : 'Login' return ( - + + export type InstantMixProps = NativeStackScreenProps export type RootStackParamList = { + Onboarding: undefined Login: NavigatorScreenParams Tabs: NavigatorScreenParams | undefined diff --git a/src/stores/settings/app.ts b/src/stores/settings/app.ts index 908b3086b..5ab424392 100644 --- a/src/stores/settings/app.ts +++ b/src/stores/settings/app.ts @@ -31,6 +31,12 @@ type AppSettingsStore = { colorPreset: ColorPreset setColorPreset: (colorPreset: ColorPreset) => void + + enableOtaUpdates: boolean + setEnableOtaUpdates: (enableOtaUpdates: boolean) => void + + onboardingCompleted: boolean + setOnboardingCompleted: (onboardingCompleted: boolean) => void } export const useAppSettingsStore = create()( @@ -55,6 +61,13 @@ export const useAppSettingsStore = create()( colorPreset: 'purple', setColorPreset: (colorPreset: ColorPreset) => set({ colorPreset }), + + enableOtaUpdates: true, + setEnableOtaUpdates: (enableOtaUpdates: boolean) => set({ enableOtaUpdates }), + + onboardingCompleted: false, + setOnboardingCompleted: (onboardingCompleted: boolean) => + set({ onboardingCompleted }), }), { name: 'app-settings-storage', @@ -106,3 +119,11 @@ export const useSendMetricsSetting: () => [boolean, (sendMetrics: boolean) => vo export const useHideRunTimesSetting: () => [boolean, (hideRunTimes: boolean) => void] = () => useAppSettingsStore(useShallow((state) => [state.hideRunTimes, state.setHideRunTimes])) + +export const useEnableOtaUpdatesSetting: () => [boolean, (enabled: boolean) => void] = () => + useAppSettingsStore(useShallow((state) => [state.enableOtaUpdates, state.setEnableOtaUpdates])) + +export const useOnboardingCompleted: () => [boolean, (completed: boolean) => void] = () => + useAppSettingsStore( + useShallow((state) => [state.onboardingCompleted, state.setOnboardingCompleted]), + )