6.5.4 commit

This commit is contained in:
Xilin Jia 2024-09-04 21:08:22 +01:00
parent 943ead25bf
commit 782c582db6
51 changed files with 1338 additions and 1458 deletions

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020237
versionName "6.5.3"
versionCode 3020238
versionName "6.5.4"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -263,7 +263,7 @@
</activity>
<activity
android:name=".ui.activity.OnlineFeedViewActivity"
android:name=".ui.activity.ShareReceiverActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.Podcini.Dark.Translucent"
android:label="@string/add_feed_label"

View File

@ -218,10 +218,10 @@ class FeedHandler {
state.namespaces[uri] = Itunes()
Logd(TAG, "Recognized ITunes namespace")
}
uri == YouTube.NSURI && prefix == YouTube.NSTAG -> {
state.namespaces[uri] = YouTube()
Logd(TAG, "Recognized YouTube namespace")
}
// uri == YouTube.NSURI && prefix == YouTube.NSTAG -> {
// state.namespaces[uri] = YouTube()
// Logd(TAG, "Recognized YouTube namespace")
// }
uri == SimpleChapters.NSURI && prefix.matches(SimpleChapters.NSTAG.toRegex()) -> {
state.namespaces[uri] = SimpleChapters()
Logd(TAG, "Recognized SimpleChapters namespace")
@ -238,6 +238,7 @@ class FeedHandler {
state.namespaces[uri] = PodcastIndex()
Logd(TAG, "Recognized PodcastIndex namespace")
}
else -> Logd(TAG, "startPrefixMapping can not handle uri: $uri")
}
}
}

View File

@ -7,6 +7,7 @@ import ac.mdiq.podcini.net.feed.parser.element.SyndElement
import ac.mdiq.podcini.net.feed.parser.utils.DurationParser.inMillis
import org.xml.sax.Attributes
// TODO: this appears not needed
class YouTube : Namespace() {
val TAG = this::class.simpleName ?: "Anonymous"

View File

@ -138,9 +138,7 @@ abstract class ServiceStatusHandler(private val activity: FragmentActivity) {
try {
activity.unregisterReceiver(statusUpdate)
activity.unregisterReceiver(notificationReceiver)
} catch (e: IllegalArgumentException) {
// ignore
}
} catch (e: IllegalArgumentException) {/* ignore */ }
initialized = false
cancelFlowEvents()
}

View File

@ -181,6 +181,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
@Throws(IllegalArgumentException::class, IllegalStateException::class)
private fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) {
Logd(TAG, "setDataSource1 called")
val url = media.getStreamUrl() ?: return
val preferences = media.episodeOrFetch()?.feed?.preferences
val user = preferences?.username
@ -191,7 +192,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
val vService = Vista.getService(0)
val streamInfo = StreamInfo.getInfo(vService, url)
val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams)
Logd(TAG, "setDataSource1 got ${audioStreamsList.size}")
Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}")
val audioIndex = if (isNetworkRestricted) 0 else audioStreamsList.size - 1
val audioStream = audioStreamsList[audioIndex]
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}")
@ -346,31 +347,37 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
callback.ensureMediaInfoLoaded(curMedia!!)
callback.onMediaChanged(false)
setPlaybackParams(getCurrentPlaybackSpeed(curMedia), UserPreferences.isSkipSilence)
when {
streaming -> {
val streamurl = curMedia!!.getStreamUrl()
if (streamurl != null) {
val media = curMedia
if (media is EpisodeMedia) {
val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
if (startWhenPrepared) runBlocking { deferred.await() }
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 setDataSource(metadata, streamurl, null, null)
}
}
}
else -> {
val localMediaurl = curMedia!!.getLocalMediaUrl()
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")
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()
}
}
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)
@ -394,6 +401,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
seekTo(newPosition)
}
if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR()
// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) }
exoPlayer?.play()
// Can't set params when paused - so always set it on start in case they changed
exoPlayer?.playbackParameters = playbackParameters

View File

@ -101,7 +101,6 @@ import kotlin.math.max
*/
@UnstableApi
class PlaybackService : MediaLibraryService() {
private var mediaSession: MediaLibrarySession? = null
internal var mPlayer: MediaPlayerBase? = null
@ -230,6 +229,37 @@ class PlaybackService : MediaLibraryService() {
}
}
private val shutdownReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "shutdownReceiver onReceive called with action: ${intent.action}")
if (intent.action == ACTION_SHUTDOWN_PLAYBACK_SERVICE)
EventFlow.postEvent(FlowEvent.PlaybackServiceEvent(FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN))
}
}
val rootItem = MediaItem.Builder()
.setMediaId("CurQueue")
.setMediaMetadata(
MediaMetadata.Builder()
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.setTitle(curQueue.name)
.build())
.build()
val mediaItemsInQueue: MutableList<MediaItem> by lazy {
val list = mutableListOf<MediaItem>()
curQueue.episodes.forEach {
if (it.media != null) {
val item = buildMediaItem(it.media!!)
if (item != null) list += item
}
}
Logd(TAG, "mediaItemsInQueue: ${list.size}")
list
}
private val mediaPlayerCallback: MediaPlayerCallback = object : MediaPlayerCallback {
override fun statusChanged(newInfo: MediaPlayerInfo?) {
currentMediaType = mPlayer?.mediaType ?: MediaType.UNKNOWN
@ -391,7 +421,6 @@ class PlaybackService : MediaLibraryService() {
j = if (curIndexInQueue >= 0 && curIndexInQueue < eList.size) curIndexInQueue else eList.size-1
} else if (i < eList.size-1) j = i+1
Logd(TAG, "getNextInQueue next j: $j")
val nextItem = unmanaged(eList[j])
Logd(TAG, "getNextInQueue nextItem ${nextItem.title}")
if (nextItem.media == null) {
@ -399,13 +428,11 @@ class PlaybackService : MediaLibraryService() {
writeNoMediaPlaying()
return null
}
if (!isFollowQueue) {
Logd(TAG, "getNextInQueue(), but follow queue is not enabled.")
writeMediaPlaying(nextItem.media, PlayerStatus.STOPPED)
return null
}
if (!nextItem.media!!.localFileAvailable() && !isStreamingAllowed && isFollowQueue && nextItem.feed?.isLocalFeed != true) {
Logd(TAG, "getNextInQueue nextItem has no local file ${nextItem.title}")
displayStreamingNotAllowedNotification(PlaybackServiceStarter(this@PlaybackService, nextItem.media!!).intent)
@ -433,11 +460,9 @@ class PlaybackService : MediaLibraryService() {
else -> EXTRA_CODE_AUDIO
})
}
override fun ensureMediaInfoLoaded(media: Playable) {
// if (media is EpisodeMedia && media.item == null) media.item = DBReader.getFeedItem(media.itemId)
}
fun writeMediaPlaying(playable: Playable?, playerStatus: PlayerStatus) {
Logd(InTheatre.TAG, "Writing playback preferences ${playable?.getIdentifier()}")
if (playable == null) writeNoMediaPlaying()
@ -457,14 +482,12 @@ class PlaybackService : MediaLibraryService() {
}
}
}
fun writePlayerStatus(playerStatus: PlayerStatus) {
Logd(InTheatre.TAG, "Writing player status playback preferences")
curState = upsertBlk(curState) {
it.curPlayerStatus = getCurPlayerStatusAsInt(playerStatus)
}
}
private fun getCurPlayerStatusAsInt(playerStatus: PlayerStatus): Int {
val playerStatusAsInt = when (playerStatus) {
PlayerStatus.PLAYING -> PLAYER_STATUS_PLAYING
@ -475,37 +498,6 @@ class PlaybackService : MediaLibraryService() {
}
}
private val shutdownReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "shutdownReceiver onReceive called with action: ${intent.action}")
if (intent.action == ACTION_SHUTDOWN_PLAYBACK_SERVICE)
EventFlow.postEvent(FlowEvent.PlaybackServiceEvent(FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN))
}
}
val rootItem = MediaItem.Builder()
.setMediaId("CurQueue")
.setMediaMetadata(
MediaMetadata.Builder()
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
.setTitle(curQueue.name)
.build())
.build()
val mediaItemsInQueue: MutableList<MediaItem> by lazy {
val list = mutableListOf<MediaItem>()
curQueue.episodes.forEach {
if (it.media != null) {
val item = buildMediaItem(it.media!!)
if (item != null) list += item
}
}
Logd(TAG, "mediaItemsInQueue: ${list.size}")
list
}
private val mediaLibrarySessionCK = object: MediaLibrarySession.Callback {
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
Logd(TAG, "in MyMediaSessionCallback onConnect")
@ -762,9 +754,9 @@ class PlaybackService : MediaLibraryService() {
val keycode = intent?.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1) ?: -1
val customAction = intent?.getStringExtra(MediaButtonReceiver.EXTRA_CUSTOM_ACTION)
val hardwareButton = intent?.getBooleanExtra(MediaButtonReceiver.EXTRA_HARDWAREBUTTON, false) ?: false
val keyEvent: KeyEvent? = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) {
val keyEvent: KeyEvent? = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
else {
@Suppress("DEPRECATION")
intent?.getParcelableExtra(EXTRA_KEY_EVENT)
}
@ -797,6 +789,7 @@ class PlaybackService : MediaLibraryService() {
return super.onStartCommand(intent, flags, startId)
}
playable != null -> {
recreateMediaSessionIfNeeded()
Logd(TAG, "onStartCommand status: $status")
val allowStreamThisTime = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false) ?: false
val allowStreamAlways = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_ALWAYS, false) ?: false
@ -963,6 +956,7 @@ class PlaybackService : MediaLibraryService() {
}
private fun startPlayingFromPreferences() {
recreateMediaSessionIfNeeded()
scope.launch {
try {
withContext(Dispatchers.IO) { loadPlayableFromPreferences() }
@ -975,7 +969,7 @@ class PlaybackService : MediaLibraryService() {
}
private fun startPlaying(allowStreamThisTime: Boolean) {
Logd(TAG, "startPlaying called $allowStreamThisTime")
Logd(TAG, "startPlaying called allowStreamThisTime: $allowStreamThisTime")
val media = curMedia ?: return
val localFeed = URLUtil.isContentUrl(media.getStreamUrl())
@ -986,18 +980,17 @@ class PlaybackService : MediaLibraryService() {
return
}
if (media.getIdentifier() != curState.curMediaId) clearCurTempSpeed()
// TODO: this is redundant
// if (media.getIdentifier() != curState.curMediaId) clearCurTempSpeed()
mPlayer?.playMediaObject(media, streaming, startWhenPrepared = true, true)
recreateMediaSessionIfNeeded()
// recreateMediaSessionIfNeeded()
// val episode = (media as? EpisodeMedia)?.episode
// if (curMedia is EpisodeMedia && episode != null) addToQueue(true, episode)
}
fun clearCurTempSpeed() {
curState = upsertBlk(curState) {
it.curTempSpeed = FeedPreferences.SPEED_USE_GLOBAL
}
curState = upsertBlk(curState) { it.curTempSpeed = FeedPreferences.SPEED_USE_GLOBAL }
}
private var eventSink: Job? = null

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.ui.actions.menuhandler
package ac.mdiq.podcini.ui.actions.handler
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.ui.actions
package ac.mdiq.podcini.ui.actions.handler
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.SelectQueueDialogBinding

View File

@ -1,4 +1,4 @@
package ac.mdiq.podcini.ui.actions.menuhandler
package ac.mdiq.podcini.ui.actions.handler
import android.view.Menu
import android.view.MenuItem

View File

@ -1,38 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import android.content.Context
import androidx.fragment.app.Fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Queues.addToQueue
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
class AddToQueueSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.ADD_TO_QUEUE
}
override fun getActionIcon(): Int {
return R.drawable.ic_playlist_play
}
override fun getActionColor(): Int {
return androidx.appcompat.R.attr.colorAccent
}
override fun getTitle(context: Context): String {
return context.getString(R.string.add_to_queue_label)
}
@OptIn(UnstableApi::class)
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
addToQueue( true, item)
// else RemoveFromQueueSwipeAction().performAction(item, fragment, filter)
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showQueued || filter.showNew
}
}

View File

@ -1,36 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.ui.utils.LocalDeleteModal.deleteEpisodesWarnLocal
import android.content.Context
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
class DeleteSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.DELETE
}
override fun getActionIcon(): Int {
return R.drawable.ic_delete
}
override fun getActionColor(): Int {
return R.attr.icon_red
}
override fun getTitle(context: Context): String {
return context.getString(R.string.delete_episode_label)
}
@UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
if (!item.isDownloaded && item.feed?.isLocalFeed != true) return
deleteEpisodesWarnLocal(fragment.requireContext(), listOf(item))
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showDownloaded && (item.isDownloaded || item.feed?.isLocalFeed == true)
}
}

View File

@ -1,36 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import android.content.Context
import androidx.fragment.app.Fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Episodes.setFavorite
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
class MarkFavoriteSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.MARK_FAV
}
override fun getActionIcon(): Int {
return R.drawable.ic_star
}
override fun getActionColor(): Int {
return R.attr.icon_yellow
}
override fun getTitle(context: Context): String {
return context.getString(R.string.add_to_favorite_label)
}
@OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
setFavorite(item, !item.isFavorite)
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showIsFavorite || filter.showNotFavorite
}
}

View File

@ -1,32 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import android.content.Context
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
class NoActionSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.NO_ACTION
}
override fun getActionIcon(): Int {
return R.drawable.ic_questionmark
}
override fun getActionColor(): Int {
return R.attr.icon_red
}
override fun getTitle(context: Context): String {
return context.getString(R.string.no_action_label)
}
@UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return false
}
}

View File

@ -1,51 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Episodes.addToHistory
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.ui.activity.MainActivity
import android.content.Context
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.google.android.material.snackbar.Snackbar
import java.util.*
class RemoveFromHistorySwipeAction : SwipeAction {
val TAG = this::class.simpleName ?: "Anonymous"
override fun getId(): String {
return SwipeAction.REMOVE_FROM_HISTORY
}
override fun getActionIcon(): Int {
return R.drawable.ic_history_remove
}
override fun getActionColor(): Int {
return R.attr.icon_purple
}
override fun getTitle(context: Context): String {
return context.getString(R.string.remove_history_label)
}
@OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
val playbackCompletionDate: Date? = item.media?.playbackCompletionDate
deleteFromHistory(item)
(fragment.requireActivity() as MainActivity)
.showSnackbarAbovePlayer(R.string.removed_history_label, Snackbar.LENGTH_LONG)
.setAction(fragment.getString(R.string.undo)) {
if (playbackCompletionDate != null) addToHistory(item, playbackCompletionDate) }
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return true
}
fun deleteFromHistory(episode: Episode) {
addToHistory(episode, Date(0))
}
}

View File

@ -1,74 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.content.Context
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Job
class RemoveFromQueueSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.REMOVE_FROM_QUEUE
}
override fun getActionIcon(): Int {
return R.drawable.ic_playlist_remove
}
override fun getActionColor(): Int {
return androidx.appcompat.R.attr.colorAccent
}
override fun getTitle(context: Context): String {
return context.getString(R.string.remove_from_queue_label)
}
@OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
val position: Int = curQueue.episodes.indexOf(item)
removeFromQueue(item)
if (willRemove(filter, item)) {
(fragment.requireActivity() as MainActivity).showSnackbarAbovePlayer(fragment.resources.getQuantityString(R.plurals.removed_from_queue_batch_label, 1, 1), Snackbar.LENGTH_LONG)
.setAction(fragment.getString(R.string.undo)) {
addToQueueAt(item, position)
}
}
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showQueued || filter.showNotQueued
}
/**
* Inserts a Episode in the queue at the specified index. The 'read'-attribute of the Episode will be set to
* true. If the Episode is already in the queue, the queue will not be modified.
* @param episode the Episode that should be added to the queue.
* @param index Destination index. Must be in range 0..queue.size()
* @throws IndexOutOfBoundsException if index < 0 || index >= queue.size()
*/
@UnstableApi
fun addToQueueAt(episode: Episode, index: Int) : Job {
return runOnIOScope {
if (curQueue.episodeIds.contains(episode.id)) return@runOnIOScope
if (episode.isNew) setPlayState(Episode.PlayState.UNPLAYED.code, false, episode)
curQueue = upsert(curQueue) {
it.episodeIds.add(index, episode.id)
it.update()
}
// curQueue.episodes.add(index, episode)
EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, index))
// if (performAutoDownload) autodownloadEpisodeMedia(context)
}
}
}

View File

@ -1,33 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import android.content.Context
import androidx.fragment.app.Fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
class ShowFirstSwipeDialogAction : SwipeAction {
override fun getId(): String {
return "SHOW_FIRST_SWIPE_DIALOG"
}
override fun getActionIcon(): Int {
return R.drawable.ic_settings
}
override fun getActionColor(): Int {
return R.attr.icon_gray
}
override fun getTitle(context: Context): String {
return ""
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
//handled in SwipeActions
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return false
}
}

View File

@ -1,36 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import android.content.Context
import androidx.fragment.app.Fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.ui.actions.actionbutton.DownloadActionButton
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
class StartDownloadSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.START_DOWNLOAD
}
override fun getActionIcon(): Int {
return R.drawable.ic_download
}
override fun getActionColor(): Int {
return R.attr.icon_green
}
override fun getTitle(context: Context): String {
return context.getString(R.string.download_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
if (!item.isDownloaded && item.feed != null && !item.feed!!.isLocalFeed) {
DownloadActionButton(item).onClick(fragment.requireContext())
}
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return false
}
}

View File

@ -1,20 +1,41 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.storage.database.Episodes.addToHistory
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync
import ac.mdiq.podcini.storage.database.Episodes.setFavorite
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem
import ac.mdiq.podcini.storage.database.Queues.addToQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.actionbutton.DownloadActionButton
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction.Companion.NO_ACTION
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.dialog.SwipeActionsDialog
import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment
import ac.mdiq.podcini.ui.fragment.DownloadsFragment
import ac.mdiq.podcini.ui.fragment.HistoryFragment
import ac.mdiq.podcini.ui.fragment.QueuesFragment
import ac.mdiq.podcini.ui.utils.LocalDeleteModal.deleteEpisodesWarnLocal
import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr
import ac.mdiq.podcini.ui.view.EpisodeViewHolder
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.SharedPreferences
import android.graphics.Canvas
import android.os.Handler
import androidx.annotation.OptIn
import androidx.core.graphics.ColorUtils
import androidx.fragment.app.Fragment
@ -23,7 +44,13 @@ import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.annimon.stream.Stream
import com.google.android.material.snackbar.Snackbar
import it.xabaras.android.recyclerview.swipedecorator.RecyclerViewSwipeDecorator
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.util.*
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
@ -245,4 +272,327 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v
return prefs!!.getBoolean(KEY_PREFIX_NO_ACTION + tag, true)
}
}
class AddToQueueSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.ADD_TO_QUEUE
}
override fun getActionIcon(): Int {
return R.drawable.ic_playlist_play
}
override fun getActionColor(): Int {
return androidx.appcompat.R.attr.colorAccent
}
override fun getTitle(context: Context): String {
return context.getString(R.string.add_to_queue_label)
}
@OptIn(UnstableApi::class)
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
addToQueue( true, item)
// else RemoveFromQueueSwipeAction().performAction(item, fragment, filter)
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showQueued || filter.showNew
}
}
class DeleteSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.DELETE
}
override fun getActionIcon(): Int {
return R.drawable.ic_delete
}
override fun getActionColor(): Int {
return R.attr.icon_red
}
override fun getTitle(context: Context): String {
return context.getString(R.string.delete_episode_label)
}
@UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
if (!item.isDownloaded && item.feed?.isLocalFeed != true) return
deleteEpisodesWarnLocal(fragment.requireContext(), listOf(item))
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showDownloaded && (item.isDownloaded || item.feed?.isLocalFeed == true)
}
}
class MarkFavoriteSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.MARK_FAV
}
override fun getActionIcon(): Int {
return R.drawable.ic_star
}
override fun getActionColor(): Int {
return R.attr.icon_yellow
}
override fun getTitle(context: Context): String {
return context.getString(R.string.add_to_favorite_label)
}
@OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
setFavorite(item, !item.isFavorite)
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showIsFavorite || filter.showNotFavorite
}
}
class NoActionSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.NO_ACTION
}
override fun getActionIcon(): Int {
return R.drawable.ic_questionmark
}
override fun getActionColor(): Int {
return R.attr.icon_red
}
override fun getTitle(context: Context): String {
return context.getString(R.string.no_action_label)
}
@UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return false
}
}
class RemoveFromHistorySwipeAction : SwipeAction {
val TAG = this::class.simpleName ?: "Anonymous"
override fun getId(): String {
return SwipeAction.REMOVE_FROM_HISTORY
}
override fun getActionIcon(): Int {
return R.drawable.ic_history_remove
}
override fun getActionColor(): Int {
return R.attr.icon_purple
}
override fun getTitle(context: Context): String {
return context.getString(R.string.remove_history_label)
}
@OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
val playbackCompletionDate: Date? = item.media?.playbackCompletionDate
deleteFromHistory(item)
(fragment.requireActivity() as MainActivity)
.showSnackbarAbovePlayer(R.string.removed_history_label, Snackbar.LENGTH_LONG)
.setAction(fragment.getString(R.string.undo)) {
if (playbackCompletionDate != null) addToHistory(item, playbackCompletionDate) }
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return true
}
fun deleteFromHistory(episode: Episode) {
addToHistory(episode, Date(0))
}
}
class RemoveFromQueueSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.REMOVE_FROM_QUEUE
}
override fun getActionIcon(): Int {
return R.drawable.ic_playlist_remove
}
override fun getActionColor(): Int {
return androidx.appcompat.R.attr.colorAccent
}
override fun getTitle(context: Context): String {
return context.getString(R.string.remove_from_queue_label)
}
@OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
val position: Int = curQueue.episodes.indexOf(item)
removeFromQueue(item)
if (willRemove(filter, item)) {
(fragment.requireActivity() as MainActivity).showSnackbarAbovePlayer(fragment.resources.getQuantityString(R.plurals.removed_from_queue_batch_label, 1, 1), Snackbar.LENGTH_LONG)
.setAction(fragment.getString(R.string.undo)) {
addToQueueAt(item, position)
}
}
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return filter.showQueued || filter.showNotQueued
}
/**
* Inserts a Episode in the queue at the specified index. The 'read'-attribute of the Episode will be set to
* true. If the Episode is already in the queue, the queue will not be modified.
* @param episode the Episode that should be added to the queue.
* @param index Destination index. Must be in range 0..queue.size()
* @throws IndexOutOfBoundsException if index < 0 || index >= queue.size()
*/
@UnstableApi
fun addToQueueAt(episode: Episode, index: Int) : Job {
return runOnIOScope {
if (curQueue.episodeIds.contains(episode.id)) return@runOnIOScope
if (episode.isNew) setPlayState(Episode.PlayState.UNPLAYED.code, false, episode)
curQueue = upsert(curQueue) {
it.episodeIds.add(index, episode.id)
it.update()
}
// curQueue.episodes.add(index, episode)
EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, index))
// if (performAutoDownload) autodownloadEpisodeMedia(context)
}
}
}
class ShowFirstSwipeDialogAction : SwipeAction {
override fun getId(): String {
return "SHOW_FIRST_SWIPE_DIALOG"
}
override fun getActionIcon(): Int {
return R.drawable.ic_settings
}
override fun getActionColor(): Int {
return R.attr.icon_gray
}
override fun getTitle(context: Context): String {
return ""
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
//handled in SwipeActions
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return false
}
}
class StartDownloadSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.START_DOWNLOAD
}
override fun getActionIcon(): Int {
return R.drawable.ic_download
}
override fun getActionColor(): Int {
return R.attr.icon_green
}
override fun getTitle(context: Context): String {
return context.getString(R.string.download_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
if (!item.isDownloaded && item.feed != null && !item.feed!!.isLocalFeed) {
DownloadActionButton(item).onClick(fragment.requireContext())
}
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return false
}
}
class TogglePlaybackStateSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.TOGGLE_PLAYED
}
override fun getActionIcon(): Int {
return R.drawable.ic_mark_played
}
override fun getActionColor(): Int {
return R.attr.icon_gray
}
override fun getTitle(context: Context): String {
return context.getString(R.string.toggle_played_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
val newState = if (item.playState == Episode.PlayState.UNPLAYED.code) Episode.PlayState.PLAYED.code else Episode.PlayState.UNPLAYED.code
Logd("TogglePlaybackStateSwipeAction", "performAction( ${item.id} )")
// we're marking it as unplayed since the user didn't actually play it
// but they don't want it considered 'NEW' anymore
var item = runBlocking { setPlayStateSync(newState, false, item) }
val h = Handler(fragment.requireContext().mainLooper)
val r = Runnable {
val media: EpisodeMedia? = item.media
val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!)
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) {
item = deleteMediaSync(fragment.requireContext(), item)
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) }
}
val playStateStringRes: Int = when (newState) {
Episode.PlayState.UNPLAYED.code -> if (item.playState == Episode.PlayState.NEW.code) R.string.removed_inbox_label //was new
else R.string.marked_as_unplayed_label //was played
Episode.PlayState.PLAYED.code -> R.string.marked_as_played_label
else -> if (item.playState == Episode.PlayState.NEW.code) R.string.removed_inbox_label
else R.string.marked_as_unplayed_label
}
val duration: Int = Snackbar.LENGTH_LONG
if (willRemove(filter, item)) {
(fragment.activity as MainActivity).showSnackbarAbovePlayer(
playStateStringRes, duration)
.setAction(fragment.getString(R.string.undo)) {
setPlayState(item.playState, false, item)
// don't forget to cancel the thing that's going to remove the media
h.removeCallbacks(r)
}
}
h.postDelayed(r, ceil((duration * 1.05f).toDouble()).toLong())
}
private fun delayedExecution(item: Episode, fragment: Fragment, duration: Float) = runBlocking {
delay(ceil((duration * 1.05f).toDouble()).toLong())
val media: EpisodeMedia? = item.media
val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!)
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) {
// deleteMediaOfEpisode(fragment.requireContext(), item)
var item = deleteMediaSync(fragment.requireContext(), item)
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) }
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return if (item.playState == Episode.PlayState.NEW.code) filter.showPlayed || filter.showNew
else filter.showUnplayed || filter.showPlayed || filter.showNew
}
}
}

View File

@ -1,93 +0,0 @@
package ac.mdiq.podcini.ui.actions.swipeactions
import android.content.Context
import androidx.fragment.app.Fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem
import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.util.Logd
import android.os.Handler
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.math.ceil
class TogglePlaybackStateSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.TOGGLE_PLAYED
}
override fun getActionIcon(): Int {
return R.drawable.ic_mark_played
}
override fun getActionColor(): Int {
return R.attr.icon_gray
}
override fun getTitle(context: Context): String {
return context.getString(R.string.toggle_played_label)
}
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
val newState = if (item.playState == Episode.PlayState.UNPLAYED.code) Episode.PlayState.PLAYED.code else Episode.PlayState.UNPLAYED.code
Logd("TogglePlaybackStateSwipeAction", "performAction( ${item.id} )")
// we're marking it as unplayed since the user didn't actually play it
// but they don't want it considered 'NEW' anymore
var item = runBlocking { setPlayStateSync(newState, false, item) }
val h = Handler(fragment.requireContext().mainLooper)
val r = Runnable {
val media: EpisodeMedia? = item.media
val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!)
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) {
item = deleteMediaSync(fragment.requireContext(), item)
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) }
}
val playStateStringRes: Int = when (newState) {
Episode.PlayState.UNPLAYED.code -> if (item.playState == Episode.PlayState.NEW.code) R.string.removed_inbox_label //was new
else R.string.marked_as_unplayed_label //was played
Episode.PlayState.PLAYED.code -> R.string.marked_as_played_label
else -> if (item.playState == Episode.PlayState.NEW.code) R.string.removed_inbox_label
else R.string.marked_as_unplayed_label
}
val duration: Int = Snackbar.LENGTH_LONG
if (willRemove(filter, item)) {
(fragment.activity as MainActivity).showSnackbarAbovePlayer(
playStateStringRes, duration)
.setAction(fragment.getString(R.string.undo)) {
setPlayState(item.playState, false, item)
// don't forget to cancel the thing that's going to remove the media
h.removeCallbacks(r)
}
}
h.postDelayed(r, ceil((duration * 1.05f).toDouble()).toLong())
}
private fun delayedExecution(item: Episode, fragment: Fragment, duration: Float) = runBlocking {
delay(ceil((duration * 1.05f).toDouble()).toLong())
val media: EpisodeMedia? = item.media
val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!)
if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) {
// deleteMediaOfEpisode(fragment.requireContext(), item)
var item = deleteMediaSync(fragment.requireContext(), item)
if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) }
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {
return if (item.playState == Episode.PlayState.NEW.code) filter.showPlayed || filter.showNew
else filter.showUnplayed || filter.showPlayed || filter.showNew
}
}

View File

@ -8,6 +8,7 @@ import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.net.feed.FeedUpdateManager.restartUpdateAlarm
import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnceOrAsk
import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher
import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.playback.cast.CastEnabledActivity
@ -417,7 +418,7 @@ class MainActivity : CastEnabledActivity() {
AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment()
DownloadsFragment.TAG -> fragment = DownloadsFragment()
HistoryFragment.TAG -> fragment = HistoryFragment()
AddFeedFragment.TAG -> fragment = AddFeedFragment()
OnlineSearchFragment.TAG -> fragment = OnlineSearchFragment()
SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment()
StatisticsFragment.TAG -> fragment = StatisticsFragment()
else -> {
@ -622,11 +623,11 @@ class MainActivity : CastEnabledActivity() {
when {
intent.hasExtra(Extras.fragment_feed_id.name) -> {
val feedId = intent.getLongExtra(Extras.fragment_feed_id.name, 0)
val args = intent.getBundleExtra(MainActivityStarter.EXTRA_FRAGMENT_ARGS)
val args = intent.getBundleExtra(MainActivityStarter.Extras.fragment_args.name)
if (feedId > 0) {
val startedFromSearch = intent.getBooleanExtra(Extras.started_from_search.name, false)
val startedFromShare = intent.getBooleanExtra(Extras.started_from_share.name, false)
val addToBackStack = intent.getBooleanExtra(Extras.add_to_back_stack.name, false)
if (startedFromSearch || addToBackStack) loadChildFragment(FeedEpisodesFragment.newInstance(feedId))
if (startedFromShare || addToBackStack) loadChildFragment(FeedEpisodesFragment.newInstance(feedId))
else loadFeedFragmentById(feedId, args)
}
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
@ -635,25 +636,26 @@ class MainActivity : CastEnabledActivity() {
val feedurl = intent.getStringExtra(Extras.fragment_feed_url.name)
if (feedurl != null) loadChildFragment(OnlineFeedViewFragment.newInstance(feedurl))
}
intent.hasExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG) -> {
val tag = intent.getStringExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG)
val args = intent.getBundleExtra(MainActivityStarter.EXTRA_FRAGMENT_ARGS)
intent.hasExtra(Extras.search_string.name) -> {
val query = intent.getStringExtra(Extras.search_string.name)
if (query != null) loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query))
}
intent.hasExtra(MainActivityStarter.Extras.fragment_tag.name) -> {
val tag = intent.getStringExtra(MainActivityStarter.Extras.fragment_tag.name)
val args = intent.getBundleExtra(MainActivityStarter.Extras.fragment_args.name)
if (tag != null) loadFragment(tag, args)
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
}
intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_PLAYER, false) -> {
intent.getBooleanExtra(MainActivityStarter.Extras.open_player.name, false) -> {
// bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED
// bottomSheetCallback.onSlide(dummyView, 1.0f)
}
else -> handleDeeplink(intent.data)
}
if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DRAWER, false)) drawerLayout?.open()
if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DOWNLOAD_LOGS, false))
if (intent.getBooleanExtra(MainActivityStarter.Extras.open_drawer.name, false)) drawerLayout?.open()
if (intent.getBooleanExtra(MainActivityStarter.Extras.open_download_logs.name, false))
DownloadLogFragment().show(supportFragmentManager, null)
if (intent.getBooleanExtra(Extras.refresh_on_start.name, false)) runOnceOrAsk(this)
// to avoid handling the intent twice when the configuration changes
@ -756,9 +758,10 @@ class MainActivity : CastEnabledActivity() {
fragment_feed_id,
fragment_feed_url,
refresh_on_start,
started_from_search,
started_from_share, // TODO: seems not needed
add_to_back_stack,
generated_view_id,
search_string,
}
companion object {
@ -781,5 +784,13 @@ class MainActivity : CastEnabledActivity() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
return intent
}
@JvmStatic
fun showOnlineSearch(context: Context, query: String): Intent {
val intent = Intent(context.applicationContext, MainActivity::class.java)
intent.putExtra(Extras.search_string.name, query)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
return intent
}
}
}

View File

@ -13,8 +13,8 @@ import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.net.URLDecoder
// this now is only used for receiving shared feed url
class OnlineFeedViewActivity : AppCompatActivity() {
class ShareReceiverActivity : AppCompatActivity() {
@OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -31,21 +31,29 @@ class OnlineFeedViewActivity : AppCompatActivity() {
if (urlString != null) feedUrl = URLDecoder.decode(urlString, "UTF-8")
}
if (feedUrl == null) {
Log.e(TAG, "feedUrl is null.")
showNoPodcastFoundError()
} else {
Logd(TAG, "Activity was started with url $feedUrl")
val intent = MainActivity.showOnlineFeed(this, feedUrl)
intent.putExtra(MainActivity.Extras.started_from_search.name, getIntent().getBooleanExtra(MainActivity.Extras.started_from_search.name, false))
startActivity(intent)
finish()
when {
feedUrl.isNullOrBlank() -> {
Log.e(TAG, "feedUrl is empty or null.")
showNoPodcastFoundError()
}
!feedUrl.matches(Regex("[./%]")) -> {
val intent = MainActivity.showOnlineSearch(this, feedUrl)
startActivity(intent)
finish()
}
else -> {
Logd(TAG, "Activity was started with url $feedUrl")
val intent = MainActivity.showOnlineFeed(this, feedUrl)
// intent.putExtra(MainActivity.Extras.started_from_share.name, getIntent().getBooleanExtra(MainActivity.Extras.started_from_share.name, false))
startActivity(intent)
finish()
}
}
}
private fun showNoPodcastFoundError() {
runOnUiThread {
MaterialAlertDialogBuilder(this@OnlineFeedViewActivity)
MaterialAlertDialogBuilder(this@ShareReceiverActivity)
.setNeutralButton(android.R.string.ok) { _: DialogInterface?, _: Int -> finish() }
.setTitle(R.string.error_label)
.setMessage(R.string.null_value_podcast_error)
@ -64,6 +72,6 @@ class OnlineFeedViewActivity : AppCompatActivity() {
companion object {
const val ARG_FEEDURL: String = "arg.feedurl"
private const val RESULT_ERROR = 2
private val TAG: String = OnlineFeedViewActivity::class.simpleName ?: "Anonymous"
private val TAG: String = ShareReceiverActivity::class.simpleName ?: "Anonymous"
}
}

View File

@ -283,9 +283,8 @@ class VideoplayerActivity : CastEnabledActivity() {
videoEpisodeFragment.isFavorite = false
invalidateOptionsMenu()
}
item.itemId == R.id.disable_sleeptimer_item || item.itemId == R.id.set_sleeptimer_item -> {
item.itemId == R.id.disable_sleeptimer_item || item.itemId == R.id.set_sleeptimer_item ->
SleepTimerDialog().show(supportFragmentManager, "SleepTimerDialog")
}
item.itemId == R.id.audio_controls -> {
val dialog = PlaybackControlsDialog.newInstance()
dialog.show(supportFragmentManager, "playback_controls")
@ -374,24 +373,22 @@ class VideoplayerActivity : CastEnabledActivity() {
private lateinit var dialog: AlertDialog
private var _binding: AudioControlsBinding? = null
private val binding get() = _binding!!
private var controller: ServiceStatusHandler? = null
private var statusHandler: ServiceStatusHandler? = null
@UnstableApi override fun onStart() {
super.onStart()
controller = object : ServiceStatusHandler(requireActivity()) {
statusHandler = object : ServiceStatusHandler(requireActivity()) {
override fun loadMediaInfo() {
setupAudioTracks()
}
}
controller?.init()
statusHandler?.init()
}
@UnstableApi override fun onStop() {
super.onStop()
controller?.release()
controller = null
statusHandler?.release()
statusHandler = null
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = AudioControlsBinding.inflate(layoutInflater)
dialog = MaterialAlertDialogBuilder(requireContext())
@ -400,20 +397,17 @@ class VideoplayerActivity : CastEnabledActivity() {
.setPositiveButton(R.string.close_label, null).create()
return dialog
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
@UnstableApi private fun setupAudioTracks() {
val butAudioTracks = binding.audioTracks
if (audioTracks.size < 2 || selectedAudioTrack < 0) {
butAudioTracks.visibility = View.GONE
return
}
butAudioTracks.visibility = View.VISIBLE
butAudioTracks.text = audioTracks[selectedAudioTrack]
butAudioTracks.setOnClickListener {

View File

@ -20,7 +20,7 @@ class MainActivityStarter(private val context: Context) {
}
fun getIntent(): Intent {
if (fragmentArgs != null) intent.putExtra(EXTRA_FRAGMENT_ARGS, fragmentArgs)
if (fragmentArgs != null) intent.putExtra(Extras.fragment_args.name, fragmentArgs)
return intent
}
@ -33,32 +33,32 @@ class MainActivityStarter(private val context: Context) {
}
fun withOpenPlayer(): MainActivityStarter {
intent.putExtra(EXTRA_OPEN_PLAYER, true)
intent.putExtra(Extras.open_player.name, true)
return this
}
fun withOpenFeed(feedId: Long): MainActivityStarter {
intent.putExtra(EXTRA_FEED_ID, feedId)
intent.putExtra(Extras.fragment_feed_id.name, feedId)
return this
}
fun withAddToBackStack(): MainActivityStarter {
intent.putExtra(EXTRA_ADD_TO_BACK_STACK, true)
intent.putExtra(Extras.add_to_back_stack.name, true)
return this
}
fun withFragmentLoaded(fragmentName: String?): MainActivityStarter {
intent.putExtra(EXTRA_FRAGMENT_TAG, fragmentName)
intent.putExtra(Extras.fragment_tag.name, fragmentName)
return this
}
fun withDrawerOpen(): MainActivityStarter {
intent.putExtra(EXTRA_OPEN_DRAWER, true)
intent.putExtra(Extras.open_drawer.name, true)
return this
}
fun withDownloadLogsOpen(): MainActivityStarter {
intent.putExtra(EXTRA_OPEN_DOWNLOAD_LOGS, true)
intent.putExtra(Extras.open_download_logs.name, true)
return this
}
@ -69,14 +69,17 @@ class MainActivityStarter(private val context: Context) {
return this
}
@Suppress("EnumEntryName")
enum class Extras {
open_player,
fragment_feed_id,
add_to_back_stack,
fragment_tag,
open_drawer,
open_download_logs,
fragment_args
}
companion object {
const val INTENT: String = "ac.mdiq.podcini.intents.MAIN_ACTIVITY"
const val EXTRA_OPEN_PLAYER: String = "open_player"
const val EXTRA_FEED_ID: String = "fragment_feed_id"
const val EXTRA_ADD_TO_BACK_STACK: String = "add_to_back_stack"
const val EXTRA_FRAGMENT_TAG: String = "fragment_tag"
const val EXTRA_OPEN_DRAWER: String = "open_drawer"
const val EXTRA_OPEN_DOWNLOAD_LOGS: String = "open_download_logs"
const val EXTRA_FRAGMENT_ARGS: String = "fragment_args"
}
}

View File

@ -1,32 +0,0 @@
package ac.mdiq.podcini.ui.activity.starter
import ac.mdiq.podcini.R
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
/**
* Launches the playback speed dialog activity of the app with specific arguments.
* Does not require a dependency on the actual implementation of the activity.
*/
class PlaybackSpeedActivityStarter(private val context: Context) {
val intent: Intent = Intent(INTENT)
init {
intent.setPackage(context.packageName)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
}
val pendingIntent: PendingIntent
get() = PendingIntent.getActivity(context, R.id.pending_intent_playback_speed, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
fun start() {
context.startActivity(intent)
}
companion object {
const val INTENT: String = "ac.mdiq.podcini.intents.PLAYBACK_SPEED"
}
}

View File

@ -23,7 +23,7 @@ import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.actionbutton.*
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment
import ac.mdiq.podcini.ui.fragment.FeedInfoFragment

View File

@ -1,147 +0,0 @@
package ac.mdiq.podcini.ui.dialog
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FilterDialogBinding
import ac.mdiq.podcini.databinding.FilterDialogRowBinding
import ac.mdiq.podcini.storage.model.FeedFilter
import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.Companion.TAG
import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.Companion.feedsFilter
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButtonToggleGroup
import org.apache.commons.lang3.StringUtils
class FeedFilterDialog : BottomSheetDialogFragment() {
private lateinit var rows: LinearLayout
private var _binding: FilterDialogBinding? = null
private val binding get() = _binding!!
var filter: FeedFilter? = null
private val buttonMap: MutableMap<String, Button> = mutableMapOf()
private val newFilterValues: Set<String>
get() {
val newFilterValues: MutableSet<String> = HashSet()
for (i in 0 until rows.childCount) {
if (rows.getChildAt(i) !is MaterialButtonToggleGroup) continue
val group = rows.getChildAt(i) as MaterialButtonToggleGroup
if (group.checkedButtonId == View.NO_ID) continue
val tag = group.findViewById<View>(group.checkedButtonId).tag as? String ?: continue
newFilterValues.add(tag)
}
return newFilterValues
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val layout = inflater.inflate(R.layout.filter_dialog, container, false)
_binding = FilterDialogBinding.bind(layout)
rows = binding.filterRows
Logd("FeedFilterDialog", "fragment onCreateView")
//add filter rows
for (item in FeedFilterGroup.entries) {
// Logd("EpisodeFilterDialog", "FeedItemFilterGroup: ${item.values[0].filterId} ${item.values[1].filterId}")
val rBinding = FilterDialogRowBinding.inflate(inflater)
// rowBinding.root.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, _: Int, _: Boolean ->
// onFilterChanged(newFilterValues)
// }
rBinding.filterButton1.setOnClickListener { onFilterChanged(newFilterValues) }
rBinding.filterButton2.setOnClickListener { onFilterChanged(newFilterValues) }
rBinding.filterButton1.setText(item.values[0].displayName)
rBinding.filterButton1.tag = item.values[0].filterId
buttonMap[item.values[0].filterId] = rBinding.filterButton1
rBinding.filterButton2.setText(item.values[1].displayName)
rBinding.filterButton2.tag = item.values[1].filterId
buttonMap[item.values[1].filterId] = rBinding.filterButton2
rBinding.filterButton1.maxLines = 3
rBinding.filterButton1.isSingleLine = false
rBinding.filterButton2.maxLines = 3
rBinding.filterButton2.isSingleLine = false
rows.addView(rBinding.root, rows.childCount - 1)
}
binding.confirmFiltermenu.setOnClickListener { dismiss() }
binding.resetFiltermenu.setOnClickListener {
onFilterChanged(emptySet())
for (i in 0 until rows.childCount) {
if (rows.getChildAt(i) is MaterialButtonToggleGroup) (rows.getChildAt(i) as MaterialButtonToggleGroup).clearChecked()
}
}
if (filter != null) {
for (filterId in filter!!.values) {
if (filterId.isNotEmpty()) {
val button = buttonMap[filterId]
if (button != null) (button.parent as MaterialButtonToggleGroup).check(button.id)
}
}
}
return layout
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnShowListener { dialogInterface: DialogInterface ->
val bottomSheetDialog = dialogInterface as BottomSheetDialog
setupFullHeight(bottomSheetDialog)
}
return dialog
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
private fun setupFullHeight(bottomSheetDialog: BottomSheetDialog) {
val bottomSheet = bottomSheetDialog.findViewById<View>(com.leinardi.android.speeddial.R.id.design_bottom_sheet) as? FrameLayout
if (bottomSheet != null) {
val behavior = BottomSheetBehavior.from(bottomSheet)
val layoutParams = bottomSheet.layoutParams
bottomSheet.layoutParams = layoutParams
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
fun onFilterChanged(newFilterValues: Set<String>) {
feedsFilter = StringUtils.join(newFilterValues, ",")
Logd(TAG, "onFilterChanged: $feedsFilter")
EventFlow.postEvent(FlowEvent.FeedsFilterEvent(newFilterValues))
}
enum class FeedFilterGroup(vararg values: ItemProperties) {
KEEP_UPDATED(ItemProperties(R.string.keep_updated, FeedFilter.States.keepUpdated.name), ItemProperties(R.string.not_keep_updated, FeedFilter.States.not_keepUpdated.name)),
PLAY_SPEED(ItemProperties(R.string.global_speed, FeedFilter.States.global_playSpeed.name), ItemProperties(R.string.custom_speed, FeedFilter.States.custom_playSpeed.name)),
SKIPS(ItemProperties(R.string.has_skips, FeedFilter.States.has_skips.name), ItemProperties(R.string.no_skips, FeedFilter.States.no_skips.name)),
AUTO_DELETE(ItemProperties(R.string.always_auto_delete, FeedFilter.States.always_auto_delete.name), ItemProperties(R.string.never_auto_delete, FeedFilter.States.never_auto_delete.name)),
AUTO_DOWNLOAD(ItemProperties(R.string.auto_download, FeedFilter.States.autoDownload.name), ItemProperties(R.string.not_auto_download, FeedFilter.States.not_autoDownload.name));
@JvmField
val values: Array<ItemProperties> = arrayOf(*values)
class ItemProperties(@JvmField val displayName: Int, @JvmField val filterId: String)
}
companion object {
fun newInstance(filter: FeedFilter?): FeedFilterDialog {
val dialog = FeedFilterDialog()
dialog.filter = filter
return dialog
}
}
}

View File

@ -23,7 +23,8 @@ import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
@OptIn(UnstableApi::class) class SwipeActionsDialog(private val context: Context, private val tag: String) {
@OptIn(UnstableApi::class)
class SwipeActionsDialog(private val context: Context, private val tag: String) {
private lateinit var keys: List<SwipeAction>
private var rightAction: SwipeAction? = null

View File

@ -45,7 +45,8 @@ import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
@OptIn(UnstableApi::class) open class VariableSpeedDialog : BottomSheetDialogFragment() {
@OptIn(UnstableApi::class)
open class VariableSpeedDialog : BottomSheetDialogFragment() {
private lateinit var adapter: SpeedSelectionAdapter
private lateinit var speedSeekBar: PlaybackSpeedSeekBar

View File

@ -1,216 +0,0 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.AddfeedBinding
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.net.feed.discovery.*
import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.isOPMLRestared
import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.performRestore
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.fragment.NavDrawerFragment.Companion.feedCount
import ac.mdiq.podcini.util.Logd
import android.content.*
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
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
/**
* Provides actions for adding new podcast subscriptions.
*/
@UnstableApi
class AddFeedFragment : Fragment() {
private var _binding: AddfeedBinding? = null
private val binding get() = _binding!!
private var activity: MainActivity? = null
private var displayUpArrow = false
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(ActivityResultContracts.GetContent()) { uri: Uri? ->
this.chooseOpmlImportPathResult(uri) }
private val addLocalFolderLauncher = registerForActivityResult<Uri?, Uri>(AddLocalFolder()) { uri: Uri? -> this.addLocalFolderResult(uri) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
_binding = AddfeedBinding.inflate(inflater)
activity = getActivity() as? MainActivity
Logd(TAG, "fragment onCreateView")
displayUpArrow = parentFragmentManager.backStackEntryCount != 0
if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
(getActivity() as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow)
binding.searchVistaGuideButton.setOnClickListener { activity?.loadChildFragment(OnlineSearchFragment.newInstance(VistaGuidePodcastSearcher::class.java)) }
binding.searchItunesButton.setOnClickListener { activity?.loadChildFragment(OnlineSearchFragment.newInstance(ItunesPodcastSearcher::class.java)) }
binding.searchFyydButton.setOnClickListener { activity?.loadChildFragment(OnlineSearchFragment.newInstance(FyydPodcastSearcher::class.java)) }
binding.searchGPodderButton.setOnClickListener { activity?.loadChildFragment(OnlineSearchFragment.newInstance(GpodnetPodcastSearcher::class.java)) }
binding.searchPodcastIndexButton.setOnClickListener { activity?.loadChildFragment(OnlineSearchFragment.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()
activity?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
}
}
binding.addLocalFolderButton.setOnClickListener {
try {
addLocalFolderLauncher.launch(null)
} catch (e: ActivityNotFoundException) {
e.printStackTrace()
activity?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
}
}
binding.searchButton.setOnClickListener { performSearch() }
if (isOPMLRestared && feedCount == 0) {
AlertDialog.Builder(requireContext())
.setTitle(R.string.restore_subscriptions_label)
.setMessage(R.string.restore_subscriptions_summary)
.setPositiveButton("Yes") { dialog, _ ->
performRestore(requireContext())
dialog.dismiss()
parentFragmentManager.popBackStack()
}
.setNegativeButton("No") { dialog, _ ->
dialog.dismiss()
}
.show()
}
return binding.root
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(KEY_UP_ARROW, displayUpArrow)
super.onSaveInstanceState(outState)
}
private fun showAddViaUrlDialog() {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(R.string.add_podcast_by_url)
val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
dialogBinding.editText.setHint(R.string.add_podcast_by_url_hint)
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData: ClipData? = clipboard.primaryClip
if (clipData != null && clipData.itemCount > 0 && clipData.getItemAt(0).text != null) {
val clipboardContent: String = clipData.getItemAt(0).text.toString()
if (clipboardContent.trim { it <= ' ' }.startsWith("http")) dialogBinding.editText.setText(clipboardContent.trim { it <= ' ' })
}
builder.setView(dialogBinding.root)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> addUrl(dialogBinding.editText.text.toString()) }
builder.setNegativeButton(R.string.cancel_label, null)
builder.show()
}
private fun addUrl(url: String) {
val fragment: Fragment = OnlineFeedViewFragment.newInstance(url)
(activity as MainActivity).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
}
activity?.loadChildFragment(OnlineSearchFragment.newInstance(CombinedSearcher::class.java, query))
binding.combinedFeedSearchEditText.post { binding.combinedFeedSearchEditText.setText("") }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
private fun chooseOpmlImportPathResult(uri: Uri?) {
if (uri == null) return
val intent = Intent(context, OpmlImportActivity::class.java)
intent.setData(uri)
startActivity(intent)
}
@UnstableApi private fun addLocalFolderResult(uri: Uri?) {
if (uri == null) return
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
try {
val feed = withContext(Dispatchers.IO) { addLocalFolder(uri) }
withContext(Dispatchers.Main) {
if (feed != null) {
val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
(getActivity() as MainActivity).loadChildFragment(fragment)
}
}
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
(getActivity() as MainActivity).showSnackbarAbovePlayer(e.localizedMessage, Snackbar.LENGTH_LONG)
}
}
}
@UnstableApi private fun addLocalFolder(uri: Uri): Feed? {
requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val documentFile = DocumentFile.fromTreeUri(requireContext(), uri)
requireNotNull(documentFile) { "Unable to retrieve document tree" }
var title = documentFile.name
if (title == null) title = getString(R.string.local_folder)
val dirFeed = Feed(Feed.PREFIX_LOCAL_FOLDER + uri.toString(), null, title)
dirFeed.episodes.clear()
dirFeed.sortOrder = EpisodeSortOrder.EPISODE_TITLE_A_Z
val fromDatabase: Feed? = updateFeed(requireContext(), dirFeed, false)
FeedUpdateManager.runOnce(requireContext(), fromDatabase)
return fromDatabase
}
private class AddLocalFolder : ActivityResultContracts.OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent {
return super.createIntent(context, input).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
companion object {
val TAG = AddFeedFragment::class.simpleName ?: "Anonymous"
private const val KEY_UP_ARROW = "up_arrow"
}
}

View File

@ -32,7 +32,7 @@ import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.utils.ChapterUtils
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode
import ac.mdiq.podcini.ui.activity.VideoplayerActivity.VideoMode

View File

@ -6,7 +6,7 @@ import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodesAdapter

View File

@ -1,274 +0,0 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FragmentOnlineSearchBinding
import ac.mdiq.podcini.databinding.SelectCountryDialogBinding
import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader
import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader.Companion.prefs
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.content.DialogInterface
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.View.OnFocusChangeListener
import android.view.ViewGroup
import android.widget.*
import android.widget.AdapterView.OnItemClickListener
import androidx.annotation.OptIn
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.MaterialAutoCompleteTextView
import kotlinx.coroutines.*
import java.util.*
/**
* Searches iTunes store for top podcasts and displays results in a list.
*/
class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var _binding: FragmentOnlineSearchBinding? = null
private val binding get() = _binding!!
// private lateinit var prefs: SharedPreferences
private lateinit var gridView: GridView
private lateinit var progressBar: ProgressBar
private lateinit var txtvError: TextView
private lateinit var butRetry: Button
private lateinit var txtvEmpty: TextView
private lateinit var toolbar: MaterialToolbar
/**
* Adapter responsible with the search results.
*/
private var adapter: OnlineFeedsAdapter? = null
/**
* List of podcasts retreived from the search.
*/
private var searchResults: List<PodcastSearchResult>? = null
private var topList: List<PodcastSearchResult>? = null
private var countryCode: String? = "US"
private var hidden = false
private var needsConfirm = false
/**
* Replace adapter data with provided search results from SearchTask.
*
* @param result List of Podcast objects containing search results
*/
private fun updateData(result: List<PodcastSearchResult>?) {
this.searchResults = result
adapter?.clear()
if (!result.isNullOrEmpty()) {
gridView.visibility = View.VISIBLE
txtvEmpty.visibility = View.GONE
for (p in result) {
adapter!!.add(p)
}
adapter?.notifyDataSetInvalidated()
} else {
gridView.visibility = View.GONE
txtvEmpty.visibility = View.VISIBLE
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// prefs = requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE)
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)
}
@OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
// Inflate the layout for this fragment
_binding = FragmentOnlineSearchBinding.inflate(inflater)
// val root = inflater.inflate(R.layout.fragment_itunes_search, container, false)
Logd(TAG, "fragment onCreateView")
gridView = binding.gridView
adapter = OnlineFeedsAdapter(requireActivity(), ArrayList())
gridView.setAdapter(adapter)
toolbar = binding.toolbar
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
toolbar.inflateMenu(R.menu.countries_menu)
val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item)
discoverHideItem.setChecked(hidden)
toolbar.setOnMenuItemClickListener(this)
//Show information about the podcast when the list item is clicked
gridView.onItemClickListener = OnItemClickListener { _: AdapterView<*>?, _: View?, position: Int, _: Long ->
val podcast = searchResults!![position]
if (podcast.feedUrl == null) return@OnItemClickListener
val fragment: Fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl)
(activity as MainActivity).loadChildFragment(fragment)
}
progressBar = binding.progressBar
txtvError = binding.txtvError
butRetry = binding.butRetry
txtvEmpty = binding.empty
loadToplist(countryCode)
return binding.root
}
override fun onDestroy() {
_binding = null
adapter = null
searchResults = null
topList = null
super.onDestroy()
}
private fun loadToplist(country: String?) {
gridView.visibility = View.GONE
txtvError.visibility = View.GONE
butRetry.visibility = View.GONE
butRetry.setText(R.string.retry_label)
txtvEmpty.visibility = View.GONE
progressBar.visibility = View.VISIBLE
if (hidden) {
gridView.visibility = View.GONE
txtvError.visibility = View.VISIBLE
txtvError.text = resources.getString(R.string.discover_is_hidden)
butRetry.visibility = View.GONE
txtvEmpty.visibility = View.GONE
progressBar.visibility = View.GONE
return
}
if (BuildConfig.FLAVOR == "free" && needsConfirm) {
txtvError.visibility = View.VISIBLE
txtvError.text = ""
butRetry.visibility = View.VISIBLE
butRetry.setText(R.string.discover_confirm)
butRetry.setOnClickListener {
prefs!!.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply()
needsConfirm = false
loadToplist(country)
}
txtvEmpty.visibility = View.GONE
progressBar.visibility = View.GONE
return
}
val loader = ItunesTopListLoader(requireContext())
lifecycleScope.launch {
try {
val podcasts = withContext(Dispatchers.IO) {
loader.loadToplist(country?:"", NUM_OF_TOP_PODCASTS, getFeedList())
}
withContext(Dispatchers.Main) {
progressBar.visibility = View.GONE
topList = podcasts
updateData(topList)
}
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
progressBar.visibility = View.GONE
txtvError.text = e.message
txtvError.visibility = View.VISIBLE
butRetry.setOnClickListener { loadToplist(country) }
butRetry.visibility = View.VISIBLE
}
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true
val itemId = item.itemId
when (itemId) {
R.id.discover_hide_item -> {
item.setChecked(!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 = 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.setChecked(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 val TAG: String = DiscoveryFragment::class.simpleName ?: "Anonymous"
private const val NUM_OF_TOP_PODCASTS = 25
}
}

View File

@ -16,7 +16,7 @@ import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.actionbutton.DeleteActionButton
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity

View File

@ -16,7 +16,7 @@ import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.fromCode
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodesAdapter

View File

@ -116,7 +116,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
binding.btnvRelatedFeeds.setOnClickListener {
val fragment = OnlineSearchFragment.newInstance(CombinedSearcher::class.java, "${binding.header.txtvAuthor.text} podcasts")
val fragment = SearchResultsFragment.newInstance(CombinedSearcher::class.java, "${binding.header.txtvAuthor.text} podcasts")
(activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE)
}
binding.txtvUrl.setOnClickListener(copyUrlToClipboard)

View File

@ -186,7 +186,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
HistoryFragment.TAG -> R.drawable.ic_history
SubscriptionsFragment.TAG -> R.drawable.ic_subscriptions
StatisticsFragment.TAG -> R.drawable.ic_chart_box
AddFeedFragment.TAG -> R.drawable.ic_add
OnlineSearchFragment.TAG -> R.drawable.ic_add
else -> 0
}
}
@ -384,7 +384,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
DownloadsFragment.TAG,
HistoryFragment.TAG,
StatisticsFragment.TAG,
AddFeedFragment.TAG,
OnlineSearchFragment.TAG,
)
fun saveLastNavFragment(tag: String?) {

View File

@ -94,6 +94,7 @@ class OnlineFeedViewFragment : Fragment() {
var feedSource: String = ""
var feedUrl: String = ""
private val feedId: Long
get() {
if (feeds == null) return 0
@ -128,6 +129,7 @@ class OnlineFeedViewFragment : Fragment() {
(activity as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow)
feedUrl = requireArguments().getString(ARG_FEEDURL) ?: ""
Logd(TAG, "feedUrl: $feedUrl")
if (feedUrl.isEmpty()) {
Log.e(TAG, "feedUrl is null.")
showNoPodcastFoundError()
@ -194,8 +196,7 @@ class OnlineFeedViewFragment : Fragment() {
private fun lookupUrlAndBuild(url: String) {
lifecycleScope.launch(Dispatchers.IO) {
val urlString = PodcastSearcherRegistry.lookupUrl1(url)
try {
startFeedBuilding(urlString)
try { startFeedBuilding(urlString)
} catch (e: FeedUrlNotFoundException) { tryToRetrieveFeedUrlBySearch(e)
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
@ -258,7 +259,8 @@ class OnlineFeedViewFragment : Fragment() {
private fun startFeedBuilding(url: String) {
Logd(TAG, "startFeedBuilding")
if (feedSource == "VistaGuide") {
if (feedSource == "VistaGuide" || url.contains("youtube.com")) {
feedSource = "VistaGuide"
lifecycleScope.launch(Dispatchers.IO) {
try {
feeds = getFeedList()

View File

@ -1,198 +1,210 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FragmentOnlineSearchBinding
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcher
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.databinding.AddfeedBinding
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.net.feed.discovery.*
import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.isOPMLRestared
import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.performRestore
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.adapter.OnlineFeedsAdapter
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
import ac.mdiq.podcini.ui.fragment.NavDrawerFragment.Companion.feedCount
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.content.*
import android.net.Uri
import android.os.Bundle
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.*
import androidx.appcompat.widget.SearchView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
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
/**
* Provides actions for adding new podcast subscriptions.
*/
@UnstableApi
class OnlineSearchFragment : Fragment() {
private var _binding: FragmentOnlineSearchBinding? = null
private var _binding: AddfeedBinding? = null
private val binding get() = _binding!!
private var adapter: OnlineFeedsAdapter? = null
private var searchProvider: PodcastSearcher? = null
private lateinit var gridView: GridView
private lateinit var progressBar: ProgressBar
private lateinit var txtvError: TextView
private lateinit var butRetry: Button
private lateinit var txtvEmpty: TextView
private var activity: MainActivity? = null
private var displayUpArrow = false
/**
* List of podcasts retreived from the search
*/
private var searchResults: MutableList<PodcastSearchResult>? = null
// private var disposable: Disposable? = null
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(ActivityResultContracts.GetContent()) { uri: Uri? ->
this.chooseOpmlImportPathResult(uri) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
for (info in PodcastSearcherRegistry.searchProviders) {
Logd(TAG, "searchProvider: $info")
if (info.searcher.javaClass.getName() == requireArguments().getString(ARG_SEARCHER)) {
searchProvider = info.searcher
break
}
}
if (searchProvider == null) Logd(TAG,"Podcast searcher not found")
}
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentOnlineSearchBinding.inflate(inflater)
private val addLocalFolderLauncher = registerForActivityResult<Uri?, Uri>(AddLocalFolder()) { uri: Uri? -> this.addLocalFolderResult(uri) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
_binding = AddfeedBinding.inflate(inflater)
activity = getActivity() as? MainActivity
Logd(TAG, "fragment onCreateView")
gridView = binding.gridView
adapter = OnlineFeedsAdapter(requireContext(), ArrayList())
gridView.setAdapter(adapter)
displayUpArrow = parentFragmentManager.backStackEntryCount != 0
if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
(getActivity() as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow)
//Show information about the podcast when the list item is clicked
gridView.onItemClickListener = AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, position: Int, _: Long ->
val podcast = searchResults!![position]
if (podcast.feedUrl != null) {
val fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl)
fragment.feedSource = podcast.source
(activity as MainActivity).loadChildFragment(fragment)
binding.searchButton.setOnClickListener { performSearch() }
binding.searchVistaGuideButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(VistaGuidePodcastSearcher::class.java)) }
binding.searchItunesButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(ItunesPodcastSearcher::class.java)) }
binding.searchFyydButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(FyydPodcastSearcher::class.java)) }
binding.searchGPodderButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(GpodnetPodcastSearcher::class.java)) }
binding.searchPodcastIndexButton.setOnClickListener { activity?.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()
activity?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
}
}
progressBar = binding.progressBar
txtvError = binding.txtvError
butRetry = binding.butRetry
txtvEmpty = binding.empty
if (searchProvider != null) binding.searchPoweredBy.text = getString(R.string.search_powered_by, searchProvider!!.name)
setupToolbar(binding.toolbar)
gridView.setOnScrollListener(object : AbsListView.OnScrollListener {
override fun onScrollStateChanged(view: AbsListView, scrollState: Int) {
if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
val imm = activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
binding.addLocalFolderButton.setOnClickListener {
try { addLocalFolderLauncher.launch(null)
} catch (e: ActivityNotFoundException) {
e.printStackTrace()
activity?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
}
override fun onScroll(view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int) {}
})
}
if (isOPMLRestared && feedCount == 0) {
AlertDialog.Builder(requireContext())
.setTitle(R.string.restore_subscriptions_label)
.setMessage(R.string.restore_subscriptions_summary)
.setPositiveButton("Yes") { dialog, _ ->
performRestore(requireContext())
dialog.dismiss()
parentFragmentManager.popBackStack()
}
.setNegativeButton("No") { dialog, _ -> dialog.dismiss() }
.show()
}
return binding.root
}
override fun onDestroy() {
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(KEY_UP_ARROW, displayUpArrow)
super.onSaveInstanceState(outState)
}
private fun showAddViaUrlDialog() {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(R.string.add_podcast_by_url)
val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
dialogBinding.editText.setHint(R.string.add_podcast_by_url_hint)
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData: ClipData? = clipboard.primaryClip
if (clipData != null && clipData.itemCount > 0 && clipData.getItemAt(0).text != null) {
val clipboardContent: String = clipData.getItemAt(0).text.toString()
if (clipboardContent.trim { it <= ' ' }.startsWith("http")) dialogBinding.editText.setText(clipboardContent.trim { it <= ' ' })
}
builder.setView(dialogBinding.root)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> addUrl(dialogBinding.editText.text.toString()) }
builder.setNegativeButton(R.string.cancel_label, null)
builder.show()
}
private fun addUrl(url: String) {
val fragment: Fragment = OnlineFeedViewFragment.newInstance(url)
(activity as MainActivity).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
}
activity?.loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query))
binding.combinedFeedSearchEditText.post { binding.combinedFeedSearchEditText.setText("") }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
searchResults = null
adapter = null
super.onDestroy()
super.onDestroyView()
}
private fun setupToolbar(toolbar: MaterialToolbar) {
toolbar.inflateMenu(R.menu.online_search)
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
private fun chooseOpmlImportPathResult(uri: Uri?) {
if (uri == null) return
val searchItem: MenuItem = toolbar.menu.findItem(R.id.action_search)
val sv = searchItem.actionView as? SearchView
if (sv != null) {
sv.queryHint = getString(R.string.search_podcast_hint)
sv.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(s: String): Boolean {
sv.clearFocus()
search(s)
return true
}
override fun onQueryTextChange(s: String): Boolean {
return false
}
})
sv.setOnQueryTextFocusChangeListener(View.OnFocusChangeListener { view: View, hasFocus: Boolean ->
if (hasFocus) showInputMethod(view.findFocus()) })
}
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
requireActivity().supportFragmentManager.popBackStack()
return true
}
})
searchItem.expandActionView()
if (requireArguments().getString(ARG_QUERY, null) != null)
sv?.setQuery(requireArguments().getString(ARG_QUERY, null), true)
val intent = Intent(context, OpmlImportActivity::class.java)
intent.setData(uri)
startActivity(intent)
}
private fun search(query: String) {
showOnlyProgressBar()
lifecycleScope.launch(Dispatchers.IO) {
@UnstableApi private fun addLocalFolderResult(uri: Uri?) {
if (uri == null) return
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
try {
val result = searchProvider?.search(query)
searchResults = result?.toMutableList()
val feed = withContext(Dispatchers.IO) { addLocalFolder(uri) }
withContext(Dispatchers.Main) {
progressBar.visibility = View.GONE
adapter?.clear()
handleSearchResults()
txtvEmpty.text = getString(R.string.no_results_for_query, query)
if (feed != null) {
val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
(getActivity() as MainActivity).loadChildFragment(fragment)
}
}
} catch (e: Exception) { handleSearchError(e, query) }
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
(getActivity() as MainActivity).showSnackbarAbovePlayer(e.localizedMessage, Snackbar.LENGTH_LONG)
}
}
}
private fun handleSearchResults() {
adapter?.addAll(searchResults!!)
adapter?.notifyDataSetInvalidated()
gridView.visibility = if (!searchResults.isNullOrEmpty()) View.VISIBLE else View.GONE
txtvEmpty.visibility = if (searchResults.isNullOrEmpty()) View.VISIBLE else View.GONE
@UnstableApi private fun addLocalFolder(uri: Uri): Feed? {
requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val documentFile = DocumentFile.fromTreeUri(requireContext(), uri)
requireNotNull(documentFile) { "Unable to retrieve document tree" }
var title = documentFile.name
if (title == null) title = getString(R.string.local_folder)
val dirFeed = Feed(Feed.PREFIX_LOCAL_FOLDER + uri.toString(), null, title)
dirFeed.episodes.clear()
dirFeed.sortOrder = EpisodeSortOrder.EPISODE_TITLE_A_Z
val fromDatabase: Feed? = updateFeed(requireContext(), dirFeed, false)
FeedUpdateManager.runOnce(requireContext(), fromDatabase)
return fromDatabase
}
private fun handleSearchError(e: Throwable, query: String) {
Logd(TAG, "exception: ${e.message}")
progressBar.visibility = View.GONE
txtvError.text = e.toString()
txtvError.visibility = View.VISIBLE
butRetry.setOnClickListener { search(query) }
butRetry.visibility = View.VISIBLE
}
private fun showOnlyProgressBar() {
gridView.visibility = View.GONE
txtvError.visibility = View.GONE
butRetry.visibility = View.GONE
txtvEmpty.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
private fun showInputMethod(view: View) {
val imm = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(view, 0)
private class AddLocalFolder : ActivityResultContracts.OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent {
return super.createIntent(context, input).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
companion object {
private val TAG: String = OnlineSearchFragment::class.simpleName ?: "Anonymous"
private const val ARG_SEARCHER = "searcher"
private const val ARG_QUERY = "query"
@JvmOverloads
fun newInstance(searchProvider: Class<out PodcastSearcher?>, query: String? = null): OnlineSearchFragment {
val fragment = OnlineSearchFragment()
val arguments = Bundle()
arguments.putString(ARG_SEARCHER, searchProvider.name)
arguments.putString(ARG_QUERY, query)
fragment.arguments = arguments
return fragment
}
val TAG = OnlineSearchFragment::class.simpleName ?: "Anonymous"
private const val KEY_UP_ARROW = "up_arrow"
}
}

View File

@ -26,7 +26,7 @@ import ac.mdiq.podcini.storage.model.PlayQueue
import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodesAdapter

View File

@ -2,28 +2,37 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FragmentSearchResultsBinding
import ac.mdiq.podcini.databinding.QuickFeedDiscoveryBinding
import ac.mdiq.podcini.databinding.QuickFeedDiscoveryItemBinding
import ac.mdiq.podcini.databinding.SelectCountryDialogBinding
import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader
import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader.Companion.prefs
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.content.DialogInterface
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.*
import androidx.annotation.OptIn
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import coil.load
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
@ -227,6 +236,245 @@ class QuickDiscoveryFragment : Fragment(), AdapterView.OnItemClickListener {
}
}
/**
* Searches iTunes store for top podcasts and displays results in a list.
*/
class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var _binding: FragmentSearchResultsBinding? = null
private val binding get() = _binding!!
// private lateinit var prefs: SharedPreferences
private lateinit var gridView: GridView
private lateinit var progressBar: ProgressBar
private lateinit var txtvError: TextView
private lateinit var butRetry: Button
private lateinit var txtvEmpty: TextView
private lateinit var toolbar: MaterialToolbar
/**
* Adapter responsible with the search results.
*/
private var adapter: OnlineFeedsAdapter? = null
/**
* List of podcasts retreived from the search.
*/
private var searchResults: List<PodcastSearchResult>? = null
private var topList: List<PodcastSearchResult>? = null
private var countryCode: String? = "US"
private var hidden = false
private var needsConfirm = false
/**
* Replace adapter data with provided search results from SearchTask.
*
* @param result List of Podcast objects containing search results
*/
private fun updateData(result: List<PodcastSearchResult>?) {
this.searchResults = result
adapter?.clear()
if (!result.isNullOrEmpty()) {
gridView.visibility = View.VISIBLE
txtvEmpty.visibility = View.GONE
for (p in result) {
adapter!!.add(p)
}
adapter?.notifyDataSetInvalidated()
} else {
gridView.visibility = View.GONE
txtvEmpty.visibility = View.VISIBLE
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// prefs = requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE)
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)
}
@OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
// Inflate the layout for this fragment
_binding = FragmentSearchResultsBinding.inflate(inflater)
// val root = inflater.inflate(R.layout.fragment_itunes_search, container, false)
Logd(TAG, "fragment onCreateView")
gridView = binding.gridView
adapter = OnlineFeedsAdapter(requireActivity(), ArrayList())
gridView.setAdapter(adapter)
toolbar = binding.toolbar
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
toolbar.inflateMenu(R.menu.countries_menu)
val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item)
discoverHideItem.setChecked(hidden)
toolbar.setOnMenuItemClickListener(this)
//Show information about the podcast when the list item is clicked
gridView.onItemClickListener = AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, position: Int, _: Long ->
val podcast = searchResults!![position]
if (podcast.feedUrl == null) return@OnItemClickListener
val fragment: Fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl)
(activity as MainActivity).loadChildFragment(fragment)
}
progressBar = binding.progressBar
txtvError = binding.txtvError
butRetry = binding.butRetry
txtvEmpty = binding.empty
loadToplist(countryCode)
return binding.root
}
override fun onDestroy() {
_binding = null
adapter = null
searchResults = null
topList = null
super.onDestroy()
}
private fun loadToplist(country: String?) {
gridView.visibility = View.GONE
txtvError.visibility = View.GONE
butRetry.visibility = View.GONE
butRetry.setText(R.string.retry_label)
txtvEmpty.visibility = View.GONE
progressBar.visibility = View.VISIBLE
if (hidden) {
gridView.visibility = View.GONE
txtvError.visibility = View.VISIBLE
txtvError.text = resources.getString(R.string.discover_is_hidden)
butRetry.visibility = View.GONE
txtvEmpty.visibility = View.GONE
progressBar.visibility = View.GONE
return
}
if (BuildConfig.FLAVOR == "free" && needsConfirm) {
txtvError.visibility = View.VISIBLE
txtvError.text = ""
butRetry.visibility = View.VISIBLE
butRetry.setText(R.string.discover_confirm)
butRetry.setOnClickListener {
prefs!!.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply()
needsConfirm = false
loadToplist(country)
}
txtvEmpty.visibility = View.GONE
progressBar.visibility = View.GONE
return
}
val loader = ItunesTopListLoader(requireContext())
lifecycleScope.launch {
try {
val podcasts = withContext(Dispatchers.IO) {
loader.loadToplist(country?:"", NUM_OF_TOP_PODCASTS, getFeedList())
}
withContext(Dispatchers.Main) {
progressBar.visibility = View.GONE
topList = podcasts
updateData(topList)
}
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
progressBar.visibility = View.GONE
txtvError.text = e.message
txtvError.visibility = View.VISIBLE
butRetry.setOnClickListener { loadToplist(country) }
butRetry.visibility = View.VISIBLE
}
}
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item)) return true
val itemId = item.itemId
when (itemId) {
R.id.discover_hide_item -> {
item.setChecked(!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.setChecked(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 val TAG: String = DiscoveryFragment::class.simpleName ?: "Anonymous"
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

@ -10,9 +10,9 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.actions.menuhandler.MenuItemUtils
import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler
import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler
import ac.mdiq.podcini.ui.actions.handler.MenuItemUtils
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodesAdapter
import ac.mdiq.podcini.ui.adapter.SelectableAdapter
@ -26,6 +26,7 @@ import ac.mdiq.podcini.ui.view.SquareImageView
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.os.Bundle
@ -63,7 +64,8 @@ import java.lang.ref.WeakReference
/**
* Performs a search operation on all feeds or one specific feed and displays the search result.
*/
@UnstableApi class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener {
@UnstableApi
class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener {
private var _binding: SearchFragmentBinding? = null
private val binding get() = _binding!!
@ -290,15 +292,14 @@ import java.lang.ref.WeakReference
search()
}
@SuppressLint("StringFormatMatches")
@UnstableApi 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) { performSearch() }
withContext(Dispatchers.Main) {
progressBar.visibility = View.GONE
if (results.first != null) {
@ -398,7 +399,7 @@ import java.lang.ref.WeakReference
(activity as MainActivity).loadChildFragment(fragment)
return
}
(activity as MainActivity).loadChildFragment(OnlineSearchFragment.newInstance(CombinedSearcher::class.java, query))
(activity as MainActivity).loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query))
}
override fun onStartSelectMode() {

View File

@ -0,0 +1,187 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FragmentSearchResultsBinding
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcher
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter
import ac.mdiq.podcini.util.Logd
import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.*
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import com.google.android.material.appbar.MaterialToolbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SearchResultsFragment : Fragment() {
private var _binding: FragmentSearchResultsBinding? = null
private val binding get() = _binding!!
private var adapter: OnlineFeedsAdapter? = null
private var searchProvider: PodcastSearcher? = null
private lateinit var gridView: GridView
private var searchResults: MutableList<PodcastSearchResult> = mutableListOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
for (info in PodcastSearcherRegistry.searchProviders) {
Logd(TAG, "searchProvider: $info")
if (info.searcher.javaClass.getName() == requireArguments().getString(ARG_SEARCHER)) {
searchProvider = info.searcher
break
}
}
if (searchProvider == null) Logd(TAG,"Podcast searcher not found")
}
@UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSearchResultsBinding.inflate(inflater)
Logd(TAG, "fragment onCreateView")
gridView = binding.gridView
adapter = OnlineFeedsAdapter(requireContext(), ArrayList())
gridView.setAdapter(adapter)
//Show information about the podcast when the list item is clicked
gridView.onItemClickListener = AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, position: Int, _: Long ->
val podcast = searchResults[position]
if (podcast.feedUrl != null) {
val fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl)
fragment.feedSource = podcast.source
(activity as MainActivity).loadChildFragment(fragment)
}
}
if (searchProvider != null) binding.searchPoweredBy.text = getString(R.string.search_powered_by, searchProvider!!.name)
setupToolbar(binding.toolbar)
gridView.setOnScrollListener(object : AbsListView.OnScrollListener {
override fun onScrollStateChanged(view: AbsListView, scrollState: Int) {
if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
val imm = activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
}
override fun onScroll(view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int) {}
})
return binding.root
}
override fun onDestroy() {
_binding = null
searchResults = mutableListOf()
adapter = null
super.onDestroy()
}
private fun setupToolbar(toolbar: MaterialToolbar) {
toolbar.inflateMenu(R.menu.online_search)
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
val searchItem: MenuItem = toolbar.menu.findItem(R.id.action_search)
val sv = searchItem.actionView as? SearchView
if (sv != null) {
sv.queryHint = getString(R.string.search_podcast_hint)
sv.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(s: String): Boolean {
sv.clearFocus()
search(s)
return true
}
override fun onQueryTextChange(s: String): Boolean {
return false
}
})
sv.setOnQueryTextFocusChangeListener(View.OnFocusChangeListener { view: View, hasFocus: Boolean ->
if (hasFocus) showInputMethod(view.findFocus()) })
}
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
requireActivity().supportFragmentManager.popBackStack()
return true
}
})
searchItem.expandActionView()
if (requireArguments().getString(ARG_QUERY, null) != null)
sv?.setQuery(requireArguments().getString(ARG_QUERY, null), true)
}
@SuppressLint("StringFormatMatches")
private fun search(query: String) {
showOnlyProgressBar()
lifecycleScope.launch(Dispatchers.IO) {
try {
val result = searchProvider?.search(query) ?: listOf()
searchResults = result.toMutableList()
withContext(Dispatchers.Main) {
binding.progressBar.visibility = View.GONE
adapter?.clear()
handleSearchResults()
binding.empty.text = getString(R.string.no_results_for_query, query)
}
} catch (e: Exception) { handleSearchError(e, query) }
}
}
private fun handleSearchResults() {
adapter?.addAll(searchResults)
adapter?.notifyDataSetInvalidated()
gridView.visibility = if (searchResults.isNotEmpty()) View.VISIBLE else View.GONE
binding.empty.visibility = if (searchResults.isEmpty()) View.VISIBLE else View.GONE
}
private fun handleSearchError(e: Throwable, query: String) {
Logd(TAG, "exception: ${e.message}")
binding.progressBar.visibility = View.GONE
binding.txtvError.text = e.toString()
binding.txtvError.visibility = View.VISIBLE
binding.butRetry.setOnClickListener { search(query) }
binding.butRetry.visibility = View.VISIBLE
}
private fun showOnlyProgressBar() {
gridView.visibility = View.GONE
binding.txtvError.visibility = View.GONE
binding.butRetry.visibility = View.GONE
binding.empty.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
}
private fun showInputMethod(view: View) {
val imm = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(view, 0)
}
companion object {
private val TAG: String = SearchResultsFragment::class.simpleName ?: "Anonymous"
private const val ARG_SEARCHER = "searcher"
private const val ARG_QUERY = "query"
@JvmOverloads
fun newInstance(searchProvider: Class<out PodcastSearcher?>, query: String? = null): SearchResultsFragment {
val fragment = SearchResultsFragment()
val arguments = Bundle()
arguments.putString(ARG_SEARCHER, searchProvider.name)
arguments.putString(ARG_QUERY, query)
fragment.arguments = arguments
return fragment
}
}
}

View File

@ -19,7 +19,6 @@ import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.SelectableAdapter
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.Spinner
import ac.mdiq.podcini.ui.dialog.FeedFilterDialog
import ac.mdiq.podcini.ui.dialog.FeedSortDialog
import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog
import ac.mdiq.podcini.ui.dialog.TagSettingsDialog
@ -32,6 +31,7 @@ import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.app.Activity.RESULT_OK
import android.app.Dialog
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.DialogInterface
@ -69,6 +69,10 @@ import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.elevation.SurfaceColors
import com.google.android.material.floatingactionbutton.FloatingActionButton
@ -77,6 +81,7 @@ import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import org.apache.commons.lang3.StringUtils
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.*
@ -184,7 +189,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd
subscriptionAddButton.setOnClickListener {
if (activity is MainActivity) (activity as MainActivity).loadChildFragment(AddFeedFragment())
if (activity is MainActivity) (activity as MainActivity).loadChildFragment(OnlineSearchFragment())
}
binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString()
@ -1075,6 +1080,128 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
}
}
class FeedFilterDialog : BottomSheetDialogFragment() {
private lateinit var rows: LinearLayout
private var _binding: FilterDialogBinding? = null
private val binding get() = _binding!!
var filter: FeedFilter? = null
private val buttonMap: MutableMap<String, Button> = mutableMapOf()
private val newFilterValues: Set<String>
get() {
val newFilterValues: MutableSet<String> = HashSet()
for (i in 0 until rows.childCount) {
if (rows.getChildAt(i) !is MaterialButtonToggleGroup) continue
val group = rows.getChildAt(i) as MaterialButtonToggleGroup
if (group.checkedButtonId == View.NO_ID) continue
val tag = group.findViewById<View>(group.checkedButtonId).tag as? String ?: continue
newFilterValues.add(tag)
}
return newFilterValues
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val layout = inflater.inflate(R.layout.filter_dialog, container, false)
_binding = FilterDialogBinding.bind(layout)
rows = binding.filterRows
Logd("FeedFilterDialog", "fragment onCreateView")
//add filter rows
for (item in FeedFilterGroup.entries) {
// Logd("EpisodeFilterDialog", "FeedItemFilterGroup: ${item.values[0].filterId} ${item.values[1].filterId}")
val rBinding = FilterDialogRowBinding.inflate(inflater)
// rowBinding.root.addOnButtonCheckedListener { _: MaterialButtonToggleGroup?, _: Int, _: Boolean ->
// onFilterChanged(newFilterValues)
// }
rBinding.filterButton1.setOnClickListener { onFilterChanged(newFilterValues) }
rBinding.filterButton2.setOnClickListener { onFilterChanged(newFilterValues) }
rBinding.filterButton1.setText(item.values[0].displayName)
rBinding.filterButton1.tag = item.values[0].filterId
buttonMap[item.values[0].filterId] = rBinding.filterButton1
rBinding.filterButton2.setText(item.values[1].displayName)
rBinding.filterButton2.tag = item.values[1].filterId
buttonMap[item.values[1].filterId] = rBinding.filterButton2
rBinding.filterButton1.maxLines = 3
rBinding.filterButton1.isSingleLine = false
rBinding.filterButton2.maxLines = 3
rBinding.filterButton2.isSingleLine = false
rows.addView(rBinding.root, rows.childCount - 1)
}
binding.confirmFiltermenu.setOnClickListener { dismiss() }
binding.resetFiltermenu.setOnClickListener {
onFilterChanged(emptySet())
for (i in 0 until rows.childCount) {
if (rows.getChildAt(i) is MaterialButtonToggleGroup) (rows.getChildAt(i) as MaterialButtonToggleGroup).clearChecked()
}
}
if (filter != null) {
for (filterId in filter!!.values) {
if (filterId.isNotEmpty()) {
val button = buttonMap[filterId]
if (button != null) (button.parent as MaterialButtonToggleGroup).check(button.id)
}
}
}
return layout
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.setOnShowListener { dialogInterface: DialogInterface ->
val bottomSheetDialog = dialogInterface as BottomSheetDialog
setupFullHeight(bottomSheetDialog)
}
return dialog
}
override fun onDestroyView() {
Logd(TAG, "onDestroyView")
_binding = null
super.onDestroyView()
}
private fun setupFullHeight(bottomSheetDialog: BottomSheetDialog) {
val bottomSheet = bottomSheetDialog.findViewById<View>(com.leinardi.android.speeddial.R.id.design_bottom_sheet) as? FrameLayout
if (bottomSheet != null) {
val behavior = BottomSheetBehavior.from(bottomSheet)
val layoutParams = bottomSheet.layoutParams
bottomSheet.layoutParams = layoutParams
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
fun onFilterChanged(newFilterValues: Set<String>) {
feedsFilter = StringUtils.join(newFilterValues, ",")
Logd(TAG, "onFilterChanged: $feedsFilter")
EventFlow.postEvent(FlowEvent.FeedsFilterEvent(newFilterValues))
}
enum class FeedFilterGroup(vararg values: ItemProperties) {
KEEP_UPDATED(ItemProperties(R.string.keep_updated, FeedFilter.States.keepUpdated.name), ItemProperties(R.string.not_keep_updated, FeedFilter.States.not_keepUpdated.name)),
PLAY_SPEED(ItemProperties(R.string.global_speed, FeedFilter.States.global_playSpeed.name), ItemProperties(R.string.custom_speed, FeedFilter.States.custom_playSpeed.name)),
SKIPS(ItemProperties(R.string.has_skips, FeedFilter.States.has_skips.name), ItemProperties(R.string.no_skips, FeedFilter.States.no_skips.name)),
AUTO_DELETE(ItemProperties(R.string.always_auto_delete, FeedFilter.States.always_auto_delete.name), ItemProperties(R.string.never_auto_delete, FeedFilter.States.never_auto_delete.name)),
AUTO_DOWNLOAD(ItemProperties(R.string.auto_download, FeedFilter.States.autoDownload.name), ItemProperties(R.string.not_auto_download, FeedFilter.States.not_autoDownload.name));
@JvmField
val values: Array<ItemProperties> = arrayOf(*values)
class ItemProperties(@JvmField val displayName: Int, @JvmField val filterId: String)
}
companion object {
fun newInstance(filter: FeedFilter?): FeedFilterDialog {
val dialog = FeedFilterDialog()
dialog.filter = filter
return dialog
}
}
}
companion object {
val TAG = SubscriptionsFragment::class.simpleName ?: "Anonymous"

View File

@ -62,10 +62,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
private val binding get() = _binding!!
private lateinit var root: ViewGroup
/**
* True if video controls are currently visible.
*/
private var videoControlsShowing = true
private var videoControlsVisible = true
private var videoSurfaceCreated = false
private var lastScreenTap: Long = 0
private val videoControlsHider = Handler(Looper.getMainLooper())
@ -75,10 +72,10 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
private var itemsLoaded = false
private var episode: Episode? = null
private var webviewData: String? = null
var webvDescription: ShownotesWebView? = null
private var webvDescription: ShownotesWebView? = null
var destroyingDueToReload = false
var controller: ServiceStatusHandler? = null
var statusHandler: ServiceStatusHandler? = null
var isFavorite = false
private val onVideoviewTouched = View.OnTouchListener { v: View, event: MotionEvent ->
@ -93,16 +90,15 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
onRewind()
showSkipAnimation(false)
}
if (videoControlsShowing) {
if (videoControlsVisible) {
hideVideoControls(false)
if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide()
videoControlsShowing = false
videoControlsVisible = false
}
return@OnTouchListener true
}
toggleVideoControlsVisibility()
if (videoControlsShowing) setupVideoControlsToggler()
if (videoControlsVisible) setupVideoControlsToggler()
lastScreenTap = System.currentTimeMillis()
true
}
@ -111,7 +107,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
holder.setFixedSize(width, height)
}
@UnstableApi
override fun surfaceCreated(holder: SurfaceHolder) {
Logd(TAG, "Videoview holder created")
@ -120,7 +115,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
if (MediaPlayerBase.status == PlayerStatus.PLAYING) playbackService?.mPlayer?.setVideoSurface(holder)
setupVideoAspectRatio()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Logd(TAG, "Videosurface was destroyed")
videoSurfaceCreated = false
@ -132,26 +126,28 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
}
private val hideVideoControls = Runnable {
if (videoControlsShowing) {
if (videoControlsVisible) {
Logd(TAG, "Hiding video controls")
hideVideoControls(true)
if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as? AppCompatActivity)?.supportActionBar?.hide()
videoControlsShowing = false
videoControlsVisible = false
}
}
@OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@OptIn(UnstableApi::class)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
Logd(TAG, "fragment onCreateView")
_binding = VideoEpisodeFragmentBinding.inflate(LayoutInflater.from(requireContext()))
root = binding.root
controller = newPlaybackController()
controller!!.init()
statusHandler = newStatusHandler()
statusHandler!!.init()
// loadMediaInfo()
setupView()
return root
}
@OptIn(UnstableApi::class) private fun newPlaybackController(): ServiceStatusHandler {
@OptIn(UnstableApi::class) private fun newStatusHandler(): ServiceStatusHandler {
return object : ServiceStatusHandler(requireActivity()) {
override fun updatePlayButton(showPlay: Boolean) {
Logd(TAG, "updatePlayButtonShowsPlay called")
@ -160,7 +156,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
else {
(activity as AppCompatActivity).window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setupVideoAspectRatio()
if (videoSurfaceCreated && controller != null) {
if (videoSurfaceCreated) {
Logd(TAG, "Videosurface already created, setting videosurface now")
// setVideoSurface(binding.videoView.holder)
playbackService?.mPlayer?.setVideoSurface(binding.videoView.holder)
@ -208,8 +204,8 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
webvDescription!!.destroy()
}
_binding = null
controller?.release()
controller = null // prevent leak
statusHandler?.release()
statusHandler = null // prevent leak
super.onDestroyView()
}
@ -259,7 +255,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
}
private fun setupVideoAspectRatio() {
if (videoSurfaceCreated && controller != null) {
if (videoSurfaceCreated) {
if (videoSize != null && videoSize!!.first > 0 && videoSize!!.second > 0) {
Logd(TAG, "Width,height of video: ${videoSize!!.first}, ${videoSize!!.second}")
val videoWidth = resources.displayMetrics.widthPixels
@ -279,7 +275,8 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
}
private var loadItemsRunning = false
@OptIn(UnstableApi::class) private fun loadMediaInfo() {
@OptIn(UnstableApi::class)
private fun loadMediaInfo() {
Logd(TAG, "loadMediaInfo called")
if (curMedia == null) return
if (MediaPlayerBase.status == PlayerStatus.PLAYING && !isPlayingVideoLocally) {
@ -314,14 +311,10 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
}
if (webviewData != null && !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))
} finally {
loadItemsRunning = false
}
} catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e))
} finally { loadItemsRunning = false }
}
}
val media = curMedia
@ -396,16 +389,14 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
}
fun toggleVideoControlsVisibility() {
if (videoControlsShowing) {
if (videoControlsVisible) {
hideVideoControls(true)
if (videoMode == VideoMode.FULL_SCREEN_VIEW) {
(activity as AppCompatActivity).supportActionBar?.hide()
}
if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide()
} else {
showVideoControls()
(activity as AppCompatActivity).supportActionBar?.show()
}
videoControlsShowing = !videoControlsShowing
videoControlsVisible = !videoControlsVisible
}
fun showSkipAnimation(isForward: Boolean) {
@ -448,7 +439,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
@UnstableApi
fun onRewind() {
if (controller == null) return
// if (statusHandler == null) return
playbackService?.mPlayer?.seekDelta(-rewindSecs * 1000)
setupVideoControlsToggler()
}
@ -461,7 +452,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
@UnstableApi
fun onFastForward() {
if (controller == null) return
// if (statusHandler == null) return
playbackService?.mPlayer?.seekDelta(fastForwardSecs * 1000)
setupVideoControlsToggler()
}
@ -503,7 +494,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
}
private fun onPositionObserverUpdate() {
if (controller == null) return
// if (statusHandler == null) return
val converter = TimeSpeedConverter(curSpeedFB)
val currentPosition = converter.convert(curPositionFB)
val duration_ = converter.convert(curDurationFB)
@ -527,7 +518,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
}
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
if (controller == null) return
// if (statusHandler == null) return
if (fromUser) {
prog = progress / (seekBar.max.toFloat())
val converter = TimeSpeedConverter(curSpeedFB)

View File

@ -3,7 +3,7 @@ package ac.mdiq.podcini.ui.view
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.utils.NetworkUtils
import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.ui.actions.menuhandler.MenuItemUtils
import ac.mdiq.podcini.ui.actions.handler.MenuItemUtils
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.utils.ShownotesCleaner
import ac.mdiq.podcini.util.*

View File

@ -1,7 +1,6 @@
package ac.mdiq.podcini.ui.widget
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.utils.ImageResourceUtils.getFallbackImageLocation
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.preferences.UserPreferences.shouldShowRemainingTime
import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createPendingIntent
@ -10,15 +9,17 @@ import ac.mdiq.podcini.receiver.PlayerWidget.Companion.isEnabled
import ac.mdiq.podcini.receiver.PlayerWidget.Companion.prefs
import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.activity.starter.PlaybackSpeedActivityStarter
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.storage.utils.ImageResourceUtils.getFallbackImageLocation
import ac.mdiq.podcini.storage.utils.TimeSpeedConverter
import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
import ac.mdiq.podcini.util.Logd
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.util.Log
@ -35,7 +36,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.max
/**
* Updates the state of the player widget.
*/
@ -206,4 +206,30 @@ object WidgetUpdater {
class WidgetState(val media: Playable?, val status: PlayerStatus, val position: Int, val duration: Int, val playbackSpeed: Float) {
constructor(status: PlayerStatus) : this(null, status, Playable.INVALID_TIME, Playable.INVALID_TIME, 1.0f)
}
/**
* Launches the playback speed dialog activity of the app with specific arguments.
* Does not require a dependency on the actual implementation of the activity.
*/
class PlaybackSpeedActivityStarter(private val context: Context) {
val intent: Intent = Intent(INTENT)
init {
intent.setPackage(context.packageName)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
}
val pendingIntent: PendingIntent
get() = PendingIntent.getActivity(context, R.id.pending_intent_playback_speed, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
fun start() {
context.startActivity(intent)
}
companion object {
const val INTENT: String = "ac.mdiq.podcini.intents.PLAYBACK_SPEED"
}
}
}

View File

@ -38,16 +38,6 @@
android:layout_height="wrap_content"
android:orientation="horizontal">
<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" />
<EditText
android:id="@+id/combinedFeedSearchEditText"
android:layout_width="0dp"
@ -65,6 +55,16 @@
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>

View File

@ -5,7 +5,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/fragment_online_search">
android:id="@+id/fragment_search_results">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"

View File

@ -1,3 +1,11 @@
# 6.5.4
* in the search bar of OnlineSearch view, search button is moved to the end of the bar
* new way of handling sharing of youtube channels from other apps
* normal text (other than url) shared from other apps is taken by OnlineSearch view for podcast search
* preparing mediaSouces is done in IO scope preventing network access blocking Main scope
* some class restructuring and refactoring
# 6.5.3
* properly assigning ids to remote episodes in OnlineFeedView to resolve the issue of duplicates

View File

@ -0,0 +1,7 @@
Version 6.5.4 brings several changes:
* in the search bar of OnlineSearch view, search button is moved to the end of the bar
* new way of handling sharing of youtube channels from other apps
* normal text (other than url) shared from other apps is taken by OnlineSearch view for podcast search
* preparing mediaSouces is done in IO scope preventing network access blocking Main scope
* some class restructuring and refactoring