├── .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 | 
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 | };
--------------------------------------------------------------------------------