slider web
This commit is contained in:
@@ -6,7 +6,6 @@ import { LinearGradient } from "./LinearGradient/LinearGradient";
|
|||||||
|
|
||||||
const INITIAL_BOX_SIZE = 6;
|
const INITIAL_BOX_SIZE = 6;
|
||||||
const HANDLE_SIZE = 20;
|
const HANDLE_SIZE = 20;
|
||||||
const HANDLE_TOP_OFFSET = (INITIAL_BOX_SIZE - HANDLE_SIZE) / 2;
|
|
||||||
|
|
||||||
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ const Slider = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [layoutWidth, setLayoutWidth] = useState(0);
|
const [layoutWidth, setLayoutWidth] = useState(0);
|
||||||
const [ratio, setRatio] = useState(
|
const [ratio, setRatio] = useState(
|
||||||
typeof progress === "number" ? clamp01(progress) : 0,
|
typeof progress === "number" ? clamp01(progress) : 0
|
||||||
);
|
);
|
||||||
const draggingRef = useRef(false);
|
const draggingRef = useRef(false);
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ const Slider = ({
|
|||||||
const nextRatio = clamp01(clampedX / available);
|
const nextRatio = clamp01(clampedX / available);
|
||||||
setRatio(nextRatio);
|
setRatio(nextRatio);
|
||||||
},
|
},
|
||||||
[layoutWidth],
|
[layoutWidth]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGrant = useCallback(
|
const handleGrant = useCallback(
|
||||||
@@ -55,7 +54,7 @@ const Slider = ({
|
|||||||
onSeekStart();
|
onSeekStart();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[seekEnabled, updateRatioFromX, onSeekStart],
|
[seekEnabled, updateRatioFromX, onSeekStart]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
@@ -63,7 +62,7 @@ const Slider = ({
|
|||||||
if (!seekEnabled || !draggingRef.current) return;
|
if (!seekEnabled || !draggingRef.current) return;
|
||||||
updateRatioFromX(event?.nativeEvent?.locationX || 0);
|
updateRatioFromX(event?.nativeEvent?.locationX || 0);
|
||||||
},
|
},
|
||||||
[seekEnabled, updateRatioFromX],
|
[seekEnabled, updateRatioFromX]
|
||||||
);
|
);
|
||||||
|
|
||||||
const finishSeeking = useCallback(() => {
|
const finishSeeking = useCallback(() => {
|
||||||
@@ -78,12 +77,15 @@ const Slider = ({
|
|||||||
}
|
}
|
||||||
}, [seekEnabled, ratio, onSeek, onSeekEnd]);
|
}, [seekEnabled, ratio, onSeek, onSeekEnd]);
|
||||||
|
|
||||||
const handleLayout = useCallback((event) => {
|
const handleLayout = useCallback(
|
||||||
const width = event?.nativeEvent?.layout?.width || 0;
|
(event) => {
|
||||||
if (width !== layoutWidth) {
|
const width = event?.nativeEvent?.layout?.width || 0;
|
||||||
setLayoutWidth(width);
|
if (width !== layoutWidth) {
|
||||||
}
|
setLayoutWidth(width);
|
||||||
}, [layoutWidth]);
|
}
|
||||||
|
},
|
||||||
|
[layoutWidth]
|
||||||
|
);
|
||||||
|
|
||||||
const maxOffset = Math.max(layoutWidth - INITIAL_BOX_SIZE, 0);
|
const maxOffset = Math.max(layoutWidth - INITIAL_BOX_SIZE, 0);
|
||||||
const offset = maxOffset * ratio;
|
const offset = maxOffset * ratio;
|
||||||
@@ -159,7 +161,7 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: "#f8f9ff",
|
backgroundColor: "#f8f9ff",
|
||||||
borderRadius: HANDLE_SIZE / 2,
|
borderRadius: HANDLE_SIZE / 2,
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: HANDLE_TOP_OFFSET,
|
// top: HANDLE_TOP_OFFSET,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
borderWidth: 4,
|
borderWidth: 4,
|
||||||
borderColor: "#9B4DFF",
|
borderColor: "#9B4DFF",
|
||||||
@@ -170,7 +172,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
shadowOpacity: 0.17,
|
shadowOpacity: 0.17,
|
||||||
shadowRadius: 3.05,
|
shadowRadius: 3.05,
|
||||||
elevation: 4,
|
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useAudioPlayer } from "expo-audio";
|
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { Image as ExpoImage } from "expo-image";
|
import { Image as ExpoImage } from "expo-image";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -31,6 +30,111 @@ import { Palette } from "../../styles";
|
|||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import Style, { gutters, size } from "../../styles/Style";
|
import Style, { gutters, size } from "../../styles/Style";
|
||||||
|
|
||||||
|
const useHtmlAudioPlayer = (source) => {
|
||||||
|
const src = typeof source === "string" ? source : source?.uri || null;
|
||||||
|
const audioRef = useRef(null);
|
||||||
|
const [state, setState] = useState({
|
||||||
|
durationMs: 0,
|
||||||
|
positionMs: 0,
|
||||||
|
isPlaying: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState({ durationMs: 0, positionMs: 0, isPlaying: false });
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.src = "";
|
||||||
|
audioRef.current.load();
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = new Audio(src);
|
||||||
|
audio.preload = "auto";
|
||||||
|
audio.crossOrigin = "anonymous";
|
||||||
|
audioRef.current = audio;
|
||||||
|
|
||||||
|
const syncDuration = () => {
|
||||||
|
const duration = Number.isFinite(audio.duration) ? audio.duration * 1000 : 0;
|
||||||
|
setState((prev) => ({ ...prev, durationMs: duration }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => syncDuration();
|
||||||
|
const handleDurationChange = () => syncDuration();
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
positionMs: audio.currentTime * 1000,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const handlePlay = () => setState((prev) => ({ ...prev, isPlaying: true }));
|
||||||
|
const handlePause = () => setState((prev) => ({ ...prev, isPlaying: false }));
|
||||||
|
const handleEnded = () =>
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
positionMs: Number.isFinite(audio.duration)
|
||||||
|
? audio.duration * 1000
|
||||||
|
: prev.positionMs,
|
||||||
|
isPlaying: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||||
|
audio.addEventListener("durationchange", handleDurationChange);
|
||||||
|
audio.addEventListener("timeupdate", handleTimeUpdate);
|
||||||
|
audio.addEventListener("play", handlePlay);
|
||||||
|
audio.addEventListener("pause", handlePause);
|
||||||
|
audio.addEventListener("ended", handleEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||||
|
audio.removeEventListener("durationchange", handleDurationChange);
|
||||||
|
audio.removeEventListener("timeupdate", handleTimeUpdate);
|
||||||
|
audio.removeEventListener("play", handlePlay);
|
||||||
|
audio.removeEventListener("pause", handlePause);
|
||||||
|
audio.removeEventListener("ended", handleEnded);
|
||||||
|
audio.src = "";
|
||||||
|
audio.load();
|
||||||
|
audioRef.current = null;
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const play = React.useCallback(async () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
try {
|
||||||
|
await audioRef.current.play();
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pause = React.useCallback(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
audioRef.current.pause();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seekTo = React.useCallback((seconds) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const next = Math.max(0, Number(seconds) || 0);
|
||||||
|
const duration = audioRef.current.duration;
|
||||||
|
const bounded = Number.isFinite(duration) ? Math.min(next, duration) : next;
|
||||||
|
audioRef.current.currentTime = bounded;
|
||||||
|
setState((prev) => ({ ...prev, positionMs: bounded * 1000 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seekTo,
|
||||||
|
playing: state.isPlaying,
|
||||||
|
positionMs: state.positionMs,
|
||||||
|
durationMs: state.durationMs,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 20 secondes
|
// 20 secondes
|
||||||
const timeBeforeIncrement = 20000;
|
const timeBeforeIncrement = 20000;
|
||||||
|
|
||||||
@@ -40,8 +144,6 @@ const MusicDetails = ({ route }) => {
|
|||||||
const projectId = params?.projectId || null;
|
const projectId = params?.projectId || null;
|
||||||
const [fav, setFav] = useState(false);
|
const [fav, setFav] = useState(false);
|
||||||
const [currentUID] = useGlobal("currentUID");
|
const [currentUID] = useGlobal("currentUID");
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
|
|
||||||
const wasPlayingBeforeSeek = React.useRef(false);
|
const wasPlayingBeforeSeek = React.useRef(false);
|
||||||
const listenedMsRef = React.useRef(0);
|
const listenedMsRef = React.useRef(0);
|
||||||
const incrementDoneRef = React.useRef(false);
|
const incrementDoneRef = React.useRef(false);
|
||||||
@@ -75,7 +177,14 @@ const MusicDetails = ({ route }) => {
|
|||||||
const coverUrl = project?.coverUrl || null;
|
const coverUrl = project?.coverUrl || null;
|
||||||
const songUrl = project?.songUrl || null;
|
const songUrl = project?.songUrl || null;
|
||||||
|
|
||||||
const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined);
|
const {
|
||||||
|
play: playAudio,
|
||||||
|
pause: pauseAudio,
|
||||||
|
seekTo: seekAudio,
|
||||||
|
playing: isPlaying,
|
||||||
|
positionMs,
|
||||||
|
durationMs,
|
||||||
|
} = useHtmlAudioPlayer(songUrl ? { uri: songUrl } : undefined);
|
||||||
|
|
||||||
// Karaoke aligned words (from timestamps)
|
// Karaoke aligned words (from timestamps)
|
||||||
const alignedWords = useMemo(() => {
|
const alignedWords = useMemo(() => {
|
||||||
@@ -99,17 +208,6 @@ const MusicDetails = ({ route }) => {
|
|||||||
incrementDoneRef.current = false;
|
incrementDoneRef.current = false;
|
||||||
}, [songUrl]);
|
}, [songUrl]);
|
||||||
|
|
||||||
// Poll player state to update progress and play state
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => {
|
|
||||||
const dur = (player?.duration || 0) * 1000;
|
|
||||||
const pos = (player?.currentTime || 0) * 1000;
|
|
||||||
setProgressInfo({ pos, dur });
|
|
||||||
setIsPlaying(!!player?.playing);
|
|
||||||
}, 300);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [player]);
|
|
||||||
|
|
||||||
// Start/stop a timer to accumulate listened milliseconds while playing
|
// Start/stop a timer to accumulate listened milliseconds while playing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const clearTimer = () => {
|
const clearTimer = () => {
|
||||||
@@ -154,14 +252,12 @@ const MusicDetails = ({ route }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
if (!player || !songUrl) return;
|
if (!songUrl) return;
|
||||||
try {
|
try {
|
||||||
if (player.playing) {
|
if (isPlaying) {
|
||||||
await player.pause?.();
|
await pauseAudio();
|
||||||
setIsPlaying(false);
|
|
||||||
} else {
|
} else {
|
||||||
await player.play?.();
|
await playAudio();
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails audio error", e?.message);
|
console.log("MusicDetails audio error", e?.message);
|
||||||
@@ -170,10 +266,10 @@ const MusicDetails = ({ route }) => {
|
|||||||
|
|
||||||
const onSeek = async (ratio) => {
|
const onSeek = async (ratio) => {
|
||||||
try {
|
try {
|
||||||
const dur = progressInfo.dur || 0;
|
const dur = durationMs || 0;
|
||||||
const pos = Math.floor(dur * ratio);
|
const pos = Math.floor(dur * ratio);
|
||||||
if (player && dur > 0) {
|
if (dur > 0) {
|
||||||
await player.seekTo?.(Math.floor((pos || 0) / 1000));
|
await seekAudio(Math.floor((pos || 0) / 1000));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails seek error", e?.message);
|
console.log("MusicDetails seek error", e?.message);
|
||||||
@@ -182,9 +278,9 @@ const MusicDetails = ({ route }) => {
|
|||||||
|
|
||||||
const seekBy = async (deltaSeconds) => {
|
const seekBy = async (deltaSeconds) => {
|
||||||
try {
|
try {
|
||||||
const cur = Math.floor((progressInfo.pos || 0) / 1000);
|
const cur = Math.floor((positionMs || 0) / 1000);
|
||||||
const next = Math.max(0, cur + deltaSeconds);
|
const next = Math.max(0, cur + deltaSeconds);
|
||||||
if (player) await player.seekTo?.(next);
|
await seekAudio(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("MusicDetails seekBy error", e?.message);
|
console.log("MusicDetails seekBy error", e?.message);
|
||||||
}
|
}
|
||||||
@@ -214,8 +310,8 @@ const MusicDetails = ({ route }) => {
|
|||||||
|
|
||||||
// Current time in seconds for highlighting
|
// Current time in seconds for highlighting
|
||||||
const currentTimeS = useMemo(
|
const currentTimeS = useMemo(
|
||||||
() => Math.max(0, (progressInfo.pos || 0) / 1000),
|
() => Math.max(0, (positionMs || 0) / 1000),
|
||||||
[progressInfo.pos]
|
[positionMs]
|
||||||
);
|
);
|
||||||
// Preview lead: show words 0.5s earlier
|
// Preview lead: show words 0.5s earlier
|
||||||
const visibleTimeS = useMemo(
|
const visibleTimeS = useMemo(
|
||||||
@@ -422,20 +518,15 @@ const MusicDetails = ({ route }) => {
|
|||||||
{songUrl && (
|
{songUrl && (
|
||||||
<View style={{ paddingTop: 22 }}>
|
<View style={{ paddingTop: 22 }}>
|
||||||
<Slider
|
<Slider
|
||||||
value={fmt(progressInfo.pos)}
|
value={fmt(positionMs)}
|
||||||
maxValue={fmt(progressInfo.dur)}
|
maxValue={fmt(durationMs)}
|
||||||
progress={
|
progress={durationMs ? (positionMs || 0) / durationMs : 0}
|
||||||
progressInfo.dur
|
|
||||||
? (progressInfo.pos || 0) / progressInfo.dur
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
seekEnabled={!!songUrl}
|
seekEnabled={!!songUrl}
|
||||||
onSeekStart={async () => {
|
onSeekStart={async () => {
|
||||||
try {
|
try {
|
||||||
wasPlayingBeforeSeek.current = !!player?.playing;
|
wasPlayingBeforeSeek.current = !!isPlaying;
|
||||||
if (player?.playing) {
|
if (isPlaying) {
|
||||||
await player.pause?.();
|
await pauseAudio();
|
||||||
setIsPlaying(false);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Pause on seek start error", e?.message);
|
console.log("Pause on seek start error", e?.message);
|
||||||
@@ -444,9 +535,8 @@ const MusicDetails = ({ route }) => {
|
|||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
onSeekEnd={async () => {
|
onSeekEnd={async () => {
|
||||||
try {
|
try {
|
||||||
if (player && wasPlayingBeforeSeek.current) {
|
if (wasPlayingBeforeSeek.current) {
|
||||||
await player.play?.();
|
await playAudio();
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
}
|
||||||
wasPlayingBeforeSeek.current = false;
|
wasPlayingBeforeSeek.current = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user