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;
+}