├── .gitignore
├── .idea
├── .gitignore
├── compiler.xml
├── deploymentTargetDropDown.xml
├── gradle.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── kotlinc.xml
├── migrations.xml
├── misc.xml
└── vcs.xml
├── Github
└── Images
│ ├── AlbumScreen.png
│ ├── ChoraBannerTransparent.png
│ ├── HomeScreen.png
│ ├── Now-Playing-PlainLyrics.png
│ ├── Now-Playing-Screen.png
│ ├── Now-Playing-SyncedLyrics.png
│ ├── PlaylistDetails.png
│ ├── PlaylistScreen.png
│ ├── RadioScreen.png
│ └── SettingScreen.png
├── LICENSE
├── README.md
├── app
├── .gitignore
├── build.gradle.kts
├── proguard-rules.pro
└── src
│ └── main
│ ├── AndroidManifest.xml
│ ├── java
│ └── com
│ │ └── craftworks
│ │ └── music
│ │ ├── MainActivity.kt
│ │ ├── NavGraph.kt
│ │ ├── data
│ │ ├── Album.kt
│ │ ├── Artist.kt
│ │ ├── Lyric.kt
│ │ ├── MediaData.kt
│ │ ├── MediaNavidromeProvider.kt
│ │ ├── NavigationItems.kt
│ │ ├── Playlist.kt
│ │ ├── Radio.kt
│ │ ├── Screen.kt
│ │ └── Song.kt
│ │ ├── lyrics
│ │ ├── GetLRCLIBLyrics.kt
│ │ └── LyricsManager.kt
│ │ ├── managers
│ │ ├── LocalProviderManager.kt
│ │ ├── NavidromeManager.kt
│ │ └── SettingsManager.kt
│ │ ├── player
│ │ ├── MediaController.kt
│ │ ├── MusicService.kt
│ │ └── SongHelper.kt
│ │ ├── providers
│ │ ├── Common.kt
│ │ ├── local
│ │ │ ├── LocalPlaylistImage.kt
│ │ │ └── LocalProvider.kt
│ │ └── navidrome
│ │ │ ├── DownloadNavidromeSongs.kt
│ │ │ ├── GetNavidromeAlbums.kt
│ │ │ ├── GetNavidromeArtists.kt
│ │ │ ├── GetNavidromeFavourites.kt
│ │ │ ├── GetNavidromeLyrics.kt
│ │ │ ├── GetNavidromePlaylists.kt
│ │ │ ├── GetNavidromeRadios.kt
│ │ │ ├── GetNavidromeSongs.kt
│ │ │ ├── GetNavidromeStatus.kt
│ │ │ ├── MarkNavidromeAsPlayed.kt
│ │ │ ├── NavidromeCache.kt
│ │ │ ├── NavidromeConnection.kt
│ │ │ └── SetNavidromeStarred.kt
│ │ └── ui
│ │ ├── elements
│ │ ├── AlbumCard.kt
│ │ ├── ArtistCard.kt
│ │ ├── ButtonAnimation.kt
│ │ ├── ConnectionErrorDialog.kt
│ │ ├── GenrePill.kt
│ │ ├── PlaylistCard.kt
│ │ ├── ProviderCard.kt
│ │ ├── RadioCard.kt
│ │ ├── SongCard.kt
│ │ ├── SongLazyLists.kt
│ │ ├── VerticalText.kt
│ │ └── dialogs
│ │ │ ├── AppearanceDialogs.kt
│ │ │ ├── PlaybackDialogs.kt
│ │ │ ├── PlaylistDialogs.kt
│ │ │ ├── ProviderDialogs.kt
│ │ │ ├── RadioDialogs.kt
│ │ │ ├── SortDialogs.kt
│ │ │ └── dialogFocusable.kt
│ │ ├── playing
│ │ ├── NowPlaying.kt
│ │ ├── NowPlayingBackground.kt
│ │ ├── NowPlayingElements.kt
│ │ ├── NowPlayingLandscape.kt
│ │ ├── NowPlayingLyrics.kt
│ │ ├── NowPlayingMiniPlayer.kt
│ │ └── NowPlayingPortrait.kt
│ │ ├── screens
│ │ ├── AlbumDetailsScreen.kt
│ │ ├── AlbumScreen.kt
│ │ ├── ArtistDetailsScreen.kt
│ │ ├── ArtistsScreen.kt
│ │ ├── HomeScreen.kt
│ │ ├── PlaylistDetailsScreen.kt
│ │ ├── PlaylistScreen.kt
│ │ ├── RadioScreen.kt
│ │ ├── SettingScreen.kt
│ │ ├── SongsScreen.kt
│ │ └── settings
│ │ │ ├── SettingsAppearance.kt
│ │ │ ├── SettingsPlayback.kt
│ │ │ └── SettingsProviders.kt
│ │ ├── theme
│ │ ├── Color.kt
│ │ ├── Theme.kt
│ │ └── Type.kt
│ │ └── viewmodels
│ │ ├── AlbumScreenViewModel.kt
│ │ ├── ArtistsScreenViewModel.kt
│ │ ├── GlobalViewModels.kt
│ │ ├── HomeScreenViewModel.kt
│ │ ├── PlaylistScreenViewModel.kt
│ │ └── SongsScreenViewModel.kt
│ └── res
│ ├── drawable-hdpi
│ └── ic_notification_icon.png
│ ├── drawable-mdpi
│ └── ic_notification_icon.png
│ ├── drawable-v34
│ └── favourites.xml
│ ├── drawable-xhdpi
│ └── ic_notification_icon.png
│ ├── drawable-xxhdpi
│ └── ic_notification_icon.png
│ ├── drawable-xxxhdpi
│ └── ic_notification_icon.png
│ ├── drawable
│ ├── albumplaceholder.png
│ ├── baseline_drag_handle_24.xml
│ ├── chevron_down.xml
│ ├── favourites.xml
│ ├── ic_banner_background.xml
│ ├── ic_banner_foreground.xml
│ ├── lrclib_logo.xml
│ ├── lyrics_active.xml
│ ├── lyrics_inactive.xml
│ ├── media3_notification_pause.xml
│ ├── media3_notification_seek_to_next.xml
│ ├── media3_notification_seek_to_previous.xml
│ ├── media3_notification_small_icon.xml
│ ├── placeholder.xml
│ ├── radioplaceholder.png
│ ├── round_favorite_24.xml
│ ├── round_favorite_border_24.xml
│ ├── round_music_note_24.xml
│ ├── round_power_settings_new_24.xml
│ ├── round_shuffle_28.xml
│ ├── round_visibility_24.xml
│ ├── round_visibility_off_24.xml
│ ├── rounded_add_24.xml
│ ├── rounded_artist_24.xml
│ ├── rounded_cell_tower_24.xml
│ ├── rounded_download_24.xml
│ ├── rounded_home_24.xml
│ ├── rounded_library_music_24.xml
│ ├── rounded_radio.xml
│ ├── rounded_repeat1_24.xml
│ ├── rounded_repeat_24.xml
│ ├── rounded_settings_24.xml
│ ├── s_a_moreinfo.xml
│ ├── s_a_navbar_items.xml
│ ├── s_a_palette.xml
│ ├── s_a_username.xml
│ ├── s_m_local_filled.xml
│ ├── s_m_media_providers.xml
│ ├── s_m_navidrome.xml
│ ├── s_m_playback.xml
│ ├── s_p_scrobble.xml
│ └── s_p_transcoding.xml
│ ├── mipmap-anydpi-v26
│ ├── ic_banner.xml
│ └── ic_launcher.xml
│ ├── mipmap-hdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── mipmap-mdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── mipmap-xhdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── mipmap-xxhdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── mipmap-xxxhdpi
│ ├── ic_launcher.png
│ ├── ic_launcher_background.png
│ ├── ic_launcher_foreground.png
│ └── ic_launcher_monochrome.png
│ ├── resources.properties
│ ├── values-fr
│ └── strings.xml
│ ├── values-it
│ └── strings.xml
│ ├── values
│ ├── colors.xml
│ ├── strings.xml
│ ├── strings_actions.xml
│ ├── strings_dialogs.xml
│ ├── strings_screens.xml
│ ├── strings_settings.xml
│ └── themes.xml
│ └── xml
│ ├── automotive_app_desc.xml
│ ├── backup_rules.xml
│ └── data_extraction_rules.xml
├── build.gradle.kts
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
/.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 | local.properties
17 | /app/release/*
18 | /.kotlin/*
19 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/deploymentTargetDropDown.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/Github/Images/AlbumScreen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/AlbumScreen.png
--------------------------------------------------------------------------------
/Github/Images/ChoraBannerTransparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/ChoraBannerTransparent.png
--------------------------------------------------------------------------------
/Github/Images/HomeScreen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/HomeScreen.png
--------------------------------------------------------------------------------
/Github/Images/Now-Playing-PlainLyrics.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/Now-Playing-PlainLyrics.png
--------------------------------------------------------------------------------
/Github/Images/Now-Playing-Screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/Now-Playing-Screen.png
--------------------------------------------------------------------------------
/Github/Images/Now-Playing-SyncedLyrics.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/Now-Playing-SyncedLyrics.png
--------------------------------------------------------------------------------
/Github/Images/PlaylistDetails.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/PlaylistDetails.png
--------------------------------------------------------------------------------
/Github/Images/PlaylistScreen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/PlaylistScreen.png
--------------------------------------------------------------------------------
/Github/Images/RadioScreen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/RadioScreen.png
--------------------------------------------------------------------------------
/Github/Images/SettingScreen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/Github/Images/SettingScreen.png
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | A simple and light-weight app that streams music from a Subsonic or Navidrome server, or from the phone's storage.
4 |
5 | *Please do not use as a learning resource. This was my first Kotlin project, and the code is not well-organized at all.*
6 |
7 |
8 |
9 | ## Features
10 |
11 | - Subsonic/Navidrome support.
12 | - Transcoding.
13 | - Material 3 UI.
14 | - Offline Mode [Download songs from server].
15 | - Internet Radio. [Metadata IceCast only]
16 | - Synced And Unsynced Lyrics. [From lrclib.net]
17 | - Navidrome and Local playlists.
18 | - Android Auto.
19 |
20 | ## W.I.P
21 |
22 | - Plain lyrics auto-scrolling.
23 |
24 | ## Known Issues
25 |
26 | - After changing some settings, all the data is cleared from screens and need to be manually refreshed.
27 |
28 | ## Roadmap
29 |
30 | - Jellyfin (Music) Support.
31 |
32 | ## Screenshots
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | ## Support the project
46 |
47 | To help keep this project free and open source to everyone, consider donating. Thank you!
48 |
49 |
50 |
51 |
52 | Made with :heart: in italy
53 |
54 | > Lyrics icon provided by [Remix Icon](https://remixicon.com/ "Remix Icon")
55 | > Other icons are provided by [Google Icons](https://fonts.google.com/icons "Google Icons")
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | id("org.jetbrains.kotlin.android")
4 | kotlin("plugin.serialization") version "2.0.0"
5 | alias(libs.plugins.compose.compiler)
6 | }
7 |
8 | android {
9 | namespace = "com.craftworks.music"
10 | compileSdk = 35
11 |
12 | androidResources {
13 | generateLocaleConfig = true
14 | }
15 |
16 | defaultConfig {
17 | applicationId = "com.craftworks.music"
18 | minSdk = 23
19 | targetSdk = 35
20 | versionCode = 271
21 | versionName = "1.27.1"
22 |
23 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
24 | vectorDrawables {
25 | useSupportLibrary = true
26 | }
27 | }
28 |
29 | buildTypes {
30 | release {
31 | isMinifyEnabled = true
32 | isShrinkResources = true
33 | proguardFiles(
34 | getDefaultProguardFile("proguard-android-optimize.txt"),
35 | "proguard-rules.pro"
36 | )
37 | signingConfig = signingConfigs.getByName("debug")
38 | }
39 | debug {
40 | isDebuggable = true
41 | isProfileable = true
42 | }
43 | }
44 | compileOptions {
45 | sourceCompatibility = JavaVersion.VERSION_11
46 | targetCompatibility = JavaVersion.VERSION_11
47 | }
48 | kotlinOptions {
49 | jvmTarget = "11"
50 | }
51 | buildFeatures {
52 | compose = true
53 | }
54 | composeOptions {
55 | kotlinCompilerExtensionVersion = "1.5.13"
56 | }
57 | packaging {
58 | resources {
59 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
60 | }
61 | }
62 | }
63 |
64 | dependencies {
65 |
66 | implementation(libs.androidx.core.ktx)
67 | implementation(libs.androidx.lifecycle.runtime.ktx)
68 | implementation(libs.androidx.activity.compose)
69 | implementation(platform(libs.androidx.compose.bom))
70 | implementation(libs.androidx.ui)
71 | implementation(libs.androidx.ui.graphics)
72 | implementation(libs.androidx.ui.tooling.preview)
73 | implementation(libs.androidx.material3)
74 |
75 | implementation(libs.androidx.navigation.compose)
76 |
77 | implementation(libs.androidx.lifecycle.runtime.ktx)
78 |
79 | implementation(libs.reorderable)
80 | implementation(libs.androidx.media)
81 |
82 | implementation(libs.konsume.xml)
83 | implementation(libs.kotlinx.serialization.json)
84 |
85 | implementation(libs.coil.compose)
86 | implementation(libs.androidx.preference.ktx)
87 | implementation(libs.androidx.palette.ktx)
88 | implementation(libs.androidx.media3.exoplayer)
89 | implementation(libs.androidx.media3.session)
90 | implementation(libs.androidx.media3.ui)
91 | implementation(libs.androidx.mediarouter)
92 | implementation(libs.androidx.material3.android)
93 | implementation(libs.androidx.datastore.preferences)
94 |
95 | implementation(libs.composefadingedges)
96 |
97 | testImplementation(libs.junit)
98 | androidTestImplementation(libs.androidx.junit)
99 | androidTestImplementation(libs.androidx.espresso.core)
100 | androidTestImplementation(libs.androidx.ui.test.junit4)
101 | debugImplementation(libs.androidx.ui.tooling)
102 | debugImplementation(libs.androidx.ui.test.manifest)
103 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 | -dontwarn javax.xml.stream.Location
23 | -dontwarn javax.xml.stream.XMLInputFactory
24 | -dontwarn javax.xml.stream.XMLStreamReader
25 |
26 | -keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
27 | ;
28 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
18 |
21 |
24 |
25 |
41 |
42 |
44 |
45 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/Album.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import android.os.Bundle
4 | import androidx.compose.runtime.mutableStateListOf
5 | import androidx.core.net.toUri
6 | import androidx.media3.common.MediaItem
7 | import androidx.media3.common.MediaMetadata
8 |
9 | var albumList:MutableList = mutableStateListOf()
10 |
11 | fun MediaData.Album.toMediaItem(): MediaItem {
12 | val mediaMetadata = MediaMetadata.Builder()
13 | .setTitle(this@toMediaItem.name)
14 | .setArtist(this@toMediaItem.artist)
15 | .setAlbumTitle(this@toMediaItem.name)
16 | .setDisplayTitle(this@toMediaItem.name)
17 | .setAlbumArtist(this@toMediaItem.artist)
18 | .setArtworkUri(this@toMediaItem.coverArt?.toUri())
19 | .setRecordingYear(this@toMediaItem.year)
20 | .setDurationMs(this@toMediaItem.duration.times(1000).toLong())
21 | .setIsBrowsable(true)
22 | .setIsPlayable(false)
23 | .setGenre(this@toMediaItem.genres?.joinToString() { it.name ?: "" })
24 | .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
25 | .setExtras(
26 | Bundle().apply {
27 | putString("navidromeID", this@toMediaItem.navidromeID)
28 | }
29 | )
30 | .build()
31 |
32 | return MediaItem.Builder()
33 | .setMediaId(
34 | if (this@toMediaItem.navidromeID.startsWith("Local_"))
35 | "folder_album_" + this@toMediaItem.navidromeID
36 | else
37 | this@toMediaItem.navidromeID
38 | )
39 | .setMediaMetadata(mediaMetadata)
40 | .build()
41 | }
42 |
43 | fun MediaItem.toAlbum(): MediaData.Album {
44 | val mediaMetadata = this.mediaMetadata
45 | val extras = mediaMetadata.extras
46 |
47 | return MediaData.Album(
48 | navidromeID = extras?.getString("navidromeID") ?: "",
49 | name = mediaMetadata.albumTitle.toString(),
50 | artist = mediaMetadata.artist.toString(),
51 | year = mediaMetadata.releaseYear ?: 0,
52 | coverArt = mediaMetadata.artworkUri.toString(),
53 | duration = extras?.getInt("Duration") ?: 0,
54 | songs = mutableListOf(),
55 | songCount = 0,
56 | artistId = ""
57 | )
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/Artist.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateListOf
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.compose.runtime.setValue
7 |
8 | var artistList: MutableList = mutableStateListOf()
9 |
10 | var selectedArtist by mutableStateOf(
11 | MediaData.Artist(
12 | name = "My Favourite Artist",
13 | artistImageUrl = "",
14 | navidromeID = "Local"
15 | )
16 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/Lyric.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import android.util.Log
4 | import androidx.compose.runtime.Stable
5 | import kotlinx.serialization.Serializable
6 |
7 | // Universal Lyric object
8 | @Stable
9 | data class Lyric(
10 | val timestamp: Int,
11 | val content: String
12 | )
13 |
14 |
15 | // LRCLIB Lyrics
16 | @Serializable
17 | data class LrcLibLyrics(
18 | val id: Int,
19 | val instrumental: Boolean,
20 | val plainLyrics: String? = "",
21 | val syncedLyrics: String? = ""
22 | )
23 |
24 | //region Convert proprietary lyric format to app format.
25 |
26 | fun MediaData.PlainLyrics.toLyric(): Lyric {
27 | return Lyric(
28 | timestamp = -1,
29 | content = value
30 | )
31 | }
32 |
33 | fun MediaData.StructuredLyrics.toLyrics(): List {
34 | return line.map { syncedLyric ->
35 | Lyric(
36 | // If not synced lyrics, set timestamp to -1
37 | timestamp = if (synced) syncedLyric.start + (offset ?: 0) else -1,
38 | content = syncedLyric.value
39 | )
40 | }
41 | }
42 |
43 | fun LrcLibLyrics.toLyrics(): List {
44 | if (instrumental) return listOf()
45 |
46 | else if (syncedLyrics.toString() != "null") {
47 | val result = mutableListOf()
48 |
49 | syncedLyrics?.lines()?.forEach { lyric ->
50 | val timeStampsRaw = getTimeStamps(lyric)[0]
51 | val time = mmssToMilliseconds(timeStampsRaw)
52 | val lyricText: String = lyric.drop(11)
53 |
54 | result.add(Lyric(time.toInt(), lyricText))
55 | }
56 |
57 | Log.d("LYRICS", "Got LRCLIB synced lyrics: $result")
58 | return result
59 | }
60 | else if (plainLyrics.toString() != "null") {
61 | Log.d("LYRICS", "Got LRCLIB plain lyrics: $plainLyrics")
62 | return listOf(Lyric(-1, plainLyrics.toString()))
63 | }
64 | else
65 | return listOf()
66 | }
67 |
68 | //endregion
69 |
70 | fun mmssToMilliseconds(mmss: String): Long {
71 | val parts = mmss.split(":", ".")
72 | if (parts.size == 3) {
73 | try {
74 | val minutes = parts[0].toLong()
75 | val seconds = parts[1].toLong()
76 | val ms = parts[2].toLong()
77 | return (minutes * 60 + seconds) * 1000 + ms * 10
78 | } catch (e: NumberFormatException) {
79 | // Handle the case where the input is not in the expected format
80 | e.printStackTrace()
81 | }
82 | }
83 | return 0L
84 | }
85 |
86 | fun getTimeStamps(input: String): List {
87 | val regex = Regex("\\[(.*?)]")
88 | val matches = regex.findAll(input)
89 |
90 | val result = mutableListOf()
91 | for (match in matches) {
92 | result.add(match.groupValues[1])
93 | }
94 |
95 | return result
96 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/MediaData.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import com.craftworks.music.providers.navidrome.SyncedLyrics
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 |
7 | sealed class MediaData {
8 | @Serializable
9 | data class Song(
10 | @SerialName("id")
11 | val navidromeID: String,
12 | val parent: String,
13 | val isDir: Boolean? = false,
14 | val title: String,
15 | val album: String,
16 | val artist: String,
17 | val track: Int? = 0,
18 | val year: Int? = 0,
19 | val genre: String? = "",
20 | @SerialName("coverArt")
21 | var imageUrl: String,
22 | val size: Int? = 0,
23 | val contentType: String? = "audio/flac",
24 | @SerialName("suffix")
25 | val format: String,
26 | val duration: Int = 0,
27 | @SerialName("bitRate")
28 | val bitrate: Int? = 0,
29 | val path: String,
30 | @SerialName("playCount")
31 | var timesPlayed: Int? = 0,
32 | val discNumber: Int? = 0,
33 | @SerialName("created")
34 | val dateAdded: String,
35 | val albumId: String,
36 | val artistId: String? = "",
37 | val type: String? = "music",
38 | val isVideo: Boolean? = false,
39 | @SerialName("played")
40 | val lastPlayed: String? = "",
41 | val bpm: Int,
42 | val comment: String? = "",
43 | val sortName: String? = "",
44 | val mediaType: String? = "song",
45 | val musicBrainzId: String? = "",
46 | val genres: List? = listOf(),
47 | val replayGain: ReplayGain? = null,
48 | val channelCount: Int? = 2,
49 | val samplingRate: Int? = 0,
50 |
51 | val isRadio: Boolean? = false,
52 | var media: String? = null,
53 | val trackIndex: Int? = 0,
54 | var starred: String? = null,
55 | ) : MediaData()
56 |
57 | @Serializable
58 | data class Album(
59 | @SerialName("id")
60 | val navidromeID : String,
61 | val parent : String? = "",
62 |
63 | val album : String? = "",
64 | val title : String? = "",
65 | val name : String? = "",
66 |
67 | val isDir : Boolean? = false,
68 | var coverArt : String?,
69 | val songCount : Int,
70 |
71 | val played : String? = "",
72 | val created : String? = "",
73 | val duration : Int,
74 | val playCount : Int? = 0,
75 |
76 | val artistId : String?,
77 | val artist : String,
78 | val year : Int? = 0,
79 | val genre : String? = "",
80 | val genres : List? = listOf(),
81 |
82 | val starred: String? = null,
83 |
84 | @SerialName("song")
85 | var songs: List? = listOf()
86 | ) : MediaData()
87 |
88 | @Serializable
89 | data class Artist(
90 | @SerialName("id")
91 | var navidromeID : String,
92 | val name : String,
93 | //val coverArt : String? = "",
94 | val artistImageUrl : String? = null,
95 | val albumCount : Int? = 0,
96 | var description : String = "",
97 | var starred : String? = "",
98 | var musicBrainzId : String? = "",
99 | // val sortName: String? = "",
100 | var similarArtist : List? = null,
101 | var album : List? = null
102 | ) : MediaData()
103 |
104 | @Serializable
105 | data class ArtistInfo(
106 | val biography : String? = "",
107 | val musicBrainzId : String? = "",
108 | val lastFmUrl : String? = "",
109 | val similarArtist : List? = null
110 | )
111 |
112 | @Serializable
113 | data class Radio(
114 | @SerialName("id")
115 | val navidromeID: String,
116 | val name: String,
117 | @SerialName("streamUrl")
118 | val media: String,
119 | val homePageUrl: String? = "",
120 | ) : MediaData()
121 |
122 | @Serializable
123 | data class Playlist(
124 | @SerialName("id")
125 | val navidromeID: String,
126 | val name: String,
127 | val comment: String? = "",
128 | val owner: String? = "",
129 | val public: Boolean? = true,
130 | val created: String,
131 | val changed: String,
132 | val songCount: Int,
133 | val duration: Int,
134 | var coverArt: String? = "",
135 | @SerialName("entry")
136 | var songs: List? = listOf()
137 | ) : MediaData()
138 |
139 | @Serializable
140 | data class PlainLyrics(
141 | val value: String,
142 | val artist: String? = "",
143 | val title: String? = ""
144 | ) : MediaData()
145 |
146 | @Serializable
147 | data class StructuredLyrics(
148 | val displayArtist: String? = "",
149 | val displayTitle: String? = "",
150 | val lang: String,
151 | val offset: Int? = 0,
152 | val synced: Boolean,
153 | val line: List
154 | ) : MediaData()
155 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/MediaNavidromeProvider.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | data class NavidromeProvider (
7 | val id: String = "0",
8 | var url:String,
9 | var username:String,
10 | val password:String,
11 | val enabled:Boolean? = true,
12 | var allowSelfSignedCert: Boolean? = false
13 | )
14 |
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/NavigationItems.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import androidx.compose.runtime.Stable
4 | import kotlinx.serialization.Serializable
5 |
6 | @Stable
7 | @Serializable
8 | data class BottomNavItem(
9 | var title: String,
10 | var icon: Int,
11 | val screenRoute: String,
12 | var enabled: Boolean = true
13 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/Playlist.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import android.net.Uri
4 | import android.os.Bundle
5 | import androidx.annotation.OptIn
6 | import androidx.compose.runtime.mutableStateListOf
7 | import androidx.core.net.toUri
8 | import androidx.media3.common.MediaItem
9 | import androidx.media3.common.MediaMetadata
10 | import androidx.media3.common.util.UnstableApi
11 |
12 | var playlistList:MutableList = mutableStateListOf()
13 |
14 | data class Playlist (
15 | val name: String,
16 | var coverArt: Uri,
17 | var songs: List = emptyList(),
18 | val navidromeID: String? = ""
19 | )
20 |
21 | @OptIn(UnstableApi::class)
22 | fun MediaData.Playlist.toMediaItem(): MediaItem {
23 | val mediaMetadata = MediaMetadata.Builder()
24 | .setTitle(this@toMediaItem.name)
25 | .setDescription(this@toMediaItem.comment)
26 | .setArtworkUri(this@toMediaItem.coverArt?.toUri())
27 | .setArtworkData(this@toMediaItem.coverArt?.toByteArray(), MediaMetadata.PICTURE_TYPE_OTHER)
28 | .setIsBrowsable(true)
29 | .setIsPlayable(false)
30 | .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
31 | .setDurationMs(this@toMediaItem.duration.toLong())
32 | .setExtras(Bundle().apply {
33 | putString("navidromeID", this@toMediaItem.navidromeID)
34 | })
35 | .build()
36 |
37 | return MediaItem.Builder()
38 | .setMediaId(this@toMediaItem.navidromeID.toString())
39 | .setMediaMetadata(mediaMetadata)
40 | .build()
41 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/Radio.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import android.os.Bundle
4 | import androidx.compose.runtime.mutableStateListOf
5 | import androidx.compose.runtime.snapshots.SnapshotStateList
6 | import androidx.core.net.toUri
7 | import androidx.media3.common.MediaItem
8 | import androidx.media3.common.MediaMetadata
9 | import com.craftworks.music.R
10 |
11 | var radioList:SnapshotStateList = mutableStateListOf()
12 |
13 | fun MediaData.Radio.toMediaItem(): MediaItem {
14 | val mediaMetadata =
15 | MediaMetadata.Builder()
16 | .setStation(this@toMediaItem.name)
17 | .setArtist(this@toMediaItem.name)
18 | .setArtworkUri(
19 | ("android.resource://com.craftworks.music/" + R.drawable.radioplaceholder).toUri()
20 | )
21 | .setIsPlayable(true)
22 | .setIsBrowsable(false)
23 | .setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
24 | .setExtras(Bundle().apply {
25 | putString("navidromeID", this@toMediaItem.navidromeID)
26 | }).build()
27 |
28 | return MediaItem.Builder()
29 | .setMediaId(this@toMediaItem.media.toString())
30 | .setUri(this@toMediaItem.media)
31 | .setMediaMetadata(mediaMetadata)
32 | .build()
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/Screen.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | sealed class Screen(val route: String) {
4 | data object Home : Screen("home_screen")
5 | data object Song : Screen("songs_screen")
6 | data object Radio : Screen("radio_screen")
7 |
8 | data object NowPlayingLandscape : Screen("playing_tv_screen")
9 |
10 | //Albums
11 | data object Albums : Screen("album_screen")
12 | data object AlbumDetails : Screen("album_details")
13 |
14 | //Artists
15 | data object Artists : Screen("artists_screen")
16 | data object ArtistDetails : Screen("artist_details")
17 |
18 | //Playlists
19 | data object Playlists : Screen("playlist_screen")
20 | data object PlaylistDetails : Screen("playlist_details")
21 |
22 | //Settings
23 | data object Setting : Screen("setting_screen")
24 | data object S_Appearance : Screen("s_appearance_screen")
25 | data object S_Providers : Screen("s_providers_screen")
26 | data object S_Playback : Screen("s_playback_screen")
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/data/Song.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.data
2 |
3 | import android.os.Bundle
4 | import androidx.compose.runtime.Immutable
5 | import androidx.compose.runtime.mutableStateListOf
6 | import androidx.core.net.toUri
7 | import androidx.media3.common.MediaItem
8 | import androidx.media3.common.MediaMetadata
9 | import kotlinx.serialization.Serializable
10 |
11 | var songsList: MutableList = mutableStateListOf()
12 |
13 | @Immutable
14 | @Serializable
15 | data class Genre(
16 | val name: String? = ""
17 | )
18 |
19 | @Immutable
20 | @Serializable
21 | data class ReplayGain(
22 | val trackGain: Float? = 0f,
23 | //val trackPeak: Float? = 0f,
24 | //val albumPeak: Float? = 0f
25 | )
26 |
27 | fun MediaData.Song.toMediaItem(): MediaItem {
28 | val mediaMetadata =
29 | MediaMetadata.Builder()
30 | .setTitle(this@toMediaItem.title)
31 | .setArtist(this@toMediaItem.artist)
32 | .setAlbumTitle(this@toMediaItem.album)
33 | .setArtworkUri(this@toMediaItem.imageUrl.toUri())
34 | .setRecordingYear(this@toMediaItem.year) // Recording Year is kept, releaseYear gets set to 'null' by ExoPlayer.
35 | .setIsBrowsable(false).setIsPlayable(true)
36 | .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
37 | .setDurationMs(this@toMediaItem.duration.times(1000).toLong())
38 | .setGenre(this@toMediaItem.genres?.joinToString() { it.name ?: "" })
39 | .setExtras(Bundle().apply {
40 | putString("navidromeID", this@toMediaItem.navidromeID)
41 | putInt("duration", this@toMediaItem.duration)
42 | putString("format", this@toMediaItem.format)
43 | putLong("bitrate", this@toMediaItem.bitrate?.toLong() ?: 0)
44 | putBoolean("isRadio", this@toMediaItem.isRadio == true)
45 | if (this@toMediaItem.replayGain?.trackGain != null)
46 | putFloat("replayGain", this@toMediaItem.replayGain.trackGain)
47 | }).build()
48 |
49 | return MediaItem.Builder()
50 | .setMediaId(this@toMediaItem.media.toString())
51 | .setUri(this@toMediaItem.media?.toUri())
52 | .setMediaMetadata(mediaMetadata)
53 | .build()
54 | }
55 |
56 | fun MediaItem.toSong(): MediaData.Song {
57 | val mediaMetadata = this@toSong.mediaMetadata
58 | val extras = mediaMetadata.extras
59 |
60 | return MediaData.Song(
61 | navidromeID = extras?.getString("navidromeID") ?: "",
62 | title = mediaMetadata.title.toString(),
63 | artist = mediaMetadata.artist.toString(),
64 | album = mediaMetadata.albumTitle.toString(),
65 | imageUrl = mediaMetadata.artworkUri.toString(),
66 | year = mediaMetadata.recordingYear ?: 0,
67 | duration = mediaMetadata.durationMs?.toInt()?.div(1000) ?: 0,
68 | format = extras?.getString("format") ?: "",
69 | bitrate = extras?.getLong("bitrate")?.toInt(),
70 | media = this@toSong.mediaId.toString(),
71 | replayGain = ReplayGain(
72 | trackGain = extras?.getFloat("replayGain") ?: 0f
73 | ),
74 | isRadio = extras?.getBoolean("isRadio"),
75 | path = "",
76 | parent = "",
77 | dateAdded = "",
78 | bpm = 0,
79 | albumId = ""
80 | )
81 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/lyrics/GetLRCLIBLyrics.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.lyrics
2 |
3 | import androidx.media3.common.MediaMetadata
4 | import com.craftworks.music.data.LrcLibLyrics
5 | import com.craftworks.music.data.Lyric
6 | import com.craftworks.music.data.toLyrics
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.withContext
9 | import kotlinx.serialization.json.Json
10 | import java.net.URL
11 | import java.net.URLEncoder
12 | import javax.net.ssl.HttpsURLConnection
13 |
14 | suspend fun getLrcLibLyrics(metadata: MediaMetadata?): List = withContext(Dispatchers.IO) {
15 | val url = URL(
16 | "https://lrclib.net/api/get?" +
17 | "artist_name=${URLEncoder.encode(metadata?.artist.toString(), "UTF-8")}&" +
18 | "track_name=${URLEncoder.encode(metadata?.title.toString(), "UTF-8")}&" +
19 | "album_name=${URLEncoder.encode(metadata?.albumTitle.toString(), "UTF-8")}" +
20 | "&duration=${metadata?.durationMs?.div(1000)?.toInt()}"
21 | )
22 |
23 | val connection = url.openConnection() as HttpsURLConnection
24 |
25 | try {
26 | connection.requestMethod = "GET"
27 |
28 | // Set User-Agent as per LRCLIB documentation: https://lrclib.net/docs
29 | connection.setRequestProperty(
30 | "User-Agent",
31 | "Chora - Navidrome Client (https://github.com/CraftWorksMC/Chora)"
32 | )
33 |
34 | println("Sent 'GET' request to URL : $url; Response Code : ${connection.responseCode}")
35 |
36 | if (connection.responseCode == 404) {
37 | return@withContext emptyList()
38 | }
39 |
40 | connection.inputStream.bufferedReader().use {
41 | val jsonParser = Json { ignoreUnknownKeys = true }
42 | val mediaDataPlainLyrics = jsonParser.decodeFromString(it.readText())
43 | return@withContext mediaDataPlainLyrics.toLyrics()
44 | }
45 | } catch (e: Exception) {
46 | e.printStackTrace()
47 | return@withContext emptyList()
48 | } finally {
49 | connection.disconnect()
50 | }
51 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/lyrics/LyricsManager.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.lyrics
2 |
3 | import android.util.Log
4 | import androidx.compose.runtime.getValue
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.compose.runtime.setValue
7 | import androidx.media3.common.MediaMetadata
8 | import com.craftworks.music.data.Lyric
9 | import com.craftworks.music.managers.NavidromeManager
10 | import com.craftworks.music.providers.navidrome.getNavidromePlainLyrics
11 | import com.craftworks.music.providers.navidrome.getNavidromeSyncedLyrics
12 | import com.craftworks.music.ui.playing.lyricsOpen
13 | import kotlinx.coroutines.flow.MutableStateFlow
14 | import kotlinx.coroutines.flow.StateFlow
15 | import kotlinx.coroutines.flow.asStateFlow
16 |
17 | object LyricsManager {
18 | private val _Lyrics = MutableStateFlow(listOf())
19 | val Lyrics: StateFlow> = _Lyrics.asStateFlow()
20 |
21 | var useLrcLib by mutableStateOf(true)
22 |
23 | suspend fun getLyrics(metadata: MediaMetadata?) {
24 | // Try getting lyrics through navidrome, first synced then plain.
25 | // If that fails, try LRCLIB.net.
26 | // If we turned it off or we cannot find lyrics, then return an empty list
27 |
28 | if (metadata?.mediaType == MediaMetadata.MEDIA_TYPE_RADIO_STATION) {
29 | _Lyrics.value = listOf()
30 | return
31 | }
32 |
33 | var foundNavidromePlainLyrics by mutableStateOf(false)
34 |
35 | if (NavidromeManager.checkActiveServers()) {
36 | getNavidromeSyncedLyrics(metadata?.extras?.getString("navidromeID") ?: "").takeIf { it.isNotEmpty() }?.let {
37 | if (it.size == 1)
38 | foundNavidromePlainLyrics = true
39 | else {
40 | Log.d("LYRICS", "Got Navidrome synced lyrics.")
41 | _Lyrics.value = it
42 | return
43 | }
44 | }
45 |
46 | getNavidromePlainLyrics(metadata).takeIf { it.isNotEmpty() }?.let {
47 | if (it.size == 1)
48 | foundNavidromePlainLyrics = true
49 |
50 | Log.d("LYRICS", "Got Navidrome plain lyrics.")
51 | _Lyrics.value = it
52 | }
53 | }
54 |
55 | if (useLrcLib) {
56 | if (foundNavidromePlainLyrics) {
57 | Log.d("LYRICS", "Got Navidrome plain lyrics, trying LRCLIB.")
58 | getLrcLibLyrics(metadata).takeIf { it.isNotEmpty() }?.let {
59 | if (it.size != 1) _Lyrics.value = it
60 | return
61 | }
62 | }
63 |
64 | getLrcLibLyrics(metadata).takeIf { it.isNotEmpty() }?.let {
65 | Log.d("LYRICS", "Got LRCLIB lyrics.")
66 | _Lyrics.value = it
67 | return
68 | }
69 | }
70 |
71 | Log.d("LYRICS", "Didn't find any lyrics.")
72 | // Hide lyrics panel if we cannot find lyrics.
73 | lyricsOpen = false
74 | _Lyrics.value = listOf()
75 | }
76 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/managers/LocalProviderManager.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.managers
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import android.util.Log
6 | import com.craftworks.music.managers.NavidromeManager.getAllServers
7 | import com.craftworks.music.providers.local.LocalProvider
8 | import com.craftworks.music.showNoProviderDialog
9 | import kotlinx.serialization.encodeToString
10 | import kotlinx.serialization.json.Json
11 |
12 | object LocalProviderManager {
13 | private val folders = mutableListOf()
14 |
15 | fun addFolder(folder: String) {
16 | Log.d("NAVIDROME", "Added server $folder")
17 | folders.add(folder)
18 | saveFolders()
19 | }
20 |
21 | fun removeFolder(folder: String) {
22 | folders.remove(folder)
23 | saveFolders()
24 | }
25 |
26 | fun checkActiveFolders(): Boolean {
27 | return folders.isNotEmpty()
28 | }
29 |
30 | fun getAllFolders(): List = folders
31 |
32 | // Save and load local folders.
33 | private lateinit var sharedPreferences: SharedPreferences
34 | private val json = Json { ignoreUnknownKeys = true }
35 | private const val PREF_FOLDERS = "local_folders"
36 |
37 | fun init(context: Context) {
38 | sharedPreferences = context.getSharedPreferences("LocalProviderPrefs", Context.MODE_PRIVATE)
39 | loadFolders()
40 |
41 | LocalProvider.getInstance().init(context)
42 |
43 | if (getAllServers().isEmpty() && getAllFolders().isEmpty()) showNoProviderDialog.value = true
44 | }
45 |
46 | private fun saveFolders() {
47 | val serversJson = json.encodeToString(folders as List)
48 | sharedPreferences.edit().putString(PREF_FOLDERS, serversJson).apply()
49 | }
50 |
51 | private fun loadFolders() {
52 | val foldersJson = sharedPreferences.getString(PREF_FOLDERS, null)
53 | if (foldersJson != null) {
54 | val loadedServers: List = json.decodeFromString(foldersJson)
55 | folders.addAll(loadedServers)
56 | }
57 | }
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/managers/NavidromeManager.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.managers
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import android.util.Log
6 | import com.craftworks.music.data.NavidromeProvider
7 | import com.craftworks.music.managers.LocalProviderManager.getAllFolders
8 | import com.craftworks.music.providers.navidrome.navidromeStatus
9 | import com.craftworks.music.showNoProviderDialog
10 | import kotlinx.coroutines.flow.MutableStateFlow
11 | import kotlinx.coroutines.flow.StateFlow
12 | import kotlinx.coroutines.flow.asStateFlow
13 | import kotlinx.serialization.encodeToString
14 | import kotlinx.serialization.json.Json
15 |
16 | object NavidromeManager {
17 | private val servers = mutableMapOf()
18 | private var currentServerId: String? = null
19 |
20 | private val _serverStatus = MutableStateFlow("")
21 | val serverStatus: StateFlow = _serverStatus.asStateFlow()
22 |
23 | private val _syncStatus = MutableStateFlow(false)
24 | val syncStatus: StateFlow = _syncStatus.asStateFlow()
25 |
26 | fun addServer(server: NavidromeProvider) {
27 | Log.d("NAVIDROME", "Added server $server")
28 | servers[server.id] = server
29 | // Set newly added server as current
30 | if (currentServerId == null) {
31 | currentServerId = server.id
32 | }
33 | saveServers()
34 | }
35 |
36 | fun removeServer(id: String) {
37 | servers.remove(id)
38 | // If we remove the current server, set the active one to be the first or null.
39 | if (currentServerId == id) {
40 | currentServerId = servers.keys.firstOrNull()
41 | }
42 | saveServers()
43 | }
44 |
45 | fun setCurrentServer(id: String?) {
46 | // if (id in servers) {
47 | // currentServerId = id
48 | // } else {
49 | // throw IllegalArgumentException("Server with id $id not found")
50 | // }
51 |
52 | currentServerId = id
53 | saveServers()
54 | }
55 |
56 | fun checkActiveServers(): Boolean {
57 | return servers.keys.isNotEmpty() && currentServerId != null
58 | }
59 |
60 | fun getAllServers(): List = servers.values.toList()
61 | fun getCurrentServer(): NavidromeProvider? = currentServerId?.let { servers[it] }
62 |
63 | fun getServerStatus(): String = navidromeStatus.value
64 | //fun setServerStatus(status: String) { _serverStatus.value = status }
65 |
66 | fun setSyncingStatus(status: Boolean) { _syncStatus.value = status }
67 |
68 | // Save and load navidrome servers.
69 | private lateinit var sharedPreferences: SharedPreferences
70 | private val json = Json { ignoreUnknownKeys = true }
71 | private const val PREF_SERVERS = "navidrome_servers"
72 | private const val PREF_CURRENT_SERVER = "current_server_id"
73 |
74 | fun init(context: Context) {
75 | setSyncingStatus(true)
76 | sharedPreferences = context.getSharedPreferences("NavidromePrefs", Context.MODE_PRIVATE)
77 | loadServers()
78 |
79 | if (getAllServers().isEmpty() && getAllFolders().isEmpty()) showNoProviderDialog.value = true
80 |
81 | setSyncingStatus(false)
82 | }
83 |
84 | private fun saveServers() {
85 | val serversJson = json.encodeToString(servers as Map)
86 | sharedPreferences.edit().putString(PREF_SERVERS, serversJson).apply()
87 | sharedPreferences.edit().putString(PREF_CURRENT_SERVER, currentServerId).apply()
88 | }
89 |
90 | private fun loadServers() {
91 | currentServerId = sharedPreferences.getString(PREF_CURRENT_SERVER, null)
92 | val serversJson = sharedPreferences.getString(PREF_SERVERS, null)
93 | if (serversJson != null) {
94 | val loadedServers: Map = json.decodeFromString(serversJson)
95 | servers.putAll(loadedServers)
96 | }
97 | }
98 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/player/MediaController.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.player
2 |
3 | import android.content.ComponentName
4 | import android.content.Context
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.DisposableEffect
7 | import androidx.compose.runtime.RememberObserver
8 | import androidx.compose.runtime.Stable
9 | import androidx.compose.runtime.State
10 | import androidx.compose.runtime.mutableStateOf
11 | import androidx.compose.runtime.remember
12 | import androidx.compose.ui.platform.LocalContext
13 | import androidx.lifecycle.Lifecycle
14 | import androidx.lifecycle.LifecycleEventObserver
15 | import androidx.lifecycle.compose.LocalLifecycleOwner
16 | import androidx.media3.session.MediaController
17 | import androidx.media3.session.SessionToken
18 | import com.google.common.util.concurrent.ListenableFuture
19 | import com.google.common.util.concurrent.MoreExecutors
20 |
21 | /**
22 | * A Singleton class that manages a MediaController instance.
23 | *
24 | * This class observes the Remember lifecycle to release the MediaController when it's no longer needed.
25 | */
26 | @Stable
27 | class MediaControllerManager private constructor(context: Context) : RememberObserver {
28 | private val appContext = context.applicationContext
29 | private var factory: ListenableFuture? = null
30 | var controller = mutableStateOf(null)
31 |
32 | init { initialize() }
33 |
34 | /**
35 | * Initializes the MediaController.
36 | *
37 | * If the MediaController has not been built or has been released, this method will build a new one.
38 | */
39 | @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
40 | internal fun initialize() {
41 | if (factory == null || factory?.isDone == true) {
42 | factory = MediaController.Builder(
43 | appContext,
44 | SessionToken(appContext, ComponentName(appContext, ChoraMediaLibraryService::class.java))
45 | ).buildAsync()
46 | }
47 | factory?.addListener(
48 | {
49 | // MediaController is available here with controllerFuture.get()
50 | controller.value = factory?.let {
51 | if (it.isDone)
52 | it.get()
53 | else
54 | null
55 | }
56 | },
57 | MoreExecutors.directExecutor()
58 | )
59 | }
60 |
61 | /**
62 | * Releases the MediaController.
63 | *
64 | * This method will release the MediaController and set the controller state to null.
65 | */
66 | internal fun release() {
67 | factory?.let {
68 | MediaController.releaseFuture(it)
69 | controller.value = null
70 | }
71 | factory = null
72 | }
73 |
74 | // Lifecycle methods for the RememberObserver interface.
75 | override fun onAbandoned() { }
76 | override fun onForgotten() { }
77 | override fun onRemembered() {}
78 |
79 | companion object {
80 | @Volatile
81 | private var instance: MediaControllerManager? = null
82 |
83 | /**
84 | * Returns the Singleton instance of the MediaControllerManager.
85 | *
86 | * @param context The context to use when creating the MediaControllerManager.
87 | * @return The Singleton instance of the MediaControllerManager.
88 | */
89 | fun getInstance(context: Context): MediaControllerManager {
90 | return instance ?: synchronized(this) {
91 | instance ?: MediaControllerManager(context).also { instance = it }
92 | }
93 | }
94 | }
95 | }
96 |
97 |
98 |
99 | /**
100 | * A Composable function that provides a managed MediaController instance.
101 | *
102 | * @param lifecycle The lifecycle of the owner of this MediaController. Defaults to the lifecycle of the LocalLifecycleOwner.
103 | * @return A State object containing the MediaController instance. The Composable will automatically re-compose whenever the state changes.
104 | */
105 | @Composable
106 | fun rememberManagedMediaController(
107 | lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle
108 | ): State {
109 | // Application context is used to prevent memory leaks
110 | val appContext = LocalContext.current.applicationContext
111 | val controllerManager = remember { MediaControllerManager.getInstance(appContext) }
112 |
113 | // Observe the lifecycle to initialize and release the MediaController at the appropriate times.
114 | DisposableEffect(lifecycle) {
115 | val observer = LifecycleEventObserver { _, event ->
116 | when (event) {
117 | Lifecycle.Event.ON_START -> controllerManager.initialize()
118 | Lifecycle.Event.ON_DESTROY -> controllerManager.release()
119 | else -> {}
120 | }
121 | }
122 | lifecycle.addObserver(observer)
123 | onDispose { lifecycle.removeObserver(observer) }
124 | }
125 |
126 | return controllerManager.controller
127 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/player/SongHelper.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(UnstableApi::class) package com.craftworks.music.player
2 |
3 | import androidx.annotation.OptIn
4 | import androidx.compose.runtime.mutableIntStateOf
5 | import androidx.media3.common.MediaItem
6 | import androidx.media3.common.util.UnstableApi
7 | import androidx.media3.session.MediaController
8 |
9 | class SongHelper {
10 | companion object{
11 | var currentTracklist = mutableListOf()
12 | var minPercentageScrobble = mutableIntStateOf(75)
13 |
14 | fun play(mediaItems: List, index: Int, mediaController: MediaController?) {
15 | currentTracklist = (mediaItems).toMutableList()
16 | mediaController?.setMediaItems(currentTracklist, index, 0)
17 | mediaController?.prepare()
18 | mediaController?.play()
19 | }
20 | }
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/DownloadNavidromeSongs.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import android.Manifest
4 | import android.content.Context
5 | import android.content.pm.PackageManager
6 | import android.os.Environment
7 | import android.widget.Toast
8 | import androidx.annotation.OptIn
9 | import androidx.core.app.ActivityCompat
10 | import androidx.core.app.NotificationCompat
11 | import androidx.core.app.NotificationManagerCompat
12 | import androidx.media3.common.MediaMetadata
13 | import androidx.media3.common.util.NotificationUtil.IMPORTANCE_LOW
14 | import androidx.media3.common.util.NotificationUtil.createNotificationChannel
15 | import androidx.media3.common.util.UnstableApi
16 | import com.craftworks.music.R
17 | import com.craftworks.music.managers.NavidromeManager.getCurrentServer
18 | import com.craftworks.music.providers.local.LocalProvider
19 | import kotlinx.coroutines.Dispatchers
20 | import kotlinx.coroutines.withContext
21 | import java.io.File
22 | import java.io.FileOutputStream
23 | import java.io.InputStream
24 | import java.net.URL
25 |
26 | @OptIn(UnstableApi::class)
27 | suspend fun downloadNavidromeSong(
28 | context: Context,
29 | song: MediaMetadata
30 | ) {
31 | val channelId = "download_channel"
32 | createNotificationChannel(
33 | context,
34 | channelId,
35 | R.string.Notification_Download_Name,
36 | R.string.Notification_Download_Desc,
37 | IMPORTANCE_LOW
38 | )
39 |
40 | val notificationBuilder = NotificationCompat.Builder(context, channelId)
41 | .setSmallIcon(android.R.drawable.stat_sys_download)
42 | .setContentTitle(context.getString(R.string.Notification_Download_Progress) + " ${song.title} - ${song.artist}")
43 | .setPriority(NotificationCompat.PRIORITY_LOW)
44 | .setOnlyAlertOnce(true)
45 | .setProgress(100, 0, true)
46 |
47 | val notificationManager = NotificationManagerCompat.from(context)
48 | if (ActivityCompat.checkSelfPermission(
49 | context,
50 | Manifest.permission.POST_NOTIFICATIONS
51 | ) != PackageManager.PERMISSION_GRANTED
52 | ) return
53 |
54 | notificationManager.notify(1, notificationBuilder.build())
55 |
56 |
57 | val server = getCurrentServer() ?: throw IllegalArgumentException("Could not get current server.")
58 |
59 | withContext(Dispatchers.IO) {
60 | val passwordSalt = generateSalt(8)
61 | val passwordHash = md5Hash(server.password + passwordSalt)
62 |
63 | val url = URL("${server.url}/rest/download.view?id=${song.extras?.getString("navidromeID")}&u=${server.username}&t=$passwordHash&s=$passwordSalt&v=1.16.1&c=Chora")
64 |
65 | println("DOWNLOADING FROM: $url")
66 |
67 | try {
68 | val inputStream: InputStream = url.openStream()
69 |
70 | val musicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC)
71 | if (!musicDir.exists()) {
72 | musicDir.mkdirs()
73 | }
74 |
75 | val fileName = "${song.title} - ${song.artist}.${song.extras?.getString("format")}"
76 | val outputFile = File(musicDir, fileName)
77 | val fileOutputStream = FileOutputStream(outputFile)
78 |
79 | val buffer = ByteArray(8192)
80 | var totalBytesRead = 0L
81 | var bytesRead: Int
82 |
83 | val fileSize = url.openConnection().contentLength
84 |
85 | // Read from the input stream and write to the file output stream
86 | while (inputStream.read(buffer).also { bytesRead = it } != -1) {
87 | fileOutputStream.write(buffer, 0, bytesRead)
88 | totalBytesRead += bytesRead
89 |
90 | // Update progress notification
91 | val progress = (totalBytesRead * 100 / fileSize).toInt()
92 | notificationBuilder.setProgress(100, progress, false)
93 | notificationManager.notify(1, notificationBuilder.build())
94 | }
95 |
96 | // Close streams
97 | fileOutputStream.close()
98 | inputStream.close()
99 |
100 | println("Download completed: ${outputFile.absolutePath}")
101 |
102 | withContext(Dispatchers.Main) {
103 | notificationManager.cancel(1)
104 |
105 | Toast.makeText(
106 | context,
107 | "${song.title} " + context.getString(R.string.Notification_Download_Success),
108 | Toast.LENGTH_SHORT
109 | ).show()
110 |
111 | LocalProvider.getInstance().scanLocalFiles()
112 | }
113 |
114 | } catch (e: Exception) {
115 | e.printStackTrace()
116 |
117 |
118 | // Show failure notification
119 | notificationBuilder
120 | .setContentText(context.getString(R.string.Notification_Download_Failure) + " " + song.title)
121 | .setProgress(0, 0, false)
122 | notificationManager.notify(1, notificationBuilder.build())
123 |
124 | withContext(Dispatchers.Main) {
125 | Toast.makeText(
126 | context,
127 | context.getString(R.string.Notification_Download_Failure) + " " + song.title,
128 | Toast.LENGTH_SHORT
129 | ).show()
130 | }
131 |
132 | println("Download failed: ${e.message}")
133 | }
134 | }
135 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/GetNavidromeAlbums.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import androidx.annotation.OptIn
4 | import androidx.core.net.toUri
5 | import androidx.media3.common.MediaItem
6 | import androidx.media3.common.util.UnstableApi
7 | import com.craftworks.music.data.MediaData
8 | import com.craftworks.music.data.albumList
9 | import com.craftworks.music.data.toMediaItem
10 | import kotlinx.serialization.Serializable
11 | import kotlinx.serialization.json.Json
12 | import kotlinx.serialization.json.decodeFromJsonElement
13 | import kotlinx.serialization.json.jsonObject
14 |
15 | @Serializable
16 | data class albumList(val album: List? = listOf())
17 |
18 | fun parseNavidromeAlbumListJSON(
19 | response: String,
20 | navidromeUrl: String,
21 | navidromeUsername: String,
22 | navidromePassword: String,
23 | ) : List {
24 | val jsonParser = Json { ignoreUnknownKeys = true }
25 | val subsonicResponse = jsonParser.decodeFromJsonElement(
26 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
27 | )
28 |
29 | // Generate password salt and hash for coverArt
30 | val passwordSaltArt = generateSalt(8)
31 | val passwordHashArt = md5Hash(navidromePassword + passwordSaltArt)
32 |
33 | val baseCoverArtUrl = "$navidromeUrl/rest/getCoverArt.view?u=$navidromeUsername&t=$passwordHashArt&s=$passwordSaltArt&v=1.16.1&c=Chora"
34 |
35 | val mediaDataAlbums = subsonicResponse.albumList?.album?.map {
36 | val mediaItem = it.toMediaItem()
37 | mediaItem.buildUpon().setMediaMetadata(
38 | mediaItem.mediaMetadata.buildUpon()
39 | .setArtworkUri("$baseCoverArtUrl&id=${it.navidromeID}".toUri())
40 | .build()
41 | ).build()
42 | } ?: emptyList()
43 |
44 | return mediaDataAlbums
45 | .asSequence()
46 | .filterNot { newAlbum ->
47 | albumList.any { existingAlbum ->
48 | existingAlbum.navidromeID == newAlbum.mediaMetadata.extras?.getString("navidromeID")
49 | }
50 | }
51 | .toList()
52 | }
53 |
54 | @OptIn(UnstableApi::class)
55 | fun parseNavidromeAlbumJSON(
56 | response: String,
57 | navidromeUrl: String,
58 | navidromeUsername: String,
59 | navidromePassword: String,
60 | ): List {
61 | val jsonParser = Json { ignoreUnknownKeys = true }
62 | val subsonicResponse = jsonParser.decodeFromJsonElement(
63 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
64 | )
65 |
66 | val passwordSalt = generateSalt(8)
67 | val passwordHash = md5Hash(navidromePassword + passwordSalt)
68 |
69 | val selectedAlbum = subsonicResponse.album
70 | val album = mutableListOf()
71 |
72 | selectedAlbum?.songs?.map {
73 | it.media = "$navidromeUrl/rest/stream.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHash&s=$passwordSalt&v=1.12.0&c=Chora"
74 | it.imageUrl = "$navidromeUrl/rest/getCoverArt.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHash&s=$passwordSalt&v=1.16.1&c=Chora"
75 | }
76 |
77 | // Create the Album MediaItem
78 | album.add(selectedAlbum?.toMediaItem() ?: MediaItem.EMPTY)
79 |
80 | println("Added album: ${selectedAlbum?.navidromeID}")
81 |
82 | album.addAll(selectedAlbum?.songs?.map {
83 | println("Added song to album: ${it.title}")
84 | it.toMediaItem()
85 | } ?: emptyList())
86 |
87 | return if (selectedAlbum != null) album
88 | else emptyList()
89 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/GetNavidromeArtists.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import android.util.Log
4 | import com.craftworks.music.data.MediaData
5 | import com.craftworks.music.data.artistList
6 | import com.craftworks.music.data.selectedArtist
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 | import kotlinx.serialization.json.Json
10 | import kotlinx.serialization.json.decodeFromJsonElement
11 | import kotlinx.serialization.json.jsonObject
12 |
13 | @Serializable
14 | @SerialName("artists")
15 | data class Artists(val index: List)
16 |
17 | @Serializable
18 | data class index(val artist: List? = listOf())
19 |
20 | fun parseNavidromeArtistsJSON(
21 | response: String
22 | ) : List {
23 | val jsonParser = Json { ignoreUnknownKeys = true }
24 | val subsonicResponse = jsonParser.decodeFromJsonElement(
25 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
26 | )
27 |
28 | val mediaDataArtists = mutableListOf()
29 |
30 | subsonicResponse.artists?.index?.forEach { index ->
31 | index.artist?.filterNot { newArtist ->
32 | //println(newArtist)
33 | artistList.any { existingArtist ->
34 | existingArtist.navidromeID == newArtist.navidromeID
35 | }
36 | }?.let { filteredArtists ->
37 | mediaDataArtists.addAll(filteredArtists) // Assuming mediaDataArtists is a mutable list
38 | }
39 | }
40 |
41 | Log.d("NAVIDROME", "Added artists. Total: ${mediaDataArtists.size}")
42 |
43 | return mediaDataArtists
44 | }
45 |
46 | fun parseNavidromeArtistAlbumsJSON(
47 | response: String,
48 | navidromeUrl: String,
49 | navidromeUsername: String,
50 | navidromePassword: String
51 | ) : MediaData.Artist {
52 | val jsonParser = Json { ignoreUnknownKeys = true }
53 | val subsonicResponse = jsonParser.decodeFromJsonElement(
54 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
55 | )
56 |
57 | val mediaDataArtist = selectedArtist
58 |
59 | // Generate password salt and hash for album coverArt
60 | val passwordSaltArt = generateSalt(8)
61 | val passwordHashArt = md5Hash(navidromePassword + passwordSaltArt)
62 |
63 | subsonicResponse.artist?.album?.map {
64 | it.coverArt = "$navidromeUrl/rest/getCoverArt.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHashArt&s=$passwordSaltArt&v=1.16.1&c=Chora"
65 | }
66 |
67 | mediaDataArtist.starred = subsonicResponse.artist?.starred
68 | mediaDataArtist.album = subsonicResponse.artist?.album
69 |
70 | Log.d("NAVIDROME", "Added Metadata to ${mediaDataArtist.name}")
71 |
72 | return mediaDataArtist
73 | }
74 |
75 | fun parseNavidromeArtistBiographyJSON(
76 | response: String
77 | ) : MediaData.Artist {
78 | val jsonParser = Json { ignoreUnknownKeys = true }
79 | val subsonicResponse = jsonParser.decodeFromJsonElement(
80 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
81 | )
82 |
83 | val mediaDataArtist = selectedArtist
84 |
85 | mediaDataArtist.description = subsonicResponse.artistInfo?.biography.toString().replace(Regex("]*>.*?"), "")
86 | mediaDataArtist.musicBrainzId = subsonicResponse.artistInfo?.musicBrainzId
87 | mediaDataArtist.similarArtist = subsonicResponse.artistInfo?.similarArtist
88 |
89 | Log.d("NAVIDROME", "Added Metadata to ${mediaDataArtist.name}")
90 |
91 | return mediaDataArtist
92 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/GetNavidromeFavourites.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import android.util.Log
4 | import androidx.media3.common.MediaItem
5 | import com.craftworks.music.data.MediaData
6 | import com.craftworks.music.data.toMediaItem
7 | import kotlinx.serialization.SerialName
8 | import kotlinx.serialization.Serializable
9 | import kotlinx.serialization.json.Json
10 | import kotlinx.serialization.json.decodeFromJsonElement
11 | import kotlinx.serialization.json.jsonObject
12 |
13 | @Serializable
14 | @SerialName("starred")
15 | data class Starred(
16 | val song: List? = null,
17 | val album: List? = null,
18 | val artist: List? = null
19 | )
20 |
21 | fun parseNavidromeFavouritesJSON(
22 | response: String,
23 | navidromeUrl: String,
24 | navidromeUsername: String,
25 | navidromePassword: String
26 | ) : List {
27 | val jsonParser = Json { ignoreUnknownKeys = true }
28 | val subsonicResponse = jsonParser.decodeFromJsonElement(
29 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
30 | )
31 |
32 | val passwordSaltMedia = generateSalt(8)
33 | val passwordHashMedia = md5Hash(navidromePassword + passwordSaltMedia)
34 |
35 |
36 | val mediaDataFavouriteSongs = mutableListOf()
37 |
38 | mediaDataFavouriteSongs.addAll(subsonicResponse.starred?.song?.map {
39 | it.copy(
40 | media = "$navidromeUrl/rest/stream.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHashMedia&s=$passwordSaltMedia&v=1.12.0&c=Chora",
41 | imageUrl = "$navidromeUrl/rest/getCoverArt.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHashMedia&s=$passwordSaltMedia&v=1.16.1&c=Chora"
42 | ).toMediaItem()
43 | } ?: emptyList())
44 |
45 | Log.d("NAVIDROME", "Got favourite songs. Total: ${mediaDataFavouriteSongs.size}")
46 |
47 | return mediaDataFavouriteSongs
48 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/GetNavidromeLyrics.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import androidx.media3.common.MediaMetadata
4 | import com.craftworks.music.data.Lyric
5 | import com.craftworks.music.data.MediaData
6 | import com.craftworks.music.data.toLyric
7 | import com.craftworks.music.data.toLyrics
8 | import kotlinx.serialization.Serializable
9 | import kotlinx.serialization.json.Json
10 | import kotlinx.serialization.json.decodeFromJsonElement
11 | import kotlinx.serialization.json.jsonObject
12 |
13 | @Serializable
14 | data class LyricsList(
15 | val structuredLyrics: List? = null
16 | )
17 |
18 | @Serializable
19 | data class SyncedLyrics(
20 | val start: Int, val value: String
21 | )
22 |
23 | suspend fun getNavidromePlainLyrics(metadata: MediaMetadata?): List {
24 | return sendNavidromeGETRequest("getLyrics.view?artist=${metadata?.artist}&title=${metadata?.title}&f=json").filterIsInstance().getOrNull(0)?.toLyric()?.takeIf { it.content.isNotEmpty() }?.let { listOf(it) } ?: emptyList()
25 | }
26 |
27 | suspend fun getNavidromeSyncedLyrics(navidromeId: String): List {
28 | return sendNavidromeGETRequest("getLyricsBySongId.view?id=${navidromeId}&f=json")
29 | .filterIsInstance().flatMap { it.toLyrics() }
30 | }
31 |
32 | fun parseNavidromePlainLyricsJSON(
33 | response: String
34 | ): MediaData {
35 |
36 | val jsonParser = Json { ignoreUnknownKeys = true }
37 | val subsonicResponse = jsonParser.decodeFromJsonElement(
38 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
39 | )
40 |
41 | val mediaDataPlainLyrics = subsonicResponse.lyrics!!
42 |
43 | return mediaDataPlainLyrics
44 | }
45 |
46 | fun parseNavidromeSyncedLyricsJSON(
47 | response: String
48 | ): List {
49 |
50 | val jsonParser = Json { ignoreUnknownKeys = true }
51 | val subsonicResponse = jsonParser.decodeFromJsonElement(
52 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
53 | )
54 |
55 | val mediaDataSyncedLyrics = subsonicResponse.lyricsList?.structuredLyrics ?: emptyList()
56 |
57 | return mediaDataSyncedLyrics
58 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/GetNavidromePlaylists.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import android.util.Log
4 | import androidx.media3.common.MediaItem
5 | import com.craftworks.music.data.MediaData
6 | import com.craftworks.music.data.playlistList
7 | import com.craftworks.music.data.toMediaItem
8 | import kotlinx.serialization.Serializable
9 | import kotlinx.serialization.json.Json
10 | import kotlinx.serialization.json.decodeFromJsonElement
11 | import kotlinx.serialization.json.jsonObject
12 |
13 | @Serializable
14 | data class PlaylistContainer(val playlist: List? = listOf())
15 |
16 | fun parseNavidromePlaylistsJSON(
17 | response: String,
18 | navidromeUrl: String,
19 | navidromeUsername: String,
20 | navidromePassword: String
21 | ) : List {
22 |
23 | val jsonParser = Json { ignoreUnknownKeys = true }
24 | val subsonicResponse = jsonParser.decodeFromJsonElement(
25 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
26 | )
27 |
28 | // Generate password salt and hash
29 | val passwordSaltMedia = generateSalt(8)
30 | val passwordHashMedia = md5Hash(navidromePassword + passwordSaltMedia)
31 |
32 | subsonicResponse.playlists?.playlist?.map {
33 | it.coverArt = "$navidromeUrl/rest/getCoverArt.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHashMedia&s=$passwordSaltMedia&v=1.16.1&c=Chora"
34 | }
35 |
36 | var mediaDataPlaylists = emptyList()
37 |
38 | subsonicResponse.playlists?.playlist?.filterNot { newPlaylist ->
39 | playlistList.any { existingPlaylist ->
40 | existingPlaylist.navidromeID == newPlaylist.navidromeID
41 | }
42 | }?.let {
43 | mediaDataPlaylists = it.map { it.toMediaItem() }
44 | }
45 |
46 | Log.d("NAVIDROME", "Added playlists. Total: ${mediaDataPlaylists.size}")
47 |
48 | return mediaDataPlaylists
49 | }
50 |
51 | fun parseNavidromePlaylistJSON(
52 | response: String,
53 | navidromeUrl: String,
54 | navidromeUsername: String,
55 | navidromePassword: String
56 | ) : List {
57 | val jsonParser = Json { ignoreUnknownKeys = true }
58 | val subsonicResponse = jsonParser.decodeFromJsonElement(
59 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
60 | )
61 |
62 | val mediaDataPlaylist = mutableListOf()
63 |
64 | // Generate password salt and hash
65 | val passwordSalt = generateSalt(8)
66 | val passwordHash = md5Hash(navidromePassword + passwordSalt)
67 |
68 | subsonicResponse.playlist?.songs?.map {
69 | it.imageUrl = "$navidromeUrl/rest/getCoverArt.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHash&s=$passwordSalt&v=1.16.1&c=Chora"
70 | it.media = "$navidromeUrl/rest/stream.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHash&s=$passwordSalt&v=1.12.0&c=Chora"
71 | }
72 |
73 | mediaDataPlaylist.addAll(subsonicResponse.playlist?.songs?.map { it.toMediaItem() } ?: emptyList())
74 |
75 | return mediaDataPlaylist
76 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/GetNavidromeRadios.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import android.util.Log
4 | import com.craftworks.music.data.MediaData
5 | import com.craftworks.music.data.radioList
6 | import kotlinx.serialization.SerialName
7 | import kotlinx.serialization.Serializable
8 | import kotlinx.serialization.json.Json
9 | import kotlinx.serialization.json.decodeFromJsonElement
10 | import kotlinx.serialization.json.jsonObject
11 |
12 | @Serializable
13 | @SerialName("artists")
14 | data class internetRadioStations(val internetRadioStation: List)
15 |
16 | fun parseNavidromeRadioJSON(
17 | response: String
18 | ) : List {
19 | val jsonParser = Json { ignoreUnknownKeys = true }
20 | val subsonicResponse = jsonParser.decodeFromJsonElement(
21 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
22 | )
23 |
24 | var mediaDataRadios = emptyList()
25 |
26 | subsonicResponse.internetRadioStations?.internetRadioStation?.filterNot { newRadio ->
27 | radioList.any { existingRadio ->
28 | existingRadio.navidromeID == newRadio.navidromeID
29 | }
30 | }?.let {
31 | mediaDataRadios = it
32 | }
33 |
34 | Log.d("NAVIDROME", "Added radios. Total: ${mediaDataRadios.size}")
35 |
36 | return mediaDataRadios
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/GetNavidromeSongs.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import androidx.annotation.OptIn
4 | import androidx.media3.common.MediaItem
5 | import androidx.media3.common.util.UnstableApi
6 | import com.craftworks.music.data.MediaData
7 | import com.craftworks.music.data.albumList
8 | import com.craftworks.music.data.artistList
9 | import com.craftworks.music.data.songsList
10 | import com.craftworks.music.data.toMediaItem
11 | import kotlinx.serialization.Serializable
12 | import kotlinx.serialization.json.Json
13 | import kotlinx.serialization.json.decodeFromJsonElement
14 | import kotlinx.serialization.json.jsonObject
15 |
16 | @Serializable
17 | data class SearchResult3(
18 | val song: List? = listOf(),
19 | val album: List? = listOf(),
20 | val artist: List? = listOf(),
21 | )
22 |
23 | @OptIn(UnstableApi::class)
24 | fun parseNavidromeSearch3JSON(
25 | response: String,
26 | navidromeUrl: String,
27 | navidromeUsername: String,
28 | navidromePassword: String,
29 | ) : List {
30 |
31 | val jsonParser = Json { ignoreUnknownKeys = true }
32 | val subsonicResponse = jsonParser.decodeFromJsonElement(
33 | jsonParser.parseToJsonElement(response).jsonObject["subsonic-response"]!!
34 | )
35 |
36 | // Generate password salt and hash
37 | val passwordSaltMedia = generateSalt(8)
38 | val passwordHashMedia = md5Hash(navidromePassword + passwordSaltMedia)
39 |
40 | subsonicResponse.searchResult3?.song?.map {
41 | it.media = "$navidromeUrl/rest/stream.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHashMedia&s=$passwordSaltMedia&v=1.12.0&c=Chora"
42 | it.imageUrl = "$navidromeUrl/rest/getCoverArt.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHashMedia&s=$passwordSaltMedia&v=1.16.1&c=Chora"
43 | }
44 |
45 | subsonicResponse.searchResult3?.album?.map {
46 | it.coverArt = "$navidromeUrl/rest/getCoverArt.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHashMedia&s=$passwordSaltMedia&v=1.16.1&c=Chora"
47 | }
48 |
49 | var mediaDataSongs = emptyList()
50 | var mediaDataAlbums = emptyList()
51 | var mediaDataArtists = emptyList()
52 |
53 | subsonicResponse.searchResult3?.song?.filterNot { newSong ->
54 | songsList.any { existingSong ->
55 | existingSong.navidromeID == newSong.navidromeID
56 | }
57 | }?.let { mediaDataSongs = it.map {
58 | it.copy(
59 | media = "$navidromeUrl/rest/stream.view?&id=${it.navidromeID}&u=$navidromeUsername&t=$passwordHashMedia&s=$passwordSaltMedia&v=1.12.0&c=Chora"
60 | ).toMediaItem()
61 | }
62 | }
63 |
64 | subsonicResponse.searchResult3?.album?.filterNot { newAlbum ->
65 | albumList.any { existingAlbum ->
66 | existingAlbum.navidromeID == newAlbum.navidromeID
67 | }
68 | }?.let { mediaDataAlbums = it.map { it.toMediaItem() } }
69 |
70 | subsonicResponse.searchResult3?.artist?.filterNot { newArtist ->
71 | artistList.any { existingArtist ->
72 | existingArtist.navidromeID == newArtist.navidromeID
73 | }
74 | }?.let { mediaDataArtists = it }
75 |
76 | return when {
77 | mediaDataSongs.isNotEmpty() -> mediaDataSongs
78 | mediaDataAlbums.isNotEmpty() -> mediaDataAlbums
79 | else -> mediaDataArtists
80 | }
81 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/GetNavidromeStatus.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import android.util.Log
4 | import androidx.compose.runtime.mutableStateOf
5 | import com.craftworks.music.data.NavidromeProvider
6 | import com.craftworks.music.managers.NavidromeManager
7 | import com.gitlab.mvysny.konsumexml.konsumeXml
8 |
9 | var navidromeStatus = mutableStateOf("")
10 |
11 | suspend fun getNavidromeStatus(server: NavidromeProvider){
12 | NavidromeManager.addServer(server)
13 | sendNavidromeGETRequest("ping.view?", true)
14 | NavidromeManager.removeServer(server.id)
15 | }
16 |
17 | fun parseNavidromeStatusXML(response: String){
18 | // Avoid crashing by removing some useless tags.
19 | val newResponse = response
20 | .replace("xmlns=\"http://subsonic.org/restapi\" ", "")
21 |
22 | newResponse.konsumeXml().apply {
23 | child("subsonic-response"){
24 | val status = attributes.getValue("status")
25 |
26 | if (status == "failed"){
27 | childOrNull("error"){
28 | val errorCode = attributes.getValue("code")
29 | val errorMessage = attributes.getValue("message")
30 | Log.d("NAVIDROME", "Navidrome Error Code: $errorCode, Message: $errorMessage")
31 | navidromeStatus.value = "Error $errorCode: $errorMessage"
32 | }
33 | }
34 | else navidromeStatus.value = "ok"
35 |
36 | skipContents()
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/MarkNavidromeAsPlayed.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import com.craftworks.music.managers.NavidromeManager
4 | import com.craftworks.music.player.SongHelper
5 |
6 | suspend fun markNavidromeSongAsPlayed(navidromeId: String, playerPos: Float, duration: Float){
7 | if (!NavidromeManager.checkActiveServers()) return
8 |
9 | val scrobblePercentage = playerPos / (duration * 1000f)
10 | if (scrobblePercentage < SongHelper.minPercentageScrobble.intValue / 100) return
11 |
12 | println("Scrobble Percentage: ${scrobblePercentage * 100}, with sliderPos = $playerPos | songDuration = $duration | minPercentage = ${SongHelper.minPercentageScrobble.intValue}")
13 | sendNavidromeGETRequest("scrobble.view?id=$navidromeId&submission=true", true) // Never cache scrobbles
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/NavidromeCache.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | import java.util.concurrent.ConcurrentHashMap
4 | import kotlin.time.Duration.Companion.minutes
5 |
6 | data class CachedResponse(val data: List, val timestamp: Long)
7 |
8 | object NavidromeCache {
9 | private val cache = ConcurrentHashMap()
10 | val cacheDuration = 15.minutes
11 |
12 | fun get(key: String) : List? {
13 | val cachedResponse = cache[key] ?: return null
14 | val now = System.currentTimeMillis()
15 | if (now - cachedResponse.timestamp > cacheDuration.inWholeMilliseconds) {
16 | cache.remove(key)
17 | return null // Cache expired
18 | }
19 | return cachedResponse.data
20 | }
21 |
22 | fun put(key: String, data: List) {
23 | cache[key] = CachedResponse(data, System.currentTimeMillis())
24 | }
25 |
26 | fun delByPrefix(prefix: String) {
27 | // Iterate over the keys in the cache
28 | val keysToRemove = cache.keys.filter { it.startsWith(prefix) }
29 |
30 | // Remove all the keys that match the prefix
31 | keysToRemove.forEach { cache.remove(it) }
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/providers/navidrome/SetNavidromeStarred.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.providers.navidrome
2 |
3 | suspend fun setNavidromeStar(star: Boolean, id: String = "", albumId: String = "", artistId: String = ""){
4 | val queryParams = mutableListOf()
5 | if (id.isNotEmpty()) {
6 | queryParams.add("id=$id")
7 | }
8 | if (albumId.isNotEmpty()) {
9 | queryParams.add("albumId=$albumId")
10 | }
11 | if (artistId.isNotEmpty()) {
12 | queryParams.add("artistId=$artistId")
13 | }
14 |
15 | val queryString = if (queryParams.isNotEmpty()) {
16 | queryParams.joinToString("&", prefix = "?")
17 | } else {
18 | ""
19 | }
20 |
21 | if (star)
22 | sendNavidromeGETRequest("star.view$queryString", true)
23 | else
24 | sendNavidromeGETRequest("unstar.view$queryString", true)
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/ArtistCard.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.aspectRatio
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.widthIn
11 | import androidx.compose.foundation.layout.wrapContentHeight
12 | import androidx.compose.foundation.shape.RoundedCornerShape
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.runtime.Stable
17 | import androidx.compose.ui.Alignment
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.draw.clip
20 | import androidx.compose.ui.layout.ContentScale
21 | import androidx.compose.ui.platform.LocalContext
22 | import androidx.compose.ui.res.painterResource
23 | import androidx.compose.ui.text.font.FontWeight
24 | import androidx.compose.ui.text.style.TextAlign
25 | import androidx.compose.ui.text.style.TextOverflow
26 | import androidx.compose.ui.unit.dp
27 | import coil.compose.AsyncImage
28 | import coil.request.ImageRequest
29 | import com.craftworks.music.R
30 | import com.craftworks.music.data.MediaData
31 |
32 | @Stable
33 | @Composable
34 | fun ArtistCard(artist: MediaData.Artist, onClick: () -> Unit) {
35 | Column(
36 | modifier = Modifier
37 | .padding(12.dp)
38 | .aspectRatio(0.8f)
39 | .widthIn(min = 96.dp, max = 256.dp)
40 | .clip(RoundedCornerShape(12.dp))
41 | .clickable { onClick() }
42 | .wrapContentHeight(),
43 | horizontalAlignment = Alignment.CenterHorizontally
44 | ) {
45 | AsyncImage(
46 | model = ImageRequest.Builder(LocalContext.current)
47 | .data(artist.artistImageUrl)
48 | .crossfade(true)
49 | .size(256)
50 | .build(),
51 | fallback = painterResource(R.drawable.rounded_artist_24),
52 | contentScale = ContentScale.Crop,
53 | contentDescription = "Album Image",
54 | modifier = Modifier
55 | .fillMaxWidth()
56 | .aspectRatio(1f)
57 | .clip(RoundedCornerShape(12.dp)),
58 | )
59 |
60 | Spacer(modifier = Modifier.height(4.dp))
61 |
62 | Text(
63 | text = artist.name,
64 | style = MaterialTheme.typography.titleMedium,
65 | fontWeight = FontWeight.SemiBold,
66 | color = MaterialTheme.colorScheme.onBackground,
67 | modifier = Modifier
68 | .fillMaxWidth()
69 | .padding(vertical = 3.dp),
70 | maxLines = 1, overflow = TextOverflow.Ellipsis,
71 | textAlign = TextAlign.Center
72 | )
73 | }
74 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/ButtonAnimation.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements
2 |
3 | import androidx.compose.animation.core.Spring
4 | import androidx.compose.animation.core.animateDpAsState
5 | import androidx.compose.animation.core.animateFloatAsState
6 | import androidx.compose.animation.core.spring
7 | import androidx.compose.foundation.gestures.awaitFirstDown
8 | import androidx.compose.foundation.gestures.waitForUpOrCancellation
9 | import androidx.compose.foundation.layout.offset
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.runtime.mutableStateOf
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.runtime.setValue
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.composed
16 | import androidx.compose.ui.graphics.graphicsLayer
17 | import androidx.compose.ui.input.pointer.pointerInput
18 | import androidx.compose.ui.unit.IntOffset
19 | import androidx.compose.ui.unit.dp
20 |
21 | // Taken from this Medium article:
22 | // https://blog.canopas.com/jetpack-compose-cool-button-click-effects-c6bbecec7bcb
23 |
24 | enum class ButtonState { Pressed, Idle }
25 | fun Modifier.bounceClick(enabled: Boolean = true) = composed {
26 | var buttonState by remember { mutableStateOf(ButtonState.Idle) }
27 | val scale by animateFloatAsState(if (buttonState == ButtonState.Pressed && enabled) 0.9f else 1f,
28 | label = "Animated Button Scale",
29 | animationSpec = spring(
30 | dampingRatio = Spring.DampingRatioMediumBouncy,
31 | stiffness = Spring.StiffnessMedium
32 | )
33 |
34 | )
35 |
36 | this
37 | .graphicsLayer {
38 | scaleX = scale
39 | scaleY = scale
40 | }
41 | // .clickable(
42 | // interactionSource = remember { MutableInteractionSource() },
43 | // indication = null,
44 | // onClick = { }
45 | // )
46 | .pointerInput(buttonState) {
47 | awaitPointerEventScope {
48 | buttonState = if (buttonState == ButtonState.Pressed) {
49 | waitForUpOrCancellation()
50 | ButtonState.Idle
51 | } else {
52 | awaitFirstDown(false)
53 | ButtonState.Pressed
54 | }
55 | }
56 | }
57 | }
58 |
59 | fun Modifier.moveClick(right: Boolean, enabled: Boolean = true) = composed {
60 | var buttonState by remember { mutableStateOf(ButtonState.Idle) }
61 | val position by animateDpAsState(if (buttonState == ButtonState.Pressed && enabled) 12.dp else 0.dp,
62 | label = "Animated Button Position",
63 | animationSpec = spring(
64 | dampingRatio = Spring.DampingRatioMediumBouncy,
65 | stiffness = Spring.StiffnessMedium
66 | )
67 |
68 | )
69 |
70 | this
71 | .offset{
72 | IntOffset(x = if (right) position.toPx().toInt() else -position.toPx().toInt(), y= 0)
73 | }
74 | // .clickable(
75 | // interactionSource = remember { MutableInteractionSource() },
76 | // indication = null,
77 | // onClick = { }
78 | // )
79 | .pointerInput(buttonState) {
80 | awaitPointerEventScope {
81 | buttonState = if (buttonState == ButtonState.Pressed) {
82 | waitForUpOrCancellation()
83 | ButtonState.Idle
84 | } else {
85 | awaitFirstDown(false)
86 | ButtonState.Pressed
87 | }
88 | }
89 | }
90 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/ConnectionErrorDialog.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements
2 |
3 | import androidx.compose.animation.animateContentSize
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.heightIn
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.shape.RoundedCornerShape
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.Text
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.collectAsState
14 | import androidx.compose.runtime.derivedStateOf
15 | import androidx.compose.runtime.getValue
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.clip
19 | import androidx.compose.ui.res.stringResource
20 | import androidx.compose.ui.text.font.FontWeight
21 | import androidx.compose.ui.tooling.preview.Preview
22 | import androidx.compose.ui.unit.dp
23 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
24 | import com.craftworks.music.R
25 | import com.craftworks.music.managers.NavidromeManager
26 |
27 | @Composable
28 | @Preview
29 | fun HorizontalLineWithNavidromeCheck() {
30 | val navidromeStatus by NavidromeManager.serverStatus.collectAsStateWithLifecycle()
31 | val showError by remember { derivedStateOf { navidromeStatus } }
32 |
33 | val syncingStatus by NavidromeManager.syncStatus.collectAsStateWithLifecycle()
34 | val showSync by remember { derivedStateOf { syncingStatus } }
35 |
36 | // Error Container
37 | Column(
38 | modifier = Modifier
39 | .animateContentSize()
40 | .fillMaxWidth()
41 | .padding(
42 | horizontal = 12.dp,
43 | vertical = if (showError.isNotBlank() && showError != "ok") 12.dp else 0.dp
44 | )
45 | .clip(RoundedCornerShape(12.dp))
46 | .background(MaterialTheme.colorScheme.errorContainer)
47 | .heightIn(
48 | max = if (showError.isNotBlank() && showError != "ok") 128.dp
49 | else 0.dp
50 | )
51 | ) {
52 | Text(
53 | text = stringResource(R.string.Navidrome_Error) + showError,
54 | color = MaterialTheme.colorScheme.onErrorContainer,
55 | fontWeight = FontWeight.SemiBold,
56 | fontSize = MaterialTheme.typography.titleMedium.fontSize,
57 | modifier = Modifier.padding(12.dp),
58 | )
59 | }
60 |
61 | // Syncing Container
62 | Column(
63 | modifier = Modifier
64 | .animateContentSize()
65 | .fillMaxWidth()
66 | .padding(horizontal = 12.dp, vertical = if (showSync) 12.dp else 0.dp)
67 | .clip(RoundedCornerShape(12.dp))
68 | .background(MaterialTheme.colorScheme.primaryContainer)
69 | .heightIn(
70 | max = if (showSync) 128.dp
71 | else 0.dp
72 | )
73 | ) {
74 | Text(
75 | text = stringResource(R.string.Navidrome_Sync),
76 | color = MaterialTheme.colorScheme.onBackground,
77 | fontWeight = FontWeight.SemiBold,
78 | fontSize = MaterialTheme.typography.titleMedium.fontSize,
79 | modifier = Modifier.padding(12.dp),
80 | )
81 | }
82 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/GenrePill.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.foundation.layout.wrapContentHeight
5 | import androidx.compose.foundation.shape.CircleShape
6 | import androidx.compose.material3.Card
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.text.font.FontWeight
12 | import androidx.compose.ui.text.style.TextAlign
13 | import androidx.compose.ui.unit.dp
14 |
15 | @Composable
16 | fun GenrePill(genre: String) {
17 | Card (
18 | modifier = Modifier
19 | .wrapContentHeight()
20 | .padding(6.dp),
21 | shape = CircleShape
22 | ) {
23 | Text(
24 | text = genre,
25 | color = MaterialTheme.colorScheme.onBackground,
26 | fontWeight = FontWeight.Normal,
27 | fontSize = MaterialTheme.typography.bodyMedium.fontSize,
28 | textAlign = TextAlign.Center,
29 | modifier = Modifier.padding(horizontal = 12.dp)
30 | )
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/PlaylistCard.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements
2 |
3 | import androidx.compose.foundation.ExperimentalFoundationApi
4 | import androidx.compose.foundation.combinedClickable
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.aspectRatio
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.widthIn
11 | import androidx.compose.foundation.layout.wrapContentHeight
12 | import androidx.compose.foundation.shape.RoundedCornerShape
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.clip
19 | import androidx.compose.ui.layout.ContentScale
20 | import androidx.compose.ui.res.painterResource
21 | import androidx.compose.ui.text.font.FontWeight
22 | import androidx.compose.ui.text.style.TextAlign
23 | import androidx.compose.ui.text.style.TextOverflow
24 | import androidx.compose.ui.unit.dp
25 | import androidx.media3.common.MediaItem
26 | import coil.compose.AsyncImage
27 | import com.craftworks.music.R
28 | import com.craftworks.music.ui.elements.dialogs.playlistToDelete
29 | import com.craftworks.music.ui.elements.dialogs.showDeletePlaylistDialog
30 |
31 | @OptIn(ExperimentalFoundationApi::class)
32 | @Composable
33 | fun PlaylistCard(playlist: MediaItem, onClick: () -> Unit) {
34 | val metadata = playlist.mediaMetadata
35 | Column(
36 | modifier = Modifier
37 | .padding(12.dp)
38 | .clip(RoundedCornerShape(12.dp))
39 | .combinedClickable(
40 | onClick = { onClick() },
41 | onLongClick = {
42 | if (playlist.mediaMetadata.extras?.getString("navidromeID") != "favourites") {
43 | playlistToDelete.value =
44 | playlist.mediaMetadata.extras?.getString("navidromeID") ?: ""
45 | showDeletePlaylistDialog.value = true
46 | }
47 | },
48 | onLongClickLabel = "Delete Playlist"
49 | )
50 | .widthIn(min = 128.dp),
51 | horizontalAlignment = Alignment.CenterHorizontally
52 | ) {
53 | AsyncImage(
54 | model = if (metadata.extras?.getString("navidromeID")?.startsWith("Local") == true)
55 | metadata.artworkData else
56 | metadata.artworkUri,
57 | placeholder = painterResource(R.drawable.placeholder),
58 | fallback = painterResource(R.drawable.placeholder),
59 | contentScale = ContentScale.FillWidth,
60 | contentDescription = "Album Image",
61 | modifier = Modifier
62 | .fillMaxWidth()
63 | .aspectRatio(1f)
64 | .clip(RoundedCornerShape(12.dp))
65 | )
66 |
67 | Text(
68 | text = metadata.title.toString(),
69 | style = MaterialTheme.typography.titleMedium,
70 | fontWeight = FontWeight.SemiBold,
71 | color = MaterialTheme.colorScheme.onBackground,
72 | modifier = Modifier
73 | .fillMaxSize()
74 | .wrapContentHeight(align = Alignment.CenterVertically),
75 | maxLines = 1,
76 | overflow = TextOverflow.Ellipsis,
77 | textAlign = TextAlign.Center
78 | )
79 | }
80 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/RadioCard.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements
2 |
3 | import android.util.Log
4 | import androidx.compose.foundation.ExperimentalFoundationApi
5 | import androidx.compose.foundation.background
6 | import androidx.compose.foundation.combinedClickable
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.widthIn
11 | import androidx.compose.foundation.layout.wrapContentHeight
12 | import androidx.compose.foundation.shape.RoundedCornerShape
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.material3.Text
15 | import androidx.compose.material3.surfaceColorAtElevation
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.runtime.Stable
18 | import androidx.compose.ui.Alignment
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.draw.clip
21 | import androidx.compose.ui.layout.ContentScale
22 | import androidx.compose.ui.platform.LocalContext
23 | import androidx.compose.ui.res.painterResource
24 | import androidx.compose.ui.text.font.FontWeight
25 | import androidx.compose.ui.text.style.TextAlign
26 | import androidx.compose.ui.text.style.TextOverflow
27 | import androidx.compose.ui.unit.dp
28 | import androidx.core.net.toUri
29 | import coil.compose.AsyncImage
30 | import coil.request.ImageRequest
31 | import com.craftworks.music.R
32 | import com.craftworks.music.data.MediaData
33 | import com.craftworks.music.data.radioList
34 | import com.craftworks.music.ui.screens.selectedRadioIndex
35 | import com.craftworks.music.ui.screens.showRadioModifyDialog
36 |
37 | @OptIn(ExperimentalFoundationApi::class)
38 | @Stable
39 | @Composable
40 | fun RadioCard(radio: MediaData.Radio, onClick: () -> Unit) {
41 | Column(
42 | modifier = Modifier
43 | .padding(12.dp)
44 | .clip(RoundedCornerShape(12.dp))
45 | .combinedClickable(
46 | onClick = { onClick(); Log.d("Play", "Clicked Radio: " + radio.name) },
47 | onLongClick = {
48 | showRadioModifyDialog.value = true
49 | selectedRadioIndex.intValue =
50 | radioList.indexOf(radioList.firstOrNull { it.name == radio.name && it.media == radio.media })
51 | },
52 | onLongClickLabel = "Modify Radio"
53 | )
54 | .widthIn(min = 128.dp)
55 | .clip(RoundedCornerShape(12.dp)),
56 | horizontalAlignment = Alignment.CenterHorizontally
57 | ) {
58 | AsyncImage(
59 | // Use generic radio image as we cannot get the radio's logo reliably.
60 | model = ImageRequest.Builder(LocalContext.current)
61 | .data(("android.resource://com.craftworks.music/" + R.drawable.radioplaceholder).toUri())
62 | .crossfade(true).size(256).build(),
63 | fallback = painterResource(R.drawable.placeholder),
64 | contentScale = ContentScale.FillWidth,
65 | contentDescription = "Album Image",
66 | modifier = Modifier
67 | .fillMaxSize()
68 | .clip(RoundedCornerShape(12.dp))
69 | .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp))
70 | )
71 |
72 | Text(
73 | text = radio.name,
74 | style = MaterialTheme.typography.titleMedium,
75 | fontWeight = FontWeight.SemiBold,
76 | color = MaterialTheme.colorScheme.onBackground,
77 | modifier = Modifier
78 | .fillMaxSize()
79 | .wrapContentHeight(align = Alignment.CenterVertically),
80 | maxLines = 1,
81 | overflow = TextOverflow.Ellipsis,
82 | textAlign = TextAlign.Center
83 | )
84 | }
85 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/VerticalText.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements
2 |
3 | import androidx.compose.ui.Modifier
4 | import androidx.compose.ui.draw.rotate
5 | import androidx.compose.ui.layout.layout
6 |
7 | fun Modifier.rotateVertically(clockwise: Boolean = true): Modifier {
8 | val rotate = rotate(if (clockwise) 90f else -90f)
9 |
10 | val adjustBounds = layout { measurable, constraints ->
11 | val placeable = measurable.measure(constraints)
12 | layout(placeable.height, placeable.width) {
13 | placeable.place(
14 | x = -(placeable.width / 2 - placeable.height / 2),
15 | y = -(placeable.height / 2 - placeable.width / 2)
16 | )
17 | }
18 | }
19 | return rotate then adjustBounds
20 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/dialogs/SortDialogs.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements.dialogs
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.shape.RoundedCornerShape
9 | import androidx.compose.material3.MaterialTheme
10 | import androidx.compose.material3.RadioButton
11 | import androidx.compose.material3.Surface
12 | import androidx.compose.material3.Text
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.mutableStateOf
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.res.stringResource
18 | import androidx.compose.ui.semantics.contentDescription
19 | import androidx.compose.ui.semantics.semantics
20 | import androidx.compose.ui.text.font.FontWeight
21 | import androidx.compose.ui.tooling.preview.Preview
22 | import androidx.compose.ui.unit.dp
23 | import androidx.compose.ui.window.Dialog
24 | import com.craftworks.music.R
25 | import com.craftworks.music.ui.elements.bounceClick
26 |
27 | //region PREVIEWS
28 | @Preview(showBackground = true)
29 | @Composable
30 | fun PreviewSortingDialog(){
31 | SortingDialog(setShowDialog = { })
32 | }
33 | //endregion
34 |
35 | val sortingList = listOf(
36 | "Title",
37 | "Recently Added",
38 | "Release Year",
39 | "Times Played"
40 | )
41 | var albumScreenSorting = mutableStateOf(sortingList[0])
42 |
43 | @Composable
44 | fun SortingDialog(setShowDialog: (Boolean) -> Unit) {
45 | Dialog(onDismissRequest = { setShowDialog(false) }) {
46 | Surface(
47 | shape = RoundedCornerShape(24.dp),
48 | ) {
49 | Box(
50 | contentAlignment = Alignment.Center
51 | ) {
52 | Column(modifier = Modifier.padding(24.dp)) {
53 | Text(
54 | text = stringResource(R.string.Label_Sorting),
55 | fontWeight = FontWeight.SemiBold,
56 | fontSize = MaterialTheme.typography.headlineSmall.fontSize,
57 | color = MaterialTheme.colorScheme.onBackground,
58 | modifier = Modifier.padding(bottom = 24.dp)
59 | )
60 | for (option in sortingList) {
61 | Row(
62 | verticalAlignment = Alignment.CenterVertically, modifier = Modifier
63 | .height(48.dp)
64 | ) {
65 | RadioButton(
66 | selected = option == albumScreenSorting.value,
67 | onClick = {
68 | albumScreenSorting.value = option
69 | setShowDialog(false)
70 | },
71 | modifier = Modifier
72 | .semantics { contentDescription = option }
73 | .bounceClick()
74 | )
75 | Text(
76 | text = option,
77 | fontWeight = FontWeight.Normal,
78 | fontSize = MaterialTheme.typography.titleMedium.fontSize,
79 | color = MaterialTheme.colorScheme.onBackground
80 | )
81 | }
82 | }
83 | }
84 | }
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/elements/dialogs/dialogFocusable.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.elements.dialogs
2 |
3 | import androidx.compose.foundation.ExperimentalFoundationApi
4 | import androidx.compose.foundation.focusGroup
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.ui.ExperimentalComposeUiApi
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.composed
10 | import androidx.compose.ui.focus.FocusDirection
11 | import androidx.compose.ui.focus.FocusRequester
12 | import androidx.compose.ui.focus.focusProperties
13 | import androidx.compose.ui.focus.focusRequester
14 | import androidx.compose.ui.platform.LocalFocusManager
15 |
16 | /**
17 | * Makes the current dialog a focus group with a [FocusRequester] and restricts the focus from
18 | * exiting its bounds while it's visible.
19 | */
20 | @ExperimentalComposeUiApi
21 | @ExperimentalFoundationApi
22 | fun Modifier.dialogFocusable() = composed {
23 | val focusRequester = remember { FocusRequester() }
24 | val focusManager = LocalFocusManager.current
25 |
26 | LaunchedEffect(Unit) {
27 | focusRequester.requestFocus()
28 | focusManager.moveFocus(FocusDirection.Enter)
29 | }
30 | this.then(
31 | Modifier
32 | .focusRequester(focusRequester)
33 | .focusProperties { exit = { FocusRequester.Cancel } }
34 | .focusGroup()
35 | )
36 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/screens/PlaylistScreen.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.screens
2 |
3 | import android.content.res.Configuration
4 | import androidx.compose.foundation.ExperimentalFoundationApi
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.WindowInsets
8 | import androidx.compose.foundation.layout.asPaddingValues
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.layout.size
12 | import androidx.compose.foundation.layout.statusBars
13 | import androidx.compose.material3.ExperimentalMaterial3Api
14 | import androidx.compose.material3.Icon
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.Text
17 | import androidx.compose.material3.pulltorefresh.PullToRefreshBox
18 | import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.runtime.getValue
21 | import androidx.compose.runtime.mutableStateOf
22 | import androidx.compose.runtime.remember
23 | import androidx.compose.runtime.rememberCoroutineScope
24 | import androidx.compose.runtime.setValue
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.graphics.vector.ImageVector
28 | import androidx.compose.ui.platform.LocalConfiguration
29 | import androidx.compose.ui.platform.LocalContext
30 | import androidx.compose.ui.res.stringResource
31 | import androidx.compose.ui.res.vectorResource
32 | import androidx.compose.ui.text.font.FontWeight
33 | import androidx.compose.ui.tooling.preview.Preview
34 | import androidx.compose.ui.unit.dp
35 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
36 | import androidx.navigation.NavHostController
37 | import androidx.navigation.compose.rememberNavController
38 | import com.craftworks.music.R
39 | import com.craftworks.music.data.Screen
40 | import com.craftworks.music.ui.elements.HorizontalLineWithNavidromeCheck
41 | import com.craftworks.music.ui.elements.PlaylistGrid
42 | import com.craftworks.music.ui.elements.dialogs.DeletePlaylist
43 | import com.craftworks.music.ui.elements.dialogs.showDeletePlaylistDialog
44 | import com.craftworks.music.ui.viewmodels.PlaylistScreenViewModel
45 | import kotlinx.coroutines.launch
46 |
47 | @OptIn(ExperimentalMaterial3Api::class)
48 | @ExperimentalFoundationApi
49 | @Preview(showBackground = true, showSystemUi = false)
50 | @Composable
51 | fun PlaylistScreen(
52 | navHostController: NavHostController = rememberNavController(),
53 | viewModel: PlaylistScreenViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
54 | ) {
55 | val leftPadding = if (LocalConfiguration.current.orientation != Configuration.ORIENTATION_LANDSCAPE) 0.dp else 80.dp
56 |
57 | val context = LocalContext.current
58 |
59 | val playlists by viewModel.allPlaylists.collectAsStateWithLifecycle()
60 |
61 | val state = rememberPullToRefreshState()
62 | var isRefreshing by remember { mutableStateOf(false) }
63 |
64 | val coroutineScope = rememberCoroutineScope()
65 |
66 | val onRefresh: () -> Unit = {
67 | coroutineScope.launch {
68 | isRefreshing = true
69 |
70 | viewModel.reloadData(context)
71 |
72 | isRefreshing = false
73 | }
74 | }
75 |
76 |
77 | PullToRefreshBox(
78 | state = state,
79 | isRefreshing = isRefreshing,
80 | onRefresh = onRefresh
81 | ) {
82 | Column(modifier = Modifier
83 | .fillMaxSize()
84 | .padding(
85 | start = leftPadding,
86 | top = WindowInsets.statusBars
87 | .asPaddingValues()
88 | .calculateTopPadding()
89 | )) {
90 | /* HEADER */
91 | Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 12.dp)) {
92 | Icon(
93 | imageVector = ImageVector.vectorResource(R.drawable.placeholder),
94 | contentDescription = "Songs Icon",
95 | tint = MaterialTheme.colorScheme.onBackground,
96 | modifier = Modifier.size(48.dp))
97 | Text(
98 | text = stringResource(R.string.playlists),
99 | color = MaterialTheme.colorScheme.onBackground,
100 | fontWeight = FontWeight.Bold,
101 | fontSize = MaterialTheme.typography.headlineLarge.fontSize
102 | )
103 | }
104 |
105 | HorizontalLineWithNavidromeCheck()
106 |
107 | PlaylistGrid(playlists, onPlaylistSelected = { playlist ->
108 | viewModel.setCurrentPlaylist(playlist)
109 | navHostController.navigate(Screen.PlaylistDetails.route) {
110 | launchSingleTop = true
111 | }
112 | })
113 |
114 | if(showDeletePlaylistDialog.value)
115 | DeletePlaylist(
116 | setShowDialog = { showDeletePlaylistDialog.value = it },
117 | onDeleted = { onRefresh.invoke()}
118 | )
119 | }
120 | }
121 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.theme
2 |
3 | import android.app.Activity
4 | import android.content.res.Configuration
5 | import android.os.Build
6 | import androidx.compose.foundation.isSystemInDarkTheme
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.material3.darkColorScheme
9 | import androidx.compose.material3.dynamicDarkColorScheme
10 | import androidx.compose.material3.dynamicLightColorScheme
11 | import androidx.compose.material3.lightColorScheme
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.SideEffect
14 | import androidx.compose.runtime.collectAsState
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.graphics.toArgb
17 | import androidx.compose.ui.platform.LocalConfiguration
18 | import androidx.compose.ui.platform.LocalContext
19 | import androidx.compose.ui.platform.LocalView
20 | import androidx.core.view.WindowCompat
21 | import com.craftworks.music.managers.SettingsManager
22 |
23 | private val DarkColorScheme = darkColorScheme(
24 | primary = Purple80,
25 | secondary = PurpleGrey80,
26 | tertiary = Pink80
27 | )
28 |
29 | private val LightColorScheme = lightColorScheme(
30 | primary = Purple40,
31 | secondary = PurpleGrey40,
32 | tertiary = Pink40
33 |
34 | /* Other default colors to override
35 | background = Color(0xFFFFFBFE),
36 | surface = Color(0xFFFFFBFE),
37 | onPrimary = Color.White,
38 | onSecondary = Color.White,
39 | onTertiary = Color.White,
40 | onBackground = Color(0xFF1C1B1F),
41 | onSurface = Color(0xFF1C1B1F),
42 | */
43 | )
44 |
45 | @Composable
46 | fun MusicPlayerTheme(
47 | darkTheme: Boolean = isSystemInDarkTheme(),
48 | // Dynamic color is available on Android 12+
49 | dynamicColor: Boolean = true,
50 | content: @Composable () -> Unit
51 | ) {
52 | var colorScheme = when {
53 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
54 | val context = LocalContext.current
55 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
56 | }
57 | darkTheme -> DarkColorScheme
58 | else -> LightColorScheme
59 | }
60 |
61 | // If on TV, read the saved theme from the datastore and use it.
62 | // Kinda hacky solution, but should work.
63 | if(LocalConfiguration.current.uiMode and Configuration.UI_MODE_TYPE_MASK == Configuration.UI_MODE_TYPE_TELEVISION) {
64 | val theme =
65 | SettingsManager(LocalContext.current).appTheme.collectAsState(SettingsManager.Companion.AppTheme.SYSTEM.name).value
66 | colorScheme = when {
67 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
68 | val context = LocalContext.current
69 | if (theme == SettingsManager.Companion.AppTheme.DARK.name) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
70 | }
71 | theme == SettingsManager.Companion.AppTheme.DARK.name -> DarkColorScheme
72 | theme == SettingsManager.Companion.AppTheme.SYSTEM.name -> DarkColorScheme
73 | else -> when (LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
74 | Configuration.UI_MODE_NIGHT_NO -> LightColorScheme
75 | Configuration.UI_MODE_NIGHT_YES -> DarkColorScheme
76 | else -> DarkColorScheme
77 | }
78 | }
79 | }
80 |
81 | val view = LocalView.current
82 | if (!view.isInEditMode) {
83 | SideEffect {
84 | val window = (view.context as Activity).window
85 | window.statusBarColor = Color.Transparent.toArgb()
86 | /*window.navigationBarColor = colorScheme.surfaceColorAtElevation(3.dp).toArgb()*/
87 | window.navigationBarColor = Color.Transparent.toArgb()
88 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
89 | }
90 | }
91 |
92 |
93 | MaterialTheme(
94 | colorScheme = colorScheme,
95 | typography = Typography,
96 | content = content
97 | )
98 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/viewmodels/AlbumScreenViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.viewmodels
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.media3.common.MediaItem
6 | import com.craftworks.music.providers.getAlbums
7 | import com.craftworks.music.providers.searchAlbum
8 | import kotlinx.coroutines.async
9 | import kotlinx.coroutines.coroutineScope
10 | import kotlinx.coroutines.flow.MutableStateFlow
11 | import kotlinx.coroutines.flow.StateFlow
12 | import kotlinx.coroutines.flow.asStateFlow
13 | import kotlinx.coroutines.launch
14 |
15 | class AlbumScreenViewModel : ViewModel(), ReloadableViewModel {
16 | private val _allAlbums = MutableStateFlow>(emptyList())
17 | val allAlbums: StateFlow> = _allAlbums.asStateFlow()
18 |
19 | override fun reloadData() {
20 | viewModelScope.launch {
21 | coroutineScope {
22 | val allAlbumsDeferred = async { getAlbums("alphabeticalByName", 20, 0, true) }
23 |
24 | _allAlbums.value = allAlbumsDeferred.await().sortedByDescending {
25 | it.mediaMetadata.extras?.getString("navidromeID")!!.startsWith("Local_")
26 | }
27 | }
28 | }
29 | }
30 |
31 | fun getMoreAlbums(sort: String? = "alphabeticalByName" , size: Int){
32 | viewModelScope.launch {
33 | val albumOffset = _allAlbums.value.size
34 | val newAlbums = getAlbums(sort, size, albumOffset)
35 | _allAlbums.value += newAlbums
36 | }
37 | }
38 |
39 | suspend fun search(query: String){
40 | _allAlbums.value = searchAlbum(query)
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/viewmodels/ArtistsScreenViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.viewmodels
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.craftworks.music.data.MediaData
6 | import com.craftworks.music.providers.getArtistDetails
7 | import com.craftworks.music.providers.getArtists
8 | import com.craftworks.music.providers.searchArtist
9 | import kotlinx.coroutines.async
10 | import kotlinx.coroutines.coroutineScope
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.StateFlow
13 | import kotlinx.coroutines.flow.asStateFlow
14 | import kotlinx.coroutines.launch
15 | import kotlinx.coroutines.runBlocking
16 |
17 | class ArtistsScreenViewModel : ViewModel(), ReloadableViewModel {
18 | private val _allArtists = MutableStateFlow>(emptyList())
19 | val allArtists: StateFlow> = _allArtists.asStateFlow()
20 |
21 | private val _selectedArtist = MutableStateFlow(null)
22 | val selectedArtist: StateFlow = _selectedArtist
23 |
24 | override fun reloadData() {
25 | viewModelScope.launch {
26 | coroutineScope {
27 | _allArtists.value = getArtists()
28 | }
29 | }
30 | }
31 |
32 | suspend fun search(query: String) {
33 | _allArtists.value = searchArtist(query)
34 | }
35 |
36 | fun setSelectedArtist(artist: MediaData.Artist) {
37 | _selectedArtist.value = artist
38 | runBlocking {
39 | val detailedArtist = async { getArtistDetails(artist.navidromeID) }
40 | _selectedArtist.value = detailedArtist.await()
41 | println("New Artist: ${_selectedArtist.value} \n Selected Artist: ${com.craftworks.music.data.selectedArtist}")
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/viewmodels/GlobalViewModels.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.viewmodels
2 |
3 | import android.util.Log
4 |
5 | // Interface for ViewModels that can reload
6 | interface ReloadableViewModel {
7 | fun reloadData()
8 | }
9 |
10 | object GlobalViewModels {
11 | private val viewModels = mutableSetOf()
12 |
13 | fun registerViewModel(viewModel: ReloadableViewModel) {
14 | viewModels.add(viewModel)
15 | }
16 |
17 | // Refresh functions
18 | fun refreshAll() {
19 | Log.d("NAVIDROME", "Reloading all ViewModels")
20 | viewModels.forEach { it.reloadData() }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/viewmodels/HomeScreenViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.viewmodels
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.media3.common.MediaItem
6 | import com.craftworks.music.providers.getAlbums
7 | import kotlinx.coroutines.async
8 | import kotlinx.coroutines.coroutineScope
9 | import kotlinx.coroutines.flow.MutableStateFlow
10 | import kotlinx.coroutines.flow.StateFlow
11 | import kotlinx.coroutines.flow.asStateFlow
12 | import kotlinx.coroutines.launch
13 |
14 | class HomeScreenViewModel : ViewModel(), ReloadableViewModel {
15 | private val _recentlyPlayedAlbums = MutableStateFlow>(emptyList())
16 | val recentlyPlayedAlbums: StateFlow> = _recentlyPlayedAlbums.asStateFlow()
17 |
18 | private val _recentAlbums = MutableStateFlow>(emptyList())
19 | val recentAlbums: StateFlow> = _recentAlbums.asStateFlow()
20 |
21 | private val _mostPlayedAlbums = MutableStateFlow>(emptyList())
22 | val mostPlayedAlbums: StateFlow> = _mostPlayedAlbums.asStateFlow()
23 |
24 | private val _shuffledAlbums = MutableStateFlow>(emptyList())
25 | val shuffledAlbums: StateFlow> = _shuffledAlbums.asStateFlow()
26 |
27 | override fun reloadData() {
28 | viewModelScope.launch {
29 | coroutineScope {
30 | val recentlyPlayedDeferred = async { getAlbums("recent", 20, 0, true) }
31 | val recentDeferred = async { getAlbums("newest", 20, 0, true) }
32 | val mostPlayedDeferred = async { getAlbums("frequent", 20, 0, true) }
33 | val shuffledDeferred = async { getAlbums("random", 20, 0, true) }
34 |
35 | _recentlyPlayedAlbums.value = recentlyPlayedDeferred.await()
36 | _recentAlbums.value = recentDeferred.await()
37 | _mostPlayedAlbums.value = mostPlayedDeferred.await()
38 | _shuffledAlbums.value = shuffledDeferred.await()
39 | }
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/viewmodels/PlaylistScreenViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.viewmodels
2 |
3 | import android.content.Context
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.viewModelScope
6 | import androidx.media3.common.MediaItem
7 | import androidx.media3.common.MediaMetadata
8 | import com.craftworks.music.providers.getFavouriteSongs
9 | import com.craftworks.music.providers.getPlaylistDetails
10 | import com.craftworks.music.providers.getPlaylists
11 | import com.craftworks.music.providers.local.localPlaylistImageGenerator
12 | import kotlinx.coroutines.async
13 | import kotlinx.coroutines.coroutineScope
14 | import kotlinx.coroutines.flow.MutableStateFlow
15 | import kotlinx.coroutines.flow.StateFlow
16 | import kotlinx.coroutines.flow.asStateFlow
17 | import kotlinx.coroutines.launch
18 |
19 | class PlaylistScreenViewModel : ViewModel() {
20 | private val _allPlaylists = MutableStateFlow>(emptyList())
21 | val allPlaylists: StateFlow> = _allPlaylists.asStateFlow()
22 |
23 | private var _selectedPlaylist = MutableStateFlow(null)
24 | val selectedPlaylist: StateFlow = _selectedPlaylist
25 |
26 | private var _selectedPlaylistSongs = MutableStateFlow>(emptyList())
27 | val selectedPlaylistSongs: StateFlow> = _selectedPlaylistSongs.asStateFlow()
28 |
29 | fun setCurrentPlaylist(playlist: MediaItem){
30 | _selectedPlaylistSongs.value = emptyList()
31 | _selectedPlaylist.value = playlist
32 | }
33 |
34 | suspend fun updatePlaylistsImages(context: Context) {
35 | _allPlaylists.value = _allPlaylists.value.map {
36 | if (it.mediaMetadata.extras?.getString("navidromeID")?.startsWith("Local") == true) {
37 | val songs = getPlaylistDetails(it.mediaMetadata.extras?.getString("navidromeID") ?: "", true)
38 | it.buildUpon()
39 | .setMediaMetadata(
40 | it.mediaMetadata.buildUpon()
41 | .setArtworkData(localPlaylistImageGenerator(songs ?: emptyList(), context), MediaMetadata.PICTURE_TYPE_OTHER)
42 | .build()
43 | )
44 | .build()
45 | } else {
46 | it
47 | }
48 | }
49 | }
50 |
51 |
52 | fun reloadData(context: Context) {
53 | viewModelScope.launch {
54 | coroutineScope {
55 | val allPlaylistsDeferred = async { getPlaylists(context, true) }
56 |
57 | _allPlaylists.value = allPlaylistsDeferred.await()
58 | }
59 | }
60 | }
61 |
62 | fun fetchPlaylistDetails() {
63 | //if (!NavidromeManager.checkActiveServers()) return
64 |
65 | if (_selectedPlaylist.value == null) return
66 |
67 | println("Fetching playlist details for playlist ID: ${_selectedPlaylist.value?.mediaMetadata?.extras?.getString("navidromeID")}")
68 |
69 | viewModelScope.launch {
70 | coroutineScope {
71 | if (_selectedPlaylist.value?.mediaMetadata?.extras?.getString("navidromeID") == "favourites") {
72 | val favouriteSongs = async { getFavouriteSongs() }
73 | _selectedPlaylistSongs.value = favouriteSongs.await()
74 | }
75 | else {
76 | val selectedPlaylistDeferred = async { getPlaylistDetails(_selectedPlaylist.value?.mediaMetadata?.extras?.getString("navidromeID") ?: "") }
77 |
78 | _selectedPlaylistSongs.value = selectedPlaylistDeferred.await() ?: emptyList()
79 | }
80 | }
81 | }
82 | }
83 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/craftworks/music/ui/viewmodels/SongsScreenViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.craftworks.music.ui.viewmodels
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import androidx.media3.common.MediaItem
6 | import com.craftworks.music.providers.getSongs
7 | import kotlinx.coroutines.coroutineScope
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.StateFlow
10 | import kotlinx.coroutines.flow.asStateFlow
11 | import kotlinx.coroutines.launch
12 |
13 | class SongsScreenViewModel : ViewModel(), ReloadableViewModel {
14 | private val _allSongs = MutableStateFlow>(emptyList())
15 | val allSongs: StateFlow> = _allSongs.asStateFlow()
16 |
17 | override fun reloadData() {
18 | viewModelScope.launch {
19 | coroutineScope {
20 | _allSongs.value = getSongs()
21 | }
22 | }
23 | }
24 | fun getMoreSongs(size: Int){
25 | viewModelScope.launch {
26 | val songOffset = _allSongs.value.size
27 | _allSongs.value += getSongs(songCount = size, songOffset = songOffset)
28 | }
29 | }
30 |
31 | suspend fun search(query: String){
32 | _allSongs.value = getSongs(query, 500)
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable-hdpi/ic_notification_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/drawable-hdpi/ic_notification_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-mdpi/ic_notification_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/drawable-mdpi/ic_notification_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xhdpi/ic_notification_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/drawable-xhdpi/ic_notification_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/albumplaceholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/drawable/albumplaceholder.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_drag_handle_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/chevron_down.xml:
--------------------------------------------------------------------------------
1 |
6 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_banner_background.xml:
--------------------------------------------------------------------------------
1 |
7 |
11 |
13 |
14 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_banner_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
14 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/lrclib_logo.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/lyrics_active.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/lyrics_inactive.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/media3_notification_pause.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/media3_notification_seek_to_next.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/media3_notification_seek_to_previous.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/media3_notification_small_icon.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/placeholder.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/radioplaceholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/drawable/radioplaceholder.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/round_favorite_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/round_favorite_border_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/round_music_note_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/round_power_settings_new_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/round_shuffle_28.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/round_visibility_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/round_visibility_off_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_add_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_artist_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_cell_tower_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_download_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_home_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_library_music_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_radio.xml:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_repeat1_24.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_repeat_24.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/rounded_settings_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_a_moreinfo.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_a_navbar_items.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_a_palette.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_a_username.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_m_local_filled.xml:
--------------------------------------------------------------------------------
1 |
5 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_m_media_providers.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_m_navidrome.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_m_playback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_p_scrobble.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/s_p_transcoding.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
--------------------------------------------------------------------------------
/app/src/main/res/resources.properties:
--------------------------------------------------------------------------------
1 | unqualifiedResLocale = en
--------------------------------------------------------------------------------
/app/src/main/res/values-it/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Sistema
4 | Fonte Media
5 | Cartella Locale
6 | Percorso
7 | URL Navidrome:
8 | Username
9 | Password
10 | Consenti certificati autofirmati
11 | Transcodifica
12 | Aggiungi Radio Internet
13 | "Modifica "
14 | Nome Radio:
15 | URL Radio:
16 | Sito della Radio
17 | Aggiungi a Navidrome
18 | Nuova Playlist
19 | Cancella Playlist
20 | Aggiungi / Alla Playlist
21 | Nome Playlist:
22 | Reimposta
23 | Cerca
24 | Riproduci
25 | Fatto
26 | Rimuovi
27 | Shuffle
28 | Aggiungi
29 | Successo
30 | Accedi
31 | Esci
32 | Crea Playlist
33 | Benvenuto
34 | Aggiunte Recentemente
35 | Brani
36 | Più Ascoltate
37 | Ascoltate Recentemente
38 | Esplora La Tua Libreria
39 | Aspetto
40 | Fonti Media
41 | Stile Sfondo
42 | Schede Della Barra Di Navigazione
43 | Mostra Altre Info
44 | Riproduzione
45 | Semplice
46 | Sfocato
47 | Sei sicuro/a di voler cancellare questa playlist?
48 | Altre Canzoni Da
49 | Nessuna Descrizione Disponibile.
50 | Discografia
51 | Canzoni Popolari
52 | Bitrate Massimo
53 | Bitrate Massimo (Dati mobili)
54 | Min. Percentuale Scrobble
55 | Solo Navidrome.
56 | Ordina Per
57 | Nessuna connessione al server Navidrome.
58 | Mostra Logo Navidrome
59 | Sincronizzazione con Navidrome in corso…
60 | Non hai aggiunto nessun media provider. Aggiungine uno ora per iniziare ad ascoltare!
61 | Andiamo!
62 | Animato
63 | Stile Dello Sfondo
64 | Nessuna transcodifica
65 | Notifiche di download
66 | Notifiche per lo stato di download
67 | Album
68 | Artisti
69 | Impostazioni
70 | Radio Internet
71 | Playlist
72 | Scaricando
73 | scaricata con successo!
74 | Uh oh, qualcosa è andato storto nel scaricare
75 | Sfocatura Testi
76 | Bitrate Massimo (WiFi)
77 | Tema
78 | Scuro
79 | Chiaro
80 | Scarica
81 | Mostra Divisori Provider
82 | Velocità Animazione Testo
83 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFFFF
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Chora
3 |
4 | Songs
5 | Albums
6 | Artists
7 | Settings
8 | Internet Radio
9 | Playlists
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings_actions.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Done
5 | Remove
6 | Reset
7 |
8 | Search
9 |
10 | Play
11 | Shuffle
12 |
13 |
14 | Add
15 | Success
16 | Login
17 | Let\'s Go!
18 |
19 | Exit
20 |
21 |
22 | Create Playlist
23 |
24 | Download
25 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings_dialogs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Background Style
5 | Plain
6 | Blurred
7 | Animated
8 |
9 |
10 | Theme
11 | Dark
12 | Light
13 | System
14 |
15 |
16 | Media Source
17 | You haven\'t added a media provider yet. Add one now to start listening to some tunes!
18 | Navidrome
19 | Local Folder
20 |
21 | Directory:
22 |
23 | Navidrome URL:
24 | Username:
25 | Password:
26 | Allow self-signed certs
27 |
28 |
29 | Transcoding
30 | No Transcoding
31 |
32 |
33 | Add Internet Radio
34 | Modify
35 | Radio Name:
36 | Radio URL:
37 | Radio Homepage URL:
38 | Add to Navidrome
39 |
40 |
41 | New Playlist
42 | Delete Playlist
43 | Are you sure you want to delete this playlist?
44 | Add / To Playlist
45 | Playlist Name:
46 |
47 |
48 | More Songs From
49 |
50 |
51 | No Description Available.
52 |
53 |
54 | Sort By
55 |
56 |
57 | Navidrome server unreachable.
58 | Navidrome sync in progress…
59 |
60 |
61 | Download Notifications
62 | Notifications for download status
63 | Downloading
64 | downloaded successfully!
65 | Uh oh, something went wrong while downloading
66 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings_screens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Welcome
5 | Recently Added
6 | Most Played
7 | Recently Played
8 | Explore From Your Library
9 |
10 |
11 | Discography
12 | Top Songs
13 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Appearance
4 | Background Style
5 | Navigation Bar Items
6 | Show More Info
7 | Lyrics Blur Effect
8 | Show Navidrome Logo
9 | Show Provider Dividers
10 | Lyrics Animation Speed
11 | Media Providers
12 | Playback
13 | Max Bitrate
14 | Max Bitrate (WiFi)
15 | Max Bitrate (Mobile data)
16 | Min. Scrobble Percentage
17 | Navidrome Only.
18 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/automotive_app_desc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | id("com.android.application") version "8.10.0" apply false
4 | id("org.jetbrains.kotlin.android") version "2.0.21" apply false
5 | id("com.android.library") version "8.10.0" apply false
6 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | activityCompose = "1.10.1"
3 | composeBom = "2025.05.00"
4 | coreKtx = "1.16.0"
5 | espressoCore = "3.6.1"
6 | githubComposefadingedges = "1.0.4"
7 | junit = "4.13.2"
8 | junitVersion = "1.2.1"
9 | konsumeXml = "1.1"
10 | kotlin = "2.1.0"
11 | datastoreCoreAndroid = "1.1.6"
12 | kotlinxSerializationJson = "1.7.3"
13 | lifecycleRuntimeKtx = "2.9.0"
14 | material3Android = "1.3.2"
15 | media = "1.7.0"
16 | media3Exoplayer = "1.7.1"
17 | media3Session = "1.7.1"
18 | media3Ui = "1.7.1"
19 | mediarouter = "1.7.0"
20 | navigationCompose = "2.9.0"
21 | paletteKtx = "1.0.0"
22 | preferenceKtx = "1.2.1"
23 |
24 | [libraries]
25 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
26 | androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
27 | androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
28 | androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastoreCoreAndroid" }
29 | androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" }
30 | androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" }
31 | androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
32 | androidx-material3 = { module = "androidx.compose.material3:material3" }
33 | androidx-material3-android = { module = "androidx.compose.material3:material3-android", version.ref = "material3Android" }
34 | androidx-media = { module = "androidx.media:media", version.ref = "media" }
35 | androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
36 | androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Session" }
37 | androidx-media3-ui = { module = "androidx.media3:media3-ui-compose", version.ref = "media3Ui" }
38 | androidx-mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "mediarouter" }
39 | androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
40 | androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" }
41 | androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
42 | androidx-ui = { module = "androidx.compose.ui:ui" }
43 | androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
44 | androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
45 | androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
46 | androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
47 | androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
48 | coil-compose = { module = "io.coil-kt:coil-compose", version = "2.7.0" }
49 | composefadingedges = { module = "com.github.GIGAMOLE:ComposeFadingEdges", version.ref = "githubComposefadingedges" }
50 | junit = { module = "junit:junit", version.ref = "junit" }
51 | konsume-xml = { module = "com.gitlab.mvysny.konsume-xml:konsume-xml", version.ref = "konsumeXml" }
52 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
53 | reorderable = { module = "sh.calvin.reorderable:reorderable", version = "2.4.3" }
54 |
55 | [plugins]
56 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CraftWorksMC/Chora/ba9ba45799f7d9860042451a4997ef1d4fa1aaee/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu Jun 13 15:53:57 CEST 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
--------------------------------------------------------------------------------
/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 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | @Suppress("UnstableApiUsage")
9 | dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | maven {
15 | setUrl("https://jitpack.io")
16 | }
17 | }
18 | }
19 |
20 | rootProject.name = "MusicPlayer"
21 | include(":app")
--------------------------------------------------------------------------------