diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5791375..2f8e604 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c80941..2d3bbf3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,14 @@ + + + + + + + + + diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt b/app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt index 3ffa0eb..26aea0e 100644 --- a/app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt +++ b/app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt @@ -1,14 +1,568 @@ package com.example.bcsd_android_2025_1 +import android.Manifest +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build import android.os.Bundle -import androidx.activity.enableEdgeToEdge +import android.os.IBinder +import android.provider.MediaStore +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.view.animation.Animation +import android.view.animation.TranslateAnimation +import android.widget.Button +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat +import androidx.appcompat.content.res.AppCompatResources +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.net.toUri +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.example.bcsd_android_2025_1.adapter.MusicAdapter +import com.example.bcsd_android_2025_1.model.MusicData +import com.example.bcsd_android_2025_1.service.MusicService +import com.example.bcsd_android_2025_1.utils.OpenSettings +import com.example.bcsd_android_2025_1.utils.getAlbumArt +import com.example.bcsd_android_2025_1.utils.toDurationFromSecond +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit class MainActivity : AppCompatActivity() { + private val requestPermission = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + permissions.entries.forEach { (permission, isGranted) -> + if (permission == Manifest.permission.POST_NOTIFICATIONS) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when (isGranted) { + true -> {} + else -> { + when (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { + true -> permissionDialog(true) + else -> permissionDialog(false) + } + } + } + } + } else if ( + permission == Manifest.permission.READ_EXTERNAL_STORAGE || permission == Manifest.permission.READ_MEDIA_AUDIO + ) { + when (isGranted) { + true -> getAudioFile() + else -> { + when ( + shouldShowRequestPermissionRationale( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO + else Manifest.permission.READ_EXTERNAL_STORAGE + ) + ) { + true -> permissionDialog(true) + else -> permissionDialog(false) + } + } + } + } + } + } + + private val openSettings = + registerForActivityResult(OpenSettings()) { + initView() + hidePermissionSettingsButton() + } + + private val onBackPressedCallback: OnBackPressedCallback = + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (isSlideUp) { + val animSlideDown = TranslateAnimation( + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 1f + ) + animSlideDown.duration = 300 + animSlideDown.fillAfter = false + + constraintLayoutExpanded.animation = animSlideDown + constraintLayoutExpanded.visibility = View.GONE + constraintLayoutNowPlaying.visibility = View.VISIBLE + isSlideUp = false + } else { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } + } + } + + lateinit var musicService: MusicService + private var isBinding = false + private var isSlideUp = false + var job: Job? = null + + private var connectionResult = object : ServiceConnection { + override fun onServiceDisconnected(name: ComponentName?) { + isBinding = false + } + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as MusicService.MusicBinder + musicService = binder.getService() + isBinding = true + initMusicView() + } + } + + private val dataList = mutableListOf() + private val musicAdapter = MusicAdapter() + private lateinit var recyclerViewMusicList: RecyclerView + private lateinit var textViewEmpty: TextView + private lateinit var textViewPermissionNeeded: TextView + private lateinit var buttonPermissionSettings: Button + private lateinit var constraintLayoutNowPlaying: ConstraintLayout + private lateinit var textViewNowTitle: TextView + private lateinit var textViewNowArtist: TextView + private lateinit var imageViewNowAlbumArt: ImageView + private lateinit var buttonPlayPause: ImageButton + private lateinit var constraintLayoutExpanded: ConstraintLayout + private lateinit var textViewExpandedTitle: TextView + private lateinit var textViewExpandedArtist: TextView + private lateinit var imageViewExpandedAlbumArt: ImageView + private lateinit var buttonPlayPauseExpanded: ImageButton + private lateinit var seekBarExpanded: SeekBar + private lateinit var textViewExpandedPlayTime: TextView + private lateinit var textViewExpandedMusicTime: TextView + + + @SuppressLint("ClickableViewAccessibility") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + + recyclerViewMusicList = findViewById(R.id.recycler_view_music_list) + textViewEmpty = findViewById(R.id.text_view_empty) + textViewPermissionNeeded = findViewById(R.id.text_view_permission_needed) + buttonPermissionSettings = findViewById(R.id.button_permission_settings) + + constraintLayoutNowPlaying = findViewById(R.id.constraint_layout_now_playing) + imageViewNowAlbumArt = findViewById(R.id.image_view_now_album_art) + textViewNowTitle = findViewById(R.id.text_view_now_title) + textViewNowArtist = findViewById(R.id.text_view_now_artist) + buttonPlayPause = findViewById(R.id.button_play_pause) + + constraintLayoutExpanded = findViewById(R.id.constraint_layout_expanded) + textViewExpandedTitle = findViewById(R.id.text_view_expanded_title) + textViewExpandedArtist = findViewById(R.id.text_view_expanded_artist) + imageViewExpandedAlbumArt = findViewById(R.id.image_view_expanded_album_art) + buttonPlayPauseExpanded = findViewById(R.id.button_expanded_play_pause) + seekBarExpanded = findViewById(R.id.seek_bar_expanded) + textViewExpandedPlayTime = findViewById(R.id.text_view_expanded_play_time) + textViewExpandedMusicTime = findViewById(R.id.text_view_expanded_music_time) + + val buttonPermissionSettings: Button = findViewById(R.id.button_permission_settings) + + initView() + when (isBinding) { + true -> initMusicView() + else -> initService() + } + + buttonPermissionSettings.setOnClickListener { + /* + val intent = Intent() + intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", packageName, null) + intent.setData(uri) + startActivity(intent) + */ + openSettings.launch(Unit) + } + + buttonPlayPause.setOnClickListener { + if (isBinding) { + if (seekBarExpanded.progress == seekBarExpanded.max) { + musicService.startMusic(musicService.nowMusic) + } else { + musicService.playPauseMusic() + } + } + } + + buttonPlayPauseExpanded.setOnClickListener { + if (isBinding) { + if (seekBarExpanded.progress == seekBarExpanded.max) { + musicService.startMusic(musicService.nowMusic) + } else { + musicService.playPauseMusic() + } + } + } + + musicAdapter.setOnClickListener { + setNowPlaying(it) + } + + constraintLayoutNowPlaying.setOnClickListener { + val animSlideUp = TranslateAnimation( + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 1f, + Animation.RELATIVE_TO_PARENT, 0f + ) + animSlideUp.duration = 300 + animSlideUp.fillAfter = true + + constraintLayoutExpanded.animation = animSlideUp + constraintLayoutExpanded.visibility = View.VISIBLE + + isSlideUp = true + } + + constraintLayoutNowPlaying.setOnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_UP) { + v.performClick() + } + true + } + + constraintLayoutExpanded.setOnTouchListener { v, event -> + if (event.action == MotionEvent.ACTION_UP) { + v.performClick() + } + true + } + + constraintLayoutExpanded.setOnClickListener { + val animSlideDown = TranslateAnimation( + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 1f + ) + animSlideDown.duration = 300 + animSlideDown.fillAfter = false + + constraintLayoutExpanded.animation = animSlideDown + constraintLayoutExpanded.visibility = View.GONE + isSlideUp = false + } + + seekBarExpanded.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (seekBar != null) { + if (fromUser) { + Log.d( + "Time", + "Progress changed: ${progress.toLong().toDurationFromSecond()}" + ) + textViewExpandedPlayTime.text = progress.toLong().toDurationFromSecond() + setMusicPosition(progress) + } + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + job?.cancel() + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + if (musicService.isPlaying()) + waitUntilMusicEnd() + } + }) + } + + private fun initService() { + val intent = Intent(this, MusicService::class.java) + startService(intent) + bindService(intent, connectionResult, Context.BIND_AUTO_CREATE) + } + + private fun initView() { + val dividerItemDecoration = DividerItemDecoration( + recyclerViewMusicList.context, + LinearLayoutManager(this).orientation + ) + + musicAdapter.dataList = dataList + recyclerViewMusicList.apply { + setHasFixedSize(true) + layoutManager = LinearLayoutManager(context) + adapter = musicAdapter + addItemDecoration(dividerItemDecoration) + } + + checkPermission() + } + + private fun initMusicView() { + if (musicService.isPlaying() || musicService.isPaused()) { + waitUntilMusicEnd() + val nowMusic = musicService.nowMusic + + val albumArt = nowMusic.albumUri.toUri().getAlbumArt(this@MainActivity, resources) + imageViewNowAlbumArt.setImageDrawable(albumArt) + imageViewExpandedAlbumArt.setImageDrawable(albumArt) + + textViewNowTitle.text = nowMusic.title + textViewExpandedTitle.text = nowMusic.title + textViewNowArtist.text = nowMusic.artist + textViewExpandedArtist.text = nowMusic.artist + + textViewExpandedPlayTime.text = TimeUnit.MILLISECONDS.toSeconds( + musicService.getCurrentPosition() + .toLong() + ).toDurationFromSecond() + textViewExpandedMusicTime.text = + TimeUnit.MILLISECONDS.toSeconds(nowMusic.duration).toDurationFromSecond() + } + + initCallback() + initPlayPauseButton(musicService.isPlaying()) + } + + private fun initCallback() { + val mediaStateChangeCallback = object : MusicService.OnMediaStateChangeCallback { + override fun onMediaStateChange(isPlaying: Boolean) { + initPlayPauseButton(isPlaying) + } + + override fun mediaPlayEnd() { + initPlayPauseButton(false) + } + + override fun mediaPlayStart() { + initMusicView() + waitUntilMusicEnd() + } + } + musicService.setMediaStateChangeCallback(mediaStateChangeCallback) + } + + fun initPlayPauseButton(isPlaying: Boolean) { + when (isPlaying) { + true -> { + buttonPlayPause.setImageDrawable( + AppCompatResources.getDrawable( + this@MainActivity, + R.drawable.ic_pause + ) + ) + buttonPlayPauseExpanded.setImageDrawable( + AppCompatResources.getDrawable( + this@MainActivity, + R.drawable.ic_pause + ) + ) + waitUntilMusicEnd() + } + + else -> { + buttonPlayPause.setImageDrawable( + AppCompatResources.getDrawable( + this@MainActivity, + R.drawable.ic_play + ) + ) + buttonPlayPauseExpanded.setImageDrawable( + AppCompatResources.getDrawable( + this@MainActivity, + R.drawable.ic_play + ) + ) + job?.cancel() + } + } + } + + private fun checkPermission() { + val permissions = arrayOf( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO + else Manifest.permission.READ_EXTERNAL_STORAGE, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.POST_NOTIFICATIONS + else "" + ) + requestPermission.launch(permissions) + } + + private fun permissionDialog(isDeniedOnce: Boolean) { + when (isDeniedOnce) { + true -> { + val builder = AlertDialog.Builder(this) + builder.setTitle(getString(R.string.dialog_permission_title)) + .setMessage(getString(R.string.dialog_permission_messsage)) + .setPositiveButton(getString(R.string.dialog_permission_ok)) { _, _ -> + checkPermission() + } + .setNegativeButton(getString(R.string.dialog_permission_cancel)) { dialog, _ -> + dialog.dismiss() + showPermissionSettingsButton() + } + .setCancelable(false) + builder.show() + } + + else -> showPermissionSettingsButton() + } + } + + private fun showPermissionSettingsButton() { + recyclerViewMusicList.visibility = View.GONE + textViewEmpty.visibility = View.GONE + constraintLayoutNowPlaying.visibility = View.GONE + textViewPermissionNeeded.visibility = View.VISIBLE + buttonPermissionSettings.visibility = View.VISIBLE + } + + private fun hidePermissionSettingsButton() { + recyclerViewMusicList.visibility = View.VISIBLE + constraintLayoutNowPlaying.visibility = View.VISIBLE + textViewPermissionNeeded.visibility = View.GONE + buttonPermissionSettings.visibility = View.GONE + } + + private fun getAudioFile() { + val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.DURATION + ) + + val sortOrder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + "${MediaStore.Files.FileColumns.ALBUM}, ${MediaStore.Files.FileColumns.ARTIST}, CAST(${MediaStore.Files.FileColumns.CD_TRACK_NUMBER} AS INTEGER)" + } else { + " ${MediaStore.Audio.AlbumColumns.ALBUM}, ${MediaStore.Audio.AlbumColumns.ARTIST}, CAST(${MediaStore.Audio.AudioColumns.TRACK} AS INTEGER)" + } + val cursor = this.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + MediaStore.Audio.Media.IS_MUSIC, + null, + sortOrder + ) + + cursor?.use { + val idColumn = cursor.getColumnIndex(MediaStore.Audio.Media._ID) + val albumIdColumn = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID) + val titleColumn = cursor.getColumnIndex(MediaStore.Audio.Media.TITLE) + val artistColumn = cursor.getColumnIndex(MediaStore.Audio.Media.ARTIST) + val durationColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DURATION) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val albumId = cursor.getLong(albumIdColumn) + val title = cursor.getString(titleColumn) + val artist = cursor.getString(artistColumn) + val duration = cursor.getLong(durationColumn) + + val musicUri = "${MediaStore.Audio.Media.EXTERNAL_CONTENT_URI}/$id" + + val albumUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "${MediaStore.Audio.Media.EXTERNAL_CONTENT_URI}/$id" + } else { + "content://media/external/audio/albumart/$albumId" + } + + dataList.add(MusicData(title, artist, duration, musicUri, albumUri)) + musicAdapter.notifyItemInserted(musicAdapter.itemCount) + } + } + checkIsMusicEmpty() + } + + private fun checkIsMusicEmpty() { + if (musicAdapter.itemCount != 0) { + textViewEmpty.visibility = View.GONE + } else { + textViewEmpty.visibility = View.VISIBLE + } + } + + private fun setNowPlaying(musicData: MusicData) { + textViewExpandedMusicTime.text = + TimeUnit.MILLISECONDS.toSeconds(musicData.duration).toDurationFromSecond() + + when (isBinding) { + true -> musicService.startMusic(musicData) + else -> initService() + } + } + + private fun setMusicPosition(seconds: Int) { + musicService.updatePosition(seconds) + } + + private fun waitUntilMusicEnd() { + val currentPosition = musicService.getCurrentPosition() / 1000 + val seconds = + TimeUnit.MILLISECONDS.toSeconds(musicService.nowMusic.duration).toInt() + + var nowSeconds = currentPosition + seekBarExpanded.max = seconds + seekBarExpanded.progress = currentPosition + + if (job != null && job!!.isActive) { + job!!.cancel() + } + + job = CoroutineScope(Dispatchers.Main).launch { + repeat(seconds - currentPosition) { + delay(1000) + nowSeconds++ + seekBarExpanded.incrementProgressBy(1) + textViewExpandedPlayTime.text = nowSeconds.toLong().toDurationFromSecond() + } + } + } + + override fun onDestroy() { + super.onDestroy() + job?.cancel() + if (isBinding) + if (!musicService.isPlaying()) { + musicService.killService() + } + unbindService(connectionResult) + isBinding = false + } + + override fun onBackPressed() { + if (isSlideUp) { + val animSlideDown = TranslateAnimation( + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 0f, + Animation.RELATIVE_TO_PARENT, 1f + ) + animSlideDown.duration = 300 + animSlideDown.fillAfter = false + + constraintLayoutExpanded.animation = animSlideDown + constraintLayoutExpanded.visibility = View.GONE + constraintLayoutNowPlaying.visibility = View.VISIBLE + isSlideUp = false + } else { + super.onBackPressed() + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/adapter/MusicAdapter.kt b/app/src/main/java/com/example/bcsd_android_2025_1/adapter/MusicAdapter.kt new file mode 100644 index 0000000..cd9136b --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/adapter/MusicAdapter.kt @@ -0,0 +1,58 @@ +package com.example.bcsd_android_2025_1.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.net.toUri +import androidx.recyclerview.widget.RecyclerView +import com.example.bcsd_android_2025_1.R +import com.example.bcsd_android_2025_1.model.MusicData +import com.example.bcsd_android_2025_1.utils.getAlbumArt +import com.example.bcsd_android_2025_1.utils.toDurationFromMillisecond + +class MusicAdapter : RecyclerView.Adapter() { + var dataList = mutableListOf() + lateinit var onClickListener: OnClickListener + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = + ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_music, parent, false)) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + with(holder) { + val item = dataList[position] + titleTextView.text = item.title + artistTextView.text = item.artist + durationTextView.text = item.duration.toDurationFromMillisecond() + + val albumArt = item.albumUri.toUri().getAlbumArt(itemView.context, itemView.resources) + albumArtImage.setImageDrawable(albumArt) + + itemView.setOnClickListener { + onClickListener.onClick(item) + } + } + } + + override fun getItemCount(): Int = dataList.size + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val titleTextView: TextView = itemView.findViewById(R.id.title_text_view) + val artistTextView: TextView = itemView.findViewById(R.id.artist_text_view) + val durationTextView: TextView = itemView.findViewById(R.id.duration_text_view) + val albumArtImage: ImageView = itemView.findViewById(R.id.album_art_image) + } + + interface OnClickListener { + fun onClick(music: MusicData) + } + + inline fun setOnClickListener(crossinline item: (MusicData) -> Unit) { + this.onClickListener = object : OnClickListener { + override fun onClick(music: MusicData) { + item(music) + } + } + } +} diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/model/MusicData.kt b/app/src/main/java/com/example/bcsd_android_2025_1/model/MusicData.kt new file mode 100644 index 0000000..018fd0d --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/model/MusicData.kt @@ -0,0 +1,9 @@ +package com.example.bcsd_android_2025_1.model + +data class MusicData( + val title: String, + val artist: String, + val duration: Long, + val musicUri: String, + val albumUri: String +) diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/service/MusicService.kt b/app/src/main/java/com/example/bcsd_android_2025_1/service/MusicService.kt new file mode 100644 index 0000000..c1b0b73 --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/service/MusicService.kt @@ -0,0 +1,223 @@ +package com.example.bcsd_android_2025_1.service + +import android.Manifest +import android.Manifest.permission.POST_NOTIFICATIONS +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.AudioManager.OnAudioFocusChangeListener +import android.media.MediaPlayer +import android.net.Uri +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.util.Size +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import com.example.bcsd_android_2025_1.MainActivity +import com.example.bcsd_android_2025_1.R +import com.example.bcsd_android_2025_1.model.MusicData +import com.example.bcsd_android_2025_1.utils.getAlbumArt +import com.example.bcsd_android_2025_1.utils.getBitmapAlbumArt +import java.io.FileNotFoundException +import java.io.InputStream + +class MusicService : Service() { + + companion object { + const val CHANNEL_ID = "notification_music" + const val notificationId = 1000 + } + + private val binder = MusicBinder() + private lateinit var mediaPlayer: MediaPlayer + lateinit var nowMusic: MusicData + lateinit var onMediaStateChangeCallback: OnMediaStateChangeCallback + + lateinit var audioFocusRequest: AudioFocusRequest + + private val audioFocusChangeListener = + OnAudioFocusChangeListener { + playPauseMusic() + onMediaStateChangeCallback.onMediaStateChange(false) + } + + override fun onBind(intent: Intent): IBinder { + return binder + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + createNotificationChannel() + + val audioManager = this.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + .setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + audioManager.requestAudioFocus(audioFocusRequest) + return START_STICKY + } + + inner class MusicBinder : Binder() { + fun getService(): MusicService { + return this@MusicService + } + } + + private fun createNotificationChannel() { + val name = getString(R.string.notification_channel_name) + val descriptionText = getString(R.string.notification_channel_desc) + val importance = NotificationManager.IMPORTANCE_LOW + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + setShowBadge(false) + } + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + private fun createNotification(musicData: MusicData) { + val image: Bitmap = musicData.albumUri.toUri().getBitmapAlbumArt(this, resources) + val intent = Intent(this, MainActivity::class.java) + + val pendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.getActivity( + this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } else { + PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_music) + .setLargeIcon(image) + .setContentTitle(musicData.title) + .setContentText(musicData.artist) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_LOW) // Need for api <= 25 + .setAutoCancel(true) + + with(NotificationManagerCompat.from(this)) { + if (checkSelfPermission(POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED) { + notify(notificationId, builder.build()) + } + } + startForeground(notificationId, builder.build()) + } + + fun startMusic(musicData: MusicData) { + nowMusic = musicData + if (this::mediaPlayer.isInitialized && isPlaying()) + stopMusic() + createNotification(musicData) + mediaPlayer = MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build() + ) + setDataSource(applicationContext, musicData.musicUri.toUri()) + prepareAsync() + } + + mediaPlayer.setOnPreparedListener { + mediaPlayer.start() + onMediaStateChangeCallback.mediaPlayStart() + } + + mediaPlayer.setOnCompletionListener { + onMediaStateChangeCallback.mediaPlayEnd() + killService() + } + onMediaStateChangeCallback.onMediaStateChange(true) + } + + private fun stopMusic() { + mediaPlayer.apply { + stop() + } + } + + fun isPlaying(): Boolean { + return if (isMediaPlayerInitialized()) + mediaPlayer.isPlaying + else + false + } + + fun isPaused(): Boolean { + return if (isMediaPlayerInitialized()) + mediaPlayer.currentPosition != 0 + else + false + } + + + private fun isMediaPlayerInitialized(): Boolean { + return this::mediaPlayer.isInitialized + } + + fun playPauseMusic() { + if (isMediaPlayerInitialized()) { + when (isPlaying()) { + true -> { + mediaPlayer.pause() + } + + else -> { + mediaPlayer.start() + } + } + onMediaStateChangeCallback.onMediaStateChange(isPlaying()) + } + } + + fun updatePosition(seconds: Int) { + mediaPlayer.seekTo(seconds * 1000) + } + + fun killService() { + val audioManager = this.getSystemService(AUDIO_SERVICE) as AudioManager + + audioManager.abandonAudioFocusRequest(audioFocusRequest) + + stopForeground(true) + stopSelf() + } + + fun getCurrentPosition(): Int { + return mediaPlayer.currentPosition + } + + fun setMediaStateChangeCallback(onMediaStateChangeCallback: OnMediaStateChangeCallback) { + this.onMediaStateChangeCallback = onMediaStateChangeCallback + + } + + interface OnMediaStateChangeCallback { + fun onMediaStateChange(isPlaying: Boolean) + fun mediaPlayEnd() + fun mediaPlayStart() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/utils/OpenSettings.kt b/app/src/main/java/com/example/bcsd_android_2025_1/utils/OpenSettings.kt new file mode 100644 index 0000000..5f82f74 --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/utils/OpenSettings.kt @@ -0,0 +1,20 @@ +package com.example.bcsd_android_2025_1.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.activity.result.contract.ActivityResultContract + +class OpenSettings : ActivityResultContract() { + override fun createIntent(context: Context, input: Unit): Intent { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri: Uri = Uri.fromParts("package", context.packageName, null) + intent.data = uri + return intent + } + + override fun parseResult(resultCode: Int, intent: Intent?): Int { + return resultCode + } +} diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/utils/Utils.kt b/app/src/main/java/com/example/bcsd_android_2025_1/utils/Utils.kt new file mode 100644 index 0000000..227f20c --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/utils/Utils.kt @@ -0,0 +1,93 @@ +package com.example.bcsd_android_2025_1.utils + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.util.Log +import android.util.Size +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import com.example.bcsd_android_2025_1.R +import java.io.FileNotFoundException +import java.io.InputStream +import java.util.Locale +import java.util.concurrent.TimeUnit + +fun Long.toDurationFromSecond(): String { + val hours = TimeUnit.SECONDS.toHours(this) + val minutes = + TimeUnit.SECONDS.toMinutes(this) - TimeUnit.HOURS.toMinutes(hours) + val newSeconds = + this - TimeUnit.HOURS.toSeconds(hours) - TimeUnit.MINUTES.toSeconds(minutes) + + val duration = if (hours.toInt() != 0) { + String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, newSeconds) + } else { + String.format(Locale.ROOT, "%02d:%02d", minutes, newSeconds) + } + return duration +} + +fun Long.toDurationFromMillisecond(): String { + val hours = TimeUnit.MILLISECONDS.toHours(this) + val minutes = + TimeUnit.MILLISECONDS.toMinutes(this) - TimeUnit.HOURS.toMinutes(hours) + val seconds = + TimeUnit.MILLISECONDS.toSeconds(this) - TimeUnit.MINUTES.toSeconds(minutes) + + Log.d("Duration", "Hours: $hours, Minutes: $minutes, Seconds: $seconds") + + val duration = if (hours.toInt() != 0) { + String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.ROOT, "%02d:%02d", minutes, seconds) + } + return duration +} + +fun Uri.getBitmapAlbumArt( + context: Context, + resources: Resources, + width: Int = 500, + height: Int = 500 +): Bitmap { + return getAlbumArt(context, resources, width, height)?.toBitmap() + ?: BitmapFactory.decodeResource(resources, R.drawable.ic_no_album_art) +} + + +fun Uri.getAlbumArt( + context: Context, + resources: Resources, + width: Int = 500, + height: Int = 500 +): Drawable? { + var inputStream: InputStream? = null + val albumArt: Drawable? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + try { + context.contentResolver.loadThumbnail(this, Size(500, 500), null) + .toDrawable(resources) + } catch (e: FileNotFoundException) { + ResourcesCompat.getDrawable(resources, R.drawable.ic_no_album_art, null) + } + } else { + try { + inputStream = context.contentResolver.openInputStream(this) + val option = BitmapFactory.Options() + option.outWidth = width + option.outHeight = height + option.inSampleSize = 2 + BitmapFactory.decodeStream(inputStream, null, option)?.toDrawable(resources) + } catch (e: FileNotFoundException) { + ResourcesCompat.getDrawable(resources, R.drawable.ic_no_album_art, null) + } + } + inputStream?.close() + + return albumArt +} diff --git a/app/src/main/res/drawable-anydpi-v24/ic_notification_music.xml b/app/src/main/res/drawable-anydpi-v24/ic_notification_music.xml new file mode 100644 index 0000000..c012b25 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_notification_music.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_notification_music.png b/app/src/main/res/drawable-hdpi/ic_notification_music.png new file mode 100644 index 0000000..3a4f1da Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notification_music.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_notification_music.png b/app/src/main/res/drawable-mdpi/ic_notification_music.png new file mode 100644 index 0000000..8e54b97 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notification_music.png differ diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_notification_music.png b/app/src/main/res/drawable-xhdpi/ic_notification_music.png new file mode 100644 index 0000000..11dc2ba Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification_music.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification_music.png b/app/src/main/res/drawable-xxhdpi/ic_notification_music.png new file mode 100644 index 0000000..285a3ed Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification_music.png differ diff --git a/app/src/main/res/drawable/ic_no_album_art.xml b/app/src/main/res/drawable/ic_no_album_art.xml new file mode 100644 index 0000000..5f32b8e --- /dev/null +++ b/app/src/main/res/drawable/ic_no_album_art.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..4cb44fe --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..1c21e6a --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 311f3cb..bed60ef 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -2,18 +2,215 @@ + + + + +