diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/FCastSession.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/FCastSession.kt index fc75661..4642c50 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/FCastSession.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/FCastSession.kt @@ -36,7 +36,7 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene private var _bytesRead = 0 private var _packetLength = 0 private var _state = SessionState.WaitingForLength - private var _outputStream: DataOutputStream? = DataOutputStream(_socket.outputStream); + private var _outputStream: DataOutputStream? = DataOutputStream(_socket.outputStream) fun sendPlaybackUpdate(value: PlaybackUpdateMessage) { send(Opcode.PlaybackUpdate, value) @@ -48,40 +48,40 @@ class FCastSession(private val _socket: Socket, private val _service: TcpListene private inline fun send(opcode: Opcode, message: T) { try { - val data: ByteArray; - var jsonString: String? = null; + val data: ByteArray + var jsonString: String? = null if (message != null) { - jsonString = Json.encodeToString(message); - data = jsonString.encodeToByteArray(); + jsonString = Json.encodeToString(message) + data = jsonString.encodeToByteArray() } else { - data = ByteArray(0); + data = ByteArray(0) } - val size = 1 + data.size; - val outputStream = _outputStream; + val size = 1 + data.size + val outputStream = _outputStream if (outputStream == null) { - Log.w(TAG, "Failed to send $size bytes, output stream is null."); - return; + Log.w(TAG, "Failed to send $size bytes, output stream is null.") + return } - val serializedSizeLE = ByteArray(4); - serializedSizeLE[0] = (size and 0xff).toByte(); - serializedSizeLE[1] = (size shr 8 and 0xff).toByte(); - serializedSizeLE[2] = (size shr 16 and 0xff).toByte(); - serializedSizeLE[3] = (size shr 24 and 0xff).toByte(); - outputStream.write(serializedSizeLE); + val serializedSizeLE = ByteArray(4) + serializedSizeLE[0] = (size and 0xff).toByte() + serializedSizeLE[1] = (size shr 8 and 0xff).toByte() + serializedSizeLE[2] = (size shr 16 and 0xff).toByte() + serializedSizeLE[3] = (size shr 24 and 0xff).toByte() + outputStream.write(serializedSizeLE) - val opcodeBytes = ByteArray(1); - opcodeBytes[0] = opcode.value; - outputStream.write(opcodeBytes); + val opcodeBytes = ByteArray(1) + opcodeBytes[0] = opcode.value + outputStream.write(opcodeBytes) 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) { - Log.i(TAG, "Failed to send message.", e); + Log.i(TAG, "Failed to send message.", e) } } diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/MainActivity.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/MainActivity.kt index 1749ed6..abe519e 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/MainActivity.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/MainActivity.kt @@ -47,8 +47,11 @@ class MainActivity : AppCompatActivity() { private lateinit var _imageSpinner: ImageView private lateinit var _layoutConnectionInfo: ConstraintLayout private lateinit var _videoBackground: StyledPlayerView + private lateinit var _viewDemo: View private lateinit var _player: ExoPlayer private var _updating: Boolean = false + private var _demoClickCount = 0 + private var _lastDemoToast: Toast? = null private val _scope: CoroutineScope = CoroutineScope(Dispatchers.Main) @@ -64,6 +67,7 @@ class MainActivity : AppCompatActivity() { _imageSpinner = findViewById(R.id.image_spinner) _layoutConnectionInfo = findViewById(R.id.layout_connection_info) _videoBackground = findViewById(R.id.video_background) + _viewDemo = findViewById(R.id.view_demo) startVideo() startAnimations() @@ -80,6 +84,18 @@ class MainActivity : AppCompatActivity() { 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) { _text.visibility = View.INVISIBLE _buttonUpdate.visibility = View.INVISIBLE @@ -249,15 +265,15 @@ class MainActivity : AppCompatActivity() { } private suspend fun checkForUpdates() { - Log.i(TAG, "Checking for updates..."); + Log.i(TAG, "Checking for updates...") withContext(Dispatchers.IO) { try { val latestVersion = downloadVersionCode() if (latestVersion != null) { - val currentVersion = BuildConfig.VERSION_CODE; - Log.i(TAG, "Current version $currentVersion latest version $latestVersion."); + val currentVersion = BuildConfig.VERSION_CODE + Log.i(TAG, "Current version $currentVersion latest version $latestVersion.") if (latestVersion > currentVersion) { 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)) _buttonUpdate.visibility = View.VISIBLE } catch (e: Throwable) { - Toast.makeText(this@MainActivity, "Failed to show update dialog", Toast.LENGTH_LONG).show(); - Log.w(TAG, "Error occurred in update dialog."); + Toast.makeText(this@MainActivity, "Failed to show update dialog", Toast.LENGTH_LONG).show() + Log.w(TAG, "Error occurred in update dialog.") } } } else { @@ -277,21 +293,21 @@ class MainActivity : AppCompatActivity() { _buttonUpdate.visibility = View.INVISIBLE //setText(getString(R.string.no_updates_available)) 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 { - Log.w(TAG, "Failed to retrieve version from version URL."); + Log.w(TAG, "Failed to retrieve version from version URL.") 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) { - Log.w(TAG, "Failed to check for updates.", e); + Log.w(TAG, "Failed to check for updates.", e) 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) } } } - return ips; + return ips } companion object { diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/PlayerActivity.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/PlayerActivity.kt index 950474e..43811a9 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/PlayerActivity.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/PlayerActivity.kt @@ -1,18 +1,25 @@ package com.futo.fcast.receiver import android.content.Context +import android.graphics.drawable.Animatable import android.net.* import android.os.Build import android.os.Bundle import android.util.Log +import android.view.KeyEvent +import android.view.View import android.view.Window import android.view.WindowInsets import android.view.WindowManager +import android.widget.ImageView +import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout import com.google.android.exoplayer2.* import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.source.dash.DashMediaSource 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.ui.StyledPlayerView import com.google.android.exoplayer2.upstream.DefaultDataSource @@ -20,14 +27,18 @@ import kotlinx.coroutines.* import java.io.File import java.io.FileOutputStream import kotlin.math.abs +import kotlin.math.max class PlayerActivity : AppCompatActivity() { 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 var _shouldPlaybackRestartOnConnectivity: Boolean = false private lateinit var _connectivityManager: ConnectivityManager private lateinit var _scope: CoroutineScope - private var _wasPlaying = false; + private var _wasPlaying = false val currentPosition get() = _exoPlayer.currentPosition val isPlaying get() = _exoPlayer.isPlaying @@ -59,16 +70,25 @@ class PlayerActivity : AppCompatActivity() { private val _playerEventListener = object: Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) + Log.i(TAG, "onPlaybackStateChanged playbackState=$playbackState") if (_shouldPlaybackRestartOnConnectivity && playbackState == ExoPlayer.STATE_READY) { Log.i(TAG, "_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) { super.onPlayerError(error) + Log.e(TAG, "onPlayerError: $error") + when (error.errorCode) { PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, @@ -83,6 +103,8 @@ class PlayerActivity : AppCompatActivity() { _shouldPlaybackRestartOnConnectivity = true } } + + setStatus(false, getFullExceptionMessage(error)) } override fun onVolumeChanged(volume: Float) { @@ -107,8 +129,13 @@ class PlayerActivity : AppCompatActivity() { setFullScreen() _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) + setStatus(true, null) + val trackSelector = DefaultTrackSelector(this) trackSelector.parameters = trackSelector.parameters .buildUpon() @@ -148,6 +175,35 @@ class PlayerActivity : AppCompatActivity() { if (hasFocus) setFullScreen() } + private fun getFullExceptionMessage(ex: Throwable): String { + val messages = mutableListOf() + 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() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { window.insetsController?.hide(WindowInsets.Type.statusBars()) @@ -189,6 +245,28 @@ class PlayerActivity : AppCompatActivity() { 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) { val mediaItemBuilder = MediaItem.Builder() if (playMessage.container.isNotEmpty()) { @@ -225,6 +303,7 @@ class PlayerActivity : AppCompatActivity() { _exoPlayer.seekTo(playMessage.time * 1000) } + setStatus(true, null) _wasPlaying = false _exoPlayer.playWhenReady = true _exoPlayer.prepare() @@ -250,5 +329,8 @@ class PlayerActivity : AppCompatActivity() { companion object { var instance: PlayerActivity? = null private const val TAG = "PlayerActivity" + + private const val SEEK_BACKWARD_MILLIS = 10_000 + private const val SEEK_FORWARD_MILLIS = 10_000 } } \ No newline at end of file diff --git a/receivers/android/app/src/main/java/com/futo/fcast/receiver/TcpListenerService.kt b/receivers/android/app/src/main/java/com/futo/fcast/receiver/TcpListenerService.kt index 8bb4bb0..37e4c8d 100644 --- a/receivers/android/app/src/main/java/com/futo/fcast/receiver/TcpListenerService.kt +++ b/receivers/android/app/src/main/java/com/futo/fcast/receiver/TcpListenerService.kt @@ -108,7 +108,7 @@ class TcpListenerService : Service() { _discoveryService = null _serverSocket?.close() - _serverSocket = null; + _serverSocket = null _listenThread?.join() _listenThread = null diff --git a/receivers/android/app/src/main/res/layout/activity_main.xml b/receivers/android/app/src/main/res/layout/activity_main.xml index bdf3cfe..cd35a2d 100644 --- a/receivers/android/app/src/main/res/layout/activity_main.xml +++ b/receivers/android/app/src/main/res/layout/activity_main.xml @@ -14,6 +14,13 @@ android:layout_height="match_parent" app:resize_mode="zoom" /> + + + + + + + + + + \ No newline at end of file