├── .gitignore ├── .idea ├── .gitignore ├── .name ├── compiler.xml ├── dbnavigator.xml ├── deploymentTargetDropDown.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── libs │ └── android-smartview-sdk-2.5.2.jar ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── androidcast │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── androidcast │ │ │ ├── CastOptionsProvider.kt │ │ │ ├── CastViewModel.kt │ │ │ ├── MainActivity.kt │ │ │ └── ui │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-anydpi-v33 │ │ └── ic_launcher.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── androidcast │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshot-1.png ├── screenshot-2.png └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | AndroidCast -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/dbnavigator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Gidex 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AndroidCast 2 | 3 | AndroidCast is an Android app built using Kotlin and Jetpack Compose that allows you to easily cast online videos to your cast device. 4 | 5 | 6 | 7 | ## Support 8 | 9 | The following devices and platforms are currently supported: 10 | 11 | - Samsung TVs (Smart View SDK) 12 | - Chromecast (Google Cast SDK) 13 | 14 | Video URL used in testing: 15 | 16 | https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 17 | 18 | ## Contributing 19 | 20 | Contributions are welcome! If you find a bug or have a feature request, please [open an issue](https://github.com/Gidex/AndroidCast/issues/new). 21 | 22 | If you would like to contribute code, please follow these steps: 23 | 24 | 1. Fork the repository 25 | 2. Create a new branch for your feature or bug fix: `git checkout -b my-feature` 26 | 3. Make your changes and commit them: `git commit -am 'Add new feature'` 27 | 4. Push your changes to your fork: `git push origin my-feature` 28 | 5. Create a new pull request 29 | 30 | ## License 31 | 32 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 33 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | } 5 | 6 | android { 7 | namespace 'com.androidcast' 8 | compileSdk 34 9 | 10 | defaultConfig { 11 | applicationId "com.androidcast" 12 | minSdk 24 13 | targetSdk 34 14 | versionCode 2 15 | versionName "1.1" 16 | 17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 18 | vectorDrawables { 19 | useSupportLibrary true 20 | } 21 | } 22 | 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility JavaVersion.VERSION_17 31 | targetCompatibility JavaVersion.VERSION_17 32 | } 33 | kotlinOptions { 34 | jvmTarget = '17' 35 | } 36 | buildFeatures { 37 | compose true 38 | } 39 | composeOptions { 40 | kotlinCompilerExtensionVersion '1.5.5' 41 | } 42 | packagingOptions { 43 | resources { 44 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 45 | } 46 | } 47 | } 48 | 49 | dependencies { 50 | 51 | implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'], exclude: []) 52 | 53 | implementation 'androidx.core:core-ktx:1.12.0' 54 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2' 55 | implementation 'androidx.activity:activity-compose:1.8.1' 56 | 57 | implementation "androidx.compose.ui:ui:$compose_version" 58 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" 59 | implementation 'androidx.compose.material3:material3:1.1.2' 60 | implementation "androidx.compose.material:material-icons-core:$compose_version" 61 | implementation "androidx.compose.material:material-icons-extended:$compose_version" 62 | 63 | implementation 'androidx.mediarouter:mediarouter:1.6.0' 64 | implementation 'com.google.android.gms:play-services-cast-framework:21.4.0' 65 | 66 | implementation 'androidx.core:core-ktx:1.12.0' 67 | testImplementation 'junit:junit:4.13.2' 68 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 69 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' 70 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" 71 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" 72 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" 73 | } -------------------------------------------------------------------------------- /app/libs/android-smartview-sdk-2.5.2.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/libs/android-smartview-sdk-2.5.2.jar -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/androidcast/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package tapioca.samsungcast 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("tapioca.samsungcast", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 19 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/androidcast/CastOptionsProvider.kt: -------------------------------------------------------------------------------- 1 | package com.androidcast 2 | 3 | import android.content.Context 4 | import com.google.android.gms.cast.CastMediaControlIntent 5 | import com.google.android.gms.cast.framework.CastOptions 6 | import com.google.android.gms.cast.framework.OptionsProvider 7 | import com.google.android.gms.cast.framework.SessionProvider 8 | 9 | class CastOptionsProvider : OptionsProvider { 10 | override fun getCastOptions(context: Context): CastOptions { 11 | return CastOptions.Builder() 12 | .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) 13 | .build() 14 | } 15 | 16 | override fun getAdditionalSessionProviders(context: Context): List? { 17 | return null 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/androidcast/CastViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.androidcast 2 | 3 | import android.net.Uri 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableFloatStateOf 6 | import androidx.compose.runtime.mutableIntStateOf 7 | import androidx.compose.runtime.mutableStateListOf 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.setValue 10 | import androidx.lifecycle.ViewModel 11 | import androidx.mediarouter.media.MediaControlIntent 12 | import androidx.mediarouter.media.MediaRouteSelector 13 | import androidx.mediarouter.media.MediaRouter 14 | import com.google.android.gms.cast.MediaInfo 15 | import com.google.android.gms.cast.MediaLoadRequestData 16 | import com.google.android.gms.cast.MediaMetadata 17 | import com.google.android.gms.cast.MediaSeekOptions 18 | import com.google.android.gms.cast.framework.CastContext 19 | import com.google.android.gms.cast.framework.CastState 20 | import com.google.android.gms.cast.framework.CastStateListener 21 | import com.google.android.gms.cast.framework.SessionManager 22 | import com.google.android.gms.cast.framework.media.RemoteMediaClient 23 | import com.samsung.multiscreen.Error 24 | import com.samsung.multiscreen.Player 25 | import com.samsung.multiscreen.Search 26 | import com.samsung.multiscreen.Service 27 | import com.samsung.multiscreen.VideoPlayer 28 | import org.json.JSONArray 29 | import org.json.JSONObject 30 | import java.util.concurrent.TimeUnit 31 | 32 | enum class CastDeviceState { 33 | SEARCHING, 34 | CONNECTING, 35 | CONNECTED 36 | 37 | } 38 | 39 | enum class PlayerState { 40 | IDLE, 41 | READY, 42 | BUFFERING 43 | } 44 | 45 | open class Device(val id: String, val name: String?, val description: String?, val enable: Boolean) 46 | data class SamsungDevice(val service: Service) : 47 | Device( 48 | service.id, 49 | service.name, 50 | service.type+ " - " + service.version, 51 | !service.isStandbyService 52 | ) 53 | data class ChromeCastDevice(val route: MediaRouter.RouteInfo) : 54 | Device( 55 | route.id, 56 | route.name, 57 | route.description, 58 | route.isEnabled 59 | ) 60 | 61 | class CastViewModel(private val search: Search, private val castContext: CastContext, private var mediaRouter: MediaRouter) : ViewModel() { 62 | 63 | private var urlToPlay by mutableStateOf("") 64 | 65 | var deviceState by mutableStateOf(CastDeviceState.SEARCHING) 66 | val deviceList = mutableStateListOf() 67 | var currentDevice by mutableStateOf(null) 68 | 69 | var playerState by mutableStateOf(PlayerState.IDLE) 70 | 71 | var isPlaying by mutableStateOf(true) 72 | 73 | val duration = mutableIntStateOf(0) 74 | val currentTime = mutableIntStateOf(0) 75 | val progress = mutableFloatStateOf(0f) 76 | 77 | private var videoPlayer by mutableStateOf(null) 78 | private var remoteMediaClient by mutableStateOf(null) 79 | 80 | init { 81 | 82 | search.setOnServiceFoundListener(serviceFound()) 83 | search.setOnServiceLostListener(serviceLost()) 84 | 85 | castContext.addCastStateListener(castStateListener()) 86 | 87 | //search.start() 88 | 89 | startSearch() 90 | } 91 | 92 | private fun serviceFound() = Search.OnServiceFoundListener { service -> 93 | println(service.name) 94 | 95 | if (!deviceList.any { it.id == service.id } && !service.isStandbyService) 96 | deviceList.add(SamsungDevice(service = service)) 97 | } 98 | 99 | private fun serviceLost() = Search.OnServiceLostListener { service -> 100 | println(service.name) 101 | 102 | val deviceById = deviceList.find { it.id == service.id } 103 | deviceList.remove(deviceById) 104 | } 105 | 106 | fun startSearch(){ 107 | search.start() 108 | 109 | val selector = MediaRouteSelector.Builder() 110 | // These are the framework-supported intents 111 | //.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO) 112 | //.addControlCategory(MediaControlIntent.CATEGORY_LIVE_VIDEO) 113 | .addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK) 114 | .build() 115 | 116 | mediaRouter.addCallback(selector, mediaRouterCallBack(), MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN) 117 | 118 | 119 | deviceState = CastDeviceState.SEARCHING 120 | } 121 | 122 | fun stopSearch(){ 123 | search.stop() 124 | 125 | mediaRouter.removeCallback(mediaRouterCallBack()) 126 | } 127 | 128 | fun connect(url: String, device: Device) { 129 | urlToPlay = url 130 | currentDevice = device 131 | 132 | deviceState = CastDeviceState.CONNECTING 133 | stopSearch() 134 | 135 | when(device) { 136 | is SamsungDevice -> { 137 | videoPlayer = device.service.createVideoPlayer("AndroidCast") 138 | videoPlayer?.addOnMessageListener(videoPlayerListener()) 139 | videoPlayer?.playContent(Uri.parse(urlToPlay), "AndroidCast", Uri.parse(""), object : com.samsung.multiscreen.Result { 140 | override fun onSuccess(p0: Boolean?) { 141 | //result(true, null) 142 | deviceState = CastDeviceState.CONNECTED 143 | } 144 | 145 | override fun onError(p0: Error?) { 146 | startSearch() 147 | } 148 | 149 | }) 150 | } 151 | is ChromeCastDevice -> { 152 | mediaRouter.selectRoute(device.route) 153 | } 154 | } 155 | 156 | 157 | } 158 | 159 | fun play() { 160 | videoPlayer?.play() 161 | remoteMediaClient?.play() 162 | } 163 | 164 | fun pause(){ 165 | videoPlayer?.pause() 166 | remoteMediaClient?.pause() 167 | } 168 | 169 | fun rewind(){ 170 | videoPlayer?.rewind() 171 | 172 | remoteMediaClient?.let { 173 | val rewindTime = it.approximateStreamPosition - 10000 174 | val mso = MediaSeekOptions.Builder().setPosition(rewindTime).build() 175 | it.seek(mso) 176 | } 177 | } 178 | 179 | fun forward(){ 180 | videoPlayer?.forward() 181 | 182 | remoteMediaClient?.let { 183 | val forwardTime = it.approximateStreamPosition + 10000 184 | val mso = MediaSeekOptions.Builder().setPosition(forwardTime).build() 185 | it.seek(mso) 186 | } 187 | } 188 | 189 | fun seek(time: Int){ 190 | videoPlayer?.seekTo(time, TimeUnit.MILLISECONDS) 191 | 192 | remoteMediaClient?.let { 193 | val mso = MediaSeekOptions.Builder().setPosition(time.toLong()).build() 194 | it.seek(mso) 195 | } 196 | } 197 | 198 | fun stop(){ 199 | videoPlayer?.stop() 200 | videoPlayer?.disconnect() 201 | 202 | remoteMediaClient?.stop() 203 | mediaRouter.unselect(MediaRouter.UNSELECT_REASON_STOPPED) 204 | 205 | startSearch() 206 | 207 | resetAll() 208 | } 209 | 210 | private fun resetAll() { 211 | videoPlayer = null 212 | remoteMediaClient= null 213 | 214 | playerState = PlayerState.IDLE 215 | isPlaying = false 216 | 217 | duration.intValue = 0 218 | currentTime.intValue = 0 219 | progress.floatValue = 0f 220 | } 221 | 222 | fun disconnect(){ 223 | videoPlayer?.disconnect() 224 | } 225 | 226 | private fun videoPlayerListener() = object : VideoPlayer.OnVideoPlayerListener { 227 | override fun onBufferingStart() { 228 | println("onBufferingStart") 229 | //onBuffering(true) 230 | playerState = PlayerState.BUFFERING 231 | 232 | } 233 | 234 | override fun onBufferingComplete() { 235 | println("onBufferingComplete") 236 | playerState = PlayerState.READY 237 | /*if (playWhenReady.value) { 238 | play() 239 | } else { 240 | pause() 241 | }*/ 242 | } 243 | 244 | override fun onBufferingProgress(progress: Int) { 245 | } 246 | 247 | override fun onCurrentPlayTime(currentProgress: Int) { 248 | println("onCurrentPlayTime") 249 | if (currentTime.intValue != currentProgress) { 250 | currentTime.intValue = currentProgress 251 | 252 | if (currentProgress > 0) { 253 | 254 | val current = currentProgress.toFloat() 255 | val dur = duration.intValue.toFloat() 256 | 257 | println((current / dur) * 100) 258 | 259 | progress.floatValue = (current / dur) 260 | } 261 | } 262 | } 263 | 264 | override fun onStreamingStarted(dur: Int) { 265 | println("onStreamingStarted $dur") 266 | duration.intValue = dur 267 | playerState = PlayerState.READY 268 | } 269 | 270 | override fun onStreamCompleted() { 271 | println("onStreamingStarted") 272 | videoPlayer?.stop() 273 | } 274 | 275 | override fun onPlayerInitialized() { 276 | println("onPlayerInitialized") 277 | } 278 | 279 | override fun onPlayerChange(p0: String?) { 280 | println("onPlayerChange") 281 | } 282 | 283 | override fun onPlay() { 284 | println("onPlay") 285 | isPlaying = true 286 | } 287 | 288 | override fun onPause() { 289 | println("onPause") 290 | isPlaying = false 291 | } 292 | 293 | override fun onStop() { 294 | println("onStop") 295 | isPlaying = false 296 | } 297 | 298 | override fun onForward() { 299 | } 300 | 301 | override fun onRewind() { 302 | } 303 | 304 | override fun onMute() { 305 | } 306 | 307 | override fun onUnMute() { 308 | } 309 | 310 | override fun onNext() { 311 | } 312 | 313 | override fun onPrevious() { 314 | } 315 | 316 | override fun onControlStatus(p0: Int, p1: Boolean?, p2: Player.RepeatMode?) { 317 | } 318 | 319 | override fun onVolumeChange(p0: Int) { 320 | } 321 | 322 | override fun onAddToList(p0: JSONObject?) { 323 | } 324 | 325 | override fun onRemoveFromList(p0: JSONObject?) { 326 | } 327 | 328 | override fun onClearList() { 329 | } 330 | 331 | override fun onGetList(p0: JSONArray?) { 332 | } 333 | 334 | override fun onRepeat(p0: Player.RepeatMode?) { 335 | } 336 | 337 | override fun onCurrentPlaying(p0: JSONObject?, p1: String?) { 338 | } 339 | 340 | override fun onApplicationResume() { 341 | } 342 | 343 | override fun onApplicationSuspend() { 344 | } 345 | 346 | override fun onError(p0: Error?) { 347 | } 348 | 349 | } 350 | 351 | /** 352 | * Chromecast 353 | */ 354 | 355 | private fun castStateListener() = CastStateListener { 356 | when(it) { 357 | CastState.CONNECTED -> { 358 | deviceState = CastDeviceState.CONNECTED 359 | 360 | val castSession = castContext.sessionManager.currentCastSession 361 | 362 | remoteMediaClient = castSession?.remoteMediaClient 363 | remoteMediaClient?.stop() 364 | 365 | remoteMediaClient?.registerCallback(mediaClientCallback()) 366 | 367 | val videoMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE).apply { 368 | putString(MediaMetadata.KEY_TITLE, urlToPlay) 369 | } 370 | 371 | val mediaInfo = MediaInfo.Builder(urlToPlay) 372 | .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) 373 | .setMetadata(videoMetadata) 374 | 375 | 376 | val mediaLoadRequestData = MediaLoadRequestData.Builder() 377 | .setMediaInfo(mediaInfo.build()) 378 | .setAutoplay(true) 379 | 380 | remoteMediaClient?.load(mediaLoadRequestData.build()) 381 | 382 | stopSearch() 383 | } 384 | CastState.NOT_CONNECTED -> { 385 | remoteMediaClient?.unregisterCallback(mediaClientCallback()) 386 | remoteMediaClient = null 387 | startSearch() 388 | } 389 | } 390 | } 391 | private fun mediaRouterCallBack() = object: MediaRouter.Callback() { 392 | override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) { 393 | if (!deviceList.any { it.id == route.id } && route.isEnabled) 394 | deviceList.add(ChromeCastDevice(route = route)) 395 | } 396 | 397 | override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) { 398 | val deviceById = deviceList.find { it.id == route.id } 399 | deviceList.remove(deviceById) 400 | } 401 | 402 | /*override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) { 403 | val old = deviceList.find { it.id == route.id } 404 | deviceList[deviceList.indexOf(old)] = ChromeCastDevice(route = route) 405 | }*/ 406 | 407 | /*override fun onRouteSelected( 408 | router: MediaRouter, 409 | selectedRoute: MediaRouter.RouteInfo, 410 | reason: Int, 411 | requestedRoute: MediaRouter.RouteInfo 412 | ) { 413 | mediaRouter = router 414 | 415 | } 416 | 417 | override fun onRouteUnselected( 418 | router: MediaRouter, 419 | route: MediaRouter.RouteInfo, 420 | reason: Int 421 | ) { 422 | super.onRouteUnselected(router, route, reason) 423 | }*/ 424 | } 425 | 426 | private fun mediaClientCallback() = object: RemoteMediaClient.Callback() { 427 | override fun onStatusUpdated() { 428 | if (remoteMediaClient == null) return 429 | 430 | remoteMediaClient?.let { 431 | isPlaying = it.isPlaying 432 | 433 | playerState = if (it.isBuffering) PlayerState.BUFFERING else PlayerState.READY 434 | 435 | it.streamDuration 436 | it.addProgressListener({ position, duration -> 437 | 438 | if (duration <= 0) return@addProgressListener 439 | 440 | this@CastViewModel.duration.intValue = duration.toInt() 441 | this@CastViewModel.currentTime.intValue = position.toInt() 442 | 443 | if (position > 0) { 444 | val current = position.toFloat() 445 | val dur = duration.toFloat() 446 | progress.floatValue = (current / dur) 447 | } 448 | }, 500) 449 | 450 | Unit 451 | } 452 | } 453 | } 454 | } -------------------------------------------------------------------------------- /app/src/main/java/com/androidcast/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.androidcast 2 | 3 | import android.content.ClipboardManager 4 | import android.content.Context 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.compose.foundation.background 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.interaction.MutableInteractionSource 11 | import androidx.compose.foundation.layout.* 12 | import androidx.compose.foundation.rememberScrollState 13 | import androidx.compose.foundation.verticalScroll 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.filled.* 16 | import androidx.compose.material3.* 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.LaunchedEffect 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.draw.alpha 26 | import androidx.compose.ui.focus.FocusRequester 27 | import androidx.compose.ui.focus.focusRequester 28 | import androidx.compose.ui.graphics.Color 29 | import androidx.compose.ui.platform.LocalContext 30 | import androidx.compose.ui.semantics.semantics 31 | import androidx.compose.ui.tooling.preview.Preview 32 | import androidx.compose.ui.unit.DpSize 33 | import androidx.compose.ui.unit.dp 34 | import androidx.mediarouter.media.MediaRouter 35 | import com.samsung.multiscreen.Service 36 | import com.androidcast.ui.theme.AndroidCastTheme 37 | import com.google.android.gms.cast.framework.CastContext 38 | 39 | class MainActivity : ComponentActivity() { 40 | 41 | private lateinit var castViewModel: CastViewModel 42 | override fun onCreate(savedInstanceState: Bundle?) { 43 | super.onCreate(savedInstanceState) 44 | 45 | val service = Service.search(this) 46 | val castContext = CastContext.getSharedInstance(this) 47 | val mediaRouter = MediaRouter.getInstance(this) 48 | 49 | castViewModel = CastViewModel(service, castContext, mediaRouter) 50 | 51 | setContent { 52 | AndroidCastTheme { 53 | // A surface container using the 'background' color from the theme 54 | Surface( 55 | modifier = Modifier.fillMaxSize(), 56 | color = MaterialTheme.colorScheme.background 57 | ) { 58 | Main(castViewModel) 59 | } 60 | } 61 | } 62 | } 63 | 64 | override fun onResume() { 65 | super.onResume() 66 | 67 | if (castViewModel.deviceState !=CastDeviceState.CONNECTED) 68 | castViewModel.startSearch() 69 | } 70 | 71 | override fun onStart() { 72 | super.onStart() 73 | 74 | if (castViewModel.deviceState != CastDeviceState.CONNECTED) 75 | castViewModel.startSearch() 76 | } 77 | 78 | override fun onStop() { 79 | super.onStop() 80 | 81 | castViewModel.stopSearch() 82 | } 83 | } 84 | 85 | @OptIn(ExperimentalMaterial3Api::class) 86 | @Composable 87 | fun Main(castViewModel: CastViewModel) { 88 | val context = LocalContext.current 89 | 90 | val deviceList = castViewModel.deviceList 91 | val deviceState = castViewModel.deviceState 92 | val currentDevice = castViewModel.currentDevice 93 | 94 | val playerState = castViewModel.playerState 95 | 96 | Column(modifier = Modifier 97 | .fillMaxSize() 98 | .padding(16.dp)) { 99 | 100 | val focusRequester = remember { FocusRequester() } 101 | var textUrl by remember { mutableStateOf("") } 102 | 103 | OutlinedTextField(modifier = Modifier 104 | .fillMaxWidth() 105 | .focusRequester(focusRequester), 106 | enabled = deviceState == CastDeviceState.SEARCHING, 107 | value = textUrl, 108 | placeholder = { Text(text = "Url to play") }, 109 | onValueChange = { 110 | textUrl = it 111 | }, 112 | ) 113 | 114 | LaunchedEffect(Unit) { 115 | focusRequester.requestFocus() 116 | 117 | val cbString = stringFromClipBoard(context) 118 | if (cbString.startsWith("http")) { 119 | textUrl = cbString 120 | } 121 | } 122 | 123 | when (deviceState) { 124 | CastDeviceState.CONNECTED -> { 125 | 126 | Column(modifier = Modifier 127 | .weight(1f) 128 | .padding(vertical = 16.dp)) { 129 | 130 | if (currentDevice != null) { 131 | DeviceItem(device = currentDevice, icon = { 132 | when(currentDevice) { 133 | is ChromeCastDevice -> Icon(imageVector = Icons.Default.Cast, contentDescription = null) 134 | is SamsungDevice -> Icon(imageVector = Icons.Default.Tv, contentDescription = null) 135 | } 136 | }) 137 | } 138 | 139 | 140 | /*ListItem( 141 | headlineContent = { 142 | Text(text = service.name ?: "Unknown") 143 | }, 144 | overlineContent = { 145 | Text(text = service.type + " - " + service.version) 146 | }, 147 | leadingContent = { 148 | Icon(imageVector = Icons.Default.Tv, contentDescription = null) 149 | } 150 | )*/ 151 | } 152 | 153 | Column(modifier = Modifier 154 | .fillMaxWidth(), 155 | verticalArrangement = Arrangement.spacedBy(16.dp)) { 156 | 157 | val isPlaying = castViewModel.isPlaying 158 | 159 | var currentTime by castViewModel.currentTime 160 | val duration by castViewModel.duration 161 | 162 | val playerReady = playerState == PlayerState.READY 163 | 164 | val startInteractionSource = remember { MutableInteractionSource() } 165 | 166 | Row(modifier = Modifier.fillMaxWidth(), 167 | horizontalArrangement = Arrangement.spacedBy(4.dp), 168 | verticalAlignment = Alignment.CenterVertically) { 169 | 170 | Text(modifier = Modifier.then(if (!playerReady) Modifier.alpha(.5f) else Modifier), 171 | text = currentTime.toTimeString(), 172 | style = MaterialTheme.typography.bodySmall ) 173 | 174 | if (!playerReady) { 175 | LinearProgressIndicator( 176 | modifier = Modifier 177 | .weight(1f) 178 | .fillMaxWidth() 179 | .height(6.dp) 180 | .padding(6.dp) 181 | ) 182 | } else { 183 | Slider(modifier = Modifier 184 | .height(6.dp) 185 | .weight(1f) 186 | .padding(6.dp), 187 | value = currentTime.toFloat(), 188 | onValueChange = { currentTime = it.toInt() }, 189 | valueRange = 0f..duration.toFloat(), 190 | onValueChangeFinished = { 191 | castViewModel.seek(currentTime) 192 | }, 193 | thumb = { 194 | SliderDefaults.Thumb(interactionSource = startInteractionSource, 195 | thumbSize = DpSize(16.dp, 20.dp)) 196 | }, 197 | track = { 198 | SliderDefaults.Track(colors = SliderDefaults.colors(activeTrackColor = MaterialTheme.colorScheme.secondary), 199 | sliderPositions = it 200 | ) 201 | } 202 | ) 203 | } 204 | 205 | Text(modifier = Modifier.then(if (!playerReady) Modifier.alpha(.5f) else Modifier), 206 | text = duration.toTimeString(), 207 | style = MaterialTheme.typography.bodySmall) 208 | } 209 | 210 | Row(modifier = Modifier.fillMaxWidth(), 211 | horizontalArrangement = Arrangement.SpaceAround 212 | ) { 213 | IconButton(enabled = playerReady, onClick = { 214 | castViewModel.rewind() 215 | }) { 216 | Icon(Icons.Filled.FastRewind, contentDescription = "Localized description") 217 | } 218 | 219 | IconButton(enabled = playerReady, onClick = { 220 | if (isPlaying) { 221 | castViewModel.pause() 222 | } else { 223 | castViewModel.play() 224 | } 225 | }) { 226 | Icon(if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, contentDescription = "Localized description") 227 | } 228 | 229 | IconButton(enabled = playerReady, onClick = { 230 | castViewModel.forward() 231 | }) { 232 | Icon(Icons.Filled.FastForward, contentDescription = "Localized description") 233 | } 234 | 235 | IconButton(onClick = { 236 | castViewModel.stop() 237 | castViewModel.startSearch() 238 | }) { 239 | Icon(Icons.Filled.Close, contentDescription = "Localized description") 240 | } 241 | } 242 | } 243 | } 244 | CastDeviceState.CONNECTING -> { 245 | 246 | Column(modifier = Modifier 247 | .weight(1f) 248 | .padding(vertical = 16.dp) 249 | .verticalScroll(rememberScrollState())) { 250 | 251 | if (currentDevice != null) { 252 | DeviceItem(device = currentDevice, icon = { 253 | when(currentDevice) { 254 | is ChromeCastDevice -> Icon(imageVector = Icons.Default.Cast, contentDescription = null) 255 | is SamsungDevice -> Icon(imageVector = Icons.Default.Tv, contentDescription = null) 256 | } 257 | }, loading = true) 258 | } 259 | 260 | 261 | /*ListItem( 262 | headlineContent = { 263 | Text(text = service.name ?: "Unknown") 264 | }, 265 | overlineContent = { 266 | Text(text = service.type + " - " + service.version) 267 | }, 268 | leadingContent = { 269 | Icon(imageVector = Icons.Default.Tv, contentDescription = null) 270 | }, 271 | trailingContent = { 272 | CircularProgressIndicator(modifier = Modifier 273 | .align(Alignment.CenterHorizontally)) 274 | } 275 | )*/ 276 | } 277 | 278 | } 279 | CastDeviceState.SEARCHING -> { 280 | 281 | Column(modifier = Modifier 282 | .weight(1f) 283 | .padding(vertical = 16.dp) 284 | .verticalScroll(rememberScrollState())) { 285 | LinearProgressIndicator( 286 | modifier = Modifier 287 | .fillMaxWidth() 288 | .height(4.dp) 289 | .semantics(mergeDescendants = true) {} 290 | .padding(10.dp) 291 | ) 292 | 293 | deviceList.forEach { 294 | 295 | DeviceItem( 296 | onClick = { 297 | if (textUrl.isNotEmpty()) { 298 | castViewModel.connect(textUrl, it) 299 | } 300 | }, 301 | device = it, 302 | icon = { 303 | when(it) { 304 | is ChromeCastDevice -> Icon(imageVector = Icons.Default.Cast, contentDescription = null) 305 | is SamsungDevice -> Icon(imageVector = Icons.Default.Tv, contentDescription = null) 306 | } 307 | } 308 | ) 309 | /*ListItem(modifier = Modifier.clickable { 310 | 311 | if (textUrl.isNotEmpty()) { 312 | castViewModel.connect(textUrl, it) 313 | } 314 | 315 | }, 316 | headlineContent = { 317 | Text(text = it.name) 318 | }, 319 | overlineContent = { 320 | Text(text = it.type + " - " + it.version) 321 | }, 322 | leadingContent = { 323 | Icon(imageVector = Icons.Default.Tv, contentDescription = null) 324 | } 325 | )*/ 326 | } 327 | } 328 | } 329 | } 330 | 331 | } 332 | 333 | ////////////////////////////////////////////////////////////////////////////////////////// 334 | /*Box(modifier = Modifier.fillMaxSize()) { 335 | 336 | Column(modifier = Modifier.padding(16.dp)) { 337 | 338 | val focusRequester = remember { FocusRequester() } 339 | var textUrl by remember { mutableStateOf("") } 340 | 341 | OutlinedTextField(modifier = Modifier 342 | .fillMaxWidth() 343 | .focusRequester(focusRequester), 344 | enabled = serviceState is CastState.SEARCHING, 345 | value = textUrl, 346 | placeholder = { Text(text = "Url to play") }, 347 | onValueChange = { 348 | textUrl = it 349 | }, 350 | ) 351 | 352 | LaunchedEffect(Unit) { 353 | focusRequester.requestFocus() 354 | 355 | if (textUrl.startsWith("http")) { 356 | textUrl = stringFromClipBoard(context) 357 | } 358 | } 359 | 360 | when (serviceState) { 361 | is CastState.SEARCHING -> { 362 | 363 | LinearProgressIndicator( 364 | modifier = Modifier 365 | .fillMaxWidth() 366 | .height(4.dp) 367 | .semantics(mergeDescendants = true) {} 368 | .padding(10.dp) 369 | ) 370 | 371 | serviceList.forEach { 372 | ListItem(modifier = Modifier.clickable { 373 | 374 | if (textUrl.isNotEmpty()) { 375 | castViewModel.connect(textUrl, it) 376 | } 377 | 378 | }, 379 | headlineContent = { 380 | Text(text = it.name) 381 | }, 382 | overlineContent = { 383 | Text(text = it.type + " - " + it.version) 384 | }, 385 | leadingContent = { 386 | Icon(imageVector = Icons.Default.Tv, contentDescription = null) 387 | } 388 | ) 389 | } 390 | } 391 | is CastState.CONNECTING -> { 392 | 393 | val service = serviceState.service 394 | 395 | ListItem( 396 | headlineContent = { 397 | Text(text = service.name ?: "Unknown") 398 | }, 399 | overlineContent = { 400 | Text(text = service.type + " - " + service.version) 401 | }, 402 | leadingContent = { 403 | Icon(imageVector = Icons.Default.Tv, contentDescription = null) 404 | }, 405 | trailingContent = { 406 | CircularProgressIndicator(modifier = Modifier 407 | .align(Alignment.CenterHorizontally)) 408 | } 409 | ) 410 | } 411 | is CastState.CONNECTED -> { 412 | 413 | 414 | } 415 | }*/ 416 | 417 | /*if (serviceState is CastState.CONNECTING) { 418 | 419 | val service = serviceState.service 420 | val connected = serviceState.connected 421 | 422 | ListItem( 423 | headlineContent = { 424 | Text(text = service.name ?: "Unknown") 425 | }, 426 | overlineContent = { 427 | Text(text = service.type + " - " + service.version) 428 | }, 429 | leadingContent = { 430 | Icon(imageVector = Icons.Default.Tv, contentDescription = null) 431 | }, 432 | trailingContent = { 433 | if (!connected) { 434 | CircularProgressIndicator(modifier = Modifier 435 | .align(Alignment.CenterHorizontally)) 436 | } 437 | } 438 | ) 439 | } else { 440 | 441 | LinearProgressIndicator( 442 | modifier = Modifier 443 | .fillMaxWidth() 444 | .height(4.dp) 445 | .semantics(mergeDescendants = true) {} 446 | .padding(10.dp) 447 | ) 448 | 449 | serviceList.forEach { 450 | ListItem(modifier = Modifier.clickable { 451 | 452 | if (textUrl.isNotEmpty()) { 453 | castViewModel.connect(textUrl, it) 454 | } 455 | 456 | }, 457 | headlineContent = { 458 | Text(text = it.name) 459 | }, 460 | overlineContent = { 461 | Text(text = it.type + " - " + it.version) 462 | }, 463 | leadingContent = { 464 | Icon(imageVector = Icons.Default.Tv, contentDescription = null) 465 | } 466 | ) 467 | } 468 | }*/ 469 | 470 | //} 471 | 472 | /*if ((serviceState as? CastState.CONNECTING)?.connected == true) { 473 | //if (true) { 474 | Column(modifier = Modifier 475 | .fillMaxWidth() 476 | .align(Alignment.BottomCenter) 477 | .padding(16.dp), 478 | verticalArrangement = Arrangement.spacedBy(16.dp)) { 479 | 480 | val playWhenReady by castViewModel.playWhenReady 481 | 482 | var currentTime by castViewModel.currentTime 483 | val duration by castViewModel.duration 484 | 485 | val playerReady = playerState == PlayerState.READY 486 | 487 | val startInteractionSource = remember { MutableInteractionSource() } 488 | 489 | Row(modifier = Modifier.fillMaxWidth(), 490 | horizontalArrangement = Arrangement.spacedBy(4.dp), 491 | verticalAlignment = Alignment.CenterVertically) { 492 | 493 | Text(modifier = Modifier.then(if (!playerReady) Modifier.alpha(.5f) else Modifier), 494 | text = currentTime.toTimeString(), 495 | style = MaterialTheme.typography.bodySmall ) 496 | 497 | if (!playerReady) { 498 | LinearProgressIndicator( 499 | modifier = Modifier 500 | .weight(1f) 501 | .fillMaxWidth() 502 | .height(6.dp) 503 | .padding(6.dp) 504 | ) 505 | } else { 506 | Slider(modifier = Modifier 507 | .height(6.dp) 508 | .weight(1f) 509 | .padding(6.dp), 510 | value = currentTime.toFloat(), 511 | onValueChange = { currentTime = it.toInt() }, 512 | valueRange = 0f..duration.toFloat(), 513 | onValueChangeFinished = { 514 | castViewModel.seek(currentTime) 515 | }, 516 | thumb = { 517 | SliderDefaults.Thumb(interactionSource = startInteractionSource, 518 | thumbSize = DpSize(16.dp, 20.dp)) 519 | }, 520 | track = { 521 | SliderDefaults.Track(colors = SliderDefaults.colors(activeTrackColor = MaterialTheme.colorScheme.secondary), 522 | sliderPositions = it 523 | ) 524 | } 525 | ) 526 | } 527 | 528 | Text(modifier = Modifier.then(if (!playerReady) Modifier.alpha(.5f) else Modifier), 529 | text = duration.toTimeString(), 530 | style = MaterialTheme.typography.bodySmall) 531 | } 532 | 533 | Row(modifier = Modifier.fillMaxWidth(), 534 | horizontalArrangement = Arrangement.SpaceAround 535 | ) { 536 | IconButton(enabled = playerReady, onClick = { 537 | castViewModel.rewind() 538 | }) { 539 | Icon(Icons.Filled.FastRewind, contentDescription = "Localized description") 540 | } 541 | 542 | IconButton(enabled = playerReady, onClick = { 543 | if (playWhenReady) { 544 | castViewModel.pause() 545 | } else { 546 | castViewModel.play() 547 | } 548 | }) { 549 | Icon(if (playWhenReady) Icons.Filled.Pause else Icons.Filled.PlayArrow, contentDescription = "Localized description") 550 | } 551 | 552 | IconButton(enabled = playerReady, onClick = { 553 | castViewModel.forward() 554 | }) { 555 | Icon(Icons.Filled.FastForward, contentDescription = "Localized description") 556 | } 557 | 558 | IconButton(onClick = { 559 | castViewModel.stop() 560 | castViewModel.startSearch() 561 | }) { 562 | Icon(Icons.Filled.Close, contentDescription = "Localized description") 563 | } 564 | } 565 | } 566 | }*/ 567 | //} 568 | } 569 | 570 | @Composable 571 | fun DeviceItem(onClick: (() -> Unit)? = null, device: Device, icon: @Composable (()-> Unit), loading: Boolean = false) { 572 | ListItem(modifier = Modifier.clickable { onClick?.invoke() }, 573 | headlineContent = { 574 | Text(text = device.name ?: "Unknown") 575 | }, 576 | overlineContent = { 577 | Text(text = device.description ?: "Unknown") 578 | }, 579 | leadingContent = { 580 | icon() 581 | }, 582 | trailingContent = { 583 | if (loading) 584 | CircularProgressIndicator() 585 | } 586 | ) 587 | } 588 | 589 | fun Int.toTimeString(): String { 590 | val timeInSeconds = this / 1000 591 | 592 | val hour = timeInSeconds / (60 * 60) % 24 593 | val minutes = (timeInSeconds / 60) % 60 594 | val seconds = timeInSeconds % 60 595 | 596 | return "%02d:%02d:%02d".format(hour, minutes, seconds) 597 | } 598 | 599 | fun stringFromClipBoard(context: Context): String { 600 | val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 601 | val clipText = clipboardManager.primaryClip?.getItemAt(0)?.text 602 | 603 | return if (clipText.isNullOrEmpty()) "" else clipText.toString() 604 | } 605 | 606 | @Preview(showBackground = true) 607 | @Composable 608 | fun MainPreview() { 609 | AndroidCastTheme() { 610 | //Main(castViewModel = CastViewModel(Service.search(LocalContext.current))) 611 | } 612 | } -------------------------------------------------------------------------------- /app/src/main/java/com/androidcast/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.androidcast.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/androidcast/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.androidcast.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.SideEffect 13 | import androidx.compose.ui.graphics.toArgb 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalView 16 | import androidx.core.view.WindowCompat 17 | 18 | private val DarkColorScheme = darkColorScheme( 19 | primary = Purple80, 20 | secondary = PurpleGrey80, 21 | tertiary = Pink80 22 | ) 23 | 24 | private val LightColorScheme = lightColorScheme( 25 | primary = Purple40, 26 | secondary = PurpleGrey40, 27 | tertiary = Pink40 28 | 29 | /* Other default colors to override 30 | background = Color(0xFFFFFBFE), 31 | surface = Color(0xFFFFFBFE), 32 | onPrimary = Color.White, 33 | onSecondary = Color.White, 34 | onTertiary = Color.White, 35 | onBackground = Color(0xFF1C1B1F), 36 | onSurface = Color(0xFF1C1B1F), 37 | */ 38 | ) 39 | 40 | @Composable 41 | fun AndroidCastTheme( 42 | darkTheme: Boolean = isSystemInDarkTheme(), 43 | // Dynamic color is available on Android 12+ 44 | dynamicColor: Boolean = true, 45 | content: @Composable () -> Unit 46 | ) { 47 | val colorScheme = when { 48 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 49 | val context = LocalContext.current 50 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 51 | } 52 | darkTheme -> DarkColorScheme 53 | else -> LightColorScheme 54 | } 55 | val view = LocalView.current 56 | if (!view.isInEditMode) { 57 | SideEffect { 58 | val activity = view.context as Activity 59 | activity.window.statusBarColor = colorScheme.primary.toArgb() 60 | 61 | WindowCompat.getInsetsController(activity.window, view).isAppearanceLightStatusBars = darkTheme 62 | } 63 | } 64 | 65 | MaterialTheme( 66 | colorScheme = colorScheme, 67 | typography = Typography, 68 | content = content 69 | ) 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/androidcast/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.androidcast.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gidex/AndroidCast/394e6125455e920a22a3598b39a8ea0757c8c02e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | AndroidCast 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |