1
0
Fork 0
mirror of https://gitlab.com/futo-org/fcast.git synced 2025-06-24 21:25:23 +00:00

Finished first version of Chrome extension to cast to FCast. Added support for WebSocket to terminal client. Added global support for setting playback speed. Added support for casting local file using terminal client. Added global support for playback error messages. Fixed crash caused by failing to unregister MDNS. Fixed issue where subtitles would always show for HLS. Added support for fractional seconds globally. Layout fixes to desktop casting client. Added footer telling user they can close the window.

This commit is contained in:
Koen 2023-12-07 16:10:18 +01:00
parent fd9a63dac0
commit 18b61d549c
26 changed files with 1116 additions and 193 deletions

View file

@ -38,8 +38,18 @@ class DiscoveryService(private val _context: Context) {
fun stop() {
if (_nsdManager == null) return
_nsdManager?.unregisterService(_registrationListenerTcp)
_nsdManager?.unregisterService(_registrationListenerWs)
try {
_nsdManager?.unregisterService(_registrationListenerTcp)
} catch (e: Throwable) {
Log.e(TAG, "Failed to unregister TCP Listener.");
}
try {
_nsdManager?.unregisterService(_registrationListenerWs)
} catch (e: Throwable) {
Log.e(TAG, "Failed to unregister TCP Listener.");
}
_nsdManager = null
}
@ -60,4 +70,8 @@ class DiscoveryService(private val _context: Context) {
Log.e("DiscoveryService", "Service unregistration failed: errorCode=$errorCode")
}
}
companion object {
private const val TAG = "DiscoveryService"
}
}

View file

@ -6,9 +6,9 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.DataOutputStream
import java.io.OutputStream
import java.net.Socket
import java.net.SocketAddress
import java.nio.ByteBuffer
import java.util.UUID
enum class SessionState {
Idle,
@ -26,7 +26,9 @@ enum class Opcode(val value: Byte) {
Seek(5),
PlaybackUpdate(6),
VolumeUpdate(7),
SetVolume(8)
SetVolume(8),
PlaybackError(9),
SetSpeed(10)
}
const val LENGTH_BYTES = 4
@ -38,6 +40,11 @@ class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress:
private var _packetLength = 0
private var _state = SessionState.WaitingForLength
private var _outputStream: DataOutputStream? = DataOutputStream(outputStream)
val id = UUID.randomUUID()
fun sendPlaybackError(value: PlaybackErrorMessage) {
send(Opcode.PlaybackError, value)
}
fun sendPlaybackUpdate(value: PlaybackUpdateMessage) {
send(Opcode.PlaybackUpdate, value)
@ -82,7 +89,7 @@ class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress:
Log.d(TAG, "Sent $size bytes: '$jsonString'.")
} catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e)
Log.i(TAG, "Failed to send message ${id}.", e)
throw e
}
}
@ -189,6 +196,7 @@ class FCastSession(outputStream: OutputStream, private val _remoteSocketAddress:
Opcode.Stop -> _service.onCastStop()
Opcode.Seek -> _service.onCastSeek(Json.decodeFromString(body!!))
Opcode.SetVolume -> _service.onSetVolume(Json.decodeFromString(body!!))
Opcode.SetSpeed -> _service.onSetSpeed(Json.decodeFromString(body!!))
else -> { }
}
} catch (e: Throwable) {

View file

@ -58,43 +58,30 @@ class NetworkService : Service() {
val onNewSession: (FCastSession) -> Unit = { session ->
_scope?.launch(Dispatchers.Main) {
Log.i(TAG, "On new session ${session.id}")
var encounteredError = false
while (!_stopped && !encounteredError) {
try {
val player = PlayerActivity.instance
val updateMessage = if (player != null) {
PlaybackUpdateMessage(
System.currentTimeMillis(),
player.currentPosition / 1000.0,
player.duration / 1000.0,
if (player.isPlaying) 1 else 2
)
} else {
PlaybackUpdateMessage(
System.currentTimeMillis(),
0.0,
0.0,
0
)
}
val updateMessage = generateUpdateMessage()
withContext(Dispatchers.IO) {
try {
session.sendPlaybackUpdate(updateMessage)
Log.i(TAG, "Update sent ${session.id}")
} catch (eSend: Throwable) {
Log.e(TAG, "Unhandled error sending update", eSend)
Log.e(TAG, "Unhandled error sending update ${session.id}", eSend)
encounteredError = true
return@withContext
}
Log.i(TAG, "Update sent")
}
} catch (eTimer: Throwable) {
Log.e(TAG, "Unhandled error on timer thread", eTimer)
Log.e(TAG, "Unhandled error on timer thread ${session.id}", eTimer)
} finally {
delay(1000)
}
}
Log.i(TAG, "Send loop closed ${session.id}")
}
}
@ -102,11 +89,11 @@ class NetworkService : Service() {
start()
}
_tcpListenerService = TcpListenerService(this, onNewSession).apply {
_tcpListenerService = TcpListenerService(this) { onNewSession(it) }.apply {
start()
}
_webSocketListenerService = WebSocketListenerService(this, onNewSession).apply {
_webSocketListenerService = WebSocketListenerService(this) { onNewSession(it) }.apply {
start()
}
@ -153,11 +140,82 @@ class NetworkService : Service() {
instance = null
}
fun generateUpdateMessage(): PlaybackUpdateMessage {
val player = PlayerActivity.instance
return if (player != null) {
PlaybackUpdateMessage(
System.currentTimeMillis(),
player.currentPosition / 1000.0,
player.duration / 1000.0,
if (player.isPlaying) 1 else 2,
player.speed.toDouble()
)
} else {
PlaybackUpdateMessage(
System.currentTimeMillis(),
0.0,
0.0,
0,
0.0
)
}
}
fun sendPlaybackError(error: String) {
val message = PlaybackErrorMessage(error)
_tcpListenerService?.forEachSession { session ->
_scope?.launch(Dispatchers.IO) {
try {
session.sendPlaybackError(message)
Log.i(TAG, "Playback error sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send playback error", e)
}
}
}
_webSocketListenerService?.forEachSession { session ->
_scope?.launch(Dispatchers.IO) {
try {
session.sendPlaybackError(message)
Log.i(TAG, "Playback error sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send playback error", e)
}
}
}
}
fun sendPlaybackUpdate(message: PlaybackUpdateMessage) {
_tcpListenerService?.forEachSession { session ->
_scope?.launch(Dispatchers.IO) {
try {
session.sendPlaybackUpdate(message)
Log.i(TAG, "Playback update sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send playback update", e)
}
}
}
_webSocketListenerService?.forEachSession { session ->
_scope?.launch(Dispatchers.IO) {
try {
session.sendPlaybackUpdate(message)
Log.i(TAG, "Playback update sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send playback update", e)
}
}
}
}
fun sendCastVolumeUpdate(value: VolumeUpdateMessage) {
_tcpListenerService?.forEachSession { session ->
_scope?.launch {
_scope?.launch(Dispatchers.IO) {
try {
session.sendVolumeUpdate(value)
Log.i(TAG, "Volume update sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send volume update", e)
}
@ -165,9 +223,10 @@ class NetworkService : Service() {
}
_webSocketListenerService?.forEachSession { session ->
_scope?.launch {
_scope?.launch(Dispatchers.IO) {
try {
session.sendVolumeUpdate(value)
Log.i(TAG, "Volume update sent ${session.id}")
} catch (e: Throwable) {
Log.w(TAG, "Failed to send volume update", e)
}
@ -178,7 +237,7 @@ class NetworkService : Service() {
fun onCastPlay(playMessage: PlayMessage) {
Log.i(TAG, "onPlay")
_scope?.launch {
_scope?.launch(Dispatchers.Main) {
try {
if (PlayerActivity.instance == null) {
val i = Intent(this@NetworkService, PlayerActivity::class.java)
@ -187,6 +246,7 @@ class NetworkService : Service() {
i.putExtra("url", playMessage.url)
i.putExtra("content", playMessage.content)
i.putExtra("time", playMessage.time)
i.putExtra("speed", playMessage.speed)
if (activityCount > 0) {
startActivity(i)
@ -219,7 +279,7 @@ class NetworkService : Service() {
fun onCastPause() {
Log.i(TAG, "onPause")
_scope?.launch {
_scope?.launch(Dispatchers.Main) {
try {
PlayerActivity.instance?.pause()
} catch (e: Throwable) {
@ -231,7 +291,7 @@ class NetworkService : Service() {
fun onCastResume() {
Log.i(TAG, "onResume")
_scope?.launch {
_scope?.launch(Dispatchers.Main) {
try {
PlayerActivity.instance?.resume()
} catch (e: Throwable) {
@ -243,7 +303,7 @@ class NetworkService : Service() {
fun onCastStop() {
Log.i(TAG, "onStop")
_scope?.launch {
_scope?.launch(Dispatchers.Main) {
try {
PlayerActivity.instance?.finish()
} catch (e: Throwable) {
@ -255,7 +315,7 @@ class NetworkService : Service() {
fun onCastSeek(seekMessage: SeekMessage) {
Log.i(TAG, "onSeek")
_scope?.launch {
_scope?.launch(Dispatchers.Main) {
try {
PlayerActivity.instance?.seek(seekMessage)
} catch (e: Throwable) {
@ -267,7 +327,7 @@ class NetworkService : Service() {
fun onSetVolume(setVolumeMessage: SetVolumeMessage) {
Log.i(TAG, "onSetVolume")
_scope?.launch {
_scope?.launch(Dispatchers.Main) {
try {
PlayerActivity.instance?.setVolume(setVolumeMessage)
} catch (e: Throwable) {
@ -276,6 +336,18 @@ class NetworkService : Service() {
}
}
fun onSetSpeed(setSpeedMessage: SetSpeedMessage) {
Log.i(TAG, "setSpeedMessage")
_scope?.launch(Dispatchers.Main) {
try {
PlayerActivity.instance?.setSpeed(setSpeedMessage)
} catch (e: Throwable) {
Log.e(TAG, "Failed to seek", e)
}
}
}
companion object {
private const val CHANNEL_ID = "NetworkListenerServiceChannel"
private const val NOTIFICATION_ID = 1

View file

@ -7,7 +7,8 @@ data class PlayMessage(
val container: String,
val url: String? = null,
val content: String? = null,
val time: Long? = null
val time: Double? = null,
val speed: Double? = null
)
@Serializable
@ -20,7 +21,8 @@ data class PlaybackUpdateMessage(
val generationTime: Long,
val time: Double,
val duration: Double,
val state: Int
val state: Int,
val speed: Double
)
@Serializable
@ -29,6 +31,16 @@ data class VolumeUpdateMessage(
val volume: Double
)
@Serializable
data class PlaybackErrorMessage(
val message: String
)
@Serializable
data class SetSpeedMessage(
val speed: Double
)
@Serializable
data class SetVolumeMessage(
val volume: Double

View file

@ -39,6 +39,7 @@ class PlayerActivity : AppCompatActivity() {
private var _wasPlaying = false
val currentPosition get() = _exoPlayer.currentPosition
val speed get() = _exoPlayer.playbackParameters.speed
val duration get() = _exoPlayer.duration
val isPlaying get() = _exoPlayer.isPlaying
@ -82,7 +83,15 @@ class PlayerActivity : AppCompatActivity() {
setStatus(true, null)
}
//TODO: Send playback update
NetworkService.instance?.generateUpdateMessage()?.let {
_scope.launch(Dispatchers.IO) {
try {
NetworkService.instance?.sendPlaybackUpdate(it)
} catch (e: Throwable) {
Log.e(TAG, "Unhandled error sending playback update", e)
}
}
}
}
override fun onPlayerError(error: PlaybackException) {
@ -105,9 +114,16 @@ class PlayerActivity : AppCompatActivity() {
}
}
//TODO: Send error notification
val fullMessage = getFullExceptionMessage(error)
setStatus(false, fullMessage)
setStatus(false, getFullExceptionMessage(error))
_scope.launch(Dispatchers.IO) {
try {
NetworkService.instance?.sendPlaybackError(fullMessage)
} catch (e: Throwable) {
Log.e(TAG, "Unhandled error sending playback error", e)
}
}
}
override fun onVolumeChanged(volume: Float) {
@ -118,8 +134,19 @@ class PlayerActivity : AppCompatActivity() {
} catch (e: Throwable) {
Log.e(TAG, "Unhandled error sending volume update", e)
}
}
}
Log.i(TAG, "Update sent")
override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
super.onPlaybackParametersChanged(playbackParameters)
NetworkService.instance?.generateUpdateMessage()?.let {
_scope.launch(Dispatchers.IO) {
try {
NetworkService.instance?.sendPlaybackUpdate(it)
} catch (e: Throwable) {
Log.e(TAG, "Unhandled error sending playback update", e)
}
}
}
}
}
@ -142,7 +169,7 @@ class PlayerActivity : AppCompatActivity() {
val trackSelector = DefaultTrackSelector(this)
trackSelector.parameters = trackSelector.parameters
.buildUpon()
.setPreferredTextLanguage("en")
.setPreferredTextLanguage("df")
.setSelectUndeterminedTextLanguage(true)
.build()
@ -165,9 +192,10 @@ class PlayerActivity : AppCompatActivity() {
val container = intent.getStringExtra("container") ?: ""
val url = intent.getStringExtra("url")
val content = intent.getStringExtra("content")
val time = intent.getLongExtra("time", 0L)
val time = intent.getDoubleExtra("time", 0.0)
val speed = intent.getDoubleExtra("speed", 1.0)
play(PlayMessage(container, url, content, time))
play(PlayMessage(container, url, content, time, speed))
instance = this
NetworkService.activityCount++
@ -302,9 +330,10 @@ class PlayerActivity : AppCompatActivity() {
}
_exoPlayer.setMediaSource(mediaSource)
_exoPlayer.setPlaybackSpeed(playMessage.speed?.toFloat() ?: 1.0f)
if (playMessage.time != null) {
_exoPlayer.seekTo(playMessage.time * 1000)
_exoPlayer.seekTo((playMessage.time * 1000).toLong())
}
setStatus(true, null)
@ -326,6 +355,10 @@ class PlayerActivity : AppCompatActivity() {
_exoPlayer.seekTo((seekMessage.time * 1000.0).toLong())
}
fun setSpeed(setSpeedMessage: SetSpeedMessage) {
_exoPlayer.setPlaybackSpeed(setSpeedMessage.speed.toFloat())
}
fun setVolume(setVolumeMessage: SetVolumeMessage) {
_exoPlayer.volume = setVolumeMessage.volume.toFloat()
}

View file

@ -1,11 +1,6 @@
package com.futo.fcast.receiver
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.net.ServerSocket
import java.net.Socket

View file

@ -6,44 +6,54 @@ import org.java_websocket.handshake.ClientHandshake
import org.java_websocket.server.WebSocketServer
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.util.IdentityHashMap
class WebSocketListenerService(private val _networkService: NetworkService, private val _onNewSession: (session: FCastSession) -> Unit) : WebSocketServer(InetSocketAddress(PORT)) {
private var _sessions = IdentityHashMap<WebSocket, FCastSession>()
private val _sockets = arrayListOf<WebSocket>()
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
val session = FCastSession(WebSocketOutputStream(conn), conn.remoteSocketAddress, _networkService)
synchronized(_sessions) {
_sessions[conn] = session
conn.setAttachment(session)
synchronized(_sockets) {
_sockets.add(conn)
}
_onNewSession(session)
Log.i(TAG, "New connection from ${conn.remoteSocketAddress}")
Log.i(TAG, "New connection from ${conn.remoteSocketAddress} ${session.id}")
}
override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) {
synchronized(_sessions) {
_sessions.remove(conn)
synchronized(_sockets) {
_sockets.remove(conn)
}
Log.i(TAG, "Closed connection from ${conn.remoteSocketAddress}")
Log.i(TAG, "Closed connection from ${conn.remoteSocketAddress} ${conn.getAttachment<FCastSession>().id}")
}
override fun onMessage(conn: WebSocket?, message: String?) {
if (conn == null) {
Log.i(TAG, "Conn is null, ignore onMessage")
return
}
Log.i(TAG, "Received string message, but not processing: $message")
}
override fun onMessage(conn: WebSocket?, message: ByteBuffer?) {
if (conn == null) {
Log.i(TAG, "Conn is null, ignore onMessage")
return
}
if (message == null) {
Log.i(TAG, "Received byte message null")
return
}
Log.i(TAG, "Received byte message (offset = ${message.arrayOffset()}, size = ${message.remaining()})")
synchronized(_sessions) {
_sessions[conn]?.processBytes(message)
}
val session = conn.getAttachment<FCastSession>()
Log.i(TAG, "Received byte message (offset = ${message.arrayOffset()}, size = ${message.remaining()}, id = ${session.id})")
session.processBytes(message)
}
override fun onError(conn: WebSocket?, ex: Exception) {
@ -55,9 +65,9 @@ class WebSocketListenerService(private val _networkService: NetworkService, priv
}
fun forEachSession(handler: (FCastSession) -> Unit) {
synchronized(_sessions) {
for (pair in _sessions) {
handler(pair.value)
synchronized(_sockets) {
_sockets.forEach {
handler(it.getAttachment())
}
}
}