diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f59a4513..4fb2a145 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -57,6 +57,7 @@ android {
lint {
abortOnError = true
warningsAsErrors = true
+ lintConfig = file("lint.xml")
disable += setOf(
"AndroidGradlePluginVersion",
"GradleDependency",
diff --git a/app/lint.xml b/app/lint.xml
new file mode 100644
index 00000000..21b5ddda
--- /dev/null
+++ b/app/lint.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/database/PlaylistDatabase.kt b/app/src/main/java/com/happyseal/zenplayer/core/database/PlaylistDatabase.kt
index 20295d0a..c5fa0c3d 100644
--- a/app/src/main/java/com/happyseal/zenplayer/core/database/PlaylistDatabase.kt
+++ b/app/src/main/java/com/happyseal/zenplayer/core/database/PlaylistDatabase.kt
@@ -2,15 +2,37 @@ package com.happyseal.zenplayer.core.database
import androidx.room.Database
import androidx.room.RoomDatabase
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
import com.happyseal.zenplayer.core.database.dao.PlaylistDao
import com.happyseal.zenplayer.core.database.entity.PlaylistEntity
import com.happyseal.zenplayer.core.database.entity.PlaylistTrackEntity
@Database(
entities = [PlaylistEntity::class, PlaylistTrackEntity::class],
- version = 1,
+ version = 2,
exportSchema = false,
)
abstract class PlaylistDatabase : RoomDatabase() {
abstract fun playlistDao(): PlaylistDao
+
+ companion object {
+ val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // (playlistId, trackId) unique index 추가
+ // 기존 중복 행 중 id가 가장 작은 것만 남기고 나머지 삭제
+ db.execSQL(
+ """
+ DELETE FROM playlist_tracks
+ WHERE id NOT IN (
+ SELECT MIN(id) FROM playlist_tracks GROUP BY playlistId, trackId
+ )
+ """.trimIndent(),
+ )
+ db.execSQL(
+ "CREATE UNIQUE INDEX IF NOT EXISTS index_playlist_tracks_playlistId_trackId ON playlist_tracks (playlistId, trackId)",
+ )
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/database/dao/PlaylistDao.kt b/app/src/main/java/com/happyseal/zenplayer/core/database/dao/PlaylistDao.kt
index 9eb353f8..f6dec264 100644
--- a/app/src/main/java/com/happyseal/zenplayer/core/database/dao/PlaylistDao.kt
+++ b/app/src/main/java/com/happyseal/zenplayer/core/database/dao/PlaylistDao.kt
@@ -38,7 +38,7 @@ interface PlaylistDao {
@Query("SELECT * FROM playlist_tracks WHERE playlistId = :playlistId ORDER BY position ASC")
suspend fun getTracksForPlaylistOnce(playlistId: Long): List
- @Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
+ @Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
suspend fun insertTrack(track: PlaylistTrackEntity): Long
@Transaction
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/database/entity/PlaylistTrackEntity.kt b/app/src/main/java/com/happyseal/zenplayer/core/database/entity/PlaylistTrackEntity.kt
index 7dfef084..c6487a10 100644
--- a/app/src/main/java/com/happyseal/zenplayer/core/database/entity/PlaylistTrackEntity.kt
+++ b/app/src/main/java/com/happyseal/zenplayer/core/database/entity/PlaylistTrackEntity.kt
@@ -18,7 +18,7 @@ import androidx.room.PrimaryKey
onDelete = ForeignKey.CASCADE,
),
],
- indices = [Index("playlistId")],
+ indices = [Index("playlistId"), Index(value = ["playlistId", "trackId"], unique = true)],
)
data class PlaylistTrackEntity(
@PrimaryKey(autoGenerate = true)
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/di/PlaylistModule.kt b/app/src/main/java/com/happyseal/zenplayer/core/di/PlaylistModule.kt
index b9431f76..498e7204 100644
--- a/app/src/main/java/com/happyseal/zenplayer/core/di/PlaylistModule.kt
+++ b/app/src/main/java/com/happyseal/zenplayer/core/di/PlaylistModule.kt
@@ -26,7 +26,9 @@ object PlaylistDatabaseModule {
context,
PlaylistDatabase::class.java,
"zenplayer_playlist.db",
- ).build()
+ )
+ .addMigrations(PlaylistDatabase.MIGRATION_1_2)
+ .build()
@Provides
@Singleton
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/service/QueuePersistenceManager.kt b/app/src/main/java/com/happyseal/zenplayer/core/service/QueuePersistenceManager.kt
index c2d47a3f..c898c4f4 100644
--- a/app/src/main/java/com/happyseal/zenplayer/core/service/QueuePersistenceManager.kt
+++ b/app/src/main/java/com/happyseal/zenplayer/core/service/QueuePersistenceManager.kt
@@ -55,7 +55,8 @@ class QueuePersistenceManager(
if (restoredTracks.isEmpty()) return@launch
withContext(Dispatchers.Main) {
- playQueueManager.setPlayQueue(restoredTracks, snapshot.currentIndex)
+ val safeIndex = snapshot.currentIndex.coerceIn(0, restoredTracks.lastIndex)
+ playQueueManager.setPlayQueue(restoredTracks, safeIndex)
val ready =
withTimeoutOrNull(5_000L) {
audioController.playbackState
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/ui/component/AlbumArtThumbnail.kt b/app/src/main/java/com/happyseal/zenplayer/core/ui/component/AlbumArtThumbnail.kt
deleted file mode 100644
index 6f1f0790..00000000
--- a/app/src/main/java/com/happyseal/zenplayer/core/ui/component/AlbumArtThumbnail.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.happyseal.zenplayer.core.ui.component
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.MusicNote
-import androidx.compose.material3.Icon
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.rememberVectorPainter
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
-import com.happyseal.zenplayer.core.ui.theme.ZenPlayerTheme
-import kotlin.math.pow
-
-@Suppress("MagicNumber")
-private fun Color.luminance(): Float {
- fun linearize(c: Float) = if (c <= 0.04045f) c / 12.92f else ((c + 0.055f) / 1.055f).pow(2.4f)
- return 0.2126f * linearize(red) + 0.7152f * linearize(green) + 0.0722f * linearize(blue)
-}
-
-@Suppress("MagicNumber")
-private fun Color.contentColor(): Color = if (luminance() > 0.179f) Color(0xFF1C1B1F) else Color.White
-
-@Suppress("MagicNumber")
-private val placeholderColors = listOf(
- Color(0xFF7986CB),
- Color(0xFF4DB6AC),
- Color(0xFFFF8A65),
- Color(0xFFA1887F),
- Color(0xFF4DD0E1),
- Color(0xFFAED581),
- Color(0xFFFFD54F),
- Color(0xFFBA68C8),
-)
-
-@Composable
-fun AlbumArtThumbnail(
- uri: String?,
- size: Dp,
- modifier: Modifier = Modifier,
- trackId: Long = 0L,
-) {
- if (!uri.isNullOrBlank()) {
- AsyncImage(
- model = uri,
- contentDescription = null,
- contentScale = ContentScale.Crop,
- error = rememberVectorPainter(Icons.Filled.MusicNote),
- modifier = modifier.size(size),
- )
- } else {
- val color = placeholderColors[trackId.toInt().mod(placeholderColors.size)]
- Box(
- modifier = modifier
- .size(size)
- .background(color),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = Icons.Filled.MusicNote,
- contentDescription = "앨범 아트",
- tint = color.contentColor(),
- )
- }
- }
-}
-
-@Preview(name = "AlbumArtThumbnail — 플레이스홀더 팔레트 전체", showBackground = true)
-@Composable
-private fun PreviewAlbumArtThumbnailPlaceholders() {
- ZenPlayerTheme {
- Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- repeat(placeholderColors.size) { index ->
- AlbumArtThumbnail(uri = null, size = 48.dp, trackId = index.toLong())
- }
- }
- }
-}
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/ui/component/MiniPlayerBar.kt b/app/src/main/java/com/happyseal/zenplayer/core/ui/component/MiniPlayerBar.kt
index 2dcf6edc..8e8b7907 100644
--- a/app/src/main/java/com/happyseal/zenplayer/core/ui/component/MiniPlayerBar.kt
+++ b/app/src/main/java/com/happyseal/zenplayer/core/ui/component/MiniPlayerBar.kt
@@ -71,11 +71,10 @@ fun MiniPlayerBar(
verticalAlignment = Alignment.CenterVertically,
) {
// 앨범아트
- AlbumArtThumbnail(
- uri = uiState.albumArtUri,
- size = ZenDimension.Thumbnail.Mini,
- trackId = uiState.trackId,
+ TrackThumbnail(
+ albumArtUri = uiState.albumArtUri,
modifier = Modifier.clip(CircleShape),
+ size = ZenDimension.Thumbnail.Mini,
)
// 트랙 정보
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/ui/component/TrackThumbnail.kt b/app/src/main/java/com/happyseal/zenplayer/core/ui/component/TrackThumbnail.kt
new file mode 100644
index 00000000..2b98acfd
--- /dev/null
+++ b/app/src/main/java/com/happyseal/zenplayer/core/ui/component/TrackThumbnail.kt
@@ -0,0 +1,55 @@
+package com.happyseal.zenplayer.core.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.Dp
+import coil3.compose.AsyncImage
+import com.happyseal.zenplayer.core.ui.theme.ZenAlpha
+import com.happyseal.zenplayer.core.ui.theme.ZenDimension
+
+@Composable
+fun TrackThumbnail(
+ albumArtUri: String?,
+ modifier: Modifier = Modifier,
+ size: Dp = ZenDimension.Thumbnail.List,
+) {
+ if (albumArtUri.isNullOrBlank()) {
+ Box(
+ modifier = modifier
+ .size(size)
+ .background(
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
+ shape = RoundedCornerShape(ZenDimension.Corner.Dialog),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.MusicNote,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = ZenAlpha.INACTIVE),
+ )
+ }
+ } else {
+ AsyncImage(
+ model = albumArtUri,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ error = rememberVectorPainter(Icons.Filled.MusicNote),
+ modifier = modifier
+ .size(size)
+ .clip(RoundedCornerShape(ZenDimension.Corner.Dialog)),
+ )
+ }
+}
diff --git a/app/src/main/java/com/happyseal/zenplayer/core/ui/theme/ZenDimension.kt b/app/src/main/java/com/happyseal/zenplayer/core/ui/theme/ZenDimension.kt
index 8d759b46..49f2b150 100644
--- a/app/src/main/java/com/happyseal/zenplayer/core/ui/theme/ZenDimension.kt
+++ b/app/src/main/java/com/happyseal/zenplayer/core/ui/theme/ZenDimension.kt
@@ -39,4 +39,9 @@ object ZenDimension {
val BarHeight = 98.dp // 내부 레이아웃 수동 합산값 (progress bar + row + surface padding)
val ListBottomPadding = BarHeight + Spacing.Xl // 플레이어 높이 + 여백
}
+
+ object Fab {
+ val ExtendedHeight = 56.dp // ExtendedFloatingActionButton 기본 높이
+ val BottomPadding = ExtendedHeight + Spacing.S // FAB 위에 리스트 콘텐츠가 가려지지 않을 패딩
+ }
}
diff --git a/app/src/main/java/com/happyseal/zenplayer/features/music/data/SampleMusicProvider.kt b/app/src/main/java/com/happyseal/zenplayer/features/music/data/SampleMusicProvider.kt
index 1d7744cf..8d488e04 100644
--- a/app/src/main/java/com/happyseal/zenplayer/features/music/data/SampleMusicProvider.kt
+++ b/app/src/main/java/com/happyseal/zenplayer/features/music/data/SampleMusicProvider.kt
@@ -1,62 +1,80 @@
package com.happyseal.zenplayer.features.music.data
import android.content.Context
+import android.media.MediaMetadataRetriever
+import com.happyseal.zenplayer.LogMusic
import com.happyseal.zenplayer.R
import com.happyseal.zenplayer.core.model.Track
+import java.io.File
/**
* 샘플 음악 제공자
*
- * raw 리소스에서 샘플 음악 파일을 로드합니다.
+ * raw 리소스에서 샘플 음악 파일을 자동으로 로드합니다.
+ * res/raw/ 폴더에 mp3 파일을 추가하면 별도 코드 수정 없이 샘플 트랙으로 등록됩니다.
*/
class SampleMusicProvider(
private val context: Context,
) {
- fun getSampleTracks(): List