10 changed files with 475 additions and 2 deletions
@ -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<NettyApplicationEngine, Context>({ init(it.applicationContext) }), PlaybackService.Callback { |
||||
|
|
||||
|
|
||||
|
private var websocketSession: ArrayList<DefaultWebSocketServerSession> = 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("<a>toto</a>") |
||||
|
} |
||||
|
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<String> = withContext(Dispatchers.IO){ |
||||
|
val result = ArrayList<String>() |
||||
|
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) |
||||
|
} |
||||
|
|
||||
Loading…
Reference in new issue