diff --git a/res/drawable/arrow_drop_down_24px.xml b/res/drawable/arrow_drop_down_24px.xml new file mode 100644 index 000000000..ea75299b3 --- /dev/null +++ b/res/drawable/arrow_drop_down_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/res/drawable/osudirect.png b/res/drawable/osudirect.png new file mode 100644 index 000000000..3f6a60b42 Binary files /dev/null and b/res/drawable/osudirect.png differ diff --git a/res/drawable/powered_by_osudirect.png b/res/drawable/powered_by_osudirect.png deleted file mode 100644 index e3d843001..000000000 Binary files a/res/drawable/powered_by_osudirect.png and /dev/null differ diff --git a/res/layout/beatmap_downloader_fragment.xml b/res/layout/beatmap_downloader_fragment.xml index c4aa876f3..5e4aae8f6 100644 --- a/res/layout/beatmap_downloader_fragment.xml +++ b/res/layout/beatmap_downloader_fragment.xml @@ -26,11 +26,13 @@ - + android:layout_toLeftOf="@id/logo" + android:drawableLeft="@drawable/osudirect" + android:drawablePadding="8dp" + android:paddingVertical="16dp" + android:paddingLeft="16dp" + android:paddingRight="2dp" + android:text="osu.direct" + android:textColor="#FFF" /> + Option( + text = buildSpannedString { + append(mirror.description) + appendLine() + color(0xBFFFFFFF.toInt()) { append(mirror.homeUrl) } + }, + value = mirror.ordinal, + icon = requireContext().getDrawable(mirror.logoResource) + ) + }) + .setSelected(mirror.ordinal) + .setOnSelectListener { value -> + value as Int + if (value != mirror.ordinal) { + Config.setInt("beatmapMirror", value) + mirror = BeatmapMirror.entries[Config.getInt("beatmapMirror", 0)] + search(false) + } + } + .setTitle("Select a beatmap mirror") + .show() } findViewById(R.id.close)!!.setOnClickListener { @@ -195,21 +213,17 @@ class BeatmapListing : BaseFragment(), ensureActive() - JsonArrayRequest(mirror.search.endpoint).use { request -> - - request.buildUrl { - - addQueryParameter("mode", "0") - addQueryParameter("query", searchBox.text.toString()) - addQueryParameter("offset", offset.toString()) - } + JsonArrayRequest( + mirror.search.request( + query = searchBox.text.toString(), + offset = offset + ) + ).use { request -> request.buildRequest { header("User-Agent", "Chrome/Android") } - ensureActive() - val beatmapSets = mirror.search.mapResponse(request.execute().json) - + val beatmapSets = mirror.search.response(request.execute().json) ensureActive() adapter.data.addAll(beatmapSets) @@ -286,7 +300,7 @@ class BeatmapListing : BaseFragment(), /** * The current selected beatmap mirror. */ - var mirror = BeatmapMirror.OSU_DIRECT + var mirror = BeatmapMirror.entries[Config.getInt("beatmapMirror", 0)] /** * Whether is a beatmap preview music playing or not. @@ -393,8 +407,10 @@ class BeatmapSetDetails(val beatmapSet: BeatmapSetModel, val holder: BeatmapSetV } downloadButton.setOnClickListener { - val url = BeatmapListing.mirror.downloadEndpoint(beatmapSet.id) - BeatmapDownloader.download(url, "${beatmapSet.id} ${beatmapSet.artist} - ${beatmapSet.title}") + BeatmapDownloader.download( + url = BeatmapListing.mirror.download.request(beatmapSet.id).toString(), + suggestedFilename = "${beatmapSet.id} ${beatmapSet.artist} - ${beatmapSet.title}" + ) } cover.setImageDrawable(holder.cover.drawable) @@ -598,8 +614,10 @@ class BeatmapSetViewHolder(itemView: View, private val mediaScope: CoroutineScop } downloadButton.setOnClickListener { - val url = BeatmapListing.mirror.downloadEndpoint(beatmapSet.id) - BeatmapDownloader.download(url, "${beatmapSet.id} ${beatmapSet.artist} - ${beatmapSet.title}") + BeatmapDownloader.download( + url = BeatmapListing.mirror.download.request(beatmapSet.id).toString(), + suggestedFilename = "${beatmapSet.id} ${beatmapSet.artist} - ${beatmapSet.title}" + ) } @@ -621,7 +639,7 @@ class BeatmapSetViewHolder(itemView: View, private val mediaScope: CoroutineScop previewJob = mediaScope.launch { try { - previewStream = URLBassStream(BeatmapListing.mirror.previewEndpoint(beatmapSet.beatmaps[0].id)) { + previewStream = URLBassStream(BeatmapListing.mirror.preview.request(beatmapSet.beatmaps[0].id).toString()) { stopPreview(true) if (BeatmapListing.isPlayingMusic) { diff --git a/src/com/reco1l/osu/beatmaplisting/BeatmapModel.kt b/src/com/reco1l/osu/beatmaplisting/BeatmapListingModels.kt similarity index 97% rename from src/com/reco1l/osu/beatmaplisting/BeatmapModel.kt rename to src/com/reco1l/osu/beatmaplisting/BeatmapListingModels.kt index 67a4c9ac8..0ebe77a03 100644 --- a/src/com/reco1l/osu/beatmaplisting/BeatmapModel.kt +++ b/src/com/reco1l/osu/beatmaplisting/BeatmapListingModels.kt @@ -6,54 +6,31 @@ import ru.nsu.ccfit.zuev.osu.RankedStatus * Beatmap set response model for beatmaps mirrors. */ data class BeatmapSetModel( - val id: Long, - val title: String, - val titleUnicode: String, - val artist: String, - val artistUnicode: String, - val status: RankedStatus, - val creator: String, - val thumbnail: String?, - val beatmaps: List - ) /** * Beatmap response model for beatmaps mirrors. */ data class BeatmapModel( - val id: Long, - val version: String, - val starRating: Double, - val ar: Double, - val cs: Double, - val hp: Double, - val od: Double, - val bpm: Double, - val lengthSec: Long, - val circleCount: Int, - val sliderCount:Int, - val spinnerCount: Int - ) \ No newline at end of file diff --git a/src/com/reco1l/osu/beatmaplisting/BeatmapMirror.kt b/src/com/reco1l/osu/beatmaplisting/BeatmapMirror.kt index ee199e221..3485e3b65 100644 --- a/src/com/reco1l/osu/beatmaplisting/BeatmapMirror.kt +++ b/src/com/reco1l/osu/beatmaplisting/BeatmapMirror.kt @@ -1,42 +1,53 @@ package com.reco1l.osu.beatmaplisting -import org.json.JSONArray -import ru.nsu.ccfit.zuev.osu.RankedStatus - +import androidx.annotation.DrawableRes +import com.reco1l.osu.beatmaplisting.mirrors.OsuDirectDownloadRequestModel +import com.reco1l.osu.beatmaplisting.mirrors.OsuDirectPreviewRequestModel +import com.reco1l.osu.beatmaplisting.mirrors.OsuDirectSearchRequestModel +import com.reco1l.osu.beatmaplisting.mirrors.OsuDirectSearchResponseModel +import ru.nsu.ccfit.zuev.osuplus.R /** - * Defines an action to be performed on a mirror API. + * Defines a beatmap mirror API and its actions. */ -data class MirrorAction( +enum class BeatmapMirror( /** - * The action API endpoint. + * The home URL of the beatmap mirror where the user will be redirected to when + * clicking on the logo. */ - // TODO replace with a request creation function, some APIs have different query arguments. - val endpoint: String, + val homeUrl: String, /** - * A function to map the response into a model. + * The description of the beatmap mirror. */ - val mapResponse: (R) -> M + val description: String, -) + /** + * The resource ID of the logo image to be displayed in the UI. + */ + @DrawableRes + val logoResource: Int, -/** - * Defines a beatmap mirror API and its actions. - */ -enum class BeatmapMirror( + + // Actions / Endpoints /** * The search query action. */ - val search: MirrorAction>, + val search: BeatmapMirrorActionWithResponse, - val downloadEndpoint: (Long) -> String, + /** + * The download action. + */ + val download: BeatmapMirrorAction, - val previewEndpoint: (Long) -> String, + /** + * The music preview action. + */ + val preview: BeatmapMirrorAction, - ) { +) { /** * osu.direct beatmap mirror. @@ -44,54 +55,17 @@ enum class BeatmapMirror( * [See documentation](https://osu.direct/api/docs) */ OSU_DIRECT( - search = MirrorAction( - endpoint = "https://osu.direct/api/v2/search", - mapResponse = { array -> - - MutableList(array.length()) { index -> - - val json = array.getJSONObject(index) - - BeatmapSetModel( - id = json.getLong("id"), - title = json.getString("title"), - titleUnicode = json.getString("title_unicode"), - artist = json.getString("artist"), - artistUnicode = json.getString("artist_unicode"), - status = RankedStatus.valueOf(json.getInt("ranked")), - creator = json.getString("creator"), - thumbnail = json.optJSONObject("covers")?.optString("card"), - beatmaps = json.getJSONArray("beatmaps").let { - - MutableList(it.length()) { i -> - - val obj = it.getJSONObject(i) - - BeatmapModel( - id = obj.getLong("id"), - version = obj.getString("version"), - starRating = obj.getDouble("difficulty_rating"), - ar = obj.getDouble("ar"), - cs = obj.getDouble("cs"), - hp = obj.getDouble("drain"), - od = obj.getDouble("accuracy"), - bpm = obj.getDouble("bpm"), - lengthSec = obj.getLong("hit_length"), - circleCount = obj.getInt("count_circles"), - sliderCount = obj.getInt("count_sliders"), - spinnerCount = obj.getInt("count_spinners") - ) - - }.sortedBy(BeatmapModel::starRating) - } - ) - } + homeUrl = "https://osu.direct", + description = "osu.direct", + logoResource = R.drawable.osudirect, - } + search = BeatmapMirrorActionWithResponse( + request = OsuDirectSearchRequestModel(), + response = OsuDirectSearchResponseModel(), ), - downloadEndpoint = { "https://osu.direct/api/d/$it" }, - previewEndpoint = { "https://osu.direct/api/media/preview/$it" }, - ); + download = BeatmapMirrorAction(OsuDirectDownloadRequestModel()), + preview = BeatmapMirrorAction(OsuDirectPreviewRequestModel()), + ) } diff --git a/src/com/reco1l/osu/beatmaplisting/BeatmapMirrorAction.kt b/src/com/reco1l/osu/beatmaplisting/BeatmapMirrorAction.kt new file mode 100644 index 000000000..f97a90b33 --- /dev/null +++ b/src/com/reco1l/osu/beatmaplisting/BeatmapMirrorAction.kt @@ -0,0 +1,24 @@ +package com.reco1l.osu.beatmaplisting + +/** + * Defines an action to be performed on a mirror API. + */ +open class BeatmapMirrorAction( + + /** + * The action API endpoint. + */ + val request: RequestModel + +) + +class BeatmapMirrorActionWithResponse( + + request: RequestModel, + + /** + * The action response mapping. + */ + val response: ResponseModel + +) : BeatmapMirrorAction(request) diff --git a/src/com/reco1l/osu/beatmaplisting/BeatmapMirrorModels.kt b/src/com/reco1l/osu/beatmaplisting/BeatmapMirrorModels.kt new file mode 100644 index 000000000..9101f66ea --- /dev/null +++ b/src/com/reco1l/osu/beatmaplisting/BeatmapMirrorModels.kt @@ -0,0 +1,38 @@ +package com.reco1l.osu.beatmaplisting + +import okhttp3.HttpUrl + + +// Request + +fun interface BeatmapMirrorSearchRequestModel { + /** + * @param query The search query. + * @param offset The search result offset. + */ + operator fun invoke(query: String, offset: Int): HttpUrl +} + +fun interface BeatmapMirrorDownloadRequestModel { + /** + * @param beatmapSetId The beatmap set ID. + */ + operator fun invoke(beatmapSetId: Long): HttpUrl +} + +fun interface BeatmapMirrorPreviewRequestModel { + /** + * @param beatmapSetId The beatmap set ID. + */ + operator fun invoke(beatmapSetId: Long): HttpUrl +} + + +// Response + +fun interface BeatmapMirrorSearchResponseModel { + /** + * @return The list of beatmap sets. + */ + operator fun invoke(response: Any): MutableList +} \ No newline at end of file diff --git a/src/com/reco1l/osu/beatmaplisting/mirrors/OsuDirect.kt b/src/com/reco1l/osu/beatmaplisting/mirrors/OsuDirect.kt new file mode 100644 index 000000000..ab5a55b25 --- /dev/null +++ b/src/com/reco1l/osu/beatmaplisting/mirrors/OsuDirect.kt @@ -0,0 +1,80 @@ +package com.reco1l.osu.beatmaplisting.mirrors + +import com.reco1l.osu.beatmaplisting.BeatmapMirrorDownloadRequestModel +import com.reco1l.osu.beatmaplisting.BeatmapMirrorPreviewRequestModel +import com.reco1l.osu.beatmaplisting.BeatmapMirrorSearchRequestModel +import com.reco1l.osu.beatmaplisting.BeatmapMirrorSearchResponseModel +import com.reco1l.osu.beatmaplisting.BeatmapModel +import com.reco1l.osu.beatmaplisting.BeatmapSetModel +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.json.JSONArray +import ru.nsu.ccfit.zuev.osu.RankedStatus + + +class OsuDirectSearchRequestModel : BeatmapMirrorSearchRequestModel { + override fun invoke(query: String, offset: Int): HttpUrl { + return "https://osu.direct/api/v2/search".toHttpUrl() + .newBuilder() + .addQueryParameter("mode", "0") + .addQueryParameter("query", query) + .addQueryParameter("offset", offset.toString()) + .build() + } +} + +class OsuDirectSearchResponseModel : BeatmapMirrorSearchResponseModel { + override fun invoke(response: Any): MutableList { + response as JSONArray + + return MutableList(response.length()) { index -> + val json = response.getJSONObject(index) + + BeatmapSetModel( + id = json.getLong("id"), + title = json.getString("title"), + titleUnicode = json.getString("title_unicode"), + artist = json.getString("artist"), + artistUnicode = json.getString("artist_unicode"), + status = RankedStatus.valueOf(json.getInt("ranked")), + creator = json.getString("creator"), + thumbnail = json.optJSONObject("covers")?.optString("card"), + beatmaps = json.getJSONArray("beatmaps").let { + + MutableList(it.length()) { i -> + + val obj = it.getJSONObject(i) + + BeatmapModel( + id = obj.getLong("id"), + version = obj.getString("version"), + starRating = obj.getDouble("difficulty_rating"), + ar = obj.getDouble("ar"), + cs = obj.getDouble("cs"), + hp = obj.getDouble("drain"), + od = obj.getDouble("accuracy"), + bpm = obj.getDouble("bpm"), + lengthSec = obj.getLong("hit_length"), + circleCount = obj.getInt("count_circles"), + sliderCount = obj.getInt("count_sliders"), + spinnerCount = obj.getInt("count_spinners") + ) + + }.sortedBy(BeatmapModel::starRating) + } + ) + } + } +} + +class OsuDirectDownloadRequestModel : BeatmapMirrorDownloadRequestModel { + override fun invoke(beatmapSetId: Long): HttpUrl { + return "https://osu.direct/api/d/$beatmapSetId".toHttpUrl() + } +} + +class OsuDirectPreviewRequestModel : BeatmapMirrorPreviewRequestModel { + override fun invoke(beatmapSetId: Long): HttpUrl { + return "https://osu.direct/api/media/preview/$beatmapSetId".toHttpUrl() + } +} \ No newline at end of file diff --git a/src/com/reco1l/osu/ui/ListDialog.kt b/src/com/reco1l/osu/ui/ListDialog.kt index 40547753e..75ffe9cd2 100644 --- a/src/com/reco1l/osu/ui/ListDialog.kt +++ b/src/com/reco1l/osu/ui/ListDialog.kt @@ -1,14 +1,17 @@ package com.reco1l.osu.ui import android.graphics.Color +import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.ViewGroup import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.core.view.forEach import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.reco1l.toolkt.android.cornerRadius import com.reco1l.toolkt.android.dp +import com.reco1l.toolkt.android.drawableLeft import com.reco1l.toolkt.android.drawableRight import ru.nsu.ccfit.zuev.osuplus.R @@ -21,12 +24,17 @@ data class Option( /** * The text to be displayed in the option. */ - val text: String, + val text: CharSequence, /** * The value to be returned when the option is selected. */ - val value: Any + val value: Any, + + /** + * The icon to be displayed in the option. + */ + val icon: Drawable? = null ) @@ -71,8 +79,8 @@ open class SelectDialog : MessageDialog() { } - fun setOptions(options: MutableList