6.10.0 commit

This commit is contained in:
Xilin Jia 2024-10-10 15:26:59 +01:00
parent 971a45c1d6
commit 9ce9b3f5b6
30 changed files with 369 additions and 419 deletions

View File

@ -12,6 +12,7 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin
[<img src="./images/external/amazon.png" alt="Amazon" height="40">](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13)
[<img src="./images/external/getItOpenapk.png" alt="OpenAPK" height="50">](https://www.openapk.net/podcini/ac.mdiq.podcini/)
#### Podcini.R 6.10 allows creating synthetic podcast and shelving any episdes to any synthetic podcasts
#### Podcini.R version 6.5 as a major step forward brings YouTube contents in the app. Channels can be searched, received from share, subscribed. Since 6.6, podcasts, playlists as well as single media from Youtube and YT Music can be shared to Podcini. For more see the Youtube section below or the changelogs
That means finally: [Nessun dorma](https://www.youtube.com/watch?v=cWc7vYjgnTs)
#### For Podcini to show up on car's HUD with Android Auto, please read AnroidAuto.md for instructions.
@ -29,10 +30,11 @@ Compared to AntennaPod this project:
4. Boasts new UI's including streamlined drawer, subscriptions view and player controller,
5. Supports multiple, virtual and circular play queues associable to any podcast
6. Auto-download is governed by policy and limit settings of individual feed
7. Features synthetic podcasts while supporting channels, playlists, single media from YouTube and YT Music, as well as normal podcasts and plain RSS,
8. Allows adding personal notes and 5-level rating on every episode
9. Offers Readability and Text-to-Speech for RSS contents,s
10. Features `instant sync` across devices without a server.
7. Features synthetic podcasts and allows episodes to be shelved to any synthetic podcast
8. Supports channels, playlists, single media from YouTube and YT Music, as well as normal podcasts and plain RSS,
9. Allows adding personal notes and 5-level rating on every episode
10. Offers Readability and Text-to-Speech for RSS contents,s
11. Features `instant sync` across devices without a server.
The project aims to profit from modern frameworks, improve efficiency and provide more useful and user-friendly features.

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020268
versionName "6.9.3"
versionCode 3020269
versionName "6.10.0"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -39,6 +39,7 @@ import androidx.annotation.OptIn
import androidx.core.app.NotificationManagerCompat
import androidx.documentfile.provider.DocumentFile
import androidx.media3.common.util.UnstableApi
import io.realm.kotlin.ext.isManaged
import kotlinx.coroutines.Job
import java.io.File
import java.util.*
@ -247,14 +248,14 @@ object Episodes {
}
}
@JvmStatic
fun setFavorite(episode: Episode, stat: Boolean?) : Job {
Logd(TAG, "setFavorite called $stat")
return runOnIOScope {
val result = upsert(episode) { it.rating = if (stat ?: !it.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code }
EventFlow.postEvent(FlowEvent.RatingEvent(result, result.rating))
}
}
// @JvmStatic
// fun setFavorite(episode: Episode, stat: Boolean?) : Job {
// Logd(TAG, "setFavorite called $stat")
// return runOnIOScope {
// val result = upsert(episode) { it.rating = if (stat ?: !it.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code }
// EventFlow.postEvent(FlowEvent.RatingEvent(result, result.rating))
// }
// }
fun setRating(episode: Episode, rating: Int) : Job {
Logd(TAG, "setRating called $rating")
@ -283,7 +284,9 @@ object Episodes {
@OptIn(UnstableApi::class)
suspend fun setPlayStateSync(played: Int, resetMediaPosition: Boolean, episode: Episode) : Episode {
Logd(TAG, "setPlayStateSync called played: $played resetMediaPosition: $resetMediaPosition ${episode.title}")
val result = upsert(episode) {
var episode_ = episode
if (!episode.isManaged()) episode_ = realm.query(Episode::class).query("id == $0", episode.id).first().find() ?: episode
val result = upsert(episode_) {
if (played >= PlayState.NEW.code && played <= PlayState.BUILDING.code) it.playState = played
else {
if (it.playState == PlayState.PLAYED.code) it.playState = PlayState.UNPLAYED.code

View File

@ -423,17 +423,11 @@ object Feeds {
var feed = getFeed(feedId, true)
if (feed != null) return feed
feed = Feed()
feed.id = feedId
if (music) feed.title = "YTMusic Syndicate" + if (video) "" else " Audio"
else feed.title = "Youtube Syndicate" + if (video) "" else " Audio"
val name = if (music) "YTMusic Syndicate" + if (video) "" else " Audio"
else "Youtube Syndicate" + if (video) "" else " Audio"
feed = createSynthetic(feedId, name)
feed.type = Feed.FeedType.YOUTUBE.name
feed.hasVideoMedia = video
feed.downloadUrl = null
feed.fileUrl = File(feedfilePath, getFeedfileName(feed)).toString()
feed.preferences = FeedPreferences(feed.id, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, "", "")
feed.preferences!!.keepUpdated = false
feed.preferences!!.queueId = -2L
feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY
upsertBlk(feed) {}
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ADDED))
@ -456,21 +450,35 @@ object Feeds {
EventFlow.postStickyEvent(FlowEvent.FeedUpdatingEvent(false))
}
private fun getMiscSyndicate(): Feed {
var feedId: Long = 11
var feed = getFeed(feedId, true)
if (feed != null) return feed
feed = Feed()
feed.id = feedId
feed.title = "Misc Syndicate"
feed.type = Feed.FeedType.RSS.name
fun createSynthetic(feedId: Long, name: String): Feed {
val feed = Feed()
var feedId_ = feedId
if (feedId_ <= 0) {
var i = 100L
while (true) {
if (getFeed(i++) != null) continue
feedId_ = --i
break
}
}
feed.id = feedId_
feed.title = name
feed.author = "Yours Truly"
feed.downloadUrl = null
feed.fileUrl = File(feedfilePath, getFeedfileName(feed)).toString()
feed.preferences = FeedPreferences(feed.id, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, "", "")
feed.preferences!!.keepUpdated = false
feed.preferences!!.queueId = -2L
// feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY
return feed
}
private fun getMiscSyndicate(): Feed {
val feedId: Long = 11
var feed = getFeed(feedId, true)
if (feed != null) return feed
feed = createSynthetic(feedId, "Misc Syndicate")
feed.type = Feed.FeedType.RSS.name
upsertBlk(feed) {}
EventFlow.postEvent(FlowEvent.FeedListEvent(FlowEvent.FeedListEvent.Action.ADDED))
return feed

View File

@ -1,196 +0,0 @@
package ac.mdiq.podcini.ui.actions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected
import ac.mdiq.podcini.net.sync.SynchronizationSettings.wifiSyncEnabledKey
import ac.mdiq.podcini.net.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.playback.base.InTheatre.curState
import ac.mdiq.podcini.playback.base.InTheatre.writeNoMediaPlaying
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.ACTION_SHUTDOWN_PLAYBACK_SERVICE
import ac.mdiq.podcini.receiver.MediaButtonReceiver
import ac.mdiq.podcini.storage.database.Episodes.setFavorite
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Queues.addToQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.ui.dialog.ShareDialog
import ac.mdiq.podcini.ui.utils.LocalDeleteModal
import ac.mdiq.podcini.util.IntentUtils
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.ShareUtils
import android.view.KeyEvent
import android.view.Menu
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Handles interactions with the FeedItemMenu.
*/
@OptIn(UnstableApi::class)
object EpisodeMenuHandler {
private val TAG: String = EpisodeMenuHandler::class.simpleName ?: "Anonymous"
/**
* This method should be called in the prepare-methods of menus. It changes
* the visibility of the menu items depending on a FeedItem's attributes.
* @param menu An instance of Menu
* @param selectedItem The FeedItem for which the menu is supposed to be prepared
* @return Returns true if selectedItem is not null.
*/
@UnstableApi
fun onPrepareMenu(menu: Menu?, selectedItem: Episode?): Boolean {
if (menu == null || selectedItem == null) return false
val hasMedia = selectedItem.media != null
val isPlaying = hasMedia && InTheatre.isCurMedia(selectedItem.media)
val isInQueue: Boolean = curQueue.contains(selectedItem)
val isLocalFile = hasMedia && selectedItem.feed?.isLocalFeed?:false
val isFavorite: Boolean = selectedItem.isFavorite
setItemVisibility(menu, R.id.skip_episode_item, isPlaying)
setItemVisibility(menu, R.id.remove_from_queue_item, isInQueue)
setItemVisibility(menu, R.id.add_to_queue_item, !isInQueue && selectedItem.media != null)
setItemVisibility(menu, R.id.visit_website_item, !(selectedItem.feed?.isLocalFeed?:false) && ShareUtils.hasLinkToShare(selectedItem))
setItemVisibility(menu, R.id.share_item, !(selectedItem.feed?.isLocalFeed?:false))
setItemVisibility(menu, R.id.mark_read_item, !selectedItem.isPlayed())
setItemVisibility(menu, R.id.mark_unread_item, selectedItem.isPlayed())
setItemVisibility(menu, R.id.reset_position, hasMedia && selectedItem.media?.getPosition() != 0)
// Display proper strings when item has no media
if (hasMedia) {
setItemTitle(menu, R.id.mark_read_item, R.string.mark_read_label)
setItemTitle(menu, R.id.mark_unread_item, R.string.mark_unread_label)
} else {
setItemTitle(menu, R.id.mark_read_item, R.string.mark_read_no_media_label)
setItemTitle(menu, R.id.mark_unread_item, R.string.mark_unread_label_no_media)
}
// setItemVisibility(menu, R.id.add_to_favorites_item, !isFavorite)
// setItemVisibility(menu, R.id.remove_from_favorites_item, isFavorite)
CoroutineScope(Dispatchers.Main).launch {
val fileDownloaded = withContext(Dispatchers.IO) { hasMedia && selectedItem.media?.fileExists() ?: false }
setItemVisibility(menu, R.id.remove_item, fileDownloaded || isLocalFile)
}
return true
}
/**
* Used to set the viability of a menu item.
* This method also does some null-checking so that neither menu nor the menu item are null
* in order to prevent nullpointer exceptions.
* @param menu The menu that should be used
* @param menuId The id of the menu item that will be used
* @param visibility The new visibility status of given menu item
*/
private fun setItemVisibility(menu: Menu?, menuId: Int, visibility: Boolean) {
if (menu == null) return
val item = menu.findItem(menuId)
item?.setVisible(visibility)
}
/**
* This method allows to replace to String of a menu item with a different one.
* @param menu Menu item that should be used
* @param id The id of the string that is going to be replaced.
* @param noMedia The id of the new String that is going to be used.
*/
private fun setItemTitle(menu: Menu, id: Int, noMedia: Int) {
val item = menu.findItem(id)
item?.setTitle(noMedia)
}
/**
* The same method as [.onPrepareMenu], but lets the
* caller also specify a list of menu items that should not be shown.
* @param excludeIds Menu item that should be excluded
* @return true if selectedItem is not null.
*/
@UnstableApi
fun onPrepareMenu(menu: Menu?, selectedItem: Episode?, vararg excludeIds: Int): Boolean {
if (menu == null || selectedItem == null) return false
val rc = onPrepareMenu(menu, selectedItem)
if (rc && excludeIds.isNotEmpty()) {
for (id in excludeIds) setItemVisibility(menu, id, false)
}
return rc
}
/**
* Default menu handling for the given FeedItem.
* A Fragment instance, (rather than the more generic Context), is needed as a parameter
* to support some UI operations, e.g., creating a Snackbar.
*/
fun onMenuItemClicked(fragment: Fragment, menuItemId: Int, selectedItem: Episode): Boolean {
val context = fragment.requireContext()
when (menuItemId) {
R.id.skip_episode_item -> context.sendBroadcast(MediaButtonReceiver.createIntent(context, KeyEvent.KEYCODE_MEDIA_NEXT))
R.id.remove_item -> {
LocalDeleteModal.deleteEpisodesWarnLocal(context, listOf(selectedItem))
}
R.id.mark_read_item -> {
// selectedItem.setPlayed(true)
setPlayState(Episode.PlayState.PLAYED.code, true, selectedItem)
if (selectedItem.feed?.isLocalFeed != true && (isProviderConnected || wifiSyncEnabledKey)) {
val media: EpisodeMedia? = selectedItem.media
// not all items have media, Gpodder only cares about those that do
if (isProviderConnected && media != null) {
val actionPlay: EpisodeAction = EpisodeAction.Builder(selectedItem, EpisodeAction.PLAY)
.currentTimestamp()
.started(media.getDuration() / 1000)
.position(media.getDuration() / 1000)
.total(media.getDuration() / 1000)
.build()
SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, actionPlay)
}
}
}
R.id.mark_unread_item -> {
// selectedItem.setPlayed(false)
setPlayState(Episode.PlayState.UNPLAYED.code, false, selectedItem)
if (isProviderConnected && selectedItem.feed?.isLocalFeed != true && selectedItem.media != null) {
val actionNew: EpisodeAction = EpisodeAction.Builder(selectedItem, EpisodeAction.NEW)
.currentTimestamp()
.build()
SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(context, actionNew)
}
}
R.id.add_to_queue_item -> addToQueue(true, selectedItem)
R.id.remove_from_queue_item -> removeFromQueue(selectedItem)
// R.id.add_to_favorites_item -> setFavorite(selectedItem, true)
// R.id.remove_from_favorites_item -> setFavorite(selectedItem, false)
R.id.reset_position -> {
selectedItem.media?.setPosition(0)
if (curState.curMediaId == (selectedItem.media?.id ?: "")) {
writeNoMediaPlaying()
IntentUtils.sendLocalBroadcast(context, ACTION_SHUTDOWN_PLAYBACK_SERVICE)
}
setPlayState(Episode.PlayState.UNPLAYED.code, true, selectedItem)
}
R.id.visit_website_item -> {
val url = selectedItem.getLinkWithFallback()
if (url != null) IntentUtils.openInBrowser(context, url)
}
R.id.share_item -> {
val shareDialog: ShareDialog = ShareDialog.newInstance(selectedItem)
shareDialog.show((fragment.requireActivity().supportFragmentManager), "ShareEpisodeDialog")
}
else -> {
Logd(TAG, "Unknown menuItemId: $menuItemId")
return false
}
}
// Refresh menu state
return true
}
}

View File

@ -13,7 +13,6 @@ object MenuItemUtils {
* context menu was created from. This assigns the listener to every menu item,
* so that the correct fragment is always called first and can consume the click.
*
*
* Note that Android still calls the onContextItemSelected methods of all fragments
* when the passed listener returns false.
*/

View File

@ -59,7 +59,7 @@ class ShareReceiverActivity : AppCompatActivity() {
CustomTheme(this) {
confirmAddYoutubeEpisode(listOf(sharedUrl!!), showDialog.value, onDismissRequest = {
showDialog.value = false
// finish()
finish()
})
}
}

View File

@ -25,7 +25,6 @@ import ac.mdiq.podcini.preferences.UserPreferences.rewindSecs
import ac.mdiq.podcini.preferences.UserPreferences.setShowRemainTimeSetting
import ac.mdiq.podcini.preferences.UserPreferences.shouldShowRemainingTime
import ac.mdiq.podcini.preferences.UserPreferences.videoPlayMode
import ac.mdiq.podcini.storage.database.Episodes.setFavorite
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia

View File

@ -17,10 +17,9 @@ import ac.mdiq.podcini.storage.database.Queues.removeFromAllQueuesQuiet
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.upsert
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.model.PlayQueue
import ac.mdiq.podcini.storage.model.ShareLog
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.model.Feed.Companion.newId
import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.EpisodeActionButton
@ -248,6 +247,62 @@ fun PutToQueueDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
}
}
@Composable
fun ShelveDialog(selected: List<Episode>, onDismissRequest: () -> Unit) {
val synthetics = realm.query(Feed::class).query("id >= 100 && id <= 1000").find()
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
val scrollState = rememberScrollState()
Column(modifier = Modifier.verticalScroll(scrollState).padding(16.dp), verticalArrangement = Arrangement.spacedBy(1.dp)) {
var removeChecked by remember { mutableStateOf(false) }
var toFeed by remember { mutableStateOf<Feed?>(null) }
for (f in synthetics) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = toFeed == f, onClick = { toFeed = f })
Text(f.title ?: "No title",)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = removeChecked, onCheckedChange = { removeChecked = it })
Text(text = stringResource(R.string.remove_from_current_feed), style = MaterialTheme.typography.bodyLarge.merge(), modifier = Modifier.padding(start = 10.dp))
}
if (toFeed != null) Row {
Spacer(Modifier.weight(1f))
Button(onClick = {
val eList: MutableList<Episode> = mutableListOf()
for (e in selected) {
var e_ = e
if (!removeChecked || (e.feedId != null && e.feedId!! >= 1000L)) {
e_ = realm.copyFromRealm(e)
e_.id = newId()
e_.media?.id = e_.id
} else {
val feed = realm.query(Feed::class).query("id == $0", e_.feedId).first().find()
if (feed != null) {
upsertBlk(feed) {
it.episodes.remove(e_)
}
}
}
upsertBlk(e_) {
it.feed = toFeed
it.feedId = toFeed!!.id
eList.add(it)
}
}
upsertBlk(toFeed!!) {
it.episodes.addAll(eList)
}
onDismissRequest()
}) {
Text("Confirm")
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>, refreshCB: (()->Unit)? = null,
@ -273,6 +328,9 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
var showPutToQueueDialog by remember { mutableStateOf(false) }
if (showPutToQueueDialog) PutToQueueDialog(selected) { showPutToQueueDialog = false }
var showShelveDialog by remember { mutableStateOf(false) }
if (showShelveDialog) ShelveDialog(selected) { showShelveDialog = false }
@Composable
fun EpisodeSpeedDial(modifier: Modifier = Modifier) {
var isExpanded by remember { mutableStateOf(false) }
@ -284,7 +342,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
Logd(TAG, "ic_delete: ${selected.size}")
LocalDeleteModal.deleteEpisodesWarnLocal(activity, selected)
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "")
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "Delete media")
Text(stringResource(id = R.string.delete_episode_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
@ -296,7 +354,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
?.download(activity, episode)
}
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "")
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "Download")
Text(stringResource(id = R.string.download_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
@ -305,7 +363,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
Logd(TAG, "ic_mark_played: ${selected.size}")
setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_mark_played), "")
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_mark_played), "Toggle played state")
Text(stringResource(id = R.string.toggle_played_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
@ -314,7 +372,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
Logd(TAG, "ic_playlist_remove: ${selected.size}")
removeFromQueue(*selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_remove), "")
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_remove), "Remove from active queue")
Text(stringResource(id = R.string.remove_from_queue_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
@ -323,17 +381,25 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
Logd(TAG, "ic_playlist_play: ${selected.size}")
Queues.addToQueue(true, *selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "")
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "Add to active queue")
Text(stringResource(id = R.string.add_to_queue_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "shelve_label: ${selected.size}")
showShelveDialog = true
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.baseline_shelves_24), "Shelve")
Text(stringResource(id = R.string.shelve_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "ic_playlist_play: ${selected.size}")
showPutToQueueDialog = true
// PutToQueueDialog(activity, selected).show()
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "")
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "Add to queue...")
Text(stringResource(id = R.string.put_in_queue_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
@ -342,7 +408,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
showChooseRatingDialog = true
isExpanded = false
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "")
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "Set rating")
Text(stringResource(id = R.string.set_rating_label)) } },
)
if (selected.isNotEmpty() && selected[0].isRemote.value)
@ -367,7 +433,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
}
}
}, verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AddCircle, "")
Icon(Icons.Filled.AddCircle, "Reserve episodes")
Text(stringResource(id = R.string.reserve_episodes_label))
}
}
@ -458,8 +524,10 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) {
val (imgvCover, checkMark) = createRefs()
val imgLoc = ImageResourceUtils.getEpisodeListImageLocation(vm.episode)
Logd(TAG, "imgLoc: $imgLoc")
AsyncImage(model = imgLoc, contentDescription = "imgvCover",
placeholder = painterResource(R.mipmap.ic_launcher),
error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(56.dp).height(56.dp)
.constrainAs(imgvCover) {
top.linkTo(parent.top)
@ -614,39 +682,38 @@ fun confirmAddYoutubeEpisode(sharedUrls: List<String>, showDialog: Boolean, onDi
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(
modifier = Modifier
.wrapContentSize(align = Alignment.Center)
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
var audioOnly by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth()) {
Checkbox(checked = audioOnly, onCheckedChange = { audioOnly = it })
Text(text = stringResource(R.string.pref_video_mode_audio_only), style = MaterialTheme.typography.bodyLarge.merge())
}
Button(onClick = {
CoroutineScope(Dispatchers.IO).launch {
for (url in sharedUrls) {
val log = realm.query(ShareLog::class).query("url == $0", url).first().find()
try {
val info = StreamInfo.getInfo(Vista.getService(0), url)
val episode = episodeFromStreamInfo(info)
addToYoutubeSyndicate(episode, !audioOnly)
if (log != null) upsert(log) { it.status = 1 }
} catch (e: Throwable) {
toastMassege = "Receive share error: ${e.message}"
Log.e(TAG, toastMassege)
if (log != null) upsert(log) { it.details = e.message?: "error" }
withContext(Dispatchers.Main) { showToast = true }
var showComfirmButton by remember { mutableStateOf(true) }
if (showComfirmButton) {
Button(onClick = {
showComfirmButton = false
CoroutineScope(Dispatchers.IO).launch {
for (url in sharedUrls) {
val log = realm.query(ShareLog::class).query("url == $0", url).first().find()
try {
val info = StreamInfo.getInfo(Vista.getService(0), url)
val episode = episodeFromStreamInfo(info)
addToYoutubeSyndicate(episode, !audioOnly)
if (log != null) upsert(log) { it.status = 1 }
} catch (e: Throwable) {
toastMassege = "Receive share error: ${e.message}"
Log.e(TAG, toastMassege)
if (log != null) upsert(log) { it.details = e.message?: "error" }
withContext(Dispatchers.Main) { showToast = true }
}
}
withContext(Dispatchers.Main) { onDismissRequest() }
}
}) {
Text("Confirm")
}
onDismissRequest()
}) {
Text("Confirm")
}
} else CircularProgressIndicator(progress = { 0.6f }, strokeWidth = 4.dp, modifier = Modifier.padding(start = 20.dp, end = 20.dp).width(30.dp).height(30.dp))
}
}
}

View File

@ -82,9 +82,8 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult) {
Row {
ConstraintLayout(modifier = Modifier.width(56.dp).height(56.dp)) {
val (imgvCover, checkMark) = createRefs()
AsyncImage(model = feed.imageUrl,
contentDescription = "imgvCover",
placeholder = painterResource(R.mipmap.ic_launcher),
AsyncImage(model = feed.imageUrl, contentDescription = "imgvCover",
placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(65.dp).height(65.dp).constrainAs(imgvCover) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)

View File

@ -30,9 +30,10 @@ class CustomFeedNameDialog(activity: Activity, private var feed: Feed) {
.setTitle(R.string.rename_feed_label)
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
val newTitle = binding.editText.text.toString()
feed = unmanaged(feed)
feed.setCustomTitle1(newTitle)
feed = upsertBlk(feed) {}
// feed = unmanaged(feed)
feed = upsertBlk(feed) {
it.setCustomTitle1(newTitle)
}
}
.setNeutralButton(R.string.reset, null)
.setNegativeButton(R.string.cancel_label, null)

View File

@ -32,16 +32,14 @@ import ac.mdiq.podcini.storage.utils.ChapterUtils
import ac.mdiq.podcini.storage.utils.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.storage.utils.TimeSpeedConverter
import ac.mdiq.podcini.ui.actions.EpisodeMenuHandler
//import ac.mdiq.podcini.ui.actions.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode
import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter
import ac.mdiq.podcini.ui.compose.ChooseRatingDialog
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.dialog.MediaPlayerErrorDialog
import ac.mdiq.podcini.ui.dialog.SkipPreferenceDialog
import ac.mdiq.podcini.ui.dialog.SleepTimerDialog
import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog
import ac.mdiq.podcini.ui.dialog.*
import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment.EpisodeHomeFragment.Companion.episode
import ac.mdiq.podcini.ui.utils.ShownotesCleaner
import ac.mdiq.podcini.ui.view.ShownotesWebView
import ac.mdiq.podcini.util.EventFlow
@ -231,25 +229,26 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
if (curMedia == null) return
if (playbackService == null) PlaybackServiceStarter(requireContext(), curMedia!!).start()
}
AsyncImage(model = imgLoc, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher),
AsyncImage(model = imgLoc, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(70.dp).height(70.dp).padding(start = 5.dp)
.clickable(onClick = {
Logd(TAG, "icon clicked!")
Logd(TAG, "playerUiFragment was clicked")
val media = curMedia
if (media != null) {
val mediaType = media.getMediaType()
if (mediaType == MediaType.AUDIO || videoPlayMode == VideoMode.AUDIO_ONLY.code || videoMode == VideoMode.AUDIO_ONLY
|| (media is EpisodeMedia && media.episode?.feed?.preferences?.videoModePolicy == VideoMode.AUDIO_ONLY)) {
Logd(TAG, "popping as audio episode")
ensureService()
(activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_EXPANDED)
} else {
Logd(TAG, "popping video activity")
val intent = getPlayerActivityIntent(requireContext(), mediaType)
startActivity(intent)
Logd(TAG, "playerUiFragment icon was clicked")
if (isCollapsed) {
val media = curMedia
if (media != null) {
val mediaType = media.getMediaType()
if (mediaType == MediaType.AUDIO || videoPlayMode == VideoMode.AUDIO_ONLY.code || videoMode == VideoMode.AUDIO_ONLY
|| (media is EpisodeMedia && media.episode?.feed?.preferences?.videoModePolicy == VideoMode.AUDIO_ONLY)) {
Logd(TAG, "popping as audio episode")
ensureService()
(activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_EXPANDED)
} else {
Logd(TAG, "popping video activity")
val intent = getPlayerActivityIntent(requireContext(), mediaType)
startActivity(intent)
}
}
}
} else (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
}))
Spacer(Modifier.weight(0.1f))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -414,7 +413,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
modifier = Modifier.width(36.dp).height(36.dp).padding(end = 10.dp).clickable(onClick = { seekToNextChapter() }))
}
}
AsyncImage(model = imgLoc, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher),
AsyncImage(model = imgLoc, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.fillMaxWidth().padding(start = 32.dp, end = 32.dp, top = 10.dp).clickable(onClick = {
}))
}
@ -730,11 +729,9 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private fun setChapterDividers() {
if (currentMedia == null) return
if (currentMedia!!.getChapters().isNotEmpty()) {
val chapters: List<Chapter> = currentMedia!!.getChapters()
val dividerPos = FloatArray(chapters.size)
for (i in chapters.indices) {
dividerPos[i] = chapters[i].start / curDurationFB.toFloat()
}
@ -929,7 +926,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private fun onRatingEvent(event: FlowEvent.RatingEvent) {
if (curEpisode?.id == event.episode.id) {
rating = event.rating
EpisodeMenuHandler.onPrepareMenu(toolbar.menu, event.episode)
// EpisodeMenuHandler.onPrepareMenu(toolbar.menu, event.episode)
}
}
@ -939,7 +936,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
val isEpisodeMedia = currentMedia is EpisodeMedia
toolbar.menu?.findItem(R.id.open_feed_item)?.setVisible(isEpisodeMedia)
val item = if (isEpisodeMedia) (currentMedia as EpisodeMedia).episodeOrFetch() else null
EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item)
// EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item)
val mediaType = curMedia?.getMediaType()
val notAudioOnly = (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY
@ -955,7 +952,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
override fun onMenuItemClick(menuItem: MenuItem): Boolean {
val media: Playable = curMedia ?: return false
val feedItem = if (media is EpisodeMedia) media.episodeOrFetch() else null
if (feedItem != null && EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, feedItem)) return true
// if (feedItem != null && EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, feedItem)) return true
val itemId = menuItem.itemId
when (itemId) {
@ -988,6 +985,12 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
context.startActivity(intent)
}
}
R.id.share_item -> {
if (currentItem != null) {
val shareDialog: ShareDialog = ShareDialog.newInstance(currentItem!!)
shareDialog.show((requireActivity().supportFragmentManager), "ShareEpisodeDialog")
}
}
else -> return false
}
return true

View File

@ -176,7 +176,7 @@ import java.util.*
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 }
if (item.media != null ) upsert(item) { it.media!!.episode = it }
}
nameEpisodeMap.clear()
for (e in episodes) {

View File

@ -5,12 +5,20 @@ import ac.mdiq.podcini.databinding.EpisodeHomeFragmentBinding
import ac.mdiq.podcini.databinding.EpisodeInfoFragmentBinding
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
import ac.mdiq.podcini.net.sync.SynchronizationSettings.isProviderConnected
import ac.mdiq.podcini.net.sync.SynchronizationSettings.wifiSyncEnabledKey
import ac.mdiq.podcini.net.sync.model.EpisodeAction
import ac.mdiq.podcini.net.sync.queue.SynchronizationQueueSink
import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource
import ac.mdiq.podcini.net.utils.NetworkUtils.isEpisodeHeadDownloadAllowed
import ac.mdiq.podcini.playback.base.InTheatre
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo
import ac.mdiq.podcini.preferences.UsageStatistics
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Queues.addToQueue
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.runOnIOScope
import ac.mdiq.podcini.storage.database.RealmDB.unmanaged
@ -25,11 +33,14 @@ import ac.mdiq.podcini.ui.actions.*
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.ChooseRatingDialog
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.dialog.ShareDialog
import ac.mdiq.podcini.ui.fragment.EpisodeInfoFragment.EpisodeHomeFragment.Companion
import ac.mdiq.podcini.ui.utils.ShownotesCleaner
import ac.mdiq.podcini.ui.utils.ThemeUtils
import ac.mdiq.podcini.ui.view.ShownotesWebView
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.IntentUtils
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev
import android.content.Context
@ -57,6 +68,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@ -110,16 +122,14 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var itemLink by mutableStateOf("")
var hasMedia by mutableStateOf(true)
var rating by mutableStateOf(episode?.rating ?: 0)
var inQueue by mutableStateOf(if (episode != null) curQueue.contains(episode!!) else false)
var isPlayed by mutableStateOf(episode?.isPlayed() ?: false)
private var webviewData by mutableStateOf("")
private lateinit var shownotesCleaner: ShownotesCleaner
private lateinit var toolbar: MaterialToolbar
// private lateinit var webvDescription: ShownotesWebView
// private lateinit var imgvCover: ImageView
// private lateinit var butAction1: ImageView
// private lateinit var butAction2: ImageView
private var actionButton1 by mutableStateOf<EpisodeActionButton?>(null)
private var actionButton2 by mutableStateOf<EpisodeActionButton?>(null)
@ -207,17 +217,49 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Column {
Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) {
val imgLoc = if (episode != null) ImageResourceUtils.getEpisodeListImageLocation(episode!!) else null
AsyncImage(model = imgLoc, contentDescription = "imgvCover", Modifier.width(56.dp).height(56.dp).clickable(onClick = { openPodcast() }))
AsyncImage(model = imgLoc, contentDescription = "imgvCover", error = painterResource(R.mipmap.ic_launcher), modifier = Modifier.width(56.dp).height(56.dp).clickable(onClick = { openPodcast() }))
Column(modifier = Modifier.padding(start = 10.dp)) {
Text(txtvPodcast, color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.clickable { openPodcast() })
Text(txtvTitle, color = textColor, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), maxLines = 5, overflow = TextOverflow.Ellipsis)
Text("$txtvPublished · $txtvDuration · $txtvSize", color = textColor, style = MaterialTheme.typography.bodyMedium)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier.padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.weight(0.4f))
var ratingIconRes = Episode.Rating.fromCode(rating).res
Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.width(15.dp).height(15.dp).clickable(onClick = {
val playedIconRes = if (isPlayed) R.drawable.ic_mark_unplayed else R.drawable.ic_mark_played
Icon(painter = painterResource(playedIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "isPlayed", modifier = Modifier.width(24.dp).height(24.dp)
.clickable(onClick = {
if (isPlayed) {
setPlayState(Episode.PlayState.UNPLAYED.code, false, episode!!)
if (isProviderConnected && episode?.feed?.isLocalFeed != true && episode?.media != null) {
val actionNew: EpisodeAction = EpisodeAction.Builder(episode!!, EpisodeAction.NEW).currentTimestamp().build()
SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(requireContext(), actionNew)
}
} else {
setPlayState(Episode.PlayState.PLAYED.code, true, episode!!)
if (episode?.feed?.isLocalFeed != true && (isProviderConnected || wifiSyncEnabledKey)) {
val media: EpisodeMedia? = episode?.media
// not all items have media, Gpodder only cares about those that do
if (isProviderConnected && media != null) {
val actionPlay: EpisodeAction = EpisodeAction.Builder(episode!!, EpisodeAction.PLAY)
.currentTimestamp()
.started(media.getDuration() / 1000)
.position(media.getDuration() / 1000)
.total(media.getDuration() / 1000)
.build()
SynchronizationQueueSink.enqueueEpisodeActionIfSyncActive(requireContext(), actionPlay)
}
}
}
}))
Spacer(modifier = Modifier.weight(0.2f))
val inQueueIconRes = if (!inQueue && episode?.media != null) R.drawable.ic_playlist_play else R.drawable.ic_playlist_remove
Icon(painter = painterResource(inQueueIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "inQueue", modifier = Modifier.width(24.dp).height(24.dp).clickable(onClick = {
if (inQueue) removeFromQueue(episode!!) else addToQueue(true, episode!!)
}))
Spacer(modifier = Modifier.weight(0.2f))
val ratingIconRes = Episode.Rating.fromCode(rating).res
Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.width(24.dp).height(24.dp).clickable(onClick = {
showChooseRatingDialog = true
}))
Spacer(modifier = Modifier.weight(0.2f))
@ -347,9 +389,22 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
return true
}
R.id.visit_website_item -> {
val url = episode?.getLinkWithFallback()
if (url != null) IntentUtils.openInBrowser(requireContext(), url)
return true
}
R.id.share_item -> {
if (episode != null) {
val shareDialog: ShareDialog = ShareDialog.newInstance(episode!!)
shareDialog.show((requireActivity().supportFragmentManager), "ShareEpisodeDialog")
}
return true
}
else -> {
if (episode == null) return false
return EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, episode!!)
return true
// if (episode == null) return false
// return EpisodeMenuHandler.onMenuItemClicked(this, menuItem.itemId, episode!!)
}
}
}
@ -382,11 +437,11 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
updateAppearance()
}
private fun prepareMenu() {
if (episode!!.media != null) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast)
// these are already available via button1 and button2
else EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item)
}
// private fun prepareMenu() {
// if (episode!!.media != null) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast)
// // these are already available via button1 and button2
// else EpisodeMenuHandler.onPrepareMenu(toolbar.menu, episode, R.id.open_podcast, R.id.mark_read_item, R.id.visit_website_item)
// }
@UnstableApi
private fun updateAppearance() {
@ -394,7 +449,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Logd(TAG, "updateAppearance item is null")
return
}
prepareMenu()
// prepareMenu()
if (episode!!.feed != null) txtvPodcast = episode!!.feed!!.title ?: ""
txtvTitle = episode!!.title ?:""
@ -540,7 +595,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
episode!!.rating = event.rating
rating = episode!!.rating
// episode = event.episode
prepareMenu()
// prepareMenu()
}
}
@ -550,7 +605,8 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
while (i < size) {
val item_ = event.episodes[i]
if (item_.id == episode?.id) {
prepareMenu()
inQueue = curQueue.contains(episode!!)
// prepareMenu()
break
}
i++
@ -584,6 +640,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
if (episode != null) episode = realm.query(Episode::class).query("id == $0", episode!!.id).first().find()
if (episode != null) {
val duration = episode!!.media?.getDuration() ?: Int.MAX_VALUE
Logd(TAG, "description: ${episode?.description}")
@ -603,6 +660,8 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
withContext(Dispatchers.Main) {
// binding.progbarLoading.visibility = View.GONE
rating = episode!!.rating
inQueue = curQueue.contains(episode!!)
isPlayed = episode!!.isPlayed()
onFragmentLoaded()
itemLoaded = true
}

View File

@ -246,8 +246,8 @@ import java.util.concurrent.Semaphore
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
})
AsyncImage(model = feed?.imageUrl?:"", contentDescription = "imgvCover",
Modifier.width(120.dp).height(120.dp).padding(start = 16.dp, end = 16.dp, bottom = 12.dp).constrainAs(imgvCover) {
AsyncImage(model = feed?.imageUrl?:"", contentDescription = "imgvCover", error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(120.dp).height(120.dp).padding(start = 16.dp, end = 16.dp, bottom = 12.dp).constrainAs(imgvCover) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
}.clickable(onClick = {

View File

@ -155,7 +155,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}))
Spacer(modifier = Modifier.weight(0.2f))
Button(onClick = { (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id)) }) {
Text(feed.episodes.size.toString() + " " + stringResource(R.string.episodes_label), color = textColor)
Text(feed.episodes.size.toString() + " " + stringResource(R.string.episodes_label))
}
Spacer(modifier = Modifier.width(15.dp))
}
@ -169,8 +169,8 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
})
AsyncImage(model = feed.imageUrl?:"", contentDescription = "imgvCover",
Modifier.width(120.dp).height(120.dp).padding(start = 16.dp, end = 16.dp, bottom = 12.dp).constrainAs(imgvCover) {
AsyncImage(model = feed.imageUrl?:"", contentDescription = "imgvCover", error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(120.dp).height(120.dp).padding(start = 16.dp, end = 16.dp, bottom = 12.dp).constrainAs(imgvCover) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
}.clickable(onClick = {
@ -240,7 +240,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
val fragment = SearchResultsFragment.newInstance(CombinedSearcher::class.java, "$txtvAuthor podcasts")
(activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE)
}) {
Text(stringResource(R.string.feeds_related_to_author), color = textColor)
Text(stringResource(R.string.feeds_related_to_author))
}
Text(stringResource(R.string.statistics_label), color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp))
val arguments = Bundle()
@ -250,7 +250,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Button({
(activity as MainActivity).loadChildFragment(StatisticsFragment(), TransitionEffect.SLIDE)
}) {
Text(stringResource(R.string.statistics_view_all), color = textColor)
Text(stringResource(R.string.statistics_view_all))
}
}
}
@ -266,7 +266,6 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Logd(TAG, "Language: ${feed.language} Author: ${feed.author}")
Logd(TAG, "URL: ${feed.downloadUrl}")
// TODO: need to generate blurred image for background
refreshToolbarState()
}

View File

@ -10,6 +10,8 @@ import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.EpisodeFilter
import ac.mdiq.podcini.storage.model.EpisodeFilter.Companion.unfiltered
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.PlayQueue
import ac.mdiq.podcini.storage.model.ShareLog
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
@ -136,7 +138,8 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
(activity as MainActivity).loadFragment(FeedEpisodesFragment.TAG, args)
(activity as MainActivity).bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
}) {
AsyncImage(model = f.imageUrl, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), modifier = Modifier.width(40.dp).height(40.dp))
AsyncImage(model = f.imageUrl, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(40.dp).height(40.dp))
Text(f.title?:"No title", color = textColor, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 10.dp))
}
}
@ -246,19 +249,14 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
*/
fun getDatasetStats() {
Logd(TAG, "getNavDrawerData() called")
val numDownloadedItems = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name))
val numItems = getEpisodesCount(unfiltered())
feedCount = getFeedCount()
while (curQueue.name.isEmpty()) runBlocking { delay(100) }
val queueSize = curQueue.episodeIds.size
Logd(TAG, "getDatasetStats: queueSize: $queueSize")
val historyCount = getNumberOfPlayed().toInt()
navMap[QueuesFragment.TAG]?.count = queueSize
navMap[QueuesFragment.TAG]?.count = realm.query(PlayQueue::class).find().sumOf { it.size()}
navMap[SubscriptionsFragment.TAG]?.count = feedCount
navMap[HistoryFragment.TAG]?.count = historyCount
navMap[DownloadsFragment.TAG]?.count = numDownloadedItems
navMap[AllEpisodesFragment.TAG]?.count = numItems
navMap[HistoryFragment.TAG]?.count = getNumberOfPlayed().toInt()
navMap[DownloadsFragment.TAG]?.count = getEpisodesCount(EpisodeFilter(EpisodeFilter.States.downloaded.name))
navMap[AllEpisodesFragment.TAG]?.count = numItems
navMap[SharedLogFragment.TAG]?.count = realm.query(ShareLog::class).count().find().toInt()
}
}
}

View File

@ -84,7 +84,7 @@ class OnlineFeedFragment : Fragment() {
private var autoDownloadChecked by mutableStateOf(false)
private var enableSubscribe by mutableStateOf(true)
private var enableEpisodes by mutableStateOf(true)
private var subButTextRes by mutableIntStateOf(R.string.subscribing_label)
private var subButTextRes by mutableIntStateOf(R.string.subscribe_label)
private val feedId: Long
get() {
@ -339,8 +339,8 @@ class OnlineFeedFragment : Fragment() {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
})
AsyncImage(model = feed?.imageUrl?:"", contentDescription = "coverImage",
Modifier.width(100.dp).height(100.dp).padding(start = 10.dp, end = 16.dp, bottom = 10.dp).constrainAs(coverImage) {
AsyncImage(model = feed?.imageUrl?:"", contentDescription = "coverImage", error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(100.dp).height(100.dp).padding(start = 10.dp, end = 16.dp, bottom = 10.dp).constrainAs(coverImage) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
}.clickable(onClick = {}))
@ -440,7 +440,7 @@ class OnlineFeedFragment : Fragment() {
// }
dli.isDownloadingEpisode(selectedDownloadUrl!!) -> {
enableSubscribe = false
subButTextRes = R.string.subscribing_label
subButTextRes = R.string.subscribe_label
}
feedInFeedlist() -> {
enableSubscribe = true
@ -470,7 +470,7 @@ class OnlineFeedFragment : Fragment() {
}
else -> {
enableSubscribe = true
subButTextRes = R.string.subscribing_label
subButTextRes = R.string.subscribe_label
}
}
}

View File

@ -342,7 +342,7 @@ class QuickDiscoveryFragment : Fragment(), AdapterView.OnItemClickListener {
}
loadToplist(countryCode)
}, ) {
Text(stringResource(id = R.string.retry_label), color = textColor)
Text(stringResource(id = R.string.retry_label))
}
// Text( getString(R.string.search_powered_by, searchProvider!!.name), color = Color.Black, style = MaterialTheme.typography.labelSmall, modifier = Modifier.background(
// Color.LightGray)

View File

@ -112,7 +112,7 @@ class SearchResultsFragment : Fragment() {
if (searchResults.isEmpty()) Text(noResultText, color = textColor, modifier = Modifier.constrainAs(empty) { centerTo(parent) })
if (errorText.isNotEmpty()) Text(errorText, color = textColor, modifier = Modifier.constrainAs(txtvError) { centerTo(parent) })
if (retryQerry.isNotEmpty()) Button(modifier = Modifier.padding(16.dp).constrainAs(butRetry) { top.linkTo(txtvError.bottom)}, onClick = { search(retryQerry) }, ) {
Text(stringResource(id = R.string.retry_label), color = textColor)
Text(stringResource(id = R.string.retry_label))
}
Text( getString(R.string.search_powered_by, searchProvider!!.name), color = Color.Black, style = MaterialTheme.typography.labelSmall, modifier = Modifier.background(Color.LightGray)
.constrainAs(powered) {

View File

@ -160,8 +160,8 @@ class SharedLogFragment : Fragment(), Toolbar.OnMenuItemClickListener {
try {
val result = withContext(Dispatchers.IO) {
Logd(TAG, "getDownloadLog() called")
val dlog = realm.query(ShareLog::class).sort("id", Sort.DESCENDING).find().toMutableList()
realm.copyFromRealm(dlog)
realm.query(ShareLog::class).sort("id", Sort.DESCENDING).find().toMutableList()
// realm.copyFromRealm(dlog)
}
withContext(Dispatchers.Main) {
logs.clear()

View File

@ -3,10 +3,12 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.*
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.playback.base.VideoMode
import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.preferences.UserPreferences.appPrefs
import ac.mdiq.podcini.preferences.fragments.ImportExportPreferencesFragment.*
import ac.mdiq.podcini.storage.database.Feeds.createSynthetic
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.getTags
import ac.mdiq.podcini.storage.database.RealmDB.realm
@ -18,6 +20,7 @@ import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.FeedAutoDeleteOpt
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.compose.Spinner
import ac.mdiq.podcini.ui.dialog.CustomFeedNameDialog
import ac.mdiq.podcini.ui.dialog.FeedSortDialog
import ac.mdiq.podcini.ui.dialog.RemoveFeedDialog
import ac.mdiq.podcini.ui.dialog.TagSettingsDialog
@ -27,6 +30,7 @@ import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatAbbrev
import android.app.Activity
import android.app.Activity.RESULT_OK
import android.app.Dialog
import android.content.ActivityNotFoundException
@ -311,6 +315,18 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
R.id.subscriptions_filter -> FeedFilterDialog.newInstance(FeedFilter(feedsFilter)).show(childFragmentManager, null)
R.id.action_search -> (activity as MainActivity).loadChildFragment(SearchFragment.newInstance())
R.id.subscriptions_sort -> FeedSortDialog().show(childFragmentManager, "FeedSortDialog")
R.id.new_synth -> {
val feed = createSynthetic(0, "")
feed.type = Feed.FeedType.RSS.name
CustomFeedNameDialog(activity as Activity, feed).show()
}
R.id.new_synth_yt -> {
val feed = createSynthetic(0, "")
feed.type = Feed.FeedType.YOUTUBE.name
// feed.hasVideoMedia = video
// feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY
CustomFeedNameDialog(activity as Activity, feed).show()
}
R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext())
R.id.toggle_grid_list -> useGrid = if (useGrid == null) !useGridLayout else !useGrid!!
else -> return false
@ -843,7 +859,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
val textColor = MaterialTheme.colorScheme.onSurface
ConstraintLayout {
val (coverImage, episodeCount, error) = createRefs()
AsyncImage(model = feed.imageUrl, contentDescription = "coverImage", placeholder = painterResource(R.mipmap.ic_launcher),
AsyncImage(model = feed.imageUrl, contentDescription = "coverImage",
placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier
.constrainAs(coverImage) {
top.linkTo(parent.top)
@ -882,7 +899,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Logd(TAG, "toggleSelected: selected: ${selected.size}")
}
Row(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)) {
AsyncImage(model = feed.imageUrl, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher),
AsyncImage(model = feed.imageUrl, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(80.dp).height(80.dp)
.clickable(onClick = {
Logd(TAG, "icon clicked!")

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,2l-5.5,9h11z"/>
<path android:fillColor="@android:color/white" android:pathData="M17.5,17.5m-4.5,0a4.5,4.5 0,1 1,9 0a4.5,4.5 0,1 1,-9 0"/>
<path android:fillColor="@android:color/white" android:pathData="M3,13.5h8v8H3z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,1v2H5V1H3v22h2v-2h14v2h2V1H19zM19,5v6h-6V7H7v4H5V5H19zM17,19v-4h-6v4H5v-6h14v6H17z"/>
</vector>

View File

@ -2,55 +2,6 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res-auto">
<item
android:id="@id/skip_episode_item"
android:title="@string/skip_episode_label"
custom:showAsAction="collapseActionView">
</item>
<item
android:id="@+id/mark_read_item"
custom:showAsAction="always"
android:icon="@drawable/ic_mark_played"
android:title="@string/mark_read_label">
</item>
<item
android:id="@+id/mark_unread_item"
custom:showAsAction="always"
android:icon="@drawable/ic_mark_unplayed"
android:title="@string/mark_unread_label">
</item>
<item
android:id="@+id/add_to_queue_item"
custom:showAsAction="always"
android:icon="@drawable/ic_playlist_play"
android:title="@string/add_to_queue_label">
</item>
<item
android:id="@+id/remove_from_queue_item"
custom:showAsAction="always"
android:icon="@drawable/ic_playlist_remove"
android:title="@string/remove_from_queue_label">
</item>
<!-- <item-->
<!-- android:id="@+id/add_to_favorites_item"-->
<!-- android:icon="@drawable/ic_star_border"-->
<!-- custom:showAsAction="always"-->
<!-- android:title="@string/add_to_favorite_label" />-->
<!-- <item-->
<!-- android:id="@+id/remove_from_favorites_item"-->
<!-- android:icon="@drawable/ic_star"-->
<!-- custom:showAsAction="always"-->
<!-- android:title="@string/remove_from_favorite_label" />-->
<item
android:id="@+id/reset_position"
custom:showAsAction="collapseActionView"
android:title="@string/reset_position">
</item>
<item
android:id="@+id/visit_website_item"
android:icon="@drawable/ic_web"
@ -66,10 +17,4 @@
android:id="@+id/share_notes"
android:title="@string/share_notes_label">
</item>
<item
android:id="@+id/open_podcast"
custom:showAsAction="collapseActionView"
android:title="@string/open_podcast">
</item>
</menu>

View File

@ -18,19 +18,6 @@
custom:showAsAction="always">
</item>
<!-- <item-->
<!-- android:id="@+id/add_to_favorites_item"-->
<!-- android:icon="@drawable/ic_star_border"-->
<!-- android:title="@string/add_to_favorite_label"-->
<!-- custom:showAsAction="always">-->
<!-- </item>-->
<!-- <item-->
<!-- android:id="@+id/remove_from_favorites_item"-->
<!-- android:icon="@drawable/ic_star"-->
<!-- android:title="@string/remove_from_favorite_label"-->
<!-- custom:showAsAction="always">-->
<!-- </item>-->
<item
android:id="@+id/disable_sleeptimer_item"
android:icon="@drawable/ic_sleep_off"

View File

@ -23,6 +23,14 @@
android:title="@string/statistics_label"
android:visible="false"
custom:showAsAction="always" />
<item
android:id="@+id/new_synth"
android:title="@string/new_synth_label"
custom:showAsAction="never" />
<item
android:id="@+id/new_synth_yt"
android:title="@string/new_synth_yt_label"
custom:showAsAction="never" />
<item
android:id="@+id/refresh_item"
android:title="@string/refresh_label"

View File

@ -120,6 +120,8 @@
<string name="error_label">Error</string>
<string name="error_msg_prefix">An error occurred:</string>
<string name="refresh_label">Refresh</string>
<string name="new_synth_label">New synthetic feed</string>
<string name="new_synth_yt_label">New synthetic Youtube</string>
<string name="toggle_grid_list">Toggle grid list</string>
<string name="refreshing_label">Refreshing</string>
<string name="reconcile_label">Reconcile</string>
@ -158,6 +160,8 @@
<string name="put_in_queue_label">Add to queue…</string>
<string name="remove_from_other_queues">Remove from other queues</string>
<string name="remove_from_current_feed">Remove from current feed</string>
<string name="feed_new_episodes_action_nothing">Nothing</string>
<string name="episode_cleanup_never">Never</string>
<string name="episode_cleanup_except_favorite_removal">When not favorited</string>
@ -274,6 +278,7 @@
<item quantity="one">%d episode marked as unplayed.</item>
<item quantity="other">%d episodes marked as unplayed.</item>
</plurals>
<string name="shelve_label">Shelve to synthetic</string>
<string name="add_to_queue_label">Add to active queue</string>
<plurals name="added_to_queue_batch_label">
<item quantity="one">%d episode added to queue.</item>

View File

@ -1,3 +1,20 @@
# 6.10.0
* in Subscriptions, added menu items to create normal or Youtube synthetic feeds for better organization
* added "Shelve to synthetic" in multi-selection menu to move/copy the selected to a synthetic feed
* episodes from normal podcasts can only be copied, while those from synthetic podcasts can be moved
* clicking on the image in Player UI toggles expand and collapse of the player detailed view
* when receiving shared single media from Youtube, wait for episode construction before dismissing the confirm dialog
* fixed Reconcile crash when episode.media is null
* in OnlineFeed, button "Subscribing" is changed to "Subscribe"
* tunes color contrast on some Compose buttons
* in EpisodeInfo, menu items "mark played" and "add to queue" are made as buttons and telltales
* cleaned up menu items handling in EpisodeInfo and AudioPlayer, removed EpisodeMenuHandler
* fixed a bug of episode properties possibly getting overwritten when changing episode play status
* in NavDrawer, the count for Queues is from all queues (previously from curQueue only)
* count of shared logs is shown on NavDrawer
* set app icon as default when cover images are unavailable
# 6.9.3
* fixed app quit issue when repairing a shared item

View File

@ -0,0 +1,16 @@
Version 6.10.0
* in Subscriptions, added menu items to create normal or Youtube synthetic feeds for better organization
* added "Shelve to synthetic" in multi-selection menu to move/copy the selected to a synthetic feed
* episodes from normal podcasts can only be copied, while those from synthetic podcasts can be moved
* clicking on the image in Player UI toggles expand and collapse of the player detailed view
* when receiving shared single media from Youtube, wait for episode construction before dismissing the confirm dialog
* fixed Reconcile crash when episode.media is null
* in OnlineFeed, button "Subscribing" is changed to "Subscribe"
* tunes color contrast on some Compose buttons
* in EpisodeInfo, menu items "mark played" and "add to queue" are made as buttons and telltales
* cleaned up menu items handling in EpisodeInfo and AudioPlayer, removed EpisodeMenuHandler
* fixed a bug of episode properties possibly getting overwritten when changing episode play status
* in NavDrawer, the count for Queues is from all queues (previously from curQueue only)
* count of shared logs is shown on NavDrawer
* set app icon as default when cover images are unavailable