diff --git a/Licenses_and_permissions.md b/Licenses_and_permissions.md index cc883a75..5cb8c377 100644 --- a/Licenses_and_permissions.md +++ b/Licenses_and_permissions.md @@ -30,12 +30,12 @@ Apache License 2.0 [com.github.skydoves](https://github.com/skydoves/Only/blob/master/LICENSE) Apache License 2.0 -[com.github.xabaras](https://github.com/xabaras/RecyclerViewSwipeDecorator/blob/master/LICENSE) Apache License 2.0 +[//]: # ([com.github.xabaras](https://github.com/xabaras/RecyclerViewSwipeDecorator/blob/master/LICENSE) Apache License 2.0) -[com.annimon](https://github.com/aNNiMON/Lightweight-Stream-API/blob/master/LICENSE) Apache License 2.0 +[//]: # ([com.annimon](https://github.com/aNNiMON/Lightweight-Stream-API/blob/master/LICENSE) Apache License 2.0) [com.github.mfietz](https://github.com/mfietz/fyydlin/blob/master/LICENSE) Apache License 2.0 -[javax.inject](https://github.com/javax-inject/javax-inject) Apache License 2.0 +[//]: # ([javax.inject](https://github.com/javax-inject/javax-inject) Apache License 2.0) [org.conscrypt](https://github.com/google/conscrypt/blob/master/LICENSE) Apache License 2.0 diff --git a/README.md b/README.md index 0edd4a39..77a71b74 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,16 @@ This project was developed from a fork of [AntennaPod]( - + + + + + + () - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - listView.divider = null + class LicensesFragment : Fragment() { + private val licenses = mutableStateListOf() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val composeView = ComposeView(requireContext()).apply { setContent { CustomTheme(requireContext()) { MainView() } } } lifecycleScope.launch(Dispatchers.IO) { licenses.clear() val stream = requireContext().assets.open("licenses.xml") @@ -83,29 +94,42 @@ class AboutFragment : PreferenceFragmentCompat() { for (i in 0 until libraryList.length) { val lib = libraryList.item(i).attributes licenses.add(LicenseItem(lib.getNamedItem("name").textContent, - String.format("By %s, %s license", lib.getNamedItem("author").textContent, lib.getNamedItem("license").textContent), - "", lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent)) + String.format("By %s, %s license", lib.getNamedItem("author").textContent, lib.getNamedItem("license").textContent), lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent)) } - withContext(Dispatchers.Main) { listAdapter = SimpleIconListAdapter(requireContext(), licenses) } }.invokeOnCompletion { throwable -> if (throwable!= null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show() } + return composeView } - private class LicenseItem(title: String, subtitle: String, imageUrl: String, val licenseUrl: String, val licenseTextFile: String) - : SimpleIconListAdapter.ListItem(title, subtitle, imageUrl) - - override fun onListItemClick(l: ListView, v: View, position: Int, id: Long) { - super.onListItemClick(l, v, position, id) - - val item = licenses[position] - val items = arrayOf("View website", "View license") - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.title) - .setItems(items) { _: DialogInterface?, which: Int -> - when (which) { - 0 -> openInBrowser(requireContext(), item.licenseUrl) - 1 -> showLicenseText(item.licenseTextFile) + @Composable + fun MainView() { + val lazyListState = rememberLazyListState() + val textColor = MaterialTheme.colorScheme.onSurface + var showDialog by remember { mutableStateOf(false) } + var curLicenseIndex by remember { mutableIntStateOf(-1) } + if (showDialog) Dialog(onDismissRequest = { showDialog = false }) { + Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(licenses[curLicenseIndex].title, color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Row { + Button(onClick = { openInBrowser(requireContext(), licenses[curLicenseIndex].licenseUrl) }) { Text("View website") } + Spacer(Modifier.weight(1f)) + Button(onClick = { showLicenseText(licenses[curLicenseIndex].licenseTextFile) }) { Text("View license") } + } } - }.show() + } + } + LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + itemsIndexed(licenses) { index, item -> + Column(Modifier.clickable(onClick = { + curLicenseIndex = index + showDialog = true + })) { + Text(item.title, color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text(item.subtitle, color = textColor, style = MaterialTheme.typography.bodySmall) + } + } + } } private fun showLicenseText(licenseTextFile: String) { @@ -122,23 +146,7 @@ class AboutFragment : PreferenceFragmentCompat() { super.onStart() (activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.licenses) } - } - class SimpleIconListAdapter(private val context: Context, private val listItems: List) - : ArrayAdapter(context, R.layout.simple_icon_list_item, listItems) { - - override fun getView(position: Int, view: View?, parent: ViewGroup): View { - var view = view - if (view == null) view = View.inflate(context, R.layout.simple_icon_list_item, null) - - val item: ListItem = listItems[position] - val binding = SimpleIconListItemBinding.bind(view!!) - binding.title.text = item.title - binding.subtitle.text = item.subtitle - binding.icon.load(item.imageUrl) - return view - } - - open class ListItem(val title: String, val subtitle: String, val imageUrl: String) + private class LicenseItem(val title: String, val subtitle: String, val licenseUrl: String, val licenseTextFile: String) } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt index 7dae28d1..09fe95cf 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt @@ -353,6 +353,7 @@ object Feeds { // Look for new or updated Items for (idx in newFeed.episodes.indices) { val episode = newFeed.episodes[idx] + if ((episode.media?.duration?: 0) < 1000) continue if (episode.getPubDate() <= priorMostRecentDate || episode.media?.getStreamUrl() == priorMostRecent?.media?.getStreamUrl()) continue Logd(TAG, "Found new episode: ${episode.title}") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt index 14ee7f23..f0f4887b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt @@ -91,6 +91,9 @@ class EpisodeMedia: EmbeddedRealmObject, Playable { @Ignore var forceVideo by mutableStateOf(false) + @Ignore + var audioUrl = "" + /* Used for loading item when restoring from parcel. */ // var episodeId: Long = 0 // private set diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/BugReportActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/BugReportActivity.kt index 45fa96b7..23420808 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/BugReportActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/BugReportActivity.kt @@ -98,12 +98,7 @@ class BugReportActivity : AppCompatActivity() { try { val authority = getString(R.string.provider_authority) val fileUri = FileProvider.getUriForFile(this, authority, filename) - - IntentBuilder(this) - .setType("text/*") - .addStream(fileUri) - .setChooserTitle(R.string.share_file_label) - .startChooser() + IntentBuilder(this).setType("text/*").addStream(fileUri).setChooserTitle(R.string.share_file_label).startChooser() } catch (e: Exception) { e.printStackTrace() val strResId = R.string.log_file_share_exception diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt index 3ebecba6..21c8ac7a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt @@ -154,7 +154,6 @@ class VideoplayerActivity : CastEnabledActivity() { if (::videoEpisodeFragment.isInitialized) videoEpisodeFragment.setForVideoMode() } - override fun onResume() { super.onResume() setForVideoMode() @@ -180,7 +179,7 @@ class VideoplayerActivity : CastEnabledActivity() { public override fun onUserLeaveHint() { super.onUserLeaveHint() - if (!isInPictureInPictureMode()) compatEnterPictureInPicture() + if (!isInPictureInPictureMode) compatEnterPictureInPicture() } override fun onStart() { @@ -230,13 +229,13 @@ class VideoplayerActivity : CastEnabledActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { super.onCreateOptionsMenu(menu) - requestCastButton(menu) + // TODO: consider enable this +// requestCastButton(menu) val inflater = menuInflater inflater.inflate(R.menu.mediaplayer, menu) return true } - override fun onPrepareOptionsMenu(menu: Menu): Boolean { super.onPrepareOptionsMenu(menu) @@ -501,7 +500,7 @@ class VideoplayerActivity : CastEnabledActivity() { private val onVideoviewTouched = View.OnTouchListener { v: View, event: MotionEvent -> Logd(TAG, "onVideoviewTouched ${event.action}") if (event.action != MotionEvent.ACTION_DOWN) return@OnTouchListener false - if (requireActivity().isInPictureInPictureMode()) return@OnTouchListener true + if (requireActivity().isInPictureInPictureMode) return@OnTouchListener true videoControlsHider.removeCallbacks(hideVideoControls) Logd(TAG, "onVideoviewTouched $videoControlsVisible ${System.currentTimeMillis() - lastScreenTap}") if (System.currentTimeMillis() - lastScreenTap < 300) { @@ -594,7 +593,7 @@ class VideoplayerActivity : CastEnabledActivity() { override fun onStop() { super.onStop() cancelFlowEvents() - if (!requireActivity().isInPictureInPictureMode()) videoControlsHider.removeCallbacks(hideVideoControls) + if (!requireActivity().isInPictureInPictureMode) videoControlsHider.removeCallbacks(hideVideoControls) // Controller released; we will not receive buffering updates binding.progressBar.visibility = View.GONE } @@ -731,8 +730,7 @@ class VideoplayerActivity : CastEnabledActivity() { invalidateOptionsMenu(activity) } } - if (!itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData, - "text/html", "utf-8", "about:blank") + if (!itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank") itemsLoaded = true } } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) @@ -746,7 +744,6 @@ class VideoplayerActivity : CastEnabledActivity() { } } - private fun setupView() { showTimeLeft = shouldShowRemainingTime() Logd(TAG, "setupView showTimeLeft: $showTimeLeft") @@ -802,8 +799,7 @@ class VideoplayerActivity : CastEnabledActivity() { (curMedia as? EpisodeMedia)?.forceVideo = false (activity as? VideoplayerActivity)?.finish() } - if (!itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData, - "text/html", "utf-8", "about:blank") + if (!itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank") } fun toggleVideoControlsVisibility() { @@ -840,19 +836,16 @@ class VideoplayerActivity : CastEnabledActivity() { }) } - fun onRewind() { playbackService?.mPlayer?.seekDelta(-rewindSecs * 1000) setupVideoControlsToggler() } - fun onPlayPause() { playPause() setupVideoControlsToggler() } - fun onFastForward() { playbackService?.mPlayer?.seekDelta(fastForwardSecs * 1000) setupVideoControlsToggler() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt index c15d8889..82d1b222 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt @@ -254,18 +254,3 @@ fun AutoCompleteTextField(suggestions: List) { } } } - -@Composable -fun InputChipExample(text: String, onDismiss: () -> Unit) { - var enabled by remember { mutableStateOf(true) } - if (!enabled) return - - InputChip(onClick = { - onDismiss() - enabled = !enabled - }, label = { Text(text) }, selected = enabled, - trailingIcon = { - Icon(Icons.Default.Delete, contentDescription = "Localized description", Modifier.size(InputChipDefaults.AvatarSize)) - }, - ) -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index 806eb9e2..afa55315 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -518,7 +518,7 @@ class AudioPlayerFragment : Fragment() { } } }, update = { webView -> - Logd(TAG, "AndroidView update: $cleanedNotes") +// Logd(TAG, "AndroidView update: $cleanedNotes") webView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") }) if (displayedChapterIndex >= 0) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt index cde2a22c..0ca32c07 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt @@ -625,9 +625,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { episode = realm.query(Episode::class).query("id == ${item_.id}").first().find() } - /** - * Displays information about an Episode (FeedItem) and actions. - */ class EpisodeHomeFragment : Fragment() { private var _binding: EpisodeHomeFragmentBinding? = null private val binding get() = _binding!! @@ -647,7 +644,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { private var tts: TextToSpeech? = null private var ttsReady = false - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) _binding = EpisodeHomeFragmentBinding.inflate(inflater, container, false) @@ -689,7 +685,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { val htmlSource = fetchHtmlSource(url) val article = Readability4JExtended(episode?.link!!, htmlSource).parse() readerText = article.textContent -// Log.d(TAG, "readability4J: ${article.textContent}") readerhtml = article.contentWithDocumentsCharsetOrUtf8 } else { readerhtml = episode!!.transcript @@ -698,26 +693,18 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { if (!readerhtml.isNullOrEmpty()) { val shownotesCleaner = ShownotesCleaner(requireContext()) cleanedNotes = shownotesCleaner.processShownotes(readerhtml!!, 0) - episode = upsertBlk(episode!!) { - it.setTranscriptIfLonger(readerhtml) - } -// persistEpisode(episode) + episode = upsertBlk(episode!!) { it.setTranscriptIfLonger(readerhtml) } } } } if (!cleanedNotes.isNullOrEmpty()) { if (!ttsReady) initializeTTS(requireContext()) withContext(Dispatchers.Main) { - binding.readerView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes ?: "No notes", - "text/html", "UTF-8", null) + binding.readerView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes ?: "No notes", "text/html", "UTF-8", null) binding.readerView.visibility = View.VISIBLE binding.webView.visibility = View.GONE } - } else { - withContext(Dispatchers.Main) { - Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() - } - } + } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() } } } @@ -767,9 +754,9 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { Logd(TAG, "onPrepareMenu called") val textSpeech = menu.findItem(R.id.text_speech) textSpeech?.isVisible = readMode && tts != null - if (textSpeech?.isVisible == true) { - if (ttsPlaying) textSpeech.setIcon(R.drawable.ic_pause) else textSpeech.setIcon(R.drawable.ic_play_24dp) - } + if (textSpeech?.isVisible == true && ttsPlaying) + textSpeech.setIcon(R.drawable.ic_pause) else textSpeech.setIcon(R.drawable.ic_play_24dp) + menu.findItem(R.id.share_notes)?.isVisible = readMode menu.findItem(R.id.switchJS)?.isVisible = !readMode val btn = menu.findItem(R.id.switch_home) @@ -812,7 +799,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } else ttsPlaying = false updateAppearance() } else Toast.makeText(context, R.string.tts_not_available, Toast.LENGTH_LONG).show() - return true } R.id.share_notes -> { @@ -829,14 +815,11 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } return true } - else -> { - return episode != null - } + else -> return episode != null } } } - override fun onResume() { super.onResume() updateAppearance() @@ -850,7 +833,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { webview.destroy() } - override fun onDestroyView() { + override fun onDestroyView() { Logd(TAG, "onDestroyView") cleatWebview(binding.webView) cleatWebview(binding.readerView) @@ -861,15 +844,12 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { super.onDestroyView() } - private fun updateAppearance() { if (episode == null) { Logd(TAG, "updateAppearance currentItem is null") return } -// onPrepareOptionsMenu(toolbar.menu) toolbar.invalidateMenu() -// menuProvider.onPrepareMenu(toolbar.menu) } companion object { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt index ce5d4a41..92ecbae4 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt @@ -196,7 +196,11 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { @VisibleForTesting const val PREF_NAME: String = "NavDrawerPrefs" private var prefs: SharedPreferences? = null - var feedCount: Int = 0 + var feedCount: Int = -1 + get() { + if (field < 0) field = getFeedCount() + return field + } fun getSharedPrefs(context: Context) { if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt index b1b61adb..aab9b6e8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt @@ -1,48 +1,101 @@ package ac.mdiq.podcini.ui.fragment +import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.AddfeedBinding +import ac.mdiq.podcini.databinding.ComposeFragmentBinding import ac.mdiq.podcini.databinding.EditTextDialogBinding +import ac.mdiq.podcini.databinding.SelectCountryDialogBinding +import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient import ac.mdiq.podcini.net.feed.FeedUpdateManager import ac.mdiq.podcini.net.feed.searcher.* import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.isOPMLRestared import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.performRestore +import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.updateFeed import ac.mdiq.podcini.storage.model.EpisodeSortOrder import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.OpmlImportActivity +import ac.mdiq.podcini.ui.compose.CustomTheme +import ac.mdiq.podcini.ui.compose.NonlazyGrid +import ac.mdiq.podcini.ui.compose.OnlineFeedItem import ac.mdiq.podcini.ui.fragment.NavDrawerFragment.Companion.feedCount +import ac.mdiq.podcini.util.EventFlow +import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.Logd import android.content.* import android.net.Uri import android.os.Bundle +import android.util.DisplayMetrics import android.util.Log -import android.view.KeyEvent import android.view.LayoutInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.TextView +import android.widget.ArrayAdapter import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.Toolbar +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment - +import androidx.lifecycle.lifecycleScope +import coil.compose.AsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest +import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collectLatest +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.util.* +import java.util.concurrent.TimeUnit +import kotlin.Throws class OnlineSearchFragment : Fragment() { - private var _binding: AddfeedBinding? = null + val prefs: SharedPreferences by lazy { requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE) } + + private var _binding: ComposeFragmentBinding? = null private val binding get() = _binding!! private var mainAct: MainActivity? = null private var displayUpArrow = false + private var showError by mutableStateOf(false) + private var errorText by mutableStateOf("") + private var showPowerBy by mutableStateOf(false) + private var showRetry by mutableStateOf(false) + private var retryTextRes by mutableIntStateOf(0) + private var showGrid by mutableStateOf(false) + + private var numColumns by mutableIntStateOf(4) + private val searchResult = mutableStateListOf() + private val chooseOpmlImportPathLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> this.chooseOpmlImportPathResult(uri) } @@ -50,38 +103,23 @@ class OnlineSearchFragment : Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) - _binding = AddfeedBinding.inflate(inflater) + _binding = ComposeFragmentBinding.inflate(inflater) mainAct = activity as? MainActivity Logd(TAG, "fragment onCreateView") displayUpArrow = parentFragmentManager.backStackEntryCount != 0 if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) mainAct?.setupToolbarToggle(binding.toolbar, displayUpArrow) - binding.searchButton.setOnClickListener { performSearch() } - binding.searchVistaGuideButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(VistaGuidePodcastSearcher::class.java)) } - binding.searchItunesButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(ItunesPodcastSearcher::class.java)) } - binding.searchFyydButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(FyydPodcastSearcher::class.java)) } - binding.searchGPodderButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(GpodnetPodcastSearcher::class.java)) } - binding.searchPodcastIndexButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(PodcastIndexPodcastSearcher::class.java)) } - binding.combinedFeedSearchEditText.setOnEditorActionListener { _: TextView?, _: Int, _: KeyEvent? -> - performSearch() - true - } - binding.addViaUrlButton.setOnClickListener { showAddViaUrlDialog() } - binding.opmlImportButton.setOnClickListener { - try { chooseOpmlImportPathLauncher.launch("*/*") - } catch (e: ActivityNotFoundException) { - e.printStackTrace() - mainAct?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG) - } - } - binding.addLocalFolderButton.setOnClickListener { - try { addLocalFolderLauncher.launch(null) - } catch (e: ActivityNotFoundException) { - e.printStackTrace() - mainAct?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG) - } - } + val displayMetrics: DisplayMetrics = requireContext().resources.displayMetrics + val screenWidthDp: Float = displayMetrics.widthPixels / displayMetrics.density + if (screenWidthDp > 600) numColumns = 6 + + // Fill with dummy elements to have a fixed height and + // prevent the UI elements below from jumping on slow connections + for (i in 0 until NUM_SUGGESTIONS) searchResult.add(PodcastSearchResult.dummy()) + binding.mainView.setContent { CustomTheme(requireContext()) { MainView() } } + + loadToplist() if (isOPMLRestared && feedCount == 0) { AlertDialog.Builder(requireContext()) @@ -98,6 +136,161 @@ class OnlineSearchFragment : Fragment() { return binding.root } + @Composable + fun MainView() { + val textColor = MaterialTheme.colorScheme.onSurface + Column(Modifier.padding(horizontal = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + var queryText by remember { mutableStateOf("") } + fun performSearch() { + if (queryText.matches("http[s]?://.*".toRegex())) addUrl(queryText) + else mainAct?.loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, queryText)) + } + TextField(value = queryText, onValueChange = { queryText = it }, label = { Text(stringResource(R.string.search_podcast_hint)) }, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { performSearch() }), modifier = Modifier.weight(1f)) + Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_search), tint = textColor, contentDescription = "right_action_icon", + modifier = Modifier.width(40.dp).height(40.dp).padding(start = 5.dp).clickable(onClick = { performSearch() })) + } + QuickDiscoveryView() + Text(stringResource(R.string.advanced), color = textColor, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.add_podcast_by_url), color = textColor, modifier = Modifier.clickable(onClick = { showAddViaUrlDialog() })) + Text(stringResource(R.string.add_local_folder), color = textColor, modifier = Modifier.clickable(onClick = { + try { addLocalFolderLauncher.launch(null) + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + mainAct?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG) + } + })) + Text(stringResource(R.string.search_vistaguide_label), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(VistaGuidePodcastSearcher::class.java)) })) + Text(stringResource(R.string.search_itunes_label), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(ItunesPodcastSearcher::class.java)) })) + Text(stringResource(R.string.search_fyyd_label), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(FyydPodcastSearcher::class.java)) })) + Text(stringResource(R.string.gpodnet_search_hint), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(GpodnetPodcastSearcher::class.java)) })) + Text(stringResource(R.string.search_podcastindex_label), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(PodcastIndexPodcastSearcher::class.java)) })) + Text(stringResource(R.string.opml_add_podcast_label), color = textColor, modifier = Modifier.clickable(onClick = { + try { chooseOpmlImportPathLauncher.launch("*/*") + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + mainAct?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG) + } + })) + } + } + + override fun onStart() { + super.onStart() + procFlowEvents() + } + + override fun onStop() { + super.onStop() + cancelFlowEvents() + } + + @Composable + fun QuickDiscoveryView() { + val textColor = MaterialTheme.colorScheme.onSurface + val context = LocalContext.current + Column(modifier = Modifier.padding(vertical = 5.dp)) { + Row { + Text(stringResource(R.string.discover), color = textColor, fontWeight = FontWeight.Bold) + Spacer(Modifier.weight(1f)) + Text(stringResource(R.string.discover_more), color = textColor, modifier = Modifier.clickable(onClick = {(activity as MainActivity).loadChildFragment(DiscoveryFragment())})) + } + ConstraintLayout(modifier = Modifier.fillMaxWidth()) { + val (grid, error) = createRefs() + if (showGrid) NonlazyGrid(columns = numColumns, itemCount = searchResult.size, modifier = Modifier.fillMaxWidth().constrainAs(grid) { centerTo(parent) }) { index -> + AsyncImage(model = ImageRequest.Builder(context).data(searchResult[index].imageUrl) + .memoryCachePolicy(CachePolicy.ENABLED).placeholder(R.mipmap.ic_launcher).error(R.mipmap.ic_launcher).build(), + contentDescription = "imgvCover", modifier = Modifier.padding(top = 8.dp) + .clickable(onClick = { + Logd(TAG, "icon clicked!") + val podcast: PodcastSearchResult? = searchResult[index] + if (!podcast?.feedUrl.isNullOrEmpty()) { + val fragment: Fragment = OnlineFeedFragment.newInstance(podcast.feedUrl) + (activity as MainActivity).loadChildFragment(fragment) + } + })) + } + if (showError) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth().constrainAs(error) { centerTo(parent) }) { + Text(errorText, color = textColor) + if (showRetry) Button(onClick = { + prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply() + loadToplist() + }) { Text(stringResource(retryTextRes)) } + } + } + Text(stringResource(R.string.discover_powered_by_itunes), color = textColor, modifier = Modifier.align(Alignment.End)) + } + } + + private var eventSink: Job? = null + private fun cancelFlowEvents() { + eventSink?.cancel() + eventSink = null + } + private fun procFlowEvents() { + if (eventSink != null) return + eventSink = lifecycleScope.launch { + EventFlow.events.collectLatest { event -> + Logd(TAG, "Received event: ${event.TAG}") + when (event) { + is FlowEvent.DiscoveryDefaultUpdateEvent -> loadToplist() + else -> {} + } + } + } + } + + private fun loadToplist() { + showError = false + showPowerBy = true + showRetry = false + retryTextRes = R.string.retry_label + val loader = ItunesTopListLoader(requireContext()) + val countryCode: String = prefs.getString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, Locale.getDefault().country)!! + if (prefs.getBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, false)) { + showError = true + errorText = requireContext().getString(R.string.discover_is_hidden) + showPowerBy = false + showRetry = false + return + } + if (BuildConfig.FLAVOR == "free" && prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true) == true) { + showError = true + errorText = "" + showGrid = true + showRetry = true + retryTextRes = R.string.discover_confirm + showPowerBy = true + return + } + + lifecycleScope.launch { + try { + val searchResults_ = withContext(Dispatchers.IO) { loader.loadToplist(countryCode, NUM_SUGGESTIONS, getFeedList()) } + withContext(Dispatchers.Main) { + showError = false + if (searchResults_.isEmpty()) { + errorText = requireContext().getString(R.string.search_status_no_results) + showError = true + showGrid = false + } else { + showGrid = true + searchResult.clear() + searchResult.addAll(searchResults_) + } + } + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + showError = true + showGrid = false + showRetry = true + errorText = e.localizedMessage ?: "" + } + } + } + override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(KEY_UP_ARROW, displayUpArrow) super.onSaveInstanceState(outState) @@ -126,19 +319,6 @@ class OnlineSearchFragment : Fragment() { mainAct?.loadChildFragment(fragment) } - private fun performSearch() { - binding.combinedFeedSearchEditText.clearFocus() - val inVal = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - inVal.hideSoftInputFromWindow(binding.combinedFeedSearchEditText.windowToken, 0) - val query = binding.combinedFeedSearchEditText.text.toString() - if (query.matches("http[s]?://.*".toRegex())) { - addUrl(query) - return - } - mainAct?.loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query)) - binding.combinedFeedSearchEditText.post { binding.combinedFeedSearchEditText.setText("") } - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) retainInstance = true @@ -198,8 +378,301 @@ class OnlineSearchFragment : Fragment() { } } + class ItunesTopListLoader(private val context: Context) { + @Throws(JSONException::class, IOException::class) + fun loadToplist(country: String, limit: Int, subscribed: List): List { + val client = getHttpClient() + val feedString: String + var loadCountry = country + if (COUNTRY_CODE_UNSET == country) loadCountry = Locale.getDefault().country + feedString = try { getTopListFeed(client, loadCountry) + } catch (e: IOException) { if (COUNTRY_CODE_UNSET == country) getTopListFeed(client, "US") else throw e } + return removeSubscribed(parseFeed(feedString), subscribed, limit) + } + @Throws(IOException::class) + private fun getTopListFeed(client: OkHttpClient?, country: String): String { + val url = "https://itunes.apple.com/%s/rss/toppodcasts/limit=$NUM_LOADED/explicit=true/json" + Logd(TAG, "Feed URL " + String.format(url, country)) + val httpReq: Request.Builder = Request.Builder() + .cacheControl(CacheControl.Builder().maxStale(1, TimeUnit.DAYS).build()) + .url(String.format(url, country)) + client!!.newCall(httpReq.build()).execute().use { response -> + if (response.isSuccessful) return response.body!!.string() + if (response.code == 400) throw IOException("iTunes does not have data for the selected country.") + val prefix = context.getString(R.string.error_msg_prefix) + throw IOException(prefix + response) + } + } + @Throws(JSONException::class) + private fun parseFeed(jsonString: String): List { + val result = JSONObject(jsonString) + val feed: JSONObject + val entries: JSONArray + try { + feed = result.getJSONObject("feed") + entries = feed.getJSONArray("entry") + } catch (_: JSONException) { return ArrayList() } + val results: MutableList = ArrayList() + for (i in 0 until entries.length()) { + val json = entries.getJSONObject(i) + results.add(PodcastSearchResult.fromItunesToplist(json)) + } + return results + } + + companion object { + private val TAG: String = ItunesTopListLoader::class.simpleName ?: "Anonymous" + const val PREF_KEY_COUNTRY_CODE: String = "country_code" + const val PREF_KEY_HIDDEN_DISCOVERY_COUNTRY: String = "hidden_discovery_country" + const val PREF_KEY_NEEDS_CONFIRM: String = "needs_confirm" + const val PREFS: String = "CountryRegionPrefs" + const val COUNTRY_CODE_UNSET: String = "99" + private const val NUM_LOADED = 25 + + private fun removeSubscribed(suggestedPodcasts: List, subscribedFeeds: List, limit: Int): List { + val subscribedPodcastsSet: MutableSet = HashSet() + for (subscribedFeed in subscribedFeeds) { + if (subscribedFeed.title != null && subscribedFeed.author != null) + subscribedPodcastsSet.add(subscribedFeed.title!!.trim { it <= ' ' } + " - " + subscribedFeed.author!!.trim { it <= ' ' }) + } + val suggestedNotSubscribed: MutableList = ArrayList() + for (suggested in suggestedPodcasts) { + if (!subscribedPodcastsSet.contains(suggested.title.trim { it <= ' ' })) suggestedNotSubscribed.add(suggested) + if (suggestedNotSubscribed.size == limit) return suggestedNotSubscribed + } + return suggestedNotSubscribed + } + } + } + + class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener { + val prefs: SharedPreferences by lazy { requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE) } + + private var _binding: ComposeFragmentBinding? = null + private val binding get() = _binding!! + + private lateinit var toolbar: MaterialToolbar + + private var topList: List? = listOf() + + private var countryCode: String? = "US" + private var hidden = false + private var needsConfirm = false + + private var searchResults = mutableStateListOf() + private var errorText by mutableStateOf("") + private var retryQerry by mutableStateOf("") + private var showProgress by mutableStateOf(true) + private var noResultText by mutableStateOf("") + + /** + * Replace adapter data with provided search results from SearchTask. + * @param result List of Podcast objects containing search results + */ + private fun updateData(result: List) { + searchResults.clear() + if (result.isNotEmpty()) { + searchResults.addAll(result) + noResultText = "" + } else noResultText = getString(R.string.no_results_for_query) + showProgress = false + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + countryCode = prefs.getString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, Locale.getDefault().country) + hidden = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, false) + needsConfirm = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + // Inflate the layout for this fragment + _binding = ComposeFragmentBinding.inflate(inflater) + Logd(TAG, "fragment onCreateView") + binding.mainView.setContent { + CustomTheme(requireContext()) { + MainView() + } + } + + toolbar = binding.toolbar + toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } + toolbar.inflateMenu(R.menu.countries_menu) + val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item) + discoverHideItem.isChecked = hidden + toolbar.setOnMenuItemClickListener(this) + + loadToplist(countryCode) + return binding.root + } + + @Composable + fun MainView() { + val textColor = MaterialTheme.colorScheme.onSurface + ConstraintLayout(modifier = Modifier.fillMaxSize()) { + val (gridView, progressBar, empty, txtvError, butRetry, powered) = createRefs() + if (showProgress) CircularProgressIndicator(progress = {0.6f}, strokeWidth = 10.dp, modifier = Modifier.size(50.dp).constrainAs(progressBar) { centerTo(parent) }) + val lazyListState = rememberLazyListState() + if (searchResults.isNotEmpty()) LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) + .constrainAs(gridView) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + }, + verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(searchResults.size) { index -> + OnlineFeedItem(activity = activity as MainActivity, searchResults[index]) + } + } + if (searchResults.isEmpty()) Text(noResultText, color = textColor, modifier = Modifier.constrainAs(empty) { centerTo(parent) }) + if (errorText.isNotEmpty()) Text(errorText, color = textColor, modifier = Modifier.constrainAs(txtvError) { centerTo(parent) }) + if (retryQerry.isNotEmpty()) Button(modifier = Modifier.padding(16.dp).constrainAs(butRetry) { top.linkTo(txtvError.bottom)}, + onClick = { + if (needsConfirm) { + prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply() + needsConfirm = false + } + loadToplist(countryCode) + }, ) { + Text(stringResource(id = R.string.retry_label)) + } +// Text( getString(R.string.search_powered_by, searchProvider!!.name), color = Color.Black, style = MaterialTheme.typography.labelSmall, modifier = Modifier.background( +// Color.LightGray) +// .constrainAs(powered) { +// bottom.linkTo(parent.bottom) +// end.linkTo(parent.end) +// }) + } + } + + override fun onDestroy() { + _binding = null + searchResults.clear() + topList = null + super.onDestroy() + } + + private fun loadToplist(country: String?) { + searchResults.clear() + errorText = "" + retryQerry = "" + noResultText = "" + showProgress = true + if (hidden) { + errorText = resources.getString(R.string.discover_is_hidden) + showProgress = false + return + } + if (BuildConfig.FLAVOR == "free" && needsConfirm) { + errorText = "" + retryQerry = resources.getString(R.string.discover_confirm) + noResultText = "" + showProgress = false + return + } + + val loader = ItunesTopListLoader(requireContext()) + lifecycleScope.launch { + try { + val podcasts = withContext(Dispatchers.IO) { loader.loadToplist(country?:"", NUM_OF_TOP_PODCASTS, getFeedList()) } + withContext(Dispatchers.Main) { + showProgress = false + topList = podcasts + updateData(topList!!) + } + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + searchResults.clear() + errorText = e.message ?: "no error message" + retryQerry = " retry" + } + } + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onOptionsItemSelected(item)) return true + + val itemId = item.itemId + when (itemId) { + R.id.discover_hide_item -> { + item.isChecked = !item.isChecked + hidden = item.isChecked + prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply() + + EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent()) + loadToplist(countryCode) + return true + } + R.id.discover_countries_item -> { + val inflater = layoutInflater + val selectCountryDialogView = inflater.inflate(R.layout.select_country_dialog, null) + val builder = MaterialAlertDialogBuilder(requireContext()) + builder.setView(selectCountryDialogView) + + val countryCodeArray: List = listOf(*Locale.getISOCountries()) + val countryCodeNames: MutableMap = HashMap() + val countryNameCodes: MutableMap = HashMap() + for (code in countryCodeArray) { + val locale = Locale("", code) + val countryName = locale.displayCountry + countryCodeNames[code] = countryName + countryNameCodes[countryName] = code + } + + val countryNamesSort: MutableList = ArrayList(countryCodeNames.values) + countryNamesSort.sort() + + val dataAdapter = ArrayAdapter(this.requireContext(), android.R.layout.simple_list_item_1, countryNamesSort) + val scBinding = SelectCountryDialogBinding.bind(selectCountryDialogView) + val textInput = scBinding.countryTextInput + val editText = textInput.editText as? MaterialAutoCompleteTextView + editText!!.setAdapter(dataAdapter) + editText.setText(countryCodeNames[countryCode]) + editText.setOnClickListener { + if (editText.text.isNotEmpty()) { + editText.setText("") + editText.postDelayed({ editText.showDropDown() }, 100) + } + } + editText.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean -> + if (hasFocus) { + editText.setText("") + editText.postDelayed({ editText.showDropDown() }, 100) + } + } + + builder.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + val countryName = editText.text.toString() + if (countryNameCodes.containsKey(countryName)) { + countryCode = countryNameCodes[countryName] + val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item) + discoverHideItem.isChecked = false + hidden = false + } + + prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply() + prefs.edit().putString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, countryCode).apply() + + EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent()) + loadToplist(countryCode) + } + builder.setNegativeButton(R.string.cancel_label, null) + builder.show() + return true + } + else -> return false + } + } + + companion object { + private const val NUM_OF_TOP_PODCASTS = 25 + } + } + companion object { val TAG = OnlineSearchFragment::class.simpleName ?: "Anonymous" private const val KEY_UP_ARROW = "up_arrow" + + private const val NUM_SUGGESTIONS = 12 } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt deleted file mode 100644 index 0b1ae905..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt +++ /dev/null @@ -1,521 +0,0 @@ -package ac.mdiq.podcini.ui.fragment - -import ac.mdiq.podcini.BuildConfig -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.ComposeFragmentBinding -import ac.mdiq.podcini.databinding.SelectCountryDialogBinding -import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient -import ac.mdiq.podcini.net.feed.searcher.PodcastSearchResult -import ac.mdiq.podcini.storage.database.Feeds.getFeedList -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.compose.CustomTheme -import ac.mdiq.podcini.ui.compose.NonlazyGrid -import ac.mdiq.podcini.ui.compose.OnlineFeedItem -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import ac.mdiq.podcini.util.Logd -import android.content.Context -import android.content.DialogInterface -import android.content.SharedPreferences -import android.os.Bundle -import android.util.DisplayMetrics -import android.util.Log -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import androidx.appcompat.widget.Toolbar -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import coil.compose.AsyncImage -import coil.request.CachePolicy -import coil.request.ImageRequest -import com.google.android.material.appbar.MaterialToolbar -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.MaterialAutoCompleteTextView -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.CacheControl -import okhttp3.OkHttpClient -import okhttp3.Request -import org.json.JSONArray -import org.json.JSONException -import org.json.JSONObject -import java.io.IOException -import java.util.* -import java.util.concurrent.TimeUnit - -class QuickDiscoveryFragment : Fragment() { - val prefs: SharedPreferences by lazy { requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE) } - - private var showError by mutableStateOf(false) - private var errorText by mutableStateOf("") - private var showPowerBy by mutableStateOf(false) - private var showRetry by mutableStateOf(false) - private var retryTextRes by mutableIntStateOf(0) - private var showGrid by mutableStateOf(false) - - private var numColumns by mutableIntStateOf(4) - private val searchResult = mutableStateListOf() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - super.onCreateView(inflater, container, savedInstanceState) - Logd(TAG, "fragment onCreateView") - val composeView = ComposeView(requireContext()).apply { - setContent { - CustomTheme(requireContext()) { - MainView() - } - } - } - - val displayMetrics: DisplayMetrics = requireContext().resources.displayMetrics - val screenWidthDp: Float = displayMetrics.widthPixels / displayMetrics.density - if (screenWidthDp > 600) numColumns = 6 - - // Fill with dummy elements to have a fixed height and - // prevent the UI elements below from jumping on slow connections - val dummies: MutableList = ArrayList() - for (i in 0 until NUM_SUGGESTIONS) { - dummies.add(PodcastSearchResult.dummy()) - searchResult.add(PodcastSearchResult.dummy()) - } - loadToplist() - return composeView - } - - override fun onStart() { - super.onStart() - procFlowEvents() - } - - override fun onStop() { - super.onStop() - cancelFlowEvents() - } - - @Composable - fun MainView() { - val textColor = MaterialTheme.colorScheme.onSurface - val context = LocalContext.current - Column { - Row { - Text(stringResource(R.string.discover), color = textColor) - Spacer(Modifier.weight(1f)) - Text(stringResource(R.string.discover_more), color = textColor, modifier = Modifier.clickable(onClick = {(activity as MainActivity).loadChildFragment(DiscoveryFragment())})) - } - ConstraintLayout(modifier = Modifier.fillMaxWidth()) { - val (grid, error) = createRefs() - if (showGrid) NonlazyGrid(columns = numColumns, itemCount = searchResult.size, modifier = Modifier.fillMaxWidth().constrainAs(grid) { centerTo(parent) }) { index -> - AsyncImage(model = ImageRequest.Builder(context).data(searchResult[index].imageUrl) - .memoryCachePolicy(CachePolicy.ENABLED).placeholder(R.mipmap.ic_launcher).error(R.mipmap.ic_launcher).build(), - contentDescription = "imgvCover", modifier = Modifier.padding(top = 8.dp) - .clickable(onClick = { - Logd(TAG, "icon clicked!") - val podcast: PodcastSearchResult? = searchResult[index] - if (!podcast?.feedUrl.isNullOrEmpty()) { - val fragment: Fragment = OnlineFeedFragment.newInstance(podcast.feedUrl) - (activity as MainActivity).loadChildFragment(fragment) - } - })) - } - if (showError) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth().constrainAs(error) { centerTo(parent) }) { - Text(errorText, color = textColor) - if (showRetry) Button(onClick = { - prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply() - loadToplist() - }) { Text(stringResource(retryTextRes)) } - } - } - Text(stringResource(R.string.discover_powered_by_itunes), color = textColor, modifier = Modifier.align(Alignment.End)) - } - } - - private var eventSink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - } - private fun procFlowEvents() { - if (eventSink != null) return - eventSink = lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.DiscoveryDefaultUpdateEvent -> loadToplist() - else -> {} - } - } - } - } - - private fun loadToplist() { - showError = false - showPowerBy = true - showRetry = false - retryTextRes = R.string.retry_label - val loader = ItunesTopListLoader(requireContext()) - val countryCode: String = prefs.getString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, Locale.getDefault().country)!! - if (prefs.getBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, false)) { - showError = true - errorText = requireContext().getString(R.string.discover_is_hidden) - showPowerBy = false - showRetry = false - return - } - if (BuildConfig.FLAVOR == "free" && prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true) == true) { - showError = true - errorText = "" - showGrid = true - showRetry = true - retryTextRes = R.string.discover_confirm - showPowerBy = true - return - } - - lifecycleScope.launch { - try { - val searchResults_ = withContext(Dispatchers.IO) { loader.loadToplist(countryCode, NUM_SUGGESTIONS, getFeedList()) } - withContext(Dispatchers.Main) { - showError = false - if (searchResults_.isEmpty()) { - errorText = requireContext().getString(R.string.search_status_no_results) - showError = true - showGrid = false - } else { - showGrid = true - searchResult.clear() - searchResult.addAll(searchResults_) - } - } - } catch (e: Throwable) { - Log.e(TAG, Log.getStackTraceString(e)) - showError = true - showGrid = false - showRetry = true - errorText = e.localizedMessage ?: "" - } - } - } - - class ItunesTopListLoader(private val context: Context) { - @Throws(JSONException::class, IOException::class) - fun loadToplist(country: String, limit: Int, subscribed: List): List { - val client = getHttpClient() - val feedString: String - var loadCountry = country - if (COUNTRY_CODE_UNSET == country) loadCountry = Locale.getDefault().country - feedString = try { getTopListFeed(client, loadCountry) - } catch (e: IOException) { - if (COUNTRY_CODE_UNSET == country) getTopListFeed(client, "US") - else throw e - } - return removeSubscribed(parseFeed(feedString), subscribed, limit) - } - @Throws(IOException::class) - private fun getTopListFeed(client: OkHttpClient?, country: String): String { - val url = "https://itunes.apple.com/%s/rss/toppodcasts/limit=$NUM_LOADED/explicit=true/json" - Logd(TAG, "Feed URL " + String.format(url, country)) - val httpReq: Request.Builder = Request.Builder() - .cacheControl(CacheControl.Builder().maxStale(1, TimeUnit.DAYS).build()) - .url(String.format(url, country)) - client!!.newCall(httpReq.build()).execute().use { response -> - if (response.isSuccessful) return response.body!!.string() - if (response.code == 400) throw IOException("iTunes does not have data for the selected country.") - - val prefix = context.getString(R.string.error_msg_prefix) - throw IOException(prefix + response) - } - } - @Throws(JSONException::class) - private fun parseFeed(jsonString: String): List { - val result = JSONObject(jsonString) - val feed: JSONObject - val entries: JSONArray - try { - feed = result.getJSONObject("feed") - entries = feed.getJSONArray("entry") - } catch (_: JSONException) { return ArrayList() } - val results: MutableList = ArrayList() - for (i in 0 until entries.length()) { - val json = entries.getJSONObject(i) - results.add(PodcastSearchResult.fromItunesToplist(json)) - } - return results - } - - companion object { - private val TAG: String = ItunesTopListLoader::class.simpleName ?: "Anonymous" - const val PREF_KEY_COUNTRY_CODE: String = "country_code" - const val PREF_KEY_HIDDEN_DISCOVERY_COUNTRY: String = "hidden_discovery_country" - const val PREF_KEY_NEEDS_CONFIRM: String = "needs_confirm" - const val PREFS: String = "CountryRegionPrefs" - const val COUNTRY_CODE_UNSET: String = "99" - private const val NUM_LOADED = 25 - - private fun removeSubscribed(suggestedPodcasts: List, subscribedFeeds: List, limit: Int): List { - val subscribedPodcastsSet: MutableSet = HashSet() - for (subscribedFeed in subscribedFeeds) { - if (subscribedFeed.title != null && subscribedFeed.author != null) - subscribedPodcastsSet.add(subscribedFeed.title!!.trim { it <= ' ' } + " - " + subscribedFeed.author!!.trim { it <= ' ' }) - } - val suggestedNotSubscribed: MutableList = ArrayList() - for (suggested in suggestedPodcasts) { - if (!subscribedPodcastsSet.contains(suggested.title.trim { it <= ' ' })) suggestedNotSubscribed.add(suggested) - if (suggestedNotSubscribed.size == limit) return suggestedNotSubscribed - } - return suggestedNotSubscribed - } - } - } - - class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener { - val prefs: SharedPreferences by lazy { requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE) } - - private var _binding: ComposeFragmentBinding? = null - private val binding get() = _binding!! - - private lateinit var toolbar: MaterialToolbar - - private var topList: List? = listOf() - - private var countryCode: String? = "US" - private var hidden = false - private var needsConfirm = false - - private var searchResults = mutableStateListOf() - private var errorText by mutableStateOf("") - private var retryQerry by mutableStateOf("") - private var showProgress by mutableStateOf(true) - private var noResultText by mutableStateOf("") - - /** - * Replace adapter data with provided search results from SearchTask. - * @param result List of Podcast objects containing search results - */ - private fun updateData(result: List) { - searchResults.clear() - if (result.isNotEmpty()) { - searchResults.addAll(result) - noResultText = "" - } else noResultText = getString(R.string.no_results_for_query) - showProgress = false - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - countryCode = prefs.getString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, Locale.getDefault().country) - hidden = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, false) - needsConfirm = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - // Inflate the layout for this fragment - _binding = ComposeFragmentBinding.inflate(inflater) - Logd(TAG, "fragment onCreateView") - binding.mainView.setContent { - CustomTheme(requireContext()) { - MainView() - } - } - - toolbar = binding.toolbar - toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } - toolbar.inflateMenu(R.menu.countries_menu) - val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item) - discoverHideItem.isChecked = hidden - toolbar.setOnMenuItemClickListener(this) - - loadToplist(countryCode) - return binding.root - } - - @Composable - fun MainView() { - val textColor = MaterialTheme.colorScheme.onSurface - ConstraintLayout(modifier = Modifier.fillMaxSize()) { - val (gridView, progressBar, empty, txtvError, butRetry, powered) = createRefs() - if (showProgress) CircularProgressIndicator(progress = {0.6f}, strokeWidth = 10.dp, modifier = Modifier.size(50.dp).constrainAs(progressBar) { centerTo(parent) }) - val lazyListState = rememberLazyListState() - if (searchResults.isNotEmpty()) LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp) - .constrainAs(gridView) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - }, - verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(searchResults.size) { index -> - OnlineFeedItem(activity = activity as MainActivity, searchResults[index]) - } - } - if (searchResults.isEmpty()) Text(noResultText, color = textColor, modifier = Modifier.constrainAs(empty) { centerTo(parent) }) - if (errorText.isNotEmpty()) Text(errorText, color = textColor, modifier = Modifier.constrainAs(txtvError) { centerTo(parent) }) - if (retryQerry.isNotEmpty()) Button(modifier = Modifier.padding(16.dp).constrainAs(butRetry) { top.linkTo(txtvError.bottom)}, - onClick = { - if (needsConfirm) { - prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply() - needsConfirm = false - } - loadToplist(countryCode) - }, ) { - Text(stringResource(id = R.string.retry_label)) - } -// Text( getString(R.string.search_powered_by, searchProvider!!.name), color = Color.Black, style = MaterialTheme.typography.labelSmall, modifier = Modifier.background( -// Color.LightGray) -// .constrainAs(powered) { -// bottom.linkTo(parent.bottom) -// end.linkTo(parent.end) -// }) - } - } - - override fun onDestroy() { - _binding = null - searchResults.clear() - topList = null - super.onDestroy() - } - - private fun loadToplist(country: String?) { - searchResults.clear() - errorText = "" - retryQerry = "" - noResultText = "" - showProgress = true - if (hidden) { - errorText = resources.getString(R.string.discover_is_hidden) - showProgress = false - return - } - if (BuildConfig.FLAVOR == "free" && needsConfirm) { - errorText = "" - retryQerry = resources.getString(R.string.discover_confirm) - noResultText = "" - showProgress = false - return - } - - val loader = ItunesTopListLoader(requireContext()) - lifecycleScope.launch { - try { - val podcasts = withContext(Dispatchers.IO) { loader.loadToplist(country?:"", NUM_OF_TOP_PODCASTS, getFeedList()) } - withContext(Dispatchers.Main) { - showProgress = false - topList = podcasts - updateData(topList!!) - } - } catch (e: Throwable) { - Log.e(TAG, Log.getStackTraceString(e)) - searchResults.clear() - errorText = e.message ?: "no error message" - retryQerry = " retry" - } - } - } - - override fun onMenuItemClick(item: MenuItem): Boolean { - if (super.onOptionsItemSelected(item)) return true - - val itemId = item.itemId - when (itemId) { - R.id.discover_hide_item -> { - item.isChecked = !item.isChecked - hidden = item.isChecked - prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply() - - EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent()) - loadToplist(countryCode) - return true - } - R.id.discover_countries_item -> { - val inflater = layoutInflater - val selectCountryDialogView = inflater.inflate(R.layout.select_country_dialog, null) - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setView(selectCountryDialogView) - - val countryCodeArray: List = listOf(*Locale.getISOCountries()) - val countryCodeNames: MutableMap = HashMap() - val countryNameCodes: MutableMap = HashMap() - for (code in countryCodeArray) { - val locale = Locale("", code) - val countryName = locale.displayCountry - countryCodeNames[code] = countryName - countryNameCodes[countryName] = code - } - - val countryNamesSort: MutableList = ArrayList(countryCodeNames.values) - countryNamesSort.sort() - - val dataAdapter = ArrayAdapter(this.requireContext(), android.R.layout.simple_list_item_1, countryNamesSort) - val scBinding = SelectCountryDialogBinding.bind(selectCountryDialogView) - val textInput = scBinding.countryTextInput - val editText = textInput.editText as? MaterialAutoCompleteTextView - editText!!.setAdapter(dataAdapter) - editText.setText(countryCodeNames[countryCode]) - editText.setOnClickListener { - if (editText.text.isNotEmpty()) { - editText.setText("") - editText.postDelayed({ editText.showDropDown() }, 100) - } - } - editText.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean -> - if (hasFocus) { - editText.setText("") - editText.postDelayed({ editText.showDropDown() }, 100) - } - } - - builder.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - val countryName = editText.text.toString() - if (countryNameCodes.containsKey(countryName)) { - countryCode = countryNameCodes[countryName] - val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item) - discoverHideItem.isChecked = false - hidden = false - } - - prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply() - prefs.edit().putString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, countryCode).apply() - - EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent()) - loadToplist(countryCode) - } - builder.setNegativeButton(R.string.cancel_label, null) - builder.show() - return true - } - else -> return false - } - } - - companion object { - private const val NUM_OF_TOP_PODCASTS = 25 - } - } - - companion object { - private val TAG: String = QuickDiscoveryFragment::class.simpleName ?: "Anonymous" - private const val NUM_SUGGESTIONS = 12 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt index 6e837f1d..d2ba8110 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt @@ -1,7 +1,7 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.SearchFragmentBinding +import ac.mdiq.podcini.databinding.ComposeFragmentBinding import ac.mdiq.podcini.net.download.DownloadStatus import ac.mdiq.podcini.net.feed.searcher.CombinedSearcher import ac.mdiq.podcini.storage.database.RealmDB.realm @@ -33,10 +33,13 @@ import android.view.inputmethod.InputMethodManager import androidx.appcompat.widget.SearchView import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -54,7 +57,6 @@ import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest import com.google.android.material.appbar.MaterialToolbar -import com.google.android.material.chip.Chip import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest @@ -63,17 +65,18 @@ import kotlinx.coroutines.withContext import java.text.NumberFormat class SearchFragment : Fragment() { - private var _binding: SearchFragmentBinding? = null + private var _binding: ComposeFragmentBinding? = null private val binding get() = _binding!! private lateinit var searchView: SearchView - private lateinit var chip: Chip private lateinit var automaticSearchDebouncer: Handler private val resultFeeds = mutableStateListOf() private val results = mutableListOf() private val vms = mutableStateListOf() private var infoBarText = mutableStateOf("") + private var searchInFeed by mutableStateOf(false) + private var feedName by mutableStateOf("") private var leftActionState = mutableStateOf(NoActionSwipeAction()) private var rightActionState = mutableStateOf(NoActionSwipeAction()) @@ -89,15 +92,25 @@ class SearchFragment : Fragment() { } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = SearchFragmentBinding.inflate(inflater) + _binding = ComposeFragmentBinding.inflate(inflater) Logd(TAG, "fragment onCreateView") setupToolbar(binding.toolbar) swipeActions = SwipeActions(this, TAG) lifecycle.addObserver(swipeActions) - binding.resultsListView.setContent { + if (requireArguments().getLong(ARG_FEED, 0) > 0L) { + searchInFeed = true + feedName = requireArguments().getString(ARG_FEED_NAME, "") + } + binding.mainView.setContent { CustomTheme(requireContext()) { Column { + if (searchInFeed) FilterChip(onClick = { }, label = { Text(feedName) }, selected = searchInFeed, + trailingIcon = { Icon(imageVector = Icons.Filled.Close, contentDescription = "Close icon", modifier = Modifier.size(FilterChipDefaults.IconSize).clickable(onClick = { + requireArguments().putLong(ARG_FEED, 0) + searchInFeed = false + })) } + ) CriteriaList() FeedsRow() InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()}) @@ -114,15 +127,7 @@ class SearchFragment : Fragment() { } } } - refreshSwipeTelltale() - chip = binding.feedTitleChip - chip.setOnCloseIconClickListener { - requireArguments().putLong(ARG_FEED, 0) - search() - } - chip.visibility = if (requireArguments().getLong(ARG_FEED, 0) == 0L) View.GONE else View.VISIBLE - chip.text = requireArguments().getString(ARG_FEED_NAME, "") if (requireArguments().getString(ARG_QUERY, null) != null) search() searchView.setOnQueryTextFocusChangeListener { view: View, hasFocus: Boolean -> @@ -193,25 +198,6 @@ class SearchFragment : Fragment() { }) } -// override fun onContextItemSelected(item: MenuItem): Boolean { -//// val selectedFeedItem: Feed? = adapterFeeds.longPressedItem -//// if (selectedFeedItem != null && onMenuItemClicked(this, item.itemId, selectedFeedItem) {}) return true -// return super.onContextItemSelected(item) -// } - -// private fun onMenuItemClicked(fragment: Fragment, menuItemId: Int, selectedFeed: Feed, callback: Runnable): Boolean { -// val context = fragment.requireContext() -// when (menuItemId) { -//// R.id.rename_folder_item -> CustomFeedNameDialog(fragment.activity as Activity, selectedFeed).show() -// R.id.edit_tags -> if (selectedFeed.preferences != null) TagSettingsDialog.newInstance(listOf(selectedFeed)) -// .show(fragment.childFragmentManager, TagSettingsDialog.TAG) -// R.id.rename_item -> CustomFeedNameDialog(fragment.activity as Activity, selectedFeed).show() -// R.id.remove_feed -> RemoveFeedDialog.show(context, selectedFeed, null) -// else -> return false -// } -// return true -// } - private var eventSink: Job? = null private var eventStickySink: Job? = null private fun cancelFlowEvents() { @@ -245,20 +231,26 @@ class SearchFragment : Fragment() { private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) { for (url in event.urls) { val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(results, url) - if (pos >= 0) { -// results[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal - vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal - } + if (pos >= 0) vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal } } @SuppressLint("StringFormatMatches") private fun search() { // adapterFeeds.setEndButton(R.string.search_online) { this.searchOnline() } - chip.visibility = if ((requireArguments().getLong(ARG_FEED, 0) == 0L)) View.GONE else View.VISIBLE lifecycleScope.launch { try { - val results_ = withContext(Dispatchers.IO) { performSearch() } + val results_ = withContext(Dispatchers.IO) { + val query = searchView.query.toString() + if (query.isEmpty()) Pair, List>(emptyList(), emptyList()) + else { + val feedID = requireArguments().getLong(ARG_FEED) + val items: List = searchEpisodes(feedID, query) + val feeds: List = searchFeeds(query) + Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}") + Pair, List>(items, feeds) + } + } withContext(Dispatchers.Main) { if (results_.first != null) { val first_ = results_.first!!.toMutableList() @@ -293,6 +285,7 @@ class SearchFragment : Fragment() { Column { Row { Button(onClick = {showGrid = !showGrid}) { Text(stringResource(R.string.show_criteria)) } + Spacer(Modifier.weight(1f)) Button(onClick = { searchOnline() }) { Text(stringResource(R.string.search_online)) } } if (showGrid) NonlazyGrid(columns = 2, itemCount = SearchBy.entries.size) { index -> @@ -359,16 +352,16 @@ class SearchFragment : Fragment() { } } - private fun performSearch(): Pair, List> { - val query = searchView.query.toString() - if (query.isEmpty()) return Pair, List>(emptyList(), emptyList()) - - val feedID = requireArguments().getLong(ARG_FEED) - val items: List = searchEpisodes(feedID, query) - val feeds: List = searchFeeds(query) - Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}") - return Pair, List>(items, feeds) - } +// private fun performSearch(): Pair, List> { +// val query = searchView.query.toString() +// if (query.isEmpty()) return Pair, List>(emptyList(), emptyList()) +// +// val feedID = requireArguments().getLong(ARG_FEED) +// val items: List = searchEpisodes(feedID, query) +// val feeds: List = searchFeeds(query) +// Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}") +// return Pair, List>(items, feeds) +// } private fun searchFeeds(query: String): List { Logd(TAG, "searchFeeds called ${SearchBy.AUTHOR.selected}") diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/SquareImageView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/SquareImageView.kt deleted file mode 100644 index f56a5c4e..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/SquareImageView.kt +++ /dev/null @@ -1,55 +0,0 @@ -package ac.mdiq.podcini.ui.view - - -import ac.mdiq.podcini.R -import android.content.Context -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatImageView -import kotlin.math.min - -/** - * From http://stackoverflow.com/a/19449488/6839 - */ -class SquareImageView : AppCompatImageView { - private var direction = DIRECTION_WIDTH - - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - loadAttrs(context, attrs) - } - - constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) { - loadAttrs(context, attrs) - } - - private fun loadAttrs(context: Context, attrs: AttributeSet?) { - val a = context.obtainStyledAttributes(attrs, R.styleable.SquareImageView) - direction = a.getInt(R.styleable.SquareImageView_direction, DIRECTION_WIDTH) - a.recycle() - } - - fun setDirection(direction: Int) { - this.direction = direction - requestLayout() - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - - when (direction) { - DIRECTION_MINIMUM -> { - val size = min(measuredWidth.toDouble(), measuredHeight.toDouble()).toInt() - setMeasuredDimension(size, size) - } - DIRECTION_HEIGHT -> setMeasuredDimension(measuredHeight, measuredHeight) - else -> setMeasuredDimension(measuredWidth, measuredWidth) - } - } - - companion object { - const val DIRECTION_WIDTH: Int = 0 - const val DIRECTION_HEIGHT: Int = 1 - const val DIRECTION_MINIMUM: Int = 2 - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/WrappingGridView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/WrappingGridView.kt deleted file mode 100644 index 4c2c7310..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/WrappingGridView.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ac.mdiq.podcini.ui.view - -import android.content.Context -import android.util.AttributeSet -import android.widget.GridView - -/** - * Source: https://stackoverflow.com/a/46350213/ - */ -class WrappingGridView : GridView { - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - var heightSpec = heightMeasureSpec - // The great Android "hackatlon", the love, the magic. - // The two leftmost bits in the height measure spec have - // a special meaning, hence we can't use them to describe height. - if (layoutParams.height == LayoutParams.WRAP_CONTENT) heightSpec = MeasureSpec.makeMeasureSpec(Int.MAX_VALUE shr 2, MeasureSpec.AT_MOST) - - super.onMeasure(widthMeasureSpec, heightSpec) - } -} diff --git a/app/src/main/res/layout/addfeed.xml b/app/src/main/res/layout/addfeed.xml deleted file mode 100644 index 82b04a4a..00000000 --- a/app/src/main/res/layout/addfeed.xml +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/compose_fragment.xml b/app/src/main/res/layout/compose_fragment.xml index 6305ceed..3c690a86 100644 --- a/app/src/main/res/layout/compose_fragment.xml +++ b/app/src/main/res/layout/compose_fragment.xml @@ -2,7 +2,6 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/simple_icon_list_item.xml b/app/src/main/res/layout/simple_icon_list_item.xml deleted file mode 100644 index fac17929..00000000 --- a/app/src/main/res/layout/simple_icon_list_item.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index bb3960a0..3e24c58b 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -14,28 +14,28 @@ - - - - - + + + + + - - - + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/styleable.xml b/app/src/main/res/values/styleable.xml index 3542cc1b..ae99a0e8 100644 --- a/app/src/main/res/values/styleable.xml +++ b/app/src/main/res/values/styleable.xml @@ -1,13 +1,5 @@ - - - - - - - - diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastEnabledActivity.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastEnabledActivity.kt index 9f76d71b..f30755d5 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastEnabledActivity.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastEnabledActivity.kt @@ -50,11 +50,7 @@ abstract class CastEnabledActivity : AppCompatActivity() { fun CastIconButton() { if (canCast) { AndroidView( modifier = Modifier.size(24.dp), - factory = { ctx -> - MediaRouteButton(ctx).apply { - CastButtonFactory.setUpMediaRouteButton(ctx, this) - } - }, + factory = { ctx -> MediaRouteButton(ctx).apply { CastButtonFactory.setUpMediaRouteButton(ctx, this) } }, ) } } diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt index 9dbdb81e..3788b110 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt @@ -6,6 +6,7 @@ import ac.mdiq.podcini.playback.base.MediaPlayerCallback import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence import ac.mdiq.podcini.storage.model.EpisodeMedia +import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.model.RemoteMedia import ac.mdiq.podcini.util.Logd @@ -13,6 +14,7 @@ import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import android.annotation.SuppressLint import android.app.UiModeManager +import android.bluetooth.BluetoothClass.Service.AUDIO import android.content.Context import android.content.res.Configuration import android.util.Log @@ -129,6 +131,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl // We don't want setPlayerStatus to handle the onPlaybackPause callback setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia) } + Log.w(TAG, "RemoteMediaPlayer state: $state") setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING) when (state) { MediaStatus.PLAYER_STATE_PLAYING -> { @@ -140,9 +143,9 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl setPlayerStatus(PlayerStatus.PLAYING, currentMedia, position) } MediaStatus.PLAYER_STATE_PAUSED -> setPlayerStatus(PlayerStatus.PAUSED, currentMedia, position) + MediaStatus.PLAYER_STATE_LOADING -> { Logd(TAG, "Remote player loading") } MediaStatus.PLAYER_STATE_BUFFERING -> setPlayerStatus( - if ((mediaChanged || status == PlayerStatus.PREPARING)) PlayerStatus.PREPARING - else PlayerStatus.SEEKING, + if ((mediaChanged || status == PlayerStatus.PREPARING)) PlayerStatus.PREPARING else PlayerStatus.SEEKING, currentMedia, currentMedia?.getPosition() ?: Playable.INVALID_TIME) MediaStatus.PLAYER_STATE_IDLE -> { val reason = mediaStatus.idleReason @@ -184,9 +187,8 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl else -> return } } - MediaStatus.PLAYER_STATE_UNKNOWN -> if (status != PlayerStatus.INDETERMINATE || curMedia !== currentMedia) { + MediaStatus.PLAYER_STATE_UNKNOWN -> if (status != PlayerStatus.INDETERMINATE || curMedia !== currentMedia) setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia) - } else -> Log.w(TAG, "Remote media state undetermined!") } if (mediaChanged) { @@ -199,10 +201,10 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl * Internal implementation of playMediaObject. This method has an additional parameter that * allows the caller to force a media player reset even if * the given playable parameter is the same object as the currently playing media. - * * @see .playMediaObject */ override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) { + Logd(TAG, "playMediaObject") if (!CastUtils.isCastable(playable, castContext.sessionManager.currentCastSession)) { Logd(TAG, "media provided is not compatible with cast device") EventFlow.postEvent(FlowEvent.PlayerErrorEvent("Media not compatible with cast device")) @@ -229,71 +231,63 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl seekTo(pos) callback.onPlaybackPause(curMedia, pos) } - if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier()) callback.onPostPlayback(prevMedia, false, skipped = false, playingNext = true) - prevMedia = curMedia setPlayerStatus(PlayerStatus.INDETERMINATE, null) } } curMedia = playable - mediaInfo = toMediaInfo(playable) this.mediaType = curMedia!!.getMediaType() this.startWhenPrepared.set(startWhenPrepared) setPlayerStatus(PlayerStatus.INITIALIZING, curMedia) -// val metadata = buildMetadata(curMedia!!) -// try { -// callback.ensureMediaInfoLoaded(curMedia!!) -// callback.onMediaChanged(false) -// setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence) -// CoroutineScope(Dispatchers.IO).launch { -// when { -// streaming -> { -// val streamurl = curMedia!!.getStreamUrl() -// if (streamurl != null) { -// val media = curMedia -// if (media is EpisodeMedia) { -// mediaItem = null -// mediaSource = null -// setDataSource(metadata, media) -//// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } -//// if (startWhenPrepared) runBlocking { deferred.await() } -//// val preferences = media.episodeOrFetch()?.feed?.preferences -//// setDataSource(metadata, streamurl, preferences?.username, preferences?.password) -// } else setDataSource(metadata, streamurl, null, null) -// } -// } -// else -> { -// val localMediaurl = curMedia!!.getLocalMediaUrl() -//// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle -//// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null) -// if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) -// else throw IOException("Unable to read local file $localMediaurl") -// } -// } -// withContext(Dispatchers.Main) { -// val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager -// if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) -// if (prepareImmediately) prepare() -// } -// } -// } catch (e: IOException) { -// e.printStackTrace() -// setPlayerStatus(PlayerStatus.ERROR, null) -// EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) -// } catch (e: IllegalStateException) { -// e.printStackTrace() -// setPlayerStatus(PlayerStatus.ERROR, null) -// EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) -// } finally { } + val metadata = buildMetadata(curMedia!!) + try { + callback.ensureMediaInfoLoaded(curMedia!!) + callback.onMediaChanged(false) + setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence) + CoroutineScope(Dispatchers.IO).launch { + when { + streaming -> { + val streamurl = curMedia!!.getStreamUrl() + if (streamurl != null) { + val media = curMedia + if (media is EpisodeMedia) { + mediaItem = null + mediaSource = null + setDataSource(metadata, media) + } else setDataSource(metadata, streamurl, null, null) + } + } + else -> { + val localMediaurl = curMedia!!.getLocalMediaUrl() + if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) + else throw IOException("Unable to read local file $localMediaurl") + } + } + mediaInfo = toMediaInfo(playable) + withContext(Dispatchers.Main) { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) + if (prepareImmediately) prepare() + } + } + } catch (e: IOException) { + e.printStackTrace() + setPlayerStatus(PlayerStatus.ERROR, null) + EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) + } catch (e: IllegalStateException) { + e.printStackTrace() + setPlayerStatus(PlayerStatus.ERROR, null) + EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: "")) + } finally { } - callback.ensureMediaInfoLoaded(curMedia!!) - callback.onMediaChanged(true) - setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) - if (prepareImmediately) prepare() +// callback.ensureMediaInfoLoaded(curMedia!!) +// callback.onMediaChanged(true) +// setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) +// if (prepareImmediately) prepare() } override fun resume() { @@ -308,7 +302,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl override fun prepare() { if (status == PlayerStatus.INITIALIZED) { - Logd(TAG, "Preparing media player") + Logd(TAG, "Preparing media player $mediaInfo") setPlayerStatus(PlayerStatus.PREPARING, curMedia) var position = curMedia!!.getPosition() if (position > 0) position = calculatePositionWithRewind(position, curMedia!!.getLastPlayedTime()) @@ -417,7 +411,6 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl } companion object { - fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? { if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) return null try { if (CastContext.getSharedInstance(context).castState == CastState.CONNECTED) return CastMediaPlayer(context, callback) } catch (e: Exception) { e.printStackTrace() } diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastOptionsProvider.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastOptionsProvider.kt index ab8a5677..a3df407e 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastOptionsProvider.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastOptionsProvider.kt @@ -2,15 +2,19 @@ package ac.mdiq.podcini.playback.cast import android.annotation.SuppressLint import android.content.Context +import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.framework.CastOptions import com.google.android.gms.cast.framework.OptionsProvider import com.google.android.gms.cast.framework.SessionProvider +import com.google.android.gms.cast.framework.media.CastMediaOptions @Suppress("unused") @SuppressLint("VisibleForTests") class CastOptionsProvider : OptionsProvider { override fun getCastOptions(context: Context): CastOptions { - return CastOptions.Builder().setReceiverApplicationId("BEBC1DB1").build() +// return CastOptions.Builder().setReceiverApplicationId("BEBC1DB1").build() +// return CastOptions.Builder().setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).build() + return CastOptions.Builder().setReceiverApplicationId("C7113B39").build() } override fun getAdditionalSessionProviders(context: Context): List? { diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastUtils.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastUtils.kt index 8eaa5a50..7328df7b 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastUtils.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastUtils.kt @@ -1,6 +1,7 @@ package ac.mdiq.podcini.playback.cast import ac.mdiq.podcini.storage.model.* +import ac.mdiq.podcini.util.Logd import android.content.ContentResolver import android.util.Log import com.google.android.gms.cast.CastDevice @@ -56,12 +57,11 @@ object CastUtils { * @return [Playable] object in a format proper for casting. */ fun makeRemoteMedia(media: MediaInfo): Playable? { + Logd(TAG, "makeRemoteMedia called") val metadata = media.metadata val version = metadata!!.getInt(KEY_FORMAT_VERSION) if (version <= 0 || version > MAX_VERSION_FORWARD_COMPATIBILITY) { - Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this" - + "version of Podcini CastUtils, curVer=" + FORMAT_VERSION_VALUE - + ", object version=" + version) + Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this version of Podcini CastUtils, curVer=$FORMAT_VERSION_VALUE, object version=$version") return null } val imageList = metadata.images @@ -87,11 +87,9 @@ object CastUtils { /** * Compares a [MediaInfo] instance with a [EpisodeMedia] one and evaluates whether they * represent the same podcast episode. - * * @param info the [MediaInfo] object to be compared. * @param media the [EpisodeMedia] object to be compared. * @return true if there's a match, `false` otherwise. - * * @see RemoteMedia.equals */ fun matches(info: MediaInfo?, media: EpisodeMedia?): Boolean { @@ -109,11 +107,9 @@ object CastUtils { /** * Compares a [MediaInfo] instance with a [RemoteMedia] one and evaluates whether they * represent the same podcast episode. - * * @param info the [MediaInfo] object to be compared. * @param media the [RemoteMedia] object to be compared. * @return true if there's a match, `false` otherwise. - * * @see RemoteMedia.equals */ fun matches(info: MediaInfo?, media: RemoteMedia?): Boolean { @@ -129,11 +125,9 @@ object CastUtils { * Compares a [MediaInfo] instance with a [Playable] and evaluates whether they * represent the same podcast episode. Useful every time we get a MediaInfo from the Cast Device * and want to avoid unnecessary conversions. - * * @param info the [MediaInfo] object to be compared. * @param media the [Playable] object to be compared. * @return true if there's a match, `false` otherwise. - * * @see RemoteMedia.equals */ fun matches(info: MediaInfo?, media: Playable?): Boolean { diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/MediaInfoCreator.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/MediaInfoCreator.kt index c83dec32..fe14c3da 100644 --- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/MediaInfoCreator.kt +++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/MediaInfoCreator.kt @@ -1,8 +1,10 @@ package ac.mdiq.podcini.playback.cast -import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.EpisodeMedia +import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.storage.model.RemoteMedia +import ac.mdiq.podcini.util.Logd import android.net.Uri import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaMetadata @@ -36,7 +38,9 @@ object MediaInfoCreator { metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()!!) val builder = MediaInfo.Builder(media.getStreamUrl()?:"") - .setContentType(media.getMimeType()) + // TODO: test +// .setContentType(media.getMimeType()) + .setContentType("audio/*") .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setMetadata(metadata) if (media.getDuration() > 0) builder.setStreamDuration(media.getDuration().toLong()) @@ -66,7 +70,7 @@ object MediaInfoCreator { val url: String = if (feedItem.imageUrl == null && feed != null) feed.imageUrl?:"" else feedItem.imageUrl?:"" if (url.isNotEmpty()) metadata.addImage(WebImage(Uri.parse(url))) val calendar = Calendar.getInstance() - if (media.episode?.getPubDate() != null) calendar.time = media.episode!!.getPubDate()!! + calendar.time = feedItem.getPubDate() metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar) if (feed != null) { if (!feed.author.isNullOrEmpty()) metadata.putString(MediaMetadata.KEY_ARTIST, feed.author!!) @@ -87,8 +91,13 @@ object MediaInfoCreator { metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE) metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()!!) - val builder = MediaInfo.Builder(media.getStreamUrl()!!) - .setContentType(media.mimeType) + Logd("MediaInfoCreator", "media.mimeType: ${media.mimeType} ${media.audioUrl}") + // TODO: these are hardcoded for audio only +// val builder = MediaInfo.Builder(media.getStreamUrl()!!) +// .setContentType(media.mimeType) + var url: String = if (media.getMediaType() == MediaType.AUDIO) media.getStreamUrl() ?: "" else media.audioUrl + val builder = MediaInfo.Builder(url) + .setContentType("audio/*") .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setMetadata(metadata) if (media.getDuration() > 0) builder.setStreamDuration(media.getDuration().toLong()) diff --git a/changelog.md b/changelog.md index 7a04a6f2..f20eb2de 100644 --- a/changelog.md +++ b/changelog.md @@ -1,9 +1,20 @@ +# 6.14.1 + +* changed the term "virtual queue" to "natural queue" in the literature to refer to the list of episodes in a given feed +* ignore updating episodes with duration of less than 1 second +* start using Podcini's own cast provider in the play app + * audio-only youtube media can now be cast to speaker (experimental for now) + * cast of any video is disabled for now +* OnlineSearch fragment is in Compose +* minor Compose amendments in fragments of Search, About +* updates in documents of required licenses + # 6.14.0 * fixed crash when adding podcast (introduced since 6.13.11) * naming changes in PlayState: InQueue -> Queue, InProgress -> Progress * PlayState Queue is user settable, once set, the episode is put to associated queue of the feed -* in getting next to play in a virtual queue, PlayStates Again and Forever are included +* in getting next to play in a natural queue, PlayStates Again and Forever are included * fixed the not-updating queue and tag spinners in Subscriptions * various dates display are in flex format * in Statistics, data for today are shown in the HH:mm format @@ -40,7 +51,7 @@ # 6.13.9 * made Spinner in Queues view update accordingly -* if playing from the virtual queue in FeedEpisodes, the next episode comes from the filtered list +* if playing from the natural queue in FeedEpisodes, the next episode comes from the filtered list * when playing another media, post playback is performed on the current media so as to set the right state * fixed timeSpent not being correctly recorded * further enhanced efficiency of statistics calculations @@ -516,7 +527,7 @@ * added ability to receive music or playlist shared from YT Music * single music received from YT Music is added to "YTMusic Syndicate" * in newly subscribed Youtube channel or playlist, prefStreamOverDownload is set to true -* ensured virtual queue (continuous streaming) for previously subscribed "Youtube" type feed when the associated queue is set to None +* ensured natural queue (continuous streaming) for previously subscribed "Youtube" type feed when the associated queue is set to None * the max number of media is dropped to 60 on subscribing a Youtube type feed (higher number may cause delay or even failure due to network issues, will investigate other ways to handle) * fixed a bug in Reconcile that can cause downloaded files to be mistakenly deleted * action button on each episode now reacts to LongClick to provide more options diff --git a/fastlane/metadata/android/en-US/changelogs/3020299.txt b/fastlane/metadata/android/en-US/changelogs/3020299.txt index 4e6302dd..8ef8a467 100644 --- a/fastlane/metadata/android/en-US/changelogs/3020299.txt +++ b/fastlane/metadata/android/en-US/changelogs/3020299.txt @@ -3,7 +3,7 @@ * fixed crash when adding podcast (introduced since 6.13.11) * naming changes in PlayState: InQueue -> Queue, InProgress -> Progress * PlayState Queue is user settable, once set, the episode is put to associated queue of the feed -* in getting next to play in a virtual queue, PlayStates Again and Forever are included +* in getting next to play in a natural queue, PlayStates Again and Forever are included * fixed the not-updating queue and tag spinners in Subscriptions * various dates display are in flex format * in Statistics, data for today are shown in the HH:mm format diff --git a/fastlane/metadata/android/en-US/changelogs/3020300.txt b/fastlane/metadata/android/en-US/changelogs/3020300.txt new file mode 100644 index 00000000..63f6bf29 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020300.txt @@ -0,0 +1,10 @@ + Version 6.14.1 + +* changed the term "virtual queue" to "natural queue" in the literature to refer to the list of episodes in a given feed +* ignore updating episodes with duration of less than 1 second +* start using Podcini's own cast provider in the play app + * audio-only youtube media can now be cast to speaker (experimental for now) + * cast of any video is disabled for now +* OnlineSearch fragment is in Compose +* minor Compose amendments in fragments of Search, About +* updates in documents of required licenses diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce303c9d..35c12081 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ fyydlin = "v0.5.0" googleMaterialTypeface = "4.0.0.3-kotlin" googleMaterialTypefaceOutlined = "4.0.0.2-kotlin" gradle = "8.6.1" -gridlayout = "1.0.0" +#gridlayout = "1.0.0" groovyXml = "3.0.19" iconicsCore = "5.5.0-b01" iconicsViews = "5.5.0-b01" @@ -51,7 +51,7 @@ rxjavaVersion = "3.1.8" searchpreference = "v2.5.0" uiToolingPreview = "1.7.5" uiTooling = "1.7.5" -viewpager2 = "1.1.0" +#viewpager2 = "1.1.0" vistaguide = "lv0.24.2.6" wearable = "2.9.0" webkit = "1.12.1" @@ -68,7 +68,7 @@ androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorl androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } -androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayout" } +#androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayout" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-material3-android = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } @@ -80,7 +80,7 @@ androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "uiToolingPreview" } -androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } +#androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } androidx-window = { module = "androidx.window:window", version.ref = "window" } androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" }