4.5.0 commit

This commit is contained in:
Xilin Jia 2024-04-01 22:15:50 +00:00
parent 725c15fbba
commit 20d0038eee
34 changed files with 423 additions and 63 deletions

View File

@ -36,6 +36,19 @@ Other notable features and changes include:
* setting for a feed: either use global or customized
* setting at the player: set for current playing and save for global
* customized feed setting takes precedence when playing an episode
* Added preference "Fast Forward Speed" under "Playback" in settings with default value of 0.0, dialog allows setting a float number (capped between 0.0 and 10.0)
* The "Skip to next episode" button on the player
* long-press moves to the next episode
* by default, single tap does nothing
* if the user customize "Fast Forward Speed" to a value greater than 0.1, it behaves in the following way:
* single tap during play, the set speed is used to play the current audio
* single tap again, the original play speed resumes
* single tap not during play has no effect
* Added preference "Fallback Speed" under "Playback" in settings with default value of 0.0, dialog allows setting a float number (capped between 0.0 and 1.5)
* the Play button on the player
* by default, it behaves the same as usual
* if the user customize "Fallback speed" to a value greater than 0.1, long-press the button during play enters the fallback mode and plays at the set fallback speed, single tap exits the fallback mode
* Subscriptions view has sorting by "Unread publication date"
* Feed info view offers a link for direct search of feeds related to author
* More info about feeds are shown in the online search view
* Online feed info display is handled in similar ways as any local feed, and offers options to subscribe or view episodes

View File

@ -149,8 +149,8 @@ android {
// Version code schema (not used):
// "1.2.3-beta4" -> 1020304
// "1.2.3" -> 1020395
versionCode 3020119
versionName "4.4.3"
versionCode 3020120
versionName "4.5.0"
def commit = ""
try {

View File

@ -57,7 +57,7 @@ class MainActivityTest {
EspressoTestUtils.openNavDrawer()
Espresso.onView(ViewMatchers.withText(R.string.add_feed_label)).perform(ViewActions.click())
Espresso.onView(ViewMatchers.withId(R.id.addViaUrlButton)).perform(ViewActions.scrollTo(), ViewActions.click())
Espresso.onView(ViewMatchers.withId(R.id.urlEditText)).perform(ViewActions.replaceText(feed.download_url))
Espresso.onView(ViewMatchers.withId(R.id.editText)).perform(ViewActions.replaceText(feed.download_url))
Espresso.onView(ViewMatchers.withText(R.string.confirm_label))
.perform(ViewActions.scrollTo(), ViewActions.click())

View File

@ -221,8 +221,8 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
PlayerStatus.PREPARING -> if (playbackService != null) {
updatePlayButtonShowsPlay(!playbackService!!.isStartWhenPrepared)
}
PlayerStatus.PAUSED, PlayerStatus.PREPARED, PlayerStatus.STOPPED, PlayerStatus.INITIALIZED -> updatePlayButtonShowsPlay(
true)
PlayerStatus.FALLBACK, PlayerStatus.PAUSED, PlayerStatus.PREPARED, PlayerStatus.STOPPED, PlayerStatus.INITIALIZED ->
updatePlayButtonShowsPlay(true)
else -> {}
}
}
@ -267,7 +267,8 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
return
}
when (status) {
PlayerStatus.PLAYING -> playbackService?.pause(true, false)
PlayerStatus.FALLBACK -> fallbackSpeed(1.0f)
PlayerStatus.PLAYING -> playbackService?.pause(abandonAudioFocus = true, reinit = false)
PlayerStatus.PAUSED, PlayerStatus.PREPARED -> playbackService?.resume()
PlayerStatus.PREPARING -> playbackService!!.isStartWhenPrepared = !playbackService!!.isStartWhenPrepared
PlayerStatus.INITIALIZED -> {
@ -345,6 +346,28 @@ abstract class PlaybackController(private val activity: FragmentActivity) {
}
}
fun speedForward(speed: Float) {
if (playbackService != null) {
playbackService!!.speedForward(speed)
}
}
fun fallbackSpeed(speed: Float) {
if (playbackService != null) {
when (status) {
PlayerStatus.PLAYING -> {
status = PlayerStatus.FALLBACK
playbackService!!.fallbackSpeed(speed)
}
PlayerStatus.FALLBACK -> {
status = PlayerStatus.PLAYING
playbackService!!.fallbackSpeed(speed)
}
else -> {}
}
}
}
fun setSkipSilence(skipSilence: Boolean) {
playbackService?.skipSilence(skipSilence)
}

View File

@ -5,6 +5,7 @@ enum class PlayerStatus(private val statusValue: Int) {
ERROR(-1),
PREPARING(19),
PAUSED(30),
FALLBACK(35),
PLAYING(40),
STOPPED(5),
PREPARED(20),

View File

@ -74,9 +74,11 @@ object UserPreferences {
private const val PREF_AUTO_DELETE_LOCAL = "prefAutoDeleteLocal"
const val PREF_SMART_MARK_AS_PLAYED_SECS: String = "prefSmartMarkAsPlayedSecs"
private const val PREF_PLAYBACK_SPEED_ARRAY = "prefPlaybackSpeedArray"
private const val PREF_FALLBACK_SPEED = "prefFallbackSpeed"
const val PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS: String = "prefPauseForFocusLoss"
private const val PREF_TIME_RESPECTS_SPEED = "prefPlaybackTimeRespectsSpeed"
const val PREF_STREAM_OVER_DOWNLOAD: String = "prefStreamOverDownload"
private const val PREF_SPEEDFORWRD_SPEED = "prefSpeedforwardSpeed"
// Network
private const val PREF_ENQUEUE_DOWNLOADED = "prefEnqueueDownloaded"
@ -127,6 +129,8 @@ object UserPreferences {
const val FEED_ORDER_COUNTER: Int = 0
const val FEED_ORDER_ALPHABETICAL: Int = 1
const val FEED_ORDER_MOST_PLAYED: Int = 3
const val FEED_ORDER_LAST_UPDATED: Int = 4
const val FEED_ORDER_LAST_UNREAD_UPDATED: Int = 5
const val DEFAULT_PAGE_REMEMBER: String = "remember"
private lateinit var context: Context
@ -175,7 +179,7 @@ object UserPreferences {
return ArrayList(listOf(*TextUtils.split(hiddenItems, ",")))
}
set(items) {
val str = TextUtils.join(",", items!!)
val str = TextUtils.join(",", items)
prefs.edit()
.putString(PREF_HIDDEN_DRAWER_ITEMS, str)
.apply()
@ -545,6 +549,40 @@ object UserPreferences {
val isEnableAutodownloadWifiFilter: Boolean
get() = Build.VERSION.SDK_INT < 29 && prefs.getBoolean(PREF_ENABLE_AUTODL_WIFI_FILTER, false)
@JvmStatic
var speedforwardSpeed: Float
get() {
try {
return prefs.getString(PREF_SPEEDFORWRD_SPEED, "0.00")!!.toFloat()
} catch (e: NumberFormatException) {
Log.e(TAG, Log.getStackTraceString(e))
speedforwardSpeed = 0.0f
return 0.0f
}
}
set(speed) {
prefs.edit()
.putString(PREF_SPEEDFORWRD_SPEED, speed.toString())
.apply()
}
@JvmStatic
var fallbackSpeed: Float
get() {
try {
return prefs.getString(PREF_FALLBACK_SPEED, "0.00")!!.toFloat()
} catch (e: NumberFormatException) {
Log.e(TAG, Log.getStackTraceString(e))
fallbackSpeed = 0.0f
return 0.0f
}
}
set(speed) {
prefs.edit()
.putString(PREF_FALLBACK_SPEED, speed.toString())
.apply()
}
@JvmStatic
var fastForwardSecs: Int
get() = prefs.getInt(PREF_FAST_FORWARD_SECS, 30)

View File

@ -138,6 +138,10 @@ class PlaybackService : MediaBrowserServiceCompat() {
private var clickCount = 0
private val clickHandler = Handler(Looper.getMainLooper())
private var isSpeedForward = false
private var normalSpeed = 1.0f
private var isFallbackSpeed = false
/**
* Used for Lollipop notifications, Android Wear, and Android Auto.
*/
@ -224,7 +228,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
var wasPlaying = false
if (mediaPlayer != null) {
media = mediaPlayer!!.getPlayable()
wasPlaying = mediaPlayer!!.playerStatus == PlayerStatus.PLAYING
wasPlaying = mediaPlayer!!.playerStatus == PlayerStatus.PLAYING || mediaPlayer!!.playerStatus == PlayerStatus.FALLBACK
mediaPlayer!!.pause(true, false)
mediaPlayer!!.shutdown()
}
@ -242,7 +246,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
super.onDestroy()
Log.d(TAG, "Service is about to be destroyed")
if (notificationBuilder.playerStatus == PlayerStatus.PLAYING) {
if (notificationBuilder.playerStatus == PlayerStatus.PLAYING || notificationBuilder.playerStatus == PlayerStatus.FALLBACK) {
notificationBuilder.playerStatus = PlayerStatus.STOPPED
val notificationManager = NotificationManagerCompat.from(this)
if (Build.VERSION.SDK_INT >= 33 && ActivityCompat.checkSelfPermission(this,
@ -618,7 +622,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
status == PlayerStatus.PLAYING -> {
mediaPlayer?.pause(!isPersistNotify, false)
}
status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED -> {
status == PlayerStatus.FALLBACK || status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED -> {
mediaPlayer?.resume()
}
status == PlayerStatus.PREPARING -> {
@ -678,7 +682,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
}
}
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
if (this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED) {
if (this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED) {
mediaPlayer?.seekDelta(fastForwardSecs * 1000)
return true
}
@ -690,7 +694,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
// Handle remapped button as notification button which is not remapped again.
return handleKeycode(hardwarePreviousButton, true)
}
this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED -> {
this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED -> {
mediaPlayer?.seekTo(0)
return true
}
@ -698,14 +702,14 @@ class PlaybackService : MediaBrowserServiceCompat() {
}
}
KeyEvent.KEYCODE_MEDIA_REWIND -> {
if (this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED) {
if (this.status == PlayerStatus.FALLBACK || this.status == PlayerStatus.PLAYING || this.status == PlayerStatus.PAUSED) {
mediaPlayer?.seekDelta(-rewindSecs * 1000)
return true
}
return false
}
KeyEvent.KEYCODE_MEDIA_STOP -> {
if (status == PlayerStatus.PLAYING) {
if (this.status == PlayerStatus.FALLBACK || status == PlayerStatus.PLAYING) {
mediaPlayer?.pause(true, true)
}
@ -714,7 +718,8 @@ class PlaybackService : MediaBrowserServiceCompat() {
}
else -> {
Log.d(TAG, "Unhandled key code: $keycode")
if (info?.playable != null && info.playerStatus == PlayerStatus.PLAYING) { // only notify the user about an unknown key event if it is actually doing something
if (info?.playable != null && info.playerStatus == PlayerStatus.PLAYING) {
// only notify the user about an unknown key event if it is actually doing something
val message = String.format(resources.getString(R.string.unknown_media_key), keycode)
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
@ -933,7 +938,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
@Subscribe(threadMode = ThreadMode.MAIN)
@Suppress("unused")
fun playerError(event: PlayerErrorEvent?) {
if (mediaPlayer?.playerStatus == PlayerStatus.PLAYING) {
if (mediaPlayer?.playerStatus == PlayerStatus.PLAYING || mediaPlayer?.playerStatus == PlayerStatus.FALLBACK) {
mediaPlayer!!.pause(true, false)
}
stateManager.stopService()
@ -1176,6 +1181,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
val state = if (playerStatus != null) {
when (playerStatus) {
PlayerStatus.PLAYING -> PlaybackStateCompat.STATE_PLAYING
PlayerStatus.FALLBACK -> PlaybackStateCompat.STATE_PLAYING
PlayerStatus.PREPARED, PlayerStatus.PAUSED -> PlaybackStateCompat.STATE_PAUSED
PlayerStatus.STOPPED -> PlaybackStateCompat.STATE_STOPPED
PlayerStatus.SEEKING -> PlaybackStateCompat.STATE_FAST_FORWARDING
@ -1401,7 +1407,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
private fun bluetoothNotifyChange(info: PSMPInfo?, whatChanged: String) {
var isPlaying = false
if (info?.playerStatus == PlayerStatus.PLAYING) {
if (info?.playerStatus == PlayerStatus.PLAYING || info?.playerStatus == PlayerStatus.FALLBACK) {
isPlaying = true
}
@ -1505,7 +1511,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
*/
private fun pauseIfPauseOnDisconnect() {
Log.d(TAG, "pauseIfPauseOnDisconnect()")
transientPause = (mediaPlayer?.playerStatus == PlayerStatus.PLAYING)
transientPause = (mediaPlayer?.playerStatus == PlayerStatus.PLAYING || mediaPlayer?.playerStatus == PlayerStatus.FALLBACK)
if (isPauseOnHeadsetDisconnect && !isCasting) {
mediaPlayer?.pause(!isPersistNotify, false)
}
@ -1598,6 +1604,8 @@ class PlaybackService : MediaBrowserServiceCompat() {
fun pause(abandonAudioFocus: Boolean, reinit: Boolean) {
mediaPlayer?.pause(abandonAudioFocus, reinit)
isSpeedForward = false
isFallbackSpeed = false
}
val pSMPInfo: PSMPInfo
@ -1610,6 +1618,9 @@ class PlaybackService : MediaBrowserServiceCompat() {
get() = mediaPlayer?.getPlayable()
fun setSpeed(speed: Float) {
isSpeedForward = false
isFallbackSpeed = false
currentlyPlayingTemporaryPlaybackSpeed = speed
if (currentMediaType == MediaType.VIDEO) {
videoPlaybackSpeed = speed
@ -1620,6 +1631,30 @@ class PlaybackService : MediaBrowserServiceCompat() {
mediaPlayer?.setPlaybackParams(speed, isSkipSilence)
}
fun speedForward(speed: Float) {
if (mediaPlayer == null || isFallbackSpeed) return
if (!isSpeedForward) {
normalSpeed = mediaPlayer!!.getPlaybackSpeed()
mediaPlayer!!.setPlaybackParams(speed, isSkipSilence)
} else {
mediaPlayer!!.setPlaybackParams(normalSpeed, isSkipSilence)
}
isSpeedForward = !isSpeedForward
}
fun fallbackSpeed(speed: Float) {
if (mediaPlayer == null || isSpeedForward) return
if (!isFallbackSpeed) {
normalSpeed = mediaPlayer!!.getPlaybackSpeed()
mediaPlayer!!.setPlaybackParams(speed, isSkipSilence)
} else {
mediaPlayer!!.setPlaybackParams(normalSpeed, isSkipSilence)
}
isFallbackSpeed = !isFallbackSpeed
}
fun skipSilence(skipSilence: Boolean) {
mediaPlayer?.setPlaybackParams(currentPlaybackSpeed, skipSilence)
}
@ -1800,13 +1835,13 @@ class PlaybackService : MediaBrowserServiceCompat() {
override fun onFastForward() {
Log.d(TAG, "onFastForward()")
// speedForward(2.5f)
seekDelta(fastForwardSecs * 1000)
}
override fun onSkipToNext() {
Log.d(TAG, "onSkipToNext()")
val uiModeManager = applicationContext
.getSystemService(UI_MODE_SERVICE) as UiModeManager
val uiModeManager = applicationContext.getSystemService(UI_MODE_SERVICE) as UiModeManager
if (hardwareForwardButton == KeyEvent.KEYCODE_MEDIA_NEXT
|| uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_CAR) {
mediaPlayer?.skip()

View File

@ -867,17 +867,17 @@ object DBReader {
// getFeedList(adapter)
// TODO:
if (false && subscriptionsFilter != null) {
feeds = subscriptionsFilter.filter(feeds, feedCounters as Map<Long?, Int>).toMutableList()
}
// if (false && subscriptionsFilter != null) {
// feeds = subscriptionsFilter.filter(feeds, feedCounters as Map<Long?, Int>).toMutableList()
// }
val comparator: Comparator<Feed>
val feedOrder = feedOrder
when (feedOrder) {
UserPreferences.FEED_ORDER_COUNTER -> {
comparator = Comparator { lhs: Feed, rhs: Feed ->
val counterLhs = (if (feedCounters.containsKey(lhs.id)) feedCounters[lhs.id] else 0)!!.toLong()
val counterRhs = (if (feedCounters.containsKey(rhs.id)) feedCounters[rhs.id] else 0)!!.toLong()
val counterLhs = (if (feedCounters.containsKey(lhs.id)) feedCounters[lhs.id]!! else 0).toLong()
val counterRhs = (if (feedCounters.containsKey(rhs.id)) feedCounters[rhs.id]!! else 0).toLong()
when {
counterLhs > counterRhs -> {
// reverse natural order: podcast with most unplayed episodes first
@ -929,7 +929,7 @@ object DBReader {
}
}
}
else -> {
UserPreferences.FEED_ORDER_LAST_UPDATED -> {
val recentPubDates = adapter.mostRecentItemDates
comparator = Comparator { lhs: Feed, rhs: Feed ->
val dateLhs = if (recentPubDates.containsKey(lhs.id)) recentPubDates[lhs.id]!! else 0
@ -937,6 +937,14 @@ object DBReader {
dateRhs.compareTo(dateLhs)
}
}
else -> {
val recentUnreadPubDates = adapter.mostRecentUnreadItemDates
comparator = Comparator { lhs: Feed, rhs: Feed ->
val dateLhs = if (recentUnreadPubDates.containsKey(lhs.id)) recentUnreadPubDates[lhs.id]!! else 0
val dateRhs = if (recentUnreadPubDates.containsKey(rhs.id)) recentUnreadPubDates[rhs.id]!! else 0
dateRhs.compareTo(dateLhs)
}
}
}
feeds.sortWith(comparator)

View File

@ -991,6 +991,27 @@ class PodDBAdapter private constructor() {
return result
}
val mostRecentUnreadItemDates: Map<Long, Long>
get() {
val query = ("SELECT " + KEY_FEED + ","
+ " MAX(" + TABLE_NAME_FEED_ITEMS + "." + KEY_PUBDATE + ") AS most_recent_pubdate"
+ " FROM " + TABLE_NAME_FEED_ITEMS
+ " WHERE " + KEY_READ + " = 0"
+ " GROUP BY " + KEY_FEED)
val c = db.rawQuery(query, null)
val result: MutableMap<Long, Long> = HashMap()
if (c.moveToFirst()) {
do {
val feedId = c.getLong(0)
val date = c.getLong(1)
result[feedId] = date
} while (c.moveToNext())
}
c.close()
return result
}
/**
* Uses DatabaseUtils to escape a search query and removes ' at the
* beginning and the end of the string returned by the escape method.

View File

@ -102,21 +102,12 @@ class Feed : FeedFile {
var itemFilter: FeedItemFilter? = null
private set
/**
* User-preferred sortOrder for display.
* Only those of scope [SortOrder.Scope.INTRA_FEED] is allowed.
*/
var sortOrder: SortOrder? = null
set(sortOrder) {
if (!(sortOrder != null && sortOrder.scope != SortOrder.Scope.INTRA_FEED)) {
Log.w("Feed sortOrder", "The specified sortOrder " + sortOrder
+ " is invalid. Only those with INTRA_FEED scope are allowed.")
if (sortOrder == null) {
Log.w("Feed sortOrder", "The specified sortOrder $sortOrder is invalid.")
return
}
// This looks suicidal:
// require(!(sortOrder != null && sortOrder.scope != SortOrder.Scope.INTRA_FEED)) {
// ("The specified sortOrder " + sortOrder
// + " is invalid. Only those with INTRA_FEED scope are allowed.")
// }
field = sortOrder
}

View File

@ -16,12 +16,14 @@ enum class SortOrder(@JvmField val code: Int, @JvmField val scope: Scope) {
SIZE_LARGE_SMALL(10, Scope.INTRA_FEED),
FEED_TITLE_A_Z(101, Scope.INTER_FEED),
FEED_TITLE_Z_A(102, Scope.INTER_FEED),
RANDOM(103, Scope.INTER_FEED),
SMART_SHUFFLE_OLD_NEW(104, Scope.INTER_FEED),
SMART_SHUFFLE_NEW_OLD(105, Scope.INTER_FEED);
enum class Scope {
INTRA_FEED, INTER_FEED
INTRA_FEED,
INTER_FEED
}
companion object {

View File

@ -0,0 +1,44 @@
package ac.mdiq.podcini.ui.dialog
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.preferences.UserPreferences.fallbackSpeed
import ac.mdiq.podcini.preferences.UserPreferences.speedforwardSpeed
import android.app.Activity
import android.content.DialogInterface
import android.text.Editable
import android.text.InputType
import android.view.LayoutInflater
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.lang.ref.WeakReference
@UnstableApi
class EditFallbackSpeedDialog(activity: Activity) {
private val activityRef = WeakReference(activity)
fun show() {
val activity = activityRef.get() ?: return
val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity))
binding.editText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
binding.editText.text = Editable.Factory.getInstance().newEditable(fallbackSpeed.toString())
MaterialAlertDialogBuilder(activity)
.setView(binding.root)
.setTitle(R.string.edit_fast_forward_speed)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
var speed = binding.editText.text.toString().toFloatOrNull() ?: 0.0f
when {
speed < 0.0f -> speed = 0.0f
speed > 1.5f -> speed = 1.5f
}
fallbackSpeed = String.format("%.1f", speed).toFloat()
}
.setNegativeButton(R.string.cancel_label, null)
.show()
}
companion object {
const val TAG: String = "EditForwardSpeedDialog"
}
}

View File

@ -0,0 +1,45 @@
package ac.mdiq.podcini.ui.dialog
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.speedforwardSpeed
import android.app.Activity
import android.content.DialogInterface
import android.text.Editable
import android.text.InputType
import android.view.LayoutInflater
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.lang.ref.WeakReference
@UnstableApi
class EditForwardSpeedDialog(activity: Activity) {
private val activityRef = WeakReference(activity)
fun show() {
val activity = activityRef.get() ?: return
val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity))
binding.editText.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
binding.editText.text = Editable.Factory.getInstance().newEditable(speedforwardSpeed.toString())
MaterialAlertDialogBuilder(activity)
.setView(binding.root)
.setTitle(R.string.edit_fast_forward_speed)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
var speed = binding.editText.text.toString().toFloatOrNull() ?: 0.0f
when {
speed < 0.0f -> speed = 0.0f
speed > 10.0f -> speed = 10.0f
}
speedforwardSpeed = String.format("%.1f", speed).toFloat()
}
.setNegativeButton(R.string.cancel_label, null)
.show()
}
companion object {
const val TAG: String = "EditForwardSpeedDialog"
}
}

View File

@ -25,12 +25,12 @@ import java.util.concurrent.ExecutionException
val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity))
binding.urlEditText.setText(feed.download_url)
binding.editText.setText(feed.download_url)
MaterialAlertDialogBuilder(activity)
.setView(binding.root)
.setTitle(R.string.edit_url_menu)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> showConfirmAlertDialog(binding.urlEditText.text.toString()) }
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> showConfirmAlertDialog(binding.editText.text.toString()) }
.setNegativeButton(R.string.cancel_label, null)
.show()
}

View File

@ -16,8 +16,7 @@ object FeedSortDialog {
dialog.setNegativeButton(android.R.string.cancel) { d: DialogInterface, _: Int -> d.dismiss() }
val selected = feedOrder
val entryValues =
listOf(*context.resources.getStringArray(R.array.nav_drawer_feed_order_values))
val entryValues = listOf(*context.resources.getStringArray(R.array.nav_drawer_feed_order_values))
val selectedIndex = entryValues.indexOf("" + selected)
val items = context.resources.getStringArray(R.array.nav_drawer_feed_order_options)

View File

@ -37,12 +37,12 @@ class RenameItemDialog {
val binding = EditTextDialogBinding.inflate(LayoutInflater.from(activity))
val title = if (feed != null) feed!!.title else drawerItem!!.title
binding.urlEditText.setText(title)
binding.editText.setText(title)
val dialog = MaterialAlertDialogBuilder(activity)
.setView(binding.root)
.setTitle(if (feed != null) R.string.rename_feed_label else R.string.rename_tag_label)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
val newTitle = binding.urlEditText.text.toString()
val newTitle = binding.editText.text.toString()
if (feed != null) {
feed!!.setCustomTitle(newTitle)
DBWriter.setFeedCustomTitle(feed!!)
@ -56,7 +56,7 @@ class RenameItemDialog {
// To prevent cancelling the dialog on button click
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
.setOnClickListener { binding.urlEditText.setText(title) }
.setOnClickListener { binding.editText.setText(title) }
}
private fun renameTag(title: String) {

View File

@ -117,18 +117,18 @@ class AddFeedFragment : Fragment() {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(R.string.add_podcast_by_url)
val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
dialogBinding.urlEditText.setHint(R.string.add_podcast_by_url_hint)
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.urlEditText.setText(clipboardContent.trim { it <= ' ' })
dialogBinding.editText.setText(clipboardContent.trim { it <= ' ' })
}
}
builder.setView(dialogBinding.root)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> addUrl(dialogBinding.urlEditText.text.toString()) }
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int -> addUrl(dialogBinding.editText.text.toString()) }
builder.setNegativeButton(R.string.cancel_label, null)
builder.show()
}

View File

@ -4,6 +4,7 @@ import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.AudioplayerFragmentBinding
import ac.mdiq.podcini.feed.util.PlaybackSpeedUtils
import ac.mdiq.podcini.playback.PlaybackController
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.playback.cast.CastEnabledActivity
import ac.mdiq.podcini.playback.event.*
import ac.mdiq.podcini.preferences.UserPreferences
@ -83,6 +84,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
private lateinit var butFF: ImageButton
private lateinit var txtvFF: TextView
private lateinit var butSkip: ImageButton
private lateinit var txtvSkip: TextView
private lateinit var toolbar: MaterialToolbar
private lateinit var playerFragment: View
@ -140,6 +142,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
butFF = binding.butFF
txtvFF = binding.txtvFF
butSkip = binding.butSkip
txtvSkip = binding.txtvSkip
progressIndicator = binding.progLoading
cardViewSeek = binding.cardViewSeek
txtvSeek = binding.txtvSeek
@ -206,6 +209,13 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
controller?.init()
controller?.playPause()
}
butPlay.setOnLongClickListener {
if (controller != null && controller!!.status == PlayerStatus.PLAYING) {
val fallbackSpeed = UserPreferences.fallbackSpeed
if (fallbackSpeed > 0.1f) controller!!.fallbackSpeed(fallbackSpeed)
}
true
}
butFF.setOnClickListener {
if (controller != null) {
val curr: Int = controller!!.position
@ -215,11 +225,18 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
butFF.setOnLongClickListener {
SkipPreferenceDialog.showSkipPreference(requireContext(),
SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, txtvFF)
false
true
}
butSkip.setOnClickListener {
if (controller != null && controller!!.status == PlayerStatus.PLAYING) {
val speedForward = UserPreferences.speedforwardSpeed
if (speedForward > 0.1f) controller!!.speedForward(speedForward)
}
}
butSkip.setOnLongClickListener {
activity?.sendBroadcast(
MediaButtonReceiver.createIntent(requireContext(), KeyEvent.KEYCODE_MEDIA_NEXT))
true
}
}
@ -323,7 +340,11 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
override fun onStart() {
super.onStart()
txtvRev.text = NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong())
txtvRev.text = NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong())
txtvFF.text = NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong())
if (UserPreferences.speedforwardSpeed > 0.1f) {
txtvSkip.text = NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed)
} else txtvSkip.visibility = View.GONE
}
override fun onStop() {

View File

@ -69,6 +69,7 @@ class ExternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
private lateinit var butFF: ImageButton
private lateinit var txtvFF: TextView
private lateinit var butSkip: ImageButton
private lateinit var txtvSkip: TextView
private lateinit var txtvPosition: TextView
private lateinit var txtvLength: TextView
@ -97,6 +98,7 @@ class ExternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
butFF = binding.butFF
txtvFF = binding.txtvFF
butSkip = binding.butSkip
txtvSkip = binding.txtvSkip
sbPosition = binding.sbPosition
txtvPosition = binding.txtvPosition
txtvLength = binding.txtvLength
@ -123,7 +125,6 @@ class ExternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
controller = setupPlaybackController()
controller!!.init()
// loadMediaInfo()
EventBus.getDefault().register(this)
return binding.root
}
@ -169,6 +170,13 @@ class ExternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
controller?.init()
controller?.playPause()
}
butPlay.setOnLongClickListener {
if (controller != null && controller!!.status == PlayerStatus.PLAYING) {
val fallbackSpeed = UserPreferences.fallbackSpeed
if (fallbackSpeed > 0.1f) controller!!.fallbackSpeed(fallbackSpeed)
}
true
}
butFF.setOnClickListener {
if (controller != null) {
val curr: Int = controller!!.position
@ -178,11 +186,18 @@ class ExternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
butFF.setOnLongClickListener {
SkipPreferenceDialog.showSkipPreference(requireContext(),
SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, txtvFF)
false
true
}
butSkip.setOnClickListener {
if (controller != null && controller!!.status == PlayerStatus.PLAYING) {
val speedForward = UserPreferences.speedforwardSpeed
if (speedForward > 0.1f) controller!!.speedForward(speedForward)
}
}
butSkip.setOnLongClickListener {
activity?.sendBroadcast(
MediaButtonReceiver.createIntent(requireContext(), KeyEvent.KEYCODE_MEDIA_NEXT))
true
}
}
@ -280,6 +295,9 @@ class ExternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
super.onStart()
txtvRev.text = NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong())
txtvFF.text = NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong())
if (UserPreferences.speedforwardSpeed > 0.1f) {
txtvSkip.text = NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed)
} else txtvSkip.visibility = View.GONE
val media = controller?.getMedia() ?: return
updatePlaybackSpeedButton(SpeedChangedEvent(PlaybackSpeedUtils.getCurrentPlaybackSpeed(media)))
}

View File

@ -552,12 +552,12 @@ class OnlineFeedViewFragment : Fragment() {
builder.setTitle(R.string.edit_url_menu)
val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
if (downloader != null) {
dialogBinding.urlEditText.setText(downloader!!.downloadRequest.source)
dialogBinding.editText.setText(downloader!!.downloadRequest.source)
}
builder.setView(dialogBinding.root)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
setLoadingLayout()
lookupUrlAndDownload(dialogBinding.urlEditText.text.toString())
lookupUrlAndDownload(dialogBinding.editText.text.toString())
}
builder.setNegativeButton(R.string.cancel_label) { dialog1: DialogInterface, _: Int -> dialog1.cancel() }
builder.setOnCancelListener {}

View File

@ -306,6 +306,7 @@ class QueueFragment : Fragment(), Toolbar.OnMenuItemClickListener, SelectableAda
@Subscribe(threadMode = ThreadMode.MAIN)
fun onUnreadItemsChanged(event: UnreadItemsUpdateEvent?) {
// Sent when playback position is reset
Log.d(TAG, "onUnreadItemsChanged() called with event = [$event]")
loadItems(false)
refreshToolbarState()
}

View File

@ -5,13 +5,17 @@ import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UsageStatistics.doNotAskAgain
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.dialog.EditFallbackSpeedDialog
import ac.mdiq.podcini.ui.dialog.EditForwardSpeedDialog
import ac.mdiq.podcini.ui.dialog.SkipPreferenceDialog
import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog
import ac.mdiq.podcini.util.event.UnreadItemsUpdateEvent
import android.app.Activity
import android.os.Build
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.collection.ArrayMap
import androidx.media3.common.util.UnstableApi
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
@ -30,7 +34,7 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
(activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.playback_pref)
}
private fun setupPlaybackScreen() {
@OptIn(UnstableApi::class) private fun setupPlaybackScreen() {
val activity: Activity? = activity
findPreference<Preference>(PREF_PLAYBACK_SPEED_LAUNCHER)!!.onPreferenceClickListener =
@ -43,6 +47,16 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND, null)
true
}
findPreference<Preference>(PREF_PLAYBACK_SPEED_FORWARD_LAUNCHER)!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
EditForwardSpeedDialog(requireActivity()).show()
true
}
findPreference<Preference>(PREF_PLAYBACK_FALLBACK_SPEED_LAUNCHER)!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
EditFallbackSpeedDialog(requireActivity()).show()
true
}
findPreference<Preference>(PREF_PLAYBACK_FAST_FORWARD_DELTA_LAUNCHER)!!.onPreferenceClickListener =
Preference.OnPreferenceClickListener {
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD, null)
@ -120,6 +134,8 @@ class PlaybackPreferencesFragment : PreferenceFragmentCompat() {
companion object {
private const val PREF_PLAYBACK_SPEED_LAUNCHER = "prefPlaybackSpeedLauncher"
private const val PREF_PLAYBACK_REWIND_DELTA_LAUNCHER = "prefPlaybackRewindDeltaLauncher"
private const val PREF_PLAYBACK_FALLBACK_SPEED_LAUNCHER = "prefPlaybackFallbackSpeedLauncher"
private const val PREF_PLAYBACK_SPEED_FORWARD_LAUNCHER = "prefPlaybackSpeedForwardLauncher"
private const val PREF_PLAYBACK_FAST_FORWARD_DELTA_LAUNCHER = "prefPlaybackFastForwardDeltaLauncher"
private const val PREF_PLAYBACK_PREFER_STREAMING = "prefStreamOverDownload"
}

View File

@ -1,3 +1,4 @@
package ac.mdiq.podcini.util.event
//TODO: need to specify ids
class FavoritesEvent

View File

@ -3,6 +3,7 @@ package ac.mdiq.podcini.util.event
import ac.mdiq.podcini.storage.model.feed.FeedItem
// TODO: this appears not being posted
class FeedItemEvent(@JvmField val items: List<FeedItem>) {
companion object {
fun updated(items: List<FeedItem>): FeedItemEvent {

View File

@ -1,3 +1,4 @@
package ac.mdiq.podcini.util.event
//TODO: need to be optimized
class PlayerStatusEvent

View File

@ -1,3 +1,4 @@
package ac.mdiq.podcini.util.event
// TODO: need to specify ids
class UnreadItemsUpdateEvent

View File

@ -283,6 +283,20 @@
app:srcCompat="@drawable/ic_skip_48dp"
tools:srcCompat="@drawable/ic_skip_48dp" />
<TextView
android:id="@+id/txtvSkip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/butSkip"
android:layout_alignStart="@id/butSkip"
android:layout_alignLeft="@id/butSkip"
android:layout_alignEnd="@id/butSkip"
android:layout_alignRight="@id/butSkip"
android:clickable="false"
android:gravity="center"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp" />
</RelativeLayout>
</LinearLayout>

View File

@ -8,8 +8,8 @@
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:inputType="text|numberDecimal"
android:ems="10"
android:id="@+id/urlEditText" />
android:id="@+id/editText" />
</LinearLayout>

View File

@ -208,6 +208,20 @@
tools:srcCompat="@drawable/ic_skip_48dp"
app:tint="@color/medium_gray"/>
<TextView
android:id="@+id/txtvSkip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/butSkip"
android:layout_alignStart="@id/butSkip"
android:layout_alignLeft="@id/butSkip"
android:layout_alignEnd="@id/butSkip"
android:layout_alignRight="@id/butSkip"
android:clickable="false"
android:gravity="center"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp" />
</RelativeLayout>
</LinearLayout>

View File

@ -181,6 +181,7 @@
<item>@string/drawer_feed_order_unplayed_episodes</item>
<item>@string/drawer_feed_order_alphabetical</item>
<item>@string/drawer_feed_order_last_update</item>
<item>@string/drawer_feed_order_last_unread_update</item>
<item>@string/drawer_feed_order_most_played</item>
</string-array>
<string-array name="nav_drawer_feed_order_values">
@ -188,6 +189,7 @@
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
</string-array>
<string-array name="nav_drawer_feed_counter_options">

View File

@ -69,10 +69,11 @@
<string name="drawer_open">Open menu</string>
<string name="drawer_close">Close menu</string>
<string name="drawer_preferences">Drawer preferences</string>
<string name="drawer_feed_order_unplayed_episodes">Sort by counter</string>
<string name="drawer_feed_order_alphabetical">Sort alphabetically</string>
<string name="drawer_feed_order_last_update">Sort by publication date</string>
<string name="drawer_feed_order_most_played">Sort by number of played episodes</string>
<string name="drawer_feed_order_unplayed_episodes">Counter</string>
<string name="drawer_feed_order_alphabetical">Title</string>
<string name="drawer_feed_order_last_update">Publication date</string>
<string name="drawer_feed_order_last_unread_update">Unread publication date</string>
<string name="drawer_feed_order_most_played">Number of played episodes</string>
<string name="drawer_feed_counter_unplayed">Number of unplayed episodes</string>
<string name="drawer_feed_counter_downloaded">Number of downloaded episodes</string>
@ -445,7 +446,7 @@
<string name="pref_tinted_theme_message">Adapt app colors based on the wallpaper</string>
<string name="pref_nav_drawer_items_title">Set navigation drawer items</string>
<string name="pref_nav_drawer_items_sum">Change which items appear in the navigation drawer</string>
<string name="pref_nav_drawer_feed_order_title">Set subscription order</string>
<string name="pref_nav_drawer_feed_order_title">Set subscription order by</string>
<string name="pref_nav_drawer_feed_order_sum">Change the order of your subscriptions</string>
<string name="pref_nav_drawer_feed_counter_title">Set subscription counter</string>
<string name="pref_nav_drawer_feed_counter_sum">Change the information displayed by the subscription counter. Also affects the sorting of subscriptions if \'Subscription Order\' is set to \'Counter\'.</string>
@ -475,6 +476,10 @@
<string name="pref_feed_skip_intro_toast">Skipped first %d seconds</string>
<string name="pref_playback_time_respects_speed_title">Adjust media info to playback speed</string>
<string name="pref_playback_time_respects_speed_sum">Displayed position and duration are adapted to playback speed</string>
<string name="pref_fallback_speed">Fallback speed</string>
<string name="pref_speed_forward">Fast forward speed</string>
<string name="pref_speed_forward_sum">Customize the speed to speed forward when the skip button is clicked</string>
<string name="pref_fallback_speed_sum">Customize the speed when the play button is long pressed</string>
<string name="pref_fast_forward">Fast forward skip time</string>
<string name="pref_fast_forward_sum">Customize the number of seconds to jump forward when the fast forward button is clicked</string>
<string name="pref_rewind">Rewind skip time</string>
@ -724,6 +729,7 @@
<string name="wait_icon" translatable="false">{fa-spinner}</string>
<string name="edit_url_menu">Edit feed URL</string>
<string name="edit_url_confirmation_msg">Changing the RSS address can easily break the playback state and episode listings of the podcast. We do NOT recommend changing it and will NOT provide support if anything goes wrong. This cannot be undone. The broken subscription CANNOT be repaired by simply changing the address back. We suggest creating a backup before continuing.</string>
<string name="edit_fast_forward_speed">Edit fast forward speed</string>
<!-- PodciniSP -->
<string name="sp_apps_importing_feeds_msg">Importing subscriptions from single-purpose apps&#8230;</string>

View File

@ -43,11 +43,19 @@
android:key="prefPlaybackSpeedLauncher"
android:summary="@string/pref_playback_speed_sum"
android:title="@string/playback_speed"/>
<Preference
android:key="prefPlaybackFallbackSpeedLauncher"
android:summary="@string/pref_fallback_speed_sum"
android:title="@string/pref_fallback_speed"/>
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="prefPlaybackTimeRespectsSpeed"
android:summary="@string/pref_playback_time_respects_speed_sum"
android:title="@string/pref_playback_time_respects_speed_title"/>
<Preference
android:key="prefPlaybackSpeedForwardLauncher"
android:summary="@string/pref_speed_forward_sum"
android:title="@string/pref_speed_forward"/>
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="prefStreamOverDownload"

View File

@ -180,4 +180,22 @@
* online episodes list view now better handles icons
* online episodes list view goes back to the online feed view
* the original online feed view activity is stripped and now only preserved for receiving shared feed
* externally shared feed opens in the online feed view fragment
* externally shared feed opens in the online feed view fragment
## 4.5.0
* fixed bug of sort order not stable in feed item list
* in Subscriptions view added sorting of "Unread publication date"
* added preference "Fast Forward Speed" under "Playback" in settings with default value of 0.0, dialog allows setting a float number (capped between 0.0 and 10.0)
* added preference "Fallback Speed" under "Playback" in settings with default value of 0.0, dialog allows setting a float number (capped between 0.0 and 1.5)
* added new ways to manipulate play speed
* the "Skip to next episode" button
* long-press moves to the next episode
* by default, single tap does nothing
* if the user customize "Fast Forward Speed" to a value greater than 0.1, it behaves in the following way:
* single tap during play, the set speed is used to play the current audio
* single tap again, the original play speed resumes
* single tap not during play has no effect
* the Play button
* by default, it behaves the same as usual
* if the user customize "Fallback speed" to a value greater than 0.1, long-press the button during play enters the fallback mode and plays at the set fallback speed, single tap exits the fallback mode

View File

@ -0,0 +1,18 @@
Version 4.5.0 brings several changes:
* fixed bug of sort order not stable in feed item list
* in Subscriptions view added sorting of "Unread publication date"
* added preference "Fast Forward Speed" under "Playback" in settings with default value of 0.0, dialog allows setting a float number (capped between 0.0 and 10.0)
* added preference "Fallback Speed" under "Playback" in settings with default value of 0.0, dialog allows setting a float number (capped between 0.0 and 1.5)
* added new ways to manipulate play speed
* the "Skip to next episode" button
* long-press moves to the next episode
* by default, single tap does nothing
* if the user customize "Fast Forward Speed" to a value greater than 0.1, it behaves in the following way:
* single tap during play, the set speed is used to play the current audio
* single tap again, the original play speed resumes
* single tap not during play has no effect
* the Play button
* by default, it behaves the same as usual
* if the user customize "Fallback speed" to a value greater than 0.1, long-press the button during play enters the fallback mode and plays at the set fallback speed, single tap exits the fallback mode