├── 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 |
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 | [](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