From 3554322760b635a4c729a8901f4c4c95360552bc Mon Sep 17 00:00:00 2001 From: elgarima77 Date: Wed, 2 Sep 2026 23:44:12 -0400 Subject: [PATCH 1/4] Add Playlists tab to Music section, replacing Videos --- .../ui/sections/audio/MusicListFragment.java | 29 ++- .../audio/MusicVideoInfoFragment.java | 236 ------------------ .../audio/MusicVideoListFragment.java | 225 ----------------- .../sections/file/MediaFileListFragment.java | 197 ++++++++++++--- app/src/main/res/menu/playlist_list_item.xml | 27 ++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 219 insertions(+), 496 deletions(-) delete mode 100644 app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoInfoFragment.java delete mode 100644 app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoListFragment.java create mode 100644 app/src/main/res/menu/playlist_list_item.xml diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicListFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicListFragment.java index e1f997309..f15307592 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicListFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicListFragment.java @@ -24,8 +24,10 @@ import androidx.viewpager2.widget.ViewPager2; import org.xbmc.kore.R; +import org.xbmc.kore.jsonrpc.method.Files; import org.xbmc.kore.ui.AbstractCursorListFragment; import org.xbmc.kore.ui.AbstractTabsFragment; +import org.xbmc.kore.ui.sections.file.MediaFileListFragment; import org.xbmc.kore.utils.LogUtils; import org.xbmc.kore.utils.TabsAdapter; @@ -35,6 +37,9 @@ public class MusicListFragment extends AbstractTabsFragment { private static final String TAG = LogUtils.makeLogTag(MusicListFragment.class); + // Kodi's special virtual folder that holds saved music playlists (.m3u/.pls files) + private static final String MUSIC_PLAYLISTS_PATH = "special://musicplaylists/"; + @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); @@ -58,7 +63,29 @@ protected TabsAdapter createTabsAdapter(DataHolder dataHolder) { .addTab(AlbumListFragment.class, getArguments(), R.string.albums, 2) .addTab(AudioGenresListFragment.class, getArguments(), R.string.genres, 3) .addTab(SongsListFragment.class, getArguments(), R.string.songs, 4) - .addTab(MusicVideoListFragment.class, getArguments(), R.string.music_videos, 5); + .addTab(MediaFileListFragment.class, createPlaylistsTabArgs(), R.string.playlists, 5); + } + + /** + * Builds the arguments that tell the reused {@link MediaFileListFragment} to start + * browsing directly inside Kodi's saved music playlists folder, instead of starting + * at the top-level file Sources list (which is what it does with no arguments). + * + * Tapping a playlist file plays it immediately (MediaFileListFragment's existing + * behaviour for any non-directory item) - see Player.Open being called from + * handleFileSelect()/playMediaFile() in that class. + */ + private Bundle createPlaylistsTabArgs() { + Bundle args = new Bundle(); + args.putString(MediaFileListFragment.MEDIA_TYPE, Files.Media.MUSIC); + args.putParcelable(MediaFileListFragment.CURRENT_LOCATION, + new MediaFileListFragment.FileLocation( + getString(R.string.playlists), // title (not shown here - this location is the root of the tab) + MUSIC_PLAYLISTS_PATH, // Kodi's special path for saved music playlists + true, // isDirectory + null, // no parent - this is the top of this tab + true)); // isRoot - stops back-navigation going further up + return args; } @Override diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoInfoFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoInfoFragment.java deleted file mode 100644 index 15add4477..000000000 --- a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoInfoFragment.java +++ /dev/null @@ -1,236 +0,0 @@ -/* - * Copyright 2015 Synced Synapse. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.xbmc.kore.ui.sections.audio; - -import android.database.Cursor; -import android.net.Uri; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.provider.BaseColumns; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.CursorLoader; -import androidx.loader.content.Loader; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.xbmc.kore.R; -import org.xbmc.kore.jsonrpc.event.MediaSyncEvent; -import org.xbmc.kore.jsonrpc.type.PlaylistType; -import org.xbmc.kore.provider.MediaContract; -import org.xbmc.kore.service.library.LibrarySyncService; -import org.xbmc.kore.ui.AbstractFragment; -import org.xbmc.kore.ui.AbstractInfoFragment; -import org.xbmc.kore.utils.FileDownloadHelper; -import org.xbmc.kore.utils.LogUtils; -import org.xbmc.kore.utils.MediaPlayerUtils; -import org.xbmc.kore.utils.UIUtils; - -import java.io.File; - -/** - * Presents music videos details - */ -public class MusicVideoInfoFragment extends AbstractInfoFragment - implements LoaderManager.LoaderCallbacks { - private static final String TAG = LogUtils.makeLogTag(MusicVideoInfoFragment.class); - - // Loader IDs - private static final int LOADER_MUSIC_VIDEO = 0; - - // Handler on which to post RPC callbacks - private final Handler callbackHandler = new Handler(Looper.getMainLooper()); - - private String musicVideoFile; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setExpandDescription(true); - } - - @Override - protected String getSyncType() { - return LibrarySyncService.SYNC_ALL_MUSIC_VIDEOS; - } - - @Override - protected void onSyncProcessEnded(MediaSyncEvent event) { - if (event.status == MediaSyncEvent.STATUS_SUCCESS) { - LoaderManager.getInstance(this).restartLoader(LOADER_MUSIC_VIDEO, null, MusicVideoInfoFragment.this); - } - } - - @Override - protected boolean setupInfoActionsBar() { - setOnQueueClickListener(view -> { - PlaylistType.Item item = new PlaylistType.Item(); - item.musicvideoid = getDataHolder().getId(); - MediaPlayerUtils.queue(MusicVideoInfoFragment.this, item, PlaylistType.GetPlaylistsReturnType.VIDEO); - }); - - setOnDownloadClickListener(view -> download()); - - return true; - } - - @Override - protected View.OnClickListener getFABClickListener() { - return (v -> { - PlaylistType.Item item = new PlaylistType.Item(); - item.musicvideoid = getDataHolder().getId(); - playItemOnKodi(item); - }); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - // Start the loaders - LoaderManager.getInstance(this).initLoader(LOADER_MUSIC_VIDEO, null, this); - } - - /* - * Loader callbacks - */ - /** {@inheritDoc} */ - @NonNull - @Override - public Loader onCreateLoader(int i, Bundle bundle) { - Uri uri; - switch (i) { - case LOADER_MUSIC_VIDEO: - default: - uri = MediaContract.MusicVideos.buildMusicVideoUri(getHostInfo().getId(), - getDataHolder().getId()); - return new CursorLoader(requireContext(), uri, - MusicVideoDetailsQuery.PROJECTION, null, null, null); - } - } - - /** {@inheritDoc} */ - @Override - public void onLoadFinished(@NonNull Loader cursorLoader, Cursor cursor) { - if (cursor != null && cursor.getCount() > 0) { - switch (cursorLoader.getId()) { - case LOADER_MUSIC_VIDEO: - cursor.moveToFirst(); - musicVideoFile = cursor.getString(MusicVideoDetailsQuery.FILE); - String artist = cursor.getString(MusicVideoDetailsQuery.ARTIST), - title = cursor.getString(MusicVideoDetailsQuery.TITLE); - - DataHolder dataHolder = getDataHolder(); - - dataHolder.setFanArtUrl(cursor.getString(MusicVideoDetailsQuery.FANART)); - dataHolder.setPosterUrl(cursor.getString(MusicVideoDetailsQuery.THUMBNAIL)); - - int runtime = cursor.getInt(MusicVideoDetailsQuery.RUNTIME); - int year = cursor.getInt(MusicVideoDetailsQuery.YEAR); - String details = runtime > 0 ? - UIUtils.formatTime(runtime) + " | " + year : - String.valueOf(year); - dataHolder.setDetails(details + "\n" + cursor.getString(MusicVideoDetailsQuery.GENRES)); - - dataHolder.setTitle(title); - dataHolder.setUndertitle(artist + " | " + cursor.getString(MusicVideoDetailsQuery.ALBUM)); - dataHolder.setDescription(cursor.getString(MusicVideoDetailsQuery.PLOT)); - dataHolder.setSearchTerms(artist + " " + title); - - //FileDownloadHelper.MusicVideoInfo musicVideoDownloadInfo = new FileDownloadHelper.MusicVideoInfo( - // dataHolder.getTitle(), cursor.getString(MusicVideoDetailsQuery.FILE)); - //setDownloadButtonState(musicVideoDownloadInfo.downloadFileExists()); - - updateView(dataHolder); - break; - } - } - } - - /** {@inheritDoc} */ - @Override - public void onLoaderReset(@NonNull Loader cursorLoader) { - // Release loader's data - } - - protected void download() { - final FileDownloadHelper.MusicVideoInfo musicVideoDownloadInfo = new FileDownloadHelper.MusicVideoInfo( - getDataHolder().getTitle(), musicVideoFile); - - // Check if the directory exists and whether to overwrite it - File file = new File(musicVideoDownloadInfo.getAbsoluteFilePath()); - if (file.exists()) { - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()); - builder.setTitle(R.string.download) - .setMessage(R.string.download_file_exists) - .setPositiveButton(R.string.overwrite, - (dialog, which) -> - FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - musicVideoDownloadInfo, FileDownloadHelper.OVERWRITE_FILES, - callbackHandler)) - .setNeutralButton(R.string.download_with_new_name, - (dialog, which) -> - FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - musicVideoDownloadInfo, FileDownloadHelper.DOWNLOAD_WITH_NEW_NAME, - callbackHandler)) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()) - .show(); - } else { - FileDownloadHelper.downloadFiles(getActivity(), getHostInfo(), - musicVideoDownloadInfo, FileDownloadHelper.DOWNLOAD_WITH_NEW_NAME, - callbackHandler); - } - } - - @Override - protected AbstractFragment getAdditionalInfoFragment() { - return null; - } - - /** - * Video details query parameters. - */ - private interface MusicVideoDetailsQuery { - String[] PROJECTION = { - BaseColumns._ID, - MediaContract.MusicVideos.TITLE, - MediaContract.MusicVideos.ALBUM, - MediaContract.MusicVideos.ARTIST, - MediaContract.MusicVideos.THUMBNAIL, - MediaContract.MusicVideos.FANART, - MediaContract.MusicVideos.YEAR, - MediaContract.MusicVideos.GENRES, - MediaContract.MusicVideos.RUNTIME, - MediaContract.MusicVideos.PLOT, - MediaContract.MusicVideos.FILE, - }; - - int ID = 0; - int TITLE = 1; - int ALBUM = 2; - int ARTIST = 3; - int THUMBNAIL =4; - int FANART = 5; - int YEAR = 6; - int GENRES = 7; - int RUNTIME = 8; - int PLOT = 9; - int FILE = 10; - } -} diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoListFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoListFragment.java deleted file mode 100644 index 55fd7174a..000000000 --- a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicVideoListFragment.java +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright 2015 Synced Synapse. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.xbmc.kore.ui.sections.audio; - -import android.content.Context; -import android.content.res.Resources; -import android.database.Cursor; -import android.net.Uri; -import android.provider.BaseColumns; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.loader.content.CursorLoader; - -import org.xbmc.kore.R; -import org.xbmc.kore.host.HostInfo; -import org.xbmc.kore.host.HostManager; -import org.xbmc.kore.provider.MediaContract; -import org.xbmc.kore.provider.MediaDatabase; -import org.xbmc.kore.service.library.LibrarySyncService; -import org.xbmc.kore.ui.AbstractCursorListFragment; -import org.xbmc.kore.ui.AbstractInfoFragment; -import org.xbmc.kore.ui.RecyclerViewCursorAdapter; -import org.xbmc.kore.utils.LogUtils; -import org.xbmc.kore.utils.UIUtils; - -/** - * Fragment that presents the artists list - */ -public class MusicVideoListFragment extends AbstractCursorListFragment { - private static final String TAG = LogUtils.makeLogTag(MusicVideoListFragment.class); - - public interface OnMusicVideoSelectedListener { - void onMusicVideoSelected(AbstractInfoFragment.DataHolder dataHolder, ImageView sharedImageView); - } - - // Activity listener - private OnMusicVideoSelectedListener listenerActivity; - - @Override - public void onAttach(@NonNull Context ctx) { - super.onAttach(ctx); - try { - listenerActivity = (OnMusicVideoSelectedListener) ctx; - } catch (ClassCastException e) { - throw new ClassCastException(ctx + " must implement OnMusicVideoSelectedListener"); - } - setSupportsSearch(true); - } - - @Override - public void onDetach() { - super.onDetach(); - listenerActivity = null; - } - - @Override - protected String getListSyncType() { return LibrarySyncService.SYNC_ALL_MUSIC_VIDEOS; } - - @Override - protected void onListItemClicked(View view, int position) { - super.onListItemClicked(view, position); - // Get the movie id from the tag - ViewHolder tag = (ViewHolder)view.getTag(); - // Notify the activity - listenerActivity.onMusicVideoSelected(tag.dataHolder, tag.artView); - } - - @Override - protected String getEmptyResultsTitle() { return getString(R.string.no_music_videos_found_refresh); } - - @Override - protected RecyclerViewCursorAdapter createCursorAdapter() { - return new MusicVideosAdapter(getActivity()); - } - - @Override - protected CursorLoader createCursorLoader() { - HostInfo hostInfo = HostManager.getInstance(requireContext()).getHostInfo(); - Uri uri = MediaContract.MusicVideos.buildMusicVideosListUri(hostInfo != null ? hostInfo.getId() : -1); - - String selection = null; - String[] selectionArgs = null; - String searchFilter = getSearchFilter(); - if (!TextUtils.isEmpty(searchFilter)) { - selection = MediaContract.MusicVideosColumns.TITLE + " LIKE ?"; - selectionArgs = new String[] {"%" + searchFilter + "%"}; - } - - return new CursorLoader(requireContext(), uri, - MusicVideosListQuery.PROJECTION, selection, selectionArgs, MusicVideosListQuery.SORT); - } - - /** - * Videos list query parameters. - */ - private interface MusicVideosListQuery { - String[] PROJECTION = { - BaseColumns._ID, - MediaContract.MusicVideos.MUSICVIDEOID, - MediaContract.MusicVideos.TITLE, - MediaContract.MusicVideos.ARTIST, - MediaContract.MusicVideos.ALBUM, - MediaContract.MusicVideos.THUMBNAIL, - MediaContract.MusicVideos.RUNTIME, - MediaContract.MusicVideos.GENRES, - MediaContract.MusicVideos.YEAR, - MediaContract.MusicVideos.PLOT, - }; - - String SORT = MediaDatabase.sortCommonTokens(MediaContract.MusicVideos.TITLE) + " COLLATE NOCASE ASC"; - - int ID = 0; - int MUSICVIDEOID = 1; - int TITLE = 2; - int ARTIST = 3; - int ALBUM = 4; - int THUMBNAIL = 5; - int RUNTIME = 6; - int GENRES = 7; - int YEAR = 8; - int PLOT = 9; - } - - private static class MusicVideosAdapter extends RecyclerViewCursorAdapter { - - private final HostManager hostManager; - private final int artWidth, artHeight; - private final Context context; - - public MusicVideosAdapter(Context context) { - this.context = context; - - this.hostManager = HostManager.getInstance(context); - - // Get the art dimensions - Resources resources = context.getResources(); - artHeight = resources.getDimensionPixelOffset(R.dimen.info_poster_width_square); - artWidth = resources.getDimensionPixelOffset(R.dimen.info_poster_height_square); - } - - @NonNull - @Override - public CursorViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(context) - .inflate(R.layout.item_music_generic, parent, false); - return new ViewHolder(view, context, hostManager, artWidth, artHeight); - } - protected int getSectionColumnIdx() { return MusicVideosListQuery.TITLE; } - } - - /** - * View holder pattern - */ - public static class ViewHolder extends RecyclerViewCursorAdapter.CursorViewHolder { - TextView titleView; - TextView artistAlbumView; - TextView durationGenresView; - ImageView artView, contextMenu; - HostManager hostManager; - int artWidth; - int artHeight; - Context context; - - AbstractInfoFragment.DataHolder dataHolder = new AbstractInfoFragment.DataHolder(0); - - ViewHolder(View itemView, Context context, HostManager hostManager, int artWidth, int artHeight) { - super(itemView); - this.context = context; - this.hostManager = hostManager; - this.artWidth = artWidth; - this.artHeight = artHeight; - titleView = itemView.findViewById(R.id.title); - artistAlbumView = itemView.findViewById(R.id.details); - durationGenresView = itemView.findViewById(R.id.other_info); - artView = itemView.findViewById(R.id.art); - contextMenu = itemView.findViewById(R.id.list_context_menu); - } - @Override - public void bindView(Cursor cursor) { - dataHolder.setId(cursor.getInt(MusicVideosListQuery.MUSICVIDEOID)); - dataHolder.setTitle(cursor.getString(MusicVideosListQuery.TITLE)); - - titleView.setText(dataHolder.getTitle()); - String artistAlbum = cursor.getString(MusicVideosListQuery.ARTIST) + " | " + - cursor.getString(MusicVideosListQuery.ALBUM); - artistAlbumView.setText(artistAlbum); - dataHolder.setUndertitle(artistAlbum); - - int runtime = cursor.getInt(MusicVideosListQuery.RUNTIME); - String genres = cursor.getString(MusicVideosListQuery.GENRES); - String durationGenres = - runtime > 0 ? - UIUtils.formatTime(runtime) + " | " + genres : - genres; - durationGenresView.setText(durationGenres); - dataHolder.setDetails(durationGenres); - - String posterUrl = cursor.getString(MusicVideosListQuery.THUMBNAIL); - dataHolder.setPosterUrl(posterUrl); - UIUtils.loadImageWithCharacterAvatar(context, hostManager, posterUrl - , dataHolder.getTitle(), artView, artWidth, artHeight); - contextMenu.setVisibility(View.GONE); - artView.setTransitionName("music_video"+dataHolder.getId()); - } - } -} diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/file/MediaFileListFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/file/MediaFileListFragment.java index 7d5e47e73..fb8e95975 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/file/MediaFileListFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/file/MediaFileListFragment.java @@ -56,6 +56,7 @@ import java.util.ArrayList; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Queue; /** @@ -172,6 +173,8 @@ void handleFileSelect(FileLocation f) { if (f == null || f.file == null) { // If at first level, browse Sources browseSources(); + } else if (isPlaylistFile(f.file)) { + playPlaylistFile(f.file); } else if (f.isDirectory) { browseDirectory(f); } else { @@ -179,6 +182,17 @@ void handleFileSelect(FileLocation f) { } } + /** + * Kodi marks saved playlist files (.m3u/.m3u8/.pls/.xsp) as "directories" in + * Files.GetDirectory results, since their contents can be browsed like a folder. + * We want tapping one to play it instead of browsing into it. + */ + private static boolean isPlaylistFile(String file) { + String lower = file.toLowerCase(Locale.ROOT); + return lower.endsWith(".m3u") || lower.endsWith(".m3u8") + || lower.endsWith(".pls") || lower.endsWith(".xsp"); + } + public boolean navigateToParentDir() { if (currentLocation != null && !currentLocation.isRoot) { handleFileSelect(currentLocation.parentDirectory); @@ -333,7 +347,106 @@ public void onError(int errorCode, String description) { } }, callbackHandler); } + /** + * Expands a saved playlist file into its individual tracks, queues them on + * Kodi's active music playlist, and starts playback from the first one - so + * Now Playing shows real track info instead of the raw playlist file. + */ + private void playPlaylistFile(final String playlistFile) { + expandAndQueuePlaylist(playlistFile, true); + } + + private void queuePlaylistFile(final String playlistFile) { + expandAndQueuePlaylist(playlistFile, false); + } + + /** + * Fetches a saved playlist's tracks. If startPlayback is true, clears Kodi's + * active music playlist first and plays from the top once queued. If false, + * appends to the end of whatever's already queued, and only starts a player + * if nothing is currently playing (so we don't interrupt other music). + */ + private void expandAndQueuePlaylist(final String playlistFile, final boolean startPlayback) { + Files.GetDirectory action = new Files.GetDirectory(playlistFile, mediaType, null, + ListType.FieldsFiles.TITLE, ListType.FieldsFiles.ARTIST, + ListType.FieldsFiles.ALBUM, ListType.FieldsFiles.DURATION, + ListType.FieldsFiles.FILE); + action.execute(hostManager.getConnection(), new ApiCallback>() { + @Override + public void onSuccess(List result) { + if (!isAdded() || result.isEmpty()) return; + final Queue tracks = new LinkedList<>(result); + final HostConnection connection = hostManager.getConnection(); + if (startPlayback) { + Playlist.Clear clearAction = new Playlist.Clear(getPlaylistId(mediaType)); + clearAction.execute(connection, new ApiCallback() { + @Override + public void onSuccess(String result) { + queuePlaylistTracks(connection, tracks, true); + } + + @Override + public void onError(int errorCode, String description) { + if (!isResumed()) return; + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_queue_media_file), description)); + } + }, callbackHandler); + } else { + queuePlaylistTracks(connection, tracks, false); + } + } + + @Override + public void onError(int errorCode, String description) { + if (!isResumed()) return; + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_play_media_file), description)); + } + }, callbackHandler); + } + + // Adds tracks one at a time (same "queue is a to-do list" pattern as + // queueMediaQueueFileLocations above). Once done: starts playback from the + // top if startPlayback, otherwise only starts a player if nothing's playing. + private void queuePlaylistTracks(final HostConnection connection, final Queue tracks, + final boolean startPlayback) { + ListType.ItemFile track = tracks.poll(); + if (track == null) { + if (startPlayback) { + Player.Open openAction = new Player.Open(Player.Open.TYPE_PLAYLIST, getPlaylistId(mediaType), 0); + openAction.execute(connection, new ApiCallback() { + @Override + public void onSuccess(String result) { } + + @Override + public void onError(int errorCode, String description) { + if (!isResumed()) return; + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_play_media_file), description)); + } + }, callbackHandler); + } else { + startPlaylistIfNoActivePlayers(connection, getPlaylistId(mediaType), callbackHandler); + } + return; + } + + PlaylistType.Item item = new PlaylistType.Item(); + item.file = track.file; + Playlist.Add addAction = new Playlist.Add(getPlaylistId(mediaType), item); + addAction.execute(connection, new ApiCallback() { + @Override + public void onSuccess(String result) { + queuePlaylistTracks(connection, tracks, startPlayback); + } + + @Override + public void onError(int errorCode, String description) { + if (!isResumed()) return; + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_queue_media_file), description)); + queuePlaylistTracks(connection, tracks, startPlayback); // skip bad track + } + }, callbackHandler); + } private final Runnable queueMediaQueueFileLocations = new Runnable() { @Override public void run() { @@ -475,39 +588,54 @@ private class MediaFileListAdapter extends RecyclerView.Adapter { @Override public void onClick(View v) { final int position = (Integer)v.getTag(); - if (fileLocationItems != null) { - final FileLocation loc = fileLocationItems.get(position); - if (!loc.isDirectory) { - final PopupMenu popupMenu = new PopupMenu(getActivity(), v); - popupMenu.getMenuInflater().inflate(R.menu.file_list_item, popupMenu.getMenu()); - popupMenu.setOnMenuItemClickListener(item -> { - int itemId = item.getItemId(); - if (itemId == R.id.action_queue_item) { - queueMediaFile(loc.file); - return true; - } else if (itemId == R.id.action_play_item) { - playMediaFile(loc.file); - return true; - } else if (itemId == R.id.action_play_local_item) { - playMediaFileLocally(loc.file); - return true; - } else if (itemId == R.id.action_play_from_this_item) { - mediaQueueFileLocation.clear(); - FileLocation fl; - // start playing the selected one, then queue the rest - for (int i = position + 1; i < fileLocationItems.size(); i++) { - fl = fileLocationItems.get(i); - if (!fl.isDirectory) { - mediaQueueFileLocation.add(fl); - } + if (fileLocationItems == null) return; + final FileLocation loc = fileLocationItems.get(position); + + if (isPlaylistFile(loc.file)) { + final PopupMenu popupMenu = new PopupMenu(getActivity(), v); + popupMenu.getMenuInflater().inflate(R.menu.playlist_list_item, popupMenu.getMenu()); + popupMenu.setOnMenuItemClickListener(item -> { + int itemId = item.getItemId(); + if (itemId == R.id.action_queue_item) { + queuePlaylistFile(loc.file); + return true; + } else if (itemId == R.id.action_play_item) { + playPlaylistFile(loc.file); + return true; + } + return false; + }); + popupMenu.show(); + } else if (!loc.isDirectory) { + final PopupMenu popupMenu = new PopupMenu(getActivity(), v); + popupMenu.getMenuInflater().inflate(R.menu.file_list_item, popupMenu.getMenu()); + popupMenu.setOnMenuItemClickListener(item -> { + int itemId = item.getItemId(); + if (itemId == R.id.action_queue_item) { + queueMediaFile(loc.file); + return true; + } else if (itemId == R.id.action_play_item) { + playMediaFile(loc.file); + return true; + } else if (itemId == R.id.action_play_local_item) { + playMediaFileLocally(loc.file); + return true; + } else if (itemId == R.id.action_play_from_this_item) { + mediaQueueFileLocation.clear(); + FileLocation fl; + // start playing the selected one, then queue the rest + for (int i = position + 1; i < fileLocationItems.size(); i++) { + fl = fileLocationItems.get(i); + if (!fl.isDirectory) { + mediaQueueFileLocation.add(fl); } - playMediaFile(loc.file); - return true; } - return false; - }); - popupMenu.show(); - } + playMediaFile(loc.file); + return true; + } + return false; + }); + popupMenu.show(); } } }; @@ -623,13 +751,14 @@ public void bindView(FileLocation fileLocation, int position) { UIUtils.loadImageWithCharacterAvatar(context, hostManager, fileLocation.artUrl, fileLocation.title, art, artWidth, artHeight); - // For the popup menu - if (fileLocation.isDirectory) { + + // For the popup menu - show it for regular files, and also for + // playlist files even though Kodi reports those as "directories" + if (fileLocation.isDirectory && !isPlaylistFile(fileLocation.file)) { contextMenu.setVisibility(View.GONE); } else { contextMenu.setVisibility(View.VISIBLE); contextMenu.setTag(position); - } } diff --git a/app/src/main/res/menu/playlist_list_item.xml b/app/src/main/res/menu/playlist_list_item.xml new file mode 100644 index 000000000..d72edfff4 --- /dev/null +++ b/app/src/main/res/menu/playlist_list_item.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 014e7423a..1db858b0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ Favourites Weather System + Playlists No media center configured Add Media Center From 2c2363d59abf4c4e2d4cfd02f39bf4e2e7dffb20 Mon Sep 17 00:00:00 2001 From: elgarima77 Date: Wed, 2 Sep 2026 23:46:00 -0400 Subject: [PATCH 2/4] Remove now-unused MusicVideo section --- .../kore/ui/sections/audio/MusicActivity.java | 37 ++----------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicActivity.java b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicActivity.java index 50ba8dafd..9beb0d3d4 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicActivity.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicActivity.java @@ -24,7 +24,6 @@ import org.xbmc.kore.R; import org.xbmc.kore.ui.AbstractFragment; -import org.xbmc.kore.ui.AbstractInfoFragment; import org.xbmc.kore.ui.BaseMediaActivity; import org.xbmc.kore.utils.LogUtils; @@ -35,8 +34,7 @@ public class MusicActivity extends BaseMediaActivity implements ArtistListFragment.OnArtistSelectedListener, AlbumListFragment.OnAlbumSelectedListener, - AudioGenresListFragment.OnAudioGenreSelectedListener, - MusicVideoListFragment.OnMusicVideoSelectedListener { + AudioGenresListFragment.OnAudioGenreSelectedListener { private static final String TAG = LogUtils.makeLogTag(MusicActivity.class); public static final String ALBUMID = "album_id"; @@ -45,17 +43,13 @@ public class MusicActivity extends BaseMediaActivity public static final String ARTISTNAME = "artist_name"; public static final String GENREID = "genre_id"; public static final String GENRETITLE = "genre_title"; - public static final String MUSICVIDEOID = "music_video_id"; - public static final String MUSICVIDEOTITLE = "music_video_title"; private int selectedAlbumId = -1; private int selectedArtistId = -1; private int selectedGenreId = -1; - private int selectedMusicVideoId = -1; private String selectedAlbumTitle = null; private String selectedArtistName = null; private String selectedGenreTitle = null; - private String selectedMusicVideoTitle = null; @Override protected String getActionBarTitle() { @@ -65,8 +59,6 @@ protected String getActionBarTitle() { return selectedArtistName; } else if (selectedGenreTitle != null) { return selectedGenreTitle; - } else if (selectedMusicVideoTitle != null) { - return selectedMusicVideoTitle; } else { return getResources().getString(R.string.music); } @@ -83,11 +75,9 @@ protected void onCreate(Bundle savedInstanceState) { selectedAlbumId = savedInstanceState.getInt(ALBUMID, -1); selectedArtistId = savedInstanceState.getInt(ARTISTID, -1); selectedGenreId = savedInstanceState.getInt(GENREID, -1); - selectedMusicVideoId = savedInstanceState.getInt(MUSICVIDEOID, -1); selectedAlbumTitle = savedInstanceState.getString(ALBUMTITLE, null); selectedArtistName = savedInstanceState.getString(ARTISTNAME, null); selectedGenreTitle = savedInstanceState.getString(GENRETITLE, null); - selectedMusicVideoTitle = savedInstanceState.getString(MUSICVIDEOTITLE, null); } super.onCreate(savedInstanceState); } @@ -98,11 +88,9 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt(ALBUMID, selectedAlbumId); outState.putInt(ARTISTID, selectedArtistId); outState.putInt(GENREID, selectedGenreId); - outState.putInt(MUSICVIDEOID, selectedMusicVideoId); outState.putString(ALBUMTITLE, selectedAlbumTitle); outState.putString(ARTISTNAME, selectedArtistName); outState.putString(GENRETITLE, selectedGenreTitle); - outState.putString(MUSICVIDEOTITLE, selectedMusicVideoTitle); } @Override @@ -126,15 +114,10 @@ public boolean onOptionsItemSelected(MenuItem item) { selectedGenreId = -1; selectedGenreTitle = null; respond = true; - } else if (selectedMusicVideoId != -1) { - selectedMusicVideoId = -1; - selectedMusicVideoTitle = null; - respond = true; } if (respond) { updateActionBar(getActionBarTitle(), selectedArtistId != -1 || - selectedGenreId != -1 || - selectedMusicVideoId != -1); + selectedGenreId != -1); getSupportFragmentManager().popBackStack(); return true; } @@ -158,14 +141,10 @@ public void onBackPressed() { } else if (selectedGenreId != -1) { selectedGenreId = -1; selectedGenreTitle = null; - } else if (selectedMusicVideoId != -1) { - selectedMusicVideoId = -1; - selectedMusicVideoTitle = null; } updateActionBar(getActionBarTitle(), selectedArtistId != -1 || - selectedGenreId != -1 || - selectedMusicVideoId != -1); + selectedGenreId != -1); super.onBackPressed(); } @@ -203,14 +182,4 @@ public void onAudioGenreSelected(int genreId, String genreTitle) { updateActionBar(selectedGenreTitle, true); } - - public void onMusicVideoSelected(AbstractInfoFragment.DataHolder dataHolder, ImageView sharedImageView) { - selectedMusicVideoId = dataHolder.getId(); - selectedMusicVideoTitle = dataHolder.getTitle(); - - // Replace list fragment - dataHolder.setSquarePoster(true); - showFragment(MusicVideoInfoFragment.class, dataHolder.getBundle(), sharedImageView); - updateActionBar(selectedMusicVideoTitle, true); - } } From 1935792ad2ec7f0e2d67abf989d0d31f76944745 Mon Sep 17 00:00:00 2001 From: elgarima77 Date: Thu, 3 Sep 2026 00:16:42 -0400 Subject: [PATCH 3/4] Create playlists-feature.md Browse & Play Saved Music Playlists in Kore --- doc/playlists-feature.md | 631 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 631 insertions(+) create mode 100644 doc/playlists-feature.md diff --git a/doc/playlists-feature.md b/doc/playlists-feature.md new file mode 100644 index 000000000..19273f7f7 --- /dev/null +++ b/doc/playlists-feature.md @@ -0,0 +1,631 @@ +# Browse & Play Saved Music Playlists in Kore + +## Summary + +Kore lets you control whatever is *currently* playing on Kodi, but had no way to +browse the audio playlists saved on the Kodi machine (e.g. `Summer Hits.m3u`, +`Chill.pls`) and start one playing. This closes that gap by replacing the +**"Videos"** tab in the Music section with a **"Playlists"** tab that lists saved +playlists and lets you play or queue them, with correct track metadata shown +during playback. + +This addresses the feature request in +[xbmc/Kore#808](https://github.com/xbmc/Kore/issues/808). + +## How it works + +A "saved playlist" isn't a database entity in Kodi's music library — it's a file +sitting in a special virtual folder, `special://musicplaylists/`. The feature +is built entirely on JSON-RPC calls Kore already had wrappers for: + +1. **List playlists** — `Files.GetDirectory` with `directory: + "special://musicplaylists/"`, `media: "music"`. +2. **Expand a playlist into tracks** — `Files.GetDirectory` again, this time + passing the *playlist's own file path* as the directory. Kodi treats a + `.m3u`/`.pls`/`.xsp` file as a browsable "directory" of its own tracks, and + marks it as `filetype: "directory"` in listings — this quirk is the source + of most of the work below. +3. **Play with correct metadata** — rather than calling `Player.Open` on the + playlist file directly (which plays audio but leaves Now Playing and the + queue empty, since Kodi has no track-level metadata for "the file that is + the whole playlist"), we clear Kodi's active music playlist + (`Playlist.Clear`), add each track individually (`Playlist.Add`), then call + `Player.Open` on Kodi's *playlist*, not the file. This is the same + mechanism Kore already uses elsewhere (e.g. playing an album), just applied + to a playlist file's expanded contents. +4. **Queue without interrupting** — same track expansion, but append instead + of clear, and only auto-start playback if nothing is currently playing. + +Rather than write a new browsing screen from scratch, the feature reuses +`MediaFileListFragment` — the fragment that already powers Kore's "Files" +section — pointed at the playlists folder instead of the file-source root. +This meant the new functionality is almost entirely small, targeted edits to +existing, well-established code paths, instead of a parallel implementation. + +## Files changed + +| File | Change | +|---|---| +| `ui/sections/audio/MusicListFragment.java` | "Videos" tab → "Playlists" tab, reusing `MediaFileListFragment` | +| `ui/sections/file/MediaFileListFragment.java` | Playlist detection, correct play/queue behavior, context menu | +| `res/menu/playlist_list_item.xml` | **New.** Play/Queue context menu for playlist rows | +| `res/values/strings.xml` | Add `playlists` string (and optionally remove now-unused `music_videos`) | +| `ui/sections/audio/MusicActivity.java` | Removed now-dead music-video selection state/callback | + +--- + +## 1. `MusicListFragment.java` + +This fragment defines which fragments appear as tabs in the Music section. + +**Before:** +```java +package org.xbmc.kore.ui.sections.audio; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.viewpager2.widget.ViewPager2; + +import org.xbmc.kore.R; +import org.xbmc.kore.ui.AbstractCursorListFragment; +import org.xbmc.kore.ui.AbstractTabsFragment; +import org.xbmc.kore.utils.LogUtils; +import org.xbmc.kore.utils.TabsAdapter; + +public class MusicListFragment extends AbstractTabsFragment { + private static final String TAG = LogUtils.makeLogTag(MusicListFragment.class); + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + setHasOptionsMenu(false); + + getViewPager().registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + Fragment fragment = getCurrentSelectedFragment(); + if (fragment instanceof AbstractCursorListFragment) { + ((AbstractCursorListFragment)fragment).saveSearchState(); + } + } + }); + } + + @Override + protected TabsAdapter createTabsAdapter(DataHolder dataHolder) { + return new TabsAdapter(this) + .addTab(ArtistListFragment.class, getArguments(), R.string.artists, 1) + .addTab(AlbumListFragment.class, getArguments(), R.string.albums, 2) + .addTab(AudioGenresListFragment.class, getArguments(), R.string.genres, 3) + .addTab(SongsListFragment.class, getArguments(), R.string.songs, 4) + .addTab(MusicVideoListFragment.class, getArguments(), R.string.music_videos, 5); + } + + @Override + protected boolean shouldRememberLastTab() { + return true; + } +} +``` + +**After:** +```java +package org.xbmc.kore.ui.sections.audio; + +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.viewpager2.widget.ViewPager2; + +import org.xbmc.kore.R; +import org.xbmc.kore.jsonrpc.method.Files; +import org.xbmc.kore.ui.AbstractCursorListFragment; +import org.xbmc.kore.ui.AbstractTabsFragment; +import org.xbmc.kore.ui.sections.file.MediaFileListFragment; +import org.xbmc.kore.utils.LogUtils; +import org.xbmc.kore.utils.TabsAdapter; + +public class MusicListFragment extends AbstractTabsFragment { + private static final String TAG = LogUtils.makeLogTag(MusicListFragment.class); + + // Kodi's special virtual folder that holds saved music playlists (.m3u/.pls files) + private static final String MUSIC_PLAYLISTS_PATH = "special://musicplaylists/"; + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + setHasOptionsMenu(false); + + getViewPager().registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + Fragment fragment = getCurrentSelectedFragment(); + if (fragment instanceof AbstractCursorListFragment) { + ((AbstractCursorListFragment)fragment).saveSearchState(); + } + } + }); + } + + @Override + protected TabsAdapter createTabsAdapter(DataHolder dataHolder) { + return new TabsAdapter(this) + .addTab(ArtistListFragment.class, getArguments(), R.string.artists, 1) + .addTab(AlbumListFragment.class, getArguments(), R.string.albums, 2) + .addTab(AudioGenresListFragment.class, getArguments(), R.string.genres, 3) + .addTab(SongsListFragment.class, getArguments(), R.string.songs, 4) + .addTab(MediaFileListFragment.class, createPlaylistsTabArgs(), R.string.playlists, 5); + } + + /** + * Builds the arguments that tell the reused {@link MediaFileListFragment} to start + * browsing directly inside Kodi's saved music playlists folder, instead of starting + * at the top-level file Sources list (which is what it does with no arguments). + */ + private Bundle createPlaylistsTabArgs() { + Bundle args = new Bundle(); + args.putString(MediaFileListFragment.MEDIA_TYPE, Files.Media.MUSIC); + args.putParcelable(MediaFileListFragment.CURRENT_LOCATION, + new MediaFileListFragment.FileLocation( + getString(R.string.playlists), + MUSIC_PLAYLISTS_PATH, + true, // isDirectory + null, // no parent - this is the top of this tab + true)); // isRoot - stops back-navigation going further up + return args; + } + + @Override + protected boolean shouldRememberLastTab() { + return true; + } +} +``` + +**Why:** `MediaFileListFragment` already supports starting from an arbitrary +directory via a `CURRENT_LOCATION` argument (used by the existing "Files" +section for its Video/Music/Pictures tabs, which all start from Sources). +Passing a `FileLocation` for `special://musicplaylists/` with `isRoot = true` +makes it open directly into the playlists folder, with no Sources screen and +no ".." row above it. + +--- + +## 2. `MediaFileListFragment.java` + +This is the shared fragment behind Kore's "Files" browsing screens. All +changes here are additive/conditional — existing behavior for regular files +and folders is unchanged. + +### 2a. New import + +```java +import java.util.Locale; +``` +Needed for locale-independent, case-insensitive extension matching in +`isPlaylistFile()` below. + +### 2b. Detecting playlist files + +**New method:** +```java + /** + * Kodi marks saved playlist files (.m3u/.m3u8/.pls/.xsp) as "directories" in + * Files.GetDirectory results, since their contents can be browsed like a folder. + * We want tapping one to play/queue it instead of browsing into it. + */ + private static boolean isPlaylistFile(String file) { + String lower = file.toLowerCase(Locale.ROOT); + return lower.endsWith(".m3u") || lower.endsWith(".m3u8") + || lower.endsWith(".pls") || lower.endsWith(".xsp"); + } +``` + +**Why:** Kodi's `Files.GetDirectory` reports playlist files with +`filetype: "directory"` (so `ListType.ItemFile.isDirectory` is `true` for +them), because their contents can be browsed the same way a folder's can. Every +other piece of this feature exists to work around that one API quirk. + +### 2c. Tapping an item — play instead of browse + +**Before:** +```java + void handleFileSelect(FileLocation f) { + if (f == null || f.file == null) { + // If at first level, browse Sources + browseSources(); + } else if (f.isDirectory) { + browseDirectory(f); + } else { + playMediaFile(f.file); + } + } +``` + +**After:** +```java + void handleFileSelect(FileLocation f) { + if (f == null || f.file == null) { + // If at first level, browse Sources + browseSources(); + } else if (isPlaylistFile(f.file)) { + playPlaylistFile(f.file); + } else if (f.isDirectory) { + browseDirectory(f); + } else { + playMediaFile(f.file); + } + } +``` + +**Why:** The playlist check has to come *before* the `isDirectory` check, +since playlist files are (mis)reported as directories. Everything else is +unaffected — regular folders and regular files behave exactly as before. + +### 2d. Playing/queueing a playlist with correct track metadata + +```java + private void playPlaylistFile(final String playlistFile) { + expandAndQueuePlaylist(playlistFile, true); + } + + private void queuePlaylistFile(final String playlistFile) { + expandAndQueuePlaylist(playlistFile, false); + } + + /** + * Fetches a saved playlist's tracks. If startPlayback is true, clears Kodi's + * active music playlist first and plays from the top once queued. If false, + * appends to the end of whatever's already queued, and only starts a player + * if nothing is currently playing (so we don't interrupt other music). + */ + private void expandAndQueuePlaylist(final String playlistFile, final boolean startPlayback) { + Files.GetDirectory action = new Files.GetDirectory(playlistFile, mediaType, null, + ListType.FieldsFiles.TITLE, ListType.FieldsFiles.ARTIST, + ListType.FieldsFiles.ALBUM, ListType.FieldsFiles.DURATION, + ListType.FieldsFiles.FILE); + action.execute(hostManager.getConnection(), new ApiCallback>() { + @Override + public void onSuccess(List result) { + if (!isAdded() || result.isEmpty()) return; + final Queue tracks = new LinkedList<>(result); + final HostConnection connection = hostManager.getConnection(); + + if (startPlayback) { + Playlist.Clear clearAction = new Playlist.Clear(getPlaylistId(mediaType)); + clearAction.execute(connection, new ApiCallback() { + @Override + public void onSuccess(String result) { + queuePlaylistTracks(connection, tracks, true); + } + + @Override + public void onError(int errorCode, String description) { + if (!isResumed()) return; + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_queue_media_file), description)); + } + }, callbackHandler); + } else { + queuePlaylistTracks(connection, tracks, false); + } + } + + @Override + public void onError(int errorCode, String description) { + if (!isResumed()) return; + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_play_media_file), description)); + } + }, callbackHandler); + } + + // Adds tracks one at a time (same "queue is a to-do list" pattern used by + // queueMediaQueueFileLocations above). Once done: starts playback from the + // top if startPlayback, otherwise only starts a player if nothing's playing. + private void queuePlaylistTracks(final HostConnection connection, final Queue tracks, + final boolean startPlayback) { + ListType.ItemFile track = tracks.poll(); + if (track == null) { + if (startPlayback) { + Player.Open openAction = new Player.Open(Player.Open.TYPE_PLAYLIST, getPlaylistId(mediaType), 0); + openAction.execute(connection, new ApiCallback() { + @Override + public void onSuccess(String result) { } + + @Override + public void onError(int errorCode, String description) { + if (!isResumed()) return; + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_play_media_file), description)); + } + }, callbackHandler); + } else { + startPlaylistIfNoActivePlayers(connection, getPlaylistId(mediaType), callbackHandler); + } + return; + } + + PlaylistType.Item item = new PlaylistType.Item(); + item.file = track.file; + Playlist.Add addAction = new Playlist.Add(getPlaylistId(mediaType), item); + addAction.execute(connection, new ApiCallback() { + @Override + public void onSuccess(String result) { + queuePlaylistTracks(connection, tracks, startPlayback); + } + + @Override + public void onError(int errorCode, String description) { + if (!isResumed()) return; + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_queue_media_file), description)); + queuePlaylistTracks(connection, tracks, startPlayback); // skip bad track + } + }, callbackHandler); + } +``` + +**Why not just call `Player.Open` on the playlist file directly?** That was +the first approach tried, and it does play the audio — but Kodi treats the +whole playlist file as a single opaque "item" it's now playing, with no +per-track metadata. Now Playing and the queue both come up empty. Every other +media type in Kore that plays a *set* of items (e.g. an album) expands it into +individual tracks and queues them via `Playlist.Add`, then opens Kodi's +playlist rather than a file — this mirrors that existing pattern instead of +introducing a new one. `queuePlaylistTracks` recurses instead of looping +because each `Playlist.Add` call is asynchronous; chaining off each +`onSuccess` guarantees tracks are added in order, one at a time, before +playback starts. + +No new imports were needed for this section — `Playlist`, `Player`, +`PlaylistType`, `Queue`, and `LinkedList` were already imported in this file +for its existing queueing logic. + +### 2e. Context menu — show it for playlists, with playlist-appropriate actions + +**Before (`ViewHolder.bindView`):** +```java + // For the popup menu + if (fileLocation.isDirectory) { + contextMenu.setVisibility(View.GONE); + } else { + contextMenu.setVisibility(View.VISIBLE); + contextMenu.setTag(position); + } +``` + +**After:** +```java + // For the popup menu - show it for regular files, and also for + // playlist files even though Kodi reports those as "directories" + if (fileLocation.isDirectory && !isPlaylistFile(fileLocation.file)) { + contextMenu.setVisibility(View.GONE); + } else { + contextMenu.setVisibility(View.VISIBLE); + contextMenu.setTag(position); + } +``` + +**Before (`itemMenuClickListener`):** +```java + private final View.OnClickListener itemMenuClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + final int position = (Integer)v.getTag(); + if (fileLocationItems != null) { + final FileLocation loc = fileLocationItems.get(position); + if (!loc.isDirectory) { + final PopupMenu popupMenu = new PopupMenu(getActivity(), v); + popupMenu.getMenuInflater().inflate(R.menu.file_list_item, popupMenu.getMenu()); + popupMenu.setOnMenuItemClickListener(item -> { + int itemId = item.getItemId(); + if (itemId == R.id.action_queue_item) { + queueMediaFile(loc.file); + return true; + } else if (itemId == R.id.action_play_item) { + playMediaFile(loc.file); + return true; + } else if (itemId == R.id.action_play_local_item) { + playMediaFileLocally(loc.file); + return true; + } else if (itemId == R.id.action_play_from_this_item) { + mediaQueueFileLocation.clear(); + FileLocation fl; + for (int i = position + 1; i < fileLocationItems.size(); i++) { + fl = fileLocationItems.get(i); + if (!fl.isDirectory) { + mediaQueueFileLocation.add(fl); + } + } + playMediaFile(loc.file); + return true; + } + return false; + }); + popupMenu.show(); + } + } + } + }; +``` + +**After:** +```java + private final View.OnClickListener itemMenuClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + final int position = (Integer)v.getTag(); + if (fileLocationItems == null) return; + final FileLocation loc = fileLocationItems.get(position); + + if (isPlaylistFile(loc.file)) { + final PopupMenu popupMenu = new PopupMenu(getActivity(), v); + popupMenu.getMenuInflater().inflate(R.menu.playlist_list_item, popupMenu.getMenu()); + popupMenu.setOnMenuItemClickListener(item -> { + int itemId = item.getItemId(); + if (itemId == R.id.action_queue_item) { + queuePlaylistFile(loc.file); + return true; + } else if (itemId == R.id.action_play_item) { + playPlaylistFile(loc.file); + return true; + } + return false; + }); + popupMenu.show(); + } else if (!loc.isDirectory) { + final PopupMenu popupMenu = new PopupMenu(getActivity(), v); + popupMenu.getMenuInflater().inflate(R.menu.file_list_item, popupMenu.getMenu()); + popupMenu.setOnMenuItemClickListener(item -> { + int itemId = item.getItemId(); + if (itemId == R.id.action_queue_item) { + queueMediaFile(loc.file); + return true; + } else if (itemId == R.id.action_play_item) { + playMediaFile(loc.file); + return true; + } else if (itemId == R.id.action_play_local_item) { + playMediaFileLocally(loc.file); + return true; + } else if (itemId == R.id.action_play_from_this_item) { + mediaQueueFileLocation.clear(); + FileLocation fl; + for (int i = position + 1; i < fileLocationItems.size(); i++) { + fl = fileLocationItems.get(i); + if (!fl.isDirectory) { + mediaQueueFileLocation.add(fl); + } + } + playMediaFile(loc.file); + return true; + } + return false; + }); + popupMenu.show(); + } + } + }; +``` + +**Why:** "Play locally"/"stream" and "Play from this item" (which queues the +rest of the current directory listing behind the tapped item) are both +meaningless for a playlist row — it represents a whole playlist, not a +position within a directory of individual songs. So playlist rows get their +own, smaller menu (`playlist_list_item.xml`, below) instead of reusing +`file_list_item.xml`; regular file rows are completely unaffected. + +--- + +## 3. `res/menu/playlist_list_item.xml` (new file) + +```xml + + + + + +``` + +Reuses the existing `queue_file`/`play_file` strings and `action_queue_item`/ +`action_play_item` IDs from `file_list_item.xml`, so wording stays consistent +with the rest of the app and no new string resources were needed for the menu +itself. + +--- + +## 4. `res/values/strings.xml` + +Add (if not already present): +```xml +Playlists +``` + +The now-unused `music_videos` string can be removed once confirmed unreferenced +elsewhere (see cleanup note below). + +--- + +## 5. `MusicActivity.java` — cleanup + +Once Playlists fully replaced the Videos tab, the music-video selection state +and callback in `MusicActivity` became dead code (Option 1 — reusing +`MediaFileListFragment`, which handles browsing *and* playback internally — +meant `MusicActivity` needed no new callback of its own, so this is a pure +subtraction with no corresponding addition). + +**Before:** +```java +public class MusicActivity extends BaseMediaActivity + implements ArtistListFragment.OnArtistSelectedListener, + AlbumListFragment.OnAlbumSelectedListener, + AudioGenresListFragment.OnAudioGenreSelectedListener, + MusicVideoListFragment.OnMusicVideoSelectedListener { + ... + public static final String MUSICVIDEOID = "music_video_id"; + public static final String MUSICVIDEOTITLE = "music_video_title"; + ... + private int selectedMusicVideoId = -1; + private String selectedMusicVideoTitle = null; + ... + // + branches for selectedMusicVideoId/-Title in getActionBarTitle(), + // onCreate(), onSaveInstanceState(), onOptionsItemSelected(), onBackPressed() + ... + public void onMusicVideoSelected(AbstractInfoFragment.DataHolder dataHolder, ImageView sharedImageView) { + selectedMusicVideoId = dataHolder.getId(); + selectedMusicVideoTitle = dataHolder.getTitle(); + dataHolder.setSquarePoster(true); + showFragment(MusicVideoInfoFragment.class, dataHolder.getBundle(), sharedImageView); + updateActionBar(selectedMusicVideoTitle, true); + } +} +``` + +**After:** all of the above removed — the `implements` clause drops +`MusicVideoListFragment.OnMusicVideoSelectedListener`, the now-unused +`import org.xbmc.kore.ui.AbstractInfoFragment;` is removed, the two constants +and two fields are gone, every method's music-video branch is gone, and +`onMusicVideoSelected(...)` is deleted entirely. `MusicActivity` now only +tracks album/artist/genre selection state. + +**Follow-up cleanup** (verified safe via *Find Usages* in Android Studio +before deleting, since "Music Videos" is also a real Kodi video-library +category and these classes needed to be confirmed as audio-section-only +first): +- Deleted `ui/sections/audio/MusicVideoListFragment.java` +- Deleted `ui/sections/audio/MusicVideoInfoFragment.java` +- Deleted their now-unused layout XML files +- Removed the unreferenced `music_videos` string + +--- + +## Testing performed + +- Playlists tab lists saved `.m3u`/`.pls` files from a real Kodi instance +- Subfolders inside the playlists folder (if any) browse correctly, unaffected by these changes +- Tapping a playlist clears the current queue, plays it from the start, and Now Playing shows correct track/artist info +- Context menu **Queue** appends a playlist to the end of an already-playing queue without interrupting playback +- Context menu **Play** behaves the same as tapping the row +- Regular (non-playlist) files and folders elsewhere in the Files section behave exactly as before +- Clean build with no references to the removed music-video classes/strings remaining + +## Known limitations / possible follow-ups + +- Tapping a playlist plays/queues it immediately; there's no way to preview + its track list before committing (an intermediate "browse into it" step was + functional during development, since Kodi does support it, but was replaced + in favor of immediate play/queue for a simpler v1). +- A dedicated "playlist details" screen — showing the track list with + play/queue as a deliberate follow-up action, similar to how Album details + work — is a natural next step if immediate play/queue turns out to be too + eager for some users. From 30e66bbdb30442b2bd2feee29e1c6017b1928753 Mon Sep 17 00:00:00 2001 From: elgarima77 Date: Thu, 3 Sep 2026 19:30:09 -0400 Subject: [PATCH 4/4] Scope playlist handling to the Playlists tab; guard against stale/overlapping async queue operations --- .../ui/sections/audio/MusicListFragment.java | 1 + .../sections/file/MediaFileListFragment.java | 79 ++++++++++++++----- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicListFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicListFragment.java index f15307592..bdd4b59fa 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicListFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/audio/MusicListFragment.java @@ -78,6 +78,7 @@ protected TabsAdapter createTabsAdapter(DataHolder dataHolder) { private Bundle createPlaylistsTabArgs() { Bundle args = new Bundle(); args.putString(MediaFileListFragment.MEDIA_TYPE, Files.Media.MUSIC); + args.putBoolean(MediaFileListFragment.ENABLE_PLAYLIST_HANDLING, true); args.putParcelable(MediaFileListFragment.CURRENT_LOCATION, new MediaFileListFragment.FileLocation( getString(R.string.playlists), // title (not shown here - this location is the root of the tab) diff --git a/app/src/main/java/org/xbmc/kore/ui/sections/file/MediaFileListFragment.java b/app/src/main/java/org/xbmc/kore/ui/sections/file/MediaFileListFragment.java index fb8e95975..04ad4c81f 100644 --- a/app/src/main/java/org/xbmc/kore/ui/sections/file/MediaFileListFragment.java +++ b/app/src/main/java/org/xbmc/kore/ui/sections/file/MediaFileListFragment.java @@ -69,6 +69,11 @@ public class MediaFileListFragment extends AbstractListFragment { public static final String SORT_METHOD = "sortMethod"; public static final String CURRENT_LOCATION = "currentLocation"; public static final String DELAY_LOAD = "delayLoad"; + // Opt-in flag: only the Playlists tab sets this. Every other tab that + // reuses this fragment (Files > Video/Music/Pictures) keeps its original + // behavior untouched - playlist files there are browsed like any other + // "directory", never auto-played. + public static final String ENABLE_PLAYLIST_HANDLING = "enablePlaylistHandling"; private static final String ADDON_SOURCE = "addons:"; @@ -88,6 +93,11 @@ public class MediaFileListFragment extends AbstractListFragment { * Whether to delay loading until onResume, instead of when we get a valid connection */ boolean delayLoad = false; + /** + * Whether playlist files (.m3u/.pls/.xsp) should be played/queued on tap + * instead of browsed into. Only true for the Music section's Playlists tab. + */ + boolean enablePlaylistHandling = false; Queue mediaQueueFileLocation = new LinkedList<>(); @@ -107,6 +117,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat sortMethod = args.getParcelable(SORT_METHOD); currentLocation = args.getParcelable(CURRENT_LOCATION); delayLoad = args.getBoolean(DELAY_LOAD, false); + enablePlaylistHandling = args.getBoolean(ENABLE_PLAYLIST_HANDLING, false); } if (savedInstanceState != null) { @@ -127,6 +138,14 @@ public void onResume() { } } + @Override + public void onPause() { + super.onPause(); + // Invalidate any in-flight playlist play/queue operation - its callbacks + // will now see a mismatched id and stop instead of continuing in the background + playlistOperationId++; + } + @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); @@ -173,7 +192,7 @@ void handleFileSelect(FileLocation f) { if (f == null || f.file == null) { // If at first level, browse Sources browseSources(); - } else if (isPlaylistFile(f.file)) { + } else if (enablePlaylistHandling && isPlaylistFile(f.file)) { playPlaylistFile(f.file); } else if (f.isDirectory) { browseDirectory(f); @@ -352,6 +371,18 @@ public void onError(int errorCode, String description) { * Kodi's active music playlist, and starts playback from the first one - so * Now Playing shows real track info instead of the raw playlist file. */ + // Bumped every time a new playlist play/queue operation starts, and again + // whenever the fragment is paused. Callbacks compare their captured value + // against this field before doing anything, so a stale or superseded + // operation's callbacks become no-ops instead of mutating Kodi's queue + // behind the user's back (e.g. after navigating away, or after starting + // a second playlist operation before the first one finished). + private int playlistOperationId = 0; + + private boolean isValidPlaylistOperation(int operationId) { + return isAdded() && operationId == playlistOperationId; + } + private void playPlaylistFile(final String playlistFile) { expandAndQueuePlaylist(playlistFile, true); } @@ -367,6 +398,8 @@ private void queuePlaylistFile(final String playlistFile) { * if nothing is currently playing (so we don't interrupt other music). */ private void expandAndQueuePlaylist(final String playlistFile, final boolean startPlayback) { + final int operationId = ++playlistOperationId; + Files.GetDirectory action = new Files.GetDirectory(playlistFile, mediaType, null, ListType.FieldsFiles.TITLE, ListType.FieldsFiles.ARTIST, ListType.FieldsFiles.ALBUM, ListType.FieldsFiles.DURATION, @@ -374,7 +407,7 @@ private void expandAndQueuePlaylist(final String playlistFile, final boolean sta action.execute(hostManager.getConnection(), new ApiCallback>() { @Override public void onSuccess(List result) { - if (!isAdded() || result.isEmpty()) return; + if (!isValidPlaylistOperation(operationId) || result.isEmpty()) return; final Queue tracks = new LinkedList<>(result); final HostConnection connection = hostManager.getConnection(); @@ -383,23 +416,24 @@ public void onSuccess(List result) { clearAction.execute(connection, new ApiCallback() { @Override public void onSuccess(String result) { - queuePlaylistTracks(connection, tracks, true); + if (!isValidPlaylistOperation(operationId)) return; + queuePlaylistTracks(connection, tracks, true, operationId); } @Override public void onError(int errorCode, String description) { - if (!isResumed()) return; + if (!isValidPlaylistOperation(operationId) || !isResumed()) return; UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_queue_media_file), description)); } }, callbackHandler); } else { - queuePlaylistTracks(connection, tracks, false); + queuePlaylistTracks(connection, tracks, false, operationId); } } @Override public void onError(int errorCode, String description) { - if (!isResumed()) return; + if (!isValidPlaylistOperation(operationId) || !isResumed()) return; UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_play_media_file), description)); } }, callbackHandler); @@ -409,7 +443,9 @@ public void onError(int errorCode, String description) { // queueMediaQueueFileLocations above). Once done: starts playback from the // top if startPlayback, otherwise only starts a player if nothing's playing. private void queuePlaylistTracks(final HostConnection connection, final Queue tracks, - final boolean startPlayback) { + final boolean startPlayback, final int operationId) { + if (!isValidPlaylistOperation(operationId)) return; // superseded or torn down + ListType.ItemFile track = tracks.poll(); if (track == null) { if (startPlayback) { @@ -420,7 +456,7 @@ public void onSuccess(String result) { } @Override public void onError(int errorCode, String description) { - if (!isResumed()) return; + if (!isValidPlaylistOperation(operationId) || !isResumed()) return; UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_play_media_file), description)); } }, callbackHandler); @@ -436,17 +472,20 @@ public void onError(int errorCode, String description) { addAction.execute(connection, new ApiCallback() { @Override public void onSuccess(String result) { - queuePlaylistTracks(connection, tracks, startPlayback); + queuePlaylistTracks(connection, tracks, startPlayback, operationId); } @Override public void onError(int errorCode, String description) { - if (!isResumed()) return; - UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_queue_media_file), description)); - queuePlaylistTracks(connection, tracks, startPlayback); // skip bad track + if (!isValidPlaylistOperation(operationId)) return; + if (isResumed()) { + UIUtils.showSnackbar(getView(), String.format(getString(R.string.error_queue_media_file), description)); + } + queuePlaylistTracks(connection, tracks, startPlayback, operationId); // skip bad track } }, callbackHandler); } + private final Runnable queueMediaQueueFileLocations = new Runnable() { @Override public void run() { @@ -591,7 +630,7 @@ public void onClick(View v) { if (fileLocationItems == null) return; final FileLocation loc = fileLocationItems.get(position); - if (isPlaylistFile(loc.file)) { + if (enablePlaylistHandling && isPlaylistFile(loc.file)) { final PopupMenu popupMenu = new PopupMenu(getActivity(), v); popupMenu.getMenuInflater().inflate(R.menu.playlist_list_item, popupMenu.getMenu()); popupMenu.setOnMenuItemClickListener(item -> { @@ -684,8 +723,8 @@ public FileLocation getItem(int position) { @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(ctx) - .inflate(resource, parent, false); - return new ViewHolder(view, getContext(), hostManager, artWidth, artHeight, itemMenuClickListener); + .inflate(resource, parent, false); + return new ViewHolder(view, getContext(), hostManager, artWidth, artHeight, enablePlaylistHandling, itemMenuClickListener); } @Override @@ -723,14 +762,16 @@ private static class ViewHolder extends RecyclerView.ViewHolder { int artWidth; int artHeight; Context context; + boolean enablePlaylistHandling; ViewHolder(View itemView, Context context, HostManager hostManager, int artWidth, int artHeight, - View.OnClickListener itemMenuClickListener) { + boolean enablePlaylistHandling, View.OnClickListener itemMenuClickListener) { super(itemView); this.context = context; this.hostManager = hostManager; this.artWidth = artWidth; this.artHeight = artHeight; + this.enablePlaylistHandling = enablePlaylistHandling; art = itemView.findViewById(R.id.art); title = itemView.findViewById(R.id.title); details = itemView.findViewById(R.id.details); @@ -753,8 +794,10 @@ public void bindView(FileLocation fileLocation, int position) { art, artWidth, artHeight); // For the popup menu - show it for regular files, and also for - // playlist files even though Kodi reports those as "directories" - if (fileLocation.isDirectory && !isPlaylistFile(fileLocation.file)) { + // playlist files (when this fragment is configured to treat them + // as such) even though Kodi reports those as "directories" + boolean isPlaylistRow = enablePlaylistHandling && isPlaylistFile(fileLocation.file); + if (fileLocation.isDirectory && !isPlaylistRow) { contextMenu.setVisibility(View.GONE); } else { contextMenu.setVisibility(View.VISIBLE);