diff --git a/application/remote-access-server/src/main/java/org/videolan/vlc/remoteaccessserver/RemoteAccessRouting.kt b/application/remote-access-server/src/main/java/org/videolan/vlc/remoteaccessserver/RemoteAccessRouting.kt index 57fda35aa40a79821363ed7ba3bf0067ad9ab14c..cbfe36f28918148633843d3a846fc53ed3435f3a 100644 --- a/application/remote-access-server/src/main/java/org/videolan/vlc/remoteaccessserver/RemoteAccessRouting.kt +++ b/application/remote-access-server/src/main/java/org/videolan/vlc/remoteaccessserver/RemoteAccessRouting.kt @@ -839,13 +839,9 @@ fun Route.setupRouting(appContext: Context, scope: CoroutineScope) { call.respond(HttpStatusCode.Forbidden) return@get } - val networkShares = RemoteAccessServer.getInstance(appContext).networkSharesLiveData.getList() - val list = ArrayList<RemoteAccessServer.PlayQueueItem>(networkShares.size) - networkShares.forEachIndexed { index, mediaLibraryItem -> - list.add(RemoteAccessServer.PlayQueueItem(3000L + index, mediaLibraryItem.title, " ", 0, mediaLibraryItem.artworkMrl - ?: "", false, "", (mediaLibraryItem as MediaWrapper).uri.toString(), true, favorite = mediaLibraryItem.isFavorite)) - } - call.respondJson(convertToJson(list)) + RemoteAccessServer.getInstance(appContext).launchNetworkDiscovery() + //No response are the result are asynchronous and sent back using websockets / long polling + call.respondJson("") } get("/stream-list") { verifyLogin(settings) diff --git a/application/remote-access-server/src/main/java/org/videolan/vlc/remoteaccessserver/RemoteAccessServer.kt b/application/remote-access-server/src/main/java/org/videolan/vlc/remoteaccessserver/RemoteAccessServer.kt index b52bd29aa204a32a152385d47cbdadab702d5eb8..f79545101914f409c5b32c2e457a7f7dbb3d8586 100644 --- a/application/remote-access-server/src/main/java/org/videolan/vlc/remoteaccessserver/RemoteAccessServer.kt +++ b/application/remote-access-server/src/main/java/org/videolan/vlc/remoteaccessserver/RemoteAccessServer.kt @@ -29,6 +29,9 @@ import android.content.Context import android.content.SharedPreferences import android.media.AudioManager import android.net.Uri +import android.os.Handler +import android.os.HandlerThread +import android.os.Process import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -77,6 +80,8 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach @@ -93,31 +98,31 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import org.slf4j.LoggerFactory import org.videolan.libvlc.MediaPlayer import org.videolan.libvlc.interfaces.IMedia +import org.videolan.libvlc.util.MediaBrowser +import org.videolan.medialibrary.MLServiceLocator import org.videolan.medialibrary.interfaces.media.MediaWrapper import org.videolan.medialibrary.media.MediaLibraryItem +import org.videolan.resources.VLCInstance import org.videolan.tools.AppScope import org.videolan.tools.KEYSTORE_PASSWORD import org.videolan.tools.KEY_REMOTE_ACCESS_LAST_STATE_STOPPED -import org.videolan.tools.NetworkMonitor import org.videolan.tools.REMOTE_ACCESS_NETWORK_BROWSER_CONTENT import org.videolan.tools.Settings import org.videolan.tools.SingletonHolder -import org.videolan.tools.livedata.LiveDataset import org.videolan.tools.putSingle import org.videolan.vlc.PlaybackService import org.videolan.vlc.PlaybackService.Companion.playerSleepTime import org.videolan.vlc.gui.DialogActivity import org.videolan.vlc.media.PlaylistManager -import org.videolan.vlc.providers.NetworkProvider +import org.videolan.vlc.remoteaccessserver.ssl.SecretGenerator +import org.videolan.vlc.remoteaccessserver.websockets.RemoteAccessWebSockets +import org.videolan.vlc.remoteaccessserver.websockets.RemoteAccessWebSockets.setupWebSockets import org.videolan.vlc.util.FileUtils import org.videolan.vlc.util.isSchemeSMB import org.videolan.vlc.viewmodels.CallBackDelegate import org.videolan.vlc.viewmodels.ICallBackHandler import org.videolan.vlc.viewmodels.browser.IPathOperationDelegate import org.videolan.vlc.viewmodels.browser.PathOperationDelegate -import org.videolan.vlc.remoteaccessserver.ssl.SecretGenerator -import org.videolan.vlc.remoteaccessserver.websockets.RemoteAccessWebSockets -import org.videolan.vlc.remoteaccessserver.websockets.RemoteAccessWebSockets.setupWebSockets import java.io.File import java.math.BigInteger import java.net.InetAddress @@ -133,6 +138,7 @@ import java.time.Duration import java.util.Calendar import java.util.Collections import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean private const val TAG = "VLC/HttpSharingServer" @@ -144,7 +150,8 @@ class RemoteAccessServer(private val context: Context) : PlaybackService.Callbac private var settings: SharedPreferences private lateinit var engine: NettyApplicationEngine var service: PlaybackService? = null - val networkSharesLiveData = LiveDataset<MediaLibraryItem>() + private val networkSharesResult = ArrayList<MediaLibraryItem>() + private val networkDiscoveryRunning = AtomicBoolean(false) private val _serverStatus = MutableLiveData(ServerStatus.NOT_INIT) @@ -164,6 +171,12 @@ class RemoteAccessServer(private val context: Context) : PlaybackService.Callbac } } + private val browserHandler by lazy { + val handlerThread = HandlerThread("vlc-provider-remote-access", Process.THREAD_PRIORITY_DEFAULT + Process.THREAD_PRIORITY_LESS_FAVORABLE) + handlerThread.start() + Handler(handlerThread.looper) + } + /** * Observes the need to login (for the browser) and display a warning on the website */ @@ -221,19 +234,83 @@ class RemoteAccessServer(private val context: Context) : PlaybackService.Callbac engine = generateServer() engine.start() } + } + + /** + * Launch a network discovery. It directly uses a dedicated [MediaBrowser] and doesn't instantiate a Provider + * It only launches once. It has a timeout. I sends the result back using websockets + * + */ + fun launchNetworkDiscovery() = scope.launch(Dispatchers.IO) { + if (!settings.getBoolean(REMOTE_ACCESS_NETWORK_BROWSER_CONTENT, false)) { + Log.i(TAG, "Preventing the network monitor to be collected as the network browsing is disabled") + return@launch + } + if (networkDiscoveryRunning.getAndSet(true)) { + //Already running only send current results + if (BuildConfig.DEBUG) Log.w(TAG, "Already running") + sendNetworkShares() + return@launch + } + val job = launch { + var finished = false + networkSharesResult.clear() + val mediaBrowser = MediaBrowser(VLCInstance.getInstance(context), object : MediaBrowser.EventListener { + override fun onMediaAdded(index: Int, media: IMedia?) { + try { + MLServiceLocator.getAbstractMediaWrapper(media) + } catch (e: Exception) { + Log.e(TAG, "Unable to generate the media wrapper. It usually happen when the IMedia fields have some encoding issues", e) + null + }?.let { + scope.launch(Dispatchers.Main) { + networkSharesResult.add(it) + sendNetworkShares() + } + } + media?.release() + } + + override fun onMediaRemoved(index: Int, media: IMedia?) {} - withContext(Dispatchers.Main) { - if (!settings.getBoolean(REMOTE_ACCESS_NETWORK_BROWSER_CONTENT, false)) { - Log.i(TAG, "Preventing the network monitor to be collected as the network browsing is disabled") - return@withContext + override fun onBrowseEnd() { + if (BuildConfig.DEBUG) Log.i(TAG, "Discovery is finished") + finished = true + } + + }, browserHandler) + try { + mediaBrowser.discoverNetworkShares() + while (!finished) { + delay(1000) + } + } finally { + if (BuildConfig.DEBUG) Log.i(TAG, "Discovery job releasing everything") + mediaBrowser.changeEventListener(null) + mediaBrowser.release() + sendNetworkShares() } - //keep track of the network shares as they are highly asynchronous - val provider = NetworkProvider(context, networkSharesLiveData, null) - NetworkMonitor.getInstance(context).connectionFlow.onEach { - if (it.connected) provider.refresh() - else networkSharesLiveData.clear() - }.launchIn(AppScope) } + // The discovery job is 30s max + delay(30000L) + if (BuildConfig.DEBUG) Log.i(TAG, "Discovery job timer exhausted") + job.cancelAndJoin() + if (BuildConfig.DEBUG) Log.i(TAG, "Discovery job quit") + networkDiscoveryRunning.set(false) + } + + private suspend fun sendNetworkShares() { + if (BuildConfig.DEBUG) Log.i(TAG, "Sending network shares: ${networkSharesResult.size}") + val list = ArrayList<PlayQueueItem>(networkSharesResult.size) + networkSharesResult.forEachIndexed { index, mediaLibraryItem -> + list.add( + PlayQueueItem( + 3000L + index, mediaLibraryItem.title, " ", 0, mediaLibraryItem.artworkMrl + ?: "", false, "", (mediaLibraryItem as MediaWrapper).uri.toString(), true, favorite = mediaLibraryItem.isFavorite + ) + ) + } + RemoteAccessWebSockets.sendToAll(NetworkShares(list)) } /** @@ -789,7 +866,7 @@ class RemoteAccessServer(private val context: Context) : PlaybackService.Callbac val list: MutableList<Pair<String, String>> = mutableListOf() if (isOtg) list.add(Pair(otgDevice, "root")) if (uri.scheme.isSchemeSMB()) { - networkSharesLiveData.getList().forEach { + networkSharesResult.forEach { (it as? MediaWrapper)?.let { share -> if (share.uri.scheme == uri.scheme && share.uri.authority == uri.authority) { list.add(Pair(share.title, Uri.Builder().scheme(uri.scheme).encodedAuthority(uri.authority).build().toString())) @@ -836,6 +913,7 @@ class RemoteAccessServer(private val context: Context) : PlaybackService.Callbac data class MLRefreshNeeded(val refreshNeeded: Boolean = true) : WSMessage(WSMessageType.ML_REFRESH_NEEDED) data class BrowserDescription(val path: String, val description: String) : WSMessage(WSMessageType.BROWSER_DESCRIPTION) data class PlaybackControlForbidden(val forbidden: Boolean = true): WSMessage(WSMessageType.PLAYBACK_CONTROL_FORBIDDEN) + data class NetworkShares(val shares: List<PlayQueueItem>): WSMessage(WSMessageType.NETWORK_SHARES) data class SearchResults(val albums: List<PlayQueueItem>, val artists: List<PlayQueueItem>, val genres: List<PlayQueueItem>, val playlists: List<PlayQueueItem>, val videos: List<PlayQueueItem>, val tracks: List<PlayQueueItem>) data class BreadcrumbItem(val title: String, val path: String) data class BrowsingResult(val content: List<PlayQueueItem>, val breadcrumb: List<BreadcrumbItem>) @@ -882,6 +960,8 @@ class RemoteAccessServer(private val context: Context) : PlaybackService.Callbac @Json(name = "browser-description") BROWSER_DESCRIPTION, @Json(name = "playback-control-forbidden") - PLAYBACK_CONTROL_FORBIDDEN + PLAYBACK_CONTROL_FORBIDDEN, + @Json(name = "network-shares") + NETWORK_SHARES } } \ No newline at end of file diff --git a/build.gradle b/build.gradle index e0dae3f9233d3c9fe139499f1675b8e249ec306b..86efff9a624bd7e62bedc0886e41bdfa581a4b52 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ ext { versionCode = 3060300 versionName = project.hasProperty('forceVlc4') && project.getProperty('forceVlc4') ? '4.0.0-preview - ' + versionCode : '3.6.3' vlcMajorVersion = project.hasProperty('forceVlc4') && project.getProperty('forceVlc4') ? 4 : 3 - remoteAccessVersion = '0.2.0' + remoteAccessVersion = '0.3.0' libvlcVersion = vlcMajorVersion == 3 ? '3.6.0' :'4.0.0-eap18' medialibraryVersion = vlcMajorVersion == 3 ? '0.13.13-rc15' : '0.13.13-vlc4-rc15' minSdkVersion = 17 diff --git a/buildsystem/compile-remoteaccess.sh b/buildsystem/compile-remoteaccess.sh index cb09402a45e998799d0fb036a2d52d7ccbdc8b52..884380615f0eeed3deda50c78b59fff974624f36 100755 --- a/buildsystem/compile-remoteaccess.sh +++ b/buildsystem/compile-remoteaccess.sh @@ -57,7 +57,7 @@ done ############################## diagnostic "Setting up the Remote Access project" - REMOTE_ACCESS_TESTED_HASH=543dcf36610e6944c85cbf355b4b934b8ae451d3 + REMOTE_ACCESS_TESTED_HASH=4c400ef0dbac2ba23606af4bf0d11e3f05807eb7 REMOTE_ACCESS_REPOSITORY=https://code.videolan.org/videolan/remoteaccess : ${VLC_REMOTE_ACCESS_PATH:="$(pwd -P)/application/remote-access-client/remoteaccess"}