diff --git a/src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.jsx b/src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.jsx
index c90ecfd4..e7e7c873 100644
--- a/src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.jsx
+++ b/src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.jsx
@@ -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";
@@ -54,6 +54,7 @@ const AudioControlBar = ({
isInitialized,
addAndPlayTrack,
play,
+ isBufferingOrLoading,
}) => {
const dispatch = useDispatch();
const { theme } = useTheme();
@@ -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,
@@ -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;
@@ -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)
@@ -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(() => {
@@ -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 (
{isDownloading && !isDownloaded && }
@@ -343,29 +412,29 @@ const AudioControlBar = ({
-
- {isPlaying ? (
-
- ) : (
-
- )}
-
-
+ {isSeekLoading || isBufferingOrLoading || !currentPlaying?.id ? (
+
+ ) : (
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+ )}
- {isSeekLoading && (
-
-
-
- )}
- {formatTime(progress.position)}
+ {formatTime(sliderValue)}
handleSeek(v)}
+ onSlidingStart={handleSlidingStart}
+ onValueChange={handleSliderValueChange}
+ onSlidingComplete={([v]) => handleSeekComplete(v)}
minimumTrackTintColor={sliderMinTrackColor}
maximumTrackTintColor={theme.staticColors.SLIDER_TRACK_COLOR}
disabled={!isAudioEnabled || isSeekLoading}
@@ -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;
diff --git a/src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx b/src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx
index 959fea8f..21e23209 100644
--- a/src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx
+++ b/src/ReaderScreen/components/AudioPlayer/components/AudioControlBar/index.test.jsx
@@ -375,7 +375,7 @@ describe("AudioControlBar", () => {
const slider = getByTestId("slider");
expect(slider.props.disabled).toBe(true);
},
- { timeout: 100 }
+ { timeout: 100 },
);
// Resolve the seek promise to complete the loading
@@ -387,7 +387,7 @@ describe("AudioControlBar", () => {
const slider = getByTestId("slider");
expect(slider.props.disabled).toBe(false);
},
- { timeout: 500 }
+ { timeout: 500 },
);
});
@@ -435,7 +435,7 @@ describe("AudioControlBar", () => {
await waitFor(() => {
expect(mockGetSequenceFromPosition).toHaveBeenCalledWith(
defaultCurrentTrack.lyricsUrl,
- defaultProgress.position
+ defaultProgress.position,
);
expect(mockSetAudioProgress).toHaveBeenCalledWith("bani-1", "track-1", 10, null);
expect(props.reset).toHaveBeenCalledTimes(1);
@@ -478,7 +478,7 @@ describe("AudioControlBar", () => {
defaultCurrentTrack.trackLengthSec,
defaultCurrentTrack.trackSizeMB,
false,
- defaultCurrentTrack.remoteUrl || defaultCurrentTrack.audioUrl
+ defaultCurrentTrack.remoteUrl || defaultCurrentTrack.audioUrl,
);
});
@@ -539,17 +539,17 @@ describe("AudioControlBar", () => {
() => {
expect(mockCheckLyricsFileAvailable).toHaveBeenCalledWith(defaultCurrentTrack.lyricsUrl);
},
- { timeout: 1000 }
+ { timeout: 1000 },
);
await waitFor(
() => {
expect(mockToggleAudioSyncScroll).toHaveBeenCalledWith(true);
expect(mockDispatch).toHaveBeenCalledWith(
- expect.objectContaining({ type: "TOGGLE_AUDIO_SYNC_SCROLL" })
+ expect.objectContaining({ type: "TOGGLE_AUDIO_SYNC_SCROLL" }),
);
},
- { timeout: 1000 }
+ { timeout: 1000 },
);
});
diff --git a/src/ReaderScreen/components/AudioPlayer/hooks/useAudioSyncScroll/index.js b/src/ReaderScreen/components/AudioPlayer/hooks/useAudioSyncScroll/index.js
index 2e94a59a..7ccf9e8e 100644
--- a/src/ReaderScreen/components/AudioPlayer/hooks/useAudioSyncScroll/index.js
+++ b/src/ReaderScreen/components/AudioPlayer/hooks/useAudioSyncScroll/index.js
@@ -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
@@ -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) {
@@ -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",
@@ -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;
@@ -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
+ 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) {
diff --git a/src/ReaderScreen/components/AudioPlayer/hooks/useDownloadManager/index.js b/src/ReaderScreen/components/AudioPlayer/hooks/useDownloadManager/index.js
index 99f09008..a7373407 100644
--- a/src/ReaderScreen/components/AudioPlayer/hooks/useDownloadManager/index.js
+++ b/src/ReaderScreen/components/AudioPlayer/hooks/useDownloadManager/index.js
@@ -27,6 +27,7 @@ const useDownloadManager = (currentPlaying, addTrackToManifest, isTrackDownloade
try {
const downloaded = checkDownloadStatus();
if (downloaded) {
+ setIsDownloaded(true);
return;
}
@@ -49,6 +50,7 @@ const useDownloadManager = (currentPlaying, addTrackToManifest, isTrackDownloade
const isDownloadedStatus = checkDownloadStatus();
if (currentPlaying?.audioUrl && !isDownloadedStatus) {
+ setIsDownloaded(false);
await handleDownload();
} else {
setIsDownloaded(true);
diff --git a/src/ReaderScreen/components/AudioPlayer/hooks/useTrackPlayer/index.js b/src/ReaderScreen/components/AudioPlayer/hooks/useTrackPlayer/index.js
index 86dd3aa6..bd5516c7 100644
--- a/src/ReaderScreen/components/AudioPlayer/hooks/useTrackPlayer/index.js
+++ b/src/ReaderScreen/components/AudioPlayer/hooks/useTrackPlayer/index.js
@@ -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 () => {
@@ -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 () => {
@@ -204,6 +208,7 @@ const useTrackPlayer = () => {
isAudioEnabled: isAudio && isInitialized,
isInitialized,
setIsPlaying,
+ isBufferingOrLoading: isBuffering || isLoading,
isInitializing,
initializationError,
retryInitialization,
diff --git a/src/ReaderScreen/components/AudioPlayer/index.jsx b/src/ReaderScreen/components/AudioPlayer/index.jsx
index 00c36193..74d87bc6 100644
--- a/src/ReaderScreen/components/AudioPlayer/index.jsx
+++ b/src/ReaderScreen/components/AudioPlayer/index.jsx
@@ -116,15 +116,18 @@ const AudioPlayer = ({ baniID, title, webViewRef }) => {
}
}, [currentPlaying, defaultAudio, baniID]);
- const handleSeek = async (value) => {
- if (!isAudioEnabled || !isInitialized) return;
- try {
- await seekTo(value);
- } catch (error) {
- logError("Error seeking:", error);
- showErrorToast(`${STRINGS.UNABLE_TO_SEEK} ${STRINGS.PLEASE_TRY_AGAIN}`);
- }
- };
+ const handleSeek = useCallback(
+ async (value) => {
+ if (!isAudioEnabled || !isInitialized) return;
+ try {
+ await seekTo(value);
+ } catch (error) {
+ logError("Error seeking:", error);
+ showErrorToast(`${STRINGS.UNABLE_TO_SEEK} ${STRINGS.PLEASE_TRY_AGAIN}`);
+ }
+ },
+ [isAudioEnabled, isInitialized, seekTo]
+ );
const handleTrackSelect = useCallback(
async (selectedTrack) => {
diff --git a/src/services/audioApi.js b/src/services/audioApi.js
index 9e23d7db..1108bbf7 100644
--- a/src/services/audioApi.js
+++ b/src/services/audioApi.js
@@ -34,7 +34,6 @@ const makeApiRequest = async (endpoint, options = {}) => {
return data;
} catch (error) {
// Network error - show toast and continue without audio features
- showErrorToast(STRINGS.NETWORK_ERROR);
return null;
}
};