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"> + + + + +