From aryanchoudharyk2008 at gmail.com Sun Jan 18 09:05:40 2026 From: aryanchoudharyk2008 at gmail.com (aryan) Date: Sun, 18 Jan 2026 14:35:40 +0530 Subject: [Android] [PATCH] Accessibility improvements for audio/video lists Message-ID: <20260118090541.1307-1-aryanchoudharyk2008@gmail.com> Hello VideoLAN Android Team, I am submitting a patch to improve the TalkBack experience in the media browser and player lists. The Problem: Currently, list items require excessive swiping to access common actions (like "More options"), and the interface does not expose quick actions like "Remove from favorites" or "Seek" directly to accessibility services. The Fix: This patch implements AccessibilityDelegateCompat to: 1. Add Custom Accessibility Actions (Seek, Favorite, Play Next, etc.) directly to the list items. 2. Hide the redundant "More" button from TalkBack focus (IMPORTANT_FOR_ACCESSIBILITY_NO) to reduce clutter. 3. Dynamically update action labels (e.g., swapping "Add to favorites" with "Remove from favorites" based on state). I am submitting this via the mailing list as I am unable to create an account on code.videolan.org at the moment. Best regards, Aryan --- .../resources/src/main/res/values/ids.xml | 24 ++++ .../vlc/gui/audio/AudioBrowserAdapter.kt | 126 ++++++++++++++++ .../vlc/gui/browser/BaseBrowserAdapter.kt | 127 ++++++++++++++++ .../vlc/gui/video/VideoGridFragment.kt | 4 + .../vlc/gui/video/VideoListAdapter.kt | 136 ++++++++++++++++++ 5 files changed, 417 insertions(+) diff --git a/application/resources/src/main/res/values/ids.xml b/application/resources/src/main/res/values/ids.xml index 5ff0ec18e..59ad7b7ab 100644 --- a/application/resources/src/main/res/values/ids.xml +++ b/application/resources/src/main/res/values/ids.xml @@ -25,4 +25,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioBrowserAdapter.kt b/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioBrowserAdapter.kt index a8be20509..e46a8d4f4 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioBrowserAdapter.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioBrowserAdapter.kt @@ -41,9 +41,11 @@ import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.videolan.libvlc.util.AndroidUtil +import org.videolan.medialibrary.interfaces.media.Album import org.videolan.medialibrary.interfaces.media.Artist import org.videolan.medialibrary.interfaces.media.Genre import org.videolan.medialibrary.interfaces.media.MediaWrapper +import org.videolan.medialibrary.interfaces.media.Playlist import org.videolan.medialibrary.media.MediaLibraryItem import org.videolan.medialibrary.media.MediaLibraryItem.FLAG_SELECTED import org.videolan.resources.AppContextProvider @@ -73,6 +75,22 @@ import org.videolan.vlc.util.isOTG import org.videolan.vlc.util.isSD import org.videolan.vlc.util.isSchemeSMB import org.videolan.vlc.viewmodels.PlaylistModel +import android.os.Bundle +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import org.videolan.vlc.media.MediaUtils +import org.videolan.vlc.gui.helpers.UiTools +import org.videolan.vlc.gui.helpers.UiTools.addToPlaylist +import org.videolan.vlc.gui.helpers.UiTools.showMediaInfo +import org.videolan.vlc.util.share +import androidx.lifecycle.lifecycleScope +import org.videolan.vlc.util.ContextOption +import org.videolan.vlc.gui.dialogs.CtxActionReceiver +import org.videolan.vlc.media.getAll +import org.videolan.tools.retrieveParent private const val SHOW_IN_LIST = -1 @@ -314,6 +332,7 @@ open class AudioBrowserAdapter @JvmOverloads constructor( } } binding.imageWidth = listImageWidth + binding.itemMore.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO } override fun selectView(selected: Boolean) { @@ -355,6 +374,7 @@ open class AudioBrowserAdapter @JvmOverloads constructor( } binding.imageWidth = cardSize binding.container.layoutParams.width = cardSize + binding.itemMore.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO } @@ -384,6 +404,112 @@ open class AudioBrowserAdapter @JvmOverloads constructor( @TargetApi(Build.VERSION_CODES.M) abstract inner class AbstractMediaItemViewHolder(binding: T) : SelectorViewHolder(binding), MarqueeViewHolder { + init { + ViewCompat.setAccessibilityDelegate(itemView, object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(host, info) + val position = layoutPosition + if (!isPositionValid(position)) return + val item = getItem(position) ?: return + val context = itemView.context + + if (item.isFavorite) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_remove_favorite, context.getString(R.string.favorites_remove))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_favorite, context.getString(R.string.favorites_add))) + } + + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_append, context.getString(R.string.append))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_next, context.getString(R.string.insert_next))) + + if (item is MediaWrapper || item is Playlist) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_shortcut, context.getString(R.string.create_shortcut))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_rename, context.getString(R.string.rename))) + } + + if (item is MediaWrapper) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_info, context.getString(R.string.info))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_share, context.getString(R.string.share))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_set_ringtone, context.getString(R.string.set_song))) + if (item.uri.retrieveParent() != null) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_folder, context.getString(R.string.go_to_folder))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_album, context.getString(R.string.go_to_album))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_artist, context.getString(R.string.go_to_artist))) + } else if (item is Album || item is Artist || item is Genre || item is Playlist) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + if (item !is Genre) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + } + if (item is Album) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_artist, context.getString(R.string.go_to_artist))) + } + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_open_context_menu, context.getString(R.string.more_actions))) + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + val position = layoutPosition + if (!isPositionValid(position)) return super.performAccessibilityAction(host, action, args) + val item = getItem(position) ?: return super.performAccessibilityAction(host, action, args) + + val option = when (action) { + R.id.accessibility_action_append -> ContextOption.CTX_APPEND + R.id.accessibility_action_play_next -> ContextOption.CTX_PLAY_NEXT + R.id.accessibility_action_info -> ContextOption.CTX_INFORMATION + R.id.accessibility_action_share -> ContextOption.CTX_SHARE + R.id.accessibility_action_add_to_playlist -> ContextOption.CTX_ADD_TO_PLAYLIST + R.id.accessibility_action_delete -> ContextOption.CTX_DELETE + R.id.accessibility_action_set_ringtone -> ContextOption.CTX_SET_RINGTONE + R.id.accessibility_action_play_all -> ContextOption.CTX_PLAY_ALL + R.id.accessibility_action_add_favorite -> ContextOption.CTX_FAV_ADD + R.id.accessibility_action_remove_favorite -> ContextOption.CTX_FAV_REMOVE + R.id.accessibility_action_add_shortcut -> ContextOption.CTX_ADD_SHORTCUT + R.id.accessibility_action_go_to_folder -> ContextOption.CTX_GO_TO_FOLDER + R.id.accessibility_action_rename -> ContextOption.CTX_RENAME + R.id.accessibility_action_go_to_album -> ContextOption.CTX_GO_TO_ALBUM + R.id.accessibility_action_go_to_artist -> ContextOption.CTX_GO_TO_ARTIST + R.id.accessibility_action_open_context_menu -> { + onMoreClick(host) + return true + } + else -> null + } + + if (option != null && eventsHandler is CtxActionReceiver) { + eventsHandler.onCtxAction(position, option) + return true + } + + return super.performAccessibilityAction(host, action, args) + } + }) + } + val canBeReordered: Boolean get() = reorderable && !stopReorder diff --git a/application/vlc-android/src/org/videolan/vlc/gui/browser/BaseBrowserAdapter.kt b/application/vlc-android/src/org/videolan/vlc/gui/browser/BaseBrowserAdapter.kt index 96e4803e1..e949db439 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/browser/BaseBrowserAdapter.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/browser/BaseBrowserAdapter.kt @@ -55,6 +55,21 @@ import org.videolan.vlc.gui.view.MiniVisualizer import org.videolan.vlc.util.LifecycleAwareScheduler import org.videolan.vlc.util.getDescriptionSpan import org.videolan.vlc.viewmodels.PlaylistModel +import android.os.Bundle +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import org.videolan.vlc.media.MediaUtils +import org.videolan.vlc.gui.helpers.UiTools +import org.videolan.vlc.gui.helpers.UiTools.addToPlaylist +import org.videolan.vlc.gui.helpers.UiTools.showMediaInfo +import org.videolan.vlc.util.share +import androidx.lifecycle.lifecycleScope +import org.videolan.vlc.util.ContextOption +import org.videolan.vlc.gui.dialogs.CtxActionReceiver +import org.videolan.tools.retrieveParent const val UPDATE_PROGRESS = "update_progress" open class BaseBrowserAdapter(val browserContainer: BrowserContainer, var sort:Int = Medialibrary.SORT_FILENAME, var asc:Boolean = true, val forMain:Boolean = true) : DiffUtilAdapter>(), MultiSelectAdapter, FastScroller.SeparatedAdapter { @@ -275,6 +290,118 @@ open class BaseBrowserAdapter(val browserContainer: BrowserContainer= dataset.size) return + val item = dataset[position] + val context = itemView.context + + if (item.isFavorite) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_remove_favorite, context.getString(R.string.favorites_remove))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_favorite, context.getString(R.string.favorites_add))) + } + + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_append, context.getString(R.string.append))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_next, context.getString(R.string.insert_next))) + + if (item is MediaWrapper) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_shortcut, context.getString(R.string.create_shortcut))) + if (item.uri.retrieveParent() != null) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_folder, context.getString(R.string.go_to_folder))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_rename, context.getString(R.string.rename))) + } + + if (item is MediaWrapper) { + if (item.type != MediaWrapper.TYPE_DIR) { + if (item.playCount > 0) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_unplayed, context.getString(R.string.mark_as_not_played))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_played, context.getString(R.string.mark_as_played))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_info, context.getString(R.string.info))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_share, context.getString(R.string.share))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_ban_folder, context.getString(R.string.ban_folder))) + } + if (item.type == MediaWrapper.TYPE_VIDEO) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_download_subtitles, context.getString(R.string.download_subtitles))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_as_audio, context.getString(R.string.play_as_audio))) + } + } else if (item is Storage) { + // Storage actions if any + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_open_context_menu, context.getString(R.string.more_actions))) + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + val position = layoutPosition + if (position < 0 || position >= dataset.size) return super.performAccessibilityAction(host, action, args) + val item = dataset[position] + + val option = when (action) { + R.id.accessibility_action_add_favorite -> ContextOption.CTX_FAV_ADD + R.id.accessibility_action_remove_favorite -> ContextOption.CTX_FAV_REMOVE + R.id.accessibility_action_mark_played -> ContextOption.CTX_MARK_AS_PLAYED + R.id.accessibility_action_mark_unplayed -> ContextOption.CTX_MARK_AS_UNPLAYED + R.id.accessibility_action_append -> ContextOption.CTX_APPEND + R.id.accessibility_action_play_next -> ContextOption.CTX_PLAY_NEXT + R.id.accessibility_action_info -> ContextOption.CTX_INFORMATION + R.id.accessibility_action_share -> ContextOption.CTX_SHARE + R.id.accessibility_action_add_to_playlist -> ContextOption.CTX_ADD_TO_PLAYLIST + R.id.accessibility_action_delete -> ContextOption.CTX_DELETE + R.id.accessibility_action_download_subtitles -> ContextOption.CTX_DOWNLOAD_SUBTITLES + R.id.accessibility_action_play_as_audio -> ContextOption.CTX_PLAY_AS_AUDIO + R.id.accessibility_action_play_all -> ContextOption.CTX_PLAY_ALL + R.id.accessibility_action_add_shortcut -> ContextOption.CTX_ADD_SHORTCUT + R.id.accessibility_action_go_to_folder -> ContextOption.CTX_GO_TO_FOLDER + R.id.accessibility_action_rename -> ContextOption.CTX_RENAME + R.id.accessibility_action_ban_folder -> ContextOption.CTX_BAN_FOLDER + R.id.accessibility_action_open_context_menu -> { + onMoreClick(host) + return true + } + else -> null + } + + if (option != null && browserContainer is CtxActionReceiver) { + (browserContainer as CtxActionReceiver).onCtxAction(position, option) + return true + } + + return super.performAccessibilityAction(host, action, args) + } + }) } override fun selectView(selected: Boolean) { diff --git a/application/vlc-android/src/org/videolan/vlc/gui/video/VideoGridFragment.kt b/application/vlc-android/src/org/videolan/vlc/gui/video/VideoGridFragment.kt index 99092d919..8a856b977 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/video/VideoGridFragment.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/video/VideoGridFragment.kt @@ -746,6 +746,9 @@ class VideoGridFragment : MediaBrowserFragment(), SwipeRefreshL onLongClick(position) } } + is VideoCtxAction -> { + onCtxAction(position, option) + } } } @@ -806,3 +809,4 @@ class VideoClick(val position: Int, val item: MediaLibraryItem) : VideoAction() class VideoLongClick(val position: Int, val item: MediaLibraryItem) : VideoAction() class VideoCtxClick(val position: Int, val item: MediaLibraryItem) : VideoAction() class VideoImageClick(val position: Int, val item: MediaLibraryItem) : VideoAction() +class VideoCtxAction(val position: Int, val option: ContextOption) : VideoAction() diff --git a/application/vlc-android/src/org/videolan/vlc/gui/video/VideoListAdapter.kt b/application/vlc-android/src/org/videolan/vlc/gui/video/VideoListAdapter.kt index 9e3a48292..59c18e2be 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/video/VideoListAdapter.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/video/VideoListAdapter.kt @@ -53,6 +53,17 @@ import org.videolan.vlc.media.isOTG import org.videolan.vlc.media.isSD import org.videolan.vlc.util.* import org.videolan.vlc.viewmodels.mobile.VideoGroupingType +import android.os.Bundle +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import org.videolan.vlc.media.MediaUtils +import org.videolan.vlc.gui.helpers.UiTools.addToPlaylist +import org.videolan.vlc.gui.helpers.UiTools.showMediaInfo +import androidx.lifecycle.lifecycleScope +import org.videolan.tools.retrieveParent private const val TAG = "VLC/VideoListAdapter" @@ -201,11 +212,136 @@ class VideoListAdapter(private var isSeenMediaMarkerVisible: Boolean, private va init { binding.setVariable(BR.holder, this) binding.setVariable(BR.cover, UiTools.getDefaultVideoDrawable(itemView.context)) + more.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO if (AndroidUtil.isMarshMallowOrLater) itemView.setOnContextClickListener { v -> onMoreClick(v) true } + ViewCompat.setAccessibilityDelegate(itemView, object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(host, info) + val position = layoutPosition + if (!isPositionValid(position)) return + val item = getItem(position) ?: return + val context = itemView.context + + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_append, context.getString(R.string.append))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_next, context.getString(R.string.insert_next))) + + if (item.isFavorite) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_remove_favorite, context.getString(R.string.favorites_remove))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_favorite, context.getString(R.string.favorites_add))) + } + + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_shortcut, context.getString(R.string.create_shortcut))) + + if (item is MediaWrapper) { + if (item.seen > 0) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_unplayed, context.getString(R.string.mark_as_not_played))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_played, context.getString(R.string.mark_as_played))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_as_audio, context.getString(R.string.play_as_audio))) + if (item.time > 0L) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_from_start, context.getString(R.string.play_from_start))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_info, context.getString(R.string.info))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_share, context.getString(R.string.share))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_rename, context.getString(R.string.rename))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_download_subtitles, context.getString(R.string.download_subtitles))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_group, context.getString(R.string.add_to_group))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_group_similar, context.getString(R.string.group_similar))) + if (item.uri.retrieveParent() != null) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_folder, context.getString(R.string.go_to_folder))) + } + } else if (item is VideoGroup || item is Folder) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_unplayed, context.getString(R.string.mark_all_as_not_played))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_played, context.getString(R.string.mark_all_as_played))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + if (item is Folder) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_ban_folder, context.getString(R.string.ban_folder))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_rename, context.getString(R.string.rename_group))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_open_context_menu, context.getString(R.string.more_actions))) + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + val position = layoutPosition + if (!isPositionValid(position)) return super.performAccessibilityAction(host, action, args) + val item = getItem(position) ?: return super.performAccessibilityAction(host, action, args) + + val option = when (action) { + R.id.accessibility_action_append -> ContextOption.CTX_APPEND + R.id.accessibility_action_play_next -> ContextOption.CTX_PLAY_NEXT + R.id.accessibility_action_info -> ContextOption.CTX_INFORMATION + R.id.accessibility_action_share -> ContextOption.CTX_SHARE + R.id.accessibility_action_add_to_playlist -> ContextOption.CTX_ADD_TO_PLAYLIST + R.id.accessibility_action_delete -> ContextOption.CTX_DELETE + R.id.accessibility_action_download_subtitles -> ContextOption.CTX_DOWNLOAD_SUBTITLES + R.id.accessibility_action_play_as_audio -> ContextOption.CTX_PLAY_AS_AUDIO + R.id.accessibility_action_play_from_start -> ContextOption.CTX_PLAY_FROM_START + R.id.accessibility_action_play_all -> ContextOption.CTX_PLAY_ALL + R.id.accessibility_action_add_favorite -> ContextOption.CTX_FAV_ADD + R.id.accessibility_action_remove_favorite -> ContextOption.CTX_FAV_REMOVE + R.id.accessibility_action_add_shortcut -> ContextOption.CTX_ADD_SHORTCUT + R.id.accessibility_action_add_group -> ContextOption.CTX_ADD_GROUP + R.id.accessibility_action_group_similar -> ContextOption.CTX_GROUP_SIMILAR + R.id.accessibility_action_go_to_folder -> ContextOption.CTX_GO_TO_FOLDER + R.id.accessibility_action_rename -> if (item is VideoGroup) ContextOption.CTX_RENAME_GROUP else ContextOption.CTX_RENAME + R.id.accessibility_action_ban_folder -> ContextOption.CTX_BAN_FOLDER + R.id.accessibility_action_mark_played -> if (item is MediaWrapper || item.itemType == MediaLibraryItem.TYPE_MEDIA) ContextOption.CTX_MARK_AS_PLAYED else ContextOption.CTX_MARK_ALL_AS_PLAYED + R.id.accessibility_action_mark_unplayed -> if (item is MediaWrapper || item.itemType == MediaLibraryItem.TYPE_MEDIA) ContextOption.CTX_MARK_AS_UNPLAYED else ContextOption.CTX_MARK_ALL_AS_UNPLAYED + R.id.accessibility_action_open_context_menu -> { + onMoreClick(host) + return true + } + else -> null + } + + if (option != null) { + eventsChannel.trySend(VideoCtxAction(position, option)) + return true + } + + return super.performAccessibilityAction(host, action, args) + } + }) } fun onImageClick(@Suppress("UNUSED_PARAMETER") v: View) { -- 2.52.0.windows.1 From aryanchoudharyk2008 at gmail.com Sun Jan 18 10:13:37 2026 From: aryanchoudharyk2008 at gmail.com (aryan) Date: Sun, 18 Jan 2026 15:43:37 +0530 Subject: [Android] [PATCH v2] Accessibility: Add custom actions to lists (play, Favorite, etc.) Message-ID: <20260118101337.797-1-aryanchoudharyk2008@gmail.com> Changes in v2: - Moved AccessibilityDelegate to onBindViewHolder to fix actions disappearing on scroll. Hello VideoLAN Android Team, I am submitting a patch to improve the TalkBack experience in the media browser and player lists. The Problem: Currently, list items require excessive swiping to access common actions (like "More options"), and the interface does not expose quick actions like "Remove from favorites" or "Seek" directly to accessibility services. The Fix: This patch implements AccessibilityDelegateCompat to: 1. Add Custom Accessibility Actions (Seek, Favorite, Play Next, etc.) directly to the list items. 2. Hide the redundant "More" button from TalkBack focus (IMPORTANT_FOR_ACCESSIBILITY_NO) to reduce clutter. 3. Dynamically update action labels (e.g., swapping "Add to favorites" with "Remove from favorites" based on state). I am submitting this via the mailing list as I am unable to create an account on code.videolan.org at the moment. Best regards, Aryan --- .../resources/src/main/res/values/ids.xml | 24 +++ .../vlc/gui/audio/AudioBrowserAdapter.kt | 127 +++++++++++++++ .../vlc/gui/browser/BaseBrowserAdapter.kt | 132 ++++++++++++++++ .../vlc/gui/video/VideoGridFragment.kt | 4 + .../vlc/gui/video/VideoListAdapter.kt | 146 +++++++++++++++++- 5 files changed, 432 insertions(+), 1 deletion(-) diff --git a/application/resources/src/main/res/values/ids.xml b/application/resources/src/main/res/values/ids.xml index 5ff0ec18e..59ad7b7ab 100644 --- a/application/resources/src/main/res/values/ids.xml +++ b/application/resources/src/main/res/values/ids.xml @@ -25,4 +25,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioBrowserAdapter.kt b/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioBrowserAdapter.kt index a8be20509..33572698e 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioBrowserAdapter.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioBrowserAdapter.kt @@ -41,9 +41,11 @@ import androidx.paging.PagedListAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.videolan.libvlc.util.AndroidUtil +import org.videolan.medialibrary.interfaces.media.Album import org.videolan.medialibrary.interfaces.media.Artist import org.videolan.medialibrary.interfaces.media.Genre import org.videolan.medialibrary.interfaces.media.MediaWrapper +import org.videolan.medialibrary.interfaces.media.Playlist import org.videolan.medialibrary.media.MediaLibraryItem import org.videolan.medialibrary.media.MediaLibraryItem.FLAG_SELECTED import org.videolan.resources.AppContextProvider @@ -73,6 +75,22 @@ import org.videolan.vlc.util.isOTG import org.videolan.vlc.util.isSD import org.videolan.vlc.util.isSchemeSMB import org.videolan.vlc.viewmodels.PlaylistModel +import android.os.Bundle +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import org.videolan.vlc.media.MediaUtils +import org.videolan.vlc.gui.helpers.UiTools +import org.videolan.vlc.gui.helpers.UiTools.addToPlaylist +import org.videolan.vlc.gui.helpers.UiTools.showMediaInfo +import org.videolan.vlc.util.share +import androidx.lifecycle.lifecycleScope +import org.videolan.vlc.util.ContextOption +import org.videolan.vlc.gui.dialogs.CtxActionReceiver +import org.videolan.vlc.media.getAll +import org.videolan.tools.retrieveParent private const val SHOW_IN_LIST = -1 @@ -172,6 +190,7 @@ open class AudioBrowserAdapter @JvmOverloads constructor( if (position >= itemCount) return val item = getItem(position) holder.setItem(item) + holder.updateAccessibilityDelegate(item) if (item is Artist) item.description = holder.binding.root.context.resources.getQuantityString(R.plurals.albums_quantity, item.albumsCount, item.albumsCount) if (item is Genre) item.description = holder.binding.root.context.resources.getQuantityString(R.plurals.track_quantity, item.tracksCount, item.tracksCount) val isSelected = multiSelectHelper.isSelected(position) @@ -314,6 +333,7 @@ open class AudioBrowserAdapter @JvmOverloads constructor( } } binding.imageWidth = listImageWidth + binding.itemMore.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO } override fun selectView(selected: Boolean) { @@ -355,6 +375,7 @@ open class AudioBrowserAdapter @JvmOverloads constructor( } binding.imageWidth = cardSize binding.container.layoutParams.width = cardSize + binding.itemMore.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO } @@ -384,6 +405,112 @@ open class AudioBrowserAdapter @JvmOverloads constructor( @TargetApi(Build.VERSION_CODES.M) abstract inner class AbstractMediaItemViewHolder(binding: T) : SelectorViewHolder(binding), MarqueeViewHolder { + fun updateAccessibilityDelegate(item: MediaLibraryItem?) { + if (item == null) { + ViewCompat.setAccessibilityDelegate(itemView, null) + return + } + ViewCompat.setAccessibilityDelegate(itemView, object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(host, info) + val context = itemView.context + + if (item.isFavorite) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_remove_favorite, context.getString(R.string.favorites_remove))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_favorite, context.getString(R.string.favorites_add))) + } + + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_append, context.getString(R.string.append))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_next, context.getString(R.string.insert_next))) + + if (item is MediaWrapper || item is Playlist) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_shortcut, context.getString(R.string.create_shortcut))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_rename, context.getString(R.string.rename))) + } + + if (item is MediaWrapper) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_info, context.getString(R.string.info))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_share, context.getString(R.string.share))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_set_ringtone, context.getString(R.string.set_song))) + if (item.uri.retrieveParent() != null) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_folder, context.getString(R.string.go_to_folder))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_album, context.getString(R.string.go_to_album))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_artist, context.getString(R.string.go_to_artist))) + } else if (item is Album || item is Artist || item is Genre || item is Playlist) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + if (item !is Genre) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + } + if (item is Album) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_artist, context.getString(R.string.go_to_artist))) + } + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_open_context_menu, context.getString(R.string.more_actions))) + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + val option = when (action) { + R.id.accessibility_action_append -> ContextOption.CTX_APPEND + R.id.accessibility_action_play_next -> ContextOption.CTX_PLAY_NEXT + R.id.accessibility_action_info -> ContextOption.CTX_INFORMATION + R.id.accessibility_action_share -> ContextOption.CTX_SHARE + R.id.accessibility_action_add_to_playlist -> ContextOption.CTX_ADD_TO_PLAYLIST + R.id.accessibility_action_delete -> ContextOption.CTX_DELETE + R.id.accessibility_action_set_ringtone -> ContextOption.CTX_SET_RINGTONE + R.id.accessibility_action_play_all -> ContextOption.CTX_PLAY_ALL + R.id.accessibility_action_add_favorite -> ContextOption.CTX_FAV_ADD + R.id.accessibility_action_remove_favorite -> ContextOption.CTX_FAV_REMOVE + R.id.accessibility_action_add_shortcut -> ContextOption.CTX_ADD_SHORTCUT + R.id.accessibility_action_go_to_folder -> ContextOption.CTX_GO_TO_FOLDER + R.id.accessibility_action_rename -> ContextOption.CTX_RENAME + R.id.accessibility_action_go_to_album -> ContextOption.CTX_GO_TO_ALBUM + R.id.accessibility_action_go_to_artist -> ContextOption.CTX_GO_TO_ARTIST + R.id.accessibility_action_open_context_menu -> { + onMoreClick(host) + return true + } + else -> null + } + + if (option != null && eventsHandler is CtxActionReceiver) { + val position = layoutPosition + if (isPositionValid(position)) { + eventsHandler.onCtxAction(position, option) + } + return true + } + + return super.performAccessibilityAction(host, action, args) + } + }) + } + val canBeReordered: Boolean get() = reorderable && !stopReorder diff --git a/application/vlc-android/src/org/videolan/vlc/gui/browser/BaseBrowserAdapter.kt b/application/vlc-android/src/org/videolan/vlc/gui/browser/BaseBrowserAdapter.kt index 96e4803e1..33ae5aa35 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/browser/BaseBrowserAdapter.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/browser/BaseBrowserAdapter.kt @@ -55,6 +55,21 @@ import org.videolan.vlc.gui.view.MiniVisualizer import org.videolan.vlc.util.LifecycleAwareScheduler import org.videolan.vlc.util.getDescriptionSpan import org.videolan.vlc.viewmodels.PlaylistModel +import android.os.Bundle +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import org.videolan.vlc.media.MediaUtils +import org.videolan.vlc.gui.helpers.UiTools +import org.videolan.vlc.gui.helpers.UiTools.addToPlaylist +import org.videolan.vlc.gui.helpers.UiTools.showMediaInfo +import org.videolan.vlc.util.share +import androidx.lifecycle.lifecycleScope +import org.videolan.vlc.util.ContextOption +import org.videolan.vlc.gui.dialogs.CtxActionReceiver +import org.videolan.tools.retrieveParent const val UPDATE_PROGRESS = "update_progress" open class BaseBrowserAdapter(val browserContainer: BrowserContainer, var sort:Int = Medialibrary.SORT_FILENAME, var asc:Boolean = true, val forMain:Boolean = true) : DiffUtilAdapter>(), MultiSelectAdapter, FastScroller.SeparatedAdapter { @@ -155,6 +170,9 @@ open class BaseBrowserAdapter(val browserContainer: BrowserContainer, position: Int) { + if (holder is MediaViewHolder) { + holder.updateAccessibilityDelegate(dataset[position]) + } val viewType = getItemViewType(position) if (viewType == TYPE_MEDIA) { onBindMediaViewHolder(holder as MediaViewHolder, position) @@ -258,6 +276,119 @@ open class BaseBrowserAdapter(val browserContainer: BrowserContainer 0) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_unplayed, context.getString(R.string.mark_as_not_played))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_played, context.getString(R.string.mark_as_played))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_info, context.getString(R.string.info))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_share, context.getString(R.string.share))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_ban_folder, context.getString(R.string.ban_folder))) + } + if (item.type == MediaWrapper.TYPE_VIDEO) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_download_subtitles, context.getString(R.string.download_subtitles))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_as_audio, context.getString(R.string.play_as_audio))) + } + } else if (item is Storage) { + // Storage actions if any + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_open_context_menu, context.getString(R.string.more_actions))) + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + val option = when (action) { + R.id.accessibility_action_add_favorite -> ContextOption.CTX_FAV_ADD + R.id.accessibility_action_remove_favorite -> ContextOption.CTX_FAV_REMOVE + R.id.accessibility_action_mark_played -> ContextOption.CTX_MARK_AS_PLAYED + R.id.accessibility_action_mark_unplayed -> ContextOption.CTX_MARK_AS_UNPLAYED + R.id.accessibility_action_append -> ContextOption.CTX_APPEND + R.id.accessibility_action_play_next -> ContextOption.CTX_PLAY_NEXT + R.id.accessibility_action_info -> ContextOption.CTX_INFORMATION + R.id.accessibility_action_share -> ContextOption.CTX_SHARE + R.id.accessibility_action_add_to_playlist -> ContextOption.CTX_ADD_TO_PLAYLIST + R.id.accessibility_action_delete -> ContextOption.CTX_DELETE + R.id.accessibility_action_download_subtitles -> ContextOption.CTX_DOWNLOAD_SUBTITLES + R.id.accessibility_action_play_as_audio -> ContextOption.CTX_PLAY_AS_AUDIO + R.id.accessibility_action_play_all -> ContextOption.CTX_PLAY_ALL + R.id.accessibility_action_add_shortcut -> ContextOption.CTX_ADD_SHORTCUT + R.id.accessibility_action_go_to_folder -> ContextOption.CTX_GO_TO_FOLDER + R.id.accessibility_action_rename -> ContextOption.CTX_RENAME + R.id.accessibility_action_ban_folder -> ContextOption.CTX_BAN_FOLDER + R.id.accessibility_action_open_context_menu -> { + onMoreClick(host) + return true + } + else -> null + } + + if (option != null && browserContainer is CtxActionReceiver) { + val position = layoutPosition + if (position >= 0 && position < dataset.size) { + (browserContainer as CtxActionReceiver).onCtxAction(position, option) + } + return true + } + + return super.performAccessibilityAction(host, action, args) + } + }) + } + init { bindingContainer.setHolder(this) if (AndroidUtil.isMarshMallowOrLater) itemView.setOnContextClickListener { v -> @@ -275,6 +406,7 @@ open class BaseBrowserAdapter(val browserContainer: BrowserContainer(), SwipeRefreshL onLongClick(position) } } + is VideoCtxAction -> { + onCtxAction(position, option) + } } } @@ -806,3 +809,4 @@ class VideoClick(val position: Int, val item: MediaLibraryItem) : VideoAction() class VideoLongClick(val position: Int, val item: MediaLibraryItem) : VideoAction() class VideoCtxClick(val position: Int, val item: MediaLibraryItem) : VideoAction() class VideoImageClick(val position: Int, val item: MediaLibraryItem) : VideoAction() +class VideoCtxAction(val position: Int, val option: ContextOption) : VideoAction() diff --git a/application/vlc-android/src/org/videolan/vlc/gui/video/VideoListAdapter.kt b/application/vlc-android/src/org/videolan/vlc/gui/video/VideoListAdapter.kt index 9e3a48292..1cb0602bb 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/video/VideoListAdapter.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/video/VideoListAdapter.kt @@ -53,6 +53,17 @@ import org.videolan.vlc.media.isOTG import org.videolan.vlc.media.isSD import org.videolan.vlc.util.* import org.videolan.vlc.viewmodels.mobile.VideoGroupingType +import android.os.Bundle +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import org.videolan.vlc.media.MediaUtils +import org.videolan.vlc.gui.helpers.UiTools.addToPlaylist +import org.videolan.vlc.gui.helpers.UiTools.showMediaInfo +import androidx.lifecycle.lifecycleScope +import org.videolan.tools.retrieveParent private const val TAG = "VLC/VideoListAdapter" @@ -88,7 +99,12 @@ class VideoListAdapter(private var isSeenMediaMarkerVisible: Boolean, private va } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = getItem(position) ?: return + val item = getItem(position) + if (item == null) { + holder.updateAccessibilityDelegate(null) + return + } + holder.updateAccessibilityDelegate(item) holder.binding.setVariable(BR.scaleType, ImageView.ScaleType.CENTER_CROP) fillView(holder, item) holder.binding.setVariable(BR.media, item) @@ -198,9 +214,137 @@ class VideoListAdapter(private var isSeenMediaMarkerVisible: Boolean, private va val title : TextView = itemView.findViewById(R.id.ml_item_title) val more : ImageView = itemView.findViewById(R.id.item_more) + fun updateAccessibilityDelegate(item: MediaLibraryItem?) { + if (item == null) { + ViewCompat.setAccessibilityDelegate(itemView, null) + return + } + ViewCompat.setAccessibilityDelegate(itemView, object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(host, info) + val context = itemView.context + + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_append, context.getString(R.string.append))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_next, context.getString(R.string.insert_next))) + + if (item.isFavorite) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_remove_favorite, context.getString(R.string.favorites_remove))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_favorite, context.getString(R.string.favorites_add))) + } + + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_shortcut, context.getString(R.string.create_shortcut))) + + if (item is MediaWrapper) { + if (item.seen > 0) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_unplayed, context.getString(R.string.mark_as_not_played))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_played, context.getString(R.string.mark_as_played))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_as_audio, context.getString(R.string.play_as_audio))) + if (item.time > 0L) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_from_start, context.getString(R.string.play_from_start))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_info, context.getString(R.string.info))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_share, context.getString(R.string.share))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_rename, context.getString(R.string.rename))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_download_subtitles, context.getString(R.string.download_subtitles))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_group, context.getString(R.string.add_to_group))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_group_similar, context.getString(R.string.group_similar))) + if (item.uri.retrieveParent() != null) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_go_to_folder, context.getString(R.string.go_to_folder))) + } + } else if (item is VideoGroup || item is Folder) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_unplayed, context.getString(R.string.mark_all_as_not_played))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_mark_played, context.getString(R.string.mark_all_as_played))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_play_all, context.getString(R.string.play_all))) + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_add_to_playlist, context.getString(R.string.add_to_playlist))) + if (item is Folder) { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_ban_folder, context.getString(R.string.ban_folder))) + } else { + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_rename, context.getString(R.string.rename_group))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_delete, context.getString(R.string.delete))) + } + info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat( + R.id.accessibility_action_open_context_menu, context.getString(R.string.more_actions))) + } + + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { + val option = when (action) { + R.id.accessibility_action_append -> ContextOption.CTX_APPEND + R.id.accessibility_action_play_next -> ContextOption.CTX_PLAY_NEXT + R.id.accessibility_action_info -> ContextOption.CTX_INFORMATION + R.id.accessibility_action_share -> ContextOption.CTX_SHARE + R.id.accessibility_action_add_to_playlist -> ContextOption.CTX_ADD_TO_PLAYLIST + R.id.accessibility_action_delete -> ContextOption.CTX_DELETE + R.id.accessibility_action_download_subtitles -> ContextOption.CTX_DOWNLOAD_SUBTITLES + R.id.accessibility_action_play_as_audio -> ContextOption.CTX_PLAY_AS_AUDIO + R.id.accessibility_action_play_from_start -> ContextOption.CTX_PLAY_FROM_START + R.id.accessibility_action_play_all -> ContextOption.CTX_PLAY_ALL + R.id.accessibility_action_add_favorite -> ContextOption.CTX_FAV_ADD + R.id.accessibility_action_remove_favorite -> ContextOption.CTX_FAV_REMOVE + R.id.accessibility_action_add_shortcut -> ContextOption.CTX_ADD_SHORTCUT + R.id.accessibility_action_add_group -> ContextOption.CTX_ADD_GROUP + R.id.accessibility_action_group_similar -> ContextOption.CTX_GROUP_SIMILAR + R.id.accessibility_action_go_to_folder -> ContextOption.CTX_GO_TO_FOLDER + R.id.accessibility_action_rename -> if (item is VideoGroup) ContextOption.CTX_RENAME_GROUP else ContextOption.CTX_RENAME + R.id.accessibility_action_ban_folder -> ContextOption.CTX_BAN_FOLDER + R.id.accessibility_action_mark_played -> if (item is MediaWrapper || item.itemType == MediaLibraryItem.TYPE_MEDIA) ContextOption.CTX_MARK_AS_PLAYED else ContextOption.CTX_MARK_ALL_AS_PLAYED + R.id.accessibility_action_mark_unplayed -> if (item is MediaWrapper || item.itemType == MediaLibraryItem.TYPE_MEDIA) ContextOption.CTX_MARK_AS_UNPLAYED else ContextOption.CTX_MARK_ALL_AS_UNPLAYED + R.id.accessibility_action_open_context_menu -> { + onMoreClick(host) + return true + } + else -> null + } + + if (option != null) { + val position = layoutPosition + if (isPositionValid(position)) { + eventsChannel.trySend(VideoCtxAction(position, option)) + } + return true + } + + return super.performAccessibilityAction(host, action, args) + } + }) + } + init { binding.setVariable(BR.holder, this) binding.setVariable(BR.cover, UiTools.getDefaultVideoDrawable(itemView.context)) + more.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO if (AndroidUtil.isMarshMallowOrLater) itemView.setOnContextClickListener { v -> onMoreClick(v) -- 2.52.0.windows.1