diff --git a/apps/common-app/src/demos/Crossfade/Crossfade.tsx b/apps/common-app/src/demos/Crossfade/Crossfade.tsx new file mode 100644 index 000000000..70ffb63b2 --- /dev/null +++ b/apps/common-app/src/demos/Crossfade/Crossfade.tsx @@ -0,0 +1,497 @@ +import React, { FC, useCallback, useEffect, useState, useRef } from 'react'; +import { + AudioBuffer, + AudioManager, + AudioBufferSourceNode, + GainNode, + AudioContext, +} from 'react-native-audio-api'; +import { + StyleSheet, + Text, + View, + Image, + ActivityIndicator, + Dimensions, + Pressable, +} from 'react-native'; +import { GestureDetector, Gesture } from 'react-native-gesture-handler'; +import Animated, { + useSharedValue, + useAnimatedStyle, + useAnimatedReaction, + withSpring, +} from 'react-native-reanimated'; +import { Heart, SkipBack, SkipForward } from 'lucide-react-native'; +import { scheduleOnRN } from 'react-native-worklets'; + +import { Container, Spacer } from '../../components'; +import PlayPauseIcon from '../../components/icons/PlayPauseIcon'; +import { colors } from '../../styles'; + +const ARTWORK_SIZE = Dimensions.get('window').width * 0.7; +const TILE_OFFSET = 60; +const TILE_DISTANCE = ARTWORK_SIZE + TILE_OFFSET; +const MAX_GAIN = 0.5; + +const TRACKS = [ + { title: 'Up-Beat', cover: require('./images/image_1.jpeg'), uri: require('./tracks/track1.mp3') }, + { title: 'Chill', cover: require('./images/image_2.jpg'), uri: require('./tracks/track2.mp3') }, +] as const; + +function equalPowerGain1(progress: number): number { + return Math.cos(progress * 0.5 * Math.PI) * MAX_GAIN; +} + +function equalPowerGain2(progress: number): number { + return Math.cos((1 - progress) * 0.5 * Math.PI) * MAX_GAIN; +} + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +const Crossfade: FC = () => { + const [isPlaying, setIsPlaying] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [trackDuration, setTrackDuration] = useState(0); + const [visibleTrack, setVisibleTrack] = useState<1 | 2>(1); + const [playbackPosition, setPlaybackPosition] = useState(0); + + const audioContext = useRef(null); + const sourceNode1 = useRef(null); + const sourceNode2 = useRef(null); + const gainNode1 = useRef(null); + const gainNode2 = useRef(null); + const buffer1 = useRef(null); + const buffer2 = useRef(null); + const visibleTrackRef = useRef<1 | 2>(1); + + const progress = useSharedValue(0); + const swipeStartProgress = useSharedValue(0); + const isPlayingShared = useSharedValue(0); + const swipeEndSnapTo = useSharedValue(-1); + + useEffect(() => { + isPlayingShared.value = isPlaying ? 1 : 0; + visibleTrackRef.current = visibleTrack; + }, [isPlaying, visibleTrack, isPlayingShared]); + + const applyGainFromProgress = useCallback((p: number) => { + const ctx = audioContext.current; + const g1 = gainNode1.current; + const g2 = gainNode2.current; + if (!ctx || !g1 || !g2) { + return; + } + const now = ctx.currentTime; + g1.gain.setValueAtTime(equalPowerGain1(p), now); + g2.gain.setValueAtTime(equalPowerGain2(p), now); + }, []); + + const commitSwipeEnd = useCallback((snapTo: number) => { + const track: 1 | 2 = snapTo < 0.5 ? 1 : 2; + setVisibleTrack(track); + visibleTrackRef.current = track; + + const buf = track === 1 ? buffer1.current : buffer2.current; + if (buf) { + setTrackDuration(buf.duration); + } + + const ctx = audioContext.current; + const g1 = gainNode1.current; + const g2 = gainNode2.current; + if (!ctx || !g1 || !g2) { + return; + } + const now = ctx.currentTime; + if (track === 1) { + g1.gain.setValueAtTime(MAX_GAIN, now); + g2.gain.setValueAtTime(0, now); + } else { + g1.gain.setValueAtTime(0, now); + g2.gain.setValueAtTime(MAX_GAIN, now); + } + }, []); + + useAnimatedReaction( + () => progress.value, + (p) => { + if (isPlayingShared.value === 1) { + scheduleOnRN(applyGainFromProgress, p); + } + }, + ); + + useAnimatedReaction( + () => swipeEndSnapTo.value, + (snapTo) => { + if (snapTo >= 0) { + scheduleOnRN(commitSwipeEnd, snapTo); + swipeEndSnapTo.value = -1; + } + }, + ); + + useEffect(() => { + const init = async () => { + audioContext.current = new AudioContext(); + + if (buffer1.current && buffer2.current) { + setIsLoading(false); + return; + } + + buffer1.current = await audioContext.current.decodeAudioData(TRACKS[0].uri); + buffer2.current = await audioContext.current.decodeAudioData(TRACKS[1].uri); + setTrackDuration(buffer1.current.duration); + setPlaybackPosition(0); + setIsLoading(false); + }; + + init(); + + return () => { + stopAudio(); + audioContext.current?.suspend(); + }; + }, []); + + const playAudio = useCallback(async () => { + if (!audioContext.current || !buffer1.current || !buffer2.current || isPlaying) { + return; + } + + AudioManager.setAudioSessionOptions({ + iosCategory: 'playback', + iosMode: 'default', + iosOptions: [], + }); + await AudioManager.setAudioSessionActivity(true); + + if (audioContext.current.state === 'suspended') { + await audioContext.current.resume(); + } + + sourceNode1.current = audioContext.current.createBufferSource(); + sourceNode1.current.buffer = buffer1.current; + sourceNode2.current = audioContext.current.createBufferSource(); + sourceNode2.current.buffer = buffer2.current; + gainNode1.current = audioContext.current.createGain(); + gainNode2.current = audioContext.current.createGain(); + + sourceNode1.current + .connect(gainNode1.current) + .connect(audioContext.current.destination); + sourceNode2.current + .connect(gainNode2.current) + .connect(audioContext.current.destination); + + const now = audioContext.current.currentTime; + const maxOffset = Math.max( + 0, + Math.min(buffer1.current.duration, buffer2.current.duration) - 0.01, + ); + const startOffset = Math.min(Math.max(0, playbackPosition), maxOffset); + + if (visibleTrack === 1) { + gainNode1.current.gain.setValueAtTime(MAX_GAIN, now); + gainNode2.current.gain.setValueAtTime(0, now); + } else { + gainNode1.current.gain.setValueAtTime(0, now); + gainNode2.current.gain.setValueAtTime(MAX_GAIN, now); + } + progress.value = visibleTrack === 1 ? 0 : 1; + + sourceNode1.current.onPositionChanged = (event) => { + if (visibleTrackRef.current === 1) { + setPlaybackPosition(event.value); + } + }; + sourceNode1.current.start(now, startOffset); + + sourceNode2.current.onPositionChanged = (event) => { + if (visibleTrackRef.current === 2) { + setPlaybackPosition(event.value); + } + }; + sourceNode2.current.start(now, startOffset); + + setIsPlaying(true); + }, [isPlaying, visibleTrack, playbackPosition]); + + const stopAudio = useCallback(async () => { + if (!isPlaying || !audioContext.current) { + return; + } + + gainNode1.current?.disconnect(); + gainNode2.current?.disconnect(); + sourceNode1.current = null; + sourceNode2.current = null; + gainNode1.current = null; + gainNode2.current = null; + + await audioContext.current.suspend(); + await AudioManager.setAudioSessionActivity(false); + setIsPlaying(false); + }, [isPlaying]); + + const togglePlayPause = useCallback(() => { + if (isPlaying) { + stopAudio(); + } else { + playAudio(); + } + }, [isPlaying, playAudio, stopAudio]); + + const progressPercent = + trackDuration > 0 ? (playbackPosition / trackDuration) * 100 : 0; + + const panGesture = Gesture.Pan() + .activeOffsetX([-20, 20]) + .onStart(() => { + swipeStartProgress.value = progress.value; + }) + .onUpdate((event) => { + const p = Math.max( + 0, + Math.min(1, swipeStartProgress.value - event.translationX / ARTWORK_SIZE), + ); + progress.value = p; + }) + .onEnd(() => { + const p = progress.value; + const snapTo = p < 0.5 ? 0 : 1; + progress.value = withSpring(snapTo, { + damping: 100, + stiffness: 300, + }); + swipeEndSnapTo.value = snapTo; + }); + + const track1TileStyle = useAnimatedStyle(() => { + const p = progress.value; + return { + transform: [{ translateX: -p * TILE_DISTANCE }], + }; + }); + + const track2TileStyle = useAnimatedStyle(() => { + const p = progress.value; + return { + transform: [{ translateX: (1 - p) * TILE_DISTANCE }], + }; + }); + + const currentTrackTitle = TRACKS[visibleTrack - 1].title; + + return ( + + {isLoading ? ( + + ) : ( + + + + + + + + + + + + + + + + + + + + {currentTrackTitle} + + + Lo-Fi Boy + + + + + + + + + + + + + + {formatTime(playbackPosition)} + + + {formatTime(trackDuration)} + + + + + + + + {}} + style={({ pressed }) => [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + > + + + [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + > + + + {}} + style={({ pressed }) => [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + > + + + + + )} + + ); +}; + +export default Crossfade; + +// ----------------------------------------------------------------------------- +// Styles +// ----------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + content: { + width: '100%', + alignItems: 'center', + paddingHorizontal: 20, + }, + artworkWrapper: { + width: ARTWORK_SIZE + 48, + height: ARTWORK_SIZE + 48, + borderRadius: 12, + backgroundColor: `${colors.main}12`, + alignItems: 'center', + justifyContent: 'center', + }, + artworkContainer: { + width: ARTWORK_SIZE, + height: ARTWORK_SIZE, + overflow: 'hidden', + position: 'relative', + }, + tile: { + position: 'absolute', + left: 0, + top: 0, + width: ARTWORK_SIZE, + height: ARTWORK_SIZE, + }, + albumCover: { + width: ARTWORK_SIZE, + height: ARTWORK_SIZE, + }, + trackInfo: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + width: ARTWORK_SIZE, + marginTop: 12, + paddingHorizontal: 4, + }, + trackInfoText: { + flex: 1, + marginRight: 8, + }, + trackTitle: { + color: colors.white, + fontSize: 18, + fontWeight: '600', + }, + trackArtist: { + color: colors.white, + fontSize: 14, + opacity: 0.8, + marginTop: 2, + }, + heartIcon: { + opacity: 0.9, + }, + progressSection: { + width: '100%', + maxWidth: ARTWORK_SIZE, + }, + progressBar: { + height: 6, + backgroundColor: colors.separator, + borderRadius: 3, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: colors.main, + borderRadius: 3, + }, + timeRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 6, + }, + timeText: { + color: colors.white, + fontSize: 12, + opacity: 0.9, + }, + controlsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 32, + }, + iconButton: { + padding: 12, + }, + iconButtonPressed: { + opacity: 0.7, + }, +}); diff --git a/apps/common-app/src/demos/Crossfade/images/image_1.jpeg b/apps/common-app/src/demos/Crossfade/images/image_1.jpeg new file mode 100644 index 000000000..2067d07bc Binary files /dev/null and b/apps/common-app/src/demos/Crossfade/images/image_1.jpeg differ diff --git a/apps/common-app/src/demos/Crossfade/images/image_2.jpg b/apps/common-app/src/demos/Crossfade/images/image_2.jpg new file mode 100644 index 000000000..4855ae7f5 Binary files /dev/null and b/apps/common-app/src/demos/Crossfade/images/image_2.jpg differ diff --git a/apps/common-app/src/demos/Crossfade/tracks/track1.mp3 b/apps/common-app/src/demos/Crossfade/tracks/track1.mp3 new file mode 100644 index 000000000..0bc14a182 Binary files /dev/null and b/apps/common-app/src/demos/Crossfade/tracks/track1.mp3 differ diff --git a/apps/common-app/src/demos/Crossfade/tracks/track2.mp3 b/apps/common-app/src/demos/Crossfade/tracks/track2.mp3 new file mode 100644 index 000000000..e7d40ff75 Binary files /dev/null and b/apps/common-app/src/demos/Crossfade/tracks/track2.mp3 differ diff --git a/apps/common-app/src/demos/Crossfade/tracks/track3.mp3 b/apps/common-app/src/demos/Crossfade/tracks/track3.mp3 new file mode 100644 index 000000000..bf04470d3 Binary files /dev/null and b/apps/common-app/src/demos/Crossfade/tracks/track3.mp3 differ diff --git a/apps/common-app/src/demos/Crossfade/tracks/track4.mp3 b/apps/common-app/src/demos/Crossfade/tracks/track4.mp3 new file mode 100644 index 000000000..0d9c54195 Binary files /dev/null and b/apps/common-app/src/demos/Crossfade/tracks/track4.mp3 differ diff --git a/apps/common-app/src/demos/index.ts b/apps/common-app/src/demos/index.ts index 5935865b9..8f1d989b8 100644 --- a/apps/common-app/src/demos/index.ts +++ b/apps/common-app/src/demos/index.ts @@ -2,6 +2,7 @@ import { icons } from 'lucide-react-native'; import PedalBoard from './PedalBoard/PedalBoard'; import Record from './Record/Record'; +import Crossfade from './Crossfade/Crossfade'; interface SimplifiedIconProps { color?: string; @@ -33,4 +34,12 @@ export const demos: DemoScreen[] = [ icon: icons.Guitar, screen: PedalBoard, }, + { + key: 'Crossfade', + title: 'Crossfade', + subtitle: + 'Demonstrates crossfading between two audio files.', + icon: icons.ArrowLeftRight, + screen: Crossfade, + } ] as const; diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index a344b24dc..cdd4bf9ed 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2514,7 +2514,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: e97c19a5a442429d1988f182a1940fb08df514da - hermes-engine: ca0c1d4fe0200e05fedd8d7c0c283b54cd461436 + hermes-engine: 471e81260adadffc041e40c5eea01333addabb53 RCTDeprecation: af44b104091a34482596cd9bd7e8d90c4e9b4bd7 RCTRequired: bb77b070f75f53398ce43c0aaaa58337cebe2bf6 RCTSwiftUI: afc0a0a635860da1040a0b894bfd529da06d7810 @@ -2523,7 +2523,7 @@ SPEC CHECKSUMS: React: 1ba7d364ade7d883a1ec055bfc3606f35fdee17b React-callinvoker: bc2a26f8d84fb01f003fc6de6c9337b64715f95b React-Core: 7840d3a80b43a95c5e80ef75146bd70925ebab0f - React-Core-prebuilt: e44365cf4785c3aa56ababc9ab204fe8bc6b17d0 + React-Core-prebuilt: 6586031f606ff8ab466cac9e8284053a91342881 React-CoreModules: 2eb010400b63b89e53a324ffb3c112e4c7c3ce42 React-cxxreact: a558e92199d26f145afa9e62c4233cf8e7950efe React-debug: 755200a6e7f5e6e0a40ff8d215493d43cce285fc @@ -2553,7 +2553,7 @@ SPEC CHECKSUMS: React-microtasksnativemodule: d1956f0eec54c619b63a379520fb4c618a55ccb9 react-native-background-timer: 4638ae3bee00320753647900b21260b10587b6f7 react-native-safe-area-context: ae7587b95fb580d1800c5b0b2a7bd48c2868e67a - react-native-skia: 5f68d3c3749bfb4f726e408410b8be5999392cd9 + react-native-skia: 9e5b3a8a4ced921df89cb625dd9eb4fb10be1acf React-NativeModulesApple: 5ba0903927f6b8d335a091700e9fda143980f819 React-networking: 3a4b7f9ed2b2d1c0441beacb79674323a24bcca6 React-oscompat: ff26abf0ae3e3fdbe47b44224571e3fc7226a573 @@ -2587,7 +2587,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: e96e93b493d8d86eeaee3e590ba0be53f6abe46f ReactCodegen: f66521b131699d6af0790f10653933b3f1f79a6f ReactCommon: 07572bf9e687c8a52fbe4a3641e9e3a1a477c78e - ReactNativeDependencies: 3467a1fea6f7a524df13b30430bebcc254d9aee2 + ReactNativeDependencies: a5d71d95f2654107eb45e6ece04caba36beac2bd RNAudioAPI: fa5c075d2fcdb1ad9a695754b38f07c8c3074396 RNGestureHandler: 07de6f059e0ee5744ae9a56feb07ee345338cc31 RNReanimated: d75c81956bf7531fe08ba4390149002ab8bdd127