init: repo
This commit is contained in:
1
MusicPlayer/app/.gitignore
vendored
Normal file
1
MusicPlayer/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
119
MusicPlayer/app/build.gradle.kts
Normal file
119
MusicPlayer/app/build.gradle.kts
Normal file
@@ -0,0 +1,119 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.hilt.android)
|
||||
alias(libs.plugins.kotlin.ksp)
|
||||
id("kotlin-kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "top.rrricardo.musicplayer"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "top.rrricardo.musicplayer"
|
||||
minSdk = 28
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.1"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
implementation(libs.constraintlayout.compose)
|
||||
implementation(libs.coil)
|
||||
kapt(libs.hilt.android.compiler)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
// ViewModel
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
// ViewModel utilities for Compose
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
// LiveData
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
// Lifecycles only (without ViewModel or LiveData)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
// Lifecycle utilities for Compose
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
|
||||
// Saved state module for ViewModel
|
||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||
|
||||
// Annotation processor
|
||||
kapt(libs.androidx.lifecycle.compiler)
|
||||
|
||||
// Navigation Controller
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(libs.androidx.room.runtime)
|
||||
annotationProcessor(libs.androidx.room.compiler)
|
||||
|
||||
// To use Kotlin Symbol Processing (KSP)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// optional - Kotlin Extensions and Coroutines support for Room
|
||||
implementation(libs.androidx.room.room.ktx5)
|
||||
|
||||
// Palette
|
||||
implementation(libs.androidx.palette)
|
||||
|
||||
// Media Exo Player
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.ui)
|
||||
}
|
||||
|
||||
// Allow references to generated code
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
21
MusicPlayer/app/proguard-rules.pro
vendored
Normal file
21
MusicPlayer/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
@@ -0,0 +1,24 @@
|
||||
package top.rrricardo.musicplayer
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("top.rrricardo.musicplayer", appContext.packageName)
|
||||
}
|
||||
}
|
36
MusicPlayer/app/src/main/AndroidManifest.xml
Normal file
36
MusicPlayer/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MusicPlayer"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.MusicPlayer">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".service.impl.MusicService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class App : Application() {
|
||||
}
|
@@ -0,0 +1,62 @@
|
||||
package top.rrricardo.musicplayer
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import top.rrricardo.musicplayer.service.impl.MusicService
|
||||
import top.rrricardo.musicplayer.ui.MusicPlayerApp
|
||||
import top.rrricardo.musicplayer.ui.theme.MusicPlayerTheme
|
||||
import top.rrricardo.musicplayer.ui.viewmodel.SharedViewModel
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val sharedViewModel: SharedViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
|
||||
MusicPlayerTheme {
|
||||
MusicPlayerApp(
|
||||
sharedViewModel = sharedViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
sharedViewModel.destroyMediaController()
|
||||
stopService(Intent(this, MusicService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Hello $name!",
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun GreetingPreview() {
|
||||
MusicPlayerTheme {
|
||||
Greeting("Android")
|
||||
}
|
||||
}
|
@@ -0,0 +1,39 @@
|
||||
package top.rrricardo.musicplayer.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import top.rrricardo.musicplayer.service.AppDatabase
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import top.rrricardo.musicplayer.service.SongRepository
|
||||
import top.rrricardo.musicplayer.service.impl.LocalSongRepository
|
||||
import top.rrricardo.musicplayer.service.impl.MusicControllerImpl
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
// @Singleton
|
||||
// @Provides
|
||||
// fun provideDatabase(@ApplicationContext context: Context) = Room.databaseBuilder(
|
||||
// context,
|
||||
// AppDatabase::class.java, "music-store"
|
||||
// ).build()
|
||||
//
|
||||
// @Singleton
|
||||
// @Provides
|
||||
// fun provideSongRepository(database: AppDatabase) = database.songRepository()
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideSongRepository(): SongRepository = LocalSongRepository()
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideMusicController(@ApplicationContext context: Context): MusicController =
|
||||
MusicControllerImpl(context)
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
package top.rrricardo.musicplayer.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ServiceComponent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.scopes.ServiceScoped
|
||||
|
||||
@Module
|
||||
@InstallIn(ServiceComponent::class)
|
||||
object ServiceModule {
|
||||
|
||||
@ServiceScoped
|
||||
@Provides
|
||||
fun provideAudioAttributes() = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
@ServiceScoped
|
||||
@Provides
|
||||
fun provideExoPlayer(
|
||||
@ApplicationContext context: Context,
|
||||
autoAttributes: AudioAttributes
|
||||
) = ExoPlayer.Builder(context).build().apply {
|
||||
setAudioAttributes(autoAttributes, true)
|
||||
setHandleAudioBecomingNoisy(true)
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
package top.rrricardo.musicplayer.model
|
||||
|
||||
sealed class HomeEvent {
|
||||
data object PlaySong : HomeEvent()
|
||||
data object PauseSong : HomeEvent()
|
||||
data object ResumeSong : HomeEvent()
|
||||
data object FetchSong : HomeEvent()
|
||||
data object SkipToNextSong : HomeEvent()
|
||||
data object SkipToPreviousSong : HomeEvent()
|
||||
data class OnSongSelected(val selectedSong: Song) : HomeEvent()
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer.model
|
||||
|
||||
data class HomeUiState (
|
||||
val loading: Boolean = false,
|
||||
val songs: List<Song>? = emptyList(),
|
||||
val selectedSong: Song? = null,
|
||||
val errorMessage: String? = null
|
||||
)
|
@@ -0,0 +1,10 @@
|
||||
package top.rrricardo.musicplayer.model
|
||||
|
||||
data class MusicControllerUiState(
|
||||
val playerState: PlayState? = null,
|
||||
val currentSong: Song? = null,
|
||||
val currentPosition: Long = 0L,
|
||||
val totalDuration: Long = 0L,
|
||||
val isShuffleEnabled: Boolean = false,
|
||||
val isRepeatOneEnabled: Boolean = false
|
||||
)
|
@@ -0,0 +1,7 @@
|
||||
package top.rrricardo.musicplayer.model
|
||||
|
||||
enum class PlayState {
|
||||
PLAYING,
|
||||
PAUSED,
|
||||
STOPPED
|
||||
}
|
@@ -0,0 +1,26 @@
|
||||
package top.rrricardo.musicplayer.model
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity
|
||||
data class Song(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Int,
|
||||
val mediaId : String,
|
||||
val title: String,
|
||||
val subTitle: String,
|
||||
val songUrl: String,
|
||||
val imageUrl: String
|
||||
)
|
||||
|
||||
fun MediaItem.toSong() =
|
||||
Song(
|
||||
id = 0,
|
||||
mediaId = mediaId,
|
||||
title = mediaMetadata.title.toString(),
|
||||
subTitle = mediaMetadata.subtitle.toString(),
|
||||
songUrl = mediaId,
|
||||
imageUrl = mediaMetadata.artworkUri.toString()
|
||||
)
|
@@ -0,0 +1,9 @@
|
||||
package top.rrricardo.musicplayer.model
|
||||
|
||||
sealed class SongEvent {
|
||||
data object PauseSong : SongEvent()
|
||||
data object ResumeSong : SongEvent()
|
||||
data object SkipToNextSong : SongEvent()
|
||||
data object SkipToPreviousSong : SongEvent()
|
||||
data class SeekSongToPosition(val position : Long) : SongEvent()
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
package top.rrricardo.musicplayer.service
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
|
||||
@Database(entities = [Song::class], version = 1)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun songRepository() : SongRepository
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
package top.rrricardo.musicplayer.service
|
||||
|
||||
import top.rrricardo.musicplayer.model.PlayState
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
|
||||
interface MusicController {
|
||||
var mediaControllerCallback: (
|
||||
(
|
||||
playerState: PlayState,
|
||||
currentMusic: Song?,
|
||||
currentPosition: Long,
|
||||
totalDuration: Long,
|
||||
isShuffleEnabled: Boolean,
|
||||
isRepeatOneEnabled: Boolean
|
||||
) -> Unit
|
||||
)?
|
||||
|
||||
fun addMediaItems(songs: List<Song>)
|
||||
|
||||
fun play(mediaItemIndex: Int)
|
||||
|
||||
fun resume()
|
||||
|
||||
fun pause()
|
||||
|
||||
fun getCurrentPosition() : Long
|
||||
|
||||
fun destroy()
|
||||
|
||||
fun skipToNextSong()
|
||||
|
||||
fun skipToPreviousSong()
|
||||
|
||||
fun getCurrentSong() : Song?
|
||||
|
||||
fun seekTo(position: Long)
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
package top.rrricardo.musicplayer.service
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
|
||||
@Dao
|
||||
interface SongRepository {
|
||||
@Query("SELECT * FROM song")
|
||||
fun getSongs() : Flow<List<Song>>
|
||||
}
|
@@ -0,0 +1,24 @@
|
||||
package top.rrricardo.musicplayer.service.impl
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
import top.rrricardo.musicplayer.service.SongRepository
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class LocalSongRepository : SongRepository {
|
||||
override fun getSongs(): Flow<List<Song>> {
|
||||
return flow {
|
||||
delay(3.seconds)
|
||||
emit(listOf(Song(
|
||||
id = 0,
|
||||
mediaId = "Liyue",
|
||||
title = "璃月",
|
||||
subTitle = "璃月",
|
||||
songUrl = "https://oss.rrricardo.top/music/01%20Liyue.flac",
|
||||
imageUrl = "https://oss.rrricardo.top/oss/Artwork.jpg"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,126 @@
|
||||
package top.rrricardo.musicplayer.service.impl
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import top.rrricardo.musicplayer.model.PlayState
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
import top.rrricardo.musicplayer.model.toSong
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
|
||||
class MusicControllerImpl(context: Context) : MusicController {
|
||||
|
||||
private var mediaControllerFuture: ListenableFuture<MediaController>
|
||||
private val mediaController: MediaController?
|
||||
get() = if (mediaControllerFuture.isDone) mediaControllerFuture.get() else null
|
||||
|
||||
init {
|
||||
val sessionToken =
|
||||
SessionToken(context, ComponentName(context, MusicService::class.java))
|
||||
mediaControllerFuture = MediaController.Builder(context, sessionToken).buildAsync()
|
||||
mediaControllerFuture.addListener({
|
||||
controllerListener()
|
||||
}, MoreExecutors.directExecutor())
|
||||
}
|
||||
|
||||
override var mediaControllerCallback: (
|
||||
(
|
||||
playerState: PlayState,
|
||||
currentMusic: Song?,
|
||||
currentPosition: Long,
|
||||
totalDuration: Long,
|
||||
isShuffleEnabled: Boolean,
|
||||
isRepeatOneEnabled: Boolean
|
||||
) -> Unit)? = null
|
||||
|
||||
private fun controllerListener() {
|
||||
mediaController?.addListener(object : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
|
||||
with(player) {
|
||||
Log.i("MusicPlayer", "${player.isPlaying}")
|
||||
mediaControllerCallback?.invoke(
|
||||
playbackState.toPlayerState(isPlaying),
|
||||
currentMediaItem?.toSong(),
|
||||
currentPosition.coerceAtLeast(0L),
|
||||
duration.coerceAtLeast(0L),
|
||||
shuffleModeEnabled,
|
||||
repeatMode == Player.REPEAT_MODE_ONE
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun Int.toPlayerState(isPlaying: Boolean) =
|
||||
when (this) {
|
||||
Player.STATE_IDLE -> PlayState.STOPPED
|
||||
Player.STATE_ENDED -> PlayState.STOPPED
|
||||
else -> if (isPlaying) PlayState.PLAYING else PlayState.PAUSED
|
||||
}
|
||||
|
||||
override fun addMediaItems(songs: List<Song>) {
|
||||
val mediaItems = songs.map {
|
||||
MediaItem.Builder()
|
||||
.setMediaId(it.songUrl)
|
||||
.setUri(it.songUrl)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(it.title)
|
||||
.setSubtitle(it.subTitle)
|
||||
.setArtist(it.subTitle)
|
||||
.setArtworkUri(Uri.parse(it.imageUrl))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
mediaController?.setMediaItems(mediaItems)
|
||||
}
|
||||
|
||||
override fun play(mediaItemIndex: Int) {
|
||||
mediaController?.apply {
|
||||
seekToDefaultPosition(mediaItemIndex)
|
||||
prepare()
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
mediaController?.play()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
mediaController?.pause()
|
||||
}
|
||||
|
||||
override fun getCurrentPosition(): Long = mediaController?.currentPosition ?: 0L
|
||||
|
||||
override fun getCurrentSong(): Song? = mediaController?.currentMediaItem?.toSong()
|
||||
|
||||
override fun seekTo(position: Long) {
|
||||
mediaController?.seekTo(position)
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
MediaController.releaseFuture(mediaControllerFuture)
|
||||
mediaControllerCallback = null
|
||||
}
|
||||
|
||||
override fun skipToNextSong() {
|
||||
mediaController?.seekToNext()
|
||||
}
|
||||
|
||||
override fun skipToPreviousSong() {
|
||||
mediaController?.seekToPrevious()
|
||||
}
|
||||
}
|
@@ -0,0 +1,44 @@
|
||||
package top.rrricardo.musicplayer.service.impl
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MusicService : MediaSessionService() {
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
@Inject
|
||||
lateinit var exoPlayer: ExoPlayer
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
mediaSession = MediaSession.Builder(this, exoPlayer)
|
||||
.setCallback(MediaSessionCallback())
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
private inner class MediaSessionCallback : MediaSession.Callback {
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
val updateMediaItem = mediaItems.map {
|
||||
it.buildUpon().setUri(it.mediaId).build()
|
||||
}.toMutableList()
|
||||
|
||||
return Futures.immediateFuture(updateMediaItem)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,83 @@
|
||||
package top.rrricardo.musicplayer.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.autoSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import top.rrricardo.musicplayer.model.HomeEvent
|
||||
import top.rrricardo.musicplayer.ui.page.HomeBottomBar
|
||||
import top.rrricardo.musicplayer.ui.page.HomePage
|
||||
import top.rrricardo.musicplayer.ui.page.SongScreen
|
||||
import top.rrricardo.musicplayer.ui.viewmodel.HomeViewModel
|
||||
import top.rrricardo.musicplayer.ui.viewmodel.SharedViewModel
|
||||
import top.rrricardo.musicplayer.ui.viewmodel.SongViewModel
|
||||
|
||||
@Composable
|
||||
fun MusicPlayerApp(sharedViewModel: SharedViewModel) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
MusicPlayerNavHost(navController = navController, sharedViewModel = sharedViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MusicPlayerNavHost(navController: NavHostController, sharedViewModel: SharedViewModel) {
|
||||
val musicControllerUiState = sharedViewModel.musicControllerUiState
|
||||
|
||||
NavHost(navController = navController, startDestination = Navigation.HOME) {
|
||||
composable(route = Navigation.HOME) {
|
||||
val homeViewModel: HomeViewModel = hiltViewModel()
|
||||
val isInitialized = rememberSaveable(stateSaver = autoSaver()) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (!isInitialized.value) {
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
homeViewModel.onEvent(HomeEvent.FetchSong)
|
||||
isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HomePage(onEvent = homeViewModel::onEvent, uiState = homeViewModel.homeUiState)
|
||||
|
||||
HomeBottomBar(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter),
|
||||
onEvent = homeViewModel::onEvent,
|
||||
playState = musicControllerUiState.playerState,
|
||||
song = musicControllerUiState.currentSong,
|
||||
onBarClick = {
|
||||
navController.navigate(Navigation.SONG_SCREEN)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(route = Navigation.SONG_SCREEN) {
|
||||
val songViewModel: SongViewModel = hiltViewModel()
|
||||
|
||||
SongScreen(
|
||||
onEvent = songViewModel::onEvent,
|
||||
musicControllerUiState = musicControllerUiState,
|
||||
onNavigateUp = {
|
||||
navController.navigateUp()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Navigation {
|
||||
const val HOME = "Home"
|
||||
const val SONG_SCREEN = "songScreen"
|
||||
}
|
@@ -0,0 +1,99 @@
|
||||
package top.rrricardo.musicplayer.ui.component
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import top.rrricardo.musicplayer.R
|
||||
import top.rrricardo.musicplayer.ui.theme.roundedShape
|
||||
|
||||
@Composable
|
||||
fun AnimatedVinyl(
|
||||
modifier: Modifier = Modifier,
|
||||
isSongPlaying: Boolean = false,
|
||||
painter: Painter
|
||||
) {
|
||||
var currentRotation by remember {
|
||||
mutableFloatStateOf(0f)
|
||||
}
|
||||
|
||||
val rotation = remember {
|
||||
Animatable(currentRotation)
|
||||
}
|
||||
|
||||
LaunchedEffect(isSongPlaying) {
|
||||
if (isSongPlaying) {
|
||||
rotation.animateTo(
|
||||
targetValue = currentRotation + 360f, animationSpec = infiniteRepeatable(
|
||||
animation = tween(3000, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
)
|
||||
) {
|
||||
currentRotation = value
|
||||
}
|
||||
} else {
|
||||
if (currentRotation > 0f) {
|
||||
rotation.animateTo(
|
||||
targetValue = currentRotation + 50f, animationSpec = tween(
|
||||
1250, easing = LinearEasing
|
||||
)
|
||||
) {
|
||||
currentRotation = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vinyl(modifier = modifier, painter = painter, rotationDegrees = rotation.value)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Vinyl(
|
||||
modifier: Modifier = Modifier,
|
||||
rotationDegrees: Float = 0f,
|
||||
painter: Painter
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(roundedShape)
|
||||
) {
|
||||
// Vinyl background
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.rotate(rotationDegrees),
|
||||
painter = painterResource(id = R.drawable.vinyl_background),
|
||||
contentDescription = "Vinyl background"
|
||||
)
|
||||
|
||||
// Vinyl song cover
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(0.5f)
|
||||
.rotate(rotationDegrees)
|
||||
.aspectRatio(1f)
|
||||
.align(Alignment.Center)
|
||||
.clip(roundedShape),
|
||||
painter = painter,
|
||||
contentDescription = "Song Cover"
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,120 @@
|
||||
package top.rrricardo.musicplayer.ui.component
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
|
||||
@Composable
|
||||
fun MusicItem(
|
||||
onClick: () -> Unit,
|
||||
song: Song
|
||||
) {
|
||||
ConstraintLayout(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.clickable {
|
||||
onClick()
|
||||
}
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
val (
|
||||
divider, songTitle, songSubtitle, image
|
||||
) = createRefs()
|
||||
|
||||
Divider(
|
||||
Modifier.constrainAs(divider) {
|
||||
top.linkTo(parent.top)
|
||||
centerHorizontallyTo(parent)
|
||||
|
||||
width = Dimension.fillToConstraints
|
||||
}
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(
|
||||
ImageRequest.Builder(LocalContext.current).data(song.imageUrl)
|
||||
.crossfade(true).build()
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.constrainAs(image) {
|
||||
end.linkTo(parent.end, 16.dp)
|
||||
top.linkTo(parent.top, 16.dp)
|
||||
bottom.linkTo(parent.bottom, 16.dp)
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = song.title,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.constrainAs(songTitle) {
|
||||
linkTo(
|
||||
start = parent.start,
|
||||
end = image.start,
|
||||
startMargin = 24.dp,
|
||||
endMargin = 16.dp,
|
||||
bias = 0f
|
||||
)
|
||||
top.linkTo(parent.top, 16.dp)
|
||||
start.linkTo(parent.start, 16.dp)
|
||||
width = Dimension.preferredWrapContent
|
||||
}
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides LocalContentColor.current.copy(alpha = 0.4f)) {
|
||||
Text(
|
||||
text = song.subTitle,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.constrainAs(songSubtitle) {
|
||||
linkTo(
|
||||
start = parent.start,
|
||||
end = image.start,
|
||||
startMargin = 24.dp,
|
||||
endMargin = 16.dp,
|
||||
bias = 0f
|
||||
)
|
||||
top.linkTo(songTitle.bottom, 6.dp)
|
||||
start.linkTo(parent.start, 16.dp)
|
||||
width = Dimension.preferredWrapContent
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun MusicItemPreview() {
|
||||
MusicItem(onClick = {}, song = Song(
|
||||
id = 0,
|
||||
mediaId = "Liyue",
|
||||
title = "璃月",
|
||||
subTitle = "璃月",
|
||||
songUrl = "https://oss.rrricardo.top/music/01%20Liyue.flac",
|
||||
imageUrl = "https://oss.rrricardo.top/oss/Artwork.jpg"
|
||||
))
|
||||
}
|
@@ -0,0 +1,285 @@
|
||||
package top.rrricardo.musicplayer.ui.page
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import top.rrricardo.musicplayer.R
|
||||
import top.rrricardo.musicplayer.model.HomeEvent
|
||||
import top.rrricardo.musicplayer.model.HomeUiState
|
||||
import top.rrricardo.musicplayer.model.PlayState
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
import top.rrricardo.musicplayer.ui.component.MusicItem
|
||||
|
||||
@Composable
|
||||
fun HomePage(
|
||||
onEvent: (HomeEvent) -> Unit,
|
||||
uiState: HomeUiState
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
val appBarColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.87f)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.background(appBarColor)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsTopHeight(WindowInsets.statusBars)
|
||||
)
|
||||
HomeAppBar(backgroundColor = appBarColor, modifier = Modifier.fillMaxWidth())
|
||||
|
||||
with(uiState) {
|
||||
when {
|
||||
loading -> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.Center)
|
||||
.padding(
|
||||
top = 16.dp,
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
!loading && songs != null -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.onBackground)
|
||||
.align(Alignment.TopCenter)
|
||||
) {
|
||||
items(songs) {
|
||||
MusicItem(onClick = {
|
||||
onEvent(HomeEvent.OnSongSelected(it))
|
||||
onEvent(HomeEvent.PlaySong)
|
||||
}, song = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeAppBar(backgroundColor: Color, modifier: Modifier = Modifier) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row {
|
||||
Text(text = "音乐播放器", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
CompositionLocalProvider(LocalContentColor provides LocalContentColor.current.copy(alpha = 0.4f)) {
|
||||
|
||||
}
|
||||
},
|
||||
modifier = modifier.background(backgroundColor)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeBottomBar(
|
||||
modifier: Modifier = Modifier,
|
||||
onEvent: (HomeEvent) -> Unit,
|
||||
playState: PlayState?,
|
||||
song: Song?,
|
||||
onBarClick: () -> Unit
|
||||
) {
|
||||
var offsetX by remember {
|
||||
mutableFloatStateOf(0f)
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = playState != PlayState.STOPPED,
|
||||
modifier = modifier) {
|
||||
if (song != null) {
|
||||
Box(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragEnd = {
|
||||
when {
|
||||
offsetX > 0 -> {
|
||||
onEvent(HomeEvent.SkipToPreviousSong)
|
||||
}
|
||||
|
||||
offsetX < 0 -> {
|
||||
onEvent(HomeEvent.SkipToNextSong)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetX = dragAmount.x
|
||||
}
|
||||
)
|
||||
}
|
||||
.background(
|
||||
if (!isSystemInDarkTheme()) {
|
||||
Color.LightGray
|
||||
} else {
|
||||
Color.DarkGray
|
||||
}
|
||||
),
|
||||
) {
|
||||
HomeBottomBarItem(song = song, onEvent = onEvent, playerState = playState, onBarClick = onBarClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeBottomBarItem(
|
||||
song: Song,
|
||||
onEvent: (HomeEvent) -> Unit,
|
||||
playerState: PlayState?,
|
||||
onBarClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(64.dp)
|
||||
.clickable(onClick = { onBarClick() })
|
||||
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(song.imageUrl),
|
||||
contentDescription = song.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.offset(16.dp)
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 8.dp, horizontal = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
song.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Text(
|
||||
song.subTitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.graphicsLayer {
|
||||
alpha = 0.60f
|
||||
}
|
||||
|
||||
)
|
||||
}
|
||||
val painter = rememberAsyncImagePainter(
|
||||
if (playerState == PlayState.PLAYING) {
|
||||
R.drawable.ic_round_pause
|
||||
} else {
|
||||
R.drawable.ic_round_play_arrow
|
||||
}
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = "Music",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(48.dp)
|
||||
.clickable(
|
||||
interactionSource = remember {
|
||||
MutableInteractionSource()
|
||||
},
|
||||
indication = rememberRipple(
|
||||
bounded = false,
|
||||
radius = 24.dp
|
||||
)
|
||||
) {
|
||||
if (playerState == PlayState.PLAYING) {
|
||||
onEvent(HomeEvent.PauseSong)
|
||||
} else {
|
||||
onEvent(HomeEvent.ResumeSong)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HomePagePreview() {
|
||||
HomePage(onEvent = {}, uiState = HomeUiState())
|
||||
}
|
@@ -0,0 +1,343 @@
|
||||
package top.rrricardo.musicplayer.ui.page
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import top.rrricardo.musicplayer.R
|
||||
import top.rrricardo.musicplayer.model.MusicControllerUiState
|
||||
import top.rrricardo.musicplayer.model.PlayState
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
import top.rrricardo.musicplayer.model.SongEvent
|
||||
import top.rrricardo.musicplayer.ui.component.AnimatedVinyl
|
||||
import top.rrricardo.musicplayer.utils.toTime
|
||||
|
||||
@Composable
|
||||
fun SongScreen(
|
||||
onEvent: (SongEvent) -> Unit,
|
||||
musicControllerUiState: MusicControllerUiState,
|
||||
onNavigateUp: () -> Unit
|
||||
) {
|
||||
if (musicControllerUiState.currentSong != null) {
|
||||
SongScreenBody(
|
||||
song = musicControllerUiState.currentSong,
|
||||
onEvent = onEvent,
|
||||
musicControllerUiState = musicControllerUiState,
|
||||
onNavigateUp = onNavigateUp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SongScreenBody(
|
||||
song: Song,
|
||||
onEvent: (SongEvent) -> Unit,
|
||||
musicControllerUiState: MusicControllerUiState,
|
||||
onNavigateUp: () -> Unit
|
||||
) {
|
||||
val endAnchor = LocalConfiguration.current.screenHeightDp * LocalDensity.current.density
|
||||
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
|
||||
val dominantColor by remember { mutableStateOf(Color.Transparent) }
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val imagePainter = rememberAsyncImagePainter(
|
||||
model = ImageRequest.Builder(context).data(song.imageUrl).crossfade(true).build()
|
||||
)
|
||||
|
||||
val iconResId =
|
||||
if (musicControllerUiState.playerState == PlayState.PLAYING) R.drawable.ic_round_pause else R.drawable.ic_round_play_arrow
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor)
|
||||
|
||||
) {
|
||||
|
||||
SongScreenContent(
|
||||
song = song,
|
||||
isSongPlaying = musicControllerUiState.playerState == PlayState.PLAYING,
|
||||
imagePainter = imagePainter,
|
||||
dominantColor = dominantColor,
|
||||
currentTime = musicControllerUiState.currentPosition,
|
||||
totalTime = musicControllerUiState.totalDuration,
|
||||
playPauseIcon = iconResId,
|
||||
playOrToggleSong = {
|
||||
onEvent(if (musicControllerUiState.playerState == PlayState.PLAYING) SongEvent.PauseSong else SongEvent.ResumeSong)
|
||||
},
|
||||
playNextSong = { onEvent(SongEvent.SkipToNextSong) },
|
||||
playPreviousSong = { onEvent(SongEvent.SkipToPreviousSong) },
|
||||
onSliderChange = { newPosition ->
|
||||
onEvent(SongEvent.SeekSongToPosition(newPosition.toLong()))
|
||||
},
|
||||
onForward = {
|
||||
onEvent(SongEvent.SeekSongToPosition(musicControllerUiState.currentPosition + 10 * 1000))
|
||||
},
|
||||
onRewind = {
|
||||
musicControllerUiState.currentPosition.let { currentPosition ->
|
||||
onEvent(SongEvent.SeekSongToPosition(if (currentPosition - 10 * 1000 < 0) 0 else currentPosition - 10 * 1000))
|
||||
}
|
||||
},
|
||||
onClose = { onNavigateUp() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SongScreenContent(
|
||||
song: Song,
|
||||
isSongPlaying: Boolean,
|
||||
imagePainter: Painter,
|
||||
dominantColor: Color,
|
||||
currentTime: Long,
|
||||
totalTime: Long,
|
||||
@DrawableRes playPauseIcon: Int,
|
||||
playOrToggleSong: () -> Unit,
|
||||
playNextSong: () -> Unit,
|
||||
playPreviousSong: () -> Unit,
|
||||
onSliderChange: (Float) -> Unit,
|
||||
onRewind: () -> Unit,
|
||||
onForward: () -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val gradientColors = if (isSystemInDarkTheme()) {
|
||||
listOf(
|
||||
dominantColor, MaterialTheme.colorScheme.background
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.background, MaterialTheme.colorScheme.background
|
||||
)
|
||||
}
|
||||
|
||||
val sliderColors = if (isSystemInDarkTheme()) {
|
||||
SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.onBackground,
|
||||
activeTrackColor = MaterialTheme.colorScheme.onBackground,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.onBackground.copy(
|
||||
alpha = 0.1f
|
||||
),
|
||||
)
|
||||
} else SliderDefaults.colors(
|
||||
thumbColor = dominantColor,
|
||||
activeTrackColor = dominantColor,
|
||||
inactiveTrackColor = dominantColor.copy(
|
||||
alpha = 0.1f
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Surface {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = gradientColors,
|
||||
endY = LocalConfiguration.current.screenHeightDp.toFloat() * LocalDensity.current.density
|
||||
)
|
||||
)
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
) {
|
||||
Column {
|
||||
IconButton(
|
||||
onClick = onClose
|
||||
) {
|
||||
Image(
|
||||
imageVector = Icons.Rounded.KeyboardArrowDown,
|
||||
contentDescription = "Close",
|
||||
colorFilter = ColorFilter.tint(LocalContentColor.current)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 32.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.weight(1f, fill = false)
|
||||
.aspectRatio(1f)
|
||||
|
||||
) {
|
||||
AnimatedVinyl(painter = imagePainter, isSongPlaying = isSongPlaying)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = song.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(song.subTitle,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = 0.60f
|
||||
})
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp)
|
||||
) {
|
||||
|
||||
Slider(
|
||||
value = currentTime.toFloat(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
valueRange = 0f..totalTime.toFloat(),
|
||||
colors = sliderColors,
|
||||
onValueChange = onSliderChange,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides LocalContentColor.current.copy(
|
||||
alpha = 0.4f
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
currentTime.toTime(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides LocalContentColor.current.copy(
|
||||
alpha = 0.4f
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
totalTime.toTime(),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.KeyboardArrowLeft,
|
||||
contentDescription = "Skip Previous",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = playPreviousSong)
|
||||
.padding(12.dp)
|
||||
)
|
||||
// Icon(
|
||||
// imageVector = Icons.Rounded.Arr,
|
||||
// contentDescription = "Replay 10 seconds",
|
||||
// modifier = Modifier
|
||||
// .clip(CircleShape)
|
||||
// .clickable(onClick = onRewind)
|
||||
// .padding(12.dp)
|
||||
// .size(32.dp)
|
||||
// )
|
||||
Icon(
|
||||
painter = painterResource(playPauseIcon),
|
||||
contentDescription = "Play",
|
||||
tint = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onBackground)
|
||||
.clickable(onClick = playOrToggleSong)
|
||||
.size(64.dp)
|
||||
.padding(8.dp)
|
||||
)
|
||||
// Icon(
|
||||
// imageVector = Icons.Rounded.,
|
||||
// contentDescription = "Forward 10 seconds",
|
||||
// modifier = Modifier
|
||||
// .clip(CircleShape)
|
||||
// .clickable(onClick = onForward)
|
||||
// .padding(12.dp)
|
||||
// .size(32.dp)
|
||||
// )
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.KeyboardArrowRight,
|
||||
contentDescription = "Skip Next",
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = playNextSong)
|
||||
.padding(12.dp)
|
||||
.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SongScreenPreview() {
|
||||
SongScreen(onEvent = {}, musicControllerUiState = MusicControllerUiState()) {
|
||||
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
package top.rrricardo.musicplayer.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
@@ -0,0 +1,37 @@
|
||||
package top.rrricardo.musicplayer.ui.theme
|
||||
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathOperation
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
|
||||
val roundedShape = object : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density
|
||||
): Outline {
|
||||
val p1 = Path().apply {
|
||||
addOval(Rect(4f, 3f, size.width - 1, size.height - 1))
|
||||
}
|
||||
val thickness = size.height / 2.10f
|
||||
val p2 = Path().apply {
|
||||
addOval(
|
||||
Rect(
|
||||
thickness,
|
||||
thickness,
|
||||
size.width - thickness,
|
||||
size.height - thickness
|
||||
)
|
||||
)
|
||||
}
|
||||
val p3 = Path()
|
||||
p3.op(p1, p2, PathOperation.Difference)
|
||||
|
||||
return Outline.Generic(p3)
|
||||
}
|
||||
}
|
@@ -0,0 +1,58 @@
|
||||
package top.rrricardo.musicplayer.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MusicPlayerTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
package top.rrricardo.musicplayer.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
@@ -0,0 +1,92 @@
|
||||
package top.rrricardo.musicplayer.ui.viewmodel
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.launch
|
||||
import top.rrricardo.musicplayer.model.HomeEvent
|
||||
import top.rrricardo.musicplayer.model.HomeUiState
|
||||
import top.rrricardo.musicplayer.usecase.AddMediaItemsUseCase
|
||||
import top.rrricardo.musicplayer.usecase.GetSongsUseCase
|
||||
import top.rrricardo.musicplayer.usecase.PauseSongUseCase
|
||||
import top.rrricardo.musicplayer.usecase.PlaySongUseCase
|
||||
import top.rrricardo.musicplayer.usecase.ResumeSongUseCase
|
||||
import top.rrricardo.musicplayer.usecase.SkipToNextSongUseCase
|
||||
import top.rrricardo.musicplayer.usecase.SkipToPreviousSongUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val getSongsUseCase: GetSongsUseCase,
|
||||
private val addMediaItemsUseCase: AddMediaItemsUseCase,
|
||||
private val playSongUseCase: PlaySongUseCase,
|
||||
private val pauseSongUseCase: PauseSongUseCase,
|
||||
private val resumeSongUseCase: ResumeSongUseCase,
|
||||
private val skipToNextSongUseCase: SkipToNextSongUseCase,
|
||||
private val skipToPreviousSongUseCase: SkipToPreviousSongUseCase
|
||||
) : ViewModel() {
|
||||
var homeUiState by mutableStateOf(HomeUiState())
|
||||
private set
|
||||
|
||||
fun onEvent(event: HomeEvent) {
|
||||
when (event) {
|
||||
HomeEvent.PlaySong -> playSong()
|
||||
|
||||
HomeEvent.PauseSong -> pauseSong()
|
||||
|
||||
HomeEvent.ResumeSong -> resumeSong()
|
||||
|
||||
HomeEvent.FetchSong -> getSong()
|
||||
|
||||
is HomeEvent.OnSongSelected -> homeUiState = homeUiState.copy(selectedSong = event.selectedSong)
|
||||
|
||||
is HomeEvent.SkipToNextSong -> skipToNextSong()
|
||||
|
||||
is HomeEvent.SkipToPreviousSong -> skipToPreviousSong()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSong() {
|
||||
homeUiState = homeUiState.copy(loading = true)
|
||||
|
||||
viewModelScope.launch {
|
||||
getSongsUseCase().catch {
|
||||
homeUiState = homeUiState.copy(
|
||||
loading = false,
|
||||
errorMessage = it.message
|
||||
)
|
||||
}.collect {
|
||||
homeUiState = homeUiState.copy(
|
||||
loading = false,
|
||||
songs = it
|
||||
)
|
||||
|
||||
addMediaItemsUseCase(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playSong() {
|
||||
homeUiState.apply {
|
||||
songs?.indexOf(selectedSong)?.let { song ->
|
||||
playSongUseCase(song)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseSong() = pauseSongUseCase()
|
||||
|
||||
private fun resumeSong() = resumeSongUseCase()
|
||||
|
||||
private fun skipToNextSong() = skipToNextSongUseCase {
|
||||
homeUiState = homeUiState.copy(selectedSong = it)
|
||||
}
|
||||
|
||||
private fun skipToPreviousSong() = skipToPreviousSongUseCase {
|
||||
homeUiState = homeUiState.copy(selectedSong = it)
|
||||
}
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
package top.rrricardo.musicplayer.ui.viewmodel
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import top.rrricardo.musicplayer.model.MusicControllerUiState
|
||||
import top.rrricardo.musicplayer.model.PlayState
|
||||
import top.rrricardo.musicplayer.usecase.DestroyMediaControllerUseCase
|
||||
import top.rrricardo.musicplayer.usecase.GetCurrentSongPositionUseCase
|
||||
import top.rrricardo.musicplayer.usecase.SetMediaControllerCallbackUseCase
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@HiltViewModel
|
||||
class SharedViewModel @Inject constructor(
|
||||
private val setMediaControllerCallbackUseCase: SetMediaControllerCallbackUseCase,
|
||||
private val getCurrentSongPositionUseCase: GetCurrentSongPositionUseCase,
|
||||
private val destroyMediaControllerUseCase: DestroyMediaControllerUseCase
|
||||
) : ViewModel() {
|
||||
var musicControllerUiState by mutableStateOf(MusicControllerUiState())
|
||||
private set
|
||||
|
||||
init {
|
||||
setMediaControllerCallback()
|
||||
}
|
||||
|
||||
fun destroyMediaController() = destroyMediaControllerUseCase()
|
||||
|
||||
private fun setMediaControllerCallback() {
|
||||
setMediaControllerCallbackUseCase {
|
||||
playState,
|
||||
currentSong,
|
||||
currentPosition,
|
||||
totalDuration,
|
||||
isShuffleEnabled,
|
||||
isRepeatOneEnabled ->
|
||||
musicControllerUiState = musicControllerUiState.copy(
|
||||
playerState = playState,
|
||||
currentSong = currentSong,
|
||||
currentPosition = currentPosition,
|
||||
totalDuration = totalDuration,
|
||||
isShuffleEnabled = isShuffleEnabled,
|
||||
isRepeatOneEnabled = isRepeatOneEnabled
|
||||
)
|
||||
|
||||
if (playState == PlayState.PLAYING) {
|
||||
viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(3.seconds)
|
||||
musicControllerUiState = musicControllerUiState.copy(
|
||||
currentPosition = getCurrentSongPositionUseCase()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,41 @@
|
||||
package top.rrricardo.musicplayer.ui.viewmodel
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.palette.graphics.Palette
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import top.rrricardo.musicplayer.model.SongEvent
|
||||
import top.rrricardo.musicplayer.usecase.PauseSongUseCase
|
||||
import top.rrricardo.musicplayer.usecase.ResumeSongUseCase
|
||||
import top.rrricardo.musicplayer.usecase.SeekSongToPositionUseCase
|
||||
import top.rrricardo.musicplayer.usecase.SkipToNextSongUseCase
|
||||
import top.rrricardo.musicplayer.usecase.SkipToPreviousSongUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SongViewModel @Inject constructor(
|
||||
private val pauseSongUseCase: PauseSongUseCase,
|
||||
private val resumeSongUseCase: ResumeSongUseCase,
|
||||
private val skipToPreviousSongUseCase: SkipToPreviousSongUseCase,
|
||||
private val skipToNextSongUseCase: SkipToNextSongUseCase,
|
||||
private val seekSongToPositionUseCase: SeekSongToPositionUseCase
|
||||
) : ViewModel() {
|
||||
fun onEvent(event: SongEvent) {
|
||||
when (event) {
|
||||
SongEvent.PauseSong -> pauseSongUseCase()
|
||||
SongEvent.ResumeSong -> resumeSongUseCase()
|
||||
is SongEvent.SeekSongToPosition -> seekSongToPositionUseCase(event.position)
|
||||
SongEvent.SkipToNextSong -> skipToNextSongUseCase {}
|
||||
SongEvent.SkipToPreviousSong -> skipToPreviousSongUseCase {}
|
||||
}
|
||||
}
|
||||
|
||||
fun calculateColorPalette(drawable: Bitmap, onFinish: (Color) -> Unit) {
|
||||
Palette.from(drawable).generate { palette ->
|
||||
palette?.dominantSwatch?.rgb?.let {
|
||||
onFinish(Color(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class AddMediaItemsUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
|
||||
operator fun invoke(songs: List<Song>) = musicController.addMediaItems(songs)
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class DestroyMediaControllerUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
operator fun invoke() = musicController.destroy()
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetCurrentSongPositionUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
operator fun invoke() = musicController.getCurrentPosition()
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.service.SongRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetSongsUseCase @Inject constructor(private val songRepository: SongRepository) {
|
||||
operator fun invoke() = songRepository.getSongs()
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class PauseSongUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
operator fun invoke() = musicController.pause()
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class PlaySongUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
operator fun invoke(mediaItemIndex: Int) = musicController.play(mediaItemIndex)
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class ResumeSongUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
operator fun invoke() = musicController.resume()
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class SeekSongToPositionUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
operator fun invoke(position: Long) = musicController.seekTo(position)
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.model.PlayState
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class SetMediaControllerCallbackUseCase @Inject constructor(
|
||||
private val musicController: MusicController
|
||||
) {
|
||||
operator fun invoke(
|
||||
callback: (
|
||||
playState: PlayState,
|
||||
currentSong: Song?,
|
||||
currentPosition: Long,
|
||||
totalDuration: Long,
|
||||
isShuffleEnabled: Boolean,
|
||||
isRepeatOneEnabled: Boolean
|
||||
)-> Unit
|
||||
) {
|
||||
musicController.mediaControllerCallback = callback
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class SkipToNextSongUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
operator fun invoke(updateHomeUi: (Song?) -> Unit) {
|
||||
musicController.skipToNextSong()
|
||||
updateHomeUi(musicController.getCurrentSong())
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
package top.rrricardo.musicplayer.usecase
|
||||
|
||||
import top.rrricardo.musicplayer.model.Song
|
||||
import top.rrricardo.musicplayer.service.MusicController
|
||||
import javax.inject.Inject
|
||||
|
||||
class SkipToPreviousSongUseCase @Inject constructor(private val musicController: MusicController) {
|
||||
operator fun invoke(updateHomeUi: (Song?) -> Unit) {
|
||||
musicController.skipToPreviousSong()
|
||||
updateHomeUi(musicController.getCurrentSong())
|
||||
}
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
package top.rrricardo.musicplayer.utils
|
||||
|
||||
fun Long.toTime(): String {
|
||||
val stringBuffer = StringBuffer()
|
||||
|
||||
val minutes = (this / 60000).toInt()
|
||||
val second = (this / 1000 % 60).toInt()
|
||||
|
||||
stringBuffer
|
||||
.append(String.format("%02d", minutes))
|
||||
.append(":")
|
||||
.append(String.format("%02d", second))
|
||||
|
||||
return stringBuffer.toString()
|
||||
}
|
5
MusicPlayer/app/src/main/res/drawable/ic_image.xml
Normal file
5
MusicPlayer/app/src/main/res/drawable/ic_image.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
|
||||
</vector>
|
170
MusicPlayer/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
170
MusicPlayer/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
15
MusicPlayer/app/src/main/res/drawable/ic_logo.xml
Normal file
15
MusicPlayer/app/src/main/res/drawable/ic_logo.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#F27405"
|
||||
android:pathData="M8.5,19c-0.83,0 -1.5,-0.43 -1.5,-0.95V0.95C7,0.43 7.67,0 8.5,0c0.82,0 1.5,0.43 1.5,0.95v17.1c0,0.52 -0.68,0.95 -1.5,0.95zM1.5,13c-0.83,0 -1.5,-0.4 -1.5,-0.88V6.88C0,6.39 0.67,6 1.5,6s1.5,0.4 1.5,0.88v5.24c0,0.49 -0.67,0.88 -1.5,0.88zM15.5,15c-0.82,0 -1.5,-0.41 -1.5,-0.92V4.92c0,-0.5 0.68,-0.92 1.5,-0.92 0.83,0 1.5,0.41 1.5,0.92v9.16c0,0.5 -0.67,0.92 -1.5,0.92zM22.5,12c-0.83,0 -1.5,-0.45 -1.5,-1V7c0,-0.55 0.67,-1 1.5,-1 0.82,0 1.5,0.45 1.5,1v4c0,0.55 -0.68,1 -1.5,1z" />
|
||||
<path
|
||||
android:fillColor="#FF9F0C"
|
||||
android:pathData="M8.5,21c-0.83,0 -1.5,-0.43 -1.5,-0.95V2.95C7,2.43 7.67,2 8.5,2c0.82,0 1.5,0.43 1.5,0.95v17.1c0,0.52 -0.68,0.95 -1.5,0.95zM1.5,15c-0.83,0 -1.5,-0.4 -1.5,-0.88V8.87C0,8.4 0.67,8 1.5,8s1.5,0.4 1.5,0.87v5.25c0,0.49 -0.67,0.88 -1.5,0.88zM15.5,17c-0.82,0 -1.5,-0.41 -1.5,-0.92V6.92c0,-0.5 0.68,-0.92 1.5,-0.92 0.83,0 1.5,0.41 1.5,0.92v9.16c0,0.5 -0.67,0.92 -1.5,0.92zM22.5,15c-0.83,0 -1.5,-0.45 -1.5,-1v-4c0,-0.55 0.67,-1 1.5,-1 0.82,0 1.5,0.45 1.5,1v4c0,0.55 -0.68,1 -1.5,1z" />
|
||||
<path
|
||||
android:fillColor="#FFD083"
|
||||
android:pathData="M8.5,24c-0.83,0 -1.5,-0.38 -1.5,-0.85V7.85C7,7.38 7.67,7 8.5,7s1.5,0.38 1.5,0.85v15.3c0,0.47 -0.67,0.85 -1.5,0.85zM1.5,19c-0.82,0 -1.5,-0.4 -1.5,-0.87v-5.26C0,12.4 0.68,12 1.5,12c0.83,0 1.5,0.4 1.5,0.87v5.26c0,0.48 -0.67,0.87 -1.5,0.87zM15.5,21c-0.83,0 -1.5,-0.38 -1.5,-0.83v-8.34c0,-0.45 0.67,-0.83 1.5,-0.83 0.82,0 1.5,0.38 1.5,0.83v8.34c0,0.45 -0.68,0.83 -1.5,0.83zM22.5,18c-0.82,0 -1.5,-0.38 -1.5,-0.83v-3.34c0,-0.45 0.68,-0.83 1.5,-0.83 0.83,0 1.5,0.38 1.5,0.83v3.34c0,0.45 -0.67,0.83 -1.5,0.83z" />
|
||||
</vector>
|
5
MusicPlayer/app/src/main/res/drawable/ic_music.xml
Normal file
5
MusicPlayer/app/src/main/res/drawable/ic_music.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
|
||||
</vector>
|
9
MusicPlayer/app/src/main/res/drawable/ic_round_pause.xml
Normal file
9
MusicPlayer/app/src/main/res/drawable/ic_round_pause.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M8,19c1.1,0 2,-0.9 2,-2L10,7c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2v10c0,1.1 0.9,2 2,2zM14,7v10c0,1.1 0.9,2 2,2s2,-0.9 2,-2L18,7c0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2z"/>
|
||||
</vector>
|
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M8,6.82v10.36c0,0.79 0.87,1.27 1.54,0.84l8.14,-5.18c0.62,-0.39 0.62,-1.29 0,-1.69L9.54,5.98C8.87,5.55 8,6.03 8,6.82z"/>
|
||||
</vector>
|
BIN
MusicPlayer/app/src/main/res/drawable/vinyl_background.png
Normal file
BIN
MusicPlayer/app/src/main/res/drawable/vinyl_background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 549 KiB |
10
MusicPlayer/app/src/main/res/values/colors.xml
Normal file
10
MusicPlayer/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
3
MusicPlayer/app/src/main/res/values/strings.xml
Normal file
3
MusicPlayer/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">MusicPlayer</string>
|
||||
</resources>
|
5
MusicPlayer/app/src/main/res/values/themes.xml
Normal file
5
MusicPlayer/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.MusicPlayer" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
13
MusicPlayer/app/src/main/res/xml/backup_rules.xml
Normal file
13
MusicPlayer/app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older that API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
19
MusicPlayer/app/src/main/res/xml/data_extraction_rules.xml
Normal file
19
MusicPlayer/app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
@@ -0,0 +1,17 @@
|
||||
package top.rrricardo.musicplayer
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user