6.14.1 commit

This commit is contained in:
Xilin Jia 2024-11-17 23:41:53 +01:00
parent c646505e93
commit a213284e40
36 changed files with 805 additions and 1209 deletions

View File

@ -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

View File

@ -24,18 +24,16 @@ This project was developed from a fork of [AntennaPod](<https://github.com/Anten
Compared to AntennaPod this project:
1. Migrated all media routines to `androidx.media3`, with `AudioOffloadMode` enabled, nicer to device battery,
2. Is purely `Kotlin` based and mono-modular, and multiple views are in Jetpack Compose,
3. Modern object-base Realm DB replaced SQLite, Coil replaced Glide, coroutines replaced RxJava and threads, and SharedFlow replaced EventBus.
4. Boasts new UI's including streamlined drawer, subscriptions view and player controller, and many more.
5. Supports multiple, virtual and circular play queues associable with any podcast.
6. Auto-download is governed by policy and limit settings of individual feed (podcast).
7. Features synthetic podcasts and allows episodes to be shelved to any synthetic podcast.
8. Supports channels, playlists, single media from YouTube and YT Music, as well as normal podcasts and plain RSS,
9. Allows setting personal notes, 5-level rating, and 12-level play state on every episode.
10. Supports sophisticated filtering and sorting on episodes and podcasts.
11. Offers Readability and Text-to-Speech for RSS contents,
12. Features `instant sync` across devices without a server.
1. Is mono-modular, purely in `Kotlin`, mostly in Jetpack Compose, based on `medai3` with `AudioOffloadMode` enabled (nicer to device battery).
2. Supports contents from YouTube and YT Music, as well as normal podcasts and plain RSS.
3. Features multiple, natural and circular play queues associable with any podcast.
4. Presents synthetic podcasts and allows episodes to be shelved to any synthetic podcast.
5. Allows setting personal notes, 5-level rating, and 12-level play state on every episode.
6. Boasts sophisticated filtering and sorting on episodes and podcasts.
7. Promotes auto-download governed by policy and limit settings of individual feed (podcast).
8. Spotlights `instant sync` across devices without a server.
9. Offers Readability and Text-to-Speech for RSS contents,
10. Replaced SQLite with modern object-base Realm DB, Glide with Coil, RxJava and threads with coroutines , and EventBus with SharedFlow,
The project aims to profit from modern frameworks, improve efficiency and provide more useful and user-friendly features.
@ -117,11 +115,10 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
* Every queue is circular: if the final item in queue finished, the first item in queue (if exists) will get played
* Every queue has a bin containing past episodes removed from the queue, useful for further review and handling
* Feed associated queue can be set to None, in which case:
* episodes in the feed are not automatically added to any queue,
* the episodes in the feed forms a virtual queue
* the next episode is determined in such a way:
* if the currently playing episode had been (manually) added to the active queue, then it's the next in queue
* else if "prefer streaming" is set, it's the next unplayed (or Again and Forever) episode in the virtual queue based on the current filter and sort order
* the episodes in the feed are not automatically added to any queue, instead forms a natural queue on their own
* the next episode to play is determined in such a way:
* if the currently playing episode had been (manually) added to the active queue, then it's the next in the queue
* else if "prefer streaming" is set, it's the next unplayed (or Again and Forever) episode in the natural queue based on the current filter and sort order
* else it's the next downloaded unplayed (or Again and Forever) episode
* Otherwise, episode played from a list other than the queue is a one-off play, unless the episode is on the active queue, in which case, the next episode in the queue will be played

View File

@ -26,8 +26,8 @@ android {
vectorDrawables.useSupportLibrary false
vectorDrawables.generatedDensities = []
versionCode 3020299
versionName "6.14.0"
versionCode 3020300
versionName "6.14.1"
applicationId "ac.mdiq.podcini.R"
def commit = ""
@ -198,7 +198,7 @@ dependencies {
implementation libs.androidx.appcompat
implementation libs.androidx.coordinatorlayout
implementation libs.androidx.fragment.fragment.ktx
implementation libs.androidx.gridlayout
// implementation libs.androidx.gridlayout
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.ui
implementation libs.androidx.media3.media3.datasource.okhttp
@ -207,7 +207,7 @@ dependencies {
implementation libs.androidx.palette.ktx
implementation libs.androidx.preference.ktx
implementation libs.androidx.recyclerview
implementation libs.androidx.viewpager2
// implementation libs.androidx.viewpager2
implementation libs.androidx.work.runtime
implementation libs.androidx.core.splashscreen
implementation libs.androidx.documentfile

View File

@ -66,12 +66,12 @@
website="https://jsoup.org/"
license="MIT"
licenseText="LICENSE_JSOUP.txt" />
<library
name="Lightweight-Stream-API"
author="Victor Melnik"
website="https://github.com/aNNiMON/Lightweight-Stream-API"
license="Apache 2.0"
licenseText="LICENSE_APACHE-2.0.txt" />
<!-- <library-->
<!-- name="Lightweight-Stream-API"-->
<!-- author="Victor Melnik"-->
<!-- website="https://github.com/aNNiMON/Lightweight-Stream-API"-->
<!-- license="Apache 2.0"-->
<!-- licenseText="LICENSE_APACHE-2.0.txt" />-->
<library
name="Material Components for Android"
author="Google"

View File

@ -183,7 +183,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
}
// stop playback of this episode
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
if (prevMedia != null && curMedia?.getIdentifier() != prevMedia?.getIdentifier())
callback.onPostPlayback(prevMedia, ended = false, skipped = true, true)
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
}

View File

@ -30,8 +30,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
@ -144,6 +147,8 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
}
val audioStream = audioStreamsList[audioIndex]
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}")
media.audioUrl = audioStream.content
val aSource = DefaultMediaSourceFactory(context).createMediaSource(
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
if (media.forceVideo || media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) {

View File

@ -7,9 +7,6 @@ import ac.mdiq.podcini.storage.model.MediaType
interface MediaPlayerCallback {
fun statusChanged(newInfo: MediaPlayerInfo?)
// TODO: not used
fun shouldStop() {}
fun onMediaChanged(reloadUI: Boolean)
fun onPostPlayback(playable: Playable?, ended: Boolean, skipped: Boolean, playingNext: Boolean)

View File

@ -2,31 +2,44 @@ package ac.mdiq.podcini.preferences.fragments
import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.SimpleIconListItemBinding
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.util.IntentUtils.openInBrowser
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.DialogInterface
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.Toast
import androidx.fragment.app.ListFragment
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import coil.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
@ -68,13 +81,11 @@ class AboutFragment : PreferenceFragmentCompat() {
(activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.about_pref)
}
class LicensesFragment : ListFragment() {
private val licenses = ArrayList<LicenseItem>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
listView.divider = null
class LicensesFragment : Fragment() {
private val licenses = mutableStateListOf<LicenseItem>()
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<CharSequence>("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<T : SimpleIconListAdapter.ListItem>(private val context: Context, private val listItems: List<T>)
: ArrayAdapter<T>(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)
}
}

View File

@ -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}")

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -254,18 +254,3 @@ fun AutoCompleteTextField(suggestions: List<String>) {
}
}
}
@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))
},
)
}

View File

@ -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) {

View File

@ -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 {

View File

@ -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)

View File

@ -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<PodcastSearchResult>()
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(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<Feed>): List<PodcastSearchResult> {
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<PodcastSearchResult> {
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<PodcastSearchResult> = 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<PodcastSearchResult>, subscribedFeeds: List<Feed>, limit: Int): List<PodcastSearchResult> {
val subscribedPodcastsSet: MutableSet<String> = 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<PodcastSearchResult> = 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<PodcastSearchResult>? = listOf()
private var countryCode: String? = "US"
private var hidden = false
private var needsConfirm = false
private var searchResults = mutableStateListOf<PodcastSearchResult>()
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<PodcastSearchResult>) {
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<String> = listOf(*Locale.getISOCountries())
val countryCodeNames: MutableMap<String?, String> = HashMap()
val countryNameCodes: MutableMap<String, String> = HashMap()
for (code in countryCodeArray) {
val locale = Locale("", code)
val countryName = locale.displayCountry
countryCodeNames[code] = countryName
countryNameCodes[countryName] = code
}
val countryNamesSort: MutableList<String> = 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
}
}

View File

@ -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<PodcastSearchResult>()
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<PodcastSearchResult> = ArrayList<PodcastSearchResult>()
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<Feed>): List<PodcastSearchResult> {
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<PodcastSearchResult> {
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<PodcastSearchResult> = 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<PodcastSearchResult>, subscribedFeeds: List<Feed>, limit: Int): List<PodcastSearchResult> {
val subscribedPodcastsSet: MutableSet<String> = 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<PodcastSearchResult> = 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<PodcastSearchResult>? = listOf()
private var countryCode: String? = "US"
private var hidden = false
private var needsConfirm = false
private var searchResults = mutableStateListOf<PodcastSearchResult>()
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<PodcastSearchResult>) {
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<String> = listOf(*Locale.getISOCountries())
val countryCodeNames: MutableMap<String?, String> = HashMap()
val countryNameCodes: MutableMap<String, String> = HashMap()
for (code in countryCodeArray) {
val locale = Locale("", code)
val countryName = locale.displayCountry
countryCodeNames[code] = countryName
countryNameCodes[countryName] = code
}
val countryNamesSort: MutableList<String> = 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
}
}

View File

@ -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<Feed>()
private val results = mutableListOf<Episode>()
private val vms = mutableStateListOf<EpisodeVM>()
private var infoBarText = mutableStateOf("")
private var searchInFeed by mutableStateOf(false)
private var feedName by mutableStateOf("")
private var leftActionState = mutableStateOf<SwipeAction>(NoActionSwipeAction())
private var rightActionState = mutableStateOf<SwipeAction>(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<Episode>, List<Feed>>(emptyList(), emptyList())
else {
val feedID = requireArguments().getLong(ARG_FEED)
val items: List<Episode> = searchEpisodes(feedID, query)
val feeds: List<Feed> = searchFeeds(query)
Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
Pair<List<Episode>, List<Feed>>(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<Episode>, List<Feed>> {
val query = searchView.query.toString()
if (query.isEmpty()) return Pair<List<Episode>, List<Feed>>(emptyList(), emptyList())
val feedID = requireArguments().getLong(ARG_FEED)
val items: List<Episode> = searchEpisodes(feedID, query)
val feeds: List<Feed> = searchFeeds(query)
Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
return Pair<List<Episode>, List<Feed>>(items, feeds)
}
// private fun performSearch(): Pair<List<Episode>, List<Feed>> {
// val query = searchView.query.toString()
// if (query.isEmpty()) return Pair<List<Episode>, List<Feed>>(emptyList(), emptyList())
//
// val feedID = requireArguments().getLong(ARG_FEED)
// val items: List<Episode> = searchEpisodes(feedID, query)
// val feeds: List<Feed> = searchFeeds(query)
// Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
// return Pair<List<Episode>, List<Feed>>(items, feeds)
// }
private fun searchFeeds(query: String): List<Feed> {
Logd(TAG, "searchFeeds called ${SearchBy.AUTHOR.selected}")

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -1,167 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:title="@string/add_feed_label"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<EditText
android:id="@+id/combinedFeedSearchEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:inputType="text"
android:imeOptions="actionSearch"
android:importantForAutofill="no"
android:layout_marginStart="0dp"
android:layout_marginLeft="0dp"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:hint="@string/search_podcast_hint"
android:background="@null" />
<ImageView
android:id="@+id/searchButton"
android:layout_width="40dp"
android:layout_height="match_parent"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:contentDescription="@string/search_podcast_hint"
android:scaleType="center"
app:srcCompat="@drawable/ic_search" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:scrollbars="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/quickFeedDiscovery"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:name="ac.mdiq.podcini.ui.fragment.QuickDiscoveryFragment" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/advanced"
android:textSize="18sp"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:accessibilityHeading="true"
android:textColor="?android:attr/textColorPrimary" />
<TextView
android:id="@+id/addViaUrlButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/add_podcast_by_url"
app:drawableStartCompat="@drawable/ic_feed"
app:drawableLeftCompat="@drawable/ic_feed"
style="@style/AddPodcastTextView" />
<TextView
android:id="@+id/addLocalFolderButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/add_local_folder"
app:drawableStartCompat="@drawable/ic_folder"
app:drawableLeftCompat="@drawable/ic_folder"
style="@style/AddPodcastTextView" />
<TextView
android:id="@+id/searchVistaGuideButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/search_vistaguide_label"
app:drawableStartCompat="@drawable/ic_search"
app:drawableLeftCompat="@drawable/ic_search"
style="@style/AddPodcastTextView" />
<TextView
android:id="@+id/searchItunesButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/search_itunes_label"
app:drawableStartCompat="@drawable/ic_search"
app:drawableLeftCompat="@drawable/ic_search"
style="@style/AddPodcastTextView" />
<TextView
android:id="@+id/searchFyydButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/search_fyyd_label"
app:drawableStartCompat="@drawable/ic_search"
app:drawableLeftCompat="@drawable/ic_search"
style="@style/AddPodcastTextView" />
<TextView
android:id="@+id/searchGPodderButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/gpodnet_search_hint"
app:drawableStartCompat="@drawable/ic_search"
app:drawableLeftCompat="@drawable/ic_search"
style="@style/AddPodcastTextView" />
<TextView
android:id="@+id/searchPodcastIndexButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/search_podcastindex_label"
app:drawableStartCompat="@drawable/ic_search"
app:drawableLeftCompat="@drawable/ic_search"
style="@style/AddPodcastTextView" />
<TextView
android:id="@+id/opmlImportButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/opml_add_podcast_label"
app:drawableStartCompat="@drawable/ic_download"
app:drawableLeftCompat="@drawable/ic_download"
style="@style/AddPodcastTextView" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@ -2,7 +2,6 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"

View File

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:fitsSystemWindows="true"
android:id="@+id/search_fragment">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?android:attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:title="@string/search_label"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />
<com.google.android.material.chip.Chip
android:id="@+id/feed_title_chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginRight="0dp"
android:visibility="gone"
app:closeIconVisible="true" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/resultsListView"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>

View File

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/simple_icon_list_item"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:id="@+id/icon"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginLeft="16dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="16dp">
<TextView
tools:text="Title"
android:textColor="?android:attr/textColorPrimary"
android:textSize="16sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/title"/>
<TextView
tools:text="Subtitle"
android:textColor="?android:attr/textColorSecondary"
android:textSize="14sp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/subtitle"/>
</LinearLayout>
</LinearLayout>

View File

@ -14,28 +14,28 @@
<attr name="icon_green" format="color"/>
<attr name="icon_purple" format="color"/>
<attr name="icon_gray" format="color"/>
<declare-styleable name="TriangleLabelView">
<attr name="backgroundColor" format="color"/>
<attr name="primaryTextColor" format="color"/>
<attr name="primaryText" format="string"/>
<attr name="primaryTextSize" format="dimension"/>
<!-- <declare-styleable name="TriangleLabelView">-->
<!-- <attr name="backgroundColor" format="color"/>-->
<!-- <attr name="primaryTextColor" format="color"/>-->
<!-- <attr name="primaryText" format="string"/>-->
<!-- <attr name="primaryTextSize" format="dimension"/>-->
<attr name="labelTopPadding" format="dimension"/>
<attr name="labelCenterPadding" format="dimension"/>
<attr name="labelBottomPadding" format="dimension"/>
<!-- <attr name="labelTopPadding" format="dimension"/>-->
<!-- <attr name="labelCenterPadding" format="dimension"/>-->
<!-- <attr name="labelBottomPadding" format="dimension"/>-->
<attr name="corner">
<enum name="leftTop" value="1" />
<enum name="rightTop" value="2" />
</attr>
</declare-styleable>
<declare-styleable name="NestedScrollableHost">
<attr name="scrollDirection" format="enum">
<enum name="both" value="0"/>
<enum name="vertical" value="1"/>
<enum name="horizontal" value="2"/>
</attr>
<attr name="preferHorizontal" format="integer"/>
<attr name="preferVertical" format="integer"/>
</declare-styleable>
<!-- <attr name="corner">-->
<!-- <enum name="leftTop" value="1" />-->
<!-- <enum name="rightTop" value="2" />-->
<!-- </attr>-->
<!-- </declare-styleable>-->
<!-- <declare-styleable name="NestedScrollableHost">-->
<!-- <attr name="scrollDirection" format="enum">-->
<!-- <enum name="both" value="0"/>-->
<!-- <enum name="vertical" value="1"/>-->
<!-- <enum name="horizontal" value="2"/>-->
<!-- </attr>-->
<!-- <attr name="preferHorizontal" format="integer"/>-->
<!-- <attr name="preferVertical" format="integer"/>-->
<!-- </declare-styleable>-->
</resources>

View File

@ -1,13 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SquareImageView">
<attr name="direction" format="enum">
<enum name="width" value="0"/>
<enum name="height" value="1"/>
<enum name="minimum" value="2"/>
</attr>
</declare-styleable>
<declare-styleable name="CircularProgressBar">
<attr name="foregroundColor" format="color" />
</declare-styleable>

View File

@ -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) } },
)
}
}

View File

@ -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() }

View File

@ -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<SessionProvider>? {

View File

@ -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>true</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>true</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>true</true> if there's a match, `false` otherwise.
*
* @see RemoteMedia.equals
*/
fun matches(info: MediaInfo?, media: Playable?): Boolean {

View File

@ -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())

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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" }