Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { View, Pressable, Animated, Platform, ActivityIndicator } from "react-native";
import { useDispatch, useSelector } from "react-redux";
import { Slider } from "@miblanchard/react-native-slider";
Expand Down Expand Up @@ -54,6 +54,7 @@ const AudioControlBar = ({
isInitialized,
addAndPlayTrack,
play,
isBufferingOrLoading,
}) => {
const dispatch = useDispatch();
const { theme } = useTheme();
Expand All @@ -67,7 +68,12 @@ const AudioControlBar = ({
const progressRef = useRef(progress);
const currentPlayingRef = useRef(currentPlaying);
const audioProgress = useSelector((state) => state.audioProgress);
const [isSeekLoading, setIsSeekLoading] = useState(false);
const [isSeekLoading, setIsSeekLoading] = useState(() => {
return !!(isInitialized && currentPlaying?.id && currentPlaying?.audioUrl);
});
const [isSliding, setIsSliding] = useState(false);
const [sliderValue, setSliderValue] = useState(progress.position);
const isSeekingRef = useRef(false);
const { modalHeight, modalOpacity } = useAnimation(isSettingsModalOpen, isMoreTracksModalOpen);
const { isDownloading, isDownloaded } = useDownloadManager(
currentPlaying,
Expand Down Expand Up @@ -97,6 +103,34 @@ const AudioControlBar = ({
currentPlayingRef.current = currentPlaying;
}, [currentPlaying]);

useEffect(() => {
if (currentPlaying?.id) {
setIsSeekLoading(true); // start loading immediately on track change
} else {
setIsSeekLoading(false);
}
setSliderValue(0);
}, [currentPlaying?.id]);

// Clear loading once TrackPlayer reports duration and buffering is done
useEffect(() => {
if (
currentPlaying?.id &&
progress.duration > 0 &&
!isBufferingOrLoading &&
!isSeekingRef.current
) {
setIsSeekLoading(false);
}
}, [currentPlaying?.id, progress.duration, isBufferingOrLoading]);

// Sync slider value with progress when not sliding and not seeking (and not loading)
useEffect(() => {
if (!isSliding && !isSeekingRef.current && !isSeekLoading) {
setSliderValue(progress.position);
}
}, [progress.position, isSliding, isSeekLoading]);

// Save progress when user closes the modal
const handleClose = async () => {
const currentProgress = progressRef.current;
Expand Down Expand Up @@ -178,10 +212,6 @@ const AudioControlBar = ({
// Load the active track when component mounts or currentPlaying changes
useEffect(() => {
const loadActiveTrack = async () => {
if (!isInitialized || !currentPlaying?.id || !currentPlaying?.audioUrl) {
return;
}

try {
setIsSeekLoading(true);
// Load the track (will seek to saved position if available)
Expand Down Expand Up @@ -233,16 +263,10 @@ const AudioControlBar = ({
}
};

loadActiveTrack();
}, [
isInitialized,
currentPlaying?.id,
currentPlaying?.audioUrl,
currentPlaying?.displayName,
currentPlaying?.lyricsUrl,
audioProgress,
baniID,
]);
if (isInitialized && currentPlaying?.id && currentPlaying?.audioUrl) {
loadActiveTrack();
}
}, [isInitialized, currentPlaying?.id, currentPlaying?.audioUrl, audioProgress, baniID]);

// Save audio progress when component unmounts or user leaves the screen
useEffect(() => {
Expand Down Expand Up @@ -276,6 +300,51 @@ const AudioControlBar = ({
return unsubscribe;
}, [navigation]);

// Handle slider value change during dragging (optimistic update)
const handleSliderValueChange = useCallback(
(value) => {
if (isSliding) {
setSliderValue(value[0]);
}
},
[isSliding]
);

// Handle slider drag start
const handleSlidingStart = useCallback(() => {
setIsSliding(true);
}, []);

// Handle seek completion - non-blocking for smooth UI
const handleSeekComplete = useCallback(
(value) => {
setIsSliding(false);
// Prevent concurrent seeks
if (isSeekingRef.current || !isAudioEnabled) {
return;
}

// Update UI immediately (optimistic update)
setSliderValue(value);

// Perform seek operation asynchronously without blocking UI
(async () => {
try {
isSeekingRef.current = true;
setIsSeekLoading(true);
await handleSeek(value);
} catch (error) {
// Error is already handled in handleSeek
logError("Error in handleSeekComplete:", error);
} finally {
isSeekingRef.current = false;
setIsSeekLoading(false);
}
})();
},
[handleSeek, isAudioEnabled]
);

return (
<View style={styles.container} pointerEvents="box-none">
{isDownloading && !isDownloaded && <DownloadBadge />}
Expand Down Expand Up @@ -343,29 +412,29 @@ const AudioControlBar = ({
</View>

<View style={styles.playbackControls}>
<Pressable style={styles.playButton} onPress={handlePlayPause}>
{isPlaying ? (
<PauseIcon size={30} color={theme.colors.audioTitleText} />
) : (
<PlayIcon size={30} color={theme.colors.audioTitleText} />
)}
</Pressable>

{isSeekLoading || isBufferingOrLoading || !currentPlaying?.id ? (
<ActivityIndicator size="small" color={theme.colors.primary} />
) : (
<Pressable style={styles.playButton} onPress={handlePlayPause}>
{isPlaying ? (
<PauseIcon size={30} color={theme.colors.audioTitleText} />
) : (
<PlayIcon size={30} color={theme.colors.audioTitleText} />
)}
</Pressable>
)}
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
{isSeekLoading && (
<View style={styles.seekLoadingOverlay} testID="seek-loading-indicator">
<ActivityIndicator size="small" color={theme.colors.primary} />
</View>
)}
<CustomText style={[styles.timestamp, styles.timestampWithColor]}>
{formatTime(progress.position)}
{formatTime(sliderValue)}
</CustomText>
<Slider
value={progress.position}
value={sliderValue}
minimumValue={0}
maximumValue={progress.duration}
onSlidingComplete={([v]) => handleSeek(v)}
onSlidingStart={handleSlidingStart}
onValueChange={handleSliderValueChange}
onSlidingComplete={([v]) => handleSeekComplete(v)}
minimumTrackTintColor={sliderMinTrackColor}
maximumTrackTintColor={theme.staticColors.SLIDER_TRACK_COLOR}
disabled={!isAudioEnabled || isSeekLoading}
Expand Down Expand Up @@ -427,6 +496,7 @@ AudioControlBar.propTypes = {
isInitialized: PropTypes.bool.isRequired,
addAndPlayTrack: PropTypes.func.isRequired,
play: PropTypes.func.isRequired,
isBufferingOrLoading: PropTypes.bool.isRequired,
};

export default AudioControlBar;
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@
const slider = getByTestId("slider");
expect(slider.props.disabled).toBe(true);
},
{ timeout: 100 }
{ timeout: 100 },

Check failure on line 378 in src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx

View workflow job for this annotation

GitHub Actions / lint-and-test

Delete `,`
);

// Resolve the seek promise to complete the loading
Expand All @@ -387,7 +387,7 @@
const slider = getByTestId("slider");
expect(slider.props.disabled).toBe(false);
},
{ timeout: 500 }
{ timeout: 500 },

Check failure on line 390 in src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx

View workflow job for this annotation

GitHub Actions / lint-and-test

Delete `,`
);
});

Expand Down Expand Up @@ -435,7 +435,7 @@
await waitFor(() => {
expect(mockGetSequenceFromPosition).toHaveBeenCalledWith(
defaultCurrentTrack.lyricsUrl,
defaultProgress.position
defaultProgress.position,

Check failure on line 438 in src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx

View workflow job for this annotation

GitHub Actions / lint-and-test

Delete `,`
);
expect(mockSetAudioProgress).toHaveBeenCalledWith("bani-1", "track-1", 10, null);
expect(props.reset).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -478,7 +478,7 @@
defaultCurrentTrack.trackLengthSec,
defaultCurrentTrack.trackSizeMB,
false,
defaultCurrentTrack.remoteUrl || defaultCurrentTrack.audioUrl
defaultCurrentTrack.remoteUrl || defaultCurrentTrack.audioUrl,

Check failure on line 481 in src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx

View workflow job for this annotation

GitHub Actions / lint-and-test

Delete `,`
);
});

Expand Down Expand Up @@ -539,17 +539,17 @@
() => {
expect(mockCheckLyricsFileAvailable).toHaveBeenCalledWith(defaultCurrentTrack.lyricsUrl);
},
{ timeout: 1000 }
{ timeout: 1000 },

Check failure on line 542 in src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx

View workflow job for this annotation

GitHub Actions / lint-and-test

Delete `,`
);

await waitFor(
() => {
expect(mockToggleAudioSyncScroll).toHaveBeenCalledWith(true);
expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: "TOGGLE_AUDIO_SYNC_SCROLL" })
expect.objectContaining({ type: "TOGGLE_AUDIO_SYNC_SCROLL" }),

Check failure on line 549 in src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx

View workflow job for this annotation

GitHub Actions / lint-and-test

Delete `,`
);
},
{ timeout: 1000 }
{ timeout: 1000 },

Check failure on line 552 in src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx

View workflow job for this annotation

GitHub Actions / lint-and-test

Delete `,`
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const useAudioSyncScroll = (progress, isPlaying, webViewRef, lyricsUrl) => {
const lastSequenceRef = useRef(null);
const isScrollingRef = useRef(false);
const scrollTimeoutRef = useRef(null);
const previousPositionRef = useRef(null);
const [baniLRC, setBaniLRC] = useState(null);

// Load LRC data when audioUrl changes
Expand Down Expand Up @@ -47,6 +48,16 @@ const useAudioSyncScroll = (progress, isPlaying, webViewRef, lyricsUrl) => {
};
};

// Reset scroll state (used when seeking)
const resetScrollState = () => {
lastSequenceRef.current = null;
isScrollingRef.current = false;
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = null;
}
};

// Scroll to specific sequence in WebView
const scrollToSequence = (sequence, timeOut) => {
if (!webViewRef?.current?.postMessage || !sequence) {
Expand All @@ -66,9 +77,8 @@ const useAudioSyncScroll = (progress, isPlaying, webViewRef, lyricsUrl) => {
clearTimeout(scrollTimeoutRef.current);
}

// Prevent multiple rapid scroll calls
if (isScrollingRef.current) return;
isScrollingRef.current = true;
// Reset scrolling flag to allow new scrolls (important for rapid seeks)
isScrollingRef.current = false;

const scrollMessage = {
action: "scrollToSequence",
Expand All @@ -80,7 +90,8 @@ const useAudioSyncScroll = (progress, isPlaying, webViewRef, lyricsUrl) => {

webViewRef.current.postMessage(JSON.stringify(scrollMessage));

// Reset scrolling flag after a short delay
// Set scrolling flag and reset after a short delay
isScrollingRef.current = true;
scrollTimeoutRef.current = setTimeout(() => {
isScrollingRef.current = false;
scrollTimeoutRef.current = null;
Expand All @@ -100,7 +111,24 @@ const useAudioSyncScroll = (progress, isPlaying, webViewRef, lyricsUrl) => {
if (!isAudioSyncScroll || !isPlaying || !progress?.position || !baniLRC) {
return;
}
const { currentSequence, timeOut } = findCurrentSequence(progress.position);

const currentPosition = progress.position;
const previousPosition = previousPositionRef.current;

// Detect seek: if position jumps more than 2 seconds, it's likely a seek
// Normal playback won't jump more than ~1 second per update
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't get this logic. What do we mean by a normal update here?

if (previousPosition !== null) {
const positionDiff = Math.abs(currentPosition - previousPosition);
if (positionDiff > 2) {
// Reset scroll state when seek is detected
resetScrollState();
}
}

// Update previous position
previousPositionRef.current = currentPosition;

const { currentSequence, timeOut } = findCurrentSequence(currentPosition);

// Only scroll if sequence changed and we have a valid sequence
if (currentSequence !== null && currentSequence !== lastSequenceRef.current) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const useDownloadManager = (currentPlaying, addTrackToManifest, isTrackDownloade
try {
const downloaded = checkDownloadStatus();
if (downloaded) {
setIsDownloaded(true);
return;
}

Expand All @@ -49,6 +50,7 @@ const useDownloadManager = (currentPlaying, addTrackToManifest, isTrackDownloade
const isDownloadedStatus = checkDownloadStatus();

if (currentPlaying?.audioUrl && !isDownloadedStatus) {
setIsDownloaded(false);
await handleDownload();
} else {
setIsDownloaded(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const useTrackPlayer = () => {
const playbackState = usePlaybackState();
const progress = useProgress();
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const isAudio = useSelector((state) => state.isAudio);

const configurePlayer = useCallback(async () => {
Expand Down Expand Up @@ -72,6 +74,8 @@ const useTrackPlayer = () => {
useEffect(() => {
if (!isInitialized) return;
setIsPlaying(playbackState?.state === State.Playing);
setIsBuffering(playbackState?.state === State.Buffering);
setIsLoading(playbackState?.state === State.Loading);
}, [playbackState, isInitialized]);

const play = async () => {
Expand Down Expand Up @@ -204,6 +208,7 @@ const useTrackPlayer = () => {
isAudioEnabled: isAudio && isInitialized,
isInitialized,
setIsPlaying,
isBufferingOrLoading: isBuffering || isLoading,
isInitializing,
initializationError,
retryInitialization,
Expand Down
Loading
Loading