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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ android {
lint {
abortOnError = true
warningsAsErrors = true
lintConfig = file("lint.xml")
disable += setOf(
"AndroidGradlePluginVersion",
"GradleDependency",
Expand Down
7 changes: 7 additions & 0 deletions app/lint.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- raw 리소스는 리플렉션(R.raw::class.java.fields)으로 동적 접근하므로 lint가 미사용으로 오탐함 -->
<issue id="UnusedResources">
<ignore path="res/raw/" />
</issue>
</lint>
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ interface PlaylistDao {
@Query("SELECT * FROM playlist_tracks WHERE playlistId = :playlistId ORDER BY position ASC")
suspend fun getTracksForPlaylistOnce(playlistId: Long): List<PlaylistTrackEntity>

@Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
suspend fun insertTrack(track: PlaylistTrackEntity): Long

@Transaction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ object PlaylistDatabaseModule {
context,
PlaylistDatabase::class.java,
"zenplayer_playlist.db",
).build()
)
.addMigrations(PlaylistDatabase.MIGRATION_1_2)
.build()

@Provides
@Singleton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

// 트랙 정보
Expand Down
Original file line number Diff line number Diff line change
@@ -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)),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 위에 리스트 콘텐츠가 가려지지 않을 패딩
}
}
Original file line number Diff line number Diff line change
@@ -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<Track> = 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<Track> = 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<Pair<Int, String>> = 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
}
}
Loading
Loading