From 24fc790691d722d2761690183c19be19da246445 Mon Sep 17 00:00:00 2001 From: jtrzupek Date: Tue, 2 Jun 2026 12:03:33 +0200 Subject: [PATCH] mobile: skeleton grid while scene lists load (perceived perf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scene-list screens showed a small spinner while waiting on the API, so a slow list read felt like a blank stall. Replace the initial-load spinner on ScenesScreen and TagScenesScreen with a SceneGridSkeleton — a 2-col grid of pulsing placeholder tiles laid out 1:1 with SceneTile (16:9 thumb + title + meta lines). It paints instantly with zero data, so the screen feels responsive even when the query takes a moment, and the skeleton->content swap doesn't reflow. Pairs with the backend list-count fix (most filtered lists are now ~0.1s); the skeleton also masks the residual slow path (enormous tags) so it no longer reads as a freeze. Co-Authored-By: Claude Opus 4.8 (1M context) --- mobile/src/components/SceneGridSkeleton.tsx | 81 +++++++++++++++++++++ mobile/src/screens/ScenesScreen.tsx | 6 +- mobile/src/screens/TagScenesScreen.tsx | 7 +- 3 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 mobile/src/components/SceneGridSkeleton.tsx diff --git a/mobile/src/components/SceneGridSkeleton.tsx b/mobile/src/components/SceneGridSkeleton.tsx new file mode 100644 index 0000000..f2a5079 --- /dev/null +++ b/mobile/src/components/SceneGridSkeleton.tsx @@ -0,0 +1,81 @@ +/** + * SceneGridSkeleton — placeholder grid pokazywany podczas ŁADOWANIA listy scen, + * zamiast spinnera. Rysuje się natychmiast (zero danych), więc ekran "ożywa" w ms + * nawet gdy backend liczy kilka sekund — percepcyjna szybkość jak w dużych apkach + * (YT/Netflix pokazują szkielety/placeholdery zanim dane dojdą). + * + * Layout 1:1 z SceneTile (2-col, thumb 16:9 + linia tytułu + linia meta), żeby + * przejście skeleton→content nie "skakało". Subtelny pulse (Animated opacity loop), + * bez zewnętrznych zależności. + */ +import React from 'react'; +import { Animated, StyleSheet, View } from 'react-native'; + +import { theme } from '../theme'; + +function usepulse() { + const v = React.useRef(new Animated.Value(0.4)).current; + React.useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(v, { toValue: 0.85, duration: 700, useNativeDriver: true }), + Animated.timing(v, { toValue: 0.4, duration: 700, useNativeDriver: true }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [v]); + return v; +} + +function SkeletonTile({ opacity }: { opacity: Animated.Value }) { + return ( + + + + + + ); +} + +/** `count` kafelków (parzysta liczba dla 2-col siatki). */ +export function SceneGridSkeleton({ count = 8 }: { count?: number }) { + const opacity = usepulse(); + const rows = Math.ceil(count / 2); + return ( + + {Array.from({ length: rows }).map((_, r) => ( + + + + + ))} + + ); +} + +const styles = StyleSheet.create({ + grid: { marginTop: 2 }, + row: { flexDirection: 'row', gap: 10, marginBottom: 14 }, + tile: { flex: 1 }, + thumb: { + width: '100%', + aspectRatio: 16 / 9, + borderRadius: 6, + backgroundColor: theme.bgElevated, + }, + lineTitle: { + height: 12, + borderRadius: 4, + backgroundColor: theme.bgElevated, + marginTop: 8, + width: '85%', + }, + lineMeta: { + height: 9, + borderRadius: 4, + backgroundColor: theme.bgElevated, + marginTop: 6, + width: '55%', + }, +}); diff --git a/mobile/src/screens/ScenesScreen.tsx b/mobile/src/screens/ScenesScreen.tsx index 0c332a3..19984d3 100644 --- a/mobile/src/screens/ScenesScreen.tsx +++ b/mobile/src/screens/ScenesScreen.tsx @@ -15,6 +15,7 @@ import { import { Image } from 'expo-image'; import * as Haptics from 'expo-haptics'; import { SceneTile } from '../components/SceneTile'; +import { SceneGridSkeleton } from '../components/SceneGridSkeleton'; import { Thumb } from '../components/Thumb'; import { useClient } from '../ClientContext'; import { theme } from '../theme'; @@ -119,9 +120,11 @@ export function ScenesScreen() { - {isLoading && } {error instanceof Error && {error.message}} + {isLoading ? ( + + ) : ( s.id} @@ -145,6 +148,7 @@ export function ScenesScreen() { ListEmptyComponent={!isLoading ? no scenes : null} contentContainerStyle={{ paddingBottom: 24 }} /> + )} - {isLoading && } {error instanceof Error && {error.message}} + {isLoading ? ( + + ) : ( s.id} @@ -62,6 +64,7 @@ export function TagScenesScreen() { ListEmptyComponent={!isLoading ? no scenes : null} contentContainerStyle={{ paddingBottom: 24 }} /> + )} ); }