diff --git a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt index f0c3f6a2..4e35e758 100644 --- a/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt +++ b/app/src/main/java/com/futo/platformplayer/activities/MainActivity.kt @@ -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"); } } diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt index 85b08f74..1d118f95 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/local/LocalClient.kt @@ -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}]"); } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt new file mode 100644 index 00000000..fba76049 --- /dev/null +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFilesFragment.kt @@ -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, FileViewHolder> { + override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator; + + val navStack = mutableListOf() + 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({ 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): InsertedViewAdapterWithLoader { + 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(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 + ) + + class FileViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder( + LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_file, + _viewGroup, false)) { + + val onClick = Event1(); + val onDelete = Event1(); + + 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 = ""; + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt index 77f56d62..45c79cbf 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/LibraryFragment.kt @@ -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(); } buttonFiles.onClick.subscribe { - UIDialogs.appToast("This is gonna require a bit more work.."); + fragment.navigate() } - buttonFiles.setButtonEnabled(false); + //buttonFiles.setButtonEnabled(false); setMusicPermissions(allowMusic ?: false); setVideoPermissions(allowVideo ?: false); } diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 61a902bc..aa1cf5b1 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -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)) diff --git a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt index 4070cc06..a3acedb5 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateLibrary.kt @@ -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("libraryFiles") + fun getFileDirectories(): List { + 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 { 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 { + 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); + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragview_library.xml b/app/src/main/res/layout/fragview_library.xml index 3c04a074..a3dd02c1 100644 --- a/app/src/main/res/layout/fragview_library.xml +++ b/app/src/main/res/layout/fragview_library.xml @@ -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" /> diff --git a/app/src/main/res/layout/list_file.xml b/app/src/main/res/layout/list_file.xml new file mode 100644 index 00000000..f3e363f1 --- /dev/null +++ b/app/src/main/res/layout/list_file.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + \ No newline at end of file