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 = listOf( - Track( - id = 1L, - fileUri = "android.resource://${context.packageName}/${R.raw.sample_track_1}", - title = "Energetic Workout", - artist = "Sample Artist", - album = "Sample Album", - durationMs = 0L, - albumArtUri = null, - ), - Track( - id = 2L, - fileUri = "android.resource://${context.packageName}/${R.raw.sample_track_2}", - title = "Rock Your Body", - artist = "Sample Artist", - album = "Sample Album", - durationMs = 0L, - albumArtUri = null, - ), - Track( - id = 3L, - fileUri = "android.resource://${context.packageName}/${R.raw.sample_track_3}", - title = "Lofi Study Beats", - artist = "Sample Artist", - album = "Sample Album", - durationMs = 0L, - albumArtUri = null, - ), - Track( - id = 4L, - fileUri = "android.resource://${context.packageName}/${R.raw.sample_track_4}", - title = "Calm Ambient", - artist = "Sample Artist", - album = "Sample Album", - durationMs = 0L, - albumArtUri = null, - ), - Track( - id = 5L, - fileUri = "android.resource://${context.packageName}/${R.raw.sample_track_5}", - title = "Indie Vibes", - artist = "Sample Artist", - album = "Sample Album", - durationMs = 0L, - albumArtUri = null, - ), + private data class Metadata( + val durationMs: Long = 0L, + val albumArtUri: String? = null, + val title: String = "", + val artist: String = "Unknown Artist", + val album: String = "Unknown Album", ) + + fun getSampleTracks(): List = discoverRawResources() + .mapIndexed { index, (resId, name) -> + val trackId = (index + 1).toLong() + val retriever = MediaMetadataRetriever() + val metadata = try { + val afd = context.resources.openRawResourceFd(resId) + retriever.setDataSource(afd.fileDescriptor, afd.startOffset, afd.length) + afd.close() + val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L + val artUri = retriever.embeddedPicture?.let { bytes -> saveAlbumArt(trackId, bytes) } + val trackTitle = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) ?: name + val trackArtist = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) ?: "Unknown Artist" + val trackAlbum = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM) ?: "Unknown Album" + Metadata(duration, artUri, trackTitle, trackArtist, trackAlbum) + } catch (e: IllegalArgumentException) { + LogMusic.w(e, "Failed to retrieve metadata for rawRes: $resId") + Metadata(title = name) + } finally { + retriever.release() + } + + Track( + id = trackId, + fileUri = "android.resource://${context.packageName}/$resId", + title = metadata.title, + artist = metadata.artist, + album = metadata.album, + durationMs = metadata.durationMs, + albumArtUri = metadata.albumArtUri, + ) + } + + private fun discoverRawResources(): List> = R.raw::class.java.fields + .mapNotNull { field -> + runCatching { + val resId = field.getInt(null) + val title = field.name + .replace('_', ' ') + .replaceFirstChar { it.uppercaseChar() } + resId to title + }.getOrNull() + } + .sortedBy { it.second } + + private fun saveAlbumArt(trackId: Long, bytes: ByteArray): String? = try { + val cacheDir = File(context.cacheDir, "sample_album_art").also { it.mkdirs() } + val file = File(cacheDir, "art_$trackId.jpg") + file.writeBytes(bytes) + file.toURI().toString() + } catch (e: Exception) { + LogMusic.w(e, "Failed to save album art for trackId: $trackId") + null + } } diff --git a/app/src/main/java/com/happyseal/zenplayer/features/music/data/SampleMusicRepository.kt b/app/src/main/java/com/happyseal/zenplayer/features/music/data/SampleMusicRepository.kt index 9b2bb8e9..a898a20e 100644 --- a/app/src/main/java/com/happyseal/zenplayer/features/music/data/SampleMusicRepository.kt +++ b/app/src/main/java/com/happyseal/zenplayer/features/music/data/SampleMusicRepository.kt @@ -3,8 +3,10 @@ package com.happyseal.zenplayer.features.music.data import com.happyseal.zenplayer.core.model.Track import com.happyseal.zenplayer.features.music.data.SampleMusicProvider import com.happyseal.zenplayer.features.music.domain.MusicRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @@ -22,10 +24,12 @@ constructor( ) : MusicRepository { override val isFallback: Flow = flowOf(false) - override suspend fun getAllTracks(): Result> = Result.success(sampleMusicProvider.getSampleTracks()) + override suspend fun getAllTracks(): Result> = withContext(Dispatchers.IO) { + Result.success(sampleMusicProvider.getSampleTracks()) + } - override suspend fun getTrackById(id: Long): Result { + override suspend fun getTrackById(id: Long): Result = withContext(Dispatchers.IO) { val track = sampleMusicProvider.getSampleTracks().find { it.id == id } - return Result.success(track) + Result.success(track) } } diff --git a/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailScreen.kt b/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailScreen.kt index 2b22f9fe..394a5cd2 100644 --- a/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/happyseal/zenplayer/features/playlist/ui/PlaylistDetailScreen.kt @@ -34,12 +34,14 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -49,9 +51,9 @@ import androidx.compose.ui.unit.dp import com.happyseal.zenplayer.R import com.happyseal.zenplayer.core.model.MiniPlayerAction import com.happyseal.zenplayer.core.model.Track -import com.happyseal.zenplayer.core.ui.component.AlbumArtThumbnail import com.happyseal.zenplayer.core.ui.component.MiniPlayerBar import com.happyseal.zenplayer.core.ui.component.MiniPlayerUiState +import com.happyseal.zenplayer.core.ui.component.TrackThumbnail import com.happyseal.zenplayer.core.ui.theme.TealThemeColor import com.happyseal.zenplayer.core.ui.theme.ZenAlpha import com.happyseal.zenplayer.core.ui.theme.ZenDimension @@ -93,17 +95,15 @@ fun PlaylistDetailScreen( Scaffold( modifier = modifier, snackbarHost = { SnackbarHost(snackbarHostState) }, - floatingActionButton = { - if (successState != null && successState.tracks.isNotEmpty()) { - ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.zen_play_all)) }, - icon = { Icon(Icons.Default.PlayArrow, null) }, - onClick = { - onAction(PlaylistDetailAction.PlayPlaylist(successState.playlist.id)) - onNavigateToPlayer() - }, - ) - } + topBar = { + TopAppBar( + title = { Text(successState?.playlist?.name ?: "") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.cd_navigate_back)) + } + }, + ) }, ) { paddingValues -> Box( @@ -132,7 +132,7 @@ fun PlaylistDetailScreen( contentPadding = PaddingValues( start = ZenDimension.Spacing.L, end = ZenDimension.Spacing.L, - bottom = ZenDimension.MiniPlayer.ListBottomPadding + navBarPadding, + bottom = ZenDimension.MiniPlayer.ListBottomPadding + ZenDimension.Fab.BottomPadding + navBarPadding, ), verticalArrangement = Arrangement.spacedBy(ZenDimension.Spacing.Xs), ) { @@ -140,7 +140,6 @@ fun PlaylistDetailScreen( item { PlaylistHeader( playlist = state.playlist, - onNavigateBack = onNavigateBack, modifier = Modifier.padding(bottom = ZenDimension.Spacing.S), ) } @@ -172,17 +171,34 @@ fun PlaylistDetailScreen( } } - if (miniPlayerUiState.currentTrackTitle != null) { - MiniPlayerBar( - uiState = miniPlayerUiState, - progressProvider = { miniPlayerProgress }, - onTogglePlayPause = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.TogglePlayPause)) }, - onSkipNext = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.SkipNext)) }, - onSkipPrevious = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.SkipPrevious)) }, - modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding(), - ) + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding(), + horizontalAlignment = Alignment.End, + ) { + if (successState != null && successState.tracks.isNotEmpty()) { + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.zen_play_all)) }, + icon = { Icon(Icons.Default.PlayArrow, null) }, + onClick = { + onAction(PlaylistDetailAction.PlayPlaylist(successState.playlist.id)) + onNavigateToPlayer() + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = Color.White, + modifier = Modifier.padding(end = ZenDimension.Spacing.L, bottom = ZenDimension.Spacing.S), + ) + } + if (miniPlayerUiState.currentTrackTitle != null) { + MiniPlayerBar( + uiState = miniPlayerUiState, + progressProvider = { miniPlayerProgress }, + onTogglePlayPause = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.TogglePlayPause)) }, + onSkipNext = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.SkipNext)) }, + onSkipPrevious = { onAction(PlaylistDetailAction.MiniPlayer(MiniPlayerAction.SkipPrevious)) }, + ) + } } } } @@ -191,41 +207,22 @@ fun PlaylistDetailScreen( @Composable private fun PlaylistHeader( playlist: Playlist, - onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier .fillMaxWidth() .padding(horizontal = ZenDimension.Spacing.L) - .padding(top = ZenDimension.Spacing.Xl), + .padding(top = ZenDimension.Spacing.L), horizontalAlignment = Alignment.CenterHorizontally, ) { - // 뒤로가기 버튼 - Row(modifier = Modifier.fillMaxWidth()) { - IconButton(onClick = onNavigateBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.cd_navigate_back), - ) - } - } // 커버 아트 (플레이리스트 첫 번째 트랙 앨범아트 사용) val firstTrack = playlist.tracks.firstOrNull() - Box( - modifier = Modifier - .size(192.dp) - .clip(RoundedCornerShape(24.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - AlbumArtThumbnail( - uri = firstTrack?.albumArtUri, - size = 192.dp, - trackId = firstTrack?.id ?: 0L, - modifier = Modifier.clip(RoundedCornerShape(24.dp)), - ) - } + TrackThumbnail( + albumArtUri = firstTrack?.albumArtUri, + size = 192.dp, + modifier = Modifier.clip(RoundedCornerShape(24.dp)), + ) Spacer(modifier = Modifier.size(ZenDimension.Spacing.L)) Text( text = playlist.name, @@ -238,7 +235,7 @@ private fun PlaylistHeader( val totalMs = playlist.tracks.sumOf { it.durationMs } val totalMinutes = totalMs / 60_000 Text( - text = "$trackCount tracks • $totalMinutes minutes", + text = stringResource(R.string.playlist_detail_track_count_info, trackCount, totalMinutes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -274,10 +271,9 @@ private fun PlaylistTrackItem( .size(ZenDimension.Thumbnail.Mini) .clip(RoundedCornerShape(ZenDimension.Spacing.S)), ) { - AlbumArtThumbnail( - uri = track.albumArtUri, + TrackThumbnail( + albumArtUri = track.albumArtUri, size = ZenDimension.Thumbnail.Mini, - trackId = track.id, ) // 현재 재생 중인 트랙에 오버레이 표시 if (isActive) { @@ -370,7 +366,6 @@ private fun PreviewPlaylistDetailContent() { item { PlaylistHeader( playlist = previewPlaylist, - onNavigateBack = {}, modifier = Modifier.padding(bottom = ZenDimension.Spacing.S), ) } diff --git a/app/src/main/java/com/happyseal/zenplayer/zen/miniplayer/ZenPlayerBar.kt b/app/src/main/java/com/happyseal/zenplayer/zen/miniplayer/ZenPlayerBar.kt index bba63dde..4c5d62e1 100644 --- a/app/src/main/java/com/happyseal/zenplayer/zen/miniplayer/ZenPlayerBar.kt +++ b/app/src/main/java/com/happyseal/zenplayer/zen/miniplayer/ZenPlayerBar.kt @@ -11,7 +11,6 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,7 +26,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Repeat @@ -54,16 +52,14 @@ import androidx.compose.runtime.setValue 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.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.happyseal.zenplayer.R import com.happyseal.zenplayer.core.model.Track +import com.happyseal.zenplayer.core.ui.component.TrackThumbnail import com.happyseal.zenplayer.core.ui.theme.ZenAlpha import com.happyseal.zenplayer.core.ui.theme.ZenDimension import com.happyseal.zenplayer.core.ui.theme.ZenPlayerTheme @@ -98,7 +94,7 @@ internal fun ZenPlayerBar( modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), shape = RoundedCornerShape(topStart = ZenDimension.Corner.Pill, topEnd = ZenDimension.Corner.Pill), - border = BorderStroke(1.dp, Color.White.copy(alpha = ZenAlpha.DISABLED)), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Column( modifier = Modifier @@ -469,35 +465,6 @@ private fun RepeatButton( } } -@Composable -private fun TrackThumbnail(albumArtUri: String?) { - if (albumArtUri.isNullOrBlank()) { - Box( - modifier = Modifier - .size(ZenDimension.Thumbnail.List) - .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(ZenDimension.Thumbnail.List), - ) - } -} - @Preview(showBackground = true, backgroundColor = 0xFFF8FAFC) @Composable private fun ZenPlayerBarNoMusicPreview() { diff --git a/app/src/main/java/com/happyseal/zenplayer/zen/musicselect/ZenAllTracksTabContent.kt b/app/src/main/java/com/happyseal/zenplayer/zen/musicselect/ZenAllTracksTabContent.kt index 6f600e1a..00e7fc62 100644 --- a/app/src/main/java/com/happyseal/zenplayer/zen/musicselect/ZenAllTracksTabContent.kt +++ b/app/src/main/java/com/happyseal/zenplayer/zen/musicselect/ZenAllTracksTabContent.kt @@ -59,7 +59,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.happyseal.zenplayer.R import com.happyseal.zenplayer.core.model.Track -import com.happyseal.zenplayer.core.ui.component.AlbumArtThumbnail +import com.happyseal.zenplayer.core.ui.component.TrackThumbnail import com.happyseal.zenplayer.core.ui.theme.ZenAlpha import com.happyseal.zenplayer.core.ui.theme.ZenDimension import com.happyseal.zenplayer.core.ui.theme.ZenPlayerTheme @@ -508,11 +508,10 @@ private fun TrackCard( modifier = Modifier.size(ZenDimension.Thumbnail.List), contentAlignment = Alignment.Center, ) { - AlbumArtThumbnail( - uri = track.albumArtUri, - size = ZenDimension.Thumbnail.List, - trackId = track.id, + TrackThumbnail( + albumArtUri = track.albumArtUri, modifier = Modifier.clip(CircleShape), + size = ZenDimension.Thumbnail.List, ) if (isSelected) { TrackOverlayIcon( diff --git a/app/src/main/res/raw/out_background_music.mp3 b/app/src/main/res/raw/out_background_music.mp3 new file mode 100644 index 00000000..f6b0d337 Binary files /dev/null and b/app/src/main/res/raw/out_background_music.mp3 differ diff --git a/app/src/main/res/raw/out_dark_ambient_background.mp3 b/app/src/main/res/raw/out_dark_ambient_background.mp3 new file mode 100644 index 00000000..b792e3e2 Binary files /dev/null and b/app/src/main/res/raw/out_dark_ambient_background.mp3 differ diff --git a/app/src/main/res/raw/out_inspiring.mp3 b/app/src/main/res/raw/out_inspiring.mp3 new file mode 100644 index 00000000..e967cc03 Binary files /dev/null and b/app/src/main/res/raw/out_inspiring.mp3 differ diff --git a/app/src/main/res/raw/out_meditation.mp3 b/app/src/main/res/raw/out_meditation.mp3 new file mode 100644 index 00000000..b58c4a83 Binary files /dev/null and b/app/src/main/res/raw/out_meditation.mp3 differ diff --git a/app/src/main/res/raw/out_nature.mp3 b/app/src/main/res/raw/out_nature.mp3 new file mode 100644 index 00000000..6c04f0bd Binary files /dev/null and b/app/src/main/res/raw/out_nature.mp3 differ diff --git a/app/src/main/res/raw/out_no_copyright_music.mp3 b/app/src/main/res/raw/out_no_copyright_music.mp3 new file mode 100644 index 00000000..0de5191f Binary files /dev/null and b/app/src/main/res/raw/out_no_copyright_music.mp3 differ diff --git a/app/src/main/res/raw/out_soft_background_music.mp3 b/app/src/main/res/raw/out_soft_background_music.mp3 new file mode 100644 index 00000000..ab18976b Binary files /dev/null and b/app/src/main/res/raw/out_soft_background_music.mp3 differ diff --git a/app/src/main/res/raw/sample_track_1.mp3 b/app/src/main/res/raw/sample_track_1.mp3 deleted file mode 100644 index 7bb741fa..00000000 Binary files a/app/src/main/res/raw/sample_track_1.mp3 and /dev/null differ diff --git a/app/src/main/res/raw/sample_track_2.mp3 b/app/src/main/res/raw/sample_track_2.mp3 deleted file mode 100644 index 7bb741fa..00000000 Binary files a/app/src/main/res/raw/sample_track_2.mp3 and /dev/null differ diff --git a/app/src/main/res/raw/sample_track_3.mp3 b/app/src/main/res/raw/sample_track_3.mp3 deleted file mode 100644 index 7bb741fa..00000000 Binary files a/app/src/main/res/raw/sample_track_3.mp3 and /dev/null differ diff --git a/app/src/main/res/raw/sample_track_4.mp3 b/app/src/main/res/raw/sample_track_4.mp3 deleted file mode 100644 index 7bb741fa..00000000 Binary files a/app/src/main/res/raw/sample_track_4.mp3 and /dev/null differ diff --git a/app/src/main/res/raw/sample_track_5.mp3 b/app/src/main/res/raw/sample_track_5.mp3 deleted file mode 100644 index 7bb741fa..00000000 Binary files a/app/src/main/res/raw/sample_track_5.mp3 and /dev/null differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2bc05024..78cea6c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,4 +91,5 @@ %d곡을 추가했습니다. 트랙 제거에 실패했습니다. 재생 목록을 불러오는데 실패했습니다. + %1$d곡 • %2$d분