diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5a8296c..8b558ae 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1660,6 +1660,63 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - react-native-slider (5.1.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsc + - React-jsi + - react-native-slider/common (= 5.1.1) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - react-native-slider/common (5.1.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsc + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - react-native-track-player (4.1.1): - React-Core - SwiftAudioEx (= 1.1.0) @@ -2466,6 +2523,7 @@ DEPENDENCIES: - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - "react-native-slider (from `../node_modules/@react-native-community/slider`)" - react-native-track-player (from `../node_modules/react-native-track-player`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) @@ -2593,6 +2651,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-slider: + :path: "../node_modules/@react-native-community/slider" react-native-track-player: :path: "../node_modules/react-native-track-player" React-NativeModulesApple: @@ -2710,6 +2770,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 0c045c844ce6d85cde53e85ab163294c6adad349 React-microtasksnativemodule: 26edbece4d0c029ef6ac694e42102a338f2bae0a react-native-safe-area-context: 8a49d796bc2fda5d22207da244713de67b4b8895 + react-native-slider: ca5fb99cb306e895d43dc08c48e1832657f9c73b react-native-track-player: 6dc2e2633265704b8ab6d8124b80239d4ed1f911 React-NativeModulesApple: 00beb364b4dd0a64d91b07658a43628672e888cb React-oscompat: 114036cd8f064558c9c1a0c04fc9ae5e1453706a diff --git a/package-lock.json b/package-lock.json index fda0cfc..e4e2035 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.3.0", "hasInstallScript": true, "dependencies": { + "@react-native-community/slider": "^5.1.1", "@react-navigation/bottom-tabs": "^7.4.9", "@react-navigation/native": "^7.1.18", "@react-navigation/native-stack": "^7.3.28", @@ -3012,6 +3013,12 @@ "node": ">=10" } }, + "node_modules/@react-native-community/slider": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.1.1.tgz", + "integrity": "sha512-W98If/LnTaziU3/0h5+G1LvJaRhMc6iLQBte6UWa4WBIHDMaDPglNBIFKcCXc9Dxp83W+f+5Wv22Olq9M2HJYA==", + "license": "MIT" + }, "node_modules/@react-native/assets-registry": { "version": "0.80.1", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.80.1.tgz", diff --git a/package.json b/package.json index 9beb0dd..0a1c455 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "prepare": "husky" }, "dependencies": { + "@react-native-community/slider": "^5.1.1", "@react-navigation/bottom-tabs": "^7.4.9", "@react-navigation/native": "^7.1.18", "@react-navigation/native-stack": "^7.3.28", diff --git a/src/app/Schedule/ArchivedShowView.tsx b/src/app/Schedule/ArchivedShowView.tsx index 62312d7..b8e5ca5 100644 --- a/src/app/Schedule/ArchivedShowView.tsx +++ b/src/app/Schedule/ArchivedShowView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { View, Text, @@ -15,8 +15,6 @@ import LinearGradient from 'react-native-linear-gradient'; import { useRoute, RouteProp } from '@react-navigation/native'; import { useHeaderHeight } from '@react-navigation/elements'; import { debugError } from '@utils/Debug'; -import Animated, { useSharedValue, runOnJS } from 'react-native-reanimated'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import TrackPlayer, { useProgress, usePlaybackState, @@ -34,6 +32,7 @@ import { generateGradientColors, } from '@utils/GradientColors'; import { ShowImage } from '@components/ShowImage'; +import PlaybackSlider from '@components/PlaybackSlider'; interface ArchivedShowViewProps { show: Show; @@ -54,13 +53,9 @@ export default function ArchivedShowView() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isArchivePlaying, setIsArchivePlaying] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [scrubPosition, setScrubPosition] = useState(0); - const [dragPercentage, setDragPercentage] = useState(0); - // Shared values for gesture handling - const progressBarWidth = useSharedValue(0); - const dragPosition = useSharedValue(0); + const [currentPosition, setCurrentPosition] = useState(0); + const [isSliding, setIsSliding] = useState(false); const playlistService = PlaylistService.getInstance(); const archiveService = ArchiveService.getInstance(); @@ -115,77 +110,17 @@ export default function ArchivedShowView() { return unsubscribe; }, [archive.url, archiveService]); - // Calculate current progress percentage - const getCurrentPercentage = useCallback(() => { - if (isDragging) { - // During dragging, clamp the visual percentage but allow the user to keep dragging - return Math.min(Math.max(dragPercentage, 0), 100); - } - if (progress.duration > 0) { - return Math.min( - Math.max((progress.position / progress.duration) * 100, 0), - 100, - ); - } - return 0; - }, [dragPercentage, isDragging, progress.duration, progress.position]); + // Update current progress to playback position, as long as user is not + // currently sliding + useEffect(() => { + !isSliding && setCurrentPosition(progress.position); + }, [isSliding, progress.position]); const [gradientStart] = generateGradientColors(show.name); const [darkGradientStart, darkGradientEnd] = generateDarkGradientColors( show.name, ); - const updateScrubPosition = (position: number, percentage: number) => { - setScrubPosition(position); - setDragPercentage(percentage); - }; - - const seekToPosition = async (position: number) => { - try { - // Clamp position to avoid seeking to exact beginning or end - const clampedPosition = Math.max( - 0.1, - Math.min(position, progress.duration - 0.1), - ); - await TrackPlayer.seekTo(clampedPosition); - } catch (e) { - debugError('Error seeking:', e); - } - }; - - const panGesture = Gesture.Pan() - .onStart(event => { - runOnJS(setIsDragging)(true); - // Set initial drag position to the touch point - allow full range - dragPosition.value = event.x; - }) - .onUpdate(event => { - // Allow dragging to full range without clamping during drag - dragPosition.value = event.x; - - if (progressBarWidth.value > 0) { - // Clamp only for visual display and position calculation - const clampedX = Math.max(0, Math.min(event.x, progressBarWidth.value)); - const percentage = (clampedX / progressBarWidth.value) * 100; - const newPosition = - (clampedX / progressBarWidth.value) * progress.duration; - runOnJS(updateScrubPosition)(newPosition, percentage); - } - }) - .onEnd(() => { - if (progressBarWidth.value > 0) { - // Clamp the final position for seeking - const clampedX = Math.max( - 0, - Math.min(dragPosition.value, progressBarWidth.value), - ); - const percentage = clampedX / progressBarWidth.value; - const newPosition = percentage * progress.duration; - runOnJS(seekToPosition)(newPosition); - } - runOnJS(setIsDragging)(false); - }); - const handlePlayPause = async () => { try { if (isArchivePlaying && playbackState?.state === State.Playing) { @@ -287,35 +222,23 @@ export default function ArchivedShowView() { {isArchivePlaying && progress.duration > 0 && ( - - - { - progressBarWidth.value = - event.nativeEvent.layout.width; - }} - > - - - - - + setIsSliding(true)} + onSlidingComplete={async value => { + try { + TrackPlayer.seekTo(value * (progress?.duration || 0)); + } catch (e) { + debugError('Error seeking to position:', e); + } + setIsSliding(false); + }} + /> + - {secondsToTime( - isDragging ? scrubPosition : progress.position, - )} + {secondsToTime(currentPosition)} {secondsToTime(progress.duration)} @@ -509,37 +432,6 @@ const styles = StyleSheet.create({ width: '100%', maxWidth: 300, }, - progressTouchArea: { - paddingVertical: 12, - paddingHorizontal: 4, - marginBottom: 12, - }, - progressBar: { - height: 4, - backgroundColor: 'rgba(255, 255, 255, 0.3)', - borderRadius: 2, - position: 'relative', - }, - progressFill: { - height: '100%', - backgroundColor: '#FFFFFF', - borderRadius: 2, - minWidth: 2, - }, - progressDot: { - position: 'absolute', - top: -4, - width: 12, - height: 12, - backgroundColor: '#FFFFFF', - borderRadius: 6, - marginLeft: -6, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, - elevation: 4, - }, timeContainer: { flexDirection: 'row', justifyContent: 'space-between', @@ -549,5 +441,10 @@ const styles = StyleSheet.create({ color: COLORS.TEXT.SECONDARY, fontSize: 12, fontWeight: '500', + fontVariant: ['tabular-nums'], + }, + slider: { + width: '100%', + height: 40, }, }); diff --git a/src/app/Schedule/ShowDetailsPage.tsx b/src/app/Schedule/ShowDetailsPage.tsx index 7676bc7..40c41d3 100644 --- a/src/app/Schedule/ShowDetailsPage.tsx +++ b/src/app/Schedule/ShowDetailsPage.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useState, - useEffect, - useRef, - useMemo, -} from 'react'; +import React, { useCallback, useState, useEffect, useMemo } from 'react'; import { useRoute, RouteProp, @@ -17,21 +11,13 @@ import { Text, TouchableOpacity, StyleSheet, - Dimensions, ScrollView, SafeAreaView, StatusBar, } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; import LinearGradient from 'react-native-linear-gradient'; -import { debugLog, debugError } from '@utils/Debug'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - Easing, - runOnJS, -} from 'react-native-reanimated'; +import { debugError } from '@utils/Debug'; import { Show, Archive } from '@customTypes/RecentlyPlayed'; import { WmbrRouteName } from '@customTypes/Navigation'; import { ArchiveService } from '@services/ArchiveService'; @@ -41,7 +27,6 @@ import { State, } from 'react-native-track-player'; import TrackPlayer from 'react-native-track-player'; -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { ShowImage } from '@components/ShowImage'; import { formatDate, @@ -54,9 +39,7 @@ import { generateDarkGradientColors, generateGradientColors, } from '@utils/GradientColors'; - -const { width } = Dimensions.get('window'); -const CIRCLE_DIAMETER = 16; +import PlaybackSlider from '@components/PlaybackSlider'; // Route params for ShowDetailsPage export type ShowDetailsPageRouteParams = { @@ -73,23 +56,11 @@ export default function ShowDetailsPage() { const headerHeight = useHeaderHeight(); - // Always call hooks at the top level, never conditionally - // Slide horizontally: start offscreen to the right (translateX = width) - const translateX = useSharedValue(width); - const opacity = useSharedValue(0); - const circleScale = useSharedValue(1); - const dragX = useSharedValue(0); - - // State hooks const [currentlyPlayingArchive, setCurrentlyPlayingArchive] = useState(null); - const [isScrubbing, setIsScrubbing] = useState(false); - const [previewTime, setPreviewTime] = useState(0); - const [progressBarWidth, setProgressBarWidth] = useState(0); - const [progressBarX, setProgressBarX] = useState(0); - // Ref for measuring progress bar position - const progressBarRef = useRef(null); + const [currentPosition, setCurrentPosition] = useState(0); + const [isSliding, setIsSliding] = useState(false); // TrackPlayer hooks - must always be called unconditionally const progressHook = useProgress(); @@ -115,58 +86,6 @@ export default function ShowDetailsPage() { return unsubscribe; }, [archiveService]); - // Update drag position when progress changes - useEffect(() => { - if ( - !isScrubbing && - progress && - progress.duration > 0 && - progressBarWidth > 0 - ) { - const maxMovement = progressBarWidth - CIRCLE_DIAMETER; // Account for circle size - const progressPercentage = progress.position / progress.duration; - dragX.value = progressPercentage * maxMovement + 32; - } - }, [ - progress.position, - progress.duration, - isScrubbing, - progressBarWidth, - dragX, - progress, - ]); - - // Animate in on mount (slide from right → left) and animate out on unmount - useEffect(() => { - translateX.value = withTiming(0, { - duration: 300, - easing: Easing.out(Easing.cubic), - }); - opacity.value = withTiming(1, { - duration: 200, - easing: Easing.out(Easing.cubic), - }); - - return () => { - translateX.value = withTiming(width, { - duration: 300, - easing: Easing.out(Easing.cubic), - }); - opacity.value = withTiming(0, { - duration: 200, - easing: Easing.out(Easing.cubic), - }); - }; - }, [translateX, opacity]); - - const circleAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: circleScale.value }], - })); - - const circlePositionStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: dragX.value - CIRCLE_DIAMETER / 2 }], - })); - // Since we're now conditionally rendered, show will always exist const [gradientStart] = useMemo( () => generateGradientColors(show.name), @@ -220,72 +139,11 @@ export default function ShowDetailsPage() { } }; - const handleProgressPress = async (event: any) => { - if ( - !currentlyPlayingArchive || - progress.duration === 0 || - progressBarWidth <= 0 || - progressBarX <= 0 - ) - return; - - const { pageX } = event.nativeEvent; - const relativeX = pageX - progressBarX; - const percentage = Math.max(0, Math.min(1, relativeX / progressBarWidth)); - const seekPosition = percentage * progress.duration; - - try { - await TrackPlayer.seekTo(seekPosition); - } catch (error) { - debugError('Error seeking:', error); - } - }; - - const handleSeekTo = async (seekPosition: number) => { - try { - await TrackPlayer.seekTo(seekPosition); - debugLog('Seeked to:', seekPosition); - } catch (error) { - debugError('Error seeking:', error); - } - }; - - const dragGesture = Gesture.Pan() - .onStart(() => { - circleScale.value = withTiming(1.5, { - duration: 120, - easing: Easing.out(Easing.quad), - }); - runOnJS(setIsScrubbing)(true); - }) - .onUpdate(event => { - if (progressBarWidth <= 0 || progressBarX <= 0) return; // Wait for layout to be measured - - const relativeX = event.absoluteX - progressBarX; - const newX = Math.max(0, Math.min(progressBarWidth, relativeX)); - - dragX.value = newX + 32; - - const percentage = newX / progressBarWidth; - const previewSeconds = percentage * (progress?.duration || 0); - runOnJS(setPreviewTime)(previewSeconds); - }) - .onEnd(() => { - circleScale.value = withTiming(1, { - duration: 120, - easing: Easing.out(Easing.quad), - }); - runOnJS(setIsScrubbing)(false); - - if (progressBarWidth > 0) { - const percentage = dragX.value / progressBarWidth; - const seekPosition = percentage * (progress?.duration || 0); - - if (seekPosition > 0 && progress?.duration > 0) { - runOnJS(handleSeekTo)(seekPosition); - } - } - }); + // Update current progress to playback position, as long as user is not + // currently sliding + useEffect(() => { + !isSliding && setCurrentPosition(progress.position); + }, [isSliding, progress.position]); return ( <> @@ -321,10 +179,6 @@ export default function ShowDetailsPage() { const isCurrentlyPlaying = currentlyPlayingArchive && currentlyPlayingArchive.url === archive.url; - const progressPercentage = - isCurrentlyPlaying && progress.duration > 0 - ? progress.position / progress.duration - : 0; return ( handleArchiveRowPress(archive)} activeOpacity={0.7} > - - - - {formatDate(archive.date)} - - - {isCurrentlyPlaying - ? `${secondsToTime(progress.position)} / ${secondsToTime(progress.duration)}` - : getDurationFromSize(archive.size)} - + + + + + {formatDate(archive.date)} + + + {isCurrentlyPlaying + ? `${secondsToTime(currentPosition)} / ${secondsToTime(progress.duration)}` + : getDurationFromSize(archive.size)} + + - - {/* Play/pause button - clickable for both play and pause */} - { - e.stopPropagation(); - handlePlayButtonPress(archive, isCurrentlyPlaying); - }} - style={styles.playIconContainer} - activeOpacity={0.7} - > - - + {/* Play/pause button - clickable for both play and pause */} + { + e.stopPropagation(); + handlePlayButtonPress(archive, isCurrentlyPlaying); + }} + style={styles.playIconContainer} + activeOpacity={0.7} + > + + + {/* Progress bar */} {isCurrentlyPlaying && ( - - { - progressBarRef.current?.measure( - (_x, _y, currentWidth, _height, pageX) => { - setProgressBarWidth(currentWidth); - setProgressBarX(pageX); - }, + setIsSliding(true)} + onSlidingComplete={async value => { + try { + await TrackPlayer.seekTo( + value * (progress?.duration || 0), ); - }} - > - - + } catch (error) { + debugError('TrackPlayer.seekTo failed', error); + } - {/* Draggable progress circle */} - - - - - - - {/* Preview time display when scrubbing */} - {isScrubbing && ( - - - {secondsToTime(previewTime)} - - - )} - + setIsSliding(false); + }} + /> )} ); @@ -502,9 +320,6 @@ const styles = StyleSheet.create({ marginBottom: 16, }, archiveItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', paddingVertical: 12, paddingHorizontal: 16, marginBottom: 8, @@ -513,6 +328,11 @@ const styles = StyleSheet.create({ position: 'relative', overflow: 'hidden', }, + archiveDetails: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + }, archiveItemPlaying: { borderWidth: 2, borderColor: '#FFFFFF', @@ -534,6 +354,7 @@ const styles = StyleSheet.create({ }, archiveSize: { color: COLORS.TEXT.SECONDARY, + fontVariant: ['tabular-nums'], fontSize: 14, marginTop: 2, }, @@ -544,59 +365,6 @@ const styles = StyleSheet.create({ padding: 8, marginRight: -8, }, - progressContainer: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: 20, - justifyContent: 'center', - paddingHorizontal: 16, - }, - progressBackground: { - height: 4, - backgroundColor: 'rgba(255, 255, 255, 0.3)', - borderRadius: 2, - }, - progressBar: { - height: '100%', - backgroundColor: '#FFFFFF', - borderRadius: 2, - }, - progressCircleContainer: { - position: 'absolute', - top: 2, // Center the circle in the 20px container - width: CIRCLE_DIAMETER, - height: CIRCLE_DIAMETER, - marginLeft: -CIRCLE_DIAMETER / 2, - justifyContent: 'center', - alignItems: 'center', - }, - progressCircle: { - width: CIRCLE_DIAMETER, - height: CIRCLE_DIAMETER, - borderRadius: CIRCLE_DIAMETER / 2, - backgroundColor: '#FFFFFF', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, - elevation: 4, - }, - previewTime: { - position: 'absolute', - top: -27, // Adjusted for new circle position (was -35, now -27 since circle moved down 8px) - backgroundColor: 'rgba(0, 0, 0, 0.8)', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 4, - marginLeft: -20, - }, - previewTimeText: { - color: COLORS.TEXT.PRIMARY, - fontSize: 12, - fontWeight: '500', - }, noArchives: { alignItems: 'center', paddingVertical: 40, @@ -609,4 +377,8 @@ const styles = StyleSheet.create({ bottomPadding: { height: 100, }, + slider: { + width: '100%', + height: 40, + }, }); diff --git a/src/components/PlaybackSlider.tsx b/src/components/PlaybackSlider.tsx new file mode 100644 index 0000000..0b37024 --- /dev/null +++ b/src/components/PlaybackSlider.tsx @@ -0,0 +1,42 @@ +import { useProgress } from 'react-native-track-player'; +import { Platform, StyleProp, ViewStyle } from 'react-native'; +import { COLORS, CORE_COLORS } from '../utils/Colors'; + +import Slider from '@react-native-community/slider'; + +export default function PlaybackSlider({ + style, + onValueChange, + onSlidingComplete, + onSlidingStart, +}: { + style?: StyleProp; + // Returns value in seconds + onValueChange?: (value: number) => void; + // Returns value in percentage (0 to 1) + onSlidingComplete?: (value: number) => void; + // Returns value in percentage (0 to 1) + onSlidingStart?: (value: number) => void; +}) { + const progress = useProgress(); + + return ( + + onValueChange?.(value * (progress?.duration || 0)) + } + tapToSeek={true} + value={progress.duration > 0 ? progress.position / progress.duration : 0} + /> + ); +}