From 1adf5ba5e41486f32f9eba3ad46347ea8016824a Mon Sep 17 00:00:00 2001 From: leon-morival Date: Mon, 3 Nov 2025 15:40:20 +0100 Subject: [PATCH] feat: next music from playlist --- src/providers/PlayerProvider.js | 203 ++++++++++++++++++++++- src/screens/Library/AllMyPlaylist.web.js | 79 ++++++++- src/screens/Library/PlaylistDetails.js | 79 ++++++++- 3 files changed, 343 insertions(+), 18 deletions(-) diff --git a/src/providers/PlayerProvider.js b/src/providers/PlayerProvider.js index b12482d..e25231c 100644 --- a/src/providers/PlayerProvider.js +++ b/src/providers/PlayerProvider.js @@ -64,6 +64,8 @@ const noop = () => {}; const DEFAULT_CONTEXT = { currentTrack: null, queue: [], + queueInfo: null, + queueIndex: -1, status: "idle", isPlaying: false, isBuffering: false, @@ -209,7 +211,12 @@ const normalizeTrack = (trackInput = {}, options = {}) => { const PlayerProvider = ({ children }) => { const [activeRouteName] = useGlobal("activeRouteName"); const [currentTrack, setCurrentTrack] = useState(null); - const [queue, setQueue] = useState([]); + const queueRef = useRef([]); + const queueInfoRef = useRef({ id: null, type: null, name: null }); + const queueIndexRef = useRef(-1); + const [queue, setQueueState] = useState([]); + const [queueInfo, setQueueInfoState] = useState(queueInfoRef.current); + const [queueIndex, setQueueIndexState] = useState(queueIndexRef.current); const [playback, setPlayback] = useState({ status: "idle", isPlaying: false, @@ -222,6 +229,127 @@ const PlayerProvider = ({ children }) => { const autoPlayRef = useRef(false); const pendingSeekValueRef = useRef(null); + const didJustFinishRef = useRef(false); + + const setQueueIndexValue = useCallback((index = -1) => { + const list = queueRef.current; + const hasItems = Array.isArray(list) && list.length > 0; + let target = Number.isInteger(index) ? index : queueIndexRef.current; + if (!hasItems) { + target = -1; + } else { + target = Math.min(Math.max(target, -1), list.length - 1); + } + if (queueIndexRef.current === target) return; + queueIndexRef.current = target; + setQueueIndexState(target); + }, []); + + const setQueueInfoValue = useCallback((info) => { + const next = { + id: info?.id ?? null, + type: info?.type ?? null, + name: info?.name ?? null, + }; + const prev = queueInfoRef.current; + if ( + prev.id === next.id && + prev.type === next.type && + prev.name === next.name + ) { + return; + } + queueInfoRef.current = next; + setQueueInfoState(next); + }, []); + + const updateQueue = useCallback( + (items = [], options = {}) => { + const normalized = Array.isArray(items) + ? items + .map((item) => { + if (!item) return null; + if (typeof item === "object") { + const { metadata, context, ...rest } = item; + const safeMetadata = + metadata && typeof metadata === "object" + ? { ...metadata } + : undefined; + if (safeMetadata) { + delete safeMetadata.queue; + } + const safeContext = + context && typeof context === "object" + ? { ...context } + : undefined; + if (safeContext) { + delete safeContext.queue; + } + return normalizeTrack( + { + ...rest, + ...(safeMetadata ? { metadata: safeMetadata } : {}), + ...(safeContext ? { context: safeContext } : {}), + }, + {} + ); + } + return normalizeTrack(item, {}); + }) + .filter((track) => !!track?.source) + : []; + + queueRef.current = normalized; + setQueueState(normalized); + + const info = { + id: options.id ?? options.queueId ?? null, + type: options.type ?? options.queueType ?? null, + name: options.name ?? options.queueName ?? null, + }; + setQueueInfoValue(info); + + const currentTrackId = + options.currentTrackId && typeof options.currentTrackId === "string" + ? options.currentTrackId + : null; + const indexCandidate = + typeof options.index === "number" + ? options.index + : typeof options.queueIndex === "number" + ? options.queueIndex + : currentTrackId + ? normalized.findIndex((track) => track.id === currentTrackId) + : queueIndexRef.current; + setQueueIndexValue(indexCandidate); + + return normalized; + }, + [setQueueIndexValue, setQueueInfoValue] + ); + + const setQueue = useCallback( + (itemsOrConfig, maybeOptions = {}) => { + if (typeof itemsOrConfig === "function") { + const result = itemsOrConfig([...(queueRef.current || [])]); + if (Array.isArray(result)) { + updateQueue(result, maybeOptions); + } + return; + } + if (Array.isArray(itemsOrConfig)) { + updateQueue(itemsOrConfig, maybeOptions); + return; + } + if (itemsOrConfig && typeof itemsOrConfig === "object") { + const { items = [], ...rest } = itemsOrConfig; + updateQueue(items, { ...rest, ...maybeOptions }); + return; + } + updateQueue([], {}); + }, + [updateQueue] + ); const source = useMemo(() => { if (!currentTrack?.source) return null; @@ -328,6 +456,19 @@ const PlayerProvider = ({ children }) => { setError(null); + if (Array.isArray(options.queue)) { + updateQueue(options.queue, { + id: options.queueId, + type: options.queueType, + name: options.queueName, + queueIndex: + typeof options.queueIndex === "number" + ? options.queueIndex + : undefined, + currentTrackId: normalized.id, + }); + } + if (sameTrack) { setCurrentTrack((prev) => ({ ...prev, ...normalized })); try { @@ -371,7 +512,7 @@ const PlayerProvider = ({ children }) => { })); setCurrentTrack(normalized); }, - [currentTrack?.id, player, toPlayerSeekValue] + [currentTrack?.id, player, toPlayerSeekValue, updateQueue] ); const resume = useCallback(async () => { @@ -474,10 +615,65 @@ const PlayerProvider = ({ children }) => { setIsLooping((prev) => !prev); }, []); + useEffect(() => { + if (!currentTrack?.id) { + if (queueIndexRef.current !== -1) { + setQueueIndexValue(-1); + } + return; + } + const idx = queueRef.current.findIndex((item) => item.id === currentTrack.id); + if (idx !== queueIndexRef.current) { + setQueueIndexValue(idx); + } + }, [currentTrack?.id, setQueueIndexValue]); + + const handleTrackDidFinish = useCallback(() => { + if (isLooping) return; + const items = queueRef.current; + if (!Array.isArray(items) || items.length === 0) return; + + let idx = queueIndexRef.current; + const currentId = currentTrack?.id ?? null; + if ((idx == null || idx < 0) && currentId) { + idx = items.findIndex((item) => item.id === currentId); + } + if (idx == null || idx < 0) return; + const nextIdx = idx + 1; + if (nextIdx >= items.length) return; + + const nextTrack = items[nextIdx]; + if (!nextTrack) return; + + play(nextTrack, { + autoPlay: true, + startPositionMs: 0, + queueId: queueInfoRef.current.id, + queueType: queueInfoRef.current.type, + queueName: queueInfoRef.current.name, + }).catch((err) => { + setError(err); + }); + }, [currentTrack?.id, isLooping, play]); + + useEffect(() => { + const finished = !!status?.didJustFinish; + if (finished) { + if (!didJustFinishRef.current) { + didJustFinishRef.current = true; + handleTrackDidFinish(); + } + } else { + didJustFinishRef.current = false; + } + }, [status?.didJustFinish, handleTrackDidFinish]); + const contextValue = useMemo( () => ({ currentTrack, queue, + queueInfo, + queueIndex, status: playback.status, isPlaying: playback.isPlaying, isBuffering: playback.isBuffering, @@ -508,6 +704,8 @@ const PlayerProvider = ({ children }) => { playback.positionMs, playback.status, queue, + queueInfo, + queueIndex, resume, seekBy, seekTo, @@ -516,6 +714,7 @@ const PlayerProvider = ({ children }) => { isLooping, setLooping, toggleLooping, + setQueue, ] ); diff --git a/src/screens/Library/AllMyPlaylist.web.js b/src/screens/Library/AllMyPlaylist.web.js index 63cb030..6a9ee2f 100644 --- a/src/screens/Library/AllMyPlaylist.web.js +++ b/src/screens/Library/AllMyPlaylist.web.js @@ -1,5 +1,5 @@ import { BlurView } from "expo-blur"; -import React, { useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Image, Pressable, Text, View } from "react-native"; import { SheetManager } from "react-native-actions-sheet"; import { useGlobal } from "reactn"; @@ -9,6 +9,7 @@ import { playlistsRef, projectsRef } from "../../config/firebase"; import useDataFromArrayDocId from "../../hooks/useDataFromArrayId"; import useDataFromRef from "../../hooks/useDataFromRef"; import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails"; +import usePlayer from "../../hooks/usePlayer"; import Page from "../../layouts/Page"; import { useUser } from "../../providers/UserDataProvider"; import { gutters, Palette, Style } from "../../styles"; @@ -42,6 +43,74 @@ const AllMyPlaylist = ({ route }) => { const [showMenu, setShowMenu] = useState(false); const [selectedProjectId, setSelectedProjectId] = useState(null); const navigateToMusicDetails = useNavigateToMusicDetails(); + const { setQueue } = usePlayer() || {}; + + const playlistQueueItems = useMemo(() => { + if (!Array.isArray(musics)) return []; + return musics + .map((project) => { + const projectId = + typeof project?.id === "string" ? project.id : project?.projectId ?? null; + const songUrl = + typeof project?.songUrl === "string" && project.songUrl.length > 0 + ? project.songUrl + : null; + if (!songUrl) return null; + const descriptorId = projectId ? `project-${projectId}` : songUrl; + return { + id: descriptorId, + uri: songUrl, + songUrl, + title: project?.title || "Sans titre", + artist: project?.userName || "", + artwork: project?.coverUrl ?? null, + coverUrl: project?.coverUrl ?? null, + metadata: { + projectId, + playlistId: selectedPlaylistId ?? null, + playlistName: playlist?.name ?? null, + }, + context: { + projectId, + playlistId: selectedPlaylistId ?? null, + playlistName: playlist?.name ?? null, + }, + }; + }) + .filter(Boolean); + }, [musics, playlist?.name, selectedPlaylistId]); + + const handlePlayFromPlaylist = useCallback( + (project) => { + if (!project?.id) return; + if (Array.isArray(playlistQueueItems) && playlistQueueItems.length > 0) { + const queueIndex = playlistQueueItems.findIndex( + (item) => item?.metadata?.projectId === project.id + ); + if (queueIndex >= 0 && typeof setQueue === "function") { + setQueue({ + items: playlistQueueItems, + id: selectedPlaylistId, + type: "playlist", + name: playlist?.name ?? null, + index: queueIndex, + }); + } + } + navigateToMusicDetails({ + projectId: project.id, + songUrl: project?.songUrl, + project, + }); + }, + [ + navigateToMusicDetails, + playlist?.name, + playlistQueueItems, + selectedPlaylistId, + setQueue, + ] + ); // delete playlist const confirmDelete = () => { @@ -209,13 +278,7 @@ const AllMyPlaylist = ({ route }) => { imageUri={p?.coverUrl || null} projectId={p?.id} likedBy={p?.likedBy || []} - onPress={() => - navigateToMusicDetails({ - projectId: p.id, - songUrl: p?.songUrl, - project: p, - }) - } + onPress={() => handlePlayFromPlaylist(p)} onPressMore={(posTop) => { setSelectedProjectId(p.id); setMenuPosition(posTop); diff --git a/src/screens/Library/PlaylistDetails.js b/src/screens/Library/PlaylistDetails.js index 5bd4a28..29a1a5d 100644 --- a/src/screens/Library/PlaylistDetails.js +++ b/src/screens/Library/PlaylistDetails.js @@ -1,5 +1,5 @@ import { useRoute } from "@react-navigation/native"; -import React, { useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { Image, Pressable, View } from "react-native"; import { SheetManager } from "react-native-actions-sheet"; import { useDataFromRef } from "react-native-minuit/src/hooks"; @@ -9,6 +9,7 @@ import { background, icons } from "../../assets"; import MoreMenu from "../../components/MoreMenu"; import { playlistsRef, projectsRef } from "../../config/firebase"; import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails"; +import usePlayer from "../../hooks/usePlayer"; import Page from "../../layouts/Page"; import { goBack } from "../../navigation/NavigationService"; import { gutters } from "../../styles"; @@ -40,6 +41,74 @@ const PlaylistDetails = () => { const [showMenu, setShowMenu] = useState(false); const [selectedProjectId, setSelectedProjectId] = useState(null); const navigateToMusicDetails = useNavigateToMusicDetails(); + const { setQueue } = usePlayer() || {}; + + const playlistQueueItems = useMemo(() => { + if (!Array.isArray(musics)) return []; + return musics + .map((project) => { + const projectId = + typeof project?.id === "string" ? project.id : project?.projectId ?? null; + const songUrl = + typeof project?.songUrl === "string" && project.songUrl.length > 0 + ? project.songUrl + : null; + if (!songUrl) return null; + const descriptorId = projectId ? `project-${projectId}` : songUrl; + return { + id: descriptorId, + uri: songUrl, + songUrl, + title: project?.title || "Sans titre", + artist: project?.userName || "", + artwork: project?.coverUrl ?? null, + coverUrl: project?.coverUrl ?? null, + metadata: { + projectId, + playlistId: playlistId ?? null, + playlistName: playlist?.name ?? null, + }, + context: { + projectId, + playlistId: playlistId ?? null, + playlistName: playlist?.name ?? null, + }, + }; + }) + .filter(Boolean); + }, [musics, playlist?.name, playlistId]); + + const handlePlayFromPlaylist = useCallback( + (project) => { + if (!project?.id) return; + if (Array.isArray(playlistQueueItems) && playlistQueueItems.length > 0) { + const queueIndex = playlistQueueItems.findIndex( + (item) => item?.metadata?.projectId === project.id + ); + if (queueIndex >= 0 && typeof setQueue === "function") { + setQueue({ + items: playlistQueueItems, + id: playlistId, + type: "playlist", + name: playlist?.name ?? null, + index: queueIndex, + }); + } + } + navigateToMusicDetails({ + projectId: project.id, + songUrl: project?.songUrl, + project, + }); + }, + [ + navigateToMusicDetails, + playlist?.name, + playlistId, + playlistQueueItems, + setQueue, + ] + ); const confirmDelete = () => { SheetManager.show("Delete", { @@ -88,13 +157,7 @@ const PlaylistDetails = () => { imageUri={p?.coverUrl || null} projectId={p?.id} likedBy={p?.likedBy || []} - onPress={() => - navigateToMusicDetails({ - projectId: p.id, - songUrl: p?.songUrl, - project: p, - }) - } + onPress={() => handlePlayFromPlaylist(p)} onPressMore={(posTop) => { setSelectedProjectId(p.id); setMenuPosition(posTop);