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

Added support for DPAD left/right to skip and back button to hide controller. Added error message and loading overlay. Added demo mode (tap top-left 5 times).

This commit is contained in:
Koen 2023-08-16 10:48:50 +02:00
parent 10afb65456
commit 72bc635dfd
6 changed files with 180 additions and 35 deletions

View file

@ -36,7 +36,7 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene
private var _bytesRead = 0 private var _bytesRead = 0
private var _packetLength = 0 private var _packetLength = 0
private var _state = SessionState.WaitingForLength private var _state = SessionState.WaitingForLength
private var _outputStream: DataOutputStream? = DataOutputStream(_socket.outputStream); private var _outputStream: DataOutputStream? = DataOutputStream(_socket.outputStream)
fun sendPlaybackUpdate(value: PlaybackUpdateMessage) { fun sendPlaybackUpdate(value: PlaybackUpdateMessage) {
send(Opcode.PlaybackUpdate, value) send(Opcode.PlaybackUpdate, value)
@ -48,40 +48,40 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene
private inline fun <reified T> send(opcode: Opcode, message: T) { private inline fun <reified T> send(opcode: Opcode, message: T) {
try { try {
val data: ByteArray; val data: ByteArray
var jsonString: String? = null; var jsonString: String? = null
if (message != null) { if (message != null) {
jsonString = Json.encodeToString(message); jsonString = Json.encodeToString(message)
data = jsonString.encodeToByteArray(); data = jsonString.encodeToByteArray()
} else { } else {
data = ByteArray(0); data = ByteArray(0)
} }
val size = 1 + data.size; val size = 1 + data.size
val outputStream = _outputStream; val outputStream = _outputStream
if (outputStream == null) { if (outputStream == null) {
Log.w(TAG, "Failed to send $size bytes, output stream is null."); Log.w(TAG, "Failed to send $size bytes, output stream is null.")
return; return
} }
val serializedSizeLE = ByteArray(4); val serializedSizeLE = ByteArray(4)
serializedSizeLE[0] = (size and 0xff).toByte(); serializedSizeLE[0] = (size and 0xff).toByte()
serializedSizeLE[1] = (size shr 8 and 0xff).toByte(); serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
serializedSizeLE[2] = (size shr 16 and 0xff).toByte(); serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
serializedSizeLE[3] = (size shr 24 and 0xff).toByte(); serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
outputStream.write(serializedSizeLE); outputStream.write(serializedSizeLE)
val opcodeBytes = ByteArray(1); val opcodeBytes = ByteArray(1)
opcodeBytes[0] = opcode.value; opcodeBytes[0] = opcode.value
outputStream.write(opcodeBytes); outputStream.write(opcodeBytes)
if (data.isNotEmpty()) { if (data.isNotEmpty()) {
outputStream.write(data); outputStream.write(data)
} }
Log.d(TAG, "Sent $size bytes: '$jsonString'."); Log.d(TAG, "Sent $size bytes: '$jsonString'.")
} catch (e: Throwable) { } catch (e: Throwable) {
Log.i(TAG, "Failed to send message.", e); Log.i(TAG, "Failed to send message.", e)
} }
} }

View file

@ -47,8 +47,11 @@ class MainActivity : AppCompatActivity() {
private lateinit var _imageSpinner: ImageView private lateinit var _imageSpinner: ImageView
private lateinit var _layoutConnectionInfo: ConstraintLayout private lateinit var _layoutConnectionInfo: ConstraintLayout
private lateinit var _videoBackground: StyledPlayerView private lateinit var _videoBackground: StyledPlayerView
private lateinit var _viewDemo: View
private lateinit var _player: ExoPlayer private lateinit var _player: ExoPlayer
private var _updating: Boolean = false private var _updating: Boolean = false
private var _demoClickCount = 0
private var _lastDemoToast: Toast? = null
private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Main) private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Main)
@ -64,6 +67,7 @@ class MainActivity : AppCompatActivity() {
_imageSpinner = findViewById(R.id.image_spinner) _imageSpinner = findViewById(R.id.image_spinner)
_layoutConnectionInfo = findViewById(R.id.layout_connection_info) _layoutConnectionInfo = findViewById(R.id.layout_connection_info)
_videoBackground = findViewById(R.id.video_background) _videoBackground = findViewById(R.id.video_background)
_viewDemo = findViewById(R.id.view_demo)
startVideo() startVideo()
startAnimations() startAnimations()
@ -80,6 +84,18 @@ class MainActivity : AppCompatActivity() {
update() update()
} }
_viewDemo.setOnClickListener {
_demoClickCount++
if (_demoClickCount in 2..4) {
val remainingClicks = 5 - _demoClickCount
_lastDemoToast?.cancel()
_lastDemoToast = Toast.makeText(this, "Click $remainingClicks more times to start demo", Toast.LENGTH_SHORT).apply { show() }
} else if (_demoClickCount == 5) {
TcpListenerService.instance?.onCastPlay(PlayMessage("video/mp4", "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"))
_demoClickCount = 0
}
}
if (BuildConfig.IS_PLAYSTORE_VERSION) { if (BuildConfig.IS_PLAYSTORE_VERSION) {
_text.visibility = View.INVISIBLE _text.visibility = View.INVISIBLE
_buttonUpdate.visibility = View.INVISIBLE _buttonUpdate.visibility = View.INVISIBLE
@ -249,15 +265,15 @@ class MainActivity : AppCompatActivity() {
} }
private suspend fun checkForUpdates() { private suspend fun checkForUpdates() {
Log.i(TAG, "Checking for updates..."); Log.i(TAG, "Checking for updates...")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val latestVersion = downloadVersionCode() val latestVersion = downloadVersionCode()
if (latestVersion != null) { if (latestVersion != null) {
val currentVersion = BuildConfig.VERSION_CODE; val currentVersion = BuildConfig.VERSION_CODE
Log.i(TAG, "Current version $currentVersion latest version $latestVersion."); Log.i(TAG, "Current version $currentVersion latest version $latestVersion.")
if (latestVersion > currentVersion) { if (latestVersion > currentVersion) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -267,8 +283,8 @@ class MainActivity : AppCompatActivity() {
setText(resources.getText(R.string.there_is_an_update_available_do_you_wish_to_update)) setText(resources.getText(R.string.there_is_an_update_available_do_you_wish_to_update))
_buttonUpdate.visibility = View.VISIBLE _buttonUpdate.visibility = View.VISIBLE
} catch (e: Throwable) { } catch (e: Throwable) {
Toast.makeText(this@MainActivity, "Failed to show update dialog", Toast.LENGTH_LONG).show(); Toast.makeText(this@MainActivity, "Failed to show update dialog", Toast.LENGTH_LONG).show()
Log.w(TAG, "Error occurred in update dialog."); Log.w(TAG, "Error occurred in update dialog.")
} }
} }
} else { } else {
@ -277,21 +293,21 @@ class MainActivity : AppCompatActivity() {
_buttonUpdate.visibility = View.INVISIBLE _buttonUpdate.visibility = View.INVISIBLE
//setText(getString(R.string.no_updates_available)) //setText(getString(R.string.no_updates_available))
setText(null) setText(null)
//Toast.makeText(this@MainActivity, "Already on latest version", Toast.LENGTH_LONG).show(); //Toast.makeText(this@MainActivity, "Already on latest version", Toast.LENGTH_LONG).show()
} }
} }
} else { } else {
Log.w(TAG, "Failed to retrieve version from version URL."); Log.w(TAG, "Failed to retrieve version from version URL.")
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Toast.makeText(this@MainActivity, "Failed to retrieve version", Toast.LENGTH_LONG).show(); Toast.makeText(this@MainActivity, "Failed to retrieve version", Toast.LENGTH_LONG).show()
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.w(TAG, "Failed to check for updates.", e); Log.w(TAG, "Failed to check for updates.", e)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Toast.makeText(this@MainActivity, "Failed to check for updates", Toast.LENGTH_LONG).show(); Toast.makeText(this@MainActivity, "Failed to check for updates", Toast.LENGTH_LONG).show()
} }
} }
} }
@ -452,7 +468,7 @@ class MainActivity : AppCompatActivity() {
addr.hostAddress?.let { ips.add(it) } addr.hostAddress?.let { ips.add(it) }
} }
} }
return ips; return ips
} }
companion object { companion object {

View file

@ -1,18 +1,25 @@
package com.futo.fcast.receiver package com.futo.fcast.receiver
import android.content.Context import android.content.Context
import android.graphics.drawable.Animatable
import android.net.* import android.net.*
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.Window import android.view.Window
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowManager import android.view.WindowManager
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import com.google.android.exoplayer2.source.dash.DashMediaSource import com.google.android.exoplayer2.source.dash.DashMediaSource
import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.text.ExoplayerCuesDecoder
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.ui.StyledPlayerView import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DefaultDataSource import com.google.android.exoplayer2.upstream.DefaultDataSource
@ -20,14 +27,18 @@ import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
class PlayerActivity : AppCompatActivity() { class PlayerActivity : AppCompatActivity() {
private lateinit var _playerControlView: StyledPlayerView private lateinit var _playerControlView: StyledPlayerView
private lateinit var _imageSpinner: ImageView
private lateinit var _textMessage: TextView
private lateinit var _layoutOverlay: ConstraintLayout
private lateinit var _exoPlayer: ExoPlayer private lateinit var _exoPlayer: ExoPlayer
private var _shouldPlaybackRestartOnConnectivity: Boolean = false private var _shouldPlaybackRestartOnConnectivity: Boolean = false
private lateinit var _connectivityManager: ConnectivityManager private lateinit var _connectivityManager: ConnectivityManager
private lateinit var _scope: CoroutineScope private lateinit var _scope: CoroutineScope
private var _wasPlaying = false; private var _wasPlaying = false
val currentPosition get() = _exoPlayer.currentPosition val currentPosition get() = _exoPlayer.currentPosition
val isPlaying get() = _exoPlayer.isPlaying val isPlaying get() = _exoPlayer.isPlaying
@ -59,16 +70,25 @@ class PlayerActivity : AppCompatActivity() {
private val _playerEventListener = object: Player.Listener { private val _playerEventListener = object: Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState) super.onPlaybackStateChanged(playbackState)
Log.i(TAG, "onPlaybackStateChanged playbackState=$playbackState")
if (_shouldPlaybackRestartOnConnectivity && playbackState == ExoPlayer.STATE_READY) { if (_shouldPlaybackRestartOnConnectivity && playbackState == ExoPlayer.STATE_READY) {
Log.i(TAG, "_shouldPlaybackRestartOnConnectivity=false") Log.i(TAG, "_shouldPlaybackRestartOnConnectivity=false")
_shouldPlaybackRestartOnConnectivity = false _shouldPlaybackRestartOnConnectivity = false
} }
if (playbackState == ExoPlayer.STATE_READY) {
setStatus(false, null)
} else if (playbackState == ExoPlayer.STATE_BUFFERING) {
setStatus(true, null)
}
} }
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error) super.onPlayerError(error)
Log.e(TAG, "onPlayerError: $error")
when (error.errorCode) { when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
@ -83,6 +103,8 @@ class PlayerActivity : AppCompatActivity() {
_shouldPlaybackRestartOnConnectivity = true _shouldPlaybackRestartOnConnectivity = true
} }
} }
setStatus(false, getFullExceptionMessage(error))
} }
override fun onVolumeChanged(volume: Float) { override fun onVolumeChanged(volume: Float) {
@ -107,8 +129,13 @@ class PlayerActivity : AppCompatActivity() {
setFullScreen() setFullScreen()
_playerControlView = findViewById(R.id.player_control_view) _playerControlView = findViewById(R.id.player_control_view)
_imageSpinner = findViewById(R.id.image_spinner)
_textMessage = findViewById(R.id.text_message)
_layoutOverlay = findViewById(R.id.layout_overlay)
_scope = CoroutineScope(Dispatchers.Main) _scope = CoroutineScope(Dispatchers.Main)
setStatus(true, null)
val trackSelector = DefaultTrackSelector(this) val trackSelector = DefaultTrackSelector(this)
trackSelector.parameters = trackSelector.parameters trackSelector.parameters = trackSelector.parameters
.buildUpon() .buildUpon()
@ -148,6 +175,35 @@ class PlayerActivity : AppCompatActivity() {
if (hasFocus) setFullScreen() if (hasFocus) setFullScreen()
} }
private fun getFullExceptionMessage(ex: Throwable): String {
val messages = mutableListOf<String>()
var current: Throwable? = ex
while (current != null) {
messages.add(current.message ?: "Unknown error")
current = current.cause
}
return messages.joinToString(separator = "")
}
private fun setStatus(isLoading: Boolean, message: String?) {
if (isLoading) {
(_imageSpinner.drawable as Animatable?)?.start()
_imageSpinner.visibility = View.VISIBLE
} else {
(_imageSpinner.drawable as Animatable?)?.stop()
_imageSpinner.visibility = View.GONE
}
if (message != null) {
_textMessage.visibility = View.VISIBLE
_textMessage.text = message
} else {
_textMessage.visibility = View.GONE
}
_layoutOverlay.visibility = if (isLoading || message != null) View.VISIBLE else View.GONE
}
private fun setFullScreen() { private fun setFullScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.hide(WindowInsets.Type.statusBars()) window.insetsController?.hide(WindowInsets.Type.statusBars())
@ -189,6 +245,28 @@ class PlayerActivity : AppCompatActivity() {
TcpListenerService.activityCount-- TcpListenerService.activityCount--
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> {
val newPosition = _exoPlayer.currentPosition - 10000
_exoPlayer.seekTo(max(0, newPosition))
return true
}
KeyEvent.KEYCODE_DPAD_RIGHT -> {
val newPosition = _exoPlayer.currentPosition + 10000
_exoPlayer.seekTo(newPosition)
return true
}
KeyEvent.KEYCODE_BACK -> {
if (_playerControlView.isControllerFullyVisible) {
_playerControlView.hideController()
return true
}
}
}
return super.onKeyDown(keyCode, event)
}
fun play(playMessage: PlayMessage) { fun play(playMessage: PlayMessage) {
val mediaItemBuilder = MediaItem.Builder() val mediaItemBuilder = MediaItem.Builder()
if (playMessage.container.isNotEmpty()) { if (playMessage.container.isNotEmpty()) {
@ -225,6 +303,7 @@ class PlayerActivity : AppCompatActivity() {
_exoPlayer.seekTo(playMessage.time * 1000) _exoPlayer.seekTo(playMessage.time * 1000)
} }
setStatus(true, null)
_wasPlaying = false _wasPlaying = false
_exoPlayer.playWhenReady = true _exoPlayer.playWhenReady = true
_exoPlayer.prepare() _exoPlayer.prepare()
@ -250,5 +329,8 @@ class PlayerActivity : AppCompatActivity() {
companion object { companion object {
var instance: PlayerActivity? = null var instance: PlayerActivity? = null
private const val TAG = "PlayerActivity" private const val TAG = "PlayerActivity"
private const val SEEK_BACKWARD_MILLIS = 10_000
private const val SEEK_FORWARD_MILLIS = 10_000
} }
} }

View file

@ -108,7 +108,7 @@ class TcpListenerService : Service() {
_discoveryService = null _discoveryService = null
_serverSocket?.close() _serverSocket?.close()
_serverSocket = null; _serverSocket = null
_listenThread?.join() _listenThread?.join()
_listenThread = null _listenThread = null

View file

@ -14,6 +14,13 @@
android:layout_height="match_parent" android:layout_height="match_parent"
app:resize_mode="zoom" /> app:resize_mode="zoom" />
<View
android:id="@+id/view_demo"
android:layout_width="40dp"
android:layout_height="40dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView <TextView
android:id="@+id/text_title" android:id="@+id/text_title"
android:layout_width="match_parent" android:layout_width="match_parent"

View file

@ -3,6 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/black"> android:background="@color/black">
<com.google.android.exoplayer2.ui.StyledPlayerView <com.google.android.exoplayer2.ui.StyledPlayerView
@ -11,4 +12,43 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:keepScreenOn="true" android:keepScreenOn="true"
app:use_controller="true" /> app:use_controller="true" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#66000000"
android:clickable="false">
<ImageView
android:id="@+id/image_spinner"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginStart="8dp"
android:alpha="0.5"
app:srcCompat="@drawable/ic_loader_animated"
android:clickable="false"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/text_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
tools:text="This is a test message"
android:textColor="@color/white"
android:fontFamily="@font/inter_regular"
android:textSize="16sp"
android:clickable="false"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="10dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout> </FrameLayout>