Skip to content
Open
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
29 changes: 28 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,33 @@ if (getPlatform() == Platform.Android) {
- **One pass, in order**: drop the marker from marked songs no longer downloaded, and from songs the user now keeps (liked while liked songs auto-download, or in a downloaded local playlist, album or playlist); rank; keep only rows YouTube confirms as songs (`MusicVideoType.isAudio`) that are not live, so videos, podcasts and unknown types never qualify; mark and `downloadTrack` the top N not downloaded yet; then evict FIFO by `downloadedAt` (in-flight downloads sort last, and a song in today's top N is never evicted) by unmarking, then `removeDownload`. Switching off removes every marked song; one the user now keeps only loses the marker.
- Smart songs post no per-song "downloaded" or "failed" notification: `MusicDownloadService.TerminalStateNotificationHelper` checks the marker first. The download service's progress notification still shows.

- **A whole album as one entry in a local playlist (2026-10-17, Phase 2a)**: "Add to a playlist" on an album now stores the album itself, and the local playlist shows it as a single row that opens the album on tap. Previously (Phase 1, commit `9845e86`) the button only expanded the album to its tracks and added those individually — one entry per song.
- **The row is the existing pair table, not a new one.** `pair_song_local_playlist` became an item table with a discriminator instead of gaining a sibling: the pager walks one table by `position` and merges nothing, so two tables sharing a position space would have to be merged in memory on every page. `songId` is now **nullable** (an album has no song; SQLite allows a NULL child column without violating the FK), and `itemType` (0 = song, 1 = album), `albumBrowseId`, `title` and `thumbnail` were added — the last three so a row can be drawn with no network call. `songId` is still the FK to `song`, so deleting the song still cascades its entries.
- **Nullable `songId` cannot go through an auto-migration**, so it is a hand-written `Migration27_28` (SQLite rebuild: create new table, `INSERT … SELECT … , 0` for every existing row, drop, rename, recreate the two indices) registered on **all three** platform builders — androidMain *and* jvm/ios, which previously carried no manual migrations at all. Database version `27 → 28`; `MusicDatabase`'s `autoMigrations` list is untouched because a pure manual migration is not listed there (same as 5→6, 10→11, 12→13). The android builder's `onOpen` trigger survives the rebuild because it is recreated with `IF NOT EXISTS` on every open, not once.
- **The paging item type was deliberately left alone.** Rather than a sealed `LocalPlaylistItem` (which would ripple through ~21 `Pair<SongEntity, PairSongLocalPlaylist>` sites in 5 files), an album row is **synthesized as a fake `SongEntity`** (`videoId = "album:<browseId>"`, title/thumbnail from the pair) so the existing `SongFullWidthItems` renders it unchanged; callers tell it apart with the extension `PairSongLocalPlaylist.isAlbumItem`. The fake is a display placeholder only — detection is always by `itemType`.
- **`songId` being nullable forced `mapNotNull` in six call sites.** `getSongByListVideoIdFull(pairs.map { it.songId })` became `mapNotNull` in the paging sources, `searchTracks`, `getFullPlaylistTracks`, and both media handlers' local-playlist queue builds. `moveSongInPlaylist` places by primary key now (`editPositionOfPairById`) since a song-only `WHERE songId =` update would silently miss an album's NULL.
- **Albums are not written to `LocalPlaylistEntity.tracks` and not added to a YouTube playlist.** That list is videoIds only; putting a browseId in it would break shuffle, `getListTrackVideoId` and YouTube sync. `addAlbumToLocalPlaylist` inserts one row with `itemType = ITEM_ALBUM` and `songId = null`.
- **Position must come from the table, not `tracks.size`.** A new DAO query `getNextPositionOfLocalPlaylist` (`COALESCE(MAX(position)+1, 0)`) is used by both `addAlbumToLocalPlaylist` **and** `addTrackToLocalPlaylist`: an album row leaves `tracks.size` unchanged, so the old formula would have placed the next song on top of the album's position.
- **Queue building skips album rows.** `LocalPlaylistViewModel`'s `ItemClick` and `PlayClick` filter them out of `listTracks` and the index, while the position-based continuation cursor still counts them. The row's tap goes to `AlbumDestination(browseId)`, and its more sheet, add-to-queue, drag and selection are withheld (`onMoreClickListener = null`, etc.) so none of them acts on the placeholder.
- **Not yet (Phase 2c)**: reorder/remove/YouTube-sync/backup for album rows. Search does not return album rows (`searchPlaylistPairSong` joins `song`), and the Title sort filter drops them for the same reason.
- Verified on the JVM (`:data:compileKotlinJvm`, `:composeApp:compileKotlinJvm`), on Android (`:data:compileAndroidMain`), and the migration SQL was run against `sqlite3` to confirm existing rows are preserved, a NULL-`songId` album row is accepted, and the NOT NULL flags/FKs match the generated `28.json`.

- **An album entry plays as one, and a row's two taps do two things (2026-10-17, Phase 2b)**: tapping an album row in a local playlist now plays that album and carries on into the rest of the list, and playing the list from the top plays each album's tracks where the album sits. A **double tap** opens the album page — the single tap is the play action, so navigation moved to the second tap.
- **The queue is flattened whole from the database, not stitched from pages.** `getAllPlaylistPairSongByPosition` reads every entry in CustomOrder and the ViewModel expands it: a song entry contributes its track, an album entry expands to the album's tracks in the album's own order at that position. The paged reader is deliberately not used for this — an album's tracks can straddle a page boundary and the continuation path cannot grow a queue in the middle of itself. Playback therefore sets `continuation = null`; the list is finite, and Endless queue still takes over past the end exactly as before.
- **It plays in the order the screen is showing.** `orderedPlaylistPairs` sorts the flattened entries the way the paged reader walked them: OlderFirst/NewerFirst by when each entry was added (`inPlaylist`), CustomOrder and Title by stored position. Resolving all pairs by position and then reversing them only for NewerFirst would have been shorter but wrong — after a manual reorder the added-time order and the position order genuinely differ, and the time filters follow the former.
- **`LocalPlaylistUIEvent.AlbumClick`** carries the `browseId` and starts the queue at that album's first track (`buildExpandedQueue(..., startAtAlbum)` returns the index); `ItemClick` and `PlayClick` start at the clicked song / the top. All three share one flattened list, so the queue is identical however playback was entered.
- **The album's tracks are read from the local database first** (`AlbumEntity.tracks` is the ordered videoId list the album page wrote when it was opened), which keeps this working offline; only an album row with no stored album falls back to `AlbumRepository.getAlbumData`.
- `SongFullWidthItems` gained `onDoubleClickListener`, wired through `combinedClickable`'s `onDoubleClick`. A row that passes none is unchanged and pays no double-tap delay — only album rows do, which is the price of telling the two taps apart.
- `AlbumRepository` is now a `LocalPlaylistViewModel` dependency (a fourth `get()` in its Koin definition). The old queue builders' `ASC`/`DESC`/`TITLE`/`CUSTOM_ORDER` continuation strings and the `Converters` field left the ViewModel with the paging path they served.
- Verified on the JVM (`:domain:compileKotlinJvm`, `:data:compileKotlinJvm`, `:composeApp:compileKotlinJvm`) and on Android (`:data:compileAndroidMain`).

- **An album entry can be removed and moved like any other, and YouTube sync simply leaves it alone (2026-10-17, Phase 2c)**: the album row's ⋯ now opens its own two-row sheet — Open album, Remove from playlist — and dragging it in the list already worked.
- **Remove had to get its own path.** `removeTrackFromLocalPlaylist`, `deleteItem` and `deletePairSongLocalPlaylist` all key by videoId, and an album's `songId` is NULL, so none of them can find the row. Removal is a new `deletePairSongLocalPlaylistById(id: Int)` (DAO → `LocalDataSource` → `removeAlbumFromLocalPlaylist(id, pairId, …)`) reached from `LocalPlaylistViewModel.deleteAlbumItem`, which also hands the paging source `PagingActions.Remove(item)` so the row disappears without a reload.
- **Moving an album is local-only.** `moveSongInPlaylist` places by position (i.e. by primary key), so it was already album-safe — the reorder itself needed nothing. What did need fixing is `moveItemInSyncedPlaylist`: an album has no `setVideoId` and does not exist on YouTube, so the move stops at the local update; and the successor used for the remote move is now the first **song** after the moved entry in the reordered list, skipping over any albums that sit between, whose `songId` is NULL and would otherwise be sent as a setVideoId of null.
- **Albums stay out of YouTube sync and out of the backup by design, not by omission.** The push reads `playlist.tracks`, which `addAlbumToLocalPlaylist` deliberately never writes (it is a videoId list — a browseId in it breaks shuffle, `getListTrackVideoId` and sync itself), so a sync can neither push nor pull an album; a pull builds a fresh local playlist of songs. Backup and restore copy the raw `DB_NAME` file, so the album rows ride along with everything else and needed no code at all.
- `LocalPlaylistAlbumBottomSheet` in `ModalBottomSheet.kt` reuses the queue sheet's `ActionButton`; the album row's `onMoreClickListener` (which 2a had left null) now opens it instead of doing nothing. Removed the same way: `open_album` was added to `strings.xml`, and — the trap that cost a build — Compose Resources accessors are per-string *imports* (`import simpmusic.composeapp.generated.resources.open_album`), not members of `Res.string`, so both it and `remove_from_playlist` needed importing where first used.
- Verified on the JVM (`:domain:compileKotlinJvm`, `:data:compileKotlinJvm`, `:composeApp:compileKotlinJvm`) and on Android (`:data:compileAndroidMain`).

## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY)

After completing any of the following types of changes, the AI agent **MUST** update this CLAUDE.md file:
Expand All @@ -1063,6 +1090,6 @@ After completing any of the following types of changes, the AI agent **MUST** up

*This document helps AI Agents quickly understand the SimpMusic project. Update regularly when there are major changes to architecture or structure.*

**Last updated**: 2026-10-16
**Last updated**: 2026-10-17
**Project version**: Check latest release on GitHub
**Maintained by**: maxrave-dev and contributors
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
<string name="title">Title</string>
<string name="custom_order">Custom order</string>
<string name="album">Album</string>
<string name="open_album">Open album</string>
<string name="youtube_url">YouTube URL</string>
<string name="download_url">Download URL</string>
<string name="now_playing">Now Playing</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ val viewModelModule =
get(),
get(),
get(),
get(),
)
}
viewModel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ fun SongFullWidthItems(
shouldShowDragHandle: Boolean = false,
onMoreClickListener: ((videoId: String) -> Unit)? = null,
onClickListener: ((videoId: String) -> Unit)? = null,
onDoubleClickListener: ((videoId: String) -> Unit)? = null,
onAddToQueue: ((videoId: String) -> Unit)? = null,
selectionMode: Boolean = false,
isSelected: Boolean = false,
Expand Down Expand Up @@ -197,6 +198,12 @@ fun SongFullWidthItems(
} else {
null
},
onDoubleClick =
if (onDoubleClickListener != null) {
{ onDoubleClickListener(itemVideoId) }
} else {
null
},
).animateContentSize()
// Keyed on selectionMode so the swipe detector is torn down when selection
// starts — keyed on Unit it would keep running with the stale flag captured.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ import simpmusic.composeapp.generated.resources.no_results_found
import simpmusic.composeapp.generated.resources.now_playing
import simpmusic.composeapp.generated.resources.now_playing_upper
import simpmusic.composeapp.generated.resources.ok
import simpmusic.composeapp.generated.resources.open_album
import simpmusic.composeapp.generated.resources.pitch
import simpmusic.composeapp.generated.resources.play_next
import simpmusic.composeapp.generated.resources.playback_speed
Expand All @@ -266,6 +267,7 @@ import simpmusic.composeapp.generated.resources.processing
import simpmusic.composeapp.generated.resources.queue
import simpmusic.composeapp.generated.resources.radio
import simpmusic.composeapp.generated.resources.remove_from_cache
import simpmusic.composeapp.generated.resources.remove_from_playlist
import simpmusic.composeapp.generated.resources.save
import simpmusic.composeapp.generated.resources.save_to_local_playlist
import simpmusic.composeapp.generated.resources.saved_to_local_playlist
Expand Down Expand Up @@ -2879,6 +2881,7 @@ fun PlaylistBottomSheet(
onEditTitle: (newTitle: String) -> Unit = {},
onSaveToLocal: () -> Unit,
onAddToQueue: (() -> Unit)? = null,
onAddToPlaylist: (() -> Unit)? = null,
localPlaylistRepository: LocalPlaylistRepository = koinInject(),
) {
val coroutineScope = rememberCoroutineScope()
Expand Down Expand Up @@ -3016,6 +3019,12 @@ fun PlaylistBottomSheet(
hideModalBottomSheet()
}
}
if (onAddToPlaylist != null) {
ActionButton(icon = SimpIcons.PlaylistAdd, text = Res.string.add_to_a_playlist) {
onAddToPlaylist.invoke()
hideModalBottomSheet()
}
}
val shareTitle = stringResource(Res.string.share)
ActionButton(icon = SimpIcons.Share, text = Res.string.share) {
val url = "https://music.youtube.com/playlist?list=${playlistId.replaceFirst("VL", "")}"
Expand Down Expand Up @@ -3312,6 +3321,58 @@ fun SortPlaylistBottomSheet(
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocalPlaylistAlbumBottomSheet(
onDismiss: () -> Unit,
onOpenAlbum: () -> Unit,
onRemove: () -> Unit,
) {
val modelBottomSheetState =
rememberModalBottomSheetState(skipPartiallyExpanded = true)
val act: (() -> Unit) -> Unit = { action ->
action()
onDismiss()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = modelBottomSheetState,
containerColor = Color.Transparent,
contentColor = Color.Transparent,
dragHandle = null,
scrimColor = Color.Black.copy(alpha = .5f),
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
) {
Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
colors = CardDefaults.cardColors().copy(containerColor = rememberSurfaceDarkColors().container),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(5.dp))
Card(
modifier = Modifier.width(60.dp).height(4.dp),
colors = CardDefaults.cardColors().copy(containerColor = rememberSurfaceDarkColors().handle),
shape = RoundedCornerShape(50),
) {}
Spacer(modifier = Modifier.height(16.dp))
ActionButton(icon = SimpIcons.Album, text = Res.string.open_album) {
act(onOpenAlbum)
}
ActionButton(
icon = SimpIcons.Delete,
text = Res.string.remove_from_playlist,
iconColor = Color.Red,
textColor = Color.Red,
) {
act(onRemove)
}
EndOfModalBottomSheet()
}
}
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DevLogInBottomSheet(
Expand Down
Loading