Compare commits

...

9 commits

Author SHA1 Message Date
4c88992e5f
Functional add button for the main activity
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: Balazs Toldi <balazs@toldi.eu>
2021-11-29 12:52:38 +01:00
bebde9bc6a
Functional add button for the main activity
Signed-off-by: Balazs Toldi <balazs@toldi.eu>
2021-11-29 12:51:44 +01:00
0432c0be90
Stability changes
Signed-off-by: Balazs Toldi <balazs@toldi.eu>
2021-11-29 12:04:52 +01:00
cf4374d34b
Major refactoring
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-23 16:28:33 +01:00
01b883ed27
Updated .drone.yml
Some checks failed
continuous-integration/drone/push Build is failing
2021-11-23 10:45:23 +01:00
dfb6cb969e
Updated .drone.yml
Some checks reported errors
continuous-integration/drone/push Build was killed
2021-11-23 10:44:12 +01:00
f35295824c
Added ProgressBar for playback duration 2021-11-23 10:43:23 +01:00
dcac6e7801
Switched to LiveData as stated in the specification 2021-11-23 09:02:45 +01:00
a4e760f9d1
Minor changes
Some checks reported errors
continuous-integration/drone/push Build was killed
Signed-off-by: Toldi Balázs Ádám <balazs@toldi.eu>
2021-11-23 08:12:46 +01:00
17 changed files with 907 additions and 252 deletions

View file

@ -29,8 +29,14 @@ steps:
rm: true
target: /home/fdroid-builder/unsigned
source: "*.apk"
when:
branch:
- main
- name: update fdroid repo
image: appleboy/drone-ssh
when:
branch:
- main
settings:
host: git.toldi.eu
username:

View file

@ -10,6 +10,27 @@
<entry key="../../../../../layout/compose-model-1633601146222.xml" value="0.18285472972972974" />
<entry key="../../../../../layout/compose-model-1633617799626.xml" value="0.3095439189189189" />
<entry key="../../../../../layout/compose-model-1633617944748.xml" value="0.4962962962962963" />
<entry key="../../../../../layout/compose-model-1633767363997.xml" value="0.17905405405405406" />
<entry key="../../../../../layout/compose-model-1634889935306.xml" value="0.3095439189189189" />
<entry key="../../../../../layout/compose-model-1634890564069.xml" value="0.4861111111111111" />
<entry key="../../../../../layout/compose-model-1634891321942.xml" value="0.33" />
<entry key="../../../../../layout/compose-model-1634891753898.xml" value="1.0" />
<entry key="../../../../../layout/compose-model-1634893431951.xml" value="0.3095439189189189" />
<entry key="../../../../../layout/compose-model-1634898066813.xml" value="0.3095439189189189" />
<entry key="../../../../../layout/compose-model-1634898107086.xml" value="0.4759259259259259" />
<entry key="../../../../../layout/compose-model-1634990577157.xml" value="0.4703703703703704" />
<entry key="../../../../../layout/compose-model-1637651431048.xml" value="0.3611111111111111" />
<entry key="../../../../../layout/compose-model-1637651867699.xml" value="0.3095439189189189" />
<entry key="../../../../../layout/compose-model-1637652867578.xml" value="0.36018518518518516" />
<entry key="../../../../../layout/compose-model-1637653004789.xml" value="0.45740740740740743" />
<entry key="../../../../../layout/compose-model-1637654654111.xml" value="0.33" />
<entry key="../../../../../layout/compose-model-1637655458616.xml" value="0.45740740740740743" />
<entry key="../../../../../layout/compose-model-1637656156044.xml" value="0.25" />
<entry key="../../../../../layout/compose-model-1637673325505.xml" value="0.11570945945945946" />
<entry key="../../../../../layout/compose-model-1637674242617.xml" value="0.49537037037037035" />
<entry key="../../../../../layout/compose-model-1638180237267.xml" value="0.3095439189189189" />
<entry key="../../../../../layout/compose-model-1638183069943.xml" value="0.33" />
<entry key="../../../../../layout/compose-model-1638183903572.xml" value="0.48703703703703705" />
</map>
</option>
</component>

View file

@ -49,21 +49,22 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.0'
implementation 'com.google.android.material:material:1.4.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.3.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
implementation 'androidx.activity:activity-compose:1.4.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.10.0'
implementation 'su.litvak.chromecast:api-v2:0.11.3'
implementation 'com.github.yausername.youtubedl-android:library:0.12.+'
implementation 'com.github.yausername.youtubedl-android:ffmpeg:0.12.+'
implementation "androidx.media:media:1.4.3"
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

View file

@ -4,8 +4,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application
android:name=".App"
android:allowBackup="true"
android:icon="@drawable/ic_caster_logo"
android:label="@string/app_name"
@ -27,7 +29,12 @@
android:name=".ChromecastManagerActivity"
android:exported="true"
android:label="@string/title_activity_chromecast_manager"
android:theme="@style/Theme.Caster.NoActionBar" />
android:theme="@style/Theme.Caster.NoActionBar" >
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="true"
@ -39,6 +46,7 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service android:name=".services.ChromecastManagerService"/>
</application>
</manifest>

View file

@ -0,0 +1,21 @@
package eu.toldi.balazs.caster
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
class App : Application() {
companion object {
const val CHANNEL_ID = "casterServiceNotificationChannel"
}
override fun onCreate() {
super.onCreate()
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(CHANNEL_ID,"Caster Service Channel",NotificationManager.IMPORTANCE_LOW)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(serviceChannel)
}
}
}

View file

@ -1,5 +1,17 @@
package eu.toldi.balazs.caster
import android.util.Log
import com.yausername.youtubedl_android.YoutubeDL
import com.yausername.youtubedl_android.YoutubeDLException
import com.yausername.youtubedl_android.YoutubeDLRequest
import com.yausername.youtubedl_android.mapper.VideoInfo
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import su.litvak.chromecast.api.v2.Application
import su.litvak.chromecast.api.v2.ChromeCast
import su.litvak.chromecast.api.v2.MediaStatus
object ChromeCastHelper {
const val APP_BACKDROP = "E8C28D3C"
@ -13,4 +25,129 @@ object ChromeCastHelper {
const val APP_BUBBLEUPNP = "3927FA74"
const val APP_BBCSOUNDS = "03977A48"
const val APP_BBCIPLAYER = "5E81F6DB"
lateinit var chromeCast: ChromeCast
fun getApplicationDisplayName(id: String): String {
return when (id) {
APP_MEDIA_RECEIVER -> "Media Player"
APP_YOUTUBE -> "Youtube"
APP_PLEX -> "Plex"
APP_BACKDROP -> "Idle"
APP_DASHCAST -> "Dashcast"
APP_HOME_ASSISTANT -> "Home Assistant"
APP_SUPLA -> "Supla"
APP_YLEAREENA -> "Yleareena"
APP_BUBBLEUPNP -> "BubbleUPNP"
APP_BBCSOUNDS -> "BBC Sounds"
APP_BBCIPLAYER -> "BBC IPlayer"
else -> "Unknown App $id"
}
}
suspend fun fetchMediaStatus(): MediaStatus? {
try {
var mediaStatus : MediaStatus? = null
withContext(IO) {
val status = chromeCast.status
if (status.runningApp != null) {
mediaStatus = chromeCast.mediaStatus
}
}
return mediaStatus
} catch (e: Exception) {
Log.e(null, e.stackTraceToString())
}
return null
}
suspend fun seek(d: Double) {
val mediaStatus = fetchMediaStatus()
if (mediaStatus != null) {
try {
withContext(IO) {
chromeCast.seek(d * mediaStatus.media.duration)
}
} catch (e: Exception) {
Log.e(null, e.stackTraceToString())
}
}
}
suspend fun stopApp() {
withContext(IO) {
if (chromeCast.runningApp != null) {
chromeCast.stopApp()
}
}
}
suspend fun play() {
withContext(IO) {
chromeCast.play()
}
}
suspend fun pause(){
withContext(IO) {
chromeCast.pause()
}
}
suspend fun setVolume(f: Float) {
withContext(IO) {
chromeCast.setVolume(f)
}
}
suspend fun castLink(link : String, callBack : () -> Unit = {}) : Boolean{
var exitStatus = true
if(link.startsWith("https://").not() && link.startsWith("http://").not()) {
callBack.invoke()
return false
}
withContext(IO){
val request =
YoutubeDLRequest(link)
request.addOption("-f", "best")
val _streamInfo = try {
YoutubeDL.getInstance().getInfo(request)
} catch (e: YoutubeDLException) {
null
}
if (_streamInfo == null) {
exitStatus = false
callBack.invoke()
} else {
try {
val streamInfo = _streamInfo as VideoInfo
val status = chromeCast.status
if (chromeCast.isAppAvailable(ChromeCastHelper.APP_MEDIA_RECEIVER) && !status.isAppRunning(
ChromeCastHelper.APP_MEDIA_RECEIVER
)
) {
val app: Application =
chromeCast.launchApp(ChromeCastHelper.APP_MEDIA_RECEIVER)
}
while (!chromeCast.status.isAppRunning(ChromeCastHelper.APP_MEDIA_RECEIVER)) {
delay(100)
}
chromeCast.load(
streamInfo.title,
streamInfo.thumbnail,
streamInfo.url,
null
)
} catch (e: Exception) {
Log.e(null, e.stackTraceToString())
} finally {
callBack.invoke()
}
}
}
return exitStatus
}
}

View file

@ -1,10 +1,13 @@
package eu.toldi.balazs.caster
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@ -14,89 +17,51 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.toldi.balazs.caster.ui.theme.CasterTheme
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import su.litvak.chromecast.api.v2.Application
import su.litvak.chromecast.api.v2.ChromeCast
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import com.yausername.youtubedl_android.YoutubeDL
import com.yausername.youtubedl_android.YoutubeDLRequest
import com.yausername.youtubedl_android.YoutubeDLException
import com.yausername.youtubedl_android.mapper.VideoInfo
import eu.toldi.balazs.caster.model.ChromecastManageViewmodel
import eu.toldi.balazs.caster.services.ChromecastManagerService
import eu.toldi.balazs.caster.ui.theme.CasterTheme
import su.litvak.chromecast.api.v2.ChromeCast
import su.litvak.chromecast.api.v2.Media
import su.litvak.chromecast.api.v2.MediaStatus
class ChromecastManagerActivity : ComponentActivity() {
companion object {
var chromeCast_: ChromeCast? = null
fun castLink(chromeCast: ChromeCast,link : String, callBack : () -> Unit = {}) : Boolean{
var exitStatus = true
if(link.startsWith("https://").not() && link.startsWith("http://").not()) {
callBack.invoke()
return false
}
GlobalScope.launch(IO) {
val request =
YoutubeDLRequest(link)
request.addOption("-f", "best")
val _streamInfo = try {
YoutubeDL.getInstance().getInfo(request)
} catch (e: YoutubeDLException) {
null
}
if (_streamInfo == null) {
exitStatus = false
callBack.invoke()
} else {
val streamInfo = _streamInfo as VideoInfo
val status = chromeCast.status
if (chromeCast.isAppAvailable(ChromeCastHelper.APP_MEDIA_RECEIVER) && !status.isAppRunning(
ChromeCastHelper.APP_MEDIA_RECEIVER
)
) {
val app: Application = chromeCast.launchApp(ChromeCastHelper.APP_MEDIA_RECEIVER)
}
while (!chromeCast.status.isAppRunning(ChromeCastHelper.APP_MEDIA_RECEIVER)) {
delay(100)
}
chromeCast.load(
streamInfo.title,
streamInfo.thumbnail,
streamInfo.url,
null
)
callBack.invoke()
}
}
return exitStatus
}
}
private lateinit var chromeCast: ChromeCast
private lateinit var viewModel : ChromecastManageViewmodel
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (chromeCast_ == null)
finish()
chromeCast = chromeCast_ as ChromeCast
ChromeCastHelper.chromeCast = chromeCast
try {
YoutubeDL.getInstance().init(application)
} catch (e: YoutubeDLException) {
Log.e("Caster", "failed to initialize youtubedl-android", e)
}
Intent(this, ChromecastManagerService::class.java).also {
it.action = ChromecastManagerService.ACTION_INIT
it.putExtra("CHROMECAST_ADDRESS", chromeCast.address)
it.putExtra("CHROMECAST_NAME", chromeCast.title)
ContextCompat.startForegroundService(this, it)
}
setContent {
CasterTheme {
viewModel = ViewModelProvider(this).get(ChromecastManageViewmodel::class.java)
viewModel.chromeCast = chromeCast
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
Column {
//viewModel.fetchMediaStatus()
MenuBar()
Column(
modifier = Modifier
@ -115,24 +80,61 @@ class ChromecastManagerActivity : ComponentActivity() {
label = { Text("Cast URL") },
modifier = Modifier.padding(vertical = 4.dp)
)
var castEnabled by remember { mutableStateOf(true)}
var castEnabled by remember { mutableStateOf(true) }
Button(
enabled = castEnabled,
onClick = {
castEnabled = false
castLink(chromeCast,text) {
viewModel.castLink(text) {
castEnabled = true
}
},
modifier = Modifier.padding(vertical = 4.dp)
) {
Text(text = "Cast")
}
if(castEnabled.not()){
if (castEnabled.not()) {
CircularProgressIndicator()
}
playBackControl()
PlayBackControl()
val mediaStatus = viewModel.mediaStatus.value
if (mediaStatus != null) {
val nowPlaying =
if (mediaStatus.media.metadata != null) mediaStatus.media.metadata[Media.METADATA_TITLE] else ""
Text(text = "Now playing: $nowPlaying")
var sliderPosition by remember { mutableStateOf(0.0f) }
var sliderMoving by remember { mutableStateOf(false) }
if (!sliderMoving)
sliderPosition =
(mediaStatus.currentTime / mediaStatus.media.duration).toFloat()
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = String.format(
"%02d:%02d",
((mediaStatus.currentTime % 3600) / 60).toInt(),
(mediaStatus.currentTime % 60).toInt()
)
)
Text(
text = String.format(
"%02d:%02d",
((mediaStatus.media.duration % 3600) / 60).toInt(),
(mediaStatus.media.duration % 60).toInt()
)
)
}
Slider(value = sliderPosition, onValueChange = {
sliderPosition = it
sliderMoving = true
}, onValueChangeFinished = {
viewModel.seek(sliderPosition.toDouble())
sliderMoving = false
})
}
}
}
}
@ -164,63 +166,54 @@ class ChromecastManagerActivity : ComponentActivity() {
}
@Composable
fun playBackControl(){
fun PlayBackControl() {
val mediaStatus = if (this::viewModel.isInitialized)
viewModel.mediaStatus.value
else null
Row {
IconButton(onClick = {
GlobalScope.launch(IO) {
if(chromeCast.mediaStatus != null) {
chromeCast.seek(chromeCast.mediaStatus.currentTime - 10)
}
if (mediaStatus != null) {
viewModel.seek(chromeCast.mediaStatus.currentTime - 10)
}
}) {
}, enabled = mediaStatus != null) {
Icon(
Icons.Filled.FastRewind,
contentDescription = "FastRewind"
)
}
var playBackState by remember { mutableStateOf(MediaStatus.PlayerState.IDLE) }
GlobalScope.launch(IO) {
if(chromeCast.mediaStatus != null)
playBackState = chromeCast.mediaStatus.playerState
}
IconButton(onClick = {
GlobalScope.launch(IO) {
if(chromeCast.mediaStatus != null)
playBackState = chromeCast.mediaStatus.playerState
if(playBackState == MediaStatus.PlayerState.PLAYING) {
chromeCast.pause()
if (mediaStatus != null) {
if (mediaStatus.playerState == MediaStatus.PlayerState.PLAYING) {
viewModel.pause()
}
if(playBackState == MediaStatus.PlayerState.PAUSED) {
chromeCast.play()
if (mediaStatus.playerState == MediaStatus.PlayerState.PAUSED) {
viewModel.play()
}
if(chromeCast.mediaStatus != null)
playBackState = chromeCast.mediaStatus.playerState
}
}) {
when(playBackState) {
MediaStatus.PlayerState.PLAYING -> Icon(
Icons.Filled.Pause,
contentDescription = "Pause"
)
MediaStatus.PlayerState.PAUSED -> Icon(
}, enabled = mediaStatus != null) {
when {
mediaStatus == null || mediaStatus.playerState == MediaStatus.PlayerState.PAUSED -> Icon(
Icons.Filled.PlayArrow,
contentDescription = "Resume"
)
mediaStatus.playerState == MediaStatus.PlayerState.PLAYING -> Icon(
Icons.Filled.Pause,
contentDescription = "Pause"
)
else -> Icon(
Icons.Filled.PlayArrow,
contentDescription = "Resume"
)
}
}
IconButton(onClick = {
GlobalScope.launch(IO) {
if(chromeCast.runningApp != null)
chromeCast.stopApp()
}
}) {
viewModel.stopApp()
}, enabled = mediaStatus != null) {
Icon(
Icons.Filled.Stop,
contentDescription = "Stop"
@ -228,12 +221,10 @@ class ChromecastManagerActivity : ComponentActivity() {
}
IconButton(onClick = {
GlobalScope.launch(IO) {
if(chromeCast.mediaStatus != null) {
chromeCast.seek(chromeCast.mediaStatus.currentTime + 10)
}
if (chromeCast.mediaStatus != null) {
viewModel.seek(chromeCast.mediaStatus.currentTime + 10)
}
}) {
}, enabled = mediaStatus != null) {
Icon(
Icons.Filled.FastForward,
contentDescription = "FastForward"
@ -273,42 +264,30 @@ class ChromecastManagerActivity : ComponentActivity() {
modifier = Modifier.padding(vertical = 4.dp)
)
Button(
onClick = {
GlobalScope.launch(IO) {
val status = chromeCast.getStatus()
if (chromeCast.isAppAvailable("CC1AD845") && !status.isAppRunning(
"CC1AD845"
)
) {
val app: Application = chromeCast.launchApp("CC1AD845")
}
while (!status.isAppRunning("CC1AD845")) {
delay(100)
}
chromeCast.load(text)
}
},
onClick = {},
modifier = Modifier.padding(vertical = 4.dp)
) {
Text(text = "Cast")
}
playBackControl()
// val viewModel = ViewModelProvider(this).get(ChromecastManageViewmodel::class.java)
PlayBackControl()
Text(text = "Now playing: Some video")
var sliderPosition by remember { mutableStateOf(0f) }
Slider(value = sliderPosition, onValueChange = { sliderPosition = it })
}
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if(keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
GlobalScope.launch(IO) {
chromeCast.setVolume(chromeCast.status.volume.level + 0.05f)
}
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
viewModel.setVolume(chromeCast.status.volume.level + 0.05f)
return true
}
if(keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
GlobalScope.launch(IO) {
chromeCast.setVolume(chromeCast.status.volume.level - 0.05f)
}
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
viewModel.setVolume(chromeCast.status.volume.level - 0.05f)
return true
}
return super.onKeyDown(keyCode, event)

View file

@ -6,32 +6,22 @@ import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModelProvider
import eu.toldi.balazs.caster.model.ChromeCastViewModel
import eu.toldi.balazs.caster.ui.theme.CasterTheme
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import su.litvak.chromecast.api.v2.Application
import su.litvak.chromecast.api.v2.ChromeCast
import su.litvak.chromecast.api.v2.ChromeCasts
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
class MainActivity : ComponentActivity() {
@ -41,38 +31,59 @@ class MainActivity : ComponentActivity() {
setContent {
CasterTheme {
GlobalScope.launch(IO) {
ChromeCasts.startDiscovery(getIPv4Address())
}
val model: ChromeCastViewModel by viewModels()
var chromecastCount by remember {
mutableStateOf(ChromeCasts.get().size)
}
val viewModel = ViewModelProvider(this).get(ChromeCastViewModel::class.java)
viewModel.startScanning()
val chromeCastState = viewModel.chromeCasts.observeAsState(initial = emptyList())
val chromeCasts = chromeCastState.value
Log.e(null, chromeCasts.toString())
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
Column {
MenuBar()
var isAddChromecastDialogOpen by remember {
mutableStateOf(false)
}
MenuBar(
refresh = {
viewModel.refresh()
},
add = {
isAddChromecastDialogOpen = true
})
Text(text = "Chromecast count $chromecastCount")
LazyColumn(modifier = Modifier
.padding(all = 4.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) {
item {
if (chromecastCount > 0)
Text(text = "Available chromecasts:")
}
items(chromecastCount) { index ->
showChromeCastButton(chromeCast = ChromeCasts.get()[index])
if (isAddChromecastDialogOpen) {
showAddChromecastDialog(dismiss = {
isAddChromecastDialogOpen = false
}) {
if (it.matches(Regex("^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}\$")))
viewModel.addChromecast(ChromeCast(it).also { chromeCast ->
chromeCast.name = "Chromecast@$it"
})
}
}
}
}
SideEffect {
GlobalScope.launch(IO) {
while (true) {
chromecastCount = ChromeCasts.get().size
delay(1000)
LazyColumn(
modifier = Modifier
.padding(all = 4.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
if (chromeCasts.isNotEmpty())
Text(text = "Available chromecasts:")
else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Looking for Chromecasts on your network...")
CircularProgressIndicator()
}
}
}
items(chromeCasts.size) { index ->
showChromeCastButton(chromeCast = chromeCasts[index])
}
}
}
}
@ -80,51 +91,84 @@ class MainActivity : ComponentActivity() {
}
}
@Composable
fun showAddChromecastDialog(dismiss: () -> Unit, add: (String) -> Unit) {
var ipaddress by remember {
mutableStateOf("")
}
AlertDialog(onDismissRequest = dismiss,
title = {
Text(text = "Add Chromecast")
},
text = {
OutlinedTextField(
value = ipaddress,
onValueChange = {
ipaddress = it
},
label = { Text("IP address of the chromecast") },
modifier = Modifier.padding(vertical = 4.dp)
)
}, buttons = {
Row(
modifier = Modifier.padding(all = 8.dp),
horizontalArrangement = Arrangement.Center
) {
Button(
onClick = {
add(ipaddress)
dismiss()
},
modifier = Modifier.padding(all = 8.dp)
) {
Text(text = "Add Chromecast")
}
Button(onClick = dismiss, modifier = Modifier.padding(all = 8.dp)) {
Text("Cancel")
}
}
})
}
@Composable
fun showChromeCastButton(chromeCast: ChromeCast) {
Button(onClick = {
ChromecastManagerActivity.chromeCast_ = chromeCast
val intent = Intent(this, ChromecastManagerActivity::class.java)
startActivity(intent)
},
modifier = Modifier.padding(5.dp)
Button(
onClick = {
ChromecastManagerActivity.chromeCast_ = chromeCast
val intent = Intent(this, ChromecastManagerActivity::class.java)
startActivity(intent)
},
modifier = Modifier.padding(5.dp)
) {
Text(text = chromeCast.model)
}
}
fun getIPv4Address(): InetAddress? {
NetworkInterface.getNetworkInterfaces().toList().forEach { interf ->
interf.inetAddresses.toList().forEach { inetAddress ->
if (!inetAddress.isLoopbackAddress && inetAddress.hostAddress.indexOf(':') < 0) {
return inetAddress
}
when {
chromeCast.title != null -> Text(text = chromeCast.title)
chromeCast.name != null -> Text(text = chromeCast.name)
else -> Text(text = chromeCast.address)
}
}
return null
}
@Composable
fun MenuBar() {
fun MenuBar(refresh: () -> Unit, add: () -> Unit) {
TopAppBar(
title = {
Text("Caster")
},
navigationIcon = {
IconButton(onClick = { }) {
Icon(
Icons.Filled.Menu,
contentDescription = "Menu Hamburger"
)
}
},
actions = {
Row {
IconButton(onClick = {}) {
IconButton(onClick = refresh) {
Icon(
Icons.Filled.Share,
contentDescription = "Search Article"
Icons.Filled.Refresh,
contentDescription = "Refresh"
)
}
IconButton(onClick = add) {
Icon(
Icons.Filled.Add,
contentDescription = "Add"
)
}
}
@ -137,7 +181,7 @@ class MainActivity : ComponentActivity() {
@Composable
fun DefaultPreview() {
CasterTheme {
MenuBar()
MenuBar({}, {})
}
}
}

View file

@ -13,17 +13,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.yausername.youtubedl_android.YoutubeDL
import com.yausername.youtubedl_android.YoutubeDLException
import eu.toldi.balazs.caster.model.ChromeCastViewModel
import eu.toldi.balazs.caster.ui.theme.CasterTheme
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
@ -50,15 +52,16 @@ class ShareRecieverActivity : ComponentActivity() {
}
setContent {
CasterTheme {
var chromecastCount by remember { mutableStateOf(ChromeCasts.get().size) }
GlobalScope.launch(IO) {
ChromeCasts.startDiscovery(getIPv4Address())
}
Log.i(null, "Chromecast count: $chromecastCount")
val viewModel = ViewModelProvider(this).get(ChromeCastViewModel::class.java)
viewModel.startScanning()
val chromeCastState = viewModel.chromeCasts.observeAsState(emptyList())
val chromeCasts = chromeCastState.value
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
Column {
MenuBar()
MenuBar{
viewModel.refresh()
}
var enabled by remember {
mutableStateOf(true)
}
@ -69,14 +72,14 @@ class ShareRecieverActivity : ComponentActivity() {
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
if (chromecastCount > 0)
if (chromeCasts.isNotEmpty())
Text(text = "Available chromecasts:")
}
items(chromecastCount) { index ->
items(chromeCasts.size) { index ->
showChromeCastButton(
enabled = enabled,
onEnableChanged = { enabled = it },
chromeCast = ChromeCasts.get()[index],
chromeCast = chromeCasts[index],
link = link
)
}
@ -87,12 +90,6 @@ class ShareRecieverActivity : ComponentActivity() {
}
}
}
LaunchedEffect(true) {
while (true) {
delay(2000)
chromecastCount = ChromeCasts.get().size
}
}
}
}
}
@ -109,8 +106,11 @@ class ShareRecieverActivity : ComponentActivity() {
Button(
onClick = {
onEnableChanged(false)
ChromecastManagerActivity.castLink(chromeCast, link) {
finish()
lifecycleScope.launch {
ChromeCastHelper.chromeCast = chromeCast
ChromeCastHelper.castLink(link) {
finish()
}
}
},
modifier = Modifier.padding(5.dp),
@ -120,37 +120,27 @@ class ShareRecieverActivity : ComponentActivity() {
}
}
fun getIPv4Address(): InetAddress? {
NetworkInterface.getNetworkInterfaces().toList().forEach { interf ->
interf.inetAddresses.toList().forEach { inetAddress ->
if (!inetAddress.isLoopbackAddress && inetAddress.hostAddress.indexOf(':') < 0) {
return inetAddress
}
}
}
return null
}
@Composable
fun MenuBar() {
fun MenuBar(refresh: () -> Unit) {
TopAppBar(
title = {
Text(stringResource(id = R.string.title_activity_share_reciever))
},
navigationIcon = {
IconButton(onClick = { }) {
Icon(
Icons.Filled.Menu,
contentDescription = "Menu Hamburger"
)
}
},
actions = {
Row {
IconButton(onClick = {}) {
IconButton(onClick = refresh) {
Icon(
Icons.Filled.Share,
contentDescription = "Search Article"
Icons.Filled.Refresh,
contentDescription = "Refresh"
)
}
IconButton(onClick = {
}) {
Icon(
Icons.Filled.Add,
contentDescription = "Add"
)
}
}
@ -163,7 +153,7 @@ class ShareRecieverActivity : ComponentActivity() {
@Composable
fun DefaultPreview() {
CasterTheme {
MenuBar()
MenuBar {}
}
}
}

View file

@ -1,19 +1,67 @@
package eu.toldi.balazs.caster.model
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import su.litvak.chromecast.api.v2.ChromeCast
import su.litvak.chromecast.api.v2.ChromeCasts
import su.litvak.chromecast.api.v2.ChromeCastsListener
import java.net.InetAddress
import java.net.NetworkInterface
class ChromeCastViewModel : ViewModel() {
private val chromeCasts : MutableLiveData<List<ChromeCast>> by lazy {
MutableLiveData<List<ChromeCast>>().also {
ChromeCasts.get()
class ChromeCastViewModel : ViewModel(),ChromeCastsListener {
private val _chromecasts: MutableLiveData<List<ChromeCast>> =
MutableLiveData<List<ChromeCast>>(listOf())
val chromeCasts: LiveData<List<ChromeCast>>
get() = _chromecasts
init {
ChromeCasts.registerListener(this)
}
fun addChromecast(chromeCast: ChromeCast) {
_chromecasts.postValue(_chromecasts.value!!.plus(chromeCast))
}
override fun newChromeCastDiscovered(chromeCast: ChromeCast?) {
if (chromeCast != null) {
Log.i(null, "Found ${chromeCast.title}")
_chromecasts.postValue(_chromecasts.value!!.plus(chromeCast))
}
}
fun getChromecasts() : LiveData<List<ChromeCast>> {
return chromeCasts
override fun chromeCastRemoved(chromeCast: ChromeCast?) {
if (chromeCast != null) {
Log.i(null,"Lost ${chromeCast.title}")
_chromecasts.postValue(_chromecasts.value!!.minus(chromeCast))
}
}
fun refresh() {
_chromecasts.value = emptyList()
viewModelScope.launch(IO) {
ChromeCasts.restartDiscovery(getIPv4Address())
}
}
fun startScanning() {
viewModelScope.launch(IO) {
ChromeCasts.startDiscovery(getIPv4Address())
}
}
fun getIPv4Address(): InetAddress? {
NetworkInterface.getNetworkInterfaces().toList().forEach { interf ->
interf.inetAddresses.toList().forEach { inetAddress ->
if (!inetAddress.isLoopbackAddress && inetAddress.hostAddress.indexOf(':') < 0) {
return inetAddress
}
}
}
return null
}
}

View file

@ -0,0 +1,86 @@
package eu.toldi.balazs.caster.model
import android.util.Log
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import eu.toldi.balazs.caster.ChromeCastHelper
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import su.litvak.chromecast.api.v2.ChromeCast
import su.litvak.chromecast.api.v2.MediaStatus
class ChromecastManageViewmodel : ViewModel() {
private val _mediaState = mutableStateOf<MediaStatus?>(null)
val mediaStatus: State<MediaStatus?>
get() = _mediaState
lateinit var chromeCast: ChromeCast
init {
viewModelScope.launch(IO) {
fetchMediaStatus()
while (true) {
fetchMediaStatus()
Thread.sleep(3000)
}
}
}
/*
override fun spontaneousEventReceived(event: ChromeCastSpontaneousEvent?) {
Log.e(null, event?.type.toString())
if (event != null) {
if (event.type == ChromeCastSpontaneousEvent.SpontaneousEventType.MEDIA_STATUS) {
_mediaState.value = event.data as MediaStatus
}
}
}*/
fun fetchMediaStatus() {
viewModelScope.launch(IO) {
try {
_mediaState.value = ChromeCastHelper.fetchMediaStatus()
} catch (e: Exception) {
Log.e(null, e.stackTraceToString())
}
}
}
fun seek(d: Double) {
viewModelScope.launch(IO) {
ChromeCastHelper.seek(d)
}
}
fun castLink(link : String,callBack: () -> Unit = {}) {
viewModelScope.launch(IO) {
ChromeCastHelper.castLink(link,callBack)
}
}
fun stopApp() {
viewModelScope.launch(IO) {
ChromeCastHelper.stopApp()
}
}
fun play() {
viewModelScope.launch(IO) {
ChromeCastHelper.play()
}
}
fun pause() {
viewModelScope.launch(IO) {
ChromeCastHelper.pause()
}
}
fun setVolume(f: Float) {
viewModelScope.launch(IO) {
ChromeCastHelper.setVolume(f)
}
}
}

View file

@ -0,0 +1,274 @@
package eu.toldi.balazs.caster.services
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.media.MediaPlayer
import android.os.Build
import android.os.IBinder
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import eu.toldi.balazs.caster.App.Companion.CHANNEL_ID
import eu.toldi.balazs.caster.ChromeCastHelper
import eu.toldi.balazs.caster.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import su.litvak.chromecast.api.v2.ChromeCast
import su.litvak.chromecast.api.v2.Media
import su.litvak.chromecast.api.v2.MediaStatus
class ChromecastManagerService : Service() {
companion object {
const val ACTION_INIT = "action_init"
const val ACTION_PLAY = "action_play"
const val ACTION_PAUSE = "action_pause"
const val ACTION_REWIND = "action_rewind"
const val ACTION_FAST_FORWARD = "action_fast_foward"
const val ACTION_NEXT = "action_next"
const val ACTION_PREVIOUS = "action_previous"
const val ACTION_STOP = "action_stop"
}
private val mMediaPlayer = MediaPlayer()
private lateinit var mSession: MediaSessionCompat
private lateinit var mController: MediaControllerCompat
private lateinit var pendingIntent : PendingIntent
private var mediaStatus: MediaStatus? = null
override fun onBind(p0: Intent?): IBinder? = null
private lateinit var chromeCast: ChromeCast
@RequiresApi(Build.VERSION_CODES.M)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when(intent!!.action) {
ACTION_INIT -> initAction(intent)
ACTION_PLAY -> mController.transportControls.play()
ACTION_PAUSE -> mController.transportControls.pause()
ACTION_FAST_FORWARD -> mController.transportControls.fastForward()
ACTION_REWIND -> mController.transportControls.rewind()
}
return START_REDELIVER_INTENT
}
private fun initAction(intent: Intent?){
initMediaSessions()
val address =
intent?.getStringExtra("CHROMECAST_ADDRESS") ?: throw IllegalArgumentException()
val name = intent?.getStringExtra("CHROMECAST_NAME") ?: "Chromecast"
Log.e(null, address)
chromeCast = ChromeCast(address)
chromeCast.name = name
val notificationIntent = Intent(this, ChromecastManagerService::class.java)
pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0)
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Caster for ${chromeCast.name}@${address}")
.setSmallIcon(R.drawable.ic_caster_logo)
.setContentIntent(pendingIntent).build()
startForeground(1, notification)
GlobalScope.launch(IO) {
while (true) {
try {
ChromeCastHelper.chromeCast = chromeCast
var status = chromeCast.status
Log.d(null, status.applications.toString())
val status_message = if (status.runningApp != null) {
ChromeCastHelper.getApplicationDisplayName(status.runningApp.id)
} else {
"${chromeCast.name} is ready for casting"
}
mediaStatus = ChromeCastHelper.fetchMediaStatus()
buildNotification()
} catch (e: Exception) {
Log.e(null,e.stackTraceToString())
}
Thread.sleep(10500)
}
}
}
private fun generateAction(
icon: Int,
title: String,
intentAction: String
): NotificationCompat.Action {
val intent = Intent(applicationContext, ChromecastManagerService::class.java)
intent.action = intentAction
val pendingIntent = PendingIntent.getService(applicationContext, 1, intent, 0)
return NotificationCompat.Action(icon,title,pendingIntent)
}
private suspend fun buildNotification() {
var status = chromeCast.status
Log.d(null, status.applications.toString())
val status_message = if (status.runningApp != null) {
ChromeCastHelper.getApplicationDisplayName(status.runningApp.id)
} else {
"${chromeCast.name} is ready for xcasting"
}
val notification: Notification
val mNotificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (mediaStatus != null) {
notification = if(mediaStatus!!.playerState == MediaStatus.PlayerState.PLAYING) {
NotificationCompat.Builder(baseContext, CHANNEL_ID)
.setContentTitle(status_message)
.setSmallIcon(R.drawable.ic_caster_logo)
.setContentText(mediaStatus!!.media.metadata[Media.METADATA_TITLE].toString())
.addAction(
generateAction(
R.drawable.rewind,
"Rewind",
ACTION_REWIND
)
)
.addAction(
generateAction(
R.drawable.pause,
"Pause",
ACTION_PAUSE
)
)
.addAction(
generateAction(
R.drawable.forward,
"Fast forward",
ACTION_FAST_FORWARD
)
)
// Apply the media style template
.setStyle(androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(1 /* #1: pause button \*/)
.setMediaSession(mSession.sessionToken))
.setContentIntent(pendingIntent).build()
} else {
NotificationCompat.Builder(baseContext, CHANNEL_ID)
.setContentTitle(status_message)
.setSmallIcon(R.drawable.ic_caster_logo)
.setContentText(mediaStatus!!.media.metadata[Media.METADATA_TITLE].toString())
.addAction(
generateAction(
android.R.drawable.ic_media_rew,
"Rewind",
ACTION_REWIND
)
)
.addAction(
generateAction(
android.R.drawable.ic_media_play,
"Pause",
ACTION_PLAY
)
)
.addAction(
generateAction(
android.R.drawable.ic_media_ff,
"Fast forward",
ACTION_FAST_FORWARD
)
)
// Apply the media style template
.setStyle(androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(1 /* #1: pause button \*/)
.setMediaSession(mSession.sessionToken))
.setContentIntent(pendingIntent).build()
}
withContext(Dispatchers.Main) {
mNotificationManager.notify(1, notification)
}
} else {
notification = NotificationCompat.Builder(baseContext, CHANNEL_ID)
.setContentTitle(status_message)
.setSmallIcon(R.drawable.ic_caster_logo)
.setContentIntent(pendingIntent).build()
withContext(Dispatchers.Main) {
mNotificationManager.notify(1, notification)
}
}
}
private fun initMediaSessions() {
mSession = MediaSessionCompat(applicationContext, "simple player session")
mController = MediaControllerCompat(applicationContext, mSession.sessionToken)
mSession.setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
super.onPlay()
Log.e("MediaPlayerService", "onPlay")
GlobalScope.launch(IO) {
if(mediaStatus != null)
ChromeCastHelper.play()
}
}
override fun onPause() {
super.onPause()
Log.e("MediaPlayerService", "onPause")
GlobalScope.launch(IO) {
if(mediaStatus != null)
ChromeCastHelper.pause()
}
}
override fun onFastForward() {
super.onFastForward()
Log.e("MediaPlayerService", "onFastForward")
GlobalScope.launch(IO) {
val mediaStatus = ChromeCastHelper.fetchMediaStatus()
if(mediaStatus != null)
ChromeCastHelper.seek(mediaStatus.currentTime+10)
}
//Manipulate current media here
}
override fun onRewind() {
super.onRewind()
Log.e("MediaPlayerService", "onRewind")
GlobalScope.launch(IO) {
val mediaStatus = ChromeCastHelper.fetchMediaStatus()
if(mediaStatus != null)
ChromeCastHelper.seek(mediaStatus.currentTime-10)
}
}
override fun onStop() {
super.onStop()
Log.e("MediaPlayerService", "onStop")
//Stop media player here
stopForeground(true)
val intent = Intent(applicationContext, ChromecastManagerService::class.java)
stopService(intent)
}
}
)
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M4,18l8.5,-6L4,6v12zM13,6v12l8.5,-6L13,6z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M11,18L11,6l-8.5,6 8.5,6zM11.5,12l8.5,6L20,6l-8.5,6z"/>
</vector>

View file

@ -24,7 +24,7 @@ ext.getVersionCode = { ->
try {
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-list', '--first-parent', '--count', 'origin/main'
commandLine 'git', 'rev-list', '--first-parent', '--count', '--all'
standardOutput = stdout
}
return Integer.parseInt(stdout.toString().trim())