├── .github └── pull_request_template.md ├── .gitignore ├── README.md ├── androidApp ├── build.gradle.kts └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── example │ │ └── musicapp_kmp │ │ └── android │ │ ├── MainActivity.kt │ │ └── MusicApplication.kt │ └── res │ ├── values │ └── theme.xml │ └── xml │ └── data_extraction_rules.xml ├── build.gradle.kts ├── desktopApp ├── build.gradle.kts └── src │ └── jvmMain │ └── kotlin │ ├── DesktopApp.kt │ └── Utils.kt ├── docs ├── META-INF │ └── MANIFEST.MF ├── composeResources │ └── musicapp_kmp.shared.generated.resources │ │ ├── drawable │ │ └── baseline_pause_24.xml │ │ └── values │ │ └── strings.commonMain.cvr ├── index.html ├── skiko.js ├── skiko.mjs ├── skiko.wasm └── webApp.js ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── iosApp ├── MusicApp-KMP-Info.plist ├── MusicApp-KMP.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ ├── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcuserdata │ │ │ └── abdulbasit.xcuserdatad │ │ │ ├── UserInterfaceState.xcuserstate │ │ │ └── xcschemes │ │ │ └── xcschememanagement.plist │ └── xcuserdata │ │ └── abdulbasit.xcuserdatad │ │ └── xcschemes │ │ ├── iosApp.xcscheme │ │ └── xcschememanagement.plist ├── Podfile └── iosApp │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── ContentView.swift │ ├── LifecycleHolder.swift │ ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json │ └── iosAppApp.swift ├── kotlin-js-store └── yarn.lock ├── settings.gradle.kts ├── shared ├── build.gradle.kts └── src │ ├── androidMain │ └── kotlin │ │ └── musicapp │ │ ├── main.android.kt │ │ ├── player │ │ ├── MediaPlayerController.android.kt │ │ ├── PlayerServiceLocator.kt │ │ ├── mapper │ │ │ └── MediaItemMapper.kt │ │ ├── notification │ │ │ ├── MusicNotificationDescriptorAdapter.kt │ │ │ └── MusicNotificationManager.kt │ │ └── service │ │ │ └── MediaService.kt │ │ └── utils │ │ └── PlatformContext.android.kt │ ├── commonMain │ ├── composeResources │ │ ├── drawable │ │ │ ├── baseline_pause_24.xml │ │ │ ├── forward.png │ │ │ ├── moon_fill.png │ │ │ ├── moon_outline.png │ │ │ └── rewind.png │ │ └── values │ │ │ └── strings.xml │ └── kotlin │ │ └── musicapp │ │ ├── MusicAppTheme.kt │ │ ├── Token.kt │ │ ├── chartdetails │ │ ├── ChartDetails.kt │ │ ├── ChartDetailsLarge.kt │ │ ├── ChartDetailsViewModel.kt │ │ ├── ChartDetailsViewState.kt │ │ └── SleepTimerModalBottomSheet.kt │ │ ├── dashboard │ │ ├── DashboardScreen.kt │ │ ├── DashboardScreenLarge.kt │ │ ├── DashboardViewModel.kt │ │ └── DashboardViewState.kt │ │ ├── decompose │ │ ├── ChartDetailsComponent.kt │ │ ├── ChartDetailsComponentImpl.kt │ │ ├── DashboardMainComponent.kt │ │ ├── DashboardMainComponentImpl.kt │ │ ├── MusicRoot.kt │ │ ├── MusicRootImpl.kt │ │ ├── PlayerComponent.kt │ │ └── PlayerComponentImpl.kt │ │ ├── main.common.kt │ │ ├── network │ │ ├── SpotifyApi.kt │ │ ├── SpotifyApiImpl.kt │ │ └── models │ │ │ ├── featuredplaylist │ │ │ ├── ExternalUrls.kt │ │ │ ├── FeaturedPlayList.kt │ │ │ ├── Image.kt │ │ │ ├── Item.kt │ │ │ ├── Owner.kt │ │ │ ├── Playlists.kt │ │ │ └── Tracks.kt │ │ │ ├── newreleases │ │ │ ├── Albums.kt │ │ │ ├── Artist.kt │ │ │ ├── ExternalUrlsX.kt │ │ │ ├── Image.kt │ │ │ ├── Item.kt │ │ │ └── NewReleasedAlbums.kt │ │ │ └── topfiftycharts │ │ │ ├── AddedBy.kt │ │ │ ├── Album.kt │ │ │ ├── ArtistX.kt │ │ │ ├── ExternalIds.kt │ │ │ ├── ExternalUrls.kt │ │ │ ├── Followers.kt │ │ │ ├── Image.kt │ │ │ ├── ImageX.kt │ │ │ ├── Item.kt │ │ │ ├── Owner.kt │ │ │ ├── TopFiftyCharts.kt │ │ │ ├── Track.kt │ │ │ ├── Tracks.kt │ │ │ └── VideoThumbnail.kt │ │ ├── player │ │ ├── MediaPlayerController.kt │ │ ├── MediaPlayerListener.kt │ │ └── TrackItem.kt │ │ ├── playerview │ │ ├── CountdownViewModel.kt │ │ ├── PlayerView.kt │ │ ├── PlayerViewModel.kt │ │ └── PlayerViewState.kt │ │ ├── sampledata │ │ ├── FeaturedPlaylistResponse.kt │ │ └── TopFiftyChartsResponse.kt │ │ ├── theme │ │ ├── Color.kt │ │ └── Dimensions.kt │ │ └── utils │ │ └── PlatformContext.kt │ ├── commonTest │ └── kotlin │ │ └── network │ │ ├── APIMockEngine.kt │ │ ├── ApiTest.kt │ │ └── mockresponse │ │ ├── APITestData.kt │ │ ├── FeaturedPlaylistsResponse.kt │ │ ├── NewReleasesResponse.kt │ │ └── TopFiftyChartsResponse.kt │ ├── desktopMain │ ├── java │ │ └── musicapp │ │ │ └── utils │ │ │ └── PlatformContext.desktop.kt │ └── kotlin │ │ ├── main.desktop.kt │ │ └── musicapp │ │ └── player │ │ └── MediaPlayerController.desktop.kt │ ├── iosMain │ └── kotlin │ │ └── musicapp │ │ ├── main.ios.kt │ │ ├── player │ │ └── MediaPlayerController.ios.kt │ │ └── utils │ │ └── PlatformContext.ios.kt │ └── jsMain │ └── kotlin │ ├── main.js.kt │ └── musicapp │ ├── player │ └── MediaPlayerController.js.kt │ └── utils │ └── PlatformContext.js.kt └── webApp ├── build.gradle.kts ├── src └── jsMain │ ├── kotlin │ └── WebApp.kt │ └── resources │ └── index.html └── webpack.config.d └── fs.js /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | ## What type of PR is this? (check all applicable) 15 | 16 | - [ ] Refactor 17 | - [ ] Feature 18 | - [ ] Bug Fix 19 | - [ ] Optimization 20 | - [ ] Documentation Update 21 | 22 | 26 | 27 | ## Related Issues & Documents 28 | 29 | 30 | ## Description 31 | 32 | 33 | ## Code Changes 34 | 35 | 36 | ## Screenshots, Recordings -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | /.idea/caches 6 | /.idea/libraries 7 | /.idea/modules.xml 8 | /.idea/workspace.xml 9 | /.idea/navEditor.xml 10 | /.idea/assetWizardSettings.xml 11 | .DS_Store 12 | build/ 13 | /captures 14 | .externalNativeBuild 15 | .cxx 16 | iosApp/Podfile.lock 17 | iosApp/Pods/* 18 | iosApp/iosApp.xcworkspace/* 19 | iosApp/iosApp.xcodeproj/* 20 | !iosApp/iosApp.xcodeproj/project.pbxproj 21 | shared/shared.podspec 22 | /webApp/.gradle/ 23 | /webApp/build/ 24 | /.kotlin -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Music Player App Using Compose Multiplatform KMP 2 | 3 | This is a music player app built using Compose Multiplatform UI that works on Android, iOS, Desktop, and Web 4 | platforms. It uses the spotify api for fetching the top 50 charts and getting the trending albums. The Google login is still in pending 5 | and for now, you need to add the spotify token manually. You can easily hit the endpoint [here](https://developer.spotify.com/documentation/web-api/reference/get-an-album) to get the album 6 | and then get the token and set in the app. 7 | 8 | ## Find it on official website of JetBrains 9 | This repository has been listed as [KMP sample](https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-samples.html#:~:text=Android%20and%20iOS-,Music%20App%20KMP,-An%20application%20showcasing) on official website of Jetbrains. 10 | 11 | ## Live 12 | You can find it live [here](https://seabdulbasit.github.io/MusicApp-KMP/) 13 | 14 | ## Platforms 15 | 16 | The app uses different media players on different platforms: 17 | 18 | For iOS, AVKit is used 19 | For Android, Media Player is used 20 | For Desktop, VLC media player is used 21 | For the Web, an HTML media player is used. 22 | 23 | ## Integration with Low-Level APIs 24 | 25 | One of the objectives of building this app was to explore how Compose UI interacts with low-level APIs. The experience 26 | was challenging yet fun, and the process taught me a lot. 27 | Out of all the media players used, integrating with the Web Media Player was the easiest. I'm grateful to IceRock 28 | Development and Aleksey Mikhailov for their demo application, which was a fantastic learning resource. 29 | 30 | ## Running the app 31 | 32 | - Clone this repository: 33 | 34 | ``` 35 | git clone https://github.com/SEAbdulbasit/MusicApp-KMP.git 36 | ``` 37 | 38 | - Open the project in Android Studio or IntelliJ IDEA. 39 | - Search for **_TOKEN_** file in the code and replace the placeholder with your **Spotify access token**. You can 40 | generate a new token from the [Spotify Developer Dashboard](https://developer.spotify.com/console/get-album-tracks/). 41 | - Run the app on your desired platform. 42 | There are a few known issues with the Music Player app using Compose Multiplatform UI: 43 | - Run on Desktop 44 | ``` 45 | ./gradlew desktopApp:run 46 | ``` 47 | - Run on Web 48 | ``` 49 | ./gradlew jsBrowserDevelopmentRun 50 | ``` 51 | 52 | ## Known Issues 53 | 54 | - When you click "Select All" on Android, Web, and Desktop, the app will autoplay the selected tracks and continue 55 | playing the next track when the previous one ends. 56 | On iOS, there are issues with the callbacks for `onReady` and `onVideoCompleted` 57 | which is causing the player to not start automatically. I was unable to configure the callbacks but hopefully, will be 58 | fixing that soon. 59 | 60 | ## Demo 61 | 62 | ![Screenshot 2023-03-05 at 4 44 45 PM](https://user-images.githubusercontent.com/33172684/222960302-eccb34b4-d77c-4c95-96af-3d4528323c42.png) 63 | 64 | ## Repository 65 | 66 | To explore what Compose UI can do, check out the repository for the latest 67 | updates: https://github.com/SEAbdulbasit/MusicApp-KMP. 68 | 69 | If you're interested in getting started with Compose Multiplatform, I have a template for you 70 | here: https://github.com/SEAbdulbasit/KMP-Compose-Template. 71 | 72 | If you find my work helpful, please consider giving it a ⭐ ❤️. 73 | 74 | ## Other Projects 75 | 76 | TravelApp: https://github.com/SEAbdulbasit/TravelApp-KMP 77 | 78 | ## Technologies and Libraries Used 79 | 80 | - Kotlin 81 | - Compose Multiplatform UI 82 | - AVKit Media Player 83 | - VLC media player 84 | - HTML media player 85 | - [Compose Image Loader](https://github.com/qdsfdhvh/compose-imageloader) 86 | - Decompose 87 | 88 | TODO 89 | - Add google login 90 | - Add local db to save favorite playlist 91 | - Add implementation for recent releases 92 | 93 | 94 | -------------------------------------------------------------------------------- /androidApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | android { 8 | namespace = "com.example.musicapp_kmp.android" 9 | compileSdk = 35 10 | defaultConfig { 11 | applicationId = "com.example.musicapp_kmp.android" 12 | minSdk = 24 13 | targetSdk = 35 14 | versionCode = 1 15 | versionName = "1.0" 16 | } 17 | buildFeatures { 18 | compose = true 19 | } 20 | composeOptions { 21 | kotlinCompilerExtensionVersion = "1.5.14" 22 | } 23 | packaging { 24 | resources { 25 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 26 | } 27 | } 28 | buildTypes { 29 | getByName("release") { 30 | isMinifyEnabled = true 31 | } 32 | } 33 | compileOptions { 34 | sourceCompatibility = JavaVersion.VERSION_1_8 35 | targetCompatibility = JavaVersion.VERSION_1_8 36 | } 37 | kotlinOptions { 38 | jvmTarget = "1.8" 39 | } 40 | } 41 | 42 | dependencies { 43 | implementation(project(":shared")) 44 | implementation(libs.androidx.activity.compose) 45 | implementation(libs.ktor.client.okhttp) 46 | } -------------------------------------------------------------------------------- /androidApp/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /androidApp/src/main/java/com/example/musicapp_kmp/android/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.musicapp_kmp.android 2 | 3 | import android.Manifest 4 | import android.content.pm.PackageManager 5 | import android.os.Build 6 | import android.os.Bundle 7 | import android.widget.Toast 8 | import androidx.activity.ComponentActivity 9 | import androidx.activity.compose.setContent 10 | import androidx.activity.result.contract.ActivityResultContracts 11 | import androidx.core.content.ContextCompat 12 | import androidx.core.view.WindowCompat 13 | import com.arkivanov.decompose.defaultComponentContext 14 | import com.example.musicapp_kmp.MainAndroid 15 | import musicapp.decompose.MusicRootImpl 16 | import musicapp.network.SpotifyApiImpl 17 | import musicapp.player.PlayerServiceLocator 18 | 19 | class MainActivity : ComponentActivity() { 20 | 21 | private val requestPermissionLauncher = registerForActivityResult( 22 | ActivityResultContracts.RequestPermission() 23 | ) { isGranted: Boolean -> 24 | if (isGranted) { 25 | // Permission granted, proceed with showing notifications 26 | } else { 27 | // Permission denied, inform the user about the consequences 28 | Toast.makeText( 29 | this, 30 | "Notification permission denied. You won't receive notifications.", 31 | Toast.LENGTH_LONG 32 | ) 33 | .show() 34 | } 35 | } 36 | 37 | private fun askNotificationPermission() { 38 | // This is only necessary for API level >= 33 (TIRAMISU) 39 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 40 | when { 41 | ContextCompat.checkSelfPermission( 42 | this, 43 | Manifest.permission.POST_NOTIFICATIONS 44 | ) == PackageManager.PERMISSION_GRANTED -> { 45 | // Permission already granted, proceed with showing notifications 46 | Toast.makeText( 47 | this, 48 | "Notification permission already granted", 49 | Toast.LENGTH_SHORT 50 | ).show() 51 | } 52 | 53 | shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> { 54 | // Explain to the user why the app needs this permission 55 | Toast.makeText( 56 | this, 57 | "Notification permission is needed to show playback controls and updates", 58 | Toast.LENGTH_LONG 59 | ).show() 60 | // Then request the permission 61 | requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) 62 | } 63 | 64 | else -> { 65 | // Directly ask for the permission 66 | requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) 67 | } 68 | } 69 | } 70 | } 71 | 72 | override fun onCreate(savedInstanceState: Bundle?) { 73 | super.onCreate(savedInstanceState) 74 | 75 | // Configure window to handle insets properly 76 | WindowCompat.setDecorFitsSystemWindows(window, false) 77 | 78 | // Ask for notification permission first 79 | askNotificationPermission() 80 | 81 | val api = SpotifyApiImpl() 82 | val root = MusicRootImpl( 83 | componentContext = defaultComponentContext(), 84 | api = api, 85 | mediaPlayerController = PlayerServiceLocator.playerController 86 | ) 87 | setContent { 88 | MainAndroid(root) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /androidApp/src/main/java/com/example/musicapp_kmp/android/MusicApplication.kt: -------------------------------------------------------------------------------- 1 | package com.example.musicapp_kmp.android 2 | 3 | import android.app.Application 4 | import musicapp.player.PlayerServiceLocator 5 | import musicapp.utils.PlatformContext 6 | 7 | class MusicApplication : Application() { 8 | override fun onCreate() { 9 | super.onCreate() 10 | // Initialize PlayerServiceLocator with application context 11 | PlayerServiceLocator.init(PlatformContext(applicationContext)) 12 | } 13 | } -------------------------------------------------------------------------------- /androidApp/src/main/res/values/theme.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /androidApp/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 29 | 30 | 36 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library).apply(false) 3 | alias(libs.plugins.jetbrains.compose).apply(false) 4 | alias(libs.plugins.android.application).apply(false) 5 | alias(libs.plugins.kotlin.android).apply(false) 6 | alias(libs.plugins.kotlin.multiplatform).apply(false) 7 | alias(libs.plugins.kotlin.jvm).apply(false) 8 | alias(libs.plugins.kotlin.parcelize).apply(false) 9 | alias(libs.plugins.kotlinx.serialization).apply(false) 10 | } 11 | 12 | allprojects { 13 | repositories { 14 | google() 15 | mavenCentral() 16 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 17 | mavenLocal() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /desktopApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | alias(libs.plugins.jetbrains.compose) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | kotlin { 8 | jvm {} 9 | sourceSets { 10 | val jvmMain by getting { 11 | dependencies { 12 | implementation(compose.desktop.currentOs) 13 | implementation(compose.desktop.macos_arm64) 14 | implementation(libs.ktor.client.okhttp) 15 | implementation(project(":shared")) 16 | } 17 | } 18 | } 19 | } 20 | 21 | compose.desktop { 22 | application { 23 | mainClass = "DesktopAppKt" 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/kotlin/DesktopApp.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.layout.* 2 | import androidx.compose.material.AlertDialog 3 | import androidx.compose.material.Text 4 | import androidx.compose.material.TextButton 5 | import androidx.compose.runtime.* 6 | import androidx.compose.ui.Alignment 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.unit.DpSize 9 | import androidx.compose.ui.unit.dp 10 | import androidx.compose.ui.window.Window 11 | import androidx.compose.ui.window.WindowPosition 12 | import androidx.compose.ui.window.application 13 | import androidx.compose.ui.window.rememberWindowState 14 | import com.arkivanov.decompose.DefaultComponentContext 15 | import com.arkivanov.decompose.extensions.compose.lifecycle.LifecycleController 16 | import com.arkivanov.essenty.lifecycle.LifecycleRegistry 17 | import com.arkivanov.essenty.statekeeper.SerializableContainer 18 | import com.arkivanov.essenty.statekeeper.StateKeeperDispatcher 19 | import musicapp.CommonMainDesktop 20 | import musicapp.decompose.MusicRootImpl 21 | import musicapp.network.SpotifyApiImpl 22 | import musicapp.player.MediaPlayerController 23 | import musicapp.utils.PlatformContext 24 | import java.awt.Dimension 25 | import java.awt.Toolkit 26 | import java.io.File 27 | import java.io.ObjectInputStream 28 | import java.io.ObjectOutputStream 29 | 30 | fun main() { 31 | val lifecycle = LifecycleRegistry() 32 | val stateKeeper = StateKeeperDispatcher(tryRestoreStateFromFile()) 33 | 34 | val rootComponent = runOnUiThread { 35 | MusicRootImpl( 36 | componentContext = DefaultComponentContext( 37 | lifecycle = lifecycle, 38 | stateKeeper = stateKeeper, 39 | ), 40 | api = SpotifyApiImpl(), 41 | mediaPlayerController = MediaPlayerController(PlatformContext()) 42 | ) 43 | } 44 | 45 | application { 46 | val windowState = rememberWindowState( 47 | position = WindowPosition.Aligned(Alignment.Center), 48 | size = getPreferredWindowSize(800, 800) 49 | ) 50 | 51 | LifecycleController(lifecycle, windowState) 52 | 53 | var isCloseRequested by remember { mutableStateOf(false) } 54 | 55 | Window( 56 | onCloseRequest = { isCloseRequested = true }, 57 | title = "MusicApp-KMP", 58 | state = windowState, 59 | ) { 60 | CommonMainDesktop(rootComponent) 61 | if (isCloseRequested) { 62 | SaveStateDialog( 63 | onSaveState = { saveStateToFile(stateKeeper.save()) }, 64 | onExitApplication = ::exitApplication, 65 | onDismiss = { isCloseRequested = false }, 66 | ) 67 | } 68 | } 69 | } 70 | } 71 | 72 | fun getPreferredWindowSize(desiredWidth: Int, desiredHeight: Int): DpSize { 73 | val screenSize: Dimension = Toolkit.getDefaultToolkit().screenSize 74 | val preferredWidth: Int = (screenSize.width * 0.8f).toInt() 75 | val preferredHeight: Int = (screenSize.height * 0.8f).toInt() 76 | val width: Int = if (desiredWidth < preferredWidth) desiredWidth else preferredWidth 77 | val height: Int = if (desiredHeight < preferredHeight) desiredHeight else preferredHeight 78 | return DpSize(width.dp, height.dp) 79 | } 80 | 81 | @Composable 82 | private fun SaveStateDialog( 83 | onSaveState: () -> Unit, 84 | onExitApplication: () -> Unit, 85 | onDismiss: () -> Unit, 86 | ) { 87 | AlertDialog( 88 | onDismissRequest = onDismiss, 89 | buttons = { 90 | Row( 91 | modifier = Modifier.fillMaxWidth().padding(8.dp), 92 | horizontalArrangement = Arrangement.End, 93 | ) { 94 | TextButton(onClick = onDismiss) { 95 | Text(text = "Cancel") 96 | } 97 | 98 | TextButton(onClick = onExitApplication) { 99 | Text(text = "No") 100 | } 101 | 102 | TextButton(onClick = { 103 | // onSaveState() 104 | onExitApplication() 105 | }) { 106 | Text(text = "Yes") 107 | } 108 | } 109 | }, 110 | title = { Text(text = "MusicApp-KMP") }, 111 | text = { Text(text = "Do you want to save the application's state?") }, 112 | modifier = Modifier.width(400.dp), 113 | ) 114 | } 115 | 116 | private const val SAVED_STATE_FILE_NAME = "saved_state.dat" 117 | 118 | private fun saveStateToFile(state: SerializableContainer) { 119 | ObjectOutputStream(File(SAVED_STATE_FILE_NAME).outputStream()).use { output -> 120 | output.writeObject(state) 121 | } 122 | } 123 | 124 | private fun tryRestoreStateFromFile(): SerializableContainer? = 125 | File(SAVED_STATE_FILE_NAME).takeIf(File::exists)?.let { file -> 126 | try { 127 | ObjectInputStream(file.inputStream()).use(ObjectInputStream::readObject) as SerializableContainer 128 | } catch (e: Exception) { 129 | null 130 | } finally { 131 | file.delete() 132 | } 133 | } 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /desktopApp/src/jvmMain/kotlin/Utils.kt: -------------------------------------------------------------------------------- 1 | import javax.swing.SwingUtilities 2 | 3 | internal fun runOnUiThread(block: () -> T): T { 4 | if (SwingUtilities.isEventDispatchThread()) { 5 | return block() 6 | } 7 | 8 | var error: Throwable? = null 9 | var result: T? = null 10 | 11 | SwingUtilities.invokeAndWait { 12 | try { 13 | result = block() 14 | } catch (e: Throwable) { 15 | error = e 16 | } 17 | } 18 | 19 | error?.also { throw it } 20 | 21 | @Suppress("UNCHECKED_CAST") 22 | return result as T 23 | } 24 | -------------------------------------------------------------------------------- /docs/META-INF/MANIFEST.MF: -------------------------------------------------------------------------------- 1 | Manifest-Version: 1.0 2 | 3 | -------------------------------------------------------------------------------- /docs/composeResources/musicapp_kmp.shared.generated.resources/drawable/baseline_pause_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/composeResources/musicapp_kmp.shared.generated.resources/values/strings.commonMain.cvr: -------------------------------------------------------------------------------- 1 | version:0 2 | string|back|QmFjaw== 3 | string|explore_details|RXhwbG9yZSBkZXRhaWxz 4 | string|favorite|RmF2b3JpdGU= 5 | string|featured_playlist|RmVhdHVyZWQgUGxheWxpc3Q= 6 | string|forward|Rm9yd2FyZA== 7 | string|go_back|R28gYmFjaw== 8 | string|likes|TGlrZXM= 9 | string|new_releases|TmV3IHJlbGVhc2Vz 10 | string|pause|UGF1c2U= 11 | string|play_all|UGxheSBBbGw= 12 | string|play|UGxheQ== 13 | string|songs|c29uZ3M= 14 | string|tracks|dHJhY2tz 15 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Music-App KMP 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/skiko.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SEAbdulbasit/MusicApp-KMP/85f8a04069b32de1a3349741de2c067a78258b2f/docs/skiko.wasm -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M" 3 | #Kotlin 4 | kotlin.code.style=official 5 | #Android 6 | android.useAndroidX=true 7 | android.nonTransitiveRClass=true 8 | #MPP 9 | kotlin.mpp.enableCInteropCommonization=true 10 | kotlin.mpp.androidSourceSetLayoutVersion=2 11 | org.jetbrains.compose.experimental.uikit.enabled=true 12 | org.jetbrains.compose.experimental.jscanvas.enabled=true 13 | org.jetbrains.compose.experimental.macos.enabled=true 14 | xcodeproj=iosApp 15 | kotlin.native.cocoapods.generate.wrapper=true 16 | kotlin.native.useEmbeddableCompilerJar=true 17 | # Enable kotlin/native experimental memory model 18 | kotlin.native.binary.memoryModel=experimental 19 | kotlin.js.ir.output.granularity=whole-program 20 | android.nonFinalResIds=false 21 | 22 | # incase running the app from IntelliJ IDE, enable this 23 | kotlin.native.cacheKind=none -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | 3 | assertk="0.28.1" 4 | kotlin="2.1.21" 5 | compose-plugin="1.8.1" 6 | kotlinx-coroutines-test="1.10.2" 7 | ktor="3.1.3" 8 | decompose="3.3.0" 9 | essenty="2.5.0" 10 | media3="1.7.1" 11 | kotlinx-serialization ="1.8.1" 12 | vlcj = "4.8.2" 13 | image-loader="1.10.0" 14 | kotlinx-datetime = "0.6.2" 15 | 16 | # Android 17 | androidx-activity-compose="1.10.1" 18 | agp = "8.7.3" 19 | androidx-core = "1.16.0" 20 | 21 | [libraries] 22 | # AndroidX 23 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } 24 | 25 | # Media3 26 | androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } 27 | androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } 28 | androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } 29 | 30 | #essenty 31 | essenty-lifecycle = { module = "com.arkivanov.essenty:lifecycle", version.ref = "essenty" } 32 | 33 | #decompose 34 | decompose={ module = "com.arkivanov.decompose:decompose", version.ref = "decompose"} 35 | #decompose-compose={ module = "com.arkivanov.decompose:extensions-compose-jetbrains", version.ref = "decompose"} 36 | decompose-compose="com.arkivanov.decompose:extensions-compose-experimental:3.2.0-alpha04" 37 | 38 | # Ktor 39 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" } 40 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 41 | ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } 42 | ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } 43 | ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } 44 | ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } 45 | ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } 46 | # ktor-client-serialization is deprecated in Ktor 3.x, using ktor-serialization-kotlinx-json instead 47 | 48 | # KotlinX 49 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 50 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } 51 | 52 | #image loaderxx 53 | image-loader = { module = "io.github.qdsfdhvh:image-loader", version.ref = "image-loader" } 54 | 55 | # Vlcj 56 | vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } 57 | 58 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } 59 | assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } 60 | androidx-core = { group = "androidx.core", name = "core", version.ref = "androidx-core" } 61 | # or if you need platform-specific test dependencies (see below) 62 | # kotlin-test-common = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } 63 | # kotlin-test-annotations-common = { module = "org.jetbrains.kotlin:kotlin-test-annotations-common", version.ref = "kotlin" } 64 | 65 | [plugins] 66 | # Multiplatform 67 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 68 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 69 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 70 | kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } 71 | jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } 72 | kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 73 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 74 | 75 | # Android 76 | android-library = { id = "com.android.library", version.ref = "agp" } 77 | android-application = { id = "com.android.application", version.ref = "agp" } 78 | 79 | [bundles] 80 | 81 | ktor = [ 82 | "ktor-client-core", 83 | "ktor-logging", 84 | "ktor-serialization", 85 | "ktor-content-negotiation", 86 | "ktor-client-js", 87 | ] 88 | 89 | decompose = [ 90 | "decompose", 91 | "decompose-compose" 92 | ] 93 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SEAbdulbasit/MusicApp-KMP/85f8a04069b32de1a3349741de2c067a78258b2f/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Use the maximum available, or set MAX_FD != -1 to use that value. 89 | MAX_FD=maximum 90 | 91 | warn () { 92 | echo "$*" 93 | } >&2 94 | 95 | die () { 96 | echo 97 | echo "$*" 98 | echo 99 | exit 1 100 | } >&2 101 | 102 | # OS specific support (must be 'true' or 'false'). 103 | cygwin=false 104 | msys=false 105 | darwin=false 106 | nonstop=false 107 | case "$( uname )" in #( 108 | CYGWIN* ) cygwin=true ;; #( 109 | Darwin* ) darwin=true ;; #( 110 | MSYS* | MINGW* ) msys=true ;; #( 111 | NONSTOP* ) nonstop=true ;; 112 | esac 113 | 114 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 115 | 116 | 117 | # Determine the Java command to use to start the JVM. 118 | if [ -n "$JAVA_HOME" ] ; then 119 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 120 | # IBM's JDK on AIX uses strange locations for the executables 121 | JAVACMD=$JAVA_HOME/jre/sh/java 122 | else 123 | JAVACMD=$JAVA_HOME/bin/java 124 | fi 125 | if [ ! -x "$JAVACMD" ] ; then 126 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 127 | 128 | Please set the JAVA_HOME variable in your environment to match the 129 | location of your Java installation." 130 | fi 131 | else 132 | JAVACMD=java 133 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 134 | 135 | Please set the JAVA_HOME variable in your environment to match the 136 | location of your Java installation." 137 | fi 138 | 139 | # Increase the maximum file descriptors if we can. 140 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 141 | case $MAX_FD in #( 142 | max*) 143 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 144 | # shellcheck disable=SC3045 145 | MAX_FD=$( ulimit -H -n ) || 146 | warn "Could not query maximum file descriptor limit" 147 | esac 148 | case $MAX_FD in #( 149 | '' | soft) :;; #( 150 | *) 151 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 152 | # shellcheck disable=SC3045 153 | ulimit -n "$MAX_FD" || 154 | warn "Could not set maximum file descriptor limit to $MAX_FD" 155 | esac 156 | fi 157 | 158 | # Collect all arguments for the java command, stacking in reverse order: 159 | # * args from the command line 160 | # * the main class name 161 | # * -classpath 162 | # * -D...appname settings 163 | # * --module-path (only if needed) 164 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 165 | 166 | # For Cygwin or MSYS, switch paths to Windows format before running java 167 | if "$cygwin" || "$msys" ; then 168 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 169 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 170 | 171 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 172 | 173 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 174 | for arg do 175 | if 176 | case $arg in #( 177 | -*) false ;; # don't mess with options #( 178 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 179 | [ -e "$t" ] ;; #( 180 | *) false ;; 181 | esac 182 | then 183 | arg=$( cygpath --path --ignore --mixed "$arg" ) 184 | fi 185 | # Roll the args list around exactly as many times as the number of 186 | # args, so each arg winds up back in the position where it started, but 187 | # possibly modified. 188 | # 189 | # NB: a `for` loop captures its iteration list before it begins, so 190 | # changing the positional parameters here affects neither the number of 191 | # iterations, nor the values presented in `arg`. 192 | shift # remove old arg 193 | set -- "$@" "$arg" # push replacement arg 194 | done 195 | fi 196 | 197 | 198 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 199 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 200 | 201 | # Collect all arguments for the java command; 202 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 203 | # shell script including quotes and variable substitutions, so put them in 204 | # double quotes to make sure that they get re-expanded; and 205 | # * put everything else in single quotes, so that it's not re-expanded. 206 | 207 | set -- \ 208 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 209 | -classpath "$CLASSPATH" \ 210 | org.gradle.wrapper.GradleWrapperMain \ 211 | "$@" 212 | 213 | # Stop when "xargs" is not available. 214 | if ! command -v xargs >/dev/null 2>&1 215 | then 216 | die "xargs is not available" 217 | fi 218 | 219 | # Use "xargs" to parse quoted args. 220 | # 221 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 222 | # 223 | # In Bash we could simply go: 224 | # 225 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 226 | # set -- "${ARGS[@]}" "$@" 227 | # 228 | # but POSIX shell has neither arrays nor command substitution, so instead we 229 | # post-process each arg (as a line of input to sed) to backslash-escape any 230 | # character that might be a shell metacharacter, then use eval to reverse 231 | # that process (while maintaining the separation between arguments), and wrap 232 | # the whole thing up as a single "set" statement. 233 | # 234 | # This will of course break if any of these variables contains a newline or 235 | # an unmatched quote. 236 | # 237 | 238 | eval "set -- $( 239 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 240 | xargs -n1 | 241 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 242 | tr '\n' ' ' 243 | )" '"$@"' 244 | 245 | exec "$JAVACMD" "$@" 246 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /iosApp/MusicApp-KMP-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIBackgroundModes 6 | 7 | audio 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /iosApp/MusicApp-KMP.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iosApp/MusicApp-KMP.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iosApp/MusicApp-KMP.xcodeproj/project.xcworkspace/xcuserdata/abdulbasit.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SEAbdulbasit/MusicApp-KMP/85f8a04069b32de1a3349741de2c067a78258b2f/iosApp/MusicApp-KMP.xcodeproj/project.xcworkspace/xcuserdata/abdulbasit.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /iosApp/MusicApp-KMP.xcodeproj/project.xcworkspace/xcuserdata/abdulbasit.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /iosApp/MusicApp-KMP.xcodeproj/xcuserdata/abdulbasit.xcuserdatad/xcschemes/iosApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 14 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /iosApp/MusicApp-KMP.xcodeproj/xcuserdata/abdulbasit.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | iosApp.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /iosApp/Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '14.1' 2 | 3 | target 'iosApp' do 4 | use_frameworks! 5 | pod 'shared', :path => '../shared' 6 | end -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // iosApp 4 | // 5 | // Created by Abdul Basit on 29/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | import shared 10 | 11 | struct ComposeView: UIViewControllerRepresentable { 12 | var lifecycleRegistyr: LifecycleHolder 13 | 14 | func makeUIViewController(context: Context) -> UIViewController { 15 | Main_iosKt.MainiOS(lifecycle: lifecycleRegistyr.lifecycle) 16 | } 17 | 18 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 19 | } 20 | 21 | struct ContentView: View { 22 | var lifecycleRegistyr: LifecycleHolder 23 | var body: some View { 24 | ComposeView(lifecycleRegistyr:lifecycleRegistyr) 25 | .ignoresSafeArea() // Compose has own keyboard handler 26 | } 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /iosApp/iosApp/LifecycleHolder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LifecycleHolder.swift 3 | // iosApp 4 | // 5 | // Created by Abdul Basit on 29/03/2024. 6 | // 7 | 8 | import shared 9 | 10 | class LifecycleHolder : ObservableObject { 11 | let lifecycle: LifecycleRegistry 12 | 13 | init() { 14 | lifecycle = LifecycleRegistryKt.LifecycleRegistry() 15 | 16 | lifecycle.onCreate() 17 | } 18 | 19 | deinit { 20 | lifecycle.onDestroy() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iosApp/iosApp/iosAppApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iosAppApp.swift 3 | // iosApp 4 | // 5 | // Created by Abdul Basit on 29/03/2024. 6 | // 7 | 8 | import SwiftUI 9 | import shared 10 | 11 | //@UIApplicationMain 12 | //class AppDelegate: UIResponder, UIApplicationDelegate { 13 | // var window: UIWindow? 14 | // private var lifecycleHolder: LifecycleHolder { LifecycleHolder() } 15 | // 16 | // func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | // window = UIWindow(frame: UIScreen.main.bounds) 18 | // let mainViewController = Main_iosKt.MainiOS(lifecycle: lifecycleHolder.lifecycle) 19 | // window?.rootViewController = mainViewController 20 | // window?.makeKeyAndVisible() 21 | // return true 22 | // } 23 | //} 24 | 25 | 26 | 27 | @main 28 | struct iosAppApp: App { 29 | private var lifecycleHolder: LifecycleHolder { LifecycleHolder() } 30 | 31 | var body: some Scene { 32 | WindowGroup { 33 | ContentView(lifecycleRegistyr: lifecycleHolder) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | gradlePluginPortal() 5 | mavenCentral() 6 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 7 | } 8 | } 9 | 10 | dependencyResolutionManagement { 11 | repositories { 12 | google() 13 | mavenCentral() 14 | } 15 | } 16 | 17 | rootProject.name = "MusicApp-KMP" 18 | include(":androidApp") 19 | include(":shared") 20 | include(":desktopApp") 21 | include(":webApp") -------------------------------------------------------------------------------- /shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | import org.jetbrains.kotlin.konan.target.Family 3 | 4 | plugins { 5 | alias(libs.plugins.kotlin.multiplatform) 6 | alias(libs.plugins.kotlinx.serialization) 7 | alias(libs.plugins.android.library) 8 | alias(libs.plugins.kotlin.parcelize) 9 | alias(libs.plugins.jetbrains.compose) 10 | alias(libs.plugins.compose.compiler) 11 | } 12 | 13 | kotlin { 14 | androidTarget { 15 | compilations.all { 16 | } 17 | compilerOptions { 18 | jvmTarget.set(JvmTarget.JVM_1_8) 19 | } 20 | } 21 | 22 | // macosX64 { 23 | // binaries { 24 | // executable { 25 | // entryPoint = "main" 26 | // } 27 | // } 28 | // } 29 | // macosArm64 { 30 | // binaries { 31 | // executable { 32 | // entryPoint = "main" 33 | // } 34 | // } 35 | // } 36 | 37 | listOf( 38 | iosX64(), 39 | iosArm64(), 40 | iosSimulatorArm64() 41 | ).filter { it.konanTarget.family == Family.IOS } 42 | .forEach { iosTarget -> 43 | iosTarget.binaries.framework { 44 | baseName = "shared" 45 | isStatic = true 46 | with(libs) { 47 | export(bundles.decompose) 48 | export(essenty.lifecycle) 49 | } 50 | } 51 | } 52 | 53 | jvm("desktop") 54 | js(IR) { 55 | browser() 56 | } 57 | 58 | applyDefaultHierarchyTemplate() 59 | 60 | /* cocoapods { 61 | summary = "Some description for the Shared Module" 62 | homepage = "Link to the Shared Module homepage" 63 | version = "1.0" 64 | ios.deploymentTarget = "14.1" 65 | podfile = project.file("../iosApp/Podfile") 66 | }*/ 67 | 68 | sourceSets { 69 | val desktopMain by getting 70 | 71 | commonMain.dependencies { 72 | with(compose) { 73 | implementation("org.jetbrains.compose.material:material-icons-extended:1.6.10") 74 | implementation(ui) 75 | implementation(foundation) 76 | implementation(material) 77 | implementation(runtime) 78 | implementation(components.resources) 79 | } 80 | 81 | with(libs) { 82 | implementation(kotlinx.datetime) 83 | implementation(kotlinx.serialization.json) 84 | implementation(ktor.client.core) 85 | implementation(ktor.logging) 86 | implementation(ktor.serialization) 87 | implementation(ktor.content.negotiation) 88 | api(bundles.decompose) 89 | implementation(image.loader) 90 | implementation(essenty.lifecycle) 91 | } 92 | } 93 | 94 | androidMain { 95 | dependencies { 96 | implementation(libs.androidx.core) 97 | implementation(libs.androidx.media3.exoplayer) 98 | implementation(libs.androidx.media3.session) 99 | implementation(libs.androidx.media3.ui) 100 | } 101 | } 102 | 103 | iosMain.dependencies { 104 | implementation("io.ktor:ktor-client-darwin:${libs.versions.ktor.get()}") 105 | } 106 | 107 | desktopMain.dependencies { 108 | implementation(compose.desktop.common) 109 | implementation(libs.vlcj) 110 | } 111 | 112 | jsMain.dependencies { 113 | implementation(compose.html.core) 114 | with(libs) { 115 | implementation(ktor.client.js) 116 | } 117 | } 118 | 119 | commonTest.dependencies { 120 | implementation(libs.kotlin.test) 121 | implementation(libs.kotlinx.coroutines.test) 122 | implementation("io.ktor:ktor-client-mock:${libs.versions.ktor.get()}") 123 | implementation(libs.assertk) 124 | } 125 | } 126 | } 127 | 128 | android { 129 | namespace = "com.example.musicapp_kmp" 130 | compileSdk = 35 131 | defaultConfig { 132 | minSdk = 24 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/musicapp/main.android.kt: -------------------------------------------------------------------------------- 1 | package com.example.musicapp_kmp 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.WindowInsets 6 | import androidx.compose.foundation.layout.WindowInsetsSides 7 | import androidx.compose.foundation.layout.asPaddingValues 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.only 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.systemBars 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.CompositionLocalProvider 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.platform.LocalContext 17 | import com.seiko.imageloader.Bitmap 18 | import com.seiko.imageloader.ImageLoader 19 | import com.seiko.imageloader.LocalImageLoader 20 | import com.seiko.imageloader.cache.memory.MemoryCacheBuilder 21 | import com.seiko.imageloader.cache.memory.MemoryKey 22 | import com.seiko.imageloader.cache.memory.maxSizePercent 23 | import com.seiko.imageloader.component.setupDefaultComponents 24 | import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig 25 | import musicapp.MainCommon 26 | import musicapp.decompose.MusicRootImpl 27 | import java.lang.System.identityHashCode 28 | 29 | 30 | @Composable 31 | fun MainAndroid(root: MusicRootImpl) { 32 | // Get the system bars insets and apply padding to avoid content being hidden behind the navigation bar 33 | val systemBarsInsets = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues() 34 | 35 | Column( 36 | modifier = Modifier 37 | .fillMaxSize() 38 | .background(color = Color(0xFF1A1E1F)) 39 | .padding(bottom = systemBarsInsets.calculateBottomPadding()) 40 | ) { 41 | val context = LocalContext.current 42 | CompositionLocalProvider( 43 | LocalImageLoader provides ImageLoader { 44 | components { 45 | setupDefaultComponents(context) 46 | } 47 | interceptor { 48 | bitmapMemoryCacheConfig( 49 | valueHashProvider = { identityHashCode(it) }, 50 | valueSizeProvider = { 500 }, 51 | block = fun MemoryCacheBuilder.() { 52 | maxSizePercent(context.applicationContext) 53 | } 54 | ) 55 | } 56 | }, 57 | ) { 58 | MainCommon(root, false) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/musicapp/player/MediaPlayerController.android.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import androidx.core.content.ContextCompat 6 | import androidx.media3.common.MediaItem 7 | import androidx.media3.common.PlaybackException 8 | import androidx.media3.common.Player 9 | import androidx.media3.common.Player.STATE_ENDED 10 | import androidx.media3.common.Player.STATE_READY 11 | import musicapp.player.mapper.toMediaItem 12 | import musicapp.player.service.MediaService 13 | import musicapp.utils.PlatformContext 14 | 15 | @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") 16 | actual class MediaPlayerController actual constructor(val platformContext: PlatformContext) { 17 | private val player = PlayerServiceLocator.exoPlayer 18 | 19 | private var trackList: List = emptyList() 20 | private var currentTrackIndex: Int = -1 21 | private var currentListener: MediaPlayerListener? = null 22 | 23 | init { 24 | player.addListener(object : Player.Listener { 25 | override fun onIsPlayingChanged(isPlaying: Boolean) { 26 | if (isPlaying) 27 | startMediaServiceIfNeeded() 28 | } 29 | 30 | override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { 31 | super.onMediaItemTransition(mediaItem, reason) 32 | if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { 33 | playNextTrack() 34 | } 35 | } 36 | }) 37 | } 38 | 39 | actual fun setTrackList(trackList: List, currentTrackId: String) { 40 | this.trackList = trackList 41 | this.currentTrackIndex = trackList.indexOfFirst { it.id == currentTrackId }.takeIf { it >= 0 } ?: 0 42 | } 43 | 44 | actual fun playNextTrack(): Boolean { 45 | if (trackList.isEmpty() || currentTrackIndex < 0) { 46 | return false 47 | } 48 | 49 | val nextIndex = currentTrackIndex + 1 50 | if (nextIndex >= trackList.size) { 51 | return false 52 | } 53 | 54 | currentTrackIndex = nextIndex 55 | val nextTrack = trackList[nextIndex] 56 | 57 | currentListener?.onTrackChanged(nextTrack.id) 58 | 59 | prepare(nextTrack, currentListener ?: return false) 60 | updateNotification() 61 | return true 62 | } 63 | 64 | actual fun playPreviousTrack(): Boolean { 65 | if (trackList.isEmpty() || currentTrackIndex <= 0) { 66 | return false 67 | } 68 | 69 | val previousIndex = currentTrackIndex - 1 70 | currentTrackIndex = previousIndex 71 | val previousTrack = trackList[previousIndex] 72 | 73 | currentListener?.onTrackChanged(previousTrack.id) 74 | 75 | prepare(previousTrack, currentListener ?: return false) 76 | updateNotification() 77 | return true 78 | } 79 | 80 | actual fun getCurrentTrack(): TrackItem? { 81 | if (trackList.isEmpty() || currentTrackIndex < 0 || currentTrackIndex >= trackList.size) { 82 | return null 83 | } 84 | return trackList[currentTrackIndex] 85 | } 86 | 87 | @SuppressLint("UnsafeOptInUsageError") 88 | private fun startMediaServiceIfNeeded() { 89 | if (MediaService.isRunning) return 90 | val intent = Intent(platformContext.applicationContext, MediaService::class.java) 91 | ContextCompat.startForegroundService(platformContext.applicationContext, intent) 92 | } 93 | 94 | private fun updateNotification() { 95 | if (MediaService.isRunning) { 96 | // Send an intent to the service to update the notification without stopping/restarting 97 | val intent = Intent(platformContext.applicationContext, MediaService::class.java) 98 | intent.action = MediaService.ACTION_UPDATE_NOTIFICATION 99 | ContextCompat.startForegroundService(platformContext.applicationContext, intent) 100 | } else { 101 | // If service is not running, start it 102 | startMediaServiceIfNeeded() 103 | } 104 | } 105 | 106 | private var playerListener: Player.Listener? = null 107 | 108 | actual fun prepare(mediaItem: TrackItem, listener: MediaPlayerListener) { 109 | this.currentListener = listener 110 | 111 | if (trackList.isNotEmpty()) { 112 | val index = trackList.indexOfFirst { it.id == mediaItem.id } 113 | if (index >= 0) { 114 | currentTrackIndex = index 115 | } 116 | } 117 | 118 | playerListener?.let { player.removeListener(it) } 119 | playerListener = object : Player.Listener { 120 | override fun onPlayerError(error: PlaybackException) { 121 | super.onPlayerError(error) 122 | listener.onError() 123 | } 124 | 125 | override fun onPlaybackStateChanged(playbackState: Int) { 126 | super.onPlaybackStateChanged(playbackState) 127 | when (playbackState) { 128 | STATE_READY -> { 129 | listener.onReady() 130 | listener.onBufferingStateChanged(false) 131 | } 132 | 133 | STATE_ENDED -> { 134 | val nextTrackPlayed = playNextTrack() 135 | 136 | if (!nextTrackPlayed) { 137 | listener.onAudioCompleted() 138 | } 139 | } 140 | } 141 | } 142 | 143 | override fun onPlayerErrorChanged(error: PlaybackException?) { 144 | super.onPlayerErrorChanged(error) 145 | listener.onError() 146 | } 147 | 148 | override fun onIsPlayingChanged(isPlaying: Boolean) { 149 | super.onIsPlayingChanged(isPlaying) 150 | listener.onPlaybackStateChanged(isPlaying) 151 | 152 | if (isPlaying) { 153 | startMediaServiceIfNeeded() 154 | } 155 | } 156 | 157 | override fun onIsLoadingChanged(isLoading: Boolean) { 158 | super.onIsLoadingChanged(isLoading) 159 | listener.onBufferingStateChanged(isLoading) 160 | } 161 | } 162 | 163 | listener.onBufferingStateChanged(true) 164 | 165 | player.addListener(playerListener!!) 166 | player.setMediaItem(mediaItem.toMediaItem()) 167 | player.prepare() 168 | player.play() 169 | 170 | // Ensure notification is updated with new track info 171 | startMediaServiceIfNeeded() 172 | updateNotification() 173 | } 174 | 175 | actual fun start() { 176 | player.play() 177 | } 178 | 179 | actual fun pause() { 180 | if (player.isPlaying) 181 | player.pause() 182 | } 183 | 184 | actual fun seekTo(seconds: Long) { 185 | player.seekTo(seconds) 186 | } 187 | 188 | actual fun getCurrentPosition(): Long? { 189 | return player.currentPosition 190 | } 191 | 192 | actual fun getDuration(): Long? { 193 | return player.duration 194 | } 195 | 196 | actual fun isPlaying(): Boolean { 197 | return player.isPlaying 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/musicapp/player/PlayerServiceLocator.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.media3.common.AudioAttributes 5 | import androidx.media3.common.C 6 | import androidx.media3.common.Player 7 | import androidx.media3.exoplayer.ExoPlayer 8 | import androidx.media3.exoplayer.trackselection.DefaultTrackSelector 9 | import androidx.media3.session.MediaSession 10 | import musicapp.player.notification.MusicNotificationManager 11 | import musicapp.utils.PlatformContext 12 | 13 | @SuppressLint("UnsafeOptInUsageError") 14 | object PlayerServiceLocator { 15 | private lateinit var appContext: PlatformContext 16 | 17 | fun init(context: PlatformContext) { 18 | if (!::appContext.isInitialized) { 19 | appContext = context 20 | } 21 | } 22 | 23 | fun isInitialized(): Boolean { 24 | return ::appContext.isInitialized 25 | } 26 | 27 | val audioAttributes: AudioAttributes by lazy { 28 | AudioAttributes.Builder() 29 | .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) 30 | .setUsage(C.USAGE_MEDIA) 31 | .build() 32 | } 33 | 34 | val exoPlayer: Player by lazy { 35 | ExoPlayer.Builder(appContext.applicationContext) 36 | .setAudioAttributes(audioAttributes, true) 37 | .setHandleAudioBecomingNoisy(true) 38 | .setTrackSelector(DefaultTrackSelector(appContext.applicationContext)) 39 | .build() 40 | } 41 | 42 | val mediaSession: MediaSession by lazy { 43 | MediaSession.Builder(appContext.applicationContext, exoPlayer).build() 44 | } 45 | 46 | val musicNotificationManager: MusicNotificationManager by lazy { 47 | MusicNotificationManager(appContext.applicationContext, exoPlayer) 48 | } 49 | 50 | val playerController: MediaPlayerController by lazy { 51 | MediaPlayerController(appContext) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/musicapp/player/mapper/MediaItemMapper.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player.mapper 2 | 3 | import android.net.Uri 4 | import androidx.media3.common.MediaItem 5 | import androidx.media3.common.MediaMetadata 6 | 7 | 8 | fun musicapp.player.TrackItem.toMediaItem(): MediaItem { 9 | val metadata = MediaMetadata.Builder() 10 | .setTitle(title) 11 | .setArtist(artist) 12 | .setArtworkUri(Uri.parse(albumImageUrl)) 13 | .build() 14 | 15 | return MediaItem.Builder() 16 | .setMediaId(pathSource) 17 | .setUri(pathSource) 18 | .setMediaMetadata(metadata) 19 | .build() 20 | } -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/musicapp/player/notification/MusicNotificationDescriptorAdapter.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player.notification 2 | 3 | import android.app.PendingIntent 4 | import android.content.Context 5 | import android.graphics.Bitmap 6 | import androidx.media3.common.Player 7 | import androidx.media3.common.util.UnstableApi 8 | import androidx.media3.ui.PlayerNotificationManager 9 | 10 | @UnstableApi 11 | class MusicNotificationDescriptorAdapter( 12 | private val context: Context, 13 | private val pendingIntent: PendingIntent? 14 | ) : PlayerNotificationManager.MediaDescriptionAdapter { 15 | 16 | override fun getCurrentContentTitle(player: Player): CharSequence = 17 | player.mediaMetadata.albumTitle ?: "Unknown" 18 | 19 | override fun createCurrentContentIntent(player: Player): PendingIntent? = pendingIntent 20 | 21 | override fun getCurrentContentText(player: Player): CharSequence = 22 | player.mediaMetadata.displayTitle ?: "Unknown" 23 | 24 | override fun getCurrentLargeIcon( 25 | player: Player, 26 | callback: PlayerNotificationManager.BitmapCallback 27 | ): Bitmap? { 28 | val uri = player.mediaMetadata.artworkUri ?: return null 29 | return null 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/musicapp/player/notification/MusicNotificationManager.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player.notification 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Notification 5 | import android.app.NotificationChannel 6 | import android.app.NotificationManager 7 | import android.content.Context 8 | import android.os.Build 9 | import androidx.annotation.RequiresApi 10 | import androidx.core.app.NotificationCompat 11 | import androidx.core.app.NotificationManagerCompat 12 | import androidx.media3.common.Player 13 | import androidx.media3.session.MediaSession 14 | import androidx.media3.session.MediaSessionService 15 | import androidx.media3.ui.PlayerNotificationManager 16 | 17 | class MusicNotificationManager( 18 | private val context: Context, 19 | private val exoPlayer: Player 20 | ) { 21 | companion object { 22 | const val NOTIFICATION_ID = 1557 23 | const val NOTIFICATION_CHANNEL_ID = "musicapp_notification_channel" 24 | const val NOTIFICATION_CHANNEL_NAME = "Musicapp Notification Channel" 25 | } 26 | 27 | private val musicNotificationManager: NotificationManagerCompat = 28 | NotificationManagerCompat.from(context) 29 | 30 | init { 31 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) 32 | createMusicNotificationChannel() 33 | } 34 | 35 | @RequiresApi(Build.VERSION_CODES.O) 36 | private fun createMusicNotificationChannel() { 37 | val musicNotificationChannel = NotificationChannel( 38 | NOTIFICATION_CHANNEL_ID, 39 | NOTIFICATION_CHANNEL_NAME, 40 | NotificationManager.IMPORTANCE_DEFAULT 41 | ) 42 | 43 | musicNotificationManager.createNotificationChannel(musicNotificationChannel) 44 | } 45 | 46 | @SuppressLint("UnsafeOptInUsageError") 47 | private fun buildMusicNotification(mediaSession: MediaSession) { 48 | PlayerNotificationManager.Builder( 49 | context, 50 | NOTIFICATION_ID, 51 | NOTIFICATION_CHANNEL_ID 52 | ) 53 | .setMediaDescriptionAdapter( 54 | MusicNotificationDescriptorAdapter( 55 | context = context, 56 | pendingIntent = mediaSession.sessionActivity 57 | ) 58 | ) 59 | .setSmallIconResourceId(androidx.media3.session.R.drawable.media_session_service_notification_ic_music_note) 60 | .build() 61 | .also { 62 | it.setMediaSessionToken(mediaSession.platformToken) 63 | it.setUseFastForwardActionInCompactView(true) 64 | it.setUseRewindActionInCompactView(true) 65 | it.setUseNextActionInCompactView(true) 66 | it.setUsePreviousActionInCompactView(true) 67 | it.setPriority(NotificationCompat.PRIORITY_DEFAULT) 68 | it.setPlayer(exoPlayer) 69 | } 70 | } 71 | 72 | fun startMusicNotificationService( 73 | mediaSessionService: MediaSessionService, 74 | mediaSession: MediaSession 75 | ) { 76 | buildMusicNotification(mediaSession) 77 | } 78 | 79 | @RequiresApi(Build.VERSION_CODES.O) 80 | private fun startForegroundMusicService(mediaSessionService: MediaSessionService) { 81 | val musicNotification = Notification.Builder(context, NOTIFICATION_CHANNEL_ID) 82 | .setCategory(Notification.CATEGORY_SERVICE) 83 | .setSmallIcon(androidx.media3.session.R.drawable.media_session_service_notification_ic_music_note) 84 | .build() 85 | 86 | mediaSessionService.startForeground(NOTIFICATION_ID, musicNotification) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/musicapp/player/service/MediaService.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player.service 2 | 3 | import android.app.Notification 4 | import android.content.Intent 5 | import android.os.Build 6 | import androidx.core.app.NotificationCompat 7 | import androidx.media3.common.Player 8 | import androidx.media3.session.MediaSession 9 | import androidx.media3.session.MediaSessionService 10 | import musicapp.player.PlayerServiceLocator 11 | import musicapp.player.notification.MusicNotificationManager 12 | import musicapp.utils.PlatformContext 13 | 14 | class MediaService : MediaSessionService() { 15 | companion object { 16 | var isRunning = false 17 | const val ACTION_UPDATE_NOTIFICATION = "musicapp.player.service.action.UPDATE_NOTIFICATION" 18 | } 19 | 20 | init { 21 | if (!PlayerServiceLocator.isInitialized()) { 22 | PlayerServiceLocator.init(PlatformContext(applicationContext)) 23 | } 24 | } 25 | 26 | private val mediaSession by lazy { PlayerServiceLocator.mediaSession } 27 | private val musicNotificationManager by lazy { 28 | PlayerServiceLocator.musicNotificationManager 29 | } 30 | 31 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 32 | isRunning = true 33 | 34 | if (intent?.action == ACTION_UPDATE_NOTIFICATION) { 35 | musicNotificationManager.startMusicNotificationService( 36 | mediaSession = mediaSession, 37 | mediaSessionService = this 38 | ) 39 | } else { 40 | val initialNotification = createInitialNotification() 41 | startForeground(MusicNotificationManager.NOTIFICATION_ID, initialNotification) 42 | 43 | musicNotificationManager.startMusicNotificationService( 44 | mediaSession = mediaSession, 45 | mediaSessionService = this 46 | ) 47 | } 48 | 49 | return super.onStartCommand(intent, flags, startId) 50 | } 51 | 52 | private fun createInitialNotification(): Notification { 53 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 54 | Notification.Builder(this, MusicNotificationManager.NOTIFICATION_CHANNEL_ID) 55 | .setContentTitle("Music App") 56 | .setContentText("Loading music...") 57 | .setSmallIcon(androidx.media3.session.R.drawable.media_session_service_notification_ic_music_note) 58 | .setCategory(Notification.CATEGORY_SERVICE) 59 | .build() 60 | } else { 61 | NotificationCompat.Builder(this, MusicNotificationManager.NOTIFICATION_CHANNEL_ID) 62 | .setContentTitle("Music App") 63 | .setContentText("Loading music...") 64 | .setSmallIcon(androidx.media3.session.R.drawable.media_session_service_notification_ic_music_note) 65 | .setPriority(NotificationCompat.PRIORITY_DEFAULT) 66 | .build() 67 | } 68 | } 69 | 70 | override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession = 71 | mediaSession 72 | 73 | 74 | override fun onDestroy() { 75 | isRunning = false 76 | super.onDestroy() 77 | mediaSession.apply { 78 | release() 79 | if (player.playbackState != Player.STATE_IDLE) { 80 | player.seekTo(0) 81 | player.playWhenReady = false 82 | player.stop() 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/musicapp/utils/PlatformContext.android.kt: -------------------------------------------------------------------------------- 1 | package musicapp.utils 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.platform.LocalContext 6 | 7 | actual class PlatformContext(val applicationContext: Context) 8 | 9 | 10 | // Global variable to store the application context 11 | private var applicationContext: Context? = null 12 | 13 | /** 14 | * Initialize the platform context with the application context. 15 | * This should be called from the Application class or MainActivity. 16 | */ 17 | fun initializePlatformContext(context: Context) { 18 | applicationContext = context.applicationContext 19 | } 20 | 21 | /** 22 | * Get the platform context for Android. 23 | * This returns a PlatformContext instance that wraps the Android application context. 24 | */ 25 | actual fun getPlatformContext(): PlatformContext { 26 | // If the application context is not initialized, throw an exception 27 | val context = applicationContext ?: throw IllegalStateException( 28 | "PlatformContext not initialized. Call initializePlatformContext() first." 29 | ) 30 | return PlatformContext(context) 31 | } 32 | 33 | /** 34 | * Get the platform context from a Composable function. 35 | * This is an alternative way to get the platform context when in a Composable scope. 36 | */ 37 | @Composable 38 | fun getPlatformContextFromComposable(): PlatformContext { 39 | val context = LocalContext.current 40 | return PlatformContext(context) 41 | } -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/baseline_pause_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/forward.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SEAbdulbasit/MusicApp-KMP/85f8a04069b32de1a3349741de2c067a78258b2f/shared/src/commonMain/composeResources/drawable/forward.png -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/moon_fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SEAbdulbasit/MusicApp-KMP/85f8a04069b32de1a3349741de2c067a78258b2f/shared/src/commonMain/composeResources/drawable/moon_fill.png -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/moon_outline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SEAbdulbasit/MusicApp-KMP/85f8a04069b32de1a3349741de2c067a78258b2f/shared/src/commonMain/composeResources/drawable/moon_outline.png -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/drawable/rewind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SEAbdulbasit/MusicApp-KMP/85f8a04069b32de1a3349741de2c067a78258b2f/shared/src/commonMain/composeResources/drawable/rewind.png -------------------------------------------------------------------------------- /shared/src/commonMain/composeResources/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Featured Playlist 3 | Explore details 4 | Likes 5 | tracks 6 | Favorite 7 | New releases 8 | Go back 9 | songs 10 | Play All 11 | Forward 12 | Back 13 | Play 14 | Pause 15 | Rewind by 5 seconds 16 | Forward by 5 seconds 17 | Stop audio in 18 | 15 minute 19 | 30 minute 20 | 45 minute 21 | 1 hour 22 | 2 hour 23 | 3 hour 24 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/MusicAppTheme.kt: -------------------------------------------------------------------------------- 1 | package musicapp 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.Shapes 6 | import androidx.compose.material.Typography 7 | import androidx.compose.material.darkColors 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.text.TextStyle 11 | import androidx.compose.ui.text.font.FontFamily 12 | import androidx.compose.ui.text.font.FontWeight 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | 16 | @Composable 17 | internal fun MusicAppTheme( 18 | content: @Composable () -> Unit 19 | ) { 20 | val colors = darkColors( 21 | primary = Color(0xFF1D2123), 22 | primaryVariant = Color(0xFF3700B3), 23 | secondary = Color(0xFFFACD66), 24 | surface = Color(0xFF1E1E1E), 25 | background = Color(0xFF1E1E1E), 26 | onSurface = Color(0xFF1E1E1E), 27 | ) 28 | 29 | val typography = Typography( 30 | body1 = TextStyle( 31 | fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp 32 | ) 33 | ) 34 | val shapes = Shapes( 35 | small = RoundedCornerShape(4.dp), 36 | medium = RoundedCornerShape(4.dp), 37 | large = RoundedCornerShape(0.dp) 38 | ) 39 | 40 | MaterialTheme( 41 | colors = colors, typography = typography, shapes = shapes, content = content 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/Token.kt: -------------------------------------------------------------------------------- 1 | package musicapp 2 | 3 | 4 | /** 5 | * Created by abdulbasit on 26/02/2023. 6 | */ 7 | 8 | const val TOKEN = "" 9 | const val SEEK_TO_SECONDS = 5000L -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/chartdetails/ChartDetailsLarge.kt: -------------------------------------------------------------------------------- 1 | package musicapp.chartdetails 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material.Icon 12 | import androidx.compose.material.IconButton 13 | import androidx.compose.material.MaterialTheme 14 | import androidx.compose.material.Text 15 | import androidx.compose.material.icons.Icons 16 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 17 | import androidx.compose.material.icons.filled.PlayArrow 18 | import androidx.compose.runtime.* 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clip 22 | import androidx.compose.ui.graphics.Brush 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.graphics.painter.Painter 25 | import androidx.compose.ui.layout.ContentScale 26 | import androidx.compose.ui.unit.dp 27 | import com.seiko.imageloader.rememberImagePainter 28 | import musicapp.decompose.ChartDetailsComponent 29 | import musicapp.network.models.topfiftycharts.Item 30 | import musicapp.network.models.topfiftycharts.TopFiftyCharts 31 | import musicapp.player.toMediaItem 32 | import musicapp_kmp.shared.generated.resources.* 33 | import org.jetbrains.compose.resources.ExperimentalResourceApi 34 | import org.jetbrains.compose.resources.painterResource 35 | import org.jetbrains.compose.resources.stringResource 36 | 37 | 38 | /** 39 | * Created by abdulbasit on 28/02/2023. 40 | */ 41 | @OptIn(ExperimentalResourceApi::class) 42 | @Composable 43 | internal fun ChartDetailsScreenLarge( 44 | chartDetailsComponent: ChartDetailsComponent, 45 | ) { 46 | val state = chartDetailsComponent.viewModel.chartDetailsViewState.collectAsState() 47 | var sleepTimerModalBottomSheetState by remember { mutableStateOf(false) } 48 | var isAnySleepTimerSelected by remember { mutableStateOf(false) } 49 | 50 | when (val resultedState = state.value) { 51 | is ChartDetailsViewState.Failure -> Failure(resultedState.error) 52 | ChartDetailsViewState.Loading -> Loading() 53 | is ChartDetailsViewState.Success -> ChartDetailsViewLarge( 54 | isAnyTimeIntervalSelected = isAnySleepTimerSelected, 55 | chartDetails = resultedState.chartDetails, 56 | playingTrackId = resultedState.playingTrackId, 57 | onSleepTimerClicked = { 58 | sleepTimerModalBottomSheetState = true 59 | }, 60 | onPlayAllClicked = { 61 | chartDetailsComponent.onOutPut( 62 | ChartDetailsComponent.Output.OnPlayAllSelected( 63 | it.mapNotNull { it.track?.toMediaItem() } 64 | ) 65 | ) 66 | }, 67 | onPlayTrack = { id, list -> 68 | chartDetailsComponent.onOutPut( 69 | ChartDetailsComponent.Output.OnTrackSelected( 70 | id, 71 | list.mapNotNull { it.track?.toMediaItem() } 72 | ) 73 | ) 74 | } 75 | ) 76 | } 77 | IconButton(onClick = { chartDetailsComponent.onOutPut(ChartDetailsComponent.Output.GoBack) }) { 78 | Icon( 79 | Icons.AutoMirrored.Filled.ArrowBack, 80 | contentDescription = stringResource(Res.string.forward), 81 | tint = Color(0xFFFACD66), 82 | modifier = Modifier.padding(all = 16.dp).size(32.dp) 83 | ) 84 | } 85 | 86 | if (sleepTimerModalBottomSheetState) 87 | SleepTimerModalBottomSheet( 88 | countdownViewModel = chartDetailsComponent.countdownViewModel, 89 | onSleepTimerExpired = { chartDetailsComponent.onSleepTimerExpired() }, 90 | onDismiss = { 91 | sleepTimerModalBottomSheetState = false 92 | }, 93 | isAnyTimeIntervalSelected = { anyTimeIntervalSelected -> 94 | isAnySleepTimerSelected = anyTimeIntervalSelected 95 | }) 96 | } 97 | 98 | @OptIn(ExperimentalResourceApi::class) 99 | @Composable 100 | internal fun ChartDetailsViewLarge( 101 | chartDetails: TopFiftyCharts, 102 | isAnyTimeIntervalSelected: Boolean, 103 | onPlayAllClicked: (List) -> Unit, 104 | onPlayTrack: (String, List) -> Unit, 105 | onSleepTimerClicked: () -> Unit, 106 | playingTrackId: String 107 | ) { 108 | val (painter, playlistCoverPainter) = backgroundImage(chartDetails, playingTrackId) 109 | 110 | val selectedTrack = remember { mutableStateOf(playingTrackId) } 111 | 112 | val sleepTimerIcon = if (isAnyTimeIntervalSelected) 113 | painterResource(Res.drawable.moon_fill) 114 | else 115 | painterResource(Res.drawable.moon_outline) 116 | 117 | LaunchedEffect(playingTrackId) { 118 | selectedTrack.value = playingTrackId 119 | } 120 | 121 | Box(modifier = Modifier.fillMaxSize()) { 122 | Image( 123 | painter, 124 | chartDetails.images?.first()?.url.orEmpty(), 125 | modifier = Modifier.fillMaxSize(), 126 | contentScale = ContentScale.Crop 127 | ) 128 | Box( 129 | modifier = Modifier.fillMaxSize().background( 130 | brush = Brush.verticalGradient( 131 | colors = listOf( 132 | Color(0xCC1D2123), Color(0xFF1D2123) 133 | ) 134 | ) 135 | ) 136 | ) 137 | } 138 | 139 | LazyColumn( 140 | modifier = Modifier.padding(horizontal = 63.dp), 141 | contentPadding = PaddingValues(vertical = 16.dp), 142 | verticalArrangement = Arrangement.spacedBy(10.dp), 143 | ) { 144 | 145 | item { 146 | Box(modifier = Modifier.fillMaxSize()) { 147 | Row(modifier = Modifier.padding(16.dp).align(Alignment.TopCenter)) { 148 | Image( 149 | painter = playlistCoverPainter, 150 | contentDescription = chartDetails.images?.first()?.url.orEmpty(), 151 | modifier = Modifier.padding(top = 24.dp, bottom = 20.dp).height(284.dp) 152 | .width(284.dp) 153 | .aspectRatio(1f).clip(RoundedCornerShape(25.dp)), 154 | contentScale = ContentScale.Crop, 155 | ) 156 | Column( 157 | horizontalAlignment = Alignment.Start, 158 | modifier = Modifier.align(Alignment.Bottom).padding(16.dp) 159 | ) { 160 | Text( 161 | text = chartDetails.name.orEmpty(), 162 | style = MaterialTheme.typography.h4.copy(color = Color(0XFFA4C7C6)) 163 | ) 164 | Text( 165 | text = chartDetails.description.orEmpty(), 166 | style = MaterialTheme.typography.body2.copy(color = Color(0XFFEFEEE0)), 167 | modifier = Modifier.padding(top = 8.dp) 168 | ) 169 | Text( 170 | text = "${chartDetails.tracks?.items?.size ?: 0} ${stringResource(Res.string.songs)}}", 171 | style = MaterialTheme.typography.body2.copy(color = Color(0XFFEFEEE0)), 172 | modifier = Modifier.padding(top = 10.dp) 173 | ) 174 | Spacer(Modifier.height(40.dp).fillMaxWidth()) 175 | Row(verticalAlignment = Alignment.CenterVertically) { 176 | OptionChips(onPlayAllClicked, chartDetails.tracks?.items ?: emptyList()) 177 | Icon( 178 | painter = sleepTimerIcon, 179 | tint = Color(0xFFFACD66), 180 | contentDescription = stringResource(Res.string.sleep_timer), 181 | modifier = Modifier.size(40.dp).padding(start = 16.dp) 182 | .clickable(onClick = { 183 | onSleepTimerClicked() 184 | }) 185 | ) 186 | } 187 | } 188 | 189 | } 190 | } 191 | } 192 | items(chartDetails.tracks?.items ?: emptyList()) { track -> 193 | Box( 194 | modifier = Modifier 195 | .clip(RoundedCornerShape(20.dp)) 196 | .fillMaxWidth() 197 | .background( 198 | if (track.track?.id.orEmpty() == selectedTrack.value) Color( 199 | 0xCCFACD66 200 | ) else Color(0xFF33373B) 201 | ) 202 | .padding(16.dp) 203 | .clickable( 204 | indication = null, 205 | interactionSource = remember { MutableInteractionSource() }) 206 | { 207 | onPlayTrack( 208 | track.track?.id.orEmpty(), 209 | chartDetails.tracks?.items ?: mutableListOf() 210 | ) 211 | } 212 | ) { 213 | Row(modifier = Modifier.fillMaxWidth()) { 214 | val active by remember { mutableStateOf(false) } 215 | val albumImageUrl = rememberImagePainter(track.track?.album?.images?.first()?.url.orEmpty()) 216 | Box( 217 | modifier = Modifier 218 | .clickable { 219 | onPlayTrack( 220 | track.track?.id.orEmpty(), 221 | chartDetails.tracks?.items ?: mutableListOf() 222 | ) 223 | }) { 224 | Image( 225 | albumImageUrl, 226 | track.track?.album?.images?.first()?.url.orEmpty(), 227 | modifier = Modifier.clip(RoundedCornerShape(5.dp)).width(40.dp) 228 | .height(40.dp), 229 | contentScale = ContentScale.Crop 230 | ) 231 | if (active) { 232 | Icon( 233 | imageVector = Icons.Default.PlayArrow, 234 | tint = Color(0xFFFACD66), 235 | contentDescription = stringResource(Res.string.play_all), 236 | modifier = Modifier.size(40.dp) 237 | .clip(RoundedCornerShape(5.dp)) 238 | .background(Color.Black.copy(alpha = 0.7f)) 239 | ) 240 | } 241 | } 242 | Column(Modifier.weight(1f).padding(start = 8.dp).align(Alignment.Top)) { 243 | Text( 244 | text = track.track?.name.orEmpty(), 245 | style = MaterialTheme.typography.caption.copy( 246 | color = Color( 247 | 0XFFEFEEE0 248 | ) 249 | ) 250 | ) 251 | Text( 252 | text = track.track?.artists?.joinToString(",") { it.name ?: "" } 253 | .orEmpty(), 254 | style = MaterialTheme.typography.caption.copy( 255 | color = Color( 256 | 0XFFEFEEE0 257 | ) 258 | ), 259 | modifier = Modifier.padding(top = 8.dp) 260 | ) 261 | } 262 | Text( 263 | text = "${(((track.track?.durationMs ?: 0) / (1000 * 60)) % 60)}:${(((track.track?.durationMs ?: 0) / (1000)) % 60)}", 264 | style = MaterialTheme.typography.caption.copy(color = Color(0XFFEFEEE0)), 265 | modifier = Modifier.align( 266 | Alignment.Bottom 267 | ) 268 | ) 269 | } 270 | } 271 | } 272 | } 273 | } 274 | 275 | @Composable 276 | fun backgroundImage( 277 | chartDetails: TopFiftyCharts, 278 | playingTrackId: String 279 | ): Pair { 280 | val currentTrack = chartDetails.tracks?.items?.find { it.track?.id.orEmpty() == playingTrackId } 281 | 282 | val backgroundImageUrl = 283 | currentTrack?.track?.album?.images?.firstOrNull()?.url ?: chartDetails.images?.first()?.url.orEmpty() 284 | val painter = rememberImagePainter(backgroundImageUrl) 285 | 286 | val playlistCoverPainter = rememberImagePainter(chartDetails.images?.first()?.url.orEmpty()) 287 | return Pair(painter, playlistCoverPainter) 288 | } 289 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/chartdetails/ChartDetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package musicapp.chartdetails 2 | 3 | import com.arkivanov.essenty.instancekeeper.InstanceKeeper 4 | import musicapp.decompose.ChartDetailsComponent 5 | import musicapp.network.SpotifyApi 6 | import kotlinx.coroutines.CoroutineExceptionHandler 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.SupervisorJob 10 | import kotlinx.coroutines.cancel 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.SharedFlow 13 | import kotlinx.coroutines.flow.collectLatest 14 | import kotlinx.coroutines.launch 15 | 16 | 17 | /** 18 | * Created by abdulbasit on 26/02/2023. 19 | */ 20 | class ChartDetailsViewModel( 21 | api: SpotifyApi, 22 | playlistId: String, 23 | playingTrackId: String, 24 | chatDetailsInput: SharedFlow 25 | ) : InstanceKeeper.Instance { 26 | 27 | private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> 28 | exception.printStackTrace() 29 | } 30 | 31 | private val job = SupervisorJob() 32 | private val viewModelScope = CoroutineScope(Dispatchers.Main + coroutineExceptionHandler + job) 33 | 34 | val chartDetailsViewState = 35 | MutableStateFlow(ChartDetailsViewState.Loading) 36 | 37 | init { 38 | viewModelScope.launch { 39 | launch { 40 | try { 41 | val playlist = api.getPlayList(playlistId) 42 | chartDetailsViewState.value = ChartDetailsViewState.Success( 43 | chartDetails = playlist, 44 | playingTrackId = playingTrackId 45 | ) 46 | } catch (e: Exception) { 47 | e.printStackTrace() 48 | chartDetailsViewState.value = 49 | ChartDetailsViewState.Failure(e.message.toString()) 50 | } 51 | } 52 | launch { 53 | chatDetailsInput.collectLatest { 54 | when (it) { 55 | is ChartDetailsComponent.Input.TrackUpdated -> 56 | when (val state = chartDetailsViewState.value) { 57 | is ChartDetailsViewState.Success -> { 58 | chartDetailsViewState.emit(state.copy(playingTrackId = it.trackId)) 59 | } 60 | 61 | else -> {} 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | 69 | override fun onDestroy() { 70 | viewModelScope.cancel() 71 | } 72 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/chartdetails/ChartDetailsViewState.kt: -------------------------------------------------------------------------------- 1 | package musicapp.chartdetails 2 | 3 | import musicapp.network.models.topfiftycharts.TopFiftyCharts 4 | 5 | 6 | /** 7 | * Created by abdulbasit on 26/02/2023. 8 | */ 9 | sealed interface ChartDetailsViewState { 10 | data object Loading : ChartDetailsViewState 11 | data class Success( 12 | val chartDetails: TopFiftyCharts, 13 | val playingTrackId: String, 14 | ) : ChartDetailsViewState 15 | 16 | data class Failure(val error: String) : ChartDetailsViewState 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/chartdetails/SleepTimerModalBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package musicapp.chartdetails 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.material.ExperimentalMaterialApi 9 | import androidx.compose.material.ModalBottomSheetLayout 10 | import androidx.compose.material.ModalBottomSheetValue 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.rememberModalBottomSheetState 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.snapshotFlow 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.unit.dp 19 | import musicapp.playerview.CountdownViewModel 20 | import musicapp_kmp.shared.generated.resources.Res 21 | import musicapp_kmp.shared.generated.resources._15_M 22 | import musicapp_kmp.shared.generated.resources._1_H 23 | import musicapp_kmp.shared.generated.resources._2_H 24 | import musicapp_kmp.shared.generated.resources._30_M 25 | import musicapp_kmp.shared.generated.resources._3_H 26 | import musicapp_kmp.shared.generated.resources._45_M 27 | import musicapp_kmp.shared.generated.resources.sleep_timer 28 | import org.jetbrains.compose.resources.stringResource 29 | 30 | const val SLEEP_TIMER_ITEM_COUNT = 6 31 | const val FIFTEEN_MIN = 15 * 60 * 1000L 32 | const val THIRTY_MIN = 30 * 60 * 1000L 33 | const val FORTY_FIVE_MIN = 45 * 60 * 1000L 34 | const val ONE_HOUR = 60 * 60 * 1000L 35 | const val TWO_HOUR = 2 * 60 * 60 * 1000L 36 | const val THREE_HOUR = 3 * 60 * 60 * 1000L 37 | const val INTERVAL = 1000L 38 | 39 | @OptIn(ExperimentalMaterialApi::class) 40 | @Composable 41 | fun SleepTimerModalBottomSheet( 42 | countdownViewModel: CountdownViewModel, 43 | onSleepTimerExpired:()-> Unit, 44 | onDismiss: () -> Unit, 45 | isAnyTimeIntervalSelected: (Boolean) -> Unit 46 | ) { 47 | val modalBottomSheetState = 48 | rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Expanded) 49 | 50 | val listOfSleepTimerTitles = listOf( 51 | stringResource(Res.string._15_M), 52 | stringResource(Res.string._30_M), 53 | stringResource(Res.string._45_M), 54 | stringResource(Res.string._1_H), 55 | stringResource(Res.string._2_H), 56 | stringResource(Res.string._3_H) 57 | ) 58 | 59 | val listOfTimeIntervalsToStopAudio = 60 | listOf(FIFTEEN_MIN, THIRTY_MIN, FORTY_FIVE_MIN, ONE_HOUR, TWO_HOUR, THREE_HOUR) 61 | 62 | LaunchedEffect(true) { 63 | modalBottomSheetState.show() 64 | } 65 | LaunchedEffect(key1 = modalBottomSheetState) { 66 | snapshotFlow { modalBottomSheetState.currentValue } 67 | .collect { currentValue -> 68 | if (currentValue == ModalBottomSheetValue.Hidden) { 69 | onDismiss() 70 | } 71 | } 72 | } 73 | ModalBottomSheetLayout(sheetContent = { 74 | Column(modifier = Modifier.padding(bottom = 70.dp)) { 75 | Text( 76 | text = stringResource(Res.string.sleep_timer), 77 | modifier = Modifier.fillMaxWidth().padding(16.dp), 78 | textAlign = TextAlign.Center 79 | ) 80 | LazyColumn(modifier = Modifier.padding(8.dp)) { 81 | items(SLEEP_TIMER_ITEM_COUNT) { index -> 82 | SleepTimeItem(listOfSleepTimerTitles[index], onItemClick = { 83 | countdownViewModel.startCountdown( 84 | initialMillis = listOfTimeIntervalsToStopAudio[index], 85 | intervalMillis = INTERVAL, 86 | onCountDownFinish = { 87 | onSleepTimerExpired 88 | isAnyTimeIntervalSelected(false) 89 | }) 90 | onDismiss() 91 | isAnyTimeIntervalSelected(true) 92 | }) 93 | } 94 | } 95 | } 96 | }, sheetState = modalBottomSheetState, content = { 97 | 98 | }) 99 | } 100 | 101 | 102 | @Composable 103 | fun SleepTimeItem(timeTitle: String, onItemClick: () -> Unit) { 104 | Text( 105 | text = timeTitle, 106 | modifier = Modifier.fillMaxWidth().padding(16.dp).clickable(onClick = onItemClick) 107 | ) 108 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/dashboard/DashboardScreen.kt: -------------------------------------------------------------------------------- 1 | package musicapp.dashboard 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.PaddingValues 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.aspectRatio 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.fillMaxWidth 14 | import androidx.compose.foundation.layout.height 15 | import androidx.compose.foundation.layout.padding 16 | import androidx.compose.foundation.layout.size 17 | import androidx.compose.foundation.layout.width 18 | import androidx.compose.foundation.lazy.LazyRow 19 | import androidx.compose.foundation.lazy.items 20 | import androidx.compose.foundation.lazy.rememberLazyListState 21 | import androidx.compose.foundation.rememberScrollState 22 | import androidx.compose.foundation.shape.RoundedCornerShape 23 | import androidx.compose.foundation.verticalScroll 24 | import androidx.compose.material.CircularProgressIndicator 25 | import androidx.compose.material.Icon 26 | import androidx.compose.material.MaterialTheme 27 | import androidx.compose.material.Text 28 | import androidx.compose.material.icons.Icons 29 | import androidx.compose.material.icons.filled.Favorite 30 | import androidx.compose.material.icons.filled.FavoriteBorder 31 | import androidx.compose.runtime.Composable 32 | import androidx.compose.runtime.collectAsState 33 | import androidx.compose.ui.Alignment 34 | import androidx.compose.ui.Modifier 35 | import androidx.compose.ui.draw.clip 36 | import androidx.compose.ui.graphics.Color 37 | import androidx.compose.ui.layout.ContentScale 38 | import androidx.compose.ui.text.font.FontWeight 39 | import androidx.compose.ui.text.style.TextOverflow 40 | import androidx.compose.ui.unit.dp 41 | import musicapp.decompose.DashboardMainComponent 42 | import musicapp.network.models.featuredplaylist.FeaturedPlayList 43 | import musicapp.network.models.newreleases.NewReleasedAlbums 44 | import musicapp.network.models.topfiftycharts.TopFiftyCharts 45 | import com.seiko.imageloader.rememberImagePainter 46 | import musicapp_kmp.shared.generated.resources.Res 47 | import musicapp_kmp.shared.generated.resources.explore_details 48 | import musicapp_kmp.shared.generated.resources.favorite 49 | import musicapp_kmp.shared.generated.resources.featured_playlist 50 | import musicapp_kmp.shared.generated.resources.likes 51 | import musicapp_kmp.shared.generated.resources.new_releases 52 | import musicapp_kmp.shared.generated.resources.tracks 53 | import org.jetbrains.compose.resources.ExperimentalResourceApi 54 | import org.jetbrains.compose.resources.stringResource 55 | 56 | 57 | /** 58 | * Created by abdulbasit on 26/02/2023. 59 | */ 60 | 61 | @Composable 62 | internal fun DashboardScreen(dashboardMainComponent: DashboardMainComponent) { 63 | val state = dashboardMainComponent.viewModel.dashboardState.collectAsState() 64 | 65 | when (val resultedState = state.value) { 66 | is DashboardViewState.Failure -> Failure(resultedState.error) 67 | DashboardViewState.Loading -> Loading() 68 | is DashboardViewState.Success -> { 69 | DashboardView(resultedState) { 70 | dashboardMainComponent.onOutPut(DashboardMainComponent.Output.PlaylistSelected(it)) 71 | } 72 | } 73 | } 74 | } 75 | 76 | @Composable 77 | internal fun Loading() { 78 | Box(modifier = Modifier.fillMaxSize()) { 79 | CircularProgressIndicator( 80 | modifier = Modifier.align(Alignment.Center), 81 | color = Color(0xFFFACD66), 82 | ) 83 | } 84 | } 85 | 86 | @Composable 87 | internal fun Failure(message: String) { 88 | Box(modifier = Modifier.fillMaxSize().padding(32.dp)) { 89 | Text( 90 | text = message, 91 | modifier = Modifier.align(Alignment.Center), 92 | style = MaterialTheme.typography.body1.copy(color = Color(0xFFFACD66)) 93 | ) 94 | } 95 | } 96 | 97 | @Composable 98 | internal fun DashboardView( 99 | dashboardState: DashboardViewState.Success, 100 | navigateToDetails: (String) -> Unit 101 | ) { 102 | val listState = rememberScrollState() 103 | Column( 104 | modifier = Modifier.background(color = Color(0xFF1D2123)).fillMaxSize() 105 | .verticalScroll(listState) 106 | .padding(bottom = 32.dp) 107 | ) { 108 | TopChartView(dashboardState.topFiftyCharts, navigateToDetails) 109 | FeaturedPlayLists(dashboardState.featuredPlayList, navigateToDetails) 110 | NewReleases(dashboardState.newReleasedAlbums, navigateToDetails) 111 | } 112 | } 113 | 114 | @OptIn(ExperimentalResourceApi::class) 115 | @Composable 116 | internal fun TopChartView(topFiftyCharts: TopFiftyCharts, navigateToDetails: (String) -> Unit) { 117 | Box( 118 | modifier = Modifier.aspectRatio(ratio = (367.0 / 450.0).toFloat()) 119 | .clip(RoundedCornerShape(20.dp)) 120 | .padding(24.dp).clickable(onClick = { navigateToDetails(topFiftyCharts.id.orEmpty()) }) 121 | ) { 122 | val painter = rememberImagePainter( 123 | topFiftyCharts.images?.first()?.url.orEmpty() 124 | ) 125 | Image( 126 | painter, 127 | topFiftyCharts.images?.first()?.url.orEmpty(), 128 | modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(20.dp)), 129 | contentScale = ContentScale.Crop 130 | ) 131 | Column(modifier = Modifier.padding(16.dp).align(Alignment.BottomStart)) { 132 | Text( 133 | topFiftyCharts.name.orEmpty(), 134 | style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), 135 | overflow = TextOverflow.Ellipsis, 136 | maxLines = 1, 137 | color = Color.White 138 | ) 139 | Text( 140 | topFiftyCharts.description.orEmpty(), 141 | style = MaterialTheme.typography.body2, 142 | color = Color.White, 143 | modifier = Modifier.padding(top = 6.dp) 144 | ) 145 | Row(modifier = Modifier.padding(top = 40.dp)) { 146 | Icon( 147 | imageVector = Icons.Filled.FavoriteBorder, 148 | tint = Color(0xFFFACD66), 149 | contentDescription = stringResource(Res.string.explore_details), 150 | modifier = Modifier.size(30.dp).align(Alignment.Top) 151 | ) 152 | Text( 153 | text = "${topFiftyCharts.followers?.total ?: 0} ${stringResource(Res.string.likes)}", 154 | style = MaterialTheme.typography.h5, 155 | color = Color.White, 156 | modifier = Modifier.padding(start = 16.dp) 157 | ) 158 | } 159 | } 160 | } 161 | } 162 | 163 | @OptIn(ExperimentalResourceApi::class) 164 | @Composable 165 | internal fun FeaturedPlayLists( 166 | featuredPlayList: FeaturedPlayList, 167 | navigateToDetails: (String) -> Unit 168 | ) { 169 | Column(modifier = Modifier.padding(top = 46.dp)) { 170 | Text( 171 | stringResource(Res.string.featured_playlist), 172 | style = MaterialTheme.typography.h6.copy( 173 | fontWeight = FontWeight.Bold, 174 | color = Color(0xFFEFEEE0) 175 | ), 176 | modifier = Modifier.padding(start = 16.dp) 177 | ) 178 | val listState = rememberLazyListState() 179 | 180 | LazyRow( 181 | modifier = Modifier.padding(top = 16.dp).fillMaxSize(), 182 | state = listState, 183 | horizontalArrangement = Arrangement.spacedBy(10.dp), 184 | contentPadding = PaddingValues(horizontal = 16.dp) 185 | ) { 186 | items(items = featuredPlayList.playlists?.items ?: emptyList()) { playList -> 187 | Box( 188 | modifier = Modifier.width(232.dp).clip(RoundedCornerShape(20.dp)) 189 | .background(Color(0xFF1A1E1F)) 190 | .clickable(onClick = { navigateToDetails(playList.id.orEmpty()) }) 191 | ) { 192 | Column( 193 | modifier = Modifier.padding(16.dp) 194 | ) { 195 | val painter = rememberImagePainter( 196 | playList.images?.first()?.url.orEmpty() 197 | ) 198 | Image( 199 | painter, 200 | playList.images?.first()?.url.orEmpty(), 201 | modifier = Modifier.clip(RoundedCornerShape(20.dp)).width(100.dp) 202 | .height(100.dp), 203 | contentScale = ContentScale.Crop 204 | ) 205 | Text( 206 | text = playList.name.orEmpty(), 207 | style = MaterialTheme.typography.body1.copy(color = Color.White), 208 | modifier = Modifier.padding(top = 16.dp), 209 | overflow = TextOverflow.Ellipsis, 210 | maxLines = 1 211 | ) 212 | Text( 213 | text = playList.description.orEmpty(), 214 | style = MaterialTheme.typography.caption.copy( 215 | color = Color.White.copy( 216 | alpha = 0.5f 217 | ) 218 | ), 219 | modifier = Modifier.padding(top = 8.dp), 220 | overflow = TextOverflow.Ellipsis, 221 | maxLines = 1 222 | ) 223 | Text( 224 | text = "${(playList.tracks?.total ?: 0)} ${stringResource(Res.string.tracks)}", 225 | style = MaterialTheme.typography.body2.copy(color = Color.White), 226 | modifier = Modifier.padding(top = 24.dp) 227 | ) 228 | } 229 | Icon( 230 | imageVector = Icons.Default.Favorite, 231 | tint = Color(0xFFFACD66), 232 | contentDescription = stringResource(Res.string.favorite), 233 | modifier = Modifier.padding(top = 16.dp, end = 16.dp).size(30.dp) 234 | .align(Alignment.TopEnd) 235 | ) 236 | } 237 | } 238 | } 239 | } 240 | } 241 | 242 | @OptIn(ExperimentalResourceApi::class) 243 | @Composable 244 | internal fun NewReleases( 245 | newReleasedAlbums: NewReleasedAlbums, 246 | navigateToDetails: (String) -> Unit 247 | ) { 248 | Column(modifier = Modifier.padding(top = 46.dp).fillMaxWidth()) { 249 | Text( 250 | stringResource(Res.string.new_releases), 251 | style = MaterialTheme.typography.h6.copy( 252 | fontWeight = FontWeight.Bold, 253 | color = Color(0xFFEFEEE0) 254 | ), 255 | modifier = Modifier.padding(start = 16.dp) 256 | ) 257 | val listState = rememberLazyListState() 258 | 259 | LazyRow( 260 | modifier = Modifier.fillMaxWidth().padding(top = 16.dp), 261 | state = listState, 262 | horizontalArrangement = Arrangement.spacedBy(16.dp), 263 | contentPadding = PaddingValues(start = 16.dp, end = 16.dp) 264 | ) { 265 | items(items = newReleasedAlbums.albums?.items ?: emptyList()) { album -> 266 | Box(Modifier.width(153.dp)) { 267 | Column { 268 | val painter = rememberImagePainter( 269 | album.images?.first()?.url.orEmpty() 270 | ) 271 | Image( 272 | painter, 273 | album.images?.first()?.url.orEmpty(), 274 | modifier = Modifier.width(153.dp).height(153.dp) 275 | .clip(RoundedCornerShape(20.dp)) 276 | .clickable(onClick = { navigateToDetails(album.id.orEmpty()) }), 277 | contentScale = ContentScale.Crop 278 | ) 279 | Text( 280 | text = album.name.orEmpty(), 281 | style = MaterialTheme.typography.caption.copy(color = Color.White), 282 | modifier = Modifier.padding(top = 16.dp), 283 | overflow = TextOverflow.Ellipsis, 284 | maxLines = 1 285 | ) 286 | Text( 287 | text = "${(album.totalTracks ?: 0)} ${stringResource(Res.string.tracks)}", 288 | style = MaterialTheme.typography.caption.copy( 289 | color = Color.White.copy( 290 | alpha = 0.5f 291 | ) 292 | ), 293 | modifier = Modifier.padding(top = 8.dp), 294 | overflow = TextOverflow.Ellipsis, 295 | maxLines = 1 296 | ) 297 | } 298 | } 299 | } 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/dashboard/DashboardScreenLarge.kt: -------------------------------------------------------------------------------- 1 | package musicapp.dashboard 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.layout.width 14 | import androidx.compose.foundation.rememberScrollState 15 | import androidx.compose.foundation.shape.RoundedCornerShape 16 | import androidx.compose.foundation.verticalScroll 17 | import androidx.compose.material.Icon 18 | import androidx.compose.material.MaterialTheme 19 | import androidx.compose.material.Text 20 | import androidx.compose.material.icons.Icons 21 | import androidx.compose.material.icons.filled.FavoriteBorder 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.collectAsState 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.clip 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.layout.ContentScale 29 | import androidx.compose.ui.text.font.FontWeight 30 | import androidx.compose.ui.text.style.TextOverflow 31 | import androidx.compose.ui.unit.dp 32 | import musicapp.decompose.DashboardMainComponent 33 | import musicapp.network.models.topfiftycharts.TopFiftyCharts 34 | import com.seiko.imageloader.rememberImagePainter 35 | import musicapp_kmp.shared.generated.resources.Res 36 | import musicapp_kmp.shared.generated.resources.explore_details 37 | import musicapp_kmp.shared.generated.resources.likes 38 | import org.jetbrains.compose.resources.ExperimentalResourceApi 39 | import org.jetbrains.compose.resources.stringResource 40 | 41 | 42 | /** 43 | * Created by abdulbasit on 26/02/2023. 44 | */ 45 | 46 | @Composable 47 | internal fun DashboardScreenLarge( 48 | component: DashboardMainComponent, 49 | ) { 50 | val state = component.viewModel.dashboardState.collectAsState() 51 | 52 | when (val resultedState = state.value) { 53 | is DashboardViewState.Failure -> Failure(resultedState.error) 54 | DashboardViewState.Loading -> Loading() 55 | is DashboardViewState.Success -> { 56 | DashboardViewLarge( 57 | resultedState 58 | ) { component.onOutPut(DashboardMainComponent.Output.PlaylistSelected(it)) } 59 | } 60 | } 61 | } 62 | 63 | @Composable 64 | internal fun DashboardViewLarge( 65 | dashboardState: DashboardViewState.Success, navigateToDetails: (String) -> Unit 66 | ) { 67 | val listState = rememberScrollState() 68 | Column( 69 | modifier = Modifier.background(color = Color(0xFF1D2123)).fillMaxSize() 70 | .verticalScroll(listState).padding(bottom = 32.dp) 71 | ) { 72 | TopChartViewLarge(dashboardState.topFiftyCharts, navigateToDetails) 73 | FeaturedPlayLists(dashboardState.featuredPlayList, navigateToDetails) 74 | NewReleases(dashboardState.newReleasedAlbums, navigateToDetails) 75 | } 76 | } 77 | 78 | 79 | @OptIn(ExperimentalResourceApi::class) 80 | @Composable 81 | internal fun TopChartViewLarge( 82 | topFiftyCharts: TopFiftyCharts, navigateToDetails: (String) -> Unit 83 | ) { 84 | Box( 85 | modifier = Modifier.clip(RoundedCornerShape(20.dp)).width(686.dp).height(450.dp) 86 | .padding(24.dp).clickable(onClick = { navigateToDetails(topFiftyCharts.id.orEmpty()) }) 87 | ) { 88 | val painter = rememberImagePainter(topFiftyCharts.images?.first()?.url.orEmpty()) 89 | Image( 90 | painter, 91 | topFiftyCharts.images?.first()?.url.orEmpty(), 92 | modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(20.dp)), 93 | contentScale = ContentScale.Crop 94 | ) 95 | Column(modifier = Modifier.padding(16.dp).align(Alignment.BottomStart)) { 96 | Text( 97 | topFiftyCharts.name.orEmpty(), 98 | style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), 99 | overflow = TextOverflow.Ellipsis, 100 | maxLines = 1, 101 | color = Color.White 102 | ) 103 | Text( 104 | topFiftyCharts.description.orEmpty(), 105 | style = MaterialTheme.typography.body2, 106 | color = Color.White, 107 | modifier = Modifier.padding(top = 6.dp) 108 | ) 109 | Row(modifier = Modifier.padding(top = 40.dp)) { 110 | Icon( 111 | imageVector = Icons.Filled.FavoriteBorder, 112 | tint = Color(0xFFFACD66), 113 | contentDescription = stringResource(Res.string.explore_details), 114 | modifier = Modifier.size(30.dp).align(Alignment.Top) 115 | ) 116 | Text( 117 | text = "${topFiftyCharts.followers?.total ?: 0} ${stringResource(Res.string.likes)}", 118 | style = MaterialTheme.typography.h5, 119 | color = Color.White, 120 | modifier = Modifier.padding(start = 16.dp) 121 | ) 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/dashboard/DashboardViewModel.kt: -------------------------------------------------------------------------------- 1 | package musicapp.dashboard 2 | 3 | import com.arkivanov.essenty.instancekeeper.InstanceKeeper 4 | import musicapp.network.SpotifyApi 5 | import kotlinx.coroutines.CoroutineExceptionHandler 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.SupervisorJob 9 | import kotlinx.coroutines.async 10 | import kotlinx.coroutines.cancel 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.launch 13 | 14 | 15 | /** 16 | * Created by abdulbasit on 26/02/2023. 17 | */ 18 | class DashboardViewModel(api: SpotifyApi) : InstanceKeeper.Instance { 19 | val dashboardState = MutableStateFlow(DashboardViewState.Loading) 20 | 21 | private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> 22 | exception.printStackTrace() 23 | dashboardState.value = DashboardViewState.Failure(exception.message.toString()) 24 | } 25 | 26 | private val job = SupervisorJob() 27 | private val viewModelScope = CoroutineScope(Dispatchers.Main + coroutineExceptionHandler + job) 28 | 29 | init { 30 | viewModelScope.launch { 31 | try { 32 | val topFiftyCharts = async { api.getTopFiftyChart() }.await() 33 | val newReleasedAlbums = async { api.getNewReleases() }.await() 34 | val featuredPlaylist = async { api.getFeaturedPlaylist() }.await() 35 | dashboardState.value = DashboardViewState.Success( 36 | topFiftyCharts = topFiftyCharts, 37 | newReleasedAlbums = newReleasedAlbums, 38 | featuredPlayList = featuredPlaylist 39 | ) 40 | } catch (e: Exception) { 41 | e.printStackTrace() 42 | dashboardState.value = DashboardViewState.Failure(e.message.toString()) 43 | } 44 | } 45 | } 46 | 47 | override fun onDestroy() { 48 | viewModelScope.cancel() 49 | } 50 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/dashboard/DashboardViewState.kt: -------------------------------------------------------------------------------- 1 | package musicapp.dashboard 2 | 3 | import musicapp.network.models.featuredplaylist.FeaturedPlayList 4 | import musicapp.network.models.newreleases.NewReleasedAlbums 5 | import musicapp.network.models.topfiftycharts.TopFiftyCharts 6 | 7 | 8 | /** 9 | * Created by abdulbasit on 26/02/2023. 10 | */ 11 | sealed interface DashboardViewState { 12 | data object Loading : DashboardViewState 13 | data class Success( 14 | val topFiftyCharts: TopFiftyCharts, 15 | val newReleasedAlbums: NewReleasedAlbums, 16 | val featuredPlayList: FeaturedPlayList 17 | ) : DashboardViewState 18 | 19 | data class Failure(val error: String) : DashboardViewState 20 | } 21 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/decompose/ChartDetailsComponent.kt: -------------------------------------------------------------------------------- 1 | package musicapp.decompose 2 | 3 | import musicapp.chartdetails.ChartDetailsViewModel 4 | import musicapp.network.models.topfiftycharts.Item 5 | import kotlinx.serialization.Serializable 6 | import musicapp.player.TrackItem 7 | import musicapp.playerview.CountdownViewModel 8 | 9 | 10 | /** 11 | * Created by abdulbasit on 19/03/2023. 12 | */ 13 | 14 | interface ChartDetailsComponent { 15 | val viewModel: ChartDetailsViewModel 16 | val countdownViewModel: CountdownViewModel 17 | fun onOutPut(output: Output) 18 | sealed class Output { 19 | data object GoBack : Output() 20 | data class OnPlayAllSelected(val playlist: List) : Output() 21 | data class OnTrackSelected(val trackId: String, val playlist: List) : Output() 22 | } 23 | 24 | fun onSleepTimerExpired() 25 | 26 | @Serializable 27 | sealed interface Input { 28 | 29 | @Serializable 30 | data class TrackUpdated(val trackId: String) : Input 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/decompose/ChartDetailsComponentImpl.kt: -------------------------------------------------------------------------------- 1 | package musicapp.decompose 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.essenty.instancekeeper.getOrCreate 5 | import musicapp.chartdetails.ChartDetailsViewModel 6 | import musicapp.network.SpotifyApi 7 | import kotlinx.coroutines.flow.SharedFlow 8 | import musicapp.playerview.CountdownViewModel 9 | 10 | 11 | /** 12 | * Created by abdulbasit on 19/03/2023. 13 | */ 14 | class ChartDetailsComponentImpl( 15 | componentContext: ComponentContext, 16 | val spotifyApi: SpotifyApi, 17 | val playlistId: String, 18 | val playingTrackId: String, 19 | val chatDetailsInput: SharedFlow, 20 | val sleepTimerExpired:()-> Unit, 21 | val output: (ChartDetailsComponent.Output) -> Unit, 22 | ) : ChartDetailsComponent, ComponentContext by componentContext { 23 | override val viewModel: ChartDetailsViewModel 24 | get() = instanceKeeper.getOrCreate { 25 | ChartDetailsViewModel( 26 | spotifyApi, 27 | playlistId, 28 | playingTrackId, 29 | chatDetailsInput 30 | ) 31 | } 32 | 33 | override val countdownViewModel: CountdownViewModel 34 | get() = CountdownViewModel() 35 | 36 | override fun onOutPut(output: ChartDetailsComponent.Output) { 37 | output(output) 38 | } 39 | 40 | override fun onSleepTimerExpired(){ 41 | sleepTimerExpired() 42 | } 43 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/decompose/DashboardMainComponent.kt: -------------------------------------------------------------------------------- 1 | package musicapp.decompose 2 | 3 | import musicapp.dashboard.DashboardViewModel 4 | 5 | 6 | /** 7 | * Created by abdulbasit on 19/03/2023. 8 | */ 9 | interface DashboardMainComponent { 10 | val viewModel: DashboardViewModel 11 | 12 | fun onOutPut(output: Output) 13 | 14 | sealed class Output { 15 | data class PlaylistSelected(val playlistId: String) : Output() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/decompose/DashboardMainComponentImpl.kt: -------------------------------------------------------------------------------- 1 | package musicapp.decompose 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.essenty.instancekeeper.getOrCreate 5 | import musicapp.dashboard.DashboardViewModel 6 | import musicapp.network.SpotifyApi 7 | 8 | 9 | /** 10 | * Created by abdulbasit on 19/03/2023. 11 | */ 12 | class DashboardMainComponentImpl( 13 | componentContext: ComponentContext, 14 | val output: (DashboardMainComponent.Output) -> Unit, 15 | val spotifyApi: SpotifyApi 16 | ) : DashboardMainComponent, ComponentContext by componentContext { 17 | override val viewModel: DashboardViewModel 18 | get() = instanceKeeper.getOrCreate { DashboardViewModel(spotifyApi) } 19 | 20 | override fun onOutPut(output: DashboardMainComponent.Output) { 21 | output(output) 22 | } 23 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/decompose/MusicRoot.kt: -------------------------------------------------------------------------------- 1 | package musicapp.decompose 2 | 3 | import com.arkivanov.decompose.router.slot.ChildSlot 4 | import com.arkivanov.decompose.router.stack.ChildStack 5 | import com.arkivanov.decompose.value.Value 6 | 7 | /** 8 | * Created by abdulbasit on 19/03/2023. 9 | */ 10 | interface MusicRoot { 11 | 12 | val childStack: Value> 13 | val dialogOverlay: Value> 14 | 15 | sealed class Child { 16 | data class Dashboard(val dashboardMainComponent: DashboardMainComponent) : Child() 17 | data class Details(val detailsComponent: ChartDetailsComponent) : Child() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/decompose/MusicRootImpl.kt: -------------------------------------------------------------------------------- 1 | package musicapp.decompose 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.decompose.DelicateDecomposeApi 5 | import com.arkivanov.decompose.router.slot.ChildSlot 6 | import com.arkivanov.decompose.router.slot.SlotNavigation 7 | import com.arkivanov.decompose.router.slot.activate 8 | import com.arkivanov.decompose.router.slot.childSlot 9 | import com.arkivanov.decompose.router.stack.* 10 | import com.arkivanov.decompose.value.Value 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.flow.MutableSharedFlow 14 | import kotlinx.coroutines.flow.SharedFlow 15 | import kotlinx.coroutines.launch 16 | import kotlinx.serialization.Serializable 17 | import kotlinx.serialization.serializer 18 | import musicapp.network.SpotifyApi 19 | import musicapp.player.MediaPlayerController 20 | import musicapp.player.TrackItem 21 | 22 | /** 23 | * Created by abdulbasit on 19/03/2023. 24 | */ 25 | class MusicRootImpl( 26 | componentContext: ComponentContext, 27 | private val mediaPlayerController: MediaPlayerController, 28 | private val dashboardMain: (ComponentContext, (DashboardMainComponent.Output) -> Unit) -> DashboardMainComponent, 29 | private val chartDetails: ( 30 | ComponentContext, playlistId: String, playingTrackId: String, chatDetailsInput: SharedFlow, (ChartDetailsComponent.Output) -> Unit 31 | ) -> ChartDetailsComponent, 32 | ) : MusicRoot, ComponentContext by componentContext { 33 | 34 | val scope: CoroutineScope = CoroutineScope(Dispatchers.Main.immediate) 35 | 36 | //to keep track of the playing track 37 | private var currentPlayingTrack = "-1" 38 | private val musicPlayerInput = MutableSharedFlow() 39 | private val chatDetailsInput = MutableSharedFlow() 40 | 41 | constructor( 42 | componentContext: ComponentContext, 43 | api: SpotifyApi, 44 | mediaPlayerController: MediaPlayerController 45 | ) : this( 46 | componentContext = componentContext, 47 | mediaPlayerController = mediaPlayerController, 48 | dashboardMain = { childContext, output -> 49 | DashboardMainComponentImpl( 50 | componentContext = childContext, spotifyApi = api, output = output 51 | ) 52 | }, 53 | chartDetails = { childContext, playlistId, playingTrackId, chartDetailsInput, output -> 54 | ChartDetailsComponentImpl( 55 | componentContext = childContext, 56 | spotifyApi = api, 57 | playlistId = playlistId, 58 | output = output, 59 | playingTrackId = playingTrackId, 60 | chatDetailsInput = chartDetailsInput, 61 | sleepTimerExpired = { 62 | mediaPlayerController.pause() 63 | } 64 | ) 65 | }) 66 | 67 | private val navigation = StackNavigation() 68 | private val dialogNavigation = SlotNavigation() 69 | 70 | private val stack = childStack( 71 | source = navigation, 72 | serializer = serializer(), 73 | initialConfiguration = Configuration.Dashboard, 74 | handleBackButton = true, 75 | childFactory = ::createChild 76 | ) 77 | 78 | private fun createChild( 79 | configuration: Configuration, componentContext: ComponentContext 80 | ): MusicRoot.Child = when (configuration) { 81 | Configuration.Dashboard -> MusicRoot.Child.Dashboard( 82 | dashboardMain(componentContext, ::dashboardOutput) 83 | ) 84 | 85 | is Configuration.Details -> MusicRoot.Child.Details( 86 | chartDetails( 87 | componentContext, 88 | configuration.playlistId, 89 | currentPlayingTrack, 90 | chatDetailsInput, 91 | ::detailsOutput 92 | ) 93 | ) 94 | } 95 | 96 | @OptIn(DelicateDecomposeApi::class) 97 | private fun dashboardOutput(output: DashboardMainComponent.Output) { 98 | when (output) { 99 | is DashboardMainComponent.Output.PlaylistSelected -> navigation.push( 100 | Configuration.Details( 101 | output.playlistId, currentPlayingTrack 102 | ) 103 | ) 104 | } 105 | } 106 | 107 | private fun detailsOutput(output: ChartDetailsComponent.Output) { 108 | when (output) { 109 | is ChartDetailsComponent.Output.GoBack -> navigation.pop() 110 | is ChartDetailsComponent.Output.OnPlayAllSelected -> { 111 | dialogNavigation.activate(DialogConfig(output.playlist, output.playlist.first().id)) 112 | scope.launch { 113 | musicPlayerInput.emit(PlayerComponent.Input.UpdateTracks(output.playlist)) 114 | } 115 | } 116 | 117 | is ChartDetailsComponent.Output.OnTrackSelected -> { 118 | dialogNavigation.activate(DialogConfig(output.playlist, output.trackId)) 119 | scope.launch { 120 | musicPlayerInput.emit(PlayerComponent.Input.PlayTrack(output.trackId, output.playlist)) 121 | } 122 | } 123 | } 124 | } 125 | 126 | private val player = childSlot( 127 | source = dialogNavigation, 128 | serializer = serializer(), 129 | initialConfiguration = { null }, 130 | key = "PlayerView", 131 | handleBackButton = true, 132 | childFactory = { config, _ -> 133 | PlayerComponentImpl( 134 | componentContext = componentContext, 135 | mediaPlayerController = mediaPlayerController, 136 | trackList = config.playlist, 137 | selectedTrack = config.selectedTrack, 138 | playerInputs = musicPlayerInput, 139 | output = { 140 | when (it) { 141 | PlayerComponent.Output.OnPause -> TODO() 142 | PlayerComponent.Output.OnPlay -> TODO() 143 | 144 | is PlayerComponent.Output.OnTrackUpdated -> { 145 | scope.launch { 146 | currentPlayingTrack = it.trackId 147 | chatDetailsInput.emit(ChartDetailsComponent.Input.TrackUpdated(it.trackId)) 148 | } 149 | } 150 | } 151 | }) 152 | }) 153 | 154 | override val childStack: Value> 155 | get() = value() 156 | 157 | override val dialogOverlay: Value> 158 | get() = player 159 | 160 | private fun value() = stack 161 | 162 | @Serializable 163 | private sealed class Configuration { 164 | @Serializable 165 | data object Dashboard : Configuration() 166 | 167 | @Serializable 168 | data class Details( 169 | val playlistId: String, 170 | val playingTrackId: String, 171 | ) : Configuration() 172 | } 173 | 174 | @Serializable 175 | private data class DialogConfig( 176 | val playlist: List, 177 | val selectedTrack: String = "" 178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/decompose/PlayerComponent.kt: -------------------------------------------------------------------------------- 1 | package musicapp.decompose 2 | 3 | import musicapp.player.TrackItem 4 | import musicapp.playerview.PlayerViewModel 5 | 6 | 7 | /** 8 | * Created by abdulbasit on 19/03/2023. 9 | */ 10 | 11 | interface PlayerComponent { 12 | val viewModel: PlayerViewModel 13 | 14 | fun onOutPut(output: Output) 15 | 16 | sealed class Output { 17 | data object OnPause : Output() 18 | data object OnPlay : Output() 19 | data class OnTrackUpdated(val trackId: String) : Output() 20 | } 21 | 22 | sealed interface Input { 23 | data class PlayTrack(val trackId: String, val tracksList: List) : Input 24 | data class UpdateTracks(val tracksList: List) : Input 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/decompose/PlayerComponentImpl.kt: -------------------------------------------------------------------------------- 1 | package musicapp.decompose 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.essenty.instancekeeper.getOrCreate 5 | import kotlinx.coroutines.flow.SharedFlow 6 | import musicapp.network.models.topfiftycharts.Item 7 | import musicapp.player.MediaPlayerController 8 | import musicapp.player.TrackItem 9 | import musicapp.playerview.PlayerViewModel 10 | 11 | class PlayerComponentImpl( 12 | componentContext: ComponentContext, 13 | private val mediaPlayerController: MediaPlayerController, 14 | private val trackList: List, 15 | private val selectedTrack: String, 16 | private val playerInputs: SharedFlow, 17 | val output: (PlayerComponent.Output) -> Unit 18 | ) : PlayerComponent, ComponentContext by componentContext { 19 | 20 | override val viewModel: PlayerViewModel 21 | get() = instanceKeeper.getOrCreate { 22 | PlayerViewModel( 23 | mediaPlayerController = mediaPlayerController, 24 | trackList = trackList, 25 | playerInputs = playerInputs, 26 | selectedTrack = selectedTrack 27 | ) 28 | } 29 | 30 | override fun onOutPut(output: PlayerComponent.Output) { 31 | output(output) 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/main.common.kt: -------------------------------------------------------------------------------- 1 | package musicapp 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import com.arkivanov.decompose.extensions.compose.stack.Children 10 | import com.arkivanov.decompose.extensions.compose.subscribeAsState 11 | import musicapp.chartdetails.ChartDetailsScreen 12 | import musicapp.chartdetails.ChartDetailsScreenLarge 13 | import musicapp.dashboard.DashboardScreen 14 | import musicapp.dashboard.DashboardScreenLarge 15 | import musicapp.decompose.MusicRoot 16 | import musicapp.playerview.PlayerView 17 | 18 | @Composable 19 | internal fun MainCommon( 20 | rootComponent: MusicRoot, 21 | isLargeScreen: Boolean 22 | ) { 23 | val dialogOverlay by rootComponent.dialogOverlay.subscribeAsState() 24 | 25 | MusicAppTheme { 26 | Box(modifier = Modifier.fillMaxSize()) { 27 | Box(modifier = Modifier.fillMaxSize()) { 28 | Children( 29 | stack = rootComponent.childStack 30 | ) { 31 | when (val child = it.instance) { 32 | is MusicRoot.Child.Dashboard -> { 33 | if (isLargeScreen) 34 | DashboardScreenLarge(child.dashboardMainComponent) 35 | else 36 | DashboardScreen(child.dashboardMainComponent) 37 | } 38 | 39 | is MusicRoot.Child.Details -> { 40 | if (isLargeScreen) 41 | ChartDetailsScreenLarge(child.detailsComponent) 42 | else 43 | ChartDetailsScreen(child.detailsComponent) 44 | } 45 | } 46 | } 47 | } 48 | Box(modifier = Modifier.align(Alignment.BottomEnd)) { 49 | dialogOverlay.child?.instance?.also { 50 | PlayerView(it) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/SpotifyApi.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network 2 | 3 | import musicapp.network.models.featuredplaylist.FeaturedPlayList 4 | import musicapp.network.models.newreleases.NewReleasedAlbums 5 | import musicapp.network.models.topfiftycharts.TopFiftyCharts 6 | 7 | 8 | /** 9 | * Created by abdulbasit on 26/02/2023. 10 | */ 11 | interface SpotifyApi { 12 | suspend fun getTopFiftyChart(): TopFiftyCharts 13 | suspend fun getNewReleases(): NewReleasedAlbums 14 | suspend fun getFeaturedPlaylist(): FeaturedPlayList 15 | suspend fun getPlayList(playlistId: String): TopFiftyCharts 16 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/SpotifyApiImpl.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network 2 | 3 | import musicapp.TOKEN 4 | import io.ktor.client.HttpClient 5 | import io.ktor.client.call.body 6 | import io.ktor.client.engine.HttpClientEngine 7 | import io.ktor.client.plugins.HttpTimeout 8 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 9 | import io.ktor.client.request.HttpRequestBuilder 10 | import io.ktor.client.request.get 11 | import io.ktor.client.request.headers 12 | import io.ktor.http.HttpHeaders 13 | import io.ktor.http.encodedPath 14 | import io.ktor.http.takeFrom 15 | import io.ktor.serialization.kotlinx.json.json 16 | import kotlinx.serialization.json.Json 17 | import musicapp.sampledata.featurePlaylistResponse 18 | import musicapp.sampledata.newReleases 19 | import musicapp.sampledata.topFiftyChartsResponse 20 | import musicapp.network.models.featuredplaylist.FeaturedPlayList 21 | import musicapp.network.models.newreleases.NewReleasedAlbums 22 | import musicapp.network.models.topfiftycharts.TopFiftyCharts 23 | 24 | 25 | /** 26 | * Created by abdulbasit on 26/02/2023. 27 | */ 28 | class SpotifyApiImpl(httpClientEngine: HttpClientEngine? = null) : SpotifyApi { 29 | override suspend fun getTopFiftyChart(): TopFiftyCharts { 30 | if (TOKEN.isEmpty()) { 31 | return Json.decodeFromString(topFiftyChartsResponse) 32 | } 33 | return client.get { 34 | headers { 35 | sptifyEndPoint("v1/playlists/37i9dQZEVXbMDoHDwVN2tF") 36 | } 37 | }.body() 38 | } 39 | 40 | override suspend fun getNewReleases(): NewReleasedAlbums { 41 | if (TOKEN.isEmpty()) { 42 | return Json.decodeFromString(newReleases) 43 | } 44 | return client.get { 45 | headers { 46 | sptifyEndPoint("v1/browse/new-releases") 47 | } 48 | }.body() 49 | } 50 | 51 | override suspend fun getFeaturedPlaylist(): FeaturedPlayList { 52 | if (TOKEN.isEmpty()) { 53 | return Json.decodeFromString(featurePlaylistResponse) 54 | } 55 | return client.get { 56 | headers { 57 | sptifyEndPoint("v1/browse/featured-playlists") 58 | } 59 | }.body() 60 | } 61 | 62 | override suspend fun getPlayList(playlistId: String): TopFiftyCharts { 63 | if (TOKEN.isEmpty()) { 64 | return Json.decodeFromString(topFiftyChartsResponse) 65 | } 66 | return client.get { 67 | headers { 68 | sptifyEndPoint("v1/playlists/$playlistId") 69 | } 70 | }.body() 71 | } 72 | 73 | private val client = if (httpClientEngine == null) { 74 | HttpClient { 75 | expectSuccess = true 76 | install(HttpTimeout) { 77 | val timeout = 30000L 78 | connectTimeoutMillis = timeout 79 | requestTimeoutMillis = timeout 80 | socketTimeoutMillis = timeout 81 | } 82 | install(ContentNegotiation) { 83 | json(Json { isLenient = true; ignoreUnknownKeys = true }) 84 | } 85 | } 86 | } else { 87 | HttpClient(engine = httpClientEngine) { 88 | install(ContentNegotiation) { 89 | json(Json { isLenient = true; ignoreUnknownKeys = true }) 90 | } 91 | } 92 | } 93 | 94 | private fun HttpRequestBuilder.sptifyEndPoint(path: String) { 95 | url { 96 | takeFrom("https://api.spotify.com/v1/") 97 | encodedPath = path 98 | headers { 99 | append( 100 | HttpHeaders.Authorization, TOKEN 101 | ) 102 | } 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/featuredplaylist/ExternalUrls.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.featuredplaylist 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class ExternalUrls( 9 | @SerialName("spotify") 10 | val spotify: String? 11 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/featuredplaylist/FeaturedPlayList.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.featuredplaylist 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class FeaturedPlayList( 9 | @SerialName("message") 10 | val message: String?, 11 | @SerialName("playlists") 12 | val playlists: Playlists? 13 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/featuredplaylist/Image.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.featuredplaylist 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Image( 9 | @SerialName("height") 10 | val height: String?, 11 | @SerialName("url") 12 | val url: String?, 13 | @SerialName("width") 14 | val width: String? 15 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/featuredplaylist/Item.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.featuredplaylist 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Item( 9 | @SerialName("collaborative") 10 | val collaborative: Boolean?, 11 | @SerialName("description") 12 | val description: String?, 13 | @SerialName("external_urls") 14 | val externalUrls: ExternalUrls?, 15 | @SerialName("href") 16 | val href: String?, 17 | @SerialName("id") 18 | val id: String?, 19 | @SerialName("images") 20 | val images: List?, 21 | @SerialName("name") 22 | val name: String?, 23 | @SerialName("owner") 24 | val owner: Owner?, 25 | @SerialName("primary_color") 26 | val primaryColor: String?, 27 | @SerialName("public") 28 | val `public`: Boolean?, 29 | @SerialName("snapshot_id") 30 | val snapshotId: String?, 31 | @SerialName("tracks") 32 | val tracks: Tracks?, 33 | @SerialName("type") 34 | val type: String?, 35 | @SerialName("uri") 36 | val uri: String? 37 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/featuredplaylist/Owner.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.featuredplaylist 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Owner( 9 | @SerialName("display_name") 10 | val displayName: String?, 11 | @SerialName("external_urls") 12 | val externalUrls: ExternalUrls?, 13 | @SerialName("href") 14 | val href: String?, 15 | @SerialName("id") 16 | val id: String?, 17 | @SerialName("type") 18 | val type: String?, 19 | @SerialName("uri") 20 | val uri: String? 21 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/featuredplaylist/Playlists.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.featuredplaylist 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Playlists( 9 | @SerialName("href") 10 | val href: String?, 11 | @SerialName("items") 12 | val items: List?, 13 | @SerialName("limit") 14 | val limit: Int?, 15 | @SerialName("next") 16 | val next: String?, 17 | @SerialName("offset") 18 | val offset: Int?, 19 | @SerialName("previous") 20 | val previous: String?, 21 | @SerialName("total") 22 | val total: Int? 23 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/featuredplaylist/Tracks.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.featuredplaylist 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Tracks( 9 | @SerialName("href") 10 | val href: String?, 11 | @SerialName("total") 12 | val total: Int? 13 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/newreleases/Albums.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.newreleases 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Albums( 9 | @SerialName("href") 10 | val href: String?, 11 | @SerialName("items") 12 | val items: List?, 13 | @SerialName("limit") 14 | val limit: Int?, 15 | @SerialName("next") 16 | val next: String?, 17 | @SerialName("offset") 18 | val offset: Int?, 19 | @SerialName("previous") 20 | val previous: String?, 21 | @SerialName("total") 22 | val total: Int? 23 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/newreleases/Artist.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.newreleases 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Artist( 9 | @SerialName("external_urls") 10 | val externalUrls: ExternalUrlsX?, 11 | @SerialName("href") 12 | val href: String?, 13 | @SerialName("id") 14 | val id: String?, 15 | @SerialName("name") 16 | val name: String?, 17 | @SerialName("type") 18 | val type: String?, 19 | @SerialName("uri") 20 | val uri: String? 21 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/newreleases/ExternalUrlsX.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.newreleases 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class ExternalUrlsX( 9 | @SerialName("spotify") 10 | val spotify: String? 11 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/newreleases/Image.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.newreleases 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Image( 9 | @SerialName("height") 10 | val height: Int?, 11 | @SerialName("url") 12 | val url: String?, 13 | @SerialName("width") 14 | val width: Int? 15 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/newreleases/Item.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.newreleases 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Item( 9 | @SerialName("album_type") 10 | val albumType: String?, 11 | @SerialName("artists") 12 | val artists: List?, 13 | @SerialName("available_markets") 14 | val availableMarkets: List?, 15 | @SerialName("external_urls") 16 | val externalUrls: ExternalUrlsX?, 17 | @SerialName("href") 18 | val href: String?, 19 | @SerialName("id") 20 | val id: String?, 21 | @SerialName("images") 22 | val images: List?, 23 | @SerialName("name") 24 | val name: String?, 25 | @SerialName("release_date") 26 | val releaseDate: String?, 27 | @SerialName("release_date_precision") 28 | val releaseDatePrecision: String?, 29 | @SerialName("total_tracks") 30 | val totalTracks: Int?, 31 | @SerialName("type") 32 | val type: String?, 33 | @SerialName("uri") 34 | val uri: String? 35 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/newreleases/NewReleasedAlbums.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.newreleases 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class NewReleasedAlbums( 9 | @SerialName("albums") 10 | val albums: Albums? 11 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/AddedBy.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class AddedBy( 9 | @SerialName("external_urls") 10 | val externalUrls: ExternalUrls?, 11 | @SerialName("href") 12 | val href: String?, 13 | @SerialName("id") 14 | val id: String?, 15 | @SerialName("type") 16 | val type: String?, 17 | @SerialName("uri") 18 | val uri: String? 19 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/Album.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Album( 9 | @SerialName("album_type") val albumType: String?, 10 | @SerialName("artists") val artists: List?, 11 | @SerialName("available_markets") val availableMarkets: List?, 12 | @SerialName("external_urls") val externalUrls: ExternalUrls?, 13 | @SerialName("href") val href: String?, 14 | @SerialName("id") val id: String?, 15 | @SerialName("images") val images: List?, 16 | @SerialName("name") val name: String?, 17 | @SerialName("release_date") val releaseDate: String?, 18 | @SerialName("release_date_precision") val releaseDatePrecision: String?, 19 | @SerialName("total_tracks") val totalTracks: Int?, 20 | @SerialName("type") val type: String?, 21 | @SerialName("uri") val uri: String? 22 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/ArtistX.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ArtistX( 8 | @SerialName("external_urls") 9 | val externalUrls: ExternalUrls?, 10 | @SerialName("href") 11 | val href: String?, 12 | @SerialName("id") 13 | val id: String?, 14 | @SerialName("name") 15 | val name: String?, 16 | @SerialName("type") 17 | val type: String?, 18 | @SerialName("uri") 19 | val uri: String? 20 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/ExternalIds.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ExternalIds( 8 | @SerialName("isrc") 9 | val isrc: String? 10 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/ExternalUrls.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class ExternalUrls( 8 | @SerialName("spotify") 9 | val spotify: String? 10 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/Followers.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Followers( 9 | @SerialName("href") 10 | val href: String?, 11 | @SerialName("total") 12 | val total: Int? 13 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/Image.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Image( 9 | @SerialName("height") 10 | val height: String?, 11 | @SerialName("url") 12 | val url: String?, 13 | @SerialName("width") 14 | val width: String? 15 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/ImageX.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class ImageX( 9 | @SerialName("height") 10 | val height: Int?, 11 | @SerialName("url") 12 | val url: String?, 13 | @SerialName("width") 14 | val width: Int? 15 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/Item.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Item( 9 | @SerialName("added_at") 10 | val addedAt: String?, 11 | @SerialName("added_by") 12 | val addedBy: AddedBy?, 13 | @SerialName("is_local") 14 | val isLocal: Boolean?, 15 | @SerialName("primary_color") 16 | val primaryColor: String?, 17 | @SerialName("track") 18 | val track: Track?, 19 | @SerialName("video_thumbnail") 20 | val videoThumbnail: VideoThumbnail? 21 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/Owner.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Owner( 9 | @SerialName("display_name") 10 | val displayName: String?, 11 | @SerialName("external_urls") 12 | val externalUrls: ExternalUrls?, 13 | @SerialName("href") 14 | val href: String?, 15 | @SerialName("id") 16 | val id: String?, 17 | @SerialName("type") 18 | val type: String?, 19 | @SerialName("uri") 20 | val uri: String? 21 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/TopFiftyCharts.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class TopFiftyCharts( 9 | @SerialName("collaborative") 10 | val collaborative: Boolean?, 11 | @SerialName("description") 12 | val description: String?, 13 | @SerialName("external_urls") 14 | val externalUrls: ExternalUrls?, 15 | @SerialName("followers") 16 | val followers: Followers?, 17 | @SerialName("href") 18 | val href: String?, 19 | @SerialName("id") 20 | val id: String?, 21 | @SerialName("images") 22 | val images: List?, 23 | @SerialName("name") 24 | val name: String?, 25 | @SerialName("owner") 26 | val owner: Owner?, 27 | @SerialName("primary_color") 28 | val primaryColor: String?, 29 | @SerialName("public") 30 | val `public`: Boolean?, 31 | @SerialName("snapshot_id") 32 | val snapshotId: String?, 33 | @SerialName("tracks") 34 | val tracks: Tracks?, 35 | @SerialName("type") 36 | val type: String?, 37 | @SerialName("uri") 38 | val uri: String? 39 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/Track.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Track( 9 | @SerialName("album") 10 | val album: Album?, 11 | @SerialName("artists") 12 | val artists: List?, 13 | @SerialName("available_markets") 14 | val availableMarkets: List?, 15 | @SerialName("disc_number") 16 | val discNumber: Int?, 17 | @SerialName("duration_ms") 18 | val durationMs: Int?, 19 | @SerialName("episode") 20 | val episode: Boolean?, 21 | @SerialName("explicit") 22 | val explicit: Boolean?, 23 | @SerialName("external_ids") 24 | val externalIds: ExternalIds?, 25 | @SerialName("external_urls") 26 | val externalUrls: ExternalUrls?, 27 | @SerialName("href") 28 | val href: String?, 29 | @SerialName("id") 30 | val id: String?, 31 | @SerialName("is_local") 32 | val isLocal: Boolean?, 33 | @SerialName("name") 34 | val name: String?, 35 | @SerialName("popularity") 36 | val popularity: Int?, 37 | @SerialName("preview_url") 38 | val previewUrl: String?, 39 | @SerialName("track") 40 | val track: Boolean?, 41 | @SerialName("track_number") 42 | val trackNumber: Int?, 43 | @SerialName("type") 44 | val type: String?, 45 | @SerialName("uri") 46 | val uri: String? 47 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/Tracks.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Tracks( 9 | @SerialName("href") 10 | val href: String?, 11 | @SerialName("items") 12 | val items: List?, 13 | @SerialName("limit") 14 | val limit: Int?, 15 | @SerialName("next") 16 | val next: String?, 17 | @SerialName("offset") 18 | val offset: Int?, 19 | @SerialName("previous") 20 | val previous: String?, 21 | @SerialName("total") 22 | val total: Int? 23 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/network/models/topfiftycharts/VideoThumbnail.kt: -------------------------------------------------------------------------------- 1 | package musicapp.network.models.topfiftycharts 2 | 3 | 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class VideoThumbnail( 9 | @SerialName("url") val url: String? 10 | ) -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/player/MediaPlayerController.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player 2 | 3 | import musicapp.utils.PlatformContext 4 | 5 | @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") 6 | expect class MediaPlayerController(platformContext: PlatformContext) { 7 | fun prepare( 8 | mediaItem: TrackItem, 9 | listener: MediaPlayerListener 10 | ) 11 | 12 | fun setTrackList(trackList: List, currentTrackId: String) 13 | 14 | fun playNextTrack(): Boolean 15 | 16 | fun playPreviousTrack(): Boolean 17 | 18 | fun start() 19 | 20 | fun pause() 21 | 22 | fun getCurrentPosition(): Long? 23 | 24 | fun getDuration(): Long? 25 | 26 | fun seekTo(seconds: Long) 27 | 28 | fun isPlaying(): Boolean 29 | 30 | fun getCurrentTrack(): TrackItem? 31 | } 32 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/player/MediaPlayerListener.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player 2 | 3 | 4 | interface MediaPlayerListener { 5 | 6 | fun onReady() 7 | fun onAudioCompleted() 8 | fun onError() 9 | fun onTrackChanged(trackId: String) 10 | fun onBufferingStateChanged(isBuffering: Boolean) { /* Optional implementation */ 11 | } 12 | 13 | fun onPlaybackStateChanged(isPlaying: Boolean) { /* Optional implementation */ 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/player/TrackItem.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player 2 | 3 | import kotlinx.serialization.Serializable 4 | import musicapp.network.models.topfiftycharts.Track 5 | 6 | @Serializable 7 | data class TrackItem( 8 | val id: String, 9 | val title: String, 10 | val artist: String, 11 | val albumImageUrl: String, 12 | val pathSource: String 13 | ) 14 | 15 | fun Track.toMediaItem(): TrackItem { 16 | return TrackItem( 17 | id = id ?: "", 18 | title = name.toString(), 19 | artist = artists?.joinToString(",") { it.name ?: "" }.toString(), 20 | albumImageUrl = album?.images?.first()?.url.orEmpty(), 21 | pathSource = previewUrl.toString() 22 | ) 23 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/playerview/CountdownViewModel.kt: -------------------------------------------------------------------------------- 1 | package musicapp.playerview 2 | 3 | import com.arkivanov.essenty.instancekeeper.InstanceKeeper 4 | import kotlinx.coroutines.CoroutineExceptionHandler 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.Job 8 | import kotlinx.coroutines.SupervisorJob 9 | import kotlinx.coroutines.cancel 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.launch 12 | import kotlinx.datetime.Clock 13 | import kotlin.time.Duration.Companion.milliseconds 14 | 15 | class CountdownViewModel : InstanceKeeper.Instance { 16 | 17 | private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> 18 | exception.printStackTrace() 19 | } 20 | 21 | private var job = SupervisorJob() 22 | private val viewModelScope = CoroutineScope(Dispatchers.Main + coroutineExceptionHandler + job) 23 | 24 | fun startCountdown(initialMillis: Long, intervalMillis: Long, onCountDownFinish: () -> Unit) { 25 | val targetTime = Clock.System.now() + initialMillis.milliseconds 26 | 27 | viewModelScope.launch { 28 | while (true) { 29 | val now = Clock.System.now() 30 | val millisUntilFinished = (targetTime - now).inWholeMilliseconds 31 | 32 | if (millisUntilFinished <= 0) { 33 | onCountDownFinish() 34 | break 35 | } 36 | 37 | delay(intervalMillis) 38 | } 39 | } 40 | } 41 | 42 | override fun onDestroy() { 43 | super.onDestroy() 44 | viewModelScope.cancel() 45 | } 46 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/playerview/PlayerView.kt: -------------------------------------------------------------------------------- 1 | package musicapp.playerview 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.basicMarquee 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.interaction.MutableInteractionSource 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.CircularProgressIndicator 11 | import androidx.compose.material.Icon 12 | import androidx.compose.material.MaterialTheme 13 | import androidx.compose.material.Text 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 16 | import androidx.compose.material.icons.automirrored.filled.ArrowForward 17 | import androidx.compose.material.icons.filled.PlayArrow 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.LaunchedEffect 20 | import androidx.compose.runtime.collectAsState 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.draw.clip 25 | import androidx.compose.ui.layout.ContentScale 26 | import androidx.compose.ui.unit.dp 27 | import com.seiko.imageloader.rememberImagePainter 28 | import musicapp.decompose.PlayerComponent 29 | import musicapp.theme.* 30 | import musicapp_kmp.shared.generated.resources.* 31 | import org.jetbrains.compose.resources.painterResource 32 | import org.jetbrains.compose.resources.stringResource 33 | 34 | 35 | @Composable 36 | internal fun PlayerView(playerComponent: PlayerComponent) { 37 | playerComponent.viewModel.syncWithMediaPlayer() 38 | 39 | val state = playerComponent.viewModel.playerViewState.collectAsState() 40 | val trackList = state.value.trackList 41 | val isPlaying = state.value.isPlaying 42 | val currentTrackId = state.value.playingTrackId 43 | val isBuffering = state.value.isBuffering 44 | val isError = state.value.errorState 45 | 46 | if (trackList.isEmpty()) return 47 | 48 | val currentIndex = playerComponent.viewModel.getCurrentTrackIndex() 49 | val currentTrack = if (currentIndex >= 0) trackList[currentIndex] else return 50 | 51 | LaunchedEffect(isError) { 52 | if (isError) { 53 | playerComponent.viewModel.setBuffering(true) 54 | playerComponent.viewModel.playNextTrack() 55 | } 56 | } 57 | 58 | LaunchedEffect(currentTrackId) { 59 | if (currentTrackId.isNotEmpty()) { 60 | playerComponent.onOutPut(PlayerComponent.Output.OnTrackUpdated(currentTrackId)) 61 | } 62 | } 63 | 64 | Box( 65 | modifier = Modifier.fillMaxWidth().background(playerBackgroundColor) 66 | .padding(spacingMedium).clickable( 67 | indication = null, 68 | interactionSource = remember { MutableInteractionSource() }) { }) { 69 | 70 | Row(modifier = Modifier.fillMaxWidth()) { 71 | val painter = rememberImagePainter( 72 | url = currentTrack.albumImageUrl, 73 | ) 74 | Box(modifier = Modifier.clip(RoundedCornerShape(borderRadiusSmall)).width(49.dp).height(49.dp)) { 75 | Image( 76 | painter = painter, 77 | contentDescription = currentTrack.albumImageUrl, 78 | modifier = Modifier.clip(RoundedCornerShape(borderRadiusSmall)).width(49.dp).height(49.dp), 79 | contentScale = ContentScale.Crop 80 | ) 81 | if (isBuffering) { 82 | Box(modifier = Modifier.fillMaxSize().background(loadingOverlayColor)) { 83 | CircularProgressIndicator( 84 | modifier = Modifier.align(Alignment.Center).padding(spacingSmall), 85 | color = accentColor, 86 | ) 87 | } 88 | } 89 | } 90 | Column(Modifier.weight(1f).padding(start = spacingSmall).align(Alignment.Top)) { 91 | Text( 92 | text = currentTrack.title, style = MaterialTheme.typography.subtitle1.copy( 93 | color = textColor 94 | ), 95 | modifier = Modifier.fillMaxWidth().basicMarquee(Int.MAX_VALUE) 96 | ) 97 | Text( 98 | text = currentTrack.artist, 99 | style = MaterialTheme.typography.subtitle1.copy( 100 | color = textColor 101 | ), 102 | modifier = Modifier.padding(top = spacingSmall) 103 | ) 104 | } 105 | Row(modifier = Modifier.align(Alignment.CenterVertically)) { 106 | Icon( 107 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 108 | tint = accentColor, 109 | contentDescription = stringResource(Res.string.back), 110 | modifier = Modifier.padding(end = spacingSmall).size(iconSizeMedium) 111 | .align(Alignment.CenterVertically) 112 | .clickable(onClick = { 113 | playerComponent.viewModel.setBuffering(true) 114 | playerComponent.viewModel.playPreviousTrack() 115 | }) 116 | ) 117 | Icon( 118 | painter = painterResource(Res.drawable.rewind), 119 | tint = accentColor, 120 | contentDescription = stringResource(Res.string.rewind_5_sec), 121 | modifier = Modifier 122 | .padding(end = spacingSmall) 123 | .size(iconSizeMedium) 124 | .align(Alignment.CenterVertically) 125 | .clickable(onClick = { 126 | playerComponent.viewModel.rewind5Seconds() 127 | }) 128 | ) 129 | PlayPauseButton( 130 | modifier = Modifier.padding(end = spacingSmall).size(iconSizeMedium) 131 | .align(Alignment.CenterVertically), 132 | isPlaying = isPlaying, 133 | onTogglePlayPause = { playerComponent.viewModel.togglePlayPause() } 134 | ) 135 | Icon( 136 | painter = painterResource(Res.drawable.forward), 137 | tint = accentColor, 138 | contentDescription = stringResource(Res.string.forward_5_sec), 139 | modifier = Modifier 140 | .padding(end = spacingSmall) 141 | .size(iconSizeMedium) 142 | .align(Alignment.CenterVertically) 143 | .clickable(onClick = { 144 | playerComponent.viewModel.forward5Seconds() 145 | }) 146 | ) 147 | Icon( 148 | imageVector = Icons.AutoMirrored.Filled.ArrowForward, 149 | tint = accentColor, 150 | contentDescription = stringResource(Res.string.forward), 151 | modifier = Modifier.padding(end = spacingSmall).size(iconSizeMedium) 152 | .align(Alignment.CenterVertically) 153 | .clickable(onClick = { 154 | playerComponent.viewModel.setBuffering(true) 155 | playerComponent.viewModel.playNextTrack() 156 | }) 157 | ) 158 | } 159 | } 160 | } 161 | } 162 | 163 | @Composable 164 | fun PlayPauseButton( 165 | modifier: Modifier, 166 | isPlaying: Boolean, 167 | onTogglePlayPause: () -> Unit 168 | ) { 169 | if (isPlaying) Icon( 170 | painter = painterResource(Res.drawable.baseline_pause_24), 171 | tint = accentColor, 172 | contentDescription = stringResource(Res.string.pause), 173 | modifier = modifier.clickable(onClick = onTogglePlayPause) 174 | ) else Icon( 175 | imageVector = Icons.Filled.PlayArrow, 176 | tint = accentColor, 177 | contentDescription = stringResource(Res.string.play), 178 | modifier = modifier.clickable(onClick = onTogglePlayPause) 179 | ) 180 | } -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/playerview/PlayerViewModel.kt: -------------------------------------------------------------------------------- 1 | package musicapp.playerview 2 | 3 | import com.arkivanov.essenty.instancekeeper.InstanceKeeper 4 | import kotlinx.coroutines.* 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.SharedFlow 7 | import kotlinx.coroutines.flow.collectLatest 8 | import musicapp.SEEK_TO_SECONDS 9 | import musicapp.decompose.PlayerComponent 10 | import musicapp.player.MediaPlayerController 11 | import musicapp.player.MediaPlayerListener 12 | import musicapp.player.TrackItem 13 | 14 | 15 | /** 16 | * Created by abdulbasit on 26/02/2023. 17 | */ 18 | class PlayerViewModel( 19 | private val mediaPlayerController: MediaPlayerController, 20 | trackList: List, 21 | selectedTrack: String, 22 | playerInputs: SharedFlow 23 | ) : InstanceKeeper.Instance { 24 | 25 | private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> 26 | exception.printStackTrace() 27 | } 28 | 29 | private val job = SupervisorJob() 30 | private val viewModelScope = CoroutineScope(Dispatchers.Main + coroutineExceptionHandler + job) 31 | 32 | val playerViewState = MutableStateFlow( 33 | PlayerViewState( 34 | trackList = trackList, 35 | playingTrackId = selectedTrack, 36 | isPlaying = false 37 | ) 38 | ) 39 | 40 | private val mediaPlayerListener = object : MediaPlayerListener { 41 | override fun onReady() { 42 | updatePlayerState { 43 | it.copy( 44 | isBuffering = false, 45 | errorState = false 46 | ) 47 | } 48 | mediaPlayerController.start() 49 | } 50 | 51 | override fun onAudioCompleted() { 52 | updatePlayerState { 53 | it.copy( 54 | isPlaying = false 55 | ) 56 | } 57 | } 58 | 59 | override fun onError() { 60 | updatePlayerState { 61 | it.copy( 62 | errorState = true, 63 | isBuffering = false 64 | ) 65 | } 66 | } 67 | 68 | override fun onTrackChanged(trackId: String) { 69 | updatePlayerState { 70 | it.copy( 71 | playingTrackId = trackId, 72 | errorState = false 73 | ) 74 | } 75 | } 76 | 77 | override fun onBufferingStateChanged(isBuffering: Boolean) { 78 | updatePlayerState { 79 | it.copy( 80 | isBuffering = isBuffering 81 | ) 82 | } 83 | } 84 | 85 | override fun onPlaybackStateChanged(isPlaying: Boolean) { 86 | updatePlayerState { 87 | it.copy( 88 | isPlaying = isPlaying 89 | ) 90 | } 91 | } 92 | } 93 | 94 | private fun updatePlayerState() { 95 | val currentTrack = mediaPlayerController.getCurrentTrack() ?: return 96 | val currentPosition = mediaPlayerController.getCurrentPosition() ?: 0L 97 | val duration = mediaPlayerController.getDuration() 98 | val isPlaying = mediaPlayerController.isPlaying() 99 | 100 | val newState = playerViewState.value.copy( 101 | playingTrackId = currentTrack.id, 102 | currentPosition = currentPosition, 103 | duration = duration, 104 | isPlaying = isPlaying, 105 | errorState = false 106 | ) 107 | playerViewState.value = newState 108 | } 109 | 110 | private fun updatePlayerState(transform: (PlayerViewState) -> PlayerViewState) { 111 | playerViewState.value = transform(playerViewState.value) 112 | } 113 | 114 | fun syncWithMediaPlayer() { 115 | updatePlayerState() 116 | } 117 | 118 | fun playTrack(trackId: String) { 119 | val track = playerViewState.value.trackList.find { it.id == trackId } ?: return 120 | updatePlayerState { 121 | it.copy( 122 | isBuffering = true 123 | ) 124 | } 125 | mediaPlayerController.prepare(track, mediaPlayerListener) 126 | } 127 | 128 | fun togglePlayPause() { 129 | if (mediaPlayerController.isPlaying()) { 130 | mediaPlayerController.pause() 131 | } else { 132 | mediaPlayerController.start() 133 | } 134 | updatePlayerState() 135 | } 136 | 137 | fun playNextTrack() { 138 | if (mediaPlayerController.playNextTrack()) { 139 | updatePlayerState() 140 | } 141 | } 142 | 143 | fun playPreviousTrack() { 144 | if (mediaPlayerController.playPreviousTrack()) { 145 | updatePlayerState() 146 | } 147 | } 148 | 149 | fun getCurrentTrackIndex(): Int { 150 | val currentTrackId = playerViewState.value.playingTrackId 151 | return playerViewState.value.trackList.indexOfFirst { it.id == currentTrackId } 152 | } 153 | 154 | init { 155 | viewModelScope.launch { 156 | playerInputs.collectLatest { 157 | when (it) { 158 | is PlayerComponent.Input.PlayTrack -> { 159 | val newState = playerViewState.value.copy( 160 | playingTrackId = it.trackId, 161 | trackList = it.tracksList 162 | ) 163 | playerViewState.value = newState 164 | 165 | mediaPlayerController.setTrackList(it.tracksList, it.trackId) 166 | playTrack(it.trackId) 167 | } 168 | 169 | is PlayerComponent.Input.UpdateTracks -> { 170 | val newState = playerViewState.value.copy(trackList = it.tracksList) 171 | playerViewState.value = newState 172 | 173 | mediaPlayerController.setTrackList(it.tracksList, newState.playingTrackId) 174 | } 175 | } 176 | } 177 | } 178 | 179 | mediaPlayerController.setTrackList(trackList, selectedTrack) 180 | 181 | if (selectedTrack.isNotEmpty()) { 182 | playTrack(selectedTrack) 183 | } 184 | } 185 | 186 | fun rewind5Seconds() { 187 | mediaPlayerController.getCurrentPosition()?.let { 188 | (it - SEEK_TO_SECONDS).coerceAtLeast(0).let(mediaPlayerController::seekTo) 189 | } 190 | updatePlayerState() 191 | } 192 | 193 | fun forward5Seconds() { 194 | mediaPlayerController.getCurrentPosition()?.let { currentPosition -> 195 | mediaPlayerController.getDuration()?.let { duration -> 196 | (currentPosition + SEEK_TO_SECONDS).coerceAtMost(duration) 197 | .let(mediaPlayerController::seekTo) 198 | } 199 | } 200 | updatePlayerState() 201 | } 202 | 203 | fun setBuffering(isBuffering: Boolean) { 204 | val newState = playerViewState.value.copy( 205 | isBuffering = isBuffering 206 | ) 207 | playerViewState.value = newState 208 | } 209 | 210 | override fun onDestroy() { 211 | viewModelScope.cancel() 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/playerview/PlayerViewState.kt: -------------------------------------------------------------------------------- 1 | package musicapp.playerview 2 | 3 | import musicapp.player.TrackItem 4 | 5 | 6 | /** 7 | * Created by abdulbasit on 09/04/2023. 8 | */ 9 | data class PlayerViewState( 10 | val trackList: List, 11 | val playingTrackId: String = "", 12 | val currentPosition: Long = 0, 13 | val isPlaying: Boolean = false, 14 | val duration: Long? = null, 15 | val isBuffering: Boolean = false, 16 | val errorState: Boolean = false 17 | ) 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package musicapp.theme 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.graphics.Brush 5 | import androidx.compose.ui.graphics.Color 6 | 7 | // Primary colors 8 | val lightPrimaryLighter = Color(0xFF8F89F8) 9 | val lightPrimary = Color(0xFF605ca8) 10 | val buttonColor = Color(0xff6864b3) 11 | val colorWhite = Color(0xffffffff) 12 | 13 | // Dashboard colors 14 | val dashboardBackgroundColor = Color(0xFF1D2123) 15 | val accentColor = Color(0xffFACD66) 16 | val textColor = Color(0xFFEFEEE0) 17 | val drawerHeaderColor = Color(0xFF673AB7) 18 | val profileImageBackgroundColor = Color.Gray 19 | 20 | // Player colors 21 | val playerBackgroundColor = Color(0xCC101010) 22 | val loadingOverlayColor = Color(0x80000000) 23 | 24 | // Browse screen colors 25 | val selectedItemBackgroundColor = Color(0xCCFACD66) 26 | val itemBackgroundColor = Color(0xFF33373B) 27 | 28 | val gradientBrush = Brush.linearGradient( 29 | colors = listOf(lightPrimaryLighter, lightPrimary), 30 | start = Offset(0f, 0f), 31 | end = Offset(0f, Float.POSITIVE_INFINITY) 32 | ) 33 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/theme/Dimensions.kt: -------------------------------------------------------------------------------- 1 | package musicapp.theme 2 | 3 | import androidx.compose.ui.unit.dp 4 | 5 | // Standard spacing values 6 | val spacingTiny = 4.dp 7 | val spacingSmall = 8.dp 8 | val spacingMedium = 16.dp 9 | val spacingLarge = 24.dp 10 | val spacingXLarge = 32.dp 11 | val spacingXXLarge = 48.dp 12 | val spacingMediumLarge = 40.dp 13 | val spacingLargeXL = 46.dp 14 | val spacingHundred = 100.dp 15 | val spacingThirty = 30.dp 16 | 17 | // Icon sizes 18 | val iconSizeXXSmall = 16.dp 19 | val iconSizeSmall = 24.dp 20 | val iconSizeMedium = 32.dp 21 | val iconSizeLarge = 48.dp 22 | val iconSizeXLarge = 64.dp 23 | 24 | // Profile image sizes 25 | val profileImageSize = 80.dp 26 | 27 | // Border radius 28 | val borderRadiusXXSmall = 5.dp 29 | val borderRadiusSmall = 4.dp 30 | val borderRadiusMedium = 8.dp 31 | val borderRadiusLarge = 16.dp 32 | val borderRadiusXLarge = 20.dp 33 | val borderRadiusXXLarge = 25.dp 34 | val borderRadiusRound = 32.dp 35 | 36 | // Component specific dimensions 37 | val horizontalSpacing = 10.dp 38 | val cardWidth = 232.dp 39 | val imageSize = 100.dp 40 | val albumWidth = 153.dp 41 | val listItemHeight = 100.dp 42 | val listItemImageSize = 40.dp 43 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/musicapp/utils/PlatformContext.kt: -------------------------------------------------------------------------------- 1 | package musicapp.utils 2 | 3 | 4 | @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") 5 | expect class PlatformContext 6 | 7 | expect fun getPlatformContext(): PlatformContext -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/network/APIMockEngine.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import io.ktor.client.engine.mock.MockEngine 4 | import io.ktor.client.engine.mock.respond 5 | import io.ktor.http.HttpHeaders 6 | import io.ktor.http.HttpStatusCode 7 | import io.ktor.http.headersOf 8 | import io.ktor.utils.io.ByteReadChannel 9 | import network.mockresponse.APITestData 10 | 11 | const val CONTENT_TYPE_JSON = "application/json" 12 | 13 | val topFiftyChartsMockEngine = MockEngine { _ -> 14 | respond( 15 | content = ByteReadChannel(APITestData.topFiftyCharts), 16 | status = HttpStatusCode.OK, 17 | headers = headersOf(HttpHeaders.ContentType, CONTENT_TYPE_JSON) 18 | ) 19 | } 20 | 21 | val newReleasedAlbumsMockEngine = MockEngine { _ -> 22 | respond( 23 | content = ByteReadChannel(APITestData.newReleases), 24 | status = HttpStatusCode.OK, 25 | headers = headersOf(HttpHeaders.ContentType, CONTENT_TYPE_JSON) 26 | ) 27 | } 28 | 29 | val featuredPlaylistsMockEngine = MockEngine { _ -> 30 | respond( 31 | content = ByteReadChannel(APITestData.featuredPlaylists), 32 | status = HttpStatusCode.OK, 33 | headers = headersOf(HttpHeaders.ContentType, CONTENT_TYPE_JSON) 34 | ) 35 | } -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/network/ApiTest.kt: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import kotlinx.coroutines.test.runTest 4 | import musicapp.network.SpotifyApi 5 | import musicapp.network.SpotifyApiImpl 6 | import musicapp.network.models.featuredplaylist.FeaturedPlayList 7 | import musicapp.network.models.newreleases.NewReleasedAlbums 8 | import musicapp.network.models.topfiftycharts.TopFiftyCharts 9 | import kotlin.test.Test 10 | import kotlin.test.assertIs 11 | 12 | class ApiTest { 13 | @Test 14 | fun `call to getTopFiftyChart should return TopFiftyCharts object as response`() = 15 | runTest { 16 | val spotifyApiImpl: SpotifyApi = 17 | SpotifyApiImpl(topFiftyChartsMockEngine) 18 | val response = spotifyApiImpl.getTopFiftyChart() 19 | assertIs(response) 20 | } 21 | 22 | @Test 23 | fun `call to getNewReleases should return NewReleasedAlbums object as response`() = 24 | runTest { 25 | val spotifyApiImpl: SpotifyApi = SpotifyApiImpl(newReleasedAlbumsMockEngine) 26 | val response = spotifyApiImpl.getNewReleases() 27 | assertIs(response) 28 | } 29 | 30 | @Test 31 | fun `call to getFeaturedPlaylist should return FeaturedPlayList object as response`() = 32 | runTest { 33 | val spotifyApiImpl: SpotifyApi = 34 | SpotifyApiImpl(featuredPlaylistsMockEngine) 35 | val response = spotifyApiImpl.getFeaturedPlaylist() 36 | assertIs(response) 37 | } 38 | } -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/network/mockresponse/APITestData.kt: -------------------------------------------------------------------------------- 1 | package network.mockresponse 2 | 3 | object APITestData { 4 | val topFiftyCharts = topFiftyChartsResponse 5 | val newReleases = newReleasesResponse 6 | val featuredPlaylists = featuredPlaylistsResponse 7 | } -------------------------------------------------------------------------------- /shared/src/desktopMain/java/musicapp/utils/PlatformContext.desktop.kt: -------------------------------------------------------------------------------- 1 | package musicapp.utils 2 | 3 | @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) 4 | actual class PlatformContext 5 | 6 | actual fun getPlatformContext(): PlatformContext { 7 | return PlatformContext() 8 | } -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/main.desktop.kt: -------------------------------------------------------------------------------- 1 | package musicapp 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.CompositionLocalProvider 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.graphics.Color 10 | import com.seiko.imageloader.Bitmap 11 | import com.seiko.imageloader.ImageLoader 12 | import com.seiko.imageloader.LocalImageLoader 13 | import com.seiko.imageloader.cache.memory.MemoryCacheBuilder 14 | import com.seiko.imageloader.cache.memory.MemoryKey 15 | import com.seiko.imageloader.cache.memory.maxSizePercent 16 | import com.seiko.imageloader.component.setupDefaultComponents 17 | import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig 18 | import com.seiko.imageloader.util.identityHashCode 19 | import musicapp.decompose.MusicRoot 20 | 21 | @Composable 22 | fun CommonMainDesktop(rootComponent: MusicRoot) { 23 | Box(Modifier.background(color = Color(0xFF1A1E1F)).fillMaxSize()) { 24 | CompositionLocalProvider( 25 | LocalImageLoader provides ImageLoader { 26 | components { 27 | setupDefaultComponents() 28 | } 29 | interceptor { 30 | bitmapMemoryCacheConfig( 31 | valueHashProvider = { identityHashCode(it) }, 32 | valueSizeProvider = { 500 }, 33 | block = fun MemoryCacheBuilder.() { 34 | maxSizePercent(0.25) 35 | } 36 | ) 37 | } 38 | }, 39 | ) { 40 | MainCommon(rootComponent, true) 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /shared/src/desktopMain/kotlin/musicapp/player/MediaPlayerController.desktop.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player 2 | 3 | import kotlinx.coroutines.* 4 | import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery 5 | import uk.co.caprica.vlcj.player.base.MediaPlayer 6 | import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter 7 | import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent 8 | import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent 9 | import java.util.* 10 | import java.util.logging.Logger 11 | 12 | actual class MediaPlayerController actual constructor(val platformContext: musicapp.utils.PlatformContext) { 13 | 14 | private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 15 | 16 | private var mediaPlayer: MediaPlayer? = null 17 | private var listener: MediaPlayerListener? = null 18 | private var currentTrack: TrackItem? = null 19 | private var trackList: List = emptyList() 20 | private var currentTrackIndex: Int = -1 21 | private val logger = Logger.getLogger(MediaPlayerController::class.java.name) 22 | 23 | init { 24 | System.setProperty("vlcj.log", "DEBUG") 25 | } 26 | 27 | private fun initMediaPlayer(): Boolean { 28 | try { 29 | NativeDiscovery().discover() 30 | releaseMediaPlayer() 31 | 32 | val component = if (isMacOS()) CallbackMediaPlayerComponent() else EmbeddedMediaPlayerComponent() 33 | mediaPlayer = component.mediaPlayerFactory().mediaPlayers().newMediaPlayer() 34 | 35 | mediaPlayer?.events()?.addMediaPlayerEventListener(object : MediaPlayerEventAdapter() { 36 | override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) { 37 | scope.launch { listener?.onReady() } 38 | } 39 | 40 | override fun finished(mediaPlayer: MediaPlayer?) { 41 | scope.launch { 42 | if (!playNextTrack()) { 43 | listener?.onAudioCompleted() 44 | } 45 | } 46 | } 47 | 48 | override fun error(mediaPlayer: MediaPlayer?) { 49 | scope.launch { listener?.onError() } 50 | } 51 | 52 | override fun playing(mediaPlayer: MediaPlayer?) { 53 | scope.launch { listener?.onPlaybackStateChanged(true) } 54 | } 55 | 56 | override fun paused(mediaPlayer: MediaPlayer?) { 57 | scope.launch { listener?.onPlaybackStateChanged(false) } 58 | } 59 | 60 | override fun buffering(mediaPlayer: MediaPlayer?, newCache: Float) { 61 | scope.launch { listener?.onBufferingStateChanged(newCache < 100f) } 62 | } 63 | }) 64 | 65 | return true 66 | } catch (e: Exception) { 67 | logger.severe("Failed to initialize media player: ${e.message}") 68 | listener?.onError() 69 | return false 70 | } 71 | } 72 | 73 | private fun releaseMediaPlayer() { 74 | try { 75 | mediaPlayer?.controls()?.stop() 76 | mediaPlayer?.release() 77 | mediaPlayer = null 78 | } catch (e: Exception) { 79 | logger.severe("Error releasing media player: ${e.message}") 80 | } 81 | } 82 | 83 | actual fun prepare(mediaItem: TrackItem, listener: MediaPlayerListener) { 84 | this.listener = listener 85 | this.currentTrack = mediaItem 86 | 87 | if (mediaItem.pathSource.isNullOrBlank()) { 88 | listener.onError() 89 | return 90 | } 91 | 92 | scope.launch { 93 | try { 94 | // Initialize player if needed 95 | if (mediaPlayer == null) { 96 | if (!initMediaPlayer()) return@launch 97 | } 98 | 99 | // Update track index if in playlist 100 | if (trackList.isNotEmpty()) { 101 | trackList.indexOfFirst { it.id == mediaItem.id } 102 | .takeIf { it >= 0 } 103 | ?.let { currentTrackIndex = it } 104 | } 105 | 106 | // Prepare and play media 107 | mediaPlayer?.media()?.prepare(mediaItem.pathSource) 108 | mediaPlayer?.controls()?.play() 109 | listener.onBufferingStateChanged(true) 110 | } catch (e: Exception) { 111 | // Try to recover once 112 | try { 113 | if (initMediaPlayer()) { 114 | mediaPlayer?.media()?.prepare(mediaItem.pathSource) 115 | mediaPlayer?.controls()?.play() 116 | listener.onBufferingStateChanged(true) 117 | } else { 118 | listener.onError() 119 | } 120 | } catch (e: Exception) { 121 | listener.onError() 122 | } 123 | } 124 | } 125 | } 126 | 127 | private fun playTrackAt(index: Int): Boolean { 128 | if (index < 0 || index >= trackList.size || listener == null) return false 129 | 130 | currentTrackIndex = index 131 | val track = trackList[index] 132 | listener?.onTrackChanged(track.id) 133 | 134 | try { 135 | prepare(track, listener!!) 136 | return true 137 | } catch (e: Exception) { 138 | listener?.onError() 139 | return false 140 | } 141 | } 142 | 143 | actual fun playNextTrack(): Boolean { 144 | if (trackList.isEmpty() || currentTrackIndex < 0) return false 145 | 146 | val nextIndex = currentTrackIndex + 1 147 | if (nextIndex >= trackList.size) return false 148 | 149 | return playTrackAt(nextIndex) 150 | } 151 | 152 | actual fun playPreviousTrack(): Boolean { 153 | if (trackList.isEmpty() || currentTrackIndex <= 0) return false 154 | 155 | return playTrackAt(currentTrackIndex - 1) 156 | } 157 | 158 | fun release() { 159 | releaseMediaPlayer() 160 | scope.cancel() 161 | } 162 | 163 | private fun isMacOS(): Boolean { 164 | val os = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) 165 | return os.contains("mac") || os.contains("darwin") 166 | } 167 | 168 | actual fun setTrackList(trackList: List, currentTrackId: String) { 169 | this.trackList = trackList 170 | this.currentTrackIndex = trackList.indexOfFirst { it.id == currentTrackId }.takeIf { it >= 0 } ?: 0 171 | } 172 | 173 | actual fun getCurrentTrack(): TrackItem? { 174 | return currentTrack ?: trackList.getOrNull(currentTrackIndex) 175 | } 176 | 177 | actual fun start() { 178 | mediaPlayer?.controls()?.play() 179 | listener?.onPlaybackStateChanged(true) 180 | } 181 | 182 | actual fun pause() { 183 | mediaPlayer?.controls()?.pause() 184 | listener?.onPlaybackStateChanged(false) 185 | } 186 | 187 | actual fun getCurrentPosition(): Long? { 188 | return mediaPlayer?.status()?.time()?.toLong() ?: 0L 189 | } 190 | 191 | actual fun getDuration(): Long? { 192 | return mediaPlayer?.status()?.length() ?: 0L 193 | } 194 | 195 | actual fun seekTo(seconds: Long) { 196 | mediaPlayer?.controls()?.setTime(seconds) 197 | } 198 | 199 | actual fun isPlaying(): Boolean { 200 | return mediaPlayer?.status()?.isPlaying ?: false 201 | } 202 | } -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/musicapp/main.ios.kt: -------------------------------------------------------------------------------- 1 | package musicapp 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.window.ComposeUIViewController 13 | import com.arkivanov.decompose.DefaultComponentContext 14 | import com.arkivanov.essenty.lifecycle.LifecycleRegistry 15 | import com.seiko.imageloader.Bitmap 16 | import com.seiko.imageloader.ImageLoader 17 | import com.seiko.imageloader.LocalImageLoader 18 | import com.seiko.imageloader.cache.memory.MemoryCacheBuilder 19 | import com.seiko.imageloader.cache.memory.MemoryKey 20 | import com.seiko.imageloader.cache.memory.maxSizePercent 21 | import com.seiko.imageloader.component.setupDefaultComponents 22 | import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig 23 | import com.seiko.imageloader.util.identityHashCode 24 | import musicapp.decompose.MusicRootImpl 25 | import musicapp.network.SpotifyApiImpl 26 | import musicapp.player.MediaPlayerController 27 | import musicapp.utils.PlatformContext 28 | import platform.UIKit.UIViewController 29 | 30 | fun MainiOS( 31 | lifecycle: LifecycleRegistry, 32 | ): UIViewController = ComposeUIViewController(configure = { enforceStrictPlistSanityCheck = false }) { 33 | val rootComponent = MusicRootImpl( 34 | componentContext = DefaultComponentContext(lifecycle = lifecycle), 35 | api = SpotifyApiImpl(), 36 | mediaPlayerController = MediaPlayerController(PlatformContext()) 37 | ) 38 | 39 | Column(Modifier.background(color = Color(0xFF1A1E1F))) { 40 | Box( 41 | modifier = Modifier.fillMaxWidth().height(40.dp).background(color = Color(0xFF1A1E1F)) 42 | ) 43 | CompositionLocalProvider( 44 | LocalImageLoader provides ImageLoader { 45 | components { 46 | setupDefaultComponents() 47 | } 48 | interceptor { 49 | bitmapMemoryCacheConfig( 50 | valueHashProvider = { identityHashCode(it) }, 51 | valueSizeProvider = { 500 }, 52 | block = fun MemoryCacheBuilder.() { 53 | maxSizePercent(0.25) 54 | } 55 | ) 56 | } 57 | }, 58 | ) { 59 | MainCommon(rootComponent, false) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/musicapp/utils/PlatformContext.ios.kt: -------------------------------------------------------------------------------- 1 | package musicapp.utils 2 | 3 | import platform.UIKit.UIViewController 4 | 5 | /** 6 | * Platform context for iOS. 7 | * This class holds the iOS UIViewController which can be used to access iOS-specific functionality. 8 | */ 9 | actual class PlatformContext { 10 | private var viewController: UIViewController? = null 11 | 12 | /** 13 | * Default constructor for PlatformContext. 14 | */ 15 | constructor() 16 | 17 | /** 18 | * Constructor that takes a UIViewController. 19 | */ 20 | constructor(viewController: UIViewController) { 21 | this.viewController = viewController 22 | } 23 | 24 | /** 25 | * Get the UIViewController. 26 | */ 27 | fun getViewController(): UIViewController? { 28 | return viewController 29 | } 30 | 31 | /** 32 | * Set the UIViewController. 33 | */ 34 | fun setViewController(viewController: UIViewController) { 35 | this.viewController = viewController 36 | } 37 | 38 | 39 | } 40 | 41 | // Global variable to store the platform context 42 | private var platformContext: PlatformContext? = null 43 | 44 | /** 45 | * Initialize the platform context with a UIViewController. 46 | * This should be called from the iOS app's entry point. 47 | */ 48 | fun initializePlatformContext(viewController: UIViewController) { 49 | platformContext = PlatformContext(viewController) 50 | } 51 | 52 | /** 53 | * Get the platform context for iOS. 54 | * This returns a PlatformContext instance that may contain a UIViewController. 55 | */ 56 | actual fun getPlatformContext(): PlatformContext { 57 | return platformContext ?: PlatformContext() 58 | } 59 | -------------------------------------------------------------------------------- /shared/src/jsMain/kotlin/main.js.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.background 2 | import androidx.compose.foundation.layout.Box 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.CompositionLocalProvider 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.graphics.Color 8 | import com.seiko.imageloader.Bitmap 9 | import com.seiko.imageloader.ImageLoader 10 | import com.seiko.imageloader.LocalImageLoader 11 | import com.seiko.imageloader.cache.memory.MemoryCacheBuilder 12 | import com.seiko.imageloader.cache.memory.MemoryKey 13 | import com.seiko.imageloader.cache.memory.maxSizePercent 14 | import com.seiko.imageloader.component.setupDefaultComponents 15 | import com.seiko.imageloader.intercept.bitmapMemoryCacheConfig 16 | import com.seiko.imageloader.util.identityHashCode 17 | import musicapp.MainCommon 18 | import musicapp.decompose.MusicRoot 19 | 20 | 21 | @Composable 22 | fun CommonMainWeb(root: MusicRoot) { 23 | CompositionLocalProvider( 24 | LocalImageLoader provides ImageLoader { 25 | components { 26 | setupDefaultComponents() 27 | } 28 | interceptor { 29 | bitmapMemoryCacheConfig( 30 | valueHashProvider = { identityHashCode(it) }, 31 | valueSizeProvider = { 500 }, 32 | block = fun MemoryCacheBuilder.() { 33 | maxSizePercent(0.25) 34 | } 35 | ) 36 | } 37 | }, 38 | ) { 39 | Box(Modifier.background(color = Color(0xFF1A1E1F)).fillMaxSize()) { 40 | MainCommon(root, true) 41 | } 42 | } 43 | } 44 | 45 | 46 | -------------------------------------------------------------------------------- /shared/src/jsMain/kotlin/musicapp/player/MediaPlayerController.js.kt: -------------------------------------------------------------------------------- 1 | package musicapp.player 2 | 3 | import kotlinx.browser.document 4 | import musicapp.utils.PlatformContext 5 | import org.w3c.dom.HTMLAudioElement 6 | 7 | actual class MediaPlayerController actual constructor(val platformContext: PlatformContext) { 8 | private val audioElement = document.createElement("audio") as HTMLAudioElement 9 | private var listener: MediaPlayerListener? = null 10 | private var currentTrack: TrackItem? = null 11 | 12 | private var trackList: List = emptyList() 13 | private var currentTrackIndex: Int = -1 14 | 15 | actual fun prepare( 16 | mediaItem: TrackItem, 17 | listener: MediaPlayerListener 18 | ) { 19 | this.listener = listener 20 | this.currentTrack = mediaItem 21 | 22 | if (trackList.isNotEmpty()) { 23 | val index = trackList.indexOfFirst { it.id == mediaItem.id } 24 | if (index >= 0) { 25 | currentTrackIndex = index 26 | } 27 | } 28 | 29 | listener.onBufferingStateChanged(true) 30 | 31 | audioElement.src = mediaItem.pathSource 32 | audioElement.addEventListener("canplaythrough", { 33 | // Audio is ready to play without interruption 34 | listener.onBufferingStateChanged(false) 35 | listener.onReady() 36 | audioElement.play() 37 | listener.onPlaybackStateChanged(true) 38 | }) 39 | 40 | audioElement.onended = { 41 | val nextTrackPlayed = playNextTrack() 42 | if (!nextTrackPlayed) { 43 | listener.onAudioCompleted() 44 | } 45 | } 46 | audioElement.addEventListener("error", { 47 | listener.onError() 48 | }) 49 | 50 | } 51 | 52 | actual fun start() { 53 | audioElement.play() 54 | listener?.onPlaybackStateChanged(true) 55 | } 56 | 57 | actual fun pause() { 58 | audioElement.pause() 59 | listener?.onPlaybackStateChanged(false) 60 | } 61 | 62 | actual fun seekTo(seconds: Long) { 63 | audioElement.currentTime = seconds / 1000.0 64 | } 65 | 66 | actual fun getCurrentPosition(): Long? { 67 | return (audioElement.currentTime * 1000).toLong() 68 | } 69 | 70 | actual fun getDuration(): Long? { 71 | return (audioElement.duration * 1000).toLong() 72 | } 73 | 74 | actual fun isPlaying(): Boolean { 75 | return !audioElement.paused 76 | } 77 | 78 | actual fun setTrackList(trackList: List, currentTrackId: String) { 79 | this.trackList = trackList 80 | this.currentTrackIndex = trackList.indexOfFirst { it.id == currentTrackId }.takeIf { it >= 0 } ?: 0 81 | 82 | } 83 | 84 | actual fun playNextTrack(): Boolean { 85 | if (trackList.isEmpty() || currentTrackIndex < 0) { 86 | return false 87 | } 88 | 89 | val nextIndex = currentTrackIndex + 1 90 | if (nextIndex >= trackList.size) { 91 | return false 92 | } 93 | 94 | currentTrackIndex = nextIndex 95 | val nextTrack = trackList[nextIndex] 96 | 97 | listener?.onTrackChanged(nextTrack.id) 98 | 99 | prepare(nextTrack, listener ?: return false) 100 | return true 101 | } 102 | 103 | actual fun playPreviousTrack(): Boolean { 104 | if (trackList.isEmpty() || currentTrackIndex <= 0) { 105 | return false 106 | } 107 | 108 | val previousIndex = currentTrackIndex - 1 109 | currentTrackIndex = previousIndex 110 | val previousTrack = trackList[previousIndex] 111 | 112 | listener?.onTrackChanged(previousTrack.id) 113 | 114 | prepare(previousTrack, listener ?: return false) 115 | return true 116 | } 117 | 118 | actual fun getCurrentTrack(): TrackItem? { 119 | currentTrack?.let { return it } 120 | 121 | if (trackList.isEmpty() || currentTrackIndex < 0 || currentTrackIndex >= trackList.size) { 122 | return null 123 | } 124 | return trackList[currentTrackIndex] 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /shared/src/jsMain/kotlin/musicapp/utils/PlatformContext.js.kt: -------------------------------------------------------------------------------- 1 | package musicapp.utils 2 | 3 | @Suppress(names = ["EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING"]) 4 | actual class PlatformContext 5 | 6 | actual fun getPlatformContext(): PlatformContext { 7 | return PlatformContext() 8 | } -------------------------------------------------------------------------------- /webApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.multiplatform) 3 | alias(libs.plugins.jetbrains.compose) 4 | alias(libs.plugins.compose.compiler) 5 | } 6 | 7 | 8 | kotlin { 9 | js(IR) { 10 | moduleName = "webApp" 11 | browser { 12 | commonWebpackConfig { 13 | outputFileName = "webApp.js" 14 | } 15 | } 16 | binaries.executable() 17 | } 18 | 19 | sourceSets { 20 | val jsMain by getting { 21 | dependencies { 22 | implementation(project(":shared")) 23 | implementation(compose.runtime) 24 | implementation(compose.ui) 25 | implementation(compose.foundation) 26 | implementation(compose.material) 27 | implementation(libs.ktor.client.js) 28 | implementation(libs.bundles.ktor) 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /webApp/src/jsMain/kotlin/WebApp.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.ui.ExperimentalComposeUiApi 2 | import androidx.compose.ui.window.CanvasBasedWindow 3 | import com.arkivanov.decompose.DefaultComponentContext 4 | import com.arkivanov.essenty.lifecycle.LifecycleRegistry 5 | import com.arkivanov.essenty.lifecycle.resume 6 | import musicapp.decompose.MusicRootImpl 7 | import musicapp.network.SpotifyApiImpl 8 | import musicapp.player.MediaPlayerController 9 | import musicapp.utils.PlatformContext 10 | import org.jetbrains.skiko.wasm.onWasmReady 11 | 12 | @OptIn(ExperimentalComposeUiApi::class) 13 | fun main() { 14 | onWasmReady { 15 | val lifecycle = LifecycleRegistry() 16 | val rootComponent = 17 | MusicRootImpl( 18 | componentContext = DefaultComponentContext( 19 | lifecycle = lifecycle, 20 | ), api = SpotifyApiImpl(), mediaPlayerController = MediaPlayerController( 21 | PlatformContext() 22 | ) 23 | ) 24 | 25 | lifecycle.resume() 26 | CanvasBasedWindow("MusicApp-KMP") { 27 | CommonMainWeb(rootComponent) 28 | } 29 | } 30 | } 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /webApp/src/jsMain/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Music-App KMP 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /webApp/webpack.config.d/fs.js: -------------------------------------------------------------------------------- 1 | config.resolve = { 2 | fallback: { 3 | // path: require.resolve("path-browserify"), 4 | // os: require.resolve("os-browserify/browser") 5 | } 6 | }; --------------------------------------------------------------------------------