From be16d57958f94b72f43f6b7abf262baec4ec515c Mon Sep 17 00:00:00 2001 From: renhaoting <370797079@qq.com> Date: Tue, 25 Nov 2025 17:08:29 +0800 Subject: [PATCH] youtube module --- app/build.gradle | 6 +- core.build.gradle | 2 +- settings.gradle | 2 + youtube/core/.gitignore | 1 + youtube/core/build.gradle | 44 +++ .../core/ExampleInstrumentedTest.kt | 17 ++ youtube/core/src/main/AndroidManifest.xml | 7 + .../core/player/PlayerConstants.kt | 39 +++ .../core/player/Providers.kt | 10 + .../core/player/YouTubePlayer.kt | 60 ++++ .../core/player/YouTubePlayerBridge.kt | 210 +++++++++++++ .../core/player/YouTubePlayerCallbacks.kt | 37 +++ .../core/player/YouTubePlayerExt.kt | 9 + .../AbstractYouTubePlayerListener.kt | 20 ++ .../player/listeners/FullscreenListener.kt | 31 ++ .../player/listeners/YouTubePlayerCallback.kt | 7 + .../player/listeners/YouTubePlayerListener.kt | 66 ++++ .../player/options/IFramePlayerOptions.kt | 222 ++++++++++++++ .../core/player/utils/NetworkObserver.kt | 121 ++++++++ .../core/player/utils/PlaybackResumer.kt | 61 ++++ .../core/player/utils/YouTubePlayerTracker.kt | 39 +++ .../core/player/utils/YouTubePlayerUtils.kt | 29 ++ .../player/views/LegacyYouTubePlayerView.kt | 210 +++++++++++++ .../player/views/SixteenByNineFrameLayout.kt | 29 ++ .../core/player/views/WebViewYouTubePlayer.kt | 185 +++++++++++ .../core/player/views/YouTubePlayerView.kt | 250 +++++++++++++++ .../src/main/res/raw/ayp_youtube_player.html | 194 ++++++++++++ youtube/core/src/main/res/values/attrs.xml | 9 + .../core/verification.properties | 3 + .../androidyoutubeplayer/core/UtilsTest.kt | 18 ++ youtube/custom-ui/.gitignore | 1 + youtube/custom-ui/build.gradle | 44 +++ youtube/custom-ui/consumer-rules.pro | 0 youtube/custom-ui/proguard-rules.pro | 21 ++ .../custom-ui/src/main/AndroidManifest.xml | 2 + .../customui/DefaultPlayerUiController.kt | 289 ++++++++++++++++++ .../core/customui/PlayerUiController.kt | 57 ++++ .../core/customui/menu/MenuItem.kt | 10 + .../core/customui/menu/YouTubePlayerMenu.kt | 13 + .../defaultMenu/DefaultYouTubePlayerMenu.kt | 73 +++++ .../customui/menu/defaultMenu/MenuAdapter.kt | 39 +++ .../core/customui/utils/FadeViewHelper.kt | 107 +++++++ .../core/customui/utils/TimeUtilities.kt | 17 ++ .../customui/views/YouTubePlayerSeekBar.kt | 191 ++++++++++++ .../src/main/res-public/values/public.xml | 13 + .../ayp_background_item_selected.xml | 4 + .../drawable/ayp_background_item_selected.xml | 4 + .../res/drawable/ayp_drop_shadow_bottom.xml | 9 + .../main/res/drawable/ayp_drop_shadow_top.xml | 9 + .../res/drawable/ayp_ic_fullscreen_24dp.xml | 7 + .../drawable/ayp_ic_fullscreen_exit_24dp.xml | 7 + .../main/res/drawable/ayp_ic_menu_24dp.xml | 9 + .../main/res/drawable/ayp_ic_pause_36dp.xml | 4 + .../main/res/drawable/ayp_ic_play_36dp.xml | 9 + .../main/res/drawable/ayp_ic_youtube_24dp.xml | 7 + .../drawable/ayp_shape_rounded_corners.xml | 4 + .../main/res/layout/ayp_default_player_ui.xml | 201 ++++++++++++ .../src/main/res/layout/ayp_menu_item.xml | 22 ++ .../src/main/res/layout/ayp_player_menu.xml | 16 + .../custom-ui/src/main/res/values/attrs.xml | 7 + .../custom-ui/src/main/res/values/colors.xml | 9 + .../custom-ui/src/main/res/values/dimens.xml | 8 + .../custom-ui/src/main/res/values/strings.xml | 9 + youtube/libVersions.gradle | 47 +++ youtube/youtube.includes.gradle | 3 + 65 files changed, 3207 insertions(+), 3 deletions(-) create mode 100644 youtube/core/.gitignore create mode 100644 youtube/core/build.gradle create mode 100644 youtube/core/src/androidTest/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/ExampleInstrumentedTest.kt create mode 100644 youtube/core/src/main/AndroidManifest.xml create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/PlayerConstants.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/Providers.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayer.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerBridge.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerCallbacks.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerExt.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/AbstractYouTubePlayerListener.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/FullscreenListener.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/YouTubePlayerCallback.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/YouTubePlayerListener.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/options/IFramePlayerOptions.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/NetworkObserver.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/PlaybackResumer.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/YouTubePlayerTracker.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/YouTubePlayerUtils.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/LegacyYouTubePlayerView.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/SixteenByNineFrameLayout.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/WebViewYouTubePlayer.kt create mode 100644 youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/YouTubePlayerView.kt create mode 100644 youtube/core/src/main/res/raw/ayp_youtube_player.html create mode 100644 youtube/core/src/main/res/values/attrs.xml create mode 100644 youtube/core/src/main/resources/META-INF/com/pierfrancescosoffritti/androidyoutubeplayer/core/verification.properties create mode 100644 youtube/core/src/test/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/UtilsTest.kt create mode 100644 youtube/custom-ui/.gitignore create mode 100644 youtube/custom-ui/build.gradle create mode 100644 youtube/custom-ui/consumer-rules.pro create mode 100644 youtube/custom-ui/proguard-rules.pro create mode 100644 youtube/custom-ui/src/main/AndroidManifest.xml create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/DefaultPlayerUiController.kt create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/PlayerUiController.kt create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/MenuItem.kt create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/YouTubePlayerMenu.kt create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/defaultMenu/DefaultYouTubePlayerMenu.kt create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/defaultMenu/MenuAdapter.kt create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/utils/FadeViewHelper.kt create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/utils/TimeUtilities.kt create mode 100644 youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/views/YouTubePlayerSeekBar.kt create mode 100644 youtube/custom-ui/src/main/res-public/values/public.xml create mode 100644 youtube/custom-ui/src/main/res/drawable-v21/ayp_background_item_selected.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_background_item_selected.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_drop_shadow_bottom.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_drop_shadow_top.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_ic_fullscreen_24dp.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_ic_fullscreen_exit_24dp.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_ic_menu_24dp.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_ic_pause_36dp.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_ic_play_36dp.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_ic_youtube_24dp.xml create mode 100644 youtube/custom-ui/src/main/res/drawable/ayp_shape_rounded_corners.xml create mode 100644 youtube/custom-ui/src/main/res/layout/ayp_default_player_ui.xml create mode 100644 youtube/custom-ui/src/main/res/layout/ayp_menu_item.xml create mode 100644 youtube/custom-ui/src/main/res/layout/ayp_player_menu.xml create mode 100644 youtube/custom-ui/src/main/res/values/attrs.xml create mode 100644 youtube/custom-ui/src/main/res/values/colors.xml create mode 100644 youtube/custom-ui/src/main/res/values/dimens.xml create mode 100644 youtube/custom-ui/src/main/res/values/strings.xml create mode 100644 youtube/libVersions.gradle create mode 100644 youtube/youtube.includes.gradle diff --git a/app/build.gradle b/app/build.gradle index 15acb8e..562f539 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -70,6 +70,10 @@ dependencies { implementation(project(":core:architecture")) //api(project(":core:architecture-reflect")) implementation(project(":core:network")) + + implementation(project(":youtube:core")) + implementation(project(":youtube:custom-ui")) + implementation libs.androidx.navigation.fragment.ktx implementation(libs.startup) implementation(libs.hilt.android) @@ -80,8 +84,6 @@ dependencies { implementation(libs.protobuf.kotlin.lite) implementation(libs.kotlinx.serialization.json) implementation 'com.squareup.retrofit2:converter-gson:2.9.0' - implementation 'com.pierfrancescosoffritti.androidyoutubeplayer:core:13.0.0' - implementation 'com.pierfrancescosoffritti.androidyoutubeplayer:custom-ui:13.0.0' implementation(libs.glide) // ImageLoader在用 implementation(libs.okhttp.logging) implementation(libs.retrofit) diff --git a/core.build.gradle b/core.build.gradle index d54893e..dc903df 100644 --- a/core.build.gradle +++ b/core.build.gradle @@ -14,7 +14,7 @@ android { buildTypes { release { - minifyEnabled false + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } diff --git a/settings.gradle b/settings.gradle index 460f2c2..d6734c5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,5 +19,7 @@ dependencyResolutionManagement { rootProject.name = "VidiDin-android" apply from: "./core/core.includes.gradle" +apply from: "./youtube/youtube.includes.gradle" include ':app' + diff --git a/youtube/core/.gitignore b/youtube/core/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/youtube/core/.gitignore @@ -0,0 +1 @@ +/build diff --git a/youtube/core/build.gradle b/youtube/core/build.gradle new file mode 100644 index 0000000..f666244 --- /dev/null +++ b/youtube/core/build.gradle @@ -0,0 +1,44 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply from: '../libVersions.gradle' + +android { + compileSdkVersion versions.compileSdk + + defaultConfig { + minSdkVersion versions.minSdk + targetSdkVersion versions.compileSdk + } + + sourceSets { + main.res.srcDirs = [ + 'src/main/res', + ] + } + namespace 'com.pierfrancescosoffritti.androidyoutubeplayer' + + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + testImplementation "junit:junit:$versions.junit" + androidTestImplementation "androidx.test:runner:$versions.runner" + androidTestImplementation "androidx.test.espresso:espresso-core:$versions.espressoCore" + + api "androidx.lifecycle:lifecycle-runtime-ktx:$versions.androidxLifecycleRuntime" +} + diff --git a/youtube/core/src/androidTest/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/ExampleInstrumentedTest.kt b/youtube/core/src/androidTest/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..4a79864 --- /dev/null +++ b/youtube/core/src/androidTest/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/ExampleInstrumentedTest.kt @@ -0,0 +1,17 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core + +import androidx.test.runner.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + } +} diff --git a/youtube/core/src/main/AndroidManifest.xml b/youtube/core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc25894 --- /dev/null +++ b/youtube/core/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/PlayerConstants.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/PlayerConstants.kt new file mode 100644 index 0000000..5004297 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/PlayerConstants.kt @@ -0,0 +1,39 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player + +class PlayerConstants { + + enum class PlayerState { + UNKNOWN, UNSTARTED, ENDED, PLAYING, PAUSED, BUFFERING, VIDEO_CUED + } + + enum class PlaybackQuality { + UNKNOWN, SMALL, MEDIUM, LARGE, HD720, HD1080, HIGH_RES, DEFAULT + } + + enum class PlayerError { + UNKNOWN, + INVALID_PARAMETER_IN_REQUEST, + HTML_5_PLAYER, + VIDEO_NOT_FOUND, + VIDEO_NOT_PLAYABLE_IN_EMBEDDED_PLAYER, + REQUEST_MISSING_HTTP_REFERER + } + + enum class PlaybackRate { + UNKNOWN, RATE_0_25, RATE_0_5, RATE_0_75, RATE_1, RATE_1_25, RATE_1_5, RATE_1_75, RATE_2 + } +} + +fun PlayerConstants.PlaybackRate.toFloat(): Float { + return when (this) { + PlayerConstants.PlaybackRate.UNKNOWN -> 1f + PlayerConstants.PlaybackRate.RATE_0_25 -> 0.25f + PlayerConstants.PlaybackRate.RATE_0_5 -> 0.5f + PlayerConstants.PlaybackRate.RATE_0_75 -> 0.75f + PlayerConstants.PlaybackRate.RATE_1 -> 1f + PlayerConstants.PlaybackRate.RATE_1_25 -> 1.25f + PlayerConstants.PlaybackRate.RATE_1_5 -> 1.5f + PlayerConstants.PlaybackRate.RATE_1_75 -> 1.75f + PlayerConstants.PlaybackRate.RATE_2 -> 2f + } +} \ No newline at end of file diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/Providers.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/Providers.kt new file mode 100644 index 0000000..1d6dccf --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/Providers.kt @@ -0,0 +1,10 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player + +/** + * A callback that accepts a Boolean value. + * + * This interface is only required to support Java 7 and below. + */ +fun interface BooleanProvider { + fun accept(value: Boolean) +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayer.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayer.kt new file mode 100644 index 0000000..76137ee --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayer.kt @@ -0,0 +1,60 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player + +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerListener + +/** + * Use this interface to control the playback of YouTube videos and to listen to their events. + */ +interface YouTubePlayer { + /** + * Loads and automatically plays the video. + * @param videoId id of the video + * @param startSeconds the time from which the video should start playing + */ + fun loadVideo(videoId: String, startSeconds: Float) + + /** + * Loads the video's thumbnail and prepares the player to play the video. Does not automatically play the video. + * @param videoId id of the video + * @param startSeconds the time from which the video should start playing + */ + fun cueVideo(videoId: String, startSeconds: Float) + + fun play() + fun pause() + + /** If the player is playing a playlist, play the next video. */ + fun nextVideo() + /** If the player is playing a playlist, play the previous video. */ + fun previousVideo() + /** If the player is playing a playlist, play the video at position [index]. */ + fun playVideoAt(index: Int) + + /** If the player is playing a playlist, enable or disable looping of the playlist. */ + fun setLoop(loop: Boolean) + + /** If the player is playing a playlist, enable or disable shuffling of the playlist. */ + fun setShuffle(shuffle: Boolean) + + fun mute() + fun unMute() + + /** Returns true if the player is muted, false otherwise. */ + fun isMutedAsync(callback: BooleanProvider) + + /** + * @param volumePercent Integer between 0 and 100 + */ + fun setVolume(volumePercent: Int) + + /** + * + * @param time The absolute time in seconds to seek to + */ + fun seekTo(time: Float) + + fun setPlaybackRate(playbackRate: PlayerConstants.PlaybackRate) + + fun addListener(listener: YouTubePlayerListener): Boolean + fun removeListener(listener: YouTubePlayerListener): Boolean +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerBridge.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerBridge.kt new file mode 100644 index 0000000..5920a04 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerBridge.kt @@ -0,0 +1,210 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player + +import android.os.Handler +import android.os.Looper +import android.text.TextUtils +import android.webkit.JavascriptInterface +import androidx.annotation.RestrictTo +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerListener + + +/** + * Bridge used for Javascript-Java communication. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class YouTubePlayerBridge(private val youTubePlayerOwner: YouTubePlayerBridgeCallbacks) { + + companion object { + // these constants correspond to the values in the Javascript player + private const val STATE_UNSTARTED = "UNSTARTED" + private const val STATE_ENDED = "ENDED" + private const val STATE_PLAYING = "PLAYING" + private const val STATE_PAUSED = "PAUSED" + private const val STATE_BUFFERING = "BUFFERING" + private const val STATE_CUED = "CUED" + + private const val QUALITY_SMALL = "small" + private const val QUALITY_MEDIUM = "medium" + private const val QUALITY_LARGE = "large" + private const val QUALITY_HD720 = "hd720" + private const val QUALITY_HD1080 = "hd1080" + private const val QUALITY_HIGH_RES = "highres" + private const val QUALITY_DEFAULT = "default" + + private const val RATE_0_25 = "0.25" + private const val RATE_0_5 = "0.5" + private const val RATE_0_75 = "0.75" + private const val RATE_1 = "1" + private const val RATE_1_25 = "1.25" + private const val RATE_1_5 = "1.5" + private const val RATE_1_75 = "1.75" + private const val RATE_2 = "2" + + private const val ERROR_INVALID_PARAMETER_IN_REQUEST = "2" + private const val ERROR_HTML_5_PLAYER = "5" + private const val ERROR_VIDEO_NOT_FOUND = "100" + private const val ERROR_VIDEO_NOT_PLAYABLE_IN_EMBEDDED_PLAYER1 = "101" + private const val ERROR_VIDEO_NOT_PLAYABLE_IN_EMBEDDED_PLAYER2 = "150" + private const val ERROR_REQUEST_MISSING_HTTP_REFERER = "153" + } + + private val mainThreadHandler: Handler = Handler(Looper.getMainLooper()) + + interface YouTubePlayerBridgeCallbacks { + val listeners: Collection + fun getInstance(): YouTubePlayer + fun onYouTubeIFrameAPIReady() + } + + @JavascriptInterface + fun sendYouTubeIFrameAPIReady() = mainThreadHandler.post { youTubePlayerOwner.onYouTubeIFrameAPIReady() } + + @JavascriptInterface + fun sendReady() = mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onReady(youTubePlayerOwner.getInstance()) } + } + + @JavascriptInterface + fun sendStateChange(state: String) { + val playerState = parsePlayerState(state) + + mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onStateChange(youTubePlayerOwner.getInstance(), playerState) } + } + } + + @JavascriptInterface + fun sendPlaybackQualityChange(quality: String) { + val playbackQuality = parsePlaybackQuality(quality) + + mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onPlaybackQualityChange(youTubePlayerOwner.getInstance(), playbackQuality) } + } + } + + @JavascriptInterface + fun sendPlaybackRateChange(rate: String) { + val playbackRate = parsePlaybackRate(rate) + + mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onPlaybackRateChange(youTubePlayerOwner.getInstance(), playbackRate) } + } + } + + @JavascriptInterface + fun sendError(error: String) { + val playerError = parsePlayerError(error) + + mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onError(youTubePlayerOwner.getInstance(), playerError) } + } + } + + @JavascriptInterface + fun sendApiChange() = mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onApiChange(youTubePlayerOwner.getInstance()) } + } + + @JavascriptInterface + fun sendVideoCurrentTime(seconds: String) { + val currentTimeSeconds = try { + seconds.toFloat() + } catch (e: NumberFormatException) { + e.printStackTrace() + return + } + + mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onCurrentSecond(youTubePlayerOwner.getInstance(), currentTimeSeconds) } + } + } + + @JavascriptInterface + fun sendVideoDuration(seconds: String) { + val videoDuration = try { + val finalSeconds = if (TextUtils.isEmpty(seconds)) "0" else seconds + finalSeconds.toFloat() + } catch (e: NumberFormatException) { + e.printStackTrace() + return + } + + mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onVideoDuration(youTubePlayerOwner.getInstance(), videoDuration) } + } + } + + @JavascriptInterface + fun sendVideoLoadedFraction(fraction: String) { + val loadedFraction = try { + fraction.toFloat() + } catch (e: NumberFormatException) { + e.printStackTrace() + return + } + + mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onVideoLoadedFraction(youTubePlayerOwner.getInstance(), loadedFraction) } + } + } + + @JavascriptInterface + fun sendVideoId(videoId: String) = mainThreadHandler.post { + youTubePlayerOwner.listeners.forEach { it.onVideoId(youTubePlayerOwner.getInstance(), videoId) } + } + + private fun parsePlayerState(state: String): PlayerConstants.PlayerState { + return when { + state.equals(STATE_UNSTARTED, ignoreCase = true) -> PlayerConstants.PlayerState.UNSTARTED + state.equals(STATE_ENDED, ignoreCase = true) -> PlayerConstants.PlayerState.ENDED + state.equals(STATE_PLAYING, ignoreCase = true) -> PlayerConstants.PlayerState.PLAYING + state.equals(STATE_PAUSED, ignoreCase = true) -> PlayerConstants.PlayerState.PAUSED + state.equals(STATE_BUFFERING, ignoreCase = true) -> PlayerConstants.PlayerState.BUFFERING + state.equals(STATE_CUED, ignoreCase = true) -> PlayerConstants.PlayerState.VIDEO_CUED + else -> PlayerConstants.PlayerState.UNKNOWN + } + } + + + private fun parsePlaybackQuality(quality: String): PlayerConstants.PlaybackQuality { + return when { + quality.equals(QUALITY_SMALL, ignoreCase = true) -> PlayerConstants.PlaybackQuality.SMALL + quality.equals(QUALITY_MEDIUM, ignoreCase = true) -> PlayerConstants.PlaybackQuality.MEDIUM + quality.equals(QUALITY_LARGE, ignoreCase = true) -> PlayerConstants.PlaybackQuality.LARGE + quality.equals(QUALITY_HD720, ignoreCase = true) -> PlayerConstants.PlaybackQuality.HD720 + quality.equals(QUALITY_HD1080, ignoreCase = true) -> PlayerConstants.PlaybackQuality.HD1080 + quality.equals( + QUALITY_HIGH_RES, + ignoreCase = true + ) -> PlayerConstants.PlaybackQuality.HIGH_RES + quality.equals(QUALITY_DEFAULT, ignoreCase = true) -> PlayerConstants.PlaybackQuality.DEFAULT + else -> PlayerConstants.PlaybackQuality.UNKNOWN + } + } + + private fun parsePlaybackRate(rate: String): PlayerConstants.PlaybackRate { + return when { + rate.equals(RATE_0_25, ignoreCase = true) -> PlayerConstants.PlaybackRate.RATE_0_25 + rate.equals(RATE_0_5, ignoreCase = true) -> PlayerConstants.PlaybackRate.RATE_0_5 + rate.equals(RATE_0_75, ignoreCase = true) -> PlayerConstants.PlaybackRate.RATE_0_75 + rate.equals(RATE_1, ignoreCase = true) -> PlayerConstants.PlaybackRate.RATE_1 + rate.equals(RATE_1_25, ignoreCase = true) -> PlayerConstants.PlaybackRate.RATE_1_25 + rate.equals(RATE_1_5, ignoreCase = true) -> PlayerConstants.PlaybackRate.RATE_1_5 + rate.equals(RATE_1_75, ignoreCase = true) -> PlayerConstants.PlaybackRate.RATE_1_75 + rate.equals(RATE_2, ignoreCase = true) -> PlayerConstants.PlaybackRate.RATE_2 + else -> PlayerConstants.PlaybackRate.UNKNOWN + } + } + + private fun parsePlayerError(error: String): PlayerConstants.PlayerError { + return when { + error.equals(ERROR_INVALID_PARAMETER_IN_REQUEST, ignoreCase = true) -> PlayerConstants.PlayerError.INVALID_PARAMETER_IN_REQUEST + error.equals(ERROR_HTML_5_PLAYER, ignoreCase = true) -> PlayerConstants.PlayerError.HTML_5_PLAYER + error.equals(ERROR_VIDEO_NOT_FOUND, ignoreCase = true) -> PlayerConstants.PlayerError.VIDEO_NOT_FOUND + error.equals(ERROR_VIDEO_NOT_PLAYABLE_IN_EMBEDDED_PLAYER1, ignoreCase = true) -> PlayerConstants.PlayerError.VIDEO_NOT_PLAYABLE_IN_EMBEDDED_PLAYER + error.equals(ERROR_VIDEO_NOT_PLAYABLE_IN_EMBEDDED_PLAYER2, ignoreCase = true) -> PlayerConstants.PlayerError.VIDEO_NOT_PLAYABLE_IN_EMBEDDED_PLAYER + error.equals(ERROR_REQUEST_MISSING_HTTP_REFERER, ignoreCase = true) -> PlayerConstants.PlayerError.REQUEST_MISSING_HTTP_REFERER + else -> PlayerConstants.PlayerError.UNKNOWN + } + } +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerCallbacks.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerCallbacks.kt new file mode 100644 index 0000000..8842a91 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerCallbacks.kt @@ -0,0 +1,37 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player + +import android.os.Handler +import android.os.Looper +import android.webkit.JavascriptInterface +import androidx.annotation.RestrictTo +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** Bridge used to extract values from Javascript and pass them to the YouTubePlayer. */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +internal class YouTubePlayerCallbacks { + private val mainThreadHandler: Handler = Handler(Looper.getMainLooper()) + + /** Callbacks registered by clients of this class to retrieve boolean values form Javascript. */ + private val booleanCallbacks = ConcurrentHashMap() + + private val requestId = AtomicLong(0) + + /** + * Registers a callback to be called when a boolean value is received from Javascript. + * @return the requestId for this callback. + */ + fun registerBooleanCallback(callback: BooleanProvider): Long { + val requestId = requestId.incrementAndGet() + booleanCallbacks[requestId] = callback + return requestId + } + + @JavascriptInterface + fun sendBooleanValue(requestId: Long, value: Boolean) { + mainThreadHandler.post { + val callback = booleanCallbacks.remove(requestId) + callback?.accept(value) + } + } +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerExt.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerExt.kt new file mode 100644 index 0000000..251b115 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/YouTubePlayerExt.kt @@ -0,0 +1,9 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player + +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** Returns true if the player is muted, false otherwise. */ +suspend fun YouTubePlayer.isMuted(): Boolean = suspendCoroutine { continuation -> + isMutedAsync { isMuted -> continuation.resume(isMuted) } +} \ No newline at end of file diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/AbstractYouTubePlayerListener.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/AbstractYouTubePlayerListener.kt new file mode 100644 index 0000000..82f257d --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/AbstractYouTubePlayerListener.kt @@ -0,0 +1,20 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners + +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer + +/** + * Extend this class if you want to implement only some of the methods of [YouTubePlayerListener] + */ +abstract class AbstractYouTubePlayerListener : YouTubePlayerListener { + override fun onReady(youTubePlayer: YouTubePlayer) {} + override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) {} + override fun onPlaybackQualityChange(youTubePlayer: YouTubePlayer, playbackQuality: PlayerConstants.PlaybackQuality) {} + override fun onPlaybackRateChange(youTubePlayer: YouTubePlayer, playbackRate: PlayerConstants.PlaybackRate) {} + override fun onError(youTubePlayer: YouTubePlayer, error: PlayerConstants.PlayerError) {} + override fun onApiChange(youTubePlayer: YouTubePlayer) {} + override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) {} + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) {} + override fun onVideoLoadedFraction(youTubePlayer: YouTubePlayer, loadedFraction: Float) {} + override fun onVideoId(youTubePlayer: YouTubePlayer, videoId: String) {} +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/FullscreenListener.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/FullscreenListener.kt new file mode 100644 index 0000000..6eb95dc --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/FullscreenListener.kt @@ -0,0 +1,31 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners + +import android.view.View + +/** + * Interface used to keep track of full screen events + */ +interface FullscreenListener { + /** + * Notify the host application that the player has entered full screen mode + * (the full screen button in the player UI has been clicked). + * After this call, the video will no longer be rendered in the [YouTubePlayerView], + * but will instead be rendered in [fullscreenView]. + * The host application should add this View to a container that fills the screen + * in order to actually display the video full screen. + * + * The application can explicitly exit fullscreen mode by invoking [exitFullscreen] + * (for example when the user presses the back button). + * However, the player will show its own UI to exist fullscreen. + * Regardless of how the player exits fullscreen mode, [onEnterFullscreen] will be invoked, + * signaling for the application to remove the custom View. + */ + fun onEnterFullscreen(fullscreenView: View, exitFullscreen: () -> Unit) + + /** + * Notify the host application that the player has exited full screen mode. + * The host application must hide the custom View (the View which was previously passed to + * [onEnterFullscreen]). After this call, the video will render in the player again. + */ + fun onExitFullscreen() +} \ No newline at end of file diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/YouTubePlayerCallback.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/YouTubePlayerCallback.kt new file mode 100644 index 0000000..e0066f0 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/YouTubePlayerCallback.kt @@ -0,0 +1,7 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners + +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer + +interface YouTubePlayerCallback { + fun onYouTubePlayer(youTubePlayer: YouTubePlayer) +} \ No newline at end of file diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/YouTubePlayerListener.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/YouTubePlayerListener.kt new file mode 100644 index 0000000..27de9f3 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/listeners/YouTubePlayerListener.kt @@ -0,0 +1,66 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners + +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer + +interface YouTubePlayerListener { + /** + * Called when the player is ready to play videos. You should start using with the player only after this method is called. + * @param youTubePlayer The [YouTubePlayer] object. + */ + fun onReady(youTubePlayer: YouTubePlayer) + + /** + * Called every time the state of the player changes. Check [PlayerConstants.PlayerState] to see all the possible states. + * @param state a state from [PlayerConstants.PlayerState] + */ + fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) + + /** + * Called every time the quality of the playback changes. Check [PlayerConstants.PlaybackQuality] to see all the possible values. + * @param playbackQuality a state from [PlayerConstants.PlaybackQuality] + */ + fun onPlaybackQualityChange( + youTubePlayer: YouTubePlayer, + playbackQuality: PlayerConstants.PlaybackQuality + ) + + /** + * Called every time the speed of the playback changes. Check [PlayerConstants.PlaybackRate] to see all the possible values. + * @param playbackRate a state from [PlayerConstants.PlaybackRate] + */ + fun onPlaybackRateChange(youTubePlayer: YouTubePlayer, playbackRate: PlayerConstants.PlaybackRate) + + /** + * Called when an error occurs in the player. Check [PlayerConstants.PlayerError] to see all the possible values. + * @param error a state from [PlayerConstants.PlayerError] + */ + fun onError(youTubePlayer: YouTubePlayer, error: PlayerConstants.PlayerError) + + /** + * Called periodically by the player, the argument is the number of seconds that have been played. + * @param second current second of the playback + */ + fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) + + /** + * Called when the total duration of the video is loaded.



+ * Note that getDuration() will return 0 until the video's metadata is loaded, which normally happens just after the video starts playing. + * @param duration total duration of the video + */ + fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) + + /** + * Called periodically by the player, the argument is the percentage of the video that has been buffered. + * @param loadedFraction a number between 0 and 1 that represents the percentage of the video that has been buffered. + */ + fun onVideoLoadedFraction(youTubePlayer: YouTubePlayer, loadedFraction: Float) + + /** + * Called when the id of the current video is loaded + * @param videoId the id of the video being played + */ + fun onVideoId(youTubePlayer: YouTubePlayer, videoId: String) + + fun onApiChange(youTubePlayer: YouTubePlayer) +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/options/IFramePlayerOptions.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/options/IFramePlayerOptions.kt new file mode 100644 index 0000000..fa0ca5b --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/options/IFramePlayerOptions.kt @@ -0,0 +1,222 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options + +import android.content.Context +import org.json.JSONException +import org.json.JSONObject + +/** + * Options used to configure the IFrame Player. All the options are listed here: + * [IFrame player parameters](https://developers.google.com/youtube/player_parameters#Parameters) + */ +class IFramePlayerOptions private constructor(private val playerOptions: JSONObject) { + + companion object { + fun getDefault(context: Context) = Builder(context).controls(1).build() + } + + override fun toString(): String { + return playerOptions.toString() + } + + internal fun getOrigin(): String { + return playerOptions.getString(Builder.ORIGIN) + } + + class Builder(context: Context) { + companion object { + private const val AUTO_PLAY = "autoplay" + private const val MUTE = "mute" + private const val CONTROLS = "controls" + private const val ENABLE_JS_API = "enablejsapi" + private const val FS = "fs" + internal const val ORIGIN = "origin" + private const val REL = "rel" + private const val IV_LOAD_POLICY = "iv_load_policy" + private const val CC_LOAD_POLICY = "cc_load_policy" + private const val CC_LANG_PREF = "cc_lang_pref" + private const val LIST = "list" + private const val LIST_TYPE = "listType" + private const val START = "start" + private const val END = "end" + } + + private val builderOptions = JSONObject() + + init { + addInt(AUTO_PLAY, 0) + addInt(MUTE, 0) + addInt(CONTROLS, 0) + addInt(ENABLE_JS_API, 1) + addInt(FS, 0) + addString(ORIGIN, "https://${context.packageName}") + addInt(REL, 0) + addInt(IV_LOAD_POLICY, 3) + addInt(CC_LOAD_POLICY, 0) + } + + fun build(): IFramePlayerOptions { + return IFramePlayerOptions(builderOptions) + } + + /** + * Controls whether the web-based UI of the IFrame player is used or not. + * @param controls If set to 0: web UI is not used. If set to 1: web UI is used. + */ + fun controls(controls: Int): Builder { + addInt(CONTROLS, controls) + return this + } + + /** + * Controls if the video is played automatically after the player is initialized. + * @param autoplay if set to 1: the player will start automatically. If set to 0: the player will not start automatically + */ + fun autoplay(controls: Int): Builder { + addInt(AUTO_PLAY, controls) + return this + } + + /** + * Controls if the player will be initialized mute or not. + * @param mute if set to 1: the player will start muted and without acquiring Audio Focus. If set to 0: the player will acquire Audio Focus + */ + fun mute(controls: Int): Builder { + addInt(MUTE, controls) + return this + } + + /** + * Controls the related videos shown at the end of a video. + * @param rel If set to 0: related videos will come from the same channel as the video that was just played. If set to 1: related videos will come from multiple channels. + */ + fun rel(rel: Int): Builder { + addInt(REL, rel) + return this + } + + /** + * Controls video annotations. + * @param ivLoadPolicy if set to 1: the player will show video annotations. If set to 3: they player won't show video annotations. + */ + fun ivLoadPolicy(ivLoadPolicy: Int): Builder { + addInt(IV_LOAD_POLICY, ivLoadPolicy) + return this + } + + /** + * This parameter specifies the default language that the player will use to display captions. + * If you use this parameter and also set the cc_load_policy parameter to 1, then the player + * will show captions in the specified language when the player loads. + * If you do not also set the cc_load_policy parameter, then captions will not display by default, + * but will display in the specified language if the user opts to turn captions on. + * + * @param languageCode ISO 639-1 two-letter language code + */ + fun langPref(languageCode: String): Builder { + addString(CC_LANG_PREF, languageCode) + return this + } + + + /** + * Controls video captions. It doesn't work with automatically generated captions. + * @param ccLoadPolicy if set to 1: the player will show captions. If set to 0: the player won't show captions. + */ + fun ccLoadPolicy(ccLoadPolicy: Int): Builder { + addInt(CC_LOAD_POLICY, ccLoadPolicy) + return this + } + + /** + * This parameter specifies the domain from which the player is running. + * Since the player in this library is not running from a website there should be no reason to change this. + * Using "https://www.youtube.com" (the default value) is recommended as some functions from the IFrame Player are only available + * when the player is running on a trusted domain. + * @param origin your domain. + */ + fun origin(origin: String): Builder { + addString(ORIGIN, origin) + return this + } + + /** + * The list parameter, in conjunction with the [listType] parameter, identifies the content that will load in the player. + * If the [listType] parameter value is "playlist", then the [list] parameter value specifies a YouTube playlist ID. + * @param list The playlist ID to be played. + * You need to prepend the playlist ID with the letters PL, for example: + * if playlist id is 1234, you should pass PL1234. + */ + fun list(list: String): Builder { + addString(LIST, list) + return this + } + + /** + * Controls if the player is playing from video IDs or from playlist IDs. + * If set to "playlist", you should use the "list" parameter to set the playlist ID + * to be played. + * See original documentation for more info: https://developers.google.com/youtube/player_parameters#Selecting_Content_to_Play + * @param listType Set to "playlist" to play playlists. Then pass the playlist id to the [list] parameter. + */ + fun listType(listType: String): Builder { + addString(LIST_TYPE, listType) + return this + } + + /** + * Setting this parameter to 0 prevents the fullscreen button from displaying in the player. + * See original documentation for more info: https://developers.google.com/youtube/player_parameters#Parameters + * @param fs if set to 1: the player fullscreen button will be show. If set to 0: the player fullscreen button will not be shown. + */ + fun fullscreen(fs: Int): Builder { + addInt(FS, fs) + return this + } + + /** + * This parameter causes the player to begin playing the video at the given number of seconds from the start of the video. + * The parameter value is a positive integer. + * @param startSeconds positive integer, number of seconds to offset playback from the start of the video. + */ + fun start(startSeconds: Int): Builder { + addInt(START, startSeconds) + return this + } + + /** + * This parameter specifies the time, measured in seconds from the beginning of the video, when the player should stop playing the video. + * The parameter value is a positive integer. + * @param endSeconds positive integer specifying the time, measured in seconds from the beginning of the video, when the player should stop playing the video. + */ + fun end(endSeconds: Int): Builder { + addInt(END, endSeconds) + return this + } + + /** + * The modestbranding parameter is deprecated and will have no effect. + * To align with YouTube's branding requirements, the player now determines the appropriate branding treatment based on a combination of factors, including player size, other API parameters (e.g. controls), and additional signals. + * See August 15, 2023 deprecation announcement: https://developers.google.com/youtube/player_parameters#release_notes_08_15_2023 + */ + @Deprecated("Deprecated and will have no effect") + fun modestBranding(modestBranding: Int): Builder { + return this + } + + private fun addString(key: String, value: String) { + try { + builderOptions.put(key, value) + } catch (e: JSONException) { + throw RuntimeException("Illegal JSON value $key: $value") + } + } + + private fun addInt(key: String, value: Int) { + try { + builderOptions.put(key, value) + } catch (e: JSONException) { + throw RuntimeException("Illegal JSON value $key: $value") + } + } + } +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/NetworkObserver.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/NetworkObserver.kt new file mode 100644 index 0000000..e73bc71 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/NetworkObserver.kt @@ -0,0 +1,121 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.annotation.RequiresApi + +/** Class used to observe changes to network state */ +internal class NetworkObserver(private val context: Context) { + + interface Listener { + fun onNetworkAvailable() + fun onNetworkUnavailable() + } + + val listeners = mutableListOf() + + private var networkBroadcastReceiver: NetworkBroadcastReceiver? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + /** Start observing network changes */ + fun observeNetwork() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + doObserveNetwork(context) + } else { + doObserveNetworkLegacy(context) + } + } + + /** Stop observing network changes and cleanup */ + fun destroy() { + // Min API for `unregisterNetworkCallback` is L, but we use `registerDefaultNetworkCallback` only for N and above. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val callback = networkCallback ?: return + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + connectivityManager.unregisterNetworkCallback(callback) + } + else { + val receiver = networkBroadcastReceiver ?: return + runCatching { context.unregisterReceiver(receiver) } + } + + listeners.clear() + networkCallback = null + networkBroadcastReceiver = null + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun doObserveNetwork(context: Context) { + val callback = object : ConnectivityManager.NetworkCallback() { + private val mainThreadHandler = Handler(Looper.getMainLooper()) + override fun onAvailable(network: Network) { + // the callback is not on the main thread + mainThreadHandler.post { + listeners.forEach { it.onNetworkAvailable() } + } + } + + override fun onLost(network: Network) { + // the callback is not on the main thread + mainThreadHandler.post { + listeners.forEach { it.onNetworkUnavailable() } + } + } + } + networkCallback = callback + + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + connectivityManager.registerDefaultNetworkCallback(callback) + } + + private fun doObserveNetworkLegacy(context: Context) { + networkBroadcastReceiver = NetworkBroadcastReceiver( + onNetworkAvailable = { listeners.forEach { it.onNetworkAvailable() } }, + onNetworkUnavailable = { listeners.forEach { it.onNetworkUnavailable() } }, + ) + context.registerReceiver(networkBroadcastReceiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)) + } +} + +/** Broadcast receiver used to react to changes in internet connectivity */ +private class NetworkBroadcastReceiver( + private val onNetworkAvailable: () -> Unit, + private val onNetworkUnavailable: () -> Unit +) : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (isConnectedToInternet(context)) { + onNetworkAvailable() + } + else { + onNetworkUnavailable() + } + } +} + +private fun isConnectedToInternet(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) ?: return false + networkCapabilities.isConnectedToInternet() + } else { + val networkInfo = connectivityManager.activeNetworkInfo + networkInfo != null && networkInfo.isConnected + } +} + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +private fun NetworkCapabilities.isConnectedToInternet(): Boolean { + return (hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/PlaybackResumer.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/PlaybackResumer.kt new file mode 100644 index 0000000..e7c5ed3 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/PlaybackResumer.kt @@ -0,0 +1,61 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils + +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener + +/** + * Class responsible for resuming the playback state in case of network problems. + * eg: player is playing -> network goes out -> player stops -> network comes back -> player resumes playback automatically. + */ +internal class PlaybackResumer : AbstractYouTubePlayerListener() { + + private var canLoad = false + private var isPlaying = false + private var error: PlayerConstants.PlayerError? = null + + private var currentVideoId: String? = null + private var currentSecond: Float = 0f + + fun resume(youTubePlayer: YouTubePlayer) { + val videoId = currentVideoId ?: return + if (isPlaying && error == PlayerConstants.PlayerError.HTML_5_PLAYER) { + youTubePlayer.loadOrCueVideo(canLoad, videoId, currentSecond) + } + else if (!isPlaying && error == PlayerConstants.PlayerError.HTML_5_PLAYER) { + youTubePlayer.cueVideo(videoId, currentSecond) + } + + error = null + } + + override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) { + when (state) { + PlayerConstants.PlayerState.ENDED, PlayerConstants.PlayerState.PAUSED -> isPlaying = false + PlayerConstants.PlayerState.PLAYING -> isPlaying = true + else -> { } + } + } + + override fun onError(youTubePlayer: YouTubePlayer, error: PlayerConstants.PlayerError) { + if (error == PlayerConstants.PlayerError.HTML_5_PLAYER) { + this.error = error + } + } + + override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { + currentSecond = second + } + + override fun onVideoId(youTubePlayer: YouTubePlayer, videoId: String) { + currentVideoId = videoId + } + + fun onLifecycleResume() { + canLoad = true + } + + fun onLifecycleStop() { + canLoad = false + } +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/YouTubePlayerTracker.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/YouTubePlayerTracker.kt new file mode 100644 index 0000000..d195269 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/YouTubePlayerTracker.kt @@ -0,0 +1,39 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils + +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener + +/** + * Utility class responsible for tracking the state of YouTubePlayer. + * This is a YouTubePlayerListener, therefore to work it has to be added as listener to a YouTubePlayer. + */ +class YouTubePlayerTracker : AbstractYouTubePlayerListener() { + /** + * @return the player state. A value from [PlayerConstants.PlayerState] + */ + var state: PlayerConstants.PlayerState = PlayerConstants.PlayerState.UNKNOWN + private set + var currentSecond: Float = 0f + private set + var videoDuration: Float = 0f + private set + var videoId: String? = null + private set + + override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) { + this.state = state + } + + override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { + currentSecond = second + } + + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + videoDuration = duration + } + + override fun onVideoId(youTubePlayer: YouTubePlayer, videoId: String) { + this.videoId = videoId + } +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/YouTubePlayerUtils.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/YouTubePlayerUtils.kt new file mode 100644 index 0000000..12d06bf --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/utils/YouTubePlayerUtils.kt @@ -0,0 +1,29 @@ +@file:JvmName("YouTubePlayerUtils") + +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils + +import androidx.lifecycle.Lifecycle +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer + +/** + * Calls [YouTubePlayer.cueVideo] or [YouTubePlayer.loadVideo] depending on which one is more appropriate. + * If it can't decide, calls [YouTubePlayer.cueVideo] by default. + * + * In most cases you want to avoid calling [YouTubePlayer.loadVideo] if the Activity/Fragment is not in the foreground. + * This function automates these checks for you. + * @param lifecycle the lifecycle of the Activity or Fragment containing the YouTubePlayerView. + * @param videoId id of the video. + * @param startSeconds the time from which the video should start playing. + */ +fun YouTubePlayer.loadOrCueVideo(lifecycle: Lifecycle, videoId: String, startSeconds: Float) { + loadOrCueVideo(lifecycle.currentState == Lifecycle.State.RESUMED, videoId, startSeconds) +} + + +@JvmSynthetic +internal fun YouTubePlayer.loadOrCueVideo(canLoad: Boolean, videoId: String, startSeconds: Float) { + if (canLoad) + loadVideo(videoId, startSeconds) + else + cueVideo(videoId, startSeconds) +} \ No newline at end of file diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/LegacyYouTubePlayerView.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/LegacyYouTubePlayerView.kt new file mode 100644 index 0000000..e220653 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/LegacyYouTubePlayerView.kt @@ -0,0 +1,210 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.FullscreenListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerCallback +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.NetworkObserver +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.PlaybackResumer + +/** + * Legacy internal implementation of YouTubePlayerView. The user facing YouTubePlayerView delegates + * most of its actions to this one. + */ +internal class LegacyYouTubePlayerView( + context: Context, + listener: FullscreenListener, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : SixteenByNineFrameLayout(context, attrs, defStyleAttr) { + + constructor(context: Context) : this(context, FakeWebViewYouTubeListener, null, 0) + + internal val webViewYouTubePlayer = WebViewYouTubePlayer(context, listener) + + private val networkObserver = NetworkObserver(context.applicationContext) + private val playbackResumer = PlaybackResumer() + + internal var isYouTubePlayerReady = false + private var initialize = { } + private val youTubePlayerCallbacks = mutableSetOf() + + internal var canPlay = true + private set + + init { + addView( + webViewYouTubePlayer, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + ) + webViewYouTubePlayer.addListener(playbackResumer) + + // stop playing if the user loads a video but then leaves the app before the video starts playing. + webViewYouTubePlayer.addListener(object : AbstractYouTubePlayerListener() { + override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) { + if (state == PlayerConstants.PlayerState.PLAYING && !isEligibleForPlayback()) { + youTubePlayer.pause() + } + } + }) + + webViewYouTubePlayer.addListener(object : AbstractYouTubePlayerListener() { + override fun onReady(youTubePlayer: YouTubePlayer) { + isYouTubePlayerReady = true + + youTubePlayerCallbacks.forEach { it.onYouTubePlayer(youTubePlayer) } + youTubePlayerCallbacks.clear() + + youTubePlayer.removeListener(this) + } + }) + + networkObserver.listeners.add(object : NetworkObserver.Listener { + override fun onNetworkAvailable() { + if (!isYouTubePlayerReady) { + initialize() + } + else { + playbackResumer.resume(webViewYouTubePlayer.youtubePlayer) + } + } + + override fun onNetworkUnavailable() { } + }) + } + + /** + * Initialize the player. You must call this method before using the player. + * @param youTubePlayerListener listener for player events + * @param handleNetworkEvents if set to true a broadcast receiver will be registered and network events will be handled automatically. + * If set to false, you should handle network events with your own broadcast receiver. + * @param playerOptions customizable options for the embedded video player, can be null. + * @param videoId optional, used to load a video right after initialization. + */ + fun initialize( + youTubePlayerListener: YouTubePlayerListener, + handleNetworkEvents: Boolean, + playerOptions: IFramePlayerOptions, + videoId: String? + ) { + if (isYouTubePlayerReady) { + throw IllegalStateException("This YouTubePlayerView has already been initialized.") + } + + if (handleNetworkEvents) { + networkObserver.observeNetwork() + } + + initialize = { + webViewYouTubePlayer.initialize({ it.addListener(youTubePlayerListener) }, playerOptions, videoId) + } + + if (!handleNetworkEvents) { + initialize() + } + } + + /** + * Initialize the player. + * @param playerOptions customizable options for the embedded video player. + * + * @see LegacyYouTubePlayerView.initialize + */ + fun initialize(youTubePlayerListener: YouTubePlayerListener, handleNetworkEvents: Boolean, playerOptions: IFramePlayerOptions) = + initialize(youTubePlayerListener, handleNetworkEvents, playerOptions, null) + + /** + * Initialize the player. + * @param handleNetworkEvents if set to true a broadcast receiver will be registered and network events will be handled automatically. + * If set to false, you should handle network events with your own broadcast receiver. + * + * @see LegacyYouTubePlayerView.initialize + */ + fun initialize(youTubePlayerListener: YouTubePlayerListener, handleNetworkEvents: Boolean) = + initialize(youTubePlayerListener, handleNetworkEvents, IFramePlayerOptions.getDefault(context)) + + /** + * Initialize the player. Network events are automatically handled by the player. + * @param youTubePlayerListener listener for player events + * + * @see LegacyYouTubePlayerView.initialize + */ + fun initialize(youTubePlayerListener: YouTubePlayerListener) = initialize(youTubePlayerListener, true) + + /** + * @param youTubePlayerCallback A callback that will be called when the YouTubePlayer is ready. + * If the player is ready when the function is called, the callback is called immediately. + * This function is called only once. + */ + fun getYouTubePlayerWhenReady(youTubePlayerCallback: YouTubePlayerCallback) { + if (isYouTubePlayerReady) { + youTubePlayerCallback.onYouTubePlayer(webViewYouTubePlayer.youtubePlayer) + } + else { + youTubePlayerCallbacks.add(youTubePlayerCallback) + } + } + + /** + * Use this method to replace the default Ui of the player with a custom Ui. + * + * You will be responsible to manage the custom Ui from your application, + * the default controller obtained through [LegacyYouTubePlayerView.getPlayerUiController] won't be available anymore. + * @param layoutId the ID of the layout defining the custom Ui. + * @return The inflated View + */ + fun inflateCustomPlayerUi(@LayoutRes layoutId: Int): View { + removeViews(1, childCount - 1) + return View.inflate(context, layoutId, this) + } + + fun setCustomPlayerUi(view: View) { + removeViews(1, childCount - 1) + addView(view) + } + + /** + * Call this method before destroying the host Fragment/Activity, or register this View as an observer of its host lifecycle + */ + fun release() { + networkObserver.destroy() + removeView(webViewYouTubePlayer) + webViewYouTubePlayer.removeAllViews() + webViewYouTubePlayer.destroy() + } + + internal fun onResume() { + playbackResumer.onLifecycleResume() + canPlay = true + } + + internal fun onStop() { + webViewYouTubePlayer.youtubePlayer.pause() + playbackResumer.onLifecycleStop() + canPlay = false + } + + /** + * Checks whether the player is in an eligible state for playback in + * respect of the {@link WebViewYouTubePlayer#isBackgroundPlaybackEnabled} + * property. + */ + internal fun isEligibleForPlayback(): Boolean { + return canPlay || webViewYouTubePlayer.isBackgroundPlaybackEnabled + } + + /** + * Don't use this method if you want to publish your app on the PlayStore. Background playback is against YouTube terms of service. + */ + fun enableBackgroundPlayback(enable: Boolean) { + webViewYouTubePlayer.isBackgroundPlaybackEnabled = enable + } +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/SixteenByNineFrameLayout.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/SixteenByNineFrameLayout.kt new file mode 100644 index 0000000..56b1bb8 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/SixteenByNineFrameLayout.kt @@ -0,0 +1,29 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.RestrictTo + +/** + * A FrameLayout with an aspect ration of 16:9, when the height is set to wrap_content. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY) +open class SixteenByNineFrameLayout : FrameLayout { + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : super(context, attrs, defStyleAttr) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (layoutParams.height == ViewGroup.LayoutParams.WRAP_CONTENT) { + val sixteenNineHeight = MeasureSpec.makeMeasureSpec( + MeasureSpec.getSize(widthMeasureSpec) * 9 / 16, + MeasureSpec.EXACTLY + ) + super.onMeasure(widthMeasureSpec, sixteenNineHeight) + } else + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } +} \ No newline at end of file diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/WebViewYouTubePlayer.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/WebViewYouTubePlayer.kt new file mode 100644 index 0000000..559c178 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/WebViewYouTubePlayer.kt @@ -0,0 +1,185 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.View +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.annotation.GuardedBy +import androidx.annotation.VisibleForTesting +import com.pierfrancescosoffritti.androidyoutubeplayer.R +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.BooleanProvider +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayerBridge +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayerCallbacks +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.FullscreenListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.toFloat +import java.io.BufferedReader +import java.io.InputStream +import java.io.InputStreamReader + + +private class YouTubePlayerImpl( + private val webView: WebView, + private val callbacks: YouTubePlayerCallbacks +) : YouTubePlayer { + private val mainThread: Handler = Handler(Looper.getMainLooper()) + + private val lock = Any() + @GuardedBy("lock") + private val listeners = mutableSetOf() + + override fun loadVideo(videoId: String, startSeconds: Float) = webView.invoke("loadVideo", videoId, startSeconds) + override fun cueVideo(videoId: String, startSeconds: Float) = webView.invoke("cueVideo", videoId, startSeconds) + override fun play() = webView.invoke("playVideo") + override fun pause() = webView.invoke("pauseVideo") + override fun nextVideo() = webView.invoke("nextVideo") + override fun previousVideo() = webView.invoke("previousVideo") + override fun playVideoAt(index: Int) = webView.invoke("playVideoAt", index) + override fun setLoop(loop: Boolean) = webView.invoke("setLoop", loop) + override fun setShuffle(shuffle: Boolean) = webView.invoke("setShuffle", shuffle) + override fun mute() = webView.invoke("mute") + override fun unMute() = webView.invoke("unMute") + override fun isMutedAsync(callback: BooleanProvider) { + val requestId = callbacks.registerBooleanCallback(callback) + webView.invoke("getMuteValue", requestId) + } + override fun setVolume(volumePercent: Int) { + require(volumePercent in 0..100) { "Volume must be between 0 and 100" } + webView.invoke("setVolume", volumePercent) + } + override fun seekTo(time: Float) = webView.invoke("seekTo", time) + override fun setPlaybackRate(playbackRate: PlayerConstants.PlaybackRate) = webView.invoke("setPlaybackRate", playbackRate.toFloat()) + override fun addListener(listener: YouTubePlayerListener) = synchronized(lock) { listeners.add(listener) } + override fun removeListener(listener: YouTubePlayerListener) = synchronized(lock) { listeners.remove(listener) } + + fun getListeners(): Collection = synchronized(lock) { listeners.toList() } + + fun release() { + synchronized(lock) { listeners.clear() } + mainThread.removeCallbacksAndMessages(null) + } + + private fun WebView.invoke(function: String, vararg args: Any) { + val stringArgs = args.map { + if (it is String) { + "'$it'" + } + else { + it.toString() + } + } + mainThread.post { loadUrl("javascript:$function(${stringArgs.joinToString(",")})") } + } +} + +internal object FakeWebViewYouTubeListener : FullscreenListener { + override fun onEnterFullscreen(fullscreenView: View, exitFullscreen: () -> Unit) {} + override fun onExitFullscreen() {} +} + +/** + * WebView implementation of [YouTubePlayer]. The player runs inside the WebView, using the IFrame Player API. + */ +internal class WebViewYouTubePlayer constructor( + context: Context, + private val listener: FullscreenListener, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : WebView(context, attrs, defStyleAttr), YouTubePlayerBridge.YouTubePlayerBridgeCallbacks { + + /** Constructor used by tools */ + constructor(context: Context) : this(context, FakeWebViewYouTubeListener) + + private val youTubePlayerCallbacks = YouTubePlayerCallbacks() + private val _youTubePlayer = YouTubePlayerImpl(this, youTubePlayerCallbacks) + internal val youtubePlayer: YouTubePlayer get() = _youTubePlayer + + private lateinit var youTubePlayerInitListener: (YouTubePlayer) -> Unit + + internal var isBackgroundPlaybackEnabled = false + + private val youTubePlayerBridge = YouTubePlayerBridge(this) + + internal fun initialize(initListener: (YouTubePlayer) -> Unit, playerOptions: IFramePlayerOptions?, videoId: String?) { + youTubePlayerInitListener = initListener + initWebView(playerOptions ?: IFramePlayerOptions.getDefault(context), videoId) + } + + override val listeners: Collection get() = _youTubePlayer.getListeners() + override fun getInstance(): YouTubePlayer = _youTubePlayer + override fun onYouTubeIFrameAPIReady() = youTubePlayerInitListener(_youTubePlayer) + fun addListener(listener: YouTubePlayerListener) = _youTubePlayer.addListener(listener) + fun removeListener(listener: YouTubePlayerListener) = _youTubePlayer.removeListener(listener) + + override fun destroy() { + _youTubePlayer.release() + super.destroy() + } + + @SuppressLint("SetJavaScriptEnabled") + private fun initWebView(playerOptions: IFramePlayerOptions, videoId: String?) { + settings.apply { + javaScriptEnabled = true + mediaPlaybackRequiresUserGesture = false + cacheMode = WebSettings.LOAD_DEFAULT + } + + addJavascriptInterface(youTubePlayerBridge, "YouTubePlayerBridge") + addJavascriptInterface(youTubePlayerCallbacks, "YouTubePlayerCallbacks") + + val htmlPage = readHTMLFromUTF8File(resources.openRawResource(R.raw.ayp_youtube_player)) + .replace("<>", if (videoId != null) { "'$videoId'" } else { "undefined" }) + .replace("<>", playerOptions.toString()) + + loadDataWithBaseURL(playerOptions.getOrigin(), htmlPage, "text/html", "utf-8", null) + + webChromeClient = object : WebChromeClient() { + + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + super.onShowCustomView(view, callback) + listener.onEnterFullscreen(view) { callback.onCustomViewHidden() } + } + + override fun onHideCustomView() { + super.onHideCustomView() + listener.onExitFullscreen() + } + + override fun getDefaultVideoPoster(): Bitmap? { + val result = super.getDefaultVideoPoster() + // if the video's thumbnail is not in memory, show a black screen + return result ?: Bitmap.createBitmap(1, 1, Bitmap.Config.RGB_565) + } + } + } + + override fun onWindowVisibilityChanged(visibility: Int) { + if (isBackgroundPlaybackEnabled && (visibility == View.GONE || visibility == View.INVISIBLE)) { + return + } + + super.onWindowVisibilityChanged(visibility) + } +} + +@VisibleForTesting +internal fun readHTMLFromUTF8File(inputStream: InputStream): String { + inputStream.use { stream -> + BufferedReader(InputStreamReader(stream, "utf-8")).use { bufferedReader -> + try { + return bufferedReader.readLines().joinToString("\n") + } catch (_: Exception) { + throw RuntimeException("Can't parse HTML file.") + } + } + } +} diff --git a/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/YouTubePlayerView.kt b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/YouTubePlayerView.kt new file mode 100644 index 0000000..2a002a9 --- /dev/null +++ b/youtube/core/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/player/views/YouTubePlayerView.kt @@ -0,0 +1,250 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams +import android.widget.FrameLayout +import androidx.annotation.LayoutRes +import androidx.lifecycle.* +import com.pierfrancescosoffritti.androidyoutubeplayer.R +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.* +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.loadOrCueVideo + +private const val AUTO_INIT_ERROR = "YouTubePlayerView: If you want to initialize this view manually, " + + "you need to set 'enableAutomaticInitialization' to false." + +private val matchParent + get() = FrameLayout.LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT + ) + +class YouTubePlayerView( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : SixteenByNineFrameLayout(context, attrs, defStyleAttr), LifecycleEventObserver { + + constructor(context: Context) : this(context, null, 0) + constructor(context: Context, attrs: AttributeSet? = null) : this(context, attrs, 0) + + private val fullscreenListeners = mutableListOf() + + /** + * A single [FullscreenListener] that is always added to the WebView, + * responsible for calling all optional listeners added from clients of the library. + */ + private val webViewFullscreenListener = object : FullscreenListener { + override fun onEnterFullscreen(fullscreenView: View, exitFullscreen: () -> Unit) { + if (fullscreenListeners.isEmpty()) { + throw IllegalStateException("To enter fullscreen you need to first register a FullscreenListener.") + } + fullscreenListeners.forEach { it.onEnterFullscreen(fullscreenView, exitFullscreen) } + } + + override fun onExitFullscreen() { + if (fullscreenListeners.isEmpty()) { + throw IllegalStateException("To enter fullscreen you need to first register a FullscreenListener.") + } + fullscreenListeners.forEach { it.onExitFullscreen() } + } + } + + private val legacyTubePlayerView = LegacyYouTubePlayerView(context, webViewFullscreenListener) + + // this is a publicly accessible API + var enableAutomaticInitialization: Boolean + + init { + addView(legacyTubePlayerView, matchParent) + + val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.YouTubePlayerView, 0, 0) + + enableAutomaticInitialization = typedArray.getBoolean(R.styleable.YouTubePlayerView_enableAutomaticInitialization, true) + val autoPlay = typedArray.getBoolean(R.styleable.YouTubePlayerView_autoPlay, false) + val handleNetworkEvents = typedArray.getBoolean(R.styleable.YouTubePlayerView_handleNetworkEvents, true) + val videoId = typedArray.getString(R.styleable.YouTubePlayerView_videoId) + + typedArray.recycle() + + if (autoPlay && videoId == null) { + throw IllegalStateException("YouTubePlayerView: videoId is not set but autoPlay is set to true. This combination is not allowed.") + } + + val youTubePlayerListener = object : AbstractYouTubePlayerListener() { + override fun onReady(youTubePlayer: YouTubePlayer) { + videoId?.let { + youTubePlayer.loadOrCueVideo(legacyTubePlayerView.canPlay && autoPlay, videoId, 0f) + } + + youTubePlayer.removeListener(this) + } + } + + if (enableAutomaticInitialization) { + legacyTubePlayerView.initialize( + youTubePlayerListener, + handleNetworkEvents, + IFramePlayerOptions.getDefault(context), + videoId + ) + } + } + + // TODO: Use @JvmOverloads instead of duplicating the method. Unfortunately that will cause a breaking change. + fun initialize(youTubePlayerListener: YouTubePlayerListener, handleNetworkEvents: Boolean, playerOptions: IFramePlayerOptions, videoId: String?) { + if (enableAutomaticInitialization) { + throw IllegalStateException(AUTO_INIT_ERROR) + } + else { + legacyTubePlayerView.initialize(youTubePlayerListener, handleNetworkEvents, playerOptions, videoId) + } + } + + /** + * Initialize the player. You must call this method before using the player. + * @param youTubePlayerListener listener for player events + * @param handleNetworkEvents if set to true a broadcast receiver will be registered and network events will be handled automatically. + * If set to false, you should handle network events with your own broadcast receiver. + * @param playerOptions customizable options for the embedded video player. + * @param videoId optional, used to load an initial video. + */ + fun initialize(youTubePlayerListener: YouTubePlayerListener, handleNetworkEvents: Boolean, playerOptions: IFramePlayerOptions) { + if (enableAutomaticInitialization) { + throw IllegalStateException(AUTO_INIT_ERROR) + } + else { + legacyTubePlayerView.initialize(youTubePlayerListener, handleNetworkEvents, playerOptions, null) + } + } + + /** + * Initialize the player. + * @param handleNetworkEvents if set to true a broadcast receiver will be registered and network events will be handled automatically. + * If set to false, you should handle network events with your own broadcast receiver. + * + * @see YouTubePlayerView.initialize + */ + fun initialize(youTubePlayerListener: YouTubePlayerListener, handleNetworkEvents: Boolean) { + if (enableAutomaticInitialization) { + throw IllegalStateException(AUTO_INIT_ERROR) + } + else { + legacyTubePlayerView.initialize(youTubePlayerListener, handleNetworkEvents, IFramePlayerOptions.getDefault(context)) + } + } + + /** + * Initialize the player with player options. + * + * @see YouTubePlayerView.initialize + */ + fun initialize(youTubePlayerListener: YouTubePlayerListener, playerOptions: IFramePlayerOptions) { + if (enableAutomaticInitialization) { + throw IllegalStateException(AUTO_INIT_ERROR) + } + else { + legacyTubePlayerView.initialize(youTubePlayerListener, true, playerOptions) + } + } + + /** + * Initialize the player. Network events are automatically handled by the player. + * @param youTubePlayerListener listener for player events + * + * @see YouTubePlayerView.initialize + */ + fun initialize(youTubePlayerListener: YouTubePlayerListener) { + if (enableAutomaticInitialization) { + throw IllegalStateException(AUTO_INIT_ERROR) + } + else { + legacyTubePlayerView.initialize(youTubePlayerListener, true) + } + } + + /** + * @param youTubePlayerCallback A callback that will be called when the YouTubePlayer is ready. + * If the player is ready when the function is called, the callback will return immediately. + * This function is called only once. + */ + fun getYouTubePlayerWhenReady(youTubePlayerCallback: YouTubePlayerCallback) = legacyTubePlayerView.getYouTubePlayerWhenReady(youTubePlayerCallback) + + /** + * Use this method to add your own custom UI to the player. + * + * You will be responsible to manage the custom Ui from your application. + * + * WARNING: if yoy intend to publish your app on the PlayStore, using a custom UI might break YouTube terms of service. + * + * @param layoutId the ID of the layout defining the custom Ui. + * @return The inflated View + */ + fun inflateCustomPlayerUi(@LayoutRes layoutId: Int) = legacyTubePlayerView.inflateCustomPlayerUi(layoutId) + + fun setCustomPlayerUi(view: View) = legacyTubePlayerView.setCustomPlayerUi(view) + + /** + * Don't use this method if you want to publish your app on the PlayStore. Background playback is against YouTube terms of service. + */ + fun enableBackgroundPlayback(enable: Boolean) = legacyTubePlayerView.enableBackgroundPlayback(enable) + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_RESUME -> onResume() + Lifecycle.Event.ON_STOP -> onStop() + Lifecycle.Event.ON_DESTROY -> release() + Lifecycle.Event.ON_CREATE, Lifecycle.Event.ON_START, Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_ANY -> { } + } + } + + /** + * Call this method before destroying the host Fragment/Activity, or register this View as an observer of its host lifecycle + */ + fun release() = legacyTubePlayerView.release() + + private fun onResume() = legacyTubePlayerView.onResume() + + private fun onStop() = legacyTubePlayerView.onStop() + + fun addYouTubePlayerListener(youTubePlayerListener: YouTubePlayerListener) = legacyTubePlayerView.webViewYouTubePlayer.addListener(youTubePlayerListener) + + fun removeYouTubePlayerListener(youTubePlayerListener: YouTubePlayerListener) = legacyTubePlayerView.webViewYouTubePlayer.removeListener(youTubePlayerListener) + + fun addFullscreenListener(fullscreenListener: FullscreenListener) = fullscreenListeners.add(fullscreenListener) + + fun removeFullscreenListener(fullscreenListener: FullscreenListener) = fullscreenListeners.remove(fullscreenListener) + + /** + * Convenience method to set the [YouTubePlayerView] width and height to match parent. + */ + fun matchParent() { + setLayoutParams( + targetWidth = ViewGroup.LayoutParams.MATCH_PARENT, + targetHeight = ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + /** + * Convenience method to set the [YouTubePlayerView] width to match parent and + * height to wrap content. + */ + fun wrapContent() { + setLayoutParams( + targetWidth = ViewGroup.LayoutParams.MATCH_PARENT, + targetHeight = ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + @Suppress("SameParameterValue") + private fun setLayoutParams(targetWidth: Int, targetHeight: Int) { + layoutParams = layoutParams.apply { + width = targetWidth + height = targetHeight + } + } +} diff --git a/youtube/core/src/main/res/raw/ayp_youtube_player.html b/youtube/core/src/main/res/raw/ayp_youtube_player.html new file mode 100644 index 0000000..1acf2ea --- /dev/null +++ b/youtube/core/src/main/res/raw/ayp_youtube_player.html @@ -0,0 +1,194 @@ + + + + + + + + + + + + + +
+ + + + diff --git a/youtube/core/src/main/res/values/attrs.xml b/youtube/core/src/main/res/values/attrs.xml new file mode 100644 index 0000000..1cb0a4e --- /dev/null +++ b/youtube/core/src/main/res/values/attrs.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/youtube/core/src/main/resources/META-INF/com/pierfrancescosoffritti/androidyoutubeplayer/core/verification.properties b/youtube/core/src/main/resources/META-INF/com/pierfrancescosoffritti/androidyoutubeplayer/core/verification.properties new file mode 100644 index 0000000..c134317 --- /dev/null +++ b/youtube/core/src/main/resources/META-INF/com/pierfrancescosoffritti/androidyoutubeplayer/core/verification.properties @@ -0,0 +1,3 @@ +#This is the verification token for the com.pierfrancescosoffritti.androidyoutubeplayer:core SDK. +#Tue Sep 24 22:28:45 PDT 2024 +token=RLCF53RSTNFKXKRKVCHR7GH2LM diff --git a/youtube/core/src/test/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/UtilsTest.kt b/youtube/core/src/test/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/UtilsTest.kt new file mode 100644 index 0000000..07e2341 --- /dev/null +++ b/youtube/core/src/test/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/UtilsTest.kt @@ -0,0 +1,18 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core + +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.readHTMLFromUTF8File +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.charset.StandardCharsets + +class UtilsTest { + @Test + fun readParseHtmlCorrectly() { + val html = "
somefakehtml
\n
somefakehtml
" + val inputStream = html.byteInputStream(StandardCharsets.UTF_8) + + val parsedHtml = readHTMLFromUTF8File(inputStream) + + assertEquals(parsedHtml, html) + } +} diff --git a/youtube/custom-ui/.gitignore b/youtube/custom-ui/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/youtube/custom-ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/youtube/custom-ui/build.gradle b/youtube/custom-ui/build.gradle new file mode 100644 index 0000000..965ebc3 --- /dev/null +++ b/youtube/custom-ui/build.gradle @@ -0,0 +1,44 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply from: '../libVersions.gradle' + +android { + compileSdkVersion versions.compileSdk + + defaultConfig { + minSdkVersion versions.minSdk + targetSdkVersion versions.compileSdk + } + + sourceSets { + main.res.srcDirs = [ + 'src/main/res', + 'src/main/res-public' + ] + } + + namespace 'com.pierfrancescosoffritti.androidyoutubeplayer.core.customui' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + +} + +dependencies { + api project(':youtube:core') + implementation "androidx.core:core-ktx:$versions.androidxCore" + implementation "androidx.recyclerview:recyclerview:$versions.androidxRecyclerView" +} + diff --git a/youtube/custom-ui/consumer-rules.pro b/youtube/custom-ui/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/youtube/custom-ui/proguard-rules.pro b/youtube/custom-ui/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/youtube/custom-ui/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/youtube/custom-ui/src/main/AndroidManifest.xml b/youtube/custom-ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/youtube/custom-ui/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/DefaultPlayerUiController.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/DefaultPlayerUiController.kt new file mode 100644 index 0000000..b6bac32 --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/DefaultPlayerUiController.kt @@ -0,0 +1,289 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui + +import android.content.Intent +import android.graphics.drawable.Drawable +import android.net.Uri +import android.util.Log +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu.YouTubePlayerMenu +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu.defaultMenu.DefaultYouTubePlayerMenu +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.utils.FadeViewHelper +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.views.YouTubePlayerSeekBar +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.views.YouTubePlayerSeekBarListener + +class DefaultPlayerUiController( + private val youTubePlayerView: YouTubePlayerView, + private val youTubePlayer: YouTubePlayer +) : PlayerUiController { + + val rootView: View = View.inflate(youTubePlayerView.context, R.layout.ayp_default_player_ui, null) + + private var youTubePlayerMenu: YouTubePlayerMenu = DefaultYouTubePlayerMenu( + youTubePlayerView.context + ) + + /** + * View used for for intercepting clicks and for drawing a black background. + * Could have used controlsContainer, but in this way I'm able to hide all the control at once by hiding controlsContainer + */ + private val panel: View = rootView.findViewById(R.id.panel) + + private val controlsContainer: View = rootView.findViewById(R.id.controls_container) + private val extraViewsContainer: LinearLayout = rootView.findViewById(R.id.extra_views_container) + + private val videoTitle: TextView = rootView.findViewById(R.id.video_title) + private val liveVideoIndicator: TextView = rootView.findViewById(R.id.live_video_indicator) + + private val progressBar: ProgressBar = rootView.findViewById(R.id.progress) + private val menuButton: ImageView = rootView.findViewById(R.id.menu_button) + private val playPauseButton: ImageView = rootView.findViewById(R.id.play_pause_button) + private val youTubeButton: ImageView = rootView.findViewById(R.id.youtube_button) + private val fullscreenButton: ImageView = rootView.findViewById(R.id.fullscreen_button) + + private val customActionLeft: ImageView = rootView.findViewById(R.id.custom_action_left_button) + private val customActionRight: ImageView = rootView.findViewById(R.id.custom_action_right_button) + + private val youtubePlayerSeekBar: YouTubePlayerSeekBar = rootView.findViewById(R.id.youtube_player_seekbar) + private val fadeControlsContainer: FadeViewHelper = FadeViewHelper(controlsContainer) + + private var onFullscreenButtonListener: View.OnClickListener + private var onMenuButtonClickListener: View.OnClickListener + + private var isPlaying = false + private var isPlayPauseButtonEnabled = true + private var isCustomActionLeftEnabled = false + private var isCustomActionRightEnabled = false + + private var isMatchParent = false + + private val youTubePlayerStateListener = object : AbstractYouTubePlayerListener() { + override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) { + updateState(state) + + if (state === PlayerConstants.PlayerState.PLAYING || state === PlayerConstants.PlayerState.PAUSED || state === PlayerConstants.PlayerState.VIDEO_CUED) { + panel.setBackgroundColor(ContextCompat.getColor(panel.context, android.R.color.transparent)) + progressBar.visibility = View.GONE + + if (isPlayPauseButtonEnabled) playPauseButton.visibility = View.VISIBLE + if (isCustomActionLeftEnabled) customActionLeft.visibility = View.VISIBLE + if (isCustomActionRightEnabled) customActionRight.visibility = View.VISIBLE + + updatePlayPauseButtonIcon(state === PlayerConstants.PlayerState.PLAYING) + + } else { + updatePlayPauseButtonIcon(false) + + if (state === PlayerConstants.PlayerState.BUFFERING) { + progressBar.visibility = View.VISIBLE + panel.setBackgroundColor( + ContextCompat.getColor( + panel.context, + android.R.color.transparent + ) + ) + if (isPlayPauseButtonEnabled) playPauseButton.visibility = View.INVISIBLE + + customActionLeft.visibility = View.GONE + customActionRight.visibility = View.GONE + } + + if (state === PlayerConstants.PlayerState.UNSTARTED) { + progressBar.visibility = View.GONE + if (isPlayPauseButtonEnabled) playPauseButton.visibility = View.VISIBLE + } + } + } + + override fun onVideoId(youTubePlayer: YouTubePlayer, videoId: String) { + youTubeButton.setOnClickListener { + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://www.youtube.com/watch?v=" + videoId + "#t=" + youtubePlayerSeekBar.seekBar.progress) + ) + try { + youTubeButton.context.startActivity(intent) + } catch (e: Exception) { + Log.e(javaClass.simpleName, e.message ?: "Can't open url to YouTube") + } + } + } + } + + init { + onFullscreenButtonListener = View.OnClickListener { + isMatchParent = !isMatchParent + when (isMatchParent) { + true -> youTubePlayerView.matchParent() + false -> youTubePlayerView.wrapContent() + } + } + + onMenuButtonClickListener = View.OnClickListener { youTubePlayerMenu.show(menuButton) } + + initClickListeners() + } + + private fun initClickListeners() { + youTubePlayer.addListener(youtubePlayerSeekBar) + youTubePlayer.addListener(fadeControlsContainer) + youTubePlayer.addListener(youTubePlayerStateListener) + + youtubePlayerSeekBar.youtubePlayerSeekBarListener = object : YouTubePlayerSeekBarListener { + override fun seekTo(time: Float) = youTubePlayer.seekTo(time) + } + panel.setOnClickListener { fadeControlsContainer.toggleVisibility() } + playPauseButton.setOnClickListener { onPlayButtonPressed() } + fullscreenButton.setOnClickListener { onFullscreenButtonListener.onClick(fullscreenButton) } + menuButton.setOnClickListener { onMenuButtonClickListener.onClick(menuButton) } + } + + override fun showVideoTitle(show: Boolean): PlayerUiController { + videoTitle.visibility = if (show) View.VISIBLE else View.GONE + return this + } + + override fun setVideoTitle(videoTitle: String): PlayerUiController { + this.videoTitle.text = videoTitle + return this + } + + override fun showUi(show: Boolean): PlayerUiController { + fadeControlsContainer.isDisabled = !show + controlsContainer.visibility = if (show) View.VISIBLE else View.INVISIBLE + return this + } + + override fun showPlayPauseButton(show: Boolean): PlayerUiController { + playPauseButton.visibility = if (show) View.VISIBLE else View.GONE + + isPlayPauseButtonEnabled = show + return this + } + + override fun enableLiveVideoUi(enable: Boolean): PlayerUiController { + youtubePlayerSeekBar.visibility = if (enable) View.INVISIBLE else View.VISIBLE + liveVideoIndicator.visibility = if (enable) View.VISIBLE else View.GONE + return this + } + + override fun setCustomAction1( + icon: Drawable, + clickListener: View.OnClickListener? + ): PlayerUiController { + customActionLeft.setImageDrawable(icon) + customActionLeft.setOnClickListener(clickListener) + showCustomAction1(true) + return this + } + + override fun setCustomAction2( + icon: Drawable, + clickListener: View.OnClickListener? + ): PlayerUiController { + customActionRight.setImageDrawable(icon) + customActionRight.setOnClickListener(clickListener) + showCustomAction2(true) + return this + } + + override fun showCustomAction1(show: Boolean): PlayerUiController { + isCustomActionLeftEnabled = show + customActionLeft.visibility = if (show) View.VISIBLE else View.GONE + return this + } + + override fun showCustomAction2(show: Boolean): PlayerUiController { + isCustomActionRightEnabled = show + customActionRight.visibility = if (show) View.VISIBLE else View.GONE + return this + } + + override fun showMenuButton(show: Boolean): PlayerUiController { + menuButton.visibility = if (show) View.VISIBLE else View.GONE + return this + } + + override fun setMenuButtonClickListener(customMenuButtonClickListener: View.OnClickListener): PlayerUiController { + onMenuButtonClickListener = customMenuButtonClickListener + return this + } + + override fun showCurrentTime(show: Boolean): PlayerUiController { + youtubePlayerSeekBar.videoCurrentTimeTextView.visibility = if (show) View.VISIBLE else View.GONE + return this + } + + override fun showDuration(show: Boolean): PlayerUiController { + youtubePlayerSeekBar.videoDurationTextView.visibility = if (show) View.VISIBLE else View.GONE + return this + } + + override fun showSeekBar(show: Boolean): PlayerUiController { + youtubePlayerSeekBar.seekBar.visibility = if (show) View.VISIBLE else View.INVISIBLE + return this + } + + override fun showBufferingProgress(show: Boolean): PlayerUiController { + youtubePlayerSeekBar.showBufferingProgress = show + return this + } + + override fun showYouTubeButton(show: Boolean): PlayerUiController { + youTubeButton.visibility = if (show) View.VISIBLE else View.GONE + return this + } + + override fun addView(view: View): PlayerUiController { + extraViewsContainer.addView(view, 0) + return this + } + + override fun removeView(view: View): PlayerUiController { + extraViewsContainer.removeView(view) + return this + } + + override fun getMenu(): YouTubePlayerMenu = youTubePlayerMenu + + override fun showFullscreenButton(show: Boolean): PlayerUiController { + fullscreenButton.visibility = if (show) View.VISIBLE else View.GONE + return this + } + + override fun setFullscreenButtonClickListener(customFullscreenButtonClickListener: View.OnClickListener): PlayerUiController { + onFullscreenButtonListener = customFullscreenButtonClickListener + return this + } + + private fun onPlayButtonPressed() { + if (isPlaying) + youTubePlayer.pause() + else + youTubePlayer.play() + } + + private fun updateState(state: PlayerConstants.PlayerState) { + when (state) { + PlayerConstants.PlayerState.ENDED -> isPlaying = false + PlayerConstants.PlayerState.PAUSED -> isPlaying = false + PlayerConstants.PlayerState.PLAYING -> isPlaying = true + else -> {} + } + + updatePlayPauseButtonIcon(!isPlaying) + } + + private fun updatePlayPauseButtonIcon(playing: Boolean) { + val drawable = if (playing) R.drawable.ayp_ic_pause_36dp else R.drawable.ayp_ic_play_36dp + playPauseButton.setImageResource(drawable) + } +} diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/PlayerUiController.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/PlayerUiController.kt new file mode 100644 index 0000000..8786c45 --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/PlayerUiController.kt @@ -0,0 +1,57 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui + +import android.graphics.drawable.Drawable +import android.view.View + +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu.YouTubePlayerMenu + + +interface PlayerUiController { + fun showUi(show: Boolean): PlayerUiController + fun showPlayPauseButton(show: Boolean): PlayerUiController + + fun showVideoTitle(show: Boolean): PlayerUiController + fun setVideoTitle(videoTitle: String): PlayerUiController + + fun enableLiveVideoUi(enable: Boolean): PlayerUiController + + /** + * Set custom action to the left of the Play/Pause button + */ + fun setCustomAction1(icon: Drawable, clickListener: View.OnClickListener?): PlayerUiController + + /** + * Set custom action to the right of the Play/Pause button + */ + fun setCustomAction2(icon: Drawable, clickListener: View.OnClickListener?): PlayerUiController + fun showCustomAction1(show: Boolean): PlayerUiController + fun showCustomAction2(show: Boolean): PlayerUiController + + fun showFullscreenButton(show: Boolean): PlayerUiController + fun setFullscreenButtonClickListener(customFullscreenButtonClickListener: View.OnClickListener): PlayerUiController + + fun showMenuButton(show: Boolean): PlayerUiController + fun setMenuButtonClickListener(customMenuButtonClickListener: View.OnClickListener): PlayerUiController + + fun showCurrentTime(show: Boolean): PlayerUiController + fun showDuration(show: Boolean): PlayerUiController + + fun showSeekBar(show: Boolean): PlayerUiController + fun showBufferingProgress(show: Boolean): PlayerUiController + + fun showYouTubeButton(show: Boolean): PlayerUiController + + /** + * Adds a View to the top of the player + * @param view View to be added + */ + fun addView(view: View): PlayerUiController + + /** + * Removes a View added with [PlayerUiController.addView] + * @param view View to be removed + */ + fun removeView(view: View): PlayerUiController + + fun getMenu(): YouTubePlayerMenu? +} diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/MenuItem.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/MenuItem.kt new file mode 100644 index 0000000..2882cdd --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/MenuItem.kt @@ -0,0 +1,10 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu + +import android.view.View +import androidx.annotation.DrawableRes + +data class MenuItem @JvmOverloads constructor( + val text: String, + @DrawableRes val icon: Int? = null, + val onClickListener: View.OnClickListener +) \ No newline at end of file diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/YouTubePlayerMenu.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/YouTubePlayerMenu.kt new file mode 100644 index 0000000..f12e454 --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/YouTubePlayerMenu.kt @@ -0,0 +1,13 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu + +import android.view.View + +interface YouTubePlayerMenu { + val itemCount: Int + fun show(anchorView: View) + fun dismiss() + + fun addItem(menuItem: MenuItem): YouTubePlayerMenu + fun removeItem(itemIndex: Int): YouTubePlayerMenu + fun removeItem(menuItem: MenuItem): YouTubePlayerMenu +} diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/defaultMenu/DefaultYouTubePlayerMenu.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/defaultMenu/DefaultYouTubePlayerMenu.kt new file mode 100644 index 0000000..7762f70 --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/defaultMenu/DefaultYouTubePlayerMenu.kt @@ -0,0 +1,73 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu.defaultMenu + +import android.content.Context +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.PopupWindow +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.R +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu.MenuItem +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu.YouTubePlayerMenu + +internal class DefaultYouTubePlayerMenu(private val context: Context) : YouTubePlayerMenu { + private val menuItems = ArrayList() + private var popupWindow: PopupWindow? = null + + override val itemCount: Int + get() = menuItems.size + + override fun show(anchorView: View) { + popupWindow = createPopupWindow() + popupWindow?.showAsDropDown( + anchorView, + -context.resources.getDimensionPixelSize(R.dimen.ayp_8dp) * 12, + -context.resources.getDimensionPixelSize(R.dimen.ayp_8dp) * 12 + ) + + if (menuItems.size == 0) + Log.e(YouTubePlayerMenu::class.java.name, "The menu is empty") + } + + override fun dismiss() { + popupWindow?.dismiss() + } + + override fun addItem(menuItem: MenuItem): YouTubePlayerMenu { + menuItems.add(menuItem) + return this + } + + override fun removeItem(itemIndex: Int): YouTubePlayerMenu { + menuItems.removeAt(itemIndex) + return this + } + + override fun removeItem(menuItem: MenuItem): YouTubePlayerMenu { + menuItems.remove(menuItem) + return this + } + + private fun createPopupWindow(): PopupWindow { + val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val view = inflater.inflate(R.layout.ayp_player_menu, null) + + val recyclerView = view.findViewById(R.id.recycler_view) + recyclerView.layoutManager = LinearLayoutManager(context) + recyclerView.adapter = MenuAdapter(context, menuItems) + recyclerView.setHasFixedSize(true) + + val popupWindow = PopupWindow( + view, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT + ) + popupWindow.contentView = view + popupWindow.isFocusable = true + popupWindow.width = WindowManager.LayoutParams.WRAP_CONTENT + popupWindow.height = WindowManager.LayoutParams.WRAP_CONTENT + + return popupWindow + } +} diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/defaultMenu/MenuAdapter.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/defaultMenu/MenuAdapter.kt new file mode 100644 index 0000000..bb91927 --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/menu/defaultMenu/MenuAdapter.kt @@ -0,0 +1,39 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu.defaultMenu + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.R +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.menu.MenuItem + +internal class MenuAdapter(private val context: Context, private val menuItems: List) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.ayp_menu_item, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.root.setOnClickListener(menuItems[position].onClickListener) + holder.textView.text = menuItems[position].text + menuItems[position].icon?.let { + holder.textView.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable(context, it), + null, null, null + ) + } + } + + override fun getItemCount(): Int { + return menuItems.size + } + + internal inner class ViewHolder(val root: View) : RecyclerView.ViewHolder(root) { + val textView: TextView = root.findViewById(R.id.text) + } +} \ No newline at end of file diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/utils/FadeViewHelper.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/utils/FadeViewHelper.kt new file mode 100644 index 0000000..be1c508 --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/utils/FadeViewHelper.kt @@ -0,0 +1,107 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.utils + +import android.animation.Animator +import android.view.View +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerListener + +class FadeViewHelper(val targetView: View) : YouTubePlayerListener { + companion object { + const val DEFAULT_ANIMATION_DURATION = 300L + const val DEFAULT_FADE_OUT_DELAY = 3000L + } + + private var isPlaying = false + + private var canFade = false + private var isVisible = true + + private var fadeOut: Runnable = Runnable { fade(0f) } + + var isDisabled = false + + /** + * Duration of the fade animation in milliseconds. + */ + var animationDuration = DEFAULT_ANIMATION_DURATION + + /** + * Delay after which the view automatically fades out. + */ + var fadeOutDelay = DEFAULT_FADE_OUT_DELAY + + fun toggleVisibility() { + fade(if (isVisible) 0f else 1f) + } + + private fun fade(finalAlpha: Float) { + if (!canFade || isDisabled) + return + + isVisible = finalAlpha != 0f + + // if the controls are shown and the player is playing they should automatically fade after a while. + // otherwise don't do anything automatically + if (finalAlpha == 1f && isPlaying) + targetView.handler?.postDelayed(fadeOut, fadeOutDelay) + else + targetView.handler?.removeCallbacks(fadeOut) + + targetView.animate() + .alpha(finalAlpha) + .setDuration(animationDuration) + .setListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animator: Animator) { + if (finalAlpha == 1f) targetView.visibility = View.VISIBLE + } + + override fun onAnimationEnd(animator: Animator) { + if (finalAlpha == 0f) targetView.visibility = View.GONE + } + + override fun onAnimationCancel(animator: Animator) {} + override fun onAnimationRepeat(animator: Animator) {} + }).start() + } + + private fun updateState(state: PlayerConstants.PlayerState) { + when (state) { + PlayerConstants.PlayerState.ENDED -> isPlaying = false + PlayerConstants.PlayerState.PAUSED -> isPlaying = false + PlayerConstants.PlayerState.PLAYING -> isPlaying = true + PlayerConstants.PlayerState.UNSTARTED -> {} + else -> {} + } + } + + override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) { + updateState(state) + + when (state) { + PlayerConstants.PlayerState.PLAYING, PlayerConstants.PlayerState.PAUSED, PlayerConstants.PlayerState.VIDEO_CUED -> { + canFade = true + if (state == PlayerConstants.PlayerState.PLAYING) + targetView.handler?.postDelayed(fadeOut, fadeOutDelay) + else + targetView.handler?.removeCallbacks(fadeOut) + } + PlayerConstants.PlayerState.BUFFERING, PlayerConstants.PlayerState.UNSTARTED -> { + fade(1f) + canFade = false + } + PlayerConstants.PlayerState.UNKNOWN -> fade(1f) + PlayerConstants.PlayerState.ENDED -> fade(1f) + } + } + + override fun onReady(youTubePlayer: YouTubePlayer) {} + override fun onPlaybackQualityChange(youTubePlayer: YouTubePlayer, playbackQuality: PlayerConstants.PlaybackQuality) {} + override fun onPlaybackRateChange(youTubePlayer: YouTubePlayer, playbackRate: PlayerConstants.PlaybackRate) {} + override fun onError(youTubePlayer: YouTubePlayer, error: PlayerConstants.PlayerError) {} + override fun onApiChange(youTubePlayer: YouTubePlayer) {} + override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) {} + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) {} + override fun onVideoLoadedFraction(youTubePlayer: YouTubePlayer, loadedFraction: Float) {} + override fun onVideoId(youTubePlayer: YouTubePlayer, videoId: String) {} +} \ No newline at end of file diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/utils/TimeUtilities.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/utils/TimeUtilities.kt new file mode 100644 index 0000000..2b80744 --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/utils/TimeUtilities.kt @@ -0,0 +1,17 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.utils + +import android.annotation.SuppressLint + +object TimeUtilities { + + /** + * Transform the time in seconds in a string with format "M:SS". + */ + @SuppressLint("DefaultLocale") + @JvmStatic + fun formatTime(timeInSeconds: Float): String { + val minutes = (timeInSeconds / 60).toInt() + val seconds = (timeInSeconds % 60).toInt() + return String.format("%d:%02d", minutes, seconds) + } +} diff --git a/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/views/YouTubePlayerSeekBar.kt b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/views/YouTubePlayerSeekBar.kt new file mode 100644 index 0000000..2723685 --- /dev/null +++ b/youtube/custom-ui/src/main/java/com/pierfrancescosoffritti/androidyoutubeplayer/core/customui/views/YouTubePlayerSeekBar.kt @@ -0,0 +1,191 @@ +package com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.views + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.widget.LinearLayout +import android.widget.SeekBar +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.R +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.YouTubePlayerListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.utils.TimeUtilities + +class YouTubePlayerSeekBar(context: Context, attrs: AttributeSet? = null) : + LinearLayout(context, attrs), SeekBar.OnSeekBarChangeListener, YouTubePlayerListener { + + private var seekBarTouchStarted = false + + // I need this variable because onCurrentSecond gets called every 100 mils, so without the proper checks on this variable in onCurrentSeconds the seek bar glitches when touched. + private var newSeekBarProgress = -1 + + private var isPlaying = false + + var showBufferingProgress = true + var youtubePlayerSeekBarListener: YouTubePlayerSeekBarListener? = null + + val videoCurrentTimeTextView = TextView(context) + val videoDurationTextView = TextView(context) + val seekBar = SeekBar(context) + + init { + val typedArray = + context.theme.obtainStyledAttributes(attrs, R.styleable.YouTubePlayerSeekBar, 0, 0) + + val fontSize = typedArray.getDimensionPixelSize( + R.styleable.YouTubePlayerSeekBar_fontSize, + resources.getDimensionPixelSize(R.dimen.ayp_12sp) + ) + val color = typedArray.getColor( + R.styleable.YouTubePlayerSeekBar_color, + ContextCompat.getColor(context, R.color.ayp_red) + ) + + typedArray.recycle() + + val padding = resources.getDimensionPixelSize(R.dimen.ayp_8dp) + + videoCurrentTimeTextView.text = resources.getString(R.string.ayp_null_time) + videoCurrentTimeTextView.setPadding(padding, padding, 0, padding) + videoCurrentTimeTextView.setTextColor(ContextCompat.getColor(context, android.R.color.white)) + videoCurrentTimeTextView.gravity = Gravity.CENTER_VERTICAL + + videoDurationTextView.text = resources.getString(R.string.ayp_null_time) + videoDurationTextView.setPadding(0, padding, padding, padding) + videoDurationTextView.setTextColor(ContextCompat.getColor(context, android.R.color.white)) + videoDurationTextView.gravity = Gravity.CENTER_VERTICAL + + setFontSize(fontSize.toFloat()) + + seekBar.setPadding(padding * 2, padding, padding * 2, padding) + setColor(color) + + addView( + videoCurrentTimeTextView, + LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ) + ) + addView(seekBar, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f)) + addView( + videoDurationTextView, + LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ) + ) + + gravity = Gravity.CENTER_VERTICAL + + seekBar.setOnSeekBarChangeListener(this) + } + + /** + * @param fontSize in pixels. + */ + fun setFontSize(fontSize: Float) { + videoCurrentTimeTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize) + videoDurationTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize) + } + + fun setColor(@ColorInt color: Int) { + DrawableCompat.setTint(seekBar.thumb, color) + DrawableCompat.setTint(seekBar.progressDrawable, color) + } + + private fun updateState(state: PlayerConstants.PlayerState) { + when (state) { + PlayerConstants.PlayerState.ENDED -> isPlaying = false + PlayerConstants.PlayerState.PAUSED -> isPlaying = false + PlayerConstants.PlayerState.PLAYING -> isPlaying = true + PlayerConstants.PlayerState.UNSTARTED -> resetUi() + else -> {} + } + } + + private fun resetUi() { + seekBar.progress = 0 + seekBar.max = 0 + videoDurationTextView.post { videoDurationTextView.text = "" } + } + + // Seekbar + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + videoCurrentTimeTextView.text = TimeUtilities.formatTime(progress.toFloat()) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + seekBarTouchStarted = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + if (isPlaying) + newSeekBarProgress = seekBar.progress + + youtubePlayerSeekBarListener?.seekTo(seekBar.progress.toFloat()) + seekBarTouchStarted = false + } + + // YouTubePlayerListener + + override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerConstants.PlayerState) { + newSeekBarProgress = -1 + updateState(state) + } + + override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { + // ignore if the user is currently moving the SeekBar + if (seekBarTouchStarted) + return + // ignore if the current time is older than what the user selected with the SeekBar + if (newSeekBarProgress > 0 && TimeUtilities.formatTime(second) != TimeUtilities.formatTime( + newSeekBarProgress.toFloat() + ) + ) + return + + newSeekBarProgress = -1 + + seekBar.progress = second.toInt() + } + + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + videoDurationTextView.text = TimeUtilities.formatTime(duration) + seekBar.max = duration.toInt() + } + + override fun onVideoLoadedFraction(youTubePlayer: YouTubePlayer, loadedFraction: Float) { + if (showBufferingProgress) + seekBar.secondaryProgress = (loadedFraction * seekBar.max).toInt() + else + seekBar.secondaryProgress = 0 + } + + override fun onReady(youTubePlayer: YouTubePlayer) {} + override fun onVideoId(youTubePlayer: YouTubePlayer, videoId: String) {} + override fun onApiChange(youTubePlayer: YouTubePlayer) {} + override fun onPlaybackQualityChange( + youTubePlayer: YouTubePlayer, + playbackQuality: PlayerConstants.PlaybackQuality + ) { + } + + override fun onPlaybackRateChange( + youTubePlayer: YouTubePlayer, + playbackRate: PlayerConstants.PlaybackRate + ) { + } + + override fun onError(youTubePlayer: YouTubePlayer, error: PlayerConstants.PlayerError) {} +} + +interface YouTubePlayerSeekBarListener { + fun seekTo(time: Float) +} \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res-public/values/public.xml b/youtube/custom-ui/src/main/res-public/values/public.xml new file mode 100644 index 0000000..5e497a2 --- /dev/null +++ b/youtube/custom-ui/src/main/res-public/values/public.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/drawable-v21/ayp_background_item_selected.xml b/youtube/custom-ui/src/main/res/drawable-v21/ayp_background_item_selected.xml new file mode 100644 index 0000000..99e267a --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable-v21/ayp_background_item_selected.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_background_item_selected.xml b/youtube/custom-ui/src/main/res/drawable/ayp_background_item_selected.xml new file mode 100644 index 0000000..d724a77 --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_background_item_selected.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_drop_shadow_bottom.xml b/youtube/custom-ui/src/main/res/drawable/ayp_drop_shadow_bottom.xml new file mode 100644 index 0000000..d6e70f9 --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_drop_shadow_bottom.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_drop_shadow_top.xml b/youtube/custom-ui/src/main/res/drawable/ayp_drop_shadow_top.xml new file mode 100644 index 0000000..cb0df6f --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_drop_shadow_top.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_ic_fullscreen_24dp.xml b/youtube/custom-ui/src/main/res/drawable/ayp_ic_fullscreen_24dp.xml new file mode 100644 index 0000000..62ce98b --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_ic_fullscreen_24dp.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_ic_fullscreen_exit_24dp.xml b/youtube/custom-ui/src/main/res/drawable/ayp_ic_fullscreen_exit_24dp.xml new file mode 100644 index 0000000..40e71f2 --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_ic_fullscreen_exit_24dp.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_ic_menu_24dp.xml b/youtube/custom-ui/src/main/res/drawable/ayp_ic_menu_24dp.xml new file mode 100644 index 0000000..87e46d1 --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_ic_menu_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_ic_pause_36dp.xml b/youtube/custom-ui/src/main/res/drawable/ayp_ic_pause_36dp.xml new file mode 100644 index 0000000..066cb82 --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_ic_pause_36dp.xml @@ -0,0 +1,4 @@ + + + diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_ic_play_36dp.xml b/youtube/custom-ui/src/main/res/drawable/ayp_ic_play_36dp.xml new file mode 100644 index 0000000..b443c66 --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_ic_play_36dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_ic_youtube_24dp.xml b/youtube/custom-ui/src/main/res/drawable/ayp_ic_youtube_24dp.xml new file mode 100644 index 0000000..ef96bcd --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_ic_youtube_24dp.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/drawable/ayp_shape_rounded_corners.xml b/youtube/custom-ui/src/main/res/drawable/ayp_shape_rounded_corners.xml new file mode 100644 index 0000000..c4f69a1 --- /dev/null +++ b/youtube/custom-ui/src/main/res/drawable/ayp_shape_rounded_corners.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/layout/ayp_default_player_ui.xml b/youtube/custom-ui/src/main/res/layout/ayp_default_player_ui.xml new file mode 100644 index 0000000..3d95d5e --- /dev/null +++ b/youtube/custom-ui/src/main/res/layout/ayp_default_player_ui.xml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/youtube/custom-ui/src/main/res/layout/ayp_menu_item.xml b/youtube/custom-ui/src/main/res/layout/ayp_menu_item.xml new file mode 100644 index 0000000..f302a8f --- /dev/null +++ b/youtube/custom-ui/src/main/res/layout/ayp_menu_item.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/layout/ayp_player_menu.xml b/youtube/custom-ui/src/main/res/layout/ayp_player_menu.xml new file mode 100644 index 0000000..be5735d --- /dev/null +++ b/youtube/custom-ui/src/main/res/layout/ayp_player_menu.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/values/attrs.xml b/youtube/custom-ui/src/main/res/values/attrs.xml new file mode 100644 index 0000000..b9879db --- /dev/null +++ b/youtube/custom-ui/src/main/res/values/attrs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/values/colors.xml b/youtube/custom-ui/src/main/res/values/colors.xml new file mode 100644 index 0000000..9613dc3 --- /dev/null +++ b/youtube/custom-ui/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + #F44336 + #90FFFFFF + #66000000 + + #757575 + #222222 + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/values/dimens.xml b/youtube/custom-ui/src/main/res/values/dimens.xml new file mode 100644 index 0000000..a5591d0 --- /dev/null +++ b/youtube/custom-ui/src/main/res/values/dimens.xml @@ -0,0 +1,8 @@ + + + 8dp + 12sp + 8dp + 12dp + 16sp + \ No newline at end of file diff --git a/youtube/custom-ui/src/main/res/values/strings.xml b/youtube/custom-ui/src/main/res/values/strings.xml new file mode 100644 index 0000000..dc9d35a --- /dev/null +++ b/youtube/custom-ui/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + Play button + 0:00 + Open video in YouTube + Full screen button + LIVE + YouTube player Custom action left + YouTube player custom action right + diff --git a/youtube/libVersions.gradle b/youtube/libVersions.gradle new file mode 100644 index 0000000..3b60c4a --- /dev/null +++ b/youtube/libVersions.gradle @@ -0,0 +1,47 @@ +ext.versions = [ + // Project + minSdk : 21, + compileSdk : 36, + + publishVersion_core : '13.0.0', + publishVersionCode_core : 22, + + publishVersion_chromecast : '0.32', + publishVersionCode_chromecast : 16, + + // Plugins + gradlePlugin : '8.10.1', + dexCount : '4.0.0', + gradleNexus : '2.0.0', + dokka : '1.8.10', + + // Kotlin + kotlin : '2.1.0', + + // Compose + composeBom : '2025.10.01', + + // AndroidX + androidxCore : '1.17.0', + appcompat : '1.7.1', + androidxAnnotations : '1.6.0', + androidxConstraintLayout : '2.2.1', + androidxRecyclerView : '1.4.0', + androidxMediarouter : '1.8.1', + androidxLifecycleRuntime : '2.9.4', + androidxActivityCompose : '1.11.0', + + // Google Play + googlePlayServicesCastFramework : '21.3.0', + + // psoffritti + sampleAppTemplate : 'v1.0.4', + + // Tests + junit : '4.13.2', + runner : '1.7.0', + espressoCore : '3.7.0', + + // Debug + leakcanary : '2.10', +] \ No newline at end of file diff --git a/youtube/youtube.includes.gradle b/youtube/youtube.includes.gradle new file mode 100644 index 0000000..5b3e7ce --- /dev/null +++ b/youtube/youtube.includes.gradle @@ -0,0 +1,3 @@ +include(":youtube:core") +include(":youtube:custom-ui") +