diff --git a/shared/src/commonMain/kotlin/App.kt b/shared/src/commonMain/kotlin/SpMp.kt similarity index 100% rename from shared/src/commonMain/kotlin/App.kt rename to shared/src/commonMain/kotlin/SpMp.kt diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/DataApi.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/DataApi.kt index dc1bbb5a2..b9678fe5c 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/DataApi.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/DataApi.kt @@ -1,5 +1,8 @@ package com.spectre7.spmp.api +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.beust.klaxon.* import com.spectre7.spmp.model.Cache import com.spectre7.spmp.model.MediaItem @@ -157,6 +160,9 @@ class DataApi { .converter(enum_converter) .converter(mediaitem_converter) + fun getYtmAuth(): YoutubeMusicAuthInfo? = + Settings.get>(Settings.KEY_YTM_AUTH).let { if (it is YoutubeMusicAuthInfo) it else YoutubeMusicAuthInfo(it) }.initialisedOrNull() + enum class YoutubeiContextType { BASE, ALT, @@ -171,6 +177,9 @@ class DataApi { } } + var ytm_authenticated: Boolean by mutableStateOf(false) + private set + private lateinit var youtubei_context: JsonObject private lateinit var youtubei_context_alt: JsonObject private lateinit var youtubei_context_android: JsonObject @@ -238,8 +247,8 @@ class DataApi { header_update_thread = thread { val headers_builder = Headers.Builder().add("user-agent", user_agent) - val ytm_auth = Settings.get>(Settings.KEY_YTM_AUTH).let { if (it is YoutubeMusicAuthInfo) it else YoutubeMusicAuthInfo(it) } - if (ytm_auth.initialised) { + val ytm_auth = getYtmAuth() + if (ytm_auth != null) { headers_builder["cookie"] = ytm_auth.cookie for (header in ytm_auth.headers) { headers_builder[header.key] = header.value @@ -260,6 +269,7 @@ class DataApi { headers_builder["user-agent"] = user_agent youtubei_headers = headers_builder.build() + ytm_authenticated = ytm_auth != null } return header_update_thread!! } diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/HomeFeed.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/HomeFeed.kt index c2175dd6c..707bba679 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/HomeFeed.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/HomeFeed.kt @@ -15,6 +15,7 @@ private val CACHE_LIFETIME = Duration.ofDays(1) fun getHomeFeed(min_rows: Int = -1, allow_cached: Boolean = true, params: String? = null, continuation: String? = null): Result, String?, List>?>> { + val hl = SpMp.data_language fun postRequest(ctoken: String?): Result { val endpoint = "/youtubei/v1/browse" val request = Request.Builder() @@ -71,7 +72,7 @@ fun getHomeFeed(min_rows: Int = -1, allow_cached: Boolean = true, params: String var data: YoutubeiBrowseResponse = DataApi.klaxon.parse(response_reader)!! response_reader.close() - val rows: MutableList = processRows(data.getShelves(continuation != null)).toMutableList() + val rows: MutableList = processRows(data.getShelves(continuation != null), hl).toMutableList() check(rows.isNotEmpty()) val chips = data.getHeaderChips() @@ -91,7 +92,7 @@ fun getHomeFeed(min_rows: Int = -1, allow_cached: Boolean = true, params: String val shelves = data.getShelves(true) check(shelves.isNotEmpty()) - rows.addAll(processRows(shelves)) + rows.addAll(processRows(shelves, hl)) ctoken = data.ctoken } @@ -105,7 +106,7 @@ fun getHomeFeed(min_rows: Int = -1, allow_cached: Boolean = true, params: String return Result.success(Triple(rows, ctoken, chips)) } -private fun processRows(rows: List): List { +private fun processRows(rows: List, hl: String): List { val ret = mutableListOf() for (row in rows) { when (val renderer = row.getRenderer()) { @@ -124,7 +125,7 @@ private fun processRows(rows: List): List { view_more: MediaItemLayout.ViewMore? = null ) { - val items = row.getMediaItems().toMutableList() + val items = row.getMediaItems(hl).toMutableList() // val final_title: String // val final_subtitle: String? @@ -282,7 +283,7 @@ data class YoutubeiBrowseResponse( } data class ItemSectionRenderer(val contents: List) -data class ItemSectionRendererContent(val didYouMeanRenderer: DidYouMeanRenderer) +data class ItemSectionRendererContent(val didYouMeanRenderer: DidYouMeanRenderer? = null) data class DidYouMeanRenderer(val correctedQuery: TextRuns) data class YoutubeiShelf( @@ -320,9 +321,9 @@ data class YoutubeiShelf( return musicShelfRenderer?.bottomEndpoint ?: musicCarouselShelfRenderer?.header?.getRenderer()?.moreContentButton?.buttonRenderer?.navigationEndpoint } - fun getMediaItems(): List { + fun getMediaItems(hl: String): List { return (musicShelfRenderer?.contents ?: musicCarouselShelfRenderer?.contents ?: musicPlaylistShelfRenderer?.contents ?: gridRenderer!!.items).mapNotNull { - val item = it.toMediaItem() + val item = it.toMediaItem(hl) item?.saveToCache() return@mapNotNull item } @@ -418,6 +419,7 @@ data class HeaderRenderer( val thumbnail: Thumbnails? = null, val foregroundThumbnail: Thumbnails? = null, val subtitle: TextRuns? = null, + val secondSubtitle: TextRuns? = null, val moreContentButton: MoreContentButton? = null ) { fun getThumbnails(): List { @@ -452,7 +454,7 @@ data class TextRun(val text: String, val strapline: TextRuns? = null, val naviga data class MusicShelfRenderer( val title: TextRuns? = null, - val contents: List, + val contents: List? = null, val continuations: List? = null, val bottomEndpoint: NavigationEndpoint? = null ) @@ -518,16 +520,19 @@ data class ThumbnailRenderer(val musicThumbnailRenderer: MusicThumbnailRenderer) data class MusicResponsiveListItemRenderer( val playlistItemData: PlaylistItemData? = null, val flexColumns: List? = null, + val fixedColumns: List? = null, val thumbnail: ThumbnailRenderer? = null, val navigationEndpoint: NavigationEndpoint? = null, val menu: YoutubeiNextResponse.Menu? = null ) data class PlaylistItemData(val videoId: String) -data class FlexColumn(val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemFlexColumnRenderer) -data class MusicResponsiveListItemFlexColumnRenderer(val text: TextRuns) + +data class FlexColumn(val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemColumnRenderer) +data class FixedColumn(val musicResponsiveListItemFixedColumnRenderer: MusicResponsiveListItemColumnRenderer) +data class MusicResponsiveListItemColumnRenderer(val text: TextRuns) data class ContentsItem(val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? = null, val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer? = null) { - fun toMediaItem(): MediaItem? { + fun toMediaItem(hl: String): MediaItem? { if (musicTwoRowItemRenderer != null) { val renderer = musicTwoRowItemRenderer @@ -586,6 +591,7 @@ data class ContentsItem(val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? = var title: String? = null var artist: Artist? = null var playlist: Playlist? = null + var duration: Long? = null if (video_id == null) { val page_type = renderer.navigationEndpoint?.browseEndpoint?.getPageType() @@ -638,9 +644,20 @@ data class ContentsItem(val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? = } } + if (renderer.fixedColumns != null) { + for (column in renderer.fixedColumns) { + val text = column.musicResponsiveListItemFixedColumnRenderer.text.first_text + val parsed = parseYoutubeDurationString(text, hl) + if (parsed != null) { + duration = parsed + break + } + } + } + val ret: MediaItem if (video_id != null) { - ret = Song.fromId(video_id) + ret = Song.fromId(video_id).supplyDuration(duration, true) renderer.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.firstOrNull()?.also { ret.supplySongType(if (it.height == it.width) Song.SongType.SONG else Song.SongType.VIDEO) } @@ -649,7 +666,7 @@ data class ContentsItem(val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? = return null } else { - ret = playlist ?: artist ?: return null + ret = (playlist?.supplyTotalDuration(duration, true)) ?: artist ?: return null } // Handle songs with no artist (or 'Various artists') diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/LoadMediaitem.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/LoadMediaitem.kt index e49e09038..08fc64862 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/LoadMediaitem.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/LoadMediaitem.kt @@ -8,6 +8,8 @@ import com.spectre7.spmp.ui.component.MediaItemLayout import okhttp3.Request import java.util.regex.Pattern +// TODO Organise and split + data class PlayerData( val videoDetails: VideoDetails? = null, // val streamingData: StreamingData? = null @@ -23,6 +25,7 @@ data class VideoDetails( fun loadBrowseId(browse_id: String, params: String? = null): Result> { val params_str = if (params == null) "" else """, "params": "$params" """ + val hl = SpMp.data_language val request = Request.Builder() .ytUrl("/youtubei/v1/browse") .addYtHeaders() @@ -54,7 +57,7 @@ fun loadBrowseId(browse_id: String, params: String? = null): Result { return result } + println("Load $item_id $item") + if (item is Artist && item.is_for_item) { return finish(true) } @@ -140,6 +145,7 @@ fun loadMediaItemData(item: MediaItem): Result { }""" else """{ "browseId": "$item_id" }""" + val hl = SpMp.data_language var request: Request = Request.Builder() .ytUrl(url) .addYtHeaders() @@ -173,8 +179,8 @@ fun loadMediaItemData(item: MediaItem): Result { val layout = MediaItemLayout( null, null, MediaItemLayout.Type.LIST, - playlist_shelf.contents.mapNotNull { data -> - return@mapNotNull data.toMediaItem().also { check(it is Song) } + playlist_shelf.contents!!.mapNotNull { data -> + return@mapNotNull data.toMediaItem(hl).also { check(it is Song) } }.toMutableList(), continuation = continuation?.let { MediaItemLayout.Continuation(it, MediaItemLayout.Continuation.Type.SONG, item_id) } ) @@ -189,22 +195,37 @@ fun loadMediaItemData(item: MediaItem): Result { item.supplyDescription(header_renderer.description?.first_text, true) item.supplyThumbnailProvider(MediaItem.ThumbnailProvider.fromThumbnails(header_renderer.getThumbnails())) - val artist = header_renderer.subtitle?.runs?.firstOrNull { - it.navigationEndpoint?.browseEndpoint?.getPageType() == "MUSIC_PAGE_TYPE_USER_CHANNEL" + header_renderer.subtitle?.runs?.also { subtitle -> + val artist_run = subtitle.firstOrNull { + it.navigationEndpoint?.browseEndpoint?.getPageType() == "MUSIC_PAGE_TYPE_USER_CHANNEL" + } + if (artist_run != null) { + item.supplyArtist( + Artist + .fromId(artist_run.navigationEndpoint!!.browseEndpoint!!.browseId) + .supplyTitle(artist_run.text, true) as Artist, + true + ) + } + + if (item is Playlist) { + item.supplyYear(subtitle.lastOrNull { it.text.all { it.isDigit() } }?.text?.toInt(), true) + } } - if (artist != null) { - item.supplyArtist( - Artist - .fromId(artist.navigationEndpoint!!.browseEndpoint!!.browseId) - .supplyTitle(artist.text, true) as Artist, - true - ) + + if (item is Playlist) { + header_renderer.secondSubtitle?.runs?.also { second_subtitle -> + check(second_subtitle.size == 2) { second_subtitle.toString() } + + item.supplyItemCount(second_subtitle[0].text.filter { it.isDigit() }.toInt(), true) + item.supplyTotalDuration(parseYoutubeDurationString(second_subtitle[1].text, hl), true) + } } if (header_renderer.subscriptionButton != null && item is Artist) { val subscribe_button = header_renderer.subscriptionButton.subscribeButtonRenderer item.supplySubscribeChannelId(subscribe_button.channelId, true) - item.supplySubscriberCountText(subscribe_button.subscriberCountText.first_text, true) + item.supplySubscriberCount(parseYoutubeSubscribersString(subscribe_button.subscriberCountText.first_text, hl), true) item.subscribed = subscribe_button.subscribed } } @@ -220,17 +241,23 @@ fun loadMediaItemData(item: MediaItem): Result { val continuation: MediaItemLayout.Continuation? = row.value.musicPlaylistShelfRenderer?.continuations?.firstOrNull()?.nextContinuationData?.continuation?.let { MediaItemLayout.Continuation(it, MediaItemLayout.Continuation.Type.PLAYLIST) } + val layout_title = row.value.title?.text?.let { + if (item is Artist && item.is_own_channel) LocalisedYoutubeString.ownChannel(it) + else LocalisedYoutubeString.mediaItemPage(it, item.type) + } + val view_more = row.value.getNavigationEndpoint()?.getViewMore() view_more?.layout_type = MediaItemLayout.Type.LIST + if (view_more?.media_item != null && item is Artist) { + view_more.media_item.supplyArtist(item, true) + view_more.media_item.supplyTitle(layout_title?.getString(), false) + } item_layouts.add(MediaItemLayout( - row.value.title?.text?.let { - if (item is Artist && item.is_own_channel) LocalisedYoutubeString.ownChannel(it) - else LocalisedYoutubeString.mediaItemPage(it, item.type) - }, + layout_title, null, if (row.index == 0) MediaItemLayout.Type.NUMBERED_LIST else MediaItemLayout.Type.GRID, - row.value.getMediaItems().toMutableList(), + row.value.getMediaItems(hl).toMutableList(), continuation = continuation, view_more = view_more )) diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/Search.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/Search.kt index d6c8de473..f19359b4c 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/Search.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/Search.kt @@ -63,6 +63,7 @@ data class SearchResults(val suggested_correction: String?, val categories: List fun searchYoutubeMusic(query: String, params: String?): Result { val params_str: String = if (params != null) "\"$params\"" else "null" + val hl = SpMp.data_language val request = Request.Builder() .ytUrl("/youtubei/v1/search") .addYtHeaders() @@ -109,8 +110,8 @@ fun searchYoutubeMusic(query: String, params: String?): Result { } val shelf = category.value.musicShelfRenderer ?: continue + val items = shelf.contents?.mapNotNull { it.toMediaItem(hl) }?.toMutableList() ?: continue val search_params = if (category.index == 0) null else chips[category.index - 1].chipCloudChipRenderer.navigationEndpoint.searchEndpoint!!.params - val items = shelf.contents.mapNotNull { it.toMediaItem() }.toMutableList() category_layouts.add(Pair( MediaItemLayout(LocalisedYoutubeString.temp(shelf.title!!.first_text), null, items = items), diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/YoutubeInteraction.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/YoutubeInteraction.kt index 7b601fd01..d137e44f6 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/YoutubeInteraction.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/YoutubeInteraction.kt @@ -40,6 +40,8 @@ fun isSubscribedToArtist(artist: Artist): Result { } fun subscribeOrUnsubscribeArtist(artist: Artist, subscribe: Boolean): Result { + check(DataApi.ytm_authenticated) + val request: Request = Request.Builder() .url("https://music.youtube.com/youtubei/v1/subscription/${if (subscribe) "subscribe" else "unsubscribe"}") .addYtHeaders() diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/YoutubeUITranslation.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/YoutubeUITranslation.kt index 73fcd7baa..fea7531e2 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/api/YoutubeUITranslation.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/api/YoutubeUITranslation.kt @@ -1,8 +1,122 @@ package com.spectre7.spmp.api +import SpMp import com.spectre7.spmp.model.MediaItem import com.spectre7.spmp.model.Settings import com.spectre7.utils.getString +import org.apache.commons.lang3.time.DurationFormatUtils +import java.time.Duration + +private const val HOUR: Long = 3600000L + +fun durationToString(duration: Long, hl: String, short: Boolean): String { + if (short) { + return DurationFormatUtils.formatDuration( + duration, + if (duration >= HOUR) "H:mm:ss" else "mm:ss", + true + ) + } + else { + val hms = getHMS(hl) + if (hms != null) { + val f = StringBuilder() + + val dur = Duration.ofMillis(duration) + dur.toHours().also { + if (it != 0L) { + f.append("$it${hms.splitter}${hms.hours} ") + } + } + dur.toMinutesPart().also { + if (it != 0) { + f.append("$it${hms.splitter}${hms.minutes} ") + } + } + dur.toSecondsPart().also { + if (it != 0) { + f.append("$it${hms.splitter}${hms.seconds}") + } + else { + f.removeSuffix(" ") + } + } + + return f.toString() + } + } + + throw NotImplementedError(hl.split('-', limit = 2).first()) +} + +fun parseYoutubeDurationString(string: String, hl: String): Long? { + if (string.contains(':')) { + val parts = string.split(':') + + if (parts.size !in 2..3) { + return null + } + + val seconds = parts.last().toLong() + val minutes = parts[parts.size - 2].toLong() + val hours = if (parts.size == 3) parts.first().toLong() else 0L + + return ((hours * 60 + minutes) * 60 + seconds) * 1000 + } + else { + val hms = getHMS(hl) + if (hms != null) { + return parseHhMmSsDurationString(string, hms) + } + } + + throw NotImplementedError(hl.split('-', limit = 2).first()) +} + +fun parseYoutubeSubscribersString(string: String, hl: String): Int? { + val suffixes = getAmountSuffixes(hl) + if (suffixes != null) { + if (string.last().isDigit()) { + return string.toFloat().toInt() + } + + val multiplier = suffixes[string.last()] ?: throw NotImplementedError(string.last().toString()) + return (string.substring(0, string.length - 1).toFloat() * multiplier).toInt() + } + + throw NotImplementedError(hl) +} + +fun amountToString(amount: Int, hl: String): String { + val suffixes = getAmountSuffixes(hl) + if (suffixes != null) { + for (suffix in suffixes) { + if (amount >= suffix.value) { + return "${amount / suffix.value}${suffix.key}" + } + } + + return amount.toString() + } + + throw NotImplementedError(hl) +} + +private fun getAmountSuffixes(hl: String): Map? = + when (hl) { + "en" -> mapOf( + 'B' to 1000000000, + 'M' to 1000000, + 'K' to 1000 + ) + "ja" -> mapOf( + '億' to 100000000, + '万' to 10000, + '千' to 1000, + '百' to 100 + ) + else -> null + } class LocalisedYoutubeString( val key: String, @@ -26,7 +140,16 @@ class LocalisedYoutubeString( fun getString(): String = when (type) { Type.RAW -> key Type.COMMON -> getString(key) - Type.HOME_FEED -> SpMp.yt_ui_translation.translateHomeFeedString(key, source_language!!) + Type.HOME_FEED -> { + val translation = SpMp.yt_ui_translation.translateHomeFeedString(key, source_language!!) + if (translation != null) { + translation + } + else { + println("WARNING: Using raw key '$key' as home feed string") + key + } + } Type.OWN_CHANNEL -> SpMp.yt_ui_translation.translateOwnChannelString(key, source_language!!) Type.ARTIST_PAGE -> SpMp.yt_ui_translation.translateArtistPageString(key, source_language!!) } ?: throw NotImplementedError("Key: '$key', Type: $type, Source lang: ${SpMp.getLanguageCode(source_language!!)}") @@ -284,3 +407,37 @@ class YoutubeUITranslation(languages: List) { } } } + +private data class HMSData(val hours: String, val minutes: String, val seconds: String, val splitter: String = "") + +private fun getHMS(hl: String): HMSData? = + when (hl.split('-', limit = 2).first()) { + "en" -> HMSData("hours", "minutes", "seconds", " ") + "ja" -> HMSData("時間", "分", "秒") + else -> null + } + +private fun parseHhMmSsDurationString(string: String, hms: HMSData): Long? { + val parts = string.split(' ') + + val h = parts.indexOf(hms.hours) + val hours = + if (h != -1) parts[h - 1].toLong() + else null + + val m = parts.indexOf(hms.minutes) + val minutes = + if (m != -1) parts[m - 1].toLong() + else null + + val s = parts.indexOf(hms.seconds) + val seconds = + if (s != -1) parts[s - 1].toLong() + else null + + if (hours == null && minutes == null && seconds == null) { + return null + } + + return (((hours ?: 0) * 60 + (minutes ?: 0)) * 60 + (seconds ?: 0)) * 1000 +} diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/model/Artist.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/model/Artist.kt index 85887d286..5e9eeab4f 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/model/Artist.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/model/Artist.kt @@ -5,11 +5,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.beust.klaxon.Klaxon -import com.spectre7.spmp.api.getOrThrowHere -import com.spectre7.spmp.api.isSubscribedToArtist -import com.spectre7.spmp.api.subscribeOrUnsubscribeArtist +import com.spectre7.spmp.api.* import com.spectre7.spmp.ui.component.ArtistPreviewLong import com.spectre7.spmp.ui.component.ArtistPreviewSquare +import com.spectre7.utils.getString import kotlin.concurrent.thread class Artist private constructor ( @@ -35,12 +34,12 @@ class Artist private constructor ( return this } - var subscriber_count_text: String? by mutableStateOf(null) + var subscriber_count: Int? by mutableStateOf(null) private set - fun supplySubscriberCountText(value: String?, certain: Boolean): MediaItem { - if (value != null && (subscriber_count_text == null || certain)) { - subscriber_count_text = value + fun supplySubscriberCount(value: Int?, certain: Boolean): MediaItem { + if (value != null && (subscriber_count == null || certain)) { + subscriber_count = value } return this } @@ -49,13 +48,14 @@ class Artist private constructor ( var is_own_channel: Boolean by mutableStateOf(false) override fun getSerialisedData(klaxon: Klaxon): List { - return super.getSerialisedData(klaxon) + listOf(stringToJson(subscribe_channel_id), klaxon.toJsonString(is_for_item)) + return super.getSerialisedData(klaxon) + listOf(stringToJson(subscribe_channel_id), klaxon.toJsonString(is_for_item), klaxon.toJsonString(subscriber_count)) } override fun supplyFromSerialisedData(data: MutableList, klaxon: Klaxon): MediaItem { - require(data.size >= 2) + require(data.size >= 3) + data.removeLast()?.also { subscriber_count = it as Int } is_for_item = data.removeLast() as Boolean - _subscribe_channel_id = data.removeLast() as String? + data.removeLast()?.also { _subscribe_channel_id = it as String } return super.supplyFromSerialisedData(data, klaxon) } @@ -111,26 +111,21 @@ class Artist private constructor ( override val url: String get() = "https://music.youtube.com/channel/$id" fun getFormattedSubscriberCount(): String { - return subscriber_count_text ?: "Unknown" -// val subs = subscriber_count.toInt() -// if (subs >= 1000000) { -// return "${subs / 1000000}M" -// } -// else if (subs >= 1000) { -// return "${subs / 1000}K" -// } -// else { -// return "$subs" -// } + return getString("artist_x_subscribers").replace("\$x", subscriber_count?.let { amountToString(it, SpMp.ui_language) } ?: "0") } fun updateSubscribed() { check(!is_for_item) + + if (is_own_channel) { + return + } subscribed = isSubscribedToArtist(this).getOrThrowHere() } fun toggleSubscribe(toggle_before_fetch: Boolean = false, onFinished: ((success: Boolean, subscribing: Boolean) -> Unit)? = null) { check(!is_for_item) + check(DataApi.ytm_authenticated) thread { if (subscribed == null) { diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/model/MediaItem.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/model/MediaItem.kt index ed4d6829f..eace22ac2 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/model/MediaItem.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/model/MediaItem.kt @@ -61,9 +61,7 @@ abstract class MediaItem(id: String) { val artist_listeners = Listeners<(Artist?) -> Unit>() fun supplyArtist(value: Artist?, certain: Boolean = false): MediaItem { - assert(this !is Artist || value == this) - - if (value != null && (artist == null || certain)) { + if (this !is Artist && value != null && (artist == null || certain)) { artist = value artist_listeners.call { it(artist) } } @@ -149,7 +147,7 @@ abstract class MediaItem(id: String) { set.remove(id) } key.set(set) - + pinned_to_home = value playerProvider().onMediaItemPinnedChanged(this, value) @@ -616,7 +614,7 @@ abstract class MediaItem(id: String) { abstract fun PreviewLong(params: PreviewParams) @Composable - fun Thumbnail(quality: ThumbnailQuality, modifier: Modifier = Modifier, contentColourProvider: (() -> Color)? = null) { + fun Thumbnail(quality: ThumbnailQuality, modifier: Modifier = Modifier, contentColourProvider: (() -> Color)? = null, onLoaded: ((ImageBitmap) -> Unit)? = null) { LaunchedEffect(quality, canLoadThumbnail()) { if (!canLoadThumbnail()) { thread { loadData() } @@ -624,11 +622,18 @@ abstract class MediaItem(id: String) { getThumbnail(quality) } + var loaded by remember { mutableStateOf(false) } + Crossfade(thumb_states[quality]!!.image) { thumbnail -> if (thumbnail == null) { SubtleLoadingIndicator(modifier.fillMaxSize(), contentColourProvider) } else { + if (!loaded) { + onLoaded?.invoke(thumbnail) + loaded = true + } + Image( thumbnail, contentDescription = null, diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/model/Playlist.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/model/Playlist.kt index 3396e8f75..01ea6574d 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/model/Playlist.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/model/Playlist.kt @@ -8,7 +8,6 @@ import com.beust.klaxon.Klaxon import com.spectre7.spmp.ui.component.PlaylistPreviewLong import com.spectre7.spmp.ui.component.PlaylistPreviewSquare import com.spectre7.utils.getString -import com.spectre7.utils.getStringTODO class Playlist private constructor ( id: String @@ -38,12 +37,45 @@ class Playlist private constructor ( return this } + var total_duration: Long? by mutableStateOf(null) + private set + + fun supplyTotalDuration(value: Long?, certain: Boolean = false): Playlist { + if (value != null && (total_duration == null || certain)) { + total_duration = value + } + return this + } + + var item_count: Int? by mutableStateOf(null) + private set + + fun supplyItemCount(value: Int?, certain: Boolean = false): Playlist { + if (value != null && (item_count == null || certain)) { + item_count = value + } + return this + } + + var year: Int? by mutableStateOf(null) + private set + + fun supplyYear(value: Int?, certain: Boolean = false): Playlist { + if (value != null && (year == null || certain)) { + year = value + } + return this + } + override fun getSerialisedData(klaxon: Klaxon): List { - return super.getSerialisedData(klaxon) + listOf(klaxon.toJsonString(playlist_type?.ordinal)) + return super.getSerialisedData(klaxon) + listOf(klaxon.toJsonString(playlist_type?.ordinal), klaxon.toJsonString(total_duration), klaxon.toJsonString(item_count), klaxon.toJsonString(year)) } override fun supplyFromSerialisedData(data: MutableList, klaxon: Klaxon): MediaItem { - require(data.size >= 1) + require(data.size >= 4) + data.removeLast()?.also { year = it as Int } + data.removeLast()?.also { item_count = it as Int } + data.removeLast()?.also { total_duration = (it as Int).toLong() } data.removeLast()?.also { playlist_type = PlaylistType.values()[it as Int] } return super.supplyFromSerialisedData(data, klaxon) } diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/model/YoutubeMusicAuthInfo.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/model/YoutubeMusicAuthInfo.kt index 26482472c..743b8f99d 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/model/YoutubeMusicAuthInfo.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/model/YoutubeMusicAuthInfo.kt @@ -15,7 +15,8 @@ class YoutubeMusicAuthInfo: Set { lateinit var headers: Map private set - fun getOwnChannelOrNull(): Artist? = if (!initialised) null else own_channel + fun initialisedOrNull(): YoutubeMusicAuthInfo? = if (initialised) this else null + fun getOwnChannelOrNull(): Artist? = initialisedOrNull()?.own_channel constructor() diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/ArtistPreview.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/ArtistPreview.kt index 3971e977b..7bb6b89c2 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/ArtistPreview.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/ArtistPreview.kt @@ -147,8 +147,8 @@ fun getArtistLongPressMenuData( sideButton = { modifier, background, accent -> ArtistSubscribeButton( artist, - { background.getContrasted() }, - { accent }, +// { background.getContrasted() }, +// { accent }, modifier = modifier ) } diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/MediaItemLayout.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/MediaItemLayout.kt index e9f039174..5b01af6be 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/MediaItemLayout.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/MediaItemLayout.kt @@ -106,6 +106,7 @@ data class MediaItemLayout( } private fun loadPlaylistContinuation(): Result, String?>> { + val hl = SpMp.data_language val request = Request.Builder() .ytUrl("/youtubei/v1/browse?ctoken=$token&continuation=$token&type=next") .addYtHeaders() @@ -122,7 +123,7 @@ data class MediaItemLayout( stream.close() val shelf = parsed.continuationContents!!.musicPlaylistShelfContinuation!! - return Result.success(Pair(shelf.contents.mapNotNull { it.toMediaItem() }, shelf.continuations?.firstOrNull()?.nextContinuationData?.continuation)) + return Result.success(Pair(shelf.contents!!.mapNotNull { it.toMediaItem(hl) }, shelf.continuations?.firstOrNull()?.nextContinuationData?.continuation)) } } @@ -249,7 +250,7 @@ data class MediaItemLayout( OutlinedButton( { if (view_more.media_item != null) { - playerProvider().openMediaItem(view_more.media_item, this@MediaItemLayout) + playerProvider().openMediaItem(view_more.media_item, true) } else if (view_more.list_page_url != null) { TODO(view_more.list_page_url) diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/SongPreview.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/SongPreview.kt index 4f2adf381..50a93037c 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/SongPreview.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/component/SongPreview.kt @@ -30,6 +30,8 @@ import com.spectre7.spmp.model.Song import com.spectre7.spmp.platform.* import com.spectre7.utils.* +val SONG_THUMB_CORNER_ROUNDING = 10.dp + @Composable fun SongPreviewSquare( song: Song, @@ -37,7 +39,7 @@ fun SongPreviewSquare( queue_index: Int? = null ) { val long_press_menu_data = remember(song) { - getSongLongPressMenuData(song, RoundedCornerShape(10.dp), queue_index = queue_index) + getSongLongPressMenuData(song, RoundedCornerShape(SONG_THUMB_CORNER_ROUNDING), queue_index = queue_index) } Column( @@ -83,7 +85,7 @@ fun SongPreviewLong( queue_index: Int? = null ) { val long_press_menu_data = remember(song, queue_index) { - getSongLongPressMenuData(song, RoundedCornerShape(20), queue_index = queue_index) + getSongLongPressMenuData(song, RoundedCornerShape(SONG_THUMB_CORNER_ROUNDING), queue_index = queue_index) } Row( diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/ArtistPlaylistPage.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/ArtistPlaylistPage.kt index 738596d94..5ff5b98bf 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/ArtistPlaylistPage.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/ArtistPlaylistPage.kt @@ -8,7 +8,9 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -18,6 +20,8 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale @@ -25,51 +29,222 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.spectre7.spmp.api.DataApi +import com.spectre7.spmp.api.durationToString import com.spectre7.spmp.api.getOrReport -import com.spectre7.spmp.model.Artist -import com.spectre7.spmp.model.MediaItem -import com.spectre7.spmp.model.MediaItemWithLayouts -import com.spectre7.spmp.model.Playlist +import com.spectre7.spmp.model.* import com.spectre7.spmp.platform.PlatformAlertDialog import com.spectre7.spmp.platform.vibrateShort +import com.spectre7.spmp.ui.component.LongPressMenuData import com.spectre7.spmp.ui.component.MediaItemLayout import com.spectre7.spmp.ui.component.PillMenu +import com.spectre7.spmp.ui.component.SONG_THUMB_CORNER_ROUNDING import com.spectre7.spmp.ui.theme.Theme import com.spectre7.utils.* import com.spectre7.utils.getString import kotlinx.coroutines.* +import org.apache.commons.lang3.time.DurationFormatUtils +import java.time.Duration import kotlin.concurrent.thread +private const val ARTIST_IMAGE_SCROLL_MODIFIER = 0.25f + +@Composable +fun PlaylistPage( + pill_menu: PillMenu, + item: Playlist, + playerProvider: () -> PlayerViewContext, + previous_item: MediaItem? = null, + close: () -> Unit +) { + val status_bar_height = SpMp.context.getStatusBarHeight() + var accent_colour: Color? by remember { mutableStateOf(null) } + + LaunchedEffect(item) { + accent_colour = null + + if (item.feed_layouts == null) { + thread { + val result = item.loadData() + result.fold( + { playlist -> + if (playlist == null) { + SpMp.error_manager.onError("ArtistPlaylistPageLoad", Exception("loadData result is null")) + } + }, + { error -> + SpMp.error_manager.onError("ArtistPlaylistPageLoad", error) + } + ) + } + } + } + + Column(Modifier.fillMaxSize().padding(horizontal = 10.dp).padding(top = status_bar_height), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (previous_item != null) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(close) { + Icon(Icons.Default.KeyboardArrowLeft, null) + } + + Spacer(Modifier.fillMaxWidth().weight(1f)) + previous_item.title!!.also { Text(it) } + Spacer(Modifier.fillMaxWidth().weight(1f)) + + IconButton({ playerProvider().showLongPressMenu(previous_item) }) { + Icon(Icons.Default.MoreVert, null) + } + } + } + + LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { + PlaylistTopInfo(item, accent_colour, playerProvider) { + if (accent_colour == null) { + accent_colour = item.getDefaultThemeColour() ?: Theme.current.accent + } + } + } + + item.feed_layouts?.also { layouts -> + val layout = layouts.single() + + item { + val total_duration_text = remember(item.total_duration) { + if (item.total_duration == null) "" + else durationToString(item.total_duration!!, SpMp.ui_language, false) + } + + Text( + "${(item.item_count ?: layout.items.size) + 1}曲 $total_duration_text", + Modifier.padding(top = 15.dp), + style = MaterialTheme.typography.titleMedium + ) + } + + items(layout.items.size) { i -> + val song = layout.items[i] + check(song is Song) + + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + song.Thumbnail(MediaItem.ThumbnailQuality.LOW, Modifier.size(50.dp).clip(RoundedCornerShape(SONG_THUMB_CORNER_ROUNDING))) + Text( + song.title!!, + Modifier.fillMaxWidth().weight(1f), + style = MaterialTheme.typography.titleSmall + ) + + val duration_text = remember(song.duration!!) { + durationToString(song.duration!!, SpMp.ui_language, true) + } + + Text(duration_text) + } + } + } + } + } +} + +@Composable +private fun PlaylistTopInfo(playlist: Playlist, accent_colour: Color?, playerProvider: () -> PlayerViewContext, onThumbLoaded: (ImageBitmap) -> Unit) { + val shape = RoundedCornerShape(10.dp) + + Row(Modifier.height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + + var thumb_size by remember { mutableStateOf(IntSize.Zero) } + playlist.Thumbnail( + MediaItem.ThumbnailQuality.HIGH, + Modifier.fillMaxWidth(0.5f).aspectRatio(1f).clip(shape).onSizeChanged { + thumb_size = it + }, + onLoaded = onThumbLoaded + ) + + Column(Modifier.height(with(LocalDensity.current) { thumb_size.height.toDp() })) { + Box(Modifier.fillMaxHeight().weight(1f), contentAlignment = Alignment.CenterStart) { + Text( + playlist.title!!, + style = MaterialTheme.typography.headlineSmall, + overflow = TextOverflow.Ellipsis + ) + } + +// playlist.artist?.title?.also { artist -> +// Text(artist, Modifier.align(Alignment.End)) +// } + + Row { + IconButton({ TODO() }) { + Icon(Icons.Default.Radio, null) + } + IconButton({ TODO() }) { + Icon(Icons.Default.Shuffle, null) + } + Crossfade(playlist.pinned_to_home) { pinned -> + IconButton({ playlist.setPinnedToHome(!pinned, playerProvider) }) { + Icon(if (pinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, null) + } + } + if (SpMp.context.canShare()) { + IconButton({ SpMp.context.shareText(playlist.url, playlist.title!!) }) { + Icon(Icons.Default.Share, null) + } + } + } + + Button( + { TODO() }, + Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = accent_colour ?: Theme.current.accent, + contentColor = accent_colour?.getContrasted() ?: Theme.current.on_accent + ), + shape = shape + ) { + Icon(Icons.Default.PlayArrow, null) + Text(getString("playlist_chip_play")) + } + } + } +} + @Composable fun ArtistPlaylistPage( pill_menu: PillMenu, item: MediaItem, playerProvider: () -> PlayerViewContext, - opened_layout: MediaItemLayout? = null, + previous_item: MediaItem? = null, close: () -> Unit ) { require(item is MediaItemWithLayouts) require(item !is Artist || !item.is_for_item) + if (item is Playlist) { + PlaylistPage(pill_menu, item, playerProvider, previous_item, close) + return + } + var show_info by remember { mutableStateOf(false) } val gradient_size = 0.35f var accent_colour: Color? by remember { mutableStateOf(null) } LaunchedEffect(item.id) { - if (opened_layout != null) { - val view_more = opened_layout.view_more!! - if (view_more.layout == null) { - thread { - view_more.loadLayout().getOrReport("ArtistPlaylistPageLoad") - } - } - } - else if (item.feed_layouts == null) { + if (item.feed_layouts == null) { thread { val result = item.loadData() result.fold( @@ -102,11 +277,13 @@ fun ArtistPlaylistPage( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + val lazy_column_state = rememberLazyListState() + // Thumbnail Crossfade(item.getThumbnail(MediaItem.ThumbnailQuality.HIGH)) { thumbnail -> if (thumbnail != null) { if (accent_colour == null) { - accent_colour = item.getDefaultThemeColour() ?: Theme.current.accent + accent_colour = Theme.current.makeVibrant(item.getDefaultThemeColour() ?: Theme.current.accent) } Image( @@ -116,6 +293,9 @@ fun ArtistPlaylistPage( modifier = Modifier .fillMaxWidth() .aspectRatio(1f) + .offset { + IntOffset(0, (lazy_column_state.firstVisibleItemScrollOffset * -ARTIST_IMAGE_SCROLL_MODIFIER).toInt()) + } ) Spacer( @@ -132,7 +312,7 @@ fun ArtistPlaylistPage( } } - LazyColumn(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize(), lazy_column_state) { // Image spacing item { @@ -145,30 +325,28 @@ fun ArtistPlaylistPage( 1f - gradient_size to Color.Transparent, 1f to Theme.current.background ) - } - .padding(bottom = 20.dp), + }, contentAlignment = Alignment.BottomCenter ) { TitleBar( item, - Modifier - .fillMaxWidth() - .aspectRatio(1.1f) - .padding(bottom = 20.dp) + playerProvider, + Modifier.offset { + IntOffset(0, (lazy_column_state.firstVisibleItemScrollOffset * ARTIST_IMAGE_SCROLL_MODIFIER).toInt()) + } ) } } - val content_padding = 10.dp + val content_padding = PaddingValues(horizontal = 10.dp) + val background_modifier = Modifier.background { Theme.current.background } // Secondary action bar item { LazyRow( - Modifier - .fillMaxWidth() - .background { Theme.current.background }, + background_modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(horizontal = content_padding) + contentPadding = content_padding ) { fun chip(text: String, icon: ImageVector, onClick: () -> Unit) { @@ -205,11 +383,7 @@ fun ArtistPlaylistPage( // Primary action bar item { - Row( - Modifier - .fillMaxWidth() - .background { Theme.current.background } - .padding(start = 20.dp, bottom = 10.dp)) { + Row(background_modifier.fillMaxWidth().padding(start = 20.dp, bottom = 10.dp)) { @Composable fun Btn(text: String, icon: ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) { OutlinedButton(onClick = onClick, modifier.height(45.dp)) { @@ -246,64 +420,78 @@ fun ArtistPlaylistPage( playerProvider().playMediaItem(item, shuffle = true) } } + } + } - if (item is Artist) { - ArtistSubscribeButton(item, { Theme.current.background }, { accent_colour }) + if (item.feed_layouts == null) { + item { + Box(background_modifier.fillMaxSize().padding(content_padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = accent_colour ?: Color.Unspecified) } } } + else if (item.feed_layouts!!.size == 1) { + val layout = item.feed_layouts!!.single() - // Loaded items - item { - Crossfade(if (opened_layout != null) opened_layout.view_more!!.layout?.let { listOf(it) } else item.feed_layouts) { layouts -> - if (layouts == null) { - Box( - Modifier - .fillMaxSize() - .background { Theme.current.background } - .padding(content_padding), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = accent_colour ?: Color.Unspecified) + item { + layout.TitleBar(playerProvider, background_modifier.padding(bottom = 5.dp)) + } + + items(layout.items.size) { i -> + Row(background_modifier, verticalAlignment = Alignment.CenterVertically) { + Text((i + 1).toString().padStart((layout.items.size + 1).toString().length, '0'), fontWeight = FontWeight.Light) + + Column { + layout.items[i].PreviewLong(MediaItem.PreviewParams(playerProvider)) } } - else { - Column( - Modifier - .background { Theme.current.background } - .fillMaxSize() - .padding(content_padding), - verticalArrangement = Arrangement.spacedBy(30.dp) - ) { - for (row in layouts) { - val type = if (row.type == null) MediaItemLayout.Type.GRID - else if (row.type == MediaItemLayout.Type.NUMBERED_LIST && item is Artist) MediaItemLayout.Type.LIST - else row.type - - type.Layout( - if (opened_layout == null) row else row.copy(title = null, subtitle = null), - playerProvider - ) - } - - val description = item.description - if (description?.isNotBlank() == true) { - DescriptionCard(description, { Theme.current.background }, { accent_colour }) { show_info = !show_info } - } + } + } + else { + item { + Column( + background_modifier + .fillMaxSize() + .padding(content_padding), + verticalArrangement = Arrangement.spacedBy(30.dp) + ) { + for (row in item.feed_layouts!!) { + val type = if (row.type == null) MediaItemLayout.Type.GRID + else if (row.type == MediaItemLayout.Type.NUMBERED_LIST && item is Artist) MediaItemLayout.Type.LIST + else row.type + + type.Layout( + if (previous_item == null) row else row.copy(title = null, subtitle = null), + playerProvider + ) + } - Spacer(Modifier.requiredHeight(50.dp)) + val description = item.description + if (description?.isNotBlank() == true) { + DescriptionCard(description, { Theme.current.background }, { accent_colour }) { show_info = !show_info } } + + Spacer(Modifier.requiredHeight(50.dp)) } } } + +// // Loaded items +// item { +// Crossfade(if (opened_layout != null) opened_layout.view_more!!.layout?.let { listOf(it) } else item.feed_layouts) { layouts -> +// } +// } } } } @OptIn(ExperimentalFoundationApi::class) @Composable -private fun TitleBar(item: MediaItem, modifier: Modifier = Modifier) { +private fun TitleBar(item: MediaItem, playerProvider: () -> PlayerViewContext, modifier: Modifier = Modifier) { + val horizontal_padding = 20.dp var editing_title by remember { mutableStateOf(false) } Crossfade(editing_title) { editing -> - Box(modifier.padding(horizontal = 20.dp), contentAlignment = Alignment.BottomCenter) { + Column(modifier.padding(start = horizontal_padding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.Bottom)) { if (editing) { var edited_title by remember(item) { mutableStateOf(item.title!!) } @@ -373,10 +561,27 @@ private fun TitleBar(item: MediaItem, modifier: Modifier = Modifier) { .fillMaxWidth(), style = LocalTextStyle.current.copy( textAlign = TextAlign.Center, - fontSize = 40.sp, + fontSize = 35.sp, ) ) } + + // Interactions + Row(verticalAlignment = Alignment.CenterVertically) { + if (item is Artist && (item.subscriber_count ?: 0) > 0) { + Text(item.getFormattedSubscriberCount(), Modifier.fillMaxWidth().weight(1f), style = MaterialTheme.typography.labelLarge ) + } + + Crossfade(item.pinned_to_home) { pinned -> + IconButton({ item.setPinnedToHome(!pinned, playerProvider) }) { + Icon(if (pinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, null) + } + } + + if (item is Artist) { + ArtistSubscribeButton(item, Modifier.padding(end = horizontal_padding - 10.dp)) + } + } } } } @@ -384,51 +589,47 @@ private fun TitleBar(item: MediaItem, modifier: Modifier = Modifier) { @Composable fun ArtistSubscribeButton( artist: Artist, - backgroundColourProvider: () -> Color, - accentColourProvider: () -> Color?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + accentColourProvider: (() -> Color)? = null, + icon_modifier: Modifier = Modifier ) { - LaunchedEffect(artist) { - thread { - artist.updateSubscribed() + if (!DataApi.ytm_authenticated) { + return + } + + LaunchedEffect(artist, artist.is_own_channel) { + if (!artist.is_own_channel) { + thread { + artist.updateSubscribed() + } } } - Box(modifier) { - Crossfade(artist.subscribed) { subscribed -> - if (subscribed != null) { - OutlinedIconButton( - { - artist.toggleSubscribe( - toggle_before_fetch = true, - ) { success, subscribing -> - if (!success) { - SpMp.context.sendToast(getStringTODO( - if (subscribing) "Subscribing to ${artist.title} failed" - else "Unsubscribing from ${artist.title} failed" - )) - } + Crossfade(artist.subscribed, modifier) { subscribed -> + if (subscribed != null) { + ShapedIconButton( + { + artist.toggleSubscribe( + toggle_before_fetch = false, + ) { success, subscribing -> + if (!success) { + SpMp.context.sendToast(getStringTODO( + if (subscribing) "Subscribing to ${artist.title} failed" + else "Unsubscribing from ${artist.title} failed" + )) } - }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = if (subscribed) accentColourProvider() ?: Color.Unspecified else backgroundColourProvider(), - contentColor = if (subscribed) accentColourProvider()?.getContrasted() ?: Color.Unspecified else Theme.current.on_background - ) - ) { - Icon(if (subscribed) Icons.Outlined.PersonRemove else Icons.Outlined.PersonAddAlt1, null) - } + } + }, + icon_modifier, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (subscribed && accentColourProvider != null) accentColourProvider() else Color.Transparent, + contentColor = if (subscribed && accentColourProvider != null) accentColourProvider().getContrasted() else LocalContentColor.current + ) + ) { + Icon(if (subscribed) Icons.Filled.PersonRemove else Icons.Outlined.PersonAddAlt1, null) } } } - // if (subscribed == null) { - // Spacer(Modifier.requiredWidth(20.dp)) - // } - // else { - // Row { - // Spacer(Modifier.requiredWidth(10.dp)) - // } - // } - } @Composable diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/DiscordLogin.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/DiscordLogin.kt index c9fd1762b..99db8e182 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/DiscordLogin.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/DiscordLogin.kt @@ -115,8 +115,11 @@ fun DiscordAccountPreview(account_token: String, modifier: Modifier = Modifier) if (me.token != account_token) { load_thread = thread { - me = getDiscordAccountInfo(account_token).getOrReport("DiscordAccountPreview") ?: DiscordMeResponse.EMPTY - load_thread = null + try { + me = getDiscordAccountInfo(account_token).getOrReport("DiscordAccountPreview") ?: DiscordMeResponse.EMPTY + load_thread = null + } + catch (_: InterruptedException) {} } me = DiscordMeResponse.EMPTY diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/PlayerView.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/PlayerView.kt index d5f62372f..7a5b36388 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/PlayerView.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/PlayerView.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onSizeChanged @@ -60,7 +61,7 @@ open class PlayerViewContext( private val upstream: PlayerViewContext? = null ) { open val np_theme_mode: ThemeMode get() = upstream!!.np_theme_mode - open val overlay_page: Triple? get() = upstream!!.overlay_page + open val overlay_page: Triple? get() = upstream!!.overlay_page open val bottom_padding: Dp get() = upstream!!.bottom_padding open val pill_menu: PillMenu get() = upstream!!.pill_menu @@ -70,7 +71,7 @@ open class PlayerViewContext( open fun getNowPlayingTopOffset(screen_height: Dp, density: Density): Int = upstream!!.getNowPlayingTopOffset(screen_height, density) - open fun setOverlayPage(page: OverlayPage?, media_item: MediaItem? = null, opened_layout: MediaItemLayout? = null) { upstream!!.setOverlayPage(page, media_item, opened_layout) } + open fun setOverlayPage(page: OverlayPage?, media_item: MediaItem? = null, from_current: Boolean = false) { upstream!!.setOverlayPage(page, media_item, from_current) } open fun navigateBack() { upstream!!.navigateBack() } @@ -91,19 +92,20 @@ open class PlayerViewContext( } } - open fun openMediaItem(item: MediaItem, opened_layout: MediaItemLayout? = null) { upstream!!.openMediaItem(item, opened_layout) } + open fun openMediaItem(item: MediaItem, from_current: Boolean = false) { upstream!!.openMediaItem(item, from_current) } open fun playMediaItem(item: MediaItem, shuffle: Boolean = false) { upstream!!.playMediaItem(item, shuffle) } open fun onMediaItemPinnedChanged(item: MediaItem, pinned: Boolean) { upstream!!.onMediaItemPinnedChanged(item, pinned) } open fun showLongPressMenu(data: LongPressMenuData) { upstream!!.showLongPressMenu(data) } + fun showLongPressMenu(item: MediaItem) { showLongPressMenu(LongPressMenuData(item)) } open fun hideLongPressMenu() { upstream!!.hideLongPressMenu() } } private class PlayerViewContextImpl: PlayerViewContext(null, null, null) { private var now_playing_switch_page: Int by mutableStateOf(-1) - private val overlay_page_undo_stack: MutableList?> = mutableListOf() + private val overlay_page_undo_stack: MutableList?> = mutableListOf() private val bottom_padding_anim = Animatable(PlayerServiceHost.session_started.toFloat() * MINIMISED_NOW_PLAYING_HEIGHT) private var main_page_showing: Boolean by mutableStateOf(false) @@ -124,7 +126,7 @@ private class PlayerViewContextImpl: PlayerViewContext(null, null, null) { private val pinned_items: MutableList = mutableStateListOf() override var np_theme_mode: ThemeMode by mutableStateOf(Settings.getEnum(Settings.KEY_NOWPLAYING_THEME_MODE)) - override var overlay_page: Triple? by mutableStateOf(null) + override var overlay_page: Triple? by mutableStateOf(null) private set override val bottom_padding: Dp get() = bottom_padding_anim.value.dp override val pill_menu = PillMenu( @@ -180,8 +182,10 @@ private class PlayerViewContextImpl: PlayerViewContext(null, null, null) { return with (density) { (-now_playing_swipe_state.offset.value.dp - (screen_height * 0.5f)).toPx().toInt() } } - override fun setOverlayPage(page: OverlayPage?, media_item: MediaItem?, opened_layout: MediaItemLayout?) { - val new_page = page?.let { Triple(page, media_item, opened_layout) } + override fun setOverlayPage(page: OverlayPage?, media_item: MediaItem?, from_current: Boolean) { + val current = if (from_current) overlay_page!!.second!! else null + + val new_page = page?.let { Triple(page, media_item, current) } if (new_page != overlay_page) { overlay_page_undo_stack.add(overlay_page) overlay_page = new_page @@ -208,12 +212,12 @@ private class PlayerViewContextImpl: PlayerViewContext(null, null, null) { }) } - override fun openMediaItem(item: MediaItem, opened_layout: MediaItemLayout?) { + override fun openMediaItem(item: MediaItem, from_current: Boolean) { if (item is Artist && item.is_for_item) { return } - setOverlayPage(OverlayPage.MEDIAITEM, item, opened_layout) + setOverlayPage(OverlayPage.MEDIAITEM, item, from_current) if (now_playing_swipe_state.targetValue != 0) { switchNowPlayingPage(0) @@ -621,7 +625,7 @@ fun PlayerView() { Column( Modifier .fillMaxSize() - .background(Theme.current.background) + .background(Theme.current.background_provider) ) { Box { val expand_state = remember { mutableStateOf(false) } diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/PrefsPage.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/PrefsPage.kt index a740ece24..bd469e5c3 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/PrefsPage.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/layout/PrefsPage.kt @@ -64,7 +64,7 @@ private enum class Category { @Composable fun PrefsPage(pill_menu: PillMenu, playerProvider: () -> PlayerViewContext, close: () -> Unit) { - var current_category: Category by remember { mutableStateOf(Category.OTHER) } + var current_category: Category by remember { mutableStateOf(Category.GENERAL) } val ytm_auth = remember { SettingsValueState( diff --git a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/theme/Theme.kt b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/theme/Theme.kt index e74d89ec4..4ff6925ce 100644 --- a/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/spectre7/spmp/ui/theme/Theme.kt @@ -95,11 +95,13 @@ class Theme(data: ThemeData) { val on_accent: Color get() = accent.getContrasted() - val vibrant_accent: Color get() { - if (accent.compare(background) > 0.8f) { - return accent.contrastAgainst(background, VIBRANT_ACCENT_CONTRAST) + val vibrant_accent: Color get() = makeVibrant(accent) + + fun makeVibrant(colour: Color, against: Color = background): Color { + if (colour.compare(background) > 0.8f) { + return colour.contrastAgainst(against, VIBRANT_ACCENT_CONTRAST) } - return accent + return colour } val background_provider: () -> Color = { background_state.value } diff --git a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml index 29d39b7da..c50deff67 100644 --- a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml +++ b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml @@ -64,6 +64,7 @@ ラジオ シャッフル 開く + 登録者$x人 確認してください このページのバリューを全てリセットしますか? アクセシビリティサービス diff --git a/shared/src/commonMain/resources/assets/values/strings.xml b/shared/src/commonMain/resources/assets/values/strings.xml index 477acd017..d01c88273 100644 --- a/shared/src/commonMain/resources/assets/values/strings.xml +++ b/shared/src/commonMain/resources/assets/values/strings.xml @@ -73,6 +73,8 @@ Open Artist info + $x subscribers + Loading feed Pause