6.2.2 commit
This commit is contained in:
parent
13870cedd2
commit
5a40c6ac23
18
README.md
18
README.md
|
@ -34,7 +34,7 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
|
||||
## Notable new features & enhancements
|
||||
|
||||
### Player
|
||||
### Player and Queues
|
||||
|
||||
* More convenient player control displayed on all pages
|
||||
* Revamped and more efficient expanded player view showing episode description on the front
|
||||
|
@ -59,6 +59,14 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
* easy switches on video player to other video mode or audio only
|
||||
* default video player mode setting in preferences
|
||||
* when video mode is set to audio only, click on image on audio player on a video episode brings up the normal player detailed view
|
||||
* Multiple queues can be used: 5 queues are provided by default, user can rename or add up to 10 queues
|
||||
* on app startup, the most recently updated queue is set to curQueue
|
||||
* any episodes can be easily added/moved to the active or any designated queues
|
||||
* any queue can be associated with any feed for customized playing experience
|
||||
* Every queue is circular: if the final item in queue finished, the first item in queue (if exists) will get played
|
||||
* Every queue has a bin containing past episodes removed from the queue
|
||||
* Episode played from a list other than the queue is now a one-off play, unless the episode is on the active queue, in which case, the next episode in the queue will be played
|
||||
|
||||
|
||||
### Podcast/Episode list
|
||||
|
||||
|
@ -82,11 +90,6 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
* on action bar of FeedEpisodes view there is a direct access to Queue
|
||||
* Long-press filter button in FeedEpisodes view enables/disables filters without changing filter settings
|
||||
* History view shows time of last play, and allows filters and sorts
|
||||
* Multiple queues can be used: 5 queues are provided by default, user can add up to 10 queues
|
||||
* on app startup, the most recently updated queue is set to curQueue
|
||||
* Every queue is circular: if the final item in queue finished, the first item in queue (if exists) will get played
|
||||
* Every queue has a bin containing past episodes removed from the queue
|
||||
|
||||
### Podcast/Episode
|
||||
|
||||
* New share notes menu option on various episode views
|
||||
|
@ -121,11 +124,12 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
|||
* Each feed also has its own download policy (only new episodes, newest episodes, and oldest episodes. "newest episodes" meaning most recent episodes, new or old)
|
||||
* Each feed has its own limit (Episode cache) for number of episodes downloaded, this limit rules in combination of the overall limit for the app.
|
||||
* Auto downloads run feeds or feed refreshes, scheduled or manual
|
||||
* auto download always includes any undownloaded episodes (regardless of feeds) added in the current queue
|
||||
* auto download always includes any undownloaded episodes (regardless of feeds) added in the Default queue
|
||||
* After auto download run, episodes with New status is changed to Unplayed.
|
||||
* auto download feed setting dialog is also changed:
|
||||
* there are now separate dialogs for inclusive and exclusive filters where filter tokens can be specified independently
|
||||
* on exclusive dialog, there are optional check boxes "Exclude episodes shorter than" and "Mark excluded episodes played"
|
||||
* Sleep timer has a new option of "To the end of episode"
|
||||
|
||||
### Security and reliability
|
||||
|
||||
|
|
|
@ -25,17 +25,14 @@ android {
|
|||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
}
|
||||
vectorDrawables.useSupportLibrary false
|
||||
vectorDrawables.generatedDensities = []
|
||||
|
||||
testApplicationId "ac.mdiq.podcini.tests"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
versionCode 3020222
|
||||
versionName "6.2.1"
|
||||
versionCode 3020223
|
||||
versionName "6.2.2"
|
||||
|
||||
applicationId "ac.mdiq.podcini.R"
|
||||
def commit = ""
|
||||
|
|
|
@ -44,7 +44,7 @@ object DownloadRequestCreator {
|
|||
|
||||
Logd(TAG, "Requesting download media from url " + media.downloadUrl)
|
||||
|
||||
val feed = media.episode?.feed
|
||||
val feed = media.episodeOrFetch()?.feed
|
||||
val username = feed?.preferences?.username
|
||||
val password = feed?.preferences?.password
|
||||
|
||||
|
|
|
@ -75,7 +75,8 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
|
|||
@OptIn(UnstableApi::class) override fun cancel(context: Context, media: EpisodeMedia) {
|
||||
Logd(TAG, "starting cancel")
|
||||
// This needs to be done here, not in the worker. Reason: The worker might or might not be running.
|
||||
if (media.episode != null) Episodes.deleteMediaOfEpisode(context, media.episode!!) // Remove partially downloaded file
|
||||
val item_ = media.episodeOrFetch()
|
||||
if (item_ != null) Episodes.deleteMediaOfEpisode(context, item_) // Remove partially downloaded file
|
||||
val tag = WORK_TAG_EPISODE_URL + media.downloadUrl
|
||||
val future: Future<List<WorkInfo>> = WorkManager.getInstance(context).getWorkInfosByTag(tag)
|
||||
|
||||
|
@ -83,8 +84,10 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
|
|||
try {
|
||||
val workInfoList = future.get() // Wait for the completion of the future operation and retrieve the result
|
||||
workInfoList.forEach { workInfo ->
|
||||
// TODO: why cancel so many times??
|
||||
if (workInfo.tags.contains(WORK_DATA_WAS_QUEUED)) {
|
||||
if (media.episode != null) Queues.removeFromQueue(media.episode!!)
|
||||
val item_ = media.episodeOrFetch()
|
||||
if (item_ != null) Queues.removeFromQueue(item_)
|
||||
}
|
||||
}
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(tag)
|
||||
|
@ -202,6 +205,10 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
|
|||
@OptIn(UnstableApi::class)
|
||||
private fun performDownload(media: EpisodeMedia, request: DownloadRequest): Result {
|
||||
Logd(TAG, "starting performDownload")
|
||||
if (request.destination == null) {
|
||||
Log.e(TAG, "performDownload request.destination is null")
|
||||
return Result.failure()
|
||||
}
|
||||
val dest = File(request.destination)
|
||||
if (!dest.exists()) {
|
||||
try {
|
||||
|
@ -338,17 +345,19 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
|
|||
return
|
||||
}
|
||||
// media.setDownloaded modifies played state
|
||||
val broadcastUnreadStateUpdate = media.episode != null && media.episode!!.isNew
|
||||
var item_ = media.episodeOrFetch()
|
||||
val broadcastUnreadStateUpdate = item_?.isNew == true
|
||||
// media.downloaded = true
|
||||
media.setIsDownloaded()
|
||||
Logd(TAG, "media.episode.isNew: ${media.episode?.isNew} ${media.episode?.playState}")
|
||||
item_ = media.episodeOrFetch()
|
||||
Logd(TAG, "media.episode.isNew: ${item_?.isNew} ${item_?.playState}")
|
||||
media.setfileUrlOrNull(request.destination)
|
||||
if (request.destination != null) media.size = File(request.destination).length()
|
||||
media.checkEmbeddedPicture() // enforce check
|
||||
// check if file has chapters
|
||||
if (media.episode != null && media.episode!!.chapters.isEmpty()) media.setChapters(ChapterUtils.loadChaptersFromMediaFile(media, context))
|
||||
if (media.episode?.podcastIndexChapterUrl != null)
|
||||
ChapterUtils.loadChaptersFromUrl(media.episode!!.podcastIndexChapterUrl!!, false)
|
||||
if (item_?.chapters.isNullOrEmpty()) media.setChapters(ChapterUtils.loadChaptersFromMediaFile(media, context))
|
||||
if (item_?.podcastIndexChapterUrl != null)
|
||||
ChapterUtils.loadChaptersFromUrl(item_.podcastIndexChapterUrl!!, false)
|
||||
// Get duration
|
||||
var durationStr: String? = null
|
||||
try {
|
||||
|
@ -364,7 +373,7 @@ class DownloadServiceInterfaceImpl : DownloadServiceInterface() {
|
|||
Log.e(TAG, "Get duration failed", e)
|
||||
media.setDuration(30000)
|
||||
}
|
||||
val item = media.episode
|
||||
val item = media.episodeOrFetch()
|
||||
item?.media = media
|
||||
try {
|
||||
// we've received the media, we don't want to autodownload it again
|
||||
|
|
|
@ -62,9 +62,10 @@ object SynchronizationQueueSink {
|
|||
fun enqueueEpisodePlayedIfSyncActive(context: Context, media: EpisodeMedia, completed: Boolean) {
|
||||
if (!isProviderConnected) return
|
||||
|
||||
if (media.episode?.feed == null || media.episode!!.feed!!.isLocalFeed) return
|
||||
val item_ = media.episodeOrFetch()
|
||||
if (item_?.feed?.isLocalFeed == true) return
|
||||
if (media.startPosition < 0 || (!completed && media.startPosition >= media.getPosition())) return
|
||||
val action = EpisodeAction.Builder(media.episode!!, EpisodeAction.PLAY)
|
||||
val action = EpisodeAction.Builder(item_!!, EpisodeAction.PLAY)
|
||||
.currentTimestamp()
|
||||
.started(media.startPosition / 1000)
|
||||
.position((if (completed) media.getDuration() else media.getPosition()) / 1000)
|
||||
|
|
|
@ -48,7 +48,7 @@ class PlaybackServiceStarter(private val context: Context, private val media: Pl
|
|||
if (media is EpisodeMedia) {
|
||||
curMedia = media
|
||||
// curEpisode = if (media.episode != null) unmanaged(media.episode!!) else null
|
||||
curEpisode = media.episode
|
||||
curEpisode = media.episodeOrFetch()
|
||||
// curMedia = curEpisode?.media
|
||||
} else curMedia = media
|
||||
|
||||
|
|
|
@ -19,20 +19,23 @@ import kotlinx.coroutines.*
|
|||
object InTheatre {
|
||||
val TAG: String = InTheatre::class.simpleName ?: "Anonymous"
|
||||
|
||||
var curIndexInQueue = -1
|
||||
|
||||
var curQueue: PlayQueue // unmanaged
|
||||
|
||||
var curEpisode: Episode? = null // unmanged
|
||||
set(value) {
|
||||
field = value
|
||||
if (curMedia != field?.media) curMedia = field?.media
|
||||
field = if (value != null) unmanaged(value) else null
|
||||
if (field?.media != null && curMedia?.getIdentifier() != field?.media?.getIdentifier()) curMedia = unmanaged(field!!.media!!)
|
||||
}
|
||||
|
||||
var curMedia: Playable? = null // unmanged if EpisodeMedia
|
||||
set(value) {
|
||||
field = if (value != null && value is EpisodeMedia) unmanaged(value) else value
|
||||
if (field is EpisodeMedia) {
|
||||
val media = (field as EpisodeMedia)
|
||||
if (curEpisode != media.episode) curEpisode = media.episode
|
||||
if (value is EpisodeMedia) {
|
||||
field = unmanaged(value)
|
||||
if (value.episode != null && curEpisode?.id != value.episode?.id) curEpisode = unmanaged(value.episode!!)
|
||||
} else {
|
||||
field = value
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -115,7 +118,7 @@ object InTheatre {
|
|||
val mediaId = curState.curMediaId
|
||||
if (mediaId != 0L) {
|
||||
curMedia = getEpisodeMedia(mediaId)
|
||||
if (curEpisode != null) curEpisode = (curMedia as EpisodeMedia).episode
|
||||
if (curEpisode != null) curEpisode = (curMedia as EpisodeMedia).episodeOrFetch()
|
||||
}
|
||||
} else Log.e(TAG, "Could not restore Playable object from preferences")
|
||||
}
|
||||
|
|
|
@ -346,7 +346,8 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
|
|||
if (media != null) {
|
||||
playbackSpeed = curState.curTempSpeed
|
||||
if (playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL && media is EpisodeMedia) {
|
||||
if (media.episode?.feed?.preferences != null) playbackSpeed = media.episode!!.feed!!.preferences!!.playSpeed
|
||||
val prefs_ = media.episodeOrFetch()?.feed?.preferences
|
||||
if (prefs_ != null) playbackSpeed = prefs_.playSpeed
|
||||
}
|
||||
}
|
||||
if (mediaType != null && playbackSpeed == FeedPreferences.SPEED_USE_GLOBAL) playbackSpeed = getPlaybackSpeed(mediaType)
|
||||
|
|
|
@ -6,7 +6,9 @@ import ac.mdiq.podcini.net.download.service.HttpCredentialEncoder
|
|||
import ac.mdiq.podcini.net.download.service.PodciniHttpClient
|
||||
import ac.mdiq.podcini.net.utils.NetworkUtils.wasDownloadBlocked
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerBase
|
||||
import ac.mdiq.podcini.playback.base.MediaPlayerCallback
|
||||
import ac.mdiq.podcini.playback.base.PlayerStatus
|
||||
|
@ -15,11 +17,13 @@ import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
|
|||
import ac.mdiq.podcini.storage.model.EpisodeMedia
|
||||
import ac.mdiq.podcini.storage.model.Playable
|
||||
import ac.mdiq.podcini.storage.model.MediaType
|
||||
import ac.mdiq.podcini.storage.utils.EpisodeUtil
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import ac.mdiq.podcini.util.config.ClientConfig
|
||||
import ac.mdiq.podcini.util.event.EventFlow
|
||||
import ac.mdiq.podcini.util.event.FlowEvent
|
||||
import ac.mdiq.podcini.util.event.FlowEvent.PlayEvent.Action
|
||||
import ac.mdiq.podcini.util.showStackTrace
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
|
@ -225,11 +229,18 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
*/
|
||||
override fun playMediaObject(playable: Playable, stream: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
|
||||
Logd(TAG, "playMediaObject status=$status stream=$stream startWhenPrepared=$startWhenPrepared prepareImmediately=$prepareImmediately forceReset=$forceReset ${playable.getEpisodeTitle()} ")
|
||||
// showStackTrace()
|
||||
if (curMedia != null) {
|
||||
Logd(TAG, "playMediaObject: curMedia exist status=$status")
|
||||
if (!forceReset && curMedia!!.getIdentifier() == prevMedia?.getIdentifier() && status == PlayerStatus.PLAYING) {
|
||||
Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.")
|
||||
return
|
||||
}
|
||||
if (curMedia is EpisodeMedia) {
|
||||
val media_ = curMedia as EpisodeMedia
|
||||
curIndexInQueue = EpisodeUtil.indexOfItemWithId(curQueue.episodes, media_.id)
|
||||
} else curIndexInQueue = -1
|
||||
|
||||
Logd(TAG, "playMediaObject starts new media playable:${playable.getIdentifier()} curMedia:${curMedia!!.getIdentifier()} prevMedia:${prevMedia?.getIdentifier()}")
|
||||
// set temporarily to pause in order to update list with current position
|
||||
if (status == PlayerStatus.PLAYING) {
|
||||
|
@ -241,12 +252,12 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
|
||||
// if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
|
||||
// callback.onPostPlayback(prevMedia, ended = false, skipped = false, true)
|
||||
prevMedia = curMedia
|
||||
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
|
||||
}
|
||||
|
||||
Logd(TAG, "playMediaObject preparing for playable:${playable.getIdentifier()} ${playable.getEpisodeTitle()}")
|
||||
curMedia = playable
|
||||
prevMedia = curMedia
|
||||
this.isStreaming = stream
|
||||
mediaType = curMedia!!.getMediaType()
|
||||
videoSize = null
|
||||
|
@ -264,7 +275,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
if (streamurl != null) {
|
||||
val media = curMedia
|
||||
if (media is EpisodeMedia) {
|
||||
val preferences = media.episode?.feed?.preferences
|
||||
val preferences = media.episodeOrFetch()?.feed?.preferences
|
||||
setDataSource(metadata, streamurl, preferences?.username, preferences?.password)
|
||||
} else setDataSource(metadata, streamurl, null, null)
|
||||
}
|
||||
|
@ -289,6 +300,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
e.printStackTrace()
|
||||
setPlayerStatus(PlayerStatus.ERROR, null)
|
||||
EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -431,8 +443,9 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
var volumeLeft = volumeLeft
|
||||
var volumeRight = volumeRight
|
||||
val playable = curMedia
|
||||
if (playable is EpisodeMedia && playable.episode?.feed?.preferences != null) {
|
||||
val preferences = playable.episode!!.feed!!.preferences!!
|
||||
if (playable is EpisodeMedia) {
|
||||
val preferences = playable.episodeOrFetch()?.feed?.preferences
|
||||
if (preferences != null) {
|
||||
val volumeAdaptionSetting = preferences.volumeAdaptionSetting
|
||||
if (volumeAdaptionSetting != null) {
|
||||
val adaptionFactor = volumeAdaptionSetting.adaptionFactor
|
||||
|
@ -440,6 +453,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
volumeRight *= adaptionFactor
|
||||
}
|
||||
}
|
||||
}
|
||||
if (volumeLeft > 1) {
|
||||
exoPlayer?.volume = 1f
|
||||
loudnessEnhancer?.setEnabled(true)
|
||||
|
@ -532,6 +546,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
|
||||
override fun endPlayback(hasEnded: Boolean, wasSkipped: Boolean, shouldContinue: Boolean, toStoppedState: Boolean) {
|
||||
releaseWifiLockIfNecessary()
|
||||
if (curMedia == null) return
|
||||
|
||||
val isPlaying = status == PlayerStatus.PLAYING
|
||||
// we're relying on the position stored in the Playable object for post-playback processing
|
||||
|
@ -566,7 +581,8 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
|||
else Logd(TAG, "Ignored call to stop: Current player state is: $status")
|
||||
}
|
||||
val hasNext = nextMedia != null
|
||||
callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext)
|
||||
if (currentMedia != null) callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, hasNext)
|
||||
// curMedia = nextMedia
|
||||
}
|
||||
isPlaying -> callback.onPlaybackPause(currentMedia, currentMedia!!.getPosition())
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import ac.mdiq.podcini.net.utils.NetworkUtils.isStreamingAllowed
|
|||
import ac.mdiq.podcini.playback.PlaybackServiceStarter
|
||||
import ac.mdiq.podcini.playback.base.InTheatre
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curEpisode
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curIndexInQueue
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curState
|
||||
|
@ -36,7 +37,6 @@ import ac.mdiq.podcini.storage.database.Episodes.persistEpisode
|
|||
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.removeFromQueueSync
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
|
||||
|
@ -47,6 +47,7 @@ import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_OTHER
|
|||
import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_PAUSED
|
||||
import ac.mdiq.podcini.storage.model.CurrentState.Companion.PLAYER_STATUS_PLAYING
|
||||
import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction
|
||||
import ac.mdiq.podcini.storage.utils.EpisodeUtil
|
||||
import ac.mdiq.podcini.storage.utils.EpisodeUtil.hasAlmostEnded
|
||||
import ac.mdiq.podcini.ui.utils.NotificationUtils
|
||||
import ac.mdiq.podcini.ui.widget.WidgetUpdater.WidgetState
|
||||
|
@ -283,7 +284,7 @@ class PlaybackService : MediaSessionService() {
|
|||
// TODO: test
|
||||
// return
|
||||
}
|
||||
var item = (playable as? EpisodeMedia)?.episode ?: currentitem
|
||||
var item = (playable as? EpisodeMedia)?.episodeOrFetch() ?: currentitem
|
||||
val smartMarkAsPlayed = hasAlmostEnded(playable)
|
||||
if (!ended && smartMarkAsPlayed) Logd(TAG, "smart mark as played")
|
||||
|
||||
|
@ -350,12 +351,17 @@ class PlaybackService : MediaSessionService() {
|
|||
|
||||
override fun getNextInQueue(currentMedia: Playable?): Playable? {
|
||||
Logd(TAG, "call getNextInQueue currentMedia: ${currentMedia?.getEpisodeTitle()}")
|
||||
if (curIndexInQueue < 0) {
|
||||
Logd(TAG, "getNextInQueue(), curMedia is not in curQueue")
|
||||
writeNoMediaPlaying()
|
||||
return null
|
||||
}
|
||||
if (currentMedia !is EpisodeMedia) {
|
||||
Logd(TAG, "getNextInQueue(), but playable not an instance of EpisodeMedia, so not proceeding")
|
||||
writeNoMediaPlaying()
|
||||
return null
|
||||
}
|
||||
val item = currentMedia.episode
|
||||
val item = currentMedia.episodeOrFetch()
|
||||
if (item == null) {
|
||||
Logd(TAG, "getNextInQueue() with EpisodeMedia object whose FeedItem is null")
|
||||
writeNoMediaPlaying()
|
||||
|
@ -367,13 +373,20 @@ class PlaybackService : MediaSessionService() {
|
|||
writeNoMediaPlaying()
|
||||
return null
|
||||
}
|
||||
val i = curQueue.episodes.indexOf(item)
|
||||
var j = 0
|
||||
val i = EpisodeUtil.indexOfItemWithId(curQueue.episodes, item.id)
|
||||
if (i < 0) {
|
||||
if (curIndexInQueue >= 0) {
|
||||
if (curIndexInQueue < curQueue.episodes.size) j = curIndexInQueue
|
||||
else j = curQueue.episodes.size-1
|
||||
}
|
||||
else {
|
||||
Logd(TAG, "getNextInQueue curMedia is not in queue ${item.title}")
|
||||
writeNoMediaPlaying()
|
||||
return null
|
||||
}
|
||||
var j = 0
|
||||
if (i < curQueue.episodes.size-1) j = i+1
|
||||
} else if (i < curQueue.episodes.size-1) j = i+1
|
||||
|
||||
val nextItem = unmanaged(curQueue.episodes[j])
|
||||
if (nextItem.media == null) {
|
||||
Logd(TAG, "getNextInQueue nextItem: $nextItem media is null")
|
||||
|
@ -388,6 +401,7 @@ class PlaybackService : MediaSessionService() {
|
|||
}
|
||||
|
||||
if (!nextItem.media!!.localFileAvailable() && !isStreamingAllowed && isFollowQueue && nextItem.feed != null && !nextItem.feed!!.isLocalFeed) {
|
||||
Logd(TAG, "getNextInQueue nextItem has no local file ${nextItem.title}")
|
||||
displayStreamingNotAllowedNotification(PlaybackServiceStarter(this@PlaybackService, nextItem.media!!).intent)
|
||||
writeNoMediaPlaying()
|
||||
return null
|
||||
|
@ -441,7 +455,7 @@ class PlaybackService : MediaSessionService() {
|
|||
curState.curMediaType = playable.getPlayableType().toLong()
|
||||
curState.curIsVideo = playable.getMediaType() == MediaType.VIDEO
|
||||
if (playable is EpisodeMedia) {
|
||||
val feedId = playable.episode?.feed?.id
|
||||
val feedId = playable.episodeOrFetch()?.feed?.id
|
||||
if (feedId != null) curState.curFeedId = feedId
|
||||
curState.curMediaId = playable.id
|
||||
} else {
|
||||
|
@ -734,7 +748,7 @@ class PlaybackService : MediaSessionService() {
|
|||
}
|
||||
|
||||
private fun skipIntro(playable: Playable) {
|
||||
val item = (playable as? EpisodeMedia)?.episode ?: currentitem ?: return
|
||||
val item = (playable as? EpisodeMedia)?.episodeOrFetch() ?: currentitem ?: return
|
||||
val feed = item.feed
|
||||
val preferences = feed?.preferences
|
||||
|
||||
|
@ -911,7 +925,7 @@ class PlaybackService : MediaSessionService() {
|
|||
|
||||
mPlayer?.playMediaObject(media, stream, startWhenPrepared = true, true)
|
||||
recreateMediaSessionIfNeeded()
|
||||
val episode = (media as? EpisodeMedia)?.episode
|
||||
// val episode = (media as? EpisodeMedia)?.episode
|
||||
// if (curMedia is EpisodeMedia && episode != null) addToQueue(true, episode)
|
||||
}
|
||||
|
||||
|
@ -963,6 +977,7 @@ class PlaybackService : MediaSessionService() {
|
|||
if (event.action == FlowEvent.QueueEvent.Action.REMOVED) {
|
||||
for (e in event.episodes) {
|
||||
if (e.id == curEpisode?.id) {
|
||||
Logd(TAG, "onQueueEvent: ending playback ${curEpisode?.title}")
|
||||
mPlayer?.endPlayback(hasEnded = false, wasSkipped = true, shouldContinue = true, toStoppedState = true)
|
||||
break
|
||||
}
|
||||
|
@ -975,7 +990,7 @@ class PlaybackService : MediaSessionService() {
|
|||
// }
|
||||
|
||||
private fun onFeedPrefsChanged(event: FlowEvent.FeedPrefsChangeEvent) {
|
||||
val item = (curMedia as? EpisodeMedia)?.episode ?: currentitem
|
||||
val item = (curMedia as? EpisodeMedia)?.episodeOrFetch() ?: currentitem
|
||||
if (item?.feed?.id == event.feed.id) {
|
||||
item.feed = null
|
||||
// seems no need to pause??
|
||||
|
@ -1028,7 +1043,7 @@ class PlaybackService : MediaSessionService() {
|
|||
|
||||
private fun skipEndingIfNecessary() {
|
||||
val remainingTime = curDuration - curPosition
|
||||
val item = (curMedia as? EpisodeMedia)?.episode ?: currentitem ?: return
|
||||
val item = (curMedia as? EpisodeMedia)?.episodeOrFetch() ?: currentitem ?: return
|
||||
|
||||
val skipEnd = item.feed?.preferences?.endingSkip?:0
|
||||
val skipEndMS = skipEnd * 1000
|
||||
|
@ -1061,7 +1076,7 @@ class PlaybackService : MediaSessionService() {
|
|||
playable.setLastPlayedTime(System.currentTimeMillis())
|
||||
|
||||
if (playable is EpisodeMedia) {
|
||||
val item = playable.episode
|
||||
val item = playable.episodeOrFetch()
|
||||
if (item != null && item.isNew) item.playState = Episode.UNPLAYED
|
||||
if (playable.startPosition >= 0 && playable.getPosition() > playable.startPosition)
|
||||
playable.playedDuration = (playable.playedDurationWhenStarted + playable.getPosition() - playable.startPosition)
|
||||
|
@ -1249,8 +1264,9 @@ class PlaybackService : MediaSessionService() {
|
|||
fun updateVolumeIfNecessary(mediaPlayer: MediaPlayerBase, feedId: Long, volumeAdaptionSetting: VolumeAdaptionSetting) {
|
||||
val playable = curMedia
|
||||
if (playable is EpisodeMedia) {
|
||||
if (playable.episode?.feed?.id == feedId) {
|
||||
playable.episode!!.feed!!.preferences?.volumeAdaptionSetting = volumeAdaptionSetting
|
||||
val item_ = playable.episodeOrFetch()
|
||||
if (item_?.feed?.id == feedId) {
|
||||
item_.feed!!.preferences?.volumeAdaptionSetting = volumeAdaptionSetting
|
||||
if (MediaPlayerBase.status == PlayerStatus.PLAYING) {
|
||||
mediaPlayer.pause(abandonFocus = false, reinit = false)
|
||||
mediaPlayer.resume()
|
||||
|
|
|
@ -56,8 +56,7 @@ class TaskManager(private val context: Context, private val callback: PSTMCallba
|
|||
*/
|
||||
@get:Synchronized
|
||||
val isSleepTimerActive: Boolean
|
||||
get() = (sleepTimer != null && sleepTimerFuture != null && !sleepTimerFuture!!.isCancelled
|
||||
&& !sleepTimerFuture!!.isDone) && sleepTimer!!.getWaitingTime() > 0
|
||||
get() = sleepTimerFuture?.isCancelled == false && sleepTimerFuture?.isDone == false && (sleepTimer?.getWaitingTime() ?: 0) > 0
|
||||
|
||||
/**
|
||||
* Returns the current sleep timer time or 0 if the sleep timer is not active.
|
||||
|
@ -248,7 +247,7 @@ class TaskManager(private val context: Context, private val callback: PSTMCallba
|
|||
EventFlow.postEvent(FlowEvent.SleepTimerUpdatedEvent.updated(timeLeft))
|
||||
while (timeLeft > 0) {
|
||||
try {
|
||||
Thread.sleep(UPDATE_INTERVAL)
|
||||
Thread.sleep(SLEEP_TIMER_UPDATE_INTERVAL)
|
||||
} catch (e: InterruptedException) {
|
||||
Logd(TAG, "Thread was interrupted while waiting")
|
||||
e.printStackTrace()
|
||||
|
@ -344,16 +343,12 @@ class TaskManager(private val context: Context, private val callback: PSTMCallba
|
|||
|
||||
companion object {
|
||||
private val TAG: String = TaskManager::class.simpleName ?: "Anonymous"
|
||||
/**
|
||||
* Update interval of position saver in milliseconds.
|
||||
*/
|
||||
const val POSITION_SAVER_WAITING_INTERVAL: Int = 5000
|
||||
/**
|
||||
* Notification interval of widget updater in milliseconds.
|
||||
*/
|
||||
const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000
|
||||
|
||||
private const val SCHED_EX_POOL_SIZE = 2
|
||||
private const val UPDATE_INTERVAL = 1000L
|
||||
const val NOTIFICATION_THRESHOLD: Long = 10000
|
||||
|
||||
private const val SLEEP_TIMER_UPDATE_INTERVAL = 10000L // in millisoconds
|
||||
const val POSITION_SAVER_WAITING_INTERVAL: Int = 5000 // in millisoconds
|
||||
const val WIDGET_UPDATER_NOTIFICATION_INTERVAL: Int = 5000 // in millisoconds
|
||||
const val NOTIFICATION_THRESHOLD: Long = 10000 // in millisoconds
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,12 +36,12 @@ object SleepTimerPreferences {
|
|||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setLastTimer(value: String?) {
|
||||
fun setLastTimer(value: String?) { // in minutes
|
||||
prefs!!.edit().putString(Prefs.LastValue.name, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun lastTimerValue(): String? {
|
||||
fun lastTimerValue(): String? { // in minutes
|
||||
return prefs!!.getString(Prefs.LastValue.name, DEFAULT_LAST_TIMER)
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import ac.mdiq.podcini.storage.model.ProxyConfig
|
|||
import ac.mdiq.podcini.storage.utils.FilesUtils
|
||||
import ac.mdiq.podcini.storage.utils.FilesUtils.createNoMediaFile
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
|
@ -18,6 +19,7 @@ import java.net.Proxy
|
|||
* init() or otherwise every public method will throw an Exception
|
||||
* when called.
|
||||
*/
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
object UserPreferences {
|
||||
private val TAG: String = UserPreferences::class.simpleName ?: "Anonymous"
|
||||
|
||||
|
|
|
@ -222,7 +222,7 @@ object Episodes {
|
|||
fun persistEpisodeMedia(media: EpisodeMedia) : Job {
|
||||
Logd(TAG, "persistEpisodeMedia called")
|
||||
return runOnIOScope {
|
||||
var episode = media.episode
|
||||
var episode = media.episodeOrFetch()
|
||||
if (episode != null) {
|
||||
episode.media = media
|
||||
episode = upsert(episode) {}
|
||||
|
|
|
@ -40,7 +40,7 @@ object LogsAndStats {
|
|||
Logd(TAG, "getStatistics called")
|
||||
|
||||
val medias = realm.query(EpisodeMedia::class).find()
|
||||
val groupdMedias = medias.groupBy { it.episode?.feedId ?: 0L }
|
||||
val groupdMedias = medias.groupBy { it.episodeOrFetch()?.feedId ?: 0L }
|
||||
val result = StatisticsResult()
|
||||
result.oldestDate = Long.MAX_VALUE
|
||||
for ((fid, feedMedias) in groupdMedias) {
|
||||
|
@ -56,7 +56,7 @@ object LogsAndStats {
|
|||
feedTotalTime += m.duration
|
||||
if (m.lastPlayedTime in timeFilterFrom..<timeFilterTo) {
|
||||
if (includeMarkedAsPlayed) {
|
||||
if ((m.playbackCompletionTime > 0 && m.playedDuration > 0) || m.episode?.playState == Episode.PLAYED || m.position > 0) {
|
||||
if ((m.playbackCompletionTime > 0 && m.playedDuration > 0) || m.episodeOrFetch()?.playState == Episode.PLAYED || m.position > 0) {
|
||||
episodesStarted += 1
|
||||
feedPlayedTime += m.duration
|
||||
}
|
||||
|
|
|
@ -216,13 +216,14 @@ object Queues {
|
|||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun removeFromAllQueuesSync(vararg episodes: Episode) {
|
||||
Logd(TAG, "removeFromAllQueues called ")
|
||||
Logd(TAG, "removeFromAllQueuesSync called ")
|
||||
val queues = realm.query(PlayQueue::class).find()
|
||||
for (q in queues) {
|
||||
if (q.id != curQueue.id) removeFromQueueSync(q, *episodes)
|
||||
}
|
||||
// ensure curQueue is last updated
|
||||
removeFromQueueSync(curQueue, *episodes)
|
||||
if (curQueue.size() > 0) removeFromQueueSync(curQueue, *episodes)
|
||||
else upsertBlk(curQueue) { it.update() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -232,8 +233,9 @@ object Queues {
|
|||
internal fun removeFromQueueSync(queue_: PlayQueue?, vararg episodes: Episode) {
|
||||
Logd(TAG, "removeFromQueueSync called ")
|
||||
if (episodes.isEmpty()) return
|
||||
|
||||
var queue = queue_ ?: curQueue
|
||||
if (queue.size() == 0) return
|
||||
|
||||
val events: MutableList<FlowEvent.QueueEvent> = ArrayList()
|
||||
val indicesToRemove: MutableList<Int> = mutableListOf()
|
||||
val qItems = queue.episodes.toMutableList()
|
||||
|
@ -269,7 +271,7 @@ object Queues {
|
|||
var idsInQueuesToRemove: MutableSet<Long>
|
||||
val queues = realm.query(PlayQueue::class).find()
|
||||
for (q in queues) {
|
||||
if (q.id == curQueue.id) continue
|
||||
if (q.size() == 0 || q.id == curQueue.id) continue
|
||||
idsInQueuesToRemove = q.episodeIds.intersect(episodeIds.toSet()).toMutableSet()
|
||||
if (idsInQueuesToRemove.isNotEmpty()) {
|
||||
q.idsBinList.removeAll(idsInQueuesToRemove)
|
||||
|
@ -284,6 +286,10 @@ object Queues {
|
|||
}
|
||||
// ensure curQueue is last updated
|
||||
val q = curQueue
|
||||
if (q.size() == 0) {
|
||||
upsert(q) { it.update() }
|
||||
return
|
||||
}
|
||||
idsInQueuesToRemove = q.episodeIds.intersect(episodeIds.toSet()).toMutableSet()
|
||||
if (idsInQueuesToRemove.isNotEmpty()) {
|
||||
q.idsBinList.removeAll(idsInQueuesToRemove)
|
||||
|
@ -374,7 +380,7 @@ object Queues {
|
|||
}
|
||||
private fun getCurrentlyPlayingPosition(queueItems: List<Episode>, currentPlaying: Playable?): Int {
|
||||
if (currentPlaying !is EpisodeMedia) return -1
|
||||
val curPlayingItemId = currentPlaying.episode!!.id
|
||||
val curPlayingItemId = currentPlaying.episodeOrFetch()?.id
|
||||
for (i in queueItems.indices) {
|
||||
if (curPlayingItemId == queueItems[i].id) return i
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@ import io.realm.kotlin.Realm
|
|||
import io.realm.kotlin.RealmConfiguration
|
||||
import io.realm.kotlin.UpdatePolicy
|
||||
import io.realm.kotlin.ext.isManaged
|
||||
import io.realm.kotlin.types.EmbeddedRealmObject
|
||||
import io.realm.kotlin.types.RealmObject
|
||||
import io.realm.kotlin.types.TypedRealmObject
|
||||
import kotlinx.coroutines.*
|
||||
|
@ -19,7 +18,7 @@ import kotlin.coroutines.ContinuationInterceptor
|
|||
object RealmDB {
|
||||
private val TAG: String = RealmDB::class.simpleName ?: "Anonymous"
|
||||
|
||||
private const val SCHEMA_VERSION_NUMBER = 16L
|
||||
private const val SCHEMA_VERSION_NUMBER = 17L
|
||||
|
||||
private val ioScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
package ac.mdiq.podcini.storage.model
|
||||
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.realm
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.update
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
|
||||
import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import io.realm.kotlin.ext.isManaged
|
||||
import io.realm.kotlin.types.EmbeddedRealmObject
|
||||
import io.realm.kotlin.types.annotations.Ignore
|
||||
import io.realm.kotlin.types.annotations.Index
|
||||
|
@ -38,11 +41,11 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
|
||||
@get:JvmName("getDurationProperty")
|
||||
@set:JvmName("setDurationProperty")
|
||||
var duration = 0
|
||||
var duration = 0 // in milliseconds
|
||||
|
||||
@get:JvmName("getPositionProperty")
|
||||
@set:JvmName("setPositionProperty")
|
||||
var position = 0 // Current position in file
|
||||
var position = 0 // Current position in file, in milliseconds
|
||||
|
||||
@get:JvmName("getLastPlayedTimeProperty")
|
||||
@set:JvmName("setLastPlayedTimeProperty")
|
||||
|
@ -58,7 +61,6 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
|
||||
var episode: Episode? = null
|
||||
|
||||
var playbackCompletionTime: Long = 0
|
||||
@Ignore
|
||||
var playbackCompletionDate: Date? = null
|
||||
get() = field?.clone() as? Date
|
||||
|
@ -66,6 +68,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
field = value?.clone() as? Date
|
||||
this.playbackCompletionTime = value?.time ?: 0
|
||||
}
|
||||
var playbackCompletionTime: Long = 0
|
||||
|
||||
var startPosition: Int = -1
|
||||
|
||||
|
@ -76,8 +79,8 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
var hasEmbeddedPicture: Boolean? = null
|
||||
|
||||
/* Used for loading item when restoring from parcel. */
|
||||
var episodeId: Long = 0
|
||||
private set
|
||||
// var episodeId: Long = 0
|
||||
// private set
|
||||
|
||||
@Ignore
|
||||
val isInProgress: Boolean
|
||||
|
@ -116,7 +119,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
}
|
||||
|
||||
fun getHumanReadableIdentifier(): String? {
|
||||
return if (episode?.title != null) episode!!.title else downloadUrl
|
||||
return episode?.title ?: downloadUrl
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -175,7 +178,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
|
||||
override fun setPosition(newPosition: Int) {
|
||||
this.position = newPosition
|
||||
if (newPosition > 0 && episode != null && episode!!.isNew) episode!!.setPlayed(false)
|
||||
if (newPosition > 0 && episode?.isNew == true) episode!!.setPlayed(false)
|
||||
}
|
||||
|
||||
override fun getLastPlayedTime(): Long {
|
||||
|
@ -236,18 +239,6 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
dest.writeLong(lastPlayedTime)
|
||||
}
|
||||
|
||||
// no longer needed
|
||||
// override fun writeToPreferences(prefEditor: SharedPreferences.Editor) {
|
||||
// if (episode == null) prefEditor.putLong(PREF_FEED_ID, 0L)
|
||||
// else {
|
||||
// val f = episode!!.feed
|
||||
// if (f != null) prefEditor.putLong(PREF_FEED_ID, f.id)
|
||||
// else prefEditor.putLong(PREF_FEED_ID, 0L)
|
||||
// }
|
||||
//
|
||||
// prefEditor.putLong(PREF_MEDIA_ID, id)
|
||||
// }
|
||||
|
||||
override fun getEpisodeTitle(): String {
|
||||
return episode?.title ?: episode?.identifyingValue ?: "No title"
|
||||
}
|
||||
|
@ -364,12 +355,22 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
result = 31 * result + startPosition
|
||||
result = 31 * result + playedDurationWhenStarted
|
||||
result = 31 * result + (hasEmbeddedPicture?.hashCode() ?: 0)
|
||||
result = 31 * result + episodeId.hashCode()
|
||||
// result = 31 * result + episodeId.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
fun getTheEpisode(): Episode? {
|
||||
return if (episode != null) episode else realm.query(Episode::class).query("id == $id").first().find()
|
||||
fun episodeOrFetch(): Episode? {
|
||||
return if (episode != null) episode else {
|
||||
var item = realm.query(Episode::class).query("id == $id").first().find()
|
||||
Logd(TAG, "episodeOrFetch warning: episode of media is null: ${id} ${item?.title}")
|
||||
if (item != null) {
|
||||
item = upsertBlk(item) {
|
||||
it.media = this@EpisodeMedia
|
||||
it.media!!.episode = it
|
||||
}
|
||||
}
|
||||
if (item == null || isManaged()) item else unmanaged(item)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -408,7 +409,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
|
|||
Date(inVal.readLong()),
|
||||
inVal.readInt(),
|
||||
inVal.readLong())
|
||||
result.episodeId = itemID
|
||||
// result.episodeId = itemID
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
|
@ -32,5 +32,9 @@ class PlayQueue : RealmObject {
|
|||
updated = Date().time
|
||||
}
|
||||
|
||||
fun size() : Int {
|
||||
return episodeIds.size
|
||||
}
|
||||
|
||||
constructor() {}
|
||||
}
|
|
@ -47,7 +47,7 @@ object ChapterUtils {
|
|||
var chaptersFromDatabase: List<Chapter>? = null
|
||||
var chaptersFromPodcastIndex: List<Chapter>? = null
|
||||
if (playable is EpisodeMedia) {
|
||||
val item = playable.episode
|
||||
val item = playable.episodeOrFetch()
|
||||
if (item != null) {
|
||||
if (item.chapters.isNotEmpty()) chaptersFromDatabase = item.chapters
|
||||
if (!item.podcastIndexChapterUrl.isNullOrEmpty()) chaptersFromPodcastIndex = loadChaptersFromUrl(item.podcastIndexChapterUrl!!, forceRefresh)
|
||||
|
|
|
@ -4,6 +4,7 @@ import ac.mdiq.podcini.storage.model.Episode
|
|||
import ac.mdiq.podcini.storage.model.EpisodeMedia
|
||||
import ac.mdiq.podcini.storage.model.Feed
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.webkit.URLUtil
|
||||
|
@ -12,6 +13,7 @@ import org.apache.commons.io.FilenameUtils
|
|||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
object FilesUtils {
|
||||
private val TAG: String = FilesUtils::class.simpleName ?: "Anonymous"
|
||||
|
||||
|
@ -47,9 +49,9 @@ object FilesUtils {
|
|||
}
|
||||
|
||||
fun getMediafilePath(media: EpisodeMedia): String {
|
||||
val item = media.getTheEpisode() ?: return ""
|
||||
Logd(TAG, "item managed: ${item?.isManaged()}")
|
||||
val title = item?.feed?.title?:return ""
|
||||
val item = media.episodeOrFetch() ?: return ""
|
||||
Logd(TAG, "item managed: ${item.isManaged()}")
|
||||
val title = item.feed?.title?:return ""
|
||||
val mediaPath = (MEDIA_DOWNLOADPATH + FileNameGenerator.generateFileName(title))
|
||||
return getDataFolder(mediaPath).toString() + "/"
|
||||
}
|
||||
|
@ -58,10 +60,8 @@ object FilesUtils {
|
|||
var titleBaseFilename = ""
|
||||
|
||||
// Try to generate the filename by the item title
|
||||
if (media.episode?.title != null) {
|
||||
val title = media.episode!!.title!!
|
||||
titleBaseFilename = FileNameGenerator.generateFileName(title)
|
||||
}
|
||||
val item_ = media.episodeOrFetch()
|
||||
if (item_?.title != null) titleBaseFilename = FileNameGenerator.generateFileName(item_.title!!)
|
||||
|
||||
val urlBaseFilename = URLUtil.guessFileName(media.downloadUrl, null, media.mimeType)
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ object ImageResourceUtils {
|
|||
@JvmStatic
|
||||
fun getFallbackImageLocation(playable: Playable): String? {
|
||||
if (playable is EpisodeMedia) {
|
||||
val item = playable.episode
|
||||
val item = playable.episodeOrFetch()
|
||||
return item?.feed?.imageUrl
|
||||
} else return playable.getImageLocation()
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import ac.mdiq.podcini.preferences.UserPreferences.isStreamOverDownload
|
|||
import ac.mdiq.podcini.storage.model.Episode
|
||||
import ac.mdiq.podcini.storage.model.MediaType
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.isCurrentlyPlaying
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
|
@ -38,7 +39,7 @@ abstract class EpisodeActionButton internal constructor(@JvmField var item: Epis
|
|||
null -> false
|
||||
else -> DownloadServiceInterface.get()?.isDownloadingEpisode(media.downloadUrl!!)?:false
|
||||
}
|
||||
// Logd("ItemActionButton", "forItem: ${episode.feedId} ${episode.feed?.isLocalFeed} ${media.downloaded} ${isCurrentlyPlaying(media)} ${curMedia is EpisodeMedia} ${media.id == (curMedia as? EpisodeMedia)?.id} ${episode.title} ")
|
||||
// Logd("ItemActionButton", "forItem: ${episode.feedId} ${episode.feed?.isLocalFeed} ${media.downloaded} ${isCurrentlyPlaying(media)} ${episode.title} ")
|
||||
return when {
|
||||
media.getMediaType() == MediaType.FLASH -> VisitWebsiteActionButton(episode)
|
||||
isCurrentlyPlaying(media) -> PauseActionButton(episode)
|
||||
|
|
|
@ -57,7 +57,7 @@ class PlayActionButton(item: Episode) : EpisodeActionButton(item) {
|
|||
fun notifyMissingEpisodeMediaFile(context: Context, media: EpisodeMedia) {
|
||||
Logd(TAG, "notifyMissingEpisodeMediaFile called")
|
||||
Log.i(TAG, "The feedmanager was notified about a missing episode. It will update its database now.")
|
||||
val episode = media.episode
|
||||
val episode = media.episodeOrFetch()
|
||||
if (episode != null) {
|
||||
episode.media = media
|
||||
episode.media?.downloaded = false
|
||||
|
|
|
@ -232,7 +232,7 @@ class VideoplayerActivity : CastEnabledActivity() {
|
|||
val hasWebsiteLink = getWebsiteLinkWithFallback(media) != null
|
||||
menu.findItem(R.id.visit_website_item).setVisible(hasWebsiteLink)
|
||||
|
||||
val isItemAndHasLink = isEpisodeMedia && hasLinkToShare((media as EpisodeMedia).episode)
|
||||
val isItemAndHasLink = isEpisodeMedia && hasLinkToShare((media as EpisodeMedia).episodeOrFetch())
|
||||
val isItemHasDownloadLink = isEpisodeMedia && (media as EpisodeMedia?)?.downloadUrl != null
|
||||
menu.findItem(R.id.share_item).setVisible(hasWebsiteLink || isItemAndHasLink || isItemHasDownloadLink)
|
||||
|
||||
|
@ -287,7 +287,7 @@ class VideoplayerActivity : CastEnabledActivity() {
|
|||
// controller == null -> return false
|
||||
else -> {
|
||||
val media = curMedia ?: return false
|
||||
val feedItem = (media as? EpisodeMedia)?.episode
|
||||
val feedItem = (media as? EpisodeMedia)?.episodeOrFetch()
|
||||
when {
|
||||
item.itemId == R.id.add_to_favorites_item && feedItem != null -> {
|
||||
setFavorite(feedItem, true)
|
||||
|
@ -470,7 +470,7 @@ class VideoplayerActivity : CastEnabledActivity() {
|
|||
return when {
|
||||
media == null -> null
|
||||
!media.getWebsiteLink().isNullOrBlank() -> media.getWebsiteLink()
|
||||
media is EpisodeMedia -> media.episode?.getLinkWithFallback()
|
||||
media is EpisodeMedia -> media.episodeOrFetch()?.getLinkWithFallback()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ abstract class EpisodeFilterDialog : BottomSheetDialogFragment() {
|
|||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val layout = inflater.inflate(R.layout.filter_dialog, null, false)
|
||||
val layout = inflater.inflate(R.layout.filter_dialog, container, false)
|
||||
_binding = FilterDialogBinding.bind(layout)
|
||||
rows = binding.filterRows
|
||||
Logd("EpisodeFilterDialog", "fragment onCreateView")
|
||||
|
|
|
@ -46,7 +46,7 @@ class FeedFilterDialog : BottomSheetDialogFragment() {
|
|||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val layout = inflater.inflate(R.layout.filter_dialog, null, false)
|
||||
val layout = inflater.inflate(R.layout.filter_dialog, container, false)
|
||||
_binding = FilterDialogBinding.bind(layout)
|
||||
rows = binding.filterRows
|
||||
Logd("FeedFilterDialog", "fragment onCreateView")
|
||||
|
|
|
@ -2,7 +2,9 @@ package ac.mdiq.podcini.ui.dialog
|
|||
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.databinding.TimeDialogBinding
|
||||
import ac.mdiq.podcini.playback.PlaybackController.Companion.curSpeedMultiplier
|
||||
import ac.mdiq.podcini.playback.PlaybackController.Companion.playbackService
|
||||
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
|
||||
import ac.mdiq.podcini.playback.service.PlaybackService
|
||||
import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnable
|
||||
import ac.mdiq.podcini.preferences.SleepTimerPreferences.autoEnableFrom
|
||||
|
@ -21,6 +23,7 @@ import ac.mdiq.podcini.storage.model.Playable
|
|||
import ac.mdiq.podcini.ui.fragment.SubscriptionsFragment.Companion.TAG
|
||||
import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr
|
||||
import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong
|
||||
import ac.mdiq.podcini.storage.utils.TimeSpeedConverter
|
||||
import ac.mdiq.podcini.util.Logd
|
||||
import ac.mdiq.podcini.util.event.EventFlow
|
||||
import ac.mdiq.podcini.util.event.FlowEvent
|
||||
|
@ -46,19 +49,15 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.*
|
||||
import kotlin.time.DurationUnit
|
||||
|
||||
class SleepTimerDialog : DialogFragment() {
|
||||
private var _binding: TimeDialogBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var etxtTime: EditText
|
||||
private lateinit var timeSetup: LinearLayout
|
||||
private lateinit var timeDisplay: LinearLayout
|
||||
private lateinit var time: TextView
|
||||
private lateinit var chAutoEnable: CheckBox
|
||||
|
||||
@UnstableApi override fun onStart() {
|
||||
|
@ -74,17 +73,13 @@ class SleepTimerDialog : DialogFragment() {
|
|||
@UnstableApi override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
_binding = TimeDialogBinding.inflate(layoutInflater)
|
||||
val content = binding.root
|
||||
// val content = View.inflate(context, R.layout.time_dialog, null)
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
builder.setTitle(R.string.sleep_timer_label)
|
||||
builder.setView(binding.root)
|
||||
builder.setPositiveButton(R.string.close_label, null)
|
||||
|
||||
etxtTime = binding.etxtTime
|
||||
timeSetup = binding.timeSetup
|
||||
timeDisplay = binding.timeDisplay
|
||||
timeDisplay.visibility = View.GONE
|
||||
time = binding.time
|
||||
binding.timeDisplay.visibility = View.GONE
|
||||
val extendSleepFiveMinutesButton = binding.extendSleepFiveMinutesButton
|
||||
extendSleepFiveMinutesButton.text = getString(R.string.extend_sleep_timer_label, 5)
|
||||
val extendSleepTenMinutesButton = binding.extendSleepTenMinutesButton
|
||||
|
@ -101,27 +96,30 @@ class SleepTimerDialog : DialogFragment() {
|
|||
extendSleepTimer((20 * 1000 * 60).toLong())
|
||||
}
|
||||
|
||||
binding.endEpisode.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean ->
|
||||
if (isChecked) etxtTime.visibility = View.GONE
|
||||
else etxtTime.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
etxtTime.setText(lastTimerValue())
|
||||
etxtTime.postDelayed({
|
||||
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(etxtTime, InputMethodManager.SHOW_IMPLICIT)
|
||||
}, 100)
|
||||
|
||||
val cbShakeToReset = binding.cbShakeToReset
|
||||
val cbVibrate = binding.cbVibrate
|
||||
chAutoEnable = binding.chAutoEnable
|
||||
val changeTimesButton = binding.changeTimesButton
|
||||
|
||||
cbShakeToReset.isChecked = shakeToReset()
|
||||
cbVibrate.isChecked = vibrate()
|
||||
binding.cbShakeToReset.isChecked = shakeToReset()
|
||||
binding.cbVibrate.isChecked = vibrate()
|
||||
chAutoEnable.setChecked(autoEnable())
|
||||
changeTimesButton.isEnabled = chAutoEnable.isChecked
|
||||
changeTimesButton.alpha = if (chAutoEnable.isChecked) 1.0f else 0.5f
|
||||
|
||||
cbShakeToReset.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean ->
|
||||
binding.cbShakeToReset.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean ->
|
||||
setShakeToReset(isChecked)
|
||||
}
|
||||
cbVibrate.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> setVibrate(isChecked) }
|
||||
binding.cbVibrate.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> setVibrate(isChecked) }
|
||||
chAutoEnable.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean ->
|
||||
setAutoEnable(isChecked)
|
||||
changeTimesButton.isEnabled = isChecked
|
||||
|
@ -135,23 +133,26 @@ class SleepTimerDialog : DialogFragment() {
|
|||
showTimeRangeDialog(from, to)
|
||||
}
|
||||
|
||||
val disableButton = binding.disableSleeptimerButton
|
||||
disableButton.setOnClickListener {
|
||||
binding.disableSleeptimerButton.setOnClickListener {
|
||||
playbackService?.taskManager?.disableSleepTimer()
|
||||
}
|
||||
val setButton = binding.setSleeptimerButton
|
||||
setButton.setOnClickListener {
|
||||
|
||||
binding.setSleeptimerButton.setOnClickListener {
|
||||
if (!PlaybackService.isRunning) {
|
||||
Snackbar.make(content, R.string.no_media_playing_label, Snackbar.LENGTH_LONG).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
try {
|
||||
val time = etxtTime.getText().toString().toLong()
|
||||
val time = if (binding.endEpisode.isChecked) {
|
||||
val curPosition = curMedia?.getPosition() ?: 0
|
||||
val duration = curMedia?.getDuration() ?: 0
|
||||
val converter = TimeSpeedConverter(curSpeedMultiplier)
|
||||
TimeUnit.MILLISECONDS.toMinutes(converter.convert(max((duration - curPosition).toDouble(), 0.0).toInt()).toLong()) // ms to minutes
|
||||
} else etxtTime.getText().toString().toLong()
|
||||
Logd(TAG, "Sleep timer set: $time")
|
||||
if (time == 0L) throw NumberFormatException("Timer must not be zero")
|
||||
|
||||
setLastTimer(etxtTime.getText().toString())
|
||||
setLastTimer(time.toString())
|
||||
setSleepTimer(timerMillis())
|
||||
|
||||
closeKeyboard(content)
|
||||
} catch (e: NumberFormatException) {
|
||||
e.printStackTrace()
|
||||
|
@ -167,12 +168,12 @@ class SleepTimerDialog : DialogFragment() {
|
|||
super.onDestroyView()
|
||||
}
|
||||
|
||||
fun extendSleepTimer(extendTime: Long) {
|
||||
private fun extendSleepTimer(extendTime: Long) {
|
||||
val timeLeft = playbackService?.taskManager?.sleepTimerTimeLeft ?: Playable.INVALID_TIME.toLong()
|
||||
if (timeLeft != Playable.INVALID_TIME.toLong()) setSleepTimer(timeLeft + extendTime)
|
||||
}
|
||||
|
||||
fun setSleepTimer(time: Long) {
|
||||
private fun setSleepTimer(time: Long) {
|
||||
playbackService?.taskManager?.setSleepTimer(time)
|
||||
}
|
||||
|
||||
|
@ -224,10 +225,10 @@ class SleepTimerDialog : DialogFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
fun timerUpdated(event: FlowEvent.SleepTimerUpdatedEvent) {
|
||||
timeDisplay.visibility = if (event.isOver || event.isCancelled) View.GONE else View.VISIBLE
|
||||
timeSetup.visibility = if (event.isOver || event.isCancelled) View.VISIBLE else View.GONE
|
||||
time.text = getDurationStringLong(event.getTimeLeft().toInt())
|
||||
private fun timerUpdated(event: FlowEvent.SleepTimerUpdatedEvent) {
|
||||
binding.timeDisplay.visibility = if (event.isOver || event.isCancelled) View.GONE else View.VISIBLE
|
||||
binding.timeSetup.visibility = if (event.isOver || event.isCancelled) View.VISIBLE else View.GONE
|
||||
binding.time.text = getDurationStringLong(event.getTimeLeft().toInt())
|
||||
}
|
||||
|
||||
private fun closeKeyboard(content: View) {
|
||||
|
@ -254,7 +255,7 @@ class SleepTimerDialog : DialogFragment() {
|
|||
private val paintSelected = Paint()
|
||||
private val paintText = Paint()
|
||||
private val bounds = RectF()
|
||||
var touching: Int = 0
|
||||
private var touching: Int = 0
|
||||
|
||||
init {
|
||||
setup()
|
||||
|
|
|
@ -261,7 +261,7 @@ import java.util.*
|
|||
Logd(TAG, "setSpeed codeArray: ${codeArray[0]} ${codeArray[1]} ${codeArray[2]}")
|
||||
if (codeArray[2]) UserPreferences.setPlaybackSpeed(speed)
|
||||
if (codeArray[1]) {
|
||||
val episode = (curMedia as? EpisodeMedia)?.episode ?: curEpisode
|
||||
val episode = (curMedia as? EpisodeMedia)?.episodeOrFetch() ?: curEpisode
|
||||
if (episode != null) {
|
||||
var feed = episode.feed
|
||||
if (feed != null) {
|
||||
|
|
|
@ -23,6 +23,7 @@ import ac.mdiq.podcini.preferences.UserPreferences
|
|||
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
|
||||
import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode
|
||||
import ac.mdiq.podcini.receiver.MediaButtonReceiver
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
|
||||
import ac.mdiq.podcini.storage.model.Chapter
|
||||
import ac.mdiq.podcini.storage.model.EpisodeMedia
|
||||
import ac.mdiq.podcini.storage.model.Playable
|
||||
|
@ -229,7 +230,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
|
|||
}
|
||||
}
|
||||
currentMedia = curMedia
|
||||
val item = (currentMedia as? EpisodeMedia)?.episode
|
||||
val item = (currentMedia as? EpisodeMedia)?.episodeOrFetch()
|
||||
if (item != null) playerDetailsFragment?.setItem(item)
|
||||
updateUi()
|
||||
playerUI?.updateUi(currentMedia)
|
||||
|
@ -327,8 +328,8 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
|
|||
|
||||
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
|
||||
// Logd(TAG, "onPlayEvent ${event.episode.title}")
|
||||
val media = event.media
|
||||
if (currentMedia?.getIdentifier() == null || media?.getIdentifier() != currentMedia?.getIdentifier()) {
|
||||
val media = event.media ?: return
|
||||
if (currentMedia?.getIdentifier() == null || media.getIdentifier() != currentMedia?.getIdentifier()) {
|
||||
currentMedia = media
|
||||
playerUI?.updateUi(currentMedia)
|
||||
playerDetailsFragment?.setItem(curEpisode!!)
|
||||
|
@ -430,7 +431,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
|
|||
|
||||
val isEpisodeMedia = currentMedia is EpisodeMedia
|
||||
toolbar.menu?.findItem(R.id.open_feed_item)?.setVisible(isEpisodeMedia)
|
||||
val item = if (isEpisodeMedia) (currentMedia as EpisodeMedia).episode else null
|
||||
val item = if (isEpisodeMedia) (currentMedia as EpisodeMedia).episodeOrFetch() else null
|
||||
EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item)
|
||||
|
||||
val mediaType = curMedia?.getMediaType()
|
||||
|
@ -445,7 +446,7 @@ class AudioPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener, Toolbar
|
|||
|
||||
override fun onMenuItemClick(menuItem: MenuItem): Boolean {
|
||||
val media: Playable = curMedia ?: return false
|
||||
val feedItem = if (media is EpisodeMedia) media.episode else null
|
||||
val feedItem = if (media is EpisodeMedia) media.episodeOrFetch() else null
|
||||
if (feedItem != null && EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, feedItem)) return true
|
||||
|
||||
val itemId = menuItem.itemId
|
||||
|
|
|
@ -359,7 +359,7 @@ import kotlinx.coroutines.withContext
|
|||
}
|
||||
|
||||
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
|
||||
val item = (event.media as? EpisodeMedia)?.episode ?: return
|
||||
val item = (event.media as? EpisodeMedia)?.episodeOrFetch() ?: return
|
||||
val pos = if (curIndex in 0..<episodes.size && event.media.getIdentifier() == episodes[curIndex].media?.getIdentifier() && isCurMedia(episodes[curIndex].media))
|
||||
curIndex else EpisodeUtil.indexOfItemWithId(episodes, item.id)
|
||||
|
||||
|
|
|
@ -194,7 +194,7 @@ class ChaptersFragment : AppCompatDialogFragment() {
|
|||
|
||||
adapter.setMedia(media)
|
||||
(dialog as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL).visibility = View.INVISIBLE
|
||||
if ((media is EpisodeMedia) && !media.episode?.podcastIndexChapterUrl.isNullOrEmpty())
|
||||
if ((media is EpisodeMedia) && !media.episodeOrFetch()?.podcastIndexChapterUrl.isNullOrEmpty())
|
||||
(dialog as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL).visibility = View.VISIBLE
|
||||
|
||||
val positionOfCurrentChapter = getCurrentChapter(media)
|
||||
|
|
|
@ -244,7 +244,8 @@ class DownloadLogFragment : BottomSheetDialogFragment(), OnItemClickListener, To
|
|||
Log.e(TAG, "Could not find feed media for feed id: " + status.feedfileId)
|
||||
return@OnClickListener
|
||||
}
|
||||
if (media.episode != null) DownloadActionButton(media.episode!!).onClick(context)
|
||||
val item_ = media.episodeOrFetch()
|
||||
if (item_ != null) DownloadActionButton(item_).onClick(context)
|
||||
(context as MainActivity).showSnackbarAbovePlayer(R.string.status_downloading_label, Toast.LENGTH_SHORT)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -7,10 +7,12 @@ import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
|
|||
import ac.mdiq.podcini.playback.base.InTheatre.isCurMedia
|
||||
import ac.mdiq.podcini.preferences.UserPreferences
|
||||
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
|
||||
import ac.mdiq.podcini.storage.database.Episodes
|
||||
import ac.mdiq.podcini.storage.database.Episodes.getEpisodes
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.realm
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.upsert
|
||||
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
|
||||
import ac.mdiq.podcini.storage.model.Episode
|
||||
import ac.mdiq.podcini.storage.model.EpisodeFilter
|
||||
|
@ -196,6 +198,13 @@ import java.util.*
|
|||
private val filesRemoved: MutableList<String> = mutableListOf()
|
||||
private fun reconsile() {
|
||||
runOnIOScope {
|
||||
val items = realm.query(Episode::class).query("media.episode == nil").find()
|
||||
Logd(TAG, "number of episode with null backlink: ${items.size}")
|
||||
for (item in items) {
|
||||
upsert(item) {
|
||||
it.media!!.episode = it
|
||||
}
|
||||
}
|
||||
nameEpisodeMap.clear()
|
||||
episodes.forEach { e ->
|
||||
var fileUrl = e.media?.fileUrl ?: return@forEach
|
||||
|
@ -375,7 +384,7 @@ import java.util.*
|
|||
}
|
||||
|
||||
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
|
||||
val item = (event.media as? EpisodeMedia)?.episode ?: return
|
||||
val item = (event.media as? EpisodeMedia)?.episodeOrFetch() ?: return
|
||||
val pos = if (curIndex in 0..<episodes.size && event.media.getIdentifier() == episodes[curIndex].media?.getIdentifier() && isCurMedia(episodes[curIndex].media))
|
||||
curIndex else EpisodeUtil.indexOfItemWithId(episodes, item.id)
|
||||
|
||||
|
@ -439,7 +448,8 @@ import java.util.*
|
|||
for (url in urls) {
|
||||
if (url == null) continue
|
||||
val media = realm.query(EpisodeMedia::class).query("downloadUrl == $0", url).first().find() ?: continue
|
||||
if (media.episode != null) episodes.add(media.episode!!)
|
||||
val item_ = media.episodeOrFetch()
|
||||
if (item_ != null) episodes.add(item_)
|
||||
}
|
||||
return realm.copyFromRealm(episodes)
|
||||
}
|
||||
|
|
|
@ -455,7 +455,7 @@ import java.util.concurrent.Semaphore
|
|||
}
|
||||
|
||||
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
|
||||
val item = (event.media as? EpisodeMedia)?.episode ?: return
|
||||
val item = (event.media as? EpisodeMedia)?.episodeOrFetch() ?: return
|
||||
if (loadItemsRunning) return
|
||||
val pos = if (curIndex in 0..<episodes.size && event.media.getIdentifier() == episodes[curIndex].media?.getIdentifier() && isCurMedia(episodes[curIndex].media))
|
||||
curIndex else EpisodeUtil.indexOfItemWithId(episodes, item.id)
|
||||
|
|
|
@ -239,7 +239,8 @@ import kotlin.math.min
|
|||
val medias = realm.query(EpisodeMedia::class).query("lastPlayedTime > $0 AND lastPlayedTime <= $1", start, end).find()
|
||||
var episodes: MutableList<Episode> = mutableListOf()
|
||||
for (m in medias) {
|
||||
if (m.episode != null) episodes.add(m.episode!!)
|
||||
val item_ = m.episodeOrFetch()
|
||||
if (item_ != null) episodes.add(item_)
|
||||
}
|
||||
getPermutor(sortOrder).reorder(episodes)
|
||||
if (episodes.size > offset) episodes = episodes.subList(offset, min(episodes.size, offset+limit))
|
||||
|
|
|
@ -459,9 +459,7 @@ import kotlin.concurrent.Volatile
|
|||
item.id = 0L
|
||||
item.feed = feed
|
||||
val media = item.media
|
||||
if (media != null) {
|
||||
media.episode = item
|
||||
}
|
||||
media?.episode = item
|
||||
}
|
||||
val fo = updateFeed(requireContext(), feed, false)
|
||||
Logd(TAG, "fo.id: ${fo?.id} feed.id: ${feed.id}")
|
||||
|
|
|
@ -148,7 +148,7 @@ class PlayerDetailsFragment : Fragment() {
|
|||
playable = curMedia
|
||||
if (playable != null && playable is EpisodeMedia) {
|
||||
val episodeMedia = playable as EpisodeMedia
|
||||
currentItem = episodeMedia.episode
|
||||
currentItem = episodeMedia.episodeOrFetch()
|
||||
showHomeText = false
|
||||
homeText = null
|
||||
}
|
||||
|
@ -266,9 +266,9 @@ class PlayerDetailsFragment : Fragment() {
|
|||
when {
|
||||
playable?.getChapters() != null -> chapterControlVisible = playable!!.getChapters().isNotEmpty()
|
||||
playable is EpisodeMedia -> {
|
||||
val fm: EpisodeMedia? = (playable as EpisodeMedia?)
|
||||
val item_ = (playable as EpisodeMedia).episodeOrFetch()
|
||||
// If an item has chapters but they are not loaded yet, still display the button.
|
||||
chapterControlVisible = fm?.episode != null && fm.episode!!.chapters.isNotEmpty()
|
||||
chapterControlVisible = !item_?.chapters.isNullOrEmpty()
|
||||
}
|
||||
}
|
||||
val newVisibility = if (chapterControlVisible) View.VISIBLE else View.GONE
|
||||
|
|
|
@ -130,8 +130,10 @@ import java.util.*
|
|||
|
||||
val queues = realm.query(PlayQueue::class).find()
|
||||
queueNames = queues.map { it.name }.toTypedArray()
|
||||
val spinnerLayout = inflater.inflate(R.layout.queue_title_spinner, null)
|
||||
val spinnerLayout = inflater.inflate(R.layout.queue_title_spinner, toolbar, false)
|
||||
queueSpinner = spinnerLayout.findViewById(R.id.queue_spinner)
|
||||
val params = Toolbar.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
params.gravity = Gravity.CENTER_VERTICAL
|
||||
toolbar.addView(spinnerLayout)
|
||||
spinnerAdaptor = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, queueNames)
|
||||
spinnerAdaptor.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
|
@ -148,7 +150,7 @@ import java.util.*
|
|||
|
||||
(activity as MainActivity).setupToolbarToggle(toolbar, displayUpArrow)
|
||||
toolbar.inflateMenu(R.menu.queue)
|
||||
refreshToolbarState()
|
||||
refreshMenuItems()
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
|
||||
recyclerView = binding.recyclerView
|
||||
|
@ -314,7 +316,7 @@ import java.util.*
|
|||
FlowEvent.QueueEvent.Action.MOVED, FlowEvent.QueueEvent.Action.DELETED_MEDIA -> return
|
||||
}
|
||||
adapter?.updateDragDropEnabled()
|
||||
refreshToolbarState()
|
||||
refreshMenuItems()
|
||||
recyclerView.saveScrollPosition(TAG)
|
||||
refreshInfoBar()
|
||||
}
|
||||
|
@ -407,7 +409,7 @@ import java.util.*
|
|||
}
|
||||
|
||||
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
|
||||
val item = (event.media as? EpisodeMedia)?.episode ?: return
|
||||
val item = (event.media as? EpisodeMedia)?.episodeOrFetch() ?: return
|
||||
val pos = if (curIndex in 0..<queueItems.size && event.media.getIdentifier() == queueItems[curIndex].media?.getIdentifier() && isCurMedia(queueItems[curIndex].media))
|
||||
curIndex else EpisodeUtil.indexOfItemWithId(queueItems, item.id)
|
||||
|
||||
|
@ -423,7 +425,7 @@ import java.util.*
|
|||
if (showBin) return
|
||||
// Logd(TAG, "onPlayerStatusChanged() called with event = [$event]")
|
||||
loadItems(false)
|
||||
refreshToolbarState()
|
||||
refreshMenuItems()
|
||||
}
|
||||
|
||||
private fun onEpisodePlayedEvent(event: FlowEvent.EpisodePlayedEvent) {
|
||||
|
@ -435,7 +437,7 @@ import java.util.*
|
|||
val pos: Int = EpisodeUtil.indexOfItemWithId(queueItems, event.episode.id)
|
||||
if (pos >= 0) queueItems[pos].setPlayed(event.episode.isPlayed())
|
||||
}
|
||||
refreshToolbarState()
|
||||
refreshMenuItems()
|
||||
}
|
||||
|
||||
private fun onFeedPrefsChanged(event: FlowEvent.FeedPrefsChangeEvent) {
|
||||
|
@ -472,12 +474,21 @@ import java.util.*
|
|||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun refreshToolbarState() {
|
||||
val keepSorted: Boolean = isQueueKeepSorted
|
||||
private fun refreshMenuItems() {
|
||||
if (showBin) {
|
||||
toolbar.menu?.findItem(R.id.queue_sort)?.setVisible(false)
|
||||
toolbar.menu?.findItem(R.id.rename_queue)?.setVisible(false)
|
||||
toolbar.menu?.findItem(R.id.add_queue)?.setVisible(false)
|
||||
toolbar.menu?.findItem(R.id.queue_lock)?.setVisible(false)
|
||||
toolbar.menu?.findItem(R.id.action_search)?.setVisible(false)
|
||||
} else {
|
||||
toolbar.menu?.findItem(R.id.action_search)?.setVisible(true)
|
||||
toolbar.menu?.findItem(R.id.queue_sort)?.setVisible(true)
|
||||
toolbar.menu?.findItem(R.id.queue_lock)?.setChecked(isQueueLocked)
|
||||
toolbar.menu?.findItem(R.id.queue_lock)?.setVisible(!keepSorted)
|
||||
toolbar.menu?.findItem(R.id.queue_lock)?.setVisible(!isQueueKeepSorted)
|
||||
toolbar.menu?.findItem(R.id.rename_queue)?.setVisible(curQueue.name != "Default")
|
||||
toolbar.menu?.findItem(R.id.add_queue)?.setVisible(queueNames.size<9)
|
||||
toolbar.menu?.findItem(R.id.add_queue)?.setVisible(queueNames.size < 9)
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
|
@ -485,6 +496,7 @@ import java.util.*
|
|||
when (itemId) {
|
||||
R.id.show_bin -> {
|
||||
showBin = !showBin
|
||||
refreshMenuItems()
|
||||
if (showBin) {
|
||||
item.setIcon(R.drawable.playlist_play)
|
||||
speedDialView.addActionItem(addToQueueActionItem)
|
||||
|
@ -656,7 +668,7 @@ import java.util.*
|
|||
|
||||
@UnstableApi private fun setQueueLocked(locked: Boolean) {
|
||||
isQueueLocked = locked
|
||||
refreshToolbarState()
|
||||
refreshMenuItems()
|
||||
adapter?.updateDragDropEnabled()
|
||||
|
||||
if (queueItems.size == 0) {
|
||||
|
@ -751,7 +763,7 @@ import java.util.*
|
|||
queueItems.clear()
|
||||
if (showBin) {
|
||||
queueItems.addAll(realm.copyFromRealm(realm.query(Episode::class, "id IN $0", curQueue.idsBinList)
|
||||
.find().sortedBy { curQueue.idsBinList.indexOf(it.id) }))
|
||||
.find().sortedByDescending { curQueue.idsBinList.indexOf(it.id) }))
|
||||
} else {
|
||||
curQueue.episodes.clear()
|
||||
curQueue.episodes.addAll(realm.copyFromRealm(realm.query(Episode::class, "id IN $0", curQueue.episodeIds)
|
||||
|
@ -770,7 +782,7 @@ import java.util.*
|
|||
override fun onStartSelectMode() {
|
||||
swipeActions.detach()
|
||||
speedDialView.visibility = View.VISIBLE
|
||||
refreshToolbarState()
|
||||
refreshMenuItems()
|
||||
binding.infoBar.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
|
|
@ -295,7 +295,7 @@ import java.lang.ref.WeakReference
|
|||
}
|
||||
|
||||
private fun onPlaybackPositionEvent(event: FlowEvent.PlaybackPositionEvent) {
|
||||
val item = (event.media as? EpisodeMedia)?.episode ?: return
|
||||
val item = (event.media as? EpisodeMedia)?.episodeOrFetch() ?: return
|
||||
val pos = if (curIndex in 0..<results.size && event.media.getIdentifier() == results[curIndex].media?.getIdentifier() && isCurMedia(results[curIndex].media))
|
||||
curIndex else EpisodeUtil.indexOfItemWithId(results, item.id)
|
||||
|
||||
|
|
|
@ -250,7 +250,7 @@ class VideoEpisodeFragment : Fragment(), OnSeekBarChangeListener {
|
|||
lifecycleScope.launch {
|
||||
try {
|
||||
item = withContext(Dispatchers.IO) {
|
||||
val feedItem = (curMedia as? EpisodeMedia)?.episode
|
||||
val feedItem = (curMedia as? EpisodeMedia)?.episodeOrFetch()
|
||||
if (feedItem != null) {
|
||||
val duration = feedItem.media?.getDuration() ?: Int.MAX_VALUE
|
||||
webviewData = ShownotesCleaner(requireContext()).processShownotes(feedItem.description ?: "", duration)
|
||||
|
|
|
@ -447,7 +447,7 @@ class StatisticsFragment : Fragment() {
|
|||
else {
|
||||
// progress import does not include playedDuration
|
||||
if (includeMarkedAsPlayed) {
|
||||
if (m.playbackCompletionTime > 0 || m.episode?.playState == Episode.PLAYED)
|
||||
if (m.playbackCompletionTime > 0 || m.episodeOrFetch()?.playState == Episode.PLAYED)
|
||||
dur += m.duration
|
||||
else if (m.position > 0) dur += m.position
|
||||
} else dur += m.position
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:id="@+id/queue_title_spinner">
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/queue_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:dropDownWidth="100dp"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:textSize="@dimen/text_size_large"
|
||||
android:textStyle="bold"
|
||||
|
|
|
@ -20,6 +20,12 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/end_episode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/end_episode" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -68,7 +74,8 @@
|
|||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:textSize="32sp"
|
||||
android:textColor="?android:attr/textColorPrimary" />
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
tools:ignore="HardcodedText"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/disableSleeptimerButton"
|
||||
|
|
|
@ -677,9 +677,10 @@
|
|||
<string name="set_sleeptimer_label">Set sleep timer</string>
|
||||
<string name="disable_sleeptimer_label">Disable sleep timer</string>
|
||||
<string name="extend_sleep_timer_label">+%d min</string>
|
||||
<string name="end_episode">To end of episode</string>
|
||||
<string name="sleep_timer_always">Always</string>
|
||||
<string name="sleep_timer_label">Sleep timer</string>
|
||||
<string name="time_dialog_invalid_input">Invalid input, time has to be an integer</string>
|
||||
<string name="time_dialog_invalid_input">Invalid input, time has to be a positive integer</string>
|
||||
<string name="shake_to_reset_label">Shake to reset</string>
|
||||
<string name="timer_vibration_label">Vibrate shortly before end</string>
|
||||
<string name="time_seconds">seconds</string>
|
||||
|
|
|
@ -85,7 +85,7 @@ class VolumeUpdaterTest {
|
|||
|
||||
val feedMedia = mockFeedMedia()
|
||||
Mockito.`when`(curMedia).thenReturn(feedMedia)
|
||||
Mockito.`when`(feedMedia.episode?.feed?.id).thenReturn(FEED_ID + 1)
|
||||
Mockito.`when`(feedMedia.episodeOrFetch()?.feed?.id).thenReturn(FEED_ID + 1)
|
||||
|
||||
// val volumeUpdater = PlaybackService.VolumeUpdater()
|
||||
PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.OFF)
|
||||
|
@ -102,7 +102,7 @@ class VolumeUpdaterTest {
|
|||
|
||||
val feedMedia = mockFeedMedia()
|
||||
Mockito.`when`(curMedia).thenReturn(feedMedia)
|
||||
val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!!
|
||||
val feedPreferences: FeedPreferences = feedMedia.episodeOrFetch()!!.feed!!.preferences!!
|
||||
|
||||
PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION)
|
||||
|
||||
|
@ -121,7 +121,7 @@ class VolumeUpdaterTest {
|
|||
|
||||
val feedMedia = mockFeedMedia()
|
||||
Mockito.`when`(curMedia).thenReturn(feedMedia)
|
||||
val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!!
|
||||
val feedPreferences: FeedPreferences = feedMedia.episodeOrFetch()!!.feed!!.preferences!!
|
||||
|
||||
PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION)
|
||||
|
||||
|
@ -140,7 +140,7 @@ class VolumeUpdaterTest {
|
|||
|
||||
val feedMedia = mockFeedMedia()
|
||||
Mockito.`when`(curMedia).thenReturn(feedMedia)
|
||||
val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!!
|
||||
val feedPreferences: FeedPreferences = feedMedia.episodeOrFetch()!!.feed!!.preferences!!
|
||||
|
||||
PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION)
|
||||
|
||||
|
@ -159,7 +159,7 @@ class VolumeUpdaterTest {
|
|||
|
||||
val feedMedia = mockFeedMedia()
|
||||
Mockito.`when`(curMedia).thenReturn(feedMedia)
|
||||
val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!!
|
||||
val feedPreferences: FeedPreferences = feedMedia.episodeOrFetch()!!.feed!!.preferences!!
|
||||
|
||||
PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION)
|
||||
|
||||
|
@ -178,7 +178,7 @@ class VolumeUpdaterTest {
|
|||
|
||||
val feedMedia = mockFeedMedia()
|
||||
Mockito.`when`(curMedia).thenReturn(feedMedia)
|
||||
val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!!
|
||||
val feedPreferences: FeedPreferences = feedMedia.episodeOrFetch()!!.feed!!.preferences!!
|
||||
|
||||
PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.LIGHT_REDUCTION)
|
||||
|
||||
|
@ -197,7 +197,7 @@ class VolumeUpdaterTest {
|
|||
|
||||
val feedMedia = mockFeedMedia()
|
||||
Mockito.`when`(curMedia).thenReturn(feedMedia)
|
||||
val feedPreferences: FeedPreferences = feedMedia.episode!!.feed!!.preferences!!
|
||||
val feedPreferences: FeedPreferences = feedMedia.episodeOrFetch()!!.feed!!.preferences!!
|
||||
|
||||
PlaybackService.updateVolumeIfNecessary(mediaPlayer!!, FEED_ID, VolumeAdaptionSetting.HEAVY_REDUCTION)
|
||||
|
||||
|
@ -214,7 +214,7 @@ class VolumeUpdaterTest {
|
|||
val feed = Mockito.mock(Feed::class.java)
|
||||
val feedPreferences = Mockito.mock(FeedPreferences::class.java)
|
||||
|
||||
Mockito.`when`(episodeMedia.episode).thenReturn(episode)
|
||||
Mockito.`when`(episodeMedia.episodeOrFetch()).thenReturn(episode)
|
||||
Mockito.`when`(episode.feed).thenReturn(feed)
|
||||
Mockito.`when`(feed.id).thenReturn(FEED_ID)
|
||||
Mockito.`when`(feed.preferences).thenReturn(feedPreferences)
|
||||
|
|
|
@ -511,9 +511,10 @@ class DbWriterTest {
|
|||
@Throws(Exception::class)
|
||||
fun testAddItemToPlaybackHistoryNotPlayedYet() {
|
||||
val media: EpisodeMedia = playbackHistorySetup(null)
|
||||
if (media.episode != null) {
|
||||
val item_ = media.episodeOrFetch()
|
||||
if (item_ != null) {
|
||||
runBlocking {
|
||||
val job = addToHistory(media.episode!!)
|
||||
val job = addToHistory(item_)
|
||||
withTimeout(TIMEOUT * 1000) { job.join() }
|
||||
}
|
||||
}
|
||||
|
@ -527,9 +528,10 @@ class DbWriterTest {
|
|||
val oldDate: Long = 0
|
||||
|
||||
val media: EpisodeMedia = playbackHistorySetup(Date(oldDate))
|
||||
if (media.episode != null) {
|
||||
val item_ = media.episodeOrFetch()
|
||||
if (item_ != null) {
|
||||
runBlocking {
|
||||
val job = addToHistory(media.episode!!)
|
||||
val job = addToHistory(item_)
|
||||
withTimeout(TIMEOUT*1000) { job.join() }
|
||||
}
|
||||
}
|
||||
|
|
11
changelog.md
11
changelog.md
|
@ -1,3 +1,14 @@
|
|||
# 6.2.2
|
||||
|
||||
* added EpisodeMedia null relationship handling
|
||||
* in sleep timer setting, added "to end of episode" option
|
||||
* frequency of sleep timer check is reduced to every 10 seconds (from 1 second)
|
||||
* in Queue bin view, items' order is changed to descending
|
||||
* in Queue bin view, disabled some menu items
|
||||
* refined "remove from queue" operation when an episode ended playing
|
||||
* eliminated the double starts when playing the next episode in queue
|
||||
* re-ensured circular queue
|
||||
|
||||
# 6.2.1
|
||||
|
||||
* likely fixed crash issue in Queue view during download
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
Version 6.2.2 brings several changes:
|
||||
|
||||
* added EpisodeMedia null relationship handling
|
||||
* in sleep timer setting, added "to end of episode" option
|
||||
* frequency of sleep timer check is reduced to every 10 seconds (from 1 second)
|
||||
* in Queue bin view, items' order is changed to descending
|
||||
* in Queue bin view, disabled some menu items
|
||||
* refined "remove from queue" operation when an episode ended playing
|
||||
* eliminated the double starts when playing the next episode in queue
|
||||
* re-ensured circular queue
|
Loading…
Reference in New Issue