diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/AccountPlaylists.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/AccountPlaylists.kt index e9fbe900f..5af7f0741 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/AccountPlaylists.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/AccountPlaylists.kt @@ -35,7 +35,7 @@ suspend fun getAccountPlaylists(): Result> = withContext(D val playlist_data = parsed .contents!! - .singleColumnBrowseResultsRenderer + .singleColumnBrowseResultsRenderer!! .tabs .first() .tabRenderer diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt index 24ab1cb37..7c12d537a 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/Api.kt @@ -38,7 +38,7 @@ import kotlin.concurrent.thread import org.schabi.newpipe.extractor.downloader.Request as NewPipeRequest import org.schabi.newpipe.extractor.downloader.Response as NewPipeResponse -const val DEFAULT_CONNECT_TIMEOUT = 3000 +const val DEFAULT_CONNECT_TIMEOUT = 10000 val PLAIN_HEADERS = listOf("accept-language", "user-agent", "accept-encoding", "content-encoding", "origin") class JsonParseException(val json_obj: JsonObject, message: String? = null, cause: Throwable? = null): RuntimeException(message, cause) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/FeedViewMorePage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/FeedViewMorePage.kt index 480b7021a..d61128e66 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/FeedViewMorePage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/FeedViewMorePage.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.withContext import okhttp3.Request suspend fun getGenericFeedViewMorePage(browse_id: String): Result> = withContext(Dispatchers.IO) { - val hl = SpMp.data_language val request = Request.Builder() .ytUrl("/youtubei/v1/browse") @@ -28,7 +27,7 @@ suspend fun getGenericFeedViewMorePage(browse_id: String): Result { + last_request = null + val endpoint = "/youtubei/v1/browse" val request = Request.Builder() .ytUrl(if (ctoken == null) endpoint else "$endpoint?ctoken=$ctoken&continuation=$ctoken&type=next") @@ -73,58 +75,19 @@ suspend fun getHomeFeed( val result = Api.request(request) val stream = result.getOrNull()?.getStream() ?: return result.cast() + last_request = request + try { return stream.use { Result.success(Api.klaxon.parse(it)!!) } } - catch (error: Throwable) { - val retry_result = Api.request(request) - val retry_stream = retry_result.getOrNull()?.getStream() ?: return retry_result.cast() - - return retry_stream.use { - Result.failure( - JsonParseException( - Api.klaxon.parseJsonObject(it.reader()).apply { - // Remove unneeded keys from JSON object - - remove("responseContext") - - val items: MutableList = mutableListOf(this) - val keys_to_remove = listOf("trackingParams", "clickTrackingParams", "serializedShareEntity", "serializedContextData", "loggingContext") - - while (items.isNotEmpty()) { - val obj = items.removeLast() - - if (obj is Collection<*>) { - items.addAll(obj as Collection) - continue - } - - check(obj is JsonObject) - - for (key in keys_to_remove) { - obj.remove(key) - } - - for (value in obj.values) { - if (value is JsonObject) { - items.add(value) - } - else if (value is Collection<*>) { - items.addAll(value.filterIsInstance()) - } - } - } - }, - cause = error - ) - ) - } + catch (e: Throwable) { + return Result.failure(e) } } - return@withContext kotlin.runCatching { + try { var data = performRequest(continuation).getOrThrow() val rows: MutableList = processRows(data.getShelves(continuation != null), hl).toMutableList() @@ -153,7 +116,53 @@ suspend fun getHomeFeed( Cache.set(chips_cache_key, Api.klaxon.toJsonString(chips).reader(), CACHE_LIFETIME) } - return@runCatching Triple(rows, ctoken, chips) + return@withContext Result.success(Triple(rows, ctoken, chips)) + } + catch (error: Throwable) { + val request = last_request ?: return@withContext Result.failure(error) + + val retry_result = Api.request(request) + val retry_stream = retry_result.getOrNull()?.getStream() ?: return@withContext retry_result.cast() + + return@withContext retry_stream.use { + Result.failure( + JsonParseException( + Api.klaxon.parseJsonObject(it.reader()).apply { + // Remove unneeded keys from JSON object + + remove("responseContext") + + val items: MutableList = mutableListOf(this) + val keys_to_remove = listOf("trackingParams", "clickTrackingParams", "serializedShareEntity", "serializedContextData", "loggingContext") + + while (items.isNotEmpty()) { + val obj = items.removeLast() + + if (obj is Collection<*>) { + items.addAll(obj as Collection) + continue + } + + check(obj is JsonObject) + + for (key in keys_to_remove) { + obj.remove(key) + } + + for (value in obj.values) { + if (value is JsonObject) { + items.add(value) + } + else if (value is Collection<*>) { + items.addAll(value.filterIsInstance()) + } + } + } + }, + cause = error + ) + ) + } } } @@ -261,7 +270,7 @@ data class YoutubeiBrowseResponse( ) { val ctoken: String? get() = continuationContents?.sectionListContinuation?.continuations?.firstOrNull()?.nextContinuationData?.continuation - ?: contents!!.singleColumnBrowseResultsRenderer.tabs.first().tabRenderer.content?.sectionListRenderer?.continuations?.firstOrNull()?.nextContinuationData?.continuation + ?: contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.continuations?.firstOrNull()?.nextContinuationData?.continuation fun getShelves(has_continuation: Boolean): List { return if (has_continuation) continuationContents?.sectionListContinuation?.contents ?: emptyList() @@ -276,13 +285,20 @@ data class YoutubeiBrowseResponse( ) } - data class Contents(val singleColumnBrowseResultsRenderer: SingleColumnBrowseResultsRenderer) + data class Contents( + val singleColumnBrowseResultsRenderer: SingleColumnBrowseResultsRenderer? = null, + val twoColumnBrowseResultsRenderer: TwoColumnBrowseResultsRenderer? = null + ) data class SingleColumnBrowseResultsRenderer(val tabs: List) data class Tab(val tabRenderer: TabRenderer) data class TabRenderer(val content: Content? = null) data class Content(val sectionListRenderer: SectionListRenderer) open class SectionListRenderer(val contents: List? = null, val header: ChipCloudRendererHeader? = null, val continuations: List? = null) + class TwoColumnBrowseResultsRenderer(val tabs: List, val secondaryContents: SecondaryContents) { + class SecondaryContents(val sectionListRenderer: SectionListRenderer) + } + data class ContinuationContents(val sectionListContinuation: SectionListRenderer? = null, val musicPlaylistShelfContinuation: MusicShelfRenderer? = null) } @@ -477,47 +493,11 @@ data class MusicCardShelfRenderer( } } -data class MusicTwoRowItemRenderer( - val navigationEndpoint: NavigationEndpoint, - val title: TextRuns, - val subtitle: TextRuns? = null, - val thumbnailRenderer: ThumbnailRenderer, - val menu: YoutubeiNextResponse.Menu? = null -) { - fun getArtist(host_item: MediaItem): Artist? { - for (run in subtitle?.runs ?: emptyList()) { - val browse_endpoint = run.navigationEndpoint?.browseEndpoint - - val endpoint_type = browse_endpoint?.getMediaItemType() - if (endpoint_type == MediaItemType.ARTIST) { - return Artist.fromId(browse_endpoint.browseId).editArtistData { supplyTitle(run.text) } - } - } - - if (host_item is Song) { - val index = if (host_item.song_type == SongType.VIDEO) 0 else 1 - subtitle?.runs?.getOrNull(index)?.also { - return Artist.createForItem(host_item).editArtistData { supplyTitle(it.text) } - } - } - - return null - } -} data class ThumbnailRenderer(val musicThumbnailRenderer: MusicThumbnailRenderer) { fun toThumbnailProvider(): MediaItemThumbnailProvider { return MediaItemThumbnailProvider.fromThumbnails(musicThumbnailRenderer.thumbnail.thumbnails)!! } } -data class MusicResponsiveListItemRenderer( - val playlistItemData: RendererPlaylistItemData? = null, - val flexColumns: List? = null, - val fixedColumns: List? = null, - val thumbnail: ThumbnailRenderer? = null, - val navigationEndpoint: NavigationEndpoint? = null, - val menu: YoutubeiNextResponse.Menu? = null -) -data class RendererPlaylistItemData(val videoId: String, val playlistSetVideoId: String? = null) data class FlexColumn(val musicResponsiveListItemFlexColumnRenderer: MusicResponsiveListItemColumnRenderer) data class FixedColumn(val musicResponsiveListItemFixedColumnRenderer: MusicResponsiveListItemColumnRenderer) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt index 181050926..0ffc942e9 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/LoadMediaitem.kt @@ -17,6 +17,7 @@ import com.toasterofbread.spmp.model.mediaitem.data.ArtistItemData import com.toasterofbread.spmp.model.mediaitem.data.MediaItemData import com.toasterofbread.spmp.model.mediaitem.data.SongItemData import com.toasterofbread.spmp.model.mediaitem.enums.PlaylistType +import com.toasterofbread.spmp.model.mediaitem.enums.SongType import com.toasterofbread.spmp.resources.uilocalisation.LocalisedYoutubeString import com.toasterofbread.spmp.resources.uilocalisation.parseYoutubeDurationString import com.toasterofbread.spmp.resources.uilocalisation.parseYoutubeSubscribersString @@ -72,7 +73,7 @@ suspend fun loadBrowseId(browse_id: String, params: String? = null): Result = mutableListOf() - for (row in parsed.contents!!.singleColumnBrowseResultsRenderer.tabs.first().tabRenderer.content!!.sectionListRenderer.contents!!.withIndex()) { + for (row in parsed.contents!!.singleColumnBrowseResultsRenderer!!.tabs.first().tabRenderer.content!!.sectionListRenderer.contents!!.withIndex()) { if (row.value.description != null) { continue } @@ -120,7 +121,7 @@ suspend fun processDefaultResponse(item: MediaItem, data: MediaItemData, respons if (item is Playlist && item.playlist_type == PlaylistType.RADIO) { val playlist_shelf = parsed .contents!! - .singleColumnBrowseResultsRenderer + .singleColumnBrowseResultsRenderer!! .tabs[0] .tabRenderer .content!! @@ -196,7 +197,17 @@ suspend fun processDefaultResponse(item: MediaItem, data: MediaItemData, respons } val item_layouts: MutableList = mutableListOf() - for (row in parsed.contents!!.singleColumnBrowseResultsRenderer.tabs.first().tabRenderer.content!!.sectionListRenderer.contents!!.withIndex()) { + + val rows = with (parsed.contents!!) { + if (singleColumnBrowseResultsRenderer != null) { + singleColumnBrowseResultsRenderer.tabs.first().tabRenderer.content!!.sectionListRenderer.contents!! + } + else { + twoColumnBrowseResultsRenderer!!.secondaryContents.sectionListRenderer.contents!! + } + } + + for (row in rows.withIndex()) { val description = row.value.description if (description != null) { data.supplyDescription(description, true) @@ -232,7 +243,15 @@ suspend fun processDefaultResponse(item: MediaItem, data: MediaItemData, respons layout_title, null, if (row.index == 0) MediaItemLayout.Type.NUMBERED_LIST else MediaItemLayout.Type.GRID, - items.map { it.first }.toMutableList(), + items.map { + if (item is Artist && it.first is Song && (it.first as Song).song_type == SongType.PODCAST) { + it.first.editData { + supplyArtist(item, true) + } + } + + it.first + }.toMutableList(), continuation = continuation, view_more = view_more ) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicMultiRowListItemRenderer.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicMultiRowListItemRenderer.kt new file mode 100644 index 000000000..00eb6230b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicMultiRowListItemRenderer.kt @@ -0,0 +1,93 @@ +package com.toasterofbread.spmp.api.model + +import com.toasterofbread.spmp.api.TextRuns +import com.toasterofbread.spmp.api.ThumbnailRenderer +import com.toasterofbread.spmp.api.radio.YoutubeiNextResponse +import com.toasterofbread.spmp.model.mediaitem.AccountPlaylist +import com.toasterofbread.spmp.model.mediaitem.Artist +import com.toasterofbread.spmp.model.mediaitem.MediaItem +import com.toasterofbread.spmp.model.mediaitem.Song +import com.toasterofbread.spmp.model.mediaitem.data.AccountPlaylistItemData +import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType +import com.toasterofbread.spmp.model.mediaitem.enums.PlaylistType +import com.toasterofbread.spmp.model.mediaitem.enums.SongType +import com.toasterofbread.spmp.resources.uilocalisation.parseYoutubeDurationString + +class OnTap( + val watchEndpoint: WatchEndpoint +) { + class WatchEndpoint(val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs) + class WatchEndpointMusicSupportedConfigs(val watchEndpointMusicConfig: WatchEndpointMusicConfig) + class WatchEndpointMusicConfig(val musicVideoType: String) + + fun getMusicVideoType(): String = + watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType +} + +class MusicMultiRowListItemRenderer( + val title: TextRuns, + val subtitle: TextRuns, + val thumbnail: ThumbnailRenderer, + val menu: YoutubeiNextResponse.Menu, + val onTap: OnTap, + val secondTitle: TextRuns? = null +) { + fun toMediaItem(hl: String): MediaItem { + val title = title.runs!!.first() + return Song.fromId( + title.navigationEndpoint!!.browseEndpoint!!.browseId.removePrefix("MPED") + ).editSongData { + var podcast_data: AccountPlaylistItemData? = null + + val podcast_text = secondTitle?.runs?.firstOrNull() + if (podcast_text != null) { + podcast_data = AccountPlaylist.fromId( + podcast_text.navigationEndpoint!!.browseEndpoint!!.browseId + ).editPlaylistDataManual { + supplyTitle(podcast_text.text) + } + } + else { + for (item in menu.menuRenderer.items) { + val browse_endpoint = item.menuNavigationItemRenderer?.navigationEndpoint?.browseEndpoint ?: continue + if (browse_endpoint.getPageType() == "MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE") { + podcast_data = AccountPlaylist.fromId( + browse_endpoint.browseId + ).data + break + } + } + } + + if (podcast_data != null) { + podcast_data.supplyPlaylistType(PlaylistType.PODCAST, true) + podcast_data.save() + supplyAlbum(podcast_data.data_item, true) + } + + for (run in subtitle.runs ?: emptyList()) { + if (run.navigationEndpoint?.browseEndpoint?.getMediaItemType() == MediaItemType.ARTIST) { + supplyArtist( + Artist.fromId(run.navigationEndpoint.browseEndpoint.browseId) + .editArtistData { + supplyTitle(run.text, true) + }, + true + ) + } + } + + if (onTap.getMusicVideoType() == "MUSIC_VIDEO_TYPE_PODCAST_EPISODE") { + supplySongType(SongType.PODCAST, true) + } + + val duration = subtitle.runs?.lastOrNull()?.text?.let { text -> + parseYoutubeDurationString(text, hl) + } + supplyDuration(duration, duration != null) + + supplyTitle(title.text, true) + supplyThumbnailProvider(thumbnail.toThumbnailProvider(), true) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicResponsiveListItemRenderer.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicResponsiveListItemRenderer.kt new file mode 100644 index 000000000..af18ba847 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicResponsiveListItemRenderer.kt @@ -0,0 +1,145 @@ +package com.toasterofbread.spmp.api.model + +import com.toasterofbread.spmp.api.FixedColumn +import com.toasterofbread.spmp.api.FlexColumn +import com.toasterofbread.spmp.api.NavigationEndpoint +import com.toasterofbread.spmp.api.ThumbnailRenderer +import com.toasterofbread.spmp.api.radio.YoutubeiNextResponse +import com.toasterofbread.spmp.model.mediaitem.AccountPlaylist +import com.toasterofbread.spmp.model.mediaitem.Artist +import com.toasterofbread.spmp.model.mediaitem.MediaItem +import com.toasterofbread.spmp.model.mediaitem.Song +import com.toasterofbread.spmp.model.mediaitem.data.MediaItemData +import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType +import com.toasterofbread.spmp.model.mediaitem.enums.PlaylistType +import com.toasterofbread.spmp.model.mediaitem.enums.SongType +import com.toasterofbread.spmp.resources.uilocalisation.parseYoutubeDurationString + +class MusicResponsiveListItemRenderer( + val playlistItemData: RendererPlaylistItemData? = null, + val flexColumns: List? = null, + val fixedColumns: List? = null, + val thumbnail: ThumbnailRenderer? = null, + val navigationEndpoint: NavigationEndpoint? = null, + val menu: YoutubeiNextResponse.Menu? = null +) { + fun toMediaItemAndPlaylistSetVideoId(hl: String): Pair? { + var video_id: String? = playlistItemData?.videoId ?: navigationEndpoint?.watchEndpoint?.videoId + var video_is_main: Boolean = true + + var title: String? = null + var artist: Artist? = null + var playlist: AccountPlaylist? = null + var duration: Long? = null + + if (video_id == null) { + val page_type = navigationEndpoint?.browseEndpoint?.getPageType() + when (page_type) { + "MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_PLAYLIST" -> { + video_is_main = false + playlist = AccountPlaylist.fromId(navigationEndpoint!!.browseEndpoint!!.browseId) + .editPlaylistData { + supplyPlaylistType(PlaylistType.fromTypeString(page_type), true) + } + } + "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL" -> { + video_is_main = false + artist = Artist.fromId(navigationEndpoint!!.browseEndpoint!!.browseId) + } + } + } + + if (flexColumns != null) { + for (column in flexColumns.withIndex()) { + val text = column.value.musicResponsiveListItemFlexColumnRenderer.text + if (text.runs == null) { + continue + } + + if (column.index == 0) { + title = text.first_text + } + + for (run in text.runs!!) { + if (run.navigationEndpoint == null) { + continue + } + + if (run.navigationEndpoint.watchEndpoint != null) { + if (video_id == null) { + video_id = run.navigationEndpoint.watchEndpoint.videoId!! + } + continue + } + + val browse_endpoint = run.navigationEndpoint.browseEndpoint + when (browse_endpoint?.getPageType()) { + "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL" -> { + if (artist == null) { + artist = Artist.fromId(browse_endpoint.browseId).editArtistData { supplyTitle(run.text) } + } + } + } + } + } + } + + if (fixedColumns != null) { + for (column in fixedColumns) { + val text = column.musicResponsiveListItemFixedColumnRenderer.text.first_text + val parsed = parseYoutubeDurationString(text, hl) + if (parsed != null) { + duration = parsed + break + } + } + } + + val item_data: MediaItemData + if (video_id != null) { + item_data = Song.fromId(video_id).editSongDataManual { + supplyDuration(duration, true) + thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.firstOrNull()?.also { + supplySongType(if (it.height == it.width) SongType.SONG else SongType.VIDEO) + } + } + } + else if (video_is_main) { + return null + } + else { + item_data = (playlist?.data?.apply { supplyTotalDuration(duration, true) }) ?: artist?.data ?: return null + } + + // Handle songs with no artist (or 'Various artists') + if (artist == null) { + if (flexColumns != null && flexColumns.size > 1) { + val text = flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text + if (text.runs != null) { + artist = Artist.createForItem(item_data.data_item).editArtistData { supplyTitle(text.first_text) } + } + } + + if (artist == null && menu != null) { + for (item in menu.menuRenderer.items) { + val browse_endpoint = (item.menuNavigationItemRenderer ?: continue).navigationEndpoint.browseEndpoint ?: continue + if (browse_endpoint.getMediaItemType() == MediaItemType.ARTIST) { + artist = Artist.fromId(browse_endpoint.browseId) + break + } + } + } + } + + with(item_data) { + supplyTitle(title) + supplyArtist(artist) + supplyThumbnailProvider(thumbnail?.toThumbnailProvider()) + save() + } + + return Pair(item_data.data_item, playlistItemData?.playlistSetVideoId) + } +} + +class RendererPlaylistItemData(val videoId: String, val playlistSetVideoId: String? = null) diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicTwoRowItemRenderer.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicTwoRowItemRenderer.kt new file mode 100644 index 000000000..a373c4c2c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/MusicTwoRowItemRenderer.kt @@ -0,0 +1,106 @@ +package com.toasterofbread.spmp.api.model + +import com.toasterofbread.spmp.api.NavigationEndpoint +import com.toasterofbread.spmp.api.TextRuns +import com.toasterofbread.spmp.api.ThumbnailRenderer +import com.toasterofbread.spmp.api.radio.YoutubeiNextResponse +import com.toasterofbread.spmp.model.Settings +import com.toasterofbread.spmp.model.mediaitem.AccountPlaylist +import com.toasterofbread.spmp.model.mediaitem.Artist +import com.toasterofbread.spmp.model.mediaitem.MediaItem +import com.toasterofbread.spmp.model.mediaitem.Song +import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType +import com.toasterofbread.spmp.model.mediaitem.enums.PlaylistType +import com.toasterofbread.spmp.model.mediaitem.enums.SongType + +class MusicTwoRowItemRenderer( + val navigationEndpoint: NavigationEndpoint, + val title: TextRuns, + val subtitle: TextRuns? = null, + val thumbnailRenderer: ThumbnailRenderer, + val menu: YoutubeiNextResponse.Menu? = null +) { + fun getArtist(host_item: MediaItem): Artist? { + for (run in subtitle?.runs ?: emptyList()) { + val browse_endpoint = run.navigationEndpoint?.browseEndpoint + + val endpoint_type = browse_endpoint?.getMediaItemType() + if (endpoint_type == MediaItemType.ARTIST) { + return Artist.fromId(browse_endpoint.browseId).editArtistData { supplyTitle(run.text) } + } + } + + if (host_item is Song) { + val index = if (host_item.song_type == SongType.VIDEO) 0 else 1 + subtitle?.runs?.getOrNull(index)?.also { + return Artist.createForItem(host_item).editArtistData { supplyTitle(it.text) } + } + } + + return null + } + + fun toMediaItem(hl: String): MediaItem? { + // Video + if (navigationEndpoint.watchEndpoint?.videoId != null) { + val first_thumbnail = thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails.first() + return Song.fromId(navigationEndpoint.watchEndpoint.videoId).editSongData { + // Is this the best way of checking? + supplySongType(if (first_thumbnail.height == first_thumbnail.width) SongType.SONG else SongType.VIDEO) + supplyTitle(title.first_text) + supplyArtist(getArtist(data_item)) + supplyThumbnailProvider(thumbnailRenderer.toThumbnailProvider()) + } + } + + val item: MediaItem + + if (navigationEndpoint.watchPlaylistEndpoint != null) { + if (!Settings.get(Settings.KEY_FEED_SHOW_RADIOS)) { + return null + } + + item = AccountPlaylist.fromId(navigationEndpoint.watchPlaylistEndpoint.playlistId) + .editPlaylistData { + supplyPlaylistType(PlaylistType.RADIO, true) + supplyTitle(title.first_text) + supplyThumbnailProvider(thumbnailRenderer.toThumbnailProvider()) + } + } + else { + // Playlist or artist + val browse_id = navigationEndpoint.browseEndpoint!!.browseId + val page_type = navigationEndpoint.browseEndpoint.getPageType()!! + + item = when (page_type) { + "MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_PLAYLIST", "MUSIC_PAGE_TYPE_AUDIOBOOK", "MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE" -> { + if (AccountPlaylist.formatId(browse_id).startsWith("RDAT") && !Settings.get(Settings.KEY_FEED_SHOW_RADIOS)) { + return null + } + + AccountPlaylist.fromId(browse_id) + .editPlaylistData { + supplyPlaylistType( + PlaylistType.fromTypeString(page_type), + true + ) + supplyArtist(getArtist(data_item)) + } + .apply { + is_editable = menu?.menuRenderer?.items + ?.any { it.menuNavigationItemRenderer?.icon?.iconType == "DELETE" } == true + } + } + "MUSIC_PAGE_TYPE_ARTIST" -> Artist.fromId(browse_id) + else -> throw NotImplementedError("$page_type ($browse_id)") + } + + item.editData { + supplyTitle(title.first_text) + supplyThumbnailProvider(thumbnailRenderer.toThumbnailProvider()) + } + } + + return item + } +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/YoutubeiShelf.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/YoutubeiShelf.kt index 7a4e6fb13..3eb1ac918 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/YoutubeiShelf.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/YoutubeiShelf.kt @@ -6,8 +6,11 @@ import com.toasterofbread.spmp.api.MusicCardShelfRenderer import com.toasterofbread.spmp.api.MusicCarouselShelfRenderer import com.toasterofbread.spmp.api.MusicDescriptionShelfRenderer import com.toasterofbread.spmp.api.MusicShelfRenderer +import com.toasterofbread.spmp.api.MusicThumbnailRenderer import com.toasterofbread.spmp.api.NavigationEndpoint import com.toasterofbread.spmp.api.TextRun +import com.toasterofbread.spmp.api.TextRuns +import com.toasterofbread.spmp.api.Thumbnails import com.toasterofbread.spmp.model.mediaitem.MediaItem data class YoutubeiShelf( @@ -18,7 +21,9 @@ data class YoutubeiShelf( val musicCardShelfRenderer: MusicCardShelfRenderer? = null, val gridRenderer: GridRenderer? = null, val itemSectionRenderer: ItemSectionRenderer? = null, - val musicTastebuilderShelfRenderer: Any? = null + val musicTastebuilderShelfRenderer: Any? = null, + val musicMultiRowListItemRenderer: MusicMultiRowListItemRenderer? = null, + val musicResponsiveHeaderRenderer: MusicResponsiveHeaderRenderer? = null ) { init { assert( @@ -30,6 +35,8 @@ data class YoutubeiShelf( || gridRenderer != null || itemSectionRenderer != null || musicTastebuilderShelfRenderer != null + || musicMultiRowListItemRenderer != null + || musicResponsiveHeaderRenderer != null ) { "No known shelf renderer" } } @@ -73,3 +80,13 @@ data class YoutubeiShelf( itemSectionRenderer ?: musicTastebuilderShelfRenderer } + +class MusicResponsiveHeaderRenderer( + val thumbnail: Thumbnails, + val title: TextRuns, + val straplineThumbnail: Thumbnails, + val straplineTextOne: TextRuns, + val description: Description? = null +) { + class Description(val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?) +} diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/YoutubeiShelfContentsItem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/YoutubeiShelfContentsItem.kt index f7cc3a7ef..ea8a72e52 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/YoutubeiShelfContentsItem.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/api/model/YoutubeiShelfContentsItem.kt @@ -1,208 +1,22 @@ package com.toasterofbread.spmp.api.model -import com.toasterofbread.spmp.api.MusicResponsiveListItemRenderer -import com.toasterofbread.spmp.api.MusicTwoRowItemRenderer -import com.toasterofbread.spmp.model.Settings -import com.toasterofbread.spmp.model.mediaitem.AccountPlaylist -import com.toasterofbread.spmp.model.mediaitem.Artist import com.toasterofbread.spmp.model.mediaitem.MediaItem -import com.toasterofbread.spmp.model.mediaitem.Song -import com.toasterofbread.spmp.model.mediaitem.data.MediaItemData -import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType -import com.toasterofbread.spmp.model.mediaitem.enums.PlaylistType -import com.toasterofbread.spmp.model.mediaitem.enums.SongType -import com.toasterofbread.spmp.resources.uilocalisation.parseYoutubeDurationString -data class YoutubeiShelfContentsItem(val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? = null, val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer? = null) { +data class YoutubeiShelfContentsItem( + val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? = null, + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer? = null, + val musicMultiRowListItemRenderer: MusicMultiRowListItemRenderer? = null +) { // Pair(item, playlistSetVideoId) fun toMediaItem(hl: String): Pair? { if (musicTwoRowItemRenderer != null) { - val renderer = musicTwoRowItemRenderer - - // Video - if (renderer.navigationEndpoint.watchEndpoint?.videoId != null) { - val first_thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails.first() - return Pair( - Song.fromId(renderer.navigationEndpoint.watchEndpoint.videoId).editSongData { - // Is this the best way of checking? - supplySongType(if (first_thumbnail.height == first_thumbnail.width) SongType.SONG else SongType.VIDEO) - supplyTitle(renderer.title.first_text) - supplyArtist(renderer.getArtist(data_item)) - supplyThumbnailProvider(renderer.thumbnailRenderer.toThumbnailProvider()) - }, - null - ) - } - - val item: MediaItem - - if (renderer.navigationEndpoint.watchPlaylistEndpoint != null) { - if (!Settings.get(Settings.KEY_FEED_SHOW_RADIOS)) { - return null - } - - item = AccountPlaylist.fromId(renderer.navigationEndpoint.watchPlaylistEndpoint.playlistId) - .editPlaylistData { - supplyPlaylistType(PlaylistType.RADIO, true) - supplyTitle(renderer.title.first_text) - supplyThumbnailProvider(renderer.thumbnailRenderer.toThumbnailProvider()) - } - } - else { - // Playlist or artist - val browse_id = renderer.navigationEndpoint.browseEndpoint!!.browseId - val page_type = renderer.navigationEndpoint.browseEndpoint.getPageType()!! - - item = when (page_type) { - "MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_PLAYLIST", "MUSIC_PAGE_TYPE_AUDIOBOOK" -> { - if (AccountPlaylist.formatId(browse_id).startsWith("RDAT") && !Settings.get(Settings.KEY_FEED_SHOW_RADIOS)) { - return null - } - - AccountPlaylist.fromId(browse_id) - .editPlaylistData { - supplyPlaylistType(when (page_type) { - "MUSIC_PAGE_TYPE_ALBUM" -> PlaylistType.ALBUM - "MUSIC_PAGE_TYPE_PLAYLIST" -> PlaylistType.PLAYLIST - else -> PlaylistType.AUDIOBOOK - }, true) - supplyArtist(renderer.getArtist(data_item)) - } - .apply { - is_editable = renderer.menu?.menuRenderer?.items - ?.any { it.menuNavigationItemRenderer?.icon?.iconType == "DELETE" } == true - } - } - "MUSIC_PAGE_TYPE_ARTIST" -> Artist.fromId(browse_id) - else -> throw NotImplementedError("$page_type ($browse_id)") - } - - item.editData { - supplyTitle(renderer.title.first_text) - supplyThumbnailProvider(renderer.thumbnailRenderer.toThumbnailProvider()) - } - } - - return Pair(item, null) + return musicTwoRowItemRenderer.toMediaItem(hl)?.let { Pair(it, null) } } else if (musicResponsiveListItemRenderer != null) { - val renderer = musicResponsiveListItemRenderer - - var video_id: String? = renderer.playlistItemData?.videoId ?: renderer.navigationEndpoint?.watchEndpoint?.videoId - var video_is_main: Boolean = true - - var title: String? = null - var artist: Artist? = null - var playlist: AccountPlaylist? = null - var duration: Long? = null - - if (video_id == null) { - val page_type = renderer.navigationEndpoint?.browseEndpoint?.getPageType() - when (page_type) { - "MUSIC_PAGE_TYPE_ALBUM", "MUSIC_PAGE_TYPE_PLAYLIST" -> { - video_is_main = false - playlist = AccountPlaylist.fromId(renderer.navigationEndpoint.browseEndpoint.browseId) - .editPlaylistData { - supplyPlaylistType(PlaylistType.fromTypeString(page_type), true) - } - } - "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL" -> { - video_is_main = false - artist = Artist.fromId(renderer.navigationEndpoint.browseEndpoint.browseId) - } - } - } - - if (renderer.flexColumns != null) { - for (column in renderer.flexColumns.withIndex()) { - val text = column.value.musicResponsiveListItemFlexColumnRenderer.text - if (text.runs == null) { - continue - } - - if (column.index == 0) { - title = text.first_text - } - - for (run in text.runs!!) { - if (run.navigationEndpoint == null) { - continue - } - - if (run.navigationEndpoint.watchEndpoint != null) { - if (video_id == null) { - video_id = run.navigationEndpoint.watchEndpoint.videoId!! - } - continue - } - - val browse_endpoint = run.navigationEndpoint.browseEndpoint - when (browse_endpoint?.getPageType()) { - "MUSIC_PAGE_TYPE_ARTIST", "MUSIC_PAGE_TYPE_USER_CHANNEL" -> { - if (artist == null) { - artist = Artist.fromId(browse_endpoint.browseId).editArtistData { supplyTitle(run.text) } - } - } - } - } - } - } - - 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 item_data: MediaItemData - if (video_id != null) { - item_data = Song.fromId(video_id).editSongDataManual { - supplyDuration(duration, true) - renderer.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.firstOrNull()?.also { - supplySongType(if (it.height == it.width) SongType.SONG else SongType.VIDEO) - } - } - } - else if (video_is_main) { - return null - } - else { - item_data = (playlist?.data?.apply { supplyTotalDuration(duration, true) }) ?: artist?.data ?: return null - } - - // Handle songs with no artist (or 'Various artists') - if (artist == null) { - if (renderer.flexColumns != null && renderer.flexColumns.size > 1) { - val text = renderer.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text - if (text.runs != null) { - artist = Artist.createForItem(item_data.data_item).editArtistData { supplyTitle(text.first_text) } - } - } - - if (artist == null && renderer.menu != null) { - for (item in renderer.menu.menuRenderer.items) { - val browse_endpoint = (item.menuNavigationItemRenderer ?: continue).navigationEndpoint.browseEndpoint ?: continue - if (browse_endpoint.getMediaItemType() == MediaItemType.ARTIST) { - artist = Artist.fromId(browse_endpoint.browseId) - break - } - } - } - } - - with(item_data) { - supplyTitle(title) - supplyArtist(artist) - supplyThumbnailProvider(renderer.thumbnail?.toThumbnailProvider()) - save() - } - - return Pair(item_data.data_item, renderer.playlistItemData?.playlistSetVideoId) + return musicResponsiveListItemRenderer.toMediaItemAndPlaylistSetVideoId(hl) + } + else if (musicMultiRowListItemRenderer != null) { + return Pair(musicMultiRowListItemRenderer.toMediaItem(hl), null) } throw NotImplementedError() diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/AccountPlaylist.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/AccountPlaylist.kt index b17b7c2bd..acc1ff9e0 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/AccountPlaylist.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/AccountPlaylist.kt @@ -67,6 +67,12 @@ class AccountPlaylist private constructor(id: String, context: PlatformContext): return this } + fun editPlaylistDataManual(action: AccountPlaylistItemData.() -> Unit): AccountPlaylistItemData { + checkNotDeleted() + action(data) + return data + } + private val pending_edit_actions: MutableList = mutableListOf() companion object { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItem.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItem.kt index 4a628674d..5f829cda1 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItem.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/MediaItem.kt @@ -21,6 +21,8 @@ import com.toasterofbread.spmp.model.Settings import com.toasterofbread.spmp.model.mediaitem.data.MediaItemData import com.toasterofbread.spmp.model.mediaitem.enums.MediaItemType import com.toasterofbread.spmp.model.mediaitem.enums.PlaylistType +import com.toasterofbread.spmp.model.mediaitem.enums.SongType +import com.toasterofbread.spmp.model.mediaitem.enums.getReadable import com.toasterofbread.spmp.platform.PlatformContext import com.toasterofbread.spmp.platform.ProjectPreferences import com.toasterofbread.spmp.platform.toImageBitmap @@ -30,7 +32,6 @@ import kotlinx.coroutines.* import java.io.File import java.net.URL import java.time.Duration -import java.util.* import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -48,6 +49,17 @@ abstract class MediaItem(val id: String, context: PlatformContext): MediaItemHol else -> throw NotImplementedError(this.javaClass.name) } + fun getReadableType(plural: Boolean): String = + when(this) { + is Song -> + if (song_type == SongType.PODCAST) + PlaylistType.PODCAST.getReadable(plural) + else if (album?.playlist_type == PlaylistType.PODCAST || album?.playlist_type == PlaylistType.AUDIOBOOK) + album?.playlist_type.getReadable(plural) + else MediaItemType.SONG.getReadable(plural) + else -> type.getReadable(plural) + } + abstract val data: MediaItemData val registry_entry: MediaItemDataRegistry.Entry diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/PlaylistType.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/PlaylistType.kt index 52332dd0f..5a9c42542 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/PlaylistType.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/PlaylistType.kt @@ -3,7 +3,7 @@ package com.toasterofbread.spmp.model.mediaitem.enums import com.toasterofbread.spmp.resources.getString enum class PlaylistType { - PLAYLIST, ALBUM, AUDIOBOOK, RADIO; + PLAYLIST, ALBUM, AUDIOBOOK, PODCAST, RADIO; companion object { fun fromTypeString(type: String): PlaylistType { @@ -11,7 +11,9 @@ enum class PlaylistType { "MUSIC_PAGE_TYPE_PLAYLIST" -> PLAYLIST "MUSIC_PAGE_TYPE_ALBUM" -> ALBUM "MUSIC_PAGE_TYPE_AUDIOBOOK" -> AUDIOBOOK - else -> throw NotImplementedError(type) + "MUSIC_PAGE_TYPE_PODCAST" -> PODCAST + "MUSIC_PAGE_TYPE_RADIO" -> RADIO + else -> PLAYLIST } } } @@ -22,6 +24,7 @@ fun PlaylistType?.getReadable(plural: Boolean): String { PlaylistType.PLAYLIST, null -> if (plural) "playlists" else "playlist" PlaylistType.ALBUM -> if (plural) "albums" else "album" PlaylistType.AUDIOBOOK -> if (plural) "audiobooks" else "audiobook" + PlaylistType.PODCAST -> if (plural) "podcasts" else "podcast" PlaylistType.RADIO -> if (plural) "radios" else "radio" }) } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/SongType.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/SongType.kt index ae86d64b9..4cce2eaf9 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/SongType.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/model/mediaitem/enums/SongType.kt @@ -2,5 +2,6 @@ package com.toasterofbread.spmp.model.mediaitem.enums enum class SongType { SONG, - VIDEO + VIDEO, + PODCAST } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/MediaItemLayout.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/MediaItemLayout.kt index 29c4ecd4e..56982f94b 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/MediaItemLayout.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/MediaItemLayout.kt @@ -185,7 +185,7 @@ data class MediaItemLayout( } val shelf = - if (initial) parsed.contents!!.singleColumnBrowseResultsRenderer.tabs.first().tabRenderer.content!!.sectionListRenderer.contents!!.single().musicPlaylistShelfRenderer!! + if (initial) parsed.contents!!.singleColumnBrowseResultsRenderer!!.tabs.first().tabRenderer.content!!.sectionListRenderer.contents!!.single().musicPlaylistShelfRenderer!! else parsed.continuationContents!!.musicPlaylistShelfContinuation!! return@withContext Result.success(Pair( @@ -410,6 +410,7 @@ fun MediaItemCard( PlaylistType.PLAYLIST, null -> Icons.Filled.PlaylistPlay PlaylistType.ALBUM -> Icons.Filled.Album PlaylistType.AUDIOBOOK -> Icons.Filled.Book + PlaylistType.PODCAST -> Icons.Filled.Podcasts PlaylistType.RADIO -> Icons.Filled.Radio } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitempreview/MediaItemPreview.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitempreview/MediaItemPreview.kt index f75a34f8f..03b6816f5 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitempreview/MediaItemPreview.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/component/mediaitempreview/MediaItemPreview.kt @@ -109,7 +109,7 @@ fun MediaItemPreviewLong( Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { if (params.show_type) { - InfoText(item.type.getReadable(false), params) + InfoText(item.getReadableType(false), params) } if (item !is Artist && item.artist?.title != null) { diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/artistpage/ArtistPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/artistpage/ArtistPage.kt index 18d05cf5d..b1ca33bbb 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/artistpage/ArtistPage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/artistpage/ArtistPage.kt @@ -325,8 +325,6 @@ fun ArtistPage( if (description?.isNotBlank() == true) { DescriptionCard(description, { Theme.current.background }, { accent_colour }) { show_info = !show_info } } - - Spacer(Modifier.requiredHeight(50.dp)) } } } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/PlayerStateImpl.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/PlayerStateImpl.kt index faa411530..f04fe2e84 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/PlayerStateImpl.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/mainpage/PlayerStateImpl.kt @@ -628,7 +628,7 @@ class PlayerStateImpl(private val context: PlatformContext): PlayerState(null, n page.first.getPage( pill_menu, page.second, - (if (session_started) MINIMISED_NOW_PLAYING_HEIGHT.dp * 2 else MINIMISED_NOW_PLAYING_HEIGHT.dp) + 10.dp, + (if (session_started) MINIMISED_NOW_PLAYING_HEIGHT.dp else 0.dp) + 10.dp, close ) } diff --git a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistPage.kt b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistPage.kt index 6023360e3..70ea38551 100644 --- a/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistPage.kt +++ b/shared/src/commonMain/kotlin/com/toasterofbread/spmp/ui/layout/playlistpage/PlaylistPage.kt @@ -154,7 +154,7 @@ fun PlaylistPage( multiselect_context, Modifier.fillMaxWidth(), show_wave_border = false, - padding = PaddingValues( + padding = padding.copy( top = if (previous_item != null) 0.dp else top_padding, start = horizontal_padding, end = horizontal_padding 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 4c58a58ee..cd7ae944e 100644 --- a/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml +++ b/shared/src/commonMain/resources/assets/values-ja-JP/strings.xml @@ -51,6 +51,8 @@ アーティスト オーディオブック オーディオブック + ポッドキャスト + ポッドキャスト ラジオ ラジオ diff --git a/shared/src/commonMain/resources/assets/values/strings.xml b/shared/src/commonMain/resources/assets/values/strings.xml index 1bfa8bde2..17dee305a 100644 --- a/shared/src/commonMain/resources/assets/values/strings.xml +++ b/shared/src/commonMain/resources/assets/values/strings.xml @@ -60,6 +60,8 @@ Album Audiobook Audiobooks + Podcast + Podcasts Radio Radios