diff --git a/USAGE.md b/USAGE.md index 05abee95..d3a946db 100644 --- a/USAGE.md +++ b/USAGE.md @@ -69,6 +69,21 @@ However, if you want to limit or change libraries you could use a workaround, if You can create multiple users , one for each library, and save each of them in Tempus app. +### Folder or index playback + +If your Subsonic-compatible server exposes the folder tree **or** provides an artist index (for example Gonic, Navidrome, or any backend with folder browsing enabled), Tempus lets you play an entire folder from anywhere in the library hierarchy: + +

+ + +

+ +- The **Library ▸ Music folders** screen shows each top-level folder with a play icon only after you drill into it. The root entry remains a simple navigator. +- When viewing **inner folders** **or artist index entries**, tap the new play button to immediately enqueue every audio track inside that folder/index and all nested subfolders. +- Video files are excluded automatically, so only playable audio ends up in the queue. + +No extra config is needed—Tempus adjusts based on the connected backend. + ### Now Playing Screen On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons. diff --git a/app/src/main/java/com/cappielloantonio/tempo/interfaces/ClickCallback.java b/app/src/main/java/com/cappielloantonio/tempo/interfaces/ClickCallback.java index d65695cb..47ca6db8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/interfaces/ClickCallback.java +++ b/app/src/main/java/com/cappielloantonio/tempo/interfaces/ClickCallback.java @@ -27,8 +27,11 @@ public interface ClickCallback { default void onInternetRadioStationClick(Bundle bundle) {} default void onInternetRadioStationLongClick(Bundle bundle) {} default void onMusicFolderClick(Bundle bundle) {} + default void onMusicFolderPlay(Bundle bundle) {} default void onMusicDirectoryClick(Bundle bundle) {} + default void onMusicDirectoryPlay(Bundle bundle) {} default void onMusicIndexClick(Bundle bundle) {} + default void onMusicIndexPlay(Bundle bundle) {} default void onDownloadGroupLongClick(Bundle bundle) {} default void onShareClick(Bundle bundle) {} default void onShareLongClick(Bundle bundle) {} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/MusicDirectoryAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/MusicDirectoryAdapter.java index f186bee6..679c262b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/MusicDirectoryAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/MusicDirectoryAdapter.java @@ -53,7 +53,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter onLongClick()); item.musicDirectoryMoreButton.setOnClickListener(v -> onClick()); + item.musicDirectoryPlayButton.setOnClickListener(v -> onPlayClick()); } public void onClick() { @@ -107,5 +108,13 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter onClick()); item.musicIndexMoreButton.setOnClickListener(v -> onClick()); + item.musicIndexPlayButton.setOnClickListener(v -> onPlayClick()); } public void onClick() { @@ -83,5 +84,11 @@ public class MusicIndexAdapter extends RecyclerView.Adapter mediaBrowserListenableFuture; + private DirectoryRepository directoryRepository; private MenuItem menuItem; @@ -77,6 +84,7 @@ public class DirectoryFragment extends Fragment implements ClickCallback { bind = FragmentDirectoryBinding.inflate(inflater, container, false); View view = bind.getRoot(); directoryViewModel = new ViewModelProvider(requireActivity()).get(DirectoryViewModel.class); + directoryRepository = new DirectoryRepository(); initAppBar(); initDirectoryListView(); @@ -197,4 +205,57 @@ public class DirectoryFragment extends Fragment implements ClickCallback { public void onMusicDirectoryClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle); } + + @Override + public void onMusicDirectoryPlay(Bundle bundle) { + String directoryId = bundle.getString(Constants.MUSIC_DIRECTORY_ID); + if (directoryId != null) { + Toast.makeText(requireContext(), getString(R.string.folder_play_collecting), Toast.LENGTH_SHORT).show(); + collectAndPlayDirectorySongs(directoryId); + } + } + + private void collectAndPlayDirectorySongs(String directoryId) { + List allSongs = new ArrayList<>(); + AtomicInteger pendingRequests = new AtomicInteger(0); + + collectSongsFromDirectory(directoryId, allSongs, pendingRequests, () -> { + if (!allSongs.isEmpty()) { + activity.runOnUiThread(() -> { + MediaManager.startQueue(mediaBrowserListenableFuture, allSongs, 0); + activity.setBottomSheetInPeek(true); + Toast.makeText(requireContext(), getString(R.string.folder_play_playing, allSongs.size()), Toast.LENGTH_SHORT).show(); + }); + } else { + activity.runOnUiThread(() -> { + Toast.makeText(requireContext(), getString(R.string.folder_play_no_songs), Toast.LENGTH_SHORT).show(); + }); + } + }); + } + + private void collectSongsFromDirectory(String directoryId, List allSongs, AtomicInteger pendingRequests, Runnable onComplete) { + pendingRequests.incrementAndGet(); + + directoryRepository.getMusicDirectory(directoryId).observe(getViewLifecycleOwner(), directory -> { + if (directory != null && directory.getChildren() != null) { + for (Child child : directory.getChildren()) { + if (child.isDir()) { + // It's a subdirectory, recurse into it + collectSongsFromDirectory(child.getId(), allSongs, pendingRequests, onComplete); + } else if (!child.isVideo()) { + // It's a song, add it to the list + synchronized (allSongs) { + allSongs.add(child); + } + } + } + } + + // Decrement pending requests and check if we're done + if (pendingRequests.decrementAndGet() == 0) { + onComplete.run(); + } + }); + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/IndexFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/IndexFragment.java index 97fd580c..42ffac3e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/IndexFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/IndexFragment.java @@ -1,27 +1,40 @@ package com.cappielloantonio.tempo.ui.fragment; +import android.content.ComponentName; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; +import androidx.media3.session.MediaBrowser; +import androidx.media3.session.SessionToken; import androidx.navigation.Navigation; import androidx.recyclerview.widget.LinearLayoutManager; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.databinding.FragmentIndexBinding; import com.cappielloantonio.tempo.interfaces.ClickCallback; +import com.cappielloantonio.tempo.repository.DirectoryRepository; +import com.cappielloantonio.tempo.service.MediaManager; +import com.cappielloantonio.tempo.service.MediaService; +import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.MusicFolder; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.MusicIndexAdapter; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.IndexUtil; import com.cappielloantonio.tempo.viewmodel.IndexViewModel; +import com.google.common.util.concurrent.ListenableFuture; @UnstableApi public class IndexFragment extends Fragment implements ClickCallback { @@ -32,6 +45,8 @@ public class IndexFragment extends Fragment implements ClickCallback { private IndexViewModel indexViewModel; private MusicIndexAdapter musicIndexAdapter; + private ListenableFuture mediaBrowserListenableFuture; + private DirectoryRepository directoryRepository; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -40,6 +55,7 @@ public class IndexFragment extends Fragment implements ClickCallback { bind = FragmentIndexBinding.inflate(inflater, container, false); View view = bind.getRoot(); indexViewModel = new ViewModelProvider(requireActivity()).get(IndexViewModel.class); + directoryRepository = new DirectoryRepository(); initAppBar(); initDirectoryListView(); @@ -48,6 +64,18 @@ public class IndexFragment extends Fragment implements ClickCallback { return view; } + @Override + public void onStart() { + super.onStart(); + initializeMediaBrowser(); + } + + @Override + public void onStop() { + releaseMediaBrowser(); + super.onStop(); + } + @Override public void onDestroyView() { super.onDestroyView(); @@ -107,4 +135,65 @@ public class IndexFragment extends Fragment implements ClickCallback { public void onMusicIndexClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle); } + + @Override + public void onMusicIndexPlay(Bundle bundle) { + String directoryId = bundle.getString(Constants.MUSIC_DIRECTORY_ID); + if (directoryId != null) { + Toast.makeText(requireContext(), getString(R.string.folder_play_collecting), Toast.LENGTH_SHORT).show(); + collectAndPlayDirectorySongs(directoryId); + } + } + + private void initializeMediaBrowser() { + mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); + } + + private void releaseMediaBrowser() { + MediaBrowser.releaseFuture(mediaBrowserListenableFuture); + } + + private void collectAndPlayDirectorySongs(String directoryId) { + List allSongs = new ArrayList<>(); + AtomicInteger pendingRequests = new AtomicInteger(0); + + collectSongsFromDirectory(directoryId, allSongs, pendingRequests, () -> { + if (!allSongs.isEmpty()) { + activity.runOnUiThread(() -> { + MediaManager.startQueue(mediaBrowserListenableFuture, allSongs, 0); + activity.setBottomSheetInPeek(true); + Toast.makeText(requireContext(), getString(R.string.folder_play_playing, allSongs.size()), Toast.LENGTH_SHORT).show(); + }); + } else { + activity.runOnUiThread(() -> { + Toast.makeText(requireContext(), getString(R.string.folder_play_no_songs), Toast.LENGTH_SHORT).show(); + }); + } + }); + } + + private void collectSongsFromDirectory(String directoryId, List allSongs, AtomicInteger pendingRequests, Runnable onComplete) { + pendingRequests.incrementAndGet(); + + directoryRepository.getMusicDirectory(directoryId).observe(getViewLifecycleOwner(), directory -> { + if (directory != null && directory.getChildren() != null) { + for (Child child : directory.getChildren()) { + if (child.isDir()) { + // It's a subdirectory, recurse into it + collectSongsFromDirectory(child.getId(), allSongs, pendingRequests, onComplete); + } else if (!child.isVideo()) { + // It's a song, add it to the list + synchronized (allSongs) { + allSongs.add(child); + } + } + } + } + + // Decrement pending requests and check if we're done + if (pendingRequests.decrementAndGet() == 0) { + onComplete.run(); + } + }); + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/LibraryFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/LibraryFragment.java index 711b1c6e..b50ee60f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/LibraryFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/LibraryFragment.java @@ -11,7 +11,11 @@ import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; +import androidx.media3.session.MediaBrowser; +import androidx.media3.session.SessionToken; import androidx.navigation.Navigation; + +import android.content.ComponentName; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; @@ -31,6 +35,8 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.LibraryViewModel; import com.google.android.material.appbar.MaterialToolbar; +import com.cappielloantonio.tempo.service.MediaService; +import com.google.common.util.concurrent.ListenableFuture; import java.util.Objects; @@ -49,6 +55,7 @@ public class LibraryFragment extends Fragment implements ClickCallback { private PlaylistHorizontalAdapter playlistHorizontalAdapter; private MaterialToolbar materialToolbar; + private ListenableFuture mediaBrowserListenableFuture; @Nullable @Override @@ -79,6 +86,7 @@ public class LibraryFragment extends Fragment implements ClickCallback { @Override public void onStart() { super.onStart(); + initializeMediaBrowser(); activity.setBottomNavigationBarVisibility(true); } @@ -292,4 +300,8 @@ public class LibraryFragment extends Fragment implements ClickCallback { public void onMusicFolderClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.indexFragment, bundle); } + + private void initializeMediaBrowser() { + mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); + } } diff --git a/app/src/main/res/layout/item_library_music_directory.xml b/app/src/main/res/layout/item_library_music_directory.xml index c3558001..71eee012 100644 --- a/app/src/main/res/layout/item_library_music_directory.xml +++ b/app/src/main/res/layout/item_library_music_directory.xml @@ -19,12 +19,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - @@ -33,13 +36,14 @@ style="@style/LabelMedium" android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_marginStart="12dp" android:ellipsize="marquee" android:paddingEnd="12dp" android:singleLine="true" android:text="@string/label_placeholder" app:layout_constraintBottom_toBottomOf="@id/music_directory_cover_image_view" app:layout_constraintEnd_toStartOf="@+id/music_directory_more_button" - app:layout_constraintStart_toEndOf="@+id/cover_image_separator" + app:layout_constraintStart_toEndOf="@+id/music_directory_play_button" app:layout_constraintTop_toTopOf="@+id/music_directory_cover_image_view" /> - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_library_music_index.xml b/app/src/main/res/layout/item_library_music_index.xml index 2a319726..8894782f 100644 --- a/app/src/main/res/layout/item_library_music_index.xml +++ b/app/src/main/res/layout/item_library_music_index.xml @@ -20,12 +20,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - @@ -34,13 +36,14 @@ style="@style/LabelMedium" android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_marginStart="12dp" android:ellipsize="marquee" android:paddingEnd="12dp" android:singleLine="true" android:text="@string/label_placeholder" app:layout_constraintBottom_toBottomOf="@id/music_index_cover_image_view" app:layout_constraintEnd_toStartOf="@+id/music_index_more_button" - app:layout_constraintStart_toEndOf="@+id/cover_image_separator" + app:layout_constraintStart_toEndOf="@+id/music_index_play_button" app:layout_constraintTop_toTopOf="@+id/music_index_cover_image_view" /> If enabled, show the album details like genre, song count etc. on the album page Sort artists by album count If enabled, sort the artists by album count. Sort by name if disabled. + + Collecting songs from folder… + Playing %d songs + No songs found in folder diff --git a/mockup/usage/music_folders_playback.png b/mockup/usage/music_folders_playback.png new file mode 100755 index 00000000..aef2038b Binary files /dev/null and b/mockup/usage/music_folders_playback.png differ diff --git a/mockup/usage/music_folders_root.png b/mockup/usage/music_folders_root.png new file mode 100755 index 00000000..3d903124 Binary files /dev/null and b/mockup/usage/music_folders_root.png differ