diff --git a/application/app/build.gradle b/application/app/build.gradle index 3272b6dac..4a2f9fb3e 100644 --- a/application/app/build.gradle +++ b/application/app/build.gradle @@ -14,7 +14,7 @@ android { pickFirsts += ['lib/armeabi-v7a/libc++_shared.so', 'lib/armeabi/libc++_shared.so', 'lib/arm64-v8a/libc++_shared.so', 'lib/x86/libc++_shared.so', 'lib/x86_64/libc++_shared.so'] } resources { - excludes += ['META-INF/main.kotlin_module', 'META-INF/donations_debug.kotlin_module', 'META-INF/mediadb_debug.kotlin_module', 'META-INF/resources_debug.kotlin_module', 'META-INF/television_debug.kotlin_module'] + excludes += ['META-INF/*'] } } diff --git a/application/resources/src/main/res/values/strings.xml b/application/resources/src/main/res/values/strings.xml index bc64c9c8b..66fb5d894 100644 --- a/application/resources/src/main/res/values/strings.xml +++ b/application/resources/src/main/res/values/strings.xml @@ -1155,5 +1155,13 @@ Useful, for instance, if you are using a headset with broken physical buttons Replace playlist + + Network sharing + Log file + Drop files + Drop files in the window to add them to your device.\nOr click on the "+" button to use the file picker. + Download files + Just click the file you want to download from your device. + diff --git a/application/tools/src/main/java/org/videolan/tools/KotlinExtensions.kt b/application/tools/src/main/java/org/videolan/tools/KotlinExtensions.kt index 40ffae827..dcd81f9a8 100644 --- a/application/tools/src/main/java/org/videolan/tools/KotlinExtensions.kt +++ b/application/tools/src/main/java/org/videolan/tools/KotlinExtensions.kt @@ -171,4 +171,11 @@ fun Resources.getDrawableOrDefault(name: String, defPackage: String, @DrawableRe return getIdentifier(name, "drawable", defPackage).let { if (it == 0) defaultDrawable else it } +} + +fun Context.resIdByName(resIdName: String?, resType: String): Int { + resIdName?.let { + return resources.getIdentifier(it, resType, packageName) + } + throw Resources.NotFoundException() } \ No newline at end of file diff --git a/application/vlc-android/build.gradle b/application/vlc-android/build.gradle index cb3117d9a..7fcc700b0 100644 --- a/application/vlc-android/build.gradle +++ b/application/vlc-android/build.gradle @@ -217,6 +217,10 @@ dependencies { androidTestImplementation "androidx.test:rules:$rootProject.ext.testCore" androidTestImplementation 'com.jraska:falcon:2.2.0' androidTestImplementation 'tools.fastlane:screengrab:2.1.0' + implementation "io.ktor:ktor:2.1.2" + implementation "io.ktor:ktor-server-netty:2.1.2" + implementation "io.ktor:ktor-gson:1.6.8" + implementation "io.ktor:ktor-server-websockets:2.1.2" if (project.hasProperty('leakCanaryEnabled') && project.getProperty('leakCanaryEnabled')) { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' diff --git a/application/vlc-android/src/org/videolan/vlc/gui/MainActivity.kt b/application/vlc-android/src/org/videolan/vlc/gui/MainActivity.kt index 40a58f635..551f7167a 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/MainActivity.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/MainActivity.kt @@ -35,7 +35,9 @@ import androidx.appcompat.view.ActionMode import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.videolan.libvlc.util.AndroidUtil import org.videolan.medialibrary.interfaces.Medialibrary import org.videolan.resources.ACTIVITY_RESULT_OPEN @@ -60,6 +62,7 @@ import org.videolan.vlc.interfaces.Filterable import org.videolan.vlc.interfaces.IRefreshable import org.videolan.vlc.media.MediaUtils import org.videolan.vlc.reloadLibrary +import org.videolan.vlc.server.NetworkSharingServer import org.videolan.vlc.util.Permissions import org.videolan.vlc.util.Util import org.videolan.vlc.util.WidgetMigration @@ -102,6 +105,12 @@ class MainActivity : ContentActivity(), // VLCBilling.getInstance(application).retrieveSkus() WidgetMigration.launchIfNeeded(this) NotificationPermissionManager.launchIfNeeded(this) + lifecycleScope.launch { + withContext(Dispatchers.IO) { + val server = NetworkSharingServer.getInstance(this@MainActivity) + } + + } } override fun onResume() { diff --git a/application/vlc-android/src/org/videolan/vlc/gui/helpers/BitmapUtil.kt b/application/vlc-android/src/org/videolan/vlc/gui/helpers/BitmapUtil.kt index 4c655e0b1..32bac51fb 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/helpers/BitmapUtil.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/helpers/BitmapUtil.kt @@ -108,6 +108,12 @@ object BitmapUtil { return cover } + fun convertBitmapToByteArray(bitmap: Bitmap): ByteArray? { + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream) + return stream.toByteArray() + } + fun centerCrop(srcBmp: Bitmap, width: Int, height: Int): Bitmap { val widthDiff = srcBmp.width - width diff --git a/application/vlc-android/src/org/videolan/vlc/server/NetworkSharingServer.kt b/application/vlc-android/src/org/videolan/vlc/server/NetworkSharingServer.kt new file mode 100644 index 000000000..2883b015a --- /dev/null +++ b/application/vlc-android/src/org/videolan/vlc/server/NetworkSharingServer.kt @@ -0,0 +1,290 @@ +/* + * ************************************************************************ + * NetworkSharingServer.kt + * ************************************************************************* + * Copyright © 2022 VLC authors and VideoLAN + * Author: Nicolas POMEPUY + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA. + * ************************************************************************** + * + * + */ + +package org.videolan.vlc.server + +import android.content.Context +import android.net.Uri +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import com.google.gson.Gson +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.http.content.* +import io.ktor.server.netty.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.videolan.libvlc.MediaPlayer +import org.videolan.libvlc.interfaces.IMedia +import org.videolan.resources.AndroidDevices +import org.videolan.tools.AppScope +import org.videolan.tools.SingletonHolder +import org.videolan.tools.resIdByName +import org.videolan.vlc.PlaybackService +import org.videolan.vlc.gui.helpers.AudioUtil +import org.videolan.vlc.gui.helpers.BitmapUtil +import org.videolan.vlc.server.NetworkSharingServer.init +import org.videolan.vlc.util.FileUtils +import java.io.File +import java.text.DateFormat +import java.time.Duration +import java.util.* +import java.util.regex.Pattern +import kotlin.collections.ArrayList + +object NetworkSharingServer: SingletonHolder({ init(it.applicationContext) }), PlaybackService.Callback { + + + private var websocketSession: ArrayList = arrayListOf() + private var service: PlaybackService? = null + private val format = DateFormat.getDateTimeInstance(DateFormat.MEDIUM,DateFormat.MEDIUM, Locale.getDefault()) + + + fun init(applicationContext: Context): NettyApplicationEngine { + copyWebServer(applicationContext) + PlaybackService.serviceFlow.onEach { onServiceChanged(it) } + .onCompletion { service?.removeCallback(this@NetworkSharingServer) } + .launchIn(AppScope) + return launchServer(applicationContext) + } + + private fun onServiceChanged(service: PlaybackService?) { + if (service !== null) { + this.service = service + service.addCallback(this) + } else this.service?.let { + it.removeCallback(this) + this.service = null + } + } + + + fun copyWebServer(context: Context) { + File("${context.filesDir.path}/server").mkdirs() + FileUtils.copyAssetFolder(context.assets, "web", "${context.filesDir.path}/server", true) + } + + + fun launchServer(context: Context) = embeddedServer(Netty, 8080) { + install(WebSockets) { + pingPeriod = Duration.ofSeconds(15) + timeout = Duration.ofSeconds(15) + maxFrameSize = Long.MAX_VALUE + masking = false + } + routing { + static("") { + files("${context.filesDir.path}/server/public") + } + get("/") { + call.respondRedirect("index.html", permanent = true) + } + get("/index.html") { + + val logs = getLogsFiles().sortedBy { File(it).lastModified() }.reversed() + val template = FileUtils.getStringFromFile("${context.filesDir.path}/server/public/log_template-mat.html.temp") + val logsHtml = buildString { + logs.forEach { + + append(template.replace("%%FILE_NAME%%", it).replace("%%FILE_NAME_SHORT%%", format.format(File(it).lastModified()))) + } + } + + val html = FileUtils.getStringFromFile("${context.filesDir.path}/server/public/index.html") + call.respondText(html.networkShareReplace(context).contentReplace(context, logsHtml), ContentType.Text.Html) + } + post("/upload.json") { + var fileDescription = "" + var fileName = "" + val multipartData = call.receiveMultipart() + + multipartData.forEachPart { part -> + when (part) { + is PartData.FormItem -> { + fileDescription = part.value + } + is PartData.FileItem -> { + File("${AndroidDevices.MediaFolders.EXTERNAL_PUBLIC_DOWNLOAD_DIRECTORY_URI.path}/uploads").mkdirs() + fileName = part.originalFileName as String + var fileBytes = part.streamProvider().readBytes() + File("${AndroidDevices.MediaFolders.EXTERNAL_PUBLIC_DOWNLOAD_DIRECTORY_URI.path}/uploads/$fileName").writeBytes(fileBytes) + } + else -> {} + } + } + call.respondText("$fileDescription is uploaded to 'uploads/$fileName'") + } + get("/logs.html") { + + call.respondText("toto") + } + get("/download") { + call.request.queryParameters["file"]?.let { filePath -> + val file = File(filePath) + if (file.exists()) { + call.response.header(HttpHeaders.ContentDisposition, ContentDisposition.Attachment.withParameter(ContentDisposition.Parameters.FileName, file.name).toString()) + call.respondFile(File(filePath)) + } + } + call.respond(HttpStatusCode.NotFound, "") + } + get("/artwork") { + try { + service?.coverArt?.let { coverArt -> + AudioUtil.readCoverBitmap(Uri.decode(coverArt), 512)?.let { bitmap -> + BitmapUtil.convertBitmapToByteArray(bitmap)?.let { + + call.respondBytes(ContentType.Image.JPEG) { it } + } + } + } + } catch (e: Exception) { + Log.e("networkShareReplace", e.message, e) + } + call.respond(HttpStatusCode.NotFound, "") + } + webSocket("/echo", protocol = "player") { + websocketSession.add(this) + // Handle a WebSocket session +// send("Please enter your name") + for (frame in incoming) { + frame as? Frame.Text ?: continue + when (frame.readText()) { + "play" -> service?.play() + "pause" -> service?.pause() + "previous" -> service?.previous(false) + "next" -> service?.next() + "previous10" -> service?.let { it.seek((it.getTime() - 10000).coerceAtLeast(0), fromUser = true) } + "next10" -> service?.let { it.seek((it.getTime() + 10000).coerceAtMost(it.length), fromUser = true) } + "shuffle" -> service?.shuffle() + "repeat" -> service?.let { + when (it.repeatType) { + PlaybackStateCompat.REPEAT_MODE_NONE -> { + it.repeatType = PlaybackStateCompat.REPEAT_MODE_ONE + } + PlaybackStateCompat.REPEAT_MODE_ONE -> if (it.hasPlaylist()) { + it.repeatType = PlaybackStateCompat.REPEAT_MODE_ALL + } else { + it.repeatType = PlaybackStateCompat.REPEAT_MODE_NONE + } + PlaybackStateCompat.REPEAT_MODE_ALL -> { + it.repeatType = PlaybackStateCompat.REPEAT_MODE_NONE + } + } + } + } + } + websocketSession.remove(this) + } + } + }.start() + + private suspend fun getLogsFiles(): List = withContext(Dispatchers.IO){ + val result = ArrayList() + val folder = File(AndroidDevices.EXTERNAL_PUBLIC_DIRECTORY) + val files = folder.listFiles() + files.forEach { + if (it.isFile && it.name.startsWith("vlc_logcat_")) result.add(it.path) + } + + return@withContext result + + } + + fun String.networkShareReplace(context: Context):String { + var newString = this + try { + val logEntry = Pattern.compile("\\{%(.*?)%\\}") + newString = newString.replace(logEntry.toRegex()) { + Log.d("networkShareReplace", it.value) + context.getString(context.resIdByName(it.value.trim().drop(2).dropLast(2), "string")) + } + } catch (e: Exception) { + Log.e("networkShareReplace", e.message, e) + } + + return newString + } + + fun String.contentReplace(context: Context, logsHtml: String = ""):String { + var newString = this + try { + val logEntry = Pattern.compile("\\{*(.*?)%*\\}") + newString = newString.replace(logEntry.toRegex()) { + when (it.value.trim().drop(2).dropLast(2)) { + "logs" -> logsHtml + else -> "" + } + } + } catch (e: Exception) { + Log.e("networkShareReplace", e.message, e) + } + + return newString + } + + override fun update() { + generateNowPlaying()?.let {nowPlaying -> + AppScope.launch { websocketSession.forEach { it.send(Frame.Text(nowPlaying)) } } + } + } + + override fun onMediaEvent(event: IMedia.Event) { + generateNowPlaying()?.let {nowPlaying -> + AppScope.launch { websocketSession.forEach {it.send(Frame.Text(nowPlaying)) }} + } + } + + override fun onMediaPlayerEvent(event: MediaPlayer.Event) { + generateNowPlaying()?.let {nowPlaying -> + AppScope.launch { websocketSession.forEach {it.send(Frame.Text(nowPlaying)) }} + } + } + + private fun generateNowPlaying():String? { + service?.let { service -> + service.currentMediaWrapper?.let {media -> + val gson = Gson() + val nowPlaying = NowPlaying(media.title ?: "", media.artist ?: "", service.isPlaying, service.getTime(), service.length, media.id, media.artworkURL?:"", media.uri.toString()) + return gson.toJson(nowPlaying) + + } + } + return null + } + + data class NowPlaying(val title: String, val artist: String, val playing: Boolean, val progress: Long, val duration: Long, val id: Long, val artworkURL: String, val uri: String) +} + diff --git a/buildsystem/network-sharing-server/html/index.html b/buildsystem/network-sharing-server/html/index.html index b0acdf206..8e48929eb 100644 --- a/buildsystem/network-sharing-server/html/index.html +++ b/buildsystem/network-sharing-server/html/index.html @@ -35,7 +35,6 @@ - @@ -108,7 +107,51 @@ +
+ +
+

+

+

+
+
+ + + + + + + + +
+
+

+

+

+

+
+
+
+ +
+ diff --git a/buildsystem/network-sharing-server/js/app.js b/buildsystem/network-sharing-server/js/app.js index 31b7ceb9e..d823a0fce 100644 --- a/buildsystem/network-sharing-server/js/app.js +++ b/buildsystem/network-sharing-server/js/app.js @@ -10,3 +10,82 @@ import favicon from "../asset/resource/favicon.ico" import favicon_16 from "../asset/resource/favicon-16x16.png" import favicon_32 from "../asset/resource/favicon-32x32.png" import index from "../html/index.html" + + +//const playerWS = new WebSocket("wss://"+window.location.origin+"/echo", "protocolOne"); +const playerWS = new WebSocket("ws://192.168.1.83:8080/echo", "player"); +playerWS.onopen = (event) => { +}; + + +export const msecToTime = ms => { + const seconds = Math.floor((ms / 1000) % 60) + const minutes = Math.floor((ms / (60 * 1000)) % 60) + const hours = Math.floor((ms / (3600 * 1000)) % 3600) + return `${hours < 10 ? '0' + hours : hours}:${minutes < 10 ? '0' + minutes : minutes}:${ + seconds < 10 ? '0' + seconds : seconds + }` +} +const play = document.getElementById("player_play"); +const pause = document.getElementById("player_pause"); +const previous = document.getElementById("player_previous"); +const next = document.getElementById("player_next"); +const shuffle = document.getElementById("player_shuffle"); +const repeat = document.getElementById("player_repeat"); +const previous10 = document.getElementById("player_previous_10"); +const next10 = document.getElementById("player_next_10"); + +play.addEventListener('click', (event) => { + playerWS.send("play"); +}); +pause.addEventListener('click', (event) => { + playerWS.send("pause"); +}); +previous.addEventListener('click', (event) => { + playerWS.send("previous"); +}); +next.addEventListener('click', (event) => { + playerWS.send("next"); +}); +shuffle.addEventListener('click', (event) => { + playerWS.send("shuffle"); +}); +repeat.addEventListener('click', (event) => { + playerWS.send("repeat"); +}); +previous10.addEventListener('click', (event) => { + playerWS.send("previous10"); +}); +next10.addEventListener('click', (event) => { + playerWS.send("next10"); +}); + +var lastLoadedMediaUri = "" + +playerWS.onmessage = (event) => { + console.log(event.data); + const player = document.getElementById("player"); + const title = document.getElementById("title"); + const artist = document.getElementById("artist"); + const time = document.getElementById("time"); + const duration = document.getElementById("duration"); + const artwork = document.getElementById("player_artwork"); + const msg = JSON.parse(event.data); + title.textContent = msg.title + artist.textContent = msg.artist + time.textContent = msecToTime(new Date(msg.progress)) + duration.textContent = msecToTime(new Date(msg.duration)) + if (lastLoadedMediaUri != msg.uri) { + artwork.src = "http://192.168.1.83:8080/artwork?randomizer="+Date.now() + lastLoadedMediaUri = msg.uri + } + + if (msg.playing) { + play.style.display = "none"; + pause.style.display = "inline-block"; + } else { + play.style.display = "inline-block"; + pause.style.display = "none"; + } + +} \ No newline at end of file diff --git a/buildsystem/network-sharing-server/scss/app.scss b/buildsystem/network-sharing-server/scss/app.scss index d9cf4a850..3ccfa3658 100644 --- a/buildsystem/network-sharing-server/scss/app.scss +++ b/buildsystem/network-sharing-server/scss/app.scss @@ -83,3 +83,30 @@ h6 { p { @include typography.typography(body1); } + +#player { + position:absolute; + display: flex; + grid-template-columns: repeat(3, 1fr); + grid-gap: 10px; + bottom: 0; + width: calc(100% - 32px); + padding: 16px; + background: #444444; + color: #ffffff; + border-radius-top-left: 8px; + border-radius-top-right: 8px; + align-items:center; +} + +.player_info, .player_right, #progress_bar { +flex: auto; +} + + #progress_bar { + min-width:200px; + } + +#player_controls_progress { +display: flex; +}