feat: changed chips in channels to swipable tabs

This commit is contained in:
Amandeep singh 2024-05-13 18:35:03 +05:30 committed by Bnyro
parent 9742b8cda5
commit f41e3b24bb
7 changed files with 295 additions and 225 deletions

View File

@ -43,4 +43,7 @@ object IntentData {
const val audioLanguage = "audioLanguage" const val audioLanguage = "audioLanguage"
const val captionLanguage = "captionLanguage" const val captionLanguage = "captionLanguage"
const val wasIntentStopped = "wasIntentStopped" const val wasIntentStopped = "wasIntentStopped"
const val tabData = "tabData"
const val videoList = "videoList"
const val nextPage = "nextPage"
} }

View File

@ -1,14 +0,0 @@
package com.github.libretube.obj
import androidx.annotation.IdRes
import com.github.libretube.R
sealed class ChannelTabs(
val identifierName: String,
@IdRes val chipId: Int
) {
object Playlists : ChannelTabs("playlists", R.id.playlists)
object Shorts : ChannelTabs("shorts", R.id.shorts)
object Livestreams : ChannelTabs("livestreams", R.id.livestreams)
object Albums : ChannelTabs("albums", R.id.albums)
}

View File

@ -0,0 +1,174 @@
package com.github.libretube.ui.fragments
import android.content.res.Configuration
import android.os.Bundle
import android.os.Parcelable
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.ChannelTab
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.FragmentChannelContentBinding
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.ceilHalf
import com.github.libretube.ui.adapters.SearchChannelAdapter
import com.github.libretube.ui.adapters.VideosAdapter
import com.github.libretube.ui.base.DynamicLayoutManagerFragment
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
class ChannelContentFragment : DynamicLayoutManagerFragment() {
private var _binding: FragmentChannelContentBinding? = null
private val binding get() = _binding!!
private var channelId: String? = null
private var searchChannelAdapter: SearchChannelAdapter? = null
private var channelAdapter: VideosAdapter? = null
private var recyclerViewState: Parcelable? = null
private var nextPage: String? = null
private var isLoading: Boolean = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentChannelContentBinding.inflate(inflater, container, false)
return _binding!!.root
}
override fun setLayoutManagers(gridItems: Int) {
binding.channelRecView.layoutManager = GridLayoutManager(
requireContext(),
gridItems.ceilHalf()
)
}
private suspend fun fetchChannelNextPage(nextPage: String): String? {
val response = withContext(Dispatchers.IO) {
RetrofitInstance.api.getChannelNextPage(channelId!!, nextPage).apply {
relatedStreams = relatedStreams.deArrow()
}
}
channelAdapter?.insertItems(response.relatedStreams)
return response.nextpage
}
private suspend fun fetchTabNextPage(nextPage: String, tab: ChannelTab): String? {
val newContent = withContext(Dispatchers.IO) {
RetrofitInstance.api.getChannelTab(tab.data, nextPage)
}.apply {
content = content.deArrow()
}
searchChannelAdapter?.let {
it.submitList(it.currentList + newContent.content)
}
return newContent.nextpage
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// manually restore the recyclerview state due to https://github.com/material-components/material-components-android/issues/3473
binding.channelRecView.layoutManager?.onRestoreInstanceState(recyclerViewState)
}
private fun loadChannelTab(tab: ChannelTab) = lifecycleScope.launch {
val response = try {
withContext(Dispatchers.IO) {
RetrofitInstance.api.getChannelTab(tab.data)
}.apply {
content = content.deArrow()
}
} catch (e: Exception) {
binding.progressBar.isGone = true
return@launch
}
nextPage = response.nextpage
val binding = _binding ?: return@launch
searchChannelAdapter = SearchChannelAdapter()
binding.channelRecView.adapter = searchChannelAdapter
searchChannelAdapter?.submitList(response.content)
binding.progressBar.isGone = true
isLoading = false
}
private fun loadNextPage(isVideo: Boolean, tab: ChannelTab) = lifecycleScope.launch {
try {
isLoading = true
nextPage = if (isVideo) {
fetchChannelNextPage(nextPage ?: return@launch)
} else {
fetchTabNextPage(nextPage ?: return@launch, tab)
}
} catch (e: Exception) {
Log.e(TAG(), e.toString())
}
isLoading = false
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val tabData = kotlin.runCatching {
Json.decodeFromString<ChannelTab>(arguments?.getString(IntentData.tabData) ?: "")
}.getOrNull()
channelId = arguments?.getString(IntentData.channelId)
nextPage = arguments?.getString(IntentData.nextPage)
binding.channelRecView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
recyclerViewState = binding.channelRecView.layoutManager?.onSaveInstanceState()
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val visibleItemCount = recyclerView.layoutManager!!.childCount
val totalItemCount = recyclerView.layoutManager!!.getItemCount()
val firstVisibleItemPosition =
(recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
if (_binding == null || isLoading) return
if (firstVisibleItemPosition + visibleItemCount >= totalItemCount) {
loadNextPage(tabData?.data!!.isEmpty(), tabData)
isLoading = false
}
}
})
if (tabData?.data.isNullOrEmpty()) {
val videoDataString = arguments?.getString(IntentData.videoList)
val videos = runCatching {
Json.decodeFromString<List<StreamItem>>(videoDataString!!)
}.getOrElse { mutableListOf() }
channelAdapter = VideosAdapter(
videos.toMutableList(),
forceMode = VideosAdapter.Companion.LayoutMode.CHANNEL_ROW
)
binding.channelRecView.adapter = channelAdapter
binding.progressBar.isGone = true
} else {
loadChannelTab(tabData ?: return)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -1,45 +1,44 @@
package com.github.libretube.ui.fragments package com.github.libretube.ui.fragments
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.children
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.ChannelTab import com.github.libretube.api.obj.ChannelTab
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.FragmentChannelBinding import com.github.libretube.databinding.FragmentChannelBinding
import com.github.libretube.enums.ShareObjectType import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.TAG import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.ceilHalf
import com.github.libretube.extensions.formatShort import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.helpers.ImageHelper import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.NavigationHelper import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.obj.ChannelTabs
import com.github.libretube.obj.ShareData import com.github.libretube.obj.ShareData
import com.github.libretube.ui.adapters.SearchChannelAdapter
import com.github.libretube.ui.adapters.VideosAdapter import com.github.libretube.ui.adapters.VideosAdapter
import com.github.libretube.ui.base.DynamicLayoutManagerFragment import com.github.libretube.ui.base.DynamicLayoutManagerFragment
import com.github.libretube.ui.dialogs.ShareDialog import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.extensions.addOnBottomReachedListener
import com.github.libretube.ui.extensions.setupSubscriptionButton import com.github.libretube.ui.extensions.setupSubscriptionButton
import com.github.libretube.ui.sheets.AddChannelToGroupSheet import com.github.libretube.ui.sheets.AddChannelToGroupSheet
import com.github.libretube.util.deArrow import com.github.libretube.util.deArrow
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import retrofit2.HttpException import retrofit2.HttpException
import java.io.IOException import java.io.IOException
@ -53,18 +52,21 @@ class ChannelFragment : DynamicLayoutManagerFragment() {
private var channelAdapter: VideosAdapter? = null private var channelAdapter: VideosAdapter? = null
private var isLoading = true private var isLoading = true
private val possibleTabs = arrayOf( private lateinit var channelContentAdapter: ChannelContentAdapter
ChannelTabs.Shorts,
ChannelTabs.Livestreams,
ChannelTabs.Playlists,
ChannelTabs.Albums
)
private var channelTabs: List<ChannelTab> = emptyList()
private var nextPages = Array<String?>(5) { null }
private var searchChannelAdapter: SearchChannelAdapter? = null
private var nextPages = Array<String?>(5) { null }
private var isAppBarFullyExpanded: Boolean = true private var isAppBarFullyExpanded: Boolean = true
private var recyclerViewState: Parcelable? = null private val tabList = mutableListOf(
ChannelTab(VIDEOS_TAB_KEY, "")
)
private val tabNamesMap = mapOf(
VIDEOS_TAB_KEY to R.string.videos,
"shorts" to R.string.yt_shorts,
"livestreams" to R.string.livestreams,
"playlists" to R.string.playlists,
"albums" to R.string.albums
)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -83,21 +85,17 @@ class ChannelFragment : DynamicLayoutManagerFragment() {
return binding.root return binding.root
} }
override fun setLayoutManagers(gridItems: Int) { override fun setLayoutManagers(gridItems: Int) {}
_binding?.channelRecView?.layoutManager = GridLayoutManager(
context,
gridItems.ceilHalf()
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
// Check if the AppBarLayout is fully expanded // Check if the AppBarLayout is fully expanded
binding.channelAppBar.addOnOffsetChangedListener { _, verticalOffset -> binding.channelAppBar.addOnOffsetChangedListener { _, verticalOffset ->
isAppBarFullyExpanded = verticalOffset == 0 isAppBarFullyExpanded = verticalOffset == 0
} }
binding.pager.reduceDragSensitivity()
// Determine if the child can scroll up // Determine if the child can scroll up
binding.channelRefresh.setOnChildScrollUpCallback { _, _ -> binding.channelRefresh.setOnChildScrollUpCallback { _, _ ->
!isAppBarFullyExpanded !isAppBarFullyExpanded
@ -107,58 +105,26 @@ class ChannelFragment : DynamicLayoutManagerFragment() {
fetchChannel() fetchChannel()
} }
binding.channelRecView.addOnBottomReachedListener {
if (_binding == null || isLoading) return@addOnBottomReachedListener
loadNextPage()
}
binding.channelRecView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
recyclerViewState = binding.channelRecView.layoutManager?.onSaveInstanceState()
}
})
fetchChannel() fetchChannel()
} }
// adjust sensitivity due to the issue of viewpager2 with SwipeToRefresh https://issuetracker.google.com/issues/138314213
private fun ViewPager2.reduceDragSensitivity() {
val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView")
recyclerViewField.isAccessible = true
val recyclerView = recyclerViewField.get(this) as RecyclerView
val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop")
touchSlopField.isAccessible = true
val touchSlop = touchSlopField.get(recyclerView) as Int
touchSlopField.set(recyclerView, touchSlop * 3)
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
private fun loadNextPage() = lifecycleScope.launch {
val binding = _binding ?: return@launch
binding.channelRefresh.isRefreshing = true
isLoading = true
try {
if (binding.tabChips.checkedChipId == binding.videos.id) {
fetchChannelNextPage(nextPages[0] ?: return@launch).let {
nextPages[0] = it
}
} else {
val currentTabIndex = binding.tabChips.children.indexOfFirst {
it.id == binding.tabChips.checkedChipId
}
val channelTab = channelTabs.first { tab ->
tab.name == possibleTabs[currentTabIndex - 1].identifierName
}
val nextPage = nextPages[currentTabIndex] ?: return@launch
fetchTabNextPage(nextPage, channelTab).let {
nextPages[currentTabIndex] = it
}
}
} catch (e: Exception) {
Log.e("error fetching tabs", e.toString())
}
}.invokeOnCompletion {
_binding?.channelRefresh?.isRefreshing = false
isLoading = false
}
private fun fetchChannel() = lifecycleScope.launch { private fun fetchChannel() = lifecycleScope.launch {
isLoading = true isLoading = true
binding.channelRefresh.isRefreshing = true binding.channelRefresh.isRefreshing = true
@ -255,105 +221,55 @@ class ChannelFragment : DynamicLayoutManagerFragment() {
) )
} }
// recyclerview of the videos by the channel channelContentAdapter = ChannelContentAdapter(
tabList,
response.relatedStreams,
response.nextpage,
channelId,
this@ChannelFragment
)
binding.pager.adapter = channelContentAdapter
TabLayoutMediator(binding.tabParent, binding.pager) { tab, position ->
tab.text = tabList[position].name
}.attach()
channelAdapter = VideosAdapter( channelAdapter = VideosAdapter(
response.relatedStreams.toMutableList(), response.relatedStreams.toMutableList(),
forceMode = VideosAdapter.Companion.LayoutMode.CHANNEL_ROW forceMode = VideosAdapter.Companion.LayoutMode.CHANNEL_ROW
) )
binding.channelRecView.adapter = channelAdapter tabList.removeAll { tab ->
tab.name != VIDEOS_TAB_KEY
setupTabs(response.tabs) }
response.tabs.forEach { channelTab ->
val tabName = tabNamesMap[channelTab.name]?.let { getString(it) }
?: channelTab.name.replaceFirstChar(Char::titlecase)
tabList.add(ChannelTab(tabName, channelTab.data))
}
channelContentAdapter.notifyItemRangeChanged(0, tabList.size - 1)
} }
private fun setupTabs(tabs: List<ChannelTab>) { companion object {
this.channelTabs = tabs private const val VIDEOS_TAB_KEY = "videos"
}
val binding = _binding ?: return }
binding.tabChips.children.forEach { chip -> class ChannelContentAdapter(
val resourceTab = possibleTabs.firstOrNull { it.chipId == chip.id } private val list: List<ChannelTab>,
resourceTab?.let { resTab -> private val videos: List<StreamItem>,
if (tabs.any { it.name == resTab.identifierName }) chip.isVisible = true private val nextPage: String?,
} private val channelId: String?,
} fragment: Fragment
) : FragmentStateAdapter(fragment) {
binding.tabChips.setOnCheckedStateChangeListener { _, _ -> override fun getItemCount() = list.size
when (binding.tabChips.checkedChipId) {
binding.videos.id -> { override fun createFragment(position: Int): Fragment {
binding.channelRecView.adapter = channelAdapter val fragment = ChannelContentFragment()
} fragment.arguments = bundleOf(
IntentData.tabData to Json.encodeToString(list[position]),
else -> { IntentData.videoList to Json.encodeToString(videos),
possibleTabs.first { binding.tabChips.checkedChipId == it.chipId }.let { IntentData.channelId to channelId,
val tab = tabs.first { tab -> tab.name == it.identifierName } IntentData.nextPage to nextPage
loadChannelTab(tab) )
} return fragment
}
}
}
// Load selected chip content if it's not videos tab.
possibleTabs.firstOrNull { binding.tabChips.checkedChipId == it.chipId }?.let {
val tab = tabs.first { tab -> tab.name == it.identifierName }
loadChannelTab(tab)
}
}
private fun loadChannelTab(tab: ChannelTab) = lifecycleScope.launch {
binding.channelRefresh.isRefreshing = true
isLoading = true
val response = try {
withContext(Dispatchers.IO) {
RetrofitInstance.api.getChannelTab(tab.data)
}.apply {
content = content.deArrow()
}
} catch (e: Exception) {
return@launch
}
nextPages[channelTabs.indexOf(tab) + 1] = response.nextpage
val binding = _binding ?: return@launch
searchChannelAdapter = SearchChannelAdapter()
binding.channelRecView.adapter = searchChannelAdapter
searchChannelAdapter?.submitList(response.content)
binding.channelRefresh.isRefreshing = false
isLoading = false
}
private suspend fun fetchChannelNextPage(nextPage: String): String? {
val response = withContext(Dispatchers.IO) {
RetrofitInstance.api.getChannelNextPage(channelId!!, nextPage).apply {
relatedStreams = relatedStreams.deArrow()
}
}
channelAdapter?.insertItems(response.relatedStreams)
return response.nextpage
}
private suspend fun fetchTabNextPage(nextPage: String, tab: ChannelTab): String? {
val newContent = withContext(Dispatchers.IO) {
RetrofitInstance.api.getChannelTab(tab.data, nextPage)
}.apply {
content = content.deArrow()
}
searchChannelAdapter?.let {
it.submitList(it.currentList + newContent.content)
}
return newContent.nextpage
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// manually restore the recyclerview state due to https://github.com/material-components/material-components-android/issues/3473
binding.channelRecView.layoutManager?.onRestoreInstanceState(recyclerViewState)
} }
} }

View File

@ -119,62 +119,24 @@
android:autoLink="web" android:autoLink="web"
android:padding="10dp" /> android:padding="10dp" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:scrollbars="none">
<com.google.android.material.chip.ChipGroup
android:id="@+id/tab_chips"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:checkedChip="@+id/videos"
app:selectionRequired="true"
app:singleLine="true"
app:singleSelection="true">
<com.google.android.material.chip.Chip
android:id="@id/videos"
style="@style/channelChip"
android:text="@string/videos"
android:visibility="visible" />
<com.google.android.material.chip.Chip
android:id="@+id/shorts"
style="@style/channelChip"
android:text="@string/yt_shorts" />
<com.google.android.material.chip.Chip
android:id="@+id/livestreams"
style="@style/channelChip"
android:text="@string/livestreams" />
<com.google.android.material.chip.Chip
android:id="@+id/playlists"
style="@style/channelChip"
android:text="@string/playlists" />
<com.google.android.material.chip.Chip
android:id="@+id/albums"
style="@style/channelChip"
android:text="@string/albums" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
</LinearLayout> </LinearLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout> </com.google.android.material.appbar.CollapsingToolbarLayout>
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
/>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.viewpager2.widget.ViewPager2
android:id="@+id/channel_recView" android:id="@+id/pager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> app:layout_behavior="@string/appbar_scrolling_view_behavior"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</com.github.libretube.ui.views.CustomSwipeToRefresh> </com.github.libretube.ui.views.CustomSwipeToRefresh>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.fragments.ChannelContentFragment">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/channel_recView"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<ProgressBar
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -551,4 +551,5 @@
<string name="also_clear_watch_positions">Also clear watch positions</string> <string name="also_clear_watch_positions">Also clear watch positions</string>
<string name="import_temp_playlist">Import temporary playlist?</string> <string name="import_temp_playlist">Import temporary playlist?</string>
<string name="import_temp_playlist_summary">Do you want to create a new playlist named \'%1$s\'? The playlist will contain %2$d videos.</string> <string name="import_temp_playlist_summary">Do you want to create a new playlist named \'%1$s\'? The playlist will contain %2$d videos.</string>
</resources> </resources>