init: repo

This commit is contained in:
2024-07-04 15:27:19 +08:00
commit a90302f7ba
293 changed files with 18994 additions and 0 deletions

1
MusicPlayer/app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View 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
View 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

View File

@@ -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)
}
}

View 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>

View File

@@ -0,0 +1,8 @@
package top.rrricardo.musicplayer
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class App : Application() {
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -0,0 +1,7 @@
package top.rrricardo.musicplayer.model
enum class PlayState {
PLAYING,
PAUSED,
STOPPED
}

View File

@@ -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()
)

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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>>
}

View File

@@ -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"
)))
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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"
}

View File

@@ -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"
)
}
}

View File

@@ -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"
))
}

View File

@@ -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())
}

View File

@@ -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()) {
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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
)
}

View File

@@ -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
)
*/
)

View File

@@ -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)
}
}

View File

@@ -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()
)
}
}
}
}
}
}

View File

@@ -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))
}
}
}
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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())
}
}

View File

@@ -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())
}
}

View File

@@ -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()
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 KiB

View 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>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">MusicPlayer</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MusicPlayer" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View 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>

View 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>

View File

@@ -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)
}
}