diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..cec0517
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+android_2026_1
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..84397a1
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..639c779
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..b2c751a
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/DevLog.txt b/DevLog.txt
new file mode 100644
index 0000000..87a7dd8
--- /dev/null
+++ b/DevLog.txt
@@ -0,0 +1,63 @@
+음악 플레이어 실습
+
+기기의 음악을 MediaStore로 읽어 RecyclerView로 리스팅
+화면 하단에 재생중인 음악을 표시할 UI 구현
+권한 취득, 실패시 알림과 설정으로 가는 버튼 생성 -> 프래그먼트 교체로 구현
+RecyclerView의 버튼들에 음악 재생 기능을 구현
+지워지지 않는 알림 띄우기
+
+1. AndroidManifest.xml에 권한 선언
+2. MainActivity 레이아웃 구성
+3. 권한 요청 구현
+4. MediaStore 데이터 조회 및 RecyclerView 구현
+5. 포그라운드 서비스 클래스 생성
+6. 알림채널 및 알림 생성
+7. MediaPlayer 초기화 및 제어 코드 작성
+8. RecyclerView 아이템 클릭 이벤트 연결
+9. 화면 하단 미니 플레이어 UI 연동
+
+MusicService.kt
+onBind
+ 바운드 서비스가 아니므로 null
+onCreate
+ 알림 채널 등록 함수 실행
+onStartCommand
+ 포그라운드/백그라운드 명령 처리
+playMusic
+ MediaPlayer 메모리 및 실행 처리
+stopMusic
+ 메모리 처리
+createNotification
+ 알림객체 생성용, intent 설정
+createNotificationChannel
+ 알림채널 구축
+onDestroy
+ 음악 리소스 해제
+
+MusicAdapter.kt
+onCreateViewHolder
+ 뷰바인딩-뷰홀더 생성
+onBindViewHolder
+ 뷰바인딩 설정 및 콜백함수 호출
+getItemCount
+ 뷰 크기 반환용
+
+MainActivity.kt
+onCreate
+ 뷰바인딩 초기화, 권한체크
+onResume
+ 권한 상태 확인 및 UI 갱신
+checkMusicPermission
+ 이름그대로
+setupMusicPlayer
+ UI갱신 및 리사이클러뷰 초기화, 어댑터 연결, 알림 권한 체크
+checkNotificationPermission
+ 이름그대로
+showPermissionDeniedUI
+ 이름그대로
+loadMusicFiles
+ ContentResolver와 MediaStore 사용
+startMusicService
+ 음악 정보를 인텐트에 담아 서비스 포그라운드로 실행
+updateBottomPlayer
+ 이름그대로
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cc8ee59..0478d2b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -29,6 +29,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
+ buildFeatures {
+ viewBinding = true
+ }
}
dependencies {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e58d838..45d874e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,11 @@
+
+
+
+
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/android_2026_1/MainActivity.kt b/app/src/main/java/com/example/android_2026_1/MainActivity.kt
index ad2a6ca..553b950 100644
--- a/app/src/main/java/com/example/android_2026_1/MainActivity.kt
+++ b/app/src/main/java/com/example/android_2026_1/MainActivity.kt
@@ -1,20 +1,154 @@
package com.example.android_2026_1
+import android.Manifest
+import android.content.ContentUris
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
import android.os.Bundle
-import androidx.activity.enableEdgeToEdge
+import android.provider.MediaStore
+import android.provider.Settings
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.example.android_2026_1.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMainBinding
+ private val musicList = mutableListOf()
+ private lateinit var adapter: MusicAdapter
+
+ private val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ setupMusicPlayer()
+ } else {
+ showPermissionDeniedUI()
+ }
+ }
+
+ private val requestNotificationPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { _ ->
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- enableEdgeToEdge()
- setContentView(R.layout.activity_main)
- ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
- val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
- v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
- insets
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ binding.btnSettings.setOnClickListener {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts(SCHEME_PACKAGE, packageName, null)
+ }
+ startActivity(intent)
}
+
+ checkMusicPermission()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ checkMusicPermission()
+ }
+
+ private fun checkMusicPermission() {
+ val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_AUDIO
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+
+ if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
+ setupMusicPlayer()
+ } else {
+ requestPermissionLauncher.launch(permission)
+ }
+ }
+
+ private fun setupMusicPlayer() {
+ binding.layoutPermissionDenied.visibility = View.GONE
+ binding.layoutMusicContainer.visibility = View.VISIBLE
+
+ if (musicList.isEmpty()) {
+ loadMusicFiles()
+ adapter = MusicAdapter(musicList) { item ->
+ startMusicService(item)
+ updateBottomPlayer(item)
+ }
+ binding.recyclerView.layoutManager = LinearLayoutManager(this)
+ binding.recyclerView.adapter = adapter
+ }
+
+ checkNotificationPermission()
+ }
+
+ private fun checkNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ }
+
+ private fun showPermissionDeniedUI() {
+ binding.layoutPermissionDenied.visibility = View.VISIBLE
+ binding.layoutMusicContainer.visibility = View.GONE
+ }
+
+ private fun loadMusicFiles() {
+ val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
+ } else {
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
+ }
+
+ val projection = arrayOf(
+ MediaStore.Audio.Media._ID,
+ MediaStore.Audio.Media.TITLE,
+ MediaStore.Audio.Media.ARTIST,
+ MediaStore.Audio.Media.DURATION
+ )
+
+ val query = contentResolver.query(collection, projection, null, null, null)
+ query?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
+ val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
+ val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
+ val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
+
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(idColumn)
+ val title = cursor.getString(titleColumn)
+ val artist = cursor.getString(artistColumn)
+ val duration = cursor.getLong(durationColumn)
+ val contentUri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)
+
+ musicList.add(MusicItem(id, title, artist, duration, contentUri))
+ }
+ }
+ }
+
+ private fun startMusicService(item: MusicItem) {
+ val intent = Intent(this, MusicService::class.java).apply {
+ putExtra(MusicService.EXTRA_TITLE, item.title)
+ putExtra(MusicService.EXTRA_ARTIST, item.artist)
+ putExtra(MusicService.EXTRA_URI, item.uri.toString())
+ }
+ ContextCompat.startForegroundService(this, intent)
+ }
+
+ private fun updateBottomPlayer(item: MusicItem) {
+ binding.tvBottomTitle.text = item.title
+ binding.tvBottomArtist.text = item.artist
+ }
+
+ companion object {
+ const val SCHEME_PACKAGE = "package"
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/android_2026_1/MusicAdapter.kt b/app/src/main/java/com/example/android_2026_1/MusicAdapter.kt
new file mode 100644
index 0000000..d7fbeff
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/MusicAdapter.kt
@@ -0,0 +1,40 @@
+package com.example.android_2026_1
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.example.android_2026_1.databinding.ItemMusicBinding
+import java.util.concurrent.TimeUnit
+
+class MusicAdapter(
+ private val musicList: List,
+ private val onItemClick: (MusicItem) -> Unit
+) : RecyclerView.Adapter() {
+
+ inner class MusicViewHolder(val binding: ItemMusicBinding) : RecyclerView.ViewHolder(binding.root)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MusicViewHolder {
+ val binding = ItemMusicBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return MusicViewHolder(binding)
+ }
+
+ @SuppressLint("DefaultLocale")
+ override fun onBindViewHolder(holder: MusicViewHolder, position: Int) {
+ val item = musicList[position]
+ holder.binding.tvTitle.text = item.title
+ holder.binding.tvArtist.text = item.artist
+
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(item.duration)
+ val seconds = TimeUnit.MILLISECONDS.toSeconds(item.duration) % 60
+ holder.binding.tvDuration.text = String.format(FORMAT_DURATION, minutes, seconds)
+
+ holder.itemView.setOnClickListener { onItemClick(item) }
+ }
+
+ override fun getItemCount(): Int = musicList.size
+
+ companion object {
+ const val FORMAT_DURATION = "%02d:%02d"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/android_2026_1/MusicItem.kt b/app/src/main/java/com/example/android_2026_1/MusicItem.kt
new file mode 100644
index 0000000..c240486
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/MusicItem.kt
@@ -0,0 +1,11 @@
+package com.example.android_2026_1
+
+import android.net.Uri
+
+data class MusicItem(
+ val id: Long,
+ val title: String,
+ val artist: String,
+ val duration: Long,
+ val uri: Uri
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/android_2026_1/MusicService.kt b/app/src/main/java/com/example/android_2026_1/MusicService.kt
new file mode 100644
index 0000000..056d834
--- /dev/null
+++ b/app/src/main/java/com/example/android_2026_1/MusicService.kt
@@ -0,0 +1,106 @@
+package com.example.android_2026_1
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.media.MediaPlayer
+import android.net.Uri
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.net.toUri
+
+class MusicService : Service() {
+
+ private var mediaPlayer: MediaPlayer? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_STOP) {
+ stopMusic()
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ val title = intent?.getStringExtra(EXTRA_TITLE) ?: DEFAULT_UNKNOWN
+ val artist = intent?.getStringExtra(EXTRA_ARTIST) ?: DEFAULT_UNKNOWN
+ val uriString = intent?.getStringExtra(EXTRA_URI)
+
+ if (uriString != null) {
+ val uri = uriString.toUri()
+ playMusic(uri)
+ val notification = createNotification(title, artist)
+ startForeground(NOTIFICATION_ID, notification)
+ }
+
+ return START_NOT_STICKY
+ }
+
+ private fun playMusic(uri: Uri) {
+ mediaPlayer?.stop()
+ mediaPlayer?.release()
+ mediaPlayer = MediaPlayer.create(this, uri).apply {
+ start()
+ }
+ }
+
+ private fun stopMusic() {
+ mediaPlayer?.stop()
+ mediaPlayer?.release()
+ mediaPlayer = null
+ }
+
+ private fun createNotification(title: String, artist: String): Notification {
+ val intent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(artist)
+ .setSmallIcon(android.R.drawable.ic_media_play)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val manager = getSystemService(NotificationManager::class.java)
+ manager?.createNotificationChannel(channel)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ stopMusic()
+ }
+
+ companion object {
+ const val CHANNEL_ID = "music_channel"
+ const val CHANNEL_NAME = "Music Playback"
+ const val NOTIFICATION_ID = 1
+ const val EXTRA_TITLE = "title"
+ const val EXTRA_ARTIST = "artist"
+ const val EXTRA_URI = "uri"
+ const val DEFAULT_UNKNOWN = "Unknown"
+ const val ACTION_STOP = "com.example.android_2026_1.ACTION_STOP"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 9affce0..b3e6bf1 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,10 +1,65 @@
-
+ android:layout_height="match_parent">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_music.xml b/app/src/main/res/layout/item_music.xml
new file mode 100644
index 0000000..f6fe1f6
--- /dev/null
+++ b/app/src/main/res/layout/item_music.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index c8524cd..f45feed 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -2,4 +2,6 @@
#FF000000
#FFFFFFFF
+ #FFEEEEEE
+ #FF666666
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c4a5053..714a23f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,7 @@
android_2026_1
+ Allow permission to show files
+ Open Settings
+ 재생 중인 음악이 없습니다
+
\ No newline at end of file