feat: support for exporting single playlists

This commit is contained in:
Bnyro 2024-12-05 22:23:26 +01:00
parent b062c6165e
commit fa493dbca5
6 changed files with 137 additions and 74 deletions

View File

@ -0,0 +1,13 @@
package com.github.libretube.enums
import androidx.annotation.StringRes
import com.github.libretube.R
enum class ImportFormat(@StringRes val value: Int, val fileExtension: String) {
NEWPIPE(R.string.import_format_newpipe, "json"),
FREETUBE(R.string.import_format_freetube, "json"),
YOUTUBECSV(R.string.import_format_youtube_csv, "csv"),
YOUTUBEJSON(R.string.youtube, "json"),
PIPED(R.string.import_format_piped, "json"),
URLSORIDS(R.string.import_format_list_of_urls, "txt")
}

View File

@ -1,13 +0,0 @@
package com.github.libretube.enums
import androidx.annotation.StringRes
import com.github.libretube.R
enum class ImportFormat(@StringRes val value: Int) {
NEWPIPE(R.string.import_format_newpipe),
FREETUBE(R.string.import_format_freetube),
YOUTUBECSV(R.string.import_format_youtube_csv),
YOUTUBEJSON(R.string.youtube),
PIPED(R.string.import_format_piped),
URLSORIDS(R.string.import_format_list_of_urls)
}

View File

@ -12,6 +12,7 @@ import android.view.View
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.widget.ScrollView import android.widget.ScrollView
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
@ -36,8 +37,10 @@ import com.github.libretube.compat.PictureInPictureCompat
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.ActivityMainBinding import com.github.libretube.databinding.ActivityMainBinding
import com.github.libretube.enums.ImportFormat
import com.github.libretube.extensions.anyChildFocused import com.github.libretube.extensions.anyChildFocused
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.helpers.ImportHelper
import com.github.libretube.helpers.IntentHelper import com.github.libretube.helpers.IntentHelper
import com.github.libretube.helpers.NavBarHelper import com.github.libretube.helpers.NavBarHelper
import com.github.libretube.helpers.NavigationHelper import com.github.libretube.helpers.NavigationHelper
@ -54,11 +57,14 @@ import com.github.libretube.ui.fragments.PlayerFragment
import com.github.libretube.ui.models.CommonPlayerViewModel import com.github.libretube.ui.models.CommonPlayerViewModel
import com.github.libretube.ui.models.SearchViewModel import com.github.libretube.ui.models.SearchViewModel
import com.github.libretube.ui.models.SubscriptionsViewModel import com.github.libretube.ui.models.SubscriptionsViewModel
import com.github.libretube.ui.preferences.BackupRestoreSettings.Companion.FILETYPE_ANY
import com.github.libretube.ui.preferences.BackupRestoreSettings.Companion.JSON
import com.github.libretube.util.UpdateChecker import com.github.libretube.util.UpdateChecker
import com.google.android.material.elevation.SurfaceColors import com.google.android.material.elevation.SurfaceColors
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import kotlin.math.exp
class MainActivity : BaseActivity() { class MainActivity : BaseActivity() {
lateinit var binding: ActivityMainBinding lateinit var binding: ActivityMainBinding
@ -75,6 +81,25 @@ class MainActivity : BaseActivity() {
private var savedSearchQuery: String? = null private var savedSearchQuery: String? = null
private var shouldOpenSuggestions = true private var shouldOpenSuggestions = true
// registering for activity results is only possible, this here should have been part of
// PlaylistOptionsBottomSheet instead if Android allowed us to
private var playlistExportFormat: ImportFormat = ImportFormat.NEWPIPE
private var exportPlaylistId: String? = null
private val createPlaylistsFile = registerForActivityResult(
ActivityResultContracts.CreateDocument(FILETYPE_ANY)
) { uri ->
if (uri == null) return@registerForActivityResult
lifecycleScope.launch(Dispatchers.IO) {
ImportHelper.exportPlaylists(
this@MainActivity,
uri,
playlistExportFormat,
selectedPlaylistIds = listOf(exportPlaylistId!!)
)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -652,4 +677,10 @@ class MainActivity : BaseActivity() {
?.let(action) ?.let(action)
?: false ?: false
} }
fun startPlaylistExport(playlistId: String, playlistName: String, format: ImportFormat) {
playlistExportFormat = format
exportPlaylistId = playlistId
createPlaylistsFile.launch("${playlistName}.${format.fileExtension}")
}
} }

View File

@ -1,5 +1,6 @@
package com.github.libretube.ui.preferences package com.github.libretube.ui.preferences
import android.content.Context
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
@ -29,26 +30,6 @@ class BackupRestoreSettings : BasePreferenceFragment() {
private val backupDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH:mm:ss") private val backupDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH:mm:ss")
private var backupFile = BackupFile() private var backupFile = BackupFile()
private var importFormat: ImportFormat = ImportFormat.NEWPIPE private var importFormat: ImportFormat = ImportFormat.NEWPIPE
private val importSubscriptionFormatList = listOf(
ImportFormat.NEWPIPE,
ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV
)
private val exportSubscriptionFormatList = listOf(
ImportFormat.NEWPIPE,
ImportFormat.FREETUBE
)
private val importPlaylistFormatList = listOf(
ImportFormat.PIPED,
ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV,
ImportFormat.URLSORIDS
)
private val exportPlaylistFormatList = listOf(
ImportFormat.PIPED,
ImportFormat.FREETUBE
)
private val importWatchHistoryFormatList = listOf(ImportFormat.YOUTUBEJSON)
override val titleResourceId: Int = R.string.backup_restore override val titleResourceId: Int = R.string.backup_restore
@ -66,7 +47,7 @@ class BackupRestoreSettings : BasePreferenceFragment() {
} }
} }
} }
private val createBackupFile = registerForActivityResult(CreateDocument(JSON)) { uri -> private val createBackupFile = registerForActivityResult(CreateDocument(FILETYPE_ANY)) { uri ->
if (uri == null) return@registerForActivityResult if (uri == null) return@registerForActivityResult
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
BackupHelper.createAdvancedBackup(requireContext(), uri, backupFile) BackupHelper.createAdvancedBackup(requireContext(), uri, backupFile)
@ -85,7 +66,7 @@ class BackupRestoreSettings : BasePreferenceFragment() {
} }
} }
private val createSubscriptionsFile = registerForActivityResult(CreateDocument(JSON)) { uri -> private val createSubscriptionsFile = registerForActivityResult(CreateDocument(FILETYPE_ANY)) { uri ->
if (uri == null) return@registerForActivityResult if (uri == null) return@registerForActivityResult
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ImportHelper.exportSubscriptions(requireActivity(), uri, importFormat) ImportHelper.exportSubscriptions(requireActivity(), uri, importFormat)
@ -112,7 +93,7 @@ class BackupRestoreSettings : BasePreferenceFragment() {
} }
} }
private val createPlaylistsFile = registerForActivityResult(CreateDocument(JSON)) { uri -> private val createPlaylistsFile = registerForActivityResult(CreateDocument(FILETYPE_ANY)) { uri ->
uri?.let { uri?.let {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
ImportHelper.exportPlaylists(requireActivity(), uri, importFormat) ImportHelper.exportPlaylists(requireActivity(), uri, importFormat)
@ -120,32 +101,13 @@ class BackupRestoreSettings : BasePreferenceFragment() {
} }
} }
private fun createImportFormatDialog(
@StringRes titleStringId: Int,
items: List<String>,
onConfirm: (Int) -> Unit
) {
var selectedIndex = 0
MaterialAlertDialogBuilder(this.requireContext())
.setTitle(getString(titleStringId))
.setSingleChoiceItems(items.toTypedArray(), selectedIndex) { _, i ->
selectedIndex = i
}
.setPositiveButton(
R.string.okay
) { _, _ -> onConfirm(selectedIndex) }
.setNegativeButton(R.string.cancel, null)
.show()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.import_export_settings, rootKey) setPreferencesFromResource(R.xml.import_export_settings, rootKey)
val importSubscriptions = findPreference<Preference>("import_subscriptions") val importSubscriptions = findPreference<Preference>("import_subscriptions")
importSubscriptions?.setOnPreferenceClickListener { importSubscriptions?.setOnPreferenceClickListener {
val list = importSubscriptionFormatList.map { getString(it.value) } createImportFormatDialog(requireContext(), R.string.import_subscriptions_from, importSubscriptionFormatList) {
createImportFormatDialog(R.string.import_subscriptions_from, list) { importFormat = it
importFormat = importSubscriptionFormatList[it]
getSubscriptionsFile.launch("*/*") getSubscriptionsFile.launch("*/*")
} }
true true
@ -153,11 +115,10 @@ class BackupRestoreSettings : BasePreferenceFragment() {
val exportSubscriptions = findPreference<Preference>("export_subscriptions") val exportSubscriptions = findPreference<Preference>("export_subscriptions")
exportSubscriptions?.setOnPreferenceClickListener { exportSubscriptions?.setOnPreferenceClickListener {
val list = exportSubscriptionFormatList.map { getString(it.value) } createImportFormatDialog(requireContext(), R.string.export_subscriptions_to, exportSubscriptionFormatList) {
createImportFormatDialog(R.string.export_subscriptions_to, list) { importFormat = it
importFormat = exportSubscriptionFormatList[it]
createSubscriptionsFile.launch( createSubscriptionsFile.launch(
"${getString(importFormat.value).lowercase()}-subscriptions.json" "${getString(importFormat.value).lowercase()}-subscriptions.${importFormat.fileExtension}"
) )
} }
true true
@ -165,9 +126,8 @@ class BackupRestoreSettings : BasePreferenceFragment() {
val importPlaylists = findPreference<Preference>("import_playlists") val importPlaylists = findPreference<Preference>("import_playlists")
importPlaylists?.setOnPreferenceClickListener { importPlaylists?.setOnPreferenceClickListener {
val list = importPlaylistFormatList.map { getString(it.value) } createImportFormatDialog(requireContext(), R.string.import_playlists_from, importPlaylistFormatList) {
createImportFormatDialog(R.string.import_playlists_from, list) { importFormat = it
importFormat = importPlaylistFormatList[it]
getPlaylistsFile.launch(arrayOf("*/*")) getPlaylistsFile.launch(arrayOf("*/*"))
} }
true true
@ -175,11 +135,10 @@ class BackupRestoreSettings : BasePreferenceFragment() {
val exportPlaylists = findPreference<Preference>("export_playlists") val exportPlaylists = findPreference<Preference>("export_playlists")
exportPlaylists?.setOnPreferenceClickListener { exportPlaylists?.setOnPreferenceClickListener {
val list = exportPlaylistFormatList.map { getString(it.value) } createImportFormatDialog(requireContext(), R.string.export_playlists_to, exportPlaylistFormatList) {
createImportFormatDialog(R.string.export_playlists_to, list) { importFormat = it
importFormat = exportPlaylistFormatList[it]
createPlaylistsFile.launch( createPlaylistsFile.launch(
"${getString(importFormat.value).lowercase()}-playlists.json" "${getString(importFormat.value).lowercase()}-playlists.${importFormat.fileExtension}"
) )
} }
true true
@ -187,9 +146,8 @@ class BackupRestoreSettings : BasePreferenceFragment() {
val importWatchHistory = findPreference<Preference>("import_watch_history") val importWatchHistory = findPreference<Preference>("import_watch_history")
importWatchHistory?.setOnPreferenceClickListener { importWatchHistory?.setOnPreferenceClickListener {
val list = importWatchHistoryFormatList.map { getString(it.value) } createImportFormatDialog(requireContext(), R.string.import_watch_history, importWatchHistoryFormatList) {
createImportFormatDialog(R.string.import_watch_history, list) { importFormat = it
importFormat = importWatchHistoryFormatList[it]
getWatchHistoryFile.launch(arrayOf("*/*")) getWatchHistoryFile.launch(arrayOf("*/*"))
} }
true true
@ -219,5 +177,50 @@ class BackupRestoreSettings : BasePreferenceFragment() {
companion object { companion object {
const val JSON = "application/json" const val JSON = "application/json"
/**
* Mimetype to use to create new files when setting extension manually
*/
const val FILETYPE_ANY = "application/octet-stream"
val importSubscriptionFormatList = listOf(
ImportFormat.NEWPIPE,
ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV
)
val exportSubscriptionFormatList = listOf(
ImportFormat.NEWPIPE,
ImportFormat.FREETUBE
)
val importPlaylistFormatList = listOf(
ImportFormat.PIPED,
ImportFormat.FREETUBE,
ImportFormat.YOUTUBECSV,
ImportFormat.URLSORIDS
)
val exportPlaylistFormatList = listOf(
ImportFormat.PIPED,
ImportFormat.FREETUBE
)
val importWatchHistoryFormatList = listOf(ImportFormat.YOUTUBEJSON)
fun createImportFormatDialog(
context: Context,
@StringRes titleStringId: Int,
formats: List<ImportFormat>,
onConfirm: (ImportFormat) -> Unit
) {
var selectedIndex = 0
MaterialAlertDialogBuilder(context)
.setTitle(context.getString(titleStringId))
.setSingleChoiceItems(formats.map { context.getString(it.value) }.toTypedArray(), selectedIndex) { _, i ->
selectedIndex = i
}
.setPositiveButton(
R.string.okay
) { _, _ -> onConfirm(formats[selectedIndex]) }
.setNegativeButton(R.string.cancel, null)
.show()
}
} }
} }

View File

@ -7,19 +7,23 @@ import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.RetrofitInstance
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHolder import com.github.libretube.db.DatabaseHolder
import com.github.libretube.enums.ImportFormat
import com.github.libretube.enums.PlaylistType import com.github.libretube.enums.PlaylistType
import com.github.libretube.enums.ShareObjectType import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.serializable import com.github.libretube.extensions.serializable
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.BackgroundHelper import com.github.libretube.helpers.BackgroundHelper
import com.github.libretube.helpers.ContextHelper
import com.github.libretube.helpers.DownloadHelper import com.github.libretube.helpers.DownloadHelper
import com.github.libretube.obj.ShareData import com.github.libretube.obj.ShareData
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.base.BaseActivity import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.dialogs.DeletePlaylistDialog import com.github.libretube.ui.dialogs.DeletePlaylistDialog
import com.github.libretube.ui.dialogs.PlaylistDescriptionDialog import com.github.libretube.ui.dialogs.PlaylistDescriptionDialog
import com.github.libretube.ui.dialogs.RenamePlaylistDialog import com.github.libretube.ui.dialogs.RenamePlaylistDialog
import com.github.libretube.ui.dialogs.ShareDialog import com.github.libretube.ui.dialogs.ShareDialog
import com.github.libretube.ui.preferences.BackupRestoreSettings
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -30,7 +34,11 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
private lateinit var playlistId: String private lateinit var playlistId: String
private lateinit var playlistType: PlaylistType private lateinit var playlistType: PlaylistType
private var exportFormat: ImportFormat = ImportFormat.NEWPIPE
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let { arguments?.let {
playlistName = it.getString(IntentData.playlistName)!! playlistName = it.getString(IntentData.playlistName)!!
playlistId = it.getString(IntentData.playlistId)!! playlistId = it.getString(IntentData.playlistId)!!
@ -57,6 +65,7 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
if (isBookmarked) R.string.remove_bookmark else R.string.add_to_bookmarks if (isBookmarked) R.string.remove_bookmark else R.string.add_to_bookmarks
) )
} else { } else {
optionsList.add(R.string.export_playlist)
optionsList.add(R.string.renamePlaylist) optionsList.add(R.string.renamePlaylist)
optionsList.add(R.string.change_playlist_description) optionsList.add(R.string.change_playlist_description)
optionsList.add(R.string.deletePlaylist) optionsList.add(R.string.deletePlaylist)
@ -139,7 +148,27 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
} }
R.string.download -> { R.string.download -> {
DownloadHelper.startDownloadPlaylistDialog(requireContext(), mFragmentManager, playlistId, playlistName, playlistType) DownloadHelper.startDownloadPlaylistDialog(
requireContext(),
mFragmentManager,
playlistId,
playlistName,
playlistType
)
}
R.string.export_playlist -> {
val context = requireContext()
BackupRestoreSettings.createImportFormatDialog(
context,
R.string.export_playlist,
BackupRestoreSettings.exportPlaylistFormatList + listOf(ImportFormat.URLSORIDS)
) {
exportFormat = it
ContextHelper.unwrapActivity<MainActivity>(context)
.startPlaylistExport(playlistId, playlistName, exportFormat)
}
} }
else -> { else -> {
@ -158,7 +187,6 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() {
} }
} }
} }
super.onCreate(savedInstanceState)
} }
companion object { companion object {

View File

@ -362,6 +362,7 @@
<string name="all_caught_up_summary">You\'ve seen all new videos</string> <string name="all_caught_up_summary">You\'ve seen all new videos</string>
<string name="import_playlists">Import playlists</string> <string name="import_playlists">Import playlists</string>
<string name="export_playlists">Export playlists</string> <string name="export_playlists">Export playlists</string>
<string name="export_playlist">Export playlist</string>
<string name="import_watch_history">Import watch history</string> <string name="import_watch_history">Import watch history</string>
<string name="import_watch_history_desc">Please note that not everything will be imported due to YouTube\'s limited export data.</string> <string name="import_watch_history_desc">Please note that not everything will be imported due to YouTube\'s limited export data.</string>
<string name="app_backup">App Backup</string> <string name="app_backup">App Backup</string>
@ -480,7 +481,7 @@
<string name="import_format_freetube" translatable="false">FreeTube</string> <string name="import_format_freetube" translatable="false">FreeTube</string>
<string name="import_format_youtube_csv" translatable="false">YouTube (CSV)</string> <string name="import_format_youtube_csv" translatable="false">YouTube (CSV)</string>
<string name="import_format_youtube_json" translatable="false">YouTube (JSON)</string> <string name="import_format_youtube_json" translatable="false">YouTube (JSON)</string>
<string name="import_format_list_of_urls">List of URls or video IDs</string> <string name="import_format_list_of_urls">List of URLs or video IDs</string>
<string name="home_tab_content">Home tab content</string> <string name="home_tab_content">Home tab content</string>
<string name="show_search_suggestions">Show search suggestions</string> <string name="show_search_suggestions">Show search suggestions</string>
<string name="audio_track_format">%1$s - %2$s</string> <string name="audio_track_format">%1$s - %2$s</string>