Browse Source

Web server implementation with ktor

transifex
Nicolas Pomepuy 4 years ago
parent
commit
fbec9b3a57
  1. 2
      application/app/build.gradle
  2. 8
      application/resources/src/main/res/values/strings.xml
  3. 7
      application/tools/src/main/java/org/videolan/tools/KotlinExtensions.kt
  4. 4
      application/vlc-android/build.gradle
  5. 9
      application/vlc-android/src/org/videolan/vlc/gui/MainActivity.kt
  6. 6
      application/vlc-android/src/org/videolan/vlc/gui/helpers/BitmapUtil.kt
  7. 290
      application/vlc-android/src/org/videolan/vlc/server/NetworkSharingServer.kt
  8. 45
      buildsystem/network-sharing-server/html/index.html
  9. 79
      buildsystem/network-sharing-server/js/app.js
  10. 27
      buildsystem/network-sharing-server/scss/app.scss

2
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/*']
}
}

8
application/resources/src/main/res/values/strings.xml

@ -1155,5 +1155,13 @@
<string name="ignore_headset_media_button_presses_summary">Useful, for instance, if you are using a headset with broken physical buttons</string>
<string name="replace_playlist">Replace playlist</string>
<!-- Network sharing-->
<string name="ns_network_sharing">Network sharing</string>
<string name="ns_log_file">Log file</string>
<string name="ns_drop_files">Drop files</string>
<string name="ns_drop_files_long">Drop files in the window to add them to your device.\nOr click on the "+" button to use the file picker.</string>
<string name="ns_download_files">Download files</string>
<string name="ns_download_files_long">Just click the file you want to download from your device.</string>
</resources>

7
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()
}

4
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'

9
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() {

6
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

290
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<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)
}

45
buildsystem/network-sharing-server/html/index.html

@ -35,7 +35,6 @@
<link rel="icon" type="image/png" sizes="16x16" href="images/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<script src="style-bundle.js"></script>
</head>
<body>
@ -108,7 +107,51 @@
</div>
</footer>
</main>
<div id="player">
<img id="player_artwork" width="48px" height="48px">
<div class="player_info">
<p id="title"/>
<p id="artist"/>
</div>
<div class="player_controls">
<div>
<button class="material-icons mdc-top-app-bar__action-item mdc-icon-button" id="player_shuffle"
aria-label="play"><div class="mdc-icon-button__ripple"></div>shuffle
</button>
<button class="material-icons mdc-top-app-bar__action-item mdc-icon-button" id="player_previous"
aria-label="play"><div class="mdc-icon-button__ripple"></div>skip_previous
</button>
<button class="material-icons mdc-top-app-bar__action-item mdc-icon-button" id="player_previous_10"
aria-label="play"><div class="mdc-icon-button__ripple"></div>replay_10
</button>
<button class="material-icons mdc-top-app-bar__action-item mdc-icon-button" id="player_play"
aria-label="play"><div class="mdc-icon-button__ripple"></div>play_circle
</button>
<button class="material-icons mdc-top-app-bar__action-item mdc-icon-button" id="player_pause"
aria-label="play"><div class="mdc-icon-button__ripple"></div>pause_circle
</button>
<button class="material-icons mdc-top-app-bar__action-item mdc-icon-button" id="player_next_10"
aria-label="play"><div class="mdc-icon-button__ripple"></div>forward_10
</button>
<button class="material-icons mdc-top-app-bar__action-item mdc-icon-button" id="player_next"
aria-label="play"><div class="mdc-icon-button__ripple"></div>skip_next
</button>
<button class="material-icons mdc-top-app-bar__action-item mdc-icon-button" id="player_repeat"
aria-label="play"><div class="mdc-icon-button__ripple"></div>repeat
</button>
</div>
<div id="player_controls_progress">
<p id="time"/>
<div id="progress_bar"></div>
<p id="duration"/>
</div>
</div>
<div class="player_right">
</div>
</div>
<link rel="stylesheet" href="bundle.css">
<script src="style-bundle.js"></script>
</body>

79
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";
}
}

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

Loading…
Cancel
Save