├── metadata └── en-US │ ├── title.txt │ ├── short_description.txt │ ├── images │ └── icon.png │ └── full_description.txt ├── app ├── src │ ├── main │ │ ├── res │ │ │ ├── xml │ │ │ │ └── automotive_app_desc.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 │ │ │ ├── drawable-hdpi │ │ │ │ └── background.webp │ │ │ ├── drawable-mdpi │ │ │ │ └── background.webp │ │ │ ├── drawable-xhdpi │ │ │ │ └── background.webp │ │ │ ├── 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 │ │ │ ├── drawable-xxhdpi │ │ │ │ └── background.webp │ │ │ ├── drawable-xxxhdpi │ │ │ │ └── background.webp │ │ │ ├── drawable-nodpi │ │ │ │ ├── default_avatar.webp │ │ │ │ └── default_album_art.webp │ │ │ ├── values-si-rLK │ │ │ │ ├── plurals.xml │ │ │ │ └── strings.xml │ │ │ ├── values-cs-rCZ │ │ │ │ ├── plurals.xml │ │ │ │ └── strings.xml │ │ │ ├── values-eo-rUY │ │ │ │ ├── plurals.xml │ │ │ │ └── strings.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ └── ic_launcher.xml │ │ │ ├── drawable │ │ │ │ ├── splash.xml │ │ │ │ ├── ic_play_arrow_24dp.xml │ │ │ │ ├── ic_pause_24dp.xml │ │ │ │ ├── ic_icon.xml │ │ │ │ ├── ic_star_24dp.xml │ │ │ │ ├── ic_star_border_24dp.xml │ │ │ │ └── logo.xml │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ ├── plurals.xml │ │ │ │ ├── themes.xml │ │ │ │ └── donottranslate.xml │ │ │ ├── values-ja-rJP │ │ │ │ └── plurals.xml │ │ │ ├── values-ko-rKR │ │ │ │ └── plurals.xml │ │ │ ├── values-zh-rCN │ │ │ │ └── plurals.xml │ │ │ ├── values-zh-rTW │ │ │ │ └── plurals.xml │ │ │ ├── values-vi-rVN │ │ │ │ └── plurals.xml │ │ │ ├── values-in-rID │ │ │ │ └── plurals.xml │ │ │ ├── values-ms-rMY │ │ │ │ └── plurals.xml │ │ │ ├── values-sv-rSE │ │ │ │ └── plurals.xml │ │ │ ├── values-en-rGB │ │ │ │ └── plurals.xml │ │ │ ├── values-es-rES │ │ │ │ └── plurals.xml │ │ │ ├── values-it-rIT │ │ │ │ └── plurals.xml │ │ │ ├── values-tr-rTR │ │ │ │ └── plurals.xml │ │ │ ├── values-nl-rNL │ │ │ │ └── plurals.xml │ │ │ ├── values-fr-rFR │ │ │ │ └── plurals.xml │ │ │ ├── values-ca-rES │ │ │ │ └── plurals.xml │ │ │ ├── values-de-rDE │ │ │ │ └── plurals.xml │ │ │ ├── values-pt-rPT │ │ │ │ └── plurals.xml │ │ │ ├── values-pl-rPL │ │ │ │ └── plurals.xml │ │ │ ├── values-lt-rLT │ │ │ │ └── plurals.xml │ │ │ └── values-ru-rRU │ │ │ │ └── plurals.xml │ │ ├── graphql │ │ │ └── me │ │ │ │ └── echeung │ │ │ │ └── moemoekyun │ │ │ │ ├── Requests.graphql │ │ │ │ ├── Users.graphql │ │ │ │ ├── Auth.graphql │ │ │ │ ├── Favorites.graphql │ │ │ │ └── Songs.graphql │ │ ├── kotlin │ │ │ └── me │ │ │ │ └── echeung │ │ │ │ └── moemoekyun │ │ │ │ ├── domain │ │ │ │ ├── user │ │ │ │ │ ├── model │ │ │ │ │ │ ├── DomainUser.kt │ │ │ │ │ │ └── UserConverter.kt │ │ │ │ │ ├── interactor │ │ │ │ │ │ ├── Register.kt │ │ │ │ │ │ ├── GetAuthenticatedUser.kt │ │ │ │ │ │ └── LoginLogout.kt │ │ │ │ │ └── UserService.kt │ │ │ │ ├── songs │ │ │ │ │ ├── interactor │ │ │ │ │ │ ├── FavoriteSong.kt │ │ │ │ │ │ ├── GetSong.kt │ │ │ │ │ │ ├── RequestSong.kt │ │ │ │ │ │ ├── GetFavoriteSongs.kt │ │ │ │ │ │ └── GetSongs.kt │ │ │ │ │ ├── model │ │ │ │ │ │ ├── DomainSong.kt │ │ │ │ │ │ └── SongConverter.kt │ │ │ │ │ └── SongsService.kt │ │ │ │ └── radio │ │ │ │ │ ├── interactor │ │ │ │ │ ├── SetStation.kt │ │ │ │ │ └── CurrentSong.kt │ │ │ │ │ └── RadioService.kt │ │ │ │ ├── client │ │ │ │ ├── model │ │ │ │ │ ├── Event.kt │ │ │ │ │ ├── User.kt │ │ │ │ │ ├── SongDescriptor.kt │ │ │ │ │ └── Song.kt │ │ │ │ ├── api │ │ │ │ │ ├── Station.kt │ │ │ │ │ ├── data │ │ │ │ │ │ └── DataTransformer.kt │ │ │ │ │ └── socket │ │ │ │ │ │ └── ResponseModel.kt │ │ │ │ └── auth │ │ │ │ │ └── AuthUtil.kt │ │ │ │ ├── ui │ │ │ │ ├── common │ │ │ │ │ ├── Constants.kt │ │ │ │ │ ├── LoadingScreen.kt │ │ │ │ │ ├── BackgroundBox.kt │ │ │ │ │ ├── preferences │ │ │ │ │ │ ├── PreferenceGroupHeader.kt │ │ │ │ │ │ ├── SwitchPreference.kt │ │ │ │ │ │ ├── TextPreference.kt │ │ │ │ │ │ ├── BasePreference.kt │ │ │ │ │ │ └── ListPreference.kt │ │ │ │ │ ├── AlbumArt.kt │ │ │ │ │ ├── SongsList.kt │ │ │ │ │ ├── DropdownMenu.kt │ │ │ │ │ └── SongsListActions.kt │ │ │ │ ├── screen │ │ │ │ │ ├── settings │ │ │ │ │ │ ├── SettingsScreenModel.kt │ │ │ │ │ │ └── SettingsScreen.kt │ │ │ │ │ ├── about │ │ │ │ │ │ └── LicensesScreen.kt │ │ │ │ │ ├── songs │ │ │ │ │ │ ├── SongsScreen.kt │ │ │ │ │ │ ├── SongsScreenModel.kt │ │ │ │ │ │ └── SongDetails.kt │ │ │ │ │ ├── auth │ │ │ │ │ │ ├── RegisterScreenModel.kt │ │ │ │ │ │ └── LoginScreenModel.kt │ │ │ │ │ ├── home │ │ │ │ │ │ ├── UnauthedHomeContent.kt │ │ │ │ │ │ └── HomeScreenModel.kt │ │ │ │ │ └── search │ │ │ │ │ │ ├── SearchScreenModel.kt │ │ │ │ │ │ └── SearchScreen.kt │ │ │ │ ├── theme │ │ │ │ │ └── Theme.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ └── util │ │ │ │ │ └── PaddingValues.kt │ │ │ │ ├── util │ │ │ │ ├── ext │ │ │ │ │ ├── StationExtensions.kt │ │ │ │ │ ├── PlayerExtensions.kt │ │ │ │ │ ├── PreferenceExtensions.kt │ │ │ │ │ ├── CoroutineExtensions.kt │ │ │ │ │ └── ContextExtension.kt │ │ │ │ ├── system │ │ │ │ │ ├── NetworkUtil.kt │ │ │ │ │ └── LocaleUtil.kt │ │ │ │ ├── PreferenceUtil.kt │ │ │ │ ├── SongsSorter.kt │ │ │ │ └── AlbumArtUtil.kt │ │ │ │ ├── di │ │ │ │ ├── ServiceModule.kt │ │ │ │ ├── SerializationModule.kt │ │ │ │ ├── ContextModule.kt │ │ │ │ ├── VoyagerModule.kt │ │ │ │ ├── MediaModule.kt │ │ │ │ └── NetworkModule.kt │ │ │ │ ├── service │ │ │ │ ├── PlaybackServiceSessionListener.kt │ │ │ │ ├── PlaybackDontBeNoisyReceiver.kt │ │ │ │ ├── PlaybackServicePlayerListener.kt │ │ │ │ └── PlaybackPlayer.kt │ │ │ │ └── App.kt │ │ └── AndroidManifest.xml │ ├── debug │ │ └── res │ │ │ └── values │ │ │ └── strings.xml │ └── test │ │ └── kotlin │ │ └── me │ │ └── echeung │ │ └── moemoekyun │ │ └── client │ │ └── api │ │ └── socket │ │ └── ResponseModelTest.kt ├── proguard-rules.pro └── build.gradle.kts ├── distribution ├── images │ ├── banner.png │ ├── logo.png │ ├── promo.png │ └── listen-moe.psd └── whatsnew │ └── whatsnew-en-US ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .editorconfig ├── .github ├── renovate.json5 ├── ISSUE_TEMPLATE.md └── workflows │ └── build.yml ├── .well-known └── assetlinks.json ├── .gitignore ├── gradle.properties ├── settings.gradle.kts ├── LICENSE ├── README.md └── gradlew.bat /metadata/en-US/title.txt: -------------------------------------------------------------------------------- 1 | LISTEN.moe -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Listen to j-pop and anime music radio 24/7 ad-free -------------------------------------------------------------------------------- /app/src/main/res/xml/automotive_app_desc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /distribution/images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/distribution/images/banner.png -------------------------------------------------------------------------------- /distribution/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/distribution/images/logo.png -------------------------------------------------------------------------------- /distribution/images/promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/distribution/images/promo.png -------------------------------------------------------------------------------- /metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /distribution/images/listen-moe.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/distribution/images/listen-moe.psd -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/drawable-hdpi/background.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/drawable-mdpi/background.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/drawable-xhdpi/background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/drawable-xxhdpi/background.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/drawable-xxxhdpi/background.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-nodpi/default_avatar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/drawable-nodpi/default_avatar.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-nodpi/default_album_art.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/drawable-nodpi/default_album_art.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LISTEN-moe/android-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/graphql/me/echeung/moemoekyun/Requests.graphql: -------------------------------------------------------------------------------- 1 | mutation RequestSong($id: Int!, $kpop: Boolean) { 2 | requestSong(id: $id, kpop: $kpop) { 3 | id 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | LISTEN.moe DEBUG 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/user/model/DomainUser.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.user.model 2 | 3 | data class DomainUser(val username: String, val avatarUrl: String?, val bannerUrl: String?) 4 | -------------------------------------------------------------------------------- /app/src/main/res/values-si-rLK/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/client/model/Event.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Event(val name: String, val image: String?) 7 | -------------------------------------------------------------------------------- /distribution/whatsnew/whatsnew-en-US: -------------------------------------------------------------------------------- 1 | - Some under the hood updates 2 | - Update icon 3 | 4 | Come join us on Discord! https://discordapp.com/invite/4S8JYr8 5 | 6 | Help translate this app into your language! https://crwd.in/listenmoe-android-app 7 | -------------------------------------------------------------------------------- /app/src/main/res/values-cs-rCZ/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/graphql/me/echeung/moemoekyun/Users.graphql: -------------------------------------------------------------------------------- 1 | query UserQuery($username: String!) { 2 | user(username: $username) { 3 | uuid 4 | username 5 | displayName 6 | avatarImage 7 | bannerImage 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/client/model/User.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class User(val uuid: String, val displayName: String, val avatarImage: String?, val bannerImage: String?) 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/Constants.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common 2 | 3 | import androidx.compose.ui.Modifier 4 | import androidx.compose.ui.draw.alpha 5 | 6 | const val SECONDARY_ITEM_ALPHA = .78f 7 | 8 | fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SECONDARY_ITEM_ALPHA) 9 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/ext/StationExtensions.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util.ext 2 | 3 | import androidx.media3.common.MediaItem 4 | import me.echeung.moemoekyun.client.api.Station 5 | 6 | fun Station.toMediaItem() = MediaItem.Builder() 7 | .setUri(streamUrl) 8 | .setMediaId(name) 9 | .build() 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/settings/SettingsScreenModel.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.settings 2 | 3 | import cafe.adriel.voyager.core.model.ScreenModel 4 | import me.echeung.moemoekyun.util.PreferenceUtil 5 | import javax.inject.Inject 6 | 7 | class SettingsScreenModel @Inject constructor(val preferenceUtil: PreferenceUtil) : ScreenModel 8 | -------------------------------------------------------------------------------- /app/src/main/res/values-eo-rUY/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 1 minuto 7 | %d minutoj 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/songs/interactor/FavoriteSong.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.songs.interactor 2 | 3 | import me.echeung.moemoekyun.domain.songs.SongsService 4 | import javax.inject.Inject 5 | 6 | class FavoriteSong @Inject constructor(private val songsService: SongsService) { 7 | 8 | suspend fun await(songId: Int): Boolean = songsService.favorite(songId).favorited 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | max_line_length = 120 3 | indent_size = 4 4 | insert_final_newline = true 5 | ktlint_code_style = intellij_idea 6 | ktlint_function_naming_ignore_when_annotated_with = Composable 7 | ij_kotlin_allow_trailing_comma = true 8 | ij_kotlin_allow_trailing_comma_on_call_site = true 9 | ij_kotlin_name_count_to_use_star_import = 2147483647 10 | ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Features: 2 | 3 | * Tune in to the radio 24/7 4 | * No ads 5 | * Login to your account to access your favorites and make requests 6 | * Search for songs to favorite and request 7 | * See the last 2 songs played 8 | * Android Auto support 9 | * Bluetooth and headphone control support 10 | 11 | About: 12 | 13 | * You can listen to the radio on any device with a browser too: https://listen.moe/ -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play_arrow_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/radio/interactor/SetStation.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.radio.interactor 2 | 3 | import me.echeung.moemoekyun.client.api.Station 4 | import me.echeung.moemoekyun.domain.radio.RadioService 5 | import javax.inject.Inject 6 | 7 | class SetStation @Inject constructor(private val radioService: RadioService) { 8 | 9 | fun set(station: Station) { 10 | radioService.setStation(station) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -dontobfuscate 2 | 3 | -keep public class me.echeung.moemoekyun.client.api.socket.response.** 4 | -keep public class me.echeung.moemoekyun.client.model.Event 5 | 6 | # OkHttp 7 | -dontwarn okhttp3.** 8 | -dontwarn okio.** 9 | -dontwarn javax.annotation.** 10 | -dontwarn org.conscrypt.** 11 | # A resource is loaded with a relative path so the package of this class must be preserved. 12 | -keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/client/model/SongDescriptor.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SongDescriptor(val name: String? = null, val nameRomaji: String? = null, val image: String? = null) { 7 | 8 | fun contains(query: String): Boolean = name.orEmpty().contains(query, ignoreCase = true) || 9 | nameRomaji.orEmpty().contains(query, ignoreCase = true) 10 | } 11 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/ext/PlayerExtensions.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util.ext 2 | 3 | import androidx.media3.common.MediaItem 4 | import androidx.media3.common.Player 5 | 6 | fun Player.editCurrentMediaItem(block: MediaItem.Builder.(MediaItem?) -> Unit) { 7 | val mediaItem = currentMediaItem 8 | val builder = mediaItem?.buildUpon() ?: MediaItem.Builder() 9 | block(builder, mediaItem) 10 | replaceMediaItem(0, builder.build()) 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/di/ServiceModule.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.android.components.ServiceComponent 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.MainScope 9 | 10 | @Module 11 | @InstallIn(ServiceComponent::class) 12 | object ServiceModule { 13 | 14 | @Provides 15 | fun scope(): CoroutineScope = MainScope() 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pause_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @color/pink 4 | @color/pink 5 | 6 | #00FFFFFF 7 | #FFFFFF 8 | #C7CCD8 9 | #2D2E2D 10 | #1A1C29 11 | 12 | #F60052 13 | 14 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | extends: [ 4 | 'config:recommended', 5 | ], 6 | schedule: [ 7 | 'on sunday', 8 | ], 9 | packageRules: [ 10 | { 11 | groupName: 'Kotlin', 12 | matchPackageNames: [ 13 | 'androidx.compose.compiler{/,}**', 14 | 'org.jetbrains.kotlin.{/,}**', 15 | 'org.jetbrains.kotlin:{/,}**', 16 | 'com.google.devtools.ksp{/,}**', 17 | ], 18 | }, 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/ext/PreferenceExtensions.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util.ext 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.State 5 | import androidx.compose.runtime.collectAsState 6 | import androidx.compose.runtime.remember 7 | import com.tfcporciuncula.flow.Preference 8 | 9 | @Composable 10 | fun Preference.collectAsState(): State { 11 | val flow = remember(this) { asFlow() } 12 | return flow.collectAsState(initial = get()) 13 | } 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | **Please describe the problem you are having in as much detail as possible:** 8 | 9 | **Further details:** 10 | 11 | - App version: 12 | - Android version: 13 | - Device: 14 | - Priority this should have – please be realistic and elaborate if possible: 15 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/system/NetworkUtil.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util.system 2 | 3 | import android.os.Build 4 | import me.echeung.moemoekyun.BuildConfig 5 | 6 | object NetworkUtil { 7 | 8 | val userAgent: String 9 | get() = String.format( 10 | "%s/%s (%s; %s; Android %s)", 11 | BuildConfig.APPLICATION_ID, 12 | BuildConfig.VERSION_NAME, 13 | Build.DEVICE, 14 | Build.BRAND, 15 | Build.VERSION.SDK_INT, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /.well-known/assetlinks.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "relation": ["delegate_permission/common.get_login_creds"], 3 | "target": { 4 | "namespace": "web", 5 | "site": "https://listen.moe" 6 | } 7 | }, 8 | { 9 | "relation": ["delegate_permission/common.get_login_creds"], 10 | "target": { 11 | "namespace": "android_app", 12 | "package_name": "me.echeung.moemoekyun", 13 | "sha256_cert_fingerprints": [ 14 | "DA:D9:6E:48:F0:FB:C3:B2:4D:CB:61:C5:D9:A0:FB:78:26:F1:4F:FC:68:7D:39:1E:5F:86:26:33:BF:7F:38:97" 15 | ] 16 | } 17 | }] 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_icon.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_star_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values-ja-rJP/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 残りの%d件のリクエスト 6 | 7 | 8 | 9 | %d分 10 | 11 | 12 | 13 | タイマーを%d分にセット 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-ko-rKR/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 요청 %d개 남은 6 | 7 | 8 | 9 | %d 분 10 | 11 | 12 | 13 | 타이머 %d분 동안 설정 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rCN/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 您剩余 %d 次请求 6 | 7 | 8 | 9 | %d 分钟 10 | 11 | 12 | 13 | 设置 %d 分钟倒计时 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rTW/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 點播歌曲剩餘 %d 首 6 | 7 | 8 | 9 | %d 分鐘 10 | 11 | 12 | 13 | 計時設為 %d 分鐘 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/graphql/me/echeung/moemoekyun/Auth.graphql: -------------------------------------------------------------------------------- 1 | mutation LoginMutation($username: String!, $password: String!) { 2 | login(username: $username, password: $password) { 3 | mfa 4 | token 5 | } 6 | } 7 | 8 | mutation LoginMfaMutation($otpToken: String!) { 9 | loginMFA(token: $otpToken) { 10 | token 11 | } 12 | } 13 | 14 | mutation RegisterMutation($email: String!, $username: String!, $password: String!) { 15 | register(email: $email, username: $username, password: $password) { 16 | uuid 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/graphql/me/echeung/moemoekyun/Favorites.graphql: -------------------------------------------------------------------------------- 1 | query FavoritesQuery($username: String!, $offset: Int!, $count: Int!, $kpop: Boolean) { 2 | user(username: $username) { 3 | favorites(offset: $offset, count: $count, kpop: $kpop) { 4 | favorites { 5 | song { 6 | ...songListFields 7 | } 8 | createdAt 9 | } 10 | } 11 | } 12 | } 13 | 14 | mutation FavoriteMutation($id: Int!) { 15 | favoriteSong(id: $id) { 16 | id 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-vi-rVN/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d yêu cầu còn lại 6 | 7 | 8 | 9 | %d phút 10 | 11 | 12 | 13 | Đặt hẹn giờ trong %d phút 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/client/api/Station.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.api 2 | 3 | import androidx.annotation.StringRes 4 | import me.echeung.moemoekyun.R 5 | 6 | enum class Station(val socketUrl: String, val streamUrl: String, @StringRes val labelRes: Int) { 7 | JPOP( 8 | "wss://listen.moe/gateway_v2", 9 | "https://listen.moe/fallback", 10 | R.string.jpop, 11 | ), 12 | KPOP( 13 | "wss://listen.moe/kpop/gateway_v2", 14 | "https://listen.moe/kpop/fallback", 15 | R.string.kpop, 16 | ), 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/res/values-in-rID/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d permintaan tersisa 6 | 7 | 8 | 9 | %d menit 10 | 11 | 12 | 13 | Atur pengatur waktu untuk %d menit 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-ms-rMY/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tinggal %d permintaan 6 | 7 | 8 | 9 | %d minit 10 | 11 | 12 | 13 | Tetapkan pemasa selama %d minit 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/client/model/Song.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Song( 7 | var id: Int = 0, 8 | var title: String? = null, 9 | var titleRomaji: String? = null, 10 | var artists: List? = null, 11 | var sources: List? = null, 12 | var albums: List? = null, 13 | var duration: Int = 0, 14 | var enabled: Boolean = false, 15 | var favorite: Boolean = false, 16 | var favoritedAt: Long? = null, 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/di/SerializationModule.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import kotlinx.serialization.json.Json 8 | import javax.inject.Singleton 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | object SerializationModule { 13 | 14 | @Provides 15 | @Singleton 16 | fun json() = Json { 17 | ignoreUnknownKeys = true 18 | explicitNulls = false 19 | encodeDefaults = true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/service/PlaybackServiceSessionListener.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.service 2 | 3 | import androidx.annotation.OptIn 4 | import androidx.media3.common.util.UnstableApi 5 | import androidx.media3.session.MediaSessionService 6 | import logcat.logcat 7 | import javax.inject.Inject 8 | 9 | @OptIn(UnstableApi::class) 10 | class PlaybackServiceSessionListener @Inject constructor() : MediaSessionService.Listener { 11 | override fun onForegroundServiceStartNotAllowedException() { 12 | logcat { "Couldn't start foreground service." } 13 | super.onForegroundServiceStartNotAllowedException() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/LoadingScreen.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.CircularProgressIndicator 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | 10 | @Composable 11 | fun LoadingScreen(modifier: Modifier = Modifier) { 12 | Box( 13 | modifier = modifier.fillMaxSize(), 14 | contentAlignment = Alignment.Center, 15 | ) { 16 | CircularProgressIndicator() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/songs/interactor/GetSong.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.songs.interactor 2 | 3 | import me.echeung.moemoekyun.domain.songs.SongsService 4 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 5 | import javax.inject.Inject 6 | 7 | class GetSong @Inject constructor( 8 | private val songsService: SongsService, 9 | private val getFavoriteSongs: GetFavoriteSongs, 10 | ) { 11 | 12 | suspend fun await(songId: Int): DomainSong { 13 | val userFavorites = getFavoriteSongs.getAll().map { it.id } 14 | return songsService.getDetailedSong(songId) 15 | .let { it.copy(favorited = it.id in userFavorites) } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_star_border_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/user/model/UserConverter.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.user.model 2 | 3 | import me.echeung.moemoekyun.client.model.User 4 | import javax.inject.Inject 5 | 6 | class UserConverter @Inject constructor() { 7 | 8 | fun toDomainUser(user: User): DomainUser = DomainUser( 9 | username = user.displayName, 10 | avatarUrl = "$CDN_AVATAR_URL/${user.avatarImage}".takeIf { user.avatarImage != null }, 11 | bannerUrl = "$CDN_BANNER_URL/${user.bannerImage}".takeIf { user.bannerImage != null }, 12 | ) 13 | } 14 | 15 | private const val CDN_AVATAR_URL = "https://cdn.listen.moe/avatars/" 16 | private const val CDN_BANNER_URL = "https://cdn.listen.moe/banners/" 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/system/LocaleUtil.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util.system 2 | 3 | import androidx.core.os.LocaleListCompat 4 | import java.util.Locale 5 | 6 | object LocaleUtil { 7 | 8 | fun getDisplayName(lang: String?): String { 9 | if (lang == null) { 10 | return "" 11 | } 12 | 13 | val locale = when (lang) { 14 | "" -> LocaleListCompat.getAdjustedDefault()[0] 15 | "zh-CN" -> Locale.forLanguageTag("zh-Hans") 16 | "zh-TW" -> Locale.forLanguageTag("zh-Hant") 17 | else -> Locale.forLanguageTag(lang) 18 | } 19 | return locale!!.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/res/values-sv-rSE/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 önskning kvar 6 | %d önskningar kvar 7 | 8 | 9 | 10 | 1 minut 11 | %d minuter 12 | 13 | 14 | 15 | Ställ timer på 1 minut 16 | Ställ timer på %d minuter 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-en-rGB/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 request remaining 6 | %d requests remaining 7 | 8 | 9 | 10 | 1 minute 11 | %d minutes 12 | 13 | 14 | 15 | Set timer for 1 minute 16 | Set timer for %d minutes 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-es-rES/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | te queda 1 request 6 | te quedan %d requests 7 | 8 | 9 | 10 | 1 minuto 11 | %d minutos 12 | 13 | 14 | 15 | Poner el timer en 1 minuto 16 | Poner el timer en %d minutos 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-it-rIT/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 richiesta rimanente 6 | %d richieste rimanenti 7 | 8 | 9 | 10 | 1 minuto 11 | %d minuti 12 | 13 | 14 | 15 | Imposta il timer a 1 minuto 16 | Imposta il timer a %d minuti 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-tr-rTR/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 kalan istek 6 | %d kalan istek 7 | 8 | 9 | 10 | 1 dakika 11 | %d dakika 12 | 13 | 14 | 15 | Zamanlayıcıyı 1 dakikalığına ayarla 16 | Zamanlayıcıyı %d dakikalığına ayarla 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-nl-rNL/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 verzoek resterend 6 | %d resterende verzoeken 7 | 8 | 9 | 10 | 1 minuut 11 | %d minuten 12 | 13 | 14 | 15 | Stel de timer in voor 1 minuut 16 | Stel de timer in voor %d minuten 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 1 request remaining 7 | %d requests remaining 8 | 9 | 10 | 11 | 12 | 1 minute 13 | %d minutes 14 | 15 | 16 | 17 | 18 | Set timer for 1 minute 19 | Set timer for %d minutes 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/values-fr-rFR/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 requête restante 6 | %d requêtes restantes 7 | 8 | 9 | 10 | 1 minute 11 | %d minutes 12 | 13 | 14 | 15 | Minuterie programmée pour 1 minute 16 | Minuterie programmée pour %d minutes 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-ca-rES/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 petició restant 6 | %d peticions restants 7 | 8 | 9 | 10 | 1 minut 11 | %d minuts 12 | 13 | 14 | 15 | Establir temporitzador durant 1 minut 16 | Establir temporitzador durant %d minuts 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-de-rDE/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 Musikwunsch verbleibend 6 | %d Musikwünsche verbleibend 7 | 8 | 9 | 10 | 1 Minute 11 | %d Minuten 12 | 13 | 14 | 15 | Stelle den Timer auf %d Minute 16 | Stelle den Timer auf %d Minuten 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-pt-rPT/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 pedido restante 6 | %d pedidos restantes 7 | 8 | 9 | 10 | 1 minuto 11 | %d minutos 12 | 13 | 14 | 15 | Temporizador configurado para 1 minuto 16 | Temporizador configurado para %d minutos 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.theme 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.darkColorScheme 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.graphics.Color 7 | 8 | private val ThemePalette = darkColorScheme( 9 | primary = Color(0xFFF60052), 10 | onPrimary = Color(0xFFFFFFFF), 11 | secondary = Color(0xFFB4B8C2), 12 | primaryContainer = Color(0xFFF60052), 13 | background = Color(0xFF1A1C29), 14 | surface = Color(0xFF1A1C29), 15 | ) 16 | 17 | @Composable 18 | fun AppTheme(content: @Composable () -> Unit) { 19 | MaterialTheme( 20 | colorScheme = ThemePalette, 21 | content = content, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/user/interactor/Register.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.user.interactor 2 | 3 | import me.echeung.moemoekyun.domain.user.UserService 4 | import javax.inject.Inject 5 | 6 | class Register @Inject constructor(private val userService: UserService) { 7 | 8 | suspend fun register(username: String, email: String, password: String): State = try { 9 | userService.register(email, username, password) 10 | userService.login(username, password) 11 | 12 | State.Complete 13 | } catch (e: Exception) { 14 | State.Error(e.message ?: e.javaClass.simpleName) 15 | } 16 | 17 | sealed interface State { 18 | data object Complete : State 19 | data class Error(val message: String) : State 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | build/ 3 | release/ 4 | *.apk 5 | *.ap_ 6 | bin/ 7 | gen/ 8 | target/ 9 | 10 | # Files for the dex VM 11 | *.dex 12 | 13 | # Java class files 14 | *.class 15 | 16 | # Kotlin compiler 17 | .kotlin/ 18 | 19 | # Local configuration file (sdk path, etc) 20 | local.properties 21 | .gitattributes 22 | 23 | # Crowdin plugin configuration 24 | crowdin.properties 25 | 26 | # Gradle generated files 27 | .gradle/ 28 | 29 | # Signing files 30 | .signing/ 31 | 32 | # Eclipse 33 | .settings 34 | .classpath 35 | .project 36 | 37 | # User-specific configurations 38 | .idea/ 39 | *.iml 40 | ajcore.*.txt 41 | captures/ 42 | 43 | # OS-specific files 44 | .DS_Store 45 | .DS_Store? 46 | ._* 47 | .Spotlight-V100 48 | .Trashes 49 | ehthumbs.db 50 | Thumbs.db 51 | 52 | # Generated files 53 | locales_config.xml -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/ext/CoroutineExtensions.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util.ext 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.launch 8 | import kotlinx.coroutines.withContext 9 | 10 | fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = launch(Dispatchers.IO, block = block) 11 | 12 | suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) 13 | 14 | suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block) 15 | 16 | suspend fun Flow.collectWithUiContext(block: suspend (T) -> Unit) = collect { withUIContext { block(it) } } 17 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | org.gradle.jvmargs=-Xmx5120m 13 | 14 | # When configured, Gradle will run in incubating parallel mode. 15 | # This option should only be used with decoupled projects. More details, visit 16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 17 | # org.gradle.parallel=true 18 | 19 | android.useAndroidX=true -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | resolutionStrategy { 3 | eachPlugin { 4 | val regex = "com.android.(library|application)".toRegex() 5 | if (regex matches requested.id.id) { 6 | useModule("com.android.tools.build:gradle:${requested.version}") 7 | } 8 | } 9 | } 10 | repositories { 11 | gradlePluginPortal() 12 | google() 13 | mavenCentral() 14 | } 15 | } 16 | 17 | plugins { 18 | id("org.gradle.toolchains.foojay-resolver-convention") version("1.0.0") 19 | } 20 | 21 | dependencyResolutionManagement { 22 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 23 | repositories { 24 | mavenCentral() 25 | google() 26 | maven(url = "https://www.jitpack.io") 27 | } 28 | } 29 | 30 | rootProject.name = "LISTEN.moe Android" 31 | include(":app") 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/radio/interactor/CurrentSong.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.radio.interactor 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.distinctUntilChanged 5 | import kotlinx.coroutines.flow.map 6 | import me.echeung.moemoekyun.domain.radio.RadioService 7 | import javax.inject.Inject 8 | import kotlin.time.ExperimentalTime 9 | import kotlin.time.Instant 10 | 11 | class CurrentSong @Inject constructor(private val radioService: RadioService) { 12 | 13 | fun albumArtFlow(): Flow = radioService.state 14 | .map { it.albumArtUrl } 15 | .distinctUntilChanged() 16 | 17 | @OptIn(ExperimentalTime::class) 18 | fun songProgressFlow(): Flow> = radioService.state 19 | .map { Pair(it.startTime, it.currentSong?.durationSeconds) } 20 | .distinctUntilChanged() 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/res/values-cs-rCZ/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/graphql/me/echeung/moemoekyun/Songs.graphql: -------------------------------------------------------------------------------- 1 | fragment songFields on Song { 2 | id 3 | title 4 | titleRomaji 5 | artists { 6 | name 7 | nameRomaji 8 | image 9 | } 10 | albums { 11 | name 12 | nameRomaji 13 | image 14 | } 15 | sources { 16 | name 17 | nameRomaji 18 | image 19 | } 20 | duration 21 | enabled 22 | } 23 | 24 | fragment songListFields on Song { 25 | id 26 | title 27 | titleRomaji 28 | artists { 29 | name 30 | nameRomaji 31 | } 32 | } 33 | 34 | query SongQuery($id: Int!) { 35 | song(id: $id) { 36 | ...songFields 37 | } 38 | } 39 | 40 | query SongsQuery($offset: Int!, $count: Int!, $kpop: Boolean) { 41 | songs(offset: $offset, count: $count, kpop: $kpop) { 42 | songs { 43 | ...songListFields 44 | } 45 | count 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/BackgroundBox.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.BoxScope 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.layout.ContentScale 9 | import androidx.compose.ui.res.painterResource 10 | import me.echeung.moemoekyun.R 11 | 12 | @Composable 13 | fun BackgroundBox(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { 14 | Box( 15 | modifier = modifier, 16 | ) { 17 | Image( 18 | modifier = Modifier.matchParentSize(), 19 | painter = painterResource(R.drawable.background), 20 | contentScale = ContentScale.Crop, 21 | contentDescription = null, 22 | ) 23 | 24 | content() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/di/ContextModule.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.di 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import androidx.preference.PreferenceManager 6 | import com.tfcporciuncula.flow.FlowSharedPreferences 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import javax.inject.Singleton 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | object ContextModule { 17 | 18 | @Provides 19 | @Singleton 20 | fun sharedPreferences(@ApplicationContext context: Context): SharedPreferences = 21 | PreferenceManager.getDefaultSharedPreferences(context) 22 | 23 | @Provides 24 | @Singleton 25 | fun flowSharedPreferences(preferences: SharedPreferences): FlowSharedPreferences = 26 | FlowSharedPreferences(preferences) 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/user/interactor/GetAuthenticatedUser.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.user.interactor 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.map 5 | import me.echeung.moemoekyun.domain.user.UserService 6 | import me.echeung.moemoekyun.domain.user.model.DomainUser 7 | import me.echeung.moemoekyun.domain.user.model.UserConverter 8 | import javax.inject.Inject 9 | 10 | class GetAuthenticatedUser @Inject constructor( 11 | private val userService: UserService, 12 | private val userConverter: UserConverter, 13 | ) { 14 | 15 | fun asFlow(): Flow = userService.state.map { get() } 16 | 17 | fun get(): DomainUser? { 18 | if (!userService.isAuthenticated || userService.state.value.user == null) { 19 | return null 20 | } 21 | 22 | return userService.state.value.user?.let(userConverter::toDomainUser) 23 | } 24 | 25 | fun isAuthenticated(): Boolean = userService.isAuthenticated 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/PreferenceUtil.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util 2 | 3 | import com.tfcporciuncula.flow.FlowSharedPreferences 4 | import me.echeung.moemoekyun.client.api.Station 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class PreferenceUtil @Inject constructor(private val prefs: FlowSharedPreferences) { 10 | 11 | fun station() = prefs.getEnum("library_mode_v2", Station.JPOP) 12 | 13 | fun shouldPreferRomaji() = prefs.getBoolean("pref_general_romaji", false) 14 | 15 | fun shouldShowRandomRequestTitle() = prefs.getBoolean("pref_general_random_request_title", true) 16 | 17 | fun shouldPauseOnNoisy() = prefs.getBoolean("pref_audio_pause_on_noisy", true) 18 | 19 | fun songsSortType() = prefs.getEnum("all_songs_sort_type", SortType.TITLE) 20 | fun songsSortDescending() = prefs.getBoolean("all_songs_sort_desc", false) 21 | 22 | fun favoritesSortType() = prefs.getEnum("favorites_sort_type", SortType.TITLE) 23 | fun favoritesSortDescending() = prefs.getBoolean("favorites_sort_desc", false) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-pl-rPL/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pozostało 1 żądanie 6 | Pozostało %d żądania 7 | Pozostało %d żądań 8 | Pozostało %d żądań 9 | 10 | 11 | 12 | 1 minuta 13 | %d minuty 14 | %d minut 15 | %d minut(y) 16 | 17 | 18 | 19 | Ustaw licznik na 1 minutę 20 | Ustaw licznik na %d minuty 21 | Ustaw licznik na %d minut 22 | Ustaw licznik na %d minut 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-lt-rLT/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Liko vienas prašymas 6 | Liko %d prašymų 7 | Liko %d prašymų 8 | Liko %d prašymų 9 | 10 | 11 | 12 | 1 minutė 13 | %d minutės 14 | %d minutės 15 | %d minutės 16 | 17 | 18 | 19 | Nustatyti laikmatį 1 minutei 20 | Nustatyti laikmatį %d minutėms 21 | Nustatyti laikmatį %d minutėms 22 | Nustatyti laikmatį %d minutėms 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/values-ru-rRU/plurals.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1 запрос остался 6 | %d запроса осталось 7 | %d запросов осталось 8 | %d запросов осталось 9 | 10 | 11 | 12 | 1 минута 13 | %d минуты 14 | %d минут 15 | %d минут 16 | 17 | 18 | 19 | Установить таймер на 1 минуту 20 | Установить таймер на %d минуты 21 | Установить таймер на %d минут 22 | Установить таймер на %d минут 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/preferences/PreferenceGroupHeader.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common.preferences 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | 13 | @Composable 14 | fun PreferenceGroupHeader(title: String, modifier: Modifier = Modifier) { 15 | Box( 16 | contentAlignment = Alignment.CenterStart, 17 | modifier = modifier 18 | .fillMaxWidth() 19 | .padding(bottom = 8.dp, top = 14.dp), 20 | ) { 21 | Text( 22 | text = title, 23 | color = MaterialTheme.colorScheme.primary, 24 | modifier = Modifier.padding(horizontal = PrefsHorizontalPadding), 25 | style = MaterialTheme.typography.bodyMedium, 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 - present arkon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/preferences/SwitchPreference.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common.preferences 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material3.Switch 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.Modifier 8 | import com.tfcporciuncula.flow.Preference 9 | import me.echeung.moemoekyun.util.ext.collectAsState 10 | 11 | @Composable 12 | fun SwitchPreference( 13 | title: String, 14 | preference: Preference, 15 | modifier: Modifier = Modifier, 16 | subtitle: String? = null, 17 | ) { 18 | val checked by preference.collectAsState() 19 | TextPreference( 20 | modifier = modifier, 21 | title = title, 22 | subtitle = subtitle, 23 | widget = { 24 | Switch( 25 | checked = checked, 26 | onCheckedChange = null, 27 | modifier = Modifier.padding(start = TrailingWidgetBuffer), 28 | ) 29 | }, 30 | onPreferenceClick = { preference.set(!checked) }, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/about/LicensesScreen.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.about 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.material3.Scaffold 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.Modifier 8 | import cafe.adriel.voyager.core.screen.Screen 9 | import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries 10 | import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer 11 | import me.echeung.moemoekyun.R 12 | import me.echeung.moemoekyun.ui.common.Toolbar 13 | 14 | object LicensesScreen : Screen { 15 | 16 | @Composable 17 | override fun Content() { 18 | val libs by produceLibraries() 19 | 20 | Scaffold( 21 | topBar = { Toolbar(titleResId = R.string.licenses, showUpButton = true) }, 22 | ) { contentPadding -> 23 | LibrariesContainer( 24 | modifier = Modifier 25 | .fillMaxSize(), 26 | contentPadding = contentPadding, 27 | libraries = libs, 28 | ) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/service/PlaybackDontBeNoisyReceiver.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.service 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.media.AudioManager 7 | import androidx.annotation.OptIn 8 | import androidx.media3.common.Player 9 | import androidx.media3.common.util.UnstableApi 10 | import dagger.assisted.Assisted 11 | import dagger.assisted.AssistedFactory 12 | import dagger.assisted.AssistedInject 13 | import me.echeung.moemoekyun.util.PreferenceUtil 14 | 15 | @OptIn(UnstableApi::class) 16 | class PlaybackDontBeNoisyReceiver @AssistedInject constructor( 17 | @Assisted private val player: Player, 18 | private val preferenceUtil: PreferenceUtil, 19 | ) : BroadcastReceiver() { 20 | 21 | override fun onReceive(context: Context, intent: Intent) { 22 | if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) { 23 | if (preferenceUtil.shouldPauseOnNoisy().get()) { 24 | player.pause() 25 | } 26 | } 27 | } 28 | 29 | @AssistedFactory 30 | interface Factory { 31 | fun create(player: Player): PlaybackDontBeNoisyReceiver 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/songs/model/DomainSong.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.songs.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.collections.immutable.ImmutableList 5 | import kotlinx.collections.immutable.toImmutableList 6 | import kotlinx.parcelize.Parcelize 7 | import java.io.Serializable 8 | 9 | @Parcelize 10 | data class DomainSong( 11 | val id: Int, 12 | val title: String, 13 | val artists: String?, 14 | val albums: String?, 15 | val sources: String?, 16 | val duration: String, 17 | val durationSeconds: Long, 18 | val albumArtUrl: String?, 19 | val favorited: Boolean, 20 | val favoritedAtEpoch: Long?, 21 | ) : Parcelable, 22 | Serializable { 23 | fun search(query: String): Boolean = title.contains(query, ignoreCase = true) || 24 | artists?.contains(query, ignoreCase = true) ?: false || 25 | albums?.contains(query, ignoreCase = true) ?: false || 26 | sources?.contains(query, ignoreCase = true) ?: false 27 | } 28 | 29 | fun ImmutableList.search(query: String?): ImmutableList = if (query.isNullOrBlank()) { 30 | this 31 | } else { 32 | asSequence() 33 | .filter { it.search(query) } 34 | .toImmutableList() 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 19 | 20 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui 2 | 3 | import android.media.AudioManager 4 | import android.os.Bundle 5 | import androidx.activity.compose.setContent 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 8 | import androidx.core.view.WindowCompat 9 | import cafe.adriel.voyager.navigator.Navigator 10 | import cafe.adriel.voyager.navigator.bottomSheet.BottomSheetNavigator 11 | import dagger.hilt.android.AndroidEntryPoint 12 | import me.echeung.moemoekyun.ui.screen.home.HomeScreen 13 | import me.echeung.moemoekyun.ui.theme.AppTheme 14 | 15 | @AndroidEntryPoint 16 | class MainActivity : AppCompatActivity() { 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | 21 | installSplashScreen() 22 | 23 | // Sets audio type to media (volume button control) 24 | volumeControlStream = AudioManager.STREAM_MUSIC 25 | 26 | // Draw edge-to-edge 27 | WindowCompat.setDecorFitsSystemWindows(window, false) 28 | 29 | setContent { 30 | AppTheme { 31 | BottomSheetNavigator { 32 | Navigator(HomeScreen) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/preferences/TextPreference.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common.preferences 2 | 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import me.echeung.moemoekyun.ui.common.secondaryItemAlpha 9 | 10 | @Composable 11 | fun TextPreference( 12 | modifier: Modifier = Modifier, 13 | title: String? = null, 14 | subtitle: String? = null, 15 | widget: @Composable (() -> Unit)? = null, 16 | onPreferenceClick: (() -> Unit)? = null, 17 | ) { 18 | BasePreference( 19 | modifier = modifier, 20 | title = title, 21 | subcomponent = if (!subtitle.isNullOrBlank()) { 22 | { 23 | Text( 24 | text = subtitle, 25 | modifier = Modifier 26 | .padding(horizontal = PrefsHorizontalPadding) 27 | .secondaryItemAlpha(), 28 | style = MaterialTheme.typography.bodySmall, 29 | maxLines = 10, 30 | ) 31 | } 32 | } else { 33 | null 34 | }, 35 | onClick = onPreferenceClick, 36 | widget = widget, 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/user/interactor/LoginLogout.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.user.interactor 2 | 3 | import me.echeung.moemoekyun.client.api.ApiClient 4 | import me.echeung.moemoekyun.domain.user.UserService 5 | import javax.inject.Inject 6 | 7 | class LoginLogout @Inject constructor(private val userService: UserService) { 8 | 9 | suspend fun login(username: String, password: String): State = try { 10 | when (userService.login(username, password)) { 11 | ApiClient.LoginResult.COMPLETE -> State.Complete 12 | ApiClient.LoginResult.REQUIRE_OTP -> State.RequireOtp 13 | else -> throw IllegalStateException() 14 | } 15 | } catch (e: Exception) { 16 | State.Error(e.message ?: e.javaClass.simpleName) 17 | } 18 | 19 | suspend fun loginMfa(token: String): State = try { 20 | when (userService.loginMfa(token)) { 21 | ApiClient.LoginResult.COMPLETE -> State.Complete 22 | else -> State.RequireOtp 23 | } 24 | } catch (e: Exception) { 25 | State.Error(e.message ?: e.javaClass.simpleName) 26 | } 27 | 28 | fun logout() = userService.logout() 29 | 30 | sealed interface State { 31 | data object Complete : State 32 | data object RequireOtp : State 33 | data class Error(val message: String) : State 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/res/values-si-rLK/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | වසන්න 8 | 9 | 10 | 11 | 12 | ලියාපදිංචි වන්න 13 | පරිශීලක නාමය 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | නැවත උත්සාහ කරන්න 26 | 27 | මේ ගැන 28 | 29 | බලපත්‍ර 30 | 31 | සැකසුම් 32 | භාෂාව 33 | ශ්‍රව්‍ය 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/util/PaddingValues.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.util 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.foundation.layout.calculateEndPadding 5 | import androidx.compose.foundation.layout.calculateStartPadding 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.ReadOnlyComposable 8 | import androidx.compose.ui.platform.LocalLayoutDirection 9 | import androidx.compose.ui.unit.Dp 10 | 11 | @Composable 12 | @ReadOnlyComposable 13 | operator fun PaddingValues.plus(other: PaddingValues): PaddingValues { 14 | val layoutDirection = LocalLayoutDirection.current 15 | return PaddingValues( 16 | start = calculateStartPadding(layoutDirection) + 17 | other.calculateStartPadding(layoutDirection), 18 | end = calculateEndPadding(layoutDirection) + 19 | other.calculateEndPadding(layoutDirection), 20 | top = calculateTopPadding() + other.calculateTopPadding(), 21 | bottom = calculateBottomPadding() + other.calculateBottomPadding(), 22 | ) 23 | } 24 | 25 | @Composable 26 | @ReadOnlyComposable 27 | operator fun PaddingValues.plus(all: Dp): PaddingValues { 28 | val layoutDirection = LocalLayoutDirection.current 29 | return PaddingValues( 30 | start = calculateStartPadding(layoutDirection) + all, 31 | end = calculateEndPadding(layoutDirection) + all, 32 | top = calculateTopPadding() + all, 33 | bottom = calculateBottomPadding() + all, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/songs/interactor/RequestSong.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.songs.interactor 2 | 3 | import android.content.Context 4 | import dagger.hilt.android.qualifiers.ApplicationContext 5 | import me.echeung.moemoekyun.R 6 | import me.echeung.moemoekyun.domain.songs.SongsService 7 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 8 | import me.echeung.moemoekyun.util.PreferenceUtil 9 | import me.echeung.moemoekyun.util.ext.toast 10 | import me.echeung.moemoekyun.util.ext.withUIContext 11 | import javax.inject.Inject 12 | 13 | class RequestSong @Inject constructor( 14 | @ApplicationContext private val context: Context, 15 | private val songsService: SongsService, 16 | private val preferenceUtil: PreferenceUtil, 17 | ) { 18 | 19 | suspend fun await(song: DomainSong) { 20 | try { 21 | songsService.request(song.id) 22 | 23 | withUIContext { 24 | when (preferenceUtil.shouldShowRandomRequestTitle().get()) { 25 | true -> context.toast( 26 | context.getString( 27 | R.string.requested_song, 28 | song.title, 29 | ), 30 | ) 31 | false -> context.toast(R.string.requested_random_song) 32 | } 33 | } 34 | } catch (e: Exception) { 35 | withUIContext { 36 | context.toast(e.message.orEmpty()) 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/SongsSorter.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util 2 | 3 | import androidx.annotation.StringRes 4 | import me.echeung.moemoekyun.R 5 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 6 | import javax.inject.Inject 7 | 8 | class SongsSorter @Inject constructor() { 9 | 10 | fun sort(songs: Collection, sortType: SortType, descending: Boolean): List = songs 11 | .distinctBy { it.id } 12 | .sortedWith(getComparator(sortType, descending)) 13 | 14 | private fun getComparator(sortType: SortType, descending: Boolean): Comparator = when (sortType) { 15 | SortType.TITLE -> 16 | if (descending) { 17 | compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.title } 18 | } else { 19 | compareBy(String.CASE_INSENSITIVE_ORDER) { it.title } 20 | } 21 | 22 | SortType.ARTIST -> 23 | if (descending) { 24 | compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.artists.orEmpty() } 25 | } else { 26 | compareBy(String.CASE_INSENSITIVE_ORDER) { it.artists.orEmpty() } 27 | } 28 | 29 | SortType.FAVORITED_AT -> 30 | if (descending) { 31 | compareByDescending { it.favoritedAtEpoch } 32 | } else { 33 | compareBy { it.favoritedAtEpoch } 34 | } 35 | } 36 | } 37 | 38 | enum class SortType(@StringRes val labelRes: Int) { 39 | TITLE(R.string.sort_title), 40 | ARTIST(R.string.sort_artist), 41 | FAVORITED_AT(R.string.sort_favorited_at), 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/songs/interactor/GetFavoriteSongs.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.songs.interactor 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.combine 5 | import kotlinx.coroutines.flow.map 6 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 7 | import me.echeung.moemoekyun.domain.user.UserService 8 | import me.echeung.moemoekyun.util.PreferenceUtil 9 | import me.echeung.moemoekyun.util.SongsSorter 10 | import me.echeung.moemoekyun.util.SortType 11 | import javax.inject.Inject 12 | 13 | class GetFavoriteSongs @Inject constructor( 14 | private val userService: UserService, 15 | private val songsSorter: SongsSorter, 16 | private val preferenceUtil: PreferenceUtil, 17 | ) { 18 | 19 | fun asFlow(): Flow> = combine( 20 | userService.state, 21 | preferenceUtil.favoritesSortType().asFlow(), 22 | preferenceUtil.favoritesSortDescending().asFlow(), 23 | ) { _, _, _ -> } 24 | .map { getAll() } 25 | 26 | fun getAll(): List = userService.state.value.favorites.let { 27 | songsSorter.sort( 28 | it, 29 | preferenceUtil.favoritesSortType().get(), 30 | preferenceUtil.favoritesSortDescending().get(), 31 | ) 32 | } 33 | 34 | fun isFavorite(songId: Int): Boolean = getAll().any { it.id == songId } 35 | 36 | fun setSortType(sortType: SortType) { 37 | preferenceUtil.favoritesSortType().set(sortType) 38 | } 39 | 40 | fun setSortDescending(descending: Boolean) { 41 | preferenceUtil.favoritesSortDescending().set(descending) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/songs/interactor/GetSongs.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.songs.interactor 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.combine 5 | import kotlinx.coroutines.flow.map 6 | import me.echeung.moemoekyun.domain.songs.SongsService 7 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 8 | import me.echeung.moemoekyun.util.PreferenceUtil 9 | import me.echeung.moemoekyun.util.SongsSorter 10 | import me.echeung.moemoekyun.util.SortType 11 | import javax.inject.Inject 12 | 13 | class GetSongs @Inject constructor( 14 | private val songsService: SongsService, 15 | private val songsSorter: SongsSorter, 16 | private val getFavoriteSongs: GetFavoriteSongs, 17 | private val preferenceUtil: PreferenceUtil, 18 | ) { 19 | 20 | fun asFlow(): Flow> = combine( 21 | songsService.songs, 22 | getFavoriteSongs.asFlow(), 23 | preferenceUtil.songsSortType().asFlow(), 24 | preferenceUtil.songsSortDescending().asFlow(), 25 | ) { songs, favorites, _, _ -> Pair(songs, favorites.map { it.id }) } 26 | .map { (songs, favorites) -> 27 | songs.values.map { it.copy(favorited = it.id in favorites) } 28 | } 29 | .map { 30 | songsSorter.sort(it, preferenceUtil.songsSortType().get(), preferenceUtil.songsSortDescending().get()) 31 | } 32 | 33 | fun setSortType(sortType: SortType) { 34 | preferenceUtil.songsSortType().set(sortType) 35 | } 36 | 37 | fun setSortDescending(descending: Boolean) { 38 | preferenceUtil.songsSortDescending().set(descending) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

Official Android app

5 | 6 | ## Download 7 | 8 | 9 | Get it on F-Droid 11 | 12 | 13 | 14 | ## About 15 | 16 | A native Android app using things like [OkHttp](http://square.github.io/okhttp/), [Apollo](https://www.apollographql.com), and [Jetpack Compose UI](https://developer.android.com/jetpack/compose). 17 | Features things like [Android Auto](https://www.android.com/auto/) and [autofill](https://android-developers.googleblog.com/2017/11/getting-your-android-app-ready-for.html) support. 18 | 19 | 20 | ## Developing 21 | 22 | [![CI](https://github.com/LISTEN-moe/android-app/actions/workflows/build.yml/badge.svg)](https://github.com/LISTEN-moe/android-app/actions/workflows/build.yml) 23 | 24 | ### Prerequisites 25 | 26 | - [Android Studio](https://developer.android.com/studio/index.html) 27 | 28 | ### Translations 29 | 30 | Translations are crowd-sourced through [CrowdIn](https://crwd.in/listenmoe-android-app) and managed by [@arkon](https://github.com/arkon/). 31 | 32 | ### Release 33 | 34 | Tagged release builds are automatically uploaded to the Google Play Store via a [workflow](https://github.com/LISTEN-moe/android-app/blob/main/.github/workflows/build.yml). 35 | 36 | F-Droid releases are also automated ([see F-Droid wiki](https://f-droid.org/wiki/page/me.echeung.moemoekyun.fdroid)). 37 | 38 | 39 | ## License 40 | 41 | [MIT](https://github.com/LISTEN-moe/android-app/blob/main/LICENSE) 42 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/AlbumArt.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.aspectRatio 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.draw.clip 10 | import androidx.compose.ui.platform.LocalUriHandler 11 | import androidx.compose.ui.res.painterResource 12 | import androidx.compose.ui.unit.dp 13 | import coil3.compose.AsyncImage 14 | import me.echeung.moemoekyun.R 15 | 16 | private val AlbumArtModifier = Modifier 17 | .aspectRatio(1f) 18 | .clip(RoundedCornerShape(8.dp)) 19 | 20 | @Composable 21 | fun AlbumArt(albumArtUrl: String?, modifier: Modifier = Modifier, openUrlOnClick: Boolean = true) { 22 | val uriHandler = LocalUriHandler.current 23 | 24 | if (albumArtUrl == null) { 25 | Image( 26 | modifier = AlbumArtModifier.then(modifier), 27 | painter = painterResource(R.drawable.default_album_art), 28 | contentDescription = null, 29 | ) 30 | } else { 31 | // val request = ImageRequest.Builder(LocalContext.current) 32 | // .data(albumArtUrl) 33 | // .transformations(BlurTransformation()) 34 | // .build() 35 | 36 | AsyncImage( 37 | modifier = AlbumArtModifier 38 | .then(modifier) 39 | .clickable { if (openUrlOnClick) uriHandler.openUri(albumArtUrl) }, 40 | model = albumArtUrl, 41 | placeholder = painterResource(R.drawable.default_album_art), 42 | contentDescription = null, 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/songs/SongsScreen.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.songs 2 | 3 | import androidx.compose.foundation.layout.WindowInsets 4 | import androidx.compose.foundation.layout.systemBars 5 | import androidx.compose.foundation.layout.windowInsetsPadding 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.itemsIndexed 8 | import androidx.compose.material3.HorizontalDivider 9 | import androidx.compose.material3.Surface 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.collectAsState 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.ui.Modifier 14 | import cafe.adriel.voyager.core.screen.Screen 15 | import cafe.adriel.voyager.hilt.getScreenModel 16 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 17 | 18 | data class SongsScreen(private val songs: List) : Screen { 19 | 20 | @Composable 21 | override fun Content() { 22 | val screenModel = getScreenModel { factory -> 23 | factory.create(songs) 24 | } 25 | val state by screenModel.state.collectAsState() 26 | 27 | Surface { 28 | LazyColumn( 29 | modifier = Modifier 30 | .windowInsetsPadding(WindowInsets.systemBars), 31 | ) { 32 | itemsIndexed(state.songs) { index, item -> 33 | SongDetails( 34 | song = item, 35 | actionsEnabled = state.actionsEnabled, 36 | toggleFavorite = screenModel::toggleFavorite, 37 | request = screenModel::request, 38 | ) 39 | 40 | if (index < state.songs.lastIndex) { 41 | HorizontalDivider() 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/App.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun 2 | 3 | import android.app.Application 4 | import android.os.Build 5 | import androidx.lifecycle.DefaultLifecycleObserver 6 | import androidx.lifecycle.LifecycleOwner 7 | import androidx.lifecycle.ProcessLifecycleOwner 8 | import coil3.ImageLoader 9 | import coil3.PlatformContext 10 | import coil3.SingletonImageLoader 11 | import coil3.annotation.ExperimentalCoilApi 12 | import coil3.gif.AnimatedImageDecoder 13 | import coil3.gif.GifDecoder 14 | import coil3.memoryCacheMaxSizePercentWhileInBackground 15 | import coil3.network.okhttp.OkHttpNetworkFetcherFactory 16 | import dagger.hilt.android.HiltAndroidApp 17 | import logcat.AndroidLogcatLogger 18 | import logcat.LogPriority 19 | import me.echeung.moemoekyun.domain.radio.RadioService 20 | import okhttp3.OkHttpClient 21 | import javax.inject.Inject 22 | 23 | @HiltAndroidApp 24 | class App : 25 | Application(), 26 | DefaultLifecycleObserver, 27 | SingletonImageLoader.Factory { 28 | 29 | @Inject 30 | lateinit var radioService: RadioService 31 | 32 | @Inject 33 | lateinit var okHttpClient: OkHttpClient 34 | 35 | override fun onCreate() { 36 | super.onCreate() 37 | 38 | AndroidLogcatLogger.installOnDebuggableApp(this, minPriority = LogPriority.VERBOSE) 39 | 40 | ProcessLifecycleOwner.get().lifecycle.addObserver(this) 41 | } 42 | 43 | override fun onStart(owner: LifecycleOwner) { 44 | radioService.connect() 45 | } 46 | 47 | @OptIn(ExperimentalCoilApi::class) 48 | override fun newImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) 49 | .memoryCacheMaxSizePercentWhileInBackground(0.5) 50 | .components { 51 | add(OkHttpNetworkFetcherFactory(okHttpClient)) 52 | 53 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 54 | add(AnimatedImageDecoder.Factory()) 55 | } else { 56 | add(GifDecoder.Factory()) 57 | } 58 | } 59 | .build() 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/di/VoyagerModule.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.di 2 | 3 | import cafe.adriel.voyager.core.model.ScreenModel 4 | import cafe.adriel.voyager.hilt.ScreenModelFactory 5 | import cafe.adriel.voyager.hilt.ScreenModelFactoryKey 6 | import cafe.adriel.voyager.hilt.ScreenModelKey 7 | import dagger.Binds 8 | import dagger.Module 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.components.ActivityComponent 11 | import dagger.multibindings.IntoMap 12 | import me.echeung.moemoekyun.ui.screen.auth.LoginScreenModel 13 | import me.echeung.moemoekyun.ui.screen.auth.RegisterScreenModel 14 | import me.echeung.moemoekyun.ui.screen.home.HomeScreenModel 15 | import me.echeung.moemoekyun.ui.screen.search.SearchScreenModel 16 | import me.echeung.moemoekyun.ui.screen.settings.SettingsScreenModel 17 | import me.echeung.moemoekyun.ui.screen.songs.SongsScreenModel 18 | 19 | @Module 20 | @InstallIn(ActivityComponent::class) 21 | abstract class VoyagerModule { 22 | 23 | @Binds 24 | @IntoMap 25 | @ScreenModelKey(HomeScreenModel::class) 26 | abstract fun homeScreenModel(homeScreenModel: HomeScreenModel): ScreenModel 27 | 28 | @Binds 29 | @IntoMap 30 | @ScreenModelKey(LoginScreenModel::class) 31 | abstract fun loginScreenModel(loginScreenModel: LoginScreenModel): ScreenModel 32 | 33 | @Binds 34 | @IntoMap 35 | @ScreenModelKey(RegisterScreenModel::class) 36 | abstract fun registerScreenModel(registerScreenModel: RegisterScreenModel): ScreenModel 37 | 38 | @Binds 39 | @IntoMap 40 | @ScreenModelKey(SearchScreenModel::class) 41 | abstract fun searchScreenModel(searchScreenModel: SearchScreenModel): ScreenModel 42 | 43 | @Binds 44 | @IntoMap 45 | @ScreenModelKey(SettingsScreenModel::class) 46 | abstract fun settingsScreenModel(settingsScreenModel: SettingsScreenModel): ScreenModel 47 | 48 | @Binds 49 | @IntoMap 50 | @ScreenModelFactoryKey(SongsScreenModel.Factory::class) 51 | abstract fun songsScreenModelFactory(songsScreenModelFactory: SongsScreenModel.Factory): ScreenModelFactory 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - v* 8 | pull_request: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build: 16 | name: Build app 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Clone repo 21 | uses: actions/checkout@v6 22 | 23 | - name: Set up JDK 24 | uses: actions/setup-java@v5 25 | with: 26 | java-version: 21 27 | distribution: adopt 28 | 29 | - name: Setup Gradle 30 | uses: gradle/actions/setup-gradle@v5 31 | 32 | - name: Build app 33 | run: ./gradlew :app:testPlaystoreReleaseUnitTest assemblePlaystoreRelease 34 | 35 | # Sign APK and create release for tags 36 | 37 | - name: Prepare build metadata 38 | if: startsWith(github.ref, 'refs/tags/') && github.repository == 'LISTEN-moe/android-app' 39 | run: | 40 | set -x 41 | echo "VERSION_TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV 42 | 43 | - name: Sign APK 44 | if: startsWith(github.ref, 'refs/tags/') && github.repository == 'LISTEN-moe/android-app' 45 | uses: r0adkll/sign-android-release@v1 46 | with: 47 | releaseDirectory: app/build/outputs/apk/playstore/release 48 | signingKeyBase64: ${{ secrets.SIGNING_KEY }} 49 | alias: ${{ secrets.ALIAS }} 50 | keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} 51 | keyPassword: ${{ secrets.KEY_PASSWORD }} 52 | env: 53 | BUILD_TOOLS_VERSION: "35.0.0" 54 | 55 | - name: Create release 56 | if: startsWith(github.ref, 'refs/tags/') && github.repository == 'LISTEN-moe/android-app' 57 | uses: softprops/action-gh-release@v2 58 | with: 59 | tag_name: ${{ env.VERSION_TAG }} 60 | name: ${{ env.VERSION_TAG }} 61 | files: | 62 | ${{ env.SIGNED_RELEASE_FILE }} 63 | draft: true 64 | prerelease: false 65 | env: 66 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/service/PlaybackServicePlayerListener.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.service 2 | 3 | import android.content.Context 4 | import android.content.IntentFilter 5 | import android.media.AudioManager 6 | import androidx.media3.common.PlaybackException 7 | import androidx.media3.common.Player 8 | import dagger.assisted.Assisted 9 | import dagger.assisted.AssistedFactory 10 | import dagger.assisted.AssistedInject 11 | import logcat.LogPriority 12 | import logcat.asLog 13 | import logcat.logcat 14 | import me.echeung.moemoekyun.util.PreferenceUtil 15 | import me.echeung.moemoekyun.util.ext.toMediaItem 16 | 17 | class PlaybackServicePlayerListener @AssistedInject constructor( 18 | @Assisted private val context: Context, 19 | @Assisted private val player: Player, 20 | private val preferenceUtil: PreferenceUtil, 21 | dontBeNoisyReceiverFactory: PlaybackDontBeNoisyReceiver.Factory, 22 | ) : Player.Listener { 23 | private val dontBeNoisyReceiver = dontBeNoisyReceiverFactory.create(player) 24 | 25 | override fun onIsPlayingChanged(isPlaying: Boolean) { 26 | if (isPlaying) { 27 | val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) 28 | context.registerReceiver(dontBeNoisyReceiver, intentFilter) 29 | } else { 30 | context.unregisterReceiver(dontBeNoisyReceiver) 31 | } 32 | } 33 | 34 | override fun onPlayerError(error: PlaybackException) { 35 | logcat(LogPriority.ERROR) { "An error occurred in the player.\n\n" + error.asLog() } 36 | val wasPlaying = player.isPlaying 37 | 38 | val mediaItem = preferenceUtil.station().get().toMediaItem() 39 | player.setMediaItem(mediaItem) 40 | // Ensure we're "reset" to live 41 | player.seekToDefaultPosition() 42 | player.prepare() 43 | 44 | if (wasPlaying) { 45 | // TODO: avoid infinitely retrying 46 | player.play() 47 | } 48 | } 49 | 50 | @AssistedFactory 51 | interface Factory { 52 | fun create(context: Context, player: Player): PlaybackServicePlayerListener 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/SongsList.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.lazy.LazyListScope 5 | import androidx.compose.foundation.lazy.items 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.outlined.Star 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.ListItem 10 | import androidx.compose.material3.ListItemDefaults 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import cafe.adriel.voyager.navigator.bottomSheet.LocalBottomSheetNavigator 16 | import kotlinx.collections.immutable.ImmutableList 17 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 18 | import me.echeung.moemoekyun.ui.screen.songs.SongsScreen 19 | 20 | fun LazyListScope.songsItems(songs: ImmutableList?, showFavoriteIcons: Boolean = false) = items( 21 | items = songs.orEmpty(), 22 | key = { it.id }, 23 | ) { 24 | val bottomSheetNavigator = LocalBottomSheetNavigator.current 25 | 26 | ListItem( 27 | modifier = Modifier 28 | .clickable { 29 | bottomSheetNavigator.show( 30 | SongsScreen(songs = listOf(it)), 31 | ) 32 | }, 33 | colors = ListItemDefaults.colors( 34 | containerColor = Color.Transparent, 35 | ), 36 | headlineContent = { 37 | Text(text = it.title) 38 | }, 39 | supportingContent = { 40 | it.artists?.let { 41 | Text( 42 | text = it, 43 | color = MaterialTheme.colorScheme.secondary, 44 | ) 45 | } 46 | }, 47 | trailingContent = { 48 | if (showFavoriteIcons && it.favorited) { 49 | Icon( 50 | imageVector = Icons.Outlined.Star, 51 | contentDescription = null, 52 | tint = MaterialTheme.colorScheme.primary, 53 | ) 54 | } 55 | }, 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/client/api/data/DataTransformer.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.api.data 2 | 3 | import com.apollographql.apollo.api.Error 4 | import me.echeung.moemoekyun.FavoritesQuery 5 | import me.echeung.moemoekyun.SongQuery 6 | import me.echeung.moemoekyun.SongsQuery 7 | import me.echeung.moemoekyun.UserQuery 8 | import me.echeung.moemoekyun.client.model.Song 9 | import me.echeung.moemoekyun.client.model.SongDescriptor 10 | import me.echeung.moemoekyun.client.model.User 11 | import me.echeung.moemoekyun.fragment.SongFields 12 | import me.echeung.moemoekyun.fragment.SongListFields 13 | 14 | fun UserQuery.User.transform() = User( 15 | this.uuid, 16 | this.displayName!!, 17 | this.avatarImage, 18 | this.bannerImage, 19 | ) 20 | 21 | fun FavoritesQuery.Favorite.transform(): Song? { 22 | val song = this.song?.songListFields?.transform() 23 | 24 | // Manually mark a user's favorite as favorited 25 | song?.favorite = true 26 | song?.favoritedAt = this.createdAt?.toLong() // e.g. "1516637758993" 27 | 28 | return song 29 | } 30 | 31 | fun SongQuery.Song.transform() = songFields.transform() 32 | 33 | fun SongsQuery.Song.transform() = songListFields.transform() 34 | 35 | fun List?.toMessage() = this?.joinToString { it.message } ?: "" 36 | 37 | private fun SongFields.transform() = Song( 38 | this.id, 39 | this.title, 40 | this.titleRomaji, 41 | this.artists.mapNotNull { it?.transform() }, 42 | this.sources.mapNotNull { it?.transform() }, 43 | this.albums.mapNotNull { it?.transform() }, 44 | this.duration, 45 | ) 46 | 47 | private fun SongFields.Artist.transform() = SongDescriptor( 48 | this.name, 49 | this.nameRomaji, 50 | this.image, 51 | ) 52 | 53 | private fun SongFields.Source.transform() = SongDescriptor( 54 | this.name, 55 | this.nameRomaji, 56 | this.image, 57 | ) 58 | 59 | private fun SongFields.Album.transform() = SongDescriptor( 60 | this.name, 61 | this.nameRomaji, 62 | this.image, 63 | ) 64 | 65 | private fun SongListFields.transform() = Song( 66 | this.id, 67 | this.title, 68 | this.titleRomaji, 69 | this.artists.mapNotNull { it?.transform() }, 70 | ) 71 | 72 | private fun SongListFields.Artist.transform() = SongDescriptor( 73 | this.name, 74 | this.nameRomaji, 75 | ) 76 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/service/PlaybackPlayer.kt: -------------------------------------------------------------------------------- 1 | @file:kotlin.OptIn(ExperimentalTime::class) 2 | 3 | package me.echeung.moemoekyun.service 4 | 5 | import androidx.annotation.OptIn 6 | import androidx.media3.common.C 7 | import androidx.media3.common.ForwardingPlayer 8 | import androidx.media3.common.util.UnstableApi 9 | import androidx.media3.exoplayer.ExoPlayer 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.flow.collectLatest 12 | import logcat.logcat 13 | import me.echeung.moemoekyun.domain.radio.interactor.CurrentSong 14 | import me.echeung.moemoekyun.util.ext.launchIO 15 | import javax.inject.Inject 16 | import kotlin.time.Clock 17 | import kotlin.time.Duration.Companion.seconds 18 | import kotlin.time.DurationUnit 19 | import kotlin.time.ExperimentalTime 20 | import kotlin.time.Instant 21 | import kotlin.time.toDuration 22 | 23 | @OptIn(UnstableApi::class) 24 | class PlaybackPlayer @Inject constructor( 25 | val player: ExoPlayer, 26 | val currentSong: CurrentSong, 27 | val scope: CoroutineScope, 28 | ) : ForwardingPlayer(player) { 29 | 30 | private var currentStartTime: Instant? = null 31 | private var currentDuration = 0L 32 | 33 | init { 34 | scope.launchIO { 35 | currentSong.songProgressFlow() 36 | .collectLatest { (startTime, duration) -> 37 | currentStartTime = startTime 38 | currentDuration = duration ?: 0L 39 | } 40 | } 41 | } 42 | 43 | override fun play() { 44 | logcat { "will seek to default position and start playing" } 45 | player.seekToDefaultPosition() 46 | super.play() 47 | } 48 | 49 | // TODO: this doesn't get reflected in things like the system UI for some reason 50 | override fun getCurrentPosition(): Long = currentStartTime?.let { 51 | val currentPosition = Clock.System.now() - it 52 | logcat { "current position: $currentPosition" } 53 | currentPosition.inWholeMilliseconds 54 | } ?: super.currentPosition 55 | 56 | override fun getDuration(): Long { 57 | val duration = currentDuration.seconds.inWholeMilliseconds.takeIf { it > 0 } ?: C.TIME_UNSET 58 | logcat { "current duration: ${duration.toDuration(DurationUnit.MILLISECONDS)}" } 59 | return duration 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/songs/model/SongConverter.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.songs.model 2 | 3 | import me.echeung.moemoekyun.client.model.Song 4 | import me.echeung.moemoekyun.client.model.SongDescriptor 5 | import me.echeung.moemoekyun.util.PreferenceUtil 6 | import java.util.Locale 7 | import javax.inject.Inject 8 | 9 | class SongConverter @Inject constructor(private val preferenceUtil: PreferenceUtil) { 10 | 11 | fun toDomainSong(song: Song): DomainSong = DomainSong( 12 | id = song.id, 13 | title = if (preferenceUtil.shouldPreferRomaji().get()) { 14 | (song.titleRomaji ?: song.title).orEmpty().trim() 15 | } else { 16 | song.title.orEmpty().trim() 17 | }, 18 | artists = song.artists?.toDomainSong(), 19 | albums = song.albums?.toDomainSong(), 20 | sources = song.sources?.toDomainSong(), 21 | duration = song.duration(), 22 | durationSeconds = song.duration.toLong(), 23 | albumArtUrl = song.albumArtUrl(), 24 | favorited = song.favorite, 25 | favoritedAtEpoch = song.favoritedAt, 26 | ) 27 | 28 | private fun List.toDomainSong(): String = this 29 | .mapNotNull { 30 | val preferredName = if (preferenceUtil.shouldPreferRomaji().get()) { 31 | it.nameRomaji 32 | } else { 33 | it.name 34 | } 35 | preferredName ?: it.name 36 | } 37 | .joinToString() 38 | 39 | private fun Song.duration(): String { 40 | var minutes = (duration / 60).toLong() 41 | val seconds = (duration % 60).toLong() 42 | return if (minutes < 60) { 43 | String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) 44 | } else { 45 | val hours = minutes / 60 46 | minutes %= 60 47 | String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) 48 | } 49 | } 50 | 51 | private fun Song.albumArtUrl(): String? { 52 | val album = albums?.firstOrNull { it.image != null } 53 | if (album != null) { 54 | return "$CDN_ALBUM_ART_URL/${album.image}" 55 | } 56 | 57 | return null 58 | } 59 | } 60 | 61 | private const val CDN_ALBUM_ART_URL = "https://cdn.listen.moe/covers" 62 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/auth/RegisterScreenModel.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.auth 2 | 3 | import androidx.compose.runtime.Immutable 4 | import cafe.adriel.voyager.core.model.StateScreenModel 5 | import cafe.adriel.voyager.core.model.screenModelScope 6 | import kotlinx.coroutines.flow.update 7 | import me.echeung.moemoekyun.domain.user.interactor.Register 8 | import me.echeung.moemoekyun.util.ext.launchIO 9 | import javax.inject.Inject 10 | 11 | class RegisterScreenModel @Inject constructor(private val register: Register) : 12 | StateScreenModel(State()) { 13 | 14 | fun register(username: String, email: String, password1: String, password2: String) { 15 | mutableState.update { 16 | it.copy(loading = true) 17 | } 18 | 19 | if (username.isEmpty() || email.isEmpty() || password1.isEmpty() || password2.isEmpty()) { 20 | mutableState.update { 21 | it.copy( 22 | result = Result.AllFieldsRequired, 23 | loading = false, 24 | ) 25 | } 26 | return 27 | } 28 | 29 | if (password1 != password2) { 30 | mutableState.update { 31 | it.copy( 32 | result = Result.MismatchedPasswords, 33 | loading = false, 34 | ) 35 | } 36 | return 37 | } 38 | 39 | screenModelScope.launchIO { 40 | val state = register.register(email, username, password1) 41 | 42 | mutableState.update { 43 | it.copy( 44 | result = when (state) { 45 | is Register.State.Complete -> Result.Complete 46 | is Register.State.Error -> Result.ApiError(state.message) 47 | }, 48 | loading = false, 49 | ) 50 | } 51 | } 52 | } 53 | 54 | @Immutable 55 | data class State(val loading: Boolean = false, val result: Result? = null) 56 | 57 | sealed interface Result { 58 | data object Complete : Result 59 | data object AllFieldsRequired : Result 60 | data object MismatchedPasswords : Result 61 | data class ApiError(val message: String) : Result 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/DropdownMenu.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common 2 | 3 | import androidx.compose.foundation.layout.ColumnScope 4 | import androidx.compose.foundation.layout.sizeIn 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.outlined.CheckBox 7 | import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank 8 | import androidx.compose.material.icons.outlined.RadioButtonChecked 9 | import androidx.compose.material.icons.outlined.RadioButtonUnchecked 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.DpOffset 14 | import androidx.compose.ui.unit.dp 15 | import androidx.compose.ui.window.PopupProperties 16 | import androidx.compose.material3.DropdownMenu as ComposeDropdownMenu 17 | 18 | @Composable 19 | fun DropdownMenu( 20 | expanded: Boolean, 21 | onDismissRequest: () -> Unit, 22 | modifier: Modifier = Modifier, 23 | offset: DpOffset = DpOffset(8.dp, (-56).dp), 24 | properties: PopupProperties = PopupProperties(focusable = true), 25 | content: @Composable ColumnScope.() -> Unit, 26 | ) { 27 | ComposeDropdownMenu( 28 | expanded = expanded, 29 | onDismissRequest = onDismissRequest, 30 | modifier = modifier.sizeIn(minWidth = 196.dp, maxWidth = 196.dp), 31 | offset = offset, 32 | properties = properties, 33 | content = content, 34 | ) 35 | } 36 | 37 | @Composable 38 | fun RadioIcon(checked: Boolean, modifier: Modifier = Modifier) { 39 | if (checked) { 40 | Icon( 41 | Icons.Outlined.RadioButtonChecked, 42 | contentDescription = null, 43 | modifier = modifier, 44 | ) 45 | } else { 46 | Icon( 47 | Icons.Outlined.RadioButtonUnchecked, 48 | contentDescription = null, 49 | modifier = modifier, 50 | ) 51 | } 52 | } 53 | 54 | @Composable 55 | fun CheckboxIcon(checked: Boolean, modifier: Modifier = Modifier) { 56 | if (checked) { 57 | Icon( 58 | Icons.Outlined.CheckBox, 59 | contentDescription = null, 60 | modifier = modifier, 61 | ) 62 | } else { 63 | Icon( 64 | Icons.Outlined.CheckBoxOutlineBlank, 65 | contentDescription = null, 66 | modifier = modifier, 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/preferences/BasePreference.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common.preferences 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.ColumnScope 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.sizeIn 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.style.TextOverflow 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | 20 | @Composable 21 | internal fun BasePreference( 22 | modifier: Modifier = Modifier, 23 | title: String? = null, 24 | subcomponent: @Composable (ColumnScope.() -> Unit)? = null, 25 | onClick: (() -> Unit)? = null, 26 | widget: @Composable (() -> Unit)? = null, 27 | ) { 28 | Row( 29 | modifier = modifier 30 | .sizeIn(minHeight = 56.dp) 31 | .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) 32 | .fillMaxWidth(), 33 | verticalAlignment = Alignment.CenterVertically, 34 | ) { 35 | Column( 36 | modifier = Modifier 37 | .weight(1f) 38 | .padding(vertical = PrefsVerticalPadding), 39 | ) { 40 | if (!title.isNullOrBlank()) { 41 | Text( 42 | modifier = Modifier.padding(horizontal = PrefsHorizontalPadding), 43 | text = title, 44 | overflow = TextOverflow.Ellipsis, 45 | maxLines = 2, 46 | style = MaterialTheme.typography.titleLarge, 47 | fontSize = TitleFontSize, 48 | ) 49 | } 50 | subcomponent?.invoke(this) 51 | } 52 | if (widget != null) { 53 | Box( 54 | modifier = Modifier.padding(end = PrefsHorizontalPadding), 55 | content = { widget() }, 56 | ) 57 | } 58 | } 59 | } 60 | 61 | internal val TrailingWidgetBuffer = 16.dp 62 | internal val PrefsHorizontalPadding = 16.dp 63 | internal val PrefsVerticalPadding = 16.dp 64 | internal val TitleFontSize = 16.sp 65 | -------------------------------------------------------------------------------- /app/src/main/res/values-eo-rUY/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sendi 5 | 6 | Haltigi 7 | Peti 8 | 9 | J-popo 10 | K-popo 11 | 12 | Agordi 13 | 14 | 15 | 16 | Titolo 17 | Artisto 18 | 19 | Registriĝo 20 | Ensaluti 21 | Elsaluti 22 | Uzantnomo / retpoŝta adreso 23 | Uzantnomo 24 | Retpoŝta adreso 25 | Pasvorto 26 | Konfirmu pasvorton 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | Titolo 38 | Albumo 39 | Artisto 40 | Daŭro 41 | 42 | Reprovu 43 | 44 | Pri 45 | 46 | Versio %s 47 | Helpu nin traduki 48 | Tradukistoj 49 | Permesiloj 50 | LISTEN.moe radio 51 | Privateca politiko 52 | 53 | Agordoj 54 | Ĝenerala 55 | Lingvo 56 | Muziko 57 | 58 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/di/MediaModule.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.di 2 | 3 | import android.content.Context 4 | import android.media.AudioManager 5 | import androidx.annotation.OptIn 6 | import androidx.media3.common.AudioAttributes 7 | import androidx.media3.common.C 8 | import androidx.media3.common.MediaItem 9 | import androidx.media3.common.util.UnstableApi 10 | import androidx.media3.datasource.DefaultDataSource 11 | import androidx.media3.datasource.DefaultHttpDataSource 12 | import androidx.media3.exoplayer.ExoPlayer 13 | import androidx.media3.exoplayer.source.ProgressiveMediaSource 14 | import androidx.media3.extractor.DefaultExtractorsFactory 15 | import dagger.Module 16 | import dagger.Provides 17 | import dagger.Reusable 18 | import dagger.hilt.InstallIn 19 | import dagger.hilt.android.components.ServiceComponent 20 | import dagger.hilt.android.qualifiers.ApplicationContext 21 | import me.echeung.moemoekyun.util.ext.audioManager 22 | import me.echeung.moemoekyun.util.system.NetworkUtil 23 | 24 | @Module 25 | @OptIn(UnstableApi::class) 26 | @InstallIn(ServiceComponent::class) 27 | object MediaModule { 28 | 29 | @Provides 30 | fun audioManager(@ApplicationContext context: Context): AudioManager = context.audioManager 31 | 32 | @Provides 33 | @Reusable 34 | fun audioAttributes(): AudioAttributes = AudioAttributes.Builder() 35 | .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) 36 | .setUsage(C.USAGE_MEDIA) 37 | .build() 38 | 39 | @Provides 40 | @Reusable 41 | fun liveConfiguration() = MediaItem.LiveConfiguration.Builder() 42 | .setTargetOffsetMs(0) 43 | .build() 44 | 45 | @OptIn(UnstableApi::class) 46 | @Provides 47 | fun dateSourceFactory(@ApplicationContext context: Context): DefaultDataSource.Factory = DefaultDataSource.Factory( 48 | context, 49 | DefaultHttpDataSource.Factory() 50 | .setUserAgent(NetworkUtil.userAgent), 51 | ) 52 | 53 | @OptIn(UnstableApi::class) 54 | @Provides 55 | fun progressiveMediaSourceFactory(dataSourceFactory: DefaultDataSource.Factory): ProgressiveMediaSource.Factory = 56 | ProgressiveMediaSource.Factory(dataSourceFactory, DefaultExtractorsFactory()) 57 | 58 | @Provides 59 | fun exoPlayer( 60 | @ApplicationContext context: Context, 61 | progressiveMediaSourceFactory: ProgressiveMediaSource.Factory, 62 | audioAttributes: AudioAttributes, 63 | ): ExoPlayer = ExoPlayer.Builder(context) 64 | .setMediaSourceFactory(progressiveMediaSourceFactory) 65 | .setAudioAttributes(audioAttributes, true) 66 | .setWakeMode(C.WAKE_MODE_NETWORK) 67 | .build() 68 | } 69 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/songs/SongsScreenModel.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.songs 2 | 3 | import androidx.compose.runtime.Immutable 4 | import cafe.adriel.voyager.core.model.StateScreenModel 5 | import cafe.adriel.voyager.core.model.screenModelScope 6 | import cafe.adriel.voyager.hilt.ScreenModelFactory 7 | import dagger.assisted.Assisted 8 | import dagger.assisted.AssistedFactory 9 | import dagger.assisted.AssistedInject 10 | import kotlinx.coroutines.flow.update 11 | import me.echeung.moemoekyun.domain.songs.interactor.FavoriteSong 12 | import me.echeung.moemoekyun.domain.songs.interactor.GetSong 13 | import me.echeung.moemoekyun.domain.songs.interactor.RequestSong 14 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 15 | import me.echeung.moemoekyun.domain.user.interactor.GetAuthenticatedUser 16 | import me.echeung.moemoekyun.util.ext.launchIO 17 | 18 | class SongsScreenModel @AssistedInject constructor( 19 | @Assisted val songs: List, 20 | private val getSong: GetSong, 21 | private val favoriteSong: FavoriteSong, 22 | private val requestSong: RequestSong, 23 | private val getAuthenticatedUser: GetAuthenticatedUser, 24 | ) : StateScreenModel(State(songs)) { 25 | 26 | init { 27 | screenModelScope.launchIO { 28 | val detailedSongs = songs.map { getSong.await(it.id) } 29 | 30 | mutableState.update { state -> 31 | state.copy( 32 | songs = detailedSongs, 33 | actionsEnabled = getAuthenticatedUser.get() != null, 34 | ) 35 | } 36 | } 37 | } 38 | 39 | fun toggleFavorite(songId: Int) { 40 | screenModelScope.launchIO { 41 | val favorited = favoriteSong.await(songId) 42 | 43 | mutableState.update { state -> 44 | state.copy( 45 | songs = state.songs.map { 46 | if (it.id == songId) { 47 | it.copy(favorited = favorited) 48 | } else { 49 | it 50 | } 51 | }, 52 | actionsEnabled = getAuthenticatedUser.get() != null, 53 | ) 54 | } 55 | } 56 | } 57 | 58 | fun request(song: DomainSong) { 59 | screenModelScope.launchIO { 60 | requestSong.await(song) 61 | } 62 | } 63 | 64 | @AssistedFactory 65 | interface Factory : ScreenModelFactory { 66 | fun create(songs: List): SongsScreenModel 67 | } 68 | 69 | @Immutable 70 | data class State(val songs: List, val actionsEnabled: Boolean = false) 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/ext/ContextExtension.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util.ext 2 | 3 | import android.app.UiModeManager 4 | import android.content.ClipData 5 | import android.content.ClipboardManager 6 | import android.content.Context 7 | import android.content.res.Configuration 8 | import android.media.AudioManager 9 | import android.net.ConnectivityManager 10 | import android.os.Build 11 | import android.widget.Toast 12 | import androidx.annotation.StringRes 13 | import androidx.core.content.getSystemService 14 | import me.echeung.moemoekyun.R 15 | 16 | /** 17 | * Display a toast in this context. 18 | * 19 | * @param resource the text resource. 20 | * @param duration the duration of the toast. Defaults to short. 21 | */ 22 | fun Context.toast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT) { 23 | Toast.makeText(this, resource, duration).show() 24 | } 25 | 26 | /** 27 | * Display a toast in this context. 28 | * 29 | * @param text the text to display. 30 | * @param duration the duration of the toast. Defaults to short. 31 | */ 32 | fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT) { 33 | Toast.makeText(this, text.orEmpty(), duration).show() 34 | } 35 | 36 | /** 37 | * Copies a string to clipboard 38 | * 39 | * @param label Label to show to the user describing the content 40 | * @param content the actual text to copy to the board 41 | */ 42 | fun Context.copyToClipboard(label: String, content: String) { 43 | if (content.isBlank()) return 44 | 45 | try { 46 | val clipboard = getSystemService()!! 47 | clipboard.setPrimaryClip(ClipData.newPlainText(label, content)) 48 | 49 | // Android 13 and higher shows a visual confirmation of copied contents 50 | // https://developer.android.com/about/versions/13/features/copy-paste 51 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { 52 | toast(getString(R.string.copied_to_clipboard_content, content)) 53 | } 54 | } catch (_: Throwable) { 55 | } 56 | } 57 | 58 | /** 59 | * Checks if the device is currently running in Android Auto mode. 60 | */ 61 | fun Context.isCarUiMode(): Boolean { 62 | val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager 63 | return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_CAR 64 | } 65 | 66 | val Context.connectivityManager: ConnectivityManager 67 | get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 68 | 69 | val Context.clipboardManager: ClipboardManager 70 | get() = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 71 | 72 | val Context.audioManager: AudioManager 73 | get() = getSystemService(Context.AUDIO_SERVICE) as AudioManager 74 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/songs/SongsService.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.songs 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.asStateFlow 5 | import kotlinx.coroutines.flow.onStart 6 | import me.echeung.moemoekyun.client.api.ApiClient 7 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 8 | import me.echeung.moemoekyun.domain.songs.model.SongConverter 9 | import java.util.Date 10 | import java.util.concurrent.TimeUnit 11 | import javax.inject.Inject 12 | import javax.inject.Singleton 13 | 14 | @Singleton 15 | class SongsService @Inject constructor(private val api: ApiClient, private val songConverter: SongConverter) { 16 | 17 | private val _songs = MutableStateFlow>(emptyMap()) 18 | val songs = _songs.asStateFlow().onStart { getSongs() } 19 | 20 | private val _favoriteEvents = MutableStateFlow(null) 21 | val favoriteEvents = _favoriteEvents.asStateFlow() 22 | 23 | private var lastUpdated = 0L 24 | 25 | suspend fun getDetailedSong(songId: Int): DomainSong { 26 | val song = _songs.value[songId] 27 | 28 | if (song != null && !song.albums.isNullOrEmpty()) { 29 | return song 30 | } 31 | 32 | val detailedSong = api.getSongDetails(songId).let(songConverter::toDomainSong) 33 | 34 | val updatedMap = _songs.value.toMutableMap() 35 | updatedMap[songId] = detailedSong 36 | _songs.value = updatedMap 37 | 38 | return detailedSong 39 | } 40 | 41 | suspend fun favorite(songId: Int): DomainSong { 42 | api.toggleFavorite(songId) 43 | 44 | val song = getDetailedSong(songId) 45 | 46 | val newState = !song.favorited 47 | val updatedSong = song.copy( 48 | favorited = newState, 49 | favoritedAtEpoch = if (newState) System.currentTimeMillis() else null, 50 | ) 51 | 52 | val updatedMap = _songs.value.toMutableMap() 53 | updatedMap[songId] = updatedSong 54 | _songs.value = updatedMap 55 | 56 | _favoriteEvents.emit(updatedSong) 57 | 58 | return updatedSong 59 | } 60 | 61 | suspend fun request(songId: Int) { 62 | api.requestSong(songId) 63 | } 64 | 65 | private suspend fun getSongs(): List { 66 | if (lastUpdated == 0L || !isCacheValid() || _songs.value.isEmpty()) { 67 | val songs = api.getAllSongs().map(songConverter::toDomainSong) 68 | lastUpdated = Date().time 69 | _songs.value = songs.associateBy { it.id }.toMutableMap() 70 | } 71 | 72 | return _songs.value.values.toList() 73 | } 74 | 75 | private fun isCacheValid() = Date().time - lastUpdated < MAX_AGE 76 | } 77 | 78 | private val MAX_AGE = TimeUnit.DAYS.toMillis(1) 79 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/SongsListActions.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.automirrored.outlined.Sort 6 | import androidx.compose.material.icons.outlined.Shuffle 7 | import androidx.compose.material3.DropdownMenuItem 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.IconButton 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.runtime.setValue 16 | import androidx.compose.ui.res.stringResource 17 | import me.echeung.moemoekyun.R 18 | import me.echeung.moemoekyun.util.SortType 19 | 20 | @Composable 21 | fun RowScope.SongsListActions( 22 | selectedSortType: SortType, 23 | onSortBy: (SortType) -> Unit, 24 | sortDescending: Boolean, 25 | onSortDescending: (Boolean) -> Unit, 26 | requestRandomSong: () -> Unit, 27 | sortTypes: List = listOf(SortType.TITLE, SortType.ARTIST), 28 | ) { 29 | var showSortMenu by remember { mutableStateOf(false) } 30 | 31 | IconButton(onClick = { showSortMenu = !showSortMenu }) { 32 | Icon( 33 | imageVector = Icons.AutoMirrored.Outlined.Sort, 34 | contentDescription = stringResource(R.string.sort), 35 | ) 36 | 37 | DropdownMenu( 38 | expanded = showSortMenu, 39 | onDismissRequest = { showSortMenu = false }, 40 | ) { 41 | sortTypes.forEach { sortType -> 42 | DropdownMenuItem( 43 | onClick = { 44 | onSortBy(sortType) 45 | showSortMenu = false 46 | }, 47 | text = { Text(stringResource(sortType.labelRes)) }, 48 | trailingIcon = { 49 | RadioIcon(checked = selectedSortType == sortType) 50 | }, 51 | ) 52 | } 53 | 54 | DropdownMenuItem( 55 | onClick = { 56 | onSortDescending(!sortDescending) 57 | showSortMenu = false 58 | }, 59 | text = { Text(stringResource(R.string.sort_desc)) }, 60 | trailingIcon = { 61 | CheckboxIcon(checked = sortDescending) 62 | }, 63 | ) 64 | } 65 | } 66 | 67 | IconButton(onClick = requestRandomSong) { 68 | Icon( 69 | imageVector = Icons.Outlined.Shuffle, 70 | contentDescription = stringResource(R.string.random_request), 71 | ) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.di 2 | 3 | import android.content.Context 4 | import com.apollographql.apollo.ApolloClient 5 | import com.apollographql.apollo.cache.http.HttpFetchPolicy 6 | import com.apollographql.apollo.cache.http.httpCache 7 | import com.apollographql.apollo.cache.http.httpFetchPolicy 8 | import com.apollographql.apollo.network.okHttpClient 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import dagger.hilt.components.SingletonComponent 14 | import me.echeung.moemoekyun.BuildConfig 15 | import me.echeung.moemoekyun.client.auth.AuthUtil 16 | import me.echeung.moemoekyun.util.system.NetworkUtil 17 | import okhttp3.OkHttpClient 18 | import okhttp3.logging.HttpLoggingInterceptor 19 | import java.io.File 20 | import java.util.concurrent.TimeUnit 21 | import javax.inject.Singleton 22 | 23 | @Module 24 | @InstallIn(SingletonComponent::class) 25 | object NetworkModule { 26 | 27 | @Provides 28 | @Singleton 29 | fun okhttpClient(authUtil: AuthUtil): OkHttpClient { 30 | val builder = OkHttpClient.Builder() 31 | .addNetworkInterceptor { chain -> 32 | val request = chain.request() 33 | 34 | val newRequest = request.newBuilder() 35 | .header("User-Agent", NetworkUtil.userAgent) 36 | .header("Content-Type", "application/json") 37 | 38 | // MFA login 39 | if (authUtil.mfaToken != null) { 40 | newRequest.header(HEADER_AUTHZ, authUtil.mfaAuthTokenWithPrefix) 41 | } 42 | 43 | // Authorized calls 44 | if (authUtil.isAuthenticated) { 45 | newRequest.header(HEADER_AUTHZ, authUtil.authTokenWithPrefix) 46 | } 47 | 48 | chain.proceed(newRequest.build()) 49 | } 50 | .connectTimeout(30, TimeUnit.SECONDS) 51 | .readTimeout(30, TimeUnit.SECONDS) 52 | 53 | if (BuildConfig.DEBUG) { 54 | val httpLoggingInterceptor = HttpLoggingInterceptor().apply { 55 | level = HttpLoggingInterceptor.Level.HEADERS 56 | } 57 | builder.addNetworkInterceptor(httpLoggingInterceptor) 58 | } 59 | 60 | return builder.build() 61 | } 62 | 63 | @Provides 64 | @Singleton 65 | fun apolloClient(@ApplicationContext context: Context, okHttpClient: OkHttpClient) = ApolloClient.Builder() 66 | .serverUrl("https://listen.moe/graphql") 67 | .httpCache( 68 | directory = File(context.externalCacheDir, "apolloCache"), 69 | maxSize = 1024 * 1024, 70 | ) 71 | .httpFetchPolicy(HttpFetchPolicy.NetworkFirst) 72 | .okHttpClient(okHttpClient) 73 | .build() 74 | } 75 | 76 | private const val HEADER_AUTHZ = "Authorization" 77 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/home/UnauthedHomeContent.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.home 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.material3.Button 14 | import androidx.compose.material3.OutlinedButton 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.layout.ContentScale 20 | import androidx.compose.ui.res.painterResource 21 | import androidx.compose.ui.res.stringResource 22 | import androidx.compose.ui.tooling.preview.PreviewLightDark 23 | import androidx.compose.ui.unit.dp 24 | import cafe.adriel.voyager.navigator.LocalNavigator 25 | import cafe.adriel.voyager.navigator.currentOrThrow 26 | import me.echeung.moemoekyun.R 27 | import me.echeung.moemoekyun.ui.screen.auth.LoginScreen 28 | import me.echeung.moemoekyun.ui.screen.auth.RegisterScreen 29 | import me.echeung.moemoekyun.ui.theme.AppTheme 30 | import me.echeung.moemoekyun.ui.util.plus 31 | 32 | @Composable 33 | fun UnauthedHomeContent(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp)) { 34 | val navigator = LocalNavigator.currentOrThrow 35 | 36 | Column( 37 | modifier = modifier 38 | .fillMaxSize() 39 | .padding(contentPadding + 16.dp), 40 | horizontalAlignment = Alignment.CenterHorizontally, 41 | verticalArrangement = Arrangement.Center, 42 | ) { 43 | Image( 44 | modifier = Modifier 45 | .fillMaxWidth(0.75f), 46 | painter = painterResource(R.drawable.logo), 47 | contentScale = ContentScale.Fit, 48 | contentDescription = null, 49 | ) 50 | 51 | Spacer(modifier = Modifier.height(32.dp)) 52 | 53 | Row( 54 | horizontalArrangement = Arrangement.spacedBy(8.dp), 55 | ) { 56 | OutlinedButton( 57 | modifier = Modifier.weight(1f), 58 | onClick = { navigator.push(RegisterScreen) }, 59 | ) { 60 | Text(stringResource(R.string.register)) 61 | } 62 | 63 | Button( 64 | modifier = Modifier.weight(1f), 65 | onClick = { navigator.push(LoginScreen) }, 66 | ) { 67 | Text(stringResource(R.string.login)) 68 | } 69 | } 70 | } 71 | } 72 | 73 | @PreviewLightDark 74 | @Composable 75 | private fun UnauthedHomeContentPreview() { 76 | AppTheme { 77 | UnauthedHomeContent() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/logo.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/search/SearchScreenModel.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.search 2 | 3 | import androidx.compose.runtime.Immutable 4 | import cafe.adriel.voyager.core.model.StateScreenModel 5 | import cafe.adriel.voyager.core.model.screenModelScope 6 | import kotlinx.collections.immutable.ImmutableList 7 | import kotlinx.collections.immutable.toImmutableList 8 | import kotlinx.coroutines.flow.collectLatest 9 | import kotlinx.coroutines.flow.combine 10 | import kotlinx.coroutines.flow.update 11 | import me.echeung.moemoekyun.domain.songs.interactor.GetSongs 12 | import me.echeung.moemoekyun.domain.songs.interactor.RequestSong 13 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 14 | import me.echeung.moemoekyun.domain.songs.model.search 15 | import me.echeung.moemoekyun.util.PreferenceUtil 16 | import me.echeung.moemoekyun.util.SortType 17 | import me.echeung.moemoekyun.util.ext.launchIO 18 | import javax.inject.Inject 19 | 20 | class SearchScreenModel @Inject constructor( 21 | private val getSongs: GetSongs, 22 | private val requestSong: RequestSong, 23 | private val preferenceUtil: PreferenceUtil, 24 | ) : StateScreenModel(State()) { 25 | 26 | init { 27 | screenModelScope.launchIO { 28 | getSongs.asFlow() 29 | .collectLatest { 30 | mutableState.update { state -> 31 | state.copy( 32 | songs = it.toImmutableList(), 33 | ) 34 | } 35 | } 36 | } 37 | 38 | screenModelScope.launchIO { 39 | combine( 40 | preferenceUtil.songsSortType().asFlow(), 41 | preferenceUtil.songsSortDescending().asFlow(), 42 | ) { sortType, descending -> Pair(sortType, descending) } 43 | .collectLatest { (sortType, descending) -> 44 | mutableState.update { state -> 45 | state.copy( 46 | sortType = sortType, 47 | sortDescending = descending, 48 | ) 49 | } 50 | } 51 | } 52 | } 53 | 54 | fun requestRandomSong() { 55 | screenModelScope.launchIO { 56 | mutableState.value.filteredSongs?.randomOrNull()?.let { 57 | requestSong.await(it) 58 | } 59 | } 60 | } 61 | 62 | fun search(query: String) { 63 | mutableState.update { state -> 64 | state.copy( 65 | searchQuery = query, 66 | ) 67 | } 68 | } 69 | 70 | fun sortBy(sortType: SortType) { 71 | getSongs.setSortType(sortType) 72 | } 73 | 74 | fun sortDescending(descending: Boolean) { 75 | getSongs.setSortDescending(descending) 76 | } 77 | 78 | @Immutable 79 | data class State( 80 | val songs: ImmutableList? = null, 81 | val searchQuery: String? = null, 82 | val sortType: SortType = SortType.TITLE, 83 | val sortDescending: Boolean = false, 84 | ) { 85 | val filteredSongs: ImmutableList? 86 | get() = songs?.search(searchQuery) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/auth/LoginScreenModel.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.auth 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Immutable 5 | import cafe.adriel.voyager.core.model.StateScreenModel 6 | import cafe.adriel.voyager.core.model.screenModelScope 7 | import kotlinx.coroutines.flow.update 8 | import me.echeung.moemoekyun.domain.user.interactor.LoginLogout 9 | import me.echeung.moemoekyun.util.ext.clipboardManager 10 | import me.echeung.moemoekyun.util.ext.launchIO 11 | import javax.inject.Inject 12 | 13 | // TODO: consider integrating with https://developer.android.com/identity/sign-in/credential-manager 14 | class LoginScreenModel @Inject constructor(private val loginLogout: LoginLogout) : 15 | StateScreenModel(State()) { 16 | 17 | fun login(username: String, password: String) { 18 | mutableState.update { 19 | it.copy(loading = true) 20 | } 21 | 22 | screenModelScope.launchIO { 23 | val state = loginLogout.login(username, password) 24 | 25 | mutableState.update { 26 | it.copy( 27 | loading = false, 28 | result = state.toResult(), 29 | ) 30 | } 31 | } 32 | } 33 | 34 | fun loginMfa(otpToken: String) { 35 | mutableState.update { 36 | it.copy(loading = true) 37 | } 38 | 39 | val token = otpToken.trim { it <= ' ' } 40 | if (token.length != OTP_LENGTH) { 41 | mutableState.update { 42 | it.copy( 43 | result = Result.InvalidOtp, 44 | loading = false, 45 | ) 46 | } 47 | return 48 | } 49 | 50 | screenModelScope.launchIO { 51 | val state = loginLogout.loginMfa(token) 52 | 53 | mutableState.update { 54 | it.copy( 55 | loading = false, 56 | result = state.toResult(), 57 | ) 58 | } 59 | } 60 | } 61 | 62 | fun getOtpTokenFromClipboardOrNull(context: Context): String? { 63 | val clipData = context.clipboardManager.primaryClip 64 | if (clipData == null || clipData.itemCount == 0) { 65 | return null 66 | } 67 | 68 | val clipDataItem = clipData.getItemAt(0) 69 | val clipboardText = clipDataItem.text.toString() 70 | 71 | if (clipboardText.length == OTP_LENGTH && clipboardText.matches(OTP_REGEX)) { 72 | return clipboardText 73 | } 74 | 75 | return null 76 | } 77 | 78 | private fun LoginLogout.State.toResult() = when (this) { 79 | is LoginLogout.State.Complete -> Result.Complete 80 | is LoginLogout.State.RequireOtp -> Result.RequireOtp 81 | is LoginLogout.State.Error -> Result.ApiError(this.message) 82 | } 83 | 84 | @Immutable 85 | data class State(val loading: Boolean = false, val result: Result? = null) { 86 | val requiresMfa: Boolean 87 | get() = result is Result.RequireOtp || result is Result.InvalidOtp 88 | } 89 | 90 | sealed interface Result { 91 | data object Complete : Result 92 | data object InvalidOtp : Result 93 | data object RequireOtp : Result 94 | data class ApiError(val message: String) : Result 95 | } 96 | } 97 | 98 | private const val OTP_LENGTH = 6 99 | private val OTP_REGEX = "^\\d*$".toRegex() 100 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 44 | 47 | 48 | 52 | 55 | 56 | 57 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/search/SearchScreen.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.search 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.foundation.layout.WindowInsetsSides 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.only 8 | import androidx.compose.foundation.layout.systemBars 9 | import androidx.compose.foundation.layout.windowInsetsPadding 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.material3.SearchBar 12 | import androidx.compose.material3.SearchBarDefaults 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.collectAsState 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.res.stringResource 19 | import cafe.adriel.voyager.core.screen.Screen 20 | import cafe.adriel.voyager.hilt.getScreenModel 21 | import cafe.adriel.voyager.navigator.LocalNavigator 22 | import cafe.adriel.voyager.navigator.currentOrThrow 23 | import me.echeung.moemoekyun.R 24 | import me.echeung.moemoekyun.ui.common.LoadingScreen 25 | import me.echeung.moemoekyun.ui.common.SongsListActions 26 | import me.echeung.moemoekyun.ui.common.UpButton 27 | import me.echeung.moemoekyun.ui.common.songsItems 28 | 29 | object SearchScreen : Screen { 30 | 31 | @Composable 32 | override fun Content() { 33 | val screenModel = getScreenModel() 34 | val state by screenModel.state.collectAsState() 35 | val navigator = LocalNavigator.currentOrThrow 36 | 37 | val onExpandedChange = { expanded: Boolean -> 38 | if (!expanded) { 39 | navigator.pop() 40 | } 41 | } 42 | 43 | SearchBar( 44 | inputField = { 45 | SearchBarDefaults.InputField( 46 | query = state.searchQuery ?: "", 47 | onQueryChange = screenModel::search, 48 | onSearch = {}, 49 | expanded = true, 50 | onExpandedChange = onExpandedChange, 51 | enabled = true, 52 | placeholder = { Text(stringResource(R.string.search)) }, 53 | leadingIcon = { UpButton() }, 54 | trailingIcon = { 55 | Row { 56 | SongsListActions( 57 | selectedSortType = state.sortType, 58 | onSortBy = screenModel::sortBy, 59 | sortDescending = state.sortDescending, 60 | onSortDescending = screenModel::sortDescending, 61 | requestRandomSong = screenModel::requestRandomSong, 62 | ) 63 | } 64 | }, 65 | ) 66 | }, 67 | expanded = true, 68 | onExpandedChange = onExpandedChange, 69 | modifier = Modifier.fillMaxWidth(), 70 | ) { 71 | if (state.songs == null) { 72 | LoadingScreen() 73 | return@SearchBar 74 | } 75 | 76 | LazyColumn( 77 | modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)), 78 | ) { 79 | songsItems( 80 | songs = state.filteredSongs, 81 | showFavoriteIcons = true, 82 | ) 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/client/auth/AuthUtil.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.auth 2 | 3 | import android.content.Context 4 | import androidx.core.content.edit 5 | import androidx.preference.PreferenceManager 6 | import dagger.hilt.android.qualifiers.ApplicationContext 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | import kotlin.math.roundToInt 10 | 11 | /** 12 | * Helper for handling authorization-related tasks. Helps with the storage of the auth token and 13 | * actions requiring it. 14 | */ 15 | @Singleton 16 | class AuthUtil @Inject constructor(@ApplicationContext context: Context) { 17 | 18 | private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context) 19 | 20 | /** 21 | * Checks if the user has previously logged in (i.e. a token is stored). 22 | * 23 | * @return Whether the user is authenticated. 24 | */ 25 | val isAuthenticated: Boolean 26 | get() = authToken != null 27 | 28 | /** 29 | * Fetches the stored auth token with the "Bearer" prefix. 30 | * 31 | * @return The user's auth token with the "Bearer" prefix. 32 | */ 33 | val authTokenWithPrefix: String 34 | get() = getPrefixedToken(authToken) 35 | 36 | /** 37 | * Stores the auth token, also tracking the time that it was stored. 38 | * Android context to fetch SharedPreferences. 39 | * 40 | * @param token The auth token to store, provided via the LISTEN.moe API. 41 | */ 42 | var authToken: String? 43 | get() = sharedPrefs.getString(USER_TOKEN, null) 44 | set(token) { 45 | sharedPrefs.edit { 46 | putString(USER_TOKEN, token) 47 | putLong(LAST_AUTH, System.currentTimeMillis() / 1000) 48 | } 49 | } 50 | 51 | /** 52 | * Stores the temporary auth token for MFA. 53 | * 54 | * @param token The auth token for MFA to store, provided via the LISTEN.moe API. 55 | */ 56 | var mfaToken: String? = null 57 | 58 | /** 59 | * Fetches the stored temporary MFA auth token with the "Bearer" prefix. 60 | * 61 | * @return The temporary MFA auth token with the "Bearer" prefix. 62 | */ 63 | val mfaAuthTokenWithPrefix: String 64 | get() = getPrefixedToken(mfaToken) 65 | 66 | /** 67 | * @return The time in seconds since the stored auth token was stored. 68 | */ 69 | private val tokenAge: Long 70 | get() = sharedPrefs.getLong(LAST_AUTH, 0L) 71 | 72 | /** 73 | * Checks how old the stored auth token is. If it's older than 28 days, it becomes invalidated. 74 | * 75 | * @return Whether the token is still valid. 76 | */ 77 | fun isAuthTokenValid(): Boolean { 78 | if (!isAuthenticated) { 79 | return false 80 | } 81 | 82 | // Check token is valid (max 28 days) 83 | val lastAuth = tokenAge 84 | if (((System.currentTimeMillis() / 1000 - lastAuth) / 86400.0).roundToInt() >= 28) { 85 | clearAuthToken() 86 | return false 87 | } 88 | 89 | return true 90 | } 91 | 92 | /** 93 | * Removes the stored auth token. 94 | */ 95 | fun clearAuthToken() { 96 | sharedPrefs.edit { 97 | putString(USER_TOKEN, null) 98 | putLong(LAST_AUTH, 0) 99 | } 100 | } 101 | 102 | /** 103 | * Removes the stored temporary MFA auth token. 104 | */ 105 | fun clearMfaAuthToken() { 106 | this.mfaToken = null 107 | } 108 | 109 | private fun getPrefixedToken(token: String?) = "Bearer $token" 110 | } 111 | 112 | private const val USER_TOKEN = "user_token" 113 | private const val LAST_AUTH = "last_auth" 114 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/common/preferences/ListPreference.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.common.preferences 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.heightIn 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.lazy.LazyColumn 9 | import androidx.compose.foundation.lazy.rememberLazyListState 10 | import androidx.compose.foundation.selection.selectable 11 | import androidx.compose.material3.AlertDialog 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.RadioButton 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.TextButton 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.runtime.setValue 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.draw.clip 24 | import androidx.compose.ui.res.stringResource 25 | import androidx.compose.ui.unit.dp 26 | import kotlinx.collections.immutable.ImmutableMap 27 | import me.echeung.moemoekyun.R 28 | 29 | @Composable 30 | fun ListPreference( 31 | value: T, 32 | title: String, 33 | subtitle: String?, 34 | entries: ImmutableMap, 35 | onValueChange: (T) -> Unit, 36 | ) { 37 | var isDialogShown by remember { mutableStateOf(false) } 38 | 39 | TextPreference( 40 | title = title, 41 | subtitle = subtitle, 42 | onPreferenceClick = { isDialogShown = true }, 43 | ) 44 | 45 | if (isDialogShown) { 46 | AlertDialog( 47 | onDismissRequest = { isDialogShown = false }, 48 | title = { Text(text = title) }, 49 | text = { 50 | Box { 51 | val state = rememberLazyListState() 52 | LazyColumn(state = state) { 53 | entries.forEach { current -> 54 | val isSelected = value == current.key 55 | item { 56 | DialogRow( 57 | label = current.value, 58 | isSelected = isSelected, 59 | onSelected = { 60 | onValueChange(current.key!!) 61 | isDialogShown = false 62 | }, 63 | ) 64 | } 65 | } 66 | } 67 | } 68 | }, 69 | confirmButton = { 70 | TextButton(onClick = { isDialogShown = false }) { 71 | Text(text = stringResource(android.R.string.cancel)) 72 | } 73 | }, 74 | ) 75 | } 76 | } 77 | 78 | @Composable 79 | private fun DialogRow(label: String, isSelected: Boolean, onSelected: () -> Unit) { 80 | Row( 81 | verticalAlignment = Alignment.CenterVertically, 82 | modifier = Modifier 83 | .clip(MaterialTheme.shapes.small) 84 | .selectable( 85 | selected = isSelected, 86 | onClick = { if (!isSelected) onSelected() }, 87 | ) 88 | .fillMaxWidth() 89 | .heightIn(min = 48.dp), 90 | ) { 91 | RadioButton( 92 | selected = isSelected, 93 | onClick = null, 94 | ) 95 | Text( 96 | text = label, 97 | style = MaterialTheme.typography.bodyLarge.merge(), 98 | modifier = Modifier.padding(start = 24.dp), 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/client/api/socket/ResponseModel.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.api.socket 2 | 3 | import kotlinx.serialization.Contextual 4 | import kotlinx.serialization.DeserializationStrategy 5 | import kotlinx.serialization.Serializable 6 | import kotlinx.serialization.json.JsonContentPolymorphicSerializer 7 | import kotlinx.serialization.json.JsonElement 8 | import kotlinx.serialization.json.intOrNull 9 | import kotlinx.serialization.json.jsonObject 10 | import kotlinx.serialization.json.jsonPrimitive 11 | import me.echeung.moemoekyun.client.model.Event 12 | import me.echeung.moemoekyun.client.model.Song 13 | import me.echeung.moemoekyun.client.model.User 14 | import kotlin.time.ExperimentalTime 15 | import kotlin.time.Instant 16 | 17 | object WebsocketResponseSerializer : JsonContentPolymorphicSerializer(WebsocketResponse::class) { 18 | override fun selectDeserializer(element: JsonElement): DeserializationStrategy = 19 | when (val opValue = element.jsonObject["op"]?.jsonPrimitive?.intOrNull) { 20 | 0 -> WebsocketResponse.Connect.serializer() 21 | 1 -> WebsocketResponse.Update.serializer() 22 | 10 -> WebsocketResponse.HeartbeatAck.serializer() 23 | else -> error("Unknown op value: $opValue") 24 | } 25 | } 26 | 27 | @Serializable(with = WebsocketResponseSerializer::class) 28 | sealed interface WebsocketResponse { 29 | 30 | @Serializable 31 | data class Connect(val d: Details) : WebsocketResponse { 32 | @Serializable 33 | data class Details(val heartbeat: Int, val message: String?, val user: User?) 34 | } 35 | 36 | @Serializable 37 | data class Update(val t: String?, val d: Details?) : WebsocketResponse { 38 | @OptIn(ExperimentalTime::class) 39 | @Serializable 40 | data class Details( 41 | val song: Song?, 42 | @Contextual 43 | val startTime: Instant?, 44 | val lastPlayed: List?, 45 | val requester: User?, 46 | val event: Event?, 47 | val listeners: Int, 48 | ) 49 | 50 | fun isValidUpdate(): Boolean = ( 51 | t == TRACK_UPDATE || 52 | t == TRACK_UPDATE_REQUEST || 53 | t == QUEUE_UPDATE || 54 | isNotification() 55 | ) 56 | 57 | private fun isNotification(): Boolean = t == NOTIFICATION 58 | 59 | companion object { 60 | private const val TRACK_UPDATE = "TRACK_UPDATE" 61 | private const val TRACK_UPDATE_REQUEST = "TRACK_UPDATE_REQUEST" 62 | private const val QUEUE_UPDATE = "QUEUE_UPDATE" 63 | private const val NOTIFICATION = "NOTIFICATION" 64 | } 65 | } 66 | 67 | @Serializable 68 | data object HeartbeatAck : WebsocketResponse 69 | 70 | // @Serializable 71 | // data class Notification( 72 | // override val op: Int, 73 | // val t: String?, 74 | // val d: Details?, 75 | // ) : ResponseModel() { 76 | // 77 | // @Serializable 78 | // data class Details( 79 | // val type: String?, 80 | // ) 81 | // } 82 | // 83 | // @Serializable 84 | // data class EventNotificationResponse( 85 | // override val op: Int, 86 | // val t: String?, 87 | // val d: Details?, 88 | // ) : ResponseModel() { 89 | // 90 | // @Serializable 91 | // data class Details( 92 | // val type: String?, 93 | // val event: Event?, 94 | // ) 95 | // 96 | // companion object { 97 | // const val TYPE = "EVENT" 98 | // } 99 | // } 100 | } 101 | 102 | @Serializable 103 | sealed interface WebsocketRequest { 104 | 105 | @Serializable 106 | data class Update(val op: Int = 2) : WebsocketRequest 107 | 108 | @Serializable 109 | data class Heartbeat(val op: Int = 9) : WebsocketRequest 110 | } 111 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/user/UserService.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.user 2 | 3 | import kotlinx.coroutines.MainScope 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.asStateFlow 6 | import kotlinx.coroutines.flow.collectLatest 7 | import kotlinx.coroutines.flow.distinctUntilChanged 8 | import kotlinx.coroutines.flow.filterNotNull 9 | import kotlinx.coroutines.flow.update 10 | import me.echeung.moemoekyun.client.api.ApiClient 11 | import me.echeung.moemoekyun.client.auth.AuthUtil 12 | import me.echeung.moemoekyun.client.model.User 13 | import me.echeung.moemoekyun.domain.songs.SongsService 14 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 15 | import me.echeung.moemoekyun.domain.songs.model.SongConverter 16 | import me.echeung.moemoekyun.util.PreferenceUtil 17 | import me.echeung.moemoekyun.util.ext.launchIO 18 | import javax.inject.Inject 19 | import javax.inject.Singleton 20 | 21 | @Singleton 22 | class UserService @Inject constructor( 23 | private val preferenceUtil: PreferenceUtil, 24 | private val authUtil: AuthUtil, 25 | private val api: ApiClient, 26 | private val songConverter: SongConverter, 27 | private val songsService: SongsService, 28 | ) { 29 | 30 | private val scope = MainScope() 31 | 32 | private val _state = MutableStateFlow(LoggedOutState) 33 | val state = _state.asStateFlow() 34 | 35 | init { 36 | scope.launchIO { 37 | getUser() 38 | } 39 | 40 | scope.launchIO { 41 | preferenceUtil.station().asFlow() 42 | .distinctUntilChanged() 43 | .collectLatest { 44 | getUser() 45 | } 46 | } 47 | 48 | scope.launchIO { 49 | songsService.favoriteEvents 50 | .filterNotNull() 51 | .collectLatest { eventSong -> 52 | _state.update { state -> 53 | state.copy( 54 | favorites = when (eventSong.favorited) { 55 | true -> state.favorites + listOf(eventSong) 56 | false -> state.favorites.filterNot { it.id == eventSong.id } 57 | }, 58 | ) 59 | } 60 | } 61 | } 62 | } 63 | 64 | val isAuthenticated: Boolean 65 | get() = authUtil.isAuthenticated 66 | 67 | suspend fun login(username: String, password: String): ApiClient.LoginResult { 68 | val (state, value) = api.authenticate(username, password) 69 | when (state) { 70 | ApiClient.LoginResult.REQUIRE_OTP -> { 71 | authUtil.mfaToken = value 72 | } 73 | ApiClient.LoginResult.COMPLETE -> { 74 | authUtil.authToken = value 75 | getUser() 76 | } 77 | else -> throw IllegalStateException(value) 78 | } 79 | return state 80 | } 81 | 82 | suspend fun loginMfa(otpToken: String): ApiClient.LoginResult { 83 | val (state, value) = api.authenticateMfa(otpToken) 84 | when (state) { 85 | ApiClient.LoginResult.COMPLETE -> { 86 | authUtil.authToken = value 87 | authUtil.clearMfaAuthToken() 88 | getUser() 89 | } 90 | else -> throw IllegalStateException(value) 91 | } 92 | return state 93 | } 94 | 95 | fun logout() { 96 | authUtil.clearAuthToken() 97 | 98 | _state.update { 99 | LoggedOutState 100 | } 101 | } 102 | 103 | suspend fun register(username: String, email: String, password: String) { 104 | api.register(email, username, password) 105 | } 106 | 107 | private suspend fun getUser() { 108 | if (!authUtil.isAuthenticated || !authUtil.isAuthTokenValid()) { 109 | logout() 110 | return 111 | } 112 | 113 | val userInfo = api.getUserInfo() 114 | val userFavorites = api.getUserFavorites() 115 | 116 | _state.update { state -> 117 | state.copy( 118 | user = userInfo, 119 | favorites = userFavorites 120 | .map(songConverter::toDomainSong) 121 | .sortedBy { it.title }, 122 | ) 123 | } 124 | } 125 | } 126 | 127 | data class UserState(val user: User?, val favorites: List) 128 | 129 | private val LoggedOutState = UserState( 130 | user = null, 131 | favorites = emptyList(), 132 | ) 133 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/util/AlbumArtUtil.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.util 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import android.graphics.BitmapFactory 6 | import android.graphics.Color 7 | import androidx.annotation.ColorInt 8 | import androidx.compose.runtime.Immutable 9 | import androidx.core.graphics.BitmapCompat 10 | import androidx.core.graphics.ColorUtils 11 | import androidx.palette.graphics.Palette 12 | import androidx.palette.graphics.Target.DARK_VIBRANT 13 | import androidx.palette.graphics.Target.MUTED 14 | import androidx.palette.graphics.Target.VIBRANT 15 | import androidx.palette.graphics.get 16 | import coil3.imageLoader 17 | import coil3.request.ImageRequest 18 | import coil3.request.SuccessResult 19 | import coil3.request.allowHardware 20 | import coil3.size.Scale 21 | import coil3.toBitmap 22 | import dagger.hilt.android.qualifiers.ApplicationContext 23 | import kotlinx.coroutines.MainScope 24 | import kotlinx.coroutines.flow.MutableStateFlow 25 | import kotlinx.coroutines.flow.asStateFlow 26 | import kotlinx.coroutines.flow.collectLatest 27 | import kotlinx.coroutines.launch 28 | import logcat.LogPriority 29 | import logcat.asLog 30 | import logcat.logcat 31 | import me.echeung.moemoekyun.R 32 | import me.echeung.moemoekyun.domain.radio.interactor.CurrentSong 33 | import me.echeung.moemoekyun.util.ext.launchIO 34 | import me.echeung.moemoekyun.util.ext.withIOContext 35 | import java.io.ByteArrayOutputStream 36 | import javax.inject.Inject 37 | import javax.inject.Singleton 38 | 39 | @Singleton 40 | class AlbumArtUtil @Inject constructor( 41 | @ApplicationContext private val context: Context, 42 | private val currentSong: CurrentSong, 43 | ) { 44 | 45 | private val defaultAlbumArt: Bitmap by lazy { 46 | BitmapFactory.decodeResource(context.resources, R.drawable.default_album_art) 47 | } 48 | 49 | private val _flow = MutableStateFlow(State.EMPTY) 50 | val flow = _flow.asStateFlow() 51 | 52 | private val scope = MainScope() 53 | 54 | init { 55 | scope.launchIO { 56 | currentSong.albumArtFlow().collectLatest(::updateAlbumArt) 57 | } 58 | } 59 | 60 | fun getCurrentAlbumArt(size: Int): ByteArray? { 61 | if (_flow.value.bitmap == null) { 62 | return null 63 | } 64 | 65 | return try { 66 | val scaledBitmap = BitmapCompat.createScaledBitmap(_flow.value.bitmap!!, size, size, null, false) 67 | val stream = ByteArrayOutputStream() 68 | scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) 69 | stream.toByteArray() 70 | } catch (e: Throwable) { 71 | // Typically OutOfMemoryError or NullPointerException 72 | e.printStackTrace() 73 | null 74 | } 75 | } 76 | 77 | private suspend fun updateAlbumArt(albumArtUrl: String?) { 78 | val bitmap = getAlbumArtBitmap(albumArtUrl) 79 | val accentColor = extractAccentColor(bitmap) 80 | 81 | scope.launch { 82 | _flow.value = State( 83 | bitmap = bitmap, 84 | accentColor = accentColor, 85 | ) 86 | } 87 | } 88 | 89 | private suspend fun getAlbumArtBitmap(url: String?): Bitmap = withIOContext { 90 | if (url == null) { 91 | return@withIOContext defaultAlbumArt 92 | } 93 | 94 | val request = ImageRequest.Builder(context) 95 | .data(url) 96 | .scale(Scale.FILL) 97 | .allowHardware(false) // Required for Palette 98 | .build() 99 | 100 | val result = context.imageLoader.execute(request) 101 | if (result !is SuccessResult) { 102 | return@withIOContext defaultAlbumArt 103 | } 104 | 105 | result.image.toBitmap() 106 | } 107 | 108 | private suspend fun extractAccentColor(resource: Bitmap): Int? = withIOContext { 109 | try { 110 | val palette = Palette.from(resource).generate() 111 | val swatch: Palette.Swatch? = palette[DARK_VIBRANT] ?: palette[VIBRANT] ?: palette[MUTED] 112 | 113 | if (swatch != null) { 114 | var color = swatch.rgb 115 | 116 | // Darken if needed 117 | if (ColorUtils.calculateLuminance(color) >= 0.4) { 118 | color = ColorUtils.blendARGB(color, Color.BLACK, 0.2f) 119 | } 120 | 121 | return@withIOContext color 122 | } 123 | null 124 | } catch (e: Exception) { 125 | logcat(LogPriority.WARN) { e.asLog() } 126 | null 127 | } 128 | } 129 | 130 | @Immutable 131 | data class State(val bitmap: Bitmap?, @ColorInt val accentColor: Int?) { 132 | companion object { 133 | val EMPTY = State(null, null) 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /app/src/main/res/values/donottranslate.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | LISTEN.moe 5 | 6 | [{ 7 | \"include\": \"https://listen.moe/.well-known/assetlinks.json\" 8 | }] 9 | 10 | 11 | - 12 | 13 | 17 | 18 | - k͢e͢k͢m͢a͢c͢s͢k͢a͢ - 19 | \n3316482 20 | \nAna Paula 21 | \nAnany Meza 22 | \nANDREY 01 23 | \nArthur Um 24 | \nAtFr 25 | \nayoubchan 26 | \nBardkauza9 27 | \nbhj 28 | \nChuen Li 29 | \nClaireStanfield 30 | \nCliptons 31 | \nCrawl 32 | \nDiavolo69 33 | \nDicky Aditya 34 | \nditokp 35 | \nditokurniap 36 | \nDominique Chapelier 37 | \ndungles 38 | \nEmachii 🦋 39 | \nemre özdemir 40 | \nEnrico Belleri 41 | \nErinç Esen 42 | \nFlutterCZ 43 | \nFoorack 44 | \nGabriel Henrique da Cunha Gonçalves 45 | \nGuai 46 | \nGuo Yunhe 47 | \nHelaBasa Group 48 | \nHoa Gia Đại Thiếu 49 | \nhypnotichemionus 50 | \nIgoor Silver 51 | \nIgor Pissolati 52 | \nIsabel Vianna Montovani 53 | \nJeluang 54 | \nKaajjo 55 | \nKaede 56 | \nkairuu 57 | \nkamal-kun 58 | \nKana 59 | \nKathya Teran 60 | \nKhaing Yin Mon Aung 61 | \nKiwitomato 62 | \nKónya Márton 63 | \nLeozard 64 | \nlinguist 65 | \nLuke Produkcje 66 | \nmarc0tjevp 67 | \nMayra Ignacia Celedon Vidal 68 | \nMelisa Wijaya 69 | \nMiinc 70 | \nMuhammed Gültekin 71 | \nNartis 72 | \nNick 73 | \nnicoro 74 | \nniseee 75 | \nOnachi- San 76 | \nOrigami 77 | \nPaul Chang 78 | \nPoussinou 79 | \nPraxic 80 | \nPytey 81 | \nRap Management 82 | \nReza Rangga Dirza 83 | \nŠarūnas 84 | \nSascha355 85 | \nSatsuki Yanagi 86 | \nSc 87 | \nSDTR 88 | \nSilvanaFP 89 | \nspoonm 90 | \nsT 91 | \nsunmoon 92 | \nTAKAHASHI Shuuji 93 | \nTatsuya Ishikawa 94 | \nTill Kottmann 95 | \nTũn A. R. M. Y 96 | \ntzium 97 | \nVinicius 98 | \nvoltrare 99 | \nWachid Adi Nugroho 100 | \nWarrock3280 101 | \nWoWnik 102 | \nxTheEc0 103 | \nyami 104 | \nYassir Lagrich 105 | \nYayaka 106 | \nพิษณุ สุพรรณ์ 107 | \n노스스 108 | \n사람 109 | \n한병규 110 | \n刘韬 111 | \n尤鈺欽 112 | 113 | 114 | 115 | @string/system_default 116 | Bahasa Indonesia 117 | Bahasa Melayu 118 | Catalan 119 | Deutsch 120 | English (UK) 121 | English (US) 122 | Español 123 | Esperanto 124 | Français 125 | Italiano 126 | Lietuvių 127 | Nederlands 128 | සිංහල 129 | Svenska 130 | Português 131 | Русский 132 | Türkçe 133 | Tiếng Việt 134 | 한국어 135 | 日本語 136 | 简体中文 137 | 繁體中文 138 | 139 | 140 | 141 | default 142 | in 143 | ms 144 | ca 145 | de 146 | en-rGB 147 | en-rUS 148 | es 149 | eo 150 | fr 151 | it 152 | lt 153 | nl 154 | si 155 | sv 156 | pt 157 | ru 158 | tr 159 | vn 160 | ko 161 | ja 162 | zh-rCN 163 | zh-rTW 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /app/src/test/kotlin/me/echeung/moemoekyun/client/api/socket/ResponseModelTest.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.client.api.socket 2 | 3 | import io.kotest.matchers.shouldBe 4 | import io.kotest.matchers.types.shouldBeInstanceOf 5 | import me.echeung.moemoekyun.client.model.Event 6 | import me.echeung.moemoekyun.client.model.Song 7 | import me.echeung.moemoekyun.client.model.SongDescriptor 8 | import me.echeung.moemoekyun.di.SerializationModule 9 | import org.junit.jupiter.api.Test 10 | 11 | class ResponseModelTest { 12 | 13 | private val json = SerializationModule.json() 14 | 15 | @Test 16 | fun `deserializes init websocket message`() { 17 | val response = json.decodeFromString( 18 | """{"op":0,"d":{"message":"Welcome to LISTEN.moe! Enjoy your stay!","heartbeat":35000}}""", 19 | ) 20 | 21 | response.shouldBeInstanceOf() 22 | response.d.heartbeat shouldBe 35000 23 | } 24 | 25 | @Test 26 | fun `serializes heartbeat message`() { 27 | val request = json.encodeToString(WebsocketRequest.Heartbeat()) 28 | 29 | request shouldBe """{"op":9}""" 30 | } 31 | 32 | @Test 33 | fun `deserializes track update message`() { 34 | val response = json.decodeFromString( 35 | """ 36 | { 37 | "op": 1, 38 | "d": { 39 | "song": { 40 | "id": 1611, 41 | "title": "Clattanoia", 42 | "sources": [], 43 | "artists": [ 44 | { 45 | "id": 705, 46 | "name": "OxT", 47 | "nameRomaji": null, 48 | "image": "OxT_image.jpg", 49 | "characters": [] 50 | } 51 | ], 52 | "characters": [], 53 | "albums": [], 54 | "duration": 238 55 | }, 56 | "requester": null, 57 | "event": { 58 | "id": 73, 59 | "name": "Throwback to some good stuff", 60 | "slug": "throwback", 61 | "presence": null, 62 | "image": "https://i.imgur.com/G3nzMLQ.png" 63 | }, 64 | "startTime": "2025-07-06T14:03:17.278Z", 65 | "lastPlayed": [ 66 | { 67 | "id": 3090, 68 | "title": "READY!!", 69 | "sources": [ 70 | { 71 | "id": 351, 72 | "name": null, 73 | "nameRomaji": "THE IDOLM@STER OP", 74 | "image": null 75 | } 76 | ], 77 | "artists": [ 78 | { 79 | "id": 1235, 80 | "name": "765PRO ALLSTARS", 81 | "nameRomaji": null, 82 | "image": null, 83 | "characters": [] 84 | } 85 | ], 86 | "characters": [], 87 | "albums": [], 88 | "duration": 0 89 | }, 90 | { 91 | "id": 3781, 92 | "title": "Hear The Universe", 93 | "sources": [], 94 | "artists": [ 95 | { 96 | "id": 889, 97 | "name": "ワルキューレ", 98 | "nameRomaji": "Walkure", 99 | "image": null, 100 | "characters": [] 101 | } 102 | ], 103 | "characters": [], 104 | "albums": [], 105 | "duration": 0 106 | } 107 | ], 108 | "listeners": 93 109 | }, 110 | "t": "TRACK_UPDATE" 111 | } 112 | """.trimIndent(), 113 | ) 114 | 115 | response.shouldBeInstanceOf() 116 | response.d?.song shouldBe Song( 117 | id = 1611, 118 | title = "Clattanoia", 119 | titleRomaji = null, 120 | artists = listOf( 121 | SongDescriptor( 122 | name = "OxT", 123 | nameRomaji = null, 124 | image = "OxT_image.jpg", 125 | ), 126 | ), 127 | sources = emptyList(), 128 | albums = emptyList(), 129 | duration = 238, 130 | enabled = false, 131 | favorite = false, 132 | favoritedAt = null, 133 | ) 134 | response.d?.event shouldBe Event( 135 | name = "Throwback to some good stuff", 136 | image = "https://i.imgur.com/G3nzMLQ.png", 137 | ) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/settings/SettingsScreen.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.settings 2 | 3 | import android.content.Context 4 | import androidx.appcompat.app.AppCompatDelegate 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.material3.Scaffold 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.LaunchedEffect 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.core.os.LocaleListCompat 16 | import cafe.adriel.voyager.core.screen.Screen 17 | import cafe.adriel.voyager.hilt.getScreenModel 18 | import kotlinx.collections.immutable.ImmutableMap 19 | import kotlinx.collections.immutable.persistentMapOf 20 | import kotlinx.collections.immutable.plus 21 | import kotlinx.collections.immutable.toImmutableMap 22 | import me.echeung.moemoekyun.R 23 | import me.echeung.moemoekyun.ui.common.Toolbar 24 | import me.echeung.moemoekyun.ui.common.preferences.ListPreference 25 | import me.echeung.moemoekyun.ui.common.preferences.PreferenceGroupHeader 26 | import me.echeung.moemoekyun.ui.common.preferences.SwitchPreference 27 | import me.echeung.moemoekyun.util.system.LocaleUtil 28 | import rikka.autoresconfig.AutoResConfigLocales 29 | 30 | object SettingsScreen : Screen { 31 | 32 | @Composable 33 | override fun Content() { 34 | val context = LocalContext.current 35 | 36 | val screenModel = getScreenModel() 37 | 38 | val langs = remember { getLangs(context) } 39 | var currentLanguage by remember { 40 | mutableStateOf(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag() ?: "") 41 | } 42 | 43 | LaunchedEffect(currentLanguage) { 44 | val locale = if (currentLanguage.isEmpty()) { 45 | LocaleListCompat.getEmptyLocaleList() 46 | } else { 47 | LocaleListCompat.forLanguageTags(currentLanguage) 48 | } 49 | AppCompatDelegate.setApplicationLocales(locale) 50 | } 51 | 52 | Scaffold( 53 | topBar = { Toolbar(titleResId = R.string.settings, showUpButton = true) }, 54 | ) { contentPadding -> 55 | LazyColumn( 56 | contentPadding = contentPadding, 57 | ) { 58 | item { 59 | PreferenceGroupHeader(title = stringResource(R.string.pref_header_general)) 60 | } 61 | item { 62 | ListPreference( 63 | title = stringResource(R.string.pref_title_language), 64 | subtitle = LocaleUtil.getDisplayName(currentLanguage), 65 | entries = langs, 66 | value = currentLanguage, 67 | onValueChange = { newValue -> 68 | currentLanguage = newValue 69 | }, 70 | ) 71 | } 72 | 73 | item { 74 | PreferenceGroupHeader(title = stringResource(R.string.pref_header_music)) 75 | } 76 | item { 77 | SwitchPreference( 78 | title = stringResource(R.string.pref_title_general_romaji), 79 | subtitle = stringResource(R.string.pref_title_general_romaji_summary), 80 | preference = screenModel.preferenceUtil.shouldPreferRomaji(), 81 | ) 82 | } 83 | item { 84 | SwitchPreference( 85 | title = stringResource(R.string.pref_title_general_random_request_title), 86 | subtitle = stringResource(R.string.pref_title_general_random_request_summary), 87 | preference = screenModel.preferenceUtil.shouldShowRandomRequestTitle(), 88 | ) 89 | } 90 | 91 | item { 92 | PreferenceGroupHeader(title = stringResource(R.string.pref_header_audio)) 93 | } 94 | item { 95 | SwitchPreference( 96 | title = stringResource(R.string.pref_title_pause_on_noisy_title), 97 | subtitle = stringResource(R.string.pref_title_pause_on_noisy_summary), 98 | preference = screenModel.preferenceUtil.shouldPauseOnNoisy(), 99 | ) 100 | } 101 | } 102 | } 103 | } 104 | 105 | private fun getLangs(context: Context): ImmutableMap = 106 | persistentMapOf("" to context.getString(R.string.system_default)) + 107 | AutoResConfigLocales.LOCALES.drop(1) 108 | .zip(AutoResConfigLocales.DISPLAY_LOCALES.drop(1).map(LocaleUtil::getDisplayName)) 109 | .toMap() 110 | .toImmutableMap() 111 | } 112 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/songs/SongDetails.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.songs 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.ColumnScope 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.material3.LocalContentColor 13 | import androidx.compose.material3.LocalTextStyle 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.OutlinedButton 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.CompositionLocalProvider 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.platform.LocalContext 22 | import androidx.compose.ui.res.stringResource 23 | import androidx.compose.ui.text.style.TextOverflow 24 | import androidx.compose.ui.unit.dp 25 | import me.echeung.moemoekyun.R 26 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 27 | import me.echeung.moemoekyun.ui.common.AlbumArt 28 | import me.echeung.moemoekyun.util.ext.copyToClipboard 29 | 30 | @Composable 31 | fun SongDetails( 32 | song: DomainSong, 33 | actionsEnabled: Boolean, 34 | toggleFavorite: (Int) -> Unit, 35 | request: (DomainSong) -> Unit, 36 | modifier: Modifier = Modifier, 37 | ) { 38 | val context = LocalContext.current 39 | 40 | Column( 41 | modifier = modifier 42 | .fillMaxWidth() 43 | .padding(16.dp), 44 | verticalArrangement = Arrangement.spacedBy(4.dp), 45 | ) { 46 | Row( 47 | modifier = Modifier.height(80.dp), 48 | horizontalArrangement = Arrangement.spacedBy(16.dp), 49 | verticalAlignment = Alignment.CenterVertically, 50 | ) { 51 | AlbumArt( 52 | albumArtUrl = song.albumArtUrl, 53 | ) 54 | 55 | Column( 56 | verticalArrangement = Arrangement.spacedBy(4.dp), 57 | ) { 58 | Text( 59 | text = song.title, 60 | maxLines = 2, 61 | overflow = TextOverflow.Ellipsis, 62 | modifier = Modifier 63 | .fillMaxWidth() 64 | .clickable { 65 | context.copyToClipboard(song.title, song.title) 66 | }, 67 | ) 68 | 69 | CompositionLocalProvider( 70 | LocalTextStyle provides MaterialTheme.typography.bodySmall, 71 | LocalContentColor provides MaterialTheme.colorScheme.secondary, 72 | ) { 73 | Text( 74 | text = song.duration.takeIf { song.durationSeconds > 0 } ?: "-", 75 | maxLines = 1, 76 | ) 77 | } 78 | } 79 | } 80 | 81 | Section(R.string.song_artist, song.artists) 82 | Section(R.string.song_album, song.albums) 83 | Section(R.string.song_source, song.sources) 84 | 85 | if (actionsEnabled) { 86 | Row( 87 | modifier = Modifier.padding(top = 8.dp), 88 | horizontalArrangement = Arrangement.spacedBy(8.dp), 89 | ) { 90 | OutlinedButton( 91 | modifier = Modifier.weight(1f), 92 | onClick = { toggleFavorite(song.id) }, 93 | ) { 94 | Text(stringResource(if (song.favorited) R.string.action_unfavorite else R.string.action_favorite)) 95 | } 96 | 97 | OutlinedButton( 98 | modifier = Modifier.weight(1f), 99 | onClick = { request(song) }, 100 | ) { 101 | Text(stringResource(R.string.action_request)) 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | @Composable 109 | private fun ColumnScope.Section(@StringRes heading: Int, value: String?) { 110 | val context = LocalContext.current 111 | 112 | value.orEmpty().takeIf { it.isNotBlank() }?.let { 113 | CompositionLocalProvider( 114 | LocalTextStyle provides MaterialTheme.typography.bodySmall, 115 | LocalContentColor provides MaterialTheme.colorScheme.secondary, 116 | ) { 117 | Text( 118 | text = stringResource(heading), 119 | modifier = Modifier.padding(top = 4.dp), 120 | ) 121 | } 122 | 123 | Text( 124 | text = it, 125 | modifier = Modifier 126 | .fillMaxWidth() 127 | .clickable { 128 | context.copyToClipboard(it, it) 129 | }, 130 | ) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/domain/radio/RadioService.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.domain.radio 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.Network 6 | import android.net.NetworkCapabilities 7 | import android.net.NetworkRequest 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import kotlinx.coroutines.MainScope 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.asStateFlow 12 | import kotlinx.coroutines.flow.collectLatest 13 | import kotlinx.coroutines.flow.combine 14 | import kotlinx.coroutines.flow.distinctUntilChanged 15 | import kotlinx.coroutines.flow.filterIsInstance 16 | import me.echeung.moemoekyun.client.api.Station 17 | import me.echeung.moemoekyun.client.api.socket.Socket 18 | import me.echeung.moemoekyun.client.model.Event 19 | import me.echeung.moemoekyun.domain.songs.interactor.GetFavoriteSongs 20 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 21 | import me.echeung.moemoekyun.domain.songs.model.SongConverter 22 | import me.echeung.moemoekyun.util.PreferenceUtil 23 | import me.echeung.moemoekyun.util.ext.connectivityManager 24 | import me.echeung.moemoekyun.util.ext.launchIO 25 | import javax.inject.Inject 26 | import javax.inject.Singleton 27 | import kotlin.time.ExperimentalTime 28 | import kotlin.time.Instant 29 | 30 | @OptIn(ExperimentalTime::class) 31 | @Singleton 32 | class RadioService @Inject constructor( 33 | @ApplicationContext private val context: Context, 34 | private val preferenceUtil: PreferenceUtil, 35 | private val socket: Socket, 36 | private val songConverter: SongConverter, 37 | private val getFavoriteSongs: GetFavoriteSongs, 38 | ) { 39 | 40 | private val scope = MainScope() 41 | 42 | private val _state = MutableStateFlow( 43 | RadioState( 44 | station = preferenceUtil.station().get(), 45 | ), 46 | ) 47 | val state = _state.asStateFlow() 48 | 49 | init { 50 | connect() 51 | initNetworkStateCallback() 52 | 53 | scope.launchIO { 54 | combine( 55 | socket.flow, 56 | getFavoriteSongs.asFlow(), 57 | preferenceUtil.shouldPreferRomaji().asFlow(), 58 | ) { socketResponse, _, _ -> socketResponse } 59 | .filterIsInstance() 60 | .collectLatest { socketResponse -> 61 | val info = socketResponse.info 62 | 63 | _state.value = _state.value.copy( 64 | currentSong = info?.song?.let(songConverter::toDomainSong)?.copy( 65 | favorited = getFavoriteSongs.isFavorite(info.song.id), 66 | ), 67 | startTime = info?.startTime, 68 | pastSongs = info?.lastPlayed.orEmpty().map(songConverter::toDomainSong), 69 | listeners = info?.listeners ?: 0, 70 | requester = info?.requester?.displayName, 71 | event = info?.event, 72 | ) 73 | } 74 | } 75 | 76 | scope.launchIO { 77 | preferenceUtil.station().asFlow() 78 | .distinctUntilChanged() 79 | .collectLatest { 80 | _state.value = _state.value.copy( 81 | station = it, 82 | ) 83 | 84 | socket.reconnect() 85 | } 86 | } 87 | } 88 | 89 | fun setStation(station: Station) { 90 | if (preferenceUtil.station().get() != station) { 91 | preferenceUtil.station().set(station) 92 | } 93 | } 94 | 95 | fun connect() { 96 | socket.connect() 97 | } 98 | 99 | fun disconnectIfIdle() { 100 | socket.disconnect() 101 | } 102 | 103 | private fun initNetworkStateCallback() { 104 | val networkRequest = NetworkRequest.Builder() 105 | .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 106 | .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) 107 | .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) 108 | .addTransportType(NetworkCapabilities.TRANSPORT_VPN) 109 | .build() 110 | 111 | val callback: ConnectivityManager.NetworkCallback = object : ConnectivityManager.NetworkCallback() { 112 | override fun onAvailable(network: Network) { 113 | super.onAvailable(network) 114 | socket.reconnect() 115 | } 116 | 117 | override fun onLost(network: Network) { 118 | super.onLost(network) 119 | socket.disconnect() 120 | } 121 | } 122 | 123 | context.connectivityManager.registerNetworkCallback(networkRequest, callback) 124 | } 125 | } 126 | 127 | @OptIn(ExperimentalTime::class) 128 | data class RadioState( 129 | val station: Station = Station.JPOP, 130 | val listeners: Int = 0, 131 | val requester: String? = null, 132 | val currentSong: DomainSong? = null, 133 | val startTime: Instant? = null, 134 | val pastSongs: List = emptyList(), 135 | val event: Event? = null, 136 | ) { 137 | val albumArtUrl: String? 138 | get() = currentSong?.albumArtUrl ?: event?.image 139 | } 140 | -------------------------------------------------------------------------------- /app/src/main/kotlin/me/echeung/moemoekyun/ui/screen/home/HomeScreenModel.kt: -------------------------------------------------------------------------------- 1 | package me.echeung.moemoekyun.ui.screen.home 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.ui.graphics.Color 5 | import cafe.adriel.voyager.core.model.StateScreenModel 6 | import cafe.adriel.voyager.core.model.screenModelScope 7 | import kotlinx.collections.immutable.ImmutableList 8 | import kotlinx.collections.immutable.toImmutableList 9 | import kotlinx.coroutines.flow.collectLatest 10 | import kotlinx.coroutines.flow.combine 11 | import kotlinx.coroutines.flow.update 12 | import me.echeung.moemoekyun.client.api.Station 13 | import me.echeung.moemoekyun.domain.radio.RadioService 14 | import me.echeung.moemoekyun.domain.radio.interactor.SetStation 15 | import me.echeung.moemoekyun.domain.songs.interactor.FavoriteSong 16 | import me.echeung.moemoekyun.domain.songs.interactor.GetFavoriteSongs 17 | import me.echeung.moemoekyun.domain.songs.interactor.RequestSong 18 | import me.echeung.moemoekyun.domain.songs.model.DomainSong 19 | import me.echeung.moemoekyun.domain.songs.model.search 20 | import me.echeung.moemoekyun.domain.user.interactor.GetAuthenticatedUser 21 | import me.echeung.moemoekyun.domain.user.interactor.LoginLogout 22 | import me.echeung.moemoekyun.domain.user.model.DomainUser 23 | import me.echeung.moemoekyun.util.AlbumArtUtil 24 | import me.echeung.moemoekyun.util.PreferenceUtil 25 | import me.echeung.moemoekyun.util.SortType 26 | import me.echeung.moemoekyun.util.ext.launchIO 27 | import javax.inject.Inject 28 | 29 | class HomeScreenModel @Inject constructor( 30 | radioService: RadioService, 31 | private val setStation: SetStation, 32 | private val favoriteSong: FavoriteSong, 33 | private val requestSong: RequestSong, 34 | private val getAuthenticatedUser: GetAuthenticatedUser, 35 | private val getFavoriteSongs: GetFavoriteSongs, 36 | private val loginLogout: LoginLogout, 37 | private val albumArtUtil: AlbumArtUtil, 38 | private val preferenceUtil: PreferenceUtil, 39 | ) : StateScreenModel(State()) { 40 | 41 | val radioState = radioService.state 42 | 43 | init { 44 | screenModelScope.launchIO { 45 | getAuthenticatedUser.asFlow() 46 | .collectLatest { 47 | mutableState.update { state -> 48 | state.copy( 49 | user = it, 50 | ) 51 | } 52 | } 53 | } 54 | 55 | screenModelScope.launchIO { 56 | getFavoriteSongs.asFlow() 57 | .collectLatest { 58 | mutableState.update { state -> 59 | state.copy( 60 | favorites = it.toImmutableList(), 61 | ) 62 | } 63 | } 64 | } 65 | 66 | screenModelScope.launchIO { 67 | albumArtUtil.flow 68 | .collectLatest { 69 | mutableState.update { state -> 70 | state.copy( 71 | accentColor = it.accentColor?.let { Color(it) }, 72 | ) 73 | } 74 | } 75 | } 76 | 77 | screenModelScope.launchIO { 78 | combine( 79 | preferenceUtil.favoritesSortType().asFlow(), 80 | preferenceUtil.favoritesSortDescending().asFlow(), 81 | ) { sortType, descending -> Pair(sortType, descending) } 82 | .collectLatest { (sortType, descending) -> 83 | mutableState.update { state -> 84 | state.copy( 85 | sortType = sortType, 86 | sortDescending = descending, 87 | ) 88 | } 89 | } 90 | } 91 | } 92 | 93 | fun toggleFavorite(songId: Int) { 94 | screenModelScope.launchIO { 95 | favoriteSong.await(songId) 96 | } 97 | } 98 | 99 | fun toggleLibrary(newStation: Station) { 100 | setStation.set(newStation) 101 | } 102 | 103 | fun logout() { 104 | loginLogout.logout() 105 | } 106 | 107 | fun search(query: String) { 108 | mutableState.update { state -> 109 | state.copy( 110 | searchQuery = query, 111 | ) 112 | } 113 | } 114 | 115 | fun sortBy(sortType: SortType) { 116 | getFavoriteSongs.setSortType(sortType) 117 | } 118 | 119 | fun sortDescending(descending: Boolean) { 120 | getFavoriteSongs.setSortDescending(descending) 121 | } 122 | 123 | fun requestRandomSong() { 124 | screenModelScope.launchIO { 125 | mutableState.value.filteredFavorites?.randomOrNull()?.let { 126 | requestSong.await(it) 127 | } 128 | } 129 | } 130 | 131 | @Immutable 132 | data class State( 133 | val user: DomainUser? = null, 134 | val favorites: ImmutableList? = null, 135 | val accentColor: Color? = null, 136 | val searchQuery: String? = null, 137 | val sortType: SortType = SortType.TITLE, 138 | val sortDescending: Boolean = false, 139 | ) { 140 | val filteredFavorites: ImmutableList? 141 | get() = favorites?.search(searchQuery) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.gradle.api.tasks.testing.logging.TestLogEvent 2 | 3 | plugins { 4 | alias(libs.plugins.android.application) 5 | alias(libs.plugins.aboutLibraries) 6 | alias(libs.plugins.kotlin.android) 7 | alias(libs.plugins.kotlin.serialization) 8 | id("kotlin-parcelize") 9 | id("dagger.hilt.android.plugin") 10 | alias(libs.plugins.ksp) 11 | alias(libs.plugins.kotlin.compose) 12 | alias(libs.plugins.apollo) 13 | alias(libs.plugins.ktlint) 14 | alias(libs.plugins.autoresconfig) 15 | } 16 | 17 | val appPackageName = "me.echeung.moemoekyun" 18 | 19 | android { 20 | compileSdk = 36 21 | namespace = appPackageName 22 | 23 | defaultConfig { 24 | applicationId = appPackageName 25 | minSdk = 26 26 | targetSdk = 36 27 | versionCode = 211 28 | versionName = "6.4.0" 29 | } 30 | 31 | buildFeatures { 32 | compose = true 33 | buildConfig = true 34 | 35 | // Disable unused AGP features 36 | aidl = false 37 | renderScript = false 38 | resValues = false 39 | shaders = false 40 | } 41 | 42 | autoResConfig { 43 | generateClass = true 44 | generateRes = true 45 | generateLocaleConfig = true 46 | } 47 | 48 | buildTypes { 49 | named("debug") { 50 | applicationIdSuffix = ".debug" 51 | versionNameSuffix = " DEBUG" 52 | } 53 | named("release") { 54 | isShrinkResources = true 55 | isMinifyEnabled = true 56 | proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") 57 | } 58 | } 59 | 60 | flavorDimensions.add("variant") 61 | productFlavors { 62 | create("playstore") { 63 | dimension = "variant" 64 | } 65 | create("fdroid") { 66 | dimension = "variant" 67 | 68 | applicationIdSuffix = ".fdroid" 69 | } 70 | } 71 | 72 | lint { 73 | disable.addAll(listOf("MissingTranslation", "ExtraTranslation")) 74 | enable.addAll(listOf("ObsoleteSdkInt")) 75 | 76 | abortOnError = true 77 | } 78 | 79 | packaging { 80 | resources.excludes.addAll( 81 | listOf( 82 | "META-INF/DEPENDENCIES", 83 | "LICENSE.txt", 84 | "META-INF/LICENSE", 85 | "META-INF/LICENSE.txt", 86 | "META-INF/README.md", 87 | "META-INF/NOTICE", 88 | "META-INF/*.kotlin_module", 89 | "META-INF/*.version", 90 | ), 91 | ) 92 | } 93 | 94 | dependenciesInfo { 95 | includeInApk = false 96 | } 97 | 98 | compileOptions { 99 | isCoreLibraryDesugaringEnabled = true 100 | } 101 | } 102 | 103 | val jvmVersion = JavaLanguageVersion.of(21) 104 | java { 105 | toolchain { 106 | languageVersion.set(jvmVersion) 107 | } 108 | } 109 | kotlin { 110 | jvmToolchain(jvmVersion.asInt()) 111 | 112 | compilerOptions { 113 | freeCompilerArgs.addAll( 114 | "-opt-in=androidx.compose.material.ExperimentalMaterialApi", 115 | "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", 116 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", 117 | "-XXLanguage:+PropertyParamAnnotationDefaultTargetMode", 118 | ) 119 | } 120 | } 121 | 122 | apollo { 123 | service("service") { 124 | packageName.set(appPackageName) 125 | } 126 | } 127 | 128 | ktlint { 129 | filter { 130 | exclude { element -> 131 | element.file.path.contains("generated/") 132 | } 133 | } 134 | } 135 | 136 | tasks { 137 | withType { 138 | useJUnitPlatform() 139 | testLogging { 140 | events = setOf(TestLogEvent.PASSED, TestLogEvent.FAILED, TestLogEvent.SKIPPED) 141 | } 142 | } 143 | 144 | named("preBuild") { 145 | dependsOn("ktlintFormat") 146 | } 147 | } 148 | 149 | dependencies { 150 | coreLibraryDesugaring(libs.desugar) 151 | implementation(libs.bundles.coroutines) 152 | implementation(libs.serialization) 153 | implementation(libs.immutables) 154 | 155 | implementation(platform(libs.okhttp.bom)) 156 | implementation(libs.bundles.okhttp) 157 | implementation(libs.bundles.coil) 158 | implementation(libs.bundles.apollo) 159 | implementation(libs.logcat) 160 | 161 | implementation(libs.hilt.android) 162 | ksp(libs.hilt.compiler) 163 | 164 | implementation(platform(libs.compose.bom)) 165 | implementation(libs.bundles.compose) 166 | implementation(libs.aboutLibraries.compose) 167 | lintChecks(libs.compose.lintchecks) 168 | 169 | implementation(libs.bundles.voyager) 170 | 171 | implementation(libs.androidx.appcompat) 172 | implementation(libs.androidx.lifecycle) 173 | implementation(libs.androidx.palette) 174 | implementation(libs.androidx.splashscreen) 175 | 176 | implementation(libs.bundles.media) 177 | implementation(libs.bundles.preferences) 178 | 179 | testImplementation(libs.junit.jupiter.api) 180 | testImplementation(libs.kotest.assertions) 181 | testRuntimeOnly(libs.bundles.junit.runtime) 182 | 183 | // For detecting memory leaks; see https://square.github.io/leakcanary/ 184 | // "debugImplementation"("com.squareup.leakcanary:leakcanary-android:2.2") 185 | } 186 | --------------------------------------------------------------------------------