├── .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 | 80 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 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 | ![Logo](https://github.com/CraftWorksMC/Chora/blob/master/Github/Images/ChoraBannerTransparent.png?raw=true) 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 | Get it on Google Play 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 | Donate with PayPal 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 |