├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ │ ├── ids.xml │ │ │ │ ├── dimens.xml │ │ │ │ ├── attrs.xml │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── styles.xml │ │ │ ├── navigation │ │ │ │ └── mobile_navigation.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── drawable │ │ │ │ ├── ic_stop.xml │ │ │ │ ├── ic_pause.xml │ │ │ │ ├── ic_close.xml │ │ │ │ ├── ic_fullscreen.xml │ │ │ │ ├── ic_fullscreen_exit.xml │ │ │ │ ├── ic_pip.xml │ │ │ │ ├── ic_play_arrow.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── layout │ │ │ │ ├── fragment_list.xml │ │ │ │ ├── fragment_listitem.xml │ │ │ │ ├── activity_main.xml │ │ │ │ └── component_video.xml │ │ │ ├── menu │ │ │ │ └── options_menu.xml │ │ │ ├── drawable-v24 │ │ │ │ ├── video_overlay.xml │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── xml │ │ │ │ └── main_activity_motion_scene.xml │ │ ├── java │ │ │ └── net │ │ │ │ └── bradball │ │ │ │ └── motionvideo │ │ │ │ ├── ui │ │ │ │ ├── list │ │ │ │ │ ├── ListFragmentViewModel.kt │ │ │ │ │ ├── ListItemAdapter.kt │ │ │ │ │ └── ListFragment.kt │ │ │ │ ├── ViewPagerAdapter.kt │ │ │ │ ├── MainActivityViewModel.kt │ │ │ │ └── MainActivity.kt │ │ │ │ └── customViews │ │ │ │ └── ControlledVideoView.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── net │ │ │ └── bradball │ │ │ └── motionvideo │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── net │ │ └── bradball │ │ └── motionvideo │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── documentation └── assets │ ├── pip.png │ ├── stopped.png │ ├── embedded.png │ └── full-screen.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .idea ├── vcs.xml ├── misc.xml ├── runConfigurations.xml └── gradle.xml ├── .gitignore ├── gradle.properties ├── gradlew.bat ├── README.md └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | -------------------------------------------------------------------------------- /documentation/assets/pip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/documentation/assets/pip.png -------------------------------------------------------------------------------- /documentation/assets/stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/documentation/assets/stopped.png -------------------------------------------------------------------------------- /documentation/assets/embedded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/documentation/assets/embedded.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /documentation/assets/full-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/documentation/assets/full-screen.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/values/ids.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bradleycorn/MotionVideo/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/navigation/mobile_navigation.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Feb 03 10:46:32 EST 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip 7 | -------------------------------------------------------------------------------- /.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 | /.idea/encodings.xml 11 | .DS_Store 12 | /build 13 | /captures 14 | .externalNativeBuild 15 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16dp 4 | 5 | 6 | 32dp 7 | 8dp 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_stop.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pause.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/java/net/bradball/motionvideo/ui/list/ListFragmentViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo.ui.list 2 | 3 | import androidx.lifecycle.ViewModel 4 | 5 | class ListFragmentViewModel: ViewModel() { 6 | fun getItems(pageTitle: String): List { 7 | return List(100) { 8 | "$pageTitle - ${it +1}" 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_close.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_fullscreen.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_fullscreen_exit.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pip.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/test/java/net/bradball/motionvideo/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_list.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/menu/options_menu.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 10 | 11 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Motion Layout Demo 3 | Play Video 4 | Stop Video 5 | Play 6 | Pause 7 | List 1 8 | List 2 9 | List 3 10 | 11 | toggle picture-in-picture 12 | Close Video 13 | fullscreen 14 | Exit Fullscreen 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/androidTest/java/net/bradball/motionvideo/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo 2 | 3 | import androidx.test.InstrumentationRegistry 4 | import androidx.test.runner.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.getTargetContext() 22 | assertEquals("net.bradball.motionvideo", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/net/bradball/motionvideo/ui/ViewPagerAdapter.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo.ui 2 | 3 | import androidx.fragment.app.Fragment 4 | import androidx.fragment.app.FragmentManager 5 | import androidx.fragment.app.FragmentPagerAdapter 6 | 7 | class ViewPagerAdapter(fm: FragmentManager): FragmentPagerAdapter(fm) { 8 | private val fragments: MutableList = mutableListOf() 9 | private val titles: MutableList = mutableListOf() 10 | 11 | fun addFragment(fragment: Fragment, title: String) { 12 | fragments.add(fragment) 13 | titles.add(title) 14 | } 15 | 16 | override fun getItem(position: Int): Fragment = fragments[position] 17 | 18 | override fun getCount(): Int = fragments.size 19 | 20 | override fun getPageTitle(position: Int): CharSequence? = titles[position] 21 | } -------------------------------------------------------------------------------- /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 22 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_listitem.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/video_overlay.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play_arrow.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/java/net/bradball/motionvideo/ui/list/ListItemAdapter.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo.ui.list 2 | 3 | import androidx.recyclerview.widget.RecyclerView 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.TextView 8 | import kotlinx.android.synthetic.main.fragment_listitem.view.* 9 | import net.bradball.motionvideo.R 10 | 11 | 12 | class ListItemAdapter(private val items: List) 13 | : RecyclerView.Adapter() { 14 | 15 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 16 | val view = LayoutInflater.from(parent.context).inflate(R.layout.fragment_listitem, parent, false) 17 | return ViewHolder(view) 18 | } 19 | 20 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 21 | val item = items[position] 22 | holder.content.text = item 23 | } 24 | 25 | override fun getItemCount(): Int = items.size 26 | 27 | inner class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) { 28 | val content: TextView = view.vh_list_item_content 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | 21 | 22 | # Dependency Versions 23 | kotlin_version = 1.2.71 24 | dagger_version = 2.16 25 | navigation_version = 1.0.0-alpha09 26 | jetpack_lifecycle_version = 2.1.0-alpha01 27 | androidx_testing_version = 2.0.0-beta01 -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3f51b5 4 | #757de8 5 | #002984 6 | #ffffff 7 | 8 | 9 | #c62828 10 | #ff5f52 11 | #8e0000 12 | #ffffff 13 | 14 | #ff9800 15 | #000000 16 | 17 | 18 | 28 | #613f51b5 29 | #DE3f51b5 30 | #993f51b5 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 18 | 25 | 26 | 30 | 31 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 22 | 26 | 27 | 28 | 29 | 33 | 34 | 35 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/java/net/bradball/motionvideo/ui/list/ListFragment.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo.ui.list 2 | 3 | import android.os.Bundle 4 | import androidx.recyclerview.widget.LinearLayoutManager 5 | import androidx.recyclerview.widget.RecyclerView 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import androidx.fragment.app.Fragment 10 | import androidx.lifecycle.ViewModelProviders 11 | import net.bradball.motionvideo.R 12 | 13 | /** 14 | * A fragment representing a list of Items. 15 | */ 16 | class ListFragment: Fragment() { 17 | 18 | companion object { 19 | private const val ARG_TITLE = "arg_title" 20 | 21 | 22 | @JvmStatic 23 | fun newInstance(title: String) = ListFragment().apply { 24 | arguments = Bundle().apply { 25 | putString(ARG_TITLE, title) 26 | } 27 | } 28 | } 29 | 30 | private val listTitle: String by lazy { 31 | arguments?.getString(ARG_TITLE) ?: "" 32 | } 33 | 34 | private lateinit var viewModel: ListFragmentViewModel 35 | 36 | 37 | override fun onCreate(savedInstanceState: Bundle?) { 38 | super.onCreate(savedInstanceState) 39 | viewModel = ViewModelProviders.of(this).get(ListFragmentViewModel::class.java) 40 | } 41 | 42 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, 43 | savedInstanceState: Bundle?): View? { 44 | val view = inflater.inflate(R.layout.fragment_list, container, false) 45 | 46 | // Set the adapter 47 | if (view is RecyclerView) { 48 | with(view) { 49 | layoutManager = LinearLayoutManager(context) 50 | adapter = ListItemAdapter(viewModel.getItems(listTitle)) 51 | } 52 | } 53 | return view 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: 'kotlin-allopen' 6 | 7 | apply plugin: 'androidx.navigation.safeargs' 8 | 9 | 10 | android { 11 | compileSdkVersion 28 12 | defaultConfig { 13 | applicationId "net.bradball.motionvideo" 14 | minSdkVersion 21 15 | targetSdkVersion 28 16 | versionCode 1 17 | versionName "1.0" 18 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | } 27 | 28 | dependencies { 29 | implementation fileTree(dir: 'libs', include: ['*.jar']) 30 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 31 | implementation 'androidx.appcompat:appcompat:1.1.0-alpha01' 32 | implementation 'androidx.core:core-ktx:1.1.0-alpha03' 33 | implementation 'androidx.legacy:legacy-support-v4:1.0.0' 34 | 35 | implementation 'androidx.activity:activity-ktx:1.0.0-alpha03' 36 | implementation 'androidx.fragment:fragment-ktx:1.1.0-alpha03' 37 | 38 | // MATERIAL COMPONENTS 39 | implementation 'com.google.android.material:material:1.1.0-alpha02' 40 | 41 | // CONSTRAINT LAYOUT 42 | implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2' 43 | 44 | // ANDROIDX WIDGETS 45 | implementation "androidx.recyclerview:recyclerview:1.0.0" 46 | implementation 'androidx.viewpager:viewpager:1.0.0' 47 | 48 | // JETPACK LIFECYCLE 49 | implementation "androidx.lifecycle:lifecycle-extensions:$jetpack_lifecycle_version" 50 | implementation "androidx.lifecycle:lifecycle-viewmodel:$jetpack_lifecycle_version" 51 | implementation "androidx.lifecycle:lifecycle-extensions:$jetpack_lifecycle_version" 52 | 53 | // JETPACK NAVIGATION 54 | implementation "android.arch.navigation:navigation-fragment:$navigation_version" 55 | implementation "android.arch.navigation:navigation-ui:$navigation_version" 56 | implementation "android.arch.navigation:navigation-fragment-ktx:$navigation_version" 57 | implementation "android.arch.navigation:navigation-ui-ktx:$navigation_version" 58 | 59 | // DAGGER 60 | implementation "com.google.dagger:dagger:$dagger_version" 61 | implementation "com.google.dagger:dagger-android:$dagger_version" 62 | implementation "com.google.dagger:dagger-android-support:$dagger_version" 63 | kapt "com.google.dagger:dagger-compiler:$dagger_version" 64 | kapt "com.google.dagger:dagger-android-processor:$dagger_version" 65 | 66 | // TESTING 67 | testImplementation 'junit:junit:4.12' 68 | androidTestImplementation 'androidx.test:runner:1.1.1' 69 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' 70 | androidTestImplementation("androidx.arch.core:core-testing:$androidx_testing_version", { 71 | exclude group: 'com.android.support', module: 'support-compat' 72 | exclude group: 'com.android.support', module: 'support-annotations' 73 | exclude group: 'com.android.support', module: 'support-core-utils' 74 | }) 75 | testImplementation("androidx.arch.core:core-testing:$androidx_testing_version", { 76 | exclude group: 'com.android.support', module: 'support-compat' 77 | exclude group: 'com.android.support', module: 'support-annotations' 78 | exclude group: 'com.android.support', module: 'support-core-utils' 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/res/layout/component_video.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 22 | 23 | 27 | 33 | 34 | 35 | 44 | 45 | 56 | 57 | 69 | 70 | 83 | 84 | 97 | 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Motion Layout Video Demo 2 | This is a sample android application that demonstrates using the Android `MotionLayout` 3 | to control a video, and move it between 3 states: Embedded (or inline), Picture-in-Picture (PIP), 4 | and Full Screen. As an added bonus, there is an additional branch that adds dragging and scrolling 5 | to the PIP implementation. 6 | 7 | 8 | ### Requirements 9 | This demo is based off of a real life application I am working on at my job, and so there are 10 | some functional requirements it adheres to based on the requirements of the business. 11 | 12 | - For everything except full screen video, the app is "locked" to portrait mode. 13 | - It's optimized for phones, though it should work well on tablets and larger screens without 14 | much (if any) effort. 15 | - The video should NOT be displayed until the user starts playback. 16 | - When playback is started, the video should be displayed inline at the top of the 17 | screen, and the other content should shift down below it, unless... 18 | - If the user has scrolled down in the list, then the video should start in PIP mode. 19 | - The video should have dedicated controls for forcing PIP mode, inline mode, and full screen mode. 20 | - When full screen mode is enabled, the video should fill the screen, and rotate to landscape view. 21 | - The video should automatically move into and out of full screen mode when the device is rotated. 22 | - When exiting full screen mode the app should "remember" whether or not to return the video to 23 | PIP or inline mode. 24 | - When the video is stopped, it should disappear entirely. 25 | - If the video is stopped and restarted, it should appear in either inline mode or pip 26 | mode, whichever was the last mode used. 27 | - If the video is stopped in full screen mode, and then restarted, the app should "remember" 28 | the last mode used (inline or pip) and restart the video in that mode. 29 | 30 |

31 | 32 | 33 | 34 | 35 |

36 | 37 | ### Dragging and Scrolling 38 | As a bonus, you'll find a `drag-and-scroll` branch in the repo. This adds 2 additional features: 39 | 1. While in PIP mode, you can drag the video around on the screen as you please. 40 | 2. If you scroll down in the main content list(s) while the the video is playing, it will 41 | automatically switch to PIP mode. Likewise, if you scroll down and THEN start the video, 42 | it will start in PIP mode. 43 | 44 | 45 | #### That's not a standard VideoView, what gives? 46 | This demo uses a custom VideoView widget (`ControlledVideoView`). This was taken from the 47 | [Google Picure-In-Picture sample app](https://github.com/googlesamples/android-PictureInPicture), 48 | and modified slightly to fit our needs. It is nearly identical to the standard VideoView, except 49 | that it provides overlay controls for playback and toggling between different states (inline, pip, full screen). 50 | 51 | #### How does DragMotionLayout work? 52 | DragMotionLayout is just an extension of MotionLayout which adds support to make one child 53 | view (our video view) draggable by using the [Android ViewDragHelper](https://developer.android.com/reference/android/support/v4/widget/ViewDragHelper). 54 | The documentation for ViewDragHelper is, uhh ... sparse ... so I found 55 | [this blog post](http://fedepaol.github.io/blog/2014/09/01/dragging-with-viewdraghelper/) to be quite helpful to get it going. 56 | 57 | #### Why use a configuration change to do the rotation for full screen; MotionLayout can handle that, right? 58 | I could've just used `MotionLayout` to rotate the video and make it full screen. However, 59 | when the device is rotated, the user would expect gestures to be available, like swiping 60 | down from the top to reveal the status bar, etc. If we just rotated the video, this functionality 61 | would not be there, instead they would have to swipe from the side to reveal a (sideways) status bar. 62 | 63 | #### Why don't you use Android's built-in Picture-in-Picture support? 64 | For this demo, the requirements for "Picture in Picture" are a bit different 65 | than the functionality that Android provides for it's [Picture in Picture Support](https://developer.android.com/guide/topics/ui/picture-in-picture). 66 | Specifically, Android PIP support is intended for putting an activity in PIP mode when LEAVING, 67 | the task to do something else (either another task within the current application, or 68 | using another application all together). For this demo, the requirements are to stay on the current 69 | task, and put just the video into PIP mode, so that the other content can fill the screen. 70 | 71 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /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/xml/main_activity_motion_scene.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 14 | 15 | 16 | 26 | 32 | 39 | 40 | 41 | 42 | 50 | 56 | 63 | 64 | 65 | 66 | 67 | 68 | 80 | 86 | 93 | 94 | 95 | 96 | 97 | 98 | 103 | 109 | 116 | 117 | 118 | 119 | 130 | 135 | 136 | 143 | 144 | -------------------------------------------------------------------------------- /app/src/main/java/net/bradball/motionvideo/ui/MainActivityViewModel.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo.ui 2 | 3 | import android.content.res.Configuration 4 | import android.os.Handler 5 | import androidx.lifecycle.LiveData 6 | import androidx.lifecycle.MutableLiveData 7 | import androidx.lifecycle.Transformations 8 | import androidx.lifecycle.ViewModel 9 | 10 | class MainActivityViewModel: ViewModel() { 11 | 12 | /** 13 | * Setup some data for our ViewPager tabs 14 | */ 15 | val pages = listOf("First", "Middle", "Last") 16 | 17 | /** 18 | * The url that our video will use 19 | */ 20 | val videoUrl = "https://video-dev.github.io/streams/x36xhzz/x36xhzz.m3u8" 21 | 22 | /** 23 | * An enum to help us keep track of whether the video is playing or stopped. 24 | * 25 | * This could be a simple boolean, but we may want to know about additional 26 | * states as well, like paused. 27 | */ 28 | enum class VideoPlaybackState { 29 | STOPPED, 30 | PLAYING 31 | } 32 | 33 | /** 34 | * An enum to help us keep track of the current layout of the video player. 35 | */ 36 | enum class VideoLayoutState { 37 | EMBEDDED, 38 | FULLSCREEN, 39 | PIP 40 | } 41 | 42 | /** 43 | * When returning from FullScreen, we need to know if we should go to 44 | * Picture-in-Picture (PIP) mode, or embedded mode. This flag will 45 | * keep track of that. 46 | */ 47 | private var showInPipMode = false 48 | 49 | /** 50 | * A LiveData that emits to observers whenever the video playback state changes 51 | */ 52 | private val _playbackState = MutableLiveData().apply { 53 | value = VideoPlaybackState.STOPPED 54 | } 55 | val playbackState: LiveData = _playbackState 56 | 57 | 58 | /** 59 | * A LiveData that emits to observers whenever the video player's layout state changes 60 | */ 61 | private val _layoutState = MutableLiveData().apply { 62 | value = VideoLayoutState.EMBEDDED 63 | } 64 | val layoutState: LiveData = Transformations.map(_layoutState) { newState -> 65 | showInPipMode = when (newState) { 66 | VideoLayoutState.PIP -> true 67 | VideoLayoutState.EMBEDDED -> false 68 | else -> showInPipMode 69 | } 70 | 71 | return@map newState 72 | } 73 | 74 | /** 75 | * A handler that we'll use to effectively debounce orientation changes 76 | * so that we don't frantically try to rotate the screen back and forth 77 | * if the orientation changes really fast. When the orientation changes, 78 | * well post delayed runnables to this handler to change the video 79 | * orientation after a short delay. 80 | */ 81 | private val orientationHandler = Handler() 82 | 83 | /** 84 | * A holder for the device's current physical orientation. Set by the 85 | * orientation handler, and facilitates debouncing orientation changes. 86 | */ 87 | private var currentOrientation: Int = Configuration.ORIENTATION_UNDEFINED 88 | 89 | /** 90 | * A lock that is set when the "Full Screen" button is clicked, 91 | * since in that case, we need to "lock" the device to a particular 92 | * orientation, and (temporarily) ignore rotation. 93 | * When set, the lock will contain the configuration that the 94 | * device is currently locked to. 95 | * The lock will be set when the full screen button is pressed, and released 96 | * when the device is rotated to the lock's configuration value. 97 | */ 98 | private var orientationLock: Int = Configuration.ORIENTATION_UNDEFINED 99 | 100 | 101 | /** 102 | * Handle orientation changes. As the device is rotated, new orientaion 103 | * values wil be generated in the range from 0-360. We don't want to make the user 104 | * rotate to EXACTLY 90 or 180 degrees to trigger fullscreen mode toggles. Instead, 105 | * we want to provide a comfortable range. Also, we'll debounce orientation changes, 106 | * so that our views won't frantically try to toggle back and forth if the user decides 107 | * to swith the orientation really fast. 108 | */ 109 | fun onOrientationChange(deviceOrientation: Int, activityOrientation: Int?) { 110 | currentOrientation = when (deviceOrientation) { 111 | in 0..20, in 160..200, in 340..359 -> { // Portrait 112 | Configuration.ORIENTATION_PORTRAIT 113 | 114 | } 115 | 116 | in 70..110, in 250..290 -> { // Landscape 117 | Configuration.ORIENTATION_LANDSCAPE 118 | } 119 | 120 | else -> currentOrientation // In between, keep the current orientation 121 | } 122 | 123 | // we need to clear the orientation lock if the 124 | // device is rotated to the same orientation as the lock, 125 | // so that we'll properly allow orientation to change 126 | // later when the device is rotated back. 127 | if (currentOrientation == orientationLock) { 128 | orientationLock = Configuration.ORIENTATION_UNDEFINED 129 | } 130 | 131 | if (orientationLock == Configuration.ORIENTATION_UNDEFINED 132 | && activityOrientation != currentOrientation) { 133 | 134 | //Post a message to set the orientation 135 | orientationHandler.removeCallbacksAndMessages(null) 136 | orientationHandler.postDelayed({ setOrientation() }, 50) 137 | } 138 | } 139 | 140 | fun onOrientationForced(orientation: Int) { 141 | orientationLock = orientation 142 | setOrientation(orientation) 143 | } 144 | 145 | fun onVideoToggled() { 146 | val newState = when (_playbackState.value) { 147 | VideoPlaybackState.STOPPED -> VideoPlaybackState.PLAYING 148 | else -> VideoPlaybackState.STOPPED 149 | } 150 | 151 | _playbackState.value = newState 152 | } 153 | 154 | fun onVideoClosed() { 155 | if (_layoutState.value == VideoLayoutState.FULLSCREEN) { 156 | _layoutState.value = if (showInPipMode) VideoLayoutState.PIP else VideoLayoutState.EMBEDDED 157 | } 158 | 159 | _playbackState.value = VideoPlaybackState.STOPPED 160 | } 161 | 162 | fun onPipToggled() { 163 | _layoutState.value = when (_layoutState.value) { 164 | VideoLayoutState.PIP -> VideoLayoutState.EMBEDDED 165 | else -> VideoLayoutState.PIP 166 | } 167 | } 168 | 169 | private fun setOrientation(requestedOrientation: Int = -1) { 170 | 171 | val newOrientation = requestedOrientation.takeUnless { it == -1 } ?: currentOrientation 172 | 173 | _layoutState.value = when (newOrientation) { 174 | Configuration.ORIENTATION_LANDSCAPE -> VideoLayoutState.FULLSCREEN 175 | else -> { 176 | if (showInPipMode) { 177 | VideoLayoutState.PIP 178 | } else { 179 | VideoLayoutState.EMBEDDED 180 | } 181 | } 182 | } 183 | } 184 | 185 | } 186 | 187 | -------------------------------------------------------------------------------- /app/src/main/java/net/bradball/motionvideo/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo.ui 2 | 3 | import android.content.pm.ActivityInfo 4 | import android.content.res.Configuration 5 | import android.os.Bundle 6 | import android.view.Menu 7 | import android.view.MenuItem 8 | import android.view.OrientationEventListener 9 | import android.view.WindowManager 10 | import androidx.activity.viewModels 11 | import androidx.appcompat.app.AppCompatActivity 12 | import androidx.lifecycle.Observer 13 | import androidx.viewpager.widget.ViewPager 14 | import kotlinx.android.synthetic.main.activity_main.* 15 | import net.bradball.motionvideo.customViews.ControlledVideoView 16 | import net.bradball.motionvideo.R 17 | import net.bradball.motionvideo.ui.list.ListFragment 18 | 19 | class MainActivity : AppCompatActivity() { 20 | 21 | private val viewModel: MainActivityViewModel by viewModels() 22 | 23 | /** 24 | * Keep track of the current playback and layout states, so the view 25 | * can take action only when they change. 26 | */ 27 | private var currentPlaybackState: MainActivityViewModel.VideoPlaybackState? = null 28 | private var currentLayoutState: MainActivityViewModel.VideoLayoutState? = null 29 | 30 | /** 31 | * A listener to allow us to react to physical device orientation changes. 32 | */ 33 | lateinit var orientationListener: OrientationEventListener 34 | 35 | private val videoPlayerCallbacks = object: ControlledVideoView.IVideoListener { 36 | override fun onPipToggleClicked() { 37 | viewModel.onPipToggled() 38 | } 39 | 40 | override fun onVideoCloseClicked() { 41 | viewModel.onVideoClosed() 42 | } 43 | 44 | override fun onVideoFullScreenClicked(isFullScreen: Boolean) { 45 | //isFullScreen = true means we're already in fullscreen, 46 | // so we want to turn it off and lock to portrait. And Vice Versa 47 | val orientationLock = when (isFullScreen) { 48 | true -> Configuration.ORIENTATION_PORTRAIT 49 | false -> Configuration.ORIENTATION_LANDSCAPE 50 | } 51 | 52 | viewModel.onOrientationForced(orientationLock) 53 | } 54 | } 55 | 56 | 57 | 58 | override fun onCreate(savedInstanceState: Bundle?) { 59 | super.onCreate(savedInstanceState) 60 | setContentView(R.layout.activity_main) 61 | title = getString(R.string.app_name) 62 | 63 | setupViewPager(list_view_pager) 64 | list_tabs.setupWithViewPager(list_view_pager) 65 | 66 | setupVideo() 67 | } 68 | 69 | override fun onPause() { 70 | super.onPause() 71 | if (currentPlaybackState == MainActivityViewModel.VideoPlaybackState.PLAYING) { 72 | video_player.pause() 73 | } 74 | } 75 | 76 | override fun onStart() { 77 | super.onStart() 78 | if (currentPlaybackState == MainActivityViewModel.VideoPlaybackState.PLAYING) { 79 | video_player.ensureSurface() 80 | video_player.showControls() 81 | attachVideoListeners() 82 | } 83 | } 84 | 85 | 86 | override fun onCreateOptionsMenu(menu: Menu?): Boolean { 87 | super.onCreateOptionsMenu(menu) 88 | menuInflater.inflate(R.menu.options_menu, menu) 89 | return true 90 | } 91 | 92 | override fun onPrepareOptionsMenu(menu: Menu?): Boolean { 93 | super.onPrepareOptionsMenu(menu) 94 | val playVideoItem = menu?.findItem(R.id.option_play_video) 95 | val stopVideoItem = menu?.findItem(R.id.option_stop_video) 96 | 97 | playVideoItem?.isVisible = currentPlaybackState == MainActivityViewModel.VideoPlaybackState.STOPPED 98 | stopVideoItem?.isVisible = currentPlaybackState == MainActivityViewModel.VideoPlaybackState.PLAYING 99 | 100 | return true 101 | } 102 | 103 | override fun onOptionsItemSelected(item: MenuItem?): Boolean { 104 | return when (item?.itemId) { 105 | R.id.option_play_video -> { 106 | viewModel.onVideoToggled() 107 | true 108 | } 109 | R.id.option_stop_video -> { 110 | viewModel.onVideoToggled() 111 | true 112 | } 113 | else -> super.onOptionsItemSelected(item) 114 | } 115 | } 116 | 117 | 118 | /** 119 | * Called by the system when the device configuration changes. 120 | * Turn on or off all of the android window decoration STUFF: 121 | * Toggle the action bar, 122 | * (Un)Lock the nav drawer, 123 | * Toggle window full screen flags 124 | * 125 | * Note that this method DOES NOT DO ANYTHING with the video 126 | * or the video view itself. It's only responsible for toggling 127 | * on/off the decor. 128 | * 129 | */ 130 | override fun onConfigurationChanged(newConfig: Configuration) { 131 | super.onConfigurationChanged(newConfig) 132 | val isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE 133 | if (isLandscape) { 134 | supportActionBar?.hide() 135 | supportActionBar?.setDisplayShowTitleEnabled(false) 136 | window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) 137 | window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) 138 | } else { // turn OFF full screen 139 | supportActionBar?.show() 140 | supportActionBar?.setDisplayShowTitleEnabled(true) 141 | window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) 142 | window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN) 143 | } 144 | } 145 | 146 | 147 | private fun setupViewPager(viewPager: ViewPager) { 148 | val adapter = ViewPagerAdapter(supportFragmentManager) 149 | 150 | viewModel.pages.forEach { pageTitle -> 151 | adapter.addFragment(ListFragment.newInstance(pageTitle), pageTitle) 152 | } 153 | 154 | viewPager.adapter = adapter 155 | } 156 | 157 | 158 | /** 159 | * Setup the video player (and the whole system for toggling playback states, layout states, 160 | * etc). 161 | */ 162 | private fun setupVideo() { 163 | 164 | /* 165 | * Setup an Orientation listener so that this fragment can react 166 | * to changes in the physical orienation of the device 167 | */ 168 | orientationListener = object: OrientationEventListener(this) { 169 | override fun onOrientationChanged(orientation: Int) { 170 | viewModel.onOrientationChange(orientation, resources.configuration.orientation) 171 | } 172 | }.apply { disable() } 173 | 174 | // Set the video URL 175 | video_player.videoUrl = viewModel.videoUrl 176 | 177 | 178 | /* 179 | * The major worker here... 180 | * 181 | * Watch for changes in playbackstate and/or layoutstate and handle them appropriately. 182 | * Start/Or stop the player, Update the layout between embedded, pip, and fullscreen mode. 183 | */ 184 | viewModel.playbackState.observe(this, Observer { playbackState -> 185 | if (playbackState != currentPlaybackState) { 186 | invalidateOptionsMenu() // update the play/stop icon in the options menu. 187 | 188 | currentPlaybackState = playbackState 189 | 190 | when (currentPlaybackState) { 191 | MainActivityViewModel.VideoPlaybackState.PLAYING -> { 192 | if (orientationListener.canDetectOrientation()) { 193 | orientationListener.enable() 194 | } 195 | playVideo() 196 | } 197 | else -> { 198 | orientationListener.disable() 199 | stopVideo() 200 | } 201 | } 202 | } 203 | 204 | updateVideoLayout() 205 | }) 206 | 207 | 208 | viewModel.layoutState.observe(this, Observer { layoutState -> 209 | if (layoutState != currentLayoutState) { 210 | currentLayoutState = layoutState 211 | updateVideoLayout() 212 | } 213 | }) 214 | } 215 | 216 | 217 | 218 | /** 219 | * This method is responsible for switching the layout to the correct state, which is 220 | * one of: EMBEDDED, PIP, OR FULLSCREEN. 221 | * 222 | * There are several things accomplished: 223 | * 1. Toggle the constraints on the various views in this layout to put the 224 | * video into the correct layout position. 225 | * 2. Set the orientaiton on the activity appropriately for the current layout state, 226 | * which will trigger a configuration change (see onConfigurationChanged() above). 227 | * 3. Enable/Disable dragging of the video appropriately. 228 | * 229 | * NOTE: You probably don't want to call this method directly. Instead, call methods 230 | * on the viewModel that will trigger an update to the current layoutstate. 231 | */ 232 | private fun updateVideoLayout() { 233 | val isFullscreen = (currentLayoutState == MainActivityViewModel.VideoLayoutState.FULLSCREEN) 234 | 235 | // Set the activity's orientation if it needs to be changed. 236 | // Doing this will trigger a configuration change, and we'll 237 | // handle switching around window items (show/hide actionbar, etc) 238 | // in onConfigurationChange() (see below). 239 | if (isFullscreen && resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE) { 240 | requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE 241 | } else if (!isFullscreen && resources.configuration.orientation != Configuration.ORIENTATION_PORTRAIT) { 242 | requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT 243 | } 244 | 245 | // In addition to changing the window items, we also need to 246 | // change the video itself, so let's do that. 247 | video_player.setFullScreenMode(isFullscreen) 248 | video_player.setPreserveAspectRatio(isFullscreen) 249 | 250 | val videoState = Pair(currentPlaybackState, currentLayoutState) 251 | 252 | // Note: Stopped, Fullscreen is impossible, so we let the else handle it. 253 | val newState = when (videoState) { 254 | Pair(MainActivityViewModel.VideoPlaybackState.PLAYING, MainActivityViewModel.VideoLayoutState.FULLSCREEN) -> R.id.video_state_fullscreen 255 | Pair(MainActivityViewModel.VideoPlaybackState.PLAYING, MainActivityViewModel.VideoLayoutState.PIP) -> R.id.video_state_pip_playing 256 | Pair(MainActivityViewModel.VideoPlaybackState.STOPPED, MainActivityViewModel.VideoLayoutState.PIP) -> R.id.video_state_pip_stopped 257 | Pair(MainActivityViewModel.VideoPlaybackState.PLAYING, MainActivityViewModel.VideoLayoutState.EMBEDDED) -> R.id.video_state_embedded_playing 258 | else -> R.id.video_state_embedded_stopped 259 | } 260 | 261 | if (activity_container.currentState != newState) { 262 | activity_container.transitionToState(newState) 263 | } 264 | } 265 | 266 | 267 | private fun playVideo() { 268 | video_player.play() 269 | attachVideoListeners() 270 | } 271 | 272 | private fun stopVideo() { 273 | if (video_player.isPlaying) { 274 | video_player.stop() 275 | video_player.setVideoListener(null) 276 | } 277 | } 278 | 279 | private fun attachVideoListeners() { 280 | video_player.setVideoListener(videoPlayerCallbacks) 281 | } 282 | 283 | } 284 | -------------------------------------------------------------------------------- /app/src/main/java/net/bradball/motionvideo/customViews/ControlledVideoView.kt: -------------------------------------------------------------------------------- 1 | package net.bradball.motionvideo.customViews 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.IntentFilter 7 | import android.graphics.Color 8 | import android.media.AudioManager 9 | import android.media.MediaPlayer 10 | import android.net.Uri 11 | import android.os.Handler 12 | import android.os.Message 13 | import android.text.TextUtils 14 | import android.transition.TransitionManager 15 | import android.util.AttributeSet 16 | import android.util.Log 17 | import android.view.SurfaceHolder 18 | import android.view.SurfaceView 19 | import android.view.View 20 | import android.view.View.OnClickListener 21 | import android.widget.ImageButton 22 | import android.widget.ProgressBar 23 | import android.widget.RelativeLayout 24 | import kotlinx.android.synthetic.main.component_video.view.* 25 | import java.io.IOException 26 | import java.lang.ref.WeakReference 27 | import net.bradball.motionvideo.R 28 | 29 | 30 | 31 | /** 32 | * Provides video playback. There is nothing directly related to Picture-in-Picture here. 33 | * 34 | * Borrowed from the Google Picture-in-Picture mode sample: 35 | * https://github.com/googlesamples/android-PictureInPicture 36 | * 37 | * This is similar to [android.widget.VideoView], but it comes with 38 | * custom controls (play/pause, fast forward, and fast rewind) that 39 | * overlay the video. 40 | */ 41 | class ControlledVideoView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : RelativeLayout(context, attrs, defStyleAttr) { 42 | 43 | /** Shows the video playback. */ 44 | private var mSurfaceView: SurfaceView? = null 45 | 46 | private val container: RelativeLayout 47 | 48 | // Controls 49 | private val mToggle: ImageButton 50 | private val mPipToggle: ImageButton 51 | private val mCloseButton: ImageButton 52 | private val mFullScreenButton: ImageButton 53 | private val mShade: View 54 | private val mProgressBar: ProgressBar 55 | private var isFullScreen: Boolean = false 56 | 57 | 58 | val becomingNoisyReceiverIntentFiler = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) 59 | var becomingNoisyReceiver: BecomingNoisyReceiver? = null 60 | 61 | /** This plays the video. This will be null when no video is set. */ 62 | private var mMediaPlayer: MediaPlayer? = null 63 | private var mMediaPlayerErrorListener: MediaPlayer.OnErrorListener? = null 64 | 65 | private var mVideoWidth = 0 66 | private var mVideoHeight = 0 67 | 68 | private var mCurrentState: Int = 0 69 | 70 | private val videoControlsListener: OnClickListener 71 | 72 | 73 | /** 74 | * The url of the video to play. 75 | * 76 | * @return Url of the video. 77 | */ 78 | var videoUrl: String? = null 79 | set(url) { 80 | if (url.equals(videoUrl)) { 81 | return 82 | } 83 | field = url 84 | } 85 | 86 | /** The title of the video */ 87 | /** 88 | * The title of the video to play. 89 | * 90 | * @return title of the video. 91 | */ 92 | var title: String? = null 93 | 94 | /** Should a loaded video start playing automatically or not */ 95 | var isAutoPlay: Boolean = false 96 | 97 | /** Whether we adjust our view bounds or we fill the remaining area with black bars */ 98 | private var mAdjustViewBounds: Boolean = false 99 | /** Whether we account for aspect ratio when AdjustViewBounds is true */ 100 | private var mPreserveAspectRatio: Boolean = false 101 | 102 | /** Handles timeout for media controls. */ 103 | private var mTimeoutHandler: TimeoutHandler? = null 104 | 105 | /** The listener for all the events we publish. */ 106 | private var mVideoListener: IVideoListener? = null 107 | 108 | private var mSavedCurrentPosition: Int = 0 109 | 110 | 111 | /** 112 | * Get notified when the video dimensions change, and update the View size accordingly. 113 | * The video size changes when going from no video, to a video playing, or vice versa. 114 | */ 115 | private val mSizeChangedListener = MediaPlayer.OnVideoSizeChangedListener { mp, _, _ -> 116 | mVideoWidth = mp.videoWidth 117 | mVideoHeight = mp.videoHeight 118 | requestLayout() 119 | } 120 | 121 | /** 122 | * Returns the current position of the video. If the the player has not been created, then 123 | * assumes the beginning of the video. 124 | * 125 | * @return The current position of the video. 126 | */ 127 | val currentPosition: Int 128 | get() { 129 | return if (mMediaPlayer == null) { 130 | 0 131 | } else mMediaPlayer!!.currentPosition 132 | } 133 | 134 | val isPlaying: Boolean 135 | get() = mMediaPlayer != null && (mCurrentState == STATE_PLAYING || mCurrentState == STATE_PAUSED || mCurrentState == STATE_BUFFERING) 136 | 137 | /** Monitors all events related to [ControlledVideoView]. */ 138 | interface IVideoListener { 139 | 140 | /** Called when the video is started or resumed. */ 141 | fun onVideoStarted() {} 142 | 143 | /** Called when the video is paused or finished. */ 144 | fun onVideoPaused() {} 145 | 146 | /** Called when the video is paused or finished. */ 147 | fun onVideoStopped() {} 148 | 149 | /** Called when this view should be minimized. */ 150 | fun onPipToggleClicked() {} 151 | 152 | /** Called when the close button is clicked. */ 153 | fun onVideoCloseClicked() {} 154 | 155 | /** Called when the fullscreen button is clicked. */ 156 | fun onVideoFullScreenClicked(isFullScreen: Boolean) {} 157 | } 158 | 159 | fun ensureSurface() { 160 | if (mSurfaceView == null) { 161 | if (getChildAt(0) is SurfaceView) { 162 | container.removeViewAt(0) 163 | } 164 | createSurface() 165 | } else if (mMediaPlayer != null) { 166 | mMediaPlayer!!.setDisplay(mSurfaceView!!.holder) 167 | } 168 | } 169 | 170 | private fun createSurface() { 171 | 172 | mSurfaceView = SurfaceView(context) 173 | mSurfaceView!!.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) 174 | mSurfaceView!!.id = R.id.video_surface 175 | 176 | mSurfaceView!!.holder.apply { 177 | addCallback( 178 | object : SurfaceHolder.Callback { 179 | override fun surfaceCreated(holder: SurfaceHolder) { 180 | mSurfaceView!!.setOnClickListener(videoControlsListener) 181 | if (mMediaPlayer != null) { 182 | mMediaPlayer?.setDisplay(holder) 183 | } else { 184 | adjustToggleState() 185 | showControls() 186 | } 187 | } 188 | 189 | override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {/* noop */} 190 | 191 | override fun surfaceDestroyed(holder: SurfaceHolder) { 192 | if (mMediaPlayer != null) { 193 | if (mCurrentState == STATE_PLAYING) { 194 | pause() 195 | } 196 | //mSavedCurrentPosition = mMediaPlayer!!.currentPosition 197 | } 198 | mSurfaceView = null 199 | } 200 | }) 201 | } 202 | 203 | container.addView(mSurfaceView, 0) 204 | } 205 | 206 | init { 207 | setBackgroundColor(Color.BLACK) 208 | 209 | container = View.inflate(context, R.layout.component_video, this) as RelativeLayout 210 | 211 | mShade = container.shade 212 | mToggle = container.toggle 213 | mPipToggle = container.video_toggle_pip 214 | mCloseButton = container.video_close 215 | mFullScreenButton = container.video_fullscreen 216 | 217 | 218 | // May be Used for Replays. Views will need to be 219 | // added in layout. 220 | // -- 221 | //mFastForward = view.fast_forward 222 | //mFastRewind = view.fast_rewind 223 | mProgressBar = container.buffering_icon 224 | 225 | val attributes = context.obtainStyledAttributes( 226 | attrs, 227 | R.styleable.ControlledVideoView, 228 | defStyleAttr, 229 | R.style.Component_ControlledVideoView) 230 | videoUrl = attributes.getString(R.styleable.ControlledVideoView_android_src) 231 | setAdjustViewBounds(attributes.getBoolean(R.styleable.ControlledVideoView_android_adjustViewBounds, false)) 232 | title = attributes.getString(R.styleable.ControlledVideoView_android_title) 233 | isAutoPlay = attributes.getBoolean(R.styleable.ControlledVideoView_autoPlay, true) 234 | isFullScreen = attributes.getBoolean(R.styleable.ControlledVideoView_isFullScreen, false) 235 | attributes.recycle() 236 | 237 | mCurrentState = STATE_IDLE 238 | 239 | // Bind view events 240 | videoControlsListener = OnClickListener { clickedView -> 241 | when (clickedView.id) { 242 | R.id.video_surface -> toggleControls() 243 | R.id.toggle -> toggle() 244 | R.id.video_toggle_pip -> handlePipToggle() 245 | R.id.video_close -> handleCloseButton() 246 | R.id.video_fullscreen -> handleFullScreenButton() 247 | } 248 | // Start or reset the timeout to hide controls 249 | if (mTimeoutHandler == null) { 250 | mTimeoutHandler = TimeoutHandler(this@ControlledVideoView) 251 | } 252 | mTimeoutHandler!!.removeMessages(TimeoutHandler.MESSAGE_HIDE_CONTROLS) 253 | if (mMediaPlayer != null && mCurrentState == STATE_PLAYING || mCurrentState == STATE_PAUSED) { 254 | mTimeoutHandler!!.sendEmptyMessageDelayed( 255 | TimeoutHandler.MESSAGE_HIDE_CONTROLS, TIMEOUT_CONTROLS.toLong()) 256 | } 257 | } 258 | mToggle.setOnClickListener(videoControlsListener) 259 | mPipToggle.setOnClickListener(videoControlsListener) 260 | mCloseButton.setOnClickListener(videoControlsListener) 261 | mFullScreenButton.setOnClickListener(videoControlsListener) 262 | } 263 | 264 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 265 | var widthMeasurement = widthMeasureSpec 266 | var heightMeasurement = heightMeasureSpec 267 | val width = View.MeasureSpec.getSize(widthMeasurement) 268 | val widthMode = View.MeasureSpec.getMode(widthMeasurement) 269 | val height = View.MeasureSpec.getSize(heightMeasurement) 270 | val heightMode = View.MeasureSpec.getMode(heightMeasurement) 271 | val aspectRatio: Float 272 | val videoWidth: Int 273 | val videoHeight: Int 274 | 275 | if (mVideoWidth > 0 && mVideoHeight > 0) { 276 | videoWidth = mVideoWidth 277 | videoHeight = mVideoHeight 278 | aspectRatio = videoHeight.toFloat() / videoWidth.toFloat() 279 | } else { 280 | aspectRatio = 9F / 16F 281 | videoWidth = width 282 | videoHeight = (width * aspectRatio).toInt() 283 | } 284 | 285 | val viewRatio = height.toFloat() / width 286 | 287 | if (videoWidth != 0 && videoHeight != 0) { 288 | var needsPadding = false 289 | 290 | if (mAdjustViewBounds) { 291 | 292 | var targetHeight: Float? = null 293 | var targetWidth: Float? = null 294 | 295 | if (widthMode == View.MeasureSpec.EXACTLY && heightMode != View.MeasureSpec.EXACTLY) { 296 | targetHeight = (width * aspectRatio) 297 | } else if ((widthMode != View.MeasureSpec.EXACTLY && heightMode == View.MeasureSpec.EXACTLY)) { 298 | targetWidth = (height / aspectRatio) 299 | } else { 300 | targetHeight = (width * aspectRatio) 301 | } 302 | 303 | if (targetHeight != null) { 304 | // When we are looking to preserve the aspect ratio and our 305 | // target height spills over available height, we just 306 | // want the width/height to be the full container and let 307 | // calculated padding handle retaining aspect ratio 308 | if (mPreserveAspectRatio && targetHeight > height) { 309 | needsPadding = true 310 | } else { 311 | heightMeasurement = View.MeasureSpec.makeMeasureSpec( 312 | targetHeight.toInt(), View.MeasureSpec.EXACTLY) 313 | } 314 | } 315 | 316 | if (targetWidth != null) { 317 | // When we are looking to preserve the aspect ratio and our 318 | // target height spills over available height, we just 319 | // want the width/height to be the full container and let 320 | // calculated padding handle retaining aspect ratio 321 | if (mPreserveAspectRatio && targetWidth > width) { 322 | needsPadding = true 323 | } else { 324 | widthMeasurement = View.MeasureSpec.makeMeasureSpec( 325 | targetWidth.toInt(), View.MeasureSpec.EXACTLY) 326 | } 327 | } 328 | } else { 329 | needsPadding = true 330 | } 331 | 332 | if (needsPadding) { 333 | // Make sure our Measurmements are Set to the Height/Width 334 | // of the current Screen Resolution EXACTLY. 335 | widthMeasurement = View.MeasureSpec.makeMeasureSpec( 336 | width, View.MeasureSpec.EXACTLY) 337 | heightMeasurement = View.MeasureSpec.makeMeasureSpec( 338 | height, View.MeasureSpec.EXACTLY) 339 | 340 | if (aspectRatio > viewRatio) { 341 | val padding = ((width - height / aspectRatio) / 2).toInt() 342 | setPadding(padding, 0, padding, 0) 343 | } else { 344 | val padding = ((height - width * aspectRatio) / 2).toInt() 345 | setPadding(0, padding, 0, padding) 346 | } 347 | } else if (paddingTop > 0 || paddingRight > 0 || paddingBottom > 0 || paddingLeft > 0) { 348 | setPadding(0, 0, 0, 0) 349 | } 350 | } 351 | 352 | super.onMeasure(widthMeasurement, heightMeasurement) 353 | } 354 | 355 | override fun onDetachedFromWindow() { 356 | if (mTimeoutHandler != null) { 357 | mTimeoutHandler!!.removeMessages(TimeoutHandler.MESSAGE_HIDE_CONTROLS) 358 | mTimeoutHandler = null 359 | } 360 | super.onDetachedFromWindow() 361 | } 362 | 363 | 364 | fun setFullScreenMode(enabled: Boolean) { 365 | isFullScreen = enabled 366 | updateFullScreenToggleButton() 367 | 368 | // If we're in fullscreen, hide the pip toggle. 369 | // If we're not, set it's visible to the same as the other controls. 370 | mPipToggle.visibility = when (enabled) { 371 | true -> View.INVISIBLE 372 | false -> mToggle.visibility 373 | } 374 | } 375 | 376 | /** 377 | * Sets the listener to monitor movie events. 378 | * 379 | * @param videoListener The listener to be set. 380 | */ 381 | fun setVideoListener(videoListener: IVideoListener?) { 382 | mVideoListener = videoListener 383 | } 384 | 385 | fun setPreserveAspectRatio(preserveAspectRatio: Boolean) { 386 | if (mPreserveAspectRatio == preserveAspectRatio) { 387 | return 388 | } 389 | 390 | mPreserveAspectRatio = preserveAspectRatio 391 | if (preserveAspectRatio) { 392 | setBackgroundColor(Color.BLACK) 393 | } else { 394 | background = null 395 | } 396 | } 397 | 398 | fun setAdjustViewBounds(adjustViewBounds: Boolean) { 399 | if (mAdjustViewBounds == adjustViewBounds) { 400 | return 401 | } 402 | mAdjustViewBounds = adjustViewBounds 403 | if (adjustViewBounds) { 404 | background = null 405 | } else { 406 | setBackgroundColor(Color.BLACK) 407 | } 408 | requestLayout() 409 | } 410 | 411 | private fun updateFullScreenToggleButton() { 412 | val iconId = when (isFullScreen) { 413 | true -> R.drawable.ic_fullscreen_exit 414 | false -> R.drawable.ic_fullscreen 415 | } 416 | mFullScreenButton.setImageDrawable(context.getDrawable(iconId)) 417 | mFullScreenButton.visibility = mToggle.visibility 418 | } 419 | 420 | /** Shows all the controls. */ 421 | fun showControls() { 422 | TransitionManager.beginDelayedTransition(this) 423 | mShade.visibility = View.VISIBLE 424 | mToggle.visibility = View.VISIBLE 425 | if (!isFullScreen) { 426 | mPipToggle.visibility = View.VISIBLE 427 | } 428 | mCloseButton.visibility = View.VISIBLE 429 | 430 | mFullScreenButton.visibility = View.VISIBLE 431 | } 432 | 433 | /** Hides all the controls. */ 434 | fun hideControls() { 435 | TransitionManager.beginDelayedTransition(this.video_controls_container) 436 | mShade.visibility = View.INVISIBLE 437 | mToggle.visibility = View.INVISIBLE 438 | mPipToggle.visibility = View.INVISIBLE 439 | mCloseButton.visibility = View.INVISIBLE 440 | mFullScreenButton.visibility = View.INVISIBLE 441 | } 442 | 443 | /** Fast-forward the video. */ 444 | fun fastForward() { 445 | if (mMediaPlayer == null) { 446 | return 447 | } 448 | showBufferingIcon() 449 | mMediaPlayer!!.seekTo(mMediaPlayer!!.currentPosition + FAST_FORWARD_REWIND_INTERVAL) 450 | } 451 | 452 | /** Fast-rewind the video. */ 453 | fun fastRewind() { 454 | if (mMediaPlayer == null) { 455 | return 456 | } 457 | showBufferingIcon() 458 | mMediaPlayer!!.seekTo(mMediaPlayer!!.currentPosition - FAST_FORWARD_REWIND_INTERVAL) 459 | } 460 | 461 | fun play() { 462 | if (mMediaPlayer == null) { 463 | initializeMediaPlayer() 464 | } else if (mCurrentState != STATE_BUFFERING) { 465 | mMediaPlayer!!.start() 466 | mCurrentState = STATE_PLAYING 467 | adjustToggleState() 468 | keepScreenOn = true 469 | if (mVideoListener != null) { 470 | mVideoListener!!.onVideoStarted() 471 | } 472 | } 473 | registerBecomingNoisyReceiver() 474 | } 475 | 476 | fun pause() { 477 | if (mMediaPlayer == null) { 478 | adjustToggleState() 479 | return 480 | } 481 | 482 | // If we Are not still preparing, pause the media Player 483 | // if we are preparing, the onPreparedListener will pause 484 | // the player for us. 485 | if (mCurrentState != STATE_BUFFERING) { 486 | mMediaPlayer!!.pause() 487 | } 488 | 489 | mCurrentState = STATE_PAUSED 490 | adjustToggleState() 491 | keepScreenOn = false 492 | if (mVideoListener != null) { 493 | mVideoListener!!.onVideoPaused() 494 | } 495 | unregisterBecomingNoisyReceiver() 496 | } 497 | 498 | fun stop() { 499 | if (mMediaPlayer != null) { 500 | if (mMediaPlayer!!.isPlaying && mCurrentState != STATE_BUFFERING) { 501 | mMediaPlayer!!.stop() 502 | } 503 | mSurfaceView = null 504 | destroyMediaPlayer() 505 | adjustToggleState() 506 | } 507 | } 508 | 509 | private fun initializeMediaPlayer() { 510 | if (TextUtils.isEmpty(videoUrl)) { 511 | return 512 | } 513 | 514 | mMediaPlayer = MediaPlayer() 515 | ensureSurface() 516 | mMediaPlayer!!.setOnVideoSizeChangedListener(mSizeChangedListener) 517 | mMediaPlayer!!.setOnSeekCompleteListener { hideBufferingIcon() } 518 | if (mMediaPlayerErrorListener != null) { 519 | mMediaPlayer!!.setOnErrorListener(mMediaPlayerErrorListener) 520 | } 521 | startVideo() 522 | } 523 | 524 | fun setOnVideoErrorListener(listener: MediaPlayer.OnErrorListener) { 525 | mMediaPlayerErrorListener = listener 526 | if (mMediaPlayer != null) { 527 | mMediaPlayer?.setOnErrorListener(listener) 528 | } 529 | } 530 | 531 | 532 | /** Restarts playback of the video. */ 533 | private fun startVideo() { 534 | mMediaPlayer!!.reset() 535 | try { 536 | mMediaPlayer!!.setDataSource(context, Uri.parse(videoUrl)) 537 | mMediaPlayer!!.setOnPreparedListener { mediaPlayer -> 538 | 539 | val latestState = mCurrentState 540 | mCurrentState = STATE_BUFFERED 541 | 542 | when (latestState) { 543 | // We were started, and now we are ready to play media. 544 | STATE_BUFFERING -> { 545 | // Adjust the aspect ratio of this view 546 | mVideoWidth = mediaPlayer.videoWidth 547 | mVideoHeight = mediaPlayer.videoHeight 548 | 549 | // The video may or may not have a size yet. 550 | // If it does, go ahead and do a layout. 551 | // If it doesn't, the SizeChanged listener will handle it. 552 | if (mVideoWidth > 0 && mVideoHeight > 0) { 553 | requestLayout() 554 | } 555 | 556 | hideBufferingIcon() 557 | if (mSavedCurrentPosition > 0) { 558 | mediaPlayer.seekTo(mSavedCurrentPosition) 559 | mSavedCurrentPosition = 0 560 | } else { 561 | // Start automatically 562 | play() 563 | } 564 | } 565 | // We have been paused while it was buffering. 566 | STATE_PAUSED -> { 567 | mediaPlayer.pause() 568 | mCurrentState = STATE_PAUSED 569 | } 570 | // We have been stopped or encountered an error. Shut it down! 571 | else -> stop() 572 | } 573 | } 574 | mMediaPlayer!!.setOnCompletionListener { 575 | adjustToggleState() 576 | keepScreenOn = false 577 | if (mVideoListener != null) { 578 | mVideoListener!!.onVideoStopped() 579 | } 580 | } 581 | mCurrentState = STATE_BUFFERING 582 | showBufferingIcon() 583 | mMediaPlayer!!.prepareAsync() 584 | } catch (e: IOException) { 585 | Log.e(TAG, "Failed to open video", e) 586 | } 587 | 588 | } 589 | 590 | 591 | private fun destroyMediaPlayer() { 592 | if (mMediaPlayer != null) { 593 | mMediaPlayer!!.reset() 594 | mMediaPlayer!!.release() 595 | mMediaPlayer = null 596 | mCurrentState = STATE_IDLE 597 | mVideoListener?.onVideoStopped() 598 | } 599 | } 600 | 601 | internal fun toggle() { 602 | if (mMediaPlayer == null || !mMediaPlayer!!.isPlaying) { 603 | play() 604 | } else { 605 | pause() 606 | } 607 | } 608 | 609 | private fun toggleControls() { 610 | if (mShade.visibility == View.VISIBLE) { 611 | hideControls() 612 | } else { 613 | showControls() 614 | } 615 | } 616 | 617 | private fun handlePipToggle() { 618 | hideControls() 619 | mVideoListener?.onPipToggleClicked() 620 | } 621 | 622 | private fun handleCloseButton() { 623 | hideControls() 624 | mVideoListener?.onVideoCloseClicked() 625 | } 626 | 627 | private fun handleFullScreenButton() { 628 | hideControls() 629 | mVideoListener?.onVideoFullScreenClicked(isFullScreen) 630 | } 631 | 632 | private fun showBufferingIcon() { 633 | hideControls() 634 | mProgressBar.visibility = View.VISIBLE 635 | } 636 | 637 | private fun hideBufferingIcon() { 638 | mProgressBar.visibility = View.GONE 639 | } 640 | 641 | internal fun adjustToggleState() { 642 | if (mCurrentState == STATE_PLAYING) { 643 | mToggle.contentDescription = resources.getString(R.string.pause) 644 | mToggle.setImageResource(R.drawable.ic_pause) 645 | } else { 646 | mToggle.contentDescription = resources.getString(R.string.play) 647 | mToggle.setImageResource(R.drawable.ic_play_arrow) 648 | } 649 | } 650 | 651 | private class TimeoutHandler internal constructor(view: ControlledVideoView) : Handler() { 652 | 653 | private val mMovieViewRef: WeakReference = WeakReference(view) 654 | 655 | override fun handleMessage(msg: Message) { 656 | when (msg.what) { 657 | MESSAGE_HIDE_CONTROLS -> { 658 | val movieView = mMovieViewRef.get() 659 | movieView?.hideControls() 660 | } 661 | else -> super.handleMessage(msg) 662 | } 663 | } 664 | 665 | companion object { 666 | 667 | internal const val MESSAGE_HIDE_CONTROLS = 1 668 | } 669 | } 670 | 671 | private fun registerBecomingNoisyReceiver() { 672 | if (becomingNoisyReceiver == null) { 673 | becomingNoisyReceiver = BecomingNoisyReceiver { pause() } 674 | context?.registerReceiver(becomingNoisyReceiver, becomingNoisyReceiverIntentFiler) 675 | } 676 | } 677 | 678 | 679 | private fun unregisterBecomingNoisyReceiver() { 680 | if (becomingNoisyReceiver != null) { 681 | context?.unregisterReceiver(becomingNoisyReceiver) 682 | becomingNoisyReceiver = null 683 | } 684 | } 685 | 686 | class BecomingNoisyReceiver(private val onNoisyCallback: () -> Unit) : BroadcastReceiver() { 687 | 688 | override fun onReceive(context: Context, intent: Intent) { 689 | if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) { 690 | onNoisyCallback.invoke() 691 | } 692 | } 693 | } 694 | 695 | companion object { 696 | 697 | private const val TAG = "ControlledVideoView" 698 | 699 | // all possible internal states 700 | private const val STATE_ERROR = -1 701 | private const val STATE_IDLE = 0 702 | private const val STATE_BUFFERING = 1 703 | private const val STATE_BUFFERED = 2 704 | private const val STATE_PLAYING = 3 705 | private const val STATE_PAUSED = 4 706 | private const val STATE_PLAYBACK_COMPLETED = 5 707 | 708 | /** The amount of time we are stepping forward or backward for fast-forward and fast-rewind. */ 709 | private const val FAST_FORWARD_REWIND_INTERVAL = 5000 // ms 710 | 711 | /** The amount of time until we fade out the controls. */ 712 | private const val TIMEOUT_CONTROLS = 3000 // ms 713 | 714 | /** 715 | * Utility to return a default size. Uses the supplied size if the 716 | * MeasureSpec imposed no constraints. Will get larger if allowed 717 | * by the MeasureSpec. 718 | * 719 | * @param size Default size for this view 720 | * @param measureSpec Constraints imposed by the parent 721 | * @return The size this view should be. 722 | */ 723 | fun getDefaultSize(size: Int, measureSpec: Int): Int { 724 | var result = size 725 | val specMode = View.MeasureSpec.getMode(measureSpec) 726 | val specSize = View.MeasureSpec.getSize(measureSpec) 727 | 728 | when (specMode) { 729 | View.MeasureSpec.UNSPECIFIED -> result = size 730 | View.MeasureSpec.AT_MOST, View.MeasureSpec.EXACTLY -> result = specSize 731 | } 732 | return result 733 | } 734 | } 735 | 736 | } 737 | --------------------------------------------------------------------------------