Skip to content
Merged
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
141 changes: 99 additions & 42 deletions src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,27 @@ const GenreDetailView = lazy(() =>
})),
);

// Each entry in the navigation history pairs a view id with its payload
// (when relevant) so back/forward can restore the exact target the user
// visited. Payload fields are optional so callers without a target (e.g.
// the initial "home" entry, or navigating to "wrapped" without a year)
// stay valid.
type HistoryEntry =
| { id: "home" }
| { id: "library" }
| { id: "settings" }
| { id: "spotify" }
| { id: "about" }
| { id: "feedback" }
| { id: "statistics" }
| { id: "liked" }
| { id: "recent" }
| { id: "wrapped"; year?: number | null }
| { id: "playlist"; playlistId?: number | null }
| { id: "album-detail"; albumId?: number | null }
| { id: "artist-detail"; artistId?: number | null }
| { id: "genre-detail"; genreId?: number | null };
Comment thread
InstaZDLL marked this conversation as resolved.

export function AppLayout() {
const { t } = useTranslation();
const { isDark } = useTheme();
Expand All @@ -109,22 +130,25 @@ export function AppLayout() {
// listener and re-reads bindings whenever Settings emits the
// shortcuts-changed event.
useGlobalShortcuts();
const [viewHistory, setViewHistory] = useState<ViewId[]>(["home"]);
const [historyIndex, setHistoryIndex] = useState(0);
// History entries carry their payload (album/artist/genre/playlist id,
// wrapped year) directly so back/forward restore the exact target the
// user visited — not whatever target was set most recently. Without
// this, navigating album A → home → album B → back → back lands on
// "album-detail" with activeAlbumId still pointing at B.
//
// History + index live in a single state object so push/replace can
// update both atomically inside one functional setter. Splitting them
// would let rapid back-to-back navigations queue setters that all read
// the same stale index, losing entries and leaving `index` past
// `history.length - 1`.
const [navState, setNavState] = useState<{
history: HistoryEntry[];
index: number;
}>({ history: [{ id: "home" }], index: 0 });
const viewHistory = navState.history;
const historyIndex = navState.index;
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [libraryTab, setLibraryTab] = useState<LibraryTab>("morceaux");
// Currently focused playlist for the "playlist" view. The view itself
// re-fetches when this id changes; the sidebar uses it to highlight
// the active row.
const [activePlaylistId, setActivePlaylistId] = useState<number | null>(null);
const [activeAlbumId, setActiveAlbumId] = useState<number | null>(null);
const [activeArtistId, setActiveArtistId] = useState<number | null>(null);
const [activeGenreId, setActiveGenreId] = useState<number | null>(null);
// Year requested when navigating into the Wrapped overlay. `null`
// tells WrappedView to pick the most recent year with plays.
const [activeWrappedYear, setActiveWrappedYear] = useState<number | null>(
null,
);

// First-run onboarding: prompt the user to point WaveFlow at a
// music folder when no library has been populated yet.
Expand Down Expand Up @@ -199,65 +223,101 @@ export function AppLayout() {
);
}, []);

const activeView = viewHistory[historyIndex];
const currentEntry = viewHistory[historyIndex];
const activeView: ViewId = currentEntry.id;
// Derived from the current history entry so back/forward restore the
// correct payload. `null` for views without a payload.
const activeAlbumId =
currentEntry.id === "album-detail" ? (currentEntry.albumId ?? null) : null;
const activeArtistId =
currentEntry.id === "artist-detail"
? (currentEntry.artistId ?? null)
: null;
const activeGenreId =
currentEntry.id === "genre-detail" ? (currentEntry.genreId ?? null) : null;
const activePlaylistId =
currentEntry.id === "playlist" ? (currentEntry.playlistId ?? null) : null;
const activeWrappedYear =
currentEntry.id === "wrapped" ? (currentEntry.year ?? null) : null;

const pushEntry = useCallback((entry: HistoryEntry) => {
setNavState(({ history, index }) => ({
history: [...history.slice(0, index + 1), entry],
index: index + 1,
}));
}, []);

// Replace the current entry in place (no index bump). Used when the
// current target no longer exists — e.g. a playlist that was just
// deleted — so Back doesn't return to a ghost page.
const replaceEntry = useCallback((entry: HistoryEntry) => {
setNavState(({ history, index }) => {
const next = [...history];
next[index] = entry;
return { history: next, index };
});
}, []);

// Wrapper used by views that only need a plain id (Home, Settings, …).
// The cast is safe because every `ViewId` matches a HistoryEntry whose
// payload fields are optional.
const setActiveView = useCallback(
(view: ViewId) => {
setViewHistory((prev) => [...prev.slice(0, historyIndex + 1), view]);
setHistoryIndex((prev) => prev + 1);
pushEntry({ id: view } as HistoryEntry);
},
[historyIndex],
[pushEntry],
);

const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < viewHistory.length - 1;

const goBack = useCallback(() => {
if (canGoBack) setHistoryIndex((i) => i - 1);
}, [canGoBack]);
setNavState(({ history, index }) =>
index > 0 ? { history, index: index - 1 } : { history, index },
);
}, []);

const goForward = useCallback(() => {
if (canGoForward) setHistoryIndex((i) => i + 1);
}, [canGoForward]);
setNavState(({ history, index }) =>
index < history.length - 1
? { history, index: index + 1 }
: { history, index },
);
}, []);

const navigateToAlbum = useCallback(
(albumId: number) => {
setActiveAlbumId(albumId);
setActiveView("album-detail");
pushEntry({ id: "album-detail", albumId });
},
[setActiveView],
[pushEntry],
);

const navigateToArtist = useCallback(
(artistId: number) => {
setActiveArtistId(artistId);
setActiveView("artist-detail");
pushEntry({ id: "artist-detail", artistId });
},
[setActiveView],
[pushEntry],
);

const navigateToGenre = useCallback(
(genreId: number) => {
setActiveGenreId(genreId);
setActiveView("genre-detail");
pushEntry({ id: "genre-detail", genreId });
},
[setActiveView],
[pushEntry],
);

const navigateToPlaylist = useCallback(
(playlistId: number) => {
setActivePlaylistId(playlistId);
setActiveView("playlist");
pushEntry({ id: "playlist", playlistId });
},
[setActiveView],
[pushEntry],
);

const navigateToWrapped = useCallback(
(year: number | null) => {
setActiveWrappedYear(year);
setActiveView("wrapped");
pushEntry({ id: "wrapped", year });
},
[setActiveView],
[pushEntry],
);

function renderView() {
Expand Down Expand Up @@ -325,10 +385,7 @@ export function AppLayout() {
return (
<PlaylistView
playlistId={activePlaylistId}
onAfterDelete={() => {
setActivePlaylistId(null);
setActiveView("home");
}}
onAfterDelete={() => replaceEntry({ id: "home" })}
onNavigateToAlbum={navigateToAlbum}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onNavigateToArtist={navigateToArtist}
/>
Expand Down Expand Up @@ -396,7 +453,7 @@ export function AppLayout() {
libraryTab={libraryTab}
setLibraryTab={setLibraryTab}
activePlaylistId={activePlaylistId}
setActivePlaylistId={setActivePlaylistId}
navigateToPlaylist={navigateToPlaylist}
/>

{/* Center Content. `min-w-0` is required so a long playlist
Expand Down
15 changes: 6 additions & 9 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ interface SidebarProps {
libraryTab: LibraryTab;
setLibraryTab: (tab: LibraryTab) => void;
activePlaylistId: number | null;
setActivePlaylistId: (id: number | null) => void;
navigateToPlaylist: (id: number) => void;
}

export function Sidebar({
Expand All @@ -48,7 +48,7 @@ export function Sidebar({
libraryTab,
setLibraryTab,
activePlaylistId,
setActivePlaylistId,
navigateToPlaylist,
}: SidebarProps) {
const { t } = useTranslation();
const { activeProfile } = useProfile();
Expand Down Expand Up @@ -162,16 +162,14 @@ export function Sidebar({
color_id: data.colorId,
icon_id: data.iconId,
});
setActivePlaylistId(created.id);
setActiveView("playlist");
navigateToPlaylist(created.id);
} catch (err) {
console.error("[Sidebar] failed to create playlist", err);
}
};

const handleSelectPlaylist = (playlistId: number) => {
setActivePlaylistId(playlistId);
setActiveView("playlist");
navigateToPlaylist(playlistId);
};

const handleImportM3u = useCallback(async () => {
Expand All @@ -183,8 +181,7 @@ export function Sidebar({
try {
const result = await importPlaylistM3u(path);
await refreshPlaylists();
setActivePlaylistId(result.playlist_id);
setActiveView("playlist");
navigateToPlaylist(result.playlist_id);
if (result.missing > 0) {
console.warn(
`[Sidebar] m3u import: ${result.missing} entries not found in library`,
Expand All @@ -194,7 +191,7 @@ export function Sidebar({
} catch (err) {
console.error("[Sidebar] import m3u failed", err);
}
}, [t, refreshPlaylists, setActivePlaylistId, setActiveView]);
}, [t, refreshPlaylists, navigateToPlaylist]);

const isPlaylistRowActive = (id: number) =>
activeView === "playlist" && activePlaylistId === id;
Expand Down
7 changes: 5 additions & 2 deletions src/components/views/AlbumDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function AlbumDetailView({
onNavigateToArtist,
}: AlbumDetailViewProps) {
const { t } = useTranslation();
const { playTracks, currentTrack, toggleShuffle, isPlaying } = usePlayer();
const { playTracks, currentTrack, toggleShuffle, isShuffled, isPlaying } =
usePlayer();
const { createPlaylist } = usePlaylist();

const [album, setAlbum] = useState<AlbumDetail | null>(null);
Expand Down Expand Up @@ -191,7 +192,9 @@ export function AlbumDetailView({
const handleShufflePlay = async () => {
if (playableTracks.length === 0) return;
await playTracks(playableTracks, 0, { type: "library", id: null });
await toggleShuffle();
// Gate the toggle so we never *disable* shuffle when the user
// explicitly clicks the Shuffle button.
if (!isShuffled) await toggleShuffle();
};

const label = enrichedLabel ?? album.label;
Expand Down
7 changes: 5 additions & 2 deletions src/components/views/ArtistDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export function ArtistDetailView({
onNavigateToArtist,
}: ArtistDetailViewProps) {
const { t } = useTranslation();
const { playTracks, currentTrack, toggleShuffle, isPlaying } = usePlayer();
const { playTracks, currentTrack, toggleShuffle, isShuffled, isPlaying } =
usePlayer();
const { createPlaylist } = usePlaylist();

const [artist, setArtist] = useState<ArtistDetail | null>(null);
Expand Down Expand Up @@ -227,7 +228,9 @@ export function ArtistDetailView({
const handleShufflePlay = async () => {
if (tracks.length === 0) return;
await playTracks(tracks, 0, { type: "library", id: null });
await toggleShuffle();
// Gate the toggle so we never *disable* shuffle when the user
// explicitly clicks the Shuffle button.
if (!isShuffled) await toggleShuffle();
};

const fansLabel =
Expand Down
7 changes: 5 additions & 2 deletions src/components/views/GenreDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export function GenreDetailView({
onNavigateToArtist,
}: GenreDetailViewProps) {
const { t } = useTranslation();
const { playTracks, currentTrack, toggleShuffle, isPlaying } = usePlayer();
const { playTracks, currentTrack, toggleShuffle, isShuffled, isPlaying } =
usePlayer();
const { createPlaylist } = usePlaylist();

const [genre, setGenre] = useState<GenreDetail | null>(null);
Expand Down Expand Up @@ -120,7 +121,9 @@ export function GenreDetailView({
const handleShufflePlay = async () => {
if (tracks.length === 0) return;
await playTracks(tracks, 0, { type: "library", id: null });
await toggleShuffle();
// Gate the toggle so we never *disable* shuffle when the user
// explicitly clicks the Shuffle button.
if (!isShuffled) await toggleShuffle();
};

return (
Expand Down
Loading