feat: Create new progress slider from scratch
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { ColorSchemeContext, themes } from './Colors';
|
||||
import DownloadManager from './DownloadManager';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
// import ErrorReportingAlert from 'utility/ErrorReportingAlert';
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
@@ -30,7 +31,8 @@ export default function App(): JSX.Element {
|
||||
Capability.SkipToPrevious,
|
||||
Capability.Stop,
|
||||
Capability.SeekTo,
|
||||
]
|
||||
],
|
||||
stopWithApp: true
|
||||
});
|
||||
}
|
||||
setupTrackPlayer();
|
||||
@@ -41,8 +43,10 @@ export default function App(): JSX.Element {
|
||||
<PersistGate loading={null} persistor={persistedStore}>
|
||||
<ColorSchemeContext.Provider value={theme}>
|
||||
<NavigationContainer theme={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Routes />
|
||||
<DownloadManager />
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Routes />
|
||||
<DownloadManager />
|
||||
</GestureHandlerRootView>
|
||||
</NavigationContainer>
|
||||
</ColorSchemeContext.Provider>
|
||||
</PersistGate>
|
||||
|
||||
52
src/components/Progresstrack.tsx
Normal file
52
src/components/Progresstrack.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import styled from 'styled-components/native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function getSeconds(seconds: number): string {
|
||||
'worklet';
|
||||
return Math.floor(seconds % 60).toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
export function getMinutes(seconds: number): number {
|
||||
'worklet';
|
||||
return Math.floor(seconds / 60);
|
||||
}
|
||||
|
||||
export function calculateProgressTranslation(
|
||||
position: number,
|
||||
reference: number,
|
||||
width: number,
|
||||
) {
|
||||
'worklet';
|
||||
const completion = position / reference;
|
||||
const output = (1 - (completion || 0)) * -1 * width;
|
||||
return output;
|
||||
}
|
||||
|
||||
export const ProgressTrackContainer = styled.View`
|
||||
overflow: hidden;
|
||||
height: 5px;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
`;
|
||||
|
||||
export interface ProgressTrackProps {
|
||||
opacity?: number;
|
||||
stroke?: number;
|
||||
}
|
||||
|
||||
const ProgressTrack = styled(Animated.View)<ProgressTrackProps>`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: ${(props) => props.stroke ? props.stroke + 'px' : '100%'};
|
||||
background-color: ${THEME_COLOR};
|
||||
opacity: ${(props) => props.opacity || 1};
|
||||
border-radius: 99px;
|
||||
`;
|
||||
|
||||
export default ProgressTrack;
|
||||
40
src/components/ReText.tsx
Normal file
40
src/components/ReText.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import type { TextProps as RNTextProps } from 'react-native';
|
||||
import { StyleSheet, TextInput } from 'react-native';
|
||||
import Animated, { useAnimatedProps } from 'react-native-reanimated';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
baseStyle: {
|
||||
color: 'black',
|
||||
},
|
||||
});
|
||||
Animated.addWhitelistedNativeProps({ text: true });
|
||||
|
||||
interface TextProps {
|
||||
text: Animated.SharedValue<string>;
|
||||
style?: Animated.AnimateProps<RNTextProps>['style'];
|
||||
}
|
||||
|
||||
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
|
||||
|
||||
const ReText = (props: TextProps) => {
|
||||
const { text, style } = { style: {}, ...props };
|
||||
const animatedProps = useAnimatedProps(() => {
|
||||
return {
|
||||
text: text.value,
|
||||
// Here we use any because the text prop is not available in the type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
});
|
||||
return (
|
||||
<AnimatedTextInput
|
||||
underlineColorAndroid="transparent"
|
||||
editable={false}
|
||||
value={text.value}
|
||||
style={[styles.baseStyle, style]}
|
||||
{...{ animatedProps }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReText;
|
||||
@@ -3,12 +3,12 @@ import Text from './Text';
|
||||
|
||||
export const Header = styled(Text)`
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 24px;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
export const SubHeader = styled(Text)`
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
margin: 0 0 6px 0;
|
||||
font-weight: 400;
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -7,12 +7,13 @@ import PlayIcon from 'assets/icons/play.svg';
|
||||
import PauseIcon from 'assets/icons/pause.svg';
|
||||
import useCurrentTrack from 'utility/useCurrentTrack';
|
||||
import TrackPlayer, { State, usePlaybackState, useProgress } from 'react-native-track-player';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { Shadow } from 'react-native-shadow-2';
|
||||
import usePrevious from 'utility/usePrevious';
|
||||
import Text from 'components/Text';
|
||||
import useDefaultStyles, { ColoredBlurView } from 'components/Colors';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { calculateProgressTranslation } from 'components/Progresstrack';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
|
||||
const NOW_PLAYING_POPOVER_MARGIN = 6;
|
||||
const NOW_PLAYING_POPOVER_WIDTH = Dimensions.get('screen').width - 2 * NOW_PLAYING_POPOVER_MARGIN;
|
||||
@@ -38,6 +39,17 @@ const InnerContainer = styled.Pressable`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ProgressTrack = styled(Animated.View)<{ stroke?: number; opacity?: number}>`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: ${(props) => props.stroke ? props.stroke + 'px' : '100%'};
|
||||
background-color: ${THEME_COLOR};
|
||||
opacity: ${(props) => props.opacity || 1};
|
||||
border-radius: 99px;
|
||||
`;
|
||||
|
||||
const ShadowOverlay = styled.View`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -61,21 +73,6 @@ const ActionButton = styled.Pressable`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
interface ProgressTrackProps {
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
const ProgressTrack = styled(Animated.View)<ProgressTrackProps>`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: ${THEME_COLOR};
|
||||
opacity: ${(props) => props.opacity || 1};
|
||||
border-radius: 99px;
|
||||
`;
|
||||
|
||||
function SelectActionButton() {
|
||||
const state = usePlaybackState();
|
||||
const defaultStyles = useDefaultStyles();
|
||||
@@ -108,11 +105,6 @@ function SelectActionButton() {
|
||||
}
|
||||
}
|
||||
|
||||
function calculateProgressTranslation(position: number, reference: number) {
|
||||
const completion = position / reference;
|
||||
return (1 - (completion || 0)) * -1 * NOW_PLAYING_POPOVER_WIDTH;
|
||||
}
|
||||
|
||||
function NowPlaying() {
|
||||
const { index, track } = useCurrentTrack();
|
||||
const { buffered, duration, position } = useProgress();
|
||||
@@ -130,13 +122,13 @@ function NowPlaying() {
|
||||
const hasChangedTrack = previousIndex !== index || duration === 0;
|
||||
|
||||
Animated.timing(bufferAnimation.current, {
|
||||
toValue: calculateProgressTranslation(buffered, duration),
|
||||
toValue: calculateProgressTranslation(buffered, duration, NOW_PLAYING_POPOVER_WIDTH),
|
||||
duration: hasChangedTrack ? 0 : 500,
|
||||
useNativeDriver: true,
|
||||
easing: Easing.ease,
|
||||
}).start();
|
||||
Animated.timing(progressAnimation.current, {
|
||||
toValue: calculateProgressTranslation(position, duration),
|
||||
toValue: calculateProgressTranslation(position, duration, NOW_PLAYING_POPOVER_WIDTH),
|
||||
duration: hasChangedTrack ? 0 : 500,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
@@ -168,9 +160,11 @@ function NowPlaying() {
|
||||
<ProgressTrack
|
||||
style={{ transform: [{ translateX: bufferAnimation.current }]}}
|
||||
opacity={0.15}
|
||||
stroke={4}
|
||||
/>
|
||||
<ProgressTrack
|
||||
style={{ transform: [{ translateX: progressAnimation.current }]}}
|
||||
stroke={4}
|
||||
/>
|
||||
</InnerContainer>
|
||||
</ColoredBlurView>
|
||||
|
||||
@@ -1,95 +1,187 @@
|
||||
import React, { Component } from 'react';
|
||||
import TrackPlayer from 'react-native-track-player';
|
||||
import React, { useEffect } from 'react';
|
||||
import TrackPlayer, { useProgress } from 'react-native-track-player';
|
||||
import styled from 'styled-components/native';
|
||||
import { Text, Platform } from 'react-native';
|
||||
import Slider from '@react-native-community/slider';
|
||||
import ProgressTrack, {
|
||||
calculateProgressTranslation,
|
||||
getMinutes,
|
||||
getSeconds,
|
||||
ProgressTrackContainer
|
||||
} from 'components/Progresstrack';
|
||||
import { Gesture, GestureDetector, gestureHandlerRootHOC } from 'react-native-gesture-handler';
|
||||
import { THEME_COLOR } from 'CONSTANTS';
|
||||
import { DefaultStylesProvider } from 'components/Colors';
|
||||
import Reanimated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
Easing,
|
||||
useDerivedValue,
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated';
|
||||
import ReText from 'components/ReText';
|
||||
|
||||
const DRAG_HANDLE_SIZE = 20;
|
||||
|
||||
const Container = styled.View`
|
||||
margin-top: 28px;
|
||||
`;
|
||||
|
||||
const NumberBar = styled.View`
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 20px 0;
|
||||
padding: 8px 0;
|
||||
`;
|
||||
|
||||
function getSeconds(seconds: number): string {
|
||||
return Math.floor(seconds % 60).toString().padStart(2, '0');
|
||||
}
|
||||
const Number = styled(ReText)`
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
function getMinutes(seconds: number): number {
|
||||
return Math.floor(seconds / 60);
|
||||
}
|
||||
const DragHandle = styled(Reanimated.View)`
|
||||
width: ${DRAG_HANDLE_SIZE}px;
|
||||
height: ${DRAG_HANDLE_SIZE}px;
|
||||
border-radius: ${DRAG_HANDLE_SIZE}px;
|
||||
background-color: ${THEME_COLOR};
|
||||
position: absolute;
|
||||
left: -${DRAG_HANDLE_SIZE / 2}px;
|
||||
top: -${DRAG_HANDLE_SIZE / 2 - 2.5}px;
|
||||
z-index: 14;
|
||||
`;
|
||||
|
||||
interface State {
|
||||
position: number;
|
||||
duration: number;
|
||||
gesture?: number;
|
||||
}
|
||||
function ProgressBar() {
|
||||
const { position, buffered, duration } = useProgress();
|
||||
|
||||
export default class ProgressBar extends Component<{}, State> {
|
||||
state: State = {
|
||||
position: 0,
|
||||
duration: 0,
|
||||
};
|
||||
const width = useSharedValue(0);
|
||||
const pos = useSharedValue(0);
|
||||
const buf = useSharedValue(0);
|
||||
const dur = useSharedValue(0);
|
||||
|
||||
timer: number | null = null;
|
||||
const isDragging = useSharedValue(false);
|
||||
const offset = useSharedValue(0);
|
||||
|
||||
componentDidMount() {
|
||||
this.timer = setInterval(this.updateProgress, 500);
|
||||
}
|
||||
const bufferAnimation = useDerivedValue(() => {
|
||||
return calculateProgressTranslation(buf.value, dur.value, width.value);
|
||||
}, [[dur, buf, width.value]]);
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
const progressAnimation = useDerivedValue(() => {
|
||||
if (isDragging.value) {
|
||||
return calculateProgressTranslation(offset.value, width.value, width.value);
|
||||
} else {
|
||||
return calculateProgressTranslation(pos.value, dur.value, width.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateProgress = async () => {
|
||||
const [position, duration] = await Promise.all([
|
||||
TrackPlayer.getPosition(),
|
||||
TrackPlayer.getDuration(),
|
||||
]);
|
||||
const timePassed = useDerivedValue(() => {
|
||||
if (isDragging.value) {
|
||||
const currentPosition = (offset.value - DRAG_HANDLE_SIZE / 2) / (width.value - DRAG_HANDLE_SIZE) * dur.value;
|
||||
return getMinutes(currentPosition) + ':' + getSeconds(currentPosition);
|
||||
} else {
|
||||
return getMinutes(pos.value) + ':' + getSeconds(pos.value);
|
||||
}
|
||||
}, [pos]);
|
||||
|
||||
this.setState({ position, duration });
|
||||
};
|
||||
const timeRemaining = useDerivedValue(() => {
|
||||
if (isDragging.value) {
|
||||
const currentPosition = (offset.value - DRAG_HANDLE_SIZE / 2) / (width.value - DRAG_HANDLE_SIZE) * dur.value;
|
||||
const remaining = (currentPosition - dur.value) * -1;
|
||||
return `-${getMinutes(remaining)}:${getSeconds((remaining))}`;
|
||||
} else {
|
||||
const remaining = (pos.value - dur.value) * -1;
|
||||
return `-${getMinutes(remaining)}:${getSeconds((remaining))}`;
|
||||
}
|
||||
}, [pos, dur]);
|
||||
|
||||
const gesture = Gesture.Pan()
|
||||
.onBegin(() => {
|
||||
isDragging.value = true;
|
||||
}).onUpdate((e) => {
|
||||
offset.value = Math.min(Math.max(DRAG_HANDLE_SIZE / 2, e.x), width.value - DRAG_HANDLE_SIZE / 2);
|
||||
}).onFinalize(() => {
|
||||
pos.value = (offset.value - DRAG_HANDLE_SIZE / 2) / (width.value - DRAG_HANDLE_SIZE) * dur.value;
|
||||
isDragging.value = false;
|
||||
runOnJS(TrackPlayer.seekTo)(pos.value);
|
||||
});
|
||||
|
||||
handleGesture = async (gesture: number) => {
|
||||
// Set relative translation in state
|
||||
this.setState({ gesture });
|
||||
};
|
||||
useEffect(() => {
|
||||
pos.value = position;
|
||||
buf.value = buffered;
|
||||
dur.value = duration;
|
||||
}, [position, buffered, duration]);
|
||||
|
||||
handleEndOfGesture = (position: number) => {
|
||||
// Calculate and set the new position
|
||||
TrackPlayer.seekTo(position);
|
||||
this.setState({ gesture: undefined, position });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { position, duration, gesture } = this.state;
|
||||
|
||||
return (
|
||||
<DefaultStylesProvider>
|
||||
{defaultStyle => (
|
||||
<>
|
||||
<Slider
|
||||
value={gesture || position}
|
||||
minimumValue={0}
|
||||
maximumValue={duration || 0}
|
||||
onValueChange={this.handleGesture}
|
||||
onSlidingComplete={this.handleEndOfGesture}
|
||||
minimumTrackTintColor={THEME_COLOR}
|
||||
thumbTintColor={Platform.OS === 'android' ? THEME_COLOR : undefined}
|
||||
disabled={!duration}
|
||||
/>
|
||||
<NumberBar>
|
||||
<Text style={defaultStyle.text}>{getMinutes(gesture || position)}:{getSeconds(gesture || position)}</Text>
|
||||
<Text style={defaultStyle.text}>{getMinutes(duration)}:{getSeconds(duration)}</Text>
|
||||
</NumberBar>
|
||||
</>
|
||||
)
|
||||
const dragHandleStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: offset.value },
|
||||
{
|
||||
scale: withTiming(isDragging.value ? 1 : 0.05, {
|
||||
duration: 100,
|
||||
easing: Easing.out(Easing.ease),
|
||||
})
|
||||
}
|
||||
</DefaultStylesProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const bufferStyles = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: bufferAnimation.value }
|
||||
]
|
||||
}));
|
||||
|
||||
const progressStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{ translateX: progressAnimation.value }
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
const timePassedStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{ translateY: withTiming(isDragging.value && offset.value < 48 ? 12 : 0, {
|
||||
duration: 145,
|
||||
easing: Easing.ease
|
||||
}) },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const timeRemainingStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{ translateY: withTiming(isDragging.value && offset.value > width.value - 48 ? 12 : 0, {
|
||||
duration: 150,
|
||||
easing: Easing.ease
|
||||
}) },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Container onLayout={(e) => { width.value = e.nativeEvent.layout.width; }}>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<>
|
||||
<ProgressTrackContainer>
|
||||
<ProgressTrack
|
||||
opacity={0.15}
|
||||
/>
|
||||
<ProgressTrack
|
||||
style={bufferStyles}
|
||||
opacity={0.15}
|
||||
/>
|
||||
<ProgressTrack
|
||||
style={progressStyles}
|
||||
/>
|
||||
</ProgressTrackContainer>
|
||||
<DragHandle style={dragHandleStyles} />
|
||||
<NumberBar style={{ flex: 1 }}>
|
||||
<Number text={timePassed} style={timePassedStyles} />
|
||||
<Number text={timeRemaining} style={timeRemainingStyles} />
|
||||
</NumberBar>
|
||||
</>
|
||||
</GestureDetector>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default gestureHandlerRootHOC(ProgressBar);
|
||||
|
||||
@@ -21,8 +21,8 @@ export default function Player() {
|
||||
<ScrollView contentContainerStyle={styles.inner} style={defaultStyles.view}>
|
||||
<NowPlaying />
|
||||
<ConnectionNotice />
|
||||
<MediaControls />
|
||||
<ProgressBar />
|
||||
<MediaControls />
|
||||
<Queue />
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user