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