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" testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020237 versionCode 3020238
versionName "6.5.3" versionName "6.5.4"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""

View File

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

View File

@ -218,10 +218,10 @@ class FeedHandler {
state.namespaces[uri] = Itunes() state.namespaces[uri] = Itunes()
Logd(TAG, "Recognized ITunes namespace") Logd(TAG, "Recognized ITunes namespace")
} }
uri == YouTube.NSURI && prefix == YouTube.NSTAG -> { // uri == YouTube.NSURI && prefix == YouTube.NSTAG -> {
state.namespaces[uri] = YouTube() // state.namespaces[uri] = YouTube()
Logd(TAG, "Recognized YouTube namespace") // Logd(TAG, "Recognized YouTube namespace")
} // }
uri == SimpleChapters.NSURI && prefix.matches(SimpleChapters.NSTAG.toRegex()) -> { uri == SimpleChapters.NSURI && prefix.matches(SimpleChapters.NSTAG.toRegex()) -> {
state.namespaces[uri] = SimpleChapters() state.namespaces[uri] = SimpleChapters()
Logd(TAG, "Recognized SimpleChapters namespace") Logd(TAG, "Recognized SimpleChapters namespace")
@ -238,6 +238,7 @@ class FeedHandler {
state.namespaces[uri] = PodcastIndex() state.namespaces[uri] = PodcastIndex()
Logd(TAG, "Recognized PodcastIndex namespace") 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 ac.mdiq.podcini.net.feed.parser.utils.DurationParser.inMillis
import org.xml.sax.Attributes import org.xml.sax.Attributes
// TODO: this appears not needed
class YouTube : Namespace() { class YouTube : Namespace() {
val TAG = this::class.simpleName ?: "Anonymous" val TAG = this::class.simpleName ?: "Anonymous"

View File

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

View File

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

View File

@ -101,7 +101,6 @@ import kotlin.math.max
*/ */
@UnstableApi @UnstableApi
class PlaybackService : MediaLibraryService() { class PlaybackService : MediaLibraryService() {
private var mediaSession: MediaLibrarySession? = null private var mediaSession: MediaLibrarySession? = null
internal var mPlayer: MediaPlayerBase? = 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 { private val mediaPlayerCallback: MediaPlayerCallback = object : MediaPlayerCallback {
override fun statusChanged(newInfo: MediaPlayerInfo?) { override fun statusChanged(newInfo: MediaPlayerInfo?) {
currentMediaType = mPlayer?.mediaType ?: MediaType.UNKNOWN currentMediaType = mPlayer?.mediaType ?: MediaType.UNKNOWN
@ -391,7 +421,6 @@ class PlaybackService : MediaLibraryService() {
j = if (curIndexInQueue >= 0 && curIndexInQueue < eList.size) curIndexInQueue else eList.size-1 j = if (curIndexInQueue >= 0 && curIndexInQueue < eList.size) curIndexInQueue else eList.size-1
} else if (i < eList.size-1) j = i+1 } else if (i < eList.size-1) j = i+1
Logd(TAG, "getNextInQueue next j: $j") Logd(TAG, "getNextInQueue next j: $j")
val nextItem = unmanaged(eList[j]) val nextItem = unmanaged(eList[j])
Logd(TAG, "getNextInQueue nextItem ${nextItem.title}") Logd(TAG, "getNextInQueue nextItem ${nextItem.title}")
if (nextItem.media == null) { if (nextItem.media == null) {
@ -399,13 +428,11 @@ class PlaybackService : MediaLibraryService() {
writeNoMediaPlaying() writeNoMediaPlaying()
return null return null
} }
if (!isFollowQueue) { if (!isFollowQueue) {
Logd(TAG, "getNextInQueue(), but follow queue is not enabled.") Logd(TAG, "getNextInQueue(), but follow queue is not enabled.")
writeMediaPlaying(nextItem.media, PlayerStatus.STOPPED) writeMediaPlaying(nextItem.media, PlayerStatus.STOPPED)
return null return null
} }
if (!nextItem.media!!.localFileAvailable() && !isStreamingAllowed && isFollowQueue && nextItem.feed?.isLocalFeed != true) { if (!nextItem.media!!.localFileAvailable() && !isStreamingAllowed && isFollowQueue && nextItem.feed?.isLocalFeed != true) {
Logd(TAG, "getNextInQueue nextItem has no local file ${nextItem.title}") Logd(TAG, "getNextInQueue nextItem has no local file ${nextItem.title}")
displayStreamingNotAllowedNotification(PlaybackServiceStarter(this@PlaybackService, nextItem.media!!).intent) displayStreamingNotAllowedNotification(PlaybackServiceStarter(this@PlaybackService, nextItem.media!!).intent)
@ -433,11 +460,9 @@ class PlaybackService : MediaLibraryService() {
else -> EXTRA_CODE_AUDIO else -> EXTRA_CODE_AUDIO
}) })
} }
override fun ensureMediaInfoLoaded(media: Playable) { override fun ensureMediaInfoLoaded(media: Playable) {
// if (media is EpisodeMedia && media.item == null) media.item = DBReader.getFeedItem(media.itemId) // if (media is EpisodeMedia && media.item == null) media.item = DBReader.getFeedItem(media.itemId)
} }
fun writeMediaPlaying(playable: Playable?, playerStatus: PlayerStatus) { fun writeMediaPlaying(playable: Playable?, playerStatus: PlayerStatus) {
Logd(InTheatre.TAG, "Writing playback preferences ${playable?.getIdentifier()}") Logd(InTheatre.TAG, "Writing playback preferences ${playable?.getIdentifier()}")
if (playable == null) writeNoMediaPlaying() if (playable == null) writeNoMediaPlaying()
@ -457,14 +482,12 @@ class PlaybackService : MediaLibraryService() {
} }
} }
} }
fun writePlayerStatus(playerStatus: PlayerStatus) { fun writePlayerStatus(playerStatus: PlayerStatus) {
Logd(InTheatre.TAG, "Writing player status playback preferences") Logd(InTheatre.TAG, "Writing player status playback preferences")
curState = upsertBlk(curState) { curState = upsertBlk(curState) {
it.curPlayerStatus = getCurPlayerStatusAsInt(playerStatus) it.curPlayerStatus = getCurPlayerStatusAsInt(playerStatus)
} }
} }
private fun getCurPlayerStatusAsInt(playerStatus: PlayerStatus): Int { private fun getCurPlayerStatusAsInt(playerStatus: PlayerStatus): Int {
val playerStatusAsInt = when (playerStatus) { val playerStatusAsInt = when (playerStatus) {
PlayerStatus.PLAYING -> PLAYER_STATUS_PLAYING 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 { private val mediaLibrarySessionCK = object: MediaLibrarySession.Callback {
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
Logd(TAG, "in MyMediaSessionCallback onConnect") Logd(TAG, "in MyMediaSessionCallback onConnect")
@ -762,9 +754,9 @@ class PlaybackService : MediaLibraryService() {
val keycode = intent?.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1) ?: -1 val keycode = intent?.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1) ?: -1
val customAction = intent?.getStringExtra(MediaButtonReceiver.EXTRA_CUSTOM_ACTION) val customAction = intent?.getStringExtra(MediaButtonReceiver.EXTRA_CUSTOM_ACTION)
val hardwareButton = intent?.getBooleanExtra(MediaButtonReceiver.EXTRA_HARDWAREBUTTON, false) ?: false 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) intent?.getParcelableExtra(EXTRA_KEY_EVENT, KeyEvent::class.java)
} else { else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
intent?.getParcelableExtra(EXTRA_KEY_EVENT) intent?.getParcelableExtra(EXTRA_KEY_EVENT)
} }
@ -797,6 +789,7 @@ class PlaybackService : MediaLibraryService() {
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
playable != null -> { playable != null -> {
recreateMediaSessionIfNeeded()
Logd(TAG, "onStartCommand status: $status") Logd(TAG, "onStartCommand status: $status")
val allowStreamThisTime = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false) ?: false val allowStreamThisTime = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false) ?: false
val allowStreamAlways = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_ALWAYS, false) ?: false val allowStreamAlways = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_ALWAYS, false) ?: false
@ -963,6 +956,7 @@ class PlaybackService : MediaLibraryService() {
} }
private fun startPlayingFromPreferences() { private fun startPlayingFromPreferences() {
recreateMediaSessionIfNeeded()
scope.launch { scope.launch {
try { try {
withContext(Dispatchers.IO) { loadPlayableFromPreferences() } withContext(Dispatchers.IO) { loadPlayableFromPreferences() }
@ -975,7 +969,7 @@ class PlaybackService : MediaLibraryService() {
} }
private fun startPlaying(allowStreamThisTime: Boolean) { private fun startPlaying(allowStreamThisTime: Boolean) {
Logd(TAG, "startPlaying called $allowStreamThisTime") Logd(TAG, "startPlaying called allowStreamThisTime: $allowStreamThisTime")
val media = curMedia ?: return val media = curMedia ?: return
val localFeed = URLUtil.isContentUrl(media.getStreamUrl()) val localFeed = URLUtil.isContentUrl(media.getStreamUrl())
@ -986,18 +980,17 @@ class PlaybackService : MediaLibraryService() {
return return
} }
if (media.getIdentifier() != curState.curMediaId) clearCurTempSpeed() // TODO: this is redundant
// if (media.getIdentifier() != curState.curMediaId) clearCurTempSpeed()
mPlayer?.playMediaObject(media, streaming, startWhenPrepared = true, true) mPlayer?.playMediaObject(media, streaming, startWhenPrepared = true, true)
recreateMediaSessionIfNeeded() // recreateMediaSessionIfNeeded()
// val episode = (media as? EpisodeMedia)?.episode // val episode = (media as? EpisodeMedia)?.episode
// if (curMedia is EpisodeMedia && episode != null) addToQueue(true, episode) // if (curMedia is EpisodeMedia && episode != null) addToQueue(true, episode)
} }
fun clearCurTempSpeed() { fun clearCurTempSpeed() {
curState = upsertBlk(curState) { curState = upsertBlk(curState) { it.curTempSpeed = FeedPreferences.SPEED_USE_GLOBAL }
it.curTempSpeed = FeedPreferences.SPEED_USE_GLOBAL
}
} }
private var eventSink: Job? = null 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.R
import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected 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.R
import ac.mdiq.podcini.databinding.SelectQueueDialogBinding 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.Menu
import android.view.MenuItem 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 package ac.mdiq.podcini.ui.actions.swipeactions
import ac.mdiq.podcini.R 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.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.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.dialog.SwipeActionsDialog
import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment
import ac.mdiq.podcini.ui.fragment.DownloadsFragment import ac.mdiq.podcini.ui.fragment.DownloadsFragment
import ac.mdiq.podcini.ui.fragment.HistoryFragment import ac.mdiq.podcini.ui.fragment.HistoryFragment
import ac.mdiq.podcini.ui.fragment.QueuesFragment 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.utils.ThemeUtils.getColorFromAttr
import ac.mdiq.podcini.ui.view.EpisodeViewHolder import ac.mdiq.podcini.ui.view.EpisodeViewHolder
import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.Canvas import android.graphics.Canvas
import android.os.Handler
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@ -23,7 +44,13 @@ import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.annimon.stream.Stream import com.annimon.stream.Stream
import com.google.android.material.snackbar.Snackbar
import it.xabaras.android.recyclerview.swipedecorator.RecyclerViewSwipeDecorator 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.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.sin 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) 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
import ac.mdiq.podcini.net.feed.FeedUpdateManager.restartUpdateAlarm import ac.mdiq.podcini.net.feed.FeedUpdateManager.restartUpdateAlarm
import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnceOrAsk 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.feed.discovery.ItunesTopListLoader
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.playback.cast.CastEnabledActivity import ac.mdiq.podcini.playback.cast.CastEnabledActivity
@ -417,7 +418,7 @@ class MainActivity : CastEnabledActivity() {
AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment() AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment()
DownloadsFragment.TAG -> fragment = DownloadsFragment() DownloadsFragment.TAG -> fragment = DownloadsFragment()
HistoryFragment.TAG -> fragment = HistoryFragment() HistoryFragment.TAG -> fragment = HistoryFragment()
AddFeedFragment.TAG -> fragment = AddFeedFragment() OnlineSearchFragment.TAG -> fragment = OnlineSearchFragment()
SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment() SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment()
StatisticsFragment.TAG -> fragment = StatisticsFragment() StatisticsFragment.TAG -> fragment = StatisticsFragment()
else -> { else -> {
@ -622,11 +623,11 @@ class MainActivity : CastEnabledActivity() {
when { when {
intent.hasExtra(Extras.fragment_feed_id.name) -> { intent.hasExtra(Extras.fragment_feed_id.name) -> {
val feedId = intent.getLongExtra(Extras.fragment_feed_id.name, 0) 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) { 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) 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) else loadFeedFragmentById(feedId, args)
} }
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
@ -635,25 +636,26 @@ class MainActivity : CastEnabledActivity() {
val feedurl = intent.getStringExtra(Extras.fragment_feed_url.name) val feedurl = intent.getStringExtra(Extras.fragment_feed_url.name)
if (feedurl != null) loadChildFragment(OnlineFeedViewFragment.newInstance(feedurl)) if (feedurl != null) loadChildFragment(OnlineFeedViewFragment.newInstance(feedurl))
} }
intent.hasExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG) -> { intent.hasExtra(Extras.search_string.name) -> {
val tag = intent.getStringExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG) val query = intent.getStringExtra(Extras.search_string.name)
val args = intent.getBundleExtra(MainActivityStarter.EXTRA_FRAGMENT_ARGS) 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) if (tag != null) loadFragment(tag, args)
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) 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 // bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED
// bottomSheetCallback.onSlide(dummyView, 1.0f) // bottomSheetCallback.onSlide(dummyView, 1.0f)
} }
else -> handleDeeplink(intent.data) else -> handleDeeplink(intent.data)
} }
if (intent.getBooleanExtra(MainActivityStarter.Extras.open_drawer.name, false)) drawerLayout?.open()
if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DRAWER, false)) drawerLayout?.open() if (intent.getBooleanExtra(MainActivityStarter.Extras.open_download_logs.name, false))
if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DOWNLOAD_LOGS, false))
DownloadLogFragment().show(supportFragmentManager, null) DownloadLogFragment().show(supportFragmentManager, null)
if (intent.getBooleanExtra(Extras.refresh_on_start.name, false)) runOnceOrAsk(this) if (intent.getBooleanExtra(Extras.refresh_on_start.name, false)) runOnceOrAsk(this)
// to avoid handling the intent twice when the configuration changes // to avoid handling the intent twice when the configuration changes
@ -756,9 +758,10 @@ class MainActivity : CastEnabledActivity() {
fragment_feed_id, fragment_feed_id,
fragment_feed_url, fragment_feed_url,
refresh_on_start, refresh_on_start,
started_from_search, started_from_share, // TODO: seems not needed
add_to_back_stack, add_to_back_stack,
generated_view_id, generated_view_id,
search_string,
} }
companion object { companion object {
@ -781,5 +784,13 @@ class MainActivity : CastEnabledActivity() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
return intent 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.net.URLDecoder import java.net.URLDecoder
// this now is only used for receiving shared feed url class ShareReceiverActivity : AppCompatActivity() {
class OnlineFeedViewActivity : AppCompatActivity() {
@OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -31,21 +31,29 @@ class OnlineFeedViewActivity : AppCompatActivity() {
if (urlString != null) feedUrl = URLDecoder.decode(urlString, "UTF-8") if (urlString != null) feedUrl = URLDecoder.decode(urlString, "UTF-8")
} }
if (feedUrl == null) { when {
Log.e(TAG, "feedUrl is null.") feedUrl.isNullOrBlank() -> {
showNoPodcastFoundError() Log.e(TAG, "feedUrl is empty or null.")
} else { showNoPodcastFoundError()
Logd(TAG, "Activity was started with url $feedUrl") }
val intent = MainActivity.showOnlineFeed(this, feedUrl) !feedUrl.matches(Regex("[./%]")) -> {
intent.putExtra(MainActivity.Extras.started_from_search.name, getIntent().getBooleanExtra(MainActivity.Extras.started_from_search.name, false)) val intent = MainActivity.showOnlineSearch(this, feedUrl)
startActivity(intent) startActivity(intent)
finish() 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() { private fun showNoPodcastFoundError() {
runOnUiThread { runOnUiThread {
MaterialAlertDialogBuilder(this@OnlineFeedViewActivity) MaterialAlertDialogBuilder(this@ShareReceiverActivity)
.setNeutralButton(android.R.string.ok) { _: DialogInterface?, _: Int -> finish() } .setNeutralButton(android.R.string.ok) { _: DialogInterface?, _: Int -> finish() }
.setTitle(R.string.error_label) .setTitle(R.string.error_label)
.setMessage(R.string.null_value_podcast_error) .setMessage(R.string.null_value_podcast_error)
@ -64,6 +72,6 @@ class OnlineFeedViewActivity : AppCompatActivity() {
companion object { companion object {
const val ARG_FEEDURL: String = "arg.feedurl" const val ARG_FEEDURL: String = "arg.feedurl"
private const val RESULT_ERROR = 2 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 videoEpisodeFragment.isFavorite = false
invalidateOptionsMenu() 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") SleepTimerDialog().show(supportFragmentManager, "SleepTimerDialog")
}
item.itemId == R.id.audio_controls -> { item.itemId == R.id.audio_controls -> {
val dialog = PlaybackControlsDialog.newInstance() val dialog = PlaybackControlsDialog.newInstance()
dialog.show(supportFragmentManager, "playback_controls") dialog.show(supportFragmentManager, "playback_controls")
@ -374,24 +373,22 @@ class VideoplayerActivity : CastEnabledActivity() {
private lateinit var dialog: AlertDialog private lateinit var dialog: AlertDialog
private var _binding: AudioControlsBinding? = null private var _binding: AudioControlsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var controller: ServiceStatusHandler? = null private var statusHandler: ServiceStatusHandler? = null
@UnstableApi override fun onStart() { @UnstableApi override fun onStart() {
super.onStart() super.onStart()
controller = object : ServiceStatusHandler(requireActivity()) { statusHandler = object : ServiceStatusHandler(requireActivity()) {
override fun loadMediaInfo() { override fun loadMediaInfo() {
setupAudioTracks() setupAudioTracks()
} }
} }
controller?.init() statusHandler?.init()
} }
@UnstableApi override fun onStop() { @UnstableApi override fun onStop() {
super.onStop() super.onStop()
controller?.release() statusHandler?.release()
controller = null statusHandler = null
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = AudioControlsBinding.inflate(layoutInflater) _binding = AudioControlsBinding.inflate(layoutInflater)
dialog = MaterialAlertDialogBuilder(requireContext()) dialog = MaterialAlertDialogBuilder(requireContext())
@ -400,20 +397,17 @@ class VideoplayerActivity : CastEnabledActivity() {
.setPositiveButton(R.string.close_label, null).create() .setPositiveButton(R.string.close_label, null).create()
return dialog return dialog
} }
override fun onDestroyView() { override fun onDestroyView() {
Logd(TAG, "onDestroyView") Logd(TAG, "onDestroyView")
_binding = null _binding = null
super.onDestroyView() super.onDestroyView()
} }
@UnstableApi private fun setupAudioTracks() { @UnstableApi private fun setupAudioTracks() {
val butAudioTracks = binding.audioTracks val butAudioTracks = binding.audioTracks
if (audioTracks.size < 2 || selectedAudioTrack < 0) { if (audioTracks.size < 2 || selectedAudioTrack < 0) {
butAudioTracks.visibility = View.GONE butAudioTracks.visibility = View.GONE
return return
} }
butAudioTracks.visibility = View.VISIBLE butAudioTracks.visibility = View.VISIBLE
butAudioTracks.text = audioTracks[selectedAudioTrack] butAudioTracks.text = audioTracks[selectedAudioTrack]
butAudioTracks.setOnClickListener { butAudioTracks.setOnClickListener {

View File

@ -20,7 +20,7 @@ class MainActivityStarter(private val context: Context) {
} }
fun getIntent(): Intent { fun getIntent(): Intent {
if (fragmentArgs != null) intent.putExtra(EXTRA_FRAGMENT_ARGS, fragmentArgs) if (fragmentArgs != null) intent.putExtra(Extras.fragment_args.name, fragmentArgs)
return intent return intent
} }
@ -33,32 +33,32 @@ class MainActivityStarter(private val context: Context) {
} }
fun withOpenPlayer(): MainActivityStarter { fun withOpenPlayer(): MainActivityStarter {
intent.putExtra(EXTRA_OPEN_PLAYER, true) intent.putExtra(Extras.open_player.name, true)
return this return this
} }
fun withOpenFeed(feedId: Long): MainActivityStarter { fun withOpenFeed(feedId: Long): MainActivityStarter {
intent.putExtra(EXTRA_FEED_ID, feedId) intent.putExtra(Extras.fragment_feed_id.name, feedId)
return this return this
} }
fun withAddToBackStack(): MainActivityStarter { fun withAddToBackStack(): MainActivityStarter {
intent.putExtra(EXTRA_ADD_TO_BACK_STACK, true) intent.putExtra(Extras.add_to_back_stack.name, true)
return this return this
} }
fun withFragmentLoaded(fragmentName: String?): MainActivityStarter { fun withFragmentLoaded(fragmentName: String?): MainActivityStarter {
intent.putExtra(EXTRA_FRAGMENT_TAG, fragmentName) intent.putExtra(Extras.fragment_tag.name, fragmentName)
return this return this
} }
fun withDrawerOpen(): MainActivityStarter { fun withDrawerOpen(): MainActivityStarter {
intent.putExtra(EXTRA_OPEN_DRAWER, true) intent.putExtra(Extras.open_drawer.name, true)
return this return this
} }
fun withDownloadLogsOpen(): MainActivityStarter { fun withDownloadLogsOpen(): MainActivityStarter {
intent.putExtra(EXTRA_OPEN_DOWNLOAD_LOGS, true) intent.putExtra(Extras.open_download_logs.name, true)
return this return this
} }
@ -69,14 +69,17 @@ class MainActivityStarter(private val context: Context) {
return this 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 { companion object {
const val INTENT: String = "ac.mdiq.podcini.intents.MAIN_ACTIVITY" 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.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.actionbutton.* 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.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment
import ac.mdiq.podcini.ui.fragment.FeedInfoFragment 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.annotation.OptIn
import androidx.media3.common.util.UnstableApi 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 lateinit var keys: List<SwipeAction>
private var rightAction: SwipeAction? = null private var rightAction: SwipeAction? = null

View File

@ -45,7 +45,8 @@ import java.text.DecimalFormat
import java.text.DecimalFormatSymbols import java.text.DecimalFormatSymbols
import java.util.* 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 adapter: SpeedSelectionAdapter
private lateinit var speedSeekBar: PlaybackSpeedSeekBar 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.ChapterUtils
import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.storage.model.MediaType 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.MainActivity
import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode
import ac.mdiq.podcini.ui.activity.VideoplayerActivity.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.Episode
import ac.mdiq.podcini.storage.model.EpisodeFilter import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.utils.EpisodeUtil 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.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodesAdapter 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.EpisodeMedia
import ac.mdiq.podcini.storage.model.EpisodeSortOrder import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.storage.utils.EpisodeUtil 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.actionbutton.DeleteActionButton
import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity 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.model.EpisodeSortOrder.Companion.fromCode
import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor 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.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodesAdapter import ac.mdiq.podcini.ui.adapter.EpisodesAdapter

View File

@ -116,7 +116,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
} }
binding.btnvRelatedFeeds.setOnClickListener { 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) (activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE)
} }
binding.txtvUrl.setOnClickListener(copyUrlToClipboard) binding.txtvUrl.setOnClickListener(copyUrlToClipboard)

View File

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

View File

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

View File

@ -1,198 +1,210 @@
package ac.mdiq.podcini.ui.fragment package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FragmentOnlineSearchBinding import ac.mdiq.podcini.databinding.AddfeedBinding
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcher import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry 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.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 ac.mdiq.podcini.util.Logd
import android.content.Context import android.content.*
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.* import android.widget.TextView
import androidx.appcompat.widget.SearchView import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/**
* Provides actions for adding new podcast subscriptions.
*/
@UnstableApi
class OnlineSearchFragment : Fragment() { class OnlineSearchFragment : Fragment() {
private var _binding: FragmentOnlineSearchBinding? = null private var _binding: AddfeedBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var adapter: OnlineFeedsAdapter? = null private var activity: MainActivity? = null
private var searchProvider: PodcastSearcher? = null private var displayUpArrow = false
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 val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(ActivityResultContracts.GetContent()) { uri: Uri? ->
* List of podcasts retreived from the search this.chooseOpmlImportPathResult(uri) }
*/
private var searchResults: MutableList<PodcastSearchResult>? = null
// private var disposable: Disposable? = null
override fun onCreate(savedInstanceState: Bundle?) { private val addLocalFolderLauncher = registerForActivityResult<Uri?, Uri>(AddLocalFolder()) { uri: Uri? -> this.addLocalFolderResult(uri) }
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)
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") Logd(TAG, "fragment onCreateView")
gridView = binding.gridView displayUpArrow = parentFragmentManager.backStackEntryCount != 0
adapter = OnlineFeedsAdapter(requireContext(), ArrayList()) if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
gridView.setAdapter(adapter) (getActivity() as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow)
//Show information about the podcast when the list item is clicked binding.searchButton.setOnClickListener { performSearch() }
gridView.onItemClickListener = AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, position: Int, _: Long -> binding.searchVistaGuideButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(VistaGuidePodcastSearcher::class.java)) }
val podcast = searchResults!![position] binding.searchItunesButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(ItunesPodcastSearcher::class.java)) }
if (podcast.feedUrl != null) { binding.searchFyydButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(FyydPodcastSearcher::class.java)) }
val fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl) binding.searchGPodderButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(GpodnetPodcastSearcher::class.java)) }
fragment.feedSource = podcast.source binding.searchPodcastIndexButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(PodcastIndexPodcastSearcher::class.java)) }
(activity as MainActivity).loadChildFragment(fragment) 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 binding.addLocalFolderButton.setOnClickListener {
txtvError = binding.txtvError try { addLocalFolderLauncher.launch(null)
butRetry = binding.butRetry } catch (e: ActivityNotFoundException) {
txtvEmpty = binding.empty e.printStackTrace()
if (searchProvider != null) binding.searchPoweredBy.text = getString(R.string.search_powered_by, searchProvider!!.name) activity?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
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) {} }
})
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 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 _binding = null
searchResults = null super.onDestroyView()
adapter = null
super.onDestroy()
} }
private fun setupToolbar(toolbar: MaterialToolbar) { private fun chooseOpmlImportPathResult(uri: Uri?) {
toolbar.inflateMenu(R.menu.online_search) if (uri == null) return
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
val searchItem: MenuItem = toolbar.menu.findItem(R.id.action_search) val intent = Intent(context, OpmlImportActivity::class.java)
val sv = searchItem.actionView as? SearchView intent.setData(uri)
if (sv != null) { startActivity(intent)
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)
} }
private fun search(query: String) { @UnstableApi private fun addLocalFolderResult(uri: Uri?) {
showOnlyProgressBar() if (uri == null) return
lifecycleScope.launch(Dispatchers.IO) { val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
try { try {
val result = searchProvider?.search(query) val feed = withContext(Dispatchers.IO) { addLocalFolder(uri) }
searchResults = result?.toMutableList()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
progressBar.visibility = View.GONE if (feed != null) {
adapter?.clear() val fragment: Fragment = FeedEpisodesFragment.newInstance(feed.id)
handleSearchResults() (getActivity() as MainActivity).loadChildFragment(fragment)
txtvEmpty.text = getString(R.string.no_results_for_query, query) }
} }
} 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() { @UnstableApi private fun addLocalFolder(uri: Uri): Feed? {
adapter?.addAll(searchResults!!) requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
adapter?.notifyDataSetInvalidated() val documentFile = DocumentFile.fromTreeUri(requireContext(), uri)
gridView.visibility = if (!searchResults.isNullOrEmpty()) View.VISIBLE else View.GONE requireNotNull(documentFile) { "Unable to retrieve document tree" }
txtvEmpty.visibility = if (searchResults.isNullOrEmpty()) View.VISIBLE else View.GONE 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) { private class AddLocalFolder : ActivityResultContracts.OpenDocumentTree() {
Logd(TAG, "exception: ${e.message}") override fun createIntent(context: Context, input: Uri?): Intent {
progressBar.visibility = View.GONE return super.createIntent(context, input).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
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)
} }
companion object { companion object {
private val TAG: String = OnlineSearchFragment::class.simpleName ?: "Anonymous" val TAG = OnlineSearchFragment::class.simpleName ?: "Anonymous"
private const val ARG_SEARCHER = "searcher" private const val KEY_UP_ARROW = "up_arrow"
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
}
} }
} }

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.DurationConverter
import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodeUtil
import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor 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.actions.swipeactions.SwipeActions
import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodesAdapter 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.BuildConfig
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FragmentSearchResultsBinding
import ac.mdiq.podcini.databinding.QuickFeedDiscoveryBinding import ac.mdiq.podcini.databinding.QuickFeedDiscoveryBinding
import ac.mdiq.podcini.databinding.QuickFeedDiscoveryItemBinding 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
import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader.Companion.prefs import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader.Companion.prefs
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult
import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.ui.activity.MainActivity 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.Logd
import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import coil.load 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.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest 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 { companion object {
private val TAG: String = QuickDiscoveryFragment::class.simpleName ?: "Anonymous" private val TAG: String = QuickDiscoveryFragment::class.simpleName ?: "Anonymous"
private const val NUM_SUGGESTIONS = 12 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.Episode
import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.EpisodeUtil 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.menuhandler.EpisodeMenuHandler import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler
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.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodesAdapter import ac.mdiq.podcini.ui.adapter.EpisodesAdapter
import ac.mdiq.podcini.ui.adapter.SelectableAdapter 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.Logd
import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Bundle 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. * 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 var _binding: SearchFragmentBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
@ -290,15 +292,14 @@ import java.lang.ref.WeakReference
search() search()
} }
@SuppressLint("StringFormatMatches")
@UnstableApi private fun search() { @UnstableApi private fun search() {
adapterFeeds.setEndButton(R.string.search_online) { this.searchOnline() } adapterFeeds.setEndButton(R.string.search_online) { this.searchOnline() }
chip.visibility = if ((requireArguments().getLong(ARG_FEED, 0) == 0L)) View.GONE else View.VISIBLE chip.visibility = if ((requireArguments().getLong(ARG_FEED, 0) == 0L)) View.GONE else View.VISIBLE
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val results = withContext(Dispatchers.IO) { val results = withContext(Dispatchers.IO) { performSearch() }
performSearch()
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
progressBar.visibility = View.GONE progressBar.visibility = View.GONE
if (results.first != null) { if (results.first != null) {
@ -398,7 +399,7 @@ import java.lang.ref.WeakReference
(activity as MainActivity).loadChildFragment(fragment) (activity as MainActivity).loadChildFragment(fragment)
return return
} }
(activity as MainActivity).loadChildFragment(OnlineSearchFragment.newInstance(CombinedSearcher::class.java, query)) (activity as MainActivity).loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query))
} }
override fun onStartSelectMode() { 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.adapter.SelectableAdapter
import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.Spinner 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.FeedSortDialog
import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog
import ac.mdiq.podcini.ui.dialog.TagSettingsDialog 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.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
import android.app.Dialog
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
@ -69,6 +69,10 @@ import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar 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.dialog.MaterialAlertDialogBuilder
import com.google.android.material.elevation.SurfaceColors import com.google.android.material.elevation.SurfaceColors
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
@ -77,6 +81,7 @@ import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView import com.leinardi.android.speeddial.SpeedDialView
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import org.apache.commons.lang3.StringUtils
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -184,7 +189,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec
val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd
subscriptionAddButton.setOnClickListener { 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() 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 { companion object {
val TAG = SubscriptionsFragment::class.simpleName ?: "Anonymous" val TAG = SubscriptionsFragment::class.simpleName ?: "Anonymous"

View File

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

View File

@ -1,7 +1,6 @@
package ac.mdiq.podcini.ui.widget package ac.mdiq.podcini.ui.widget
import ac.mdiq.podcini.R import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.utils.ImageResourceUtils.getFallbackImageLocation
import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.preferences.UserPreferences.shouldShowRemainingTime import ac.mdiq.podcini.preferences.UserPreferences.shouldShowRemainingTime
import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createPendingIntent 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.receiver.PlayerWidget.Companion.prefs
import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.model.Playable 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.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.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.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.util.Log import android.util.Log
@ -35,7 +36,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.math.max import kotlin.math.max
/** /**
* Updates the state of the player widget. * 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) { 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) 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:layout_height="wrap_content"
android:orientation="horizontal"> 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 <EditText
android:id="@+id/combinedFeedSearchEditText" android:id="@+id/combinedFeedSearchEditText"
android:layout_width="0dp" android:layout_width="0dp"
@ -65,6 +55,16 @@
android:hint="@string/search_podcast_hint" android:hint="@string/search_podcast_hint"
android:background="@null" /> 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> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>

View File

@ -5,7 +5,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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 <com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar" 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 # 6.5.3
* properly assigning ids to remote episodes in OnlineFeedView to resolve the issue of duplicates * 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