diff --git a/app/build.gradle b/app/build.gradle index e02802c4..cf636007 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { testApplicationId "ac.mdiq.podcini.tests" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020237 - versionName "6.5.3" + versionCode 3020238 + versionName "6.5.4" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a62c9c2..3e0af24a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -263,7 +263,7 @@ { - state.namespaces[uri] = YouTube() - Logd(TAG, "Recognized YouTube namespace") - } +// uri == YouTube.NSURI && prefix == YouTube.NSTAG -> { +// state.namespaces[uri] = YouTube() +// Logd(TAG, "Recognized YouTube namespace") +// } uri == SimpleChapters.NSURI && prefix.matches(SimpleChapters.NSTAG.toRegex()) -> { state.namespaces[uri] = SimpleChapters() Logd(TAG, "Recognized SimpleChapters namespace") @@ -238,6 +238,7 @@ class FeedHandler { state.namespaces[uri] = PodcastIndex() Logd(TAG, "Recognized PodcastIndex namespace") } + else -> Logd(TAG, "startPrefixMapping can not handle uri: $uri") } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt index 6506d002..ec803e54 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/YouTube.kt @@ -7,6 +7,7 @@ import ac.mdiq.podcini.net.feed.parser.element.SyndElement import ac.mdiq.podcini.net.feed.parser.utils.DurationParser.inMillis import org.xml.sax.Attributes +// TODO: this appears not needed class YouTube : Namespace() { val TAG = this::class.simpleName ?: "Anonymous" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/ServiceStatusHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/ServiceStatusHandler.kt index 38599a37..f81f249f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/ServiceStatusHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/ServiceStatusHandler.kt @@ -138,9 +138,7 @@ abstract class ServiceStatusHandler(private val activity: FragmentActivity) { try { activity.unregisterReceiver(statusUpdate) activity.unregisterReceiver(notificationReceiver) - } catch (e: IllegalArgumentException) { - // ignore - } + } catch (e: IllegalArgumentException) {/* ignore */ } initialized = false cancelFlowEvents() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt index 27f94405..c55c01c0 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/LocalMediaPlayer.kt @@ -181,6 +181,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP @Throws(IllegalArgumentException::class, IllegalStateException::class) private fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) { + Logd(TAG, "setDataSource1 called") val url = media.getStreamUrl() ?: return val preferences = media.episodeOrFetch()?.feed?.preferences val user = preferences?.username @@ -191,7 +192,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP val vService = Vista.getService(0) val streamInfo = StreamInfo.getInfo(vService, url) val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams) - Logd(TAG, "setDataSource1 got ${audioStreamsList.size}") + Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}") val audioIndex = if (isNetworkRestricted) 0 else audioStreamsList.size - 1 val audioStream = audioStreamsList[audioIndex] Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate}") @@ -346,31 +347,37 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP callback.ensureMediaInfoLoaded(curMedia!!) callback.onMediaChanged(false) setPlaybackParams(getCurrentPlaybackSpeed(curMedia), UserPreferences.isSkipSilence) - when { - streaming -> { - val streamurl = curMedia!!.getStreamUrl() - if (streamurl != null) { - val media = curMedia - if (media is EpisodeMedia) { - val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } - if (startWhenPrepared) runBlocking { deferred.await() } + CoroutineScope(Dispatchers.IO).launch { + when { + streaming -> { + val streamurl = curMedia!!.getStreamUrl() + if (streamurl != null) { + val media = curMedia + if (media is EpisodeMedia) { + mediaItem = null + mediaSource = null + setDataSource(metadata, media) +// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) } +// if (startWhenPrepared) runBlocking { deferred.await() } // val preferences = media.episodeOrFetch()?.feed?.preferences // setDataSource(metadata, streamurl, preferences?.username, preferences?.password) - } else setDataSource(metadata, streamurl, null, null) + } else setDataSource(metadata, streamurl, null, null) + } } - } - else -> { - val localMediaurl = curMedia!!.getLocalMediaUrl() + else -> { + val localMediaurl = curMedia!!.getLocalMediaUrl() // File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle // if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null) - if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) - else throw IOException("Unable to read local file $localMediaurl") + if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null) + else throw IOException("Unable to read local file $localMediaurl") + } + } + withContext(Dispatchers.Main) { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) + if (prepareImmediately) prepare() } } - val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager - if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia) - - if (prepareImmediately) prepare() } catch (e: IOException) { e.printStackTrace() setPlayerStatus(PlayerStatus.ERROR, null) @@ -394,6 +401,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP seekTo(newPosition) } if (exoPlayer?.playbackState == STATE_IDLE || exoPlayer?.playbackState == STATE_ENDED ) prepareWR() +// while (mediaItem == null && mediaSource == null) runBlocking { delay(100) } exoPlayer?.play() // Can't set params when paused - so always set it on start in case they changed exoPlayer?.playbackParameters = playbackParameters diff --git a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt index b689adc6..5fec5816 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/playback/service/PlaybackService.kt @@ -101,7 +101,6 @@ import kotlin.math.max */ @UnstableApi class PlaybackService : MediaLibraryService() { - private var mediaSession: MediaLibrarySession? = null internal var mPlayer: MediaPlayerBase? = null @@ -230,6 +229,37 @@ class PlaybackService : MediaLibraryService() { } } + private val shutdownReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "shutdownReceiver onReceive called with action: ${intent.action}") + if (intent.action == ACTION_SHUTDOWN_PLAYBACK_SERVICE) + EventFlow.postEvent(FlowEvent.PlaybackServiceEvent(FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN)) + } + } + + val rootItem = MediaItem.Builder() + .setMediaId("CurQueue") + .setMediaMetadata( + MediaMetadata.Builder() + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .setTitle(curQueue.name) + .build()) + .build() + + val mediaItemsInQueue: MutableList by lazy { + val list = mutableListOf() + curQueue.episodes.forEach { + if (it.media != null) { + val item = buildMediaItem(it.media!!) + if (item != null) list += item + } + } + Logd(TAG, "mediaItemsInQueue: ${list.size}") + list + } + private val mediaPlayerCallback: MediaPlayerCallback = object : MediaPlayerCallback { override fun statusChanged(newInfo: MediaPlayerInfo?) { currentMediaType = mPlayer?.mediaType ?: MediaType.UNKNOWN @@ -391,7 +421,6 @@ class PlaybackService : MediaLibraryService() { j = if (curIndexInQueue >= 0 && curIndexInQueue < eList.size) curIndexInQueue else eList.size-1 } else if (i < eList.size-1) j = i+1 Logd(TAG, "getNextInQueue next j: $j") - val nextItem = unmanaged(eList[j]) Logd(TAG, "getNextInQueue nextItem ${nextItem.title}") if (nextItem.media == null) { @@ -399,13 +428,11 @@ class PlaybackService : MediaLibraryService() { writeNoMediaPlaying() return null } - if (!isFollowQueue) { Logd(TAG, "getNextInQueue(), but follow queue is not enabled.") writeMediaPlaying(nextItem.media, PlayerStatus.STOPPED) return null } - if (!nextItem.media!!.localFileAvailable() && !isStreamingAllowed && isFollowQueue && nextItem.feed?.isLocalFeed != true) { Logd(TAG, "getNextInQueue nextItem has no local file ${nextItem.title}") displayStreamingNotAllowedNotification(PlaybackServiceStarter(this@PlaybackService, nextItem.media!!).intent) @@ -433,11 +460,9 @@ class PlaybackService : MediaLibraryService() { else -> EXTRA_CODE_AUDIO }) } - override fun ensureMediaInfoLoaded(media: Playable) { // if (media is EpisodeMedia && media.item == null) media.item = DBReader.getFeedItem(media.itemId) } - fun writeMediaPlaying(playable: Playable?, playerStatus: PlayerStatus) { Logd(InTheatre.TAG, "Writing playback preferences ${playable?.getIdentifier()}") if (playable == null) writeNoMediaPlaying() @@ -457,14 +482,12 @@ class PlaybackService : MediaLibraryService() { } } } - fun writePlayerStatus(playerStatus: PlayerStatus) { Logd(InTheatre.TAG, "Writing player status playback preferences") curState = upsertBlk(curState) { it.curPlayerStatus = getCurPlayerStatusAsInt(playerStatus) } } - private fun getCurPlayerStatusAsInt(playerStatus: PlayerStatus): Int { val playerStatusAsInt = when (playerStatus) { PlayerStatus.PLAYING -> PLAYER_STATUS_PLAYING @@ -475,37 +498,6 @@ class PlaybackService : MediaLibraryService() { } } - private val shutdownReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Log.d(TAG, "shutdownReceiver onReceive called with action: ${intent.action}") - if (intent.action == ACTION_SHUTDOWN_PLAYBACK_SERVICE) - EventFlow.postEvent(FlowEvent.PlaybackServiceEvent(FlowEvent.PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN)) - } - } - - val rootItem = MediaItem.Builder() - .setMediaId("CurQueue") - .setMediaMetadata( - MediaMetadata.Builder() - .setIsBrowsable(true) - .setIsPlayable(false) - .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) - .setTitle(curQueue.name) - .build()) - .build() - - val mediaItemsInQueue: MutableList by lazy { - val list = mutableListOf() - curQueue.episodes.forEach { - if (it.media != null) { - val item = buildMediaItem(it.media!!) - if (item != null) list += item - } - } - Logd(TAG, "mediaItemsInQueue: ${list.size}") - list - } - private val mediaLibrarySessionCK = object: MediaLibrarySession.Callback { override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { Logd(TAG, "in MyMediaSessionCallback onConnect") @@ -762,9 +754,9 @@ class PlaybackService : MediaLibraryService() { val keycode = intent?.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1) ?: -1 val customAction = intent?.getStringExtra(MediaButtonReceiver.EXTRA_CUSTOM_ACTION) val hardwareButton = intent?.getBooleanExtra(MediaButtonReceiver.EXTRA_HARDWAREBUTTON, false) ?: false - val keyEvent: KeyEvent? = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { + val keyEvent: KeyEvent? = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) intent?.getParcelableExtra(EXTRA_KEY_EVENT, KeyEvent::class.java) - } else { + else { @Suppress("DEPRECATION") intent?.getParcelableExtra(EXTRA_KEY_EVENT) } @@ -797,6 +789,7 @@ class PlaybackService : MediaLibraryService() { return super.onStartCommand(intent, flags, startId) } playable != null -> { + recreateMediaSessionIfNeeded() Logd(TAG, "onStartCommand status: $status") val allowStreamThisTime = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false) ?: false val allowStreamAlways = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_ALWAYS, false) ?: false @@ -963,6 +956,7 @@ class PlaybackService : MediaLibraryService() { } private fun startPlayingFromPreferences() { + recreateMediaSessionIfNeeded() scope.launch { try { withContext(Dispatchers.IO) { loadPlayableFromPreferences() } @@ -975,7 +969,7 @@ class PlaybackService : MediaLibraryService() { } private fun startPlaying(allowStreamThisTime: Boolean) { - Logd(TAG, "startPlaying called $allowStreamThisTime") + Logd(TAG, "startPlaying called allowStreamThisTime: $allowStreamThisTime") val media = curMedia ?: return val localFeed = URLUtil.isContentUrl(media.getStreamUrl()) @@ -986,18 +980,17 @@ class PlaybackService : MediaLibraryService() { return } - if (media.getIdentifier() != curState.curMediaId) clearCurTempSpeed() +// TODO: this is redundant +// if (media.getIdentifier() != curState.curMediaId) clearCurTempSpeed() mPlayer?.playMediaObject(media, streaming, startWhenPrepared = true, true) - recreateMediaSessionIfNeeded() +// recreateMediaSessionIfNeeded() // val episode = (media as? EpisodeMedia)?.episode // if (curMedia is EpisodeMedia && episode != null) addToQueue(true, episode) } fun clearCurTempSpeed() { - curState = upsertBlk(curState) { - it.curTempSpeed = FeedPreferences.SPEED_USE_GLOBAL - } + curState = upsertBlk(curState) { it.curTempSpeed = FeedPreferences.SPEED_USE_GLOBAL } } private var eventSink: Job? = null diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/EpisodeMenuHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMenuHandler.kt similarity index 99% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/EpisodeMenuHandler.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMenuHandler.kt index 601790f8..4ea474d6 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/EpisodeMenuHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMenuHandler.kt @@ -1,4 +1,4 @@ -package ac.mdiq.podcini.ui.actions.menuhandler +package ac.mdiq.podcini.ui.actions.handler import ac.mdiq.podcini.R import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt similarity index 99% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt index 0502a202..8b8714c7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/EpisodeMultiSelectHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/EpisodeMultiSelectHandler.kt @@ -1,4 +1,4 @@ -package ac.mdiq.podcini.ui.actions +package ac.mdiq.podcini.ui.actions.handler import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.SelectQueueDialogBinding diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/MenuItemUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/MenuItemUtils.kt similarity index 95% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/MenuItemUtils.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/MenuItemUtils.kt index 9ad6b855..7291d0ac 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/menuhandler/MenuItemUtils.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/handler/MenuItemUtils.kt @@ -1,4 +1,4 @@ -package ac.mdiq.podcini.ui.actions.menuhandler +package ac.mdiq.podcini.ui.actions.handler import android.view.Menu import android.view.MenuItem diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/AddToQueueSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/AddToQueueSwipeAction.kt deleted file mode 100644 index b673a07f..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/AddToQueueSwipeAction.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/DeleteSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/DeleteSwipeAction.kt deleted file mode 100644 index d103e9b7..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/DeleteSwipeAction.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/MarkFavoriteSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/MarkFavoriteSwipeAction.kt deleted file mode 100644 index f99b328c..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/MarkFavoriteSwipeAction.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/NoActionSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/NoActionSwipeAction.kt deleted file mode 100644 index 6d95e348..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/NoActionSwipeAction.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromHistorySwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromHistorySwipeAction.kt deleted file mode 100644 index 72ea5c6f..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromHistorySwipeAction.kt +++ /dev/null @@ -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)) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromQueueSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromQueueSwipeAction.kt deleted file mode 100644 index d8b1aaa2..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/RemoveFromQueueSwipeAction.kt +++ /dev/null @@ -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) - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/ShowFirstSwipeDialogAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/ShowFirstSwipeDialogAction.kt deleted file mode 100644 index 19b210f7..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/ShowFirstSwipeDialogAction.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/StartDownloadSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/StartDownloadSwipeAction.kt deleted file mode 100644 index 6bf1e5f6..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/StartDownloadSwipeAction.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt index 2aac1da5..ebe46775 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/SwipeActions.kt @@ -1,20 +1,41 @@ package ac.mdiq.podcini.ui.actions.swipeactions import ac.mdiq.podcini.R +import ac.mdiq.podcini.playback.base.InTheatre.curQueue +import ac.mdiq.podcini.storage.database.Episodes.addToHistory +import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync +import ac.mdiq.podcini.storage.database.Episodes.setFavorite +import ac.mdiq.podcini.storage.database.Episodes.setPlayState +import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync +import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue +import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem +import ac.mdiq.podcini.storage.database.Queues.addToQueue +import ac.mdiq.podcini.storage.database.Queues.removeFromQueue +import ac.mdiq.podcini.storage.database.Queues.removeFromQueueSync +import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope +import ac.mdiq.podcini.storage.database.RealmDB.upsert +import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeFilter +import ac.mdiq.podcini.storage.model.EpisodeMedia +import ac.mdiq.podcini.storage.utils.EpisodeUtil +import ac.mdiq.podcini.ui.actions.actionbutton.DownloadActionButton import ac.mdiq.podcini.ui.actions.swipeactions.SwipeAction.Companion.NO_ACTION +import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.dialog.SwipeActionsDialog import ac.mdiq.podcini.ui.fragment.AllEpisodesFragment import ac.mdiq.podcini.ui.fragment.DownloadsFragment import ac.mdiq.podcini.ui.fragment.HistoryFragment import ac.mdiq.podcini.ui.fragment.QueuesFragment +import ac.mdiq.podcini.ui.utils.LocalDeleteModal.deleteEpisodesWarnLocal import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr import ac.mdiq.podcini.ui.view.EpisodeViewHolder import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent +import ac.mdiq.podcini.util.Logd import android.content.Context import android.content.SharedPreferences import android.graphics.Canvas +import android.os.Handler import androidx.annotation.OptIn import androidx.core.graphics.ColorUtils import androidx.fragment.app.Fragment @@ -23,7 +44,13 @@ import androidx.media3.common.util.UnstableApi import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.annimon.stream.Stream +import com.google.android.material.snackbar.Snackbar import it.xabaras.android.recyclerview.swipedecorator.RecyclerViewSwipeDecorator +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.util.* +import kotlin.math.ceil import kotlin.math.max import kotlin.math.min import kotlin.math.sin @@ -245,4 +272,327 @@ open class SwipeActions(dragDirs: Int, private val fragment: Fragment, private v return prefs!!.getBoolean(KEY_PREFIX_NO_ACTION + tag, true) } } + + class AddToQueueSwipeAction : SwipeAction { + override fun getId(): String { + return SwipeAction.ADD_TO_QUEUE + } + + override fun getActionIcon(): Int { + return R.drawable.ic_playlist_play + } + + override fun getActionColor(): Int { + return androidx.appcompat.R.attr.colorAccent + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.add_to_queue_label) + } + + @OptIn(UnstableApi::class) + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + addToQueue( true, item) +// else RemoveFromQueueSwipeAction().performAction(item, fragment, filter) + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return filter.showQueued || filter.showNew + } + } + + class DeleteSwipeAction : SwipeAction { + override fun getId(): String { + return SwipeAction.DELETE + } + + override fun getActionIcon(): Int { + return R.drawable.ic_delete + } + + override fun getActionColor(): Int { + return R.attr.icon_red + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.delete_episode_label) + } + + @UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + if (!item.isDownloaded && item.feed?.isLocalFeed != true) return + deleteEpisodesWarnLocal(fragment.requireContext(), listOf(item)) + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return filter.showDownloaded && (item.isDownloaded || item.feed?.isLocalFeed == true) + } + } + + class MarkFavoriteSwipeAction : SwipeAction { + override fun getId(): String { + return SwipeAction.MARK_FAV + } + + override fun getActionIcon(): Int { + return R.drawable.ic_star + } + + override fun getActionColor(): Int { + return R.attr.icon_yellow + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.add_to_favorite_label) + } + + @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + setFavorite(item, !item.isFavorite) + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return filter.showIsFavorite || filter.showNotFavorite + } + } + + class NoActionSwipeAction : SwipeAction { + override fun getId(): String { + return SwipeAction.NO_ACTION + } + + override fun getActionIcon(): Int { + return R.drawable.ic_questionmark + } + + override fun getActionColor(): Int { + return R.attr.icon_red + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.no_action_label) + } + + @UnstableApi override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {} + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return false + } + } + + class RemoveFromHistorySwipeAction : SwipeAction { + val TAG = this::class.simpleName ?: "Anonymous" + + override fun getId(): String { + return SwipeAction.REMOVE_FROM_HISTORY + } + + override fun getActionIcon(): Int { + return R.drawable.ic_history_remove + } + + override fun getActionColor(): Int { + return R.attr.icon_purple + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.remove_history_label) + } + + @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + val playbackCompletionDate: Date? = item.media?.playbackCompletionDate + deleteFromHistory(item) + + (fragment.requireActivity() as MainActivity) + .showSnackbarAbovePlayer(R.string.removed_history_label, Snackbar.LENGTH_LONG) + .setAction(fragment.getString(R.string.undo)) { + if (playbackCompletionDate != null) addToHistory(item, playbackCompletionDate) } + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return true + } + + fun deleteFromHistory(episode: Episode) { + addToHistory(episode, Date(0)) + } + } + + class RemoveFromQueueSwipeAction : SwipeAction { + override fun getId(): String { + return SwipeAction.REMOVE_FROM_QUEUE + } + + override fun getActionIcon(): Int { + return R.drawable.ic_playlist_remove + } + + override fun getActionColor(): Int { + return androidx.appcompat.R.attr.colorAccent + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.remove_from_queue_label) + } + + @OptIn(UnstableApi::class) override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + val position: Int = curQueue.episodes.indexOf(item) + removeFromQueue(item) + if (willRemove(filter, item)) { + (fragment.requireActivity() as MainActivity).showSnackbarAbovePlayer(fragment.resources.getQuantityString(R.plurals.removed_from_queue_batch_label, 1, 1), Snackbar.LENGTH_LONG) + .setAction(fragment.getString(R.string.undo)) { + addToQueueAt(item, position) + } + } + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return filter.showQueued || filter.showNotQueued + } + + /** + * Inserts a Episode in the queue at the specified index. The 'read'-attribute of the Episode will be set to + * true. If the Episode is already in the queue, the queue will not be modified. + * @param episode the Episode that should be added to the queue. + * @param index Destination index. Must be in range 0..queue.size() + * @throws IndexOutOfBoundsException if index < 0 || index >= queue.size() + */ + @UnstableApi + fun addToQueueAt(episode: Episode, index: Int) : Job { + return runOnIOScope { + if (curQueue.episodeIds.contains(episode.id)) return@runOnIOScope + if (episode.isNew) setPlayState(Episode.PlayState.UNPLAYED.code, false, episode) + curQueue = upsert(curQueue) { + it.episodeIds.add(index, episode.id) + it.update() + } +// curQueue.episodes.add(index, episode) + EventFlow.postEvent(FlowEvent.QueueEvent.added(episode, index)) +// if (performAutoDownload) autodownloadEpisodeMedia(context) + } + } + } + + class ShowFirstSwipeDialogAction : SwipeAction { + override fun getId(): String { + return "SHOW_FIRST_SWIPE_DIALOG" + } + + override fun getActionIcon(): Int { + return R.drawable.ic_settings + } + + override fun getActionColor(): Int { + return R.attr.icon_gray + } + + override fun getTitle(context: Context): String { + return "" + } + + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + //handled in SwipeActions + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return false + } + } + + class StartDownloadSwipeAction : SwipeAction { + override fun getId(): String { + return SwipeAction.START_DOWNLOAD + } + + override fun getActionIcon(): Int { + return R.drawable.ic_download + } + + override fun getActionColor(): Int { + return R.attr.icon_green + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.download_label) + } + + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + if (!item.isDownloaded && item.feed != null && !item.feed!!.isLocalFeed) { + DownloadActionButton(item).onClick(fragment.requireContext()) + } + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return false + } + } + + class TogglePlaybackStateSwipeAction : SwipeAction { + override fun getId(): String { + return SwipeAction.TOGGLE_PLAYED + } + + override fun getActionIcon(): Int { + return R.drawable.ic_mark_played + } + + override fun getActionColor(): Int { + return R.attr.icon_gray + } + + override fun getTitle(context: Context): String { + return context.getString(R.string.toggle_played_label) + } + + override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { + val newState = if (item.playState == Episode.PlayState.UNPLAYED.code) Episode.PlayState.PLAYED.code else Episode.PlayState.UNPLAYED.code + + Logd("TogglePlaybackStateSwipeAction", "performAction( ${item.id} )") + // we're marking it as unplayed since the user didn't actually play it + // but they don't want it considered 'NEW' anymore + var item = runBlocking { setPlayStateSync(newState, false, item) } + + val h = Handler(fragment.requireContext().mainLooper) + val r = Runnable { + val media: EpisodeMedia? = item.media + val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!) + if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) { + item = deleteMediaSync(fragment.requireContext(), item) + if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) } + } + val playStateStringRes: Int = when (newState) { + Episode.PlayState.UNPLAYED.code -> if (item.playState == Episode.PlayState.NEW.code) R.string.removed_inbox_label //was new + else R.string.marked_as_unplayed_label //was played + Episode.PlayState.PLAYED.code -> R.string.marked_as_played_label + else -> if (item.playState == Episode.PlayState.NEW.code) R.string.removed_inbox_label + else R.string.marked_as_unplayed_label + } + val duration: Int = Snackbar.LENGTH_LONG + + if (willRemove(filter, item)) { + (fragment.activity as MainActivity).showSnackbarAbovePlayer( + playStateStringRes, duration) + .setAction(fragment.getString(R.string.undo)) { + setPlayState(item.playState, false, item) + // don't forget to cancel the thing that's going to remove the media + h.removeCallbacks(r) + } + } + + h.postDelayed(r, ceil((duration * 1.05f).toDouble()).toLong()) + } + + private fun delayedExecution(item: Episode, fragment: Fragment, duration: Float) = runBlocking { + delay(ceil((duration * 1.05f).toDouble()).toLong()) + val media: EpisodeMedia? = item.media + val shouldAutoDelete = if (item.feed == null) false else shouldAutoDeleteItem(item.feed!!) + if (media != null && EpisodeUtil.hasAlmostEnded(media) && shouldAutoDelete) { +// deleteMediaOfEpisode(fragment.requireContext(), item) + var item = deleteMediaSync(fragment.requireContext(), item) + if (shouldDeleteRemoveFromQueue()) removeFromQueueSync(null, item) } + } + + override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean { + return if (item.playState == Episode.PlayState.NEW.code) filter.showPlayed || filter.showNew + else filter.showUnplayed || filter.showPlayed || filter.showNew + } + } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/TogglePlaybackStateSwipeAction.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/TogglePlaybackStateSwipeAction.kt deleted file mode 100644 index 3fef1a42..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/actions/swipeactions/TogglePlaybackStateSwipeAction.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index 70b75eb3..339f8484 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -8,6 +8,7 @@ import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface import ac.mdiq.podcini.net.feed.FeedUpdateManager import ac.mdiq.podcini.net.feed.FeedUpdateManager.restartUpdateAlarm import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnceOrAsk +import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink import ac.mdiq.podcini.playback.cast.CastEnabledActivity @@ -417,7 +418,7 @@ class MainActivity : CastEnabledActivity() { AllEpisodesFragment.TAG -> fragment = AllEpisodesFragment() DownloadsFragment.TAG -> fragment = DownloadsFragment() HistoryFragment.TAG -> fragment = HistoryFragment() - AddFeedFragment.TAG -> fragment = AddFeedFragment() + OnlineSearchFragment.TAG -> fragment = OnlineSearchFragment() SubscriptionsFragment.TAG -> fragment = SubscriptionsFragment() StatisticsFragment.TAG -> fragment = StatisticsFragment() else -> { @@ -622,11 +623,11 @@ class MainActivity : CastEnabledActivity() { when { intent.hasExtra(Extras.fragment_feed_id.name) -> { val feedId = intent.getLongExtra(Extras.fragment_feed_id.name, 0) - val args = intent.getBundleExtra(MainActivityStarter.EXTRA_FRAGMENT_ARGS) + val args = intent.getBundleExtra(MainActivityStarter.Extras.fragment_args.name) if (feedId > 0) { - val startedFromSearch = intent.getBooleanExtra(Extras.started_from_search.name, false) + val startedFromShare = intent.getBooleanExtra(Extras.started_from_share.name, false) val addToBackStack = intent.getBooleanExtra(Extras.add_to_back_stack.name, false) - if (startedFromSearch || addToBackStack) loadChildFragment(FeedEpisodesFragment.newInstance(feedId)) + if (startedFromShare || addToBackStack) loadChildFragment(FeedEpisodesFragment.newInstance(feedId)) else loadFeedFragmentById(feedId, args) } bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) @@ -635,25 +636,26 @@ class MainActivity : CastEnabledActivity() { val feedurl = intent.getStringExtra(Extras.fragment_feed_url.name) if (feedurl != null) loadChildFragment(OnlineFeedViewFragment.newInstance(feedurl)) } - intent.hasExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG) -> { - val tag = intent.getStringExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG) - val args = intent.getBundleExtra(MainActivityStarter.EXTRA_FRAGMENT_ARGS) + intent.hasExtra(Extras.search_string.name) -> { + val query = intent.getStringExtra(Extras.search_string.name) + if (query != null) loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query)) + } + intent.hasExtra(MainActivityStarter.Extras.fragment_tag.name) -> { + val tag = intent.getStringExtra(MainActivityStarter.Extras.fragment_tag.name) + val args = intent.getBundleExtra(MainActivityStarter.Extras.fragment_args.name) if (tag != null) loadFragment(tag, args) bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) } - intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_PLAYER, false) -> { + intent.getBooleanExtra(MainActivityStarter.Extras.open_player.name, false) -> { // bottomSheet.state = BottomSheetBehavior.STATE_EXPANDED // bottomSheetCallback.onSlide(dummyView, 1.0f) } else -> handleDeeplink(intent.data) } - - if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DRAWER, false)) drawerLayout?.open() - - if (intent.getBooleanExtra(MainActivityStarter.EXTRA_OPEN_DOWNLOAD_LOGS, false)) + if (intent.getBooleanExtra(MainActivityStarter.Extras.open_drawer.name, false)) drawerLayout?.open() + if (intent.getBooleanExtra(MainActivityStarter.Extras.open_download_logs.name, false)) DownloadLogFragment().show(supportFragmentManager, null) - if (intent.getBooleanExtra(Extras.refresh_on_start.name, false)) runOnceOrAsk(this) // to avoid handling the intent twice when the configuration changes @@ -756,9 +758,10 @@ class MainActivity : CastEnabledActivity() { fragment_feed_id, fragment_feed_url, refresh_on_start, - started_from_search, + started_from_share, // TODO: seems not needed add_to_back_stack, generated_view_id, + search_string, } companion object { @@ -781,5 +784,13 @@ class MainActivity : CastEnabledActivity() { intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) return intent } + + @JvmStatic + fun showOnlineSearch(context: Context, query: String): Intent { + val intent = Intent(context.applicationContext, MainActivity::class.java) + intent.putExtra(Extras.search_string.name, query) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + return intent + } } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/OnlineFeedViewActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/ShareReceiverActivity.kt similarity index 66% rename from app/src/main/kotlin/ac/mdiq/podcini/ui/activity/OnlineFeedViewActivity.kt rename to app/src/main/kotlin/ac/mdiq/podcini/ui/activity/ShareReceiverActivity.kt index cfd942cd..5a7f7556 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/OnlineFeedViewActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/ShareReceiverActivity.kt @@ -13,8 +13,8 @@ import androidx.media3.common.util.UnstableApi import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.net.URLDecoder -// this now is only used for receiving shared feed url -class OnlineFeedViewActivity : AppCompatActivity() { +class ShareReceiverActivity : AppCompatActivity() { + @OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -31,21 +31,29 @@ class OnlineFeedViewActivity : AppCompatActivity() { if (urlString != null) feedUrl = URLDecoder.decode(urlString, "UTF-8") } - if (feedUrl == null) { - Log.e(TAG, "feedUrl is null.") - showNoPodcastFoundError() - } else { - Logd(TAG, "Activity was started with url $feedUrl") - val intent = MainActivity.showOnlineFeed(this, feedUrl) - intent.putExtra(MainActivity.Extras.started_from_search.name, getIntent().getBooleanExtra(MainActivity.Extras.started_from_search.name, false)) - startActivity(intent) - finish() + when { + feedUrl.isNullOrBlank() -> { + Log.e(TAG, "feedUrl is empty or null.") + showNoPodcastFoundError() + } + !feedUrl.matches(Regex("[./%]")) -> { + val intent = MainActivity.showOnlineSearch(this, feedUrl) + startActivity(intent) + finish() + } + else -> { + Logd(TAG, "Activity was started with url $feedUrl") + val intent = MainActivity.showOnlineFeed(this, feedUrl) +// intent.putExtra(MainActivity.Extras.started_from_share.name, getIntent().getBooleanExtra(MainActivity.Extras.started_from_share.name, false)) + startActivity(intent) + finish() + } } } private fun showNoPodcastFoundError() { runOnUiThread { - MaterialAlertDialogBuilder(this@OnlineFeedViewActivity) + MaterialAlertDialogBuilder(this@ShareReceiverActivity) .setNeutralButton(android.R.string.ok) { _: DialogInterface?, _: Int -> finish() } .setTitle(R.string.error_label) .setMessage(R.string.null_value_podcast_error) @@ -64,6 +72,6 @@ class OnlineFeedViewActivity : AppCompatActivity() { companion object { const val ARG_FEEDURL: String = "arg.feedurl" private const val RESULT_ERROR = 2 - private val TAG: String = OnlineFeedViewActivity::class.simpleName ?: "Anonymous" + private val TAG: String = ShareReceiverActivity::class.simpleName ?: "Anonymous" } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt index 3153486c..0bfffbbb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt @@ -283,9 +283,8 @@ class VideoplayerActivity : CastEnabledActivity() { videoEpisodeFragment.isFavorite = false invalidateOptionsMenu() } - item.itemId == R.id.disable_sleeptimer_item || item.itemId == R.id.set_sleeptimer_item -> { + item.itemId == R.id.disable_sleeptimer_item || item.itemId == R.id.set_sleeptimer_item -> SleepTimerDialog().show(supportFragmentManager, "SleepTimerDialog") - } item.itemId == R.id.audio_controls -> { val dialog = PlaybackControlsDialog.newInstance() dialog.show(supportFragmentManager, "playback_controls") @@ -374,24 +373,22 @@ class VideoplayerActivity : CastEnabledActivity() { private lateinit var dialog: AlertDialog private var _binding: AudioControlsBinding? = null private val binding get() = _binding!! - private var controller: ServiceStatusHandler? = null + private var statusHandler: ServiceStatusHandler? = null @UnstableApi override fun onStart() { super.onStart() - controller = object : ServiceStatusHandler(requireActivity()) { + statusHandler = object : ServiceStatusHandler(requireActivity()) { override fun loadMediaInfo() { setupAudioTracks() } } - controller?.init() + statusHandler?.init() } - @UnstableApi override fun onStop() { super.onStop() - controller?.release() - controller = null + statusHandler?.release() + statusHandler = null } - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { _binding = AudioControlsBinding.inflate(layoutInflater) dialog = MaterialAlertDialogBuilder(requireContext()) @@ -400,20 +397,17 @@ class VideoplayerActivity : CastEnabledActivity() { .setPositiveButton(R.string.close_label, null).create() return dialog } - override fun onDestroyView() { Logd(TAG, "onDestroyView") _binding = null super.onDestroyView() } - @UnstableApi private fun setupAudioTracks() { val butAudioTracks = binding.audioTracks if (audioTracks.size < 2 || selectedAudioTrack < 0) { butAudioTracks.visibility = View.GONE return } - butAudioTracks.visibility = View.VISIBLE butAudioTracks.text = audioTracks[selectedAudioTrack] butAudioTracks.setOnClickListener { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/MainActivityStarter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/MainActivityStarter.kt index b99286d6..7a29c887 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/MainActivityStarter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/MainActivityStarter.kt @@ -20,7 +20,7 @@ class MainActivityStarter(private val context: Context) { } fun getIntent(): Intent { - if (fragmentArgs != null) intent.putExtra(EXTRA_FRAGMENT_ARGS, fragmentArgs) + if (fragmentArgs != null) intent.putExtra(Extras.fragment_args.name, fragmentArgs) return intent } @@ -33,32 +33,32 @@ class MainActivityStarter(private val context: Context) { } fun withOpenPlayer(): MainActivityStarter { - intent.putExtra(EXTRA_OPEN_PLAYER, true) + intent.putExtra(Extras.open_player.name, true) return this } fun withOpenFeed(feedId: Long): MainActivityStarter { - intent.putExtra(EXTRA_FEED_ID, feedId) + intent.putExtra(Extras.fragment_feed_id.name, feedId) return this } fun withAddToBackStack(): MainActivityStarter { - intent.putExtra(EXTRA_ADD_TO_BACK_STACK, true) + intent.putExtra(Extras.add_to_back_stack.name, true) return this } fun withFragmentLoaded(fragmentName: String?): MainActivityStarter { - intent.putExtra(EXTRA_FRAGMENT_TAG, fragmentName) + intent.putExtra(Extras.fragment_tag.name, fragmentName) return this } fun withDrawerOpen(): MainActivityStarter { - intent.putExtra(EXTRA_OPEN_DRAWER, true) + intent.putExtra(Extras.open_drawer.name, true) return this } fun withDownloadLogsOpen(): MainActivityStarter { - intent.putExtra(EXTRA_OPEN_DOWNLOAD_LOGS, true) + intent.putExtra(Extras.open_download_logs.name, true) return this } @@ -69,14 +69,17 @@ class MainActivityStarter(private val context: Context) { return this } + @Suppress("EnumEntryName") + enum class Extras { + open_player, + fragment_feed_id, + add_to_back_stack, + fragment_tag, + open_drawer, + open_download_logs, + fragment_args + } companion object { const val INTENT: String = "ac.mdiq.podcini.intents.MAIN_ACTIVITY" - const val EXTRA_OPEN_PLAYER: String = "open_player" - const val EXTRA_FEED_ID: String = "fragment_feed_id" - const val EXTRA_ADD_TO_BACK_STACK: String = "add_to_back_stack" - const val EXTRA_FRAGMENT_TAG: String = "fragment_tag" - const val EXTRA_OPEN_DRAWER: String = "open_drawer" - const val EXTRA_OPEN_DOWNLOAD_LOGS: String = "open_download_logs" - const val EXTRA_FRAGMENT_ARGS: String = "fragment_args" } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/PlaybackSpeedActivityStarter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/PlaybackSpeedActivityStarter.kt deleted file mode 100644 index daf7c5e6..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/starter/PlaybackSpeedActivityStarter.kt +++ /dev/null @@ -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" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt index 71fb62c8..79061dac 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/adapter/EpisodesAdapter.kt @@ -23,7 +23,7 @@ import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.ui.actions.actionbutton.* -import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler +import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.fragment.FeedEpisodesFragment import ac.mdiq.podcini.ui.fragment.FeedInfoFragment diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/FeedFilterDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/FeedFilterDialog.kt deleted file mode 100644 index 4b41ee68..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/FeedFilterDialog.kt +++ /dev/null @@ -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 = mutableMapOf() - - private val newFilterValues: Set - get() { - val newFilterValues: MutableSet = 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(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(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) { - 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 = 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 - } - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt index 44ca5911..70a66aab 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt @@ -23,7 +23,8 @@ import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi -@OptIn(UnstableApi::class) class SwipeActionsDialog(private val context: Context, private val tag: String) { +@OptIn(UnstableApi::class) +class SwipeActionsDialog(private val context: Context, private val tag: String) { private lateinit var keys: List private var rightAction: SwipeAction? = null diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt index 64da4b04..b64ed2b2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/VariableSpeedDialog.kt @@ -45,7 +45,8 @@ import java.text.DecimalFormat import java.text.DecimalFormatSymbols import java.util.* -@OptIn(UnstableApi::class) open class VariableSpeedDialog : BottomSheetDialogFragment() { +@OptIn(UnstableApi::class) +open class VariableSpeedDialog : BottomSheetDialogFragment() { private lateinit var adapter: SpeedSelectionAdapter private lateinit var speedSeekBar: PlaybackSpeedSeekBar diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AddFeedFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AddFeedFragment.kt deleted file mode 100644 index 65899532..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AddFeedFragment.kt +++ /dev/null @@ -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(ActivityResultContracts.GetContent()) { uri: Uri? -> - this.chooseOpmlImportPathResult(uri) } - - private val addLocalFolderLauncher = registerForActivityResult(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" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index 4d528d73..5e152dbc 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -32,7 +32,7 @@ import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.utils.ChapterUtils import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.model.MediaType -import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler +import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode import ac.mdiq.podcini.ui.activity.VideoplayerActivity.VideoMode diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt index 7f3d27d1..9f894cbb 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/BaseEpisodesFragment.kt @@ -6,7 +6,7 @@ import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.EpisodeFilter import ac.mdiq.podcini.storage.utils.EpisodeUtil -import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler +import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.adapter.EpisodesAdapter diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DiscoveryFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DiscoveryFragment.kt deleted file mode 100644 index 18d23724..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DiscoveryFragment.kt +++ /dev/null @@ -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? = null - private var topList: List? = 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?) { - 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 = listOf(*Locale.getISOCountries()) - val countryCodeNames: MutableMap = HashMap() - val countryNameCodes: MutableMap = HashMap() - for (code in countryCodeArray) { - val locale = Locale("", code) - val countryName = locale.displayCountry - countryCodeNames[code] = countryName - countryNameCodes[countryName] = code - } - - val countryNamesSort: MutableList = ArrayList(countryCodeNames.values) - countryNamesSort.sort() - - val dataAdapter = ArrayAdapter(this.requireContext(), android.R.layout.simple_list_item_1, countryNamesSort) - val scBinding = SelectCountryDialogBinding.bind(selectCountryDialogView) - val textInput = scBinding.countryTextInput - val editText = textInput.editText as? MaterialAutoCompleteTextView - editText!!.setAdapter(dataAdapter) - editText.setText(countryCodeNames[countryCode]) - editText.setOnClickListener { - if (editText.text.isNotEmpty()) { - editText.setText("") - editText.postDelayed({ editText.showDropDown() }, 100) - } - } - editText.onFocusChangeListener = 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 - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt index 6f4672c5..a2feba71 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/DownloadsFragment.kt @@ -16,7 +16,7 @@ import ac.mdiq.podcini.storage.model.EpisodeFilter import ac.mdiq.podcini.storage.model.EpisodeMedia import ac.mdiq.podcini.storage.model.EpisodeSortOrder import ac.mdiq.podcini.storage.utils.EpisodeUtil -import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler +import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler import ac.mdiq.podcini.ui.actions.actionbutton.DeleteActionButton import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.activity.MainActivity diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt index bd9e59bc..13cd6e4f 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt @@ -16,7 +16,7 @@ import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.storage.model.EpisodeSortOrder.Companion.fromCode import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor -import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler +import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.adapter.EpisodesAdapter diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt index 2f4cfeea..0c626244 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt @@ -116,7 +116,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } binding.btnvRelatedFeeds.setOnClickListener { - val fragment = OnlineSearchFragment.newInstance(CombinedSearcher::class.java, "${binding.header.txtvAuthor.text} podcasts") + val fragment = SearchResultsFragment.newInstance(CombinedSearcher::class.java, "${binding.header.txtvAuthor.text} podcasts") (activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE) } binding.txtvUrl.setOnClickListener(copyUrlToClipboard) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt index 44221492..d4aab6f8 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt @@ -186,7 +186,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { HistoryFragment.TAG -> R.drawable.ic_history SubscriptionsFragment.TAG -> R.drawable.ic_subscriptions StatisticsFragment.TAG -> R.drawable.ic_chart_box - AddFeedFragment.TAG -> R.drawable.ic_add + OnlineSearchFragment.TAG -> R.drawable.ic_add else -> 0 } } @@ -384,7 +384,7 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { DownloadsFragment.TAG, HistoryFragment.TAG, StatisticsFragment.TAG, - AddFeedFragment.TAG, + OnlineSearchFragment.TAG, ) fun saveLastNavFragment(tag: String?) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt index f4d96f02..753fde48 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedViewFragment.kt @@ -94,6 +94,7 @@ class OnlineFeedViewFragment : Fragment() { var feedSource: String = "" var feedUrl: String = "" + private val feedId: Long get() { if (feeds == null) return 0 @@ -128,6 +129,7 @@ class OnlineFeedViewFragment : Fragment() { (activity as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow) feedUrl = requireArguments().getString(ARG_FEEDURL) ?: "" + Logd(TAG, "feedUrl: $feedUrl") if (feedUrl.isEmpty()) { Log.e(TAG, "feedUrl is null.") showNoPodcastFoundError() @@ -194,8 +196,7 @@ class OnlineFeedViewFragment : Fragment() { private fun lookupUrlAndBuild(url: String) { lifecycleScope.launch(Dispatchers.IO) { val urlString = PodcastSearcherRegistry.lookupUrl1(url) - try { - startFeedBuilding(urlString) + try { startFeedBuilding(urlString) } catch (e: FeedUrlNotFoundException) { tryToRetrieveFeedUrlBySearch(e) } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) @@ -258,7 +259,8 @@ class OnlineFeedViewFragment : Fragment() { private fun startFeedBuilding(url: String) { Logd(TAG, "startFeedBuilding") - if (feedSource == "VistaGuide") { + if (feedSource == "VistaGuide" || url.contains("youtube.com")) { + feedSource = "VistaGuide" lifecycleScope.launch(Dispatchers.IO) { try { feeds = getFeedList() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt index cdd1e5e4..44aef33e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt @@ -1,198 +1,210 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.FragmentOnlineSearchBinding -import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult -import ac.mdiq.podcini.net.feed.discovery.PodcastSearcher -import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry +import ac.mdiq.podcini.databinding.AddfeedBinding +import ac.mdiq.podcini.databinding.EditTextDialogBinding +import ac.mdiq.podcini.net.feed.FeedUpdateManager +import ac.mdiq.podcini.net.feed.discovery.* +import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.isOPMLRestared +import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.performRestore +import ac.mdiq.podcini.storage.database.Feeds.updateFeed +import ac.mdiq.podcini.storage.model.EpisodeSortOrder +import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.ui.activity.MainActivity -import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter +import ac.mdiq.podcini.ui.activity.OpmlImportActivity +import ac.mdiq.podcini.ui.fragment.NavDrawerFragment.Companion.feedCount import ac.mdiq.podcini.util.Logd -import android.content.Context +import android.content.* +import android.net.Uri import android.os.Bundle +import android.util.Log +import android.view.KeyEvent import android.view.LayoutInflater -import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager -import android.widget.* -import androidx.appcompat.widget.SearchView +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.documentfile.provider.DocumentFile import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi -import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** + * Provides actions for adding new podcast subscriptions. + */ +@UnstableApi class OnlineSearchFragment : Fragment() { - private var _binding: FragmentOnlineSearchBinding? = null + private var _binding: AddfeedBinding? = null private val binding get() = _binding!! - private var adapter: OnlineFeedsAdapter? = null - private var searchProvider: PodcastSearcher? = null - - private lateinit var gridView: GridView - private lateinit var progressBar: ProgressBar - private lateinit var txtvError: TextView - private lateinit var butRetry: Button - private lateinit var txtvEmpty: TextView - - /** - * List of podcasts retreived from the search - */ - private var searchResults: MutableList? = null -// private var disposable: Disposable? = null + private var activity: MainActivity? = null + private var displayUpArrow = false - 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") - } + private val chooseOpmlImportPathLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + this.chooseOpmlImportPathResult(uri) } - @UnstableApi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentOnlineSearchBinding.inflate(inflater) + private val addLocalFolderLauncher = registerForActivityResult(AddLocalFolder()) { uri: Uri? -> this.addLocalFolderResult(uri) } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + _binding = AddfeedBinding.inflate(inflater) + activity = getActivity() as? MainActivity Logd(TAG, "fragment onCreateView") - gridView = binding.gridView - adapter = OnlineFeedsAdapter(requireContext(), ArrayList()) - gridView.setAdapter(adapter) - - //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) + displayUpArrow = parentFragmentManager.backStackEntryCount != 0 + if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW) + (getActivity() as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow) + + binding.searchButton.setOnClickListener { performSearch() } + binding.searchVistaGuideButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(VistaGuidePodcastSearcher::class.java)) } + binding.searchItunesButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(ItunesPodcastSearcher::class.java)) } + binding.searchFyydButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(FyydPodcastSearcher::class.java)) } + binding.searchGPodderButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(GpodnetPodcastSearcher::class.java)) } + binding.searchPodcastIndexButton.setOnClickListener { activity?.loadChildFragment(SearchResultsFragment.newInstance(PodcastIndexPodcastSearcher::class.java)) } + binding.combinedFeedSearchEditText.setOnEditorActionListener { _: TextView?, _: Int, _: KeyEvent? -> + performSearch() + true + } + binding.addViaUrlButton.setOnClickListener { showAddViaUrlDialog() } + binding.opmlImportButton.setOnClickListener { + try { chooseOpmlImportPathLauncher.launch("*/*") + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + activity?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG) } } - progressBar = binding.progressBar - txtvError = binding.txtvError - butRetry = binding.butRetry - txtvEmpty = binding.empty - if (searchProvider != null) binding.searchPoweredBy.text = getString(R.string.search_powered_by, searchProvider!!.name) - setupToolbar(binding.toolbar) - - gridView.setOnScrollListener(object : AbsListView.OnScrollListener { - override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { - if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) { - val imm = activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - } + binding.addLocalFolderButton.setOnClickListener { + try { addLocalFolderLauncher.launch(null) + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + activity?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG) } - override fun onScroll(view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int) {} - }) + } + + if (isOPMLRestared && feedCount == 0) { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.restore_subscriptions_label) + .setMessage(R.string.restore_subscriptions_summary) + .setPositiveButton("Yes") { dialog, _ -> + performRestore(requireContext()) + dialog.dismiss() + parentFragmentManager.popBackStack() + } + .setNegativeButton("No") { dialog, _ -> dialog.dismiss() } + .show() + } return binding.root } - override fun onDestroy() { - _binding = null - searchResults = null - adapter = null - super.onDestroy() + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(KEY_UP_ARROW, displayUpArrow) + super.onSaveInstanceState(outState) } - 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()) }) + 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 <= ' ' }) } - 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) + 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 search(query: String) { - showOnlyProgressBar() - lifecycleScope.launch(Dispatchers.IO) { - try { - val result = searchProvider?.search(query) - searchResults = result?.toMutableList() - withContext(Dispatchers.Main) { - progressBar.visibility = View.GONE - adapter?.clear() - handleSearchResults() - txtvEmpty.text = getString(R.string.no_results_for_query, query) - } - } catch (e: Exception) { handleSearchError(e, query) } + 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 + super.onDestroyView() } - private fun handleSearchResults() { - adapter?.addAll(searchResults!!) - adapter?.notifyDataSetInvalidated() - gridView.visibility = if (!searchResults.isNullOrEmpty()) View.VISIBLE else View.GONE - txtvEmpty.visibility = if (searchResults.isNullOrEmpty()) View.VISIBLE else View.GONE + private fun chooseOpmlImportPathResult(uri: Uri?) { + if (uri == null) return + + val intent = Intent(context, OpmlImportActivity::class.java) + intent.setData(uri) + startActivity(intent) } - private fun handleSearchError(e: Throwable, query: String) { - Logd(TAG, "exception: ${e.message}") - progressBar.visibility = View.GONE - txtvError.text = e.toString() - txtvError.visibility = View.VISIBLE - butRetry.setOnClickListener { search(query) } - butRetry.visibility = View.VISIBLE + @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) + } + } } - private fun showOnlyProgressBar() { - gridView.visibility = View.GONE - txtvError.visibility = View.GONE - butRetry.visibility = View.GONE - txtvEmpty.visibility = View.GONE - progressBar.visibility = View.VISIBLE + @UnstableApi private fun addLocalFolder(uri: Uri): Feed? { + requireActivity().contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + val documentFile = DocumentFile.fromTreeUri(requireContext(), uri) + requireNotNull(documentFile) { "Unable to retrieve document tree" } + var title = documentFile.name + if (title == null) title = getString(R.string.local_folder) + + val dirFeed = Feed(Feed.PREFIX_LOCAL_FOLDER + uri.toString(), null, title) + dirFeed.episodes.clear() + dirFeed.sortOrder = EpisodeSortOrder.EPISODE_TITLE_A_Z + val fromDatabase: Feed? = updateFeed(requireContext(), dirFeed, false) + FeedUpdateManager.runOnce(requireContext(), fromDatabase) + return fromDatabase } - private fun showInputMethod(view: View) { - val imm = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(view, 0) + private class AddLocalFolder : ActivityResultContracts.OpenDocumentTree() { + override fun createIntent(context: Context, input: Uri?): Intent { + return super.createIntent(context, input).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } companion object { - private val TAG: String = OnlineSearchFragment::class.simpleName ?: "Anonymous" - private const val ARG_SEARCHER = "searcher" - private const val ARG_QUERY = "query" - - @JvmOverloads - fun newInstance(searchProvider: Class, query: String? = null): OnlineSearchFragment { - val fragment = OnlineSearchFragment() - val arguments = Bundle() - arguments.putString(ARG_SEARCHER, searchProvider.name) - arguments.putString(ARG_QUERY, query) - fragment.arguments = arguments - return fragment - } + val TAG = OnlineSearchFragment::class.simpleName ?: "Anonymous" + private const val KEY_UP_ARROW = "up_arrow" } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt index 3aa4c533..f6b077ec 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QueuesFragment.kt @@ -26,7 +26,7 @@ import ac.mdiq.podcini.storage.model.PlayQueue import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.EpisodeUtil import ac.mdiq.podcini.storage.utils.EpisodesPermutors.getPermutor -import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler +import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler import ac.mdiq.podcini.ui.actions.swipeactions.SwipeActions import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.adapter.EpisodesAdapter diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt index f19ab5bc..96c12001 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt @@ -2,28 +2,37 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.BuildConfig import ac.mdiq.podcini.R +import ac.mdiq.podcini.databinding.FragmentSearchResultsBinding import ac.mdiq.podcini.databinding.QuickFeedDiscoveryBinding import ac.mdiq.podcini.databinding.QuickFeedDiscoveryItemBinding +import ac.mdiq.podcini.databinding.SelectCountryDialogBinding import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader import ac.mdiq.podcini.net.feed.discovery.ItunesTopListLoader.Companion.prefs import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent +import android.content.DialogInterface import android.os.Bundle import android.util.DisplayMetrics import android.util.Log import android.view.LayoutInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.* import androidx.annotation.OptIn +import androidx.appcompat.widget.Toolbar import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.media3.common.util.UnstableApi import coil.load +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.MaterialAutoCompleteTextView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest @@ -227,6 +236,245 @@ class QuickDiscoveryFragment : Fragment(), AdapterView.OnItemClickListener { } } + /** + * Searches iTunes store for top podcasts and displays results in a list. + */ + class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener { + private var _binding: FragmentSearchResultsBinding? = null + private val binding get() = _binding!! + + // private lateinit var prefs: SharedPreferences + private lateinit var gridView: GridView + private lateinit var progressBar: ProgressBar + private lateinit var txtvError: TextView + private lateinit var butRetry: Button + private lateinit var txtvEmpty: TextView + private lateinit var toolbar: MaterialToolbar + + /** + * Adapter responsible with the search results. + */ + private var adapter: OnlineFeedsAdapter? = null + + /** + * List of podcasts retreived from the search. + */ + private var searchResults: List? = null + private var topList: List? = 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?) { + 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 = listOf(*Locale.getISOCountries()) + val countryCodeNames: MutableMap = HashMap() + val countryNameCodes: MutableMap = HashMap() + for (code in countryCodeArray) { + val locale = Locale("", code) + val countryName = locale.displayCountry + countryCodeNames[code] = countryName + countryNameCodes[countryName] = code + } + + val countryNamesSort: MutableList = ArrayList(countryCodeNames.values) + countryNamesSort.sort() + + val dataAdapter = ArrayAdapter(this.requireContext(), android.R.layout.simple_list_item_1, countryNamesSort) + val scBinding = SelectCountryDialogBinding.bind(selectCountryDialogView) + val textInput = scBinding.countryTextInput + val editText = textInput.editText as? MaterialAutoCompleteTextView + editText!!.setAdapter(dataAdapter) + editText.setText(countryCodeNames[countryCode]) + editText.setOnClickListener { + if (editText.text.isNotEmpty()) { + editText.setText("") + editText.postDelayed({ editText.showDropDown() }, 100) + } + } + editText.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean -> + if (hasFocus) { + editText.setText("") + editText.postDelayed({ editText.showDropDown() }, 100) + } + } + + builder.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + val countryName = editText.text.toString() + if (countryNameCodes.containsKey(countryName)) { + countryCode = countryNameCodes[countryName] + val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item) + discoverHideItem.setChecked(false) + hidden = false + } + + prefs!!.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply() + prefs!!.edit().putString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, countryCode).apply() + + EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent()) + loadToplist(countryCode) + } + builder.setNegativeButton(R.string.cancel_label, null) + builder.show() + return true + } + else -> return false + } + } + + companion object { + private val TAG: String = DiscoveryFragment::class.simpleName ?: "Anonymous" + private const val NUM_OF_TOP_PODCASTS = 25 + } + } + companion object { private val TAG: String = QuickDiscoveryFragment::class.simpleName ?: "Anonymous" private const val NUM_SUGGESTIONS = 12 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt index 60f1f2b0..3fe15397 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt @@ -10,9 +10,9 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.model.Episode import ac.mdiq.podcini.storage.model.Feed import ac.mdiq.podcini.storage.utils.EpisodeUtil -import ac.mdiq.podcini.ui.actions.EpisodeMultiSelectHandler -import ac.mdiq.podcini.ui.actions.menuhandler.EpisodeMenuHandler -import ac.mdiq.podcini.ui.actions.menuhandler.MenuItemUtils +import ac.mdiq.podcini.ui.actions.handler.EpisodeMultiSelectHandler +import ac.mdiq.podcini.ui.actions.handler.EpisodeMenuHandler +import ac.mdiq.podcini.ui.actions.handler.MenuItemUtils import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.adapter.EpisodesAdapter import ac.mdiq.podcini.ui.adapter.SelectableAdapter @@ -26,6 +26,7 @@ import ac.mdiq.podcini.ui.view.SquareImageView import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent +import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.os.Bundle @@ -63,7 +64,8 @@ import java.lang.ref.WeakReference /** * Performs a search operation on all feeds or one specific feed and displays the search result. */ -@UnstableApi class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { +@UnstableApi +class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener { private var _binding: SearchFragmentBinding? = null private val binding get() = _binding!! @@ -290,15 +292,14 @@ import java.lang.ref.WeakReference search() } + @SuppressLint("StringFormatMatches") @UnstableApi private fun search() { adapterFeeds.setEndButton(R.string.search_online) { this.searchOnline() } chip.visibility = if ((requireArguments().getLong(ARG_FEED, 0) == 0L)) View.GONE else View.VISIBLE lifecycleScope.launch { try { - val results = withContext(Dispatchers.IO) { - performSearch() - } + val results = withContext(Dispatchers.IO) { performSearch() } withContext(Dispatchers.Main) { progressBar.visibility = View.GONE if (results.first != null) { @@ -398,7 +399,7 @@ import java.lang.ref.WeakReference (activity as MainActivity).loadChildFragment(fragment) return } - (activity as MainActivity).loadChildFragment(OnlineSearchFragment.newInstance(CombinedSearcher::class.java, query)) + (activity as MainActivity).loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query)) } override fun onStartSelectMode() { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt new file mode 100644 index 00000000..0585572c --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt @@ -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 = 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, 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 + } + } +} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt index 6d1d52ba..09703e37 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt @@ -19,7 +19,6 @@ import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.adapter.SelectableAdapter import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.Spinner -import ac.mdiq.podcini.ui.dialog.FeedFilterDialog import ac.mdiq.podcini.ui.dialog.FeedSortDialog import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog import ac.mdiq.podcini.ui.dialog.TagSettingsDialog @@ -32,6 +31,7 @@ import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import android.app.Activity.RESULT_OK +import android.app.Dialog import android.content.ActivityNotFoundException import android.content.Context import android.content.DialogInterface @@ -69,6 +69,10 @@ import androidx.media3.common.util.UnstableApi import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.elevation.SurfaceColors import com.google.android.material.floatingactionbutton.FloatingActionButton @@ -77,6 +81,7 @@ import com.leinardi.android.speeddial.SpeedDialActionItem import com.leinardi.android.speeddial.SpeedDialView import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest +import org.apache.commons.lang3.StringUtils import java.text.NumberFormat import java.text.SimpleDateFormat import java.util.* @@ -184,7 +189,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec val subscriptionAddButton: FloatingActionButton = binding.subscriptionsAdd subscriptionAddButton.setOnClickListener { - if (activity is MainActivity) (activity as MainActivity).loadChildFragment(AddFeedFragment()) + if (activity is MainActivity) (activity as MainActivity).loadChildFragment(OnlineSearchFragment()) } binding.count.text = feedListFiltered.size.toString() + " / " + feedList.size.toString() @@ -1075,6 +1080,128 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener, Selec } } + class FeedFilterDialog : BottomSheetDialogFragment() { + private lateinit var rows: LinearLayout + private var _binding: FilterDialogBinding? = null + private val binding get() = _binding!! + + var filter: FeedFilter? = null + private val buttonMap: MutableMap = mutableMapOf() + + private val newFilterValues: Set + get() { + val newFilterValues: MutableSet = 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(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(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) { + 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 = arrayOf(*values) + + class ItemProperties(@JvmField val displayName: Int, @JvmField val filterId: String) + } + + companion object { + fun newInstance(filter: FeedFilter?): FeedFilterDialog { + val dialog = FeedFilterDialog() + dialog.filter = filter + return dialog + } + } + } + companion object { val TAG = SubscriptionsFragment::class.simpleName ?: "Anonymous" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt index cba4950c..004f53d7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/VideoEpisodeFragment.kt @@ -62,10 +62,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { private val binding get() = _binding!! private lateinit var root: ViewGroup - /** - * True if video controls are currently visible. - */ - private var videoControlsShowing = true + private var videoControlsVisible = true private var videoSurfaceCreated = false private var lastScreenTap: Long = 0 private val videoControlsHider = Handler(Looper.getMainLooper()) @@ -75,10 +72,10 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { private var itemsLoaded = false private var episode: Episode? = null private var webviewData: String? = null - var webvDescription: ShownotesWebView? = null + private var webvDescription: ShownotesWebView? = null var destroyingDueToReload = false - var controller: ServiceStatusHandler? = null + var statusHandler: ServiceStatusHandler? = null var isFavorite = false private val onVideoviewTouched = View.OnTouchListener { v: View, event: MotionEvent -> @@ -93,16 +90,15 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { onRewind() showSkipAnimation(false) } - if (videoControlsShowing) { + if (videoControlsVisible) { hideVideoControls(false) if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide() - videoControlsShowing = false + videoControlsVisible = false } return@OnTouchListener true } toggleVideoControlsVisibility() - if (videoControlsShowing) setupVideoControlsToggler() - + if (videoControlsVisible) setupVideoControlsToggler() lastScreenTap = System.currentTimeMillis() true } @@ -111,7 +107,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { holder.setFixedSize(width, height) } - @UnstableApi override fun surfaceCreated(holder: SurfaceHolder) { Logd(TAG, "Videoview holder created") @@ -120,7 +115,6 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { if (MediaPlayerBase.status == PlayerStatus.PLAYING) playbackService?.mPlayer?.setVideoSurface(holder) setupVideoAspectRatio() } - override fun surfaceDestroyed(holder: SurfaceHolder) { Logd(TAG, "Videosurface was destroyed") videoSurfaceCreated = false @@ -132,26 +126,28 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } private val hideVideoControls = Runnable { - if (videoControlsShowing) { + if (videoControlsVisible) { Logd(TAG, "Hiding video controls") hideVideoControls(true) if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as? AppCompatActivity)?.supportActionBar?.hide() - videoControlsShowing = false + videoControlsVisible = false } } - @OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + @OptIn(UnstableApi::class) + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) + Logd(TAG, "fragment onCreateView") _binding = VideoEpisodeFragmentBinding.inflate(LayoutInflater.from(requireContext())) root = binding.root - controller = newPlaybackController() - controller!!.init() + statusHandler = newStatusHandler() + statusHandler!!.init() // loadMediaInfo() setupView() return root } - @OptIn(UnstableApi::class) private fun newPlaybackController(): ServiceStatusHandler { + @OptIn(UnstableApi::class) private fun newStatusHandler(): ServiceStatusHandler { return object : ServiceStatusHandler(requireActivity()) { override fun updatePlayButton(showPlay: Boolean) { Logd(TAG, "updatePlayButtonShowsPlay called") @@ -160,7 +156,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { else { (activity as AppCompatActivity).window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) setupVideoAspectRatio() - if (videoSurfaceCreated && controller != null) { + if (videoSurfaceCreated) { Logd(TAG, "Videosurface already created, setting videosurface now") // setVideoSurface(binding.videoView.holder) playbackService?.mPlayer?.setVideoSurface(binding.videoView.holder) @@ -208,8 +204,8 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { webvDescription!!.destroy() } _binding = null - controller?.release() - controller = null // prevent leak + statusHandler?.release() + statusHandler = null // prevent leak super.onDestroyView() } @@ -259,7 +255,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } private fun setupVideoAspectRatio() { - if (videoSurfaceCreated && controller != null) { + if (videoSurfaceCreated) { if (videoSize != null && videoSize!!.first > 0 && videoSize!!.second > 0) { Logd(TAG, "Width,height of video: ${videoSize!!.first}, ${videoSize!!.second}") val videoWidth = resources.displayMetrics.widthPixels @@ -279,7 +275,8 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } private var loadItemsRunning = false - @OptIn(UnstableApi::class) private fun loadMediaInfo() { + @OptIn(UnstableApi::class) + private fun loadMediaInfo() { Logd(TAG, "loadMediaInfo called") if (curMedia == null) return if (MediaPlayerBase.status == PlayerStatus.PLAYING && !isPlayingVideoLocally) { @@ -314,14 +311,10 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } if (webviewData != null && !itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData!!, "text/html", "utf-8", "about:blank") - itemsLoaded = true } - } catch (e: Throwable) { - Log.e(TAG, Log.getStackTraceString(e)) - } finally { - loadItemsRunning = false - } + } catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e)) + } finally { loadItemsRunning = false } } } val media = curMedia @@ -396,16 +389,14 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } fun toggleVideoControlsVisibility() { - if (videoControlsShowing) { + if (videoControlsVisible) { hideVideoControls(true) - if (videoMode == VideoMode.FULL_SCREEN_VIEW) { - (activity as AppCompatActivity).supportActionBar?.hide() - } + if (videoMode == VideoMode.FULL_SCREEN_VIEW) (activity as AppCompatActivity).supportActionBar?.hide() } else { showVideoControls() (activity as AppCompatActivity).supportActionBar?.show() } - videoControlsShowing = !videoControlsShowing + videoControlsVisible = !videoControlsVisible } fun showSkipAnimation(isForward: Boolean) { @@ -448,7 +439,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { @UnstableApi fun onRewind() { - if (controller == null) return +// if (statusHandler == null) return playbackService?.mPlayer?.seekDelta(-rewindSecs * 1000) setupVideoControlsToggler() } @@ -461,7 +452,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { @UnstableApi fun onFastForward() { - if (controller == null) return +// if (statusHandler == null) return playbackService?.mPlayer?.seekDelta(fastForwardSecs * 1000) setupVideoControlsToggler() } @@ -503,7 +494,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } private fun onPositionObserverUpdate() { - if (controller == null) return +// if (statusHandler == null) return val converter = TimeSpeedConverter(curSpeedFB) val currentPosition = converter.convert(curPositionFB) val duration_ = converter.convert(curDurationFB) @@ -527,7 +518,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener { } override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (controller == null) return +// if (statusHandler == null) return if (fromUser) { prog = progress / (seekBar.max.toFloat()) val converter = TimeSpeedConverter(curSpeedFB) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ShownotesWebView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ShownotesWebView.kt index 0bf4a1e1..3140cd55 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ShownotesWebView.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ShownotesWebView.kt @@ -3,7 +3,7 @@ package ac.mdiq.podcini.ui.view import ac.mdiq.podcini.R import ac.mdiq.podcini.net.utils.NetworkUtils import ac.mdiq.podcini.storage.utils.DurationConverter -import ac.mdiq.podcini.ui.actions.menuhandler.MenuItemUtils +import ac.mdiq.podcini.ui.actions.handler.MenuItemUtils import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.util.* diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt index 9fc0e2ee..f91c2404 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/widget/WidgetUpdater.kt @@ -1,7 +1,6 @@ package ac.mdiq.podcini.ui.widget import ac.mdiq.podcini.R -import ac.mdiq.podcini.storage.utils.ImageResourceUtils.getFallbackImageLocation import ac.mdiq.podcini.playback.base.PlayerStatus import ac.mdiq.podcini.preferences.UserPreferences.shouldShowRemainingTime import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createPendingIntent @@ -10,15 +9,17 @@ import ac.mdiq.podcini.receiver.PlayerWidget.Companion.isEnabled import ac.mdiq.podcini.receiver.PlayerWidget.Companion.prefs import ac.mdiq.podcini.storage.model.MediaType import ac.mdiq.podcini.storage.model.Playable +import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong +import ac.mdiq.podcini.storage.utils.ImageResourceUtils.getFallbackImageLocation +import ac.mdiq.podcini.storage.utils.TimeSpeedConverter import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter -import ac.mdiq.podcini.ui.activity.starter.PlaybackSpeedActivityStarter import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter -import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.storage.utils.TimeSpeedConverter +import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context +import android.content.Intent import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.util.Log @@ -35,7 +36,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.max - /** * Updates the state of the player widget. */ @@ -206,4 +206,30 @@ object WidgetUpdater { class WidgetState(val media: Playable?, val status: PlayerStatus, val position: Int, val duration: Int, val playbackSpeed: Float) { constructor(status: PlayerStatus) : this(null, status, Playable.INVALID_TIME, Playable.INVALID_TIME, 1.0f) } + + /** + * Launches the playback speed dialog activity of the app with specific arguments. + * Does not require a dependency on the actual implementation of the activity. + */ + class PlaybackSpeedActivityStarter(private val context: Context) { + val intent: Intent = Intent(INTENT) + + init { + intent.setPackage(context.packageName) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) + } + + val pendingIntent: PendingIntent + get() = PendingIntent.getActivity(context, R.id.pending_intent_playback_speed, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + fun start() { + context.startActivity(intent) + } + + companion object { + const val INTENT: String = "ac.mdiq.podcini.intents.PLAYBACK_SPEED" + } + } + } diff --git a/app/src/main/res/layout/addfeed.xml b/app/src/main/res/layout/addfeed.xml index 228f4a3a..d0efb9d0 100644 --- a/app/src/main/res/layout/addfeed.xml +++ b/app/src/main/res/layout/addfeed.xml @@ -38,16 +38,6 @@ android:layout_height="wrap_content" android:orientation="horizontal"> - - + + diff --git a/app/src/main/res/layout/fragment_online_search.xml b/app/src/main/res/layout/fragment_search_results.xml similarity index 98% rename from app/src/main/res/layout/fragment_online_search.xml rename to app/src/main/res/layout/fragment_search_results.xml index ca78565d..35758429 100644 --- a/app/src/main/res/layout/fragment_online_search.xml +++ b/app/src/main/res/layout/fragment_search_results.xml @@ -5,7 +5,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:id="@+id/fragment_online_search"> + android:id="@+id/fragment_search_results">