├── 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 |
13 |
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 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
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 |
--------------------------------------------------------------------------------