File size: 7,156 Bytes
5c85958
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import React, { useState, useEffect, useMemo } from 'react';
import { Box } from '@mui/material';

// Load all images from reachies/small-top-sided folder dynamically with Vite
const imageModules = import.meta.glob('../assets/reachies/small-top-sided/*.png', { eager: true });

/**
 * Component that loads all PNG images from reachies/small-top-sided folder,
 * stores them in memory and displays them in sequence with overlapping fade transition.
 * 
 * Images are loaded dynamically and displayed one after another
 * in a fixed frame, with a fade in/out transition between each image.
 */
export default function ReachiesCarousel({ 
  width = 100, 
  height = 100, 
  interval = 1000, // Display duration of each image in ms (faster)
  transitionDuration = 150, // Fade transition duration in ms (very sharp) - DEPRECATED, use fadeInDuration and fadeOutDuration
  fadeInDuration = 350, // Fade-in duration for incoming image (slower, Apple/Google style)
  fadeOutDuration = 120, // Fade-out duration for outgoing image (faster, Apple/Google style)
  zoom = 1.8, // Zoom factor to enlarge the sticker
  verticalAlign = 'center', // Vertical alignment: 'top', 'center', 'bottom', or percentage (e.g.: '60%')
  darkMode = false,
  sx = {} 
}) {
  // Extract URLs of loaded images and sort them for consistent order
  const imagePaths = useMemo(() => {
    const paths = Object.values(imageModules)
      .map(module => {
        // With eager: true, module is already loaded, access .default
        return typeof module === 'object' && module !== null && 'default' in module 
          ? module.default 
          : module;
      })
      .filter(Boolean) // Filter null/undefined values
      .sort(); // Sort for consistent order
    
    return paths;
  }, []);

  const [currentIndex, setCurrentIndex] = useState(0);
  const [previousIndex, setPreviousIndex] = useState(null);
  const [isTransitioning, setIsTransitioning] = useState(false);
  const [fadeOutComplete, setFadeOutComplete] = useState(false);

  // Preload all images in memory for smooth transitions
  useEffect(() => {
    imagePaths.forEach(imagePath => {
      const img = new Image();
      img.src = imagePath;
    });
  }, [imagePaths]);

  // Function to get a random index different from current
  const getRandomIndex = (currentIdx, total) => {
    if (total <= 1) return 0;
    let newIndex;
    do {
      newIndex = Math.floor(Math.random() * total);
    } while (newIndex === currentIdx && total > 1);
    return newIndex;
  };

  // Automatically change image with overlap and random selection
  useEffect(() => {
    if (imagePaths.length > 0) {
      const timer = setInterval(() => {
        // Save previous index BEFORE changing to guarantee crossfade
        const prevIdx = currentIndex;
        setPreviousIndex(prevIdx);
        setIsTransitioning(true);
        setFadeOutComplete(false); // Reset at start of transition
        
        // Select a random image different from current
        const newIndex = getRandomIndex(currentIndex, imagePaths.length);
        setCurrentIndex(newIndex);
        
        // Outgoing image starts disappearing after a delay to create more overlap
        // Both images remain visible together longer
        const overlapDelay = Math.min(fadeInDuration * 0.4, fadeOutDuration * 2); // 40% of fade-in or 2x fade-out
        setTimeout(() => {
          setFadeOutComplete(true);
        }, overlapDelay);
        
        // Reset transition state after longest duration (fade-in)
        setTimeout(() => {
          setIsTransitioning(false);
          setPreviousIndex(null);
          setFadeOutComplete(false);
        }, Math.max(fadeInDuration, fadeOutDuration));
      }, interval);

      return () => clearInterval(timer);
    }
  }, [imagePaths.length, interval, currentIndex, fadeInDuration, fadeOutDuration]);

  if (imagePaths.length === 0) {
    return (
      <Box
        sx={{
          width,
          height,
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          ...sx,
        }}
      />
    );
  }

  return (
    <Box
      sx={{
        position: 'relative',
        width,
        height,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        overflow: 'hidden', // Prevent zoom overflow
        ...sx,
      }}
    >
      {imagePaths.map((imageSrc, index) => {
        const isActive = index === currentIndex;
        const isPrevious = index === previousIndex && isTransitioning;
        
        // Calculate vertical position according to alignment
        let topValue, transformY;
        if (verticalAlign === 'top') {
          topValue = 0;
          transformY = '0';
        } else if (verticalAlign === 'bottom') {
          topValue = '100%';
          transformY = '-100%';
        } else if (typeof verticalAlign === 'string' && verticalAlign.includes('%')) {
          // Custom percentage
          topValue = verticalAlign;
          transformY = '-50%';
        } else {
          // Default: center
          topValue = '50%';
          transformY = '-50%';
        }
        
        // Crossfade style Apple/Google: outgoing disappears faster than incoming appears
        const baseOpacity = darkMode ? 0.8 : 0.9;
        let opacity = 0;
        let transitionStyle = 'none';
        
        // Crossfade logic: both images must be visible simultaneously
        if (isActive) {
          // Incoming image: slow and progressive fade-in (premium style)
          opacity = baseOpacity;
          transitionStyle = `opacity ${fadeInDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`; // Smooth ease-out
        } else if (isPrevious) {
          // Outgoing image: fast fade-out (disappears quickly to make room)
          // Starts visible, then disappears after fadeOutDuration
          opacity = fadeOutComplete ? 0 : baseOpacity;
          transitionStyle = `opacity ${fadeOutDuration}ms cubic-bezier(0.4, 0, 1, 1)`; // More aggressive ease-out
        }
        // Otherwise opacity stays at 0 (invisible)
        
        return (
          <Box
            key={`${imageSrc}-${index}`} // Unique key to force re-render
            component="img"
            src={imageSrc}
            alt={`Reachy ${index + 1}`}
            sx={{
              position: 'absolute',
              width: width * zoom,
              height: height * zoom,
              objectFit: 'cover',
              objectPosition: 'center top', // Align top of image to top
              opacity,
              transform: `translate(-50%, ${transformY})`, // No scale
              transition: transitionStyle,
              pointerEvents: 'none',
              // Position zoomed image with custom vertical alignment
              left: '50%',
              top: topValue,
              zIndex: isActive ? 2 : (isPrevious ? 1 : 0), // Active image on top
              willChange: 'opacity', // GPU optimization
              backfaceVisibility: 'hidden', // Avoid rendering artifacts
              WebkitBackfaceVisibility: 'hidden',
            }}
          />
        );
      })}
    </Box>
  );
}