Twelve: Initial UI and backend support for multiple providers
Change-Id: Ie49a21884b57d6ce1dfeb0a04f6d860405b53e1c
diff --git a/app/src/main/java/org/lineageos/twelve/TwelveApplication.kt b/app/src/main/java/org/lineageos/twelve/TwelveApplication.kt
index c6f12d5..bf8e06a 100644
--- a/app/src/main/java/org/lineageos/twelve/TwelveApplication.kt
+++ b/app/src/main/java/org/lineageos/twelve/TwelveApplication.kt
@@ -9,6 +9,7 @@
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import com.google.android.material.color.DynamicColors
+import kotlinx.coroutines.MainScope
import org.lineageos.twelve.database.TwelveDatabase
import org.lineageos.twelve.repositories.MediaRepository
import org.lineageos.twelve.repositories.ResumptionPlaylistRepository
@@ -16,7 +17,7 @@
@androidx.annotation.OptIn(UnstableApi::class)
class TwelveApplication : Application() {
private val database by lazy { TwelveDatabase.getInstance(applicationContext) }
- val mediaRepository by lazy { MediaRepository(applicationContext, database) }
+ val mediaRepository by lazy { MediaRepository(applicationContext, MainScope(), database) }
val resumptionPlaylistRepository by lazy { ResumptionPlaylistRepository(database) }
val audioSessionId by lazy { Util.generateAudioSessionIdV21(applicationContext) }
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/LocalDataSource.kt b/app/src/main/java/org/lineageos/twelve/datasources/LocalDataSource.kt
index 73dc995..cf09ce5 100644
--- a/app/src/main/java/org/lineageos/twelve/datasources/LocalDataSource.kt
+++ b/app/src/main/java/org/lineageos/twelve/datasources/LocalDataSource.kt
@@ -176,6 +176,16 @@
)
}
+ override fun isMediaItemCompatible(mediaItemUri: Uri) = listOf(
+ albumsUri,
+ artistsUri,
+ genresUri,
+ audiosUri,
+ playlistsBaseUri,
+ ).any {
+ mediaItemUri.toString().startsWith(it.toString())
+ }
+
override fun albums() = contentResolver.queryFlow(
albumsUri,
albumsProjection,
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/MediaDataSource.kt b/app/src/main/java/org/lineageos/twelve/datasources/MediaDataSource.kt
index 4e3e080..26de920 100644
--- a/app/src/main/java/org/lineageos/twelve/datasources/MediaDataSource.kt
+++ b/app/src/main/java/org/lineageos/twelve/datasources/MediaDataSource.kt
@@ -21,6 +21,14 @@
*/
interface MediaDataSource {
/**
+ * Check whether this data source can handle the given media item.
+ *
+ * @param mediaItemUri The media item to check
+ * @return Whether this data source can handle the given media item
+ */
+ fun isMediaItemCompatible(mediaItemUri: Uri): Boolean
+
+ /**
* Get all the albums. All albums must have at least one audio associated with them.
*/
fun albums(): Flow<RequestStatus<List<Album>>>
diff --git a/app/src/main/java/org/lineageos/twelve/datasources/SubsonicDataSource.kt b/app/src/main/java/org/lineageos/twelve/datasources/SubsonicDataSource.kt
index fea8244..1ab93a5 100644
--- a/app/src/main/java/org/lineageos/twelve/datasources/SubsonicDataSource.kt
+++ b/app/src/main/java/org/lineageos/twelve/datasources/SubsonicDataSource.kt
@@ -66,6 +66,10 @@
*/
private val _playlistsChanged = MutableStateFlow(Any())
+ override fun isMediaItemCompatible(mediaItemUri: Uri) = mediaItemUri.toString().startsWith(
+ dataSourceBaseUri.toString()
+ )
+
override fun albums() = suspend {
subsonicClient.getAlbumList2("alphabeticalByName", 500).toRequestStatus {
album.map { it.toMediaItem() }
diff --git a/app/src/main/java/org/lineageos/twelve/ext/AutoCompleteTextView.kt b/app/src/main/java/org/lineageos/twelve/ext/AutoCompleteTextView.kt
new file mode 100644
index 0000000..ac732b0
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/ext/AutoCompleteTextView.kt
@@ -0,0 +1,17 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.ext
+
+import android.widget.AutoCompleteTextView
+
+fun AutoCompleteTextView.selectItem(position: Int = 0) {
+ setText(adapter.getItem(position).toString(), false)
+ showDropDown()
+ setSelection(position)
+ listSelection = position
+ performCompletion()
+ dismissDropDown()
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/AddOrRemoveFromPlaylistsFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/AddOrRemoveFromPlaylistsFragment.kt
index f00f600..c6ecf87 100644
--- a/app/src/main/java/org/lineageos/twelve/fragments/AddOrRemoveFromPlaylistsFragment.kt
+++ b/app/src/main/java/org/lineageos/twelve/fragments/AddOrRemoveFromPlaylistsFragment.kt
@@ -37,14 +37,12 @@
import org.lineageos.twelve.utils.PermissionsChecker
import org.lineageos.twelve.utils.PermissionsUtils
import org.lineageos.twelve.viewmodels.AddOrRemoveFromPlaylistsViewModel
-import org.lineageos.twelve.viewmodels.PlaylistsViewModel
/**
* Fragment from which you can add or remove a specific audio from a list of playlists.
*/
class AddOrRemoveFromPlaylistsFragment : Fragment(R.layout.fragment_add_or_remove_from_playlists) {
// View models
- private val playlistsViewModel by viewModels<PlaylistsViewModel>()
private val viewModel by viewModels<AddOrRemoveFromPlaylistsViewModel>()
// Views
@@ -177,7 +175,7 @@
private fun openCreateNewPlaylistDialog() {
EditTextMaterialAlertDialogBuilder(requireContext())
.setPositiveButton(R.string.create_playlist_confirm) { text ->
- playlistsViewModel.createPlaylist(text)
+ viewModel.createPlaylist(text)
}
.setTitle(R.string.create_playlist)
.setNegativeButton(android.R.string.cancel, null)
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/MainFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/MainFragment.kt
index 66a35f6..3db2677 100644
--- a/app/src/main/java/org/lineageos/twelve/fragments/MainFragment.kt
+++ b/app/src/main/java/org/lineageos/twelve/fragments/MainFragment.kt
@@ -18,6 +18,7 @@
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.lineageos.twelve.R
@@ -25,6 +26,7 @@
import org.lineageos.twelve.models.RequestStatus
import org.lineageos.twelve.ui.views.NowPlayingBar
import org.lineageos.twelve.viewmodels.NowPlayingViewModel
+import org.lineageos.twelve.viewmodels.ProvidersViewModel
/**
* The home page.
@@ -32,10 +34,12 @@
class MainFragment : Fragment(R.layout.fragment_main) {
// View models
private val viewModel by viewModels<NowPlayingViewModel>()
+ private val providersViewModel by viewModels<ProvidersViewModel>()
// Views
private val bottomNavigationView by getViewProperty<BottomNavigationView>(R.id.bottomNavigationView)
private val nowPlayingBar by getViewProperty<NowPlayingBar>(R.id.nowPlayingBar)
+ private val providerMaterialButton by getViewProperty<MaterialButton>(R.id.providerMaterialButton)
private val toolbar by getViewProperty<MaterialToolbar>(R.id.toolbar)
private val viewPager2 by getViewProperty<ViewPager2>(R.id.viewPager2)
@@ -55,6 +59,12 @@
toolbar.setupWithNavController(findNavController())
+ providerMaterialButton.setOnClickListener {
+ findNavController().navigate(
+ R.id.action_mainFragment_to_fragment_provider_selector_dialog
+ )
+ }
+
viewPager2.isUserInputEnabled = false
viewPager2.adapter = object : FragmentStateAdapter(this) {
override fun getItemCount() = fragments.size
@@ -95,6 +105,15 @@
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
+ providersViewModel.navigationProvider.collectLatest {
+ it?.let {
+ providerMaterialButton.text = it.name
+ providerMaterialButton.setIconResource(it.type.iconDrawableResId)
+ }
+ }
+ }
+
+ launch {
viewModel.durationCurrentPositionMs.collectLatest {
nowPlayingBar.updateDurationCurrentPositionMs(it.first, it.second)
}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/ManageProviderFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/ManageProviderFragment.kt
new file mode 100644
index 0000000..bdedfe0
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/ManageProviderFragment.kt
@@ -0,0 +1,366 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import androidx.core.os.bundleOf
+import androidx.core.view.isVisible
+import androidx.core.widget.doAfterTextChanged
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.ui.setupWithNavController
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.checkbox.MaterialCheckBox
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textfield.MaterialAutoCompleteTextView
+import com.google.android.material.textfield.TextInputLayout
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.getSerializable
+import org.lineageos.twelve.ext.getViewProperty
+import org.lineageos.twelve.ext.selectItem
+import org.lineageos.twelve.models.ProviderArgument
+import org.lineageos.twelve.models.ProviderArgument.Companion.getArgument
+import org.lineageos.twelve.models.ProviderType
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter
+import org.lineageos.twelve.viewmodels.ManageProviderViewModel
+
+/**
+ * Fragment used to add, modify or delete a provider.
+ */
+class ManageProviderFragment : Fragment(R.layout.fragment_manage_provider) {
+ // View models
+ private val viewModel by viewModels<ManageProviderViewModel>()
+
+ // Views
+ private val argumentsRecyclerView by getViewProperty<RecyclerView>(R.id.argumentsRecyclerView)
+ private val confirmMaterialButton by getViewProperty<MaterialButton>(R.id.confirmMaterialButton)
+ private val deleteMaterialButton by getViewProperty<MaterialButton>(R.id.deleteMaterialButton)
+ private val providerNameTextInputLayout by getViewProperty<TextInputLayout>(R.id.providerNameTextInputLayout)
+ private val providerTypeAutoCompleteTextView by getViewProperty<MaterialAutoCompleteTextView>(R.id.providerTypeAutoCompleteTextView)
+ private val providerTypeTextInputLayout by getViewProperty<TextInputLayout>(R.id.providerTypeTextInputLayout)
+ private val toolbar by getViewProperty<MaterialToolbar>(R.id.toolbar)
+
+ // Arguments
+ private val providerType: ProviderType?
+ get() = arguments?.getSerializable(ARG_PROVIDER_TYPE, ProviderType::class)
+ private val providerTypeId: Long?
+ get() = arguments?.getLong(ARG_PROVIDER_TYPE_ID, -1L).takeIf { it != -1L }
+
+ // Providers
+ private val remoteProviderTypes = ProviderType.entries.filter { it != ProviderType.LOCAL }
+ private var selectedProviderType: ProviderType? = null
+ private val providerArguments = Bundle()
+
+ // Recyclerview
+ private val argumentsAdapter = object : SimpleListAdapter<ProviderArgument<*>, View>(
+ argumentsDiffCallback,
+ { layoutInflater.inflate(R.layout.argument_item, null) }
+ ) {
+ // Views
+ private val ViewHolder.booleanCheckBox
+ get() = view.findViewById<MaterialCheckBox>(R.id.booleanMaterialCheckBox)!!
+ private val ViewHolder.stringTextInputLayout
+ get() = view.findViewById<TextInputLayout>(R.id.stringTextInputLayout)!!
+
+ override fun ViewHolder.onPrepareView() {
+ booleanCheckBox.setOnCheckedChangeListener { _, isChecked ->
+ item?.let {
+ providerArguments.putBoolean(it.key, isChecked)
+ }
+ }
+
+ stringTextInputLayout.editText?.doAfterTextChanged { inputText ->
+ item?.let { item ->
+ inputText.toString().takeIf { text -> text.isNotBlank() }?.also {
+ providerArguments.putString(item.key, it)
+ } ?: providerArguments.remove(item.key)
+ }
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: ProviderArgument<*>) {
+ booleanCheckBox.isVisible = false
+ stringTextInputLayout.isVisible = false
+
+ when (item.type) {
+ Boolean::class -> {
+ val value = providerArguments.getBoolean(
+ item.key, (item.defaultValue as? Boolean) ?: false
+ )
+
+ booleanCheckBox.setText(item.nameStringResId)
+ booleanCheckBox.isChecked = value
+ booleanCheckBox.isVisible = true
+ }
+
+ String::class -> {
+ val value = providerArguments.getString(item.key)
+
+ stringTextInputLayout.setHint(item.nameStringResId)
+ stringTextInputLayout.editText?.setText(value)
+ stringTextInputLayout.endIconMode = when (item.hidden) {
+ true -> TextInputLayout.END_ICON_PASSWORD_TOGGLE
+ false -> TextInputLayout.END_ICON_CLEAR_TEXT
+ }
+ stringTextInputLayout.editText?.inputType = when (item.hidden) {
+ true -> android.text.InputType.TYPE_CLASS_TEXT or
+ android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
+
+ false -> android.text.InputType.TYPE_CLASS_TEXT
+ }
+ stringTextInputLayout.isVisible = true
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ viewModel.setProvider(
+ providerType?.let { providerType ->
+ providerTypeId?.let {
+ providerType to it
+ }
+ }
+ )
+
+ selectedProviderType = providerType?.also {
+ require(remoteProviderTypes.contains(it)) { "Invalid provider type: $it" }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ toolbar.setupWithNavController(findNavController())
+
+ argumentsRecyclerView.adapter = argumentsAdapter
+
+ confirmMaterialButton.setOnClickListener {
+ val name = providerNameTextInputLayout.editText?.text?.toString()?.takeIf {
+ it.isNotBlank()
+ }?.also {
+ providerNameTextInputLayout.error = null
+ } ?: run {
+ providerNameTextInputLayout.error = getString(R.string.provider_name_error)
+ return@setOnClickListener
+ }
+
+ val providerType = selectedProviderType?.also {
+ providerTypeTextInputLayout.error = null
+ } ?: run {
+ providerTypeTextInputLayout.error = getString(R.string.provider_type_error)
+ return@setOnClickListener
+ }
+
+ val wrongArguments = providerType.arguments.filter { argument ->
+ val value = providerArguments.getArgument(argument)
+
+ (value ?: argument.defaultValue) == null && argument.required
+ }
+
+ if (wrongArguments.isNotEmpty()) {
+ showMissingArgumentsDialog(wrongArguments)
+ return@setOnClickListener
+ }
+
+ if (viewModel.inEditMode.value) {
+ viewModel.updateProvider(name, providerArguments)
+ } else {
+ viewModel.addProvider(providerType, name, providerArguments)
+ }
+
+ findNavController().navigateUp()
+ }
+
+ deleteMaterialButton.setOnClickListener {
+ showDeleteDialog()
+ }
+
+ providerTypeAutoCompleteTextView.setSimpleItems(
+ remoteProviderTypes.map {
+ getString(it.nameStringResId)
+ }.toTypedArray()
+ )
+
+ providerTypeAutoCompleteTextView.setOnItemClickListener { _, _, position, _ ->
+ viewModel.setProviderType(remoteProviderTypes[position])
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ launch {
+ viewModel.inEditMode.collectLatest { inEditMode ->
+ toolbar.setTitle(
+ when (inEditMode) {
+ true -> R.string.manage_provider
+ false -> R.string.add_provider
+ }
+ )
+
+ confirmMaterialButton.setContentDescription(
+ getString(
+ when (inEditMode) {
+ true -> R.string.save_provider_action
+ false -> R.string.add_provider_action
+ }
+ )
+ )
+
+ deleteMaterialButton.isVisible = inEditMode
+
+ providerTypeTextInputLayout.isEnabled = !inEditMode
+ }
+ }
+
+ launch {
+ viewModel.provider.collectLatest {
+ when (it) {
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ it.data?.let { provider ->
+ providerNameTextInputLayout.editText?.setText(
+ provider.name
+ )
+ }
+ }
+
+ is RequestStatus.Error -> {
+ Log.e(LOG_TAG, "Failed to load provider")
+
+ if (it.type == RequestStatus.Error.Type.NOT_FOUND) {
+ // Get out of here
+ findNavController().navigateUp()
+ }
+ }
+ }
+ }
+ }
+
+ launch {
+ viewModel.providerTypeWithArguments.collectLatest { providerTypeWithArguments ->
+ val (providerType, providerArguments) = providerTypeWithArguments
+
+ if (providerType != selectedProviderType) {
+ // Clear the provided arguments regardless since they aren't
+ // valid anymore
+ this@ManageProviderFragment.providerArguments.clear()
+ }
+
+ providerArguments?.let {
+ // Load the values from the database as defaults
+ this@ManageProviderFragment.providerArguments.clear()
+ this@ManageProviderFragment.providerArguments.putAll(it)
+ }
+
+ if (providerType != selectedProviderType) {
+ selectedProviderType = providerType
+
+ providerType?.also {
+ providerTypeAutoCompleteTextView.selectItem(
+ remoteProviderTypes.indexOf(it)
+ )
+
+ providerTypeTextInputLayout.setStartIconDrawable(
+ it.iconDrawableResId
+ )
+
+ argumentsAdapter.submitList(it.arguments)
+ } ?: run {
+ providerTypeTextInputLayout.startIconDrawable = null
+
+ argumentsAdapter.submitList(listOf())
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ argumentsRecyclerView.adapter = null
+
+ super.onDestroyView()
+ }
+
+ private fun showMissingArgumentsDialog(wrongArguments: List<ProviderArgument<*>>) {
+ MaterialAlertDialogBuilder(requireContext())
+ .setMessage(
+ getString(
+ R.string.missing_provider_arguments,
+ wrongArguments.joinToString {
+ getString(it.nameStringResId)
+ }
+ )
+ )
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ // Do nothing
+ }
+ .show()
+ }
+
+ private fun showDeleteDialog() {
+ MaterialAlertDialogBuilder(requireContext())
+ .setMessage(R.string.delete_provider_confirmation)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ viewModel.deleteProvider()
+ }
+ .setNegativeButton(android.R.string.cancel) { _, _ ->
+ // Do nothing
+ }
+ .show()
+ }
+
+ companion object {
+ private val LOG_TAG = ManageProviderFragment::class.simpleName!!
+
+ private const val ARG_PROVIDER_TYPE = "provider_type"
+ private const val ARG_PROVIDER_TYPE_ID = "provider_type_id"
+
+ private val argumentsDiffCallback = object : DiffUtil.ItemCallback<ProviderArgument<*>>() {
+ override fun areItemsTheSame(
+ oldItem: ProviderArgument<*>,
+ newItem: ProviderArgument<*>
+ ) = oldItem.key == newItem.key && oldItem.type == newItem.type
+
+ override fun areContentsTheSame(
+ oldItem: ProviderArgument<*>,
+ newItem: ProviderArgument<*>
+ ) = false // Reload all items
+ }
+
+ /**
+ * Create a [Bundle] to use as the arguments for this fragment.
+ * @param providerType A [ProviderType] to either use as an hint for the creation of a new
+ * instance or the type of the provider to edit or delete
+ * @param providerTypeId The type specific ID of the provider to edit or delete
+ */
+ fun createBundle(
+ providerType: ProviderType? = null,
+ providerTypeId: Long? = null,
+ ) = bundleOf(
+ ARG_PROVIDER_TYPE to providerType,
+ ARG_PROVIDER_TYPE_ID to providerTypeId,
+ )
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/fragments/ProviderSelectorDialogFragment.kt b/app/src/main/java/org/lineageos/twelve/fragments/ProviderSelectorDialogFragment.kt
new file mode 100644
index 0000000..b22e9ff
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/fragments/ProviderSelectorDialogFragment.kt
@@ -0,0 +1,115 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.fragments
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.R
+import org.lineageos.twelve.ext.getViewProperty
+import org.lineageos.twelve.models.Provider
+import org.lineageos.twelve.models.ProviderType
+import org.lineageos.twelve.models.RequestStatus
+import org.lineageos.twelve.ui.recyclerview.SimpleListAdapter
+import org.lineageos.twelve.ui.recyclerview.UniqueItemDiffCallback
+import org.lineageos.twelve.ui.views.ListItem
+import org.lineageos.twelve.viewmodels.ProvidersViewModel
+
+/**
+ * Fragment used to select a media provider.
+ */
+class ProviderSelectorDialogFragment : DialogFragment(R.layout.fragment_provider_selector_dialog) {
+ // View models
+ private val viewModel by viewModels<ProvidersViewModel>()
+
+ // Views
+ private val addProviderMaterialButton by getViewProperty<MaterialButton>(R.id.addProviderMaterialButton)
+ private val recyclerView by getViewProperty<RecyclerView>(R.id.recyclerView)
+
+ // Recyclerview
+ private val adapter = object : SimpleListAdapter<Provider, ListItem>(
+ UniqueItemDiffCallback(),
+ ::ListItem,
+ ) {
+ override fun ViewHolder.onPrepareView() {
+ view.setOnClickListener {
+ item?.let {
+ viewModel.setNavigationProvider(it)
+ findNavController().navigateUp()
+ }
+ }
+
+ view.setOnLongClickListener {
+ item?.takeIf { it.type != ProviderType.LOCAL }?.let {
+ findNavController().navigate(
+ R.id.action_providerSelectorDialogFragment_to_fragment_manage_provider,
+ ManageProviderFragment.createBundle(it.type, it.typeId)
+ )
+ }
+
+ true
+ }
+ }
+
+ override fun ViewHolder.onBindView(item: Provider) {
+ view.setLeadingIconImage(item.type.iconDrawableResId)
+ view.headlineText = item.name
+ view.setSupportingText(item.type.nameStringResId)
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?) = MaterialAlertDialogBuilder(
+ requireContext()
+ ).show()!!
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ recyclerView.adapter = adapter
+
+ addProviderMaterialButton.setOnClickListener {
+ findNavController().navigate(
+ R.id.action_providerSelectorDialogFragment_to_fragment_manage_provider,
+ ManageProviderFragment.createBundle()
+ )
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.providers.collect {
+ when (it) {
+ is RequestStatus.Loading -> {
+ // Do nothing
+ }
+
+ is RequestStatus.Success -> {
+ adapter.submitList(it.data)
+ }
+
+ is RequestStatus.Error -> throw Exception(
+ "Error while loading providers"
+ )
+ }
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ recyclerView.adapter = null
+
+ super.onDestroyView()
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/Provider.kt b/app/src/main/java/org/lineageos/twelve/models/Provider.kt
new file mode 100644
index 0000000..d689075
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/Provider.kt
@@ -0,0 +1,37 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import org.lineageos.twelve.datasources.MediaDataSource
+
+/**
+ * A provider instance. Two instances are the same if they have the same [typeId] and [type].
+ * The [type] determines how data should be retrieved from the provider.
+ * Each provider has an associated [MediaDataSource] and related arguments, but those are not
+ * exposed outside of the media repository.
+ *
+ * @param type The provider type
+ * @param typeId The ID of the provider relative to the [ProviderType]
+ * @param name The name of the provider given by the user
+ */
+class Provider(
+ val type: ProviderType,
+ val typeId: Long,
+ val name: String,
+) : UniqueItem<Provider> {
+ override fun areItemsTheSame(other: Provider) = compareValuesBy(
+ this,
+ other,
+ Provider::typeId,
+ Provider::type,
+ ) == 0
+
+ override fun areContentsTheSame(other: Provider) = compareValuesBy(
+ this,
+ other,
+ Provider::name,
+ ) == 0
+}
diff --git a/app/src/main/java/org/lineageos/twelve/models/ProviderType.kt b/app/src/main/java/org/lineageos/twelve/models/ProviderType.kt
new file mode 100644
index 0000000..4535825
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/models/ProviderType.kt
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.models
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import org.lineageos.twelve.R
+import org.lineageos.twelve.datasources.LocalDataSource
+import org.lineageos.twelve.datasources.MediaDataSource
+import org.lineageos.twelve.datasources.SubsonicDataSource
+
+/**
+ * Data provider type. This regulates how data should be fetched, usually having a [MediaDataSource]
+ * for each one.
+ *
+ * @param nameStringResId String resource ID of the display name of the provider
+ * @param iconDrawableResId The drawable resource ID of the provider
+ * @param arguments The arguments of the provider required to start a session. Those will be used
+ * by the providers manager to show the user a dialog to configure the provider
+ */
+enum class ProviderType(
+ @StringRes val nameStringResId: Int,
+ @DrawableRes val iconDrawableResId: Int,
+ val arguments: List<ProviderArgument<*>>,
+) {
+ /**
+ * Local provider, only one instance of [LocalDataSource] exists.
+ */
+ LOCAL(
+ R.string.provider_type_local,
+ R.drawable.ic_shelves,
+ listOf(),
+ ),
+
+ /**
+ * Subsonic provider.
+ *
+ * [Home page](https://www.subsonic.org/pages/index.jsp)
+ */
+ SUBSONIC(
+ R.string.provider_type_subsonic,
+ R.drawable.ic_sailing,
+ listOf(
+ SubsonicDataSource.ARG_SERVER,
+ SubsonicDataSource.ARG_USERNAME,
+ SubsonicDataSource.ARG_PASSWORD,
+ SubsonicDataSource.ARG_USE_LEGACY_AUTHENTICATION,
+ ),
+ ),
+}
diff --git a/app/src/main/java/org/lineageos/twelve/repositories/MediaRepository.kt b/app/src/main/java/org/lineageos/twelve/repositories/MediaRepository.kt
index 6f6d7c6..9b1162e 100644
--- a/app/src/main/java/org/lineageos/twelve/repositories/MediaRepository.kt
+++ b/app/src/main/java/org/lineageos/twelve/repositories/MediaRepository.kt
@@ -7,10 +7,25 @@
import android.content.Context
import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
import org.lineageos.twelve.database.TwelveDatabase
import org.lineageos.twelve.datasources.LocalDataSource
import org.lineageos.twelve.datasources.MediaDataSource
+import org.lineageos.twelve.datasources.SubsonicDataSource
import org.lineageos.twelve.models.Album
import org.lineageos.twelve.models.Artist
import org.lineageos.twelve.models.ArtistWorks
@@ -18,93 +33,458 @@
import org.lineageos.twelve.models.Genre
import org.lineageos.twelve.models.MediaItem
import org.lineageos.twelve.models.Playlist
+import org.lineageos.twelve.models.Provider
+import org.lineageos.twelve.models.ProviderArgument.Companion.requireArgument
+import org.lineageos.twelve.models.ProviderType
import org.lineageos.twelve.models.RequestStatus
-class MediaRepository(context: Context, database: TwelveDatabase) {
+/**
+ * Media repository. This class coordinates all the providers and their data source.
+ * All methods that involves a URI as a parameter will be redirected to the
+ * proper data source that can handle the media item. Methods that just returns a list of things
+ * will be redirected to the provider selected by the user (see [navigationProvider]).
+ * If the navigation provider disappears, the local provider will be used as a fallback.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class MediaRepository(
+ context: Context,
+ scope: CoroutineScope,
+ private val database: TwelveDatabase,
+) {
+ /**
+ * Local data source singleton.
+ */
private val localDataSource = LocalDataSource(context, database)
/**
+ * Local provider singleton.
+ */
+ private val localProvider = Provider(
+ ProviderType.LOCAL,
+ LOCAL_PROVIDER_ID,
+ Build.MODEL,
+ )
+
+ /**
+ * All the providers. This is our single point of truth for the providers.
+ */
+ private val allProvidersToDataSource = combine(
+ flowOf(listOf(localProvider to localDataSource)),
+ database.getSubsonicProviderDao().getAll().mapLatest { subsonicProviders ->
+ subsonicProviders.map {
+ val arguments = bundleOf(
+ SubsonicDataSource.ARG_SERVER.key to it.url,
+ SubsonicDataSource.ARG_USERNAME.key to it.username,
+ SubsonicDataSource.ARG_PASSWORD.key to it.password,
+ SubsonicDataSource.ARG_USE_LEGACY_AUTHENTICATION.key to
+ it.useLegacyAuthentication,
+ )
+
+ Provider(
+ ProviderType.SUBSONIC,
+ it.id,
+ it.name,
+ ) to SubsonicDataSource(arguments)
+ }
+ }
+ ) { providers -> providers.toList().flatten() }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ listOf(localProvider to localDataSource),
+ )
+
+ /**
+ * The current navigation provider's identifiers.
+ */
+ private var _navigationProvider = MutableStateFlow(
+ ProviderType.LOCAL to LOCAL_PROVIDER_ID
+ )
+
+ /**
+ * The current navigation provider's data source.
+ */
+ private val navigationDataSource = _navigationProvider
+ .flatMapLatest {
+ dataSource(it.first, it.second).mapLatest { dataSource ->
+ dataSource ?: localDataSource
+ }
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ localDataSource,
+ )
+
+ /**
+ * All providers available to the app.
+ */
+ val allProviders = allProvidersToDataSource.mapLatest {
+ it.map { (provider, _) -> provider }
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ listOf(localProvider),
+ )
+
+ /**
+ * The current navigation provider. This is used when the user looks for all media types,
+ * like the home page, or with the search feature. In case the selected one disappears, the
+ * repository will automatically fallback to the local provider.
+ */
+ val navigationProvider = _navigationProvider
+ .flatMapLatest {
+ provider(it.first, it.second).mapLatest { currentNavigationProvider ->
+ // Default to local provider if not found
+ currentNavigationProvider ?: localProvider
+ }
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ localProvider,
+ )
+
+ /**
+ * Given a media item, get a flow of the provider that handles these media items' URIs.
+ * All URIs must be supported by the same provider to get a valid result.
+ *
+ * @param uris The media items' URIs
+ * @return A flow of the provider that handles these media items' URIs.
+ */
+ fun providerOfMediaItems(vararg uris: Uri) = allProvidersToDataSource.mapLatest {
+ it.firstOrNull { (_, dataSource) ->
+ uris.all { uri -> dataSource.isMediaItemCompatible(uri) }
+ }?.first
+ }
+
+ /**
+ * Given a media item, get the provider that handles these media items' URIs.
+ * All URIs must be supported by the same provider to get a valid result.
+ *
+ * @param uris The media items' URIs
+ * @return The provider that handles these media items' URIs.
+ */
+ fun getProviderOfMediaItems(
+ vararg uris: Uri
+ ) = allProvidersToDataSource.value.firstOrNull { (_, dataSource) ->
+ uris.all { uri -> dataSource.isMediaItemCompatible(uri) }
+ }?.first
+
+ /**
+ * Get a flow of the [Provider].
+ *
+ * @param providerType The [ProviderType]
+ * @param providerTypeId The [ProviderType] specific provider ID
+ * @return A flow of the corresponding [Provider].
+ */
+ fun provider(providerType: ProviderType, providerTypeId: Long) = allProviders.mapLatest {
+ it.firstOrNull { provider ->
+ providerType == provider.type && providerTypeId == provider.typeId
+ }
+ }
+
+ /**
+ * Get a flow of the [Bundle] containing the arguments. This method should only be used by the
+ * provider manager fragment.
+ *
+ * @param providerType The [ProviderType]
+ * @param providerTypeId The [ProviderType] specific provider ID
+ * @return A flow of [Bundle] containing the arguments.
+ */
+ fun providerArguments(providerType: ProviderType, providerTypeId: Long) = when (providerType) {
+ ProviderType.LOCAL -> flowOf(bundleOf())
+
+ ProviderType.SUBSONIC -> database.getSubsonicProviderDao().getById(
+ providerTypeId
+ ).mapLatest { subsonicProvider ->
+ subsonicProvider?.let {
+ bundleOf(
+ SubsonicDataSource.ARG_SERVER.key to it.url,
+ SubsonicDataSource.ARG_USERNAME.key to it.username,
+ SubsonicDataSource.ARG_PASSWORD.key to it.password,
+ SubsonicDataSource.ARG_USE_LEGACY_AUTHENTICATION.key to
+ it.useLegacyAuthentication,
+ )
+ }
+ }
+ }
+
+ /**
+ * Add a new provider to the database.
+ *
+ * @param providerType The [ProviderType]
+ * @param name The name of the new provider
+ * @param arguments The arguments of the new provider. They must have been validated beforehand
+ * @return A [Pair] containing the [ProviderType] and the ID of the new provider. You can then
+ * use those values to retrieve the new [Provider]
+ */
+ suspend fun addProvider(
+ providerType: ProviderType, name: String, arguments: Bundle
+ ) = when (providerType) {
+ ProviderType.LOCAL -> throw Exception("Cannot create local providers")
+
+ ProviderType.SUBSONIC -> {
+ val server = arguments.requireArgument(SubsonicDataSource.ARG_SERVER)
+ val username = arguments.requireArgument(SubsonicDataSource.ARG_USERNAME)
+ val password = arguments.requireArgument(SubsonicDataSource.ARG_PASSWORD)
+ val useLegacyAuthentication = arguments.requireArgument(
+ SubsonicDataSource.ARG_USE_LEGACY_AUTHENTICATION
+ )
+
+ val typeId = database.getSubsonicProviderDao().create(
+ name, server, username, password, useLegacyAuthentication
+ )
+
+ Pair(providerType, typeId)
+ }
+ }
+
+ /**
+ * Update an already existing provider.
+ *
+ * @param providerType The [ProviderType]
+ * @param providerTypeId The [ProviderType] specific provider ID
+ * @param name The updated name
+ * @param arguments The updated arguments
+ */
+ suspend fun updateProvider(
+ providerType: ProviderType,
+ providerTypeId: Long,
+ name: String,
+ arguments: Bundle
+ ) {
+ when (providerType) {
+ ProviderType.LOCAL -> throw Exception("Cannot update local providers")
+
+ ProviderType.SUBSONIC -> {
+ val server = arguments.requireArgument(SubsonicDataSource.ARG_SERVER)
+ val username = arguments.requireArgument(SubsonicDataSource.ARG_USERNAME)
+ val password = arguments.requireArgument(SubsonicDataSource.ARG_PASSWORD)
+ val useLegacyAuthentication = arguments.requireArgument(
+ SubsonicDataSource.ARG_USE_LEGACY_AUTHENTICATION
+ )
+
+ database.getSubsonicProviderDao().update(
+ providerTypeId,
+ name,
+ server,
+ username,
+ password,
+ useLegacyAuthentication,
+ )
+ }
+ }
+ }
+
+ /**
+ * Delete a provider.
+ *
+ * @param providerType The [ProviderType]
+ * @param providerTypeId The [ProviderType] specific provider ID
+ */
+ suspend fun deleteProvider(providerType: ProviderType, providerTypeId: Long) {
+ when (providerType) {
+ ProviderType.LOCAL -> throw Exception("Cannot delete local providers")
+
+ ProviderType.SUBSONIC -> database.getSubsonicProviderDao().delete(providerTypeId)
+ }
+ }
+
+ /**
+ * Change the default navigation provider. In case this provider disappears the repository will
+ * automatically fallback to the local provider.
+ *
+ * @param provider The new navigation provider
+ */
+ fun setNavigationProvider(provider: Provider) {
+ _navigationProvider.value = provider.type to provider.typeId
+ }
+
+ /**
* @see MediaDataSource.albums
*/
- fun albums(): Flow<RequestStatus<List<Album>>> = localDataSource.albums()
+ fun albums(): Flow<RequestStatus<List<Album>>> =
+ navigationDataSource.flatMapLatest { it.albums() }
/**
* @see MediaDataSource.artists
*/
- fun artists(): Flow<RequestStatus<List<Artist>>> = localDataSource.artists()
+ fun artists(): Flow<RequestStatus<List<Artist>>> =
+ navigationDataSource.flatMapLatest { it.artists() }
/**
* @see MediaDataSource.genres
*/
- fun genres(): Flow<RequestStatus<List<Genre>>> = localDataSource.genres()
+ fun genres(): Flow<RequestStatus<List<Genre>>> =
+ navigationDataSource.flatMapLatest { it.genres() }
/**
* @see MediaDataSource.playlists
*/
- fun playlists(): Flow<RequestStatus<List<Playlist>>> = localDataSource.playlists()
+ fun playlists(): Flow<RequestStatus<List<Playlist>>> =
+ navigationDataSource.flatMapLatest { it.playlists() }
/**
* @see MediaDataSource.search
*/
fun search(query: String): Flow<RequestStatus<List<MediaItem<*>>>> =
- localDataSource.search(query)
+ navigationDataSource.flatMapLatest { it.search(query) }
/**
* @see MediaDataSource.audio
*/
- fun audio(audioUri: Uri): Flow<RequestStatus<Audio>> = localDataSource.audio(audioUri)
+ fun audio(audioUri: Uri): Flow<RequestStatus<Audio>> = withMediaItemsDataSourceFlow(audioUri) {
+ audio(audioUri)
+ }
/**
* @see MediaDataSource.album
*/
fun album(albumUri: Uri): Flow<RequestStatus<Pair<Album, List<Audio>>>> =
- localDataSource.album(albumUri)
+ withMediaItemsDataSourceFlow(albumUri) {
+ album(albumUri)
+ }
/**
* @see MediaDataSource.artist
*/
fun artist(artistUri: Uri): Flow<RequestStatus<Pair<Artist, ArtistWorks>>> =
- localDataSource.artist(artistUri)
+ withMediaItemsDataSourceFlow(artistUri) {
+ artist(artistUri)
+ }
/**
* @see MediaDataSource.playlist
*/
fun playlist(playlistUri: Uri): Flow<RequestStatus<Pair<Playlist, List<Audio?>>>> =
- localDataSource.playlist(playlistUri)
+ withMediaItemsDataSourceFlow(playlistUri) {
+ playlist(playlistUri)
+ }
/**
* @see MediaDataSource.audioPlaylistsStatus
*/
fun audioPlaylistsStatus(audioUri: Uri): Flow<RequestStatus<List<Pair<Playlist, Boolean>>>> =
- localDataSource.audioPlaylistsStatus(audioUri)
+ withMediaItemsDataSourceFlow(audioUri) {
+ audioPlaylistsStatus(audioUri)
+ }
/**
* @see MediaDataSource.createPlaylist
*/
- suspend fun createPlaylist(name: String): RequestStatus<Uri> =
- localDataSource.createPlaylist(name)
+ suspend fun createPlaylist(
+ provider: Provider, name: String
+ ): RequestStatus<Uri> = getDataSource(provider)?.createPlaylist(
+ name
+ ) ?: RequestStatus.Error(
+ RequestStatus.Error.Type.NOT_FOUND
+ )
/**
* @see MediaDataSource.renamePlaylist
*/
suspend fun renamePlaylist(playlistUri: Uri, name: String): RequestStatus<Unit> =
- localDataSource.renamePlaylist(playlistUri, name)
+ withMediaItemsDataSource(playlistUri) {
+ renamePlaylist(playlistUri, name)
+ }
/**
* @see MediaDataSource.deletePlaylist
*/
suspend fun deletePlaylist(playlistUri: Uri): RequestStatus<Unit> =
- localDataSource.deletePlaylist(playlistUri)
+ withMediaItemsDataSource(playlistUri) {
+ deletePlaylist(playlistUri)
+ }
/**
* @see MediaDataSource.addAudioToPlaylist
*/
suspend fun addAudioToPlaylist(playlistUri: Uri, audioUri: Uri): RequestStatus<Unit> =
- localDataSource.addAudioToPlaylist(playlistUri, audioUri)
+ withMediaItemsDataSource(playlistUri, audioUri) {
+ addAudioToPlaylist(playlistUri, audioUri)
+ }
/**
* @see MediaDataSource.removeAudioFromPlaylist
*/
suspend fun removeAudioFromPlaylist(playlistUri: Uri, audioUri: Uri): RequestStatus<Unit> =
- localDataSource.removeAudioFromPlaylist(playlistUri, audioUri)
+ withMediaItemsDataSource(playlistUri, audioUri) {
+ removeAudioFromPlaylist(playlistUri, audioUri)
+ }
+
+ /**
+ * Get a flow of the [MediaDataSource] associated with the given [Provider].
+ *
+ * @param providerType The [ProviderType]
+ * @param providerTypeId The [ProviderType] specific provider ID
+ * @return The corresponding [MediaDataSource]
+ */
+ private fun dataSource(
+ providerType: ProviderType, providerTypeId: Long
+ ) = allProvidersToDataSource.mapLatest {
+ it.firstOrNull { (provider, _) ->
+ providerType == provider.type && providerTypeId == provider.typeId
+ }?.second
+ }
+
+ /**
+ * Get the [MediaDataSource] associated with the given [Provider].
+ *
+ * @param providerType The [ProviderType]
+ * @param providerTypeId The [ProviderType] specific provider ID
+ * @return The corresponding [MediaDataSource]
+ */
+ private fun getDataSource(
+ providerType: ProviderType, providerTypeId: Long
+ ) = allProvidersToDataSource.value.firstOrNull { (provider, _) ->
+ providerType == provider.type && providerTypeId == provider.typeId
+ }?.second
+
+ /**
+ * Get the [MediaDataSource] associated with the given [Provider].
+ *
+ * @param provider The provider
+ * @return The corresponding [MediaDataSource]
+ */
+ private fun getDataSource(provider: Provider) = getDataSource(provider.type, provider.typeId)
+
+ /**
+ * Find the [MediaDataSource] that handles the given URIs and call the given predicate on it.
+ *
+ * @param uris The URIs to check
+ * @param predicate The predicate to call on the [MediaDataSource]
+ * @return A flow containing the result of the predicate. It will emit a not found error if
+ * no [MediaDataSource] can handle the given URIs
+ */
+ private fun <T> withMediaItemsDataSourceFlow(
+ vararg uris: Uri, predicate: MediaDataSource.() -> Flow<RequestStatus<T>>
+ ) = allProvidersToDataSource.flatMapLatest {
+ it.firstOrNull { (_, dataSource) ->
+ uris.all { uri -> dataSource.isMediaItemCompatible(uri) }
+ }?.second?.predicate() ?: flowOf(RequestStatus.Error(RequestStatus.Error.Type.NOT_FOUND))
+ }
+
+ /**
+ * Find the [MediaDataSource] that handles the given URIs and call the given predicate on it.
+ *
+ * @param uris The URIs to check
+ * @param predicate The predicate to call on the [MediaDataSource]
+ * @return A [RequestStatus] containing the result of the predicate. It will return a not found
+ * error if no [MediaDataSource] can handle the given URIs
+ */
+ private suspend fun <T> withMediaItemsDataSource(
+ vararg uris: Uri, predicate: suspend MediaDataSource.() -> RequestStatus<T>
+ ) = allProvidersToDataSource.value.firstOrNull { (_, dataSource) ->
+ uris.all { uri -> dataSource.isMediaItemCompatible(uri) }
+ }?.second?.predicate() ?: RequestStatus.Error(RequestStatus.Error.Type.NOT_FOUND)
+
+ companion object {
+ private const val LOCAL_PROVIDER_ID = 0L
+ }
}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/AddOrRemoveFromPlaylistsViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/AddOrRemoveFromPlaylistsViewModel.kt
index bffbdd2..8375b19 100644
--- a/app/src/main/java/org/lineageos/twelve/viewmodels/AddOrRemoveFromPlaylistsViewModel.kt
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/AddOrRemoveFromPlaylistsViewModel.kt
@@ -14,6 +14,7 @@
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
import org.lineageos.twelve.models.RequestStatus
class AddOrRemoveFromPlaylistsViewModel(application: Application) : AudioViewModel(application) {
@@ -29,4 +30,15 @@
SharingStarted.WhileSubscribed(),
RequestStatus.Loading()
)
+
+ /**
+ * Create a new playlist in the same provider as the audio.
+ */
+ fun createPlaylist(name: String) = viewModelScope.launch {
+ audioUri.value?.let {
+ mediaRepository.getProviderOfMediaItems(it)?.let { provider ->
+ mediaRepository.createPlaylist(provider, name)
+ }
+ }
+ }
}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/ManageProviderViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/ManageProviderViewModel.kt
new file mode 100644
index 0000000..e9fcb16
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/ManageProviderViewModel.kt
@@ -0,0 +1,162 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import android.os.Bundle
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.lineageos.twelve.models.ProviderType
+import org.lineageos.twelve.models.RequestStatus
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ManageProviderViewModel(application: Application) : ProvidersViewModel(application) {
+ /**
+ * The provider identifiers to manage.
+ */
+ private val providerIds = MutableStateFlow<Pair<ProviderType, Long>?>(null)
+
+ /**
+ * The user defined provider type. The one in [providerIds] will always take
+ * precedence over this.
+ */
+ private val _selectedProviderType = MutableStateFlow<ProviderType?>(null)
+
+ /**
+ * Whether we're managing an existing provider or adding a new one.
+ */
+ val inEditMode = providerIds
+ .mapLatest { it != null }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ started = SharingStarted.Eagerly,
+ initialValue = false
+ )
+
+ /**
+ * The provider to manage.
+ */
+ val provider = providerIds.flatMapLatest {
+ it?.let { providerIds ->
+ mediaRepository.provider(
+ providerIds.first, providerIds.second
+ ).mapLatest { provider ->
+ provider?.let {
+ RequestStatus.Success(it)
+ } ?: RequestStatus.Error(RequestStatus.Error.Type.NOT_FOUND)
+ }
+ } ?: flowOf(RequestStatus.Success(null))
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = RequestStatus.Success(null)
+ )
+
+ /**
+ * The [Bundle] containing the arguments of the provider to manage.
+ */
+ private val providerArguments = providerIds
+ .filterNotNull()
+ .flatMapLatest {
+ mediaRepository.providerArguments(it.first, it.second)
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = null
+ )
+
+ /**
+ * The provider type.
+ */
+ private val providerType = combine(
+ _selectedProviderType,
+ provider,
+ ) { selectedProviderType, provider ->
+ when (provider) {
+ is RequestStatus.Success -> {
+ provider.data?.type
+ }
+
+ else -> null
+ } ?: selectedProviderType
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = null
+ )
+
+ /**
+ * The provider type and the arguments of the provider to manage.
+ */
+ val providerTypeWithArguments = combine(
+ providerType,
+ providerArguments,
+ ) { providerType, providerArguments ->
+ providerType to providerArguments
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = null to null
+ )
+
+ /**
+ * Set the provider to manage. When null, we're adding a new provider.
+ */
+ fun setProvider(provider: Pair<ProviderType, Long>?) {
+ providerIds.value = provider
+ }
+
+ fun setProviderType(providerType: ProviderType?) {
+ _selectedProviderType.value = providerType
+ }
+
+ /**
+ * Add a new provider.
+ */
+ fun addProvider(
+ providerType: ProviderType, name: String, arguments: Bundle
+ ) = viewModelScope.launch {
+ mediaRepository.addProvider(providerType, name, arguments)
+ }
+
+ /**
+ * Update the provider.
+ */
+ fun updateProvider(name: String, arguments: Bundle) = viewModelScope.launch {
+ val (providerType, providerTypeId) = providerIds.value ?: return@launch
+
+ mediaRepository.updateProvider(providerType, providerTypeId, name, arguments)
+ }
+
+ /**
+ * Delete the provider.
+ */
+ fun deleteProvider() = viewModelScope.launch {
+ val (providerType, providerTypeId) = providerIds.value ?: return@launch
+
+ mediaRepository.deleteProvider(providerType, providerTypeId)
+ }
+}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/PlaylistsViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/PlaylistsViewModel.kt
index 4905629..07ec2c5 100644
--- a/app/src/main/java/org/lineageos/twelve/viewmodels/PlaylistsViewModel.kt
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/PlaylistsViewModel.kt
@@ -24,6 +24,6 @@
)
fun createPlaylist(name: String) = viewModelScope.launch {
- mediaRepository.createPlaylist(name)
+ mediaRepository.createPlaylist(mediaRepository.navigationProvider.value, name)
}
}
diff --git a/app/src/main/java/org/lineageos/twelve/viewmodels/ProvidersViewModel.kt b/app/src/main/java/org/lineageos/twelve/viewmodels/ProvidersViewModel.kt
new file mode 100644
index 0000000..283cded
--- /dev/null
+++ b/app/src/main/java/org/lineageos/twelve/viewmodels/ProvidersViewModel.kt
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.twelve.viewmodels
+
+import android.app.Application
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import org.lineageos.twelve.models.Provider
+import org.lineageos.twelve.models.RequestStatus
+
+open class ProvidersViewModel(application: Application) : TwelveViewModel(application) {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val providers = mediaRepository.allProviders
+ .mapLatest { RequestStatus.Success(it) }
+ .flowOn(Dispatchers.IO)
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ RequestStatus.Loading()
+ )
+
+ val navigationProvider = mediaRepository.navigationProvider
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ null,
+ )
+
+ fun setNavigationProvider(provider: Provider) {
+ mediaRepository.setNavigationProvider(provider)
+ }
+}
diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml
new file mode 100644
index 0000000..e1c06cc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ SPDX-FileCopyrightText: Material Design Authors / Google LLC
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="#000000"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml
new file mode 100644
index 0000000..1c9847b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_delete.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ SPDX-FileCopyrightText: Material Design Authors / Google LLC
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="#000000"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_sailing.xml b/app/src/main/res/drawable/ic_sailing.xml
new file mode 100644
index 0000000..6e8ef53
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sailing.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ SPDX-FileCopyrightText: Material Design Authors / Google LLC
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="#000000"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M120,540L440,80L440,540L120,540ZM273,460L360,460L360,335L273,460ZM500,540Q512,512 526,442Q540,372 540,300Q540,228 526.5,152Q513,76 500,40Q561,58 621.5,107Q682,156 730.5,224Q779,292 809.5,373.5Q840,455 840,540L500,540ZM604,460L752,460Q735,383 696.5,319Q658,255 615,210Q617,231 618.5,253.5Q620,276 620,300Q620,347 615.5,387Q611,427 604,460ZM360,760Q324,760 293,743Q262,726 240,700Q226,715 209.5,728Q193,741 173,749Q138,723 113.5,684.5Q89,646 80,600L880,600Q871,646 846.5,684.5Q822,723 787,749Q767,741 750.5,728Q734,715 720,700Q697,726 666.5,743Q636,760 600,760Q564,760 533,743Q502,726 480,700Q458,726 427,743Q396,760 360,760ZM80,920L80,840L120,840Q152,840 182.5,830Q213,820 240,800Q267,820 297.5,829.5Q328,839 360,839Q392,839 422,829.5Q452,820 480,800Q507,820 537.5,829.5Q568,839 600,839Q632,839 662,829.5Q692,820 720,800Q748,820 778,830Q808,840 840,840L880,840L880,920L840,920Q809,920 779,912.5Q749,905 720,890Q691,905 661,912.5Q631,920 600,920Q569,920 539,912.5Q509,905 480,890Q451,905 421,912.5Q391,920 360,920Q329,920 299,912.5Q269,905 240,890Q211,905 181,912.5Q151,920 120,920L80,920ZM360,460L360,460L360,460ZM604,460Q604,460 604,460Q604,460 604,460Q604,460 604,460Q604,460 604,460Q604,460 604,460Q604,460 604,460Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml
new file mode 100644
index 0000000..953342e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_save.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ SPDX-FileCopyrightText: Material Design Authors / Google LLC
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="#000000"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M840,280L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z" />
+
+</vector>
diff --git a/app/src/main/res/drawable/ic_shelves.xml b/app/src/main/res/drawable/ic_shelves.xml
new file mode 100644
index 0000000..0a7a754
--- /dev/null
+++ b/app/src/main/res/drawable/ic_shelves.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ SPDX-FileCopyrightText: Material Design Authors / Google LLC
+ SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:tint="#000000"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M120,920L120,40L200,40L200,120L760,120L760,40L840,40L840,920L760,920L760,840L200,840L200,920L120,920ZM200,440L280,440L280,280L520,280L520,440L760,440L760,200L200,200L200,440ZM200,760L440,760L440,600L680,600L680,760L760,760L760,520L200,520L200,760ZM360,440L440,440L440,360L360,360L360,440ZM520,760L600,760L600,680L520,680L520,760ZM360,440L360,440L440,440L440,440L360,440ZM520,760L520,760L600,760L600,760L520,760Z" />
+
+</vector>
diff --git a/app/src/main/res/layout/argument_item.xml b/app/src/main/res/layout/argument_item.xml
new file mode 100644
index 0000000..41dc3f2
--- /dev/null
+++ b/app/src/main/res/layout/argument_item.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingTop="16dp">
+
+ <com.google.android.material.checkbox.MaterialCheckBox
+ android:id="@+id/booleanMaterialCheckBox"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="gone" />
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/stringTextInputLayout"
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="gone">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="text"
+ android:labelFor="@+id/providerTypeTextInputLayout" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+</FrameLayout>
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
index 508a840..f768670 100644
--- a/app/src/main/res/layout/fragment_main.xml
+++ b/app/src/main/res/layout/fragment_main.xml
@@ -5,6 +5,7 @@
-->
<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">
@@ -20,7 +21,20 @@
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
- android:layout_height="wrap_content" />
+ android:layout_height="wrap_content"
+ tools:title="@string/app_name" >
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/providerMaterialButton"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="end"
+ android:layout_marginHorizontal="16dp"
+ tools:icon="@drawable/ic_shelves"
+ tools:text="Pixel 7 Pro" />
+
+ </com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
diff --git a/app/src/main/res/layout/fragment_manage_provider.xml b/app/src/main/res/layout/fragment_manage_provider.xml
new file mode 100644
index 0000000..fb9f469
--- /dev/null
+++ b/app/src/main/res/layout/fragment_manage_provider.xml
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true"
+ app:liftOnScrollTargetViewId="@+id/nestedScrollView">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/confirmMaterialButton"
+ style="@style/Widget.Material3.Button.IconButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical|end"
+ android:layout_marginEnd="8dp"
+ android:contentDescription="@string/add_provider_action"
+ app:icon="@drawable/ic_save" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/deleteMaterialButton"
+ style="@style/Widget.Material3.Button.IconButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_vertical|end"
+ android:contentDescription="@string/delete_provider_action"
+ android:visibility="gone"
+ app:icon="@drawable/ic_delete" />
+
+ </com.google.android.material.appbar.MaterialToolbar>
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/nestedScrollView"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:layout_marginHorizontal="16dp">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/providerNameTextInputLayout"
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/provider_name">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="text" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/providerTypeTextInputLayout"
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:hint="@string/provider_type">
+
+ <com.google.android.material.textfield.MaterialAutoCompleteTextView
+ android:id="@+id/providerTypeAutoCompleteTextView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="none" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/argumentsRecyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/fragment_provider_selector_dialog.xml b/app/src/main/res/layout/fragment_provider_selector_dialog.xml
new file mode 100644
index 0000000..a76ee25
--- /dev/null
+++ b/app/src/main/res/layout/fragment_provider_selector_dialog.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurfaceContainer"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="18dp"
+ android:layout_marginTop="6dp"
+ android:gravity="center_vertical"
+ android:orientation="horizontal">
+
+ <TextView
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/providers"
+ android:textAppearance="?attr/textAppearanceTitleLarge"
+ android:textColor="?attr/colorOnSurface" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/addProviderMaterialButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/add_provider_action"
+ app:icon="@drawable/ic_add" />
+
+ </LinearLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/recyclerView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:spanCount="1" />
+
+</LinearLayout>
diff --git a/app/src/main/res/navigation/fragment_main.xml b/app/src/main/res/navigation/fragment_main.xml
index e279c3d..f314846 100644
--- a/app/src/main/res/navigation/fragment_main.xml
+++ b/app/src/main/res/navigation/fragment_main.xml
@@ -13,9 +13,11 @@
<include app:graph="@navigation/fragment_album" />
<include app:graph="@navigation/fragment_artist" />
<include app:graph="@navigation/fragment_audio_bottom_sheet_dialog" />
+ <include app:graph="@navigation/fragment_manage_provider" />
<include app:graph="@navigation/fragment_now_playing" />
<include app:graph="@navigation/fragment_now_playing_stats_dialog" />
<include app:graph="@navigation/fragment_playlist" />
+ <include app:graph="@navigation/fragment_provider_selector_dialog" />
<fragment
android:id="@+id/mainFragment"
@@ -55,6 +57,14 @@
app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" />
+ <action
+ android:id="@+id/action_mainFragment_to_fragment_provider_selector_dialog"
+ app:destination="@+id/fragment_provider_selector_dialog"
+ app:enterAnim="@anim/nav_default_enter_anim"
+ app:exitAnim="@anim/nav_default_exit_anim"
+ app:popEnterAnim="@anim/nav_default_pop_enter_anim"
+ app:popExitAnim="@anim/nav_default_pop_exit_anim" />
+
</fragment>
</navigation>
diff --git a/app/src/main/res/navigation/fragment_manage_provider.xml b/app/src/main/res/navigation/fragment_manage_provider.xml
new file mode 100644
index 0000000..5893491
--- /dev/null
+++ b/app/src/main/res/navigation/fragment_manage_provider.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<navigation 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:id="@+id/fragment_manage_provider"
+ app:startDestination="@id/manageProviderFragment">
+
+ <fragment
+ android:id="@+id/manageProviderFragment"
+ android:name="org.lineageos.twelve.fragments.ManageProviderFragment"
+ tools:layout="@layout/fragment_manage_provider" />
+
+</navigation>
diff --git a/app/src/main/res/navigation/fragment_provider_selector_dialog.xml b/app/src/main/res/navigation/fragment_provider_selector_dialog.xml
new file mode 100644
index 0000000..e218b33
--- /dev/null
+++ b/app/src/main/res/navigation/fragment_provider_selector_dialog.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ SPDX-FileCopyrightText: 2024 The LineageOS Project
+ SPDX-License-Identifier: Apache-2.0
+-->
+<navigation 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:id="@+id/fragment_provider_selector_dialog"
+ app:startDestination="@id/providerSelectorDialogFragment">
+
+ <dialog
+ android:id="@+id/providerSelectorDialogFragment"
+ android:name="org.lineageos.twelve.fragments.ProviderSelectorDialogFragment"
+ android:label="@string/providers"
+ tools:layout="@layout/fragment_provider_selector_dialog">
+
+ <action
+ android:id="@+id/action_providerSelectorDialogFragment_to_fragment_manage_provider"
+ app:destination="@+id/fragment_manage_provider"
+ app:enterAnim="@anim/nav_default_enter_anim"
+ app:exitAnim="@anim/nav_default_exit_anim"
+ app:popEnterAnim="@anim/nav_default_pop_enter_anim"
+ app:popExitAnim="@anim/nav_default_pop_exit_anim" />
+
+ </dialog>
+
+</navigation>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index feb399d..77b77f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -104,9 +104,29 @@
<string name="audio_bitrate_unknown">Unknown</string>
<string name="audio_bitrate_format">%1$s kbps</string>
+ <!-- Provider types -->
+ <string name="provider_type_local">Local</string>
+ <string name="provider_type_subsonic" translatable="false">Subsonic / OpenSubsonic</string>
+
<!-- Provider arguments -->
<string name="provider_argument_server">Server</string>
<string name="provider_argument_username">Username</string>
<string name="provider_argument_password">Password</string>
<string name="provider_argument_use_legacy_authentication">Use legacy authentication</string>
+
+ <!-- Provider selector dialog fragment -->
+ <string name="providers">Providers</string>
+ <string name="add_provider_action">Add</string>
+
+ <!-- Manage provider fragment -->
+ <string name="add_provider">Add provider</string>
+ <string name="manage_provider">Manage provider</string>
+ <string name="save_provider_action">Save</string>
+ <string name="delete_provider_action">Delete</string>
+ <string name="delete_provider_confirmation">Are you sure you want to delete this provider?</string>
+ <string name="provider_name">Name</string>
+ <string name="provider_name_error">Enter a name</string>
+ <string name="provider_type">Provider type</string>
+ <string name="provider_type_error">Select a provider type</string>
+ <string name="missing_provider_arguments">The following arguments are required: %1$s</string>
</resources>