File browser support

This commit is contained in:
Kelvin
2025-10-30 21:19:47 +01:00
parent da44e86163
commit 9b97e05e3b
8 changed files with 491 additions and 13 deletions
@@ -61,6 +61,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
@@ -191,6 +192,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
lateinit var _fragLibraryArtists: LibraryArtistsFragment;
lateinit var _fragLibraryArtist: LibraryArtistFragment;
lateinit var _fragLibraryVideos: LibraryVideosFragment;
lateinit var _fragLibraryFiles: LibraryFilesFragment;
lateinit var _fragBrowser: BrowserFragment;
@@ -368,6 +370,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibraryArtists = LibraryArtistsFragment.newInstance();
_fragLibraryArtist = LibraryArtistFragment.newInstance();
_fragLibraryVideos = LibraryVideosFragment.newInstance();
_fragLibraryFiles = LibraryFilesFragment.newInstance();
_fragBrowser = BrowserFragment.newInstance();
@@ -505,6 +508,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragLibraryArtists.topBar = _fragTopBarNavigation;
_fragLibraryArtist.topBar = _fragTopBarNavigation;
_fragLibraryVideos.topBar = _fragTopBarNavigation;
_fragLibraryFiles.topBar = _fragTopBarNavigation;
_fragBrowser.topBar = _fragTopBarNavigation;
@@ -1314,6 +1318,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
LibraryArtistsFragment::class -> _fragLibraryArtists as T;
LibraryArtistFragment::class -> _fragLibraryArtist as T;
LibraryVideosFragment::class -> _fragLibraryVideos as T;
LibraryFilesFragment::class -> _fragLibraryFiles as T;
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
}
}
@@ -45,12 +45,17 @@ class LocalClient: IPlatformClient {
try {
val uri = Uri.parse(url);
return ContentResolver.SCHEME_CONTENT == uri.scheme
&& MediaStore.AUTHORITY == uri.authority;
&& (
MediaStore.AUTHORITY == uri.authority ||
uri.authority == "com.android.externalstorage.documents"
)
}
catch(ex: MalformedURLException) {
return false;
}
}
val audioExtensions = listOf(".mp3", ".wav", ".flac", ".mp4a", ".m4a");
override fun getContentDetails(url: String): IPlatformContentDetails {
val uri = Uri.parse(url);
@@ -60,6 +65,12 @@ class LocalClient: IPlatformClient {
else if("video" in uri.pathSegments) {
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
}
else if(uri.toString().contains("com.android.externalstorage.documents")) {
if(audioExtensions.any { uri.lastPathSegment?.lowercase()?.endsWith(it) ?: false })
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
else
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
}
else
throw Exception("Unknown content url [${url}]");
}
@@ -0,0 +1,235 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.buttons.BigButton
class LibraryFilesFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryFilesFragment().apply {}
}
class FragView : FeedView<LibraryFilesFragment, FileEntry, FileEntry, IPager<FileEntry>, FileViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
val navStack = mutableListOf<FileStack>()
var buttonUp: BigButton? = null;
var buttonAdd: BigButton? = null;
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
}
fun onShown() {
loadTop();
}
fun loadTop() {
val initialDirectories = StateLibrary.instance.getFileDirectories();
if(initialDirectories.size == 0) {
setEmptyPager(true);
setPager(EmptyPager());
buttonAdd?.let {
it.isVisible = false;
}
buttonUp?.let {
it.isVisible = false;
}
return;
}
else
setEmptyPager(false);
navStack.clear();
navStack.add(FileStack("", initialDirectories));
openDirectory(navStack.last());
}
fun leaveDirectory() {
if(navStack.size > 1) {
navStack.removeLast();
openDirectory(navStack.last());
}
else {}
}
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
if(addToStack)
navStack.add(stack);
buttonAdd?.let {
it.isVisible = navStack.size < 2
}
buttonUp?.let {
it.isVisible = navStack.size > 1;
}
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
setLoading(false);
}
override fun getEmptyPagerView(): View? {
return NoResultsView(context, "No Directories Added",
"To see files in Grayjay you have to add directories to view",
R.drawable.ic_library, listOf(
BigButton(context, "Add Directory", "Select a directory to add", R.drawable.ic_add, {
StateLibrary.instance.addFileDirectory {
loadTop();
};
})
))
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<FileEntry>): InsertedViewAdapterWithLoader<FileViewHolder> {
val buttonUp = BigButton(fragment.requireContext(), "Go up", "Go up a directory", R.drawable.ic_move_up) {
if(navStack.size > 1)
leaveDirectory();
}
val buttonAdd = BigButton(fragment.requireContext(), "Add Directory", "Select a directory to add", R.drawable.ic_add) {
StateLibrary.instance.addFileDirectory {
loadTop();
};
}
this.buttonUp = buttonUp;
this.buttonAdd = buttonAdd;
return InsertedViewAdapterWithLoader(context, arrayListOf(buttonUp), arrayListOf(buttonAdd),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = FileViewHolder(viewGroup);
holder.onClick.subscribe { c ->
if (c != null) {
if(c.isDirectory) {
openDirectory(FileStack(c.path, c.getSubFiles()), true);
} else {
fragment.navigate<VideoDetailFragment>(c.path)
}
}
};
holder.onDelete.subscribe { c ->
if(c != null) {
StateLibrary.instance.deleteFileDirectory(c.path);
loadTop();
}
}
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
class FileStack(
val path: String,
val files: List<FileEntry>
)
class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<FileEntry>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_file,
_viewGroup, false)) {
val onClick = Event1<FileEntry?>();
val onDelete = Event1<FileEntry?>();
protected var _file: FileEntry? = null;
protected val _imageThumbnail: ImageView
protected val _buttonDelete: ImageButton;
protected val _textName: TextView
protected val _textMetadata: TextView
init {
_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_buttonDelete = _view.findViewById(R.id.button_delete);
_view.setOnClickListener { onClick.emit(_file) };
_buttonDelete.setOnClickListener { onDelete.emit(_file) }
}
override fun bind(file: FileEntry) {
_file = file;
_imageThumbnail?.let {
if(file.isDirectory)
it.setImageResource(R.drawable.ic_library);
else {
Glide.with(it)
.load(file.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(it)
}
};
_buttonDelete.isVisible = file.removable;
_textName.text = file.name;
if(file.isDirectory)
_textMetadata.text = "Directory";
else
_textMetadata.text = "";
}
}
}
@@ -53,10 +53,12 @@ class LibraryFragment : MainFragment() {
fun setPermissionResultAudio(access: Boolean) {
allowedMusic = access;
view?.setMusicPermissions(access);
StateApp.instance.hasMediaStoreAudioPermission = (access);
}
fun setPermissionResultVideo(access: Boolean) {
allowedVideo = access;
view?.setVideoPermissions(access);
StateApp.instance.hasMediaStoreVideoPermission = (access);
}
fun requestPermissionMusic() {
@@ -161,9 +163,9 @@ class LibraryFragment : MainFragment() {
fragment.navigate<PlaylistsFragment>();
}
buttonFiles.onClick.subscribe {
UIDialogs.appToast("This is gonna require a bit more work..");
fragment.navigate<LibraryFilesFragment>()
}
buttonFiles.setButtonEnabled(false);
//buttonFiles.setButtonEnabled(false);
setMusicPermissions(allowMusic ?: false);
setVideoPermissions(allowVideo ?: false);
}
@@ -80,6 +80,9 @@ class StateApp {
privateModeChanged.emit(privateMode);
}
var hasMediaStoreAudioPermission: Boolean = false;
var hasMediaStoreVideoPermission: Boolean = false;
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
val generalUri = Settings.instance.storage.getStorageGeneralUri();
if(isValidStorageUri(context, generalUri))
@@ -1,11 +1,16 @@
package com.futo.platformplayer.states
import android.content.ContentUris
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Artists
import android.provider.MediaStore.Images.ImageColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail
@@ -21,11 +26,10 @@ import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.Album.Companion
import com.futo.platformplayer.states.Album.Companion.TAG
import com.futo.platformplayer.states.StateLibrary.Companion.getAudioTrack
import com.futo.platformplayer.states.StateLibrary.Companion.videoFromCursor
import com.google.protobuf.Empty
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
@@ -33,8 +37,56 @@ import java.time.ZoneOffset
class StateLibrary {
private val _files = FragmentedStorage.get<StringArrayStorage>("libraryFiles")
fun getFileDirectories(): List<FileEntry> {
val context = StateApp.instance.contextOrNull ?: return listOf();
return _files.getAllValues().map {
if(it.startsWith("content://")) {
val uri = it.toUri();
val docFile = DocumentFile.fromTreeUri(context, uri) ?: return@map null;
//val access = context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission }
if(!docFile.isDirectory) {
_files.remove(it);
return@map null;
}
if(docFile == null)
return@map null;
return@map FileEntry.fromFile(docFile).apply { this.removable = true }
}
else
FileEntry.fromPath(it);
}.filterNotNull();
}
fun deleteFileDirectory(path: String) {
_files.remove(path);
_files.save();
}
fun addFileDirectory(onAdded: ((entry: FileEntry) -> Unit)? = null): Boolean {
if(!StateApp.instance.isMainActive)
return false;
val mainActivity = StateApp.instance.contextOrNull as MainActivity? ?: return false;
StateApp.instance.requestDirectoryAccess(mainActivity, "Select Directory",
"Select a directory you would like to make accessible to Grayjay", null, {
if(it != null) {
mainActivity.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
try {
val file = DocumentFile.fromTreeUri(mainActivity, it) ?: return@requestDirectoryAccess;
val dir = FileEntry.fromFile(file);
_files.add(dir.path);
_files.save();
onAdded?.invoke(dir);
}
catch(ex: Throwable) {
Logger.e(TAG, "Something went wrong converting requested directory", ex);
}
}
});
return false;
}
fun getAlbums(): List<Album> {
return Album.getAlbums();
}
@@ -133,11 +185,41 @@ class StateLibrary {
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7
);
fun getDocumentTrack(url: String): IPlatformContentDetails? {
if(!url.contains("com.android.externalstorage.documents"))
return null;
val docFile = DocumentFile.fromSingleUri(StateApp.instance.context, url.toUri()) ?: return null;
val contentUri = docFile.uri.toString();
val mimeType = MimeTypeMap.getFileExtensionFromUrl(contentUri);
if(docFile.name != null) {
if (StateApp.instance.hasMediaStoreAudioPermission && mimeType.startsWith("audio/")) {
val aud = findAudioByName(docFile.name!!);
if (aud != null)
return aud;
}
if (StateApp.instance.hasMediaStoreVideoPermission && mimeType.startsWith("video/")) {
val vid = findVideoByName(docFile.name!!);
if (vid != null)
return vid;
}
}
return LocalVideoDetails(
PlatformID("FILE", contentUri, null, 0, -1),
docFile.name ?: docFile.uri.toString(), Thumbnails(arrayOf(
Thumbnail(docFile.uri.toString(), 0)
)), PlatformAuthorLink.UNKNOWN, contentUri, 0, mimeType, null);
}
fun getAudioTrack(url: String): IPlatformContentDetails? {
val uri = Uri.parse(url);
val id = uri.lastPathSegment?.toLongOrNull();
if(id == null)
return null;
if(id == null) {
return getDocumentTrack(url);
}
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
@@ -152,11 +234,25 @@ class StateLibrary {
return null;
return audioFromCursor(cursor);
}
fun findAudioByName(name: String): IPlatformContentDetails? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Audio contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.DISPLAY_NAME} = ?", arrayOf(name),
null) ?: return null;
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return audioFromCursor(cursor);
}
fun getVideoTrack(url: String): IPlatformContentDetails? {
val uri = Uri.parse(url);
val id = uri.lastPathSegment?.toLongOrNull();
if(id == null)
return null;
return getDocumentTrack(url);
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
@@ -171,6 +267,20 @@ class StateLibrary {
return null;
return videoFromCursor(cursor);
}
fun findVideoByName(name: String): IPlatformContentDetails? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media.DISPLAY_NAME} = ?", arrayOf(name),
null) ?: return null;
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return videoFromCursor(cursor);
}
fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails {
val id = cursor.getString(0);
@@ -456,4 +566,46 @@ class Album {
return list;
}
}
}
class FileEntry(
val path: String,
val name: String,
val isDirectory: Boolean = false,
val thumbnail: String? = null,
var removable: Boolean = false
) {
fun getSubFiles(): List<FileEntry> {
if(isDirectory) {
if(path.startsWith("content://"))
return DocumentFile.fromTreeUri(StateApp.instance.context, path.toUri())?.listFiles()
?.map { fromFile(it) } ?: return listOf();
return File(path).listFiles()
.map { fromFile(it) }
}
return listOf();
}
companion object {
fun fromPath(path: String): FileEntry {
/*
val cursor = StateApp.instance.context.contentResolver.query(path.toUri(), null, null, null, null);
cursor?.moveToFirst();
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
cursor?.close();
return FileEntry(path, fileName, );
*/
val file = File(path);
return FileEntry(file.path, file.name, file.isDirectory);
}
fun fromFile(file: File): FileEntry {
return FileEntry(file.path, file.name, file.isDirectory);
}
fun fromFile(file: DocumentFile): FileEntry {
return FileEntry(file.uri.toString(), file.name ?: "", file.isDirectory);
}
}
}
+2 -2
View File
@@ -51,7 +51,7 @@
android:layout_height="wrap_content"
app:buttonIcon="@drawable/ic_videocam"
app:buttonText="Videos"
app:buttonSubText="All artists known on this phone"
app:buttonSubText="All local videos on this phone"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginBottom="8dp" />
@@ -79,7 +79,7 @@
android:layout_height="wrap_content"
android:textAlignment="center"
android:layout_marginTop="20dp"
android:text="Library UI is temporary, and will be replaced"
android:text=""
android:textColor="#AAAAAA" />
</LinearLayout>
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:id="@+id/root"
android:clickable="true">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/image_thumbnail"
android:layout_height="50dp"
android:layout_width="50dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/roundedCorners_4dp"
app:srcCompat="@drawable/placeholder_video_thumbnail"
android:background="@drawable/video_thumbnail_outline"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent" />
<TextView
android:id="@+id/text_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:textSize="13dp"
android:textColor="@color/white"
android:fontFamily="@font/inter_light"
tools:text="Legendary grant recipient: Marvin Wißfeld Very Long Title That is Long"
android:maxLines="2"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintRight_toLeftOf="@id/button_trash"
app:layout_constraintBottom_toTopOf="@id/text_metadata"
android:layout_marginStart="10dp" />
<TextView
android:id="@+id/text_metadata"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:textSize="10dp"
android:textColor="@color/gray_e0"
android:fontFamily="@font/inter_extra_light"
tools:text="3 videos"
android:maxLines="1"
app:layout_constraintTop_toBottomOf="@id/text_name"
app:layout_constraintLeft_toRightOf="@id/image_thumbnail"
app:layout_constraintRight_toLeftOf="@id/button_delete"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginStart="10dp" />
<ImageButton
android:id="@+id/button_delete"
android:layout_width="34dp"
android:layout_height="34dp"
app:srcCompat="@drawable/ic_trash"
android:scaleType="fitCenter"
android:padding="8dp"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginEnd="10dp"/>
</androidx.constraintlayout.widget.ConstraintLayout>