From 04c282186657e71b09474d2906089bc4a2048304 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:31:52 +0000 Subject: [PATCH] feat: add playlist split feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "Split" mode that allows users to distribute songs from source playlists into multiple target playlists. The feature includes: - Mode selection screen (Compose vs Split) on the home page - Source playlist picker (reuses existing PlaylistPicker) - Target playlist picker with minimum 2 playlists required - Song-by-song assignment UI with audio preview, showing target playlist buttons for each song with a skip option - Finish screen with progress tracking and cross-service song matching The source playlists remain unchanged. Songs are added to existing target playlists rather than creating new ones. Closes #46 Co-authored-by: Moritz Gößl --- src/app/page.tsx | 11 + src/components/ModeSelection.tsx | 61 +++++ src/components/splitter/SongAssigner.tsx | 170 +++++++++++++ src/components/splitter/SplitFinishScreen.tsx | 235 ++++++++++++++++++ src/components/splitter/Splitter.tsx | 37 +++ .../splitter/TargetPlaylistPicker.tsx | 137 ++++++++++ 6 files changed, 651 insertions(+) create mode 100644 src/components/ModeSelection.tsx create mode 100644 src/components/splitter/SongAssigner.tsx create mode 100644 src/components/splitter/SplitFinishScreen.tsx create mode 100644 src/components/splitter/Splitter.tsx create mode 100644 src/components/splitter/TargetPlaylistPicker.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index cf034ce..fd8a81c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,15 @@ "use client" import { useState, useEffect } from "react" import Composer from "@/components/composer/Composer" +import Splitter from "@/components/splitter/Splitter" import Authorize from "@/components/authorization/Authorize" import { getAccessToken } from "@/spotify/authorization" import { isAppleMusicAuthorized, initMusicKit } from "@/apple/music" +import ModeSelection from "@/components/ModeSelection" export default function HomePage() { const [hasAuthorization, setHasAuthorization] = useState(null) + const [mode, setMode] = useState<"compose" | "split" | null>(null) useEffect(() => { const checkAuth = async () => { @@ -35,5 +38,13 @@ export default function HomePage() { return } + if (!mode) { + return + } + + if (mode === "split") { + return + } + return } diff --git a/src/components/ModeSelection.tsx b/src/components/ModeSelection.tsx new file mode 100644 index 0000000..24bed6e --- /dev/null +++ b/src/components/ModeSelection.tsx @@ -0,0 +1,61 @@ +"use client" + +import React from "react" +import { motion } from "motion/react" +import { FaMusic, FaRandom } from "react-icons/fa" + +interface Props { + setMode: (mode: "compose" | "split") => void +} + +const ModeSelection: React.FC = ({ setMode }) => { + return ( + +

What would you like to do?

+

+ Choose a mode to get started with your playlists. +

+ +
+ setMode("compose")} + > +
+ +
+

Compose

+

+ Select songs from your playlists and create a new playlist from your favorites. +

+
+ + setMode("split")} + > +
+ +
+

Split

+

+ Go through songs from your playlists and assign each one to different target playlists. +

+
+
+
+ ) +} + +export default ModeSelection diff --git a/src/components/splitter/SongAssigner.tsx b/src/components/splitter/SongAssigner.tsx new file mode 100644 index 0000000..0d53789 --- /dev/null +++ b/src/components/splitter/SongAssigner.tsx @@ -0,0 +1,170 @@ +"use client" + +import React, { useCallback, useEffect, useState } from "react" +import { AnimatePresence, motion } from "motion/react" +import SongBackground from "@/components/composer/song/SongBackground" +import SongAudioPreview from "@/components/composer/song/audio/SongAudioPreview" +import SongAudioControls from "@/components/composer/song/audio/SongAudioControls" +import LoadingScreen from "@/components/composer/LoadingScreen" +import useAsync from "@/utils/useAsync" +import { GenericPlaylist, GenericSong } from "@/types/music" +import { collectSongs, SongLoadingState } from "@/utils/music" +import { SongAssignment } from "@/components/splitter/Splitter" +import { FaSpotify, FaApple, FaForward } from "react-icons/fa" + +interface Props { + sourcePlaylists: GenericPlaylist[] + targetPlaylists: GenericPlaylist[] + setAssignments: (assignments: SongAssignment) => void +} + +const SongAssigner: React.FC = ({ sourcePlaylists, targetPlaylists, setAssignments }) => { + const [loadingState, setLoadingState] = useState() + const loadSongs = useCallback(() => collectSongs(sourcePlaylists, setLoadingState), [sourcePlaylists]) + const { result: songs, state } = useAsync(loadSongs) + + const [index, setIndex] = useState(0) + const [assignments, setLocalAssignments] = useState(() => { + const initial: SongAssignment = {} + targetPlaylists.forEach(p => { + initial[p.id + ":" + p.provider.name] = [] + }) + return initial + }) + + const [targetVolume, setTargetVolume] = useState(readFromLocalStorage() ?? 0.15) + const setVolume = (value: number) => { + writeToLocalStorage(value) + setTargetVolume(value) + } + + useEffect(() => { + if (state === "done" && songs && index === songs.length) { + setAssignments(assignments) + } + }, [state, index, songs, setAssignments, assignments]) + + function assignTo(playlist: GenericPlaylist) { + if (!songs) return + const key = playlist.id + ":" + playlist.provider.name + setLocalAssignments(prev => ({ + ...prev, + [key]: [...(prev[key] || []), songs[index]] + })) + setIndex(prev => prev + 1) + } + + function skip() { + setIndex(prev => prev + 1) + } + + const currentSong = songs && songs[index] + const left = songs ? songs.length - index : 0 + + return ( +
+ + {currentSong && songs ? ( + + + + +
+
+

{left} songs left

+

{currentSong.name}

+
+ {currentSong.provider.name === "spotify" ? ( + + ) : ( + + )} +

{currentSong.artist}

+
+
+ +
+ {targetPlaylists.map(playlist => ( + assignTo(playlist)} + > + {playlist.artworkUrl ? ( + {playlist.name} + ) : ( +
+ )} +
+

{playlist.name}

+

+ {(assignments[playlist.id + ":" + playlist.provider.name] || []).length} songs assigned +

+
+ {playlist.provider.name === "spotify" ? ( + + ) : ( + + )} + + ))} + + + Skip + +
+
+ + + + ) : ( + + )} + +
+ ) +} + +function writeToLocalStorage(volume: number) { + localStorage.setItem("volume", String(volume)) +} + +function readFromLocalStorage(): number | null { + const item = localStorage.getItem("volume") + if (item) { + const number = parseFloat(item) + return isNaN(number) ? null : number + } else return null +} + +export default SongAssigner diff --git a/src/components/splitter/SplitFinishScreen.tsx b/src/components/splitter/SplitFinishScreen.tsx new file mode 100644 index 0000000..3e99ce1 --- /dev/null +++ b/src/components/splitter/SplitFinishScreen.tsx @@ -0,0 +1,235 @@ +"use client" + +import React, { useState } from "react" +import { motion } from "motion/react" +import { GenericPlaylist, GenericSong } from "@/types/music" +import { SongAssignment } from "@/components/splitter/Splitter" +import { addSongsToPlaylist, searchSpotifyByISRC, searchSpotifyByMetadata } from "@/spotify/playlists" +import { addSongsToAppleMusicPlaylist, getAppleMusicSongByISRC, searchAppleMusicByMetadata } from "@/apple/music" +import { FaSpotify, FaApple, FaCheck, FaExclamationTriangle } from "react-icons/fa" +import FinishButton from "@/components/composer/finish/FinishButton" + +interface Props { + assignments: SongAssignment + targetPlaylists: GenericPlaylist[] +} + +interface PlaylistStatus { + state: "pending" | "processing" | "done" | "error" + matched: number + failed: number + total: number +} + +const SplitFinishScreen: React.FC = ({ assignments, targetPlaylists }) => { + const [working, setWorking] = useState(false) + const [statuses, setStatuses] = useState<{ [key: string]: PlaylistStatus }>({}) + const [done, setDone] = useState(false) + + const totalAssigned = targetPlaylists.reduce((sum, p) => { + const key = p.id + ":" + p.provider.name + return sum + (assignments[key]?.length || 0) + }, 0) + + async function matchSongToProvider(song: GenericSong, provider: "spotify" | "apple-music"): Promise { + if (song.provider.name === provider) return song + + if (provider === "spotify") { + let uri = song.isrc ? await searchSpotifyByISRC(song.isrc, song.album) : null + if (!uri) uri = await searchSpotifyByMetadata(song.name, song.artist, song.album) + if (uri) return { ...song, uri, provider: { name: "spotify", id: "spotify" } } + } else { + let amSong = song.isrc ? await getAppleMusicSongByISRC(song.isrc, song.album) : null + if (!amSong) amSong = await searchAppleMusicByMetadata(song.name, song.artist, song.album) + if (amSong) return amSong + } + + return null + } + + async function execute() { + if (working || done) return + setWorking(true) + + try { + for (const playlist of targetPlaylists) { + const key = playlist.id + ":" + playlist.provider.name + const songs = assignments[key] || [] + + if (songs.length === 0) continue + + setStatuses(prev => ({ + ...prev, + [key]: { state: "processing", matched: 0, failed: 0, total: songs.length } + })) + + const matchedSongs: GenericSong[] = [] + let failedCount = 0 + + for (const song of songs) { + const matched = await matchSongToProvider(song, playlist.provider.name) + if (matched) { + matchedSongs.push(matched) + } else { + failedCount++ + } + setStatuses(prev => ({ + ...prev, + [key]: { state: "processing", matched: matchedSongs.length, failed: failedCount, total: songs.length } + })) + } + + try { + if (playlist.provider.name === "spotify") { + await addSongsToPlaylist(playlist.id, matchedSongs) + } else { + await addSongsToAppleMusicPlaylist(playlist.id, matchedSongs) + } + + setStatuses(prev => ({ + ...prev, + [key]: { state: "done", matched: matchedSongs.length, failed: failedCount, total: songs.length } + })) + } catch (e) { + console.error(`Failed to add songs to playlist ${playlist.name}`, e) + setStatuses(prev => ({ + ...prev, + [key]: { state: "error", matched: matchedSongs.length, failed: failedCount, total: songs.length } + })) + } + } + + setDone(true) + } catch (e) { + console.error("Split execution failed", e) + } finally { + setWorking(false) + } + } + + return ( +
+

+ {done ? "Done!" : "Ready to split"} +

+

+ {done + ? "All songs have been added to their target playlists." + : `${totalAssigned} songs will be distributed across ${targetPlaylists.length} playlists. The source playlists will remain unchanged.`} +

+ +
+ {targetPlaylists.map(playlist => { + const key = playlist.id + ":" + playlist.provider.name + const songs = assignments[key] || [] + const status = statuses[key] + + return ( + + {playlist.artworkUrl ? ( + {playlist.name} + ) : ( +
+ )} + +
+
+ {playlist.provider.name === "spotify" ? ( + + ) : ( + + )} +

{playlist.name}

+
+

+ {songs.length} {songs.length === 1 ? "song" : "songs"} to add + {status && status.state === "processing" && ( + + Matching: {status.matched + status.failed}/{status.total} + + )} + {status && status.state === "done" && ( + + Added {status.matched} songs + {status.failed > 0 && `, ${status.failed} not found`} + + )} + {status && status.state === "error" && ( + Error adding songs + )} +

+
+ +
+ {status?.state === "done" ? ( + + ) : status?.state === "error" ? ( + + ) : status?.state === "processing" ? ( + + ) : ( +
+ )} +
+
+ ) + })} +
+ + {totalAssigned > 0 && ( +
+

Song details

+ {targetPlaylists.map(playlist => { + const key = playlist.id + ":" + playlist.provider.name + const playlistSongs = assignments[key] || [] + if (playlistSongs.length === 0) return null + + return ( +
+

{playlist.name}

+
+ {playlistSongs.map((song, i) => ( +
+ {song.artworkUrl ? ( + {song.name} + ) : ( +
+ )} +
+

{song.name}

+

{song.artist}

+
+
+ ))} +
+
+ ) + })} +
+ )} + + {!done && ( + + )} +
+ ) +} + +export default SplitFinishScreen diff --git a/src/components/splitter/Splitter.tsx b/src/components/splitter/Splitter.tsx new file mode 100644 index 0000000..d1a6860 --- /dev/null +++ b/src/components/splitter/Splitter.tsx @@ -0,0 +1,37 @@ +"use client" + +import React, { useState } from "react" +import PlaylistPicker from "@/components/composer/playlist/PlaylistPicker" +import SongAssigner from "@/components/splitter/SongAssigner" +import SplitFinishScreen from "@/components/splitter/SplitFinishScreen" +import { GenericPlaylist, GenericSong } from "@/types/music" +import TargetPlaylistPicker from "@/components/splitter/TargetPlaylistPicker" + +export interface SongAssignment { + [playlistId: string]: GenericSong[] +} + +const Splitter: React.FC = () => { + const [sourcePlaylists, setSourcePlaylists] = useState(null) + const [targetPlaylists, setTargetPlaylists] = useState(null) + const [assignments, setAssignments] = useState(null) + + if (assignments && targetPlaylists) + return + + if (targetPlaylists && sourcePlaylists) + return ( + + ) + + if (sourcePlaylists) + return + + return +} + +export default Splitter diff --git a/src/components/splitter/TargetPlaylistPicker.tsx b/src/components/splitter/TargetPlaylistPicker.tsx new file mode 100644 index 0000000..a0d4aca --- /dev/null +++ b/src/components/splitter/TargetPlaylistPicker.tsx @@ -0,0 +1,137 @@ +"use client" + +import React, { useEffect, useState } from "react" +import { AnimatePresence, motion } from "motion/react" +import PlaylistCard from "@/components/composer/playlist/PlaylistCard" +import { GenericPlaylist, mapSpotifyPlaylist } from "@/types/music" +import { getAccessToken } from "@/spotify/authorization" +import { buildPseudoPlaylistFromLibrary, getPlaylists } from "@/spotify/playlists" +import { getAppleMusicPlaylists, isAppleMusicAuthorized } from "@/apple/music" +import LoadingScreen from "@/components/composer/LoadingScreen" + +interface Props { + sourcePlaylists: GenericPlaylist[] + setTargetPlaylists: (playlists: GenericPlaylist[]) => void +} + +const TargetPlaylistPicker: React.FC = ({ sourcePlaylists, setTargetPlaylists }) => { + const [playlists, setPlaylists] = useState([]) + const [selectedPlaylists, setSelectedPlaylists] = useState([]) + const [loading, setLoading] = useState(true) + + const updateTrackCount = (id: string, providerName: string, count: number) => { + setPlaylists(prev => prev.map(p => (p.id === id && p.provider.name === providerName ? { ...p, trackCount: count } : p))) + setSelectedPlaylists(prev => + prev.map(p => (p.id === id && p.provider.name === providerName ? { ...p, trackCount: count } : p)) + ) + } + + const isSourcePlaylist = (playlist: GenericPlaylist) => + sourcePlaylists.some(sp => sp.id === playlist.id && sp.provider.name === playlist.provider.name) + + useEffect(() => { + const fetchAllPlaylists = async () => { + const allPlaylists: GenericPlaylist[] = [] + + try { + if (getAccessToken()) { + try { + allPlaylists.push(mapSpotifyPlaylist(await buildPseudoPlaylistFromLibrary())) + const spotifyPlaylists = await getPlaylists() + allPlaylists.push(...spotifyPlaylists.map(mapSpotifyPlaylist)) + } catch (e) { + console.error("Error fetching Spotify playlists", e) + } + } + + if (isAppleMusicAuthorized()) { + try { + const applePlaylists = await getAppleMusicPlaylists() + allPlaylists.push(...applePlaylists) + } catch (e) { + console.error("Error fetching Apple Music playlists", e) + } + } + + setPlaylists(allPlaylists.filter(p => !isSourcePlaylist(p))) + } finally { + setLoading(false) + } + } + + fetchAllPlaylists() + }, []) + + const togglePlaylist = (playlist: GenericPlaylist) => { + setSelectedPlaylists(prevState => { + const newState = [...prevState] + if (newState.find(p => p.id === playlist.id && p.provider.name === playlist.provider.name)) { + return newState.filter(p => !(p.id === playlist.id && p.provider.name === playlist.provider.name)) + } else { + newState.push(playlist) + } + return newState + }) + } + + const finishSelection = () => { + if (selectedPlaylists.length >= 2) { + setTargetPlaylists(selectedPlaylists) + } + } + + return ( + + {!loading ? ( + +
+

Select target playlists

+

+ Choose at least 2 playlists to split your songs into +

+
+
+ {playlists.map(playlist => ( + it.id === playlist.id && it.provider.name === playlist.provider.name + ) + } + playlist={playlist} + togglePlaylist={togglePlaylist} + onTrackCountLoaded={updateTrackCount} + /> + ))} +
+ = 2 + ? "bg-purple-600 border-purple-500" + : "bg-gray-400 border-gray-300 cursor-not-allowed" + }`} + whileHover={selectedPlaylists.length >= 2 ? { scale: 1.05 } : {}} + whileTap={selectedPlaylists.length >= 2 ? { scale: 0.95 } : {}} + onClick={finishSelection} + > + {selectedPlaylists.length >= 2 + ? `Continue with ${selectedPlaylists.length} playlists` + : `Select at least ${2 - selectedPlaylists.length} more`} + +
+ ) : ( + + )} +
+ ) +} + +export default TargetPlaylistPicker